Completion Strategies
One outputSchema agent, run on a model that supports forced tool use (the forced_tool strategy) and on a model from Anthropic's 5.5 generation, which does not (the native_output strategy), plus the capabilities override for a LiteLLM proxy alias. The agent definition is identical in every case: the strategy is chosen per forced call from the capabilities the adapter reports. See Finishing Agents → Completion strategies.
The agent
import { defineAgent, defineTool } from '@helix-agents/core';
import type { LLMConfig } from '@helix-agents/core';
import { z } from 'zod';
const ReviewSchema = z.object({
verdict: z.enum(['approve', 'revise']),
issues: z.array(z.string()),
confidence: z.number().min(0).max(1), // bounds dropped on the wire for 5.5 models, still enforced
});
const fetchDraft = defineTool({
name: 'fetch_draft',
description: 'Fetch the current draft',
inputSchema: z.object({ id: z.string() }),
execute: async ({ id }) => ({ id, text: '…' }),
});
export function makeReviewer(model: LLMConfig['model']) {
return defineAgent({
name: 'reviewer',
// Keep the rendered prompt stable within a session: 5.5 models bind thinking to it.
systemPrompt: 'Review the draft. Finish with your verdict.',
tools: [fetchDraft],
outputSchema: ReviewSchema,
maxCompletionRetries: 2, // the same budget under either strategy
llmConfig: { model, maxOutputTokens: 4096 },
});
}Running it on both profiles
import { anthropic } from '@ai-sdk/anthropic';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
const executor = new JSAgentExecutor(
new InMemoryStateStore(),
new InMemoryStreamManager(),
new VercelAIAdapter()
);
// Profile anthropic-structured → forced_tool: a forced call names the __finish__ tool
// (one tool, trimmed prompt, reasoning disabled) — the same request as before strategies existed.
const legacyRun = await executor.execute(
makeReviewer(anthropic('claude-sonnet-5')),
'Review draft 42'
);
// Profile anthropic-5.5 → native_output: a forced call keeps the full tools and prompt and asks
// for a response matching ReviewSchema; the JSON answer becomes a __finish__ call.
const nativeRun = await executor.execute(
makeReviewer(anthropic('claude-sonnet-5-5')),
'Review draft 42'
);
for (const handle of [legacyRun, nativeRun]) {
const stream = await handle.stream();
for await (const chunk of stream ?? []) {
if (chunk.type === 'forced_completion' && chunk.phase === 'attempt_started') {
console.log(
`forced attempt ${chunk.attempt}: ${chunk.strategy} (${chunk.capabilityProfile})`
);
}
}
const result = await handle.result();
console.log(result.status, result.output);
}Most runs never force: the model calls __finish__ on its own. The forced_completion chunks only appear when the model ends with plain text, is truncated, or runs out of maxSteps first. Under native_output no text_delta chunks are streamed during the forced call.
A LiteLLM alias
Behind a proxy the adapter only sees the alias, and an unknown Anthropic model id is refused for an outputSchema agent (framework_completion_strategy_unavailable) before the run starts. Map the alias to the real model:
import { createAnthropic } from '@ai-sdk/anthropic';
import { HelixError, modelIdOf } from '@helix-agents/core';
import { VercelAIAdapter, resolveAnthropicCapabilities } from '@helix-agents/llm-vercel';
const litellm = createAnthropic({
baseURL: process.env.LITELLM_URL,
apiKey: process.env.LITELLM_API_KEY,
});
const LITELLM_ALIASES: Record<string, string> = {
'reviewer-default': 'claude-sonnet-5-5',
'reviewer-cheap': 'claude-haiku-4-5',
};
const proxiedExecutor = new JSAgentExecutor(
new InMemoryStateStore(),
new InMemoryStreamManager(),
new VercelAIAdapter({
capabilities: (config) => {
const alias = modelIdOf(config.model);
const real = alias ? LITELLM_ALIASES[alias] : undefined;
return real ? resolveAnthropicCapabilities(real) : undefined;
},
})
);
try {
const handle = await proxiedExecutor.execute(
makeReviewer(litellm('reviewer-default')),
'Review draft 42'
);
console.log((await handle.result()).output);
} catch (err) {
// Without the override: thrown before anything is written.
if (err instanceof HelixError && err.code === 'framework_completion_strategy_unavailable') {
console.error(err.message); // names the model and the `capabilities` option
}
}Testing both strategies offline
MockLLMAdapter with capabilities behaves like a model with those capabilities: under the 5.5 profile it rejects a forced tool choice with the API's 400, so a test proves the agent finishes through native_output.
import { MockLLMAdapter } from '@helix-agents/core';
const mock = new MockLLMAdapter(
[
{ type: 'text', content: 'Looks fine to me.', shouldStop: true, stopReason: 'end_turn' }, // triggers forced completion
{
type: 'text',
content: '{"verdict":"approve","issues":[],"confidence":0.9}', // the native_output answer
shouldStop: true,
stopReason: 'end_turn',
},
],
{
capabilities: {
forcedToolChoice: false,
nativeStructuredOutput: true,
historyBoundThinking: true,
},
capabilityProfile: 'anthropic-5.5',
}
);
const executor = new JSAgentExecutor(new InMemoryStateStore(), new InMemoryStreamManager(), mock);
const result = await (
await executor.execute(makeReviewer('mock-model'), 'Review draft 42')
).result();
// result.output → { verdict: 'approve', issues: [], confidence: 0.9 }
// mock.getAllInputs()[1].outputFormat?.name → '__finish__'; no toolChoice was sentRun on Temporal or DBOS, the 5.5 case still fails until those runtimes are migrated (MR 2 / MR 3); see Finishing Agents → Temporal and DBOS.