Repository navigation
Expand file tree
/
Copy pathfunc_source.go
More file actions
181 lines (167 loc) · 6.49 KB
/
Copy pathfunc_source.go
File metadata and controls
181 lines (167 loc) · 6.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
package agent
import (
"context"
"encoding/json"
"fmt"
"sync"
"github.com/panyam/mcpkit/core"
)
// FuncSource serves host-local Go functions as tools, so an agent can carry
// small utilities (dates, math, environment lookups) without running an MCP
// server for them. Registration is typed via AddFunc; schemas come from
// core.GenerateSchema on the input struct.
type FuncSource struct {
mu sync.RWMutex
defs []core.ToolDef
handlers map[string]func(ctx context.Context, args map[string]any) (*core.ToolResult, error)
}
// NewFuncSource returns an empty source; register tools with AddFunc or
// AddToolFunc before handing it to a Runner.
func NewFuncSource() *FuncSource {
return &FuncSource{
handlers: make(map[string]func(ctx context.Context, args map[string]any) (*core.ToolResult, error)),
}
}
// FuncOption adjusts the tool definition AddFunc or AddAsyncFunc builds.
type FuncOption func(*core.ToolDef)
// ReadOnly sets readOnlyHint on the tool. An approval policy such as
// TieredApproval in ModeReadOnlyAuto or ModeReversibleAuto then runs it without
// asking, and the Runner treats it as non-destructive and idempotent. Leave it
// off for anything that writes, sends, or starts work: the spec's default for
// an unannotated tool is the cautious one.
func ReadOnly() FuncOption {
return func(d *core.ToolDef) {
if d.Annotations == nil {
d.Annotations = map[string]any{}
}
d.Annotations["readOnlyHint"] = true
}
}
func funcToolDef[In any](name, description string, opts []FuncOption) (core.ToolDef, error) {
var schema any
if err := json.Unmarshal(core.GenerateSchema[In](), &schema); err != nil {
return core.ToolDef{}, fmt.Errorf("agent: schema generation for %q: %w", name, err)
}
def := core.ToolDef{Name: name, Description: description, InputSchema: schema}
for _, o := range opts {
o(&def)
}
return def, nil
}
// AddFunc registers a typed function as a tool. Arguments are decoded into
// In via JSON round-trip and validated only by that decoding; the returned
// string becomes a single text content item. A handler error becomes an
// IsError tool result (the model sees the failure), not a dispatch error.
// Registering a duplicate name returns an error. Options adjust the
// definition, for instance ReadOnly for a tool that changes nothing.
func AddFunc[In any](s *FuncSource, name, description string, fn func(ctx context.Context, in In) (string, error), opts ...FuncOption) error {
def, err := funcToolDef[In](name, description, opts)
if err != nil {
return err
}
return s.AddToolFunc(def,
func(ctx context.Context, args map[string]any) (*core.ToolResult, error) {
var in In
raw, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("agent: encode args for %q: %w", name, err)
}
if err := json.Unmarshal(raw, &in); err != nil {
return errorToolResult(fmt.Sprintf("invalid arguments: %v", err)), nil
}
out, err := fn(ctx, in)
if err != nil {
return errorToolResult(err.Error()), nil
}
return &core.ToolResult{Content: []core.Content{{Type: "text", Text: out}}}, nil
})
}
// AddToolFunc registers a tool with full control over the definition and the
// result shape. Use this when the tool needs structured content, a custom
// output schema, or non-text content items.
func (s *FuncSource) AddToolFunc(def core.ToolDef, fn func(ctx context.Context, args map[string]any) (*core.ToolResult, error)) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.handlers[def.Name]; exists {
return fmt.Errorf("agent: tool %q already registered", def.Name)
}
s.defs = append(s.defs, def)
s.handlers[def.Name] = fn
return nil
}
// Tools returns the registered definitions in registration order.
func (s *FuncSource) Tools(ctx context.Context) ([]core.ToolDef, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]core.ToolDef, len(s.defs))
copy(out, s.defs)
return out, nil
}
// Call dispatches to the registered handler. Unknown names are dispatch
// errors; handler failures registered via AddFunc surface as IsError results.
func (s *FuncSource) Call(ctx context.Context, name string, args map[string]any) (*core.ToolResult, error) {
s.mu.RLock()
fn, ok := s.handlers[name]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownTool, name)
}
return fn(ctx, args)
}
func errorToolResult(msg string) *core.ToolResult {
return &core.ToolResult{
IsError: true,
Content: []core.Content{{Type: "text", Text: msg}},
}
}
// AsyncResult is what an AddAsyncFunc handler returns: an immediate
// acknowledgment (shown to the model as the tool result now), plus an
// optional completion whose event is injected when the background work
// finishes.
type AsyncResult struct {
// Ack is the model-facing text returned immediately (e.g. "job started").
Ack string
// Await, when non-nil, runs in a goroutine; its returned event is
// handed to onComplete when it finishes. Nil means fire-and-forget.
Await func(ctx context.Context) (IncomingEvent, error)
}
// AddAsyncFunc registers a host-local tool that does background work without
// a server-side task: the handler returns an ack immediately (satisfying the
// tool-call contract — every call needs one response), and if it supplies an
// Await, that runs on a goroutine and its completion event is delivered to
// onComplete (a host wires onComplete to its injection policy, so the result
// reaches the model on a later turn — the same shape as a task.completed
// event, without a task runtime). For model-visible poll/cancel on local
// work, use a real server-side task instead.
func AddAsyncFunc[In any](s *FuncSource, name, description string,
fn func(ctx context.Context, in In) (AsyncResult, error),
onComplete func(IncomingEvent, error), opts ...FuncOption) error {
def, err := funcToolDef[In](name, description, opts)
if err != nil {
return err
}
return s.AddToolFunc(def,
func(ctx context.Context, args map[string]any) (*core.ToolResult, error) {
var in In
raw, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("agent: encode args for %q: %w", name, err)
}
if err := json.Unmarshal(raw, &in); err != nil {
return errorToolResult(fmt.Sprintf("invalid arguments: %v", err)), nil
}
res, err := fn(ctx, in)
if err != nil {
return errorToolResult(err.Error()), nil
}
if res.Await != nil {
go func() {
ev, err := res.Await(context.WithoutCancel(ctx))
if onComplete != nil {
onComplete(ev, err)
}
}()
}
return &core.ToolResult{Content: []core.Content{{Type: "text", Text: res.Ack}}}, nil
})
}