Add host-mediated tool calls for extensions
- commit
- beceda1becfc62caca497c102b7fc6a5698e56ee
- author
- patriceckhart · · 2026-09-24 17:17 UTC
- parent
- e344aa8
- signature
- unsigned
- refs
- tag: v0.3.95
18 files · +843 -68
M docs/extensions.md +51 -0
| @@ -180,11 +180,62 @@ other stdout frame before `hello`. | |||
| 180 | 180 | "capabilities":["commands","tools","panels"]} | |
| 181 | 181 | ``` | |
| 182 | 182 | ||
| 183 | + | Include `"call_tool"` to opt in to host-mediated calls to built-in and | |
| 184 | + | extension tools. The host advertises support in `hello_ack.capabilities`. | |
| 185 | + | Older hosts omit it, and callers must not send `call_tool` to those hosts. | |
| 186 | + | ||
| 183 | 187 | Include `"tool_cancel"` in `capabilities` to opt in to host cancellation | |
| 184 | 188 | notifications. The Go SDK advertises this automatically. The host only sends | |
| 185 | 189 | `tool_cancel` to extensions advertising that exact capability. Missing or | |
| 186 | 190 | unknown capabilities do not enable cancellation notifications. | |
| 187 | 191 | ||
| 192 | + | #### `call_tool` | |
| 193 | + | ||
| 194 | + | An opt-in extension may request an active tool by name with JSON object args: | |
| 195 | + | ||
| 196 | + | ```json | |
| 197 | + | {"type":"call_tool","id":"request-1","name":"skill","args":{"name":"example"}} | |
| 198 | + | ``` | |
| 199 | + | ||
| 200 | + | The host replies with a correlated result: | |
| 201 | + | ||
| 202 | + | ```json | |
| 203 | + | {"type":"tool_result","id":"request-1","name":"skill", | |
| 204 | + | "content":[{"type":"text","text":"..."}],"is_error":false} | |
| 205 | + | ``` | |
| 206 | + | ||
| 207 | + | `is_error` reports tool or policy failure. Content contains text or base64 | |
| 208 | + | image blocks. Results are bounded to 256 KiB and mark `truncated: true` when | |
| 209 | + | shortened. Each request is synchronous for its caller, but multiple requests | |
| 210 | + | can be outstanding concurrently if they use distinct IDs. A | |
| 211 | + | `call_tool_cancel` frame with the same ID cancels the request. Calls time out | |
| 212 | + | after 60 seconds or when the extension disconnects. Cancellation cannot undo | |
| 213 | + | side effects that have already completed. | |
| 214 | + | ||
| 215 | + | Calls use the active agent's tool registry, guard interception, and normal | |
| 216 | + | confirmation policy. Headless modes retain their normal no-confirmation | |
| 217 | + | behavior. A call does not create a model tool-use message in the session | |
| 218 | + | transcript. Tool-call, tool-result, confirmation, permission-decision, and | |
| 219 | + | tool-interception frames identify these calls through `origin_extension`. | |
| 220 | + | As with model-initiated calls, event subscribers may receive sensitive tool | |
| 221 | + | output. | |
| 222 | + | ||
| 223 | + | Tool handlers can invoke other tools by setting `parent_id` to the ID of the | |
| 224 | + | host's `tool_call` frame. The host carries the parent's cancellation context | |
| 225 | + | through the nested call, refuses repeated tool names in a chain, and allows at | |
| 226 | + | most four tool calls in one chain, including the original tool. Unknown or | |
| 227 | + | expired parent IDs fail. To avoid untraceable recursion, requests without | |
| 228 | + | `parent_id` are refused while that extension has a host-invoked tool active. | |
| 229 | + | A concurrent command-initiated request from that extension is therefore also | |
| 230 | + | refused until its host-invoked tool finishes. Interactive tools can outlive the | |
| 231 | + | 60-second request deadline only when invoked by the model, not via `call_tool`. | |
| 232 | + | ||
| 233 | + | The Go SDK exposes `e.CallTool(ctx, name, args)` and rejects older hosts | |
| 234 | + | immediately. A tool handler's context supplies `parent_id` automatically. | |
| 235 | + | Tool failures return `ToolResult.IsError`, while context and transport failures | |
| 236 | + | return a Go error. Extensions have no direct access to another extension's | |
| 237 | + | process or built-in implementation. | |
| 238 | + | ||
| 188 | 239 | #### `register_command` | |
| 189 | 240 | ||
| 190 | 241 | ```json | |
| @@ -180,11 +180,62 @@ other stdout frame before `hello`. | |||||
| 180 | "capabilities":["commands","tools","panels"]} | 180 | "capabilities":["commands","tools","panels"]} | ||
| 181 | ``` | 181 | ``` | ||
| 182 | 182 | ||||
| 183 | + | Include `"call_tool"` to opt in to host-mediated calls to built-in and | |||
| 184 | + | extension tools. The host advertises support in `hello_ack.capabilities`. | |||
| 185 | + | Older hosts omit it, and callers must not send `call_tool` to those hosts. | |||
| 186 | + | ||||
| 183 | Include `"tool_cancel"` in `capabilities` to opt in to host cancellation | 187 | Include `"tool_cancel"` in `capabilities` to opt in to host cancellation | ||
| 184 | notifications. The Go SDK advertises this automatically. The host only sends | 188 | notifications. The Go SDK advertises this automatically. The host only sends | ||
| 185 | `tool_cancel` to extensions advertising that exact capability. Missing or | 189 | `tool_cancel` to extensions advertising that exact capability. Missing or | ||
| 186 | unknown capabilities do not enable cancellation notifications. | 190 | unknown capabilities do not enable cancellation notifications. | ||
| 187 | 191 | ||||
| 192 | + | #### `call_tool` | |||
| 193 | + | ||||
| 194 | + | An opt-in extension may request an active tool by name with JSON object args: | |||
| 195 | + | ||||
| 196 | + | ```json | |||
| 197 | + | {"type":"call_tool","id":"request-1","name":"skill","args":{"name":"example"}} | |||
| 198 | + | ``` | |||
| 199 | + | ||||
| 200 | + | The host replies with a correlated result: | |||
| 201 | + | ||||
| 202 | + | ```json | |||
| 203 | + | {"type":"tool_result","id":"request-1","name":"skill", | |||
| 204 | + | "content":[{"type":"text","text":"..."}],"is_error":false} | |||
| 205 | + | ``` | |||
| 206 | + | ||||
| 207 | + | `is_error` reports tool or policy failure. Content contains text or base64 | |||
| 208 | + | image blocks. Results are bounded to 256 KiB and mark `truncated: true` when | |||
| 209 | + | shortened. Each request is synchronous for its caller, but multiple requests | |||
| 210 | + | can be outstanding concurrently if they use distinct IDs. A | |||
| 211 | + | `call_tool_cancel` frame with the same ID cancels the request. Calls time out | |||
| 212 | + | after 60 seconds or when the extension disconnects. Cancellation cannot undo | |||
| 213 | + | side effects that have already completed. | |||
| 214 | + | ||||
| 215 | + | Calls use the active agent's tool registry, guard interception, and normal | |||
| 216 | + | confirmation policy. Headless modes retain their normal no-confirmation | |||
| 217 | + | behavior. A call does not create a model tool-use message in the session | |||
| 218 | + | transcript. Tool-call, tool-result, confirmation, permission-decision, and | |||
| 219 | + | tool-interception frames identify these calls through `origin_extension`. | |||
| 220 | + | As with model-initiated calls, event subscribers may receive sensitive tool | |||
| 221 | + | output. | |||
| 222 | + | ||||
| 223 | + | Tool handlers can invoke other tools by setting `parent_id` to the ID of the | |||
| 224 | + | host's `tool_call` frame. The host carries the parent's cancellation context | |||
| 225 | + | through the nested call, refuses repeated tool names in a chain, and allows at | |||
| 226 | + | most four tool calls in one chain, including the original tool. Unknown or | |||
| 227 | + | expired parent IDs fail. To avoid untraceable recursion, requests without | |||
| 228 | + | `parent_id` are refused while that extension has a host-invoked tool active. | |||
| 229 | + | A concurrent command-initiated request from that extension is therefore also | |||
| 230 | + | refused until its host-invoked tool finishes. Interactive tools can outlive the | |||
| 231 | + | 60-second request deadline only when invoked by the model, not via `call_tool`. | |||
| 232 | + | ||||
| 233 | + | The Go SDK exposes `e.CallTool(ctx, name, args)` and rejects older hosts | |||
| 234 | + | immediately. A tool handler's context supplies `parent_id` automatically. | |||
| 235 | + | Tool failures return `ToolResult.IsError`, while context and transport failures | |||
| 236 | + | return a Go error. Extensions have no direct access to another extension's | |||
| 237 | + | process or built-in implementation. | |||
| 238 | + | ||||
| 188 | #### `register_command` | 239 | #### `register_command` | ||
| 189 | 240 | ||||
| 190 | ```json | 241 | ```json | ||
M packages/agent/cli.go +6 -2
| @@ -352,6 +352,7 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr | |||
| 352 | 352 | if ag == nil || extMgr == nil { | |
| 353 | 353 | return | |
| 354 | 354 | } | |
| 355 | + | extMgr.SetToolCaller(extensionToolCaller(extMgr, func() *core.Agent { return ag })) | |
| 355 | 356 | wireBeforeAgentStart(ag, extMgr, "") | |
| 356 | 357 | ag.BeforeToolExecuteContext = func(ctx context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 357 | 358 | res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |
| @@ -1536,14 +1537,17 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1536 | 1537 | }, | |
| 1537 | 1538 | }) | |
| 1538 | 1539 | ||
| 1540 | + | extMgr.SetToolCaller(extensionToolCaller(extMgr, func() *core.Agent { return liveInteractiveAgent(iv, ag) })) | |
| 1541 | + | ||
| 1539 | 1542 | // Bind the interactive TUI as the Confirmer. We deferred this | |
| 1540 | 1543 | // until now because the gate is constructed before the TUI | |
| 1541 | 1544 | // (the BeforeToolExecute closure captures it). SetConfirmer | |
| 1542 | 1545 | // is mutex-guarded on the gate so this is safe. | |
| 1543 | 1546 | if confirmGate != nil { | |
| 1544 | 1547 | confirmGate.SetConfirmer(&confirmationEventConfirmer{ | |
| 1545 | - | inner: iv, | |
| 1546 | - | emit: extMgr.EmitEvent, | |
| 1548 | + | inner: iv, | |
| 1549 | + | emit: extMgr.EmitEvent, | |
| 1550 | + | origin: extMgr.ToolCallOrigin, | |
| 1547 | 1551 | }) | |
| 1548 | 1552 | } | |
| 1549 | 1553 | ||
| @@ -352,6 +352,7 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr | |||||
| 352 | if ag == nil || extMgr == nil { | 352 | if ag == nil || extMgr == nil { | ||
| 353 | return | 353 | return | ||
| 354 | } | 354 | } | ||
| 355 | + | extMgr.SetToolCaller(extensionToolCaller(extMgr, func() *core.Agent { return ag })) | |||
| 355 | wireBeforeAgentStart(ag, extMgr, "") | 356 | wireBeforeAgentStart(ag, extMgr, "") | ||
| 356 | ag.BeforeToolExecuteContext = func(ctx context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) { | 357 | ag.BeforeToolExecuteContext = func(ctx context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) { | ||
| 357 | res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | 358 | res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | ||
| @@ -1536,14 +1537,17 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1536 | }, | 1537 | }, | ||
| 1537 | }) | 1538 | }) | ||
| 1538 | 1539 | ||||
| 1540 | + | extMgr.SetToolCaller(extensionToolCaller(extMgr, func() *core.Agent { return liveInteractiveAgent(iv, ag) })) | |||
| 1541 | + | ||||
| 1539 | // Bind the interactive TUI as the Confirmer. We deferred this | 1542 | // Bind the interactive TUI as the Confirmer. We deferred this | ||
| 1540 | // until now because the gate is constructed before the TUI | 1543 | // until now because the gate is constructed before the TUI | ||
| 1541 | // (the BeforeToolExecute closure captures it). SetConfirmer | 1544 | // (the BeforeToolExecute closure captures it). SetConfirmer | ||
| 1542 | // is mutex-guarded on the gate so this is safe. | 1545 | // is mutex-guarded on the gate so this is safe. | ||
| 1543 | if confirmGate != nil { | 1546 | if confirmGate != nil { | ||
| 1544 | confirmGate.SetConfirmer(&confirmationEventConfirmer{ | 1547 | confirmGate.SetConfirmer(&confirmationEventConfirmer{ | ||
| 1545 | - | inner: iv, | 1548 | + | inner: iv, |
| 1546 | - | emit: extMgr.EmitEvent, | 1549 | + | emit: extMgr.EmitEvent, |
| 1550 | + | origin: extMgr.ToolCallOrigin, | |||
| 1547 | }) | 1551 | }) | ||
| 1548 | } | 1552 | } | ||
| 1549 | 1553 | ||||
M packages/agent/confirmation_events.go +12 -6
| @@ -9,8 +9,9 @@ import ( | |||
| 9 | 9 | // extension lifecycle event. ConfirmGate only invokes this decorator when a | |
| 10 | 10 | // call actually needs user input, after remembered approvals have been checked. | |
| 11 | 11 | type confirmationEventConfirmer struct { | |
| 12 | - | inner core.Confirmer | |
| 13 | - | emit func(extproto.EventFromHost) | |
| 12 | + | inner core.Confirmer | |
| 13 | + | emit func(extproto.EventFromHost) | |
| 14 | + | origin func(string) string | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | 17 | func (c *confirmationEventConfirmer) Confirm(toolName, preview string) core.ConfirmDecision { | |
| @@ -31,10 +32,15 @@ func (c *confirmationEventConfirmer) emitRequest(call core.ToolCallConfirmation) | |||
| 31 | 32 | if c.emit == nil { | |
| 32 | 33 | return | |
| 33 | 34 | } | |
| 35 | + | origin := "" | |
| 36 | + | if c.origin != nil { | |
| 37 | + | origin = c.origin(call.ID) | |
| 38 | + | } | |
| 34 | 39 | c.emit(extproto.EventFromHost{ | |
| 35 | - | Event: "tool_confirmation_requested", | |
| 36 | - | ToolID: call.ID, | |
| 37 | - | ToolName: call.Name, | |
| 38 | - | ToolPreview: call.Summary, | |
| 40 | + | OriginExtension: origin, | |
| 41 | + | Event: "tool_confirmation_requested", | |
| 42 | + | ToolID: call.ID, | |
| 43 | + | ToolName: call.Name, | |
| 44 | + | ToolPreview: call.Summary, | |
| 39 | 45 | }) | |
| 40 | 46 | } | |
| @@ -9,8 +9,9 @@ import ( | |||||
| 9 | // extension lifecycle event. ConfirmGate only invokes this decorator when a | 9 | // extension lifecycle event. ConfirmGate only invokes this decorator when a | ||
| 10 | // call actually needs user input, after remembered approvals have been checked. | 10 | // call actually needs user input, after remembered approvals have been checked. | ||
| 11 | type confirmationEventConfirmer struct { | 11 | type confirmationEventConfirmer struct { | ||
| 12 | - | inner core.Confirmer | 12 | + | inner core.Confirmer |
| 13 | - | emit func(extproto.EventFromHost) | 13 | + | emit func(extproto.EventFromHost) |
| 14 | + | origin func(string) string | |||
| 14 | } | 15 | } | ||
| 15 | 16 | ||||
| 16 | func (c *confirmationEventConfirmer) Confirm(toolName, preview string) core.ConfirmDecision { | 17 | func (c *confirmationEventConfirmer) Confirm(toolName, preview string) core.ConfirmDecision { | ||
| @@ -31,10 +32,15 @@ func (c *confirmationEventConfirmer) emitRequest(call core.ToolCallConfirmation) | |||||
| 31 | if c.emit == nil { | 32 | if c.emit == nil { | ||
| 32 | return | 33 | return | ||
| 33 | } | 34 | } | ||
| 35 | + | origin := "" | |||
| 36 | + | if c.origin != nil { | |||
| 37 | + | origin = c.origin(call.ID) | |||
| 38 | + | } | |||
| 34 | c.emit(extproto.EventFromHost{ | 39 | c.emit(extproto.EventFromHost{ | ||
| 35 | - | Event: "tool_confirmation_requested", | 40 | + | OriginExtension: origin, |
| 36 | - | ToolID: call.ID, | 41 | + | Event: "tool_confirmation_requested", |
| 37 | - | ToolName: call.Name, | 42 | + | ToolID: call.ID, |
| 38 | - | ToolPreview: call.Summary, | 43 | + | ToolName: call.Name, |
| 44 | + | ToolPreview: call.Summary, | |||
| 39 | }) | 45 | }) | ||
| 40 | } | 46 | } | ||
M packages/agent/confirmation_events_test.go +18 -0
| @@ -21,6 +21,24 @@ func (c *rememberingDetailedConfirmer) ConfirmToolCall(call core.ToolCallConfirm | |||
| 21 | 21 | return core.ConfirmDecision{Allow: true, RememberTool: true} | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | + | func TestConfirmationEventIncludesExtensionOrigin(t *testing.T) { | |
| 25 | + | var event extproto.EventFromHost | |
| 26 | + | confirmer := &confirmationEventConfirmer{ | |
| 27 | + | inner: &rememberingDetailedConfirmer{}, | |
| 28 | + | emit: func(ev extproto.EventFromHost) { event = ev }, | |
| 29 | + | origin: func(id string) string { | |
| 30 | + | if id != "host-call" { | |
| 31 | + | t.Fatalf("tool ID: %q", id) | |
| 32 | + | } | |
| 33 | + | return "caller" | |
| 34 | + | }, | |
| 35 | + | } | |
| 36 | + | confirmer.ConfirmToolCall(core.ToolCallConfirmation{ID: "host-call", Name: "read"}) | |
| 37 | + | if event.OriginExtension != "caller" || event.Event != "tool_confirmation_requested" { | |
| 38 | + | t.Fatalf("confirmation event: %+v", event) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 24 | 42 | func TestConfirmationEventConfirmerEmitsOnlyWhenPrompting(t *testing.T) { | |
| 25 | 43 | inner := &rememberingDetailedConfirmer{} | |
| 26 | 44 | var events []extproto.EventFromHost | |
| @@ -21,6 +21,24 @@ func (c *rememberingDetailedConfirmer) ConfirmToolCall(call core.ToolCallConfirm | |||||
| 21 | return core.ConfirmDecision{Allow: true, RememberTool: true} | 21 | return core.ConfirmDecision{Allow: true, RememberTool: true} | ||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | + | func TestConfirmationEventIncludesExtensionOrigin(t *testing.T) { | |||
| 25 | + | var event extproto.EventFromHost | |||
| 26 | + | confirmer := &confirmationEventConfirmer{ | |||
| 27 | + | inner: &rememberingDetailedConfirmer{}, | |||
| 28 | + | emit: func(ev extproto.EventFromHost) { event = ev }, | |||
| 29 | + | origin: func(id string) string { | |||
| 30 | + | if id != "host-call" { | |||
| 31 | + | t.Fatalf("tool ID: %q", id) | |||
| 32 | + | } | |||
| 33 | + | return "caller" | |||
| 34 | + | }, | |||
| 35 | + | } | |||
| 36 | + | confirmer.ConfirmToolCall(core.ToolCallConfirmation{ID: "host-call", Name: "read"}) | |||
| 37 | + | if event.OriginExtension != "caller" || event.Event != "tool_confirmation_requested" { | |||
| 38 | + | t.Fatalf("confirmation event: %+v", event) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 24 | func TestConfirmationEventConfirmerEmitsOnlyWhenPrompting(t *testing.T) { | 42 | func TestConfirmationEventConfirmerEmitsOnlyWhenPrompting(t *testing.T) { | ||
| 25 | inner := &rememberingDetailedConfirmer{} | 43 | inner := &rememberingDetailedConfirmer{} | ||
| 26 | var events []extproto.EventFromHost | 44 | var events []extproto.EventFromHost | ||
A packages/agent/ext/call_tool.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package ext | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type parentCallKey struct{} | |
| 13 | + | ||
| 14 | + | // CallTool asks zot to invoke an active built-in or extension tool. Tool | |
| 15 | + | // failures are returned as ToolResult.IsError, transport failures as err. | |
| 16 | + | // Calls made from tool handlers inherit their parent call for cycle detection | |
| 17 | + | // and cancellation. | |
| 18 | + | func (e *Extension) CallTool(ctx context.Context, name string, args any) (ToolResult, error) { | |
| 19 | + | if !e.callToolReady.Load() { | |
| 20 | + | return ToolResult{}, fmt.Errorf("host does not support call_tool") | |
| 21 | + | } | |
| 22 | + | if ctx == nil { | |
| 23 | + | return ToolResult{}, fmt.Errorf("call_tool requires a context") | |
| 24 | + | } | |
| 25 | + | data, err := json.Marshal(args) | |
| 26 | + | if err != nil { | |
| 27 | + | return ToolResult{}, fmt.Errorf("encode tool args: %w", err) | |
| 28 | + | } | |
| 29 | + | if len(data) == 0 || data[0] != '{' { | |
| 30 | + | return ToolResult{}, fmt.Errorf("call_tool args must be a JSON object") | |
| 31 | + | } | |
| 32 | + | ctx, cancel := context.WithTimeout(ctx, 60*time.Second) | |
| 33 | + | defer cancel() | |
| 34 | + | id := fmt.Sprintf("sdk-%d", e.callSeq.Add(1)) | |
| 35 | + | ch := make(chan extproto.ToolResultFromHost, 1) | |
| 36 | + | e.mu.Lock() | |
| 37 | + | e.pendingCalls[id] = ch | |
| 38 | + | e.mu.Unlock() | |
| 39 | + | defer func() { | |
| 40 | + | e.mu.Lock() | |
| 41 | + | delete(e.pendingCalls, id) | |
| 42 | + | e.mu.Unlock() | |
| 43 | + | }() | |
| 44 | + | parent, _ := ctx.Value(parentCallKey{}).(string) | |
| 45 | + | if err := e.send(extproto.CallToolFromExt{Type: "call_tool", ID: id, Name: name, Args: data, ParentID: parent}); err != nil { | |
| 46 | + | return ToolResult{}, err | |
| 47 | + | } | |
| 48 | + | select { | |
| 49 | + | case reply := <-ch: | |
| 50 | + | out := ToolResult{IsError: reply.IsError} | |
| 51 | + | for _, block := range reply.Content { | |
| 52 | + | out.Content = append(out.Content, ToolContent{Type: block.Type, Text: block.Text, MimeType: block.MimeType, Data: block.Data}) | |
| 53 | + | } | |
| 54 | + | if reply.Truncated { | |
| 55 | + | out.Content = append(out.Content, Text("[tool result truncated by host]")) | |
| 56 | + | } | |
| 57 | + | return out, nil | |
| 58 | + | case <-ctx.Done(): | |
| 59 | + | _ = e.send(extproto.Frame{Type: "call_tool_cancel", ID: id}) | |
| 60 | + | return ToolResult{}, ctx.Err() | |
| 61 | + | case <-e.done: | |
| 62 | + | return ToolResult{}, fmt.Errorf("host disconnected") | |
| 63 | + | } | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package ext | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type parentCallKey struct{} | |||
| 13 | + | ||||
| 14 | + | // CallTool asks zot to invoke an active built-in or extension tool. Tool | |||
| 15 | + | // failures are returned as ToolResult.IsError, transport failures as err. | |||
| 16 | + | // Calls made from tool handlers inherit their parent call for cycle detection | |||
| 17 | + | // and cancellation. | |||
| 18 | + | func (e *Extension) CallTool(ctx context.Context, name string, args any) (ToolResult, error) { | |||
| 19 | + | if !e.callToolReady.Load() { | |||
| 20 | + | return ToolResult{}, fmt.Errorf("host does not support call_tool") | |||
| 21 | + | } | |||
| 22 | + | if ctx == nil { | |||
| 23 | + | return ToolResult{}, fmt.Errorf("call_tool requires a context") | |||
| 24 | + | } | |||
| 25 | + | data, err := json.Marshal(args) | |||
| 26 | + | if err != nil { | |||
| 27 | + | return ToolResult{}, fmt.Errorf("encode tool args: %w", err) | |||
| 28 | + | } | |||
| 29 | + | if len(data) == 0 || data[0] != '{' { | |||
| 30 | + | return ToolResult{}, fmt.Errorf("call_tool args must be a JSON object") | |||
| 31 | + | } | |||
| 32 | + | ctx, cancel := context.WithTimeout(ctx, 60*time.Second) | |||
| 33 | + | defer cancel() | |||
| 34 | + | id := fmt.Sprintf("sdk-%d", e.callSeq.Add(1)) | |||
| 35 | + | ch := make(chan extproto.ToolResultFromHost, 1) | |||
| 36 | + | e.mu.Lock() | |||
| 37 | + | e.pendingCalls[id] = ch | |||
| 38 | + | e.mu.Unlock() | |||
| 39 | + | defer func() { | |||
| 40 | + | e.mu.Lock() | |||
| 41 | + | delete(e.pendingCalls, id) | |||
| 42 | + | e.mu.Unlock() | |||
| 43 | + | }() | |||
| 44 | + | parent, _ := ctx.Value(parentCallKey{}).(string) | |||
| 45 | + | if err := e.send(extproto.CallToolFromExt{Type: "call_tool", ID: id, Name: name, Args: data, ParentID: parent}); err != nil { | |||
| 46 | + | return ToolResult{}, err | |||
| 47 | + | } | |||
| 48 | + | select { | |||
| 49 | + | case reply := <-ch: | |||
| 50 | + | out := ToolResult{IsError: reply.IsError} | |||
| 51 | + | for _, block := range reply.Content { | |||
| 52 | + | out.Content = append(out.Content, ToolContent{Type: block.Type, Text: block.Text, MimeType: block.MimeType, Data: block.Data}) | |||
| 53 | + | } | |||
| 54 | + | if reply.Truncated { | |||
| 55 | + | out.Content = append(out.Content, Text("[tool result truncated by host]")) | |||
| 56 | + | } | |||
| 57 | + | return out, nil | |||
| 58 | + | case <-ctx.Done(): | |||
| 59 | + | _ = e.send(extproto.Frame{Type: "call_tool_cancel", ID: id}) | |||
| 60 | + | return ToolResult{}, ctx.Err() | |||
| 61 | + | case <-e.done: | |||
| 62 | + | return ToolResult{}, fmt.Errorf("host disconnected") | |||
| 63 | + | } | |||
| 64 | + | } | |||
A packages/agent/ext/call_tool_test.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package ext | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCallToolRoundTrip(t *testing.T) { | |
| 14 | + | h := newHarness("caller") | |
| 15 | + | done := make(chan error, 1) | |
| 16 | + | go func() { done <- h.ext.Run() }() | |
| 17 | + | t.Cleanup(func() { | |
| 18 | + | h.hostW.Close() | |
| 19 | + | select { | |
| 20 | + | case err := <-done: | |
| 21 | + | if err != nil { | |
| 22 | + | t.Error(err) | |
| 23 | + | } | |
| 24 | + | case <-time.After(time.Second): | |
| 25 | + | t.Error("SDK did not exit") | |
| 26 | + | } | |
| 27 | + | }) | |
| 28 | + | h.drainUntil(t, "hello") | |
| 29 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", Capabilities: []string{"call_tool"}}) | |
| 30 | + | h.drainUntil(t, "ready") | |
| 31 | + | result := make(chan ToolResult, 1) | |
| 32 | + | errs := make(chan error, 1) | |
| 33 | + | go func() { | |
| 34 | + | r, err := h.ext.CallTool(context.Background(), "skill", map[string]string{"name": "test"}) | |
| 35 | + | result <- r | |
| 36 | + | errs <- err | |
| 37 | + | }() | |
| 38 | + | frame := h.drainUntil(t, "call_tool") | |
| 39 | + | var call extproto.CallToolFromExt | |
| 40 | + | if err := json.Unmarshal(frame.raw, &call); err != nil || call.Name != "skill" || string(call.Args) != `{"name":"test"}` { | |
| 41 | + | t.Fatalf("call: %+v, %v", call, err) | |
| 42 | + | } | |
| 43 | + | h.sendToExt(t, extproto.ToolResultFromHost{Type: "tool_result", ID: call.ID, Name: "skill", Content: []extproto.ContentBlock{{Type: "text", Text: "loaded"}}}) | |
| 44 | + | if err := <-errs; err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | if r := <-result; len(r.Content) != 1 || r.Content[0].Text != "loaded" { | |
| 48 | + | t.Fatalf("result: %+v", r) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestCallToolFromToolHandlerCarriesParent(t *testing.T) { | |
| 53 | + | h := newHarness("caller") | |
| 54 | + | h.ext.InteractiveTool("ask", "ask", json.RawMessage(`{}`), func(ctx context.Context, _ json.RawMessage) ToolResult { | |
| 55 | + | res, err := h.ext.CallTool(ctx, "read", map[string]string{"path": "a"}) | |
| 56 | + | if err != nil { | |
| 57 | + | return TextErrorResult(err.Error()) | |
| 58 | + | } | |
| 59 | + | return res | |
| 60 | + | }) | |
| 61 | + | done := make(chan error, 1) | |
| 62 | + | go func() { done <- h.ext.Run() }() | |
| 63 | + | t.Cleanup(func() { | |
| 64 | + | h.hostW.Close() | |
| 65 | + | select { | |
| 66 | + | case err := <-done: | |
| 67 | + | if err != nil { | |
| 68 | + | t.Error(err) | |
| 69 | + | } | |
| 70 | + | case <-time.After(time.Second): | |
| 71 | + | t.Error("SDK did not exit") | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | h.drainUntil(t, "hello") | |
| 75 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", Capabilities: []string{"call_tool"}}) | |
| 76 | + | h.drainUntil(t, "ready") | |
| 77 | + | h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "parent", Name: "ask", Args: json.RawMessage(`{}`)}) | |
| 78 | + | var call extproto.CallToolFromExt | |
| 79 | + | if err := json.Unmarshal(h.drainUntil(t, "call_tool").raw, &call); err != nil || call.ParentID != "parent" || call.Name != "read" { | |
| 80 | + | t.Fatalf("nested request: %+v, %v", call, err) | |
| 81 | + | } | |
| 82 | + | h.sendToExt(t, extproto.ToolResultFromHost{Type: "tool_result", ID: call.ID, Name: "read", Content: []extproto.ContentBlock{{Type: "text", Text: "loaded"}}}) | |
| 83 | + | var reply extproto.ToolResultFromExt | |
| 84 | + | if err := json.Unmarshal(h.drainUntil(t, "tool_result").raw, &reply); err != nil || reply.ID != "parent" || reply.Content[0].Text != "loaded" { | |
| 85 | + | t.Fatalf("parent result: %+v, %v", reply, err) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestCallToolCancellation(t *testing.T) { | |
| 90 | + | h := newHarness("caller") | |
| 91 | + | done := make(chan error, 1) | |
| 92 | + | go func() { done <- h.ext.Run() }() | |
| 93 | + | t.Cleanup(func() { | |
| 94 | + | h.hostW.Close() | |
| 95 | + | select { | |
| 96 | + | case err := <-done: | |
| 97 | + | if err != nil { | |
| 98 | + | t.Error(err) | |
| 99 | + | } | |
| 100 | + | case <-time.After(time.Second): | |
| 101 | + | t.Error("SDK did not exit") | |
| 102 | + | } | |
| 103 | + | }) | |
| 104 | + | h.drainUntil(t, "hello") | |
| 105 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", Capabilities: []string{"call_tool"}}) | |
| 106 | + | h.drainUntil(t, "ready") | |
| 107 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 108 | + | result := make(chan error, 1) | |
| 109 | + | go func() { _, err := h.ext.CallTool(ctx, "read", map[string]string{}); result <- err }() | |
| 110 | + | var call extproto.CallToolFromExt | |
| 111 | + | if err := json.Unmarshal(h.drainUntil(t, "call_tool").raw, &call); err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | cancel() | |
| 115 | + | frame := h.drainUntil(t, "call_tool_cancel") | |
| 116 | + | if frame.hdr.ID != call.ID { | |
| 117 | + | t.Fatalf("canceled ID = %q, want %q", frame.hdr.ID, call.ID) | |
| 118 | + | } | |
| 119 | + | select { | |
| 120 | + | case err := <-result: | |
| 121 | + | if err != context.Canceled { | |
| 122 | + | t.Fatalf("cancel error: %v", err) | |
| 123 | + | } | |
| 124 | + | case <-time.After(time.Second): | |
| 125 | + | t.Fatal("call did not cancel") | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestCallToolUnsupportedHost(t *testing.T) { | |
| 130 | + | e := New("caller", "test") | |
| 131 | + | _, err := e.CallTool(context.Background(), "read", map[string]string{"path": "a"}) | |
| 132 | + | if err == nil || !strings.Contains(err.Error(), "does not support") { | |
| 133 | + | t.Fatalf("unsupported host: %v", err) | |
| 134 | + | } | |
| 135 | + | } | |
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | package ext | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestCallToolRoundTrip(t *testing.T) { | |||
| 14 | + | h := newHarness("caller") | |||
| 15 | + | done := make(chan error, 1) | |||
| 16 | + | go func() { done <- h.ext.Run() }() | |||
| 17 | + | t.Cleanup(func() { | |||
| 18 | + | h.hostW.Close() | |||
| 19 | + | select { | |||
| 20 | + | case err := <-done: | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Error(err) | |||
| 23 | + | } | |||
| 24 | + | case <-time.After(time.Second): | |||
| 25 | + | t.Error("SDK did not exit") | |||
| 26 | + | } | |||
| 27 | + | }) | |||
| 28 | + | h.drainUntil(t, "hello") | |||
| 29 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", Capabilities: []string{"call_tool"}}) | |||
| 30 | + | h.drainUntil(t, "ready") | |||
| 31 | + | result := make(chan ToolResult, 1) | |||
| 32 | + | errs := make(chan error, 1) | |||
| 33 | + | go func() { | |||
| 34 | + | r, err := h.ext.CallTool(context.Background(), "skill", map[string]string{"name": "test"}) | |||
| 35 | + | result <- r | |||
| 36 | + | errs <- err | |||
| 37 | + | }() | |||
| 38 | + | frame := h.drainUntil(t, "call_tool") | |||
| 39 | + | var call extproto.CallToolFromExt | |||
| 40 | + | if err := json.Unmarshal(frame.raw, &call); err != nil || call.Name != "skill" || string(call.Args) != `{"name":"test"}` { | |||
| 41 | + | t.Fatalf("call: %+v, %v", call, err) | |||
| 42 | + | } | |||
| 43 | + | h.sendToExt(t, extproto.ToolResultFromHost{Type: "tool_result", ID: call.ID, Name: "skill", Content: []extproto.ContentBlock{{Type: "text", Text: "loaded"}}}) | |||
| 44 | + | if err := <-errs; err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | if r := <-result; len(r.Content) != 1 || r.Content[0].Text != "loaded" { | |||
| 48 | + | t.Fatalf("result: %+v", r) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestCallToolFromToolHandlerCarriesParent(t *testing.T) { | |||
| 53 | + | h := newHarness("caller") | |||
| 54 | + | h.ext.InteractiveTool("ask", "ask", json.RawMessage(`{}`), func(ctx context.Context, _ json.RawMessage) ToolResult { | |||
| 55 | + | res, err := h.ext.CallTool(ctx, "read", map[string]string{"path": "a"}) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return TextErrorResult(err.Error()) | |||
| 58 | + | } | |||
| 59 | + | return res | |||
| 60 | + | }) | |||
| 61 | + | done := make(chan error, 1) | |||
| 62 | + | go func() { done <- h.ext.Run() }() | |||
| 63 | + | t.Cleanup(func() { | |||
| 64 | + | h.hostW.Close() | |||
| 65 | + | select { | |||
| 66 | + | case err := <-done: | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Error(err) | |||
| 69 | + | } | |||
| 70 | + | case <-time.After(time.Second): | |||
| 71 | + | t.Error("SDK did not exit") | |||
| 72 | + | } | |||
| 73 | + | }) | |||
| 74 | + | h.drainUntil(t, "hello") | |||
| 75 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", Capabilities: []string{"call_tool"}}) | |||
| 76 | + | h.drainUntil(t, "ready") | |||
| 77 | + | h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "parent", Name: "ask", Args: json.RawMessage(`{}`)}) | |||
| 78 | + | var call extproto.CallToolFromExt | |||
| 79 | + | if err := json.Unmarshal(h.drainUntil(t, "call_tool").raw, &call); err != nil || call.ParentID != "parent" || call.Name != "read" { | |||
| 80 | + | t.Fatalf("nested request: %+v, %v", call, err) | |||
| 81 | + | } | |||
| 82 | + | h.sendToExt(t, extproto.ToolResultFromHost{Type: "tool_result", ID: call.ID, Name: "read", Content: []extproto.ContentBlock{{Type: "text", Text: "loaded"}}}) | |||
| 83 | + | var reply extproto.ToolResultFromExt | |||
| 84 | + | if err := json.Unmarshal(h.drainUntil(t, "tool_result").raw, &reply); err != nil || reply.ID != "parent" || reply.Content[0].Text != "loaded" { | |||
| 85 | + | t.Fatalf("parent result: %+v, %v", reply, err) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestCallToolCancellation(t *testing.T) { | |||
| 90 | + | h := newHarness("caller") | |||
| 91 | + | done := make(chan error, 1) | |||
| 92 | + | go func() { done <- h.ext.Run() }() | |||
| 93 | + | t.Cleanup(func() { | |||
| 94 | + | h.hostW.Close() | |||
| 95 | + | select { | |||
| 96 | + | case err := <-done: | |||
| 97 | + | if err != nil { | |||
| 98 | + | t.Error(err) | |||
| 99 | + | } | |||
| 100 | + | case <-time.After(time.Second): | |||
| 101 | + | t.Error("SDK did not exit") | |||
| 102 | + | } | |||
| 103 | + | }) | |||
| 104 | + | h.drainUntil(t, "hello") | |||
| 105 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", Capabilities: []string{"call_tool"}}) | |||
| 106 | + | h.drainUntil(t, "ready") | |||
| 107 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 108 | + | result := make(chan error, 1) | |||
| 109 | + | go func() { _, err := h.ext.CallTool(ctx, "read", map[string]string{}); result <- err }() | |||
| 110 | + | var call extproto.CallToolFromExt | |||
| 111 | + | if err := json.Unmarshal(h.drainUntil(t, "call_tool").raw, &call); err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | cancel() | |||
| 115 | + | frame := h.drainUntil(t, "call_tool_cancel") | |||
| 116 | + | if frame.hdr.ID != call.ID { | |||
| 117 | + | t.Fatalf("canceled ID = %q, want %q", frame.hdr.ID, call.ID) | |||
| 118 | + | } | |||
| 119 | + | select { | |||
| 120 | + | case err := <-result: | |||
| 121 | + | if err != context.Canceled { | |||
| 122 | + | t.Fatalf("cancel error: %v", err) | |||
| 123 | + | } | |||
| 124 | + | case <-time.After(time.Second): | |||
| 125 | + | t.Fatal("call did not cancel") | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestCallToolUnsupportedHost(t *testing.T) { | |||
| 130 | + | e := New("caller", "test") | |||
| 131 | + | _, err := e.CallTool(context.Background(), "read", map[string]string{"path": "a"}) | |||
| 132 | + | if err == nil || !strings.Contains(err.Error(), "does not support") { | |||
| 133 | + | t.Fatalf("unsupported host: %v", err) | |||
| 134 | + | } | |||
| 135 | + | } | |||
M packages/agent/ext/ext.go +25 -2
| @@ -39,7 +39,9 @@ import ( | |||
| 39 | 39 | "fmt" | |
| 40 | 40 | "io" | |
| 41 | 41 | "os" | |
| 42 | + | "slices" | |
| 42 | 43 | "sync" | |
| 44 | + | "sync/atomic" | |
| 43 | 45 | ||
| 44 | 46 | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 45 | 47 | ) | |
| @@ -307,6 +309,10 @@ type Extension struct { | |||
| 307 | 309 | descriptions []descTuple // ordered so register frames arrive in registration order | |
| 308 | 310 | tools map[string]InteractiveToolHandler | |
| 309 | 311 | toolCancels map[string]context.CancelFunc | |
| 312 | + | pendingCalls map[string]chan extproto.ToolResultFromHost | |
| 313 | + | callSeq atomic.Uint64 | |
| 314 | + | callToolReady atomic.Bool | |
| 315 | + | done chan struct{} | |
| 310 | 316 | toolDefs []toolDef // ordered so register frames arrive in registration order | |
| 311 | 317 | eventHandlers map[string]EventHandler | |
| 312 | 318 | eventNames []string // declared subscription order | |
| @@ -364,10 +370,12 @@ func New(name, version string) *Extension { | |||
| 364 | 370 | commands: map[string]CommandHandler{}, | |
| 365 | 371 | tools: map[string]InteractiveToolHandler{}, | |
| 366 | 372 | toolCancels: map[string]context.CancelFunc{}, | |
| 373 | + | pendingCalls: map[string]chan extproto.ToolResultFromHost{}, | |
| 374 | + | done: make(chan struct{}), | |
| 367 | 375 | eventHandlers: map[string]EventHandler{}, | |
| 368 | 376 | panelKeys: map[string]func(key, text string){}, | |
| 369 | 377 | panelCloses: map[string]func(){}, | |
| 370 | - | caps: []string{"commands", "tools", "events", "panels", "tool_cancel"}, | |
| 378 | + | caps: []string{"commands", "tools", "events", "panels", "tool_cancel", "call_tool"}, | |
| 371 | 379 | } | |
| 372 | 380 | } | |
| 373 | 381 | ||
| @@ -556,6 +564,7 @@ func (e *Extension) Notify(level, message string) { | |||
| 556 | 564 | // Run starts the protocol loop. Blocks until stdin closes (zot has | |
| 557 | 565 | // shut us down). Returns the first fatal error, or nil on clean exit. | |
| 558 | 566 | func (e *Extension) Run() error { | |
| 567 | + | defer close(e.done) | |
| 559 | 568 | defer func() { | |
| 560 | 569 | e.mu.Lock() | |
| 561 | 570 | defer e.mu.Unlock() | |
| @@ -588,6 +597,7 @@ func (e *Extension) Run() error { | |||
| 588 | 597 | if ack.Type != "hello_ack" { | |
| 589 | 598 | return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type) | |
| 590 | 599 | } | |
| 600 | + | e.callToolReady.Store(slices.Contains(ack.Capabilities, "call_tool")) | |
| 591 | 601 | e.host = HostInfo{ | |
| 592 | 602 | ProtocolVersion: ack.ProtocolVersion, | |
| 593 | 603 | ZotVersion: ack.ZotVersion, | |
| @@ -704,7 +714,7 @@ func (e *Extension) Run() error { | |||
| 704 | 714 | e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name))) | |
| 705 | 715 | continue | |
| 706 | 716 | } | |
| 707 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 717 | + | ctx, cancel := context.WithCancel(context.WithValue(context.Background(), parentCallKey{}, tc.ID)) | |
| 708 | 718 | e.mu.Lock() | |
| 709 | 719 | e.toolCancels[tc.ID] = cancel | |
| 710 | 720 | e.mu.Unlock() | |
| @@ -725,6 +735,19 @@ func (e *Extension) Run() error { | |||
| 725 | 735 | e.respondTool(id, res) | |
| 726 | 736 | } | |
| 727 | 737 | }(tc.ID, fn, tc.Args) | |
| 738 | + | case "tool_result": | |
| 739 | + | var tr extproto.ToolResultFromHost | |
| 740 | + | if json.Unmarshal(line, &tr) == nil { | |
| 741 | + | e.mu.Lock() | |
| 742 | + | ch := e.pendingCalls[tr.ID] | |
| 743 | + | e.mu.Unlock() | |
| 744 | + | if ch != nil { | |
| 745 | + | select { | |
| 746 | + | case ch <- tr: | |
| 747 | + | default: | |
| 748 | + | } | |
| 749 | + | } | |
| 750 | + | } | |
| 728 | 751 | case "tool_cancel": | |
| 729 | 752 | var tc extproto.ToolCancelFromHost | |
| 730 | 753 | if json.Unmarshal(line, &tc) != nil { | |
| @@ -39,7 +39,9 @@ import ( | |||||
| 39 | "fmt" | 39 | "fmt" | ||
| 40 | "io" | 40 | "io" | ||
| 41 | "os" | 41 | "os" | ||
| 42 | + | "slices" | |||
| 42 | "sync" | 43 | "sync" | ||
| 44 | + | "sync/atomic" | |||
| 43 | 45 | ||||
| 44 | "github.com/patriceckhart/zot/packages/agent/extproto" | 46 | "github.com/patriceckhart/zot/packages/agent/extproto" | ||
| 45 | ) | 47 | ) | ||
| @@ -307,6 +309,10 @@ type Extension struct { | |||||
| 307 | descriptions []descTuple // ordered so register frames arrive in registration order | 309 | descriptions []descTuple // ordered so register frames arrive in registration order | ||
| 308 | tools map[string]InteractiveToolHandler | 310 | tools map[string]InteractiveToolHandler | ||
| 309 | toolCancels map[string]context.CancelFunc | 311 | toolCancels map[string]context.CancelFunc | ||
| 312 | + | pendingCalls map[string]chan extproto.ToolResultFromHost | |||
| 313 | + | callSeq atomic.Uint64 | |||
| 314 | + | callToolReady atomic.Bool | |||
| 315 | + | done chan struct{} | |||
| 310 | toolDefs []toolDef // ordered so register frames arrive in registration order | 316 | toolDefs []toolDef // ordered so register frames arrive in registration order | ||
| 311 | eventHandlers map[string]EventHandler | 317 | eventHandlers map[string]EventHandler | ||
| 312 | eventNames []string // declared subscription order | 318 | eventNames []string // declared subscription order | ||
| @@ -364,10 +370,12 @@ func New(name, version string) *Extension { | |||||
| 364 | commands: map[string]CommandHandler{}, | 370 | commands: map[string]CommandHandler{}, | ||
| 365 | tools: map[string]InteractiveToolHandler{}, | 371 | tools: map[string]InteractiveToolHandler{}, | ||
| 366 | toolCancels: map[string]context.CancelFunc{}, | 372 | toolCancels: map[string]context.CancelFunc{}, | ||
| 373 | + | pendingCalls: map[string]chan extproto.ToolResultFromHost{}, | |||
| 374 | + | done: make(chan struct{}), | |||
| 367 | eventHandlers: map[string]EventHandler{}, | 375 | eventHandlers: map[string]EventHandler{}, | ||
| 368 | panelKeys: map[string]func(key, text string){}, | 376 | panelKeys: map[string]func(key, text string){}, | ||
| 369 | panelCloses: map[string]func(){}, | 377 | panelCloses: map[string]func(){}, | ||
| 370 | - | caps: []string{"commands", "tools", "events", "panels", "tool_cancel"}, | 378 | + | caps: []string{"commands", "tools", "events", "panels", "tool_cancel", "call_tool"}, |
| 371 | } | 379 | } | ||
| 372 | } | 380 | } | ||
| 373 | 381 | ||||
| @@ -556,6 +564,7 @@ func (e *Extension) Notify(level, message string) { | |||||
| 556 | // Run starts the protocol loop. Blocks until stdin closes (zot has | 564 | // Run starts the protocol loop. Blocks until stdin closes (zot has | ||
| 557 | // shut us down). Returns the first fatal error, or nil on clean exit. | 565 | // shut us down). Returns the first fatal error, or nil on clean exit. | ||
| 558 | func (e *Extension) Run() error { | 566 | func (e *Extension) Run() error { | ||
| 567 | + | defer close(e.done) | |||
| 559 | defer func() { | 568 | defer func() { | ||
| 560 | e.mu.Lock() | 569 | e.mu.Lock() | ||
| 561 | defer e.mu.Unlock() | 570 | defer e.mu.Unlock() | ||
| @@ -588,6 +597,7 @@ func (e *Extension) Run() error { | |||||
| 588 | if ack.Type != "hello_ack" { | 597 | if ack.Type != "hello_ack" { | ||
| 589 | return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type) | 598 | return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type) | ||
| 590 | } | 599 | } | ||
| 600 | + | e.callToolReady.Store(slices.Contains(ack.Capabilities, "call_tool")) | |||
| 591 | e.host = HostInfo{ | 601 | e.host = HostInfo{ | ||
| 592 | ProtocolVersion: ack.ProtocolVersion, | 602 | ProtocolVersion: ack.ProtocolVersion, | ||
| 593 | ZotVersion: ack.ZotVersion, | 603 | ZotVersion: ack.ZotVersion, | ||
| @@ -704,7 +714,7 @@ func (e *Extension) Run() error { | |||||
| 704 | e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name))) | 714 | e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name))) | ||
| 705 | continue | 715 | continue | ||
| 706 | } | 716 | } | ||
| 707 | - | ctx, cancel := context.WithCancel(context.Background()) | 717 | + | ctx, cancel := context.WithCancel(context.WithValue(context.Background(), parentCallKey{}, tc.ID)) |
| 708 | e.mu.Lock() | 718 | e.mu.Lock() | ||
| 709 | e.toolCancels[tc.ID] = cancel | 719 | e.toolCancels[tc.ID] = cancel | ||
| 710 | e.mu.Unlock() | 720 | e.mu.Unlock() | ||
| @@ -725,6 +735,19 @@ func (e *Extension) Run() error { | |||||
| 725 | e.respondTool(id, res) | 735 | e.respondTool(id, res) | ||
| 726 | } | 736 | } | ||
| 727 | }(tc.ID, fn, tc.Args) | 737 | }(tc.ID, fn, tc.Args) | ||
| 738 | + | case "tool_result": | |||
| 739 | + | var tr extproto.ToolResultFromHost | |||
| 740 | + | if json.Unmarshal(line, &tr) == nil { | |||
| 741 | + | e.mu.Lock() | |||
| 742 | + | ch := e.pendingCalls[tr.ID] | |||
| 743 | + | e.mu.Unlock() | |||
| 744 | + | if ch != nil { | |||
| 745 | + | select { | |||
| 746 | + | case ch <- tr: | |||
| 747 | + | default: | |||
| 748 | + | } | |||
| 749 | + | } | |||
| 750 | + | } | |||
| 728 | case "tool_cancel": | 751 | case "tool_cancel": | ||
| 729 | var tc extproto.ToolCancelFromHost | 752 | var tc extproto.ToolCancelFromHost | ||
| 730 | if json.Unmarshal(line, &tc) != nil { | 753 | if json.Unmarshal(line, &tc) != nil { | ||
A packages/agent/extension_call_tool.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "sync/atomic" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 10 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 11 | + | "github.com/patriceckhart/zot/packages/core" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | var extensionCallSeq atomic.Uint64 | |
| 15 | + | ||
| 16 | + | type extensionOriginKey struct{} | |
| 17 | + | ||
| 18 | + | // extensionToolCaller keeps host-initiated calls out of model transcripts, | |
| 19 | + | // while sharing the active agent's guard and confirmation path. | |
| 20 | + | func extensionToolCaller(mgr *extensions.Manager, active func() *core.Agent) func(context.Context, string, string, json.RawMessage) extproto.ToolResultFromHost { | |
| 21 | + | return func(ctx context.Context, origin, name string, args json.RawMessage) extproto.ToolResultFromHost { | |
| 22 | + | out := extproto.ToolResultFromHost{Name: name} | |
| 23 | + | ag := active() | |
| 24 | + | if ag == nil { | |
| 25 | + | out.IsError = true | |
| 26 | + | out.Content = []extproto.ContentBlock{{Type: "text", Text: "no active agent"}} | |
| 27 | + | return out | |
| 28 | + | } | |
| 29 | + | id := fmt.Sprintf("ext-call-%d", extensionCallSeq.Add(1)) | |
| 30 | + | defer mgr.TrackToolCall(id, origin)() | |
| 31 | + | sink := func(ev core.AgentEvent) { | |
| 32 | + | switch e := ev.(type) { | |
| 33 | + | case core.EvToolCall: | |
| 34 | + | mgr.EmitEvent(extproto.EventFromHost{Event: "tool_call", OriginExtension: origin, ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args}) | |
| 35 | + | case core.EvToolResult: | |
| 36 | + | mgr.EmitEvent(extproto.EventFromHost{Event: "tool_result", OriginExtension: origin, ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args, Status: e.Status, Executed: &e.Executed, Result: extensionEventResult(e.Result)}) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | res := ag.CallTool(context.WithValue(ctx, extensionOriginKey{}, origin), id, name, args, sink) | |
| 40 | + | visible := extensionEventResult(res) | |
| 41 | + | out.Content, out.IsError, out.Truncated = visible.Content, visible.IsError, visible.Truncated | |
| 42 | + | return out | |
| 43 | + | } | |
| 44 | + | } | |
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | var extensionCallSeq atomic.Uint64 | |||
| 15 | + | ||||
| 16 | + | type extensionOriginKey struct{} | |||
| 17 | + | ||||
| 18 | + | // extensionToolCaller keeps host-initiated calls out of model transcripts, | |||
| 19 | + | // while sharing the active agent's guard and confirmation path. | |||
| 20 | + | func extensionToolCaller(mgr *extensions.Manager, active func() *core.Agent) func(context.Context, string, string, json.RawMessage) extproto.ToolResultFromHost { | |||
| 21 | + | return func(ctx context.Context, origin, name string, args json.RawMessage) extproto.ToolResultFromHost { | |||
| 22 | + | out := extproto.ToolResultFromHost{Name: name} | |||
| 23 | + | ag := active() | |||
| 24 | + | if ag == nil { | |||
| 25 | + | out.IsError = true | |||
| 26 | + | out.Content = []extproto.ContentBlock{{Type: "text", Text: "no active agent"}} | |||
| 27 | + | return out | |||
| 28 | + | } | |||
| 29 | + | id := fmt.Sprintf("ext-call-%d", extensionCallSeq.Add(1)) | |||
| 30 | + | defer mgr.TrackToolCall(id, origin)() | |||
| 31 | + | sink := func(ev core.AgentEvent) { | |||
| 32 | + | switch e := ev.(type) { | |||
| 33 | + | case core.EvToolCall: | |||
| 34 | + | mgr.EmitEvent(extproto.EventFromHost{Event: "tool_call", OriginExtension: origin, ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args}) | |||
| 35 | + | case core.EvToolResult: | |||
| 36 | + | mgr.EmitEvent(extproto.EventFromHost{Event: "tool_result", OriginExtension: origin, ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args, Status: e.Status, Executed: &e.Executed, Result: extensionEventResult(e.Result)}) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | res := ag.CallTool(context.WithValue(ctx, extensionOriginKey{}, origin), id, name, args, sink) | |||
| 40 | + | visible := extensionEventResult(res) | |||
| 41 | + | out.Content, out.IsError, out.Truncated = visible.Content, visible.IsError, visible.Truncated | |||
| 42 | + | return out | |||
| 43 | + | } | |||
| 44 | + | } | |||
A packages/agent/extension_call_tool_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestExtensionToolCallerHonorsGuardAndTranscript(t *testing.T) { | |
| 15 | + | tool := &lifecycleTool{} | |
| 16 | + | ag := core.NewAgent(nil, "test", "", core.Registry{"probe": tool}) | |
| 17 | + | mgr := extensions.New(t.TempDir(), t.TempDir(), "test", "", "", nil) | |
| 18 | + | var callID string | |
| 19 | + | ag.BeforeToolExecuteContext = func(_ context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 20 | + | callID = call.ID | |
| 21 | + | if got := mgr.ToolCallOrigin(call.ID); got != "caller" { | |
| 22 | + | t.Errorf("call origin = %q", got) | |
| 23 | + | } | |
| 24 | + | return false, "denied by policy", nil | |
| 25 | + | } | |
| 26 | + | call := extensionToolCaller(mgr, func() *core.Agent { return ag }) | |
| 27 | + | out := call(context.Background(), "caller", "probe", json.RawMessage(`{}`)) | |
| 28 | + | if !out.IsError || len(out.Content) != 1 || !strings.Contains(out.Content[0].Text, "denied by policy") { | |
| 29 | + | t.Fatalf("policy result: %+v", out) | |
| 30 | + | } | |
| 31 | + | if mgr.ToolCallOrigin(callID) != "" { | |
| 32 | + | t.Fatal("call origin was not cleared") | |
| 33 | + | } | |
| 34 | + | if tool.args != nil || len(ag.Messages()) != 0 { | |
| 35 | + | t.Fatalf("tool ran or transcript changed: args=%s messages=%v", tool.args, ag.Messages()) | |
| 36 | + | } | |
| 37 | + | out = call(context.Background(), "caller", "missing", json.RawMessage(`{}`)) | |
| 38 | + | if !out.IsError || !strings.Contains(out.Content[0].Text, "unknown tool") { | |
| 39 | + | t.Fatalf("unknown tool: %+v", out) | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestExtensionToolCallerHonorsGuardAndTranscript(t *testing.T) { | |||
| 15 | + | tool := &lifecycleTool{} | |||
| 16 | + | ag := core.NewAgent(nil, "test", "", core.Registry{"probe": tool}) | |||
| 17 | + | mgr := extensions.New(t.TempDir(), t.TempDir(), "test", "", "", nil) | |||
| 18 | + | var callID string | |||
| 19 | + | ag.BeforeToolExecuteContext = func(_ context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 20 | + | callID = call.ID | |||
| 21 | + | if got := mgr.ToolCallOrigin(call.ID); got != "caller" { | |||
| 22 | + | t.Errorf("call origin = %q", got) | |||
| 23 | + | } | |||
| 24 | + | return false, "denied by policy", nil | |||
| 25 | + | } | |||
| 26 | + | call := extensionToolCaller(mgr, func() *core.Agent { return ag }) | |||
| 27 | + | out := call(context.Background(), "caller", "probe", json.RawMessage(`{}`)) | |||
| 28 | + | if !out.IsError || len(out.Content) != 1 || !strings.Contains(out.Content[0].Text, "denied by policy") { | |||
| 29 | + | t.Fatalf("policy result: %+v", out) | |||
| 30 | + | } | |||
| 31 | + | if mgr.ToolCallOrigin(callID) != "" { | |||
| 32 | + | t.Fatal("call origin was not cleared") | |||
| 33 | + | } | |||
| 34 | + | if tool.args != nil || len(ag.Messages()) != 0 { | |||
| 35 | + | t.Fatalf("tool ran or transcript changed: args=%s messages=%v", tool.args, ag.Messages()) | |||
| 36 | + | } | |||
| 37 | + | out = call(context.Background(), "caller", "missing", json.RawMessage(`{}`)) | |||
| 38 | + | if !out.IsError || !strings.Contains(out.Content[0].Text, "unknown tool") { | |||
| 39 | + | t.Fatalf("unknown tool: %+v", out) | |||
| 40 | + | } | |||
| 41 | + | } | |||
A packages/agent/extensions/call_tool.go +99 -0
| @@ -0,0 +1,99 @@ | |||
| 1 | + | package extensions | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "slices" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // TrackToolCall records the initiator for confirmation and interception | |
| 13 | + | // events until the call finishes. The returned function removes the entry. | |
| 14 | + | func (m *Manager) TrackToolCall(id, origin string) func() { | |
| 15 | + | m.mu.Lock() | |
| 16 | + | if m.callOrigins == nil { | |
| 17 | + | m.callOrigins = make(map[string]string) | |
| 18 | + | } | |
| 19 | + | m.callOrigins[id] = origin | |
| 20 | + | m.mu.Unlock() | |
| 21 | + | return func() { | |
| 22 | + | m.mu.Lock() | |
| 23 | + | delete(m.callOrigins, id) | |
| 24 | + | m.mu.Unlock() | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // ToolCallOrigin returns the initiating extension for an active host call. | |
| 29 | + | func (m *Manager) ToolCallOrigin(id string) string { | |
| 30 | + | m.mu.RLock() | |
| 31 | + | defer m.mu.RUnlock() | |
| 32 | + | return m.callOrigins[id] | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // The chain follows a call across extension processes through the host. | |
| 36 | + | // Only host-created parent IDs can carry it into a nested request. | |
| 37 | + | type toolChainKey struct{} | |
| 38 | + | ||
| 39 | + | type outboundCall struct { | |
| 40 | + | ctx context.Context | |
| 41 | + | chain []string | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | const maxToolCallDepth = 4 | |
| 45 | + | ||
| 46 | + | // SetToolCaller binds host-managed execution to the current agent mode. | |
| 47 | + | // The callback must enforce the same policy as ordinary model tool calls. | |
| 48 | + | func (m *Manager) SetToolCaller(fn func(context.Context, string, string, json.RawMessage) extproto.ToolResultFromHost) { | |
| 49 | + | m.mu.Lock() | |
| 50 | + | m.callTool = fn | |
| 51 | + | m.mu.Unlock() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (m *Manager) handleCallTool(ctx context.Context, ext *Extension, req extproto.CallToolFromExt, cancel context.CancelFunc, parentFound, activeOutbound bool, chain []string) { | |
| 55 | + | defer cancel() | |
| 56 | + | defer func() { | |
| 57 | + | ext.mu.Lock() | |
| 58 | + | delete(ext.inboundCalls, req.ID) | |
| 59 | + | ext.mu.Unlock() | |
| 60 | + | }() | |
| 61 | + | result := extproto.ToolResultFromHost{Type: "tool_result", ID: req.ID, Name: req.Name} | |
| 62 | + | fail := func(message string) { | |
| 63 | + | result.IsError = true | |
| 64 | + | result.Content = []extproto.ContentBlock{{Type: "text", Text: message}} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | m.mu.RLock() | |
| 68 | + | caller := m.callTool | |
| 69 | + | m.mu.RUnlock() | |
| 70 | + | switch { | |
| 71 | + | case !ext.callTool: | |
| 72 | + | fail("extension did not opt in to call_tool") | |
| 73 | + | case req.ParentID != "" && !parentFound: | |
| 74 | + | fail("unknown or expired parent tool call") | |
| 75 | + | case req.ParentID == "" && activeOutbound: | |
| 76 | + | fail("parent_id is required while an extension tool is active") | |
| 77 | + | case slices.Contains(chain, req.Name): | |
| 78 | + | fail("tool call cycle detected") | |
| 79 | + | case len(chain) >= maxToolCallDepth: | |
| 80 | + | fail("tool call depth limit reached") | |
| 81 | + | case caller == nil: | |
| 82 | + | fail("host tool calls are unavailable") | |
| 83 | + | case req.Name == "" || len(req.Args) == 0 || !json.Valid(req.Args) || bytes.TrimSpace(req.Args)[0] != '{': | |
| 84 | + | fail("call_tool requires a name and JSON object args") | |
| 85 | + | default: | |
| 86 | + | ctx = context.WithValue(ctx, toolChainKey{}, append(append([]string(nil), chain...), req.Name)) | |
| 87 | + | result = caller(ctx, ext.Manifest.Name, req.Name, req.Args) | |
| 88 | + | result.Type, result.ID, result.Name = "tool_result", req.ID, req.Name | |
| 89 | + | } | |
| 90 | + | frame, err := extproto.Encode(result) | |
| 91 | + | if err != nil { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | if pipe, ok := ext.stdin.(*orderedPipe); ok { | |
| 95 | + | _ = pipe.enqueue(frame, nil) | |
| 96 | + | } else if ext.stdin != nil { | |
| 97 | + | _, _ = ext.stdin.Write(frame) | |
| 98 | + | } | |
| 99 | + | } | |
| @@ -0,0 +1,99 @@ | |||||
| 1 | + | package extensions | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "slices" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // TrackToolCall records the initiator for confirmation and interception | |||
| 13 | + | // events until the call finishes. The returned function removes the entry. | |||
| 14 | + | func (m *Manager) TrackToolCall(id, origin string) func() { | |||
| 15 | + | m.mu.Lock() | |||
| 16 | + | if m.callOrigins == nil { | |||
| 17 | + | m.callOrigins = make(map[string]string) | |||
| 18 | + | } | |||
| 19 | + | m.callOrigins[id] = origin | |||
| 20 | + | m.mu.Unlock() | |||
| 21 | + | return func() { | |||
| 22 | + | m.mu.Lock() | |||
| 23 | + | delete(m.callOrigins, id) | |||
| 24 | + | m.mu.Unlock() | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // ToolCallOrigin returns the initiating extension for an active host call. | |||
| 29 | + | func (m *Manager) ToolCallOrigin(id string) string { | |||
| 30 | + | m.mu.RLock() | |||
| 31 | + | defer m.mu.RUnlock() | |||
| 32 | + | return m.callOrigins[id] | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // The chain follows a call across extension processes through the host. | |||
| 36 | + | // Only host-created parent IDs can carry it into a nested request. | |||
| 37 | + | type toolChainKey struct{} | |||
| 38 | + | ||||
| 39 | + | type outboundCall struct { | |||
| 40 | + | ctx context.Context | |||
| 41 | + | chain []string | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | const maxToolCallDepth = 4 | |||
| 45 | + | ||||
| 46 | + | // SetToolCaller binds host-managed execution to the current agent mode. | |||
| 47 | + | // The callback must enforce the same policy as ordinary model tool calls. | |||
| 48 | + | func (m *Manager) SetToolCaller(fn func(context.Context, string, string, json.RawMessage) extproto.ToolResultFromHost) { | |||
| 49 | + | m.mu.Lock() | |||
| 50 | + | m.callTool = fn | |||
| 51 | + | m.mu.Unlock() | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (m *Manager) handleCallTool(ctx context.Context, ext *Extension, req extproto.CallToolFromExt, cancel context.CancelFunc, parentFound, activeOutbound bool, chain []string) { | |||
| 55 | + | defer cancel() | |||
| 56 | + | defer func() { | |||
| 57 | + | ext.mu.Lock() | |||
| 58 | + | delete(ext.inboundCalls, req.ID) | |||
| 59 | + | ext.mu.Unlock() | |||
| 60 | + | }() | |||
| 61 | + | result := extproto.ToolResultFromHost{Type: "tool_result", ID: req.ID, Name: req.Name} | |||
| 62 | + | fail := func(message string) { | |||
| 63 | + | result.IsError = true | |||
| 64 | + | result.Content = []extproto.ContentBlock{{Type: "text", Text: message}} | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | m.mu.RLock() | |||
| 68 | + | caller := m.callTool | |||
| 69 | + | m.mu.RUnlock() | |||
| 70 | + | switch { | |||
| 71 | + | case !ext.callTool: | |||
| 72 | + | fail("extension did not opt in to call_tool") | |||
| 73 | + | case req.ParentID != "" && !parentFound: | |||
| 74 | + | fail("unknown or expired parent tool call") | |||
| 75 | + | case req.ParentID == "" && activeOutbound: | |||
| 76 | + | fail("parent_id is required while an extension tool is active") | |||
| 77 | + | case slices.Contains(chain, req.Name): | |||
| 78 | + | fail("tool call cycle detected") | |||
| 79 | + | case len(chain) >= maxToolCallDepth: | |||
| 80 | + | fail("tool call depth limit reached") | |||
| 81 | + | case caller == nil: | |||
| 82 | + | fail("host tool calls are unavailable") | |||
| 83 | + | case req.Name == "" || len(req.Args) == 0 || !json.Valid(req.Args) || bytes.TrimSpace(req.Args)[0] != '{': | |||
| 84 | + | fail("call_tool requires a name and JSON object args") | |||
| 85 | + | default: | |||
| 86 | + | ctx = context.WithValue(ctx, toolChainKey{}, append(append([]string(nil), chain...), req.Name)) | |||
| 87 | + | result = caller(ctx, ext.Manifest.Name, req.Name, req.Args) | |||
| 88 | + | result.Type, result.ID, result.Name = "tool_result", req.ID, req.Name | |||
| 89 | + | } | |||
| 90 | + | frame, err := extproto.Encode(result) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | if pipe, ok := ext.stdin.(*orderedPipe); ok { | |||
| 95 | + | _ = pipe.enqueue(frame, nil) | |||
| 96 | + | } else if ext.stdin != nil { | |||
| 97 | + | _, _ = ext.stdin.Write(frame) | |||
| 98 | + | } | |||
| 99 | + | } | |||
A packages/agent/extensions/call_tool_test.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | package extensions | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestCallToolDispatchAndRefusal(t *testing.T) { | |
| 15 | + | m, ext, frames, replies := toolPeer(t) | |
| 16 | + | ext.callTool = true | |
| 17 | + | m.SetToolCaller(func(ctx context.Context, origin, name string, args json.RawMessage) extproto.ToolResultFromHost { | |
| 18 | + | if origin != ext.Manifest.Name || name != "read" { | |
| 19 | + | t.Errorf("unexpected request: %s %s %s", origin, name, args) | |
| 20 | + | } | |
| 21 | + | return extproto.ToolResultFromHost{Content: []extproto.ContentBlock{{Type: "text", Text: "ok"}}} | |
| 22 | + | }) | |
| 23 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "one", Name: "read", Args: json.RawMessage(`{"path":"a"}`)}) | |
| 24 | + | var result extproto.ToolResultFromHost | |
| 25 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || result.ID != "one" || result.IsError || result.Content[0].Text != "ok" { | |
| 26 | + | t.Fatalf("result: %+v, %v", result, err) | |
| 27 | + | } | |
| 28 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "two", Name: "read", Args: json.RawMessage(`{}`), ParentID: "unknown"}) | |
| 29 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "parent") { | |
| 30 | + | t.Fatalf("parent refusal: %+v, %v", result, err) | |
| 31 | + | } | |
| 32 | + | ext.mu.Lock() | |
| 33 | + | ext.outboundCalls = map[string]outboundCall{"parent": {ctx: context.Background(), chain: []string{"ask"}}} | |
| 34 | + | ext.mu.Unlock() | |
| 35 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "three", Name: "read", Args: json.RawMessage(`{}`)}) | |
| 36 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "parent_id") { | |
| 37 | + | t.Fatalf("missing parent refusal: %+v, %v", result, err) | |
| 38 | + | } | |
| 39 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "four", ParentID: "parent", Name: "ask", Args: json.RawMessage(`{}`)}) | |
| 40 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "cycle") { | |
| 41 | + | t.Fatalf("cycle refusal: %+v, %v", result, err) | |
| 42 | + | } | |
| 43 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "five", ParentID: "parent", Name: "read", Args: json.RawMessage(`{}`)}) | |
| 44 | + | result = extproto.ToolResultFromHost{} | |
| 45 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || result.IsError || result.Content[0].Text != "ok" { | |
| 46 | + | t.Fatalf("nested call: %+v, %v", result, err) | |
| 47 | + | } | |
| 48 | + | ext.mu.Lock() | |
| 49 | + | ext.outboundCalls["parent"] = outboundCall{ctx: context.Background(), chain: []string{"one", "two", "three", "four"}} | |
| 50 | + | ext.mu.Unlock() | |
| 51 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "six", ParentID: "parent", Name: "read", Args: json.RawMessage(`{}`)}) | |
| 52 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "depth") { | |
| 53 | + | t.Fatalf("depth refusal: %+v, %v", result, err) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestCallToolRequiresOptIn(t *testing.T) { | |
| 58 | + | m, _, frames, replies := toolPeer(t) | |
| 59 | + | m.SetToolCaller(func(context.Context, string, string, json.RawMessage) extproto.ToolResultFromHost { | |
| 60 | + | t.Error("unadvertised call reached tool caller") | |
| 61 | + | return extproto.ToolResultFromHost{} | |
| 62 | + | }) | |
| 63 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "one", Name: "read", Args: json.RawMessage(`{}`)}) | |
| 64 | + | var result extproto.ToolResultFromHost | |
| 65 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "opt in") { | |
| 66 | + | t.Fatalf("opt-in refusal: %+v, %v", result, err) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestCallToolToSameExtension(t *testing.T) { | |
| 71 | + | m, ext, frames, replies := toolPeer(t) | |
| 72 | + | ext.callTool = true | |
| 73 | + | m.SetToolCaller(func(ctx context.Context, _, name string, args json.RawMessage) extproto.ToolResultFromHost { | |
| 74 | + | tool, err := NewTool(m, ToolInfo{Name: name, Extension: ext.Manifest.Name}).Execute(ctx, args, nil) | |
| 75 | + | if err != nil { | |
| 76 | + | return extproto.ToolResultFromHost{IsError: true} | |
| 77 | + | } | |
| 78 | + | return extproto.ToolResultFromHost{IsError: tool.IsError, Content: []extproto.ContentBlock{{Type: "text", Text: tool.Content[0].(provider.TextBlock).Text}}} | |
| 79 | + | }) | |
| 80 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "one", Name: "ask", Args: json.RawMessage(`{}`)}) | |
| 81 | + | call := toolCallFrame(t, frames) | |
| 82 | + | sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "answer"}}}) | |
| 83 | + | var result extproto.ToolResultFromHost | |
| 84 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || result.IsError || result.Content[0].Text != "answer" { | |
| 85 | + | t.Fatalf("round trip: %+v, %v", result, err) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestNestedCallAcrossHostAndExtension(t *testing.T) { | |
| 90 | + | m, ext, frames, replies := toolPeer(t) | |
| 91 | + | ext.callTool = true | |
| 92 | + | m.SetToolCaller(func(ctx context.Context, _, name string, args json.RawMessage) extproto.ToolResultFromHost { | |
| 93 | + | if name == "read" { | |
| 94 | + | chain, _ := ctx.Value(toolChainKey{}).([]string) | |
| 95 | + | if len(chain) != 2 || chain[0] != "ask" || chain[1] != "read" { | |
| 96 | + | t.Errorf("nested chain: %v", chain) | |
| 97 | + | } | |
| 98 | + | return extproto.ToolResultFromHost{Content: []extproto.ContentBlock{{Type: "text", Text: "loaded"}}} | |
| 99 | + | } | |
| 100 | + | result, err := NewTool(m, ToolInfo{Name: name, Extension: ext.Manifest.Name}).Execute(ctx, args, nil) | |
| 101 | + | if err != nil || result.IsError { | |
| 102 | + | return extproto.ToolResultFromHost{IsError: true} | |
| 103 | + | } | |
| 104 | + | return extproto.ToolResultFromHost{Content: []extproto.ContentBlock{{Type: "text", Text: "done"}}} | |
| 105 | + | }) | |
| 106 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "root", Name: "ask", Args: json.RawMessage(`{}`)}) | |
| 107 | + | parent := toolCallFrame(t, frames) | |
| 108 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "cycle", ParentID: parent.ID, Name: "ask", Args: json.RawMessage(`{}`)}) | |
| 109 | + | var refused extproto.ToolResultFromHost | |
| 110 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &refused); err != nil || refused.ID != "cycle" || !refused.IsError || !strings.Contains(refused.Content[0].Text, "cycle") { | |
| 111 | + | t.Fatalf("cycle: %+v, %v", refused, err) | |
| 112 | + | } | |
| 113 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "child", ParentID: parent.ID, Name: "read", Args: json.RawMessage(`{}`)}) | |
| 114 | + | var child extproto.ToolResultFromHost | |
| 115 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &child); err != nil || child.ID != "child" || child.IsError || child.Content[0].Text != "loaded" { | |
| 116 | + | t.Fatalf("child: %+v, %v", child, err) | |
| 117 | + | } | |
| 118 | + | sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: parent.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "ok"}}}) | |
| 119 | + | var root extproto.ToolResultFromHost | |
| 120 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &root); err != nil || root.ID != "root" || root.IsError || root.Content[0].Text != "done" { | |
| 121 | + | t.Fatalf("root: %+v, %v", root, err) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestNestedCallUsesParentContext(t *testing.T) { | |
| 126 | + | m, ext, frames, replies := toolPeer(t) | |
| 127 | + | ext.callTool = true | |
| 128 | + | parentCtx, cancel := context.WithCancel(context.Background()) | |
| 129 | + | defer cancel() | |
| 130 | + | ext.mu.Lock() | |
| 131 | + | ext.outboundCalls = map[string]outboundCall{"parent": {ctx: parentCtx, chain: []string{"ask"}}} | |
| 132 | + | ext.mu.Unlock() | |
| 133 | + | started := make(chan context.Context, 1) | |
| 134 | + | m.SetToolCaller(func(ctx context.Context, _, _ string, _ json.RawMessage) extproto.ToolResultFromHost { | |
| 135 | + | started <- ctx | |
| 136 | + | <-ctx.Done() | |
| 137 | + | return extproto.ToolResultFromHost{IsError: true, Content: []extproto.ContentBlock{{Type: "text", Text: ctx.Err().Error()}}} | |
| 138 | + | }) | |
| 139 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "child", ParentID: "parent", Name: "read", Args: json.RawMessage(`{}`)}) | |
| 140 | + | select { | |
| 141 | + | case ctx := <-started: | |
| 142 | + | if chain, _ := ctx.Value(toolChainKey{}).([]string); len(chain) != 2 || chain[0] != "ask" || chain[1] != "read" { | |
| 143 | + | t.Fatalf("chain: %v", chain) | |
| 144 | + | } | |
| 145 | + | case <-time.After(time.Second): | |
| 146 | + | t.Fatal("nested call did not start") | |
| 147 | + | } | |
| 148 | + | cancel() | |
| 149 | + | var result extproto.ToolResultFromHost | |
| 150 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "canceled") { | |
| 151 | + | t.Fatalf("parent cancellation: %+v, %v", result, err) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestCallToolCancel(t *testing.T) { | |
| 156 | + | m, ext, frames, replies := toolPeer(t) | |
| 157 | + | ext.callTool = true | |
| 158 | + | started := make(chan struct{}) | |
| 159 | + | m.SetToolCaller(func(ctx context.Context, _, _ string, _ json.RawMessage) extproto.ToolResultFromHost { | |
| 160 | + | close(started) | |
| 161 | + | <-ctx.Done() | |
| 162 | + | return extproto.ToolResultFromHost{IsError: true, Content: []extproto.ContentBlock{{Type: "text", Text: ctx.Err().Error()}}} | |
| 163 | + | }) | |
| 164 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "one", Name: "read", Args: json.RawMessage(`{}`)}) | |
| 165 | + | select { | |
| 166 | + | case <-started: | |
| 167 | + | case <-time.After(time.Second): | |
| 168 | + | t.Fatal("call did not start") | |
| 169 | + | } | |
| 170 | + | sendToolFrame(t, replies, extproto.Frame{Type: "call_tool_cancel", ID: "one"}) | |
| 171 | + | var result extproto.ToolResultFromHost | |
| 172 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "canceled") { | |
| 173 | + | t.Fatalf("cancel: %+v, %v", result, err) | |
| 174 | + | } | |
| 175 | + | } | |
| @@ -0,0 +1,175 @@ | |||||
| 1 | + | package extensions | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestCallToolDispatchAndRefusal(t *testing.T) { | |||
| 15 | + | m, ext, frames, replies := toolPeer(t) | |||
| 16 | + | ext.callTool = true | |||
| 17 | + | m.SetToolCaller(func(ctx context.Context, origin, name string, args json.RawMessage) extproto.ToolResultFromHost { | |||
| 18 | + | if origin != ext.Manifest.Name || name != "read" { | |||
| 19 | + | t.Errorf("unexpected request: %s %s %s", origin, name, args) | |||
| 20 | + | } | |||
| 21 | + | return extproto.ToolResultFromHost{Content: []extproto.ContentBlock{{Type: "text", Text: "ok"}}} | |||
| 22 | + | }) | |||
| 23 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "one", Name: "read", Args: json.RawMessage(`{"path":"a"}`)}) | |||
| 24 | + | var result extproto.ToolResultFromHost | |||
| 25 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || result.ID != "one" || result.IsError || result.Content[0].Text != "ok" { | |||
| 26 | + | t.Fatalf("result: %+v, %v", result, err) | |||
| 27 | + | } | |||
| 28 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "two", Name: "read", Args: json.RawMessage(`{}`), ParentID: "unknown"}) | |||
| 29 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "parent") { | |||
| 30 | + | t.Fatalf("parent refusal: %+v, %v", result, err) | |||
| 31 | + | } | |||
| 32 | + | ext.mu.Lock() | |||
| 33 | + | ext.outboundCalls = map[string]outboundCall{"parent": {ctx: context.Background(), chain: []string{"ask"}}} | |||
| 34 | + | ext.mu.Unlock() | |||
| 35 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "three", Name: "read", Args: json.RawMessage(`{}`)}) | |||
| 36 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "parent_id") { | |||
| 37 | + | t.Fatalf("missing parent refusal: %+v, %v", result, err) | |||
| 38 | + | } | |||
| 39 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "four", ParentID: "parent", Name: "ask", Args: json.RawMessage(`{}`)}) | |||
| 40 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "cycle") { | |||
| 41 | + | t.Fatalf("cycle refusal: %+v, %v", result, err) | |||
| 42 | + | } | |||
| 43 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "five", ParentID: "parent", Name: "read", Args: json.RawMessage(`{}`)}) | |||
| 44 | + | result = extproto.ToolResultFromHost{} | |||
| 45 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || result.IsError || result.Content[0].Text != "ok" { | |||
| 46 | + | t.Fatalf("nested call: %+v, %v", result, err) | |||
| 47 | + | } | |||
| 48 | + | ext.mu.Lock() | |||
| 49 | + | ext.outboundCalls["parent"] = outboundCall{ctx: context.Background(), chain: []string{"one", "two", "three", "four"}} | |||
| 50 | + | ext.mu.Unlock() | |||
| 51 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "six", ParentID: "parent", Name: "read", Args: json.RawMessage(`{}`)}) | |||
| 52 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "depth") { | |||
| 53 | + | t.Fatalf("depth refusal: %+v, %v", result, err) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestCallToolRequiresOptIn(t *testing.T) { | |||
| 58 | + | m, _, frames, replies := toolPeer(t) | |||
| 59 | + | m.SetToolCaller(func(context.Context, string, string, json.RawMessage) extproto.ToolResultFromHost { | |||
| 60 | + | t.Error("unadvertised call reached tool caller") | |||
| 61 | + | return extproto.ToolResultFromHost{} | |||
| 62 | + | }) | |||
| 63 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "one", Name: "read", Args: json.RawMessage(`{}`)}) | |||
| 64 | + | var result extproto.ToolResultFromHost | |||
| 65 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "opt in") { | |||
| 66 | + | t.Fatalf("opt-in refusal: %+v, %v", result, err) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestCallToolToSameExtension(t *testing.T) { | |||
| 71 | + | m, ext, frames, replies := toolPeer(t) | |||
| 72 | + | ext.callTool = true | |||
| 73 | + | m.SetToolCaller(func(ctx context.Context, _, name string, args json.RawMessage) extproto.ToolResultFromHost { | |||
| 74 | + | tool, err := NewTool(m, ToolInfo{Name: name, Extension: ext.Manifest.Name}).Execute(ctx, args, nil) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return extproto.ToolResultFromHost{IsError: true} | |||
| 77 | + | } | |||
| 78 | + | return extproto.ToolResultFromHost{IsError: tool.IsError, Content: []extproto.ContentBlock{{Type: "text", Text: tool.Content[0].(provider.TextBlock).Text}}} | |||
| 79 | + | }) | |||
| 80 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "one", Name: "ask", Args: json.RawMessage(`{}`)}) | |||
| 81 | + | call := toolCallFrame(t, frames) | |||
| 82 | + | sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "answer"}}}) | |||
| 83 | + | var result extproto.ToolResultFromHost | |||
| 84 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || result.IsError || result.Content[0].Text != "answer" { | |||
| 85 | + | t.Fatalf("round trip: %+v, %v", result, err) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestNestedCallAcrossHostAndExtension(t *testing.T) { | |||
| 90 | + | m, ext, frames, replies := toolPeer(t) | |||
| 91 | + | ext.callTool = true | |||
| 92 | + | m.SetToolCaller(func(ctx context.Context, _, name string, args json.RawMessage) extproto.ToolResultFromHost { | |||
| 93 | + | if name == "read" { | |||
| 94 | + | chain, _ := ctx.Value(toolChainKey{}).([]string) | |||
| 95 | + | if len(chain) != 2 || chain[0] != "ask" || chain[1] != "read" { | |||
| 96 | + | t.Errorf("nested chain: %v", chain) | |||
| 97 | + | } | |||
| 98 | + | return extproto.ToolResultFromHost{Content: []extproto.ContentBlock{{Type: "text", Text: "loaded"}}} | |||
| 99 | + | } | |||
| 100 | + | result, err := NewTool(m, ToolInfo{Name: name, Extension: ext.Manifest.Name}).Execute(ctx, args, nil) | |||
| 101 | + | if err != nil || result.IsError { | |||
| 102 | + | return extproto.ToolResultFromHost{IsError: true} | |||
| 103 | + | } | |||
| 104 | + | return extproto.ToolResultFromHost{Content: []extproto.ContentBlock{{Type: "text", Text: "done"}}} | |||
| 105 | + | }) | |||
| 106 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "root", Name: "ask", Args: json.RawMessage(`{}`)}) | |||
| 107 | + | parent := toolCallFrame(t, frames) | |||
| 108 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "cycle", ParentID: parent.ID, Name: "ask", Args: json.RawMessage(`{}`)}) | |||
| 109 | + | var refused extproto.ToolResultFromHost | |||
| 110 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &refused); err != nil || refused.ID != "cycle" || !refused.IsError || !strings.Contains(refused.Content[0].Text, "cycle") { | |||
| 111 | + | t.Fatalf("cycle: %+v, %v", refused, err) | |||
| 112 | + | } | |||
| 113 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "child", ParentID: parent.ID, Name: "read", Args: json.RawMessage(`{}`)}) | |||
| 114 | + | var child extproto.ToolResultFromHost | |||
| 115 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &child); err != nil || child.ID != "child" || child.IsError || child.Content[0].Text != "loaded" { | |||
| 116 | + | t.Fatalf("child: %+v, %v", child, err) | |||
| 117 | + | } | |||
| 118 | + | sendToolFrame(t, replies, extproto.ToolResultFromExt{Type: "tool_result", ID: parent.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "ok"}}}) | |||
| 119 | + | var root extproto.ToolResultFromHost | |||
| 120 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &root); err != nil || root.ID != "root" || root.IsError || root.Content[0].Text != "done" { | |||
| 121 | + | t.Fatalf("root: %+v, %v", root, err) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestNestedCallUsesParentContext(t *testing.T) { | |||
| 126 | + | m, ext, frames, replies := toolPeer(t) | |||
| 127 | + | ext.callTool = true | |||
| 128 | + | parentCtx, cancel := context.WithCancel(context.Background()) | |||
| 129 | + | defer cancel() | |||
| 130 | + | ext.mu.Lock() | |||
| 131 | + | ext.outboundCalls = map[string]outboundCall{"parent": {ctx: parentCtx, chain: []string{"ask"}}} | |||
| 132 | + | ext.mu.Unlock() | |||
| 133 | + | started := make(chan context.Context, 1) | |||
| 134 | + | m.SetToolCaller(func(ctx context.Context, _, _ string, _ json.RawMessage) extproto.ToolResultFromHost { | |||
| 135 | + | started <- ctx | |||
| 136 | + | <-ctx.Done() | |||
| 137 | + | return extproto.ToolResultFromHost{IsError: true, Content: []extproto.ContentBlock{{Type: "text", Text: ctx.Err().Error()}}} | |||
| 138 | + | }) | |||
| 139 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "child", ParentID: "parent", Name: "read", Args: json.RawMessage(`{}`)}) | |||
| 140 | + | select { | |||
| 141 | + | case ctx := <-started: | |||
| 142 | + | if chain, _ := ctx.Value(toolChainKey{}).([]string); len(chain) != 2 || chain[0] != "ask" || chain[1] != "read" { | |||
| 143 | + | t.Fatalf("chain: %v", chain) | |||
| 144 | + | } | |||
| 145 | + | case <-time.After(time.Second): | |||
| 146 | + | t.Fatal("nested call did not start") | |||
| 147 | + | } | |||
| 148 | + | cancel() | |||
| 149 | + | var result extproto.ToolResultFromHost | |||
| 150 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "canceled") { | |||
| 151 | + | t.Fatalf("parent cancellation: %+v, %v", result, err) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestCallToolCancel(t *testing.T) { | |||
| 156 | + | m, ext, frames, replies := toolPeer(t) | |||
| 157 | + | ext.callTool = true | |||
| 158 | + | started := make(chan struct{}) | |||
| 159 | + | m.SetToolCaller(func(ctx context.Context, _, _ string, _ json.RawMessage) extproto.ToolResultFromHost { | |||
| 160 | + | close(started) | |||
| 161 | + | <-ctx.Done() | |||
| 162 | + | return extproto.ToolResultFromHost{IsError: true, Content: []extproto.ContentBlock{{Type: "text", Text: ctx.Err().Error()}}} | |||
| 163 | + | }) | |||
| 164 | + | sendToolFrame(t, replies, extproto.CallToolFromExt{Type: "call_tool", ID: "one", Name: "read", Args: json.RawMessage(`{}`)}) | |||
| 165 | + | select { | |||
| 166 | + | case <-started: | |||
| 167 | + | case <-time.After(time.Second): | |||
| 168 | + | t.Fatal("call did not start") | |||
| 169 | + | } | |||
| 170 | + | sendToolFrame(t, replies, extproto.Frame{Type: "call_tool_cancel", ID: "one"}) | |||
| 171 | + | var result extproto.ToolResultFromHost | |||
| 172 | + | if err := json.Unmarshal(nextLifecycleFrame(t, frames), &result); err != nil || !result.IsError || !strings.Contains(result.Content[0].Text, "canceled") { | |||
| 173 | + | t.Fatalf("cancel: %+v, %v", result, err) | |||
| 174 | + | } | |||
| 175 | + | } | |||
M packages/agent/extensions/events.go +5 -4
| @@ -104,10 +104,11 @@ func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string | |||
| 104 | 104 | current := args | |
| 105 | 105 | for _, ext := range subs { | |
| 106 | 106 | r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{ | |
| 107 | - | Event: "tool_call", | |
| 108 | - | ToolID: toolID, | |
| 109 | - | ToolName: toolName, | |
| 110 | - | ToolArgs: current, | |
| 107 | + | OriginExtension: m.ToolCallOrigin(toolID), | |
| 108 | + | Event: "tool_call", | |
| 109 | + | ToolID: toolID, | |
| 110 | + | ToolName: toolName, | |
| 111 | + | ToolArgs: current, | |
| 111 | 112 | }) | |
| 112 | 113 | if r.Block { | |
| 113 | 114 | r.ModifiedArgs = current | |
| @@ -104,10 +104,11 @@ func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string | |||||
| 104 | current := args | 104 | current := args | ||
| 105 | for _, ext := range subs { | 105 | for _, ext := range subs { | ||
| 106 | r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{ | 106 | r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{ | ||
| 107 | - | Event: "tool_call", | 107 | + | OriginExtension: m.ToolCallOrigin(toolID), |
| 108 | - | ToolID: toolID, | 108 | + | Event: "tool_call", |
| 109 | - | ToolName: toolName, | 109 | + | ToolID: toolID, |
| 110 | - | ToolArgs: current, | 110 | + | ToolName: toolName, |
| 111 | + | ToolArgs: current, | |||
| 111 | }) | 112 | }) | ||
| 112 | if r.Block { | 113 | if r.Block { | ||
| 113 | r.ModifiedArgs = current | 114 | r.ModifiedArgs = current | ||
M packages/agent/extensions/lifecycle_test.go +3 -2
| @@ -106,7 +106,8 @@ func TestNotificationPrecedesInterceptOnWire(t *testing.T) { | |||
| 106 | 106 | m := New(t.TempDir(), "/work", "test", "", "", nil) | |
| 107 | 107 | frames, ext := lifecyclePeer(t, m, "tool_call") | |
| 108 | 108 | ext.interceptSubs["tool_call"] = struct{}{} | |
| 109 | - | m.EmitEvent(extproto.EventFromHost{Event: "tool_call", ToolID: "call"}) | |
| 109 | + | defer m.TrackToolCall("call", "caller")() | |
| 110 | + | m.EmitEvent(extproto.EventFromHost{Event: "tool_call", ToolID: "call", OriginExtension: "caller"}) | |
| 110 | 111 | result := make(chan InterceptResult, 1) | |
| 111 | 112 | go func() { result <- m.InterceptToolCall(context.Background(), "call", "bash", json.RawMessage(`{}`)) }() | |
| 112 | 113 | var event extproto.EventFromHost | |
| @@ -116,7 +117,7 @@ func TestNotificationPrecedesInterceptOnWire(t *testing.T) { | |||
| 116 | 117 | } | |
| 117 | 118 | var intercept extproto.EventInterceptFromHost | |
| 118 | 119 | json.Unmarshal(nextLifecycleFrame(t, frames), &intercept) | |
| 119 | - | if intercept.Type != "event_intercept" { | |
| 120 | + | if intercept.Type != "event_intercept" || intercept.OriginExtension != "caller" { | |
| 120 | 121 | t.Fatalf("second frame: %+v", intercept) | |
| 121 | 122 | } | |
| 122 | 123 | ext.mu.Lock() | |
| @@ -106,7 +106,8 @@ func TestNotificationPrecedesInterceptOnWire(t *testing.T) { | |||||
| 106 | m := New(t.TempDir(), "/work", "test", "", "", nil) | 106 | m := New(t.TempDir(), "/work", "test", "", "", nil) | ||
| 107 | frames, ext := lifecyclePeer(t, m, "tool_call") | 107 | frames, ext := lifecyclePeer(t, m, "tool_call") | ||
| 108 | ext.interceptSubs["tool_call"] = struct{}{} | 108 | ext.interceptSubs["tool_call"] = struct{}{} | ||
| 109 | - | m.EmitEvent(extproto.EventFromHost{Event: "tool_call", ToolID: "call"}) | 109 | + | defer m.TrackToolCall("call", "caller")() |
| 110 | + | m.EmitEvent(extproto.EventFromHost{Event: "tool_call", ToolID: "call", OriginExtension: "caller"}) | |||
| 110 | result := make(chan InterceptResult, 1) | 111 | result := make(chan InterceptResult, 1) | ||
| 111 | go func() { result <- m.InterceptToolCall(context.Background(), "call", "bash", json.RawMessage(`{}`)) }() | 112 | go func() { result <- m.InterceptToolCall(context.Background(), "call", "bash", json.RawMessage(`{}`)) }() | ||
| 112 | var event extproto.EventFromHost | 113 | var event extproto.EventFromHost | ||
| @@ -116,7 +117,7 @@ func TestNotificationPrecedesInterceptOnWire(t *testing.T) { | |||||
| 116 | } | 117 | } | ||
| 117 | var intercept extproto.EventInterceptFromHost | 118 | var intercept extproto.EventInterceptFromHost | ||
| 118 | json.Unmarshal(nextLifecycleFrame(t, frames), &intercept) | 119 | json.Unmarshal(nextLifecycleFrame(t, frames), &intercept) | ||
| 119 | - | if intercept.Type != "event_intercept" { | 120 | + | if intercept.Type != "event_intercept" || intercept.OriginExtension != "caller" { |
| 120 | t.Fatalf("second frame: %+v", intercept) | 121 | t.Fatalf("second frame: %+v", intercept) | ||
| 121 | } | 122 | } | ||
| 122 | ext.mu.Lock() | 123 | ext.mu.Lock() | ||
M packages/agent/extensions/manager.go +54 -0
| @@ -70,6 +70,7 @@ type Extension struct { | |||
| 70 | 70 | helloAck bool | |
| 71 | 71 | // Set during the handshake, before the extension is published. | |
| 72 | 72 | toolCancel bool | |
| 73 | + | callTool bool | |
| 73 | 74 | commands []extproto.RegisterCommandFromExt | |
| 74 | 75 | tools []extproto.RegisterToolFromExt | |
| 75 | 76 | ||
| @@ -86,6 +87,8 @@ type Extension struct { | |||
| 86 | 87 | pending map[string]chan extproto.CommandResponseFromExt | |
| 87 | 88 | pendingTool map[string]chan extproto.ToolResultFromExt | |
| 88 | 89 | pendingIntercept map[string]chan extproto.EventInterceptResponseFromExt | |
| 90 | + | outboundCalls map[string]outboundCall | |
| 91 | + | inboundCalls map[string]context.CancelFunc | |
| 89 | 92 | ||
| 90 | 93 | // lastFrameTime is updated by the read loop on every frame it | |
| 91 | 94 | // processes. Used by the auto-ready idle watchdog so legacy | |
| @@ -179,6 +182,9 @@ type Manager struct { | |||
| 179 | 182 | // by the host so it can rebuild the agent's tool registry with | |
| 180 | 183 | // the freshly-registered extension tools. | |
| 181 | 184 | onReload func() | |
| 185 | + | // callTool is wired by the active host mode, not by the extension registry. | |
| 186 | + | callTool func(context.Context, string, string, json.RawMessage) extproto.ToolResultFromHost | |
| 187 | + | callOrigins map[string]string | |
| 182 | 188 | ||
| 183 | 189 | // lifecycleMu serializes session boundaries and notification enqueue order. | |
| 184 | 190 | lifecycleMu sync.Mutex | |
| @@ -441,6 +447,8 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 441 | 447 | readyCh: make(chan struct{}), | |
| 442 | 448 | pending: map[string]chan extproto.CommandResponseFromExt{}, | |
| 443 | 449 | pendingTool: map[string]chan extproto.ToolResultFromExt{}, | |
| 450 | + | outboundCalls: map[string]outboundCall{}, | |
| 451 | + | inboundCalls: map[string]context.CancelFunc{}, | |
| 444 | 452 | pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}, | |
| 445 | 453 | eventSubs: map[string]struct{}{}, | |
| 446 | 454 | interceptSubs: map[string]struct{}{}, | |
| @@ -786,10 +794,12 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 786 | 794 | // Trust the manifest's name; ignore mismatch from the hello. | |
| 787 | 795 | ext.helloAck = true | |
| 788 | 796 | ext.toolCancel = slices.Contains(hello.Capabilities, "tool_cancel") | |
| 797 | + | ext.callTool = slices.Contains(hello.Capabilities, "call_tool") | |
| 789 | 798 | ||
| 790 | 799 | ack, _ := extproto.Encode(extproto.HelloAckFromHost{ | |
| 791 | 800 | Type: "hello_ack", | |
| 792 | 801 | ProtocolVersion: extproto.ProtocolVersion, | |
| 802 | + | Capabilities: []string{"call_tool"}, | |
| 793 | 803 | ZotVersion: m.zotVersion, | |
| 794 | 804 | Provider: m.provider, | |
| 795 | 805 | Model: m.model, | |
| @@ -882,6 +892,11 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) { | |||
| 882 | 892 | // Returns when stdout closes. | |
| 883 | 893 | func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |
| 884 | 894 | defer func() { | |
| 895 | + | ext.mu.Lock() | |
| 896 | + | for _, cancel := range ext.inboundCalls { | |
| 897 | + | cancel() | |
| 898 | + | } | |
| 899 | + | ext.mu.Unlock() | |
| 885 | 900 | if ext.stdin != nil { | |
| 886 | 901 | _ = ext.stdin.Close() | |
| 887 | 902 | } | |
| @@ -994,6 +1009,34 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 994 | 1009 | } | |
| 995 | 1010 | } | |
| 996 | 1011 | } | |
| 1012 | + | case "call_tool": | |
| 1013 | + | var req extproto.CallToolFromExt | |
| 1014 | + | if json.Unmarshal(line, &req) == nil && req.ID != "" { | |
| 1015 | + | ext.mu.Lock() | |
| 1016 | + | if ext.inboundCalls == nil { | |
| 1017 | + | ext.inboundCalls = make(map[string]context.CancelFunc) | |
| 1018 | + | } | |
| 1019 | + | _, duplicate := ext.inboundCalls[req.ID] | |
| 1020 | + | if !duplicate { | |
| 1021 | + | parent, found := ext.outboundCalls[req.ParentID] | |
| 1022 | + | activeOutbound := len(ext.outboundCalls) > 0 | |
| 1023 | + | base := context.Background() | |
| 1024 | + | if req.ParentID != "" && found { | |
| 1025 | + | base = parent.ctx | |
| 1026 | + | } | |
| 1027 | + | ctx, cancel := context.WithTimeout(base, 60*time.Second) | |
| 1028 | + | ext.inboundCalls[req.ID] = cancel | |
| 1029 | + | go m.handleCallTool(ctx, ext, req, cancel, found, activeOutbound, parent.chain) | |
| 1030 | + | } | |
| 1031 | + | ext.mu.Unlock() | |
| 1032 | + | } | |
| 1033 | + | case "call_tool_cancel": | |
| 1034 | + | ext.mu.Lock() | |
| 1035 | + | cancel := ext.inboundCalls[frame.ID] | |
| 1036 | + | ext.mu.Unlock() | |
| 1037 | + | if cancel != nil { | |
| 1038 | + | cancel() | |
| 1039 | + | } | |
| 997 | 1040 | case "tool_result": | |
| 998 | 1041 | var tr extproto.ToolResultFromExt | |
| 999 | 1042 | if err := json.Unmarshal(line, &tr); err == nil { | |
| @@ -1257,12 +1300,23 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||
| 1257 | 1300 | ||
| 1258 | 1301 | id := newCorrelationID() | |
| 1259 | 1302 | ch := make(chan extproto.ToolResultFromExt, 1) | |
| 1303 | + | chain, _ := ctx.Value(toolChainKey{}).([]string) | |
| 1304 | + | if len(chain) == 0 || chain[len(chain)-1] != name { | |
| 1305 | + | chain = append(append([]string(nil), chain...), name) | |
| 1306 | + | } | |
| 1307 | + | callCtx, callCancel := context.WithCancel(ctx) | |
| 1260 | 1308 | ext.mu.Lock() | |
| 1261 | 1309 | ext.pendingTool[id] = ch | |
| 1310 | + | if ext.outboundCalls == nil { | |
| 1311 | + | ext.outboundCalls = make(map[string]outboundCall) | |
| 1312 | + | } | |
| 1313 | + | ext.outboundCalls[id] = outboundCall{ctx: callCtx, chain: chain} | |
| 1262 | 1314 | ext.mu.Unlock() | |
| 1263 | 1315 | defer func() { | |
| 1316 | + | callCancel() | |
| 1264 | 1317 | ext.mu.Lock() | |
| 1265 | 1318 | delete(ext.pendingTool, id) | |
| 1319 | + | delete(ext.outboundCalls, id) | |
| 1266 | 1320 | ext.mu.Unlock() | |
| 1267 | 1321 | }() | |
| 1268 | 1322 | ||
| @@ -70,6 +70,7 @@ type Extension struct { | |||||
| 70 | helloAck bool | 70 | helloAck bool | ||
| 71 | // Set during the handshake, before the extension is published. | 71 | // Set during the handshake, before the extension is published. | ||
| 72 | toolCancel bool | 72 | toolCancel bool | ||
| 73 | + | callTool bool | |||
| 73 | commands []extproto.RegisterCommandFromExt | 74 | commands []extproto.RegisterCommandFromExt | ||
| 74 | tools []extproto.RegisterToolFromExt | 75 | tools []extproto.RegisterToolFromExt | ||
| 75 | 76 | ||||
| @@ -86,6 +87,8 @@ type Extension struct { | |||||
| 86 | pending map[string]chan extproto.CommandResponseFromExt | 87 | pending map[string]chan extproto.CommandResponseFromExt | ||
| 87 | pendingTool map[string]chan extproto.ToolResultFromExt | 88 | pendingTool map[string]chan extproto.ToolResultFromExt | ||
| 88 | pendingIntercept map[string]chan extproto.EventInterceptResponseFromExt | 89 | pendingIntercept map[string]chan extproto.EventInterceptResponseFromExt | ||
| 90 | + | outboundCalls map[string]outboundCall | |||
| 91 | + | inboundCalls map[string]context.CancelFunc | |||
| 89 | 92 | ||||
| 90 | // lastFrameTime is updated by the read loop on every frame it | 93 | // lastFrameTime is updated by the read loop on every frame it | ||
| 91 | // processes. Used by the auto-ready idle watchdog so legacy | 94 | // processes. Used by the auto-ready idle watchdog so legacy | ||
| @@ -179,6 +182,9 @@ type Manager struct { | |||||
| 179 | // by the host so it can rebuild the agent's tool registry with | 182 | // by the host so it can rebuild the agent's tool registry with | ||
| 180 | // the freshly-registered extension tools. | 183 | // the freshly-registered extension tools. | ||
| 181 | onReload func() | 184 | onReload func() | ||
| 185 | + | // callTool is wired by the active host mode, not by the extension registry. | |||
| 186 | + | callTool func(context.Context, string, string, json.RawMessage) extproto.ToolResultFromHost | |||
| 187 | + | callOrigins map[string]string | |||
| 182 | 188 | ||||
| 183 | // lifecycleMu serializes session boundaries and notification enqueue order. | 189 | // lifecycleMu serializes session boundaries and notification enqueue order. | ||
| 184 | lifecycleMu sync.Mutex | 190 | lifecycleMu sync.Mutex | ||
| @@ -441,6 +447,8 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||||
| 441 | readyCh: make(chan struct{}), | 447 | readyCh: make(chan struct{}), | ||
| 442 | pending: map[string]chan extproto.CommandResponseFromExt{}, | 448 | pending: map[string]chan extproto.CommandResponseFromExt{}, | ||
| 443 | pendingTool: map[string]chan extproto.ToolResultFromExt{}, | 449 | pendingTool: map[string]chan extproto.ToolResultFromExt{}, | ||
| 450 | + | outboundCalls: map[string]outboundCall{}, | |||
| 451 | + | inboundCalls: map[string]context.CancelFunc{}, | |||
| 444 | pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}, | 452 | pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}, | ||
| 445 | eventSubs: map[string]struct{}{}, | 453 | eventSubs: map[string]struct{}{}, | ||
| 446 | interceptSubs: map[string]struct{}{}, | 454 | interceptSubs: map[string]struct{}{}, | ||
| @@ -786,10 +794,12 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||||
| 786 | // Trust the manifest's name; ignore mismatch from the hello. | 794 | // Trust the manifest's name; ignore mismatch from the hello. | ||
| 787 | ext.helloAck = true | 795 | ext.helloAck = true | ||
| 788 | ext.toolCancel = slices.Contains(hello.Capabilities, "tool_cancel") | 796 | ext.toolCancel = slices.Contains(hello.Capabilities, "tool_cancel") | ||
| 797 | + | ext.callTool = slices.Contains(hello.Capabilities, "call_tool") | |||
| 789 | 798 | ||||
| 790 | ack, _ := extproto.Encode(extproto.HelloAckFromHost{ | 799 | ack, _ := extproto.Encode(extproto.HelloAckFromHost{ | ||
| 791 | Type: "hello_ack", | 800 | Type: "hello_ack", | ||
| 792 | ProtocolVersion: extproto.ProtocolVersion, | 801 | ProtocolVersion: extproto.ProtocolVersion, | ||
| 802 | + | Capabilities: []string{"call_tool"}, | |||
| 793 | ZotVersion: m.zotVersion, | 803 | ZotVersion: m.zotVersion, | ||
| 794 | Provider: m.provider, | 804 | Provider: m.provider, | ||
| 795 | Model: m.model, | 805 | Model: m.model, | ||
| @@ -882,6 +892,11 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) { | |||||
| 882 | // Returns when stdout closes. | 892 | // Returns when stdout closes. | ||
| 883 | func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | 893 | func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | ||
| 884 | defer func() { | 894 | defer func() { | ||
| 895 | + | ext.mu.Lock() | |||
| 896 | + | for _, cancel := range ext.inboundCalls { | |||
| 897 | + | cancel() | |||
| 898 | + | } | |||
| 899 | + | ext.mu.Unlock() | |||
| 885 | if ext.stdin != nil { | 900 | if ext.stdin != nil { | ||
| 886 | _ = ext.stdin.Close() | 901 | _ = ext.stdin.Close() | ||
| 887 | } | 902 | } | ||
| @@ -994,6 +1009,34 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 994 | } | 1009 | } | ||
| 995 | } | 1010 | } | ||
| 996 | } | 1011 | } | ||
| 1012 | + | case "call_tool": | |||
| 1013 | + | var req extproto.CallToolFromExt | |||
| 1014 | + | if json.Unmarshal(line, &req) == nil && req.ID != "" { | |||
| 1015 | + | ext.mu.Lock() | |||
| 1016 | + | if ext.inboundCalls == nil { | |||
| 1017 | + | ext.inboundCalls = make(map[string]context.CancelFunc) | |||
| 1018 | + | } | |||
| 1019 | + | _, duplicate := ext.inboundCalls[req.ID] | |||
| 1020 | + | if !duplicate { | |||
| 1021 | + | parent, found := ext.outboundCalls[req.ParentID] | |||
| 1022 | + | activeOutbound := len(ext.outboundCalls) > 0 | |||
| 1023 | + | base := context.Background() | |||
| 1024 | + | if req.ParentID != "" && found { | |||
| 1025 | + | base = parent.ctx | |||
| 1026 | + | } | |||
| 1027 | + | ctx, cancel := context.WithTimeout(base, 60*time.Second) | |||
| 1028 | + | ext.inboundCalls[req.ID] = cancel | |||
| 1029 | + | go m.handleCallTool(ctx, ext, req, cancel, found, activeOutbound, parent.chain) | |||
| 1030 | + | } | |||
| 1031 | + | ext.mu.Unlock() | |||
| 1032 | + | } | |||
| 1033 | + | case "call_tool_cancel": | |||
| 1034 | + | ext.mu.Lock() | |||
| 1035 | + | cancel := ext.inboundCalls[frame.ID] | |||
| 1036 | + | ext.mu.Unlock() | |||
| 1037 | + | if cancel != nil { | |||
| 1038 | + | cancel() | |||
| 1039 | + | } | |||
| 997 | case "tool_result": | 1040 | case "tool_result": | ||
| 998 | var tr extproto.ToolResultFromExt | 1041 | var tr extproto.ToolResultFromExt | ||
| 999 | if err := json.Unmarshal(line, &tr); err == nil { | 1042 | if err := json.Unmarshal(line, &tr); err == nil { | ||
| @@ -1257,12 +1300,23 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||||
| 1257 | 1300 | ||||
| 1258 | id := newCorrelationID() | 1301 | id := newCorrelationID() | ||
| 1259 | ch := make(chan extproto.ToolResultFromExt, 1) | 1302 | ch := make(chan extproto.ToolResultFromExt, 1) | ||
| 1303 | + | chain, _ := ctx.Value(toolChainKey{}).([]string) | |||
| 1304 | + | if len(chain) == 0 || chain[len(chain)-1] != name { | |||
| 1305 | + | chain = append(append([]string(nil), chain...), name) | |||
| 1306 | + | } | |||
| 1307 | + | callCtx, callCancel := context.WithCancel(ctx) | |||
| 1260 | ext.mu.Lock() | 1308 | ext.mu.Lock() | ||
| 1261 | ext.pendingTool[id] = ch | 1309 | ext.pendingTool[id] = ch | ||
| 1310 | + | if ext.outboundCalls == nil { | |||
| 1311 | + | ext.outboundCalls = make(map[string]outboundCall) | |||
| 1312 | + | } | |||
| 1313 | + | ext.outboundCalls[id] = outboundCall{ctx: callCtx, chain: chain} | |||
| 1262 | ext.mu.Unlock() | 1314 | ext.mu.Unlock() | ||
| 1263 | defer func() { | 1315 | defer func() { | ||
| 1316 | + | callCancel() | |||
| 1264 | ext.mu.Lock() | 1317 | ext.mu.Lock() | ||
| 1265 | delete(ext.pendingTool, id) | 1318 | delete(ext.pendingTool, id) | ||
| 1319 | + | delete(ext.outboundCalls, id) | |||
| 1266 | ext.mu.Unlock() | 1320 | ext.mu.Unlock() | ||
| 1267 | }() | 1321 | }() | ||
| 1268 | 1322 | ||||
M packages/agent/extproto/extproto.go +74 -51
| @@ -160,14 +160,15 @@ type ShutdownAckFromExt struct { | |||
| 160 | 160 | } | |
| 161 | 161 | ||
| 162 | 162 | type HelloAckFromHost struct { | |
| 163 | - | Type string `json:"type"` | |
| 164 | - | ProtocolVersion int `json:"protocol_version"` | |
| 165 | - | ZotVersion string `json:"zot_version"` | |
| 166 | - | Provider string `json:"provider"` | |
| 167 | - | Model string `json:"model"` | |
| 168 | - | CWD string `json:"cwd"` | |
| 169 | - | ExtensionDir string `json:"extension_dir,omitempty"` | |
| 170 | - | DataDir string `json:"data_dir,omitempty"` | |
| 163 | + | Type string `json:"type"` | |
| 164 | + | ProtocolVersion int `json:"protocol_version"` | |
| 165 | + | Capabilities []string `json:"capabilities,omitempty"` | |
| 166 | + | ZotVersion string `json:"zot_version"` | |
| 167 | + | Provider string `json:"provider"` | |
| 168 | + | Model string `json:"model"` | |
| 169 | + | CWD string `json:"cwd"` | |
| 170 | + | ExtensionDir string `json:"extension_dir,omitempty"` | |
| 171 | + | DataDir string `json:"data_dir,omitempty"` | |
| 171 | 172 | } | |
| 172 | 173 | ||
| 173 | 174 | type CommandInvokedFromHost struct { | |
| @@ -190,6 +191,26 @@ type ToolCallFromHost struct { | |||
| 190 | 191 | Args json.RawMessage `json:"args"` | |
| 191 | 192 | } | |
| 192 | 193 | ||
| 194 | + | // CallToolFromExt asks the host to execute an active tool. ParentID is set | |
| 195 | + | // by the SDK when the request originates inside a host-initiated tool call. | |
| 196 | + | type CallToolFromExt struct { | |
| 197 | + | Type string `json:"type"` | |
| 198 | + | ID string `json:"id"` | |
| 199 | + | Name string `json:"name"` | |
| 200 | + | Args json.RawMessage `json:"args"` | |
| 201 | + | ParentID string `json:"parent_id,omitempty"` | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // ToolResultFromHost is correlated to CallToolFromExt, not to a model call. | |
| 205 | + | type ToolResultFromHost struct { | |
| 206 | + | Type string `json:"type"` | |
| 207 | + | ID string `json:"id"` | |
| 208 | + | Name string `json:"name"` | |
| 209 | + | Content []ContentBlock `json:"content"` | |
| 210 | + | IsError bool `json:"is_error,omitempty"` | |
| 211 | + | Truncated bool `json:"truncated,omitempty"` | |
| 212 | + | } | |
| 213 | + | ||
| 193 | 214 | // EventResult is a tool outcome without extension-private Details or activation data. | |
| 194 | 215 | type EventResult struct { | |
| 195 | 216 | Content []ContentBlock `json:"content"` | |
| @@ -198,52 +219,54 @@ type EventResult struct { | |||
| 198 | 219 | } | |
| 199 | 220 | ||
| 200 | 221 | type EventFromHost struct { | |
| 201 | - | SessionID string `json:"session_id,omitempty"` | |
| 202 | - | AgentRunID string `json:"agent_run_id,omitempty"` | |
| 203 | - | Queued bool `json:"queued,omitempty"` | |
| 204 | - | ImageCount int `json:"image_count,omitempty"` | |
| 205 | - | CWD string `json:"cwd,omitempty"` | |
| 206 | - | Sequence uint64 `json:"sequence,omitempty"` | |
| 207 | - | Status string `json:"status,omitempty"` | |
| 208 | - | Reason string `json:"reason,omitempty"` | |
| 209 | - | Source string `json:"source,omitempty"` | |
| 210 | - | Decision string `json:"decision,omitempty"` | |
| 211 | - | Stage string `json:"stage,omitempty"` | |
| 212 | - | Result *EventResult `json:"result,omitempty"` | |
| 213 | - | Executed *bool `json:"executed,omitempty"` | |
| 214 | - | CompactionID string `json:"compaction_id,omitempty"` | |
| 215 | - | MessageCount *int `json:"message_count,omitempty"` | |
| 216 | - | TokenEstimate *int `json:"token_estimate,omitempty"` | |
| 217 | - | AgentID string `json:"agent_id,omitempty"` | |
| 218 | - | Name string `json:"name,omitempty"` | |
| 219 | - | Type string `json:"type"` | |
| 220 | - | Event string `json:"event"` | |
| 221 | - | Step int `json:"step,omitempty"` | |
| 222 | - | Stop string `json:"stop,omitempty"` | |
| 223 | - | Error string `json:"error,omitempty"` | |
| 224 | - | ToolID string `json:"tool_id,omitempty"` | |
| 225 | - | ToolName string `json:"tool_name,omitempty"` | |
| 226 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 227 | - | ToolPreview string `json:"tool_preview,omitempty"` | |
| 228 | - | ToolArgsRaw string `json:"tool_args_raw,omitempty"` | |
| 229 | - | Text string `json:"text,omitempty"` | |
| 222 | + | SessionID string `json:"session_id,omitempty"` | |
| 223 | + | AgentRunID string `json:"agent_run_id,omitempty"` | |
| 224 | + | Queued bool `json:"queued,omitempty"` | |
| 225 | + | ImageCount int `json:"image_count,omitempty"` | |
| 226 | + | CWD string `json:"cwd,omitempty"` | |
| 227 | + | Sequence uint64 `json:"sequence,omitempty"` | |
| 228 | + | Status string `json:"status,omitempty"` | |
| 229 | + | Reason string `json:"reason,omitempty"` | |
| 230 | + | Source string `json:"source,omitempty"` | |
| 231 | + | Decision string `json:"decision,omitempty"` | |
| 232 | + | Stage string `json:"stage,omitempty"` | |
| 233 | + | Result *EventResult `json:"result,omitempty"` | |
| 234 | + | Executed *bool `json:"executed,omitempty"` | |
| 235 | + | CompactionID string `json:"compaction_id,omitempty"` | |
| 236 | + | MessageCount *int `json:"message_count,omitempty"` | |
| 237 | + | TokenEstimate *int `json:"token_estimate,omitempty"` | |
| 238 | + | AgentID string `json:"agent_id,omitempty"` | |
| 239 | + | OriginExtension string `json:"origin_extension,omitempty"` | |
| 240 | + | Name string `json:"name,omitempty"` | |
| 241 | + | Type string `json:"type"` | |
| 242 | + | Event string `json:"event"` | |
| 243 | + | Step int `json:"step,omitempty"` | |
| 244 | + | Stop string `json:"stop,omitempty"` | |
| 245 | + | Error string `json:"error,omitempty"` | |
| 246 | + | ToolID string `json:"tool_id,omitempty"` | |
| 247 | + | ToolName string `json:"tool_name,omitempty"` | |
| 248 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 249 | + | ToolPreview string `json:"tool_preview,omitempty"` | |
| 250 | + | ToolArgsRaw string `json:"tool_args_raw,omitempty"` | |
| 251 | + | Text string `json:"text,omitempty"` | |
| 230 | 252 | } | |
| 231 | 253 | ||
| 232 | 254 | type EventInterceptFromHost struct { | |
| 233 | - | SystemPrompt *string `json:"system_prompt,omitempty"` | |
| 234 | - | SessionID string `json:"session_id,omitempty"` | |
| 235 | - | AgentRunID string `json:"agent_run_id,omitempty"` | |
| 236 | - | CWD string `json:"cwd,omitempty"` | |
| 237 | - | Provider string `json:"provider,omitempty"` | |
| 238 | - | Model string `json:"model,omitempty"` | |
| 239 | - | Type string `json:"type"` | |
| 240 | - | ID string `json:"id"` | |
| 241 | - | Event string `json:"event"` | |
| 242 | - | ToolID string `json:"tool_id,omitempty"` | |
| 243 | - | ToolName string `json:"tool_name,omitempty"` | |
| 244 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 245 | - | Step int `json:"step,omitempty"` | |
| 246 | - | Text string `json:"text,omitempty"` | |
| 255 | + | SystemPrompt *string `json:"system_prompt,omitempty"` | |
| 256 | + | OriginExtension string `json:"origin_extension,omitempty"` | |
| 257 | + | SessionID string `json:"session_id,omitempty"` | |
| 258 | + | AgentRunID string `json:"agent_run_id,omitempty"` | |
| 259 | + | CWD string `json:"cwd,omitempty"` | |
| 260 | + | Provider string `json:"provider,omitempty"` | |
| 261 | + | Model string `json:"model,omitempty"` | |
| 262 | + | Type string `json:"type"` | |
| 263 | + | ID string `json:"id"` | |
| 264 | + | Event string `json:"event"` | |
| 265 | + | ToolID string `json:"tool_id,omitempty"` | |
| 266 | + | ToolName string `json:"tool_name,omitempty"` | |
| 267 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 268 | + | Step int `json:"step,omitempty"` | |
| 269 | + | Text string `json:"text,omitempty"` | |
| 247 | 270 | } | |
| 248 | 271 | ||
| 249 | 272 | type PanelKeyFromHost struct { | |
| @@ -160,14 +160,15 @@ type ShutdownAckFromExt struct { | |||||
| 160 | } | 160 | } | ||
| 161 | 161 | ||||
| 162 | type HelloAckFromHost struct { | 162 | type HelloAckFromHost struct { | ||
| 163 | - | Type string `json:"type"` | 163 | + | Type string `json:"type"` |
| 164 | - | ProtocolVersion int `json:"protocol_version"` | 164 | + | ProtocolVersion int `json:"protocol_version"` |
| 165 | - | ZotVersion string `json:"zot_version"` | 165 | + | Capabilities []string `json:"capabilities,omitempty"` |
| 166 | - | Provider string `json:"provider"` | 166 | + | ZotVersion string `json:"zot_version"` |
| 167 | - | Model string `json:"model"` | 167 | + | Provider string `json:"provider"` |
| 168 | - | CWD string `json:"cwd"` | 168 | + | Model string `json:"model"` |
| 169 | - | ExtensionDir string `json:"extension_dir,omitempty"` | 169 | + | CWD string `json:"cwd"` |
| 170 | - | DataDir string `json:"data_dir,omitempty"` | 170 | + | ExtensionDir string `json:"extension_dir,omitempty"` |
| 171 | + | DataDir string `json:"data_dir,omitempty"` | |||
| 171 | } | 172 | } | ||
| 172 | 173 | ||||
| 173 | type CommandInvokedFromHost struct { | 174 | type CommandInvokedFromHost struct { | ||
| @@ -190,6 +191,26 @@ type ToolCallFromHost struct { | |||||
| 190 | Args json.RawMessage `json:"args"` | 191 | Args json.RawMessage `json:"args"` | ||
| 191 | } | 192 | } | ||
| 192 | 193 | ||||
| 194 | + | // CallToolFromExt asks the host to execute an active tool. ParentID is set | |||
| 195 | + | // by the SDK when the request originates inside a host-initiated tool call. | |||
| 196 | + | type CallToolFromExt struct { | |||
| 197 | + | Type string `json:"type"` | |||
| 198 | + | ID string `json:"id"` | |||
| 199 | + | Name string `json:"name"` | |||
| 200 | + | Args json.RawMessage `json:"args"` | |||
| 201 | + | ParentID string `json:"parent_id,omitempty"` | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // ToolResultFromHost is correlated to CallToolFromExt, not to a model call. | |||
| 205 | + | type ToolResultFromHost struct { | |||
| 206 | + | Type string `json:"type"` | |||
| 207 | + | ID string `json:"id"` | |||
| 208 | + | Name string `json:"name"` | |||
| 209 | + | Content []ContentBlock `json:"content"` | |||
| 210 | + | IsError bool `json:"is_error,omitempty"` | |||
| 211 | + | Truncated bool `json:"truncated,omitempty"` | |||
| 212 | + | } | |||
| 213 | + | ||||
| 193 | // EventResult is a tool outcome without extension-private Details or activation data. | 214 | // EventResult is a tool outcome without extension-private Details or activation data. | ||
| 194 | type EventResult struct { | 215 | type EventResult struct { | ||
| 195 | Content []ContentBlock `json:"content"` | 216 | Content []ContentBlock `json:"content"` | ||
| @@ -198,52 +219,54 @@ type EventResult struct { | |||||
| 198 | } | 219 | } | ||
| 199 | 220 | ||||
| 200 | type EventFromHost struct { | 221 | type EventFromHost struct { | ||
| 201 | - | SessionID string `json:"session_id,omitempty"` | 222 | + | SessionID string `json:"session_id,omitempty"` |
| 202 | - | AgentRunID string `json:"agent_run_id,omitempty"` | 223 | + | AgentRunID string `json:"agent_run_id,omitempty"` |
| 203 | - | Queued bool `json:"queued,omitempty"` | 224 | + | Queued bool `json:"queued,omitempty"` |
| 204 | - | ImageCount int `json:"image_count,omitempty"` | 225 | + | ImageCount int `json:"image_count,omitempty"` |
| 205 | - | CWD string `json:"cwd,omitempty"` | 226 | + | CWD string `json:"cwd,omitempty"` |
| 206 | - | Sequence uint64 `json:"sequence,omitempty"` | 227 | + | Sequence uint64 `json:"sequence,omitempty"` |
| 207 | - | Status string `json:"status,omitempty"` | 228 | + | Status string `json:"status,omitempty"` |
| 208 | - | Reason string `json:"reason,omitempty"` | 229 | + | Reason string `json:"reason,omitempty"` |
| 209 | - | Source string `json:"source,omitempty"` | 230 | + | Source string `json:"source,omitempty"` |
| 210 | - | Decision string `json:"decision,omitempty"` | 231 | + | Decision string `json:"decision,omitempty"` |
| 211 | - | Stage string `json:"stage,omitempty"` | 232 | + | Stage string `json:"stage,omitempty"` |
| 212 | - | Result *EventResult `json:"result,omitempty"` | 233 | + | Result *EventResult `json:"result,omitempty"` |
| 213 | - | Executed *bool `json:"executed,omitempty"` | 234 | + | Executed *bool `json:"executed,omitempty"` |
| 214 | - | CompactionID string `json:"compaction_id,omitempty"` | 235 | + | CompactionID string `json:"compaction_id,omitempty"` |
| 215 | - | MessageCount *int `json:"message_count,omitempty"` | 236 | + | MessageCount *int `json:"message_count,omitempty"` |
| 216 | - | TokenEstimate *int `json:"token_estimate,omitempty"` | 237 | + | TokenEstimate *int `json:"token_estimate,omitempty"` |
| 217 | - | AgentID string `json:"agent_id,omitempty"` | 238 | + | AgentID string `json:"agent_id,omitempty"` |
| 218 | - | Name string `json:"name,omitempty"` | 239 | + | OriginExtension string `json:"origin_extension,omitempty"` |
| 219 | - | Type string `json:"type"` | 240 | + | Name string `json:"name,omitempty"` |
| 220 | - | Event string `json:"event"` | 241 | + | Type string `json:"type"` |
| 221 | - | Step int `json:"step,omitempty"` | 242 | + | Event string `json:"event"` |
| 222 | - | Stop string `json:"stop,omitempty"` | 243 | + | Step int `json:"step,omitempty"` |
| 223 | - | Error string `json:"error,omitempty"` | 244 | + | Stop string `json:"stop,omitempty"` |
| 224 | - | ToolID string `json:"tool_id,omitempty"` | 245 | + | Error string `json:"error,omitempty"` |
| 225 | - | ToolName string `json:"tool_name,omitempty"` | 246 | + | ToolID string `json:"tool_id,omitempty"` |
| 226 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | 247 | + | ToolName string `json:"tool_name,omitempty"` |
| 227 | - | ToolPreview string `json:"tool_preview,omitempty"` | 248 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` |
| 228 | - | ToolArgsRaw string `json:"tool_args_raw,omitempty"` | 249 | + | ToolPreview string `json:"tool_preview,omitempty"` |
| 229 | - | Text string `json:"text,omitempty"` | 250 | + | ToolArgsRaw string `json:"tool_args_raw,omitempty"` |
| 251 | + | Text string `json:"text,omitempty"` | |||
| 230 | } | 252 | } | ||
| 231 | 253 | ||||
| 232 | type EventInterceptFromHost struct { | 254 | type EventInterceptFromHost struct { | ||
| 233 | - | SystemPrompt *string `json:"system_prompt,omitempty"` | 255 | + | SystemPrompt *string `json:"system_prompt,omitempty"` |
| 234 | - | SessionID string `json:"session_id,omitempty"` | 256 | + | OriginExtension string `json:"origin_extension,omitempty"` |
| 235 | - | AgentRunID string `json:"agent_run_id,omitempty"` | 257 | + | SessionID string `json:"session_id,omitempty"` |
| 236 | - | CWD string `json:"cwd,omitempty"` | 258 | + | AgentRunID string `json:"agent_run_id,omitempty"` |
| 237 | - | Provider string `json:"provider,omitempty"` | 259 | + | CWD string `json:"cwd,omitempty"` |
| 238 | - | Model string `json:"model,omitempty"` | 260 | + | Provider string `json:"provider,omitempty"` |
| 239 | - | Type string `json:"type"` | 261 | + | Model string `json:"model,omitempty"` |
| 240 | - | ID string `json:"id"` | 262 | + | Type string `json:"type"` |
| 241 | - | Event string `json:"event"` | 263 | + | ID string `json:"id"` |
| 242 | - | ToolID string `json:"tool_id,omitempty"` | 264 | + | Event string `json:"event"` |
| 243 | - | ToolName string `json:"tool_name,omitempty"` | 265 | + | ToolID string `json:"tool_id,omitempty"` |
| 244 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | 266 | + | ToolName string `json:"tool_name,omitempty"` |
| 245 | - | Step int `json:"step,omitempty"` | 267 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` |
| 246 | - | Text string `json:"text,omitempty"` | 268 | + | Step int `json:"step,omitempty"` |
| 269 | + | Text string `json:"text,omitempty"` | |||
| 247 | } | 270 | } | ||
| 248 | 271 | ||||
| 249 | type PanelKeyFromHost struct { | 272 | type PanelKeyFromHost struct { | ||
M packages/agent/lifecycle.go +2 -1
| @@ -76,7 +76,8 @@ func emitPermissionDecision(ctx context.Context, mgr *extensions.Manager, call p | |||
| 76 | 76 | if ctx.Err() != nil { | |
| 77 | 77 | decision = "cancelled" | |
| 78 | 78 | } | |
| 79 | - | mgr.EmitEvent(extproto.EventFromHost{Event: "permission_decision", ToolID: call.ID, ToolName: call.Name, Decision: decision, Source: d.Source, Reason: d.Reason, Stage: "pre_execution"}) | |
| 79 | + | origin, _ := ctx.Value(extensionOriginKey{}).(string) | |
| 80 | + | mgr.EmitEvent(extproto.EventFromHost{Event: "permission_decision", OriginExtension: origin, ToolID: call.ID, ToolName: call.Name, Decision: decision, Source: d.Source, Reason: d.Reason, Stage: "pre_execution"}) | |
| 80 | 81 | } | |
| 81 | 82 | ||
| 82 | 83 | func emitSwarmLifecycle(mgr *extensions.Manager, e swarm.LifecycleEvent) { | |
| @@ -76,7 +76,8 @@ func emitPermissionDecision(ctx context.Context, mgr *extensions.Manager, call p | |||||
| 76 | if ctx.Err() != nil { | 76 | if ctx.Err() != nil { | ||
| 77 | decision = "cancelled" | 77 | decision = "cancelled" | ||
| 78 | } | 78 | } | ||
| 79 | - | mgr.EmitEvent(extproto.EventFromHost{Event: "permission_decision", ToolID: call.ID, ToolName: call.Name, Decision: decision, Source: d.Source, Reason: d.Reason, Stage: "pre_execution"}) | 79 | + | origin, _ := ctx.Value(extensionOriginKey{}).(string) |
| 80 | + | mgr.EmitEvent(extproto.EventFromHost{Event: "permission_decision", OriginExtension: origin, ToolID: call.ID, ToolName: call.Name, Decision: decision, Source: d.Source, Reason: d.Reason, Stage: "pre_execution"}) | |||
| 80 | } | 81 | } | ||
| 81 | 82 | ||||
| 82 | func emitSwarmLifecycle(mgr *extensions.Manager, e swarm.LifecycleEvent) { | 83 | func emitSwarmLifecycle(mgr *extensions.Manager, e swarm.LifecycleEvent) { | ||
M packages/core/agent.go +11 -0
| @@ -728,6 +728,17 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St | |||
| 728 | 728 | return stop, finalMsg, finalErr | |
| 729 | 729 | } | |
| 730 | 730 | ||
| 731 | + | // CallTool executes a host-initiated tool without adding a model tool-use | |
| 732 | + | // message to the transcript. The caller supplies a unique ID and event sink. | |
| 733 | + | // It shares the model tool path's guards, confirmation hook and error handling. | |
| 734 | + | func (a *Agent) CallTool(ctx context.Context, id, name string, args json.RawMessage, sink func(AgentEvent)) ToolResult { | |
| 735 | + | if sink == nil { | |
| 736 | + | sink = func(AgentEvent) {} | |
| 737 | + | } | |
| 738 | + | sink(EvToolCall{ID: id, Name: name, Args: args}) | |
| 739 | + | return a.runOneTool(ctx, provider.ToolCallBlock{ID: id, Name: name, Arguments: args}, sink) | |
| 740 | + | } | |
| 741 | + | ||
| 731 | 742 | // executeTools runs every tool call in the assistant message and returns | |
| 732 | 743 | // a single tool-role message carrying all results. | |
| 733 | 744 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | |
| @@ -728,6 +728,17 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St | |||||
| 728 | return stop, finalMsg, finalErr | 728 | return stop, finalMsg, finalErr | ||
| 729 | } | 729 | } | ||
| 730 | 730 | ||||
| 731 | + | // CallTool executes a host-initiated tool without adding a model tool-use | |||
| 732 | + | // message to the transcript. The caller supplies a unique ID and event sink. | |||
| 733 | + | // It shares the model tool path's guards, confirmation hook and error handling. | |||
| 734 | + | func (a *Agent) CallTool(ctx context.Context, id, name string, args json.RawMessage, sink func(AgentEvent)) ToolResult { | |||
| 735 | + | if sink == nil { | |||
| 736 | + | sink = func(AgentEvent) {} | |||
| 737 | + | } | |||
| 738 | + | sink(EvToolCall{ID: id, Name: name, Args: args}) | |||
| 739 | + | return a.runOneTool(ctx, provider.ToolCallBlock{ID: id, Name: name, Arguments: args}, sink) | |||
| 740 | + | } | |||
| 741 | + | ||||
| 731 | // executeTools runs every tool call in the assistant message and returns | 742 | // executeTools runs every tool call in the assistant message and returns | ||
| 732 | // a single tool-role message carrying all results. | 743 | // a single tool-role message carrying all results. | ||
| 733 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | 744 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | ||
M packages/core/intercept_test.go +24 -0
| @@ -25,6 +25,30 @@ func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func( | |||
| 25 | 25 | }, nil | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | + | func TestCallToolUsesPolicyWithoutChangingTranscript(t *testing.T) { | |
| 29 | + | rec := &recordingTool{} | |
| 30 | + | a := NewAgent(nil, "test", "", Registry{"echo": rec}) | |
| 31 | + | var events []AgentEvent | |
| 32 | + | a.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 33 | + | return false, "denied", nil | |
| 34 | + | } | |
| 35 | + | result := a.CallTool(context.Background(), "extension-call", "echo", json.RawMessage(`{}`), func(ev AgentEvent) { | |
| 36 | + | events = append(events, ev) | |
| 37 | + | }) | |
| 38 | + | if !result.IsError || rec.lastArgs != nil || len(a.Messages()) != 0 { | |
| 39 | + | t.Fatalf("policy or transcript bypassed: result=%+v, args=%s, messages=%v", result, rec.lastArgs, a.Messages()) | |
| 40 | + | } | |
| 41 | + | if len(events) != 2 { | |
| 42 | + | t.Fatalf("expected call/result events, got %v", events) | |
| 43 | + | } | |
| 44 | + | if _, ok := events[0].(EvToolCall); !ok { | |
| 45 | + | t.Fatalf("first event = %T", events[0]) | |
| 46 | + | } | |
| 47 | + | if _, ok := events[1].(EvToolResult); !ok { | |
| 48 | + | t.Fatalf("second event = %T", events[1]) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 28 | 52 | // TestBeforeToolExecuteModifiesArgs verifies that a non-nil | |
| 29 | 53 | // modifiedArgs returned from BeforeToolExecute is what the tool | |
| 30 | 54 | // actually sees. | |
| @@ -25,6 +25,30 @@ func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func( | |||||
| 25 | }, nil | 25 | }, nil | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | + | func TestCallToolUsesPolicyWithoutChangingTranscript(t *testing.T) { | |||
| 29 | + | rec := &recordingTool{} | |||
| 30 | + | a := NewAgent(nil, "test", "", Registry{"echo": rec}) | |||
| 31 | + | var events []AgentEvent | |||
| 32 | + | a.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 33 | + | return false, "denied", nil | |||
| 34 | + | } | |||
| 35 | + | result := a.CallTool(context.Background(), "extension-call", "echo", json.RawMessage(`{}`), func(ev AgentEvent) { | |||
| 36 | + | events = append(events, ev) | |||
| 37 | + | }) | |||
| 38 | + | if !result.IsError || rec.lastArgs != nil || len(a.Messages()) != 0 { | |||
| 39 | + | t.Fatalf("policy or transcript bypassed: result=%+v, args=%s, messages=%v", result, rec.lastArgs, a.Messages()) | |||
| 40 | + | } | |||
| 41 | + | if len(events) != 2 { | |||
| 42 | + | t.Fatalf("expected call/result events, got %v", events) | |||
| 43 | + | } | |||
| 44 | + | if _, ok := events[0].(EvToolCall); !ok { | |||
| 45 | + | t.Fatalf("first event = %T", events[0]) | |||
| 46 | + | } | |||
| 47 | + | if _, ok := events[1].(EvToolResult); !ok { | |||
| 48 | + | t.Fatalf("second event = %T", events[1]) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 28 | // TestBeforeToolExecuteModifiesArgs verifies that a non-nil | 52 | // TestBeforeToolExecuteModifiesArgs verifies that a non-nil | ||
| 29 | // modifiedArgs returned from BeforeToolExecute is what the tool | 53 | // modifiedArgs returned from BeforeToolExecute is what the tool | ||
| 30 | // actually sees. | 54 | // actually sees. | ||