返回 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 "slices"
11 "sort"
12 "strings"
13 "sync"
14 "sync/atomic"
15
16 "reasonix/internal/diff"
17 "reasonix/internal/provider"
18 )
19
20 // Tool is a capability the model can invoke.
21 type Tool interface {
22 Name() string
23 Description() string
24 // Schema returns the JSON Schema for the tool's parameters.
25 Schema() json.RawMessage
26 // Execute parses the model-generated raw JSON args and returns result text
27 // to feed back to the model.
28 Execute(ctx context.Context, args json.RawMessage) (string, error)
29 // ReadOnly reports whether the tool has no observable side effects on the
30 // host. The agent parallelises a batch of tool calls only when every call
31 // in the batch is ReadOnly; mixed batches stay sequential so write/read
32 // ordering is preserved. bash and plugin tools must return false because
33 // their effects can't be inferred statically from args.
34 ReadOnly() bool
35 }
36
37 // IsShellToolName reports current and compatibility names for the built-in
38 // command shell. It keeps policy, evidence, and UI routing stable while Windows
39 // exposes pwsh and older sessions continue to contain bash calls.
40 func IsShellToolName(name string) bool {
41 switch strings.ToLower(strings.TrimSpace(name)) {
42 case "bash", "pwsh", "powershell", "shell":
43 return true
44 default:
45 return false
46 }
47 }
48
49 // CallClass is a pure, argument-aware dispatch classification. Generation is a
50 // target schema/lifecycle fingerprint checked again by the execution adapter;
51 // an empty generation keeps the call on the serial path.
52 type CallClass struct {
53 Known bool
54 ReadOnly bool
55 ParallelSafe bool
56 ResourceKey string
57 Generation string
58 }
59
60 // BatchClassifier lets a fixed proxy classify its resolved target without
61 // executing discovery, starting a process, or making a network request.
62 type BatchClassifier interface {
63 ClassifyCall(json.RawMessage) CallClass
64 }
65
66 // ContextualTool is an execution-time availability contract for tools whose
67 // ownership depends on the active workflow context. Provider schemas remain
68 // static for cache stability; the host must still consult this contract before
69 // permissions, hooks, leases, or Execute so stale transcripts fail closed.
70 type ContextualTool interface {
71 ProviderVisible(context.Context) bool
72 }
73
74 // CapabilityCatalogHidden marks compatibility-only routes that remain
75 // executable for replay but must not be suggested to new model turns.
76 type CapabilityCatalogHidden interface {
77 HiddenFromCapabilityCatalog() bool
78 }
79
80 // Previewer is an optional capability a writer Tool may implement: given the
81 // same raw JSON args Execute would receive, compute the file change the call
82 // *would* make — without touching disk. ctx must be Execute's, so the preview
83 // resolves through the same FileOverlay and a user never approves a diff that
84 // differs from what runs. Type-assert to discover support; the file-writing
85 // built-ins implement it, most tools do not.
86 type Previewer interface {
87 Preview(ctx context.Context, args json.RawMessage) (diff.Change, error)
88 }
89
90 // PreviewChange returns the change a writer tool would make for args, or ok=false
91 // when there's nothing renderable: t is read-only, doesn't implement Previewer,
92 // the preview errored (the edit will likely fail too), or the file is binary.
93 func PreviewChange(ctx context.Context, t Tool, args json.RawMessage) (diff.Change, bool) {
94 if t == nil || t.ReadOnly() {
95 return diff.Change{}, false
96 }
97 pv, ok := t.(Previewer)
98 if !ok {
99 return diff.Change{}, false
100 }
101 ch, err := pv.Preview(ctx, args)
102 if err != nil || ch.Binary {
103 return diff.Change{}, false
104 }
105 return ch, true
106 }
107
108 // ImageTool is an optional capability a Tool may implement when its results can
109 // carry images alongside text (e.g. an MCP tool returning a screenshot).
110 // ExecuteWithImages returns the same text Execute would — including a short
111 // placeholder marker where each image occurred — plus the images as data URLs
112 // (data:<mime>;base64,<payload>). Callers with a structural image channel (the
113 // agent stores them on the tool message, where vision-capable providers embed
114 // them) use this instead of Execute; everything else falls back to Execute and
115 // the placeholders alone describe the images. Keeping images out of the text
116 // matters: tool output text is truncated at a fixed byte budget, which would
117 // corrupt an embedded base64 payload.
118 type ImageTool interface {
119 ExecuteWithImages(ctx context.Context, args json.RawMessage) (text string, images []string, err error)
120 }
121
122 // PresentedFile is host-only metadata emitted by the built-in present tool.
123 // Path is the exact stable resource reference recorded in the conversation; it
124 // may be relative to the session workspace or an authorized absolute path.
125 // File bytes never travel through this structure or provider requests.
126 type PresentedFile struct {
127 Path string `json:"path"`
128 Description string `json:"description,omitempty"`
129 }
130
131 type presentedFilesCollectorKey struct{}
132
133 // WithPresentedFilesCollector installs the per-call collector consumed by the
134 // agent after a successful execution. Keeping this out of the model-visible
135 // result lets presentation metadata share the tool-result commit boundary.
136 func WithPresentedFilesCollector(ctx context.Context) (context.Context, func() []PresentedFile) {
137 var files []PresentedFile
138 ctx = context.WithValue(ctx, presentedFilesCollectorKey{}, &files)
139 return ctx, func() []PresentedFile { return append([]PresentedFile(nil), files...) }
140 }
141
142 // RecordPresentedFiles publishes a validated, successful present result to the
143 // current call collector. It is intentionally a no-op outside an agent call.
144 func RecordPresentedFiles(ctx context.Context, files []PresentedFile) {
145 target, _ := ctx.Value(presentedFilesCollectorKey{}).(*[]PresentedFile)
146 if target == nil {
147 return
148 }
149 *target = append((*target)[:0], files...)
150 }
151
152 // PlanModeClassifier is an optional capability a Tool may implement to declare
153 // its stance on running during the planning phase. It is deliberately distinct
154 // from ReadOnly(): a tool can be a delegation that is safe only in a read-only
155 // variant (read_only_task). A false result is an explicit phase opt-out; tools
156 // without this interface continue to the ordinary Permissions/Sandbox path.
157 type PlanModeClassifier interface {
158 PlanModeSafe() bool
159 }
160
161 // ReadOnlyExecutionHostMutation marks a target that is logically read-only but
162 // must first mutate host state to become executable, such as starting an
163 // on-demand MCP process. Strict read-only agents reject these targets even when
164 // their eventual remote operation is trusted read-only.
165 type ReadOnlyExecutionHostMutation interface {
166 ReadOnlyExecutionHostMutation() bool
167 }
168
169 // ReadOnlyExecutionBlockReason lets a deferred capability explain which
170 // parent-session action is required when a strict read-only child cannot run
171 // it. The reason is host-local and never enters provider tool schemas.
172 type ReadOnlyExecutionBlockReason interface {
173 ReadOnlyExecutionBlockReason() string
174 }
175
176 // MCPMetadata exposes the original MCP identity behind a model-visible
177 // "mcp__<server>__<tool>" adapter. The model name may be normalized for provider
178 // function-name rules; host policy and diagnostics use the raw server-local
179 // tool name.
180 type MCPMetadata interface {
181 MCPServerName() string
182 MCPRawToolName() string
183 }
184
185 // MCPVisibleMetadata exposes the server-local name after any host-configured
186 // prefix stripping. It is the short name authors usually write in skills.
187 type MCPVisibleMetadata interface {
188 MCPVisibleToolName() string
189 }
190
191 // MCPPackageMetadata identifies the plugin package that contributed an MCP
192 // server. Empty means the server came from ordinary user/workspace config.
193 type MCPPackageMetadata interface {
194 MCPPackageName() string
195 }
196
197 // MCPBinding describes one stable MCP capability and the exact provider-visible
198 // name currently bound to it. Bindings are host metadata only: they never add
199 // aliases to provider schemas or alter schema ordering.
200 type MCPBinding struct {
201 Package string
202 Server string
203 RawName string
204 VisibleName string
205 CallableName string
206 CapabilityID string
207 }
208
209 // MCPAnnotations exposes safety-relevant annotations reported by an installed
210 // MCP server. These hints do not change the provider-visible tool contract;
211 // execution policy consumes them locally.
212 type MCPAnnotations interface {
213 MCPDestructiveHint() bool
214 }
215
216 // MCPServerAuthorization reports whether the user installed this MCP server or
217 // authorized its exact project identity. Authorization belongs to the server,
218 // not to individual tools; readOnly/destructive metadata is checked separately.
219 type MCPServerAuthorization interface {
220 MCPServerAuthorized() bool
221 }
222
223 // readerExecutionIntentKey carries a per-call, immutable authorization basis:
224 // the call was approved as a non-destructive reader. The MCP dispatcher makes
225 // the final, linearizable check against live security state and must never
226 // promote such a call into a writer lane; drift after authorization returns an
227 // error instead of executing.
228 type readerExecutionIntentKey struct{}
229
230 // nonDestructiveMCPExecutionIntentKey carries a per-call, immutable
231 // authorization basis for Planner-trusted MCP: the server is authorized and the
232 // live tool is non-destructive, even when it lacks readOnlyHint. The MCP
233 // dispatcher re-checks authorization and destructiveHint before tools/call;
234 // drift returns a retryable error with zero execution.
235 type nonDestructiveMCPExecutionIntentKey struct{}
236
237 // WithReaderExecutionIntent marks ctx as a reader-authorized MCP invocation.
238 func WithReaderExecutionIntent(ctx context.Context) context.Context {
239 return context.WithValue(ctx, readerExecutionIntentKey{}, true)
240 }
241
242 // HasReaderExecutionIntent reports whether this call entered through the
243 // non-destructive reader lane.
244 func HasReaderExecutionIntent(ctx context.Context) bool {
245 intent, _ := ctx.Value(readerExecutionIntentKey{}).(bool)
246 return intent
247 }
248
249 // WithNonDestructiveMCPExecutionIntent marks ctx as a Planner-trusted MCP
250 // invocation: authorized server, non-destructive live tool. Unlike the reader
251 // lane it does not require readOnlyHint.
252 func WithNonDestructiveMCPExecutionIntent(ctx context.Context) context.Context {
253 return context.WithValue(ctx, nonDestructiveMCPExecutionIntentKey{}, true)
254 }
255
256 // HasNonDestructiveMCPExecutionIntent reports whether this call entered through
257 // the Planner non-destructive MCP lane.
258 func HasNonDestructiveMCPExecutionIntent(ctx context.Context) bool {
259 intent, _ := ctx.Value(nonDestructiveMCPExecutionIntentKey{}).(bool)
260 return intent
261 }
262
263 // SnipHint describes how context maintenance should shorten a stale, oversized
264 // result this tool produced. Head/Tail are the line counts kept from each end
265 // when the result has many lines; HeadChars/TailChars bound the kept runes when
266 // the result is one giant line. A zero value is invalid — implementers return
267 // positive counts. The geometry lives on the tool, not in a lookup table keyed
268 // by name, so renaming a tool carries its snip policy with it and a new tool
269 // cannot silently fall back to a generic default unnoticed (the contract test
270 // forces every registered tool to either implement SnipHinter or opt into the
271 // read-only/side-effecting default explicitly).
272 type SnipHint struct {
273 Head int
274 Tail int
275 HeadChars int
276 TailChars int
277 }
278
279 // SnipHinter is an optional capability a Tool implements when its output has a
280 // known shape that a generic head/tail split would garble — e.g. read_file
281 // front-loads the most relevant lines, while bash output is equally meaningful
282 // at both ends. Type-assert a Tool to discover support; tools that omit it take
283 // the ReadOnly-tiered default in the maintainer.
284 type SnipHinter interface {
285 SnipHint() SnipHint
286 }
287
288 // process-global built-in set (populated by builtin subpackage init)
289
290 var builtins = map[string]Tool{}
291
292 // RegisterBuiltin registers a compile-time built-in tool. Intended for init().
293 // It panics on a duplicate name, which is a compile-time wiring mistake.
294 func RegisterBuiltin(t Tool) {
295 name := t.Name()
296 if _, dup := builtins[name]; dup {
297 panic("tool: duplicate built-in " + name)
298 }
299 builtins[name] = t
300 }
301
302 // Builtins returns all registered built-in tools, sorted by name.
303 func Builtins() []Tool {
304 names := make([]string, 0, len(builtins))
305 for n := range builtins {
306 if n == "complete_step" || n == "session_read_strategy_receipt" {
307 continue
308 }
309 names = append(names, n)
310 }
311 sort.Strings(names)
312 out := make([]Tool, 0, len(names))
313 for _, n := range names {
314 out = append(out, builtins[n])
315 }
316 return out
317 }
318
319 // LookupBuiltin returns a registered built-in by name.
320 func LookupBuiltin(name string) (Tool, bool) {
321 if name == "complete_step" || name == "session_read_strategy_receipt" {
322 return nil, false
323 }
324 t, ok := builtins[name]
325 return t, ok
326 }
327
328 // per-run registry instance
329
330 // Registry is a per-run set of tools: enabled built-ins plus plugin tools.
331 type Registry struct {
332 mu sync.RWMutex
333 tools map[string]Tool
334 order []string
335 canon map[string]json.RawMessage
336 suspended map[string]bool
337 // providerVisible, when non-nil, restricts Schemas/ContractEntries to the
338 // listed tool names. Get/Execute still resolve every registered tool so
339 // use_capability can dispatch tool:<name> without changing the provider
340 // schema. Nil means every registered tool is provider-visible (tests and
341 // legacy direct construction).
342 providerVisible map[string]bool
343 schemaRev atomic.Uint64
344 }
345
346 // NewRegistry returns an empty registry.
347 func NewRegistry() *Registry {
348 return &Registry{tools: map[string]Tool{}, canon: map[string]json.RawMessage{}, suspended: map[string]bool{}}
349 }
350
351 // SetProviderVisibleTools restricts the provider-visible schema surface to the
352 // given names while keeping all registered tools executable via Get. Passing
353 // nil clears the restriction. Names are normalized with strings.TrimSpace.
354 func (r *Registry) SetProviderVisibleTools(names []string) {
355 if r == nil {
356 return
357 }
358 r.mu.Lock()
359 defer r.mu.Unlock()
360 if names == nil {
361 if r.providerVisible != nil {
362 r.providerVisible = nil
363 r.schemaRev.Add(1)
364 }
365 return
366 }
367 visible := make(map[string]bool, len(names))
368 for _, name := range names {
369 name = strings.TrimSpace(name)
370 if name != "" {
371 visible[name] = true
372 }
373 }
374 changed := len(visible) != len(r.providerVisible) || r.providerVisible == nil
375 if !changed {
376 for name := range visible {
377 if !r.providerVisible[name] {
378 changed = true
379 break
380 }
381 }
382 }
383 r.providerVisible = visible
384 if changed {
385 r.schemaRev.Add(1)
386 }
387 }
388
389 // ProviderVisible reports whether name is currently provider-visible.
390 func (r *Registry) ProviderVisible(name string) bool {
391 if r == nil {
392 return false
393 }
394 r.mu.RLock()
395 defer r.mu.RUnlock()
396 if r.providerVisible == nil {
397 return true
398 }
399 return r.providerVisible[strings.TrimSpace(name)]
400 }
401
402 func (r *Registry) isProviderVisibleLocked(name string) bool {
403 if r.providerVisible == nil {
404 return true
405 }
406 return r.providerVisible[name]
407 }
408
409 // Add inserts (or replaces) a tool, preserving first-seen order. The schema is
410 // canonicalized once here — it never changes after registration, so Schemas()
411 // (called every turn) reuses the result instead of re-marshaling.
412 func (r *Registry) Add(t Tool) {
413 r.mu.Lock()
414 defer r.mu.Unlock()
415
416 name := t.Name()
417 for prefix := range r.suspended {
418 if strings.HasPrefix(name, prefix) {
419 return
420 }
421 }
422 if _, ok := r.tools[name]; !ok {
423 r.order = append(r.order, name)
424 }
425 r.tools[name] = t
426 r.canon[name] = provider.CanonicalizeSchema(t.Schema())
427 r.schemaRev.Add(1)
428 }
429
430 // Remove unregisters one exact tool name. Compatibility routing remains the
431 // responsibility of ResolveCall, so an old name can stay executable without
432 // appearing in schemas or capability catalogs.
433 func (r *Registry) Remove(name string) bool {
434 r.mu.Lock()
435 defer r.mu.Unlock()
436 if _, ok := r.tools[name]; !ok {
437 return false
438 }
439 delete(r.tools, name)
440 delete(r.canon, name)
441 for i, registered := range r.order {
442 if registered == name {
443 r.order = append(r.order[:i], r.order[i+1:]...)
444 break
445 }
446 }
447 delete(r.providerVisible, name)
448 r.schemaRev.Add(1)
449 return true
450 }
451
452 // MCPNamePrefix is the namespace every MCP tool name carries: the
453 // model-visible name is "mcp__<server>__<tool>".
454 const MCPNamePrefix = "mcp__"
455
456 // SplitMCPName splits a model-visible MCP tool name "mcp__<server>__<tool>" into
457 // its server and tool parts. ok is false for non-MCP (built-in) names and for
458 // malformed names missing either part.
459 func SplitMCPName(name string) (server, tool string, ok bool) {
460 if !strings.HasPrefix(name, MCPNamePrefix) {
461 return "", "", false
462 }
463 rest := name[len(MCPNamePrefix):]
464 parts := strings.SplitN(rest, "__", 2)
465 if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
466 return "", "", false
467 }
468 return parts[0], parts[1], true
469 }
470
471 // RemovePrefix unregisters every tool whose name starts with prefix — used to
472 // drop an MCP server's "mcp__<server>__" namespace when it's disconnected — and
473 // returns the count removed.
474 func (r *Registry) RemovePrefix(prefix string) int {
475 r.mu.Lock()
476 defer r.mu.Unlock()
477
478 kept := r.order[:0]
479 removed := 0
480 for _, name := range r.order {
481 if strings.HasPrefix(name, prefix) {
482 delete(r.tools, name)
483 delete(r.canon, name)
484 removed++
485 continue
486 }
487 kept = append(kept, name)
488 }
489 r.order = kept
490 if removed > 0 {
491 r.schemaRev.Add(1)
492 }
493 return removed
494 }
495
496 // SuspendPrefix unregisters matching tools and prevents future Add calls for
497 // that prefix until ResumePrefix is called. It is used for per-session MCP
498 // disables where an in-flight background handshake may otherwise swap tools back
499 // into this registry after the user turned the server off.
500 func (r *Registry) SuspendPrefix(prefix string) int {
501 r.mu.Lock()
502 defer r.mu.Unlock()
503
504 r.suspended[prefix] = true
505 kept := r.order[:0]
506 removed := 0
507 for _, name := range r.order {
508 if strings.HasPrefix(name, prefix) {
509 delete(r.tools, name)
510 delete(r.canon, name)
511 removed++
512 continue
513 }
514 kept = append(kept, name)
515 }
516 r.order = kept
517 if removed > 0 {
518 r.schemaRev.Add(1)
519 }
520 return removed
521 }
522
523 // ResumePrefix allows future Add calls for a previously suspended prefix.
524 func (r *Registry) ResumePrefix(prefix string) {
525 r.mu.Lock()
526 defer r.mu.Unlock()
527 delete(r.suspended, prefix)
528 }
529
530 // Get looks up a tool by name.
531 func (r *Registry) Get(name string) (Tool, bool) {
532 r.mu.RLock()
533 defer r.mu.RUnlock()
534
535 t, ok := r.tools[name]
536 return t, ok
537 }
538
539 // MCPBindings returns live MCP capability bindings in canonical-name order.
540 func (r *Registry) MCPBindings() []MCPBinding {
541 r.mu.RLock()
542 defer r.mu.RUnlock()
543
544 out := make([]MCPBinding, 0, len(r.tools))
545 for _, t := range r.tools {
546 if b, ok := mcpBinding(t); ok {
547 out = append(out, b)
548 }
549 }
550 sort.Slice(out, func(i, j int) bool { return out[i].CallableName < out[j].CallableName })
551 return out
552 }
553
554 // ResolveCall resolves an exact provider-visible name or a unique portable MCP
555 // reference. Exact names always win. Ambiguous aliases return their canonical
556 // candidates and are never executed.
557 func (r *Registry) ResolveCall(name string) (resolved Tool, canonical string, candidates []string) {
558 r.mu.RLock()
559 defer r.mu.RUnlock()
560
561 if t, ok := r.tools[name]; ok {
562 return t, name, nil
563 }
564 if IsShellToolName(name) {
565 if t, ok := r.tools["pwsh"]; ok {
566 return t, "pwsh", nil
567 }
568 if t, ok := r.tools["bash"]; ok {
569 return t, "bash", nil
570 }
571 }
572 matches := map[string]Tool{}
573 for canonicalName, t := range r.tools {
574 b, ok := mcpBinding(t)
575 if !ok {
576 continue
577 }
578 if slices.Contains(mcpBindingAliases(b), name) {
579 matches[canonicalName] = t
580 }
581 }
582 if len(matches) == 1 {
583 for canonicalName, t := range matches {
584 return t, canonicalName, nil
585 }
586 }
587 if len(matches) > 1 {
588 candidates = make([]string, 0, len(matches))
589 for canonicalName := range matches {
590 candidates = append(candidates, canonicalName)
591 }
592 sort.Strings(candidates)
593 }
594 return nil, "", candidates
595 }
596
597 // MCPBindingOf snapshots the canonical identity metadata of an MCP adapter.
598 // It does not call the tool or connect to its server.
599 func MCPBindingOf(t Tool) (MCPBinding, bool) {
600 return mcpBinding(t)
601 }
602
603 func mcpBinding(t Tool) (MCPBinding, bool) {
604 meta, ok := t.(MCPMetadata)
605 if !ok {
606 return MCPBinding{}, false
607 }
608 server := strings.TrimSpace(meta.MCPServerName())
609 raw := strings.TrimSpace(meta.MCPRawToolName())
610 if server == "" || raw == "" {
611 return MCPBinding{}, false
612 }
613 visible := raw
614 if v, ok := t.(MCPVisibleMetadata); ok && strings.TrimSpace(v.MCPVisibleToolName()) != "" {
615 visible = strings.TrimSpace(v.MCPVisibleToolName())
616 }
617 pkg := ""
618 if p, ok := t.(MCPPackageMetadata); ok {
619 pkg = strings.TrimSpace(p.MCPPackageName())
620 }
621 return MCPBinding{
622 Package: pkg,
623 Server: server,
624 RawName: raw,
625 VisibleName: visible,
626 CallableName: t.Name(),
627 CapabilityID: "mcp-tool:" + server + "/" + raw,
628 }, true
629 }
630
631 func mcpBindingAliases(b MCPBinding) []string {
632 aliases := []string{
633 b.RawName,
634 b.VisibleName,
635 b.Server + "/" + b.RawName,
636 b.Server + "/" + b.VisibleName,
637 b.CapabilityID,
638 "mcp-tool:" + b.Server + "/" + b.VisibleName,
639 "mcp__" + portableMCPPart(b.Server) + "__" + portableMCPPart(b.RawName),
640 "mcp__" + portableMCPPart(b.Server) + "__" + portableMCPPart(b.VisibleName),
641 }
642 if b.Package != "" {
643 prefix := "mcp__plugin_" + portableMCPPart(b.Package) + "_" + portableMCPPart(b.Server) + "__"
644 aliases = append(aliases, prefix+portableMCPPart(b.RawName), prefix+portableMCPPart(b.VisibleName))
645 }
646 return aliases
647 }
648
649 // MCPBindingAliases returns accepted portable references for a binding. The
650 // canonical provider-visible name remains MCPBinding.CallableName.
651 func MCPBindingAliases(b MCPBinding) []string {
652 return append([]string(nil), mcpBindingAliases(b)...)
653 }
654
655 func portableMCPPart(s string) string {
656 var b strings.Builder
657 for _, r := range s {
658 switch {
659 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
660 b.WriteRune(r)
661 default:
662 b.WriteByte('_')
663 }
664 }
665 return b.String()
666 }
667
668 // Len returns the number of registered tools.
669 func (r *Registry) Len() int {
670 r.mu.RLock()
671 defer r.mu.RUnlock()
672
673 return len(r.order)
674 }
675
676 // SchemaRevision changes whenever the provider-visible tool set changes.
677 func (r *Registry) SchemaRevision() uint64 {
678 if r == nil {
679 return 0
680 }
681 return r.schemaRev.Load()
682 }
683
684 // Names returns the registered tool names in insertion order.
685 func (r *Registry) Names() []string {
686 r.mu.RLock()
687 defer r.mu.RUnlock()
688
689 out := make([]string, len(r.order))
690 copy(out, r.order)
691 return out
692 }
693
694 // Schemas exports tool definitions in stable name order for the provider.
695 // When a provider-visible allowlist is set, only those tools appear.
696 func (r *Registry) Schemas() []provider.ToolSchema {
697 r.mu.RLock()
698 defer r.mu.RUnlock()
699
700 names := make([]string, 0, len(r.order))
701 for _, name := range r.order {
702 if r.isProviderVisibleLocked(name) {
703 names = append(names, name)
704 }
705 }
706 sort.Strings(names)
707
708 out := make([]provider.ToolSchema, 0, len(names))
709 for _, name := range names {
710 t := r.tools[name]
711 if t == nil {
712 continue
713 }
714 out = append(out, provider.ToolSchema{
715 Name: t.Name(),
716 Description: t.Description(),
717 Parameters: r.canon[name],
718 })
719 }
720 return out
721 }
722
723 // AllNames returns every registered tool name, including tools hidden from the
724 // provider-visible schema. Used by capability catalogs and diagnostics.
725 func (r *Registry) AllNames() []string {
726 r.mu.RLock()
727 defer r.mu.RUnlock()
728 out := make([]string, len(r.order))
729 copy(out, r.order)
730 return out
731 }
732
733 // SchemasForContext returns the contextual projection for host metadata and
734 // diagnostics. Provider requests intentionally use Schemas so phase changes do
735 // not churn the cache-stable tool contract.
736 func (r *Registry) SchemasForContext(ctx context.Context) []provider.ToolSchema {
737 if ctx == nil {
738 ctx = context.Background()
739 }
740 r.mu.RLock()
741 names := append([]string(nil), r.order...)
742 entries := make(map[string]struct {
743 t Tool
744 data json.RawMessage
745 }, len(names))
746 for _, name := range names {
747 if t := r.tools[name]; t != nil {
748 entries[name] = struct {
749 t Tool
750 data json.RawMessage
751 }{t: t, data: r.canon[name]}
752 }
753 }
754 r.mu.RUnlock()
755 sort.Strings(names)
756 out := make([]provider.ToolSchema, 0, len(names))
757 for _, name := range names {
758 entry, ok := entries[name]
759 if !ok || entry.t == nil {
760 continue
761 }
762 if contextual, ok := entry.t.(ContextualTool); ok && !contextual.ProviderVisible(ctx) {
763 continue
764 }
765 out = append(out, provider.ToolSchema{Name: entry.t.Name(), Description: entry.t.Description(), Parameters: entry.data})
766 }
767 return out
768 }
769
769 lines GO