Finishing Agents
Agents need a way to signal completion and return structured output. Helix provides two mechanisms for this: the auto-injected __finish__ tool and user-defined finishWith tools.
outputSchema Is a Lifecycle Contract
When you give an agent an outputSchema, that schema isn't just a parsing hint applied to whatever text the model happens to produce — it's a lifecycle contract. The agent's terminal state is binary:
completedwith schema-valid output, orfailedwith an explicit, classified error.
An outputSchema agent never reports completed with missing output. This holds on every runtime (JS, Cloudflare Durable Objects, Cloudflare Workflows, Temporal, DBOS). If the model produces terminal text instead of calling the completion tool, its response gets truncated by max_tokens, or the agent runs out of maxSteps while still mid-tool-loop, the framework does not silently finalize with output: undefined — it enters a deterministic forced-completion phase that either recovers a schema-valid output or fails the run outright.
Not on later turns of a DBOS persistent-mode session (MA-21)
In DBOS persistent mode (the long-lived inbox workflow), the turns after the first that arrive through the inbox (a follow-up execute() routed into the live workflow, or a companion sendMessage) run without the agent's outputSchema. On those turns __finish__ arguments are saved unvalidated, and a turn that ends on plain text, max_tokens or maxSteps completes with no output instead of entering forced completion. See MA-21 in the conformance findings.
The Two Completion Mechanisms
__finish__ Tool (Auto-Injected)
When you define an agent with an outputSchema, Helix automatically injects a __finish__ tool:
const agent = defineAgent({
name: 'analyzer',
systemPrompt: 'Analyze the input and return results',
llmConfig: { model: openai('gpt-6-luna') },
outputSchema: z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number(),
}),
// __finish__ tool is auto-injected with schema from outputSchema
});The LLM calls __finish__ with data matching your outputSchema to complete the agent:
// LLM calls: __finish__({ sentiment: 'positive', confidence: 0.95 })
// Agent completes with output: { sentiment: 'positive', confidence: 0.95 }Characteristics of __finish__:
- Auto-generated from
outputSchema - No side effects (just captures output)
- Simple and straightforward
- Good for most use cases
- The completion is recorded as a paired tool call + tool result. When the LLM calls
__finish__, the persisted history includes the finish tool call and a synthetic{ acknowledged: true }tool result that pairs it — so the transcript never has a dangling tool_use with no matching tool_result (see Continuing a completed structured-output agent). Every adapter surfaces__finish__as a tool call,@helix-agents/llm-vercelincluded (RM-35), so this holds whatever model you use.
Strict (constrained) structured output
By default the model is asked to call __finish__ with schema-conforming JSON, but a model can occasionally lapse into malformed JSON (e.g. a markdown bullet list where a string[] is expected). That output is hard to recover.
Set llmConfig.strictOutput: true to opt the __finish__ tool into the provider's constrained decoding (a.k.a. strict / structured tool use). A capable model is then forced to emit output matching outputSchema:
const agent = defineAgent({
name: 'researcher',
outputSchema: z.object({
findings: z.string(),
gaps: z.array(z.string()),
}),
llmConfig: {
model: anthropic('claude-sonnet-5-5'),
strictOutput: true, // force schema-conforming __finish__ output
},
systemPrompt: '...',
});Default is OFF. When unset, behavior is unchanged.
Capability-gated, safe no-op. strictOutput is forwarded to the provider by the LLM adapter, which honors it only for models that support constrained decoding. On a model/provider that doesn't (or an adapter that ignores the field), the request is simply sent without strict — no error. For the Anthropic provider this is gated by its per-model supportsStructuredOutput table; for OpenAI it maps to strict function calling.
Schema compatibility. Strict providers reject some exotic schemas. If a request 400s under strict, prefer fixing the schema to be compliant (avoid z.record, raw pattern, exotic unions; for OpenAI, all properties must be required and additionalProperties: false) over disabling strict. Disable it for a single agent only as a last resort.
finishWith tools. Agents that complete via a finishWith tool instead of __finish__ opt in per-tool: defineTool({ ..., strict: true }).
Observability. When structured output can't be matched to the schema and can't be repaired, the framework now logs a loud warn (with the Zod issues) instead of silently passing the value through. This warning goes through the configured Logger; the default logger is silent (noopLogger), so wire a real logger (e.g. consoleLogger or your pino/winston instance) on the executor to see it. Enabling strictOutput on a capable model prevents that situation entirely.
Continuing a completed structured-output agent
A completed structured-output agent can be continued after it returns — as a new turn that preserves its memory and produces a fresh typed result.
This works because of the history invariant noted above: a completed structured-output agent's persisted history now includes a synthetic __finish__tool result ({ acknowledged: true }) that pairs the __finish__ tool call. History therefore never contains a __finish__ tool_use without a matching tool_result. Previously, the dangling __finish__ tool_use broke continuation on real LLM providers (Anthropic/OpenAI reject a transcript whose last assistant turn has an unanswered tool_use), so a structured-output agent could not be re-run on its own session.
This applies to root agents, sub-agents, and persistent companions, and works identically on all five runtimes (JS, Cloudflare DO, Cloudflare Workflows, Temporal, DBOS).
Two ways a completed structured-output agent gets continued:
- A root agent — call
execute({ sessionId })against thecompletedsession to start a fresh turn. See Interrupt & Resume — Continuing a completed agent. - A persistent companion (critic loop) — re-consult a
completedchild withcompanion__spawnAgent/companion__sendMessage; the child continues on its preserved session and returns a fresh verdict. See the critic-loop recipe.
finishWith Tools (User-Defined)
For cases where you need side effects when completing—like saving to a database, sending notifications, or performing validation—use finishWith tools:
const submitAnswerTool = defineTool({
name: 'submit_answer',
description: 'Submit the final answer after verification',
inputSchema: z.object({
answer: z.string(),
verified: z.boolean(),
}),
outputSchema: z.object({
result: z.string(),
submittedAt: z.string(),
}),
finishWith: true, // <-- This makes it a finishWith tool
execute: async (input, context) => {
// Side effects execute here
await saveToDatabase(input.answer);
await sendNotification(`Answer submitted: ${input.answer}`);
return {
result: input.answer,
submittedAt: new Date().toISOString(),
};
},
});Characteristics of finishWith tools:
- User-defined with custom logic
- Can perform side effects (API calls, DB writes, etc.)
- Execute function runs before completion
- Tool output becomes agent output (or is transformed)
Forced Completion
Terminal text without a completion-tool call, max_tokens truncation, and running out of maxSteps mid-tool-loop are not failure modes an outputSchema agent silently falls through — they are triggers into a deterministic forced-completion phase, shared by every runtime. A stopWhen predicate that fires before the agent has produced output enters the same phase (on JS, the Cloudflare DO and Cloudflare Workflows; Temporal and DBOS do not evaluate stopWhen yet, DI-29). It is recorded with the max_steps trigger.
The phase
When one of the triggers fires, the framework:
- Picks a completion strategy for the model the call uses —
forced_toolornative_output(see Completion strategies below). The strategy is resolved again on every forced call; it is never stored. - Makes a forced call that enforces the answer. Under
forced_toolit sends a named tool-choice directive for the expected completion tool (the agent'sfinishWithtool if one is configured, else the auto-injected__finish__), e.g.{ type: 'tool', toolName: '__finish__' }. Undernative_outputit asks the provider for a response constrained to that tool's input schema. Either way it is a constraint on the call, not a suggestion in a message, and the response is checked: only the expected completion counts. No other tool, sub-agent, workspace, memory, skill or companion call is ever executed while this phase is active. - Is bounded by its own budget,
maxCompletionRetries, independent ofmaxSteps(see Two independent budgets). - Either completes with schema-valid output, or fails with a classified error. There is no path back to ordinary execution once forced completion begins, and no path to
completedwith missing or schema-invalid output.
The schema-valid guarantee in step 4 is enforced unconditionally, not just when llmConfig.strictOutput is set. If a forced call's output does not satisfy the agent's outputSchema, that is not a silent success — the framework validates it (framework_completion_invalid_output) and treats it as a recoverable miss, consuming one maxCompletionRetries attempt like any other miss. The next forced attempt is made, or the phase exhausts if the budget runs out. strictOutput still helps a capable model produce schema-conforming arguments on the first try (see Strict (constrained) structured output above), but it is not what makes the guarantee true — validation is enforced either way.
The phase is durable: it survives interrupt/resume, replay, and Durable Object eviction/hibernation on every runtime, because whether the next call is forced is derived from persisted state (ForcedCompletionState on SessionState), never from process memory or message-prefix scanning. A hidden hint message is appended to conversation history on entry and after each miss so the model has explanatory context:
[completion] Provide your final answer now (attempt N) in the required structured format. Do not call any other tool and do not reply with plain prose.
(plus Your previous response was truncated by the token limit — keep this answer short. after a max_tokens trigger). The wording names no mechanism because the message is persisted before the next call's strategy is known. It is not the enforcement mechanism — the strategy's directive is. (The hint is tagged metadata.forcedCompletion, not read back to reconstruct state.)
Completion strategies
How a forced call can enforce a structured answer depends on the model. Anthropic's Claude Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1 reject a named or required tool choice (HTTP 400 tool_choice: type "tool" and "any" are not supported for this model.), so the framework has two strategies and picks one per forced call from the capabilities the LLM adapter reports for the model:
| Strategy | Enforced by | Used when the model |
|---|---|---|
forced_tool | a named tool choice on the completion tool (the mechanism every model used before) | supports forced tool choice (forcedToolChoice) |
native_output | the provider's native structured output, constrained to the completion tool's input schema, plus response checks | does not, but supports native structured output (nativeStructuredOutput) |
forced_tool wins whenever the model supports it, so every model that worked before keeps the forced request it sent before (only the hidden hint's wording changed). When a model supports neither, there is no strategy and the run fails with framework_completion_strategy_unavailable (see Unknown models). The @helix-agents/llm-vercel adapter maps models to three named capability profiles:
| Profile | Forced tool choice | Native structured output | Thinking bound to history | Strategy | Models |
|---|---|---|---|---|---|
legacy | yes | no | no | forced_tool | every non-Anthropic model; Claude 3.x, Sonnet 4 / 4.0, Opus 4 / 4.0 / 4.1 |
anthropic-structured | yes | yes | no | forced_tool | Haiku 4.5, Sonnet 4.5 / 4.6, Opus 4.5 – 4.8, Sonnet 5, Opus 5, Fable 5, Mythos 5 / preview |
anthropic-5.5 | no | yes | yes | native_output | Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1, Mythos 5.1 |
An adapter that reports no capabilities (any custom adapter written before this) is treated as legacy. See Vercel adapter → Model capabilities for the full table, the id normalisation (Bedrock, Vertex, gateway and OpenRouter spellings) and the capabilities override for proxy aliases and new models.
What each forced call sends:
forced_tool, thinking not bound to history (legacy, anthropic-structured) | forced_tool, thinking bound to history (only via an override) | native_output | |
|---|---|---|---|
| tools | only the completion tool | the full tool set | the full tool set |
| system prompt | trimmed (no skills catalog, preloaded skills or workspace fragment) | unchanged | unchanged |
| tool choice | the named completion tool | the named completion tool | none (auto) |
| reasoning | disabled | unchanged | unchanged |
| output format | none | none | the completion tool's JSON schema |
| max output tokens | llmConfig.maxOutputTokens | llmConfig.maxOutputTokens | at least 16,000 |
The first column is the request every model sent before completion strategies existed (apart from the hint message's wording). The other two keep the system prompt and tool set unchanged, because a model whose thinking is bound to history rejects a request whose system prompt or tools differ from the ones its earlier thinking was produced with (see Preserved thinking). Per-turn memory auto-injection is skipped on every forced call, under either strategy.
The strategy is chosen per call. It is resolved inside the unit that makes the LLM call, from the model that call actually uses: on runtime-js and the Cloudflare DO after llmConfigOverride, so an override that switches the forced call to another model switches the strategy with it. Nothing about the strategy is persisted, so a retry(), a resume() or a branch of a session whose forced phase is pending simply resolves it again against the adapter it runs on. The strategy actually applied is recorded on the forced_completion chunk's attempt_started phase (strategy, capabilityProfile; see Streaming), on the beforeLLMCall / afterLLMCall hook payloads (completionStrategy, capabilityProfile) and on Langfuse generation metadata. Key hooks on executionPhase === 'forced_completion' to detect a forced call: toolChoice is absent under native_output.
How a native_output answer is handled
The model answers with JSON text. The framework normalises it before any runtime code sees it:
- Text that parses as JSON becomes a call to the expected completion tool with the parsed value as its arguments (a synthetic
tool_useidtoolu_helix_<hash>, salted with the run, step and attempt so it is unique and replay-stable). From there it is an ordinary completion: a__finish__target is validated againstoutputSchemaand gets its{"acknowledged":true}result; afinishWithtarget runs itsexecute,ctx.updateStateandfinishWithTransformexactly as when the model calls it, and must be a JSON object. The persisted assistant message carries the call in place of the JSON text, plusmetadata.helixNativeCompletion: true. - A call to the completion tool itself is accepted too (the tool stays in the request, and models sometimes answer that way).
- Any other tool call is a protocol violation: nothing is executed or persisted, and it consumes one attempt.
- Text that does not parse (
framework_completion_invalid_output) or amax_tokenstruncation is a recoverable miss.refusal,content_filteranderrorstop reasons fail at once, as underforced_tool.
A native_output call streams no text_delta chunks: the answer arrives as the completion (the output chunk and the persisted tool call), so a chat UI never shows raw JSON that would disappear on reload. Thinking still streams. A native miss (unparseable text or a max_tokens cut-off) is persisted hidden (metadata.hidden: true), so it does not appear after a reload either; it is still sent to the model. Its thinking, which streamed live, is hidden with it after a reload.
Schema support. Native structured output accepts a subset of JSON Schema. For Anthropic models the adapter drops the constraints the API rejects (numeric minimum / maximum / exclusiveMinimum / exclusiveMaximum / multipleOf, array maxItems, array minItems above 1, and a pattern that uses lookaround, which z.email(), z.hostname() and z.iso.duration() emit; it logs a warning naming that path), rewrites oneOf to anyOf and closes every object (additionalProperties: false). Dropped constraints are still enforced, because the framework validates the answer with the full Zod schema. A violation is a recoverable miss, and the miss names the failing path and Zod issue code ($.email: invalid_format (email)), never the value. If the attempts run out, the failure (framework_forced_completion_exhausted) keeps the last miss as errorDetail.cause. Constructs it cannot represent are refused up front with framework_completion_schema_unsupported: z.record (an object with schema-valued or trueadditionalProperties), tuples (z.tuple: prefixItems or array-form items), object intersections (.and(), an allOf of objects) and anything z.toJSONSchema cannot convert (z.date()). Restructure the completion schema as a single z.object when you see it. This applies only to agents whose model resolves to native_output.
Unknown models and early refusal
An outputSchema agent whose configured model has no completion strategy is refused before any paid call and before its run starts: no run and no message is written. On runtime-js and the Cloudflare DO nothing at all is written. On Cloudflare Workflows and Temporal a brand-new session created by that call is recorded failed, and an existing session is left untouched (see the table below). Cloudflare Workflows writes only that session, never its stream. Temporal also fails the stream that call made live (FU-TEMPORAL-NEVER-RUN-PRE-COMMIT-STREAM-WRITE).
The two refusal codes:
framework_completion_strategy_unavailable— the adapter does not recognise the model (an Anthropic model id with no row in the capability table, such as a model released after your@helix-agents/llm-vercelversion or a newer point release of a listed one), or reports a profile with neither forced tool choice nor native structured output. The message names the model and the fix: declare its capabilities with the adapter'scapabilitiesoverride.framework_completion_schema_unsupported— the model resolves tonative_outputand the completion tool's schema cannot be sent natively (above).
Both are non-retryable. Where the check runs:
| Runtime | Refused at |
|---|---|
| runtime-js | execute(), resume(), retry() and handle.resume() throw the typed HelixError before any store read or write. A sub-agent or persistent companion is checked with its own config and adapter before its session exists; the parent sees a failed delegation |
| Cloudflare DO | /start, /resume, /retry and /submit-tool-result answer HTTP 400 { error, code } before the DO claims or writes anything (a refused submit leaves its call pending; a submit the root forwards to a sub-agent DO is refused first when the root's own agent is refused). A stranded active session found on wake whose agent is now refused ends failed with the typed code instead of being re-driven |
| Cloudflare Workflows | the workflow body refuses before its run-start commit; execute() / resume() / retry() reject with the typed error. A brand-new session's row (created by that execute()) records the failure; an existing session is left untouched |
| Temporal | the initializeAgentState / startRunCommit activity, before the run-start commit; execute() / resume() / retry() throw the typed refusal. A brand-new session (no run yet) records the failure; an existing session is left untouched; a sub-agent or companion is refused at its own start |
| DBOS | not yet: no early refusal (planned) |
Over HTTP, @helix-agents/agent-server answers the runtime-js refusal the same way the Cloudflare DO does: /start, /resume and the submit-tool-result routes return 400 { error, code } with the core code.
Agents without outputSchema are never refused: an unrecognised model just gets legacy capabilities, so a new model release is never an outage for them. Neither is an outputSchema agent with maxCompletionRetries: 0: it never makes a forced call (it fails with framework_forced_completion_exhausted as soon as forced completion triggers), so it runs on any model and finishes only by calling __finish__ or its finishWith tool itself. A model from a provider the adapter has no table for (OpenAI, Google, …) also gets legacy, which is exactly the behaviour before capabilities existed.
On runtime-js and the Cloudflare DO, which run llmConfigOverride before every LLM call, the override may choose the forced call's model, so an outputSchema agent with an llmConfigOverride is not refused early there (its base model may be one the adapter does not know). Its forced calls are checked one by one instead: a forced call whose override selects a model with no strategy fails that call with framework_completion_strategy_unavailable, and one whose schema the selected native_output model cannot take fails with framework_completion_schema_unsupported (the forced phase is marked failed, before any forced model call). Cloudflare Workflows, Temporal and DBOS never run the override, so there the base model is the forced-call model and the early refusal stays the authority.
Provider errors during a forced call
On runtime-js, both Cloudflare runtimes and Temporal, a provider error on a forced call never spends another forced attempt: a request the provider rejects stays rejected, and re-asking cannot fix it.
- runtime-js and the Cloudflare DO: a retryable error (
retryable: true) is retried inside the step under the executor'sllmStepRetrywithout consuming a forced attempt (Retrying a failed model call); a non-retryable or unclassified one, or one whose retries ran out, fails the run with the provider's own error detail (code, message, cause). The adapter's own retries (llmConfig.maxRetrieson the Vercel adapter) come first. - Cloudflare Workflows: a retryable error is rethrown so
step.doretries the step underllmStepRetrywithout consuming a forced attempt; a non-retryable or unclassified one fails the run at once with the provider's detail. A run whose retries run out fails with the last error, and its forced phase is closedfailedin the same terminal commit. A provider code that is not a HelixErrorCodeis dropped from the run'serrorDetailwhen the retries run out, as on runtime-js and Temporal (a work call's error keeps it). - Temporal: a retryable or unclassified error is rethrown so the activity retry policy (3 attempts) repeats the same forced attempt without consuming one; when the retries run out, the run fails with the provider's detail and its forced phase is closed
failedin the same terminal commit. A non-retryable one fails the run at once with the provider's detail. - DBOS: unchanged until DBOS support lands (planned) — every forced-call error is a recoverable miss that consumes an attempt, and the run ends
framework_forced_completion_exhaustedwith the provider's code lost (see DBOS).
On every runtime except DBOS, the run's failure message for a provider_invalid_request on a forced_tool call gets a hint appended: if this model rejects forced tool choice, declare its capabilities with a resolver on the adapter.
Two independent budgets
maxSteps and maxCompletionRetries bound different things:
| Budget | Bounds | Default |
|---|---|---|
maxSteps | Ordinary work — tool calls, sub-agents, LLM turns | 50 |
maxCompletionRetries | Forced-completion attempts, once a trigger has fired | 2 |
The maxSteps default of 50 is applied on every runtime when the agent sets none (DEFAULT_MAX_STEPS); maxSteps: Infinity disables the cap.
Forced calls still increment the monotonic stepCount for observability, so a run can finish with stepCount up to maxSteps + maxCompletionRetries. The maxSteps guard never preempts a pending forced attempt — once forced completion begins, it runs to success or exhaustion regardless of the ordinary step count.
Without an outputSchema there is no forced completion. An agent that runs out of maxSteps before the model finished ends failed with framework_max_steps_exhausted (Max steps (N) reached, non-retryable), and retry() continues it from its last step with a fresh budget. That holds on JS, the Cloudflare DO and Cloudflare Workflows; Temporal and DBOS still end it completed with no output (DI-25). See When maxSteps runs out.
maxCompletionRetries: 0 fails immediately the moment a trigger fires — no forced call is made at all. It's a bounded budget, not a strategy selector: 0 does not mean "skip forced completion and complete anyway," it means "there is no recovery budget," so the run fails right away with framework_forced_completion_exhausted.
const agent = defineAgent({
name: 'strict-reporter',
outputSchema: z.object({ summary: z.string() }),
maxCompletionRetries: 0, // any trigger fails the run immediately
llmConfig: { model: anthropic('claude-sonnet-5-5') },
});Reasoning disable
Only a forced_tool call on a model whose thinking is not bound to history (the legacy and anthropic-structured profiles) disables reasoning — an unconstrained thinking budget is often what caused the original truncation or terminal-text miss. A native_output call, and a forced_tool call on a history-bound model, leave reasoning exactly as configured (Opus 5.5 cannot disable thinking at all). For the Vercel AI SDK adapter with Anthropic models, disabling is done by omitting the thinking block from the request rather than sending a literal disabled marker: extended thinking on Anthropic is opt-in (the provider only adds a thinking block to the request when it's explicitly enabled), so omission is how "disabled" is expressed on the wire. The named tool-choice directive (tool_choice: { type: 'tool', name: '__finish__' }) and the single-tool tools array are still sent as normal — only reasoning is suppressed. Providers with no documented reasoning-disable mapping keep their existing config unchanged and get a structured adapter warning instead; the named tool choice is never weakened to compensate.
Minimal prompt
The same forced_tool call (thinking not bound to history) also sends the minimum viable prompt. Because its tool list is narrowed to just the completion tool, the model can't call skills (load_skill / read_skill_file), workspace, or memory tools — so the framework drops the Level-1 skills catalog, any preloaded skill bodies, and the workspace capability fragment from the system prompt for that call only. This keeps the recovery call as small as possible under exactly the tight token budget that usually triggered forced completion. A native_output call keeps the full system prompt and tool set (the tools stay visible but are never executed). Ordinary "work" calls are unchanged — they still carry the full catalog, preloaded bodies, workspace guidance, and memory. (Realtime memory extraction likewise runs only when the turn actually terminates — on a forced completed or failed, never on an intermediate forced retry.)
Exhaustion means the run failed
If every forced attempt misses (or the budget was 0), the run ends failed, not completed. These codes describe how the forced phase went:
framework_forced_completion_exhausted— the budget was consumed without valid output.framework_completion_protocol_violation— the last miss was a protocol violation: plain text despite the directive, or a call other than the single expected completion (this blames the adapter/provider, not the agent).framework_completion_invalid_output— not a run failure code but the code of a miss whose output failed schema validation (or, undernative_output, text that did not parse): it is theattempt_failedchunk'serrorCodeand the phase'slastErrorCode. When that miss was the last attempt, the run failsframework_forced_completion_exhausted.framework_completion_tool_missing— the expected tool disappeared from the effective tool list (e.g. corrupted state). Fails before any LLM call.framework_completion_strategy_unavailable— the model this call uses has no completion strategy (for example anllmConfigOverrideswitched to an unrecognised model). Fails before any LLM call.framework_prompt_prefix_changed— the system prompt or tool set changed while signed, history-bound thinking exists in the session (see Preserved thinking). Fails before any LLM call.- a provider code (
provider_invalid_request,provider_auth_error, …) — the provider rejected the forced call (every runtime except DBOS; see above).
refusal / content_filter stop reasons keep their own codes (provider_refused, provider_content_filtered). The former framework_completion_tool_choice_unsupported code no longer exists: a model that rejects forced tool choice now gets the native_output strategy, or framework_completion_strategy_unavailable when it has no strategy at all.
See the @helix-agents/core reference for the full type/function/error surface.
DBOS (known gap)
DBOS does not resolve completion strategies yet (support is planned). There every forced call uses the legacy request (named tool choice, one tool, trimmed prompt, reasoning disabled) whatever the adapter reports, there is no early refusal and no prompt-prefix check, and a forced call's provider error is a recoverable miss. So forced completion on the 5.5 generation (Opus 5.5, Sonnet 5.5, Fable 5.1, Mythos 5.1) still fails on DBOS: each forced call is rejected with HTTP 400, and the run ends framework_forced_completion_exhausted. An outputSchema agent on those models that finishes by calling __finish__ or its finishWith tool on its own (no forced completion) is not affected. Run such agents on runtime-js, a Cloudflare runtime or Temporal, or keep them on a model whose profile supports forced tool choice, until DBOS support lands. Temporal resolves completion strategies since @helix-agents/runtime-temporal 4.0 (see Temporal → Completion strategies).
App-side validation is still required
Forced completion guarantees that a completed outputSchema agent's terminal state is binary: completed with schema-valid output, or failed with an explicit, classified error. There is no third outcome — schema-invalid or missing output never reaches you as a completed result. This is enforced unconditionally by the framework (not just when strictOutput is set — see The phase above), so it does not, and cannot, judge whether that output is correct for your domain. Keep validating completed output against your own business rules; forced completion is not a substitute for application-level review.
Mutual Exclusivity
Important: When an agent has one or more finishWith tools, the __finish__ tool is NOT injected.
// Agent with finishWith tool - NO __finish__ injected
const agentWithFinishWith = defineAgent({
name: 'submission-agent',
tools: [submitAnswerTool], // finishWith: true
outputSchema: OutputSchema,
// Tools available to LLM: [submit_answer]
// __finish__ is NOT added
});
// Agent without finishWith tool - __finish__ IS injected
const agentWithoutFinishWith = defineAgent({
name: 'simple-agent',
tools: [searchTool], // finishWith: false (default)
outputSchema: OutputSchema,
// Tools available to LLM: [search, __finish__]
});This is intentional: if you define a finishWith tool, you want the LLM to use YOUR tool to complete, not the generic __finish__.
finishWithTransform
When your finishWith tool's output doesn't match the agent's outputSchema, use finishWithTransform to map the output:
const processDataTool = defineTool({
name: 'process_data',
description: 'Process and submit the data',
inputSchema: z.object({
rawData: z.string(),
multiplier: z.number().optional(),
}),
outputSchema: z.object({
// Tool returns this shape
rawData: z.string(),
multiplier: z.number().optional(),
processedAt: z.string(),
}),
finishWith: true,
finishWithTransform: (toolOutput) => ({
// Transform to agent's outputSchema
result: toolOutput.rawData.toUpperCase(),
score: toolOutput.multiplier ?? 1,
}),
execute: async (input) => {
// Process the data
return {
rawData: input.rawData,
multiplier: input.multiplier,
processedAt: new Date().toISOString(),
};
},
});
const agent = defineAgent({
name: 'processor',
tools: [processDataTool],
outputSchema: z.object({
// Agent output schema
result: z.string(),
score: z.number(),
}),
});Flow:
- LLM calls
process_data({ rawData: 'hello', multiplier: 5 }) execute()runs, returns{ rawData: 'hello', multiplier: 5, processedAt: '...' }finishWithTransform()maps to{ result: 'HELLO', score: 5 }- Agent completes with output
{ result: 'HELLO', score: 5 }
When to Use Each Approach
Use __finish__ (no finishWith tools) when:
- You just need structured output with no side effects
- The output comes directly from LLM reasoning
- You want the simplest setup
// Simple case: LLM analyzes and returns result
const analyzer = defineAgent({
name: 'analyzer',
systemPrompt: 'Analyze the text and determine sentiment',
outputSchema: z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
reasoning: z.string(),
}),
// LLM will call __finish__({ sentiment: 'positive', reasoning: '...' })
});Use finishWith tools when:
- You need side effects on completion (save, send, validate)
- You want custom validation before completing
- You need to transform or enrich the output
- You want explicit control over the completion flow
// Complex case: Save results and notify
const submissionTool = defineTool({
name: 'submit_results',
description: 'Submit final results to the system',
inputSchema: z.object({
findings: z.array(z.string()),
confidence: z.number(),
}),
finishWith: true,
execute: async (input, context) => {
// Validate
if (input.confidence < 0.5) {
throw new Error('Confidence too low. Please gather more data.');
}
// Save to database
const id = await db.results.create({ data: input });
// Send notification
await notify(`Results submitted: ${id}`);
// Update state for logging
context.updateState<{ submittedAt: string }>((draft) => {
draft.submittedAt = new Date().toISOString();
});
return {
id,
...input,
};
},
});Multiple finishWith Tools
You can define multiple finishWith tools when there are different ways to complete:
const approveWithCommentsTool = defineTool({
name: 'approve_with_comments',
description: 'Approve the submission with reviewer comments',
inputSchema: z.object({
comments: z.string(),
}),
finishWith: true,
execute: async (input) => {
await updateStatus('approved');
return { status: 'approved', comments: input.comments };
},
});
const rejectTool = defineTool({
name: 'reject',
description: 'Reject the submission with reason',
inputSchema: z.object({
reason: z.string(),
}),
finishWith: true,
execute: async (input) => {
await updateStatus('rejected');
return { status: 'rejected', reason: input.reason };
},
});
const reviewer = defineAgent({
name: 'reviewer',
tools: [approveWithCommentsTool, rejectTool],
outputSchema: z.object({
status: z.enum(['approved', 'rejected']),
comments: z.string().optional(),
reason: z.string().optional(),
}),
});Parallel Execution: First Wins
If the LLM calls multiple finishWith tools in parallel, the first one (by array order) determines the output:
// LLM calls both in parallel:
// - approve_with_comments({ comments: 'Good work!' })
// - reject({ reason: 'Missing data' })
// Result: approve_with_comments wins (first in tool order)The losing call is not executed, and its persisted result says so. This applies to any mix of terminal calls in one step: multiple finishWith calls, multiple __finish__ calls, or a regular/sub-agent call co-emitted alongside __finish__ (the output is already fixed by __finish__'s arguments, so running a sibling would only add side effects after completion). The winner keeps its real result (reject's real output above, or { acknowledged: true } for __finish__); every other call in the step gets a synthetic not-executed tool result instead of being left without one — persisted history never has a dangling tool call. A failing finishWith is the one exception: it keeps its real error result, and the agent tries the next finishWith call in the step.
The Vercel AI SDK adapter returns every call co-emitted with __finish__, and the step's text, so the rule above holds through it too (RM-35; before, they were dropped).
The not-executed result is a normal failed tool result (content: {"error":"Tool call was not executed: <reason>"}) marked with metadata.helixSynthetic: { kind: 'not_executed', reason: 'superseded_by_terminal_call' }. Code that reads persisted helix Messages (getMessages / the state store) can detect it with isNotExecutedToolResult(message) (exported from @helix-agents/core) to show "not executed" instead of a generic failure.
This detection works on persisted helix messages only. The live stream emits no tool_end chunk for a synthetic result (the losing call's tool_start is emitted, but it gets no end chunk), and the AI SDK converter (@helix-agents/ai-sdk) renders a persisted synthetic result as a generic output-error part without the helixSynthetic marker. So a useChat UI cannot tell "not executed" from a real failure yet.
Error Handling
When finishWith Execute Throws
If a finishWith tool's execute function throws an error, the agent does NOT complete. The error is reported back to the LLM, which can try again or use a different approach:
const submitTool = defineTool({
name: 'submit',
inputSchema: z.object({ data: z.string() }),
finishWith: true,
execute: async (input) => {
if (input.data.length < 10) {
throw new Error('Data too short. Please provide more detail.');
}
return { result: input.data };
},
});
// LLM calls: submit({ data: 'Hi' })
// Error: "Data too short. Please provide more detail."
// LLM sees error and can call: submit({ data: 'A longer and more detailed response' })
// Agent completes successfullyWhen finishWithTransform Throws
If finishWithTransform throws, the agent fails:
const tool = defineTool({
name: 'submit',
finishWith: true,
finishWithTransform: (output) => {
if (!output.valid) {
throw new Error('Invalid output'); // Agent fails
}
return { result: output.data };
},
execute: async (input) => ({ data: input.data, valid: false }),
});Best Practice: Keep finishWithTransform pure and simple. Put validation logic in execute.
System Prompt Behavior
The framework automatically updates the system prompt based on which completion mechanism is available:
With __finish__:
## Output Requirement
This task requires structured output. You MUST complete your work by calling
the `__finish__` tool. This tool will process your output and complete the task.
DO NOT use any other method to return your final answer.With finishWith tool:
## Output Requirement
This task requires structured output. You MUST complete your work by calling
the `submit_answer` tool. This tool will process your output and complete the task.
DO NOT use any other method to return your final answer.The LLM is instructed to use the correct tool based on what's available.
State Mutations in finishWith Tools
State changes made in finishWith tools are persisted:
const submitTool = defineTool({
name: 'submit',
finishWith: true,
execute: async (input, context) => {
// This state change is saved
context.updateState<{ lastSubmission: string }>((draft) => {
draft.lastSubmission = input.data;
});
return { result: input.data };
},
});This is useful for:
- Recording completion metadata
- Tracking when/how the agent completed
- Enabling conversation continuation with context
Hook Guarantees
The onAgentComplete hook fires regardless of which completion mechanism the agent uses. This is important for tracing, metrics, and cleanup logic that needs to run on every successful completion.
| Completion Path | onAgentComplete Fires | Notes |
|---|---|---|
__finish__ tool | Yes | State is already completed during step processing |
finishWith tool | Yes | State is promoted to completed during stream finalization |
Sub-agent with __finish__ | Yes | Fires without closing parent's stream |
Sub-agent with finishWith | Yes | Fires without closing parent's stream |
This behavior is consistent across all runtimes (JS, Temporal, Cloudflare).
Tracing Integration
If you use Langfuse tracing hooks, onAgentComplete is where the root trace span is finalized. The finishWith completion path ensures this happens even though the state update follows a different code path than __finish__.
Testing finishWith Tools
Unit Testing the Tool
import { describe, it, expect } from 'vitest';
describe('submitTool', () => {
it('should execute side effects and return output', async () => {
const mockContext = {
getState: () => ({}),
updateState: vi.fn(),
emit: vi.fn(),
abortSignal: new AbortController().signal,
};
const result = await submitTool.execute({ answer: 'test' }, mockContext as any);
expect(result).toEqual({ result: 'test' });
});
});Integration Testing with MockLLM
import { defineAgent } from '@helix-agents/core';
import { MockLLMAdapter } from '@helix-agents/core/testing/mocks';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
describe('finishWith integration', () => {
it('should complete agent via finishWith tool', async () => {
const mockLLM = new MockLLMAdapter();
const executor = new JSAgentExecutor(
new InMemoryStateStore(),
new InMemoryStreamManager(),
mockLLM
);
// Configure mock to call finishWith tool
mockLLM.addResponse({
type: 'tool_calls',
toolCalls: [
{
id: 'tool-1',
name: 'submit_answer',
arguments: { answer: 'The answer' },
},
],
});
const handle = await executor.execute(agentWithFinishWith, 'Question');
const result = await handle.result();
expect(result.status).toBe('completed');
expect(result.output).toEqual({ result: 'The answer' });
// Verify __finish__ was NOT in tools
const input = mockLLM.getLastInput();
const toolNames = input.tools.map((t) => t.name);
expect(toolNames).not.toContain('__finish__');
expect(toolNames).toContain('submit_answer');
});
});Complete Example
import { defineAgent, defineTool } from '@helix-agents/sdk';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// Output schema
const OutputSchema = z.object({
analysis: z.string(),
confidence: z.number(),
savedId: z.string().optional(),
});
// finishWith tool with side effects
const submitAnalysisTool = defineTool({
name: 'submit_analysis',
description: 'Submit the final analysis after validation',
inputSchema: z.object({
analysis: z.string().min(50, 'Analysis must be at least 50 characters'),
confidence: z.number().min(0).max(1),
saveToDb: z.boolean().default(true),
}),
outputSchema: z.object({
analysis: z.string(),
confidence: z.number(),
savedId: z.string().optional(),
}),
finishWith: true,
execute: async (input, context) => {
// Validation
if (input.confidence < 0.3) {
throw new Error('Confidence too low. Please gather more evidence.');
}
let savedId: string | undefined;
// Side effect: save to database
if (input.saveToDb) {
savedId = await database.analyses.create({
data: {
content: input.analysis,
confidence: input.confidence,
agentId: context.agentId,
},
});
// Emit event for streaming consumers
await context.emit('analysis_saved', { id: savedId });
}
// Update state for logging
context.updateState<{ lastSavedId: string | null }>((draft) => {
draft.lastSavedId = savedId ?? null;
});
return {
analysis: input.analysis,
confidence: input.confidence,
savedId,
};
},
});
// Regular research tool
const searchTool = defineTool({
name: 'search',
description: 'Search for information',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ results: z.array(z.string()) }),
execute: async (input) => {
const results = await performSearch(input.query);
return { results };
},
});
// Agent definition
const AnalysisAgent = defineAgent({
name: 'analysis-agent',
systemPrompt: `You are a research analyst.
Use the search tool to gather information.
When ready, call submit_analysis with your findings.
Ensure confidence is above 0.3 before submitting.`,
tools: [searchTool, submitAnalysisTool],
stateSchema: z.object({
lastSavedId: z.string().nullable().default(null),
}),
outputSchema: OutputSchema,
llmConfig: {
model: openai('gpt-6-luna'),
},
});Next Steps
- Defining Tools - Learn more about tool creation
- State Management - Manage state in finishWith tools
- Streaming - Stream events from finishWith tools
- Hooks - Use
afterToolhooks to observe finishWith execution