jackwener/maka-agent

bug(runtime): every pre-dispatch tool refusal kills the turn — the synthetic result lands as an orphan_response the ledger rejects

开放

#2,234 创建于 2026年8月5日

 (0 条评论) (0 个反应) (0 位负责人)TypeScript (0 个派生)github user discovery
help wanted

仓库指标

星标
 (1 个星标)
PR 合并指标
 (PR 指标待抓取)

描述

What happened

A model sent agent_swarm one malformed item (items[2] carried neither subagent_id nor a legacy profile). That is a recoverable mistake, and the runtime is built to treat it as one: formatToolArgsViolationText exists so the refusal names the fields the call does take, and packages/runtime/src/__tests__/tool-args-violation.test.ts asserts the refusal comes back as a tool result the model can read and correct.

Instead the whole turn died. From the user's side the transcript simply stops under a red Agent Swarm card:

Tool "agent_swarm" arguments failed validation: [
  {
    "code": "custom",
    "message": "Provide exactly one of subagent_id or legacy profile.",
    "path": [ "items", 2 ]
  }
] agent_swarm takes `items`, `prompt_template`, `profile`, `subagent_id`, `resume_run_ids`, `max_concurrency`.

No retry, no further steps, composer idle. The model was never given the chance to fix its third item.

What the durable state shows

The refused call left nothing in the ledger, and the run never reached a terminal state:

  • runtime_events for the turn stops at event_seq 102 — the assistant text that preceded the call. There is no function_call and no function_response for agent_swarm anywhere in the session.
  • tool_journal_events / tool_operations have no row for it either; the last entries are the preceding Read operations, cleanly outcome_committed.
  • core_agent_runs still holds "status": "running" for that run (it becomes failureClass: "app_restarted" on the next launch), carrying:
"traceWriteError": "append runtime event: Tool ledger transition rejected: orphan_response at 8de09ddf-1b18-4b0c-964f-8d69be078d69"

So the append of the synthetic error result was rejected by the ledger, the rejection threw out of the append, and the turn unwound with the run row stuck mid-flight.

Root cause

packages/runtime/src/tool-runtime.ts:858-864 already names this exact failure mode — but it only closes it for one of the refusal paths:

// Exclusive-step rejection is preflight: it must remain on the generic
// call/response lane instead of claiming the T1 dispatch protocol. If the
// call carried an operationId here, AgentRun would (correctly) skip its
// generic projection assuming commitToolPrepared already persisted it;
// the synthetic response would then become an orphan.
const operationId =
  this.input.runtimeCommitSink && invocationId && !admissionFailure
    ? buildToolOperationId({ invocationId, providerToolCallId: toolUseId })
    : undefined;

admissionFailure is the only pre-dispatch refusal excluded. Every other one returns before prepareDurableToolAttempt (:1107), which is what calls commitToolPrepared and actually persists the call on the T1 lane:

refusal site
arguments failed schema validation :950
loop-gate / repeated failing call :1005, :1026, :1049
permission denial, sandbox boundary :1066, :1077
subagent tool limit :1102

For all of those the tool_start event has already been pushed with an operationId (id: ${operationId}_call), so AgentRun skips its generic projection of the call and waits for a commitToolPrepared that never comes. writeSyntheticToolResult (:728) then finds no durable attempt for the call:

const durableAttempt = this.durableToolAttempts.get(durableAttemptKey(turnId, toolUseId));
const durableOutcome = await durableAttempt?.commitOutcome(content, true);
…
queue.push({
  type: 'tool_result',
  id: durableOutcome?.id ?? this.input.newId(),          // ← UUID, not `${operationId}_response`
  …
  ...(durableOutcome ? { operationId: durableOutcome.operationId } : {}),  // ← omitted

— and writes the result onto the generic lane with a fresh UUID and no operationId. tool-ledger-scanner.ts:351-372 matches responses to calls by (invocationId, toolCallId), finds no call, and raises orphan_response. The append throws, and the turn dies.

The UUID in the recorded traceWriteError (rather than a toolop_<hash>_response id) is the fingerprint of that fallback path.

Scope

Observed via the argument-validation path. The other rows in the table above share the same shape — pushed tool_start with an operationId, returned before prepareDurableToolAttempt, synthetic result on the generic lane — so they should be lethal in the same way; that part is read off the code, not yet reproduced. If so, the class is "every recoverable pre-dispatch refusal is fatal whenever runtimeCommitSink is active", i.e. in the real Desktop runtime.

Two secondary observations, separable from the fix:

  1. agent_swarm's refusal text says Provide exactly one of subagent_id or legacy profile. without listing the valid profile enum values or pointing at agent_list. The field list appended by formatToolArgsViolationText is the top-level one, which is not where the violation was (path: ["items", 2]). Once the turn survives the refusal, this is what decides whether the model actually repairs the call.
  2. A single rejected event append killing the turn silently — and leaving the run row at status: "running" with no terminal event — is its own weakness, independent of what caused the rejection.

How to reproduce

  1. Desktop, a real session (durable runtimeCommitSink active).
  2. Get any tool call whose arguments the tool's own zod schema rejects. A cross-field superRefine rule is the reliable way to reach ToolRuntime's validation, e.g. an agent_swarm item with neither subagent_id nor profile.
  3. The refusal renders under the tool's card and the turn ends there. Afterwards the session's runtime_events hold no call/response pair for it, and the run row in core_agent_runs carries traceWriteError: … orphan_response … while still reading status: "running".

Environment

  • Maka commit: cef8c44d8 (locally packaged unsigned build, app version 0.1.5)
  • OS: macOS 26.6 (arm64)
  • Surface: Desktop
  • Node.js: v22.17.0
  • Model: deepseek-v4-pro over an openai-compatible connection

贡献者指南