fix(core): enforce agent-scoped ownership boundaries
This commit is contained in:
@@ -32,7 +32,7 @@ export const name = 'subagent-fork'
|
||||
// per-run structured runtime gates its capture-tool registration on `tools`
|
||||
// itself, so this backend's apply timing (and the delegation tool's position
|
||||
// in the model-visible tool list) is unchanged by structured output.
|
||||
export const inject = ['subagents', 'agents']
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
@@ -77,7 +77,6 @@ class ForkProvider implements SubagentProvider {
|
||||
start(request: SubagentStartRequest) {
|
||||
const seed = completedTurnPrefix(request.parent)
|
||||
return startInProcessRun(this.ctx, request, {
|
||||
providerName: this.name,
|
||||
// Only pass a seed when there's a completed turn to inherit; an empty seed
|
||||
// is equivalent to a fresh child, so omit it to keep the session unseeded.
|
||||
...seed.length > 0 ? { seed } : {},
|
||||
|
||||
@@ -200,12 +200,12 @@ describe('dsh-subagent-fork', () => {
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in fork).toBe(false)
|
||||
expect(fork.name).toBe('subagent-fork')
|
||||
expect(fork.inject).toEqual(['subagents', 'agents'])
|
||||
expect(fork.inject).toEqual(['subagents'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(fork) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(fork)
|
||||
expect(unwrapped.name).toBe('subagent-fork')
|
||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
||||
expect(unwrapped.inject).toEqual(['subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,16 +7,18 @@
|
||||
* a prefix of the parent's log); everything downstream — drive the child, read
|
||||
* its final output, map the stop reason, dispose — is identical and lives here.
|
||||
*
|
||||
* This package owns no provider and registers nothing; it is a pure library the
|
||||
* backend packages depend on, so neither backend needs to know about the other.
|
||||
* This package declares no provider and performs no import-time registration;
|
||||
* it is a library the backend packages depend on, so neither backend needs to
|
||||
* know about the other. Each accepted run does install one provider-owned
|
||||
* effect for structured-concurrency cleanup.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, isJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -86,8 +88,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
|
||||
|
||||
/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
|
||||
export interface InProcessRunOptions {
|
||||
/** The provider name (`spawn`/`fork`), for error context only. */
|
||||
readonly providerName: string
|
||||
/**
|
||||
* The child session's seed: a balanced, contiguous-from-0 prefix of the
|
||||
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
|
||||
@@ -95,6 +95,12 @@ export interface InProcessRunOptions {
|
||||
readonly seed?: SessionEvent[]
|
||||
}
|
||||
|
||||
/** Dispose a run-owner fiber and follow an already-started unload to quiescence. */
|
||||
async function quiesceFiber(fiber: Fiber): Promise<void> {
|
||||
await Promise.resolve(fiber.dispose())
|
||||
while (fiber.inertia !== undefined) await fiber.inertia
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an in-process child agent for `request` and return a {@link SubagentRun}.
|
||||
*
|
||||
@@ -108,9 +114,10 @@ export interface InProcessRunOptions {
|
||||
*
|
||||
* Throws {@link SubagentDepthError} before creating anything when the child's
|
||||
* depth (parent depth + 1) would exceed `request.maxDepth`.
|
||||
* @param ctx - the context whose `agents` factory creates and owns the child.
|
||||
* @param ctx - the provider context that owns the live run as a second
|
||||
* structured-concurrency boundary alongside the parent agent.
|
||||
* @param request - the start request (prompt, parent, signal, per-child options).
|
||||
* @param options - the backend's inputs: provider name plus the optional seed.
|
||||
* @param options - the backend's optional child-session seed.
|
||||
* @returns the live run handle for the child agent.
|
||||
*/
|
||||
export function startInProcessRun(
|
||||
@@ -118,7 +125,15 @@ export function startInProcessRun(
|
||||
request: SubagentStartRequest,
|
||||
options: InProcessRunOptions,
|
||||
): SubagentRun {
|
||||
const childDepth = depthOf(request.parent) + 1
|
||||
// Snapshot the accepted request synchronously. The parent and signal are
|
||||
// identity capabilities (kept live but never reread from the mutable request
|
||||
// record); every data field is detached before asynchronous owner setup.
|
||||
const parent = request.parent
|
||||
const signal = request.signal
|
||||
const persona = request.persona
|
||||
const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter)
|
||||
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
|
||||
const childDepth = depthOf(parent) + 1
|
||||
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
|
||||
throw new SubagentDepthError(childDepth, request.maxDepth)
|
||||
}
|
||||
@@ -134,6 +149,17 @@ export function startInProcessRun(
|
||||
// validateStructuredValue to one isolation-immutable value.
|
||||
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
||||
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
|
||||
// The accepted request owns a value snapshot, not the caller's mutable
|
||||
// content array. Validate the same lossless-JSON contract Session.append
|
||||
// enforces before any child exists, then detach it synchronously so mutation
|
||||
// during async creation cannot change what is logged or sent to the model.
|
||||
if (!isJsonValue(request.prompt)) {
|
||||
throw new TypeError('subagent prompt must be losslessly JSON-serializable')
|
||||
}
|
||||
const prompt = structuredClone(request.prompt)
|
||||
if (!isJsonValue(prompt)) {
|
||||
throw new TypeError('subagent prompt must be stable losslessly JSON-serializable data')
|
||||
}
|
||||
|
||||
const childId = AgentId(randomUUID())
|
||||
// The child's OWN events begin after the seed (fork seeds the parent's
|
||||
@@ -141,76 +167,43 @@ export function startInProcessRun(
|
||||
// boundary so a child that produces no message of its own never returns the
|
||||
// SEEDED parent's last assistant message as its result.
|
||||
const seedLength = options.seed?.length ?? 0
|
||||
const parentHeader = request.parent.session.header
|
||||
const parentHeader = 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 deployment
|
||||
// persona needs no inheritance (a context-wide section both render); a
|
||||
// per-child `request.persona` becomes a SCOPED section of the same name in
|
||||
// the setup below, shadowing the deployment's for this child alone.
|
||||
const agentOptions: AgentOptions = {
|
||||
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
|
||||
const agentOptions: AgentOptions = structuredClone({
|
||||
...parent.options.model !== undefined ? { model: parent.options.model } : {},
|
||||
...request.agentOptions,
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
})
|
||||
|
||||
// The child's scoped world, composed in the factory's setup window (after
|
||||
// the child's scope exists and it is registered, before agent/session-start
|
||||
// and the first prompt assembly; a throw here unwinds the half-created
|
||||
// child inside the factory's rollback boundary):
|
||||
// The child's scoped world, composed in the factory's unpublished setup
|
||||
// window. The factory awaits it before inserting or announcing the child, so
|
||||
// a throw/rejection exposes neither id and every first assembly sees it:
|
||||
// - persona: a scoped `deployment:persona` section shadowing the global one;
|
||||
// - toolFilter: a scoped restrict() masking the global tool surface
|
||||
// (loud unknown-name validation lives in the registry);
|
||||
// - outputSchema: the structured runtime, attached as scoped registrations.
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
if (request.persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
|
||||
if (persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: persona })
|
||||
}
|
||||
if (request.toolFilter !== undefined) {
|
||||
childCtx.tools.restrict(request.toolFilter)
|
||||
if (toolFilter !== undefined) {
|
||||
childCtx.tools.restrict(toolFilter)
|
||||
}
|
||||
if (schema !== undefined) {
|
||||
structured = attachStructuredRuntime(childCtx, schema)
|
||||
}
|
||||
}
|
||||
|
||||
const handle: AgentHandle = ctx.agents.create({
|
||||
agentId: childId,
|
||||
sessionId: SessionId(randomUUID()),
|
||||
meta: {
|
||||
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
|
||||
parentSession: parentHeader.id,
|
||||
// Record the seed boundary so a reload (and a replay harness) can tell the
|
||||
// inherited prefix from the child's OWN events. 0 for a fresh spawn.
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
},
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
agentOptions,
|
||||
setup,
|
||||
})
|
||||
const child = handle.agent
|
||||
|
||||
// Structured-concurrency link: the child's teardown rides the PARENT's
|
||||
// scope, so a disposed parent reaches its whole subtree even if the
|
||||
// delegating tool's `finally` never runs — through the MEMOIZED handle, so
|
||||
// every path (tool finally, parent teardown, owner unload) observes the
|
||||
// same quiescence boundary. Registered AFTER the child exists; if the
|
||||
// parent began disposing in between, the registration throws
|
||||
// INACTIVE_EFFECT — dispose the fresh child before rethrowing (no orphan).
|
||||
// Definite assignment: the catch rethrows, so past this block the unlink
|
||||
// disposer always exists.
|
||||
let unlink!: () => Promise<void> | void
|
||||
try {
|
||||
unlink = request.parent.ctx.effect(() => () => handle.dispose())
|
||||
} catch (error: unknown) {
|
||||
// Fire-and-forget: start() must rethrow synchronously; the child's
|
||||
// teardown (stop → unregister → detach) reaches quiescence on its own.
|
||||
void handle.dispose()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Bridge the request's abort signal to the child (the consumer also bridges
|
||||
// its own exec.signal, but a backend-level bridge keeps the contract local).
|
||||
// Install it after provider ownership succeeds but BEFORE awaiting creation,
|
||||
// so an inactive provider cannot leave an orphaned listener and abort/dispose
|
||||
// during async setup is still recorded and applied the moment a child exists.
|
||||
// `cancelled` records that a cancel was requested at all, so the pre-turn
|
||||
// cancel window — where the child clears the queued prompt before any
|
||||
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
|
||||
@@ -220,31 +213,104 @@ export function startInProcessRun(
|
||||
// abort listener, run.cancel), which control-flow narrowing cannot see — an
|
||||
// inline read at the result mapping would narrow to the initializer.
|
||||
const isCancelled = (): boolean => cancelled
|
||||
let child: Agent | undefined
|
||||
let handle: AgentHandle | undefined
|
||||
let disposeRequested = false
|
||||
const isDisposeRequested = (): boolean => disposeRequested
|
||||
const requestCancel = (reason: string): void => {
|
||||
cancelled = true
|
||||
child.cancel(reason)
|
||||
child?.cancel(reason)
|
||||
}
|
||||
const onAbort = (): void => { requestCancel('subagent cancelled') }
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
// One run-owned Cordis fiber is the common ownership node. Install the
|
||||
// provider effect FIRST: a start racing an already-unloading provider fails
|
||||
// before it can mint anything under the parent. The owner fiber is then
|
||||
// nested under the parent scope, and the provider/run handle both dispose
|
||||
// this exact fiber. AgentFactory binds its lifecycle to `ownerCtx`, so any of
|
||||
// the three owners moves the fiber out of ACTIVE synchronously and setup
|
||||
// cannot publish afterward.
|
||||
let ownerCtx: Context | undefined
|
||||
function subagentRunOwner(inner: Context): void { ownerCtx = inner }
|
||||
let ownerFiber: (Fiber & PromiseLike<Fiber>) | undefined
|
||||
let ownerSetupError: unknown
|
||||
let ownerDisposing: Promise<void> | undefined
|
||||
const disposeOwner = (): Promise<void> => (ownerDisposing ??= ownerFiber === undefined
|
||||
? Promise.resolve()
|
||||
: quiesceFiber(ownerFiber))
|
||||
let manualDisposeRequested = false
|
||||
const isManualDisposeRequested = (): boolean => manualDisposeRequested
|
||||
const unlinkProvider = ctx.effect(() => () => {
|
||||
requestCancel('subagent provider disposed')
|
||||
return disposeOwner()
|
||||
}, 'subagent-inprocess.run()')
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal?.aborted) requestCancel('subagent cancelled')
|
||||
try {
|
||||
ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, {
|
||||
inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'],
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
ownerSetupError = error
|
||||
}
|
||||
|
||||
const creation: Promise<Agent> = (async () => {
|
||||
if (ownerSetupError !== undefined) {
|
||||
throw ownerSetupError instanceof Error
|
||||
? ownerSetupError
|
||||
: new Error('subagent run owner setup failed with a non-Error value', { cause: ownerSetupError })
|
||||
}
|
||||
await ownerFiber
|
||||
if (ownerCtx === undefined) {
|
||||
throw new Error('subagent run owner became inactive before child creation')
|
||||
}
|
||||
// Invoke the factory THROUGH the parent scope. Cordis binds the factory's
|
||||
// lifecycle effect to the accessing context, so parent ownership exists
|
||||
// before persistence/setup and publication—not as a fallible link added
|
||||
// after the child is already visible. A disposed parent therefore rejects
|
||||
// before any session/agent notification, and disposal during async setup
|
||||
// wins the unpublished transaction.
|
||||
const created = await ownerCtx.agents.create({
|
||||
agentId: childId,
|
||||
sessionId: SessionId(randomUUID()),
|
||||
meta: {
|
||||
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
|
||||
parentSession: parentHeader.id,
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
},
|
||||
...seed !== undefined ? { seed } : {},
|
||||
agentOptions,
|
||||
setup,
|
||||
})
|
||||
handle = created
|
||||
child = created.agent
|
||||
|
||||
if (isCancelled()) created.agent.cancel('subagent cancelled')
|
||||
return created.agent
|
||||
})()
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
try {
|
||||
// A signal already aborted BEFORE the run starts never fires an `abort`
|
||||
// event (`addEventListener` only fires on the transition), so the listener
|
||||
// above won't catch it — settle `aborted` without running the child rather
|
||||
// than completing an already-cancelled request.
|
||||
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
|
||||
child.send(request.prompt)
|
||||
await child.whenIdle()
|
||||
let liveChild: Agent
|
||||
try {
|
||||
liveChild = await creation
|
||||
} catch (error: unknown) {
|
||||
if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' }
|
||||
throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error })
|
||||
}
|
||||
if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' }
|
||||
liveChild.send(prompt)
|
||||
await liveChild.whenIdle()
|
||||
// Deliberately NO re-prompt when a structured child finishes cleanly
|
||||
// without calling structured_output: readResult maps that to `error` —
|
||||
// the shortfall goes to the parent instead of buying extra model turns.
|
||||
return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
|
||||
return readResult(liveChild, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
|
||||
} finally {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
let disposing: Promise<void> | undefined
|
||||
return {
|
||||
id: childId,
|
||||
result,
|
||||
@@ -252,13 +318,26 @@ export function startInProcessRun(
|
||||
requestCancel(reason ?? 'subagent cancelled')
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
// Through the parent-scope unlink when the parent is still live (one
|
||||
// disposal path, and the dead effect leaves the parent's list); the
|
||||
// memoized handle keeps a direct dispose equivalent if the parent's
|
||||
// teardown already ran the unlink.
|
||||
await unlink()
|
||||
await handle.dispose()
|
||||
return (disposing ??= (async () => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
disposeRequested = true
|
||||
manualDisposeRequested = true
|
||||
requestCancel('subagent disposed during creation')
|
||||
// Removing provider ownership and disposing the common run-owner fiber
|
||||
// are the same quiescence transaction; parent disposal may already have
|
||||
// claimed it, in which case disposeOwner follows fiber inertia.
|
||||
await unlinkProvider()
|
||||
try {
|
||||
await creation
|
||||
} catch {
|
||||
// Creation rollback already reached quiescence; there is no handle
|
||||
// left to dispose, and dispose must not mask result's infrastructure
|
||||
// rejection with the same error from a finally block.
|
||||
return
|
||||
}
|
||||
await disposeOwner()
|
||||
await handle?.dispose()
|
||||
})())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,44 +14,39 @@
|
||||
* a disposed child leaves no residue — no placeholder schema,
|
||||
* strip-for-everyone-else pass, or refcounted global runtime.
|
||||
*
|
||||
* Four listeners enforce the contract:
|
||||
* The child scope's registrations enforce the contract:
|
||||
*
|
||||
* - `system-prompt/assemble` (prepend, scoped): assembly re-assert — the
|
||||
* listener post-processes its downstream chain so a listener inside that
|
||||
* chain cannot leave the child's capture tool or instruction stripped or
|
||||
* replaced. Tools are replaced in place and the section is re-inserted at
|
||||
* its ascending-order position, so the untampered path keeps the registry's
|
||||
* ordering (up to intra-band section order, which carries no contract). A
|
||||
* listener prepended later can still wrap and transform this result; this is
|
||||
* an ordinary waterfall listener, not a service-level finalizer. The loop
|
||||
* logs the rendered assembly as the request header, so the demand is
|
||||
* reconstructable log state, never a wire-only mutation.
|
||||
* - `agent/turn-continuation` (prepend, scoped): stop the child's turn once
|
||||
* its output is captured — the loop's default "had tool calls ⇒ continue"
|
||||
* would buy a wasted extra model step per structured child.
|
||||
* - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the
|
||||
* step — deny every call arriving after the capture, so a response that
|
||||
* lists `structured_output` before further tool calls cannot run side
|
||||
* effects after the final answer was accepted.
|
||||
* - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body
|
||||
* only STAGES the validated value, KEYED BY THE EXECUTION OBJECT in a
|
||||
* WeakMap; it becomes the run's captured result when this listener's
|
||||
* downstream post-execute decision accepts THAT SAME pipeline trip. A
|
||||
* later-prepended wrapper remains outside that decision. Execution-keyed
|
||||
* staging makes the stale-stage class structurally impossible: a value
|
||||
* orphaned by an outer short-circuiting listener (a post-execute block, or
|
||||
* a pre-execute deny whose call never dispatched) can never match another
|
||||
* execution's lookup — whatever call id that execution carries — and is
|
||||
* reclaimed with the execution object itself.
|
||||
* - `systemPrompt.protect()` declaratively protects the capture tool and its
|
||||
* instruction. The service restores their canonical pre-waterfall state
|
||||
* after EVERY assembly listener. Canonical absence is protected too: pure
|
||||
* Code Mode keeps `structured_output` in the SDK only and never grows a
|
||||
* second native wire tool. Code Mode's owner independently protects its SDK
|
||||
* and `run_code` transport. The loop logs the finalized assembly as the
|
||||
* request header, so the demand is reconstructable log state, never a
|
||||
* wire-only mutation.
|
||||
* - `agent/turn-stop` (serial, scoped): stop the child's turn once its output
|
||||
* is captured. This terminal checkpoint runs after the ordinary continuation
|
||||
* waterfall and steering folding, so listener order cannot resurrect a
|
||||
* completed structured run or carry terminal steering into another turn.
|
||||
* - `tools.guard()` is the monotonic terminal gate after the extensible
|
||||
* pre-execute waterfall: once capture commits, no later listener can turn
|
||||
* the denial back into a dispatched side effect.
|
||||
* - `tools/result` is the capture COMMIT point. The tool body only STAGES the
|
||||
* validated value in a WeakMap keyed by the execution object; the awaited,
|
||||
* non-transforming notification promotes it only when the authoritative
|
||||
* result after the whole pre/execute/post pipeline succeeds. For a Code Mode
|
||||
* sub-dispatch, promotion waits again for the enclosing `run_code` result, so
|
||||
* a runtime failure or outer post-policy block cannot report structured
|
||||
* success. Execution identity makes call-id reuse and orphaned stages
|
||||
* irrelevant.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess/structured
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The model-facing tool name a structured child must call to finish. */
|
||||
@@ -71,7 +66,7 @@ export const STRUCTURED_OUTPUT_INSTRUCTION
|
||||
export interface StructuredAttachment {
|
||||
/**
|
||||
* The captured value, once the child called the tool with valid arguments
|
||||
* and the final post-execute decision accepted that call.
|
||||
* and the authoritative final tool result accepted that call.
|
||||
* @returns the committed value, or undefined while none was accepted.
|
||||
*/
|
||||
captured(): { value: unknown } | undefined
|
||||
@@ -80,7 +75,7 @@ export interface StructuredAttachment {
|
||||
/**
|
||||
* Attach the structured-output runtime to a child for `schema`: register the
|
||||
* scoped capture tool (real schema), the scoped instruction section, and the
|
||||
* four scoped enforcement listeners (see the module doc). Call from the
|
||||
* scoped enforcement registrations (see the module doc). Call from the
|
||||
* agent-creation `setup` window with the child's scope context — every
|
||||
* registration rides the child's fiber and unwinds with the child.
|
||||
* @param childCtx - the child agent's scope context (`setup`'s argument).
|
||||
@@ -91,19 +86,16 @@ export interface StructuredAttachment {
|
||||
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
|
||||
/**
|
||||
* Validated values staged by the capture tool body, awaiting THEIR OWN
|
||||
* call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT,
|
||||
* the one token that provably ties a stage to one trip through the
|
||||
* pipeline. A call id cannot key this: ids are adapter-minted and may
|
||||
* repeat across steps. Keying by execution makes the stale-stage class
|
||||
* structurally impossible — an entry orphaned by an outer short-circuiting
|
||||
* listener can never match a different execution's lookup, needs no drop
|
||||
* bookkeeping (the WeakMap reclaims it with the execution object), and two
|
||||
* in-flight captures can never cross-clobber each other's STAGE should
|
||||
* tool execution ever go parallel (the loop's documented TODO). Staging is
|
||||
* the only layer this future-proofs: a parallel-execution cut would still
|
||||
* owe its own single-accept rule for `captured` itself.
|
||||
* authoritative `tools/result` notification. The execution object's identity
|
||||
* uniquely identifies a trip through the pipeline: adapter call ids may
|
||||
* repeat across steps, but another execution can never reach this WeakMap
|
||||
* entry. This is distinct from the opaque `ToolExecutionToken` used to
|
||||
* correlate nested transports. The final notification always deletes its own
|
||||
* stage, whether the result succeeded or failed.
|
||||
*/
|
||||
const staged = new WeakMap<ToolExecution, { value: unknown }>()
|
||||
/** Successful nested capture waiting for its enclosing transport to commit. */
|
||||
let pending: { parent: ToolExecution['token']; value: unknown } | undefined
|
||||
let captured: { value: unknown } | undefined
|
||||
|
||||
const schemaEntry: ToolSchema = {
|
||||
@@ -123,10 +115,10 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||
// within the same turn, exactly like a schema-validated defineTool call.
|
||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||
// Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the
|
||||
// post-execute listener promotes exactly this pipeline trip's entry
|
||||
// when its downstream decision accepts it.
|
||||
staged.set(exec, { value: args })
|
||||
// Two-phase commit, keyed by THIS execution: later transformable
|
||||
// waterfalls may still turn the success into an error. Snapshot the
|
||||
// validated value independently of the already-frozen pipeline arguments.
|
||||
staged.set(exec, { value: structuredClone(args) })
|
||||
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
||||
},
|
||||
})
|
||||
@@ -137,105 +129,58 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
})
|
||||
|
||||
// PREPENDED assembly re-assert: scoped dispatch means this fires only for the
|
||||
// child's assemblies; `await next()` returns whatever this listener's
|
||||
// downstream chain produced, and the capture tool + instruction are
|
||||
// re-asserted onto it if anything stripped them. A listener prepended later
|
||||
// can still wrap and transform the returned assembly; this is not a
|
||||
// service-level finalizer.
|
||||
childCtx.on('system-prompt/assemble', async function (
|
||||
this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise<PromptAssembly>,
|
||||
): Promise<PromptAssembly> {
|
||||
const final = await next()
|
||||
// REPLACE, not merely ensure-present: a downstream listener may have
|
||||
// mutated or injected a same-named entry with the WRONG schema/text, and
|
||||
// the model-visible demand must be exactly this run's own — the same
|
||||
// schema validateStructuredValue enforces. Placement-preserving on both
|
||||
// arrays: the untampered path keeps the registry's ordering (tool order
|
||||
// is the `toolOrder`/lexicographic contract, section order the ascending
|
||||
// contract `renderPrompt` trusts), so this never reorders what it only
|
||||
// re-asserts — up to intra-band section order, which carries no contract
|
||||
// (a 190-order section registered AFTER this runtime sorts before the
|
||||
// instruction in the registry but after it here).
|
||||
const freshTool: ToolSchema = { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }
|
||||
// Tools: replace the first same-named entry IN PLACE (its position is the
|
||||
// chain's product; a tool's list position carries no semantic band to
|
||||
// restore), drop any duplicates, append only when stripped entirely.
|
||||
const tools: ToolSchema[] = []
|
||||
let toolReplaced = false
|
||||
for (const tool of final.tools) {
|
||||
if (tool.name !== STRUCTURED_OUTPUT_TOOL) {
|
||||
tools.push(tool)
|
||||
} else if (!toolReplaced) {
|
||||
tools.push(freshTool)
|
||||
toolReplaced = true
|
||||
// Service-owned finalization, not waterfall ordering. The canonical
|
||||
// assembly determines both presence and absence: native/both modes restore
|
||||
// the capture schema on the wire, while pure Code Mode removes any injected
|
||||
// native entry. ToolRegistry's own protection independently restores the SDK
|
||||
// section and run_code transport that carry the same schema.
|
||||
childCtx.systemPrompt.protect({
|
||||
sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`],
|
||||
tools: [STRUCTURED_OUTPUT_TOOL],
|
||||
})
|
||||
|
||||
// Stop the child's turn once its output is captured. This monotonic serial
|
||||
// checkpoint runs after the ordinary continuation waterfall, its reason,
|
||||
// and late-steering folding, so no ordering trick can resume a finished run.
|
||||
childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined {
|
||||
return captured === undefined ? undefined : { action: 'stop' }
|
||||
})
|
||||
|
||||
// Terminal WITHIN the step. Guards run after the whole pre-execute
|
||||
// waterfall and compose monotonically (deny or abstain, never allow), so a
|
||||
// later prepended listener cannot resurrect dispatch. Calls that precede
|
||||
// capture in the same response remain untouched.
|
||||
childCtx.tools.guard(exec => captured === undefined && pending === undefined
|
||||
? undefined
|
||||
: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`)
|
||||
|
||||
// The capture COMMIT observes the immutable, authoritative result after the
|
||||
// complete pipeline and outer error normalization. This notification cannot
|
||||
// transform the outcome, so there is no wrapper outside the commit verdict.
|
||||
childCtx.on('tools/result', function (this: unknown, exec, result): void {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
const entry = staged.get(exec)
|
||||
if (entry === undefined) return
|
||||
staged.delete(exec)
|
||||
if (result.isError) return
|
||||
if (exec.parent === undefined) {
|
||||
/* v8 ignore else -- sequential agent-loop dispatch lets the guard block every later supported call */
|
||||
if (captured === undefined) captured = { value: entry.value }
|
||||
} else {
|
||||
/* v8 ignore else -- Code Mode serializes sub-dispatches, so the guard blocks every later supported call */
|
||||
if (captured === undefined && pending === undefined) {
|
||||
pending = { parent: exec.parent, value: entry.value }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!toolReplaced) tools.push(freshTool)
|
||||
final.tools = tools
|
||||
// Sections: remove every same-named entry and re-insert at the
|
||||
// ascending-correct position (the first entry above order 190) — sections
|
||||
// DO carry an order contract, and the renderer reads array order, so a
|
||||
// stripped-or-moved instruction is restored to its band, not appended
|
||||
// after unrelated higher-order sections. On the untampered path this
|
||||
// lands at the end of the 190 band — where the registry's stable sort
|
||||
// put it too, unless another 190-order section registered later.
|
||||
const sectionName = `tool:${STRUCTURED_OUTPUT_TOOL}`
|
||||
const sections = final.sections.filter(section => section.name !== sectionName)
|
||||
const insertAt = sections.findIndex(section => section.order > 190)
|
||||
sections.splice(insertAt === -1 ? sections.length : insertAt, 0, { name: sectionName, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION })
|
||||
final.sections = sections
|
||||
return final
|
||||
}, { prepend: true })
|
||||
|
||||
// Stop the child's turn once its output is captured. `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 finished.
|
||||
childCtx.on('agent/turn-continuation', function (
|
||||
this: unknown, _agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
|
||||
): Promise<ContinuationDecision> {
|
||||
if (captured) return Promise.resolve({ action: 'stop' })
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
|
||||
// Terminal WITHIN the step: deny every call after the capture. Calls that
|
||||
// PRECEDE the capture in the same response ran before `captured` was set
|
||||
// and are untouched; a second `structured_output` is denied like any other.
|
||||
childCtx.on('tools/pre-execute', function (
|
||||
this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
|
||||
): Promise<PreToolDecision> {
|
||||
if (captured) {
|
||||
return Promise.resolve({
|
||||
kind: 'deny',
|
||||
reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
|
||||
})
|
||||
}
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
|
||||
// The capture COMMIT: promote a staged value only when the final
|
||||
// post-execute decision accepts THE SAME EXECUTION that staged it — the
|
||||
// lookup key IS the execution, so a stale entry from a different pipeline
|
||||
// trip (its own chain short-circuited past this commit by an outer
|
||||
// post-execute block, or an outer pre-execute deny whose call never
|
||||
// dispatched) is unreachable here by construction, whatever the current
|
||||
// call's id.
|
||||
childCtx.on('tools/post-execute', async function (
|
||||
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
|
||||
): Promise<PostToolDecision> {
|
||||
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return next()
|
||||
const entry = staged.get(exec)
|
||||
if (entry === undefined) return next()
|
||||
// Single-shot per execution: this trip's verdict is decided by the chain
|
||||
// below, never revisited (the WeakMap would reclaim the entry either way;
|
||||
// deleting states the intent).
|
||||
staged.delete(exec)
|
||||
const decision = await next()
|
||||
if (decision.kind === 'accept') captured = { value: entry.value }
|
||||
return decision
|
||||
}, { prepend: true })
|
||||
if (pending?.parent !== exec.token) return
|
||||
const entry = pending
|
||||
pending = undefined
|
||||
if (result.isError) return
|
||||
/* v8 ignore else -- Code Mode serializes outer executions, so the guard blocks every later supported call */
|
||||
if (captured === undefined) captured = { value: entry.value }
|
||||
})
|
||||
|
||||
return { captured: () => captured }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import type { 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'
|
||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
import {
|
||||
@@ -19,6 +20,15 @@ import {
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
interface CodeRunRequestLike {
|
||||
bindings: { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }[]
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
toolMode?: ToolConfig['mode']
|
||||
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
|
||||
}
|
||||
|
||||
const SCHEMA: StructuredOutputSchema = {
|
||||
type: 'object',
|
||||
properties: { answer: { type: 'number' }, note: { type: 'string' } },
|
||||
@@ -33,13 +43,20 @@ const SCHEMA: StructuredOutputSchema = {
|
||||
* coverage lives in the spawn/fork specs. The mock model script drives the
|
||||
* child's structured_output calls.
|
||||
*/
|
||||
async function setup(script: Script) {
|
||||
async function setup(script: Script, options: SetupOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(script)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolRegistry, { mode: options.toolMode ?? 'native' })
|
||||
if (options.toolMode === 'code' || options.toolMode === 'both') {
|
||||
ctx.provide('codeRuntime', {
|
||||
language: 'typescript',
|
||||
isolation: 'test',
|
||||
run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
|
||||
} as never)
|
||||
}
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
@@ -48,7 +65,7 @@ async function setup(script: Script) {
|
||||
name: 'spawn',
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, {}),
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
@@ -121,6 +138,44 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a later prepended pre-execute listener cannot resurrect dispatch after capture', async () => {
|
||||
const response = [
|
||||
...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
] as Script[number]
|
||||
const { ctx, parent } = await setup([response])
|
||||
let sideEffectRan = false
|
||||
ctx.tools.register({
|
||||
name: 'side_effect',
|
||||
description: 'probe',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute(): Promise<ContentBlock[]> {
|
||||
sideEffectRan = true
|
||||
return Promise.resolve([{ type: 'text', text: 'ran' }])
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// Registered after the child and prepended: this listener returns allow
|
||||
// after every downstream pre-execute decision. The service-owned guard
|
||||
// runs after the waterfall and can only deny, so the body still cannot run.
|
||||
ctx.on('tools/pre-execute', async (_exec, next) => {
|
||||
await next()
|
||||
return { kind: 'allow' as const }
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 5 })
|
||||
expect(sideEffectRan).toBe(false)
|
||||
const child = ctx.agents.get(run.id)
|
||||
const sideEffectResult = child?.session.events.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === 'c2')
|
||||
expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
|
||||
const response = [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
@@ -173,23 +228,67 @@ 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 () => {
|
||||
// A goal-style listener registered BEFORE the child exists, returning a
|
||||
// forced continue WITHOUT calling next(). Without prepend on the scoped
|
||||
// veto, this would decide the turn first and buy a wasted model step —
|
||||
// the one-response script would then throw on the second request.
|
||||
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
let wrapperInstalled = false
|
||||
// Register this observer only after start() returns. The child session-start
|
||||
// boundary is after its unpublished setup attached structured output but
|
||||
// before the loop can run; install a prepended wrapper there. It awaits the
|
||||
// explicit downstream stop above, then overwrites that result with continue.
|
||||
// The later terminal checkpoint still wins.
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
if (child.id !== run.id) return
|
||||
wrapperInstalled = true
|
||||
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise<ContinuationDecision> => {
|
||||
const downstream = await next()
|
||||
expect(downstream).toEqual({ action: 'stop' })
|
||||
return { action: 'continue' }
|
||||
}, { prepend: true })
|
||||
})
|
||||
const result = await run.result
|
||||
expect(wrapperInstalled).toBe(true)
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
// The downstream ordinary policy says stop. A wrapper registered after
|
||||
// start() delegates to that stop, then queues steering; ordinary folding
|
||||
// would turn the stop back into continue. The terminal checkpoint runs
|
||||
// afterwards and discards that steering.
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
if (child.id !== run.id) return
|
||||
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise<ContinuationDecision> => {
|
||||
const downstream = await next()
|
||||
expect(downstream).toEqual({ action: 'stop' })
|
||||
subject.steer([{ type: 'text', text: 'late steering after downstream stop' }])
|
||||
return downstream
|
||||
}, { prepend: true })
|
||||
})
|
||||
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)
|
||||
|
||||
expect(result.structured).toEqual({ answer: 9 })
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
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' }),
|
||||
@@ -236,11 +335,11 @@ describe('in-process structured output', () => {
|
||||
it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('prose, no capture')])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Cancel synchronously inside the turn's end recording: the cancel
|
||||
// contract outranks the schema shortfall, so the result maps to aborted.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === child.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
|
||||
const child = ctx.agents.get(run.id)
|
||||
if (session === child?.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
@@ -270,8 +369,8 @@ describe('in-process structured output', () => {
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
textResponse('continues after the blocked capture'),
|
||||
])
|
||||
// A PostToolUse-style hook, registered AFTER the runtime (so the runtime's
|
||||
// prepend commit listener stays outermost and composes this verdict).
|
||||
// A PostToolUse-style hook turns the tool body's provisional success into
|
||||
// the authoritative final error observed by the commit notification.
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
|
||||
@@ -311,6 +410,31 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('commits only after a later prepended post-execute wrapper returns the authoritative result', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
|
||||
textResponse('capture was rejected'),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// Registered after attachment and prepended, so it wraps every listener
|
||||
// the child installed. It delegates first, then converts the apparent
|
||||
// capture success into the pipeline's authoritative failure.
|
||||
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
||||
const downstream = await next()
|
||||
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return downstream
|
||||
return { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected after downstream' }] }
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(result.stopReason).toBe('error')
|
||||
const child = ctx.agents.get(run.id)
|
||||
const captureResult = child?.session.events.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === 'c1')
|
||||
expect(captureResult?.type === 'tool/result' && captureResult.data.isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
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
|
||||
@@ -327,6 +451,100 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
|
||||
], {
|
||||
toolMode: 'code',
|
||||
codeRun: async (request) => {
|
||||
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
|
||||
if (!capture) throw new Error('structured_output binding missing')
|
||||
await capture({ answer: 12 })
|
||||
return { logs: [], value: 'captured' }
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
// This listener is registered after the child's protection and prepended.
|
||||
// Service finalization still restores the stripped transport and prompt
|
||||
// parts, while removing the fabricated native capture tool.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
return {
|
||||
sections: result.sections.filter(section =>
|
||||
section.name !== 'tools:sdk' && section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`),
|
||||
tools: [
|
||||
...result.tools.filter(tool => tool.name !== RUN_CODE_NAME),
|
||||
{ name: STRUCTURED_OUTPUT_TOOL, description: 'wrong native duplicate', parameters: {} },
|
||||
],
|
||||
variables: result.variables,
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 12 })
|
||||
const request = adapter.requests[0]!
|
||||
expect(toolNames(request)).toEqual([RUN_CODE_NAME])
|
||||
expect(request.system).toContain('declare const tools:')
|
||||
expect(request.system).toContain('structured_output(args:')
|
||||
expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('discards a nested capture when the enclosing run_code execution fails', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")' }),
|
||||
textResponse('outer code failed'),
|
||||
], {
|
||||
toolMode: 'code',
|
||||
codeRun: async (request) => {
|
||||
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
|
||||
if (!capture) throw new Error('structured_output binding missing')
|
||||
await capture({ answer: 12 })
|
||||
return {
|
||||
logs: [],
|
||||
error: { kind: 'runtime', message: 'boom after capture' },
|
||||
} as never
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const child = ctx.agents.get(run.id)!
|
||||
const outer = child.session.events.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === CallId('c1'))
|
||||
expect(outer?.type === 'tool/result' && outer.data.isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
|
||||
textResponse('outer code was blocked'),
|
||||
], {
|
||||
toolMode: 'code',
|
||||
codeRun: async (request) => {
|
||||
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
|
||||
if (!capture) throw new Error('structured_output binding missing')
|
||||
await capture({ answer: 12 })
|
||||
return { logs: [], value: 'captured' }
|
||||
},
|
||||
})
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
|
||||
? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
|
||||
: next())
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
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'),
|
||||
@@ -412,12 +630,12 @@ describe('in-process structured output', () => {
|
||||
await runB.dispose()
|
||||
})
|
||||
|
||||
it('the re-assert REPLACES a conflicting injected schema, not merely ensures presence', async () => {
|
||||
it('protection replaces a conflicting injected schema, not merely ensuring presence', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
])
|
||||
// A global listener that INJECTS a wrong-schema structured_output entry:
|
||||
// the child's re-assert must replace it with the run's own schema.
|
||||
// protection restores the run's own canonical schema.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const replaced = await next()
|
||||
return {
|
||||
@@ -438,14 +656,14 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the re-assert wins against a downstream listener that REPLACES the assembly object', async () => {
|
||||
it('protection wins against a listener that replaces the assembly object', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
])
|
||||
// A global (every-assembly) listener that returns a brand-new assembly
|
||||
// WITHOUT the capture tool or instruction — the composition caveat that
|
||||
// erases cooperative mutations. The child's prepend re-assert runs
|
||||
// OUTERMOST and restores both.
|
||||
// erases cooperative mutations. Service finalization restores both
|
||||
// after the complete waterfall.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const replaced = await next()
|
||||
return {
|
||||
@@ -465,13 +683,13 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the re-assert preserves the untampered assembly: tool position and section band are the registry\'s own', async () => {
|
||||
it('protection preserves the canonical tool position and section band', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
])
|
||||
// A global tool sorting lexicographically AFTER structured_output and a
|
||||
// global section ABOVE the 190 band: the re-assert must leave both
|
||||
// exactly where the registry's ordering put them (no move-to-end).
|
||||
// global section above the 190 band: protection leaves both exactly
|
||||
// where the canonical registry ordering put them.
|
||||
ctx.tools.register({
|
||||
name: 'zz_probe',
|
||||
description: 'probe',
|
||||
@@ -498,7 +716,7 @@ describe('in-process structured output', () => {
|
||||
])
|
||||
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
|
||||
// Strip the instruction section entirely AND add a wrong-schema
|
||||
// duplicate tool entry ALONGSIDE the registry's own: the re-assert must
|
||||
// duplicate tool entry alongside the registry's own: protection must
|
||||
// restore the section INTO its band (before the order-200 section, not
|
||||
// appended after it) and collapse the tools to exactly one entry
|
||||
// carrying the run's schema.
|
||||
@@ -578,15 +796,14 @@ describe('in-process structured output', () => {
|
||||
expect(result.error?.code).toBe('UNKNOWN_TOOL')
|
||||
})
|
||||
|
||||
it('a stale stage from a short-circuited chain is never promoted by a later call (execution-keyed commit)', async () => {
|
||||
it('a failed execution stage is discarded and never promoted by a later call', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// An OUTER post-execute listener (registered after attach, prepend ⇒
|
||||
// outermost) that BLOCKS the first capture WITHOUT delegating: the commit
|
||||
// listener never runs for c1, so its staged value would linger.
|
||||
// A prepended post-execute listener blocks the first capture without
|
||||
// delegating. The final-result notification discards that execution's
|
||||
// stage when it observes the error.
|
||||
let blocks = 1
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
@@ -596,11 +813,12 @@ describe('in-process structured output', () => {
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// The blocked capture must NOT surface as structured success…
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.structured).toBeUndefined()
|
||||
// …and a LATER invalid call (its own body staged nothing) must not
|
||||
// resurrect c1's orphaned value: drive the pipeline directly.
|
||||
// resurrect c1's discarded value: drive the pipeline directly.
|
||||
const invalid = await ctx.tools.execute({
|
||||
callId: 'c2' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
@@ -619,14 +837,13 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a later capture call REUSING a stale stage\'s call id never promotes it (unconditional commit safety)', async () => {
|
||||
it('reusing a failed execution\'s call id never promotes its discarded stage', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Orphan a stage: an outer short-circuiting post-execute BLOCK on the
|
||||
// first capture (its chain never reaches the commit listener).
|
||||
// Block the first capture after its body stages a value. Its final error
|
||||
// discards that execution's stage.
|
||||
let blocks = 1
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
@@ -636,8 +853,9 @@ describe('in-process structured output', () => {
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// A SECOND capture call with the SAME call id whose body never stages
|
||||
// (invalid args throw before the stage): the stale value must not ride
|
||||
// (invalid args throw before the stage): the discarded value must not ride
|
||||
// its acceptance.
|
||||
const reused = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
@@ -657,13 +875,12 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('an outer pre-execute deny with call-id reuse cannot promote an orphaned stage either', async () => {
|
||||
it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Orphan a stage via an outer post-execute BLOCK on the first capture.
|
||||
// Discard the first capture's stage via a final post-execute block.
|
||||
let blocks = 1
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
@@ -673,9 +890,9 @@ describe('in-process structured output', () => {
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
await run.result
|
||||
// An OUTERMOST prepend pre-execute deny: the structured runtime's own
|
||||
// pre-execute never runs for this call, and the denied call still goes
|
||||
// through post-execute — with the SAME call id as the orphaned stage.
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// A prepended pre-execute deny skips the body, while the denied call still
|
||||
// reaches the final notification with the same adapter-minted call id.
|
||||
const offDeny = ctx.on('tools/pre-execute', (exec) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
|
||||
@@ -690,7 +907,7 @@ describe('in-process structured output', () => {
|
||||
})
|
||||
expect(denied.isError).toBe(true)
|
||||
offDeny()
|
||||
// The orphan was never promoted: a fresh valid call is still required
|
||||
// The discarded value was never promoted: a fresh valid call is required
|
||||
// (and succeeds, proving the runtime is not wedged).
|
||||
const valid = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -49,9 +49,166 @@ describe('depthOf', () => {
|
||||
})
|
||||
|
||||
describe('startInProcessRun', () => {
|
||||
it('rejects a non-JSON prompt before acquiring any run ownership', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: Number.NaN as unknown as string }],
|
||||
parent,
|
||||
}, {})).toThrow('subagent prompt must be losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('rejects a prompt whose getter becomes non-JSON while it is snapshotted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
let reads = 0
|
||||
const prompt = [{
|
||||
type: 'text' as const,
|
||||
get text(): string {
|
||||
reads += 1
|
||||
return reads === 1 ? 'valid during pre-check' : Number.NaN as unknown as string
|
||||
},
|
||||
}]
|
||||
|
||||
expect(() => startInProcessRun(ctx, { prompt, parent }, {}))
|
||||
.toThrow('subagent prompt must be stable losslessly JSON-serializable data')
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects when the run-owner fiber settles without installing its context', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
function inertOwner(): void {}
|
||||
const inertFiber = ctx.plugin(inertOwner)
|
||||
await inertFiber
|
||||
const parentWithoutOwnerContext = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: { plugin: () => inertFiber },
|
||||
} as unknown as Agent
|
||||
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithoutOwnerContext,
|
||||
}, {})
|
||||
await expect(run.result).rejects.toThrow('subagent run owner became inactive before child creation')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a non-Error thrown while installing the run-owner fiber', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const setupFailure = 'non-Error owner setup failure'
|
||||
const parentWithFailingOwnerSetup = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: { plugin: () => { throw setupFailure } },
|
||||
} as unknown as Agent
|
||||
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithFailingOwnerSetup,
|
||||
}, {})
|
||||
await expect(run.result).rejects.toMatchObject({
|
||||
message: 'subagent run owner setup failed with a non-Error value',
|
||||
cause: setupFailure,
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a non-Error rejected by asynchronous child creation', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const creationFailure = 'non-Error child creation failure'
|
||||
function inertOwner(): void {}
|
||||
const ownerFiber = ctx.plugin(inertOwner)
|
||||
await ownerFiber
|
||||
const rejectWithNonError = (): Promise<never> => {
|
||||
// Deliberately violate the promise contract to exercise boundary normalization.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject(creationFailure)
|
||||
}
|
||||
const rejectingOwnerCtx = {
|
||||
agents: { create: rejectWithNonError },
|
||||
} as unknown as Context
|
||||
const parentWithRejectingFactory = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
plugin(plugin: (inner: Context) => void) {
|
||||
plugin(rejectingOwnerCtx)
|
||||
return ownerFiber
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithRejectingFactory,
|
||||
}, {})
|
||||
await expect(run.result).rejects.toMatchObject({
|
||||
message: 'subagent child creation failed with a non-Error value',
|
||||
cause: creationFailure,
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('follows owner-fiber inertia when raw teardown was already in flight', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let inertia: Promise<undefined> | undefined = gate.promise
|
||||
const fakeFiber = {
|
||||
dispose: vi.fn(() => undefined),
|
||||
get inertia() { return inertia },
|
||||
} as unknown as Fiber & PromiseLike<Fiber>
|
||||
const rejectingOwnerCtx = {
|
||||
agents: { create: () => Promise.reject(new Error('creation stopped by teardown')) },
|
||||
} as unknown as Context
|
||||
const parentWithDisposingOwner = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
plugin(plugin: (inner: Context) => void) {
|
||||
plugin(rejectingOwnerCtx)
|
||||
return fakeFiber
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithDisposingOwner,
|
||||
}, {})
|
||||
|
||||
let settled = false
|
||||
const disposing = run.dispose().then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(fakeFiber.dispose).toHaveBeenCalledOnce()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inertia = undefined
|
||||
gate.resolve(undefined)
|
||||
await disposing
|
||||
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
})
|
||||
|
||||
it('does not attach an abort listener when provider ownership is already inactive', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
let providerCtx: Context | undefined
|
||||
function provider(inner: Context): void { providerCtx = inner }
|
||||
const providerFiber = await ctx.plugin(provider)
|
||||
await providerFiber.dispose()
|
||||
if (providerCtx === undefined) throw new Error('provider context was not captured')
|
||||
const inactiveProviderCtx = providerCtx
|
||||
|
||||
const controller = new AbortController()
|
||||
const addListener = vi.spyOn(controller.signal, 'addEventListener')
|
||||
expect(() => startInProcessRun(inactiveProviderCtx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent,
|
||||
signal: controller.signal,
|
||||
}, {})).toThrow(/inactive context/)
|
||||
expect(addListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drives a fresh child (no seed) to completion and returns its output', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver child answer')])
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' })
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, {})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('driver child answer')
|
||||
@@ -59,9 +216,25 @@ describe('startInProcessRun', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('snapshots the prompt before asynchronous child creation', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const prompt = [{ type: 'text' as const, text: 'original prompt' }]
|
||||
const run = startInProcessRun(ctx, { prompt, parent }, {})
|
||||
|
||||
prompt[0]!.text = 'mutated after start'
|
||||
prompt.push({ type: 'text', text: 'also injected' })
|
||||
await run.result
|
||||
|
||||
const child = ctx.agents.get(run.id)!
|
||||
const userMessage = child.session.events.find(event => event.type === 'user/message')
|
||||
expect(userMessage?.type === 'user/message' && userMessage.data.content)
|
||||
.toEqual([{ type: 'text', text: 'original prompt' }])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' }))
|
||||
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, {}))
|
||||
.toThrow(SubagentDepthError)
|
||||
})
|
||||
|
||||
@@ -73,7 +246,7 @@ describe('startInProcessRun', () => {
|
||||
parent.send([{ type: 'text', text: 'parent q' }])
|
||||
await parent.whenIdle()
|
||||
const seed = parent.session.events.slice()
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed })
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { seed })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('seeded child reply')
|
||||
|
||||
@@ -29,7 +29,7 @@ export const name = 'subagent-spawn'
|
||||
// output through the child's creation context, whose factory already requires
|
||||
// the tool service. Keeping it out of this backend's inject list preserves the
|
||||
// provider's independent apply timing.
|
||||
export const inject = ['subagents', 'agents']
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
@@ -59,7 +59,7 @@ class SpawnProvider implements SubagentProvider {
|
||||
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
|
||||
// depth, drives the one-shot (including the structured capture when the
|
||||
// request carries an outputSchema), and maps the result.
|
||||
return startInProcessRun(this.ctx, request, { providerName: this.name })
|
||||
return startInProcessRun(this.ctx, request, {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
@@ -160,6 +160,38 @@ describe('dsh-subagent-spawn', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
ctx.on('agent/queued', (agent) => {
|
||||
if (agent.id === run.id) run.cancel('queued-window')
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const result = await run.result
|
||||
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('dispose during async child creation waits for rollback and leaves no orphan', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
const beforeSessions = ctx.sessions.list().length
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
|
||||
// Same tick: the factory has reserved ids and entered its async setup
|
||||
// transaction, but has not published the child yet.
|
||||
await run.dispose()
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted', output: [] })
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => {
|
||||
// 'hang' makes the child's model stream one chunk then wait until aborted.
|
||||
const controller = new AbortController()
|
||||
@@ -206,7 +238,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('inherits the parent cwd into the child session', async () => {
|
||||
const { ctx } = await setup([textResponse('x')])
|
||||
// A parent WITH a cwd (config agents have none, so create one explicitly).
|
||||
const parentHandle = ctx.agents.create({
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('cwd-parent'),
|
||||
sessionId: SessionId('cwd-parent-session'),
|
||||
meta: { cwd: '/tmp/parent-workspace' },
|
||||
@@ -223,7 +255,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('uses request.agentOptions.model when the parent has no model of its own', async () => {
|
||||
const { ctx } = await setup([textResponse('explicit model child')])
|
||||
// A parent with NO model (its own turns would need one supplied per-request).
|
||||
const parentHandle = ctx.agents.create({
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('modelless-parent'),
|
||||
sessionId: SessionId('modelless-parent-session'),
|
||||
agentOptions: {},
|
||||
@@ -305,15 +337,70 @@ describe('dsh-subagent-spawn', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a backend unload during child creation prevents every publication notification', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never run' }], parent,
|
||||
})
|
||||
await fiber.dispose()
|
||||
await run.result.catch(() => undefined)
|
||||
await run.dispose()
|
||||
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('a start racing an already-unloading backend cannot mint a run-owner fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
const parentEffects = parent.ctx.fiber.getEffects().length
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const unloading = fiber.dispose()
|
||||
expect(() => ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never start' }], parent,
|
||||
})).toThrow(/inactive context/)
|
||||
await unloading
|
||||
|
||||
expect(parent.ctx.fiber.getEffects()).toHaveLength(parentEffects)
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in spawn).toBe(false)
|
||||
expect(spawn.name).toBe('subagent-spawn')
|
||||
expect(spawn.inject).toEqual(['subagents', 'agents'])
|
||||
expect(spawn.inject).toEqual(['subagents'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(spawn) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(spawn)
|
||||
expect(unwrapped.name).toBe('subagent-spawn')
|
||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
||||
expect(unwrapped.inject).toEqual(['subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
@@ -369,11 +456,13 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const before = ctx.agents.list().length
|
||||
expect(() => ctx.subagents.start('spawn', {
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['no_such_tool'] },
|
||||
})).toThrow(/unknown tool "no_such_tool"/)
|
||||
})
|
||||
await expect(run.result).rejects.toThrow(/unknown tool "no_such_tool"/)
|
||||
await run.dispose()
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -381,19 +470,53 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('spawning from a DISPOSING parent fails loud with no orphaned child (INACTIVE_EFFECT teaching error)', async () => {
|
||||
const { ctx } = await setup([])
|
||||
// A handle-owned parent we can dispose (config agents dispose with the loop fiber).
|
||||
const parentHandle = ctx.agents.create({
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('doomed-parent'),
|
||||
sessionId: SessionId('doomed-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
await parentHandle.dispose()
|
||||
const before = ctx.agents.list().length
|
||||
expect(() => ctx.subagents.start('spawn', {
|
||||
const sessionsBefore = ctx.sessions.list().length
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent: parentHandle.agent,
|
||||
})).toThrow(/inactive context/)
|
||||
// The freshly created child's disposal was initiated before the rethrow
|
||||
// (fire-and-forget — start() throws synchronously); quiescence follows.
|
||||
await vi.waitFor(() => { expect(ctx.agents.list().length).toBe(before) })
|
||||
})
|
||||
await expect(run.result).rejects.toThrow(/inactive context/)
|
||||
await run.dispose()
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('parent disposal during the child setup transaction prevents every publication notification', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('setup-race-parent'),
|
||||
sessionId: SessionId('setup-race-parent-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never run' }],
|
||||
parent: parentHandle.agent,
|
||||
})
|
||||
// The factory has entered its awaited unpublished setup transaction. Parent
|
||||
// ownership was installed before that await, so disposal wins without an
|
||||
// observer ever seeing the child.
|
||||
await parentHandle.dispose()
|
||||
await expect(run.result).rejects.toThrow(/owner disposed during setup|inactive context/)
|
||||
await run.dispose()
|
||||
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -93,7 +95,7 @@ declare module 'cordis' {
|
||||
* @param info - which provider started which child agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/start'(info: SubagentRunInfo): void
|
||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||
/**
|
||||
* A subagent run settled — emitted when {@link SubagentRun.result}
|
||||
* resolves (any stop reason). Paired with {@link Events['subagent/start']}.
|
||||
@@ -104,7 +106,7 @@ declare module 'cordis' {
|
||||
* @param info - the run identity plus stop reason and final output.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/end'(info: SubagentRunEndInfo): void
|
||||
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,13 +226,32 @@ export class SubagentService extends Service {
|
||||
* @returns the live run (its `result` resolves when the child settles).
|
||||
*/
|
||||
start(name: string, request: SubagentStartRequest): SubagentRun {
|
||||
// Parent is the lifecycle scope identity accepted at start. Never reread it
|
||||
// from the caller-owned request after the provider/result async boundary,
|
||||
// or start/end could be dispatched into different agent scopes.
|
||||
const parent = request.parent
|
||||
const provider = this.providers.get(name)
|
||||
if (!provider) {
|
||||
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
|
||||
}
|
||||
this.assertCapabilities(provider, request)
|
||||
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
||||
|
||||
const run = provider.start(request)
|
||||
// Detach every data field before crossing into a provider. Parent/signal
|
||||
// are live identity capabilities and stay exact; the mutable request record
|
||||
// and its arrays/objects are never retained, so every backend (including an
|
||||
// async out-of-process one) observes the request accepted at start.
|
||||
const accepted: SubagentStartRequest = {
|
||||
prompt: structuredClone(request.prompt),
|
||||
parent,
|
||||
...request.signal !== undefined ? { signal: request.signal } : {},
|
||||
...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {},
|
||||
...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {},
|
||||
...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {},
|
||||
...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {},
|
||||
...request.persona !== undefined ? { persona: request.persona } : {},
|
||||
}
|
||||
const run = provider.start(accepted)
|
||||
// Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}):
|
||||
// the run is already live, so neither a throwing subscriber escaping
|
||||
// `start()` (the caller would never receive the run to dispose it — a leaked
|
||||
@@ -238,7 +259,7 @@ export class SubagentService extends Service {
|
||||
// acceptable. `ctx.emit` halts the dispatch on the first throw, so a single
|
||||
// surrounding try/catch is not enough — each listener is invoked and
|
||||
// contained individually.
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, request.parent)
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
|
||||
// Emit `subagent/end` when the run settles. The result promise does not
|
||||
// reject on a child-level failure (it resolves with stopReason 'error'),
|
||||
// so a rejection here is an infrastructure fault — surface its stop reason
|
||||
@@ -268,9 +289,9 @@ export class SubagentService extends Service {
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
|
||||
}
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, request.parent)
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, parent)
|
||||
},
|
||||
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, request.parent) },
|
||||
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent) },
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
@@ -120,9 +120,11 @@ export interface SubagentResult {
|
||||
/** The child's final assistant output (the last assistant message's content). */
|
||||
output: ContentBlock[]
|
||||
/**
|
||||
* The structured result, present IFF the request carried an `outputSchema`
|
||||
* AND the provider honored it. Shape is validated against the request schema
|
||||
* by the provider; `unknown` here because the seam is schema-agnostic.
|
||||
* The structured result after a requested `outputSchema` was successfully
|
||||
* satisfied. Requesting a schema does not guarantee presence: a provider can
|
||||
* end with `stopReason: 'error'` when the child fails or finishes without a
|
||||
* valid capture. Shape is validated against the request schema by the
|
||||
* provider; `unknown` here because the seam is schema-agnostic.
|
||||
*/
|
||||
structured?: unknown
|
||||
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentService, {
|
||||
SubagentError,
|
||||
type SubagentCapabilities,
|
||||
@@ -227,6 +228,45 @@ describe('SubagentService', () => {
|
||||
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
|
||||
})
|
||||
|
||||
it('pins start and end to the parent accepted at start despite caller mutation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const gate = Promise.withResolvers<SubagentResult>()
|
||||
let acceptedRequest: SubagentStartRequest | undefined
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'deferred',
|
||||
capabilities: NO_CAPS,
|
||||
inheritsParentContext: false,
|
||||
start: (accepted) => {
|
||||
acceptedRequest = accepted
|
||||
return {
|
||||
id: AgentId('deferred-child'),
|
||||
result: gate.promise,
|
||||
cancel() {},
|
||||
async dispose() {},
|
||||
}
|
||||
},
|
||||
})
|
||||
const accepted = fakeParent('accepted-parent')
|
||||
const replacement = fakeParent('replacement-parent')
|
||||
const keys: unknown[] = []
|
||||
ctx.on('subagent/start', function () { keys.push(carrierKeyOf(this)) })
|
||||
ctx.on('subagent/end', function () { keys.push(carrierKeyOf(this)) })
|
||||
const request = baseRequest({ parent: accepted })
|
||||
|
||||
const run = ctx.subagents.start('deferred', request)
|
||||
request.parent = replacement
|
||||
request.prompt[0] = { type: 'text', text: 'mutated prompt' }
|
||||
expect(acceptedRequest?.parent).toBe(accepted)
|
||||
expect(acceptedRequest?.prompt).toEqual([{ type: 'text', text: 'do a thing' }])
|
||||
expect(acceptedRequest?.prompt).not.toBe(request.prompt)
|
||||
gate.resolve({ output: [], stopReason: 'completed' })
|
||||
await run.result
|
||||
await Promise.resolve()
|
||||
|
||||
expect(keys).toEqual([accepted, accepted])
|
||||
})
|
||||
|
||||
it('carries lastAssistantMessage (the child output) onto the end event', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
|
||||
Reference in New Issue
Block a user