Skip to content

Migrating to Deterministic Forced Completion

Overview

Completion recovery for outputSchema agents has been replaced across every runtime (JS, Cloudflare Durable Objects, Cloudflare Workflows, Temporal, DBOS). outputSchema is now a lifecycle contract: an agent that reaches terminal text, max_tokens truncation, or maxSteps exhaustion without having produced valid output enters a deterministic, durable forced-completion phase instead of completing with missing output or retrying via a prose "nudge" message. See Finishing Agents → Forced completion for the full conceptual guide.

This is not a config toggle — there is no "old mode" to opt back into. A few concrete things change behavior or require action; they're covered below.


1. Temporal: register the new persistForcedCompletion activity

What changed

The Temporal runtime persists forced-completion phase state via a new persistForcedCompletion activity (GenericActivities.persistForcedCompletion in @helix-agents/runtime-temporal).

What you may need to do

If your Temporal Worker registers activities explicitly (rather than spreading the whole object returned by a helper), you must add this activity to your activities.ts / createActivities() return value:

typescript
return {
  // ...your existing activities...
  persistForcedCompletion: activities.persistForcedCompletion.bind(activities),
};

If you don't add it, the workflow throws "activity not registered" the first time an outputSchema agent's run enters forced completion — which may not happen on every run, so this can surface later than other upgrade breakage. See the updated runtime-temporal reference createActivities() example, which now includes this line.

If you don't have any outputSchema agents on Temporal, no action is needed.


2. maxCompletionRetries: 0 now fails immediately (not "retry disabled")

What changed

BeforeAfter
maxCompletionRetries: 0 disabled the prose-retry nudge; the run failed with a generic "returned text without structured output" errormaxCompletionRetries: 0 fails immediately with framework_forced_completion_exhausted the moment a trigger fires — no forced call is ever made
maxCompletionRetries counted prose-retry attempts (correction messages), reconstructed by scanning message history for a [SYSTEM] You must call... prefixmaxCompletionRetries is the forced-completion budget (maxAttempts), tracked in durable typed state (ForcedCompletionState), never from message prose

Default remains 2.

What you may need to do

If you rely on the exact error message/code produced when an outputSchema agent fails to produce output, update assertions to expect framework_forced_completion_exhausted (or, less commonly, framework_completion_protocol_violation / framework_completion_tool_missing / framework_completion_tool_choice_unsupported / framework_completion_invalid_output) instead of a generic text message. See Finishing Agents → Exhaustion means the run failed.


3. New forced_completion stream chunk

What changed

A new chunk type, forced_completion, is emitted during the forced-completion phase (phase: 'started' | 'attempt_started' | 'attempt_failed' | 'succeeded' | 'exhausted'). It's added to both StreamChunkSchema and StreamMessageSchema.

What you may need to do

If you have an exhaustive switch/type-check over StreamChunk or StreamMessage variants, add a case for forced_completion (a no-op/ignore case is fine — it's a diagnostic chunk, not chat content). TypeScript will flag missing cases with a discriminated-union exhaustiveness error once you upgrade.

@helix-agents/ai-sdk's convertToUIMessages already ignores this chunk, so useChat-based frontends need no changes.


4. DBOS now validates outputSchema output

What changed

The DBOS runtime's agent loop now passes the agent's live outputSchema to planStepProcessing(). Previously it didn't — a schema-violating __finish__ payload silently completed the run; the run's completionReason could even end up mismatched ('failed' on a completed run).

What you may need to do

If you have DBOS-only tests or code paths that relied on an invalid __finish__ payload being accepted, they now correctly reject it (consistent with JS/Temporal/Cloudflare, which always validated). This is a bug fix, not a new restriction — the schema was always meant to be enforced.


5. State-store migrations (automatic, additive)

What changed

Each durable state store gained a nullable forced_completion column/field to persist ForcedCompletionState, applied automatically on startup:

StoreMigration
store-postgresv12 — adds forced_completion jsonb, nullable
store-cloudflare (D1)V15 — adds forced_completion column, nullable
store-cloudflare (Durable Object)V9 — adds forced_completion field, nullable
store-redisNew hash field forcedCompletion (no migration needed — schemaless)
store-memoryNew optional property (no migration needed)

What you may need to do

Nothing for normal use — migrations are additive/nullable and apply automatically on the store's existing migration-runner path (same mechanism as prior migrations, e.g. interrupt_context). If you have out-of-band tooling that asserts on the Postgres/D1 schema version number or column list (e.g. a schema-drift check in CI), update it to expect the new version/column.


6. Recorded-replay LLM test fixtures may need re-recording

What changed

When an outputSchema agent recovers (terminal text / max_tokens / maxSteps without output), the SDK now issues a different outgoing LLM request than before: it forces the completion tool by name (tool_choice: { type: 'tool', name: '__finish__' }), narrows the tool list to just that tool, disables provider reasoning, and replaces the old hidden [SYSTEM] You must call the '__finish__'… correction message with a concise [completion] … message carrying metadata.forcedCompletion.

What you may need to do

If your tests replay LLM interactions from recorded fixtures keyed by a request hash (VCR/cassette style — e.g. the mock at examples/*/test-utils/openai-mock-server.ts), any fixture that captured the old completion-retry request will no longer match the new forced-completion request and will surface as a fixture miss. Re-record the affected fixtures against a live provider (see docs/dev/example-app-testing.md for the record procedure — fixtures must be real recordings, never hand-edited). Within this repo, the research-assistant-cloudflare-do and opennext-cloudflare-do example Playwright suites use outputSchema agents; both have already been re-recorded (OPENAI_MOCK_MODE=record, real OPENAI_API_KEY) on this branch and pass in replay (18/18 and 40/40, zero fixture misses). For opennext, record serially (playwright test --workers=1 --retries=0) with DISABLE_MEMORY=true so the shared "current time" multi-turn requests aren't overwritten with mismatched model responses mid-run. Unit / integration / e2e SDK-package tests are unaffected (they use MockLLMAdapter/RoutingMockLLMAdapter, which are scripted, not request-hashed).


7. llmConfigOverride's forced-completion ctx is not yet honored on every runtime

What changed

Nothing changed in this release — but it's worth stating precisely, since the AgentConfig.llmConfigOverride JSDoc advertises a forced-completion use case for its third ctx argument (executionPhase, forcedCompletion). That callback is currently invoked only on runtime-js and the Cloudflare Durable Object runtime (which delegates its step loop to the JS iterator). The Cloudflare Workflows, Temporal, and DBOS runtimes do not yet invoke llmConfigOverride at all. This is a pre-existing gap, not something forced completion introduced or fixed.

What you may need to do

If you rely on llmConfigOverride to vary LLM config during forced completion (e.g. lowering temperature or switching to a cheaper model for forced calls) and you run on CF Workflows, Temporal, or DBOS, that override will not currently fire on those runtimes — no config change compensates for this today; it's tracked as follow-up work.


8. Forced calls now send a trimmed system prompt (no skills / workspace / memory)

What changed

A forced-completion LLM call now carries the minimum viable prompt (design Invariant H). Because a forced call narrows the tool list to exactly the completion tool under a named toolChoice, the model can no longer invoke skills (load_skill / read_skill_file), workspace (bash, etc.), or memory tools — so advertising them is pure token cost under exactly the tight budget that triggered forced completion. On every runtime, a forced call now:

  • omits the Level-1 skills catalog and preloaded skill bodies from the system prompt (previously included every step);
  • omits the workspace capability fragment from the system prompt;
  • skips per-turn memory auto-injection (no recall embedding / vector search / persisted <relevant_memories> message) — runtime-js + Cloudflare;
  • runs realtime memory extraction only on a TERMINAL forced outcome (completed/failed), never on a forced continue (phase entry or a recoverable miss).

Ordinary (non-forced) "work" calls are unchanged — they still carry the full catalog, preloaded bodies, workspace fragment, and memory injection.

What you may need to do

Nothing. This only affects the hidden forced-completion recovery call, which still deterministically produces schema-valid output or a classified failure. If you have a test that inspects the raw system prompt of a forced call and asserts the presence of a skills/workspace fragment, update it — those fragments are intentionally absent now. (Assertions on ordinary calls are unaffected.)


Summary

#ChangeTypeAction required
1Temporal: register persistForcedCompletion activityBreaking (explicit registration only)Add the activity to your createActivities() return value
2maxCompletionRetries: 0 fails immediatelyBehavior changeUpdate error-code assertions if you test the exhaustion path
3New forced_completion stream chunkBehavior changeHandle/ignore it in exhaustive StreamChunk switches
4DBOS validates outputSchema outputBug fixNone (invalid payloads now correctly rejected)
5State-store migrations (Postgres v12, D1 V15, DO V9)AdditiveNone (automatic); update schema-drift tooling if you have any
6Recorded-replay LLM fixtures driftTest-infraRe-record request-hashed LLM fixtures (both repo examples done)
7llmConfigOverride forced-completion ctx not invoked on CF Workflows/Temporal/DBOSKnown gap (pre-existing)None; don't rely on it for config overrides on those runtimes
8Forced calls send a trimmed prompt (no skills/workspace/memory)Behavior change (efficiency)None; update tests that assert those fragments on forced calls

For the full conceptual treatment, see Finishing Agents → Forced completion, Step Processing, and the @helix-agents/core reference.

Released under the MIT License.