structured.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. /**
  2. * Structured-output support for the in-process subagent backends: the mechanism
  3. * behind `SubagentStartRequest.outputSchema` for children that run as agents on
  4. * the same context.
  5. *
  6. * The model-facing surface is one globally registered `structured_output` tool
  7. * whose REGISTERED parameters are a placeholder — the real schema is per run.
  8. * Because the tool registry and prompt assembly are context-global while
  9. * schemas differ per child (two concurrent structured runs may carry different
  10. * schemas), per-agent shaping happens on the `system-prompt/assemble`
  11. * waterfall with a `prepend: true` listener that post-processes `await next()`
  12. * — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or
  13. * replaced, the assembly the loop renders never carries `structured_output`
  14. * for an agent without a structured run, and for one that has it always
  15. * carries the run's OWN schema plus a trailing
  16. * {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the
  17. * tool). The loop logs what the assembly produced as the request header, so
  18. * the injection is a reconstructable fact of the session log, never a
  19. * wire-only mutation (the reconstructability RFC).
  20. * (Cooperative mutate-then-`next()` would not survive a downstream listener
  21. * returning a replacement assembly — see the waterfall composition caveat in
  22. * docs/architecture.md.)
  23. *
  24. * A companion `agent/turn-continuation` listener stops a child's turn once its
  25. * output is captured — without it, the loop's default "had tool calls ⇒
  26. * continue" buys a wasted extra model step per structured child. It is also
  27. * `prepend: true`: the veto must run before any earlier-registered listener
  28. * that could short-circuit the chain into a forced continue. A third listener
  29. * closes the within-step window the continuation veto cannot: a
  30. * `tools/pre-execute` deny for any call arriving after the agent's capture, so
  31. * a response that lists `structured_output` before further tool calls cannot
  32. * run side effects after the final answer was accepted.
  33. *
  34. * Lifetime is refcounted with two kinds of holder: each backend acquires for
  35. * its plugin lifetime (so the tool exists before any run), and each structured
  36. * RUN acquires from start to settle (so a backend hot-reload mid-run cannot
  37. * unregister the capture tool out from under a live child). Registrations are
  38. * effects on the ROOT context — their natural upper bound is app teardown — and
  39. * the refcount disposes them when the last holder releases.
  40. *
  41. * @module @deepseek-ai/dsh-subagent-inprocess/structured
  42. */
  43. import type { Context } from 'cordis'
  44. import type { Agent } from '@deepseek-ai/dsh-agent'
  45. import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
  46. import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
  47. import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
  48. import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
  49. import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
  50. /** The model-facing tool name a structured child must call to finish. */
  51. export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
  52. /**
  53. * The instruction the assembly listener appends to a structured child's
  54. * system prompt as a trailing section on every assembly. Per-assembly state,
  55. * NOT agent prompt state: `AgentOptions` has no prompt field (the persona is
  56. * deployment config on the system-prompt plugin), so the same final-assembly
  57. * enforcement that injects the schema'd tool carries the instruction that
  58. * demands calling it.
  59. */
  60. export const STRUCTURED_OUTPUT_INSTRUCTION
  61. = 'When you have your final answer, you MUST report it by calling the '
  62. + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
  63. + 'Do not finish with a plain text answer: only the tool call counts as your result.'
  64. /** The nudge sent when a structured child finishes cleanly without calling the tool. */
  65. export const STRUCTURED_OUTPUT_NUDGE
  66. = `You finished without calling \`${STRUCTURED_OUTPUT_TOOL}\`. `
  67. + `Call \`${STRUCTURED_OUTPUT_TOOL}\` now with your final result matching its parameter schema.`
  68. /** One structured run's state: the schema to enforce and the captured value, once recorded. */
  69. interface RunState {
  70. readonly schema: StructuredOutputSchema
  71. captured?: { value: unknown }
  72. }
  73. /** The per-root-context runtime: run states plus the shared registrations. */
  74. interface StructuredRuntime {
  75. refs: number
  76. readonly states: WeakMap<Agent, RunState>
  77. readonly disposers: (() => void)[]
  78. }
  79. /** One root context ⇒ one runtime (multi-app test isolation). */
  80. const runtimes = new WeakMap<Context, StructuredRuntime>()
  81. /**
  82. * One holder's handle on the shared structured runtime. `release()` is
  83. * idempotent per acquisition; the runtime's registrations are disposed when the
  84. * LAST holder (backend plugin or live run) releases.
  85. */
  86. export interface StructuredAcquisition {
  87. /** Enforce `schema` on `agent`'s requests and start capturing its `structured_output` call. */
  88. attach(agent: Agent, schema: StructuredOutputSchema): void
  89. /** The captured value, once the child called the tool with valid arguments. */
  90. captured(agent: Agent): { value: unknown } | undefined
  91. /** Stop enforcing/capturing for `agent` (WeakMap-backed; safe to call twice). */
  92. detach(agent: Agent): void
  93. /** Drop this holder's reference (idempotent); the last release unregisters everything. */
  94. release(): void
  95. }
  96. /**
  97. * Acquire the per-root-context structured runtime, registering the capture tool
  98. * and the two waterfall listeners on the FIRST acquisition. See the module doc
  99. * for the enforcement and lifetime design.
  100. * @param ctx - any context of the app; the runtime keys off `ctx.root`.
  101. * @returns this holder's handle (attach/captured/detach + idempotent release).
  102. */
  103. export function acquireStructuredRuntime(ctx: Context): StructuredAcquisition {
  104. const root: Context = ctx.root
  105. let runtime = runtimes.get(root)
  106. if (!runtime) {
  107. runtime = { refs: 0, states: new WeakMap(), disposers: [] }
  108. runtimes.set(root, runtime)
  109. registerRuntime(root, runtime)
  110. }
  111. runtime.refs += 1
  112. let released = false
  113. return {
  114. attach(agent: Agent, schema: StructuredOutputSchema): void {
  115. runtime.states.set(agent, { schema })
  116. },
  117. captured(agent: Agent): { value: unknown } | undefined {
  118. return runtime.states.get(agent)?.captured
  119. },
  120. detach(agent: Agent): void {
  121. runtime.states.delete(agent)
  122. },
  123. release(): void {
  124. if (released) return
  125. released = true
  126. runtime.refs -= 1
  127. if (runtime.refs > 0) return
  128. runtimes.delete(root)
  129. for (const dispose of runtime.disposers.splice(0)) dispose()
  130. },
  131. }
  132. }
  133. /** Register the capture tool + the two listeners on the root context (first acquire). */
  134. function registerRuntime(root: Context, runtime: StructuredRuntime): void {
  135. // The registered parameters are a PLACEHOLDER: the request listener below
  136. // swaps in the run's real schema per child, and strips the tool entirely for
  137. // every agent without a structured run — so this shape is never model-visible.
  138. //
  139. // Registration does NOT ride on the acquiring backend's plugin-level
  140. // `inject`: a backend that waited on `tools` would apply later than it did
  141. // before this module existed, shifting when its PROVIDER registers — and the
  142. // delegation tool mirrors provider lifecycle, so that shift would reorder
  143. // the model-visible tool list of every existing prompt. Instead the capture
  144. // tool registers synchronously when `tools` is already live (the common
  145. // case), and through a scoped inject fiber when the Loader happens to start
  146. // the backend first. Either way the registration lands on root and is
  147. // disposed by the runtime's refcount; disposing the fiber also covers the
  148. // never-activated case.
  149. let disposeTool: (() => void) | undefined
  150. const registerCapture = (tools: Context['tools']): void => {
  151. disposeTool = tools.register({
  152. name: STRUCTURED_OUTPUT_TOOL,
  153. description:
  154. 'Report your final structured result. Call this exactly once, when your answer is complete; '
  155. + 'the arguments must match this tool\'s parameter schema exactly.',
  156. parameters: { type: 'object', properties: {} },
  157. execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
  158. const state = exec.agent ? runtime.states.get(exec.agent) : undefined
  159. if (!state) {
  160. // Reachable only if a non-structured agent somehow calls the tool (the
  161. // request listener strips it, so the model never sees it) — fail loud
  162. // rather than capture into nowhere.
  163. throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`)
  164. }
  165. const violations = validateStructuredValue(state.schema, args)
  166. // ToolArgsError → isError result with INVALID_ARGS: the model retries
  167. // within the same turn, exactly like a schema-validated defineTool call.
  168. if (violations.length > 0) throw new ToolArgsError(violations)
  169. state.captured = { value: args }
  170. return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
  171. },
  172. })
  173. }
  174. const liveTools = root.get('tools')
  175. const toolsFiber = liveTools ? undefined : root.inject(['tools'], (childCtx: Context) => {
  176. registerCapture(childCtx.root.tools)
  177. })
  178. if (liveTools) registerCapture(liveTools)
  179. runtime.disposers.push(() => {
  180. disposeTool?.()
  181. void toolsFiber?.dispose()
  182. })
  183. // FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST
  184. // wrapper): post-process whatever the downstream listeners and the registry
  185. // produced, so a downstream listener returning a replacement assembly cannot
  186. // leak the tool to other agents or erase the child's schema. The loop logs
  187. // the rendered assembly as the step's request header, so the swap is
  188. // reconstructable log state, never a wire-only mutation.
  189. runtime.disposers.push(root.on('system-prompt/assemble', async function (
  190. this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>,
  191. ): Promise<PromptAssembly> {
  192. const final = await next()
  193. const state = context.agent ? runtime.states.get(context.agent) : undefined
  194. if (state) {
  195. const schemaEntry: ToolSchema = {
  196. name: STRUCTURED_OUTPUT_TOOL,
  197. description:
  198. 'Report your final structured result. Call this exactly once, when your answer is complete; '
  199. + 'the arguments must match this tool\'s parameter schema exactly.',
  200. // ToolSchema.parameters is the wire-level JSON Schema object; the
  201. // asserted subset type is structurally exactly that.
  202. parameters: state.schema as unknown as Record<string, unknown>,
  203. }
  204. final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
  205. // The demand travels WITH the tool: a trailing section in the
  206. // tool-guidance order band, appended after next() so it renders last
  207. // (renderPrompt joins in array order).
  208. final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
  209. return final
  210. }
  211. // No structured run: strip the placeholder so it is never model-visible.
  212. // An empty tools array canonicalizes to an absent header/wire field
  213. // (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here.
  214. final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
  215. return final
  216. }, { prepend: true }))
  217. // Stop a structured child's turn once its output is captured: the default
  218. // "had tool calls ⇒ continue" would otherwise buy a wasted extra model step
  219. // after every successful capture. `prepend: true` puts the veto OUTERMOST —
  220. // an earlier-registered listener that short-circuits the chain (a goal-style
  221. // force-continue returning without `next()`) would otherwise decide the turn
  222. // before this listener ever ran, and no downstream decision may resurrect a
  223. // structured turn that is already finished.
  224. runtime.disposers.push(root.on('agent/turn-continuation', function (
  225. this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
  226. ): Promise<ContinuationDecision> {
  227. if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' })
  228. return next()
  229. }, { prepend: true }))
  230. // Terminal means terminal WITHIN the step, not only at its end: the
  231. // turn-continuation veto above runs after every call in the current model
  232. // response has executed, so a response that puts `structured_output` before
  233. // further tool calls would still perform those side effects after the final
  234. // answer was accepted. Deny every later call for a captured agent at the
  235. // allow/deny gate — dispatch is skipped and the model sees an `isError`
  236. // result naming the contract. Calls that PRECEDE the capture in the same
  237. // response ran before `captured` was set and are untouched; a second
  238. // `structured_output` is denied like any other call. `prepend: true` for the
  239. // same reason as the continuation veto: no earlier-registered allow may
  240. // short-circuit past the terminal contract.
  241. runtime.disposers.push(root.on('tools/pre-execute', function (
  242. this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
  243. ): Promise<PreToolDecision> {
  244. if (exec.agent && runtime.states.get(exec.agent)?.captured) {
  245. return Promise.resolve({
  246. kind: 'deny',
  247. reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
  248. })
  249. }
  250. return next()
  251. }, { prepend: true }))
  252. }