Merge remote-tracking branch 'origin/master' into worktree-dynamic-workflows
Master's reconstructable-requests overhaul (#179) meets the workflow tool: - subagent-inprocess structured-output nudge becomes a system-prompt section plus logged context (the injected-request waterfall shape is gone upstream) - snapshot fixtures re-recorded on the merged tree so every request/header carries the workflow tool; authored error-finish/cancel headers patched to the merged tool list and system text - architecture.md condensed back under its word ceiling; module graph regenerated
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
|
||||
@@ -7,16 +7,18 @@
|
||||
* whose REGISTERED parameters are a placeholder — the real schema is per run.
|
||||
* Because the tool registry and prompt assembly are context-global while
|
||||
* schemas differ per child (two concurrent structured runs may carry different
|
||||
* schemas), per-agent shaping happens on the `agent/request` waterfall with a
|
||||
* `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 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).
|
||||
* schemas), per-agent shaping happens on the `system-prompt/assemble`
|
||||
* waterfall with a `prepend: true` listener that post-processes `await next()`
|
||||
* — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or
|
||||
* replaced, the assembly the loop renders never carries `structured_output`
|
||||
* for an agent without a structured run, and for one that has it always
|
||||
* carries the run's OWN schema plus a trailing
|
||||
* {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the
|
||||
* tool). The loop logs what the assembly produced as the request header, so
|
||||
* the injection is a reconstructable fact of the session log, never a
|
||||
* wire-only mutation (the reconstructability RFC).
|
||||
* (Cooperative mutate-then-`next()` would not survive a downstream listener
|
||||
* returning a replacement request — see the waterfall composition caveat in
|
||||
* returning a replacement assembly — see the waterfall composition caveat in
|
||||
* docs/architecture.md.)
|
||||
*
|
||||
* A companion `agent/turn-continuation` listener stops a child's turn once its
|
||||
@@ -37,8 +39,9 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -46,11 +49,12 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f
|
||||
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The instruction the assembly listener appends to a structured child's
|
||||
* system prompt as a trailing section on every assembly. Per-assembly state,
|
||||
* NOT agent prompt state: `AgentOptions` has no prompt field (the persona is
|
||||
* deployment config on the system-prompt plugin), so the same final-assembly
|
||||
* 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 '
|
||||
@@ -161,15 +165,17 @@ function registerRuntime(root: Context, runtime: StructuredRuntime): void {
|
||||
},
|
||||
}))
|
||||
|
||||
// FINAL-REQUEST enforcement (prepend: true = first registered = OUTERMOST
|
||||
// wrapper): post-process whatever the downstream listeners and the core
|
||||
// produced, so a downstream listener returning a replacement request cannot
|
||||
// leak the tool to other agents or erase the child's schema.
|
||||
runtime.disposers.push(root.on('agent/request', async function (
|
||||
this: unknown, agent: Agent, _turn: number, _step: number, _options: GenerateOptions, next: () => Promise<GenerateOptions>,
|
||||
): Promise<GenerateOptions> {
|
||||
// FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST
|
||||
// wrapper): post-process whatever the downstream listeners and the registry
|
||||
// produced, so a downstream listener returning a replacement assembly cannot
|
||||
// leak the tool to other agents or erase the child's schema. The loop logs
|
||||
// the rendered assembly as the step's request header, so the swap is
|
||||
// reconstructable log state, never a wire-only mutation.
|
||||
runtime.disposers.push(root.on('system-prompt/assemble', async function (
|
||||
this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>,
|
||||
): Promise<PromptAssembly> {
|
||||
const final = await next()
|
||||
const state = runtime.states.get(agent)
|
||||
const state = context.agent ? runtime.states.get(context.agent) : undefined
|
||||
if (state) {
|
||||
const schemaEntry: ToolSchema = {
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
@@ -180,21 +186,17 @@ function registerRuntime(root: Context, runtime: StructuredRuntime): void {
|
||||
// asserted subset type is structurally exactly that.
|
||||
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}`
|
||||
final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
|
||||
// The demand travels WITH the tool: a trailing section in the
|
||||
// tool-guidance order band, appended after next() so it renders last
|
||||
// (renderPrompt joins in array order).
|
||||
final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
|
||||
return final
|
||||
}
|
||||
// No structured run: strip the placeholder if present; leave an absent
|
||||
// tools field absent (an adapter may treat `tools: []` and no tools
|
||||
// differently on the wire).
|
||||
if (final.tools?.some(tool => tool.name === STRUCTURED_OUTPUT_TOOL)) {
|
||||
final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
|
||||
}
|
||||
// No structured run: strip the placeholder so it is never model-visible.
|
||||
// An empty tools array canonicalizes to an absent header/wire field
|
||||
// (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here.
|
||||
final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
|
||||
return final
|
||||
}, { prepend: true }))
|
||||
|
||||
|
||||
@@ -298,16 +298,16 @@ describe('in-process structured output', () => {
|
||||
await runB.dispose()
|
||||
})
|
||||
|
||||
it('wins against a downstream listener that REPLACES the request object', async () => {
|
||||
it('wins against a downstream listener that REPLACES the assembly object', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
])
|
||||
// A downstream (non-prepend) listener that returns a brand-new request —
|
||||
// A downstream (non-prepend) listener that returns a brand-new assembly —
|
||||
// the composition caveat that erases cooperative mutations. Registered
|
||||
// AFTER the runtime's prepend listener, so it runs INSIDE it.
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const replaced = await next()
|
||||
return { ...replaced, tools: [...(replaced.tools ?? [])] }
|
||||
return { sections: [...replaced.sections], tools: [...replaced.tools], variables: { ...replaced.variables } }
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
@@ -333,22 +333,23 @@ describe('in-process structured output', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
it('handles a request with NO tools field at all, for plain and structured agents alike', async () => {
|
||||
// Drive the waterfall directly with a toolless request — the enforcement
|
||||
// listener must tolerate `tools: undefined` on both branches: leave it
|
||||
// absent for a plain agent, and create the array for a structured child.
|
||||
it('shapes a bare assembly on the waterfall: no-agent context strips the placeholder; a structured agent gains schema + trailing instruction section', async () => {
|
||||
// Drive ctx.systemPrompt.assemble directly — the enforcement listener
|
||||
// must tolerate a context with NO agent (a bare diagnostic assemble)
|
||||
// and shape a structured agent's assembly on the same path the loop
|
||||
// renders and logs as the request header.
|
||||
const { ctx, parent } = await setup([])
|
||||
const bare: GenerateOptions = { model: 'mock', messages: [] }
|
||||
const plain = await ctx.waterfall('agent/request', parent, 1, 1, bare, () => Promise.resolve(bare))
|
||||
expect(plain.tools).toBeUndefined()
|
||||
const bare = await ctx.systemPrompt.assemble({})
|
||||
expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
acquisition.attach(parent, SCHEMA)
|
||||
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)
|
||||
const shaped = await ctx.systemPrompt.assemble({ agent: parent })
|
||||
expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
expect(shaped.tools.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters).toEqual(SCHEMA)
|
||||
// The demand travels with the tool: the instruction renders LAST
|
||||
// (appended post-next(); renderPrompt joins in array order).
|
||||
expect(shaped.sections.at(-1)).toMatchObject({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, text: STRUCTURED_OUTPUT_INSTRUCTION })
|
||||
acquisition.detach(parent)
|
||||
acquisition.release()
|
||||
})
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user