mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
chore: remove spontaneous panel review plan
Remove the review-only planning document before merging PR #20. Co-authored-by: Raymond Gasper <raymondgasper@fastmail.com>
This commit is contained in:
parent
17fc959c41
commit
e6d8408a4f
1 changed files with 0 additions and 245 deletions
|
|
@ -1,245 +0,0 @@
|
|||
# Plan: Spontaneous `open_panel` + Human-in-the-Loop Tool Gate
|
||||
|
||||
## Problem
|
||||
|
||||
`open_panel` is only valid as the `action` of a `command_response`, which must
|
||||
be a direct reply to a `command_invoked` frame. A panel can therefore only open
|
||||
when the user types a slash command. There is no way for an extension to open a
|
||||
panel in response to a tool call — making human-in-the-loop approval gates,
|
||||
secret collection, and freeform user-input patterns impossible without awkward
|
||||
workarounds (`/approve` slash commands, `threading.Event` polling, etc.).
|
||||
|
||||
---
|
||||
|
||||
## What we are building
|
||||
|
||||
**Part 1 (required):** A new top-level `open_panel` frame an extension can send
|
||||
at any time, uncoupled from any command invocation.
|
||||
|
||||
**Part 2 (falls out of Part 1 for free):** A blocking tool result pattern where
|
||||
a tool goroutine opens a panel, waits on a Go channel for the user's response,
|
||||
and only then returns a `tool_result`. No new wire frames needed — standard
|
||||
concurrency on the extension side.
|
||||
|
||||
**Part 3 (separate, required for intercepting built-in tools):** Raise or
|
||||
remove the 5-second `event_intercept_response` timeout so that human-interactive
|
||||
intercept handlers don't time out before the user can respond.
|
||||
|
||||
---
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### 1. `packages/agent/extproto/extproto.go`
|
||||
|
||||
Add one new struct (≈5 lines):
|
||||
|
||||
```go
|
||||
// OpenPanelFromExt is a spontaneous one-way frame an extension can send
|
||||
// at any time to open an interactive panel without a prior command invocation.
|
||||
type OpenPanelFromExt struct {
|
||||
Type string `json:"type"` // "open_panel"
|
||||
Panel PanelSpec `json:"panel"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `packages/agent/extensions/manager.go`
|
||||
|
||||
Add one case to the `readLoop` switch (≈5 lines):
|
||||
|
||||
```go
|
||||
case "open_panel":
|
||||
var op extproto.OpenPanelFromExt
|
||||
if err := json.Unmarshal(line, &op); err == nil {
|
||||
m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
|
||||
}
|
||||
```
|
||||
|
||||
`HostHooks.OpenPanel` already exists. `interactive.go` requires **zero changes**.
|
||||
|
||||
### 3. `packages/agent/ext/ext.go`
|
||||
|
||||
Add one method on `Extension` (≈5 lines):
|
||||
|
||||
```go
|
||||
// OpenPanel opens an interactive panel spontaneously from extension code
|
||||
// without requiring a slash command. Safe to call from a tool handler goroutine.
|
||||
func (e *Extension) OpenPanel(id, title string, lines []string, footer string) {
|
||||
_ = e.send(extproto.OpenPanelFromExt{
|
||||
Type: "open_panel",
|
||||
Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Part 3 — intercept timeout (built-in tool gating)
|
||||
|
||||
Locate the hardcoded 5-second deadline in `packages/agent/extensions/manager.go`
|
||||
(the `InterceptEvent` / `pendingIntercept` timeout). Two options, pick one:
|
||||
|
||||
- **Option A (preferred):** Add `intercept_timeout_sec` to `Manifest` in
|
||||
`manager.go` and honour it when building the intercept deadline. Zero/absent
|
||||
means keep 5s default.
|
||||
- **Option B:** Add a `timeout_ms` field to `EventInterceptFromHost` in
|
||||
`extproto.go` that the host sets per-call when the target extension declares
|
||||
the `panels` capability.
|
||||
|
||||
### 5. `docs/extensions.md`
|
||||
|
||||
- Add `open_panel` to the Extension → host frame table.
|
||||
- Add a short prose section under Phase 4 describing the spontaneous form,
|
||||
the blocking tool pattern, and the secret-collection pattern.
|
||||
- Note the concurrent-panel limitation.
|
||||
|
||||
### 6. `examples/extensions/` (new example)
|
||||
|
||||
Add `examples/extensions/approval/` — a minimal extension demonstrating:
|
||||
- An LLM-callable tool that opens an approval panel before proceeding.
|
||||
- A secret-collection variant (masked password input).
|
||||
|
||||
No changes to any other file.
|
||||
|
||||
---
|
||||
|
||||
## Example usages
|
||||
|
||||
### Simple approve / deny
|
||||
|
||||
```go
|
||||
e.Tool("risky_op", "Performs a risky operation.", schema, func(args json.RawMessage) ext.ToolResult {
|
||||
result := make(chan bool, 1)
|
||||
pid := "approve-" + randomID()
|
||||
|
||||
e.OnPanelKey(pid, func(key, text string) {
|
||||
switch {
|
||||
case key == "rune" && text == "y":
|
||||
e.ClosePanel(pid); result <- true
|
||||
case key == "rune" && text == "n", key == "esc":
|
||||
e.ClosePanel(pid); result <- false
|
||||
}
|
||||
}, func() { result <- false })
|
||||
|
||||
e.OpenPanel(pid, "Approve?",
|
||||
[]string{"Agent wants to run: " + summary(args), "", " y approve", " n deny"},
|
||||
"y approve n deny esc cancel")
|
||||
|
||||
if !<-result {
|
||||
return ext.TextErrorResult("user denied")
|
||||
}
|
||||
return doWork(args)
|
||||
})
|
||||
```
|
||||
|
||||
### Secret / credential collection
|
||||
|
||||
The secret is used directly inside the extension and never written to any JSON
|
||||
frame or the transcript. The model receives only a success/failure status.
|
||||
|
||||
```go
|
||||
e.Tool("fetch_authenticated", "Fetch a URL that requires a password.", schema,
|
||||
func(args json.RawMessage) ext.ToolResult {
|
||||
var in struct{ URL string `json:"url"` }
|
||||
json.Unmarshal(args, &in)
|
||||
|
||||
type result struct{ secret string; ok bool }
|
||||
ch := make(chan result, 1)
|
||||
pid := "secret-" + randomID()
|
||||
var mu sync.Mutex
|
||||
var input string
|
||||
|
||||
render := func() {
|
||||
mu.Lock(); masked := strings.Repeat("●", len([]rune(input))); mu.Unlock()
|
||||
e.RenderPanel(pid, "Password required",
|
||||
[]string{" URL: " + in.URL, "", " Password: " + masked + "▌"},
|
||||
"enter confirm esc cancel")
|
||||
}
|
||||
e.OnPanelKey(pid, func(key, text string) {
|
||||
mu.Lock()
|
||||
switch key {
|
||||
case "rune": input += text
|
||||
case "backspace": if len(input) > 0 { r := []rune(input); input = string(r[:len(r)-1]) }
|
||||
case "enter": secret := input; mu.Unlock(); e.ClosePanel(pid); ch <- result{secret, true}; return
|
||||
case "esc": mu.Unlock(); e.ClosePanel(pid); ch <- result{}; return
|
||||
}
|
||||
mu.Unlock(); render()
|
||||
}, func() { ch <- result{} })
|
||||
|
||||
e.OpenPanel(pid, "Password required",
|
||||
[]string{" URL: " + in.URL, "", " Password: ▌"},
|
||||
"enter confirm esc cancel")
|
||||
|
||||
r := <-ch
|
||||
if !r.ok { return ext.TextErrorResult("cancelled") }
|
||||
return doFetch(in.URL, r.secret) // secret never leaves the extension process
|
||||
})
|
||||
```
|
||||
|
||||
### Freeform text / override justification
|
||||
|
||||
Same pattern as secret collection but without masking and with the result
|
||||
injected into the tool's output rather than used as a credential — e.g. a
|
||||
human-written review comment, an override reason for a blocked action, or a
|
||||
value the model should not control.
|
||||
|
||||
### Intercepting a built-in tool (requires Part 3)
|
||||
|
||||
```go
|
||||
e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
|
||||
if tool != "bash" { return ext.ToolCallDecision{} }
|
||||
ch := make(chan ext.ToolCallDecision, 1)
|
||||
pid := "guard-" + randomID()
|
||||
e.OnPanelKey(pid, func(key, text string) {
|
||||
if key == "rune" && text == "y" { e.ClosePanel(pid); ch <- ext.ToolCallDecision{} }
|
||||
if key == "rune" && text == "n" || key == "esc" {
|
||||
e.ClosePanel(pid); ch <- ext.ToolCallDecision{Block: true, Reason: "user denied"}
|
||||
}
|
||||
}, func() { ch <- ext.ToolCallDecision{Block: true, Reason: "panel closed"} })
|
||||
e.OpenPanel(pid, "Approve bash?", renderBashLines(args), "y approve n deny")
|
||||
return <-ch // blocks intercept goroutine — requires Part 3 timeout increase
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out-of-scope risks
|
||||
|
||||
**Intercept timeout (5s) blocks Part 3.**
|
||||
The `event_intercept_response` timeout is hardcoded at 5 seconds. Human
|
||||
interaction always exceeds this. Part 3 must be resolved before
|
||||
`InterceptToolCallX` can be used as an approval gate for built-in tools.
|
||||
Extension-registered tools are unaffected (no timeout on the tool goroutine).
|
||||
|
||||
**Only one panel open at a time.**
|
||||
`extPanelDialog` is a single slot. A second spontaneous `open_panel` while
|
||||
another panel is active will replace it. Extensions that may receive concurrent
|
||||
tool calls must serialise approvals internally. Multi-panel stacking is a
|
||||
separate future concern.
|
||||
|
||||
**Goroutine leak if panel is abandoned.**
|
||||
If the user quits zot or the process is interrupted while a tool goroutine is
|
||||
blocked on a channel, that goroutine leaks until process exit. Mitigation:
|
||||
extension authors should `select` on a context cancellation channel alongside
|
||||
the result channel. The SDK's `ToolHandler` signature does not currently expose
|
||||
a context — passing one through is a separate improvement.
|
||||
|
||||
**No panel scrolling / wrapping.**
|
||||
Panel lines are plain strings (ANSI colour permitted). There is no built-in word
|
||||
wrap or scroll. Long prompts must be pre-wrapped by the extension. Adequate for
|
||||
approve/deny and credential collection; insufficient for displaying large
|
||||
structured content.
|
||||
|
||||
**Panel ID collisions under concurrent tool calls.**
|
||||
If two tool calls for the same tool arrive concurrently, naive panel ID
|
||||
generation could produce the same ID and stomp state. Use the tool-call ID (from
|
||||
`ToolCallFromHost.ID`) as a suffix: `"approve-" + toolCallID`.
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `packages/agent/extproto/extproto.go` | Add `OpenPanelFromExt` struct |
|
||||
| `packages/agent/extensions/manager.go` | Add `case "open_panel":` in `readLoop`; Part 3: raise intercept timeout |
|
||||
| `packages/agent/ext/ext.go` | Add `Extension.OpenPanel(...)` method |
|
||||
| `docs/extensions.md` | Document spontaneous frame, blocking pattern, limitations |
|
||||
| `examples/extensions/approval/` | New example extension (approval + secret collection) |
|
||||
Loading…
Add table
Reference in a new issue