Tools and introspection

A fisk application is introspected once into a set of tools. Peer agents, MCP servers, harness built-ins and caller-supplied Go functions land in the same namespace, and the model addresses every one of them by a single flat name.

Where it lives

internal/toolkit holds the vocabulary every kind shares: the Tool interface, tag behavior, the confirm predicate, the prompter and deferral. internal/toolkit/fisk turns a CLI command tree into tools, internal/toolkit/functool backs everything else, and internal/toolkit/builtin holds the harness’s own. internal/mcpclient and internal/remotetools import from elsewhere.

One namespace, five sources

fisk appcommand treebuilt-insmemory, knowledgea2a peersprefix on clashMCP serversalways prefixedcustom toolsregistered lastflat namespacecollision aborts the runfirst claim keeps itmodelone name per toolai:deny is stripped before any include or exclude runs
Sources are assembled in a fixed order. A name already taken aborts the run rather than shadowing what holds it.

The contract

Kind-specific policy is never folded into Tool; consumers reach for narrow capability interfaces instead.

type Tool interface {
	Name() string
	Description() string
	ModelDescription() string
	InputSchema() map[string]any
	Definition(deferLoading bool) llm.ToolDef
	Execute(ctx context.Context, input json.RawMessage, deps ExecDeps) (*Outcome, error)
	MCPExposable() bool
	A2AExposable() bool
}

Exposure is the deliberate exception to that rule. It sits in the interface so the compiler forces every new kind to answer it, since a new kind would otherwise reach MCP or a2a with no exposure decision recorded.

A tool that does not implement BehaviorDescriber is safe, because consumers fall back to conservative defaults. A tool that cannot answer Confirmable is refused by both serving surfaces, because “needs no gate” and “cannot report a gate” must not look the same.

fisk.FiskCommandTool runs a subprocess of the wrapped application; functool.Tool backs everything else, discriminated by whether its spec names a remote agent or an MCP server.

Kind
The accounting axis: application, builtin, remote, custom, mcp, unknown. A log line and a metric label carry the kind.
Presentation
The visibility axis: command, remote, self-rendered, traced. A built-in presents as self-rendered or traced while being one kind; an MCP tool presents as remote while being accounted as MCP.
Outcome
Output plus an optional exec record. The presence of that record is the whole discriminator: a command's output is wrapped in a CommandResult envelope, an in-process tool's is passed through as the JSON the caller asked for.

Introspecting a fisk application

Running <binary> --fisk-introspect returns the command model. Hidden commands and their subtrees are skipped, grouping nodes are not tools, and only leaves become tools, named by joining the command path with underscores. Every leaf must arrive with a precomputed schema or the whole load fails.

The introspection subprocess gets thirty seconds when the caller supplied no deadline, and its output is capped at 16 MiB. Over the limit is a rejection rather than a truncation, because the document has to decode whole.

Model arguments reach the binary only as argv, never through a shell. Exposed global flags are merged into each command’s schema by cloning rather than mutating, since the model schema is reused on every request, and they are placed after the command path and before the -- separator so they always read as flags.

Command output is captured through a single writer on both streams, so stdout and stderr keep their interleaving, and held to a head-and-tail ring within 64 KiB so a runaway command cannot grow the process.

The reserved tag vocabulary

ai:deny, ai:no_defer and ai:confirm change what the harness does. The behavior tags are advice.

TagEffect
ai:denyStripped before include and exclude run, and it can never be added back on any surface
ai:no_deferAlways sent to the model directly, never hidden behind tool search
ai:confirmAlways gated, matched unconditionally, so leaving it out of confirm_tags cannot weaken it
ai:read_only, ai:destructive, ai:additive, ai:idempotentCarried to clients as advice and nothing more

Operators gate further tools by listing them under confirm_tags, and any tag works, not only ai: ones. The trigger reported in the prompt prefers ai:confirm when present, and otherwise names the first of the tool’s own tags found in the operator’s list, in the tool’s tag order, so the message is deterministic.

Load-bearing decision

The behavior vocabulary describes and does not enforce. No behavior tag gates a call, because the tool supplies its own tags and could drop ai:destructive to escape the gate. The gate is ai:confirm plus the operator’s confirm_tags; the reliable off switch is ai:deny.

Conflicting tags resolve conservatively and never fail a run: the tags come from a binary the operator often cannot edit, so one mistagged command must not stop everything. read_only combined with a write tag loses read_only, and destructive beats additive. Resolution happens after all tags are collected, so it does not depend on order. An unrecognized ai: tag is a warning, since a private one is legitimate.

Collisions

The run builds a taken map in a fixed order: application tools, then human-in-the-loop, memory, knowledge, a2a peers, MCP servers, and custom tools last.

SourceOn a clash
Built-insAbort the run, naming the tool to exclude or rename
a2a peersPrefix with the host alias, but only when a local tool holds the name or more than one host exposes it, and then symmetrically for all of them
MCP serversAlways prefix with the server alias, so a name depends only on the server it came from and nothing is renamed when another server’s list changes
Custom toolsAbort. An injected tool may never shadow anything, and may not claim to be remote or MCP-backed

The a2a naming decision is a global pass over the whole set rather than a per-host one, and residual collisions are found by counting final names. Both make the outcome independent of discovery order.

Importing from an MCP server

Connection is per server, in configuration order, with each server’s own startup timeout covering transport setup and the handshake. A single failure closes everything already opened. The client advertises nothing: no roots, no sampling handler, so a foreign server cannot spend this agent’s model budget, and no elicitation handler.

A third-party tool descriptor is validated before it can reach the model API. A missing name, a schema that is not an object, a root type other than object, or a missing description each fail the import, because all definitions travel in one request and one bad descriptor would fail every call in the run.

A run import is strict: any server error or collision fails the run. The discovery path used by fisk info is lenient, connects to each server alone, closes as soon as names are read, and returns tools that are structurally not callable.

On a mid-conversation tools/list_changed rebuild, a collision is skipped and recorded rather than failing the run, because a third party is editing its own list.

Stdio children inherit the environment minus the credential union, rather than getting a replaced one, because a child with no PATH or HOME cannot run the servers operators actually wire up. Resolved URL secrets of eight characters or more are replaced everywhere they might be printed, longest first so a value containing another is replaced whole. Configured header names are dropped from any cross-host redirect.

Deferred loading

Past ten tools, counting deferrable plus built-ins, definitions are deferred and the model reaches them through tool search. Built-ins are never deferred. The threshold is re-evaluated per tool set, so a set that grows starts deferring and one that shrinks stops.

A tool set is immutable. A change arrives as a whole new set published to the source the loop snapshots.

Serving surfaces gate differently

Over MCP the calling client is asked through elicitation, and anything that is not an explicit approval fails closed. Over a2a, confirm-gated tools are dropped at selection time, because no operator stands behind a served call. Both surfaces refuse a deferred result: the answer would arrive against a session the path does not have.

functool.New enforces the matching rules at construction. A remote or MCP-backed tool may not also declare a confirm gate, since gating another party’s tool is not this process’s to do, and a tool that declares exposure may not be remote, MCP-backed or gated, because re-serving somebody else’s tool under this agent’s identity needs an operator’s explicit opt-in, and a spec has nowhere to record one.

Not yet wired

Nothing sets a2a exposure on a functool spec today, so only fisk command tools reach that surface. There is no a2a builtins allowlist, and declaring exposure without one would serve the tool the moment a2a is enabled, with no operator opt-in.

Next

Continue to Model providers for how a tool definition is rendered for the API, or Serving for the surfaces that hand these tools to somebody else.