1package extensions
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "time"
8
9 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/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// ToolResultDetails identifies extension metadata carried out of the
22// subprocess. State is persisted with the active session when present and
23// is never serialized into a provider request.
24type ToolResultDetails struct {
25 Extension string
26 Tool string
27 State json.RawMessage
28}
29
30type extensionTool struct {
31 name string
32 description string
33 schema json.RawMessage
34 extension string
35 manager *Manager
36 timeout time.Duration
37 deferred bool
38}
39
40// NewTool returns a core.Tool that round-trips invocations through
41// mgr to the extension that registered (name, schema). The default
42// per-call timeout is 60 seconds; callers can override.
43func NewTool(mgr *Manager, info ToolInfo) core.Tool {
44 return &extensionTool{
45 name: info.Name,
46 description: info.Description,
47 schema: info.Schema,
48 extension: info.Extension,
49 manager: mgr,
50 timeout: 60 * time.Second,
51 deferred: info.Deferred,
52 }
53}
54
55func (t *extensionTool) Name() string { return t.name }
56func (t *extensionTool) Description() string { return t.description }
57func (t *extensionTool) Schema() json.RawMessage { return t.schema }
58func (t *extensionTool) Extension() string { return t.extension }
59func (t *extensionTool) Deferred() bool { return t.deferred }
60
61// Execute is what the agent calls when the LLM invokes the tool. It
62// hands args to the owning extension, waits up to t.timeout for the
63// reply, and converts the response into a core.ToolResult.
64func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ func(string)) (core.ToolResult, error) {
65 if len(args) == 0 {
66 args = json.RawMessage(`{}`)
67 }
68 resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)
69 if err != nil {
70 return core.ToolResult{
71 IsError: true,
72 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},
73 }, nil
74 }
75 out := core.ToolResult{IsError: resp.IsError, ActivateTools: resp.ActivateTools}
76 for _, b := range resp.Content {
77 switch b.Type {
78 case "text":
79 if b.Text != "" {
80 out.Content = append(out.Content, provider.TextBlock{Text: b.Text})
81 }
82 case "image":
83 data, dErr := decodeBase64(b.Data)
84 if dErr != nil {
85 out.IsError = true
86 out.Content = append(out.Content, provider.TextBlock{Text: fmt.Sprintf("extension %s/%s returned undecodable image: %v", t.extension, t.name, dErr)})
87 continue
88 }
89 out.Content = append(out.Content, provider.ImageBlock{
90 MimeType: b.MimeType,
91 Data: data,
92 })
93 default:
94 // Unknown block type: stringify for debug visibility.
95 out.Content = append(out.Content, provider.TextBlock{Text: fmt.Sprintf("[unknown block type %q from extension %s]", b.Type, t.extension)})
96 }
97 }
98 if len(out.Content) == 0 {
99 // Defensive: an empty content slice would confuse the model.
100 out.Content = []provider.Content{provider.TextBlock{Text: "(extension returned no content)"}}
101 }
102 out.Details = ToolResultDetails{
103 Extension: t.extension,
104 Tool: t.name,
105 State: append(json.RawMessage(nil), resp.Details...),
106 }
107 return out, nil
108}
109
110// decodeBase64 is a tiny wrapper around encoding/base64 so we can
111// validate the extension's image data in one place.
112func decodeBase64(s string) ([]byte, error) {
113 if s == "" {
114 return nil, nil
115 }
116 return base64DecodeStd(s)
117}