refactor(runtime): extract tool execution into pkg/runtime/toolexec#2545
Merged
dgageot merged 5 commits intodocker:mainfrom Apr 28, 2026
Merged
Conversation
10934f2 to
f191a16
Compare
rumpl
previously approved these changes
Apr 28, 2026
…time/toolexec
Mirrors the compactor and delegator extractions: a new sub-package
hosts the tool-execution primitives that don't need runtime-private
state, leaving runtime to shrink toward pure orchestration.
Moved verbatim (renamed to exported APIs, no behaviour change):
- tool_loop_detector.go -> toolexec/loop_detector.go
toolLoopDetector -> LoopDetector
newToolLoopDetector(\u2026) -> NewLoopDetector(\u2026)
record/reset/consecutive -> Record/Reset/Consecutive()
- tool_model_override.go -> toolexec/model_override.go
resolveToolCallModelOverride -> ResolveModelOverride
Tests follow each file. loop.go is updated to call the new APIs.
Assisted-By: docker-agent
Lift the two pure helpers used to build hooks.Input from a tool call out of pkg/runtime and into pkg/runtime/toolexec, where they belong with the rest of the tool-execution primitives: newHooksInput -> toolexec.NewHooksInput parseToolInput -> toolexec.ParseToolInput Both functions are pure and depend only on pkg/hooks, pkg/session and pkg/tools — packages already imported by toolexec callers — so the move doesn't introduce a new dependency. Tests added for ParseToolInput (empty/invalid/valid/empty-object) and NewHooksInput (field population, invalid-arguments path). tool_dispatch.go is updated to call the toolexec.* APIs; no behaviour change. Assisted-By: docker-agent
…ecide
The yolo-then-checkers-then-readonly-then-default ladder used to live as
a tangled switch inside (*LocalRuntime).executeWithApproval, mixing the
policy decision with three different runtime side effects (run the tool,
emit a deny error response, ask the user).
Lift the policy out as a pure function:
toolexec.Decide(yoloApproved, []NamedChecker, name, args, readOnly)
-> PermissionDecision{Outcome, Reason, Source}
Outcome = Allow | Deny | Ask (what to do)
Reason = Yolo | Checker | ReadOnlyHint | Default
(why; lets the caller log
the same messages as before)
Source = checker label, when Reason == Checker
executeWithApproval becomes a flat 3-arm switch that just translates the
decision into runtime side effects. permissionChecker (the unexported
pair type) is replaced by toolexec.NamedChecker so the runtime adapter
permissionCheckers() returns the type Decide consumes directly.
ForceAsk now correctly overrides the read-only fast path (it always did,
but the new Decide makes the invariant explicit and unit-testable).
12 table tests cover the full matrix in pkg/runtime/toolexec without
needing a runtime; runtime tests still pass unchanged.
Assisted-By: docker-agent
The 442-line tool dispatch in pkg/runtime/tool_dispatch.go used to be a
set of methods on *LocalRuntime that pulled in tracer, hooks, resume
channel, agent resolver, permissions, runtime-managed handlers, and the
events channel implicitly via the receiver. Extract that orchestration
into a self-contained toolexec.Dispatcher with explicit dependencies, so
any embedder can drive tool execution without dragging the runtime in.
API:
type Dispatcher struct {
Tracer trace.Tracer
Hooks HookDispatcher // 2-method interface
Resume <-chan ResumeRequest
AgentFor func(*session.Session) *agent.Agent
Permissions func(*session.Session) []NamedChecker
Handlers map[string]ToolHandler
}
func (d *Dispatcher) Process(ctx, sess, calls, agentTools, em Emitter)
Emitter is a 5-method interface covering the dispatch-shaped events
(ToolCall, ToolCallResponse, ToolCallConfirmation, HookBlocked,
MessageAdded). Anything else stays a runtime-private concern.
Runtime side becomes a thin adapter:
pkg/runtime/tool_dispatch.go (~140 LOC, was 442) wires:
chanEmitter — chan Event sender for the 5 typed events
hookDispatcher — wraps r.dispatchHook + r.executeOnUserInputHooks
bindRuntimeHandlers — closes chan Event into ToolHandler shape
pkg/runtime/runtime.go aliases the shared types so the public
Runtime API is byte-for-byte identical:
runtime.ResumeRequest = toolexec.ResumeRequest
runtime.ResumeType = toolexec.ResumeType
+ the four ResumeType* constants
10 dispatcher unit tests cover routing (toolset vs runtime handler),
approval outcomes (allow/deny/ask/cancel/reject/approve-tool),
read-only auto-approval, and batch-cancellation synthesis. They run
without a runtime, with race detection, in <0.1s each.
Existing pkg/runtime tests (44 of them, including processToolCalls
permission tests, transfer_task validation, yolo-mode bypass, steer
race tests) all pass unchanged because processToolCalls remains a
method on *LocalRuntime that just constructs a Dispatcher and calls
Process.
Assisted-By: docker-agent
The previous dispatcher.go threaded 7-8 arguments (ctx, sess, toolCall,
tool, em, a, runTool, ...) through every helper, making signatures hard
to scan and adding a paper cut every time a new piece of per-call state
needed to be plumbed. Bundle that state in a value type and turn the
helpers into methods on it.
Changes:
* Introduce an internal *call value (one per tool invocation) carrying
{d, sess, em, a, tc, tool, available}. Most helper signatures shrink
to (ctx, runTool) or just ctx — the rest comes from the receiver.
* Track tool availability with an explicit 'available bool' instead
of the tool.Name == "" sentinel. The synthetic 'unavailable' tool
still carries the requested name so error events stay informative.
* Rename the public method that wraps tracing/telemetry/event-emission
around the actual handler call from execute() to invoke(), so it no
longer collides conceptually with the runTool closure parameter that
represents "deferred work to perform once approval clears".
* Extract two tiny helpers from a 70-line method:
- translateError(ctx, span, err) — context-cancel vs error mapping
- buildMultiContent(text, images) — image MultiContent assembly
* Drop the 3-line permissionsFor() nil-guard helper (inlined at the
one caller) and the free logAllow() function (now a method that
reads tool/session info from the receiver).
* tool_dispatch.go: inline the 7-line bindRuntimeHandlers helper
(single caller) and drop newHookDispatcher (struct literal at the
one callsite).
* runtime.go: remove a dead 'const _ ResumeType' block and an
orphaned ResumeRequest doc comment left over from aliasing.
Bug fix surfaced during review:
Storing context.Context in *call would have been a documented Go
anti-pattern (containedctx lint rule) — ctx is passed explicitly to
every method that needs it instead.
Validation: go build, go vet, go test -race ./pkg/runtime/...,
golangci-lint run ./pkg/runtime/... — all clean. The full repository
test suite passes unchanged. dispatcher.go grows by 60 lines (mostly
comments and the small helpers); the *call refactor cuts roughly 80
argument-passing lines from the rest of the file.
Assisted-By: docker-agent
f191a16 to
66f4022
Compare
rumpl
approved these changes
Apr 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Goal
Make tool execution easier to read, test, and build on top of, by extracting it from
pkg/runtimeinto a self-containedpkg/runtime/toolexecsub-package — following the existingpkg/runtime/compactorandpkg/runtime/delegatorprecedents.pkg/runtime/tool_dispatch.goshrinks from 442 LOC of orchestration to 123 LOC of runtime adapters, and the new package is fully unit-testable without spinning up a runtime.Public API
Zero changes.
runtime.Runtime,runtime.ResumeRequest,runtime.ResumeType, theResumeType*constants,runtime.LocalRuntime.Resume(...), theRun/RunStream/Steer/FollowUpflow — all byte-for-byte identical. Resume types are now type aliases overtoolexec.Commits (each one builds, lints, and tests cleanly)
extract pure tool-execution utilities into pkg/runtime/toolexecmove hooks-input helpers into toolexecparseToolInput,newHooksInput(+ tests)extract approval-pipeline decision into toolexec.DecideDecidefunction with full unit-test matrixextract Dispatcher into pkg/runtime/toolexectoolexec.Dispatcher+Emitter/HookDispatcherinterfaces, with 10 dispatcher unit tests covering routing, approval outcomes, and batch-cancellation synthesissimplify dispatcher with per-call value type*callvalue type to cut 7-arg method signatures, plus 4 review fixes (see below)What lives in
pkg/runtime/toolexecnowDesign highlights
Dispatchercarries its dependencies as struct fields:Tracer,Hooks,Resume,AgentFor,Permissions,Handlers. No method-on-LocalRuntime hidden state.Emitteris a 5-method interface covering only the dispatch-shaped events (ToolCall, ToolCallResponse, ToolCallConfirmation, HookBlocked, MessageAdded). Anything else stays runtime-private.HookDispatcheris a 2-method interface so the dispatcher doesn't depend on the runtime's hook registry directly.toolexec.Decide(...) → PermissionDecision{Outcome, Reason, Source}. Every log message the original code emitted is preserved via theReasonfield, with no behavior change.*callvalue type so helper methods take(ctx)or(ctx, runTool)instead of 7 arguments.ctxis never stored in a struct — passed explicitly to every method that needs it (idiomatic Go, satisfiescontainedctx).Validation
go build ./...✅go vet ./...✅mise lint(golangci-lint + custom 800-file Go cop +go mod tidy --diff) → 0 issues ✅mise test→ all packages pass, no failures ✅go test -race -count=1 ./pkg/runtime/...✅Reviewing tip
Each commit is independently reviewable. For the cleanest reading order:
Suggested follow-ups (not in this PR)
ToolHandlerFuncandr.toolMapregistration intotoolexecso the runtime stops needing to bind handlers to achan Eventper stream.pkg/runtime/event.goconstructors topkg/runtime/events, then drop thechanEmitteradapter in favour of a directchan Eventimplementation ofEmitter.