Merge remote-tracking branch 'origin/master' into worktree-dynamic-workflows

Beyond the mechanical conflicts (provider capability lines vs master's new
inheritsParentContext field; generated catalogs regenerated rather than
hand-merged; knip/lockfile), three master-side reworks required semantic
adaptation of this branch:

- The persona rework removed AgentOptions.systemPrompt, which was the
  structured-output instruction's channel. The instruction now rides the
  SAME final-request enforcement listener that injects the schema'd tool:
  appended per request to final.system (per-request wire state, not agent
  prompt state). Tests assert the wire request (adapter.requests) instead
  of child.options; the bare-direct-dispatch test pins the no-system arm.
- Tool guidance moved out of deployment prompts into per-tool prompt
  sections; the examples' workflow paragraph became a tool:<toolName>
  section contributed by dsh-tool-workflow (explicit-ask-only policy),
  and both example personas resolve to master's minimal identity+behavior
  form. tool-workflow gains inject: systemPrompt (+ peer dep, tsconfig
  ref); the export-shape guard updated.
- The uniform-RFC-format gate: the dynamic-workflows RFC restructured to
  the implemented/ skeleton (bare Status line; Proposal -> Decision;
  What-was-rejected -> Alternatives considered; new Consequences), and
  the overall-run-timeout deferral is now recorded in the RFC's Deferred
  list. The doc-graphs atlas classification gains the workflows seam
  (workflow-vm implementation, tool-workflow consumer).

Master's harness-identity section made "empty assembled prompt" states
unreachable through the loop, so the instruction-append is a plain
undefined-ternary and the structured tests assert append-not-replace.
All snapshot goldens (including workflow-run) replay unchanged. Full
local CI-equivalent gate sequence green on the merged tree.
This commit is contained in:
Tianyi Cui
2026-07-06 03:14:07 +08:00
244 changed files with 6025 additions and 1629 deletions

View File

@@ -9,7 +9,7 @@ The shared **in-process subagent run driver**. A pure library (no provider, no r
Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) before any child exists;
2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited; a structured run appends the `structured_output` instruction after the caller's prompt);
2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the deployment persona needs no inheritance — it is a context-wide prompt section);
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); a structured child that finished a turn CLEANLY without calling `structured_output` is re-prompted (a nudge — a fresh turn) up to `options.structuredNudgeRetries` times;
4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).
@@ -23,7 +23,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/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 for one that has it always carries the run's OWN schema (as the tool's `parameters`) plus the calling instruction appended to its `system` text (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry 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 (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

@@ -22,7 +22,6 @@ import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import {
acquireStructuredRuntime,
STRUCTURED_OUTPUT_INSTRUCTION,
STRUCTURED_OUTPUT_NUDGE,
type StructuredAcquisition,
} from './structured.ts'
@@ -133,18 +132,14 @@ export function startInProcessRun(
const seedLength = options.seed?.length ?? 0
const parentHeader = request.parent.session.header
// Inherit the parent's model by default (a child with no model cannot run);
// an explicit `request.agentOptions.model` overrides it. The parent's
// systemPrompt is NOT inherited — a fresh child is a clean specialist unless
// the caller supplies one. A structured run appends the structured_output
// instruction after whatever prompt the caller supplied.
const callerPrompt = request.agentOptions?.systemPrompt
const systemPrompt = schema === undefined
? callerPrompt
: [callerPrompt, STRUCTURED_OUTPUT_INSTRUCTION].filter(text => text !== undefined && text.length > 0).join('\n\n')
// an explicit `request.agentOptions.model` overrides it. The persona needs
// no inheritance: the deployment persona is a context-wide prompt section,
// so parent and child render the same one. A structured run's
// structured_output instruction is NOT prompt state either — the structured
// runtime's final-request listener appends it per request (see structured.ts).
const agentOptions: AgentOptions = {
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
...request.agentOptions,
...systemPrompt !== undefined ? { systemPrompt } : {},
subagentDepth: childDepth,
}

View File

@@ -11,7 +11,10 @@
* `prepend: true` listener that post-processes `await next()` — FINAL-REQUEST
* enforcement: whatever downstream listeners mutated or replaced, 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 for one that has it.
* structured run, and for one that has it always carries the run's OWN schema
* plus the {@link STRUCTURED_OUTPUT_INSTRUCTION} appended to its `system`
* text (the demand travels with the tool — `AgentOptions` has no per-agent
* prompt field to carry it).
* (Cooperative mutate-then-`next()` would not survive a downstream listener
* returning a replacement request — see the waterfall composition caveat in
* docs/architecture.md.)
@@ -42,7 +45,13 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f
/** The model-facing tool name a structured child must call to finish. */
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
/** The per-child instruction appended to a structured child's system prompt. */
/**
* The instruction the request listener appends to a structured child's
* `system` on every request. Per-request wire state, NOT agent prompt state:
* `AgentOptions` has no prompt field (the persona is deployment config on the
* system-prompt plugin), so the same final-request enforcement that injects
* the schema'd tool carries the instruction that demands calling it.
*/
export const STRUCTURED_OUTPUT_INSTRUCTION
= 'When you have your final answer, you MUST report it by calling the '
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
@@ -172,6 +181,12 @@ function registerRuntime(root: Context, runtime: StructuredRuntime): void {
parameters: state.schema as unknown as Record<string, unknown>,
}
final.tools = [...(final.tools ?? []).filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
// The demand travels WITH the tool: the instruction is appended to the
// final request's system text (the loop always assembles one; a bare
// direct dispatch may carry none).
final.system = final.system === undefined
? STRUCTURED_OUTPUT_INSTRUCTION
: `${final.system}\n\n${STRUCTURED_OUTPUT_INSTRUCTION}`
return final
}
// No structured run: strip the placeholder if present; leave an absent

View File

@@ -187,23 +187,37 @@ describe('in-process structured output', () => {
expect(ctx.agents.get(AgentId('parent'))).toBeDefined()
})
it('appends the structured instruction to the child system prompt (caller prompt preserved)', async () => {
const { ctx, parent } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
const run = ctx.subagents.start('spawn', structuredRequest(parent, {
agentOptions: { systemPrompt: 'You are a counter.' },
}))
it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
// A context-wide section stands in for the deployment persona: the
// instruction must APPEND to whatever the prompt pipeline assembled, not
// replace it (AgentOptions has no prompt field — the instruction is
// per-request wire state added by the final-request listener).
ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
const run = ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const child = ctx.agents.get(run.id)!
expect(child.options.systemPrompt).toBe(`You are a counter.\n\n${STRUCTURED_OUTPUT_INSTRUCTION}`)
const childRequest = adapter.requests.at(-1)!
expect(childRequest.system).toContain('You are a counter.')
expect(childRequest.system!.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
expect(childRequest.system!.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)).toBeGreaterThan(0)
await run.dispose()
})
it('a structured child WITHOUT a caller prompt gets exactly the instruction', async () => {
const { ctx, parent } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
const { ctx, parent, adapter } = await setup([
textResponse('parent answer'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
parent.send([{ type: 'text', text: 'hello' }])
await parent.whenIdle()
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
const run = ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const child = ctx.agents.get(run.id)!
expect(child.options.systemPrompt).toBe(STRUCTURED_OUTPUT_INSTRUCTION)
// The loop always assembles a base prompt (the harness identity section),
// so the instruction APPENDS — never replaces.
const childSystem = adapter.requests.at(-1)!.system!
expect(childSystem.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
expect(childSystem.length).toBeGreaterThan(STRUCTURED_OUTPUT_INSTRUCTION.length)
await run.dispose()
})
@@ -314,6 +328,8 @@ describe('in-process structured output', () => {
const bare2: GenerateOptions = { model: 'mock', messages: [] }
const shaped = await ctx.waterfall('agent/request', parent, 1, 1, bare2, () => Promise.resolve(bare2))
expect(shaped.tools!.map(tool => tool.name)).toEqual([STRUCTURED_OUTPUT_TOOL])
// A bare request carries no system text: the instruction IS the system.
expect(shaped.system).toBe(STRUCTURED_OUTPUT_INSTRUCTION)
acquisition.detach(parent)
acquisition.release()
})