Tool Execution Pipeline

Tool Execution Pipeline

In One Sentence

After the model emits a tool call, it is not “just calling a function”: it traverses a fixed order of pre-execute → monotonic guard → execute → post-execute → result, each stage carrying one class of policy; dsh-go implements these with a four-level waterfall chain, and the three-state decision plus monotonic guard ensure safety policies can only tighten, never be bypassed.

Overall Order

Upstream summarizes the order as tools/pre-execute → monotonic guard → tools/execute → tools/post-execute → tools/result. dsh-go’s pkg/tools.Pipeline maps them to four separate waterfall chains:

ToolCallRequest
   ├─ pre-execute    intercept/rewrite args (permission, sandbox, hooks); can deny
   ├─ monotonic guard final line: decisions only tighten
   ├─ execute        call the real implementation (can set signal=cancel)
   ├─ post-execute   post-process (accept/block, truncate, meta)
   └─ result         final wrapping, metrics
ToolCallResult

The first pluggable stages are waterfalls: a listener may call next() to delegate or return a decision to short-circuit. The ordering of policy plugins is adjustable at wiring time, hence pre-execute is the “reorderable policy layer”.

Three-State Decision: allow / deny / ask

pre-execute returns a typed PreToolDecision:

DecisionMeaningFollow-up
PreAllowallow this callproceed to execute
PreDenyrejectshort-circuit; tool not run; result isError
PreAskask the userAskFunc queries; only proceeds if approved

The key semantics is allowed-once: in an ask, user approval only allows this single call; the next invocation of the same tool asks again. There is no permanent per-tool allow.

dsh-go reuses pkg/approval.Decision as the carrier, so tool decisions and the approval service share semantics and can be mapped directly from approval.Service.Evaluate.

Monotonic Guard: Only Tighten

Multiple policy plugins in pre-execute may each give a decision — which one wins? The monotonic guard sits between the policy layer and real execution. pkg/tools.MonotonicGuard defines a strictness order deny > ask > allow:

  • The first assignment is always accepted;
  • Later decisions may only move stricter (allow→deny, ask→deny);
  • Trying to relax an already stricter decision (deny→allow) returns ErrGuardRelaxed and keeps the original.
1g := tools.NewMonotonicGuard()
2_ = g.Update(tools.PreAllow)
3_ = g.Update(tools.PreDeny)              // tighten, accepted
4err := g.Update(tools.PreAllow)         // relax → ErrGuardRelaxed
5// g.Decision() stays PreDeny (fail closed)

Parallel Scheduling: Bounded Pool

Parallel-safe tool calls run in a bounded rolling pool (default 10 in flight), while exclusive calls act as barriers. tools.RunParallel runs concurrently but slots results back by input index, so execution is concurrent yet commits stay model-ordered; any error cancels the rest.

Bounded Output

retain.ItemRetainer answers only the mechanical question: keep the first N observed units and count the rest as exact omissions. Budget omission is distinct from an incomplete upstream, which stays a tool-domain state.

Layered Tool Restriction

Beyond per-call decisions, tool visibility uses a Restriction mask. RestrictionSet holds an ordered layer stack (host outermost, nearer scope wins), resolved nearest-scope-wins: scan from the nearest layer back to host; the first layer that mentions a tool decides. host deny + scope allow(exempt) restores a tool; both deny → reject; unmentioned → allowed by default. It serves Subagent capability limits and Preset tool hiding via Filter.

PTC Mode: The Model Writes a Program

In standard mode the model emits tool calls one at a time. Upstream also offers PTC (Programmatic Tool Calling): the model writes a program (an async function body) that composes multiple calls via tools.name(args) and returns one curated result.

dsh-go splits this into two layers:

  • pkg/coderuntime fixes the execution seam: RunRequest (program + binding namespaces), RunResult (value + logs + six failure kinds), and the Runtime interface. A Go process has no in-process JS engine, so no language backend is built in; the user implements the interface (external process / embedded interpreter / remote service).
  • pkg/tools.NewRunCodeTool bridges: it maps the tool set into a "tools" binding namespace, and each member still runs through the same tool pipeline when called by the program — so permission, sandbox, and the monotonic guard apply identically.

The key semantics: sub-dispatches are logged for reconstruction; only the outer curated result enters model history.

Why no built-in code engine in Go?

Upstream’s only published PTC backend is a Node worker thread running TypeScript — natural in the JS ecosystem. Embedding a JS engine in Go would be heavy and fragile. dsh-go replicates the protocol and bridge semantics while leaving the execution backend a replaceable interface.

Source Map

ConceptGo implementationUpstream TypeScript
Four-level pipelinepkg/tools/pipeline.go — Pipelinepackages/core/tools
Three-state decisionpkg/tools/predecision.gotools pre-execute
Monotonic guardpkg/tools/monotonic.go — MonotonicGuardmonotonic guard
Layered maskpkg/tools/restriction.go — RestrictionSettools restriction
PTC seampkg/coderuntime/coderuntime.go — Runtimepackages/code-runtime
Bounded parallel poolpkg/tools/pool.go — RunParallelpackages/core/agent-loop/src/tool-calls.ts
Bounded outputpkg/retain/retain.go — ItemRetainerpackages/util/output-retention
run_code bridgepkg/tools/ptc.go — NewRunCodeTooltools/src/ptc.ts
Object poolpkg/tools/pooled.go — SetPooled(dsh-go addition)

Next Steps