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.
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-4o') },
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).
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-4-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.
The phase
When one of the triggers fires, the framework:
- Narrows the LLM-visible tool list to exactly one tool — the expected completion tool (the agent's
finishWithtool if one is configured, else the auto-injected__finish__). No other tool, sub-agent, workspace, memory, skill, or companion tool is exposed while this phase is active. - Sends a named tool-choice directive forcing that exact tool (e.g.
{ type: 'tool', toolName: '__finish__' }) — a hard constraint on the call, not a suggestion in a message. - Disables model reasoning where the adapter/provider supports it (see Reasoning disable below).
- Is bounded by its own budget,
maxCompletionRetries, independent ofmaxSteps(see Two independent budgets). - Either completes with schema-valid output, or fails with a classified
framework_*error. There is no path back to ordinary execution once forced completion begins, and no path tocompletedwith missing or schema-invalid output.
The schema-valid guarantee in step 5 is enforced unconditionally, not just when llmConfig.strictOutput is set. If a forced call's tool 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, but it is not the enforcement mechanism — the named tool-choice directive is. (The hint is tagged metadata.forcedCompletion, not read back to reconstruct state.)
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 |
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.
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-4-5') },
});Reasoning disable
During a forced call the framework asks the adapter to disable model reasoning/thinking — an unconstrained thinking budget is often what caused the original truncation or terminal-text miss. For the Vercel AI SDK adapter with Anthropic models, this 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
A forced call also sends the minimum viable prompt. Because the 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, and skips per-turn memory auto-injection, for that call only. This keeps the recovery call as small as possible under exactly the tight token budget that usually triggered forced completion. 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. The error is one of five classified codes:
framework_forced_completion_exhausted— the budget was consumed without valid output.framework_completion_protocol_violation— the adapter returned text despite a named tool-choice directive (this blames the adapter/provider, not the agent).framework_completion_tool_missing— the expected tool disappeared from the effective tool list (e.g. corrupted state).framework_completion_tool_choice_unsupported— the adapter can't honor named tool selection at all.framework_completion_invalid_output— a forced completion produced output that failed schema validation; a recoverable miss, but the last one exhausts the budget with this code.
See the @helix-agents/core reference for the full type/function/error surface.
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)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 { MockLLMAdapter, defineAgent } from '@helix-agents/core';
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-4o'),
temperature: 0.7,
},
});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