toolset

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package toolset is the built-in agent_toolset_20260401: the six tools the platform executes for the model — bash, read, write, edit, glob, grep — run inside the session's sandbox.

Two halves. Tools turns an agent's toolset entry into the definitions the model is handed (name, description, input schema); Runner.Run executes one call of a named tool against a sandbox. Nothing here talks to the event log or the work queue: what a tool call means for the session is the executor's, and this package only knows how to run one.

The reference implementation of these six is anthropic-sdk-go's tools/agenttoolset, which runs them on the host and therefore has to confine the file tools to a workdir and warn that bash cannot be confined at all. Here the container IS the confinement, and bash runs in it like everything else, so the file tools resolve relative paths against the workdir and otherwise let a path be a path: a model that wants /etc can read it with bash regardless, and a lexical check that bash ignores is theatre, not a boundary.

Divergences from that reference, all deliberate:

  • No workdir confinement (above). Absolute paths and absolute glob patterns are accepted.
  • grep shells out to GNU grep inside the sandbox (PCRE where the image's grep has it, POSIX ERE otherwise) rather than preferring ripgrep and falling back to a Go walker. One implementation, one behaviour, and no dependence on what the image happens to ship beyond the /bin/bash the sandbox already requires.
  • The tools carry no state between calls except bash's, which is the shell package's snapshot; there is no per-runner session object to close.
  • write and edit preserve the permission bits of an existing regular file they replace, where the reference writes a fixed 0644 (its atomicWriteFile chmods the temporary file to that constant before renaming). Where nothing is carried over — a new file, a symlink, a docker sandbox whose user cannot chmod the temporary file (#209) — 0644 is what lands here too. The Claude Code harness preserves them, and the workflow that decided it is ordinary — `chmod +x` a script in bash, edit it, run it (#204). The rename that makes the write atomic is the sandbox backends'; both carry the mode over (internal/sandbox/filefault.go).

Index

Constants

View Source
const (
	DefaultAgentToolsetPolicy = domain.PolicyAlwaysAllow
	DefaultMCPToolsetPolicy   = domain.PolicyAlwaysAsk
)

The permission policy each toolset kind resolves to when an entry sets none. Both are documented rather than inferred: "Each toolset kind has its own default: the agent toolset defaults to `always_allow`, and MCP toolsets default to `always_ask`", and, for the built-in kind again, "`default_config` is optional. If you omit it, the agent toolset is enabled with the default permission policy, `always_allow`" (the reference's permission-policies guide). MCP asks by default so that a tool newly appearing on someone else's server cannot execute without approval.

View Source
const (
	// MaxOutputBytes caps what a tool call returns to the model. The sandbox
	// caps a command's output an order of magnitude higher (that cap is a
	// memory guard on the executor); this one is the model's context budget,
	// and it is the tool result that goes on the event log forever.
	MaxOutputBytes = 100 << 10

	// DefaultTimeout bounds a tool call the model did not time itself, and
	// MaxTimeout bounds the one it did. A model-chosen timeout is a lease the
	// executor has to keep alive, so it cannot be unbounded.
	DefaultTimeout = 2 * time.Minute
	MaxTimeout     = 10 * time.Minute
)
View Source
const DefaultStallBudget = 3 * MaxTimeout

DefaultStallBudget is the budget a binary uses when its operator sets none: three times the step every deployment has, so a slow image pull behind a slow mount behind a full-length `bash` still clears it while a wedge costs half an hour rather than the process's life.

Derived rather than written out, and shared rather than copied, because both binaries want the same number for the same reason and a literal in each would let one follow a change to MaxTimeout while the other did not — the drift the refusals were moved here to prevent (#383).

View Source
const MetricToolDuration = "tool.execution.duration"

MetricToolDuration is deliberately not one of OTel's gen_ai.* metrics. Those describe a client's call to a GenAI provider and require gen_ai.provider.name; running bash in a container is not that, and inventing a provider value to satisfy the convention would make the metric lie about what it measured. So the name is the platform's own, following OTel's naming rules (dotted, lowercase, unit in the Unit field rather than the name), while the attributes reuse the semconv keys that genuinely apply — gen_ai.tool.name is the same tool the model named, and error.type is the standard failure dimension. It is exported so the telemetry contract test can assert this name reaches an OTLP collector.

Variables

This section is empty.

Functions

func CapOutput added in v0.2.0

func CapOutput(s string) string

CapOutput trims content to MaxOutputBytes, marking the truncation. Exported for the same reason RecordRun is: the executor's web driver produces tool results outside this Runner and must honor the SAME log budget — one cap, one meaning, whatever process ran the tool.

func CheckStallDefault added in v0.3.0

func CheckStallDefault(name, stepName string, def, longestStep time.Duration) error

CheckStallDefault refuses a default budget that the caller's longest step has outgrown, naming both knobs and the number that would work.

The budget an operator does *not* set needs the same floor as the one they do, and it is the case that actually happens: raising a clone timeout for a monorepo is a thing an operator does, touching a knob about stalls is not, so the default would go on cancelling that clone on every reclaim — the exact loop the floor exists to prevent, and silently, since a default is never compared with anything (#383). Only a caller with a step an operator can lengthen needs this; a binary whose longest step is MaxTimeout cannot reach a floor its own default does not already clear.

func IsWebTool added in v0.2.0

func IsWebTool(name string) bool

IsWebTool reports whether name is a built-in tool that executes in the executor's process rather than the sandbox. The executor's sandbox scan, the BYOC worker's, and the queue-kind decisions all consult this one predicate, so the split cannot drift between them.

func Materialize added in v0.3.0

func Materialize(raw json.RawMessage) json.RawMessage

Materialize resolves one tools[] entry for the wire's response shape: both toolset kinds come back carrying `configs` and `default_config`, and every `enabled` / `permission_policy` inside them carries a concrete value, because the reference's response types mark all of them required (BetaManagedAgentsAgentToolset20260401 and BetaManagedAgentsMCPToolset, and their AgentToolConfig / MCPToolConfig / *DefaultConfig members). Entries of any other type — custom tools, a type this build does not know — pass through byte for byte, as does anything that is not a JSON object.

Two readings are ours and recorded in docs/DIVERGENCES.md. First, `configs` echoes the entries the client supplied, each resolved, rather than one entry per tool: an MCP server's tool names are unknowable when the agent is written, so listing every tool is impossible for that kind, and one rule that holds for both beats two that diverge. Second, resolution happens here, at render, rather than at write — so a stored row keeps the client's bytes, a row written before this code echoes resolved all the same, and the default policies stay constants to flip (DefaultAgentToolsetPolicy, #59) instead of values frozen into old rows.

It fills in what was omitted and validates nothing: a supplied value is echoed as stored, malformed or not. Rejecting malformed input is the API boundary's job (Validate, ValidateMCPToolset), and a read of an older row must not fail because of what a write once let through. One thing does not survive: an unknown *key* nested inside default_config or a resolved configs[] entry is dropped, because those objects are rebuilt from the three fields the schema defines. Only a row written before that validation existed can carry one, an unknown key at the toolset object's own level is preserved, and the drop is toward the safe reading — a stored `permission_polciy` disappears from the echo and the tool renders with the default policy it actually resolves to. A configs[] entry that resolves to no tool at all is not rebuilt, so it keeps every key it was stored with (materializeConfigs).

func MaterializeTools added in v0.3.0

func MaterializeTools(tools []json.RawMessage) []json.RawMessage

MaterializeTools returns a copy of an agent's tools[] with every toolset entry's configuration resolved (see Materialize). It never writes through its argument: the render funnels call it on the spec they are about to echo, while the bytes the store holds — and the update paths merge — stay exactly as the client sent them. A nil list stays nil so Normalize keeps deciding how an absent list renders.

func ParseStallTimeout added in v0.3.0

func ParseStallTimeout(name, value string, longestStep time.Duration) (time.Duration, error)

ParseStallTimeout reads a stall budget from the environment variable named by name, naming it in every refusal.

Malformed fails startup rather than falling back to the default, and so does a non-positive value: it parses, but the consumer's own defaulting would replace it, so "-30m" or "0" would otherwise start a process whose bound is silently not the one configured. A typo and a negative are told apart — answering "30mm" with a complaint about its sign helps nobody. There is deliberately no off switch.

func Policies

Policies resolves the permission policy of every built-in tool an agent_toolset_20260401 entry enables, keyed by tool name. It mirrors Tools' enable resolution, so disabled tools are absent; the brain reads it to stamp evaluated_permission on each tool_use and to decide whether a turn's calls suspend for human confirmation.

func RecordRun added in v0.2.0

func RecordRun(ctx context.Context, name string, d time.Duration, res Result, err error)

RecordRun records one tool call's duration. It resolves the meter per call rather than caching an instrument at package scope: a tool call costs a sandbox round trip, which dwarfs this, and a cached instrument would pin whichever MeterProvider happened to be installed first — leaving the metric silently wired to a dead provider in any process that configures telemetry after the first call, and untestable besides.

Exported because the executor's web driver runs the web tools outside this package's Runner and must record through the SAME instrument — one metric name, one meaning, whatever process ran the tool.

func SanitizeText added in v0.2.0

func SanitizeText(s string) string

SanitizeText strips NUL bytes from tool output. Postgres's jsonb cannot store \u0000 inside a string value, so a NUL anywhere in a result — one byte of /dev/zero on stdout is enough — would fault the event append, and a faulted work item reclaim-loops, re-running the same command into the same failure. Sanitized before CapOutput so the log budget is spent on bytes that survive. Exported for the executor's web driver, which produces results outside this Runner (the same reason CapOutput is exported).

func SpillFile added in v0.3.0

func SpillFile(ctx context.Context, sb sandbox.Sandbox, id domain.ID, full string) (string, error)

SpillFile writes one call's oversized output to the sandbox and returns the path, or the error the sandbox refused the write with. It is the half of spill that decides *where*, without the budget test or the notice.

It hands back the error rather than a bool because the sandbox classifies the refusal (ErrNotFound, ErrNotDirectory, ErrNotWritable) and that classification is the only account of why no file exists — this package logs nothing, by design, so a caller that runs on a shared process is where it can be said.

Exported for the executor's MCP driver (plan 29 slice 4c), which spills to the same directory under the same id-per-call convention — so a model that has learned where its truncated output goes is right whichever tool produced it — but says something different about it. The two differ where they must and nowhere else: an MCP answer spills its *text*, so it cannot borrow a sentence promising the full output, and it spills on a trigger of its own — whether the rendering lost anything, which a length test cannot answer for an answer made of blocks — so it cannot borrow the budget test either.

func StallFloor added in v0.3.0

func StallFloor(longestStep time.Duration) time.Duration

StallFloor is the shortest stall budget a healthy run survives: the longest single step it may spend inside one silent interval, plus a minute.

The minute is not slack. A step that hits its own cap answers *after* the cap: a sandbox backend waits a kill grace past the deadline for the command to die, and the result still has to come back over the transport. A floor of exactly the cap would cancel that healthy, timed-out step moments before it answered, and its use would stay unanswered on every reclaim.

A budget under the floor does not degrade into a retry, it loops: every reclaim re-runs the same step and is cancelled at the same point, with no error ever reaching the session (#383). MaxTimeout is the step every deployment has — one `bash` call — and longestStep names any longer one a caller knows about, which for the executor is a repository clone: one clone is a single silent interval of exactly RepoCloneTimeout, an operator-raisable knob with no relation to this budget until it is given one. The steps only a deployment can measure — a cold image pull, a large checkpoint restore — stay its own to clear.

func Tools

func Tools(raw json.RawMessage) ([]json.RawMessage, error)

Tools returns the model-facing definitions of the built-in tools an agent_toolset_20260401 entry enables, in the wire's order.

func TruncateRunes added in v0.3.0

func TruncateRunes(s string, n int) string

TruncateRunes returns s cut to at most n bytes, backing off to a rune boundary so a split multi-byte character never reaches the event log as a replacement character.

Exported for the reason CapOutput is: other packages cut strings against budgets of their own — the executor's MCP driver caps a resource label, the brain caps a name its tool notes quote — and every hand-rolled cut is another chance to land mid-rune, where json.Marshal coerces the tail to U+FFFD and the corruption is silent.

func Validate

func Validate(raw json.RawMessage) error

Validate checks that an agent_toolset_20260401 entry resolves — its enable flags and the permission policies of its enabled tools are well-formed. It is the create-time counterpart to Tools/Policies: an entry that fails here would otherwise be stored on the agent and wedge every turn when the brain resolves it, so the API validates at agent creation to make a malformed toolset a 400 instead.

func ValidateMCPToolset added in v0.3.0

func ValidateMCPToolset(raw json.RawMessage) error

ValidateMCPToolset checks an mcp_toolset entry's shape the way Validate checks an agent_toolset_20260401 one: unknown keys anywhere in the default_config / configs / permission_policy nest, and permission policy types this platform cannot evaluate, are rejected at the API boundary rather than stored. Without it a misspelled `permission_polciy` would be dropped by encoding/json and the tool would silently resolve to the toolset default — the fail-open at the confirmation boundary that #26 closed for the other kind. It differs from Validate in two ways the wire forces: mcp_server_name is an accepted key here, and no tool *name* is checked against a known set, because an MCP server reports its own.

Unlike Validate it is eager about policies rather than lazy: an MCP entry has no enumerable tool list, so "is this tool actually enabled" cannot be decided here, and a policy that cannot be evaluated is a defect wherever it sits. Every check walks the raw JSON rather than decoding into Go types. A typed unmarshal would answer the leaf questions ("is enabled a boolean") for free, but its error text is a dump of the receiving struct — unexported field names and all — and this package's errors are the message of a 400, so they name the field's path instead. A raw walk is also the only way to tell an explicit null from an absent key, which a pointer field cannot. One typed decode survives at the end as a backstop, for the single question a raw walk cannot answer; see there.

Types

type MCPResolved added in v0.3.0

type MCPResolved struct {
	MCPTool
	Policy domain.PermissionPolicyType
}

MCPResolved is one enabled MCP tool: what the server reported, plus the permission policy the mcp_toolset entry resolves for it.

func ResolveMCP added in v0.3.0

func ResolveMCP(raw json.RawMessage, tools []MCPTool) ([]MCPResolved, []string, error)

ResolveMCP applies an mcp_toolset entry's default_config and configs[] onto the tools one server reported, returning the enabled ones in the order the server listed them and the configs[] names that named no reported tool.

It is resolveToolset for a list this package does not know: enable and policy resolve independently, a per-tool config overrides default_config, and default_config overrides the toolset default — on, and DefaultMCPToolsetPolicy. Three things differ, and each is the wire's doing.

The unknown names are returned rather than rejected. An MCP server's tool list is dynamic and unknowable when the agent is written, which is why the docs make an unrecognised configs[] name a warning and not an error; the caller says so where a human will see it.

A tool with no name, and a repeated name after its first listing, are dropped silently rather than returned for the caller to report. Both are already refused where a listing is read (internal/mcp drops an unusable or repeated name before a catalog row is written), so reaching either means a row nothing in this platform wrote — and a note about a state that cannot arise is noise on every turn rather than news. Dropping is still what happens, because neither can be offered: two definitions under one name is a request the endpoint rejects (which would cost the whole turn rather than the duplicate, and nothing downstream could tell the two apart — a result names a tool, not a position), and an empty name composes to a model-facing name that is legal on its face while the wire event it commits requires a tool name.

Unknown keys are not rejected here, where Tools and Policies do reject them for the built-in kind. The API boundary (ValidateMCPToolset) is where an mcp_toolset's shape is checked, and a row stored before that validation existed must not turn a turn that used to expand to nothing into a turn that fails. An unevaluable *policy* is the exception, for the reason it always is: defaulting it would run an unconfirmed tool (#26), so it is an error wherever a live tool carries one.

type MCPTool added in v0.3.0

type MCPTool struct {
	Name        string          `json:"name"`
	Description string          `json:"description,omitempty"`
	InputSchema json.RawMessage `json:"input_schema"`
}

MCPTool is one tool as an MCP server reported it, in the shape the session's catalog row stores. It is this package's own type rather than internal/mcp's on purpose: the go-sdk that package wraps has no business in the brain's request-assembly path, and the listing is three fields either way.

type Result

type Result struct {
	Content string
	// SearchResults, when non-nil, is the structured content of a web_search
	// answer: the tool_result carries these search_result blocks instead of a
	// text block (an empty non-nil slice is an empty content array — a search
	// with no hits). Only the executor's web driver sets it; nil keeps today's
	// text shape byte-identical.
	SearchResults []domain.SearchResultBlock
	IsError       bool
}

Result is one tool call as the model sees it. IsError marks a tool-level failure — a missing file, a bad regex, a nonzero exit — which the model reads and can recover from. A backend fault (the sandbox is gone, the daemon is unreachable) is never a Result: it comes back from Run as an error, and what happens to the tool call then is the executor's decision, not the model's.

type Runner

type Runner struct {
	Sandbox sandbox.Sandbox
	// Session scopes the bash shell's state in the container.
	Session domain.ID
	// Workdir is where relative tool paths resolve. Empty means the sandbox's
	// own default, which is where its Exec already runs.
	Workdir string
}

Runner executes built-in tool calls inside one session's sandbox.

func (Runner) Run

func (r Runner) Run(ctx context.Context, id domain.ID, name string, input json.RawMessage) (res Result, err error)

Run executes the named built-in tool. id names this call — the tool-use event's id — and scopes the bash shell's per-call files.

Every tool call the platform runs arrives here, from the cloud executor and the BYOC worker alike, so this is the one place the tool-execution metric can be recorded once and mean the same thing at both deployment points.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL