detached beceda1

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`.
180180 "capabilities":["commands","tools","panels"]}
181181 ```
182182
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+
183187 Include `"tool_cancel"` in `capabilities` to opt in to host cancellation
184188 notifications. The Go SDK advertises this automatically. The host only sends
185189 `tool_cancel` to extensions advertising that exact capability. Missing or
186190 unknown capabilities do not enable cancellation notifications.
187191
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+
188239 #### `register_command`
189240
190241 ```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 cancellation187 Include `"tool_cancel"` in `capabilities` to opt in to host cancellation
184 notifications. The Go SDK advertises this automatically. The host only sends188 notifications. The Go SDK advertises this automatically. The host only sends
185 `tool_cancel` to extensions advertising that exact capability. Missing or189 `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 ```json241 ```json
M packages/agent/cli.go +6 -2
@@ -352,6 +352,7 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr
352352 if ag == nil || extMgr == nil {
353353 return
354354 }
355+ extMgr.SetToolCaller(extensionToolCaller(extMgr, func() *core.Agent { return ag }))
355356 wireBeforeAgentStart(ag, extMgr, "")
356357 ag.BeforeToolExecuteContext = func(ctx context.Context, call provider.ToolCallBlock) (bool, string, json.RawMessage) {
357358 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
@@ -1536,14 +1537,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
15361537 },
15371538 })
15381539
1540+ extMgr.SetToolCaller(extensionToolCaller(extMgr, func() *core.Agent { return liveInteractiveAgent(iv, ag) }))
1541+
15391542 // Bind the interactive TUI as the Confirmer. We deferred this
15401543 // until now because the gate is constructed before the TUI
15411544 // (the BeforeToolExecute closure captures it). SetConfirmer
15421545 // is mutex-guarded on the gate so this is safe.
15431546 if confirmGate != nil {
15441547 confirmGate.SetConfirmer(&confirmationEventConfirmer{
1545- inner: iv,
1546- emit: extMgr.EmitEvent,
1548+ inner: iv,
1549+ emit: extMgr.EmitEvent,
1550+ origin: extMgr.ToolCallOrigin,
15471551 })
15481552 }
15491553
@@ -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 return353 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 this1542 // Bind the interactive TUI as the Confirmer. We deferred this
1540 // until now because the gate is constructed before the TUI1543 // until now because the gate is constructed before the TUI
1541 // (the BeforeToolExecute closure captures it). SetConfirmer1544 // (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 (
99 // extension lifecycle event. ConfirmGate only invokes this decorator when a
1010 // call actually needs user input, after remembered approvals have been checked.
1111 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
1415 }
1516
1617 func (c *confirmationEventConfirmer) Confirm(toolName, preview string) core.ConfirmDecision {
@@ -31,10 +32,15 @@ func (c *confirmationEventConfirmer) emitRequest(call core.ToolCallConfirmation)
3132 if c.emit == nil {
3233 return
3334 }
35+ origin := ""
36+ if c.origin != nil {
37+ origin = c.origin(call.ID)
38+ }
3439 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,
3945 })
4046 }
@@ -9,8 +9,9 @@ import (
9 // extension lifecycle event. ConfirmGate only invokes this decorator when a9 // 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.Confirmer12+ 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 return33 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
2121 return core.ConfirmDecision{Allow: true, RememberTool: true}
2222 }
2323
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+
2442 func TestConfirmationEventConfirmerEmitsOnlyWhenPrompting(t *testing.T) {
2543 inner := &rememberingDetailedConfirmer{}
2644 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.EventFromHost44 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 (
3939 "fmt"
4040 "io"
4141 "os"
42+ "slices"
4243 "sync"
44+ "sync/atomic"
4345
4446 "github.com/patriceckhart/zot/packages/agent/extproto"
4547 )
@@ -307,6 +309,10 @@ type Extension struct {
307309 descriptions []descTuple // ordered so register frames arrive in registration order
308310 tools map[string]InteractiveToolHandler
309311 toolCancels map[string]context.CancelFunc
312+ pendingCalls map[string]chan extproto.ToolResultFromHost
313+ callSeq atomic.Uint64
314+ callToolReady atomic.Bool
315+ done chan struct{}
310316 toolDefs []toolDef // ordered so register frames arrive in registration order
311317 eventHandlers map[string]EventHandler
312318 eventNames []string // declared subscription order
@@ -364,10 +370,12 @@ func New(name, version string) *Extension {
364370 commands: map[string]CommandHandler{},
365371 tools: map[string]InteractiveToolHandler{},
366372 toolCancels: map[string]context.CancelFunc{},
373+ pendingCalls: map[string]chan extproto.ToolResultFromHost{},
374+ done: make(chan struct{}),
367375 eventHandlers: map[string]EventHandler{},
368376 panelKeys: map[string]func(key, text string){},
369377 panelCloses: map[string]func(){},
370- caps: []string{"commands", "tools", "events", "panels", "tool_cancel"},
378+ caps: []string{"commands", "tools", "events", "panels", "tool_cancel", "call_tool"},
371379 }
372380 }
373381
@@ -556,6 +564,7 @@ func (e *Extension) Notify(level, message string) {
556564 // Run starts the protocol loop. Blocks until stdin closes (zot has
557565 // shut us down). Returns the first fatal error, or nil on clean exit.
558566 func (e *Extension) Run() error {
567+ defer close(e.done)
559568 defer func() {
560569 e.mu.Lock()
561570 defer e.mu.Unlock()
@@ -588,6 +597,7 @@ func (e *Extension) Run() error {
588597 if ack.Type != "hello_ack" {
589598 return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type)
590599 }
600+ e.callToolReady.Store(slices.Contains(ack.Capabilities, "call_tool"))
591601 e.host = HostInfo{
592602 ProtocolVersion: ack.ProtocolVersion,
593603 ZotVersion: ack.ZotVersion,
@@ -704,7 +714,7 @@ func (e *Extension) Run() error {
704714 e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))
705715 continue
706716 }
707- ctx, cancel := context.WithCancel(context.Background())
717+ ctx, cancel := context.WithCancel(context.WithValue(context.Background(), parentCallKey{}, tc.ID))
708718 e.mu.Lock()
709719 e.toolCancels[tc.ID] = cancel
710720 e.mu.Unlock()
@@ -725,6 +735,19 @@ func (e *Extension) Run() error {
725735 e.respondTool(id, res)
726736 }
727737 }(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+ }
728751 case "tool_cancel":
729752 var tc extproto.ToolCancelFromHost
730753 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 order309 descriptions []descTuple // ordered so register frames arrive in registration order
308 tools map[string]InteractiveToolHandler310 tools map[string]InteractiveToolHandler
309 toolCancels map[string]context.CancelFunc311 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 order316 toolDefs []toolDef // ordered so register frames arrive in registration order
311 eventHandlers map[string]EventHandler317 eventHandlers map[string]EventHandler
312 eventNames []string // declared subscription order318 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 has564 // 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 continue715 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] = cancel719 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.ToolCancelFromHost752 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
104104 current := args
105105 for _, ext := range subs {
106106 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,
111112 })
112113 if r.Block {
113114 r.ModifiedArgs = current
@@ -104,10 +104,11 @@ func (m *Manager) InterceptToolCall(ctx context.Context, toolID, toolName string
104 current := args104 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 = current114 r.ModifiedArgs = current
M packages/agent/extensions/lifecycle_test.go +3 -2
@@ -106,7 +106,8 @@ func TestNotificationPrecedesInterceptOnWire(t *testing.T) {
106106 m := New(t.TempDir(), "/work", "test", "", "", nil)
107107 frames, ext := lifecyclePeer(t, m, "tool_call")
108108 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"})
110111 result := make(chan InterceptResult, 1)
111112 go func() { result <- m.InterceptToolCall(context.Background(), "call", "bash", json.RawMessage(`{}`)) }()
112113 var event extproto.EventFromHost
@@ -116,7 +117,7 @@ func TestNotificationPrecedesInterceptOnWire(t *testing.T) {
116117 }
117118 var intercept extproto.EventInterceptFromHost
118119 json.Unmarshal(nextLifecycleFrame(t, frames), &intercept)
119- if intercept.Type != "event_intercept" {
120+ if intercept.Type != "event_intercept" || intercept.OriginExtension != "caller" {
120121 t.Fatalf("second frame: %+v", intercept)
121122 }
122123 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.EventFromHost113 var event extproto.EventFromHost
@@ -116,7 +117,7 @@ func TestNotificationPrecedesInterceptOnWire(t *testing.T) {
116 }117 }
117 var intercept extproto.EventInterceptFromHost118 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 {
7070 helloAck bool
7171 // Set during the handshake, before the extension is published.
7272 toolCancel bool
73+ callTool bool
7374 commands []extproto.RegisterCommandFromExt
7475 tools []extproto.RegisterToolFromExt
7576
@@ -86,6 +87,8 @@ type Extension struct {
8687 pending map[string]chan extproto.CommandResponseFromExt
8788 pendingTool map[string]chan extproto.ToolResultFromExt
8889 pendingIntercept map[string]chan extproto.EventInterceptResponseFromExt
90+ outboundCalls map[string]outboundCall
91+ inboundCalls map[string]context.CancelFunc
8992
9093 // lastFrameTime is updated by the read loop on every frame it
9194 // processes. Used by the auto-ready idle watchdog so legacy
@@ -179,6 +182,9 @@ type Manager struct {
179182 // by the host so it can rebuild the agent's tool registry with
180183 // the freshly-registered extension tools.
181184 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
182188
183189 // lifecycleMu serializes session boundaries and notification enqueue order.
184190 lifecycleMu sync.Mutex
@@ -441,6 +447,8 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
441447 readyCh: make(chan struct{}),
442448 pending: map[string]chan extproto.CommandResponseFromExt{},
443449 pendingTool: map[string]chan extproto.ToolResultFromExt{},
450+ outboundCalls: map[string]outboundCall{},
451+ inboundCalls: map[string]context.CancelFunc{},
444452 pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{},
445453 eventSubs: map[string]struct{}{},
446454 interceptSubs: map[string]struct{}{},
@@ -786,10 +794,12 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
786794 // Trust the manifest's name; ignore mismatch from the hello.
787795 ext.helloAck = true
788796 ext.toolCancel = slices.Contains(hello.Capabilities, "tool_cancel")
797+ ext.callTool = slices.Contains(hello.Capabilities, "call_tool")
789798
790799 ack, _ := extproto.Encode(extproto.HelloAckFromHost{
791800 Type: "hello_ack",
792801 ProtocolVersion: extproto.ProtocolVersion,
802+ Capabilities: []string{"call_tool"},
793803 ZotVersion: m.zotVersion,
794804 Provider: m.provider,
795805 Model: m.model,
@@ -882,6 +892,11 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) {
882892 // Returns when stdout closes.
883893 func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
884894 defer func() {
895+ ext.mu.Lock()
896+ for _, cancel := range ext.inboundCalls {
897+ cancel()
898+ }
899+ ext.mu.Unlock()
885900 if ext.stdin != nil {
886901 _ = ext.stdin.Close()
887902 }
@@ -994,6 +1009,34 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
9941009 }
9951010 }
9961011 }
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+ }
9971040 case "tool_result":
9981041 var tr extproto.ToolResultFromExt
9991042 if err := json.Unmarshal(line, &tr); err == nil {
@@ -1257,12 +1300,23 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
12571300
12581301 id := newCorrelationID()
12591302 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)
12601308 ext.mu.Lock()
12611309 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}
12621314 ext.mu.Unlock()
12631315 defer func() {
1316+ callCancel()
12641317 ext.mu.Lock()
12651318 delete(ext.pendingTool, id)
1319+ delete(ext.outboundCalls, id)
12661320 ext.mu.Unlock()
12671321 }()
12681322
@@ -70,6 +70,7 @@ type Extension struct {
70 helloAck bool70 helloAck bool
71 // Set during the handshake, before the extension is published.71 // Set during the handshake, before the extension is published.
72 toolCancel bool72 toolCancel bool
73+ callTool bool
73 commands []extproto.RegisterCommandFromExt74 commands []extproto.RegisterCommandFromExt
74 tools []extproto.RegisterToolFromExt75 tools []extproto.RegisterToolFromExt
75 76
@@ -86,6 +87,8 @@ type Extension struct {
86 pending map[string]chan extproto.CommandResponseFromExt87 pending map[string]chan extproto.CommandResponseFromExt
87 pendingTool map[string]chan extproto.ToolResultFromExt88 pendingTool map[string]chan extproto.ToolResultFromExt
88 pendingIntercept map[string]chan extproto.EventInterceptResponseFromExt89 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 it93 // lastFrameTime is updated by the read loop on every frame it
91 // processes. Used by the auto-ready idle watchdog so legacy94 // 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 with182 // 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.Mutex190 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 = true795 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.ToolResultFromExt1041 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] = ch1309 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 {
160160 }
161161
162162 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"`
171172 }
172173
173174 type CommandInvokedFromHost struct {
@@ -190,6 +191,26 @@ type ToolCallFromHost struct {
190191 Args json.RawMessage `json:"args"`
191192 }
192193
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+
193214 // EventResult is a tool outcome without extension-private Details or activation data.
194215 type EventResult struct {
195216 Content []ContentBlock `json:"content"`
@@ -198,52 +219,54 @@ type EventResult struct {
198219 }
199220
200221 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"`
230252 }
231253
232254 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"`
247270 }
248271
249272 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
7676 if ctx.Err() != nil {
7777 decision = "cancelled"
7878 }
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"})
8081 }
8182
8283 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
728728 return stop, finalMsg, finalErr
729729 }
730730
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+
731742 // executeTools runs every tool call in the assistant message and returns
732743 // a single tool-role message carrying all results.
733744 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, finalErr728 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 returns742 // 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(
2525 }, nil
2626 }
2727
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+
2852 // TestBeforeToolExecuteModifiesArgs verifies that a non-nil
2953 // modifiedArgs returned from BeforeToolExecute is what the tool
3054 // actually sees.
@@ -25,6 +25,30 @@ func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func(
25 }, nil25 }, 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-nil52 // TestBeforeToolExecuteModifiesArgs verifies that a non-nil
29 // modifiedArgs returned from BeforeToolExecute is what the tool53 // modifiedArgs returned from BeforeToolExecute is what the tool
30 // actually sees.54 // actually sees.

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close