A failed hand-off that never reached you, because a framework reports a broken tool two different ways
A delegation that blew up rendered as "Delegating to the research specialist" forever, or as "the research specialist finished". The trace reader handled neither shape: a caught tool failure arrives as an ordinary end event carrying an error status, and an uncaught one arrives as a different event the reader had no case for. Four surfaces now say so — including after a reload, which used to rewrite every failure as a success. Release notes 2026-09-16 — vocion-core#368.
Status: shipped. #368, commit e6410c47, carried by v2.83.0. No migrations, no environment variables, no flags. Part of Vocion 2.88. The other half of the same pull request is Tag @artifact, and the turn owes you a document. One limit stated up front: none of this fires on the out-of-process execution targets, and that is explained at the end.
Someone asked for a report. What came back was a Tool error badge, one line reading "Delegating to the research specialist", a paragraph of narration, and nothing else. The delegation never completed. Nothing on the screen said so.
The interesting part is not that a tool failed. Tools fail. The interesting part is that the failure had reached the process, twice, in two different shapes, and the code that turns those events into the timeline you read had a case for neither.
Shape one: the failure that looks like a success
LangGraph's tool node catches a throwing tool. It does not let the run die; it returns a ToolMessage carrying status: 'error' so the model can see what went wrong and try something else. That is the right behaviour for the loop, and it means a failed tool arrives at the trace reader as a perfectly ordinary on_tool_end event.
The reader's delegate branch looked like this:
return [{
type: 'trace_node', id, parentId, actor, kind,
status: 'done',
label: labelFor('delegate', 'done', name),
}];
status: 'done', unconditionally. Label: `${subject} finished`. An on_tool_end arrived, so the delegation ended, so it finished. The status riding inside the payload was never read.
The fix is small and the comment beside it says exactly why it matters:
/**
* LangGraph's ToolNode catches a throwing tool and returns a ToolMessage with
* `status: 'error'` rather than letting the run fail, so a failed tool reaches
* us as a perfectly ordinary `on_tool_end`. Reading the status is the only way
* to tell "the specialist answered" from "the specialist blew up", and getting
* that wrong is how a delegation that failed rendered as `<name> finished`.
*/
export function toolResultStatus(output: unknown): 'success' | 'error' | undefined {
const status = (output as { status?: unknown } | null | undefined)?.status;
return status === 'error' || status === 'success' ? status : undefined;
}
Total and narrow: anything that is not one of the two known strings is undefined, so a shape change upstream degrades to "we don't know" rather than to a confident wrong answer.
Shape two: the failure that produces no end event at all
The second shape is on_tool_error, emitted when the failure is not caught — and in that case there is no on_tool_end at all.
The reader's switch had no case for it. It fell through to default: return [].
So the trace node opened at status: 'start' with the label "Delegating to the research specialist" and stayed there for the rest of time. And a node stuck at start is indistinguishable from a delegation that is still running, which is the worst possible rendering: the screen is telling you to wait for something that already died.
There is now a case for it, and a message extractor that survives everything a framework might hand over:
export function toolErrorMessage(raw: unknown): string {
const text = raw instanceof Error
? (raw.message || String(raw))
: typeof raw === 'string' ? raw
: (raw && typeof raw === 'object' && typeof (raw as { message?: unknown }).message === 'string')
? (raw as { message: string }).message
: raw === undefined || raw === null ? '' : String(raw);
const cleaned = text.replace(/^Error:\s*/i, '').replace(/\n\s*Please fix your mistakes\.?\s*$/i, '').trim();
return (cleaned || 'the step failed with no message').slice(0, 300);
}
An Error, a bare string, an object with a message, null, or something else entirely. It strips the Error: prefix and the trailing "Please fix your mistakes." the framework appends for the model's benefit, caps at 300 characters so a stack trace never becomes a timeline label, and falls back to a sentence rather than to an empty string — because a blank label is another way of saying nothing happened.
Shape three, which is not a shape: the run dies mid-delegation
Both of the above assume an event arrives. Sometimes the run itself throws and no per-tool event is emitted at all.
So the emitter now keeps a map of delegations that started and have not reported an outcome:
/**
* Delegations that started and have not yet ended, so a run that dies
* mid-delegation can still close them as failures. A delegate node left at
* `start` forever is exactly the trace this class shipped with, and it is
* indistinguishable from a delegation that is still running.
*/
private readonly openDelegations = new Map<string, { actor: TraceActor; parentId?: string; name: string }>();
and closeDelegations(message) in the run's own catch walks that map and emits an error node for each, carrying the run's failure message.
The labels were given an error voice at the same time. A delegate that failed reads "<specialist> could not finish" rather than "<specialist> finished"; a search reads "Search failed <query>"; anything else reads "<subject> failed".
Where the person actually sees it
A trace node is not enough, because plenty of people never open the timeline. Four surfaces:
1. The answer says so. When delegations failed, a sentence is appended to the answer:
The hand-off to the research specialist did not complete (rate limit exceeded), so nothing from that step is in this answer.
That last clause is the load-bearing one. The failure matters to the reader because of what is missing from what they are about to act on.
The notice suppresses itself when the answer already owns up, and the test for that is two conditions rather than one: the answer must name the specialist and match a regular expression of admissions (failed, couldn't, wasn't able, unable, errored, didn't complete, broke). Naming the specialist alone is not enough — an answer that says "I asked the research specialist and here is what I found" names it and admits nothing.
2. The live trace, where the delegate node flips to an error state as it happens.
3. The in-flight run row, which turns red mid-turn. Both of these existed before this change — what was new is that the event that drives them now actually gets emitted.
4. After a reload. This is the one worth dwelling on.
The hydrate that rewrote history
Persisted runs were rehydrated like this:
runsRaw.map(run => (run.type === 'tool' ? { ...run, state: 'done' as const } : run))
Every tool run, on every page load, became done. A turn whose delegation had failed, whose answer said so, whose live trace had shown it in red — reloaded as a clean success.
runsRaw.map(run => (run.type === 'tool' ? { ...run, state: run.state ?? ('done' as const) } : run))
A recorded state wins; the default only applies where there is none. Paired with that, a run collector on the server now handles a tool error by writing the message and an error state onto the matching run, adding one if no run is open. That collector was extracted out of the streaming route in this pull request precisely so it could be tested — it had been inline, and inline code in a streaming route is code nothing asserts against.
The state rides the existing runs JSON column, so there is no migration for any of this.
What is not covered
Be exact about this, because the failure mode being fixed here is a surface that confidently claims something it does not know.
The out-of-process execution targets report no failed delegations at all. The code path that runs a loop in a separate worker hard-codes an empty list, with the reason stated in a comment: delegation nesting is not visible across the transport. The events the emitter reads simply do not cross that boundary. So on those targets a failed hand-off still produces no notice — the answer will not mention it, and the timeline will not show it.
That is a real gap, named rather than implied away. Everything above holds for the in-process loop.
Two smaller ones from the same pull request: the deliverable contract is not forwarded by one of the managed providers, and the prompt-level hint that accompanies it exists in exactly one runtime rather than all of them — the backstop is what makes the guarantee hold where the hint is missing.
Where this lives in the repo
packages/core/src/services/agents/traceEmitter.ts—toolResultStatus,toolErrorMessage,openDelegations,closeDelegations,delegateName,openDelegationNames, and thelabelForerror casespackages/core/src/services/AgentService.ts—delegationFailureNotice, and thecatchthat closes open delegationspackages/core/src/services/chat/runCollector.ts—onToolErrorpackages/core/src/features/dashboard/chat/useChatSession.ts— the hydrate fix- Tests:
services/agents/traceEmitter.test.ts(both shapes, run-death closing, and the success case),services/chat/runCollector.test.ts,services/AgentService.turnGuarantees.test.ts - All in
vocion/vocion-core