Skip to content

refactor(core): settle declined tool calls durably with typed reasons - #38734

Merged
kitlangton merged 1 commit into
v2from
decline-settlement
Jul 24, 2026
Merged

kitlangton merged 1 commit into
v2from
decline-settlement

Conversation

@kitlangton

Copy link
Copy Markdown
Contributor

What

When a user declines a permission or dismisses a question, the declined tool call's durable record now says so — "The user declined this tool call" / "The user dismissed this question" — instead of the generic "Tool execution interrupted" sweep. Under the hood, the decline defect tunnel gets a typed exit, and both ends of the tunnel are now documented in the code.

Before / After

Before: PermissionV2.assert and the question tool deliberately Effect.die on a bare decline so the leaves' blanket mapError (~30 sites) can't convert a user's "no" into model-facing tool output. The defect tunneled through the registry untyped; the runner sniffed it back out of causes with instanceof on defects (isUserDeclined); settlement couldn't attribute it to a call, so the declined call was swept together with everything else as aborted: "Tool execution interrupted". Clients saw a decline and an infrastructure interrupt identically.

After: the tunnel is unchanged at its entrance (a decline still never enters leaf error channels) but resurfaces as a typed failure exactly once, at SessionModelRequest.executeTool — the seam the runner executes through. The classifier reads failure _tags instead of defect sniffing, pairs each decline with its originating call via toolRuns (exits align by construction), and settlement publishes Tool.Failed for that specific call with its actual reason before the generic sweeps close the rest. The drain still halts via interrupt, and CorrectedError (decline with feedback) still rides the typed lane into ToolFailure so the model continues.

flowchart LR
    A[leaf tool<br/>blanket mapError] -->|"decline dies (tunnel)"| B[registry<br/>permission-agnostic]
    B --> C["SessionModelRequest.executeTool<br/>catchCauseFilter: defect → typed failure"]
    C --> D["classifier: _tag match,<br/>paired with its ToolCall"]
    D --> E["Tool.Failed for THAT call,<br/>with the decline reason"]
    E --> F[sweep rest as aborted;<br/>drain halts]
Loading

How

  • model-request.ts — new ExecuteError = ToolOutputStore.Error | PermissionV2.DeclinedError | QuestionTool.CancelledError; declineDefect filter (Effect.catchCauseFilter) resurfaces the tunnel defects as typed failures. The widened union lives only on Prepared.executeTool; ToolRegistry.ToolSet["execute"] keeps its clean error type and the registry stays permission-agnostic.
  • runner/llm.ts — isUserDeclined (defect instanceof) replaced by a typed isDecline guard; classifyToolExits takes the calls and returns per-call declines: [{ call, reason }]. The failure cause is rebuilt via Cause.isFailReason/makeFailReason/fromReasons with decline reasons stripped, so the drain's error channel is Cause<ToolOutputStore.Error> — no decline type leak, no casts. When the settlement await itself is interrupted there are no per-call exits and behavior degrades to today's sweep.
  • publish-llm-event.ts — failTool(callID, error) extracted from failTools' loop body; the bulk sweep now delegates to it and skips already-settled calls, so the decline settles first and the sweep never double-publishes.
  • permission.ts / tool/question.ts — the two bare Effect.die sites gained comments explaining the tunnel and pointing at the typed exit.
  • tool/AGENTS.md — the leaf contract now states declines must never be caught or converted by leaves, and where they resurface.

Scope

The durable error stays { type: "aborted" } with a specific message; a first-class declined error variant in the schema (visible to clients as a distinct type) is deferred — it needs client regeneration and its own decision. Single-writer settlement (c26e466a) and the two-phase tool contract (041a640b) remain separate follow-ups.

Testing

From packages/core:

  • bun typecheck — clean
  • 9 focused suites (runner, recorded, tool-events, prompt, create, subagent, permission, question, tool-question) — 238/238; the two decline tests updated to pin the new per-call messages
  • bun run test test/session-runner.test.ts test/session-runner-recorded.test.ts --rerun-each 3 — 417/417
  • Full core suite: only location-layer and config/plugin fail intermittently — verified flaky on clean v2 with identical reruns (3 failures, zero changes)

@kitlangton
kitlangton enabled auto-merge (squash) July 24, 2026 18:12
@kitlangton
kitlangton merged commit 5ae2d6d into v2 Jul 24, 2026
9 checks passed
@kitlangton
kitlangton deleted the decline-settlement branch July 24, 2026 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant