Skip to content

Upgrade: child identity (child dispatch and identity, MR 1) ​

A sub-agent child's session id is now minted, never derived from its parent, and every child is bound to the delegation that spawned it. Before, a child was {parentSessionId}-sub-{toolCallId} (runtime-js, Temporal), {parentSessionId}__sub__{toolCallId} (DBOS, CF Workflows) or {parentSessionId}-remote-{toolCallId} (remote): anyone who knew a parent id and a tool call id could name, and enter, its child.

It is a major release of @helix-agents/core (2.0.0: exports removed, a child's ids and its binding added to core types), @helix-agents/runtime-js, @helix-agents/runtime-cloudflare, @helix-agents/runtime-temporal, @helix-agents/runtime-dbos and @helix-agents/agent-server, and of @helix-agents/ai-sdk through the peer-dependency cascade. Every store package (@helix-agents/store-memory, @helix-agents/store-redis, @helix-agents/store-postgres, @helix-agents/store-cloudflare) takes a minor: they are 0.x packages, where a minor may break (see Versioning policy). There is no backward compatibility layer.

Concepts: Child identity in the sub-agents guide, and Child identity (cross-runtime). API reference: Child identity.

Deploying ​

  • Run the store migrations (below) before the new runtimes: the runtimes write the new columns.
  • Drain in-flight workflows that dispatch children on Temporal, DBOS and CF Workflows. Their workflow code now mints ids (Temporal wf.uuid4(), DBOS DBOS.randomUUID(), CF Workflows inside the register step), so a workflow that started on the old code and replays on the new one diverges at its first dispatch. Workflows without sub-agent calls are unaffected.
  • Deploy remote consumers and producers together. The remote protocol's /start and /resume carry the new binding, and a producer refuses an entry on a bound child without it.

1. Child ids are opaque ​

WasNow
{parent}-sub-{callId}, {parent}__sub__{callId}, {parent}-remote-{callId}child_<uuid>, minted by the runtime
no id for the delegationd_<uuid> on the persisted call (ToolCallRequest.dispatchId) and on its ref
core generateSubSessionId(parentSessionId, toolCallId)removed: read the id, never rebuild it
core childStartRequestKey / childResumeRequestKeyremoved: a remote /start keys on the dispatch id; its resume on dispatchResumeRequestKey(dispatchId)

Read a child's id from:

  • the subagent_start stream chunk's subSessionId (with callId, the parent's tool call);
  • the parent's refs: stateStore.getSubSessionRefs(parentSessionId), matching parentToolCallId (each ref also carries dispatchId);
  • the afterSubAgent hook payload's subSessionId.

Code that predicted a child's id (a test script routed by child id, a dashboard joining on the derived form, a pre-created child session) must read it instead. A pre-created child session at a guessed id is never entered: the runtime mints its own.

Companions (persistentAgents) are unchanged in this release: they keep {parentSessionId}-agent-{name} and carry no binding yet.

2. The binding: only its parent enters a child ​

A child session is created with its delegation's ChildBinding ({ parentSessionId, parentRunId, parentToolCallId, dispatchId, kind }), written once:

  • Run-starts. Every run-start on a bound child must carry a binding for the same delegation (same parent and dispatch). Anything else, a root entry without a binding included, is refused RunStartRejectedError with cause child_binding_mismatch, and nothing is written. The runtimes pass the binding on their own spawns, re-spawns and remote calls. A runtime's own continuation of a child's existing work (a submit's continuation, a recovery reset) adopts the stored binding.
  • Creating a child. createSession(id, { binding }) over an id that belongs to another delegation, or to a root session, throws ChildSessionTakenError (state_child_session_taken, 409, non-retryable). The same delegation's repeat proceeds and writes nothing.
  • Executor calls. ExecuteOptions, every ResumeOptions variant and RetryOptions take an optional binding, which an app sets only when it is itself the parent of a remote child (agent-server does this for the protocol routes), or, as the low-level escape hatch below, when it enters a local child directly.
  • Telling a child from a root. Use core isChildSession(state) / readChildBinding(state), never a session-id pattern.
  • MR 1 interim. CreateSessionOptions.parentSessionId and the executor's parentSessionId option stay for this release. With a binding, the parent comes from the binding, and a parentSessionId that differs from it is a validation_error. They are removed with the companion half of this design.

Breaking: resuming a sub-agent child directly

The v7 pattern for a client tool (or approval) inside a sub-agent was: submit the answer at the root, then resume the child yourself, then resume the root. A direct resume() (or retry()) of a child session is an entry from outside the child, and it now carries no binding, so it is refused, and nothing is written:

text
RunStartRejectedError: Run start rejected for session 'child_…': child_binding_mismatch
  code: 'state_run_start_conflict', rejectCause: 'child_binding_mismatch'

Migrate to resuming the root. A root continue drives its awaited in-process children itself, each through its own entry with its binding (CP-10), and then continues the parent:

ts
// Before (v7): submit at the root, then resume the child, then the root.
await executor.submitToolResult({
  kind: 'client-tool-result',
  sessionId: rootId,
  toolCallId,
  result,
});
await (await executor.resume(childAgent, childSessionId)).result();
await (await executor.resume(rootAgent, rootId)).result();

// After: submit at the root, then resume the root.
await executor.submitToolResult({
  kind: 'client-tool-result',
  sessionId: rootId,
  toolCallId,
  result,
});
await (await executor.resume(rootAgent, rootId)).result();

Escape hatch (low level). An app that must enter a local child directly passes the child's own stored binding:

ts
import { readChildBinding } from '@helix-agents/core';

const binding = readChildBinding((await stateStore.loadState(childSessionId)) ?? {});
await executor.resume(childAgent, childSessionId, { mode: 'continue', binding });

The binding is an integrity check (only the delegation that started a child enters it), not authentication: reading it from the store is how the app, which owns the store, asserts that delegation.

3. Remote protocol ​

RemoteStartRequest and RemoteResumeRequest gain binding?: ChildBinding. A consumer's dispatch sends the binding, and its /start request key (X-Helix-Request-Key) is the call's dispatch id. A producer:

  • stores the binding on the child session it creates (agent-server's /start, the CF DO's /subagent/:type/start);
  • refuses a /resume whose binding names another delegation, or carries none, on a bound child: agent-server answers 409 state_run_start_conflict with cause: 'child_binding_mismatch', the CF DO's /subagent routes their protocol envelope;
  • checks the binding before a request key (X-Helix-Request-Key) answers: a keyed repeat on a bound child is attached to (or replays) the child's run only for the child's own delegation. Without the binding, or with another delegation's, it is refused the same way and nothing is written (agent-server, the CF DO and runtime-js execute() / resume() / retry());
  • answers an invalid binding with a typed 400 validation_error (no schema detail).

agent-server now also answers every refused run start (RunStartRejectedError, e.g. entry_status_changed) as 409 state_run_start_conflict with its cause, where it used to answer a 500 INTERNAL_ERROR.

4. Sub-agent tool options on the CF Durable Object ​

The CF DO runs every sub-agent in a sibling Durable Object, converting each createSubAgentTool tool with the new core toRemoteSubAgentTool(tool, { transport, remoteAgentType }). Every option is carried or refused, never dropped:

  • description and timeoutMs (default 10 minutes) are carried, as before.
  • inheritWorkspace: true is now refused with a typed, non-retryable framework_not_supported when the agent is materialized, before any run or model call. A sibling DO cannot share the parent's in-process workspace; before, the option was silently ignored. Remove the option, or run that sub-agent on a runtime that executes it in-process.
  • A sub-agent without an outputSchema stays a typed validation_error.

createSubAgentTool's options are now typed by SubAgentToolOptions (z.infer of core subAgentToolOptionsSchema()), the single list of sub-agent options.

5. Storage ​

Every store persists the binding and the ref's dispatch id, and keeps one child per dispatch under a parent:

StoreChange
Postgresmigration V25: __agents_states.child_binding (JSONB), __agents_sub_session_refs.dispatch_id and a unique index on (parent session, dispatch id)
D1migration 29: the same columns and index
CF DO (DO-SQLite)migration 24: the same columns and index
Redishash fields; the commit scripts check the binding and the dispatch
Memoryin-memory

Custom SessionStateStore implementations must: store binding at createSession and apply core decideCreateBinding there (assertCreateSessionParent first); apply decideEntryBinding inside the run-start commit (StartRun.binding); require dispatchId on an ephemeral SubSessionRef (assertValidSubSessionRefs) and refuse a second ref for one dispatch (assertNoDispatchClash). The core contract suite (stateStoreContractTests) checks all of it.

Released under the MIT License.