Skip to content

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 ​

typescript
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 ​

typescript
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:

typescript
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.

typescript
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 sent

Run 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.

Released under the MIT License.