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 | |||
| 6 | 6 | can read and write JSON lines from stdio — Go, TypeScript, Python, | |
| 7 | 7 | Rust, shell with `jq`, anything. | |
| 8 | 8 | ||
| 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. | |
| 12 | 16 | ||
| 13 | 17 | ## Quick start | |
| 14 | 18 | ||
| @@ -122,8 +126,8 @@ restarted. | |||
| 122 | 126 | ## Wire format | |
| 123 | 127 | ||
| 124 | 128 | 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. | |
| 127 | 131 | ||
| 128 | 132 | ### Extension → host | |
| 129 | 133 | ||
| @@ -131,7 +135,7 @@ their responses. | |||
| 131 | 135 | ||
| 132 | 136 | ```json | |
| 133 | 137 | {"type":"hello","name":"weather","version":"1.0.0", | |
| 134 | - | "capabilities":["commands"]} | |
| 138 | + | "capabilities":["commands","tools"]} | |
| 135 | 139 | ``` | |
| 136 | 140 | ||
| 137 | 141 | #### `register_command` | |
| @@ -141,6 +145,47 @@ their responses. | |||
| 141 | 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 | 189 | #### `command_response` (reply to `command_invoked`) | |
| 145 | 190 | ||
| 146 | 191 | ```json | |
| @@ -200,6 +245,21 @@ on macOS, only register a model-specific shortcut for opus, etc.). | |||
| 200 | 245 | ||
| 201 | 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 | 263 | #### `shutdown` | |
| 204 | 264 | ||
| 205 | 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 | 290 | ```go | |
| 231 | 291 | 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 | 298 | func main() { | |
| 236 | 299 | ext := zotext.New("hello", "1.0.0") | |
| 300 | + | ||
| 301 | + | // Slash command | |
| 237 | 302 | ext.Command("hello", "say hi", func(args string) zotext.Response { | |
| 238 | 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 | 315 | ext.Run() | |
| 241 | 316 | } | |
| 242 | 317 | ``` | |
| @@ -244,7 +319,10 @@ func main() { | |||
| 244 | 319 | Build with `go build -o hello .`, drop the binary + an `extension.json` | |
| 245 | 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 | 327 | ### TypeScript / Python | |
| 250 | 328 | ||
| @@ -266,16 +344,16 @@ trust or run zot under your platform's sandboxing tool (`bwrap` / | |||
| 266 | 344 | ||
| 267 | 345 | ## Roadmap | |
| 268 | 346 | ||
| 269 | - | Phase 1 (this document): | |
| 347 | + | Phase 1 (shipped): | |
| 270 | 348 | - [x] subprocess lifecycle + hello handshake | |
| 271 | 349 | - [x] `register_command` + `command_invoked` | |
| 272 | 350 | - [x] `notify` | |
| 273 | 351 | - [x] `zot ext` CLI | |
| 274 | 352 | ||
| 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 | |
| 279 | 357 | ||
| 280 | 358 | Phase 3: | |
| 281 | 359 | - [ ] 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. Future | 9 | + | 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 start | 17 | ## Quick start | ||
| 14 | 18 | ||||
| @@ -122,8 +126,8 @@ restarted. | |||||
| 122 | ## Wire format | 126 | ## Wire format | ||
| 123 | 127 | ||||
| 124 | All frames are one JSON object per line. Top-level `type` is the | 128 | All frames are one JSON object per line. Top-level `type` is the | ||
| 125 | - | discriminator. Optional `id` correlates command invocations with | 129 | + | discriminator. Optional `id` correlates request frames with their |
| 126 | - | their responses. | 130 | + | responses. |
| 127 | 131 | ||||
| 128 | ### Extension → host | 132 | ### Extension → host | ||
| 129 | 133 | ||||
| @@ -131,7 +135,7 @@ their responses. | |||||
| 131 | 135 | ||||
| 132 | ```json | 136 | ```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 | ```json | 191 | ```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 | ```go | 290 | ```go | ||
| 231 | package main | 291 | 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 / Python | 327 | ### TypeScript / Python | ||
| 250 | 328 | ||||
| @@ -266,16 +344,16 @@ trust or run zot under your platform's sandboxing tool (`bwrap` / | |||||
| 266 | 344 | ||||
| 267 | ## Roadmap | 345 | ## Roadmap | ||
| 268 | 346 | ||||
| 269 | - | Phase 1 (this document): | 347 | + | Phase 1 (shipped): |
| 270 | - [x] subprocess lifecycle + hello handshake | 348 | - [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` CLI | 351 | - [x] `zot ext` CLI | ||
| 274 | 352 | ||||
| 275 | - | Phase 2: | 353 | + | Phase 2 (shipped): |
| 276 | - | - [ ] `register_tool` + `tool_call` + `tool_result` (extension-defined | 354 | + | - [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 attribution | 356 | + | - [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 { | |||
| 31 | 31 | // agent's tool registry, or nil if no SKILL.md files were | |
| 32 | 32 | // discovered. Exposed so the tui can list / preview skills. | |
| 33 | 33 | 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 | 43 | // HasCredential reports whether a credential was resolved. | |
| 37 | 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 | 117 | // Resolve merges args, config, and env into a Resolved set. | |
| 40 | 118 | // | |
| 41 | 119 | // 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 | 216 | 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), | |
| 153 | 234 | }, 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 | 247 | // NewClient returns a provider.Client for r, choosing the auth mode | |
| 157 | 248 | // based on r.AuthMethod. Panics if no credential is present; callers | |
| 158 | 249 | // must check HasCredential() first. | |
| @@ -31,11 +31,89 @@ type Resolved struct { | |||||
| 31 | // agent's tool registry, or nil if no SKILL.md files were | 31 | // 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.Tool | 33 | 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 for | 119 | // 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 | }, nil | 234 | }, 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 mode | 247 | // NewClient returns a provider.Client for r, choosing the auth mode | ||
| 157 | // based on r.AuthMethod. Panics if no credential is present; callers | 248 | // 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) { | |||
| 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 | 89 | // Run is the top-level entrypoint for the zot binary. | |
| 58 | 90 | func Run(rawArgs []string, version string) error { | |
| 59 | 91 | // Subcommand router: `zot bot ...` is handled separately so the | |
| @@ -179,6 +211,26 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 179 | 211 | // rebuilt tool instances must share the same one so the lock sticks. | |
| 180 | 212 | 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 | 234 | // Capture current args in a closure so BuildAgent can re-resolve | |
| 183 | 235 | // after a successful login (picks up the newly stored credential). | |
| 184 | 236 | buildAgent := func() (*core.Agent, string, string, error) { | |
| @@ -187,6 +239,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 187 | 239 | return nil, "", "", err | |
| 188 | 240 | } | |
| 189 | 241 | resolved.UseSandbox(sharedSandbox) | |
| 242 | + | resolved.MergeExtensionTools(extToolAdapter) | |
| 190 | 243 | 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 | 257 | return nil, "", "", err | |
| 205 | 258 | } | |
| 206 | 259 | resolved.UseSandbox(sharedSandbox) | |
| 260 | + | resolved.MergeExtensionTools(extToolAdapter) | |
| 207 | 261 | 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 | 322 | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| 282 | 323 | Terminal: term, | |
| 283 | 324 | 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 the | 91 | // 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.Sandbox | 212 | 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-resolve | 234 | // 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, "", "", err | 239 | 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, nil | 243 | 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, "", "", err | 257 | 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, nil | 261 | 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 { | |||
| 63 | 63 | logFile *os.File | |
| 64 | 64 | helloAck bool | |
| 65 | 65 | 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 | 73 | // pending command invocations waiting on a CommandResponseFromExt | |
| 68 | 74 | // 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 | |
| 71 | 79 | } | |
| 72 | 80 | ||
| 73 | 81 | // HostHooks is the small interface the manager calls back into the | |
| @@ -106,6 +114,10 @@ type Manager struct { | |||
| 106 | 114 | // later registrations of the same command are dropped with a | |
| 107 | 115 | // warning. | |
| 108 | 116 | 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 | 123 | // 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 | 132 | hooks: hooks, | |
| 121 | 133 | ext: map[string]*Extension{}, | |
| 122 | 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 | 208 | 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{}, | |
| 199 | 214 | } | |
| 200 | 215 | if err := m.spawn(ctx, ext); err != nil { | |
| 201 | 216 | return err | |
| @@ -203,18 +218,34 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 203 | 218 | ||
| 204 | 219 | m.mu.Lock() | |
| 205 | 220 | 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. | |
| 214 | 225 | m.mu.Unlock() | |
| 215 | 226 | 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 | 249 | // spawn launches the subprocess, hooks up pipes, logs stderr, and | |
| 219 | 250 | // runs the synchronous portion of the hello handshake. Asynchronous | |
| 220 | 251 | // frames are processed in a goroutine started here. | |
| @@ -292,16 +323,22 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 292 | 323 | // Returns when stdout closes. | |
| 293 | 324 | func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |
| 294 | 325 | 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. | |
| 298 | 329 | m.mu.Lock() | |
| 299 | 330 | for name, owner := range m.commandIndex { | |
| 300 | 331 | if owner == ext { | |
| 301 | 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 | 340 | m.mu.Unlock() | |
| 341 | + | ext.readyOnce.Do(func() { close(ext.readyCh) }) | |
| 305 | 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 | 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 | 402 | case "notify": | |
| 327 | 403 | var n extproto.NotifyFromExt | |
| 328 | 404 | if err := json.Unmarshal(line, &n); err == nil { | |
| @@ -378,6 +454,89 @@ type CommandInfo struct { | |||
| 378 | 454 | 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 | 540 | // HasCommand reports whether name is registered by any extension. | |
| 382 | 541 | func (m *Manager) HasCommand(name string) bool { | |
| 383 | 542 | m.mu.RLock() | |
| @@ -63,11 +63,19 @@ type Extension struct { | |||||
| 63 | logFile *os.File | 63 | logFile *os.File | ||
| 64 | helloAck bool | 64 | helloAck bool | ||
| 65 | commands []extproto.RegisterCommandFromExt | 65 | 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 CommandResponseFromExt | 73 | // 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.Mutex | 75 | + | // pendingTool is the same idea for tool calls. |
| 70 | - | pending map[string]chan extproto.CommandResponseFromExt | 76 | + | 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 the | 81 | // 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 a | 114 | // later registrations of the same command are dropped with a | ||
| 107 | // warning. | 115 | // warning. | ||
| 108 | commandIndex map[string]*Extension | 116 | 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 from | 123 | // 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 err | 216 | 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] = ext | 220 | 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 nil | 226 | 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, and | 249 | // spawn launches the subprocess, hooks up pipes, logs stderr, and | ||
| 219 | // runs the synchronous portion of the hello handshake. Asynchronous | 250 | // 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 future | 326 | + | // On close, drop every command + tool this extension owned so |
| 296 | - | // invocations don't dangle. The subprocess is gone; we won't | 327 | + | // 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.NotifyFromExt | 403 | 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 string | 454 | 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 ( | |||
| 10 | 10 | "os" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "sync" | |
| 13 | + | "time" | |
| 13 | 14 | ||
| 15 | + | "github.com/patriceckhart/zot/internal/agent/extensions" | |
| 14 | 16 | "github.com/patriceckhart/zot/internal/agent/modes" | |
| 15 | 17 | "github.com/patriceckhart/zot/internal/core" | |
| 16 | 18 | "github.com/patriceckhart/zot/internal/provider" | |
| @@ -41,6 +43,20 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 41 | 43 | if err != nil { | |
| 42 | 44 | 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 | 60 | ag := r.NewAgent() | |
| 45 | 61 | ||
| 46 | 62 | server := &rpcServer{ | |
| @@ -52,9 +68,40 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 52 | 68 | out: os.Stdout, | |
| 53 | 69 | version: version, | |
| 54 | 70 | } | |
| 71 | + | extHooks.server = server | |
| 55 | 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 | 105 | type rpcServer struct { | |
| 59 | 106 | ctx context.Context | |
| 60 | 107 | 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 err | 44 | 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.Context | 106 | ctx context.Context | ||
| 60 | args Args | 107 | args Args | ||
M internal/extproto/extproto.go +57 -0
| @@ -50,6 +50,52 @@ type RegisterCommandFromExt struct { | |||
| 50 | 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 | 99 | // CommandResponseFromExt is the extension's answer to a | |
| 54 | 100 | // CommandInvokedFromHost. Action drives what zot does next: | |
| 55 | 101 | // | |
| @@ -107,6 +153,17 @@ type CommandInvokedFromHost struct { | |||
| 107 | 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 | 167 | // ShutdownFromHost asks the extension to clean up and exit. Zot | |
| 111 | 168 | // sends this when the user runs /reload-ext or zot itself is exiting | |
| 112 | 169 | // 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 a | 99 | // 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. Zot | 167 | // ShutdownFromHost asks the extension to clean up and exit. Zot | ||
| 111 | // sends this when the user runs /reload-ext or zot itself is exiting | 168 | // sends this when the user runs /reload-ext or zot itself is exiting | ||
| 112 | // gracefully. Extensions that don't reply within a few seconds get | 169 | // gracefully. Extensions that don't reply within a few seconds get | ||
M pkg/zotext/zotext.go +130 -3
| @@ -28,6 +28,7 @@ package zotext | |||
| 28 | 28 | ||
| 29 | 29 | import ( | |
| 30 | 30 | "bufio" | |
| 31 | + | "encoding/base64" | |
| 31 | 32 | "encoding/json" | |
| 32 | 33 | "fmt" | |
| 33 | 34 | "io" | |
| @@ -37,12 +38,61 @@ import ( | |||
| 37 | 38 | "github.com/patriceckhart/zot/internal/extproto" | |
| 38 | 39 | ) | |
| 39 | 40 | ||
| 41 | + | func base64Encode(b []byte) string { return base64.StdEncoding.EncodeToString(b) } | |
| 42 | + | ||
| 40 | 43 | // CommandHandler is invoked when the user runs the extension's | |
| 41 | 44 | // registered slash command. args is everything the user typed after | |
| 42 | 45 | // the command name (already trimmed). Return a Response describing | |
| 43 | 46 | // what zot should do next. | |
| 44 | 47 | 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 | 96 | // Response tells zot how to react to a command invocation. Construct | |
| 47 | 97 | // one with Prompt(), Insert(), Display(), or Noop(). | |
| 48 | 98 | type Response struct { | |
| @@ -91,10 +141,12 @@ type Extension struct { | |||
| 91 | 141 | mu sync.Mutex | |
| 92 | 142 | commands map[string]CommandHandler | |
| 93 | 143 | 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 | 147 | // 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. | |
| 98 | 150 | caps []string | |
| 99 | 151 | ||
| 100 | 152 | // hostInfo is filled in once HelloAck arrives. | |
| @@ -105,6 +157,12 @@ type descTuple struct { | |||
| 105 | 157 | name, desc string | |
| 106 | 158 | } | |
| 107 | 159 | ||
| 160 | + | type toolDef struct { | |
| 161 | + | name string | |
| 162 | + | description string | |
| 163 | + | schema json.RawMessage | |
| 164 | + | } | |
| 165 | + | ||
| 108 | 166 | // HostInfo is what the host (zot) tells us in HelloAck. Useful for | |
| 109 | 167 | // extensions that want to behave differently per provider. | |
| 110 | 168 | type HostInfo struct { | |
| @@ -125,7 +183,8 @@ func New(name, version string) *Extension { | |||
| 125 | 183 | out: os.Stdout, | |
| 126 | 184 | stderr: os.Stderr, | |
| 127 | 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 | 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 | 230 | // Notify pushes an info-level status note into zot's chat without | |
| 158 | 231 | // requiring a slash command from the user. | |
| 159 | 232 | func (e *Extension) Notify(level, message string) { | |
| @@ -180,6 +253,7 @@ func (e *Extension) Run() error { | |||
| 180 | 253 | } | |
| 181 | 254 | e.mu.Lock() | |
| 182 | 255 | descs := append([]descTuple(nil), e.descriptions...) | |
| 256 | + | toolDefs := append([]toolDef(nil), e.toolDefs...) | |
| 183 | 257 | e.mu.Unlock() | |
| 184 | 258 | for _, d := range descs { | |
| 185 | 259 | _ = e.send(extproto.RegisterCommandFromExt{ | |
| @@ -188,6 +262,19 @@ func (e *Extension) Run() error { | |||
| 188 | 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 | 279 | scanner := bufio.NewScanner(e.in) | |
| 193 | 280 | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | |
| @@ -233,6 +320,27 @@ func (e *Extension) Run() error { | |||
| 233 | 320 | resp := fn(args) | |
| 234 | 321 | e.respond(id, resp) | |
| 235 | 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 | 344 | case "shutdown": | |
| 237 | 345 | _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"}) | |
| 238 | 346 | 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 | 389 | // send marshals v + LF and writes it under a mutex (so concurrent | |
| 263 | 390 | // goroutines don't interleave bytes on stdout). | |
| 264 | 391 | 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's | 43 | // CommandHandler is invoked when the user runs the extension's | ||
| 41 | // registered slash command. args is everything the user typed after | 44 | // registered slash command. args is everything the user typed after | ||
| 42 | // the command name (already trimmed). Return a Response describing | 45 | // 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) Response | 47 | 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. Construct | 96 | // 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.Mutex | 141 | mu sync.Mutex | ||
| 92 | commands map[string]CommandHandler | 142 | commands map[string]CommandHandler | ||
| 93 | descriptions []descTuple // ordered so register frames arrive in registration order | 143 | 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 live | 148 | + | // and "tools" automatically; future capabilities (events) will |
| 97 | - | // here when those phases land. | 149 | + | // live here when those phases land. |
| 98 | caps []string | 150 | 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 string | 157 | 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 for | 166 | // 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 without | 230 | // 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 nil | 346 | 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 concurrent | 389 | // 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 { | ||