detached 74709a0

feat(extensions): phase 2 — extension-defined tools

Extensions can now register tools the LLM calls directly. The model
sees them in its tool list alongside the built-ins (read, write,
edit, bash, skill); when it invokes one, zot routes the tool_call
to the owning extension subprocess and feeds the tool_result back.

Wire format additions (internal/extproto):
  ext -> host:
    register_tool {name, description, schema}
    ready                                       # all initial regs flushed
    tool_result {id, content[], is_error}       # reply to a tool_call
  host -> ext:
    tool_call {id, name, args}                  # raw json args from the model

Manager (internal/agent/extensions):
  - tracks per-extension RegisterToolFromExt frames
  - validates schemas parse as JSON before registering (bad schema
    skipped + logged, doesn't crash zot)
  - toolIndex map for O(1) lookup
  - WaitForReady(grace): blocks per extension on its readyCh until
    a ready frame arrives or the grace expires; called once after
    Discover so the agent's tool registry is built against the
    final set
  - Tools() / HasTool() / InvokeTool() public surface
  - readLoop closes readyCh on stdout EOF so a wedged extension
    doesn't permanently block WaitForReady

extensionTool (internal/agent/extensions/tool.go):
  implements core.Tool. Execute() round-trips through
  Manager.InvokeTool with a 60s default timeout, decodes
  base64 image blocks, surfaces extension+tool name in
  ToolResult.Details for the renderer.

internal/agent/build.go:
  - new ExtensionToolSource interface (declared here to avoid the
    build->extensions->core import cycle) + ExtensionToolInfo
    mirror of extensions.ToolInfo
  - Resolved.MergeExtensionTools(): folds extension tools into
    ToolRegistry, re-renders the system prompt's tool summary
    with both built-in and extension tools listed
  - Resolved gains private bookkeeping fields so the rebuild
    works without re-running Resolve

internal/agent/cli.go:
  extension manager built BEFORE the agent in interactive mode
  so MergeExtensionTools can fire before NewAgent. Same in
  buildAgent + buildAgentFor closures so login / model-switch
  rebuilds also include extension tools. extToolAdapter bridges
  *extensions.Manager to ExtensionToolSource.

internal/agent/rpc.go:
  extension lifecycle now also runs in `zot rpc` mode. Notify and
  Display from extensions surface as `ext_notify` / `ext_display`
  events on the rpc stream so any consumer can react.

pkg/zotext (Go SDK):
  - ToolHandler, ToolResult, ToolContent types
  - Tool(name, desc, schema, fn) registration method
  - TextResult / TextErrorResult / Image / ImageBytes constructors
  - Run() now also flushes register_tool frames + a final ready
    sentinel after the last registration

examples/extensions/weather: working Go example registering one
tool. Deterministic fake weather (sha1 of city -> temp + cond) so
the demo is repeatable. Plus README explaining how to install.

Tests:
  internal/agent/extensions/tool_test.go: spawns a mock /bin/sh
  extension that registers a tool, sends ready, and echoes tool
  calls. Verifies registration timing, lookup via HasTool/Tools,
  invoke roundtrip via InvokeTool.

End-to-end verified against live anthropic backend:
  prompt: "What is the weather in Berlin?"
  -> [tool_call] weather({"city":"Berlin"})
  -> [tool_result] Berlin: 16°C, fog (deterministic fake)
  -> reply: "Berlin is 16°C."

Docs/extensions.md updated with phase 2 wire format, the new SDK
tool API, and the weather example.
commit
74709a0bd9285b893f8c99852d08e6695cedf4c0
author
patriceckhart · · 2026-04-19 12:46 UTC
parent
222a62c
signature
unsigned
refs
tag: v0.0.23
13 files · +973 -59
M docs/extensions.md +91 -13
@@ -6,9 +6,13 @@ its stdin/stdout. Extensions can be written in **any language** that
66 can read and write JSON lines from stdio — Go, TypeScript, Python,
77 Rust, shell with `jq`, anything.
88
9-This is **phase 1**: slash commands and chat notifications. Future
10-phases will add tools the model can call, lifecycle event subscriptions,
11-and tool-call interception.
9+Two phases shipped so far:
10+
11+- **Phase 1**: slash commands + chat notifications.
12+- **Phase 2**: tools the LLM can call.
13+
14+Phase 3 (lifecycle event subscriptions + tool-call interception for
15+guardrail extensions) is on the roadmap below.
1216
1317 ## Quick start
1418
@@ -122,8 +126,8 @@ restarted.
122126 ## Wire format
123127
124128 All frames are one JSON object per line. Top-level `type` is the
125-discriminator. Optional `id` correlates command invocations with
126-their responses.
129+discriminator. Optional `id` correlates request frames with their
130+responses.
127131
128132 ### Extension → host
129133
@@ -131,7 +135,7 @@ their responses.
131135
132136 ```json
133137 {"type":"hello","name":"weather","version":"1.0.0",
134- "capabilities":["commands"]}
138+ "capabilities":["commands","tools"]}
135139 ```
136140
137141 #### `register_command`
@@ -141,6 +145,47 @@ their responses.
141145 "description":"current weather for a city"}
142146 ```
143147
148+#### `register_tool`
149+
150+Registers a tool the LLM can call. `schema` is a JSON Schema object
151+describing the tool's args (the same shape Anthropic and OpenAI accept).
152+
153+```json
154+{"type":"register_tool","name":"weather",
155+ "description":"Get the current weather for a city.",
156+ "schema":{
157+ "type":"object",
158+ "properties":{"city":{"type":"string"}},
159+ "required":["city"]
160+ }}
161+```
162+
163+Tool names live in the same namespace as built-in tools (`read`,
164+`write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by
165+the built-in.
166+
167+#### `ready`
168+
169+Sentinel telling zot "all initial registrations are flushed". Send it
170+right after your last `register_*` frame so the host can build the
171+agent's tool registry without racing the registration window.
172+
173+```json
174+{"type":"ready"}
175+```
176+
177+#### `tool_result`
178+
179+Reply to a `tool_call` from the host. `content[]` is a list of
180+message blocks; each block is `{"type":"text","text":"..."}` or
181+`{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set
182+`is_error: true` to mark the call as failed.
183+
184+```json
185+{"type":"tool_result","id":"...",
186+ "content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
187+```
188+
144189 #### `command_response` (reply to `command_invoked`)
145190
146191 ```json
@@ -200,6 +245,21 @@ on macOS, only register a model-specific shortcut for opus, etc.).
200245
201246 `args` is everything the user typed after the command name, trimmed.
202247
248+#### `tool_call`
249+
250+Sent when the LLM invokes a tool the extension registered. `args` is
251+the parsed JSON object the model produced; the extension is
252+responsible for validating/coercing it.
253+
254+```json
255+{"type":"tool_call","id":"...","name":"weather",
256+ "args":{"city":"Berlin"}}
257+```
258+
259+Reply with `tool_result` within the host's tool timeout (default 60s).
260+Missing the timeout surfaces an error to the model and the call is
261+marked as failed.
262+
203263 #### `shutdown`
204264
205265 Sent during graceful zot exit (or `/reload-ext` once that lands).
@@ -230,13 +290,28 @@ for anything bigger the SDKs handle the boilerplate.
230290 ```go
231291 package main
232292
233-import "github.com/patriceckhart/zot/pkg/zotext"
293+import (
294+ "encoding/json"
295+ "github.com/patriceckhart/zot/pkg/zotext"
296+)
234297
235298 func main() {
236299 ext := zotext.New("hello", "1.0.0")
300+
301+ // Slash command
237302 ext.Command("hello", "say hi", func(args string) zotext.Response {
238303 return zotext.Prompt("Greet me in one short sentence.")
239304 })
305+
306+ // LLM-callable tool
307+ ext.Tool("weather", "Current weather for a city.",
308+ json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
309+ func(args json.RawMessage) zotext.ToolResult {
310+ var in struct{ City string `json:"city"` }
311+ json.Unmarshal(args, &in)
312+ return zotext.TextResult(in.City + ": sunny")
313+ })
314+
240315 ext.Run()
241316 }
242317 ```
@@ -244,7 +319,10 @@ func main() {
244319 Build with `go build -o hello .`, drop the binary + an `extension.json`
245320 into `$ZOT_HOME/extensions/hello/`.
246321
247-See `examples/extensions/hello/` for the full working example.
322+See:
323+- `examples/extensions/hello/` — slash commands
324+- `examples/extensions/clock/` — slash commands in plain Node, no SDK
325+- `examples/extensions/weather/` — LLM-callable tool
248326
249327 ### TypeScript / Python
250328
@@ -266,16 +344,16 @@ trust or run zot under your platform's sandboxing tool (`bwrap` /
266344
267345 ## Roadmap
268346
269-Phase 1 (this document):
347+Phase 1 (shipped):
270348 - [x] subprocess lifecycle + hello handshake
271349 - [x] `register_command` + `command_invoked`
272350 - [x] `notify`
273351 - [x] `zot ext` CLI
274352
275-Phase 2:
276-- [ ] `register_tool` + `tool_call` + `tool_result` (extension-defined
277- tools that the LLM can call)
278-- [ ] tool result rendering with extension attribution
353+Phase 2 (shipped):
354+- [x] `register_tool` + `tool_call` + `tool_result`
355+- [x] `ready` sentinel for safe agent-registry build timing
356+- [x] tool result attribution surfaces extension name in details
279357
280358 Phase 3:
281359 - [ ] event subscriptions (`turn_start`, `turn_end`, `tool_call_*`,
@@ -6,9 +6,13 @@ its stdin/stdout. Extensions can be written in **any language** that
6 can read and write JSON lines from stdio — Go, TypeScript, Python,6 can read and write JSON lines from stdio — Go, TypeScript, Python,
7 Rust, shell with `jq`, anything.7 Rust, shell with `jq`, anything.
8 8
9-This is **phase 1**: slash commands and chat notifications. Future9+Two phases shipped so far:
10-phases will add tools the model can call, lifecycle event subscriptions,10+
11-and tool-call interception.11+- **Phase 1**: slash commands + chat notifications.
12+- **Phase 2**: tools the LLM can call.
13+
14+Phase 3 (lifecycle event subscriptions + tool-call interception for
15+guardrail extensions) is on the roadmap below.
12 16
13 ## Quick start17 ## Quick start
14 18
@@ -122,8 +126,8 @@ restarted.
122 ## Wire format126 ## Wire format
123 127
124 All frames are one JSON object per line. Top-level `type` is the128 All frames are one JSON object per line. Top-level `type` is the
125-discriminator. Optional `id` correlates command invocations with129+discriminator. Optional `id` correlates request frames with their
126-their responses.130+responses.
127 131
128 ### Extension → host132 ### Extension → host
129 133
@@ -131,7 +135,7 @@ their responses.
131 135
132 ```json136 ```json
133 {"type":"hello","name":"weather","version":"1.0.0",137 {"type":"hello","name":"weather","version":"1.0.0",
134- "capabilities":["commands"]}138+ "capabilities":["commands","tools"]}
135 ```139 ```
136 140
137 #### `register_command`141 #### `register_command`
@@ -141,6 +145,47 @@ their responses.
141 "description":"current weather for a city"}145 "description":"current weather for a city"}
142 ```146 ```
143 147
148+#### `register_tool`
149+
150+Registers a tool the LLM can call. `schema` is a JSON Schema object
151+describing the tool's args (the same shape Anthropic and OpenAI accept).
152+
153+```json
154+{"type":"register_tool","name":"weather",
155+ "description":"Get the current weather for a city.",
156+ "schema":{
157+ "type":"object",
158+ "properties":{"city":{"type":"string"}},
159+ "required":["city"]
160+ }}
161+```
162+
163+Tool names live in the same namespace as built-in tools (`read`,
164+`write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by
165+the built-in.
166+
167+#### `ready`
168+
169+Sentinel telling zot "all initial registrations are flushed". Send it
170+right after your last `register_*` frame so the host can build the
171+agent's tool registry without racing the registration window.
172+
173+```json
174+{"type":"ready"}
175+```
176+
177+#### `tool_result`
178+
179+Reply to a `tool_call` from the host. `content[]` is a list of
180+message blocks; each block is `{"type":"text","text":"..."}` or
181+`{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set
182+`is_error: true` to mark the call as failed.
183+
184+```json
185+{"type":"tool_result","id":"...",
186+ "content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
187+```
188+
144 #### `command_response` (reply to `command_invoked`)189 #### `command_response` (reply to `command_invoked`)
145 190
146 ```json191 ```json
@@ -200,6 +245,21 @@ on macOS, only register a model-specific shortcut for opus, etc.).
200 245
201 `args` is everything the user typed after the command name, trimmed.246 `args` is everything the user typed after the command name, trimmed.
202 247
248+#### `tool_call`
249+
250+Sent when the LLM invokes a tool the extension registered. `args` is
251+the parsed JSON object the model produced; the extension is
252+responsible for validating/coercing it.
253+
254+```json
255+{"type":"tool_call","id":"...","name":"weather",
256+ "args":{"city":"Berlin"}}
257+```
258+
259+Reply with `tool_result` within the host's tool timeout (default 60s).
260+Missing the timeout surfaces an error to the model and the call is
261+marked as failed.
262+
203 #### `shutdown`263 #### `shutdown`
204 264
205 Sent during graceful zot exit (or `/reload-ext` once that lands).265 Sent during graceful zot exit (or `/reload-ext` once that lands).
@@ -230,13 +290,28 @@ for anything bigger the SDKs handle the boilerplate.
230 ```go290 ```go
231 package main291 package main
232 292
233-import "github.com/patriceckhart/zot/pkg/zotext"293+import (
294+ "encoding/json"
295+ "github.com/patriceckhart/zot/pkg/zotext"
296+)
234 297
235 func main() {298 func main() {
236 ext := zotext.New("hello", "1.0.0")299 ext := zotext.New("hello", "1.0.0")
300+
301+ // Slash command
237 ext.Command("hello", "say hi", func(args string) zotext.Response {302 ext.Command("hello", "say hi", func(args string) zotext.Response {
238 return zotext.Prompt("Greet me in one short sentence.")303 return zotext.Prompt("Greet me in one short sentence.")
239 })304 })
305+
306+ // LLM-callable tool
307+ ext.Tool("weather", "Current weather for a city.",
308+ json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
309+ func(args json.RawMessage) zotext.ToolResult {
310+ var in struct{ City string `json:"city"` }
311+ json.Unmarshal(args, &in)
312+ return zotext.TextResult(in.City + ": sunny")
313+ })
314+
240 ext.Run()315 ext.Run()
241 }316 }
242 ```317 ```
@@ -244,7 +319,10 @@ func main() {
244 Build with `go build -o hello .`, drop the binary + an `extension.json`319 Build with `go build -o hello .`, drop the binary + an `extension.json`
245 into `$ZOT_HOME/extensions/hello/`.320 into `$ZOT_HOME/extensions/hello/`.
246 321
247-See `examples/extensions/hello/` for the full working example.322+See:
323+- `examples/extensions/hello/` — slash commands
324+- `examples/extensions/clock/` — slash commands in plain Node, no SDK
325+- `examples/extensions/weather/` — LLM-callable tool
248 326
249 ### TypeScript / Python327 ### TypeScript / Python
250 328
@@ -266,16 +344,16 @@ trust or run zot under your platform's sandboxing tool (`bwrap` /
266 344
267 ## Roadmap345 ## Roadmap
268 346
269-Phase 1 (this document):347+Phase 1 (shipped):
270 - [x] subprocess lifecycle + hello handshake348 - [x] subprocess lifecycle + hello handshake
271 - [x] `register_command` + `command_invoked`349 - [x] `register_command` + `command_invoked`
272 - [x] `notify`350 - [x] `notify`
273 - [x] `zot ext` CLI351 - [x] `zot ext` CLI
274 352
275-Phase 2:353+Phase 2 (shipped):
276-- [ ] `register_tool` + `tool_call` + `tool_result` (extension-defined354+- [x] `register_tool` + `tool_call` + `tool_result`
277- tools that the LLM can call)355+- [x] `ready` sentinel for safe agent-registry build timing
278-- [ ] tool result rendering with extension attribution356+- [x] tool result attribution surfaces extension name in details
279 357
280 Phase 3:358 Phase 3:
281 - [ ] event subscriptions (`turn_start`, `turn_end`, `tool_call_*`,359 - [ ] event subscriptions (`turn_start`, `turn_end`, `tool_call_*`,
A examples/extensions/weather/README.md +45 -0
@@ -0,0 +1,45 @@
1+# weather — example zot extension (Go, tool-providing)
2+
3+Demonstrates the **tool registration** half of the extension protocol
4+(phase 2). The extension exposes a `weather(city)` tool that the LLM
5+can invoke; the result is fake-but-deterministic so repeated calls
6+for the same city return the same thing.
7+
8+## Build
9+
10+```bash
11+cd examples/extensions/weather
12+go build -o weather .
13+```
14+
15+## Install
16+
17+```bash
18+zot ext install .
19+```
20+
21+Copies the directory (manifest + binary) into
22+`$ZOT_HOME/extensions/weather/`. zot picks it up the next time you
23+launch the TUI and registers the tool with the agent.
24+
25+## Use
26+
27+In zot, ask:
28+
29+- "What's the weather in Berlin?"
30+- "Compare the weather in Tokyo and Reykjavik."
31+
32+The model decides on its own to call the `weather` tool because the
33+description tells it what the tool is for. You don't need to invoke
34+anything by hand.
35+
36+## Add the leading slash to your /help table
37+
38+The tool also shows up in the system prompt's tool list because zot
39+folds extension tools into the agent's registry at startup.
40+
41+## See also
42+
43+- `examples/extensions/hello` — slash-command extension (Go SDK)
44+- `examples/extensions/clock` — slash-command extension (Node, no SDK)
45+- `docs/extensions.md` — full protocol reference
@@ -0,0 +1,45 @@
1+# weather — example zot extension (Go, tool-providing)
2+
3+Demonstrates the **tool registration** half of the extension protocol
4+(phase 2). The extension exposes a `weather(city)` tool that the LLM
5+can invoke; the result is fake-but-deterministic so repeated calls
6+for the same city return the same thing.
7+
8+## Build
9+
10+```bash
11+cd examples/extensions/weather
12+go build -o weather .
13+```
14+
15+## Install
16+
17+```bash
18+zot ext install .
19+```
20+
21+Copies the directory (manifest + binary) into
22+`$ZOT_HOME/extensions/weather/`. zot picks it up the next time you
23+launch the TUI and registers the tool with the agent.
24+
25+## Use
26+
27+In zot, ask:
28+
29+- "What's the weather in Berlin?"
30+- "Compare the weather in Tokyo and Reykjavik."
31+
32+The model decides on its own to call the `weather` tool because the
33+description tells it what the tool is for. You don't need to invoke
34+anything by hand.
35+
36+## Add the leading slash to your /help table
37+
38+The tool also shows up in the system prompt's tool list because zot
39+folds extension tools into the agent's registry at startup.
40+
41+## See also
42+
43+- `examples/extensions/hello` — slash-command extension (Go SDK)
44+- `examples/extensions/clock` — slash-command extension (Node, no SDK)
45+- `docs/extensions.md` — full protocol reference
A examples/extensions/weather/extension.json +8 -0
@@ -0,0 +1,8 @@
1+{
2+ "name": "weather",
3+ "version": "1.0.0",
4+ "exec": "./weather",
5+ "language": "go",
6+ "description": "registers a `weather` tool the LLM can call",
7+ "enabled": true
8+}
@@ -0,0 +1,8 @@
1+{
2+ "name": "weather",
3+ "version": "1.0.0",
4+ "exec": "./weather",
5+ "language": "go",
6+ "description": "registers a `weather` tool the LLM can call",
7+ "enabled": true
8+}
A examples/extensions/weather/main.go +69 -0
@@ -0,0 +1,69 @@
1+// weather — demonstrates a tool-providing extension.
2+//
3+// Registers ONE tool the LLM can call:
4+//
5+// weather(city: string) -> short fake weather string
6+//
7+// Build it:
8+//
9+// cd examples/extensions/weather
10+// go build -o weather .
11+//
12+// Then drop it next to its extension.json under
13+// $ZOT_HOME/extensions/weather/, or run `zot ext install ./weather`
14+// from this directory. After that, asking zot anything like
15+// "what's the weather in Berlin?" makes the model call the tool.
16+package main
17+
18+import (
19+ "crypto/sha1"
20+ "encoding/json"
21+ "fmt"
22+ "strings"
23+
24+ "github.com/patriceckhart/zot/pkg/zotext"
25+)
26+
27+const weatherSchema = `{
28+ "type": "object",
29+ "properties": {
30+ "city": {
31+ "type": "string",
32+ "description": "City name to look up the weather for."
33+ }
34+ },
35+ "required": ["city"]
36+}`
37+
38+func main() {
39+ ext := zotext.New("weather", "1.0.0")
40+
41+ ext.Tool("weather", "Get the current weather for a city.", json.RawMessage(weatherSchema), func(args json.RawMessage) zotext.ToolResult {
42+ var in struct {
43+ City string `json:"city"`
44+ }
45+ if err := json.Unmarshal(args, &in); err != nil {
46+ return zotext.TextErrorResult("invalid args: " + err.Error())
47+ }
48+ city := strings.TrimSpace(in.City)
49+ if city == "" {
50+ return zotext.TextErrorResult("city is required")
51+ }
52+ // Stable fake "weather": derive temperature + condition from a
53+ // hash of the city name so the same city always reports the
54+ // same thing. No API calls, no timezone math, no surprises.
55+ return zotext.TextResult(fakeWeather(city))
56+ })
57+
58+ if err := ext.Run(); err != nil {
59+ ext.Logf("fatal: %v", err)
60+ }
61+}
62+
63+func fakeWeather(city string) string {
64+ sum := sha1.Sum([]byte(strings.ToLower(city)))
65+ temp := (int(sum[0])%30 - 5) // -5..24 °C
66+ conditions := []string{"sunny", "cloudy", "drizzle", "rain", "thunderstorm", "fog", "snow", "windy"}
67+ cond := conditions[int(sum[1])%len(conditions)]
68+ return fmt.Sprintf("%s: %d°C, %s (this is a deterministic fake; the weather extension is a demo)", city, temp, cond)
69+}
@@ -0,0 +1,69 @@
1+// weather — demonstrates a tool-providing extension.
2+//
3+// Registers ONE tool the LLM can call:
4+//
5+// weather(city: string) -> short fake weather string
6+//
7+// Build it:
8+//
9+// cd examples/extensions/weather
10+// go build -o weather .
11+//
12+// Then drop it next to its extension.json under
13+// $ZOT_HOME/extensions/weather/, or run `zot ext install ./weather`
14+// from this directory. After that, asking zot anything like
15+// "what's the weather in Berlin?" makes the model call the tool.
16+package main
17+
18+import (
19+ "crypto/sha1"
20+ "encoding/json"
21+ "fmt"
22+ "strings"
23+
24+ "github.com/patriceckhart/zot/pkg/zotext"
25+)
26+
27+const weatherSchema = `{
28+ "type": "object",
29+ "properties": {
30+ "city": {
31+ "type": "string",
32+ "description": "City name to look up the weather for."
33+ }
34+ },
35+ "required": ["city"]
36+}`
37+
38+func main() {
39+ ext := zotext.New("weather", "1.0.0")
40+
41+ ext.Tool("weather", "Get the current weather for a city.", json.RawMessage(weatherSchema), func(args json.RawMessage) zotext.ToolResult {
42+ var in struct {
43+ City string `json:"city"`
44+ }
45+ if err := json.Unmarshal(args, &in); err != nil {
46+ return zotext.TextErrorResult("invalid args: " + err.Error())
47+ }
48+ city := strings.TrimSpace(in.City)
49+ if city == "" {
50+ return zotext.TextErrorResult("city is required")
51+ }
52+ // Stable fake "weather": derive temperature + condition from a
53+ // hash of the city name so the same city always reports the
54+ // same thing. No API calls, no timezone math, no surprises.
55+ return zotext.TextResult(fakeWeather(city))
56+ })
57+
58+ if err := ext.Run(); err != nil {
59+ ext.Logf("fatal: %v", err)
60+ }
61+}
62+
63+func fakeWeather(city string) string {
64+ sum := sha1.Sum([]byte(strings.ToLower(city)))
65+ temp := (int(sum[0])%30 - 5) // -5..24 °C
66+ conditions := []string{"sunny", "cloudy", "drizzle", "rain", "thunderstorm", "fog", "snow", "windy"}
67+ cond := conditions[int(sum[1])%len(conditions)]
68+ return fmt.Sprintf("%s: %d°C, %s (this is a deterministic fake; the weather extension is a demo)", city, temp, cond)
69+}
M internal/agent/build.go +105 -14
@@ -31,11 +31,89 @@ type Resolved struct {
3131 // agent's tool registry, or nil if no SKILL.md files were
3232 // discovered. Exposed so the tui can list / preview skills.
3333 SkillTool *skills.Tool
34+
35+ // Bookkeeping for MergeExtensionTools. Captured at Resolve time
36+ // so the system prompt can be rebuilt later without re-running
37+ // resolve.
38+ systemAppend []string
39+ systemCustom string
40+ toolDescriptions map[string]string
3441 }
3542
3643 // HasCredential reports whether a credential was resolved.
3744 func (r Resolved) HasCredential() bool { return r.Credential != "" }
3845
46+// MergeExtensionTools folds every tool registered by an extension
47+// into r's ToolRegistry and re-renders the system prompt's tool
48+// summary so the model sees both built-in and extension tools.
49+//
50+// Idempotent: calling twice with the same manager state has no
51+// effect on the second pass (existing names are preserved). Built-in
52+// tools always win on conflict.
53+func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
54+ if mgr == nil {
55+ return
56+ }
57+ infos := mgr.Tools()
58+ if len(infos) == 0 {
59+ return
60+ }
61+ changed := false
62+ for _, info := range infos {
63+ if _, exists := r.ToolRegistry[info.Name]; exists {
64+ continue
65+ }
66+ r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
67+ changed = true
68+ }
69+ if !changed {
70+ return
71+ }
72+ // Re-render the system prompt with the merged tool list. Skill
73+ // addendum is preserved by walking the existing append slice.
74+ append_ := r.systemAppend
75+ r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
76+ CWD: r.CWD,
77+ Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
78+ Custom: r.systemCustom,
79+ Append: append_,
80+ })
81+}
82+
83+// ExtensionToolSource is the slice of the extension manager that
84+// MergeExtensionTools needs. Lives here as an interface so the
85+// build package doesn't import internal/agent/extensions (which
86+// imports core, which imports... avoid the cycle).
87+type ExtensionToolSource interface {
88+ Tools() []ExtensionToolInfo
89+ NewExtensionTool(info ExtensionToolInfo) core.Tool
90+}
91+
92+// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
93+// ExtensionToolSource here without importing the extensions
94+// package. The cli wires a tiny adapter to bridge them.
95+type ExtensionToolInfo struct {
96+ Extension string
97+ Name string
98+ Description string
99+ Schema []byte
100+}
101+
102+// toolSummariesFromRegistry rebuilds the system-prompt tool list
103+// from a (possibly extended) registry, using cached descriptions for
104+// the human-readable summary text.
105+func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
106+ out := make([]ToolSummary, 0, len(reg))
107+ for name, t := range reg {
108+ desc := t.Description()
109+ if d, ok := cached[name]; ok && d != "" {
110+ desc = d
111+ }
112+ out = append(out, ToolSummary{Name: name, Description: desc})
113+ }
114+ return out
115+}
116+
39117 // Resolve merges args, config, and env into a Resolved set.
40118 //
41119 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -136,23 +214,36 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
136214 }
137215
138216 return Resolved{
139- Provider: provName,
140- Model: model,
141- Credential: cred,
142- AuthMethod: method,
143- AccountID: accountID,
144- BaseURL: args.BaseURL,
145- CWD: args.CWD,
146- Reasoning: reasoning,
147- ToolRegistry: reg,
148- ToolSummary: summaries,
149- SystemPrompt: sys,
150- MaxSteps: max,
151- Sandbox: sandbox,
152- SkillTool: skillTool,
217+ Provider: provName,
218+ Model: model,
219+ Credential: cred,
220+ AuthMethod: method,
221+ AccountID: accountID,
222+ BaseURL: args.BaseURL,
223+ CWD: args.CWD,
224+ Reasoning: reasoning,
225+ ToolRegistry: reg,
226+ ToolSummary: summaries,
227+ SystemPrompt: sys,
228+ MaxSteps: max,
229+ Sandbox: sandbox,
230+ SkillTool: skillTool,
231+ systemAppend: append_,
232+ systemCustom: args.SystemPrompt,
233+ toolDescriptions: descMapFromSummaries(summaries),
153234 }, nil
154235 }
155236
237+// descMapFromSummaries indexes the human-readable descriptions for
238+// the renderToolsSection rebuild path.
239+func descMapFromSummaries(summaries []ToolSummary) map[string]string {
240+ out := make(map[string]string, len(summaries))
241+ for _, s := range summaries {
242+ out[s.Name] = s.Description
243+ }
244+ return out
245+}
246+
156247 // NewClient returns a provider.Client for r, choosing the auth mode
157248 // based on r.AuthMethod. Panics if no credential is present; callers
158249 // must check HasCredential() first.
@@ -31,11 +31,89 @@ type Resolved struct {
31 // agent's tool registry, or nil if no SKILL.md files were31 // agent's tool registry, or nil if no SKILL.md files were
32 // discovered. Exposed so the tui can list / preview skills.32 // discovered. Exposed so the tui can list / preview skills.
33 SkillTool *skills.Tool33 SkillTool *skills.Tool
34+
35+ // Bookkeeping for MergeExtensionTools. Captured at Resolve time
36+ // so the system prompt can be rebuilt later without re-running
37+ // resolve.
38+ systemAppend []string
39+ systemCustom string
40+ toolDescriptions map[string]string
34 }41 }
35 42
36 // HasCredential reports whether a credential was resolved.43 // HasCredential reports whether a credential was resolved.
37 func (r Resolved) HasCredential() bool { return r.Credential != "" }44 func (r Resolved) HasCredential() bool { return r.Credential != "" }
38 45
46+// MergeExtensionTools folds every tool registered by an extension
47+// into r's ToolRegistry and re-renders the system prompt's tool
48+// summary so the model sees both built-in and extension tools.
49+//
50+// Idempotent: calling twice with the same manager state has no
51+// effect on the second pass (existing names are preserved). Built-in
52+// tools always win on conflict.
53+func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
54+ if mgr == nil {
55+ return
56+ }
57+ infos := mgr.Tools()
58+ if len(infos) == 0 {
59+ return
60+ }
61+ changed := false
62+ for _, info := range infos {
63+ if _, exists := r.ToolRegistry[info.Name]; exists {
64+ continue
65+ }
66+ r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
67+ changed = true
68+ }
69+ if !changed {
70+ return
71+ }
72+ // Re-render the system prompt with the merged tool list. Skill
73+ // addendum is preserved by walking the existing append slice.
74+ append_ := r.systemAppend
75+ r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
76+ CWD: r.CWD,
77+ Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
78+ Custom: r.systemCustom,
79+ Append: append_,
80+ })
81+}
82+
83+// ExtensionToolSource is the slice of the extension manager that
84+// MergeExtensionTools needs. Lives here as an interface so the
85+// build package doesn't import internal/agent/extensions (which
86+// imports core, which imports... avoid the cycle).
87+type ExtensionToolSource interface {
88+ Tools() []ExtensionToolInfo
89+ NewExtensionTool(info ExtensionToolInfo) core.Tool
90+}
91+
92+// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
93+// ExtensionToolSource here without importing the extensions
94+// package. The cli wires a tiny adapter to bridge them.
95+type ExtensionToolInfo struct {
96+ Extension string
97+ Name string
98+ Description string
99+ Schema []byte
100+}
101+
102+// toolSummariesFromRegistry rebuilds the system-prompt tool list
103+// from a (possibly extended) registry, using cached descriptions for
104+// the human-readable summary text.
105+func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
106+ out := make([]ToolSummary, 0, len(reg))
107+ for name, t := range reg {
108+ desc := t.Description()
109+ if d, ok := cached[name]; ok && d != "" {
110+ desc = d
111+ }
112+ out = append(out, ToolSummary{Name: name, Description: desc})
113+ }
114+ return out
115+}
116+
39 // Resolve merges args, config, and env into a Resolved set.117 // Resolve merges args, config, and env into a Resolved set.
40 //118 //
41 // Unlike the earlier version, Resolve NEVER returns an error for119 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -136,23 +214,36 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
136 }214 }
137 215
138 return Resolved{216 return Resolved{
139- Provider: provName,217+ Provider: provName,
140- Model: model,218+ Model: model,
141- Credential: cred,219+ Credential: cred,
142- AuthMethod: method,220+ AuthMethod: method,
143- AccountID: accountID,221+ AccountID: accountID,
144- BaseURL: args.BaseURL,222+ BaseURL: args.BaseURL,
145- CWD: args.CWD,223+ CWD: args.CWD,
146- Reasoning: reasoning,224+ Reasoning: reasoning,
147- ToolRegistry: reg,225+ ToolRegistry: reg,
148- ToolSummary: summaries,226+ ToolSummary: summaries,
149- SystemPrompt: sys,227+ SystemPrompt: sys,
150- MaxSteps: max,228+ MaxSteps: max,
151- Sandbox: sandbox,229+ Sandbox: sandbox,
152- SkillTool: skillTool,230+ SkillTool: skillTool,
231+ systemAppend: append_,
232+ systemCustom: args.SystemPrompt,
233+ toolDescriptions: descMapFromSummaries(summaries),
153 }, nil234 }, nil
154 }235 }
155 236
237+// descMapFromSummaries indexes the human-readable descriptions for
238+// the renderToolsSection rebuild path.
239+func descMapFromSummaries(summaries []ToolSummary) map[string]string {
240+ out := make(map[string]string, len(summaries))
241+ for _, s := range summaries {
242+ out[s.Name] = s.Description
243+ }
244+ return out
245+}
246+
156 // NewClient returns a provider.Client for r, choosing the auth mode247 // NewClient returns a provider.Client for r, choosing the auth mode
157 // based on r.AuthMethod. Panics if no credential is present; callers248 // based on r.AuthMethod. Panics if no credential is present; callers
158 // must check HasCredential() first.249 // must check HasCredential() first.
M internal/agent/cli.go +54 -13
@@ -54,6 +54,38 @@ func (h *interactiveExtHooks) Display(extName, text string) {
5454 }
5555 }
5656
57+// extToolAdapter bridges *extensions.Manager to the
58+// ExtensionToolSource interface declared in build.go (kept narrow to
59+// avoid a build->extensions import cycle). One adapter instance per
60+// run; used at every Resolve point so re-built agents pick up the
61+// same set of extension tools.
62+type extToolAdapter struct {
63+ mgr *extensions.Manager
64+}
65+
66+func (a *extToolAdapter) Tools() []ExtensionToolInfo {
67+ infos := a.mgr.Tools()
68+ out := make([]ExtensionToolInfo, len(infos))
69+ for i, t := range infos {
70+ out[i] = ExtensionToolInfo{
71+ Extension: t.Extension,
72+ Name: t.Name,
73+ Description: t.Description,
74+ Schema: t.Schema,
75+ }
76+ }
77+ return out
78+}
79+
80+func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
81+ return extensions.NewTool(a.mgr, extensions.ToolInfo{
82+ Extension: info.Extension,
83+ Name: info.Name,
84+ Description: info.Description,
85+ Schema: info.Schema,
86+ })
87+}
88+
5789 // Run is the top-level entrypoint for the zot binary.
5890 func Run(rawArgs []string, version string) error {
5991 // Subcommand router: `zot bot ...` is handled separately so the
@@ -179,6 +211,26 @@ func runInteractive(ctx context.Context, args Args, version string) error {
179211 // rebuilt tool instances must share the same one so the lock sticks.
180212 sharedSandbox := r.Sandbox
181213
214+ // Build the extension manager BEFORE the agent so we can fold
215+ // extension-defined tools into the registry. Forward-declare iv so
216+ // the host hooks adapter can dereference it after construction.
217+ var iv *modes.Interactive
218+ extHooks := &interactiveExtHooks{ivPtr: &iv}
219+ extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
220+ discoveryErrs := extMgr.Discover(ctx)
221+ for _, e := range discoveryErrs {
222+ fmt.Fprintln(os.Stderr, "extension load:", e)
223+ }
224+ // Wait briefly for extensions to flush their initial register_tool
225+ // frames before we build the agent's tool registry. Half a second
226+ // is plenty for any extension that's actually well-behaved; ones
227+ // that don't send a ready frame eat the full grace and proceed.
228+ extMgr.WaitForReady(500 * time.Millisecond)
229+ defer extMgr.Stop(2 * time.Second)
230+
231+ extToolAdapter := &extToolAdapter{mgr: extMgr}
232+ r.MergeExtensionTools(extToolAdapter)
233+
182234 // Capture current args in a closure so BuildAgent can re-resolve
183235 // after a successful login (picks up the newly stored credential).
184236 buildAgent := func() (*core.Agent, string, string, error) {
@@ -187,6 +239,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
187239 return nil, "", "", err
188240 }
189241 resolved.UseSandbox(sharedSandbox)
242+ resolved.MergeExtensionTools(extToolAdapter)
190243 return resolved.NewAgent(), resolved.Provider, resolved.Model, nil
191244 }
192245
@@ -204,6 +257,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
204257 return nil, "", "", err
205258 }
206259 resolved.UseSandbox(sharedSandbox)
260+ resolved.MergeExtensionTools(extToolAdapter)
207261 return resolved.NewAgent(), resolved.Provider, resolved.Model, nil
208262 }
209263
@@ -265,19 +319,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
265319 }
266320 }()
267321
268- // Forward-declare iv so the extension manager hook adapter can
269- // dereference it after construction. The manager is started after
270- // iv is built so iv (which implements extensions.HostHooks) is
271- // available as the hook target.
272- var iv *modes.Interactive
273- extHooks := &interactiveExtHooks{ivPtr: &iv}
274- extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
275- discoveryErrs := extMgr.Discover(ctx)
276- for _, e := range discoveryErrs {
277- fmt.Fprintln(os.Stderr, "extension load:", e)
278- }
279- defer extMgr.Stop(2 * time.Second)
280-
281322 iv = modes.NewInteractive(modes.InteractiveConfig{
282323 Terminal: term,
283324 Theme: tui.Dark,
@@ -54,6 +54,38 @@ func (h *interactiveExtHooks) Display(extName, text string) {
54 }54 }
55 }55 }
56 56
57+// extToolAdapter bridges *extensions.Manager to the
58+// ExtensionToolSource interface declared in build.go (kept narrow to
59+// avoid a build->extensions import cycle). One adapter instance per
60+// run; used at every Resolve point so re-built agents pick up the
61+// same set of extension tools.
62+type extToolAdapter struct {
63+ mgr *extensions.Manager
64+}
65+
66+func (a *extToolAdapter) Tools() []ExtensionToolInfo {
67+ infos := a.mgr.Tools()
68+ out := make([]ExtensionToolInfo, len(infos))
69+ for i, t := range infos {
70+ out[i] = ExtensionToolInfo{
71+ Extension: t.Extension,
72+ Name: t.Name,
73+ Description: t.Description,
74+ Schema: t.Schema,
75+ }
76+ }
77+ return out
78+}
79+
80+func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
81+ return extensions.NewTool(a.mgr, extensions.ToolInfo{
82+ Extension: info.Extension,
83+ Name: info.Name,
84+ Description: info.Description,
85+ Schema: info.Schema,
86+ })
87+}
88+
57 // Run is the top-level entrypoint for the zot binary.89 // Run is the top-level entrypoint for the zot binary.
58 func Run(rawArgs []string, version string) error {90 func Run(rawArgs []string, version string) error {
59 // Subcommand router: `zot bot ...` is handled separately so the91 // Subcommand router: `zot bot ...` is handled separately so the
@@ -179,6 +211,26 @@ func runInteractive(ctx context.Context, args Args, version string) error {
179 // rebuilt tool instances must share the same one so the lock sticks.211 // rebuilt tool instances must share the same one so the lock sticks.
180 sharedSandbox := r.Sandbox212 sharedSandbox := r.Sandbox
181 213
214+ // Build the extension manager BEFORE the agent so we can fold
215+ // extension-defined tools into the registry. Forward-declare iv so
216+ // the host hooks adapter can dereference it after construction.
217+ var iv *modes.Interactive
218+ extHooks := &interactiveExtHooks{ivPtr: &iv}
219+ extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
220+ discoveryErrs := extMgr.Discover(ctx)
221+ for _, e := range discoveryErrs {
222+ fmt.Fprintln(os.Stderr, "extension load:", e)
223+ }
224+ // Wait briefly for extensions to flush their initial register_tool
225+ // frames before we build the agent's tool registry. Half a second
226+ // is plenty for any extension that's actually well-behaved; ones
227+ // that don't send a ready frame eat the full grace and proceed.
228+ extMgr.WaitForReady(500 * time.Millisecond)
229+ defer extMgr.Stop(2 * time.Second)
230+
231+ extToolAdapter := &extToolAdapter{mgr: extMgr}
232+ r.MergeExtensionTools(extToolAdapter)
233+
182 // Capture current args in a closure so BuildAgent can re-resolve234 // Capture current args in a closure so BuildAgent can re-resolve
183 // after a successful login (picks up the newly stored credential).235 // after a successful login (picks up the newly stored credential).
184 buildAgent := func() (*core.Agent, string, string, error) {236 buildAgent := func() (*core.Agent, string, string, error) {
@@ -187,6 +239,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
187 return nil, "", "", err239 return nil, "", "", err
188 }240 }
189 resolved.UseSandbox(sharedSandbox)241 resolved.UseSandbox(sharedSandbox)
242+ resolved.MergeExtensionTools(extToolAdapter)
190 return resolved.NewAgent(), resolved.Provider, resolved.Model, nil243 return resolved.NewAgent(), resolved.Provider, resolved.Model, nil
191 }244 }
192 245
@@ -204,6 +257,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
204 return nil, "", "", err257 return nil, "", "", err
205 }258 }
206 resolved.UseSandbox(sharedSandbox)259 resolved.UseSandbox(sharedSandbox)
260+ resolved.MergeExtensionTools(extToolAdapter)
207 return resolved.NewAgent(), resolved.Provider, resolved.Model, nil261 return resolved.NewAgent(), resolved.Provider, resolved.Model, nil
208 }262 }
209 263
@@ -265,19 +319,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
265 }319 }
266 }()320 }()
267 321
268- // Forward-declare iv so the extension manager hook adapter can
269- // dereference it after construction. The manager is started after
270- // iv is built so iv (which implements extensions.HostHooks) is
271- // available as the hook target.
272- var iv *modes.Interactive
273- extHooks := &interactiveExtHooks{ivPtr: &iv}
274- extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
275- discoveryErrs := extMgr.Discover(ctx)
276- for _, e := range discoveryErrs {
277- fmt.Fprintln(os.Stderr, "extension load:", e)
278- }
279- defer extMgr.Stop(2 * time.Second)
280-
281 iv = modes.NewInteractive(modes.InteractiveConfig{322 iv = modes.NewInteractive(modes.InteractiveConfig{
282 Terminal: term,323 Terminal: term,
283 Theme: tui.Dark,324 Theme: tui.Dark,
A internal/agent/extensions/base64.go +9 -0
@@ -0,0 +1,9 @@
1+package extensions
2+
3+import "encoding/base64"
4+
5+// base64DecodeStd is a one-line indirection around the stdlib so the
6+// tool wrapper can call decodeBase64 without growing its imports.
7+func base64DecodeStd(s string) ([]byte, error) {
8+ return base64.StdEncoding.DecodeString(s)
9+}
@@ -0,0 +1,9 @@
1+package extensions
2+
3+import "encoding/base64"
4+
5+// base64DecodeStd is a one-line indirection around the stdlib so the
6+// tool wrapper can call decodeBase64 without growing its imports.
7+func base64DecodeStd(s string) ([]byte, error) {
8+ return base64.StdEncoding.DecodeString(s)
9+}
M internal/agent/extensions/manager.go +175 -16
@@ -63,11 +63,19 @@ type Extension struct {
6363 logFile *os.File
6464 helloAck bool
6565 commands []extproto.RegisterCommandFromExt
66+ tools []extproto.RegisterToolFromExt
67+
68+ // readyCh is closed when the extension sends a ReadyFromExt
69+ // frame, or when the host gives up waiting (registrationGrace).
70+ readyCh chan struct{}
71+ readyOnce sync.Once
6672
6773 // pending command invocations waiting on a CommandResponseFromExt
6874 // keyed by the id we sent in CommandInvokedFromHost.
69- mu sync.Mutex
70- pending map[string]chan extproto.CommandResponseFromExt
75+ // pendingTool is the same idea for tool calls.
76+ mu sync.Mutex
77+ pending map[string]chan extproto.CommandResponseFromExt
78+ pendingTool map[string]chan extproto.ToolResultFromExt
7179 }
7280
7381 // HostHooks is the small interface the manager calls back into the
@@ -106,6 +114,10 @@ type Manager struct {
106114 // later registrations of the same command are dropped with a
107115 // warning.
108116 commandIndex map[string]*Extension
117+
118+ // toolIndex maps an extension-defined tool name to its owning
119+ // extension. Same first-come-first-served rule as commandIndex.
120+ toolIndex map[string]*Extension
109121 }
110122
111123 // New constructs an empty Manager. Call Discover to populate it from
@@ -120,6 +132,7 @@ func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Man
120132 hooks: hooks,
121133 ext: map[string]*Extension{},
122134 commandIndex: map[string]*Extension{},
135+ toolIndex: map[string]*Extension{},
123136 }
124137 }
125138
@@ -193,9 +206,11 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
193206 }
194207
195208 ext := &Extension{
196- Manifest: mf,
197- Dir: dir,
198- pending: map[string]chan extproto.CommandResponseFromExt{},
209+ Manifest: mf,
210+ Dir: dir,
211+ readyCh: make(chan struct{}),
212+ pending: map[string]chan extproto.CommandResponseFromExt{},
213+ pendingTool: map[string]chan extproto.ToolResultFromExt{},
199214 }
200215 if err := m.spawn(ctx, ext); err != nil {
201216 return err
@@ -203,18 +218,34 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
203218
204219 m.mu.Lock()
205220 m.ext[mf.Name] = ext
206- for _, c := range ext.commands {
207- if _, exists := m.commandIndex[c.Name]; exists {
208- // Conflict: keep the existing registration; drop this one.
209- fmt.Fprintf(ext.logFile, "[zot] /%s already registered, ignoring\n", c.Name)
210- continue
211- }
212- m.commandIndex[c.Name] = ext
213- }
221+ // Note: ext.commands and ext.tools may be empty here — they're
222+ // populated by the read loop as register_* frames arrive after
223+ // hello. Indexing happens in the read loop too. Discover()'s
224+ // caller can WaitForReady() before relying on the registries.
214225 m.mu.Unlock()
215226 return nil
216227 }
217228
229+// WaitForReady blocks until every loaded extension has sent its
230+// ReadyFromExt sentinel, or the per-extension grace period expires.
231+// Call after Discover and before relying on tool registrations.
232+func (m *Manager) WaitForReady(grace time.Duration) {
233+ m.mu.RLock()
234+ exts := make([]*Extension, 0, len(m.ext))
235+ for _, e := range m.ext {
236+ exts = append(exts, e)
237+ }
238+ m.mu.RUnlock()
239+ for _, ext := range exts {
240+ select {
241+ case <-ext.readyCh:
242+ case <-time.After(grace):
243+ fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n")
244+ ext.readyOnce.Do(func() { close(ext.readyCh) })
245+ }
246+ }
247+}
248+
218249 // spawn launches the subprocess, hooks up pipes, logs stderr, and
219250 // runs the synchronous portion of the hello handshake. Asynchronous
220251 // frames are processed in a goroutine started here.
@@ -292,16 +323,22 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
292323 // Returns when stdout closes.
293324 func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
294325 defer func() {
295- // On close, drop every command this extension owned so future
296- // invocations don't dangle. The subprocess is gone; we won't
297- // hear back about its commands anymore.
326+ // On close, drop every command + tool this extension owned so
327+ // future invocations don't dangle. The subprocess is gone; we
328+ // won't hear back about its commands or tool calls anymore.
298329 m.mu.Lock()
299330 for name, owner := range m.commandIndex {
300331 if owner == ext {
301332 delete(m.commandIndex, name)
302333 }
303334 }
335+ for name, owner := range m.toolIndex {
336+ if owner == ext {
337+ delete(m.toolIndex, name)
338+ }
339+ }
304340 m.mu.Unlock()
341+ ext.readyOnce.Do(func() { close(ext.readyCh) })
305342 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))
306343 }()
307344
@@ -323,6 +360,45 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
323360 }
324361 m.mu.Unlock()
325362 }
363+ case "register_tool":
364+ var rt extproto.RegisterToolFromExt
365+ if err := json.Unmarshal(line, &rt); err != nil {
366+ fmt.Fprintf(ext.logFile, "[zot] bad register_tool frame: %v\n", err)
367+ continue
368+ }
369+ // Validate the schema parses as JSON. If not, refuse to
370+ // register — a broken schema confuses the model.
371+ if len(rt.Schema) > 0 {
372+ var tmp any
373+ if err := json.Unmarshal(rt.Schema, &tmp); err != nil {
374+ fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err)
375+ continue
376+ }
377+ }
378+ ext.tools = append(ext.tools, rt)
379+ m.mu.Lock()
380+ if _, exists := m.toolIndex[rt.Name]; !exists {
381+ m.toolIndex[rt.Name] = ext
382+ }
383+ m.mu.Unlock()
384+ case "ready":
385+ ext.readyOnce.Do(func() { close(ext.readyCh) })
386+ case "tool_result":
387+ var tr extproto.ToolResultFromExt
388+ if err := json.Unmarshal(line, &tr); err == nil {
389+ ext.mu.Lock()
390+ ch, ok := ext.pendingTool[tr.ID]
391+ if ok {
392+ delete(ext.pendingTool, tr.ID)
393+ }
394+ ext.mu.Unlock()
395+ if ok {
396+ select {
397+ case ch <- tr:
398+ default:
399+ }
400+ }
401+ }
326402 case "notify":
327403 var n extproto.NotifyFromExt
328404 if err := json.Unmarshal(line, &n); err == nil {
@@ -378,6 +454,89 @@ type CommandInfo struct {
378454 Description string
379455 }
380456
457+// ToolInfo is one extension-registered tool. Used by the agent's
458+// build step to materialise core.Tool wrappers.
459+type ToolInfo struct {
460+ Extension string
461+ Name string
462+ Description string
463+ Schema json.RawMessage
464+}
465+
466+// Tools returns a snapshot of every (extension, tool) pair currently
467+// registered. Used at agent-build time to fold extension tools into
468+// the runtime tool registry.
469+func (m *Manager) Tools() []ToolInfo {
470+ m.mu.RLock()
471+ defer m.mu.RUnlock()
472+ var out []ToolInfo
473+ for _, ext := range m.ext {
474+ for _, t := range ext.tools {
475+ out = append(out, ToolInfo{
476+ Extension: ext.Manifest.Name,
477+ Name: t.Name,
478+ Description: t.Description,
479+ Schema: t.Schema,
480+ })
481+ }
482+ }
483+ return out
484+}
485+
486+// HasTool reports whether name is registered by any extension.
487+func (m *Manager) HasTool(name string) bool {
488+ m.mu.RLock()
489+ defer m.mu.RUnlock()
490+ _, ok := m.toolIndex[name]
491+ return ok
492+}
493+
494+// InvokeTool sends a tool_call to the owning extension and waits for
495+// the matching tool_result. Used by the core.Tool wrapper that the
496+// agent registers per extension-defined tool.
497+func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) {
498+ m.mu.RLock()
499+ ext, ok := m.toolIndex[name]
500+ m.mu.RUnlock()
501+ if !ok {
502+ return extproto.ToolResultFromExt{}, fmt.Errorf("no extension registered for tool %q", name)
503+ }
504+
505+ id := newCorrelationID()
506+ ch := make(chan extproto.ToolResultFromExt, 1)
507+ ext.mu.Lock()
508+ ext.pendingTool[id] = ch
509+ ext.mu.Unlock()
510+
511+ frame, _ := extproto.Encode(extproto.ToolCallFromHost{
512+ Type: "tool_call",
513+ ID: id,
514+ Name: name,
515+ Args: args,
516+ })
517+ if _, err := ext.stdin.Write(frame); err != nil {
518+ ext.mu.Lock()
519+ delete(ext.pendingTool, id)
520+ ext.mu.Unlock()
521+ return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err)
522+ }
523+
524+ select {
525+ case resp := <-ch:
526+ return resp, nil
527+ case <-time.After(timeout):
528+ ext.mu.Lock()
529+ delete(ext.pendingTool, id)
530+ ext.mu.Unlock()
531+ return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
532+ case <-ctx.Done():
533+ ext.mu.Lock()
534+ delete(ext.pendingTool, id)
535+ ext.mu.Unlock()
536+ return extproto.ToolResultFromExt{}, ctx.Err()
537+ }
538+}
539+
381540 // HasCommand reports whether name is registered by any extension.
382541 func (m *Manager) HasCommand(name string) bool {
383542 m.mu.RLock()
@@ -63,11 +63,19 @@ type Extension struct {
63 logFile *os.File63 logFile *os.File
64 helloAck bool64 helloAck bool
65 commands []extproto.RegisterCommandFromExt65 commands []extproto.RegisterCommandFromExt
66+ tools []extproto.RegisterToolFromExt
67+
68+ // readyCh is closed when the extension sends a ReadyFromExt
69+ // frame, or when the host gives up waiting (registrationGrace).
70+ readyCh chan struct{}
71+ readyOnce sync.Once
66 72
67 // pending command invocations waiting on a CommandResponseFromExt73 // pending command invocations waiting on a CommandResponseFromExt
68 // keyed by the id we sent in CommandInvokedFromHost.74 // keyed by the id we sent in CommandInvokedFromHost.
69- mu sync.Mutex75+ // pendingTool is the same idea for tool calls.
70- pending map[string]chan extproto.CommandResponseFromExt76+ mu sync.Mutex
77+ pending map[string]chan extproto.CommandResponseFromExt
78+ pendingTool map[string]chan extproto.ToolResultFromExt
71 }79 }
72 80
73 // HostHooks is the small interface the manager calls back into the81 // HostHooks is the small interface the manager calls back into the
@@ -106,6 +114,10 @@ type Manager struct {
106 // later registrations of the same command are dropped with a114 // later registrations of the same command are dropped with a
107 // warning.115 // warning.
108 commandIndex map[string]*Extension116 commandIndex map[string]*Extension
117+
118+ // toolIndex maps an extension-defined tool name to its owning
119+ // extension. Same first-come-first-served rule as commandIndex.
120+ toolIndex map[string]*Extension
109 }121 }
110 122
111 // New constructs an empty Manager. Call Discover to populate it from123 // New constructs an empty Manager. Call Discover to populate it from
@@ -120,6 +132,7 @@ func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Man
120 hooks: hooks,132 hooks: hooks,
121 ext: map[string]*Extension{},133 ext: map[string]*Extension{},
122 commandIndex: map[string]*Extension{},134 commandIndex: map[string]*Extension{},
135+ toolIndex: map[string]*Extension{},
123 }136 }
124 }137 }
125 138
@@ -193,9 +206,11 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
193 }206 }
194 207
195 ext := &Extension{208 ext := &Extension{
196- Manifest: mf,209+ Manifest: mf,
197- Dir: dir,210+ Dir: dir,
198- pending: map[string]chan extproto.CommandResponseFromExt{},211+ readyCh: make(chan struct{}),
212+ pending: map[string]chan extproto.CommandResponseFromExt{},
213+ pendingTool: map[string]chan extproto.ToolResultFromExt{},
199 }214 }
200 if err := m.spawn(ctx, ext); err != nil {215 if err := m.spawn(ctx, ext); err != nil {
201 return err216 return err
@@ -203,18 +218,34 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
203 218
204 m.mu.Lock()219 m.mu.Lock()
205 m.ext[mf.Name] = ext220 m.ext[mf.Name] = ext
206- for _, c := range ext.commands {221+ // Note: ext.commands and ext.tools may be empty here — they're
207- if _, exists := m.commandIndex[c.Name]; exists {222+ // populated by the read loop as register_* frames arrive after
208- // Conflict: keep the existing registration; drop this one.223+ // hello. Indexing happens in the read loop too. Discover()'s
209- fmt.Fprintf(ext.logFile, "[zot] /%s already registered, ignoring\n", c.Name)224+ // caller can WaitForReady() before relying on the registries.
210- continue
211- }
212- m.commandIndex[c.Name] = ext
213- }
214 m.mu.Unlock()225 m.mu.Unlock()
215 return nil226 return nil
216 }227 }
217 228
229+// WaitForReady blocks until every loaded extension has sent its
230+// ReadyFromExt sentinel, or the per-extension grace period expires.
231+// Call after Discover and before relying on tool registrations.
232+func (m *Manager) WaitForReady(grace time.Duration) {
233+ m.mu.RLock()
234+ exts := make([]*Extension, 0, len(m.ext))
235+ for _, e := range m.ext {
236+ exts = append(exts, e)
237+ }
238+ m.mu.RUnlock()
239+ for _, ext := range exts {
240+ select {
241+ case <-ext.readyCh:
242+ case <-time.After(grace):
243+ fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n")
244+ ext.readyOnce.Do(func() { close(ext.readyCh) })
245+ }
246+ }
247+}
248+
218 // spawn launches the subprocess, hooks up pipes, logs stderr, and249 // spawn launches the subprocess, hooks up pipes, logs stderr, and
219 // runs the synchronous portion of the hello handshake. Asynchronous250 // runs the synchronous portion of the hello handshake. Asynchronous
220 // frames are processed in a goroutine started here.251 // frames are processed in a goroutine started here.
@@ -292,16 +323,22 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
292 // Returns when stdout closes.323 // Returns when stdout closes.
293 func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {324 func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
294 defer func() {325 defer func() {
295- // On close, drop every command this extension owned so future326+ // On close, drop every command + tool this extension owned so
296- // invocations don't dangle. The subprocess is gone; we won't327+ // future invocations don't dangle. The subprocess is gone; we
297- // hear back about its commands anymore.328+ // won't hear back about its commands or tool calls anymore.
298 m.mu.Lock()329 m.mu.Lock()
299 for name, owner := range m.commandIndex {330 for name, owner := range m.commandIndex {
300 if owner == ext {331 if owner == ext {
301 delete(m.commandIndex, name)332 delete(m.commandIndex, name)
302 }333 }
303 }334 }
335+ for name, owner := range m.toolIndex {
336+ if owner == ext {
337+ delete(m.toolIndex, name)
338+ }
339+ }
304 m.mu.Unlock()340 m.mu.Unlock()
341+ ext.readyOnce.Do(func() { close(ext.readyCh) })
305 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))342 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))
306 }()343 }()
307 344
@@ -323,6 +360,45 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
323 }360 }
324 m.mu.Unlock()361 m.mu.Unlock()
325 }362 }
363+ case "register_tool":
364+ var rt extproto.RegisterToolFromExt
365+ if err := json.Unmarshal(line, &rt); err != nil {
366+ fmt.Fprintf(ext.logFile, "[zot] bad register_tool frame: %v\n", err)
367+ continue
368+ }
369+ // Validate the schema parses as JSON. If not, refuse to
370+ // register — a broken schema confuses the model.
371+ if len(rt.Schema) > 0 {
372+ var tmp any
373+ if err := json.Unmarshal(rt.Schema, &tmp); err != nil {
374+ fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err)
375+ continue
376+ }
377+ }
378+ ext.tools = append(ext.tools, rt)
379+ m.mu.Lock()
380+ if _, exists := m.toolIndex[rt.Name]; !exists {
381+ m.toolIndex[rt.Name] = ext
382+ }
383+ m.mu.Unlock()
384+ case "ready":
385+ ext.readyOnce.Do(func() { close(ext.readyCh) })
386+ case "tool_result":
387+ var tr extproto.ToolResultFromExt
388+ if err := json.Unmarshal(line, &tr); err == nil {
389+ ext.mu.Lock()
390+ ch, ok := ext.pendingTool[tr.ID]
391+ if ok {
392+ delete(ext.pendingTool, tr.ID)
393+ }
394+ ext.mu.Unlock()
395+ if ok {
396+ select {
397+ case ch <- tr:
398+ default:
399+ }
400+ }
401+ }
326 case "notify":402 case "notify":
327 var n extproto.NotifyFromExt403 var n extproto.NotifyFromExt
328 if err := json.Unmarshal(line, &n); err == nil {404 if err := json.Unmarshal(line, &n); err == nil {
@@ -378,6 +454,89 @@ type CommandInfo struct {
378 Description string454 Description string
379 }455 }
380 456
457+// ToolInfo is one extension-registered tool. Used by the agent's
458+// build step to materialise core.Tool wrappers.
459+type ToolInfo struct {
460+ Extension string
461+ Name string
462+ Description string
463+ Schema json.RawMessage
464+}
465+
466+// Tools returns a snapshot of every (extension, tool) pair currently
467+// registered. Used at agent-build time to fold extension tools into
468+// the runtime tool registry.
469+func (m *Manager) Tools() []ToolInfo {
470+ m.mu.RLock()
471+ defer m.mu.RUnlock()
472+ var out []ToolInfo
473+ for _, ext := range m.ext {
474+ for _, t := range ext.tools {
475+ out = append(out, ToolInfo{
476+ Extension: ext.Manifest.Name,
477+ Name: t.Name,
478+ Description: t.Description,
479+ Schema: t.Schema,
480+ })
481+ }
482+ }
483+ return out
484+}
485+
486+// HasTool reports whether name is registered by any extension.
487+func (m *Manager) HasTool(name string) bool {
488+ m.mu.RLock()
489+ defer m.mu.RUnlock()
490+ _, ok := m.toolIndex[name]
491+ return ok
492+}
493+
494+// InvokeTool sends a tool_call to the owning extension and waits for
495+// the matching tool_result. Used by the core.Tool wrapper that the
496+// agent registers per extension-defined tool.
497+func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) {
498+ m.mu.RLock()
499+ ext, ok := m.toolIndex[name]
500+ m.mu.RUnlock()
501+ if !ok {
502+ return extproto.ToolResultFromExt{}, fmt.Errorf("no extension registered for tool %q", name)
503+ }
504+
505+ id := newCorrelationID()
506+ ch := make(chan extproto.ToolResultFromExt, 1)
507+ ext.mu.Lock()
508+ ext.pendingTool[id] = ch
509+ ext.mu.Unlock()
510+
511+ frame, _ := extproto.Encode(extproto.ToolCallFromHost{
512+ Type: "tool_call",
513+ ID: id,
514+ Name: name,
515+ Args: args,
516+ })
517+ if _, err := ext.stdin.Write(frame); err != nil {
518+ ext.mu.Lock()
519+ delete(ext.pendingTool, id)
520+ ext.mu.Unlock()
521+ return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err)
522+ }
523+
524+ select {
525+ case resp := <-ch:
526+ return resp, nil
527+ case <-time.After(timeout):
528+ ext.mu.Lock()
529+ delete(ext.pendingTool, id)
530+ ext.mu.Unlock()
531+ return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
532+ case <-ctx.Done():
533+ ext.mu.Lock()
534+ delete(ext.pendingTool, id)
535+ ext.mu.Unlock()
536+ return extproto.ToolResultFromExt{}, ctx.Err()
537+ }
538+}
539+
381 // HasCommand reports whether name is registered by any extension.540 // HasCommand reports whether name is registered by any extension.
382 func (m *Manager) HasCommand(name string) bool {541 func (m *Manager) HasCommand(name string) bool {
383 m.mu.RLock()542 m.mu.RLock()
A internal/agent/extensions/tool.go +101 -0
@@ -0,0 +1,101 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/provider"
11+)
12+
13+// extensionTool wraps a single extension-registered tool as a
14+// core.Tool. The agent's tool registry contains one of these per
15+// extension tool; Execute round-trips through the manager to the
16+// owning subprocess.
17+//
18+// One concrete type instead of a closure-driven anonymous tool
19+// keeps the schema, name, and ownership inspectable for logs and
20+// dialogs.
21+type extensionTool struct {
22+ name string
23+ description string
24+ schema json.RawMessage
25+ extension string
26+ manager *Manager
27+ timeout time.Duration
28+}
29+
30+// NewTool returns a core.Tool that round-trips invocations through
31+// mgr to the extension that registered (name, schema). The default
32+// per-call timeout is 60 seconds; callers can override.
33+func NewTool(mgr *Manager, info ToolInfo) core.Tool {
34+ return &extensionTool{
35+ name: info.Name,
36+ description: info.Description,
37+ schema: info.Schema,
38+ extension: info.Extension,
39+ manager: mgr,
40+ timeout: 60 * time.Second,
41+ }
42+}
43+
44+func (t *extensionTool) Name() string { return t.name }
45+func (t *extensionTool) Description() string { return t.description }
46+func (t *extensionTool) Schema() json.RawMessage { return t.schema }
47+func (t *extensionTool) Extension() string { return t.extension }
48+
49+// Execute is what the agent calls when the LLM invokes the tool. It
50+// hands args to the owning extension, waits up to t.timeout for the
51+// reply, and converts the response into a core.ToolResult.
52+func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ func(string)) (core.ToolResult, error) {
53+ if len(args) == 0 {
54+ args = json.RawMessage(`{}`)
55+ }
56+ resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)
57+ if err != nil {
58+ return core.ToolResult{
59+ IsError: true,
60+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},
61+ }, nil
62+ }
63+ out := core.ToolResult{IsError: resp.IsError}
64+ for _, b := range resp.Content {
65+ switch b.Type {
66+ case "text":
67+ if b.Text != "" {
68+ out.Content = append(out.Content, provider.TextBlock{Text: b.Text})
69+ }
70+ case "image":
71+ data, dErr := decodeBase64(b.Data)
72+ if dErr != nil {
73+ out.IsError = true
74+ out.Content = append(out.Content, provider.TextBlock{Text: fmt.Sprintf("extension %s/%s returned undecodable image: %v", t.extension, t.name, dErr)})
75+ continue
76+ }
77+ out.Content = append(out.Content, provider.ImageBlock{
78+ MimeType: b.MimeType,
79+ Data: data,
80+ })
81+ default:
82+ // Unknown block type: stringify for debug visibility.
83+ out.Content = append(out.Content, provider.TextBlock{Text: fmt.Sprintf("[unknown block type %q from extension %s]", b.Type, t.extension)})
84+ }
85+ }
86+ if len(out.Content) == 0 {
87+ // Defensive: an empty content slice would confuse the model.
88+ out.Content = []provider.Content{provider.TextBlock{Text: "(extension returned no content)"}}
89+ }
90+ out.Details = map[string]any{"extension": t.extension, "tool": t.name}
91+ return out, nil
92+}
93+
94+// decodeBase64 is a tiny wrapper around encoding/base64 so we can
95+// validate the extension's image data in one place.
96+func decodeBase64(s string) ([]byte, error) {
97+ if s == "" {
98+ return nil, nil
99+ }
100+ return base64DecodeStd(s)
101+}
@@ -0,0 +1,101 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/provider"
11+)
12+
13+// extensionTool wraps a single extension-registered tool as a
14+// core.Tool. The agent's tool registry contains one of these per
15+// extension tool; Execute round-trips through the manager to the
16+// owning subprocess.
17+//
18+// One concrete type instead of a closure-driven anonymous tool
19+// keeps the schema, name, and ownership inspectable for logs and
20+// dialogs.
21+type extensionTool struct {
22+ name string
23+ description string
24+ schema json.RawMessage
25+ extension string
26+ manager *Manager
27+ timeout time.Duration
28+}
29+
30+// NewTool returns a core.Tool that round-trips invocations through
31+// mgr to the extension that registered (name, schema). The default
32+// per-call timeout is 60 seconds; callers can override.
33+func NewTool(mgr *Manager, info ToolInfo) core.Tool {
34+ return &extensionTool{
35+ name: info.Name,
36+ description: info.Description,
37+ schema: info.Schema,
38+ extension: info.Extension,
39+ manager: mgr,
40+ timeout: 60 * time.Second,
41+ }
42+}
43+
44+func (t *extensionTool) Name() string { return t.name }
45+func (t *extensionTool) Description() string { return t.description }
46+func (t *extensionTool) Schema() json.RawMessage { return t.schema }
47+func (t *extensionTool) Extension() string { return t.extension }
48+
49+// Execute is what the agent calls when the LLM invokes the tool. It
50+// hands args to the owning extension, waits up to t.timeout for the
51+// reply, and converts the response into a core.ToolResult.
52+func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ func(string)) (core.ToolResult, error) {
53+ if len(args) == 0 {
54+ args = json.RawMessage(`{}`)
55+ }
56+ resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)
57+ if err != nil {
58+ return core.ToolResult{
59+ IsError: true,
60+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},
61+ }, nil
62+ }
63+ out := core.ToolResult{IsError: resp.IsError}
64+ for _, b := range resp.Content {
65+ switch b.Type {
66+ case "text":
67+ if b.Text != "" {
68+ out.Content = append(out.Content, provider.TextBlock{Text: b.Text})
69+ }
70+ case "image":
71+ data, dErr := decodeBase64(b.Data)
72+ if dErr != nil {
73+ out.IsError = true
74+ out.Content = append(out.Content, provider.TextBlock{Text: fmt.Sprintf("extension %s/%s returned undecodable image: %v", t.extension, t.name, dErr)})
75+ continue
76+ }
77+ out.Content = append(out.Content, provider.ImageBlock{
78+ MimeType: b.MimeType,
79+ Data: data,
80+ })
81+ default:
82+ // Unknown block type: stringify for debug visibility.
83+ out.Content = append(out.Content, provider.TextBlock{Text: fmt.Sprintf("[unknown block type %q from extension %s]", b.Type, t.extension)})
84+ }
85+ }
86+ if len(out.Content) == 0 {
87+ // Defensive: an empty content slice would confuse the model.
88+ out.Content = []provider.Content{provider.TextBlock{Text: "(extension returned no content)"}}
89+ }
90+ out.Details = map[string]any{"extension": t.extension, "tool": t.name}
91+ return out, nil
92+}
93+
94+// decodeBase64 is a tiny wrapper around encoding/base64 so we can
95+// validate the extension's image data in one place.
96+func decodeBase64(s string) ([]byte, error) {
97+ if s == "" {
98+ return nil, nil
99+ }
100+ return base64DecodeStd(s)
101+}
A internal/agent/extensions/tool_test.go +82 -0
@@ -0,0 +1,82 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "testing"
10+ "time"
11+)
12+
13+func writeMockToolExtension(t *testing.T, root string) {
14+ t.Helper()
15+ if runtime.GOOS == "windows" {
16+ t.Skip("mock uses /bin/sh; skip on windows")
17+ }
18+ dir := filepath.Join(root, "tool-mock")
19+ if err := os.MkdirAll(dir, 0o755); err != nil {
20+ t.Fatal(err)
21+ }
22+
23+ // Shell-script extension: hello, register one tool, ready, then
24+ // loop on stdin echoing back tool_call args as tool_result text.
25+ script := `#!/bin/sh
26+printf '%s\n' '{"type":"hello","name":"tool-mock","version":"0.1","capabilities":["tools"]}'
27+printf '%s\n' '{"type":"register_tool","name":"echo","description":"echo back the args","schema":{"type":"object","properties":{"msg":{"type":"string"}}}}'
28+printf '%s\n' '{"type":"ready"}'
29+while IFS= read -r line; do
30+ case "$line" in
31+ *'"type":"tool_call"'*)
32+ id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
33+ printf '%s\n' "{\"type\":\"tool_result\",\"id\":\"$id\",\"content\":[{\"type\":\"text\",\"text\":\"echoed\"}]}"
34+ ;;
35+ *'"type":"shutdown"'*)
36+ printf '%s\n' '{"type":"shutdown_ack"}'
37+ exit 0
38+ ;;
39+ esac
40+done
41+`
42+ if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
43+ t.Fatal(err)
44+ }
45+ mfb, _ := json.Marshal(map[string]any{"name": "tool-mock", "exec": "./run.sh"})
46+ if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
47+ t.Fatal(err)
48+ }
49+}
50+
51+func TestManagerToolRegistrationAndInvoke(t *testing.T) {
52+ tmp := t.TempDir()
53+ writeMockToolExtension(t, filepath.Join(tmp, "extensions"))
54+
55+ mgr := New(tmp, "", "0.0.0", "anthropic", "opus", &stubHooks{})
56+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
57+ t.Fatalf("discover errs: %v", errs)
58+ }
59+ defer mgr.Stop(2 * time.Second)
60+
61+ // Wait for ready (the script sends it right after register_tool).
62+ mgr.WaitForReady(time.Second)
63+
64+ tools := mgr.Tools()
65+ if len(tools) != 1 || tools[0].Name != "echo" {
66+ t.Fatalf("expected one tool 'echo', got %#v", tools)
67+ }
68+ if !mgr.HasTool("echo") {
69+ t.Fatal("HasTool(\"echo\") = false")
70+ }
71+
72+ resp, err := mgr.InvokeTool(context.Background(), "echo", json.RawMessage(`{"msg":"hi"}`), 2*time.Second)
73+ if err != nil {
74+ t.Fatalf("invoke: %v", err)
75+ }
76+ if resp.IsError {
77+ t.Errorf("expected success, got is_error=true")
78+ }
79+ if len(resp.Content) != 1 || resp.Content[0].Type != "text" || resp.Content[0].Text != "echoed" {
80+ t.Errorf("unexpected content: %#v", resp.Content)
81+ }
82+}
@@ -0,0 +1,82 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "testing"
10+ "time"
11+)
12+
13+func writeMockToolExtension(t *testing.T, root string) {
14+ t.Helper()
15+ if runtime.GOOS == "windows" {
16+ t.Skip("mock uses /bin/sh; skip on windows")
17+ }
18+ dir := filepath.Join(root, "tool-mock")
19+ if err := os.MkdirAll(dir, 0o755); err != nil {
20+ t.Fatal(err)
21+ }
22+
23+ // Shell-script extension: hello, register one tool, ready, then
24+ // loop on stdin echoing back tool_call args as tool_result text.
25+ script := `#!/bin/sh
26+printf '%s\n' '{"type":"hello","name":"tool-mock","version":"0.1","capabilities":["tools"]}'
27+printf '%s\n' '{"type":"register_tool","name":"echo","description":"echo back the args","schema":{"type":"object","properties":{"msg":{"type":"string"}}}}'
28+printf '%s\n' '{"type":"ready"}'
29+while IFS= read -r line; do
30+ case "$line" in
31+ *'"type":"tool_call"'*)
32+ id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
33+ printf '%s\n' "{\"type\":\"tool_result\",\"id\":\"$id\",\"content\":[{\"type\":\"text\",\"text\":\"echoed\"}]}"
34+ ;;
35+ *'"type":"shutdown"'*)
36+ printf '%s\n' '{"type":"shutdown_ack"}'
37+ exit 0
38+ ;;
39+ esac
40+done
41+`
42+ if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
43+ t.Fatal(err)
44+ }
45+ mfb, _ := json.Marshal(map[string]any{"name": "tool-mock", "exec": "./run.sh"})
46+ if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
47+ t.Fatal(err)
48+ }
49+}
50+
51+func TestManagerToolRegistrationAndInvoke(t *testing.T) {
52+ tmp := t.TempDir()
53+ writeMockToolExtension(t, filepath.Join(tmp, "extensions"))
54+
55+ mgr := New(tmp, "", "0.0.0", "anthropic", "opus", &stubHooks{})
56+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
57+ t.Fatalf("discover errs: %v", errs)
58+ }
59+ defer mgr.Stop(2 * time.Second)
60+
61+ // Wait for ready (the script sends it right after register_tool).
62+ mgr.WaitForReady(time.Second)
63+
64+ tools := mgr.Tools()
65+ if len(tools) != 1 || tools[0].Name != "echo" {
66+ t.Fatalf("expected one tool 'echo', got %#v", tools)
67+ }
68+ if !mgr.HasTool("echo") {
69+ t.Fatal("HasTool(\"echo\") = false")
70+ }
71+
72+ resp, err := mgr.InvokeTool(context.Background(), "echo", json.RawMessage(`{"msg":"hi"}`), 2*time.Second)
73+ if err != nil {
74+ t.Fatalf("invoke: %v", err)
75+ }
76+ if resp.IsError {
77+ t.Errorf("expected success, got is_error=true")
78+ }
79+ if len(resp.Content) != 1 || resp.Content[0].Type != "text" || resp.Content[0].Text != "echoed" {
80+ t.Errorf("unexpected content: %#v", resp.Content)
81+ }
82+}
M internal/agent/rpc.go +47 -0
@@ -10,7 +10,9 @@ import (
1010 "os"
1111 "strings"
1212 "sync"
13+ "time"
1314
15+ "github.com/patriceckhart/zot/internal/agent/extensions"
1416 "github.com/patriceckhart/zot/internal/agent/modes"
1517 "github.com/patriceckhart/zot/internal/core"
1618 "github.com/patriceckhart/zot/internal/provider"
@@ -41,6 +43,20 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
4143 if err != nil {
4244 return err
4345 }
46+
47+ // Extensions: same lifecycle as interactive mode, minus the
48+ // host-hooks integration. Notify/Display calls from extensions
49+ // emit RPC events instead of TUI lines so any consumer can react.
50+ extHooks := &rpcExtHooks{}
51+ extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
52+ discoveryErrs := extMgr.Discover(ctx)
53+ for _, e := range discoveryErrs {
54+ fmt.Fprintln(os.Stderr, "extension load:", e)
55+ }
56+ extMgr.WaitForReady(500 * time.Millisecond)
57+ defer extMgr.Stop(2 * time.Second)
58+ r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
59+
4460 ag := r.NewAgent()
4561
4662 server := &rpcServer{
@@ -52,9 +68,40 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
5268 out: os.Stdout,
5369 version: version,
5470 }
71+ extHooks.server = server
5572 return server.run(os.Stdin)
5673 }
5774
75+// rpcExtHooks implements extensions.HostHooks for the headless RPC
76+// loop. Notify and Display surface as `event` frames so any RPC
77+// client can render them; Submit and Insert are no-ops because the
78+// RPC loop has no editor and the prompt comes from the client.
79+type rpcExtHooks struct {
80+ server *rpcServer
81+}
82+
83+func (h *rpcExtHooks) Notify(extName, level, message string) {
84+ if h.server != nil {
85+ h.server.writeEvent(map[string]any{
86+ "type": "ext_notify",
87+ "extension": extName,
88+ "level": level,
89+ "message": message,
90+ })
91+ }
92+}
93+func (h *rpcExtHooks) Display(extName, text string) {
94+ if h.server != nil {
95+ h.server.writeEvent(map[string]any{
96+ "type": "ext_display",
97+ "extension": extName,
98+ "text": text,
99+ })
100+ }
101+}
102+func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
103+func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
104+
58105 type rpcServer struct {
59106 ctx context.Context
60107 args Args
@@ -10,7 +10,9 @@ import (
10 "os"10 "os"
11 "strings"11 "strings"
12 "sync"12 "sync"
13+ "time"
13 14
15+ "github.com/patriceckhart/zot/internal/agent/extensions"
14 "github.com/patriceckhart/zot/internal/agent/modes"16 "github.com/patriceckhart/zot/internal/agent/modes"
15 "github.com/patriceckhart/zot/internal/core"17 "github.com/patriceckhart/zot/internal/core"
16 "github.com/patriceckhart/zot/internal/provider"18 "github.com/patriceckhart/zot/internal/provider"
@@ -41,6 +43,20 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
41 if err != nil {43 if err != nil {
42 return err44 return err
43 }45 }
46+
47+ // Extensions: same lifecycle as interactive mode, minus the
48+ // host-hooks integration. Notify/Display calls from extensions
49+ // emit RPC events instead of TUI lines so any consumer can react.
50+ extHooks := &rpcExtHooks{}
51+ extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
52+ discoveryErrs := extMgr.Discover(ctx)
53+ for _, e := range discoveryErrs {
54+ fmt.Fprintln(os.Stderr, "extension load:", e)
55+ }
56+ extMgr.WaitForReady(500 * time.Millisecond)
57+ defer extMgr.Stop(2 * time.Second)
58+ r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
59+
44 ag := r.NewAgent()60 ag := r.NewAgent()
45 61
46 server := &rpcServer{62 server := &rpcServer{
@@ -52,9 +68,40 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
52 out: os.Stdout,68 out: os.Stdout,
53 version: version,69 version: version,
54 }70 }
71+ extHooks.server = server
55 return server.run(os.Stdin)72 return server.run(os.Stdin)
56 }73 }
57 74
75+// rpcExtHooks implements extensions.HostHooks for the headless RPC
76+// loop. Notify and Display surface as `event` frames so any RPC
77+// client can render them; Submit and Insert are no-ops because the
78+// RPC loop has no editor and the prompt comes from the client.
79+type rpcExtHooks struct {
80+ server *rpcServer
81+}
82+
83+func (h *rpcExtHooks) Notify(extName, level, message string) {
84+ if h.server != nil {
85+ h.server.writeEvent(map[string]any{
86+ "type": "ext_notify",
87+ "extension": extName,
88+ "level": level,
89+ "message": message,
90+ })
91+ }
92+}
93+func (h *rpcExtHooks) Display(extName, text string) {
94+ if h.server != nil {
95+ h.server.writeEvent(map[string]any{
96+ "type": "ext_display",
97+ "extension": extName,
98+ "text": text,
99+ })
100+ }
101+}
102+func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
103+func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
104+
58 type rpcServer struct {105 type rpcServer struct {
59 ctx context.Context106 ctx context.Context
60 args Args107 args Args
M internal/extproto/extproto.go +57 -0
@@ -50,6 +50,52 @@ type RegisterCommandFromExt struct {
5050 Description string `json:"description,omitempty"`
5151 }
5252
53+// RegisterToolFromExt asks zot to expose a tool to the LLM. The
54+// schema is a JSON Schema object describing Args; zot doesn't validate
55+// the model's arguments against it (the model providers do that), but
56+// it must parse as valid JSON or registration is rejected.
57+//
58+// Tool names live in the same namespace as built-in tools (read,
59+// write, edit, bash, skill). Conflicts are silently shadowed by the
60+// built-in; check the extension's log for a warning.
61+type RegisterToolFromExt struct {
62+ Type string `json:"type"` // "register_tool"
63+ Name string `json:"name"`
64+ Description string `json:"description,omitempty"`
65+ Schema json.RawMessage `json:"schema"`
66+}
67+
68+// ReadyFromExt signals "all initial registrations sent". The host
69+// waits for this (with a short timeout) before building the agent's
70+// tool registry, so model calls don't race extension tool
71+// registration.
72+type ReadyFromExt struct {
73+ Type string `json:"type"` // "ready"
74+}
75+
76+// ToolResultFromExt is the extension's reply to a ToolCallFromHost.
77+// Content[] follows the same shape as elsewhere in zot:
78+//
79+// {"type":"text", "text":"..."}
80+// {"type":"image", "mime_type":"image/png", "data":"<base64>"}
81+//
82+// Set IsError true to mark the tool call as failed; the model sees
83+// the content as the error explanation.
84+type ToolResultFromExt struct {
85+ Type string `json:"type"` // "tool_result"
86+ ID string `json:"id"`
87+ Content []ContentBlock `json:"content"`
88+ IsError bool `json:"is_error,omitempty"`
89+}
90+
91+// ContentBlock is one entry in a tool result's content array.
92+type ContentBlock struct {
93+ Type string `json:"type"` // "text" | "image"
94+ Text string `json:"text,omitempty"`
95+ MimeType string `json:"mime_type,omitempty"`
96+ Data string `json:"data,omitempty"` // base64
97+}
98+
5399 // CommandResponseFromExt is the extension's answer to a
54100 // CommandInvokedFromHost. Action drives what zot does next:
55101 //
@@ -107,6 +153,17 @@ type CommandInvokedFromHost struct {
107153 Args string `json:"args,omitempty"`
108154 }
109155
156+// ToolCallFromHost is sent when the LLM invokes a tool the extension
157+// registered. Args is the raw JSON object the model produced; the
158+// extension is responsible for validating/coercing it. Reply with
159+// ToolResultFromExt within the host's tool timeout (default 60s).
160+type ToolCallFromHost struct {
161+ Type string `json:"type"` // "tool_call"
162+ ID string `json:"id"`
163+ Name string `json:"name"`
164+ Args json.RawMessage `json:"args"`
165+}
166+
110167 // ShutdownFromHost asks the extension to clean up and exit. Zot
111168 // sends this when the user runs /reload-ext or zot itself is exiting
112169 // gracefully. Extensions that don't reply within a few seconds get
@@ -50,6 +50,52 @@ type RegisterCommandFromExt struct {
50 Description string `json:"description,omitempty"`50 Description string `json:"description,omitempty"`
51 }51 }
52 52
53+// RegisterToolFromExt asks zot to expose a tool to the LLM. The
54+// schema is a JSON Schema object describing Args; zot doesn't validate
55+// the model's arguments against it (the model providers do that), but
56+// it must parse as valid JSON or registration is rejected.
57+//
58+// Tool names live in the same namespace as built-in tools (read,
59+// write, edit, bash, skill). Conflicts are silently shadowed by the
60+// built-in; check the extension's log for a warning.
61+type RegisterToolFromExt struct {
62+ Type string `json:"type"` // "register_tool"
63+ Name string `json:"name"`
64+ Description string `json:"description,omitempty"`
65+ Schema json.RawMessage `json:"schema"`
66+}
67+
68+// ReadyFromExt signals "all initial registrations sent". The host
69+// waits for this (with a short timeout) before building the agent's
70+// tool registry, so model calls don't race extension tool
71+// registration.
72+type ReadyFromExt struct {
73+ Type string `json:"type"` // "ready"
74+}
75+
76+// ToolResultFromExt is the extension's reply to a ToolCallFromHost.
77+// Content[] follows the same shape as elsewhere in zot:
78+//
79+// {"type":"text", "text":"..."}
80+// {"type":"image", "mime_type":"image/png", "data":"<base64>"}
81+//
82+// Set IsError true to mark the tool call as failed; the model sees
83+// the content as the error explanation.
84+type ToolResultFromExt struct {
85+ Type string `json:"type"` // "tool_result"
86+ ID string `json:"id"`
87+ Content []ContentBlock `json:"content"`
88+ IsError bool `json:"is_error,omitempty"`
89+}
90+
91+// ContentBlock is one entry in a tool result's content array.
92+type ContentBlock struct {
93+ Type string `json:"type"` // "text" | "image"
94+ Text string `json:"text,omitempty"`
95+ MimeType string `json:"mime_type,omitempty"`
96+ Data string `json:"data,omitempty"` // base64
97+}
98+
53 // CommandResponseFromExt is the extension's answer to a99 // CommandResponseFromExt is the extension's answer to a
54 // CommandInvokedFromHost. Action drives what zot does next:100 // CommandInvokedFromHost. Action drives what zot does next:
55 //101 //
@@ -107,6 +153,17 @@ type CommandInvokedFromHost struct {
107 Args string `json:"args,omitempty"`153 Args string `json:"args,omitempty"`
108 }154 }
109 155
156+// ToolCallFromHost is sent when the LLM invokes a tool the extension
157+// registered. Args is the raw JSON object the model produced; the
158+// extension is responsible for validating/coercing it. Reply with
159+// ToolResultFromExt within the host's tool timeout (default 60s).
160+type ToolCallFromHost struct {
161+ Type string `json:"type"` // "tool_call"
162+ ID string `json:"id"`
163+ Name string `json:"name"`
164+ Args json.RawMessage `json:"args"`
165+}
166+
110 // ShutdownFromHost asks the extension to clean up and exit. Zot167 // ShutdownFromHost asks the extension to clean up and exit. Zot
111 // sends this when the user runs /reload-ext or zot itself is exiting168 // sends this when the user runs /reload-ext or zot itself is exiting
112 // gracefully. Extensions that don't reply within a few seconds get169 // gracefully. Extensions that don't reply within a few seconds get
M pkg/zotext/zotext.go +130 -3
@@ -28,6 +28,7 @@ package zotext
2828
2929 import (
3030 "bufio"
31+ "encoding/base64"
3132 "encoding/json"
3233 "fmt"
3334 "io"
@@ -37,12 +38,61 @@ import (
3738 "github.com/patriceckhart/zot/internal/extproto"
3839 )
3940
41+func base64Encode(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
42+
4043 // CommandHandler is invoked when the user runs the extension's
4144 // registered slash command. args is everything the user typed after
4245 // the command name (already trimmed). Return a Response describing
4346 // what zot should do next.
4447 type CommandHandler func(args string) Response
4548
49+// ToolHandler is invoked when the LLM calls a tool the extension
50+// registered. args is the raw JSON object the model produced; the
51+// handler is responsible for parsing/validating it. Return a
52+// ToolResult describing what zot should send back to the model.
53+type ToolHandler func(args json.RawMessage) ToolResult
54+
55+// ToolResult is the extension's reply to a tool invocation. Build
56+// one with TextResult, ImageResult, or directly when you need to
57+// combine multiple blocks.
58+type ToolResult struct {
59+ Content []ToolContent
60+ IsError bool
61+}
62+
63+// ToolContent is one block of tool output. Either Text is set, or
64+// MimeType+Data (base64-encoded). Use the Text/Image helpers below.
65+type ToolContent struct {
66+ Type string // "text" | "image"
67+ Text string
68+ MimeType string
69+ Data string // base64
70+}
71+
72+// Text returns a text content block.
73+func Text(s string) ToolContent { return ToolContent{Type: "text", Text: s} }
74+
75+// Image returns an image content block. data must already be
76+// base64-encoded; use ImageBytes to encode raw bytes.
77+func Image(mimeType, base64Data string) ToolContent {
78+ return ToolContent{Type: "image", MimeType: mimeType, Data: base64Data}
79+}
80+
81+// ImageBytes returns an image content block from raw bytes,
82+// encoding them to base64 for the wire.
83+func ImageBytes(mimeType string, data []byte) ToolContent {
84+ return ToolContent{Type: "image", MimeType: mimeType, Data: base64Encode(data)}
85+}
86+
87+// TextResult returns a tool result with one text block, success.
88+func TextResult(s string) ToolResult { return ToolResult{Content: []ToolContent{Text(s)}} }
89+
90+// TextErrorResult returns a tool result with one text block, marked
91+// as an error to the model.
92+func TextErrorResult(s string) ToolResult {
93+ return ToolResult{Content: []ToolContent{Text(s)}, IsError: true}
94+}
95+
4696 // Response tells zot how to react to a command invocation. Construct
4797 // one with Prompt(), Insert(), Display(), or Noop().
4898 type Response struct {
@@ -91,10 +141,12 @@ type Extension struct {
91141 mu sync.Mutex
92142 commands map[string]CommandHandler
93143 descriptions []descTuple // ordered so register frames arrive in registration order
144+ tools map[string]ToolHandler
145+ toolDefs []toolDef // ordered so register frames arrive in registration order
94146
95147 // Caps reported in the hello frame. The SDK enables "commands"
96- // automatically; future capabilities (tools, events) will live
97- // here when those phases land.
148+ // and "tools" automatically; future capabilities (events) will
149+ // live here when those phases land.
98150 caps []string
99151
100152 // hostInfo is filled in once HelloAck arrives.
@@ -105,6 +157,12 @@ type descTuple struct {
105157 name, desc string
106158 }
107159
160+type toolDef struct {
161+ name string
162+ description string
163+ schema json.RawMessage
164+}
165+
108166 // HostInfo is what the host (zot) tells us in HelloAck. Useful for
109167 // extensions that want to behave differently per provider.
110168 type HostInfo struct {
@@ -125,7 +183,8 @@ func New(name, version string) *Extension {
125183 out: os.Stdout,
126184 stderr: os.Stderr,
127185 commands: map[string]CommandHandler{},
128- caps: []string{"commands"},
186+ tools: map[string]ToolHandler{},
187+ caps: []string{"commands", "tools"},
129188 }
130189 }
131190
@@ -154,6 +213,20 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
154213 e.mu.Unlock()
155214 }
156215
216+// Tool registers an LLM-callable tool. schema is a JSON Schema
217+// object describing the tool's args (the same shape Anthropic /
218+// OpenAI accept). Call this BEFORE Run(); zot folds extension tools
219+// into the agent's registry once the extension's ready frame fires.
220+//
221+// Naming conflicts with built-in tools (read, write, edit, bash,
222+// skill) are silently shadowed by the built-in.
223+func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
224+ e.mu.Lock()
225+ e.tools[name] = fn
226+ e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema})
227+ e.mu.Unlock()
228+}
229+
157230 // Notify pushes an info-level status note into zot's chat without
158231 // requiring a slash command from the user.
159232 func (e *Extension) Notify(level, message string) {
@@ -180,6 +253,7 @@ func (e *Extension) Run() error {
180253 }
181254 e.mu.Lock()
182255 descs := append([]descTuple(nil), e.descriptions...)
256+ toolDefs := append([]toolDef(nil), e.toolDefs...)
183257 e.mu.Unlock()
184258 for _, d := range descs {
185259 _ = e.send(extproto.RegisterCommandFromExt{
@@ -188,6 +262,19 @@ func (e *Extension) Run() error {
188262 Description: d.desc,
189263 })
190264 }
265+ for _, td := range toolDefs {
266+ _ = e.send(extproto.RegisterToolFromExt{
267+ Type: "register_tool",
268+ Name: td.name,
269+ Description: td.description,
270+ Schema: td.schema,
271+ })
272+ }
273+ // Sentinel: tells the host that all initial registrations have
274+ // been flushed and the agent registry can be built. Never block
275+ // on this; if the host doesn't act on it, registrations still
276+ // land in time for the typical use case.
277+ _ = e.send(extproto.ReadyFromExt{Type: "ready"})
191278
192279 scanner := bufio.NewScanner(e.in)
193280 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
@@ -233,6 +320,27 @@ func (e *Extension) Run() error {
233320 resp := fn(args)
234321 e.respond(id, resp)
235322 }(ci.ID, fn, ci.Args)
323+ case "tool_call":
324+ var tc extproto.ToolCallFromHost
325+ if err := json.Unmarshal(line, &tc); err != nil {
326+ continue
327+ }
328+ e.mu.Lock()
329+ fn := e.tools[tc.Name]
330+ e.mu.Unlock()
331+ if fn == nil {
332+ e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))
333+ continue
334+ }
335+ go func(id string, fn ToolHandler, args json.RawMessage) {
336+ defer func() {
337+ if r := recover(); r != nil {
338+ e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r)))
339+ }
340+ }()
341+ res := fn(args)
342+ e.respondTool(id, res)
343+ }(tc.ID, fn, tc.Args)
236344 case "shutdown":
237345 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
238346 return nil
@@ -259,6 +367,25 @@ func (e *Extension) respond(id string, r Response) {
259367 })
260368 }
261369
370+// respondTool serialises a ToolResultFromExt for the given id.
371+func (e *Extension) respondTool(id string, r ToolResult) {
372+ blocks := make([]extproto.ContentBlock, 0, len(r.Content))
373+ for _, c := range r.Content {
374+ blocks = append(blocks, extproto.ContentBlock{
375+ Type: c.Type,
376+ Text: c.Text,
377+ MimeType: c.MimeType,
378+ Data: c.Data,
379+ })
380+ }
381+ _ = e.send(extproto.ToolResultFromExt{
382+ Type: "tool_result",
383+ ID: id,
384+ Content: blocks,
385+ IsError: r.IsError,
386+ })
387+}
388+
262389 // send marshals v + LF and writes it under a mutex (so concurrent
263390 // goroutines don't interleave bytes on stdout).
264391 func (e *Extension) send(v any) error {
@@ -28,6 +28,7 @@ package zotext
28 28
29 import (29 import (
30 "bufio"30 "bufio"
31+ "encoding/base64"
31 "encoding/json"32 "encoding/json"
32 "fmt"33 "fmt"
33 "io"34 "io"
@@ -37,12 +38,61 @@ import (
37 "github.com/patriceckhart/zot/internal/extproto"38 "github.com/patriceckhart/zot/internal/extproto"
38 )39 )
39 40
41+func base64Encode(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
42+
40 // CommandHandler is invoked when the user runs the extension's43 // CommandHandler is invoked when the user runs the extension's
41 // registered slash command. args is everything the user typed after44 // registered slash command. args is everything the user typed after
42 // the command name (already trimmed). Return a Response describing45 // the command name (already trimmed). Return a Response describing
43 // what zot should do next.46 // what zot should do next.
44 type CommandHandler func(args string) Response47 type CommandHandler func(args string) Response
45 48
49+// ToolHandler is invoked when the LLM calls a tool the extension
50+// registered. args is the raw JSON object the model produced; the
51+// handler is responsible for parsing/validating it. Return a
52+// ToolResult describing what zot should send back to the model.
53+type ToolHandler func(args json.RawMessage) ToolResult
54+
55+// ToolResult is the extension's reply to a tool invocation. Build
56+// one with TextResult, ImageResult, or directly when you need to
57+// combine multiple blocks.
58+type ToolResult struct {
59+ Content []ToolContent
60+ IsError bool
61+}
62+
63+// ToolContent is one block of tool output. Either Text is set, or
64+// MimeType+Data (base64-encoded). Use the Text/Image helpers below.
65+type ToolContent struct {
66+ Type string // "text" | "image"
67+ Text string
68+ MimeType string
69+ Data string // base64
70+}
71+
72+// Text returns a text content block.
73+func Text(s string) ToolContent { return ToolContent{Type: "text", Text: s} }
74+
75+// Image returns an image content block. data must already be
76+// base64-encoded; use ImageBytes to encode raw bytes.
77+func Image(mimeType, base64Data string) ToolContent {
78+ return ToolContent{Type: "image", MimeType: mimeType, Data: base64Data}
79+}
80+
81+// ImageBytes returns an image content block from raw bytes,
82+// encoding them to base64 for the wire.
83+func ImageBytes(mimeType string, data []byte) ToolContent {
84+ return ToolContent{Type: "image", MimeType: mimeType, Data: base64Encode(data)}
85+}
86+
87+// TextResult returns a tool result with one text block, success.
88+func TextResult(s string) ToolResult { return ToolResult{Content: []ToolContent{Text(s)}} }
89+
90+// TextErrorResult returns a tool result with one text block, marked
91+// as an error to the model.
92+func TextErrorResult(s string) ToolResult {
93+ return ToolResult{Content: []ToolContent{Text(s)}, IsError: true}
94+}
95+
46 // Response tells zot how to react to a command invocation. Construct96 // Response tells zot how to react to a command invocation. Construct
47 // one with Prompt(), Insert(), Display(), or Noop().97 // one with Prompt(), Insert(), Display(), or Noop().
48 type Response struct {98 type Response struct {
@@ -91,10 +141,12 @@ type Extension struct {
91 mu sync.Mutex141 mu sync.Mutex
92 commands map[string]CommandHandler142 commands map[string]CommandHandler
93 descriptions []descTuple // ordered so register frames arrive in registration order143 descriptions []descTuple // ordered so register frames arrive in registration order
144+ tools map[string]ToolHandler
145+ toolDefs []toolDef // ordered so register frames arrive in registration order
94 146
95 // Caps reported in the hello frame. The SDK enables "commands"147 // Caps reported in the hello frame. The SDK enables "commands"
96- // automatically; future capabilities (tools, events) will live148+ // and "tools" automatically; future capabilities (events) will
97- // here when those phases land.149+ // live here when those phases land.
98 caps []string150 caps []string
99 151
100 // hostInfo is filled in once HelloAck arrives.152 // hostInfo is filled in once HelloAck arrives.
@@ -105,6 +157,12 @@ type descTuple struct {
105 name, desc string157 name, desc string
106 }158 }
107 159
160+type toolDef struct {
161+ name string
162+ description string
163+ schema json.RawMessage
164+}
165+
108 // HostInfo is what the host (zot) tells us in HelloAck. Useful for166 // HostInfo is what the host (zot) tells us in HelloAck. Useful for
109 // extensions that want to behave differently per provider.167 // extensions that want to behave differently per provider.
110 type HostInfo struct {168 type HostInfo struct {
@@ -125,7 +183,8 @@ func New(name, version string) *Extension {
125 out: os.Stdout,183 out: os.Stdout,
126 stderr: os.Stderr,184 stderr: os.Stderr,
127 commands: map[string]CommandHandler{},185 commands: map[string]CommandHandler{},
128- caps: []string{"commands"},186+ tools: map[string]ToolHandler{},
187+ caps: []string{"commands", "tools"},
129 }188 }
130 }189 }
131 190
@@ -154,6 +213,20 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
154 e.mu.Unlock()213 e.mu.Unlock()
155 }214 }
156 215
216+// Tool registers an LLM-callable tool. schema is a JSON Schema
217+// object describing the tool's args (the same shape Anthropic /
218+// OpenAI accept). Call this BEFORE Run(); zot folds extension tools
219+// into the agent's registry once the extension's ready frame fires.
220+//
221+// Naming conflicts with built-in tools (read, write, edit, bash,
222+// skill) are silently shadowed by the built-in.
223+func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
224+ e.mu.Lock()
225+ e.tools[name] = fn
226+ e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema})
227+ e.mu.Unlock()
228+}
229+
157 // Notify pushes an info-level status note into zot's chat without230 // Notify pushes an info-level status note into zot's chat without
158 // requiring a slash command from the user.231 // requiring a slash command from the user.
159 func (e *Extension) Notify(level, message string) {232 func (e *Extension) Notify(level, message string) {
@@ -180,6 +253,7 @@ func (e *Extension) Run() error {
180 }253 }
181 e.mu.Lock()254 e.mu.Lock()
182 descs := append([]descTuple(nil), e.descriptions...)255 descs := append([]descTuple(nil), e.descriptions...)
256+ toolDefs := append([]toolDef(nil), e.toolDefs...)
183 e.mu.Unlock()257 e.mu.Unlock()
184 for _, d := range descs {258 for _, d := range descs {
185 _ = e.send(extproto.RegisterCommandFromExt{259 _ = e.send(extproto.RegisterCommandFromExt{
@@ -188,6 +262,19 @@ func (e *Extension) Run() error {
188 Description: d.desc,262 Description: d.desc,
189 })263 })
190 }264 }
265+ for _, td := range toolDefs {
266+ _ = e.send(extproto.RegisterToolFromExt{
267+ Type: "register_tool",
268+ Name: td.name,
269+ Description: td.description,
270+ Schema: td.schema,
271+ })
272+ }
273+ // Sentinel: tells the host that all initial registrations have
274+ // been flushed and the agent registry can be built. Never block
275+ // on this; if the host doesn't act on it, registrations still
276+ // land in time for the typical use case.
277+ _ = e.send(extproto.ReadyFromExt{Type: "ready"})
191 278
192 scanner := bufio.NewScanner(e.in)279 scanner := bufio.NewScanner(e.in)
193 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)280 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
@@ -233,6 +320,27 @@ func (e *Extension) Run() error {
233 resp := fn(args)320 resp := fn(args)
234 e.respond(id, resp)321 e.respond(id, resp)
235 }(ci.ID, fn, ci.Args)322 }(ci.ID, fn, ci.Args)
323+ case "tool_call":
324+ var tc extproto.ToolCallFromHost
325+ if err := json.Unmarshal(line, &tc); err != nil {
326+ continue
327+ }
328+ e.mu.Lock()
329+ fn := e.tools[tc.Name]
330+ e.mu.Unlock()
331+ if fn == nil {
332+ e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))
333+ continue
334+ }
335+ go func(id string, fn ToolHandler, args json.RawMessage) {
336+ defer func() {
337+ if r := recover(); r != nil {
338+ e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r)))
339+ }
340+ }()
341+ res := fn(args)
342+ e.respondTool(id, res)
343+ }(tc.ID, fn, tc.Args)
236 case "shutdown":344 case "shutdown":
237 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})345 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
238 return nil346 return nil
@@ -259,6 +367,25 @@ func (e *Extension) respond(id string, r Response) {
259 })367 })
260 }368 }
261 369
370+// respondTool serialises a ToolResultFromExt for the given id.
371+func (e *Extension) respondTool(id string, r ToolResult) {
372+ blocks := make([]extproto.ContentBlock, 0, len(r.Content))
373+ for _, c := range r.Content {
374+ blocks = append(blocks, extproto.ContentBlock{
375+ Type: c.Type,
376+ Text: c.Text,
377+ MimeType: c.MimeType,
378+ Data: c.Data,
379+ })
380+ }
381+ _ = e.send(extproto.ToolResultFromExt{
382+ Type: "tool_result",
383+ ID: id,
384+ Content: blocks,
385+ IsError: r.IsError,
386+ })
387+}
388+
262 // send marshals v + LF and writes it under a mutex (so concurrent389 // send marshals v + LF and writes it under a mutex (so concurrent
263 // goroutines don't interleave bytes on stdout).390 // goroutines don't interleave bytes on stdout).
264 func (e *Extension) send(v any) error {391 func (e *Extension) send(v any) error {

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