detached c4c30ff

feat(extensions): add cancellable interactive tools

commit
c4c30ffcedfc989e3a5af3373e1181e007624317
author
patriceckhart · · 2026-09-14 07:42 UTC
parent
f60e492
signature
unsigned
refs
tag: v0.3.73
10 files · +761 -30
M docs/extensions.md +75 -2
@@ -180,6 +180,11 @@ other stdout frame before `hello`.
180180 "capabilities":["commands","tools","panels"]}
181181 ```
182182
183+Include `"tool_cancel"` in `capabilities` to opt in to host cancellation
184+notifications. The Go SDK advertises this automatically. The host only sends
185+`tool_cancel` to extensions advertising that exact capability. Missing or
186+unknown capabilities do not enable cancellation notifications.
187+
183188 #### `register_command`
184189
185190 ```json
@@ -489,6 +494,51 @@ tokens scoped to that extension).
489494
490495 `args` is everything the user typed after the command name, trimmed.
491496
497+#### Interactive tools
498+
499+Set `"interactive": true` on `register_tool` for a tool that intentionally
500+waits for user input. Omitted or false retains the normal 60-second reply
501+timeout. Interactive tools have no host-imposed reply deadline, but context
502+cancellation, context deadlines, and extension disconnects still end the call.
503+Print, JSON, RPC, and other hosts without interactive tool support reject
504+these calls with a tool error instead of opening an invisible panel.
505+
506+```json
507+{"type":"register_tool","name":"ask_user",
508+ "description":"Ask the user a question","schema":{"type":"object"},
509+ "interactive":true}
510+```
511+
512+The Go SDK provides a context-aware handler:
513+
514+```go
515+e.InteractiveTool("ask_user", "Ask the user a question", schema,
516+ func(ctx context.Context, args json.RawMessage) ext.ToolResult {
517+ // Open your panel and arrange for its callbacks to deliver an answer.
518+ // Use invocation-local state, including a unique panel ID.
519+ defer closeQuestionPanel()
520+ select {
521+ case answer := <-answers:
522+ return ext.TextResult(answer)
523+ case <-ctx.Done():
524+ return ext.TextErrorResult("Question cancelled")
525+ }
526+ })
527+```
528+
529+Here `schema`, `answers`, and `closeQuestionPanel` are extension-owned values.
530+Panel dismissal must also resolve the handler's wait. Cancellation is
531+cooperative, the SDK cannot stop a handler that ignores its context. It
532+cancels active handler contexts on `tool_cancel`, shutdown, or host EOF.
533+Existing `Tool` and `DeferredTool` handlers remain source-compatible.
534+
535+The registration field is additive and does not change the protocol version.
536+Older hosts ignore it and still apply their normal timeout. Legacy extensions
537+that do not advertise `"tool_cancel"` receive no cancellation frames. The host
538+still stops waiting and discards late results, but cannot request cleanup of
539+abandoned work in those extensions. Both host and extension must support this
540+feature for cancellable, unlimited waits.
541+
492542 #### `tool_call`
493543
494544 Sent when the LLM invokes a tool the extension registered. `args` is
@@ -501,8 +551,31 @@ responsible for validating/coercing it.
501551 ```
502552
503553 Reply with `tool_result` within the host's tool timeout (default 60s).
504-Missing the timeout surfaces an error to the model and the call is
505-marked as failed.
554+Interactive tools have no reply timeout. Missing a bounded timeout surfaces
555+an error to the model and marks the call as `timed_out`. Request writes
556+retain a separate bounded transport timeout, even for interactive tools.
557+Cancellation during a blocked write disconnects the extension transport
558+because a partially written frame cannot safely be withdrawn. Other pending
559+calls on that transport also fail.
560+
561+#### `tool_cancel`
562+
563+Sent best-effort when a delivered tool invocation is abandoned by cancellation
564+or its reply timeout, and only if the extension advertised `"tool_cancel"` in
565+`hello.capabilities`. Normal completion does not send a cancellation frame.
566+`id` is the opaque correlation ID from `tool_call`, not an agent transcript
567+tool ID.
568+
569+```json
570+{"type":"tool_cancel","id":"..."}
571+```
572+
573+Stop the associated work, close its panel, and discard its pending state.
574+Unknown or already-completed IDs should be ignored. Cancellation can arrive
575+before the handler goroutine starts, so register cancellation state before
576+dispatching work. Delivery is not guaranteed after a transport disconnect.
577+Extensions must also clean up on shutdown and EOF. The host safely ignores
578+late results for abandoned calls, including races with cancellation.
506579
507580 #### `event`
508581
@@ -180,6 +180,11 @@ other stdout frame before `hello`.
180 "capabilities":["commands","tools","panels"]}180 "capabilities":["commands","tools","panels"]}
181 ```181 ```
182 182
183+Include `"tool_cancel"` in `capabilities` to opt in to host cancellation
184+notifications. The Go SDK advertises this automatically. The host only sends
185+`tool_cancel` to extensions advertising that exact capability. Missing or
186+unknown capabilities do not enable cancellation notifications.
187+
183 #### `register_command`188 #### `register_command`
184 189
185 ```json190 ```json
@@ -489,6 +494,51 @@ tokens scoped to that extension).
489 494
490 `args` is everything the user typed after the command name, trimmed.495 `args` is everything the user typed after the command name, trimmed.
491 496
497+#### Interactive tools
498+
499+Set `"interactive": true` on `register_tool` for a tool that intentionally
500+waits for user input. Omitted or false retains the normal 60-second reply
501+timeout. Interactive tools have no host-imposed reply deadline, but context
502+cancellation, context deadlines, and extension disconnects still end the call.
503+Print, JSON, RPC, and other hosts without interactive tool support reject
504+these calls with a tool error instead of opening an invisible panel.
505+
506+```json
507+{"type":"register_tool","name":"ask_user",
508+ "description":"Ask the user a question","schema":{"type":"object"},
509+ "interactive":true}
510+```
511+
512+The Go SDK provides a context-aware handler:
513+
514+```go
515+e.InteractiveTool("ask_user", "Ask the user a question", schema,
516+ func(ctx context.Context, args json.RawMessage) ext.ToolResult {
517+ // Open your panel and arrange for its callbacks to deliver an answer.
518+ // Use invocation-local state, including a unique panel ID.
519+ defer closeQuestionPanel()
520+ select {
521+ case answer := <-answers:
522+ return ext.TextResult(answer)
523+ case <-ctx.Done():
524+ return ext.TextErrorResult("Question cancelled")
525+ }
526+ })
527+```
528+
529+Here `schema`, `answers`, and `closeQuestionPanel` are extension-owned values.
530+Panel dismissal must also resolve the handler's wait. Cancellation is
531+cooperative, the SDK cannot stop a handler that ignores its context. It
532+cancels active handler contexts on `tool_cancel`, shutdown, or host EOF.
533+Existing `Tool` and `DeferredTool` handlers remain source-compatible.
534+
535+The registration field is additive and does not change the protocol version.
536+Older hosts ignore it and still apply their normal timeout. Legacy extensions
537+that do not advertise `"tool_cancel"` receive no cancellation frames. The host
538+still stops waiting and discards late results, but cannot request cleanup of
539+abandoned work in those extensions. Both host and extension must support this
540+feature for cancellable, unlimited waits.
541+
492 #### `tool_call`542 #### `tool_call`
493 543
494 Sent when the LLM invokes a tool the extension registered. `args` is544 Sent when the LLM invokes a tool the extension registered. `args` is
@@ -501,8 +551,31 @@ responsible for validating/coercing it.
501 ```551 ```
502 552
503 Reply with `tool_result` within the host's tool timeout (default 60s).553 Reply with `tool_result` within the host's tool timeout (default 60s).
504-Missing the timeout surfaces an error to the model and the call is554+Interactive tools have no reply timeout. Missing a bounded timeout surfaces
505-marked as failed.555+an error to the model and marks the call as `timed_out`. Request writes
556+retain a separate bounded transport timeout, even for interactive tools.
557+Cancellation during a blocked write disconnects the extension transport
558+because a partially written frame cannot safely be withdrawn. Other pending
559+calls on that transport also fail.
560+
561+#### `tool_cancel`
562+
563+Sent best-effort when a delivered tool invocation is abandoned by cancellation
564+or its reply timeout, and only if the extension advertised `"tool_cancel"` in
565+`hello.capabilities`. Normal completion does not send a cancellation frame.
566+`id` is the opaque correlation ID from `tool_call`, not an agent transcript
567+tool ID.
568+
569+```json
570+{"type":"tool_cancel","id":"..."}
571+```
572+
573+Stop the associated work, close its panel, and discard its pending state.
574+Unknown or already-completed IDs should be ignored. Cancellation can arrive
575+before the handler goroutine starts, so register cancellation state before
576+dispatching work. Delivery is not guaranteed after a transport disconnect.
577+Extensions must also clean up on shutdown and EOF. The host safely ignores
578+late results for abandoned calls, including races with cancellation.
506 579
507 #### `event`580 #### `event`
508 581
M packages/agent/cli.go +2 -0
@@ -72,6 +72,8 @@ func (h *interactiveExtHooks) ClearNotes(extName string) {
7272 iv.ClearNotes(extName)
7373 }
7474 }
75+func (h *interactiveExtHooks) SupportsInteractiveTools() bool { return h.iv() != nil }
76+
7577 func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
7678 if iv := h.iv(); iv != nil {
7779 iv.OpenPanel(extName, spec)
@@ -72,6 +72,8 @@ func (h *interactiveExtHooks) ClearNotes(extName string) {
72 iv.ClearNotes(extName)72 iv.ClearNotes(extName)
73 }73 }
74 }74 }
75+func (h *interactiveExtHooks) SupportsInteractiveTools() bool { return h.iv() != nil }
76+
75 func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {77 func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
76 if iv := h.iv(); iv != nil {78 if iv := h.iv(); iv != nil {
77 iv.OpenPanel(extName, spec)79 iv.OpenPanel(extName, spec)
M packages/agent/ext/ext.go +57 -8
@@ -33,6 +33,7 @@ package ext
3333
3434 import (
3535 "bufio"
36+ "context"
3637 "encoding/base64"
3738 "encoding/json"
3839 "fmt"
@@ -57,6 +58,10 @@ type CommandHandler func(args string) Response
5758 // ToolResult describing what zot should send back to the model.
5859 type ToolHandler func(args json.RawMessage) ToolResult
5960
61+// InteractiveToolHandler waits for input and must release its resources when
62+// ctx is cancelled. Cancellation is cooperative, it cannot stop a goroutine.
63+type InteractiveToolHandler func(ctx context.Context, args json.RawMessage) ToolResult
64+
6065 // EventHandler is called for each lifecycle event the extension
6166 // subscribed to via Subscribe. The handler is invoked synchronously
6267 // on the read goroutine; keep it quick or hand off to your own
@@ -300,7 +305,8 @@ type Extension struct {
300305 mu sync.Mutex
301306 commands map[string]CommandHandler
302307 descriptions []descTuple // ordered so register frames arrive in registration order
303- tools map[string]ToolHandler
308+ tools map[string]InteractiveToolHandler
309+ toolCancels map[string]context.CancelFunc
304310 toolDefs []toolDef // ordered so register frames arrive in registration order
305311 eventHandlers map[string]EventHandler
306312 eventNames []string // declared subscription order
@@ -331,6 +337,7 @@ type toolDef struct {
331337 description string
332338 schema json.RawMessage
333339 deferred bool
340+ interactive bool
334341 }
335342
336343 // HostInfo is what the host (zot) tells us in HelloAck. Useful for
@@ -355,11 +362,12 @@ func New(name, version string) *Extension {
355362 out: os.Stdout,
356363 stderr: os.Stderr,
357364 commands: map[string]CommandHandler{},
358- tools: map[string]ToolHandler{},
365+ tools: map[string]InteractiveToolHandler{},
366+ toolCancels: map[string]context.CancelFunc{},
359367 eventHandlers: map[string]EventHandler{},
360368 panelKeys: map[string]func(key, text string){},
361369 panelCloses: map[string]func(){},
362- caps: []string{"commands", "tools", "events", "panels"},
370+ caps: []string{"commands", "tools", "events", "panels", "tool_cancel"},
363371 }
364372 }
365373
@@ -449,9 +457,19 @@ func (e *Extension) DeferredTool(name, description string, schema json.RawMessag
449457 e.registerTool(name, description, schema, true, fn)
450458 }
451459
452-func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) {
460+// InteractiveTool registers a tool that intentionally waits for user input.
461+// The interactive host imposes no reply deadline. Headless hosts reject it.
462+// The handler must honor ctx cancellation and close any panel it owns.
463+func (e *Extension) InteractiveTool(name, description string, schema json.RawMessage, fn InteractiveToolHandler) {
453464 e.mu.Lock()
465+ defer e.mu.Unlock()
454466 e.tools[name] = fn
467+ e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, interactive: true})
468+}
469+
470+func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) {
471+ e.mu.Lock()
472+ e.tools[name] = func(_ context.Context, args json.RawMessage) ToolResult { return fn(args) }
455473 e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred})
456474 e.mu.Unlock()
457475 }
@@ -538,6 +556,14 @@ func (e *Extension) Notify(level, message string) {
538556 // Run starts the protocol loop. Blocks until stdin closes (zot has
539557 // shut us down). Returns the first fatal error, or nil on clean exit.
540558 func (e *Extension) Run() error {
559+ defer func() {
560+ e.mu.Lock()
561+ defer e.mu.Unlock()
562+ for id, cancel := range e.toolCancels {
563+ cancel()
564+ delete(e.toolCancels, id)
565+ }
566+ }()
541567 scanner := bufio.NewScanner(e.in)
542568 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
543569
@@ -604,6 +630,7 @@ func (e *Extension) Run() error {
604630 Description: td.description,
605631 Schema: td.schema,
606632 Deferred: td.deferred,
633+ Interactive: td.interactive,
607634 })
608635 }
609636 var intercepts []string
@@ -677,15 +704,37 @@ func (e *Extension) Run() error {
677704 e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))
678705 continue
679706 }
680- go func(id string, fn ToolHandler, args json.RawMessage) {
707+ ctx, cancel := context.WithCancel(context.Background())
708+ e.mu.Lock()
709+ e.toolCancels[tc.ID] = cancel
710+ e.mu.Unlock()
711+ go func(id string, fn InteractiveToolHandler, args json.RawMessage) {
681712 defer func() {
682- if r := recover(); r != nil {
713+ cancel()
714+ e.mu.Lock()
715+ delete(e.toolCancels, id)
716+ e.mu.Unlock()
717+ }()
718+ defer func() {
719+ if r := recover(); r != nil && ctx.Err() == nil {
683720 e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r)))
684721 }
685722 }()
686- res := fn(args)
687- e.respondTool(id, res)
723+ res := fn(ctx, args)
724+ if ctx.Err() == nil {
725+ e.respondTool(id, res)
726+ }
688727 }(tc.ID, fn, tc.Args)
728+ case "tool_cancel":
729+ var tc extproto.ToolCancelFromHost
730+ if json.Unmarshal(line, &tc) != nil {
731+ continue
732+ }
733+ e.mu.Lock()
734+ if cancel := e.toolCancels[tc.ID]; cancel != nil {
735+ cancel()
736+ }
737+ e.mu.Unlock()
689738 case "event":
690739 var ef extproto.EventFromHost
691740 if err := json.Unmarshal(line, &ef); err != nil {
@@ -33,6 +33,7 @@ package ext
33 33
34 import (34 import (
35 "bufio"35 "bufio"
36+ "context"
36 "encoding/base64"37 "encoding/base64"
37 "encoding/json"38 "encoding/json"
38 "fmt"39 "fmt"
@@ -57,6 +58,10 @@ type CommandHandler func(args string) Response
57 // ToolResult describing what zot should send back to the model.58 // ToolResult describing what zot should send back to the model.
58 type ToolHandler func(args json.RawMessage) ToolResult59 type ToolHandler func(args json.RawMessage) ToolResult
59 60
61+// InteractiveToolHandler waits for input and must release its resources when
62+// ctx is cancelled. Cancellation is cooperative, it cannot stop a goroutine.
63+type InteractiveToolHandler func(ctx context.Context, args json.RawMessage) ToolResult
64+
60 // EventHandler is called for each lifecycle event the extension65 // EventHandler is called for each lifecycle event the extension
61 // subscribed to via Subscribe. The handler is invoked synchronously66 // subscribed to via Subscribe. The handler is invoked synchronously
62 // on the read goroutine; keep it quick or hand off to your own67 // on the read goroutine; keep it quick or hand off to your own
@@ -300,7 +305,8 @@ type Extension struct {
300 mu sync.Mutex305 mu sync.Mutex
301 commands map[string]CommandHandler306 commands map[string]CommandHandler
302 descriptions []descTuple // ordered so register frames arrive in registration order307 descriptions []descTuple // ordered so register frames arrive in registration order
303- tools map[string]ToolHandler308+ tools map[string]InteractiveToolHandler
309+ toolCancels map[string]context.CancelFunc
304 toolDefs []toolDef // ordered so register frames arrive in registration order310 toolDefs []toolDef // ordered so register frames arrive in registration order
305 eventHandlers map[string]EventHandler311 eventHandlers map[string]EventHandler
306 eventNames []string // declared subscription order312 eventNames []string // declared subscription order
@@ -331,6 +337,7 @@ type toolDef struct {
331 description string337 description string
332 schema json.RawMessage338 schema json.RawMessage
333 deferred bool339 deferred bool
340+ interactive bool
334 }341 }
335 342
336 // HostInfo is what the host (zot) tells us in HelloAck. Useful for343 // HostInfo is what the host (zot) tells us in HelloAck. Useful for
@@ -355,11 +362,12 @@ func New(name, version string) *Extension {
355 out: os.Stdout,362 out: os.Stdout,
356 stderr: os.Stderr,363 stderr: os.Stderr,
357 commands: map[string]CommandHandler{},364 commands: map[string]CommandHandler{},
358- tools: map[string]ToolHandler{},365+ tools: map[string]InteractiveToolHandler{},
366+ toolCancels: map[string]context.CancelFunc{},
359 eventHandlers: map[string]EventHandler{},367 eventHandlers: map[string]EventHandler{},
360 panelKeys: map[string]func(key, text string){},368 panelKeys: map[string]func(key, text string){},
361 panelCloses: map[string]func(){},369 panelCloses: map[string]func(){},
362- caps: []string{"commands", "tools", "events", "panels"},370+ caps: []string{"commands", "tools", "events", "panels", "tool_cancel"},
363 }371 }
364 }372 }
365 373
@@ -449,9 +457,19 @@ func (e *Extension) DeferredTool(name, description string, schema json.RawMessag
449 e.registerTool(name, description, schema, true, fn)457 e.registerTool(name, description, schema, true, fn)
450 }458 }
451 459
452-func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) {460+// InteractiveTool registers a tool that intentionally waits for user input.
461+// The interactive host imposes no reply deadline. Headless hosts reject it.
462+// The handler must honor ctx cancellation and close any panel it owns.
463+func (e *Extension) InteractiveTool(name, description string, schema json.RawMessage, fn InteractiveToolHandler) {
453 e.mu.Lock()464 e.mu.Lock()
465+ defer e.mu.Unlock()
454 e.tools[name] = fn466 e.tools[name] = fn
467+ e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, interactive: true})
468+}
469+
470+func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) {
471+ e.mu.Lock()
472+ e.tools[name] = func(_ context.Context, args json.RawMessage) ToolResult { return fn(args) }
455 e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred})473 e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred})
456 e.mu.Unlock()474 e.mu.Unlock()
457 }475 }
@@ -538,6 +556,14 @@ func (e *Extension) Notify(level, message string) {
538 // Run starts the protocol loop. Blocks until stdin closes (zot has556 // Run starts the protocol loop. Blocks until stdin closes (zot has
539 // shut us down). Returns the first fatal error, or nil on clean exit.557 // shut us down). Returns the first fatal error, or nil on clean exit.
540 func (e *Extension) Run() error {558 func (e *Extension) Run() error {
559+ defer func() {
560+ e.mu.Lock()
561+ defer e.mu.Unlock()
562+ for id, cancel := range e.toolCancels {
563+ cancel()
564+ delete(e.toolCancels, id)
565+ }
566+ }()
541 scanner := bufio.NewScanner(e.in)567 scanner := bufio.NewScanner(e.in)
542 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)568 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
543 569
@@ -604,6 +630,7 @@ func (e *Extension) Run() error {
604 Description: td.description,630 Description: td.description,
605 Schema: td.schema,631 Schema: td.schema,
606 Deferred: td.deferred,632 Deferred: td.deferred,
633+ Interactive: td.interactive,
607 })634 })
608 }635 }
609 var intercepts []string636 var intercepts []string
@@ -677,15 +704,37 @@ func (e *Extension) Run() error {
677 e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))704 e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))
678 continue705 continue
679 }706 }
680- go func(id string, fn ToolHandler, args json.RawMessage) {707+ ctx, cancel := context.WithCancel(context.Background())
708+ e.mu.Lock()
709+ e.toolCancels[tc.ID] = cancel
710+ e.mu.Unlock()
711+ go func(id string, fn InteractiveToolHandler, args json.RawMessage) {
681 defer func() {712 defer func() {
682- if r := recover(); r != nil {713+ cancel()
714+ e.mu.Lock()
715+ delete(e.toolCancels, id)
716+ e.mu.Unlock()
717+ }()
718+ defer func() {
719+ if r := recover(); r != nil && ctx.Err() == nil {
683 e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r)))720 e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r)))
684 }721 }
685 }()722 }()
686- res := fn(args)723+ res := fn(ctx, args)
687- e.respondTool(id, res)724+ if ctx.Err() == nil {
725+ e.respondTool(id, res)
726+ }
688 }(tc.ID, fn, tc.Args)727 }(tc.ID, fn, tc.Args)
728+ case "tool_cancel":
729+ var tc extproto.ToolCancelFromHost
730+ if json.Unmarshal(line, &tc) != nil {
731+ continue
732+ }
733+ e.mu.Lock()
734+ if cancel := e.toolCancels[tc.ID]; cancel != nil {
735+ cancel()
736+ }
737+ e.mu.Unlock()
689 case "event":738 case "event":
690 var ef extproto.EventFromHost739 var ef extproto.EventFromHost
691 if err := json.Unmarshal(line, &ef); err != nil {740 if err := json.Unmarshal(line, &ef); err != nil {
A packages/agent/ext/interactive_tool_test.go +115 -0
@@ -0,0 +1,115 @@
1+package ext
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "slices"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/agent/extproto"
12+)
13+
14+func TestInteractiveToolLifecycle(t *testing.T) {
15+ for _, end := range []string{"cancel", "shutdown", "eof", "complete"} {
16+ t.Run(end, func(t *testing.T) {
17+ h := newHarness("interactive")
18+ started := make(chan context.Context, 2)
19+ finished := make(chan struct{}, 2)
20+ answer := make(chan struct{})
21+ h.ext.InteractiveTool("ask", "Ask the user", json.RawMessage(`{}`), func(ctx context.Context, _ json.RawMessage) ToolResult {
22+ started <- ctx
23+ defer func() { finished <- struct{}{} }()
24+ select {
25+ case <-ctx.Done():
26+ return TextErrorResult("cancelled")
27+ case <-answer:
28+ return TextResult("answer")
29+ }
30+ })
31+ done := make(chan error, 1)
32+ go func() { done <- h.ext.Run() }()
33+ t.Cleanup(func() {
34+ h.hostW.Close()
35+ select {
36+ case err := <-done:
37+ if err != nil {
38+ t.Error(err)
39+ }
40+ case <-time.After(time.Second):
41+ t.Error("SDK did not exit")
42+ }
43+ h.ext.out.(io.Closer).Close()
44+ h.ext.in.(io.Closer).Close()
45+ })
46+ var hello extproto.HelloFromExt
47+ if err := json.Unmarshal(h.next(t).raw, &hello); err != nil {
48+ t.Fatal(err)
49+ }
50+ if hello.Type != "hello" || !slices.Contains(hello.Capabilities, "tool_cancel") {
51+ t.Fatalf("SDK did not advertise cancellation support: %+v", hello)
52+ }
53+ h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack"})
54+ var registration extproto.RegisterToolFromExt
55+ json.Unmarshal(h.drainUntil(t, "register_tool").raw, &registration)
56+ if !registration.Interactive || registration.Name != "ask" {
57+ t.Fatalf("registration: %+v", registration)
58+ }
59+ h.drainUntil(t, "ready")
60+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "one", Name: "ask", Args: json.RawMessage(`{}`)})
61+ // Send cancellation without waiting for the goroutine to start.
62+ if end == "cancel" {
63+ h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "one"})
64+ }
65+ var ctx context.Context
66+ select {
67+ case ctx = <-started:
68+ case <-time.After(time.Second):
69+ t.Fatal("handler did not start")
70+ }
71+ switch end {
72+ case "shutdown":
73+ h.sendToExt(t, extproto.ShutdownFromHost{Type: "shutdown"})
74+ h.drainUntil(t, "shutdown_ack")
75+ case "eof":
76+ h.hostW.Close()
77+ case "complete":
78+ close(answer)
79+ var result extproto.ToolResultFromExt
80+ json.Unmarshal(h.drainUntil(t, "tool_result").raw, &result)
81+ if result.ID != "one" || len(result.Content) != 1 || result.Content[0].Text != "answer" {
82+ t.Fatalf("result: %+v", result)
83+ }
84+ }
85+ select {
86+ case <-finished:
87+ case <-time.After(time.Second):
88+ t.Fatal("handler did not finish")
89+ }
90+ if end != "complete" && ctx.Err() != context.Canceled {
91+ t.Fatal("handler context was not cancelled")
92+ }
93+ if end == "cancel" {
94+ // Unknown and duplicate cancellation must not affect another call.
95+ h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "one"})
96+ h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "unknown"})
97+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "two", Name: "ask", Args: json.RawMessage(`{}`)})
98+ select {
99+ case ctx = <-started:
100+ case <-time.After(time.Second):
101+ t.Fatal("second handler did not start")
102+ }
103+ if ctx.Err() != nil {
104+ t.Fatal("second call was cancelled")
105+ }
106+ close(answer)
107+ var result extproto.ToolResultFromExt
108+ json.Unmarshal(h.drainUntil(t, "tool_result").raw, &result)
109+ if result.ID != "two" {
110+ t.Fatalf("unexpected result: %+v", result)
111+ }
112+ }
113+ })
114+ }
115+}
@@ -0,0 +1,115 @@
1+package ext
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "slices"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/agent/extproto"
12+)
13+
14+func TestInteractiveToolLifecycle(t *testing.T) {
15+ for _, end := range []string{"cancel", "shutdown", "eof", "complete"} {
16+ t.Run(end, func(t *testing.T) {
17+ h := newHarness("interactive")
18+ started := make(chan context.Context, 2)
19+ finished := make(chan struct{}, 2)
20+ answer := make(chan struct{})
21+ h.ext.InteractiveTool("ask", "Ask the user", json.RawMessage(`{}`), func(ctx context.Context, _ json.RawMessage) ToolResult {
22+ started <- ctx
23+ defer func() { finished <- struct{}{} }()
24+ select {
25+ case <-ctx.Done():
26+ return TextErrorResult("cancelled")
27+ case <-answer:
28+ return TextResult("answer")
29+ }
30+ })
31+ done := make(chan error, 1)
32+ go func() { done <- h.ext.Run() }()
33+ t.Cleanup(func() {
34+ h.hostW.Close()
35+ select {
36+ case err := <-done:
37+ if err != nil {
38+ t.Error(err)
39+ }
40+ case <-time.After(time.Second):
41+ t.Error("SDK did not exit")
42+ }
43+ h.ext.out.(io.Closer).Close()
44+ h.ext.in.(io.Closer).Close()
45+ })
46+ var hello extproto.HelloFromExt
47+ if err := json.Unmarshal(h.next(t).raw, &hello); err != nil {
48+ t.Fatal(err)
49+ }
50+ if hello.Type != "hello" || !slices.Contains(hello.Capabilities, "tool_cancel") {
51+ t.Fatalf("SDK did not advertise cancellation support: %+v", hello)
52+ }
53+ h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack"})
54+ var registration extproto.RegisterToolFromExt
55+ json.Unmarshal(h.drainUntil(t, "register_tool").raw, &registration)
56+ if !registration.Interactive || registration.Name != "ask" {
57+ t.Fatalf("registration: %+v", registration)
58+ }
59+ h.drainUntil(t, "ready")
60+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "one", Name: "ask", Args: json.RawMessage(`{}`)})
61+ // Send cancellation without waiting for the goroutine to start.
62+ if end == "cancel" {
63+ h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "one"})
64+ }
65+ var ctx context.Context
66+ select {
67+ case ctx = <-started:
68+ case <-time.After(time.Second):
69+ t.Fatal("handler did not start")
70+ }
71+ switch end {
72+ case "shutdown":
73+ h.sendToExt(t, extproto.ShutdownFromHost{Type: "shutdown"})
74+ h.drainUntil(t, "shutdown_ack")
75+ case "eof":
76+ h.hostW.Close()
77+ case "complete":
78+ close(answer)
79+ var result extproto.ToolResultFromExt
80+ json.Unmarshal(h.drainUntil(t, "tool_result").raw, &result)
81+ if result.ID != "one" || len(result.Content) != 1 || result.Content[0].Text != "answer" {
82+ t.Fatalf("result: %+v", result)
83+ }
84+ }
85+ select {
86+ case <-finished:
87+ case <-time.After(time.Second):
88+ t.Fatal("handler did not finish")
89+ }
90+ if end != "complete" && ctx.Err() != context.Canceled {
91+ t.Fatal("handler context was not cancelled")
92+ }
93+ if end == "cancel" {
94+ // Unknown and duplicate cancellation must not affect another call.
95+ h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "one"})
96+ h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "unknown"})
97+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "two", Name: "ask", Args: json.RawMessage(`{}`)})
98+ select {
99+ case ctx = <-started:
100+ case <-time.After(time.Second):
101+ t.Fatal("second handler did not start")
102+ }
103+ if ctx.Err() != nil {
104+ t.Fatal("second call was cancelled")
105+ }
106+ close(answer)
107+ var result extproto.ToolResultFromExt
108+ json.Unmarshal(h.drainUntil(t, "tool_result").raw, &result)
109+ if result.ID != "two" {
110+ t.Fatalf("unexpected result: %+v", result)
111+ }
112+ }
113+ })
114+ }
115+}
A packages/agent/extensions/interactive_tool_test.go +287 -0
@@ -0,0 +1,287 @@
1+package extensions
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "os"
10+ "sync"
11+ "testing"
12+ "testing/synctest"
13+ "time"
14+
15+ "github.com/patriceckhart/zot/packages/agent/extproto"
16+)
17+
18+type interactiveHooks struct{ stubHooks }
19+
20+func (*interactiveHooks) SupportsInteractiveTools() bool { return true }
21+
22+func toolPeer(t *testing.T) (*Manager, *Extension, <-chan []byte, *io.PipeWriter) {
23+ t.Helper()
24+ m := New(t.TempDir(), "", "test", "", "", &interactiveHooks{})
25+ frames, ext := lifecyclePeer(t, m)
26+ ext.pendingTool = map[string]chan extproto.ToolResultFromExt{}
27+ ext.toolCancel = true
28+ ext.readyCh = make(chan struct{})
29+ log, err := os.CreateTemp(t.TempDir(), "log")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ ext.logFile = log
34+ r, w := io.Pipe()
35+ done := make(chan struct{})
36+ go func() {
37+ m.readLoop(ext, bufio.NewScanner(r))
38+ close(done)
39+ }()
40+ t.Cleanup(func() {
41+ w.Close()
42+ <-done
43+ r.Close()
44+ log.Close()
45+ })
46+ m.toolIndex["ask"] = ext
47+ return m, ext, frames, w
48+}
49+
50+func sendToolFrame(t *testing.T, w io.Writer, frame any) {
51+ t.Helper()
52+ b, err := extproto.Encode(frame)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ if _, err := w.Write(b); err != nil {
57+ t.Fatal(err)
58+ }
59+}
60+
61+func toolCallFrame(t *testing.T, frames <-chan []byte) extproto.ToolCallFromHost {
62+ t.Helper()
63+ var call extproto.ToolCallFromHost
64+ if err := json.Unmarshal(nextLifecycleFrame(t, frames), &call); err != nil {
65+ t.Fatal(err)
66+ }
67+ if call.Type != "tool_call" {
68+ t.Fatalf("expected tool_call, got %+v", call)
69+ }
70+ return call
71+}
72+
73+func awaitToolError(t *testing.T, done <-chan error) error {
74+ t.Helper()
75+ select {
76+ case err := <-done:
77+ return err
78+ case <-time.After(2 * time.Second):
79+ t.Fatal("invocation did not finish")
80+ return nil
81+ }
82+}
83+
84+func TestInteractiveToolRegistrationAndCompletion(t *testing.T) {
85+ m, _, frames, replies := toolPeer(t)
86+ sendToolFrame(t, replies, extproto.RegisterToolFromExt{Type: "register_tool", Name: "ask", Schema: json.RawMessage(`{}`), Interactive: true})
87+ sendToolFrame(t, replies, extproto.ReadyFromExt{Type: "ready"})
88+ m.WaitForReady(time.Second)
89+ infos := m.Tools()
90+ if len(infos) != 1 || !infos[0].Interactive {
91+ t.Fatalf("registration: %+v", infos)
92+ }
93+ tool := NewTool(m, infos[0]).(*extensionTool)
94+ if tool.timeout != 0 {
95+ t.Fatal("interactive timeout is enabled")
96+ }
97+ if normal := NewTool(m, ToolInfo{}).(*extensionTool); normal.timeout != 60*time.Second {
98+ t.Fatal("normal timeout changed")
99+ }
100+ done := make(chan error, 1)
101+ go func() {
102+ result, err := tool.Execute(context.Background(), nil, nil)
103+ if err == nil && result.IsError {
104+ err = errors.New("interactive completion failed")
105+ }
106+ done <- err
107+ }()
108+ call := toolCallFrame(t, frames)
109+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
110+ if err := awaitToolError(t, done); err != nil {
111+ t.Fatal(err)
112+ }
113+}
114+
115+func TestInteractiveToolTermination(t *testing.T) {
116+ for _, reason := range []string{"cancel", "timeout", "disconnect", "shutdown"} {
117+ t.Run(reason, func(t *testing.T) {
118+ synctest.Test(t, func(t *testing.T) {
119+ m, ext, frames, replies := toolPeer(t)
120+ ctx, cancel := context.WithCancel(context.Background())
121+ defer cancel()
122+ timeout := time.Duration(0)
123+ if reason == "timeout" {
124+ timeout = time.Nanosecond
125+ }
126+ done := make(chan error, 1)
127+ go func() {
128+ _, err := m.InvokeTool(ctx, "ask", nil, timeout)
129+ done <- err
130+ }()
131+ call := toolCallFrame(t, frames)
132+ // Ensure request delivery has completed before cancelling the reply wait.
133+ synctest.Wait()
134+ switch reason {
135+ case "cancel":
136+ cancel()
137+ case "disconnect":
138+ replies.Close()
139+ case "shutdown":
140+ m.Stop(time.Second)
141+ }
142+ err := awaitToolError(t, done)
143+ if err == nil {
144+ t.Fatal("expected terminal error")
145+ }
146+ if reason == "cancel" && !errors.Is(err, context.Canceled) {
147+ t.Fatal(err)
148+ }
149+ if reason == "timeout" && !errors.Is(err, context.DeadlineExceeded) {
150+ t.Fatal(err)
151+ }
152+ ext.mu.Lock()
153+ pending := len(ext.pendingTool)
154+ ext.mu.Unlock()
155+ if pending != 0 {
156+ t.Fatal("pending call leaked")
157+ }
158+ if reason == "cancel" || reason == "timeout" {
159+ var frame extproto.ToolCancelFromHost
160+ json.Unmarshal(nextLifecycleFrame(t, frames), &frame)
161+ if frame.Type != "tool_cancel" || frame.ID != call.ID {
162+ t.Fatalf("cancel: %+v", frame)
163+ }
164+ // A late result must not block delivery of the next result.
165+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
166+ go func() {
167+ _, err := m.InvokeTool(context.Background(), "ask", nil, 0)
168+ done <- err
169+ }()
170+ next := toolCallFrame(t, frames)
171+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: next.ID})
172+ if err := awaitToolError(t, done); err != nil {
173+ t.Fatal(err)
174+ }
175+ }
176+ })
177+ })
178+ }
179+}
180+
181+func TestInteractiveToolConcurrentReplies(t *testing.T) {
182+ m, _, frames, replies := toolPeer(t)
183+ const count = 32
184+ done := make(chan error, count)
185+ for n := 0; n < count; n++ {
186+ go func() {
187+ _, err := m.InvokeTool(context.Background(), "ask", nil, 0)
188+ done <- err
189+ }()
190+ }
191+ ids := make([]string, count)
192+ seen := map[string]bool{}
193+ for n := range ids {
194+ ids[n] = toolCallFrame(t, frames).ID
195+ if seen[ids[n]] {
196+ t.Fatal("duplicate invocation ID")
197+ }
198+ seen[ids[n]] = true
199+ }
200+ for n := count - 1; n >= 0; n-- {
201+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: ids[n]})
202+ }
203+ for range ids {
204+ if err := awaitToolError(t, done); err != nil {
205+ t.Fatal(err)
206+ }
207+ }
208+}
209+
210+func TestInteractiveToolWaitsBeyondDefaultTimeout(t *testing.T) {
211+ synctest.Test(t, func(t *testing.T) {
212+ m, ext, frames, replies := toolPeer(t)
213+ done := make(chan error, 1)
214+ go func() {
215+ _, err := m.InvokeTool(context.Background(), "ask", nil, 0)
216+ done <- err
217+ }()
218+ call := toolCallFrame(t, frames)
219+ synctest.Wait()
220+ // Advance the fake clock, not wall-clock time.
221+ time.Sleep(2 * time.Minute)
222+ synctest.Wait()
223+ select {
224+ case err := <-done:
225+ t.Fatalf("interactive call ended early: %v", err)
226+ default:
227+ }
228+ ext.mu.Lock()
229+ pending := len(ext.pendingTool)
230+ ext.mu.Unlock()
231+ if pending != 1 {
232+ t.Fatal("interactive call is no longer pending")
233+ }
234+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
235+ if err := awaitToolError(t, done); err != nil {
236+ t.Fatal(err)
237+ }
238+ })
239+}
240+
241+func TestInteractiveToolHeadless(t *testing.T) {
242+ m := New(t.TempDir(), "", "test", "", "", &stubHooks{})
243+ tool := NewTool(m, ToolInfo{Name: "ask", Interactive: true})
244+ result, err := tool.Execute(context.Background(), nil, nil)
245+ if err != nil || !result.IsError || result.Status != "failed" {
246+ t.Fatalf("headless result: %+v, %v", result, err)
247+ }
248+}
249+
250+type blockedToolWriter struct {
251+ started chan struct{}
252+ closed chan struct{}
253+ once sync.Once
254+}
255+
256+func (w *blockedToolWriter) Write([]byte) (int, error) {
257+ close(w.started)
258+ <-w.closed
259+ return 0, io.ErrClosedPipe
260+}
261+
262+func (w *blockedToolWriter) Close() error {
263+ w.once.Do(func() { close(w.closed) })
264+ return nil
265+}
266+
267+func TestToolCancellationDuringWrite(t *testing.T) {
268+ w := &blockedToolWriter{started: make(chan struct{}), closed: make(chan struct{})}
269+ pipe := newOrderedPipe(w)
270+ defer pipe.Close()
271+ ctx, cancel := context.WithCancel(context.Background())
272+ defer cancel()
273+ done := make(chan error, 1)
274+ go func() {
275+ _, err := pipe.writeContext(ctx, []byte("{}\n"), time.Hour)
276+ done <- err
277+ }()
278+ select {
279+ case <-w.started:
280+ case <-time.After(time.Second):
281+ t.Fatal("write did not start")
282+ }
283+ cancel()
284+ if err := awaitToolError(t, done); !errors.Is(err, context.Canceled) {
285+ t.Fatalf("write cancellation: %v", err)
286+ }
287+}
@@ -0,0 +1,287 @@
1+package extensions
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "os"
10+ "sync"
11+ "testing"
12+ "testing/synctest"
13+ "time"
14+
15+ "github.com/patriceckhart/zot/packages/agent/extproto"
16+)
17+
18+type interactiveHooks struct{ stubHooks }
19+
20+func (*interactiveHooks) SupportsInteractiveTools() bool { return true }
21+
22+func toolPeer(t *testing.T) (*Manager, *Extension, <-chan []byte, *io.PipeWriter) {
23+ t.Helper()
24+ m := New(t.TempDir(), "", "test", "", "", &interactiveHooks{})
25+ frames, ext := lifecyclePeer(t, m)
26+ ext.pendingTool = map[string]chan extproto.ToolResultFromExt{}
27+ ext.toolCancel = true
28+ ext.readyCh = make(chan struct{})
29+ log, err := os.CreateTemp(t.TempDir(), "log")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ ext.logFile = log
34+ r, w := io.Pipe()
35+ done := make(chan struct{})
36+ go func() {
37+ m.readLoop(ext, bufio.NewScanner(r))
38+ close(done)
39+ }()
40+ t.Cleanup(func() {
41+ w.Close()
42+ <-done
43+ r.Close()
44+ log.Close()
45+ })
46+ m.toolIndex["ask"] = ext
47+ return m, ext, frames, w
48+}
49+
50+func sendToolFrame(t *testing.T, w io.Writer, frame any) {
51+ t.Helper()
52+ b, err := extproto.Encode(frame)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ if _, err := w.Write(b); err != nil {
57+ t.Fatal(err)
58+ }
59+}
60+
61+func toolCallFrame(t *testing.T, frames <-chan []byte) extproto.ToolCallFromHost {
62+ t.Helper()
63+ var call extproto.ToolCallFromHost
64+ if err := json.Unmarshal(nextLifecycleFrame(t, frames), &call); err != nil {
65+ t.Fatal(err)
66+ }
67+ if call.Type != "tool_call" {
68+ t.Fatalf("expected tool_call, got %+v", call)
69+ }
70+ return call
71+}
72+
73+func awaitToolError(t *testing.T, done <-chan error) error {
74+ t.Helper()
75+ select {
76+ case err := <-done:
77+ return err
78+ case <-time.After(2 * time.Second):
79+ t.Fatal("invocation did not finish")
80+ return nil
81+ }
82+}
83+
84+func TestInteractiveToolRegistrationAndCompletion(t *testing.T) {
85+ m, _, frames, replies := toolPeer(t)
86+ sendToolFrame(t, replies, extproto.RegisterToolFromExt{Type: "register_tool", Name: "ask", Schema: json.RawMessage(`{}`), Interactive: true})
87+ sendToolFrame(t, replies, extproto.ReadyFromExt{Type: "ready"})
88+ m.WaitForReady(time.Second)
89+ infos := m.Tools()
90+ if len(infos) != 1 || !infos[0].Interactive {
91+ t.Fatalf("registration: %+v", infos)
92+ }
93+ tool := NewTool(m, infos[0]).(*extensionTool)
94+ if tool.timeout != 0 {
95+ t.Fatal("interactive timeout is enabled")
96+ }
97+ if normal := NewTool(m, ToolInfo{}).(*extensionTool); normal.timeout != 60*time.Second {
98+ t.Fatal("normal timeout changed")
99+ }
100+ done := make(chan error, 1)
101+ go func() {
102+ result, err := tool.Execute(context.Background(), nil, nil)
103+ if err == nil && result.IsError {
104+ err = errors.New("interactive completion failed")
105+ }
106+ done <- err
107+ }()
108+ call := toolCallFrame(t, frames)
109+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
110+ if err := awaitToolError(t, done); err != nil {
111+ t.Fatal(err)
112+ }
113+}
114+
115+func TestInteractiveToolTermination(t *testing.T) {
116+ for _, reason := range []string{"cancel", "timeout", "disconnect", "shutdown"} {
117+ t.Run(reason, func(t *testing.T) {
118+ synctest.Test(t, func(t *testing.T) {
119+ m, ext, frames, replies := toolPeer(t)
120+ ctx, cancel := context.WithCancel(context.Background())
121+ defer cancel()
122+ timeout := time.Duration(0)
123+ if reason == "timeout" {
124+ timeout = time.Nanosecond
125+ }
126+ done := make(chan error, 1)
127+ go func() {
128+ _, err := m.InvokeTool(ctx, "ask", nil, timeout)
129+ done <- err
130+ }()
131+ call := toolCallFrame(t, frames)
132+ // Ensure request delivery has completed before cancelling the reply wait.
133+ synctest.Wait()
134+ switch reason {
135+ case "cancel":
136+ cancel()
137+ case "disconnect":
138+ replies.Close()
139+ case "shutdown":
140+ m.Stop(time.Second)
141+ }
142+ err := awaitToolError(t, done)
143+ if err == nil {
144+ t.Fatal("expected terminal error")
145+ }
146+ if reason == "cancel" && !errors.Is(err, context.Canceled) {
147+ t.Fatal(err)
148+ }
149+ if reason == "timeout" && !errors.Is(err, context.DeadlineExceeded) {
150+ t.Fatal(err)
151+ }
152+ ext.mu.Lock()
153+ pending := len(ext.pendingTool)
154+ ext.mu.Unlock()
155+ if pending != 0 {
156+ t.Fatal("pending call leaked")
157+ }
158+ if reason == "cancel" || reason == "timeout" {
159+ var frame extproto.ToolCancelFromHost
160+ json.Unmarshal(nextLifecycleFrame(t, frames), &frame)
161+ if frame.Type != "tool_cancel" || frame.ID != call.ID {
162+ t.Fatalf("cancel: %+v", frame)
163+ }
164+ // A late result must not block delivery of the next result.
165+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
166+ go func() {
167+ _, err := m.InvokeTool(context.Background(), "ask", nil, 0)
168+ done <- err
169+ }()
170+ next := toolCallFrame(t, frames)
171+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: next.ID})
172+ if err := awaitToolError(t, done); err != nil {
173+ t.Fatal(err)
174+ }
175+ }
176+ })
177+ })
178+ }
179+}
180+
181+func TestInteractiveToolConcurrentReplies(t *testing.T) {
182+ m, _, frames, replies := toolPeer(t)
183+ const count = 32
184+ done := make(chan error, count)
185+ for n := 0; n < count; n++ {
186+ go func() {
187+ _, err := m.InvokeTool(context.Background(), "ask", nil, 0)
188+ done <- err
189+ }()
190+ }
191+ ids := make([]string, count)
192+ seen := map[string]bool{}
193+ for n := range ids {
194+ ids[n] = toolCallFrame(t, frames).ID
195+ if seen[ids[n]] {
196+ t.Fatal("duplicate invocation ID")
197+ }
198+ seen[ids[n]] = true
199+ }
200+ for n := count - 1; n >= 0; n-- {
201+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: ids[n]})
202+ }
203+ for range ids {
204+ if err := awaitToolError(t, done); err != nil {
205+ t.Fatal(err)
206+ }
207+ }
208+}
209+
210+func TestInteractiveToolWaitsBeyondDefaultTimeout(t *testing.T) {
211+ synctest.Test(t, func(t *testing.T) {
212+ m, ext, frames, replies := toolPeer(t)
213+ done := make(chan error, 1)
214+ go func() {
215+ _, err := m.InvokeTool(context.Background(), "ask", nil, 0)
216+ done <- err
217+ }()
218+ call := toolCallFrame(t, frames)
219+ synctest.Wait()
220+ // Advance the fake clock, not wall-clock time.
221+ time.Sleep(2 * time.Minute)
222+ synctest.Wait()
223+ select {
224+ case err := <-done:
225+ t.Fatalf("interactive call ended early: %v", err)
226+ default:
227+ }
228+ ext.mu.Lock()
229+ pending := len(ext.pendingTool)
230+ ext.mu.Unlock()
231+ if pending != 1 {
232+ t.Fatal("interactive call is no longer pending")
233+ }
234+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
235+ if err := awaitToolError(t, done); err != nil {
236+ t.Fatal(err)
237+ }
238+ })
239+}
240+
241+func TestInteractiveToolHeadless(t *testing.T) {
242+ m := New(t.TempDir(), "", "test", "", "", &stubHooks{})
243+ tool := NewTool(m, ToolInfo{Name: "ask", Interactive: true})
244+ result, err := tool.Execute(context.Background(), nil, nil)
245+ if err != nil || !result.IsError || result.Status != "failed" {
246+ t.Fatalf("headless result: %+v, %v", result, err)
247+ }
248+}
249+
250+type blockedToolWriter struct {
251+ started chan struct{}
252+ closed chan struct{}
253+ once sync.Once
254+}
255+
256+func (w *blockedToolWriter) Write([]byte) (int, error) {
257+ close(w.started)
258+ <-w.closed
259+ return 0, io.ErrClosedPipe
260+}
261+
262+func (w *blockedToolWriter) Close() error {
263+ w.once.Do(func() { close(w.closed) })
264+ return nil
265+}
266+
267+func TestToolCancellationDuringWrite(t *testing.T) {
268+ w := &blockedToolWriter{started: make(chan struct{}), closed: make(chan struct{})}
269+ pipe := newOrderedPipe(w)
270+ defer pipe.Close()
271+ ctx, cancel := context.WithCancel(context.Background())
272+ defer cancel()
273+ done := make(chan error, 1)
274+ go func() {
275+ _, err := pipe.writeContext(ctx, []byte("{}\n"), time.Hour)
276+ done <- err
277+ }()
278+ select {
279+ case <-w.started:
280+ case <-time.After(time.Second):
281+ t.Fatal("write did not start")
282+ }
283+ cancel()
284+ if err := awaitToolError(t, done); !errors.Is(err, context.Canceled) {
285+ t.Fatalf("write cancellation: %v", err)
286+ }
287+}
M packages/agent/extensions/manager.go +57 -18
@@ -22,9 +22,11 @@ import (
2222 "os"
2323 "os/exec"
2424 "path/filepath"
25+ "slices"
2526 "sort"
2627 "strings"
2728 "sync"
29+ "sync/atomic"
2830 "syscall"
2931 "time"
3032
@@ -66,8 +68,10 @@ type Extension struct {
6668 stdout io.ReadCloser
6769 logFile *os.File
6870 helloAck bool
69- commands []extproto.RegisterCommandFromExt
70- tools []extproto.RegisterToolFromExt
71+ // Set during the handshake, before the extension is published.
72+ toolCancel bool
73+ commands []extproto.RegisterCommandFromExt
74+ tools []extproto.RegisterToolFromExt
7175
7276 // readyCh is closed when the extension sends a ReadyFromExt
7377 // frame, or when the host gives up waiting (registrationGrace).
@@ -134,6 +138,12 @@ type HostHooks interface {
134138 ClosePanel(extName, panelID string)
135139 }
136140
141+// Interactive tool support is opt-in so headless hosts fail closed.
142+func (m *Manager) supportsInteractiveTools() bool {
143+ h, ok := m.hooks.(interface{ SupportsInteractiveTools() bool })
144+ return ok && h.SupportsInteractiveTools()
145+}
146+
137147 type commandRegistration struct {
138148 ext *Extension
139149 name string
@@ -768,6 +778,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
768778 }
769779 // Trust the manifest's name; ignore mismatch from the hello.
770780 ext.helloAck = true
781+ ext.toolCancel = slices.Contains(hello.Capabilities, "tool_cancel")
771782
772783 ack, _ := extproto.Encode(extproto.HelloAckFromHost{
773784 Type: "hello_ack",
@@ -864,6 +875,9 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) {
864875 // Returns when stdout closes.
865876 func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
866877 defer func() {
878+ if ext.stdin != nil {
879+ _ = ext.stdin.Close()
880+ }
867881 // On close, drop every command + tool this extension owned so
868882 // future invocations don't dangle. The subprocess is gone; we
869883 // won't hear back about its commands or tool calls anymore.
@@ -1187,6 +1201,7 @@ type ToolInfo struct {
11871201 Description string
11881202 Schema json.RawMessage
11891203 Deferred bool
1204+ Interactive bool
11901205 }
11911206
11921207 // Tools returns a snapshot of every (extension, tool) pair currently
@@ -1204,6 +1219,7 @@ func (m *Manager) Tools() []ToolInfo {
12041219 Description: t.Description,
12051220 Schema: t.Schema,
12061221 Deferred: t.Deferred,
1222+ Interactive: t.Interactive,
12071223 })
12081224 }
12091225 }
@@ -1222,6 +1238,9 @@ func (m *Manager) HasTool(name string) bool {
12221238 // the matching tool_result. Used by the core.Tool wrapper that the
12231239 // agent registers per extension-defined tool.
12241240 func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) {
1241+ if err := ctx.Err(); err != nil {
1242+ return extproto.ToolResultFromExt{}, err
1243+ }
12251244 m.mu.RLock()
12261245 ext, ok := m.toolIndex[name]
12271246 m.mu.RUnlock()
@@ -1234,6 +1253,11 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
12341253 ext.mu.Lock()
12351254 ext.pendingTool[id] = ch
12361255 ext.mu.Unlock()
1256+ defer func() {
1257+ ext.mu.Lock()
1258+ delete(ext.pendingTool, id)
1259+ ext.mu.Unlock()
1260+ }()
12371261
12381262 frame, _ := extproto.Encode(extproto.ToolCallFromHost{
12391263 Type: "tool_call",
@@ -1241,26 +1265,41 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
12411265 Name: name,
12421266 Args: args,
12431267 })
1244- if _, err := ext.stdin.Write(frame); err != nil {
1245- ext.mu.Lock()
1246- delete(ext.pendingTool, id)
1247- ext.mu.Unlock()
1268+ var disconnected <-chan struct{}
1269+ var err error
1270+ pipe, ordered := ext.stdin.(*orderedPipe)
1271+ if ordered {
1272+ disconnected = pipe.done
1273+ _, err = pipe.writeContext(ctx, frame, interceptTimeout)
1274+ } else {
1275+ _, err = ext.stdin.Write(frame)
1276+ }
1277+ if err != nil {
12481278 return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err)
12491279 }
1250-
1280+ cancel := func() {
1281+ if ordered && ext.toolCancel {
1282+ frame, _ := extproto.Encode(extproto.ToolCancelFromHost{Type: "tool_cancel", ID: id})
1283+ _ = pipe.enqueue(frame, nil)
1284+ }
1285+ }
1286+ var deadline <-chan time.Time
1287+ if timeout > 0 {
1288+ timer := time.NewTimer(timeout)
1289+ defer timer.Stop()
1290+ deadline = timer.C
1291+ }
12511292 select {
12521293 case resp := <-ch:
12531294 return resp, nil
1254- case <-time.After(timeout):
1255- ext.mu.Lock()
1256- delete(ext.pendingTool, id)
1257- ext.mu.Unlock()
1295+ case <-deadline:
1296+ cancel()
12581297 return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s: %w", ext.Manifest.Name, name, context.DeadlineExceeded)
12591298 case <-ctx.Done():
1260- ext.mu.Lock()
1261- delete(ext.pendingTool, id)
1262- ext.mu.Unlock()
1299+ cancel()
12631300 return extproto.ToolResultFromExt{}, ctx.Err()
1301+ case <-disconnected:
1302+ return extproto.ToolResultFromExt{}, fmt.Errorf("extension %s disconnected", ext.Manifest.Name)
12641303 }
12651304 }
12661305
@@ -1426,9 +1465,9 @@ func (m *Manager) All() []*Extension {
14261465 return out
14271466 }
14281467
1429-// newCorrelationID returns a short non-cryptographic id. We don't
1430-// need uniqueness across processes, just within the lifetime of one
1431-// extension's pending map.
1468+var correlationSequence atomic.Uint64
1469+
1470+// newCorrelationID is unique across invocations in this host process.
14321471 func newCorrelationID() string {
1433- return strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "")
1472+ return fmt.Sprint(correlationSequence.Add(1))
14341473 }
@@ -22,9 +22,11 @@ import (
22 "os"22 "os"
23 "os/exec"23 "os/exec"
24 "path/filepath"24 "path/filepath"
25+ "slices"
25 "sort"26 "sort"
26 "strings"27 "strings"
27 "sync"28 "sync"
29+ "sync/atomic"
28 "syscall"30 "syscall"
29 "time"31 "time"
30 32
@@ -66,8 +68,10 @@ type Extension struct {
66 stdout io.ReadCloser68 stdout io.ReadCloser
67 logFile *os.File69 logFile *os.File
68 helloAck bool70 helloAck bool
69- commands []extproto.RegisterCommandFromExt71+ // Set during the handshake, before the extension is published.
70- tools []extproto.RegisterToolFromExt72+ toolCancel bool
73+ commands []extproto.RegisterCommandFromExt
74+ tools []extproto.RegisterToolFromExt
71 75
72 // readyCh is closed when the extension sends a ReadyFromExt76 // readyCh is closed when the extension sends a ReadyFromExt
73 // frame, or when the host gives up waiting (registrationGrace).77 // frame, or when the host gives up waiting (registrationGrace).
@@ -134,6 +138,12 @@ type HostHooks interface {
134 ClosePanel(extName, panelID string)138 ClosePanel(extName, panelID string)
135 }139 }
136 140
141+// Interactive tool support is opt-in so headless hosts fail closed.
142+func (m *Manager) supportsInteractiveTools() bool {
143+ h, ok := m.hooks.(interface{ SupportsInteractiveTools() bool })
144+ return ok && h.SupportsInteractiveTools()
145+}
146+
137 type commandRegistration struct {147 type commandRegistration struct {
138 ext *Extension148 ext *Extension
139 name string149 name string
@@ -768,6 +778,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
768 }778 }
769 // Trust the manifest's name; ignore mismatch from the hello.779 // Trust the manifest's name; ignore mismatch from the hello.
770 ext.helloAck = true780 ext.helloAck = true
781+ ext.toolCancel = slices.Contains(hello.Capabilities, "tool_cancel")
771 782
772 ack, _ := extproto.Encode(extproto.HelloAckFromHost{783 ack, _ := extproto.Encode(extproto.HelloAckFromHost{
773 Type: "hello_ack",784 Type: "hello_ack",
@@ -864,6 +875,9 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) {
864 // Returns when stdout closes.875 // Returns when stdout closes.
865 func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {876 func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
866 defer func() {877 defer func() {
878+ if ext.stdin != nil {
879+ _ = ext.stdin.Close()
880+ }
867 // On close, drop every command + tool this extension owned so881 // On close, drop every command + tool this extension owned so
868 // future invocations don't dangle. The subprocess is gone; we882 // future invocations don't dangle. The subprocess is gone; we
869 // won't hear back about its commands or tool calls anymore.883 // won't hear back about its commands or tool calls anymore.
@@ -1187,6 +1201,7 @@ type ToolInfo struct {
1187 Description string1201 Description string
1188 Schema json.RawMessage1202 Schema json.RawMessage
1189 Deferred bool1203 Deferred bool
1204+ Interactive bool
1190 }1205 }
1191 1206
1192 // Tools returns a snapshot of every (extension, tool) pair currently1207 // Tools returns a snapshot of every (extension, tool) pair currently
@@ -1204,6 +1219,7 @@ func (m *Manager) Tools() []ToolInfo {
1204 Description: t.Description,1219 Description: t.Description,
1205 Schema: t.Schema,1220 Schema: t.Schema,
1206 Deferred: t.Deferred,1221 Deferred: t.Deferred,
1222+ Interactive: t.Interactive,
1207 })1223 })
1208 }1224 }
1209 }1225 }
@@ -1222,6 +1238,9 @@ func (m *Manager) HasTool(name string) bool {
1222 // the matching tool_result. Used by the core.Tool wrapper that the1238 // the matching tool_result. Used by the core.Tool wrapper that the
1223 // agent registers per extension-defined tool.1239 // agent registers per extension-defined tool.
1224 func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) {1240 func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) {
1241+ if err := ctx.Err(); err != nil {
1242+ return extproto.ToolResultFromExt{}, err
1243+ }
1225 m.mu.RLock()1244 m.mu.RLock()
1226 ext, ok := m.toolIndex[name]1245 ext, ok := m.toolIndex[name]
1227 m.mu.RUnlock()1246 m.mu.RUnlock()
@@ -1234,6 +1253,11 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
1234 ext.mu.Lock()1253 ext.mu.Lock()
1235 ext.pendingTool[id] = ch1254 ext.pendingTool[id] = ch
1236 ext.mu.Unlock()1255 ext.mu.Unlock()
1256+ defer func() {
1257+ ext.mu.Lock()
1258+ delete(ext.pendingTool, id)
1259+ ext.mu.Unlock()
1260+ }()
1237 1261
1238 frame, _ := extproto.Encode(extproto.ToolCallFromHost{1262 frame, _ := extproto.Encode(extproto.ToolCallFromHost{
1239 Type: "tool_call",1263 Type: "tool_call",
@@ -1241,26 +1265,41 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
1241 Name: name,1265 Name: name,
1242 Args: args,1266 Args: args,
1243 })1267 })
1244- if _, err := ext.stdin.Write(frame); err != nil {1268+ var disconnected <-chan struct{}
1245- ext.mu.Lock()1269+ var err error
1246- delete(ext.pendingTool, id)1270+ pipe, ordered := ext.stdin.(*orderedPipe)
1247- ext.mu.Unlock()1271+ if ordered {
1272+ disconnected = pipe.done
1273+ _, err = pipe.writeContext(ctx, frame, interceptTimeout)
1274+ } else {
1275+ _, err = ext.stdin.Write(frame)
1276+ }
1277+ if err != nil {
1248 return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err)1278 return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err)
1249 }1279 }
1250-1280+ cancel := func() {
1281+ if ordered && ext.toolCancel {
1282+ frame, _ := extproto.Encode(extproto.ToolCancelFromHost{Type: "tool_cancel", ID: id})
1283+ _ = pipe.enqueue(frame, nil)
1284+ }
1285+ }
1286+ var deadline <-chan time.Time
1287+ if timeout > 0 {
1288+ timer := time.NewTimer(timeout)
1289+ defer timer.Stop()
1290+ deadline = timer.C
1291+ }
1251 select {1292 select {
1252 case resp := <-ch:1293 case resp := <-ch:
1253 return resp, nil1294 return resp, nil
1254- case <-time.After(timeout):1295+ case <-deadline:
1255- ext.mu.Lock()1296+ cancel()
1256- delete(ext.pendingTool, id)
1257- ext.mu.Unlock()
1258 return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s: %w", ext.Manifest.Name, name, context.DeadlineExceeded)1297 return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s: %w", ext.Manifest.Name, name, context.DeadlineExceeded)
1259 case <-ctx.Done():1298 case <-ctx.Done():
1260- ext.mu.Lock()1299+ cancel()
1261- delete(ext.pendingTool, id)
1262- ext.mu.Unlock()
1263 return extproto.ToolResultFromExt{}, ctx.Err()1300 return extproto.ToolResultFromExt{}, ctx.Err()
1301+ case <-disconnected:
1302+ return extproto.ToolResultFromExt{}, fmt.Errorf("extension %s disconnected", ext.Manifest.Name)
1264 }1303 }
1265 }1304 }
1266 1305
@@ -1426,9 +1465,9 @@ func (m *Manager) All() []*Extension {
1426 return out1465 return out
1427 }1466 }
1428 1467
1429-// newCorrelationID returns a short non-cryptographic id. We don't1468+var correlationSequence atomic.Uint64
1430-// need uniqueness across processes, just within the lifetime of one1469+
1431-// extension's pending map.1470+// newCorrelationID is unique across invocations in this host process.
1432 func newCorrelationID() string {1471 func newCorrelationID() string {
1433- return strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "")1472+ return fmt.Sprint(correlationSequence.Add(1))
1434 }1473 }
M packages/agent/extensions/outbound.go +11 -0
@@ -65,6 +65,13 @@ func (p *orderedPipe) Write(data []byte) (int, error) {
6565 }
6666
6767 func (p *orderedPipe) writeTimeout(data []byte, timeout time.Duration) (int, error) {
68+ return p.writeContext(context.Background(), data, timeout)
69+}
70+
71+func (p *orderedPipe) writeContext(ctx context.Context, data []byte, timeout time.Duration) (int, error) {
72+ if err := ctx.Err(); err != nil {
73+ return 0, err
74+ }
6875 if timeout <= 0 {
6976 p.Close()
7077 return 0, io.ErrClosedPipe
@@ -83,6 +90,10 @@ func (p *orderedPipe) writeTimeout(data []byte, timeout time.Duration) (int, err
8390 return len(data), nil
8491 case <-p.done:
8592 return 0, io.ErrClosedPipe
93+ case <-ctx.Done():
94+ // A partially written frame cannot be safely withdrawn.
95+ p.Close()
96+ return 0, ctx.Err()
8697 case <-timer.C:
8798 p.Close()
8899 return 0, fmt.Errorf("extension outbound write timed out: %w", context.DeadlineExceeded)
@@ -65,6 +65,13 @@ func (p *orderedPipe) Write(data []byte) (int, error) {
65 }65 }
66 66
67 func (p *orderedPipe) writeTimeout(data []byte, timeout time.Duration) (int, error) {67 func (p *orderedPipe) writeTimeout(data []byte, timeout time.Duration) (int, error) {
68+ return p.writeContext(context.Background(), data, timeout)
69+}
70+
71+func (p *orderedPipe) writeContext(ctx context.Context, data []byte, timeout time.Duration) (int, error) {
72+ if err := ctx.Err(); err != nil {
73+ return 0, err
74+ }
68 if timeout <= 0 {75 if timeout <= 0 {
69 p.Close()76 p.Close()
70 return 0, io.ErrClosedPipe77 return 0, io.ErrClosedPipe
@@ -83,6 +90,10 @@ func (p *orderedPipe) writeTimeout(data []byte, timeout time.Duration) (int, err
83 return len(data), nil90 return len(data), nil
84 case <-p.done:91 case <-p.done:
85 return 0, io.ErrClosedPipe92 return 0, io.ErrClosedPipe
93+ case <-ctx.Done():
94+ // A partially written frame cannot be safely withdrawn.
95+ p.Close()
96+ return 0, ctx.Err()
86 case <-timer.C:97 case <-timer.C:
87 p.Close()98 p.Close()
88 return 0, fmt.Errorf("extension outbound write timed out: %w", context.DeadlineExceeded)99 return 0, fmt.Errorf("extension outbound write timed out: %w", context.DeadlineExceeded)
M packages/agent/extensions/tool.go +13 -2
@@ -27,20 +27,26 @@ type extensionTool struct {
2727 manager *Manager
2828 timeout time.Duration
2929 deferred bool
30+ interactive bool
3031 }
3132
3233 // NewTool returns a core.Tool that round-trips invocations through
3334 // mgr to the extension that registered (name, schema). The default
34-// per-call timeout is 60 seconds; callers can override.
35+// per-call timeout is 60 seconds. Interactive tools have no reply deadline.
3536 func NewTool(mgr *Manager, info ToolInfo) core.Tool {
37+ timeout := 60 * time.Second
38+ if info.Interactive {
39+ timeout = 0
40+ }
3641 return &extensionTool{
3742 name: info.Name,
3843 description: info.Description,
3944 schema: info.Schema,
4045 extension: info.Extension,
4146 manager: mgr,
42- timeout: 60 * time.Second,
47+ timeout: timeout,
4348 deferred: info.Deferred,
49+ interactive: info.Interactive,
4450 }
4551 }
4652
@@ -57,6 +63,11 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun
5763 if len(args) == 0 {
5864 args = json.RawMessage(`{}`)
5965 }
66+ if t.interactive && !t.manager.supportsInteractiveTools() {
67+ return core.ToolResult{Status: "failed", IsError: true, Content: []provider.Content{
68+ provider.TextBlock{Text: "interactive extension tools require an interactive host"},
69+ }}, nil
70+ }
6071 resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)
6172 if err != nil {
6273 status := "failed"
@@ -27,20 +27,26 @@ type extensionTool struct {
27 manager *Manager27 manager *Manager
28 timeout time.Duration28 timeout time.Duration
29 deferred bool29 deferred bool
30+ interactive bool
30 }31 }
31 32
32 // NewTool returns a core.Tool that round-trips invocations through33 // NewTool returns a core.Tool that round-trips invocations through
33 // mgr to the extension that registered (name, schema). The default34 // mgr to the extension that registered (name, schema). The default
34-// per-call timeout is 60 seconds; callers can override.35+// per-call timeout is 60 seconds. Interactive tools have no reply deadline.
35 func NewTool(mgr *Manager, info ToolInfo) core.Tool {36 func NewTool(mgr *Manager, info ToolInfo) core.Tool {
37+ timeout := 60 * time.Second
38+ if info.Interactive {
39+ timeout = 0
40+ }
36 return &extensionTool{41 return &extensionTool{
37 name: info.Name,42 name: info.Name,
38 description: info.Description,43 description: info.Description,
39 schema: info.Schema,44 schema: info.Schema,
40 extension: info.Extension,45 extension: info.Extension,
41 manager: mgr,46 manager: mgr,
42- timeout: 60 * time.Second,47+ timeout: timeout,
43 deferred: info.Deferred,48 deferred: info.Deferred,
49+ interactive: info.Interactive,
44 }50 }
45 }51 }
46 52
@@ -57,6 +63,11 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun
57 if len(args) == 0 {63 if len(args) == 0 {
58 args = json.RawMessage(`{}`)64 args = json.RawMessage(`{}`)
59 }65 }
66+ if t.interactive && !t.manager.supportsInteractiveTools() {
67+ return core.ToolResult{Status: "failed", IsError: true, Content: []provider.Content{
68+ provider.TextBlock{Text: "interactive extension tools require an interactive host"},
69+ }}, nil
70+ }
60 resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)71 resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)
61 if err != nil {72 if err != nil {
62 status := "failed"73 status := "failed"
A packages/agent/extensions/tool_cancel_test.go +137 -0
@@ -0,0 +1,137 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "testing/synctest"
12+ "time"
13+
14+ "github.com/patriceckhart/zot/packages/agent/extproto"
15+)
16+
17+func TestToolCancelCapabilityHandshake(t *testing.T) {
18+ for _, tc := range []struct {
19+ name string
20+ caps []string
21+ want bool
22+ }{
23+ {name: "omitted"},
24+ {name: "legacy", caps: []string{"tools", "panels"}},
25+ {name: "unknown", caps: []string{"tool_cancel_future", "TOOL_CANCEL"}},
26+ {name: "supported", caps: []string{"tools", "tool_cancel"}, want: true},
27+ } {
28+ t.Run(tc.name, func(t *testing.T) {
29+ root := t.TempDir()
30+ writeMockToolExtension(t, filepath.Join(root, "extensions"))
31+ path := filepath.Join(root, "extensions", "tool-mock", "run.sh")
32+ script, err := os.ReadFile(path)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ hello, err := json.Marshal(extproto.HelloFromExt{Type: "hello", Name: "tool-mock", Version: "0.1", Capabilities: tc.caps})
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ lines := strings.Split(string(script), "\n")
41+ lines[1] = "printf '%s\\n' '" + string(hello) + "'"
42+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o755); err != nil {
43+ t.Fatal(err)
44+ }
45+ m := New(root, "", "test", "", "", &stubHooks{})
46+ t.Cleanup(func() { m.Stop(time.Second) })
47+ if errs := m.Discover(context.Background()); len(errs) != 0 {
48+ t.Fatal(errs)
49+ }
50+ m.WaitForReady(time.Second)
51+ m.mu.RLock()
52+ ext := m.toolIndex["echo"]
53+ m.mu.RUnlock()
54+ if ext == nil {
55+ t.Fatal("tool was not registered")
56+ }
57+ if ext.toolCancel != tc.want {
58+ t.Fatalf("cancellation support = %v, want %v", ext.toolCancel, tc.want)
59+ }
60+ })
61+ }
62+}
63+
64+func TestToolCancelCapabilityDelivery(t *testing.T) {
65+ for _, supported := range []bool{false, true} {
66+ name := "legacy"
67+ if supported {
68+ name = "supported"
69+ }
70+ for _, outcome := range []string{"cancel", "timeout", "complete"} {
71+ t.Run(name+"/"+outcome, func(t *testing.T) {
72+ synctest.Test(t, func(t *testing.T) {
73+ m, ext, frames, replies := toolPeer(t)
74+ ext.toolCancel = supported
75+ ctx, cancel := context.WithCancel(context.Background())
76+ defer cancel()
77+ timeout := time.Duration(0)
78+ if outcome == "timeout" {
79+ timeout = time.Nanosecond
80+ }
81+ done := make(chan error, 1)
82+ go func() {
83+ _, err := m.InvokeTool(ctx, "ask", nil, timeout)
84+ done <- err
85+ }()
86+ call := toolCallFrame(t, frames)
87+ synctest.Wait()
88+ var want error
89+ switch outcome {
90+ case "cancel":
91+ want = context.Canceled
92+ cancel()
93+ case "timeout":
94+ want = context.DeadlineExceeded
95+ case "complete":
96+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
97+ }
98+ if err := awaitToolError(t, done); !errors.Is(err, want) {
99+ t.Fatalf("invocation error = %v, want %v", err, want)
100+ }
101+ if supported && outcome != "complete" {
102+ var frame extproto.ToolCancelFromHost
103+ if err := json.Unmarshal(nextLifecycleFrame(t, frames), &frame); err != nil {
104+ t.Fatal(err)
105+ }
106+ if frame.Type != "tool_cancel" || frame.ID != call.ID {
107+ t.Fatalf("unexpected cancellation frame: %+v", frame)
108+ }
109+ }
110+ synctest.Wait()
111+ select {
112+ case frame := <-frames:
113+ t.Fatalf("unexpected host frame: %s", frame)
114+ default:
115+ }
116+ ext.mu.Lock()
117+ pending := len(ext.pendingTool)
118+ ext.mu.Unlock()
119+ if pending != 0 {
120+ t.Fatal("pending invocation leaked")
121+ }
122+ // Abandoned results remain harmless without cancellation support.
123+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
124+ go func() {
125+ _, err := m.InvokeTool(context.Background(), "ask", nil, 0)
126+ done <- err
127+ }()
128+ next := toolCallFrame(t, frames)
129+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: next.ID})
130+ if err := awaitToolError(t, done); err != nil {
131+ t.Fatal(err)
132+ }
133+ })
134+ })
135+ }
136+ }
137+}
@@ -0,0 +1,137 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "testing/synctest"
12+ "time"
13+
14+ "github.com/patriceckhart/zot/packages/agent/extproto"
15+)
16+
17+func TestToolCancelCapabilityHandshake(t *testing.T) {
18+ for _, tc := range []struct {
19+ name string
20+ caps []string
21+ want bool
22+ }{
23+ {name: "omitted"},
24+ {name: "legacy", caps: []string{"tools", "panels"}},
25+ {name: "unknown", caps: []string{"tool_cancel_future", "TOOL_CANCEL"}},
26+ {name: "supported", caps: []string{"tools", "tool_cancel"}, want: true},
27+ } {
28+ t.Run(tc.name, func(t *testing.T) {
29+ root := t.TempDir()
30+ writeMockToolExtension(t, filepath.Join(root, "extensions"))
31+ path := filepath.Join(root, "extensions", "tool-mock", "run.sh")
32+ script, err := os.ReadFile(path)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ hello, err := json.Marshal(extproto.HelloFromExt{Type: "hello", Name: "tool-mock", Version: "0.1", Capabilities: tc.caps})
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ lines := strings.Split(string(script), "\n")
41+ lines[1] = "printf '%s\\n' '" + string(hello) + "'"
42+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o755); err != nil {
43+ t.Fatal(err)
44+ }
45+ m := New(root, "", "test", "", "", &stubHooks{})
46+ t.Cleanup(func() { m.Stop(time.Second) })
47+ if errs := m.Discover(context.Background()); len(errs) != 0 {
48+ t.Fatal(errs)
49+ }
50+ m.WaitForReady(time.Second)
51+ m.mu.RLock()
52+ ext := m.toolIndex["echo"]
53+ m.mu.RUnlock()
54+ if ext == nil {
55+ t.Fatal("tool was not registered")
56+ }
57+ if ext.toolCancel != tc.want {
58+ t.Fatalf("cancellation support = %v, want %v", ext.toolCancel, tc.want)
59+ }
60+ })
61+ }
62+}
63+
64+func TestToolCancelCapabilityDelivery(t *testing.T) {
65+ for _, supported := range []bool{false, true} {
66+ name := "legacy"
67+ if supported {
68+ name = "supported"
69+ }
70+ for _, outcome := range []string{"cancel", "timeout", "complete"} {
71+ t.Run(name+"/"+outcome, func(t *testing.T) {
72+ synctest.Test(t, func(t *testing.T) {
73+ m, ext, frames, replies := toolPeer(t)
74+ ext.toolCancel = supported
75+ ctx, cancel := context.WithCancel(context.Background())
76+ defer cancel()
77+ timeout := time.Duration(0)
78+ if outcome == "timeout" {
79+ timeout = time.Nanosecond
80+ }
81+ done := make(chan error, 1)
82+ go func() {
83+ _, err := m.InvokeTool(ctx, "ask", nil, timeout)
84+ done <- err
85+ }()
86+ call := toolCallFrame(t, frames)
87+ synctest.Wait()
88+ var want error
89+ switch outcome {
90+ case "cancel":
91+ want = context.Canceled
92+ cancel()
93+ case "timeout":
94+ want = context.DeadlineExceeded
95+ case "complete":
96+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
97+ }
98+ if err := awaitToolError(t, done); !errors.Is(err, want) {
99+ t.Fatalf("invocation error = %v, want %v", err, want)
100+ }
101+ if supported && outcome != "complete" {
102+ var frame extproto.ToolCancelFromHost
103+ if err := json.Unmarshal(nextLifecycleFrame(t, frames), &frame); err != nil {
104+ t.Fatal(err)
105+ }
106+ if frame.Type != "tool_cancel" || frame.ID != call.ID {
107+ t.Fatalf("unexpected cancellation frame: %+v", frame)
108+ }
109+ }
110+ synctest.Wait()
111+ select {
112+ case frame := <-frames:
113+ t.Fatalf("unexpected host frame: %s", frame)
114+ default:
115+ }
116+ ext.mu.Lock()
117+ pending := len(ext.pendingTool)
118+ ext.mu.Unlock()
119+ if pending != 0 {
120+ t.Fatal("pending invocation leaked")
121+ }
122+ // Abandoned results remain harmless without cancellation support.
123+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
124+ go func() {
125+ _, err := m.InvokeTool(context.Background(), "ask", nil, 0)
126+ done <- err
127+ }()
128+ next := toolCallFrame(t, frames)
129+ sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: next.ID})
130+ if err := awaitToolError(t, done); err != nil {
131+ t.Fatal(err)
132+ }
133+ })
134+ })
135+ }
136+ }
137+}
M packages/agent/extproto/extproto.go +7 -0
@@ -45,6 +45,7 @@ type RegisterToolFromExt struct {
4545 Description string `json:"description,omitempty"`
4646 Schema json.RawMessage `json:"schema"`
4747 Deferred bool `json:"deferred,omitempty"`
48+ Interactive bool `json:"interactive,omitempty"`
4849 }
4950
5051 type ReadyFromExt struct {
@@ -176,6 +177,12 @@ type CommandInvokedFromHost struct {
176177 Args string `json:"args,omitempty"`
177178 }
178179
180+// ToolCancelFromHost asks the extension to stop an abandoned invocation.
181+type ToolCancelFromHost struct {
182+ Type string `json:"type"`
183+ ID string `json:"id"`
184+}
185+
179186 type ToolCallFromHost struct {
180187 Type string `json:"type"`
181188 ID string `json:"id"`
@@ -45,6 +45,7 @@ type RegisterToolFromExt struct {
45 Description string `json:"description,omitempty"`45 Description string `json:"description,omitempty"`
46 Schema json.RawMessage `json:"schema"`46 Schema json.RawMessage `json:"schema"`
47 Deferred bool `json:"deferred,omitempty"`47 Deferred bool `json:"deferred,omitempty"`
48+ Interactive bool `json:"interactive,omitempty"`
48 }49 }
49 50
50 type ReadyFromExt struct {51 type ReadyFromExt struct {
@@ -176,6 +177,12 @@ type CommandInvokedFromHost struct {
176 Args string `json:"args,omitempty"`177 Args string `json:"args,omitempty"`
177 }178 }
178 179
180+// ToolCancelFromHost asks the extension to stop an abandoned invocation.
181+type ToolCancelFromHost struct {
182+ Type string `json:"type"`
183+ ID string `json:"id"`
184+}
185+
179 type ToolCallFromHost struct {186 type ToolCallFromHost struct {
180 Type string `json:"type"`187 Type string `json:"type"`
181 ID string `json:"id"`188 ID string `json:"id"`

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close