返回 DeepSeek-Reasonix
tool.go
根目录 / internal / tool / tool.go
1 // Package tool defines the Tool abstraction and a Registry. Built-in tools live
2 // in tool/builtin and self-register via init(); plugin-provided tools are added
3 // to a runtime Registry alongside the enabled built-ins. The agent sees only a
4 // *Registry, never the global built-in set directly.
5 package tool
6
7 import (
8 "context"
9 "encoding/json"
10 "sort"
11 "strings"
12 "sync"
13
14 "reasonix/internal/diff"
15 "reasonix/internal/provider"
16 )
17
18 // Tool is a capability the model can invoke.
19 type Tool interface {
20 Name() string
21 Description() string
22 // Schema returns the JSON Schema for the tool's parameters.
23 Schema() json.RawMessage
24 // Execute parses the model-generated raw JSON args and returns result text
25 // to feed back to the model.
26 Execute(ctx context.Context, args json.RawMessage) (string, error)
27 // ReadOnly reports whether the tool has no observable side effects on the
28 // host. The agent parallelises a batch of tool calls only when every call
29 // in the batch is ReadOnly; mixed batches stay sequential so write/read
30 // ordering is preserved. bash and plugin tools must return false because
31 // their effects can't be inferred statically from args.
32 ReadOnly() bool
33 }
34
35 // Previewer is an optional capability a writer Tool may implement: given the
36 // same raw JSON args Execute would receive, compute the file change the call
37 // *would* make — without touching disk. A front-end uses it to show an approval
38 // card or a changed-files panel before the call runs (the permission gate, not
39 // Preview, decides whether it may proceed). Type-assert a Tool to Previewer to
40 // discover support; the file-writing built-ins implement it, most tools do not.
41 type Previewer interface {
42 Preview(args json.RawMessage) (diff.Change, error)
43 }
44
45 // PreviewChange returns the change a writer tool would make for args, or ok=false
46 // when there's nothing renderable: t is read-only, doesn't implement Previewer,
47 // the preview errored (the edit will likely fail too), or the file is binary.
48 func PreviewChange(t Tool, args json.RawMessage) (diff.Change, bool) {
49 if t == nil || t.ReadOnly() {
50 return diff.Change{}, false
51 }
52 pv, ok := t.(Previewer)
53 if !ok {
54 return diff.Change{}, false
55 }
56 ch, err := pv.Preview(args)
57 if err != nil || ch.Binary {
58 return diff.Change{}, false
59 }
60 return ch, true
61 }
62
63 // ImageTool is an optional capability a Tool may implement when its results can
64 // carry images alongside text (e.g. an MCP tool returning a screenshot).
65 // ExecuteWithImages returns the same text Execute would — including a short
66 // placeholder marker where each image occurred — plus the images as data URLs
67 // (data:<mime>;base64,<payload>). Callers with a structural image channel (the
68 // agent stores them on the tool message, where vision-capable providers embed
69 // them) use this instead of Execute; everything else falls back to Execute and
70 // the placeholders alone describe the images. Keeping images out of the text
71 // matters: tool output text is truncated at a fixed byte budget, which would
72 // corrupt an embedded base64 payload.
73 type ImageTool interface {
74 ExecuteWithImages(ctx context.Context, args json.RawMessage) (text string, images []string, err error)
75 }
76
77 // PlanModeClassifier is an optional capability a Tool may implement to declare
78 // its stance on running during the planning phase. It is deliberately distinct
79 // from ReadOnly(): a tool can be side-effect-free yet belong only to the
80 // post-approval execution phase (complete_step reports ReadOnly()==true but must
81 // not run while planning), or be a delegation that is safe only in a read-only
82 // variant (read_only_task). A false result is an explicit phase opt-out; tools
83 // without this interface continue to the ordinary Permissions/Sandbox path.
84 type PlanModeClassifier interface {
85 PlanModeSafe() bool
86 }
87
88 // ReadOnlyExecutionHostMutation marks a target that is logically read-only but
89 // must first mutate host state to become executable, such as starting an
90 // on-demand MCP process. Strict read-only agents reject these targets even when
91 // their eventual remote operation is trusted read-only.
92 type ReadOnlyExecutionHostMutation interface {
93 ReadOnlyExecutionHostMutation() bool
94 }
95
96 // ReadOnlyExecutionBlockReason lets a deferred capability explain which
97 // parent-session action is required when a strict read-only child cannot run
98 // it. The reason is host-local and never enters provider tool schemas.
99 type ReadOnlyExecutionBlockReason interface {
100 ReadOnlyExecutionBlockReason() string
101 }
102
103 // MCPMetadata exposes the original MCP identity behind a model-visible
104 // "mcp__<server>__<tool>" adapter. The model name may be normalized for provider
105 // function-name rules; host policy and diagnostics use the raw server-local
106 // tool name.
107 type MCPMetadata interface {
108 MCPServerName() string
109 MCPRawToolName() string
110 }
111
112 // MCPVisibleMetadata exposes the server-local name after any host-configured
113 // prefix stripping. It is the short name authors usually write in skills.
114 type MCPVisibleMetadata interface {
115 MCPVisibleToolName() string
116 }
117
118 // MCPPackageMetadata identifies the plugin package that contributed an MCP
119 // server. Empty means the server came from ordinary user/workspace config.
120 type MCPPackageMetadata interface {
121 MCPPackageName() string
122 }
123
124 // MCPBinding describes one stable MCP capability and the exact provider-visible
125 // name currently bound to it. Bindings are host metadata only: they never add
126 // aliases to provider schemas or alter schema ordering.
127 type MCPBinding struct {
128 Package string
129 Server string
130 RawName string
131 VisibleName string
132 CallableName string
133 CapabilityID string
134 }
135
136 // MCPAnnotations exposes safety-relevant annotations reported by an installed
137 // MCP server. These hints do not change the provider-visible tool contract;
138 // execution policy consumes them locally.
139 type MCPAnnotations interface {
140 MCPDestructiveHint() bool
141 }
142
143 // MCPServerAuthorization reports whether the user installed this MCP server or
144 // authorized its exact project identity. Authorization belongs to the server,
145 // not to individual tools; readOnly/destructive metadata is checked separately.
146 type MCPServerAuthorization interface {
147 MCPServerAuthorized() bool
148 }
149
150 // readerExecutionIntentKey carries a per-call, immutable authorization basis:
151 // the call was approved as a non-destructive reader. The MCP dispatcher makes
152 // the final, linearizable check against live security state and must never
153 // promote such a call into a writer lane; drift after authorization returns an
154 // error instead of executing.
155 type readerExecutionIntentKey struct{}
156
157 // nonDestructiveMCPExecutionIntentKey carries a per-call, immutable
158 // authorization basis for Planner-trusted MCP: the server is authorized and the
159 // live tool is non-destructive, even when it lacks readOnlyHint. The MCP
160 // dispatcher re-checks authorization and destructiveHint before tools/call;
161 // drift returns a retryable error with zero execution.
162 type nonDestructiveMCPExecutionIntentKey struct{}
163
164 // planReplacementAuthorizationKey carries a one-call authorization from the
165 // host's Auto plan gate. It lets todo_write replace the current in_progress
166 // step after the plan transition has been reviewed, without weakening ordinary
167 // todo continuity or exposing an authorization bit in the model-visible schema.
168 type planReplacementAuthorizationKey struct{}
169
170 // WithReaderExecutionIntent marks ctx as a reader-authorized MCP invocation.
171 func WithReaderExecutionIntent(ctx context.Context) context.Context {
172 return context.WithValue(ctx, readerExecutionIntentKey{}, true)
173 }
174
175 // HasReaderExecutionIntent reports whether this call entered through the
176 // non-destructive reader lane.
177 func HasReaderExecutionIntent(ctx context.Context) bool {
178 intent, _ := ctx.Value(readerExecutionIntentKey{}).(bool)
179 return intent
180 }
181
182 // WithNonDestructiveMCPExecutionIntent marks ctx as a Planner-trusted MCP
183 // invocation: authorized server, non-destructive live tool. Unlike the reader
184 // lane it does not require readOnlyHint.
185 func WithNonDestructiveMCPExecutionIntent(ctx context.Context) context.Context {
186 return context.WithValue(ctx, nonDestructiveMCPExecutionIntentKey{}, true)
187 }
188
189 // HasNonDestructiveMCPExecutionIntent reports whether this call entered through
190 // the Planner non-destructive MCP lane.
191 func HasNonDestructiveMCPExecutionIntent(ctx context.Context) bool {
192 intent, _ := ctx.Value(nonDestructiveMCPExecutionIntentKey{}).(bool)
193 return intent
194 }
195
196 // WithPlanReplacementAuthorization marks one reviewed todo_write invocation as
197 // allowed to replace its current step. Callers must not reuse the returned
198 // context for unrelated tool calls.
199 func WithPlanReplacementAuthorization(ctx context.Context) context.Context {
200 return context.WithValue(ctx, planReplacementAuthorizationKey{}, true)
201 }
202
203 // HasPlanReplacementAuthorization reports whether the host approved replacing
204 // the active step for this exact tool invocation.
205 func HasPlanReplacementAuthorization(ctx context.Context) bool {
206 authorized, _ := ctx.Value(planReplacementAuthorizationKey{}).(bool)
207 return authorized
208 }
209
210 // SnipHint describes how context maintenance should shorten a stale, oversized
211 // result this tool produced. Head/Tail are the line counts kept from each end
212 // when the result has many lines; HeadChars/TailChars bound the kept runes when
213 // the result is one giant line. A zero value is invalid — implementers return
214 // positive counts. The geometry lives on the tool, not in a lookup table keyed
215 // by name, so renaming a tool carries its snip policy with it and a new tool
216 // cannot silently fall back to a generic default unnoticed (the contract test
217 // forces every registered tool to either implement SnipHinter or opt into the
218 // read-only/side-effecting default explicitly).
219 type SnipHint struct {
220 Head int
221 Tail int
222 HeadChars int
223 TailChars int
224 }
225
226 // SnipHinter is an optional capability a Tool implements when its output has a
227 // known shape that a generic head/tail split would garble — e.g. read_file
228 // front-loads the most relevant lines, while bash output is equally meaningful
229 // at both ends. Type-assert a Tool to discover support; tools that omit it take
230 // the ReadOnly-tiered default in the maintainer.
231 type SnipHinter interface {
232 SnipHint() SnipHint
233 }
234
235 // --- process-global built-in set (populated by builtin subpackage init) ---
236
237 var builtins = map[string]Tool{}
238
239 // RegisterBuiltin registers a compile-time built-in tool. Intended for init().
240 // It panics on a duplicate name, which is a compile-time wiring mistake.
241 func RegisterBuiltin(t Tool) {
242 name := t.Name()
243 if _, dup := builtins[name]; dup {
244 panic("tool: duplicate built-in " + name)
245 }
246 builtins[name] = t
247 }
248
249 // Builtins returns all registered built-in tools, sorted by name.
250 func Builtins() []Tool {
251 names := make([]string, 0, len(builtins))
252 for n := range builtins {
253 names = append(names, n)
254 }
255 sort.Strings(names)
256 out := make([]Tool, 0, len(names))
257 for _, n := range names {
258 out = append(out, builtins[n])
259 }
260 return out
261 }
262
263 // LookupBuiltin returns a registered built-in by name.
264 func LookupBuiltin(name string) (Tool, bool) {
265 t, ok := builtins[name]
266 return t, ok
267 }
268
269 // --- per-run registry instance ---
270
271 // Registry is a per-run set of tools: enabled built-ins plus plugin tools.
272 type Registry struct {
273 mu sync.RWMutex
274 tools map[string]Tool
275 order []string
276 canon map[string]json.RawMessage
277 suspended map[string]bool
278 }
279
280 // NewRegistry returns an empty registry.
281 func NewRegistry() *Registry {
282 return &Registry{tools: map[string]Tool{}, canon: map[string]json.RawMessage{}, suspended: map[string]bool{}}
283 }
284
285 // Add inserts (or replaces) a tool, preserving first-seen order. The schema is
286 // canonicalized once here — it never changes after registration, so Schemas()
287 // (called every turn) reuses the result instead of re-marshaling.
288 func (r *Registry) Add(t Tool) {
289 r.mu.Lock()
290 defer r.mu.Unlock()
291
292 name := t.Name()
293 for prefix := range r.suspended {
294 if strings.HasPrefix(name, prefix) {
295 return
296 }
297 }
298 if _, ok := r.tools[name]; !ok {
299 r.order = append(r.order, name)
300 }
301 r.tools[name] = t
302 r.canon[name] = provider.CanonicalizeSchema(t.Schema())
303 }
304
305 // MCPNamePrefix is the namespace every MCP tool name carries: the
306 // model-visible name is "mcp__<server>__<tool>".
307 const MCPNamePrefix = "mcp__"
308
309 // SplitMCPName splits a model-visible MCP tool name "mcp__<server>__<tool>" into
310 // its server and tool parts. ok is false for non-MCP (built-in) names and for
311 // malformed names missing either part.
312 func SplitMCPName(name string) (server, tool string, ok bool) {
313 if !strings.HasPrefix(name, MCPNamePrefix) {
314 return "", "", false
315 }
316 rest := name[len(MCPNamePrefix):]
317 parts := strings.SplitN(rest, "__", 2)
318 if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
319 return "", "", false
320 }
321 return parts[0], parts[1], true
322 }
323
324 // RemovePrefix unregisters every tool whose name starts with prefix — used to
325 // drop an MCP server's "mcp__<server>__" namespace when it's disconnected — and
326 // returns the count removed.
327 func (r *Registry) RemovePrefix(prefix string) int {
328 r.mu.Lock()
329 defer r.mu.Unlock()
330
331 kept := r.order[:0]
332 removed := 0
333 for _, name := range r.order {
334 if strings.HasPrefix(name, prefix) {
335 delete(r.tools, name)
336 delete(r.canon, name)
337 removed++
338 continue
339 }
340 kept = append(kept, name)
341 }
342 r.order = kept
343 return removed
344 }
345
346 // SuspendPrefix unregisters matching tools and prevents future Add calls for
347 // that prefix until ResumePrefix is called. It is used for per-session MCP
348 // disables where an in-flight background handshake may otherwise swap tools back
349 // into this registry after the user turned the server off.
350 func (r *Registry) SuspendPrefix(prefix string) int {
351 r.mu.Lock()
352 defer r.mu.Unlock()
353
354 r.suspended[prefix] = true
355 kept := r.order[:0]
356 removed := 0
357 for _, name := range r.order {
358 if strings.HasPrefix(name, prefix) {
359 delete(r.tools, name)
360 delete(r.canon, name)
361 removed++
362 continue
363 }
364 kept = append(kept, name)
365 }
366 r.order = kept
367 return removed
368 }
369
370 // ResumePrefix allows future Add calls for a previously suspended prefix.
371 func (r *Registry) ResumePrefix(prefix string) {
372 r.mu.Lock()
373 defer r.mu.Unlock()
374 delete(r.suspended, prefix)
375 }
376
377 // Get looks up a tool by name.
378 func (r *Registry) Get(name string) (Tool, bool) {
379 r.mu.RLock()
380 defer r.mu.RUnlock()
381
382 t, ok := r.tools[name]
383 return t, ok
384 }
385
386 // MCPBindings returns live MCP capability bindings in canonical-name order.
387 func (r *Registry) MCPBindings() []MCPBinding {
388 r.mu.RLock()
389 defer r.mu.RUnlock()
390
391 out := make([]MCPBinding, 0, len(r.tools))
392 for _, t := range r.tools {
393 if b, ok := mcpBinding(t); ok {
394 out = append(out, b)
395 }
396 }
397 sort.Slice(out, func(i, j int) bool { return out[i].CallableName < out[j].CallableName })
398 return out
399 }
400
401 // ResolveCall resolves an exact provider-visible name or a unique portable MCP
402 // reference. Exact names always win. Ambiguous aliases return their canonical
403 // candidates and are never executed.
404 func (r *Registry) ResolveCall(name string) (resolved Tool, canonical string, candidates []string) {
405 r.mu.RLock()
406 defer r.mu.RUnlock()
407
408 if t, ok := r.tools[name]; ok {
409 return t, name, nil
410 }
411 matches := map[string]Tool{}
412 for canonicalName, t := range r.tools {
413 b, ok := mcpBinding(t)
414 if !ok {
415 continue
416 }
417 for _, alias := range mcpBindingAliases(b) {
418 if name == alias {
419 matches[canonicalName] = t
420 break
421 }
422 }
423 }
424 if len(matches) == 1 {
425 for canonicalName, t := range matches {
426 return t, canonicalName, nil
427 }
428 }
429 if len(matches) > 1 {
430 candidates = make([]string, 0, len(matches))
431 for canonicalName := range matches {
432 candidates = append(candidates, canonicalName)
433 }
434 sort.Strings(candidates)
435 }
436 return nil, "", candidates
437 }
438
439 func mcpBinding(t Tool) (MCPBinding, bool) {
440 meta, ok := t.(MCPMetadata)
441 if !ok {
442 return MCPBinding{}, false
443 }
444 server := strings.TrimSpace(meta.MCPServerName())
445 raw := strings.TrimSpace(meta.MCPRawToolName())
446 if server == "" || raw == "" {
447 return MCPBinding{}, false
448 }
449 visible := raw
450 if v, ok := t.(MCPVisibleMetadata); ok && strings.TrimSpace(v.MCPVisibleToolName()) != "" {
451 visible = strings.TrimSpace(v.MCPVisibleToolName())
452 }
453 pkg := ""
454 if p, ok := t.(MCPPackageMetadata); ok {
455 pkg = strings.TrimSpace(p.MCPPackageName())
456 }
457 return MCPBinding{
458 Package: pkg,
459 Server: server,
460 RawName: raw,
461 VisibleName: visible,
462 CallableName: t.Name(),
463 CapabilityID: "mcp-tool:" + server + "/" + raw,
464 }, true
465 }
466
467 func mcpBindingAliases(b MCPBinding) []string {
468 aliases := []string{
469 b.RawName,
470 b.VisibleName,
471 b.Server + "/" + b.RawName,
472 b.Server + "/" + b.VisibleName,
473 b.CapabilityID,
474 "mcp-tool:" + b.Server + "/" + b.VisibleName,
475 "mcp__" + portableMCPPart(b.Server) + "__" + portableMCPPart(b.RawName),
476 "mcp__" + portableMCPPart(b.Server) + "__" + portableMCPPart(b.VisibleName),
477 }
478 if b.Package != "" {
479 prefix := "mcp__plugin_" + portableMCPPart(b.Package) + "_" + portableMCPPart(b.Server) + "__"
480 aliases = append(aliases, prefix+portableMCPPart(b.RawName), prefix+portableMCPPart(b.VisibleName))
481 }
482 return aliases
483 }
484
485 // MCPBindingAliases returns accepted portable references for a binding. The
486 // canonical provider-visible name remains MCPBinding.CallableName.
487 func MCPBindingAliases(b MCPBinding) []string {
488 return append([]string(nil), mcpBindingAliases(b)...)
489 }
490
491 func portableMCPPart(s string) string {
492 var b strings.Builder
493 for _, r := range s {
494 switch {
495 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
496 b.WriteRune(r)
497 default:
498 b.WriteByte('_')
499 }
500 }
501 return b.String()
502 }
503
504 // Len returns the number of registered tools.
505 func (r *Registry) Len() int {
506 r.mu.RLock()
507 defer r.mu.RUnlock()
508
509 return len(r.order)
510 }
511
512 // Names returns the registered tool names in insertion order.
513 func (r *Registry) Names() []string {
514 r.mu.RLock()
515 defer r.mu.RUnlock()
516
517 out := make([]string, len(r.order))
518 copy(out, r.order)
519 return out
520 }
521
522 // Schemas exports tool definitions in stable name order for the provider.
523 func (r *Registry) Schemas() []provider.ToolSchema {
524 r.mu.RLock()
525 defer r.mu.RUnlock()
526
527 names := make([]string, len(r.order))
528 copy(names, r.order)
529 sort.Strings(names)
530
531 out := make([]provider.ToolSchema, 0, len(names))
532 for _, name := range names {
533 t := r.tools[name]
534 if t == nil {
535 continue
536 }
537 out = append(out, provider.ToolSchema{
538 Name: t.Name(),
539 Description: t.Description(),
540 Parameters: r.canon[name],
541 })
542 }
543 return out
544 }
545
545 lines GO