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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user