Skip to content

A companion paused on client calls (CP-40) ​

This guide covers the release in which a persistent companion that pauses on a client-executed or approval-gated call is answered suspended instead of hanging its parent, read as completed (runtime-js) or recorded failed (CF Workflows), and in which the CF Durable Object refuses such a companion at spawn time. It is aimed at people who read companion tool results, switch over core's companion unions, call resolveChildName, or implement their own companion dispatch deps.

Versions:

  • @helix-agents/core takes a major (it is at 1.x: a breaking interface change; read this guide).
  • @helix-agents/runtime-cloudflare takes a major (an observable behaviour change: a companion with client / approval tools is refused on the DO).
  • @helix-agents/runtime-js takes a minor (observable: CP-127, a paused companion is no longer completed).
  • @helix-agents/runtime-dbos takes a patch (it adapts to resolveChildName's new shape; its behaviour is unchanged).
  • @helix-agents/ai-sdk takes a major through the peer cascade (it peer-depends on runtime-cloudflare); no change of its own.

No migration. Deploy client and server together; a CF Durable Object parent and its companion children must run the same version.

What changed ​

New union members ​

Any exhaustive switch over these core types needs the new members:

  • ChildTerminalResult, CompanionSpawnResult and CompanionWaitResult gain the suspended arm: { name, status: 'suspended', pendingCalls, message } (ChildTerminalResult: { status: 'suspended', pendingCalls }). pendingCalls is PendingCallSummary[]: { toolCallId, toolName, kind: 'client' | 'approval' }, never the call's input. A paused companion is NOT terminal: its ref reads paused_awaiting_client (no completedAt, never marked delivered), so its later outcome still reaches the parent.
  • CompanionNotSpawnedReason (COMPANION_NOT_SPAWNED_REASONS) gains child_not_supported, child_interrupted and child_awaiting_client (seven reasons). CompanionEntryOutcome.reason stays the four entry reasons (COMPANION_ENTRY_FAILURE_REASONS).
  • CompanionNotDeliveredReason (COMPANION_NOT_DELIVERED_REASONS) gains child_awaiting_client (eight reasons).
  • ErrorCode gains framework_companion_not_supported (category framework, non-retryable, HTTP 500): a companion refused because its tree can call a client / approval tool nothing on the runtime can answer.

resolveChildName returns a union ​

ts
// Before
const name: string = resolveChildName(providedName, agentType, refs); // threw for a live name

// After
const resolved = resolveChildName(providedName, agentType, refs, { pendingCalls });
if (resolved.kind === 'refused') {
  // resolved.outcome: { started: false, reason: 'child_busy' | 'child_interrupted' | 'child_awaiting_client',
  //                     errorDetail }; answer it, e.g. companionSpawnResultFromEntry(resolved.name, resolved.outcome)
} else {
  use(resolved.name);
}

Only a name over 128 characters still throws.

Answers that used to be errors ​

  • A same-name companion__spawnAgent of a live companion answers not_spawned (child_busy, child_interrupted, child_awaiting_client), a successful tool result, instead of the already running / is interrupted error (DBOS still answers the error).
  • companion__sendMessage to a child paused on calls answers { delivered: false, reason: 'child_awaiting_client', errorDetail } instead of the unresolved client-tool calls error (DBOS still answers the error).

The CF Durable Object refuses a companion with client / approval tools ​

CompanionToolDispatchDeps.refuseChild? (core) lets a runtime refuse a companion before anything is written; only the DO supplies it, with core companionNotSupportedDetail(agent) (a walk of the agent's tools, its sub-agent tools' agents and its persistentAgents, at any depth; remote tools are opaque). A refused spawn answers { status: 'not_spawned', reason: 'child_not_supported', errorDetail } with the code framework_companion_not_supported. The child DO refuses the same agent at its own consult entry. Move a client / approval tool out of a DO companion (the parent can call it), or run the companion on runtime-js or CF Workflows, where a client answers its calls through the root.

pendingCalls order and a stale pause ​

On runtime-js and CF Workflows pendingCalls follows the model's call order (the child's log); on the CF DO it follows the child's /status listing (CP-157 RemoteStatusResponse.pendingCalls: oldest first, then by call id; core pendingCallSummariesFromListing). A pause whose calls were all answered meanwhile is not reported as a suspension: the wait goes on and sendMessage / a same-name spawn answer child_busy.

RemoteStatusResponse.stopping ​

The sub-agent /status carries stopping?: boolean (a stop is recorded for the current run and has not ended it yet), and committedOutcome: 'suspension' for a committed suspension.

Reference ​

Released under the MIT License.