workflow, subagent: fix Codex code-review round-1 blockers

Six verified A-findings from the code-stage review, each with a regression test:

- parallel()/pipeline() resolved to HOST arrays inside the vm realm, exposing
  host Array.prototype to scripts; combinator results are now realm-built
  (in-realm Array.from bound at context setup).
- materializeFromRealm ran proxy traps (ownKeys/getOwnPropertyDescriptor/
  getPrototypeOf) during the descriptor walk — realm code on the host stack,
  outside the vm timeout, escaping as raw errors; proxies (root, nested, and
  in the prototype position) are now rejected trap-free via util.types.isProxy
  before any inspection.
- an already-aborted signal or an immediate cancel() no longer reports
  'completed' for a hook-free script: drive() checks cancellation before
  running the body and again when the script settles.
- dispose() now waits (bounded by disposeGraceMs) for stray agent() children
  to FINISH disposing, not just for the script to settle: every agent() call
  is tracked and quiesce() drains the in-flight set.
- workflow/* event payloads were live mutable aliases shared across emissions;
  emitWorkflowEvent now hands each listener its own structural clone.
- the structured-output turn-continuation veto is now prepend: true, so an
  earlier-registered force-continue listener cannot short-circuit it.

Docs updated in the same change (READMEs, core-data-structures/workflow.md,
the dynamic-workflows RFC, regenerated cordis catalogs).
This commit is contained in:
Tianyi Cui
2026-07-05 19:04:38 +08:00
parent 1d43ea3cd5
commit e264a106fd
17 changed files with 358 additions and 51 deletions

View File

@@ -24,7 +24,7 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (
The mechanism behind `outputSchema` for in-process children. One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus two listeners, registered once per root context and shared by every holder:
- an `agent/request` waterfall listener registered `prepend: true` that post-processes `await next()`**final-request enforcement**: the request that hits the wire never carries `structured_output` for an agent without a structured run, and always carries the run's OWN schema (as the tool's `parameters`) for one that has it. Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child; cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement request.
- an `agent/turn-continuation` listener that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
- an `agent/turn-continuation` listener (also `prepend: true` — an earlier-registered force-continue listener returning without `next()` must not decide the turn before the veto runs) that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
The capture tool validates each call against the run's schema (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError result the model retries in-turn; a valid call records the value.

View File

@@ -18,7 +18,9 @@
*
* A companion `agent/turn-continuation` listener stops a child's turn once its
* output is captured — without it, the loop's default "had tool calls ⇒
* continue" buys a wasted extra model step per structured child.
* continue" buys a wasted extra model step per structured child. It is also
* `prepend: true`: the veto must run before any earlier-registered listener
* that could short-circuit the chain into a forced continue.
*
* Lifetime is refcounted with two kinds of holder: each backend acquires for
* its plugin lifetime (so the tool exists before any run), and each structured
@@ -183,11 +185,15 @@ function registerRuntime(root: Context, runtime: StructuredRuntime): void {
// Stop a structured child's turn once its output is captured: the default
// "had tool calls ⇒ continue" would otherwise buy a wasted extra model step
// after every successful capture.
// after every successful capture. `prepend: true` puts the veto OUTERMOST —
// an earlier-registered listener that short-circuits the chain (a goal-style
// force-continue returning without `next()`) would otherwise decide the turn
// before this listener ever ran, and no downstream decision may resurrect a
// structured turn that is already finished.
runtime.disposers.push(root.on('agent/turn-continuation', function (
this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
): Promise<ContinuationDecision> {
if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' })
return next()
}))
}, { prepend: true }))
}

View File

@@ -5,6 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
@@ -85,6 +86,34 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
// Registered BEFORE the structured runtime exists — without prepend, this
// goal-style listener would decide the turn first (returning WITHOUT
// calling next()) and the veto would never run.
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
const acquisition = acquireStructuredRuntime(ctx)
const agent = { id: AgentId('structured-child') } as unknown as Agent
acquisition.attach(agent, SCHEMA)
const captured = await ctx.tools.execute({
callId: 'call-1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
agent,
})
expect(captured.isError).toBeFalsy()
const decision = await ctx.waterfall(
'agent/turn-continuation', agent, 1,
{ action: 'continue' },
() => Promise.resolve<ContinuationDecision>({ action: 'continue' }),
)
expect(decision).toEqual({ action: 'stop' })
acquisition.detach(agent)
acquisition.release()
})
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),