fix(confirm-write): report failed writes, label retries, and approve one write at a time - #23
Conversation
Creating one Linear project asked for approval four times. The agent proposed save_project with a team named "Growth" and two date resolutions with no dates; Linear rejected it, the agent corrected the arguments and called again, and every corrected call re-entered the approval gate. Three approvals wrote nothing and only the fourth created the project. Nothing said so: each card flipped to "Approved -- writing now" and stayed there, so the thread read as the same question asked over and over. The interceptor sits inside langchain_mcp_adapters' result conversion, so it sees CallToolResult(isError=True) before it becomes the ToolException the agent handles. That is the only point where the write's real outcome is known, so read it there: emit the failure to the thread, and remember it so the next confirmation for the same tool opens with the attempt number and the reason the last one failed. Failure memory is keyed by the graph's thread id, so one conversation can never label another's card, and is dropped entirely when there is no thread id rather than sharing a key. Success and decline both clear it; the map is bounded because the interceptor outlives every conversation. The approved card also stopped claiming more than it knows. It is never revisited -- the agent, not the click handler, learns whether the tool accepted the write -- so it now says the write is running and lets the outcome follow in the thread.
The repeated approvals behind the previous commit were avoidable: the
agent passed the user's wording ("Growth") through as a team identifier
when the team is "Growth & Partnerships", and volunteered
startDateResolution and targetDateResolution without the dates they
qualify. Every wrong argument costs the user another approval, because
the retry is a new tool call and the gate is per call.
Tell the agent to look names up before proposing a write, to send only
the fields the user asked for plus the ones the tool requires, and to
say plainly what failed and what it changed when it does retry.
Added: a second, harder failure in the same gateTyler hit this in production: Cause, reproduced against LangGraph directly. A model turn can emit several tool calls at once, and LangGraph runs each as its own task. Two mutating calls in one turn therefore both reach I checked the obvious alternative first: a second turn arriving while a card is still unanswered. It does not stack — the new input replaces the pending interrupt and a bare resume still works. So the accumulation is strictly within one turn.
Fix. Only the first write on a conversation may pause for approval. The rest come back unrun with a result telling the model to re-issue them, which it does on the next turn — so writes queue behind one card at a time instead of racing for the same resume. The claim is keyed by the call itself so it survives the replay that resume performs, and is released only when an approval resolves, approved or declined. The gate is shared across interceptors because each MCP server gets its own — two writes to different servers in one turn is exactly the case a per-server gate would miss. Verified end to end with the real interceptor inside a real graph, real 77 agent tests pass (7 new for this): parallel writes deferring, a replayed call reclaiming its own pause, release on approve and on decline, cross-server sharing, reads never deferred, and no-thread-id still gating the write rather than skipping approval. The recursion-limit failure from the other log is separate and is in #27. |
What happened
Creating one Linear project asked Jerel for approval four times (Slack thread).
Cards were posted at 12:53:08, 12:53:26, 12:53:41 and 12:54:02 PDT. Three were approved; one is still sitting there pending. Only one project exists in Linear, created at 19:54:11Z — so the first two approvals wrote nothing.
The winning call's arguments differ from what the first card proposed:
GrowthstartDateResolutionmonthtargetDateResolutionmonthThe agent was correcting itself. There is no Linear team named "Growth", and
startDateResolutionis documented as pair-with-startDatebut was sent alone. Approve → Linear rejects → agent searches → corrects → the gate fires again, because it is per tool call.Nothing communicated any of that. Each card flipped to "Approved — writing now" and stayed there even when the write was rejected, so the thread read as the same question asked over and over.
What this changes
Retries are labelled. A re-ask now opens with the attempt number and the reason the last one failed:
First asks are unchanged.
Failed writes reach the thread. The interceptor sits inside
langchain_mcp_adapters' result conversion, so it seesCallToolResult(isError=True)before it becomes theToolExceptionthe agent handles — the only point where the write's real outcome is known. On failure it emits⚠️ **Save project** failed — <error>and remembers the failure for the next card.Failure memory is keyed by the graph's
thread_id, so one conversation can never label another's card, and is dropped entirely when there is no thread id rather than sharing a key. Success and decline clear it; the map is bounded, since the interceptor outlives every conversation.The approved card stops over-claiming. It is never revisited — the agent, not the click handler, learns whether the tool accepted the write — so it now says the write is running and lets the outcome follow in the thread.
Prompt rules address the cause. Resolve names to real records before proposing a write; send only required and requested fields; say what changed when retrying.
Testing
Pre-existing, not touched
app/cleanup.test.tsfails andtscreports 4 errors on a clean tree too:package.jsondeclares@copilotkit/channels@0.6.1while the installed tree is0.2.2-canary.rc-1. Same 4 errors and same 1 failing test before and after this branch. That is #15 / #16 territory.Still open from the investigation
onTurnlocks,onInteractiondoes not). That is how two runs completed on the same thread at the same nanosecond (19:53:49.084168 / .084177) — a click racing a message. SDK-side fix.