Guides
Error handling and completion
An unhandled error aborts the session permanently. This is what the engine does with a failure, how to end a workflow deliberately, and why throwing from a tool handler is rarely what you want.
Two things share this page because they share a field: runStatus. A session is running,
completed or aborted, and only the first can be resumed. Understanding how each state is
reached is what stops a transient failure from destroying a conversation.
What an unhandled error does
FlowEngine catches everything thrown during a run and converts it:
{
success: false,
completed: true,
message: error.message,
session: sessionId ?? "",
contentType: HttpContentType.Plain,
}
Before returning that, it sets runStatus = "aborted" on the session, writes the message to
the document’s error array with SessionLogger, and persists. If that persistence itself
fails, the returned message is extended with
; failed to persist aborted session: <reason>.
Two details catch people out:
The failure envelope sets completed: true alongside success: false. Clients must branch on success, not on completed. Treating completed as "the workflow finished normally" will make an aborted session look like a successful one.
An aborted session cannot be resumed. The next request carrying that session ID does not restore it — it silently creates a brand-new session with a new ID, exactly as it does for a completed one. A single thrown exception therefore discards the entire conversation.
The three errors handled differently
SessionConflictError, SessionFlowMismatchError and SessionFlowInvariantError are
returned as failures without marking the session aborted and without writing to it. The
winning document must remain untouched. See
Concurrency and session conflicts.
Prefer stay() over throw
Because abortion is permanent, a thrown exception is the strongest possible reaction to a problem. It is right for programmer errors and unrecoverable infrastructure failures. It is wrong for anything the conversation could recover from.
// Recoverable: tell the model what to fix, keep the session alive
@Tool
protected async address(args: Record<string, any>): Promise<ToolResponseType> {
const response = ValidateAddress(args?.address);
if (!response) {
return stay("Invalid address. Ask for street, city, two-letter state, and ZIP.");
}
this.saveState({ address: response });
return go(TerminateSessionStep);
}
// Unrecoverable: the configured file cannot be read, so the run cannot continue
try {
const result = await fileMgr.uploadFile(localPath);
// ...
} catch (_error) {
throw new Error(`read file ${fileName} failed`);
}
Rule of thumb: if a human could plausibly correct it in the next message, return stay(...).
If they could not, throw — and accept that the session ends.
Transient downstream failures deserve a third treatment: retry inside the handler, then
degrade to stay(...) with an honest message. Do not let a 503 from a pricing API destroy a
twenty-turn booking conversation.
Completing through TerminateSessionStep
For any user-facing workflow, complete through the terminal step:
@Tool
protected async terminate_session(): Promise<ToolResponseType> {
return go(TerminateSessionStep).withPrompt(DemoPrompt.AbruptEnd);
}
TerminateSessionStep is bundled and does four things:
onEnter()callsflow.markCompleted(), settingrunStatus = "completed";isEnd()returnstrue, so the response reportscompleted: true;onCrossing()inherits the prior step’s content type and supplies its own closing message;getPrompt()returnssuper.getPrompt() ?? AbruptEndPrompt, sowithPrompt(...)from the transition wins.
It also defines the terminate_session tool. Expose it on any step where the user should be
able to leave, with a @Tool terminate_session handler that routes to it.
It is included in the base defineSteps(), but the moment you override that method you own
the list — register it explicitly:
protected defineSteps(): Step[] {
return [
new CollectCustomerStep(this).useMemory("customer"),
new TerminateSessionStep(this).useMemory("end"),
];
}
Give it its own memory namespace. Sharing one with a conversational step replays that transcript into the closing turn.
sessionCompleted() for workers and coordinators
A step can mark completion directly, without a terminal conversation turn:
step.sessionCompleted(); // on a Step
this.flow.markCompleted(); // on a Flow
Both set runStatus = "completed". Use them where a closing message would be noise:
// A batch coordinator
protected async spawnSteps(): Promise<string> {
const step = await this.goto(PresidentStep);
await this.concurrentSteps({ /* ... */ });
const msg = `Finished concurrent flow: ${this.id}`;
new SessionLogger(this.getSessionDoc()).log(msg);
step.sessionCompleted();
return msg;
}
// A one-shot extraction that returns raw JSON
this.flow.markCompleted();
return direct(args?.json).withContentType(HttpContentType.Json);
Neither concurrentSteps() nor a returned message changes runStatus. A coordinator that omits sessionCompleted() leaves its session running indefinitely — which is exactly what InvoiceFlow.spawnSteps() does today.
Note the interaction with the response envelope. completed is computed from
requireCurrentStep().isEnd(), and the default isEnd() reads runStatus. So calling
sessionCompleted() mid-turn makes the whole turn report completed: true.
deleteSession()
Completion keeps the record; deletion destroys it.
const result = await flowEngine.deleteSession(sessionId);
// { success: true, session } | { success: false, message, session }
It takes the same per-session lock as a run and performs a revision-checked delete, so it cannot race a local turn. Use it only when the stored record itself must not exist — a retention policy or a privacy request — not as a way to end a conversation.
endChat() is a deprecated delegate to deleteSession(). Its name conflates the two ideas;
prefer the explicit call.
Direct responses and content types
The response envelope carries the current step’s content type, and the demo controller branches on it:
if (result.session) {
res.header(K.ChatSessionID, result.session);
}
if (!result.success) {
res.status(HttpStatus.BAD_REQUEST);
}
if (result.contentType && result.contentType !== HttpContentType.Plain) {
return res.type(result.contentType).send(result.message);
}
return res.send(result);
Consequences to design around:
- a non-plain content type sends the raw message instead of the envelope, so
success,completedandsessiondisappear from the body — the session ID remains in the header; - error responses are always plain text, because the failure envelope sets
HttpContentType.Plain, so a JSON client must handle a plain-text failure body; - only set a content type the HTTP adapter and the caller both understand.
Where to look when something fails
The session document is the log. SessionLogger writes structured entries into five arrays —
log, error, warn, debug, verbose — that persist with the session.
| Array | Written by |
|---|---|
error |
The engine’s abort path; your own SessionLogger calls |
warn |
Missing tool handlers, hallucinated tools, model retry attempts, skipped memory compaction |
log |
Batch progress from concurrentSteps(); your own calls |
A model that “silently ignores” a tool almost always left missing tool handler: x or
hallucinated tool: x in warn.
Failure modes
| Symptom | Cause |
|---|---|
| A conversation restarts after one bad turn | Something threw; the session was aborted and a new one was created |
| Client thinks a failure succeeded | It checked completed instead of success |
LLM call failed. Reason:length. |
Retries exhausted; often a truncated response — raise maxTokens |
Sessions accumulate in running forever |
A coordinator or worker never called sessionCompleted() |
| Terminal step replays an old transcript | It shares a memory namespace with a conversational step |
Flow 'X' has no current step. |
flow.currentStep is null — usually a migration that cleared it |
| Failure body is plain text where JSON was expected | The failure envelope always uses HttpContentType.Plain |
| No error detail anywhere | The abort save itself failed; check the appended failed to persist aborted session text |
Related: Persistence and session stores, Concurrency and session conflicts, and LogicStep and TerminateSessionStep.