feat(subagent): persona + toolFilter become real; structured runtime collapses to scoped registrations
SubagentStartRequest gains persona (capability-gated like toolFilter); the
in-process driver composes the child's scoped world in the factory's setup
window — persona as a scoped shadowing deployment:persona section,
toolFilter as a scoped tools.restrict() (loud unknown-name validation),
outputSchema as the scoped structured runtime. spawn/fork now advertise
every start-time capability; ACP stays all-false. A parent-scope teardown
effect links each child to its parent through the memoized handle, so a
disposed parent reaches its whole subtree even if the delegating tool's
finally never runs; subagent/start|end dispatch in the delegating parent's
scope.
structured.ts loses the placeholder schema, the final-assembly swap/strip,
the refcounted root runtime, and the WeakMap state: each child registers
its OWN capture tool (real schema), instruction section, and enforcement
listeners on child.ctx, riding the child's fiber. The commit listener is
call-keyed (a stale stage from a short-circuited post-execute chain is
dropped, never promoted on a later call), and one scoped prepend re-assert
listener preserves the final-assembly guarantee against a stripping global
listener.
tool-subagent gains persona/toolFilter/maxDepth passthrough config —
deny-listing the delegation tool (or maxDepth) is how a deployment bounds
recursion; the omitted-toolFilter schema key is forced absent (a
materialized {} would mean an empty allow-list, i.e. deny-everything).
This commit is contained in:
@@ -89,7 +89,7 @@ type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||
* a request needing any of them before `start` runs).
|
||||
*/
|
||||
class AcpProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
|
||||
// Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary.
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
|
||||
@@ -530,7 +530,7 @@ describe('dsh-subagent-acp', () => {
|
||||
it('advertises no start-time capabilities (out-of-process child)', async () => {
|
||||
const ctx = await setup()
|
||||
const provider = ctx.subagents.getProvider('acp')!
|
||||
expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false })
|
||||
expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
|
||||
@@ -64,11 +64,11 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] {
|
||||
|
||||
/**
|
||||
* The fork provider. Supports `depthLimit` and `outputSchema` (via the shared
|
||||
* in-process structured runtime); NOT `toolFilter` this cut (the service
|
||||
* rejects a request needing it before `start` runs).
|
||||
* in-process structured runtime), plus `toolFilter`/`persona` (scoped
|
||||
* restrict() and a scoped shadowing persona section).
|
||||
*/
|
||||
class ForkProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
|
||||
// Context contract: a forked child IS seeded with the parent's completed-turn prefix.
|
||||
readonly inheritsParentContext = true
|
||||
|
||||
|
||||
@@ -182,9 +182,9 @@ describe('dsh-subagent-fork', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('advertises depthLimit and outputSchema but not toolFilter', async () => {
|
||||
it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => {
|
||||
const { ctx } = await setup([])
|
||||
expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
|
||||
expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
|
||||
@@ -21,13 +21,13 @@ 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'
|
||||
import {
|
||||
acquireStructuredRuntime,
|
||||
type StructuredAcquisition,
|
||||
attachStructuredRuntime,
|
||||
type StructuredAttachment,
|
||||
} from './structured.ts'
|
||||
|
||||
// The runtime itself (acquire/attach/release) is package-internal: runs
|
||||
// acquire it inside startInProcessRun, and no other package drives it. Only
|
||||
// the model-facing vocabulary is public.
|
||||
// The runtime itself (attach) is package-internal: runs attach it inside
|
||||
// startInProcessRun's setup window, and no other package drives it. Only the
|
||||
// model-facing vocabulary is public.
|
||||
export {
|
||||
STRUCTURED_OUTPUT_TOOL,
|
||||
STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
@@ -143,21 +143,36 @@ export function startInProcessRun(
|
||||
const seedLength = options.seed?.length ?? 0
|
||||
const parentHeader = request.parent.session.header
|
||||
// Inherit the parent's model by default (a child with no model cannot run);
|
||||
// an explicit `request.agentOptions.model` overrides it. The persona needs
|
||||
// no inheritance: the deployment persona is a context-wide prompt section,
|
||||
// so parent and child render the same one. A structured run's
|
||||
// structured_output instruction is NOT prompt state either — the structured
|
||||
// runtime's final-request listener appends it per request (see structured.ts).
|
||||
// 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 } : {},
|
||||
...request.agentOptions,
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
|
||||
// The structured runtime is held for the WHOLE run (acquired before the child
|
||||
// exists, released when the result settles), so a backend hot-reload mid-run
|
||||
// cannot unregister the capture tool out from under this live child.
|
||||
const structured: StructuredAcquisition | undefined = schema !== undefined ? acquireStructuredRuntime(ctx) : undefined
|
||||
// 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):
|
||||
// - 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 (request.toolFilter !== undefined) {
|
||||
childCtx.tools.restrict(request.toolFilter)
|
||||
}
|
||||
if (schema !== undefined) {
|
||||
structured = attachStructuredRuntime(childCtx, schema)
|
||||
}
|
||||
}
|
||||
|
||||
const handle: AgentHandle = ctx.agents.create({
|
||||
agentId: childId,
|
||||
@@ -171,9 +186,26 @@ export function startInProcessRun(
|
||||
},
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
agentOptions,
|
||||
setup,
|
||||
})
|
||||
const child = handle.agent
|
||||
if (structured && schema !== undefined) structured.attach(child, schema)
|
||||
|
||||
// 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) {
|
||||
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).
|
||||
@@ -205,13 +237,9 @@ export function startInProcessRun(
|
||||
// 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(child) } : undefined)
|
||||
return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
|
||||
} finally {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
if (structured) {
|
||||
structured.detach(child)
|
||||
structured.release()
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -223,6 +251,11 @@ export function startInProcessRun(
|
||||
},
|
||||
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()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,63 +1,49 @@
|
||||
/**
|
||||
* Structured-output support for the in-process subagent backends: the mechanism
|
||||
* behind `SubagentStartRequest.outputSchema` for children that run as agents on
|
||||
* the same context.
|
||||
* Structured-output support for the in-process subagent backends: the
|
||||
* mechanism behind `SubagentStartRequest.outputSchema` for children that run
|
||||
* as agents on the same context.
|
||||
*
|
||||
* The model-facing surface is one globally registered `structured_output` tool
|
||||
* whose REGISTERED parameters are a placeholder — the real schema is per run.
|
||||
* Because the tool registry and prompt assembly are context-global while
|
||||
* schemas differ per child (two concurrent structured runs may carry different
|
||||
* schemas), per-agent shaping happens on the `system-prompt/assemble`
|
||||
* waterfall with a `prepend: true` listener that post-processes `await next()`
|
||||
* — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or
|
||||
* replaced, the assembly the loop renders never carries `structured_output`
|
||||
* for an agent without a structured run, and for one that has it always
|
||||
* carries the run's OWN schema plus a trailing
|
||||
* {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the
|
||||
* tool). The loop logs what the assembly produced as the request header, so
|
||||
* the injection is a reconstructable fact of the session log, never a
|
||||
* wire-only mutation (the reconstructability RFC).
|
||||
* (Cooperative mutate-then-`next()` would not survive a downstream listener
|
||||
* returning a replacement assembly — see the waterfall composition caveat in
|
||||
* docs/architecture.md.)
|
||||
* Everything is a SCOPED registration on the child agent's context
|
||||
* (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool
|
||||
* carries the run's REAL schema as its registered parameters (each child sees
|
||||
* exactly its own schema — two concurrent structured runs never interact), the
|
||||
* demand instruction is an ordinary order-190 scoped section, and the
|
||||
* enforcement listeners fire only for this child (scope-filtered dispatch).
|
||||
* Registration lifetime rides the child's fiber, so a backend hot-reload
|
||||
* mid-run cannot unregister the capture tool out from under a live child, and
|
||||
* a disposed child leaves no residue — no placeholder schema, no
|
||||
* strip-for-everyone-else, no refcounted global runtime, no `WeakMap` state.
|
||||
*
|
||||
* FIXME: the whole enforcement dance above exists because the tool registry
|
||||
* and prompt assembly are context-global. If they become per-agent or
|
||||
* per-session scoped, a structured run just registers its own schema'd tool on
|
||||
* the child's scope and this module reduces to the capture tool plus the
|
||||
* turn-stop — no placeholder, no final-assembly swap, no strip-for-everyone-
|
||||
* else, no global-registration lifetime dance.
|
||||
* Four listeners enforce the contract:
|
||||
*
|
||||
* A companion `agent/turn-continuation` listener stops a child's turn once its
|
||||
* output is captured — without it, the loop's default "had tool calls ⇒
|
||||
* continue" buys a wasted extra model step per structured child. It is also
|
||||
* `prepend: true`: the veto must run before any earlier-registered listener
|
||||
* that could short-circuit the chain into a forced continue. A third listener
|
||||
* closes the within-step window the continuation veto cannot: a
|
||||
* `tools/pre-execute` deny for any call arriving after the agent's capture, so
|
||||
* a response that lists `structured_output` before further tool calls cannot
|
||||
* run side effects after the final answer was accepted. A fourth,
|
||||
* `tools/post-execute`, is the capture COMMIT: the tool body only stages the
|
||||
* validated value, and it becomes the run's captured result only when the
|
||||
* final post-execute decision accepts the call — a blocking hook downstream
|
||||
* yields `isError` in the log, and the run must not report success for it.
|
||||
*
|
||||
* Lifetime is refcounted by structured RUNS: each acquires from start to
|
||||
* settle, so the registrations exist exactly while at least one structured
|
||||
* child is live — a plain deployment that never passes `outputSchema` carries
|
||||
* no always-on global state, and a backend hot-reload mid-run cannot
|
||||
* unregister the capture tool out from under a live child (the run holds its
|
||||
* own acquisition). Registrations land on the ROOT context and the refcount
|
||||
* disposes them when the last run settles; the next structured run
|
||||
* re-registers them.
|
||||
* - `system-prompt/assemble` (prepend, scoped): FINAL-ASSEMBLY re-assert —
|
||||
* whatever downstream listeners mutated or replaced, the child's assembly
|
||||
* always carries its capture tool and the trailing instruction section. The
|
||||
* registry already contributes both; this outermost wrapper preserves the
|
||||
* guarantee against a (global) listener that strips or replaces the
|
||||
* assembly. 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 CALL ID; it becomes the run's
|
||||
* captured result only when the final post-execute decision accepts THAT
|
||||
* call. Call-keyed staging closes a stale-stage hole: an outer
|
||||
* short-circuiting post-execute listener can orphan a staged value, and an
|
||||
* un-keyed commit would then promote it on a LATER call's acceptance —
|
||||
* reporting success for a value the model saw fail.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess/structured
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { CallId, 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 { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
@@ -66,247 +52,141 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f
|
||||
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
|
||||
|
||||
/**
|
||||
* The instruction the assembly listener appends to a structured child's
|
||||
* system prompt as a trailing section on every assembly. Per-assembly state,
|
||||
* NOT agent prompt state: `AgentOptions` has no prompt field (the persona is
|
||||
* deployment config on the system-prompt plugin), so the same final-assembly
|
||||
* enforcement that injects the schema'd tool carries the instruction that
|
||||
* demands calling it.
|
||||
* The instruction registered as the child's trailing (order-190, the end of
|
||||
* the tool-guidance band) scoped prompt section: the demand travels with the
|
||||
* tool, as ordinary prompt state of exactly one agent.
|
||||
*/
|
||||
export const STRUCTURED_OUTPUT_INSTRUCTION
|
||||
= 'When you have your final answer, you MUST report it by calling the '
|
||||
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
|
||||
+ 'Do not finish with a plain text answer: only the tool call counts as your result.'
|
||||
|
||||
/** One structured run's state: the schema to enforce and the captured value, once recorded. */
|
||||
interface RunState {
|
||||
readonly schema: StructuredOutputSchema
|
||||
/** One structured run's live handle: read the captured value once the child settles. */
|
||||
export interface StructuredAttachment {
|
||||
/**
|
||||
* A validated value awaiting the post-execute verdict on ITS OWN call. Set
|
||||
* by the capture tool's body, promoted to {@link RunState.captured} only
|
||||
* when the final `tools/post-execute` decision accepts the call — a
|
||||
* downstream block turns the logged result into `isError`, and a value
|
||||
* committed at body time would let the run report success for a call the
|
||||
* model saw fail.
|
||||
* The captured value, once the child called the tool with valid arguments
|
||||
* and the final post-execute decision accepted that call.
|
||||
* @returns the committed value, or undefined while none was accepted.
|
||||
*/
|
||||
pending?: { value: unknown }
|
||||
captured?: { value: unknown }
|
||||
}
|
||||
|
||||
/** The per-root-context runtime: run states plus the shared registrations. */
|
||||
interface StructuredRuntime {
|
||||
refs: number
|
||||
readonly states: WeakMap<Agent, RunState>
|
||||
readonly disposers: (() => void)[]
|
||||
}
|
||||
|
||||
/** One root context ⇒ one runtime (multi-app test isolation). */
|
||||
const runtimes = new WeakMap<Context, StructuredRuntime>()
|
||||
|
||||
/**
|
||||
* One holder's handle on the shared structured runtime. `release()` is
|
||||
* idempotent per acquisition; the runtime's registrations are disposed when the
|
||||
* LAST holder (backend plugin or live run) releases.
|
||||
*/
|
||||
export interface StructuredAcquisition {
|
||||
/** Enforce `schema` on `agent`'s requests and start capturing its `structured_output` call. */
|
||||
attach(agent: Agent, schema: StructuredOutputSchema): void
|
||||
/** The captured value, once the child called the tool with valid arguments. */
|
||||
captured(agent: Agent): { value: unknown } | undefined
|
||||
/** Stop enforcing/capturing for `agent` (WeakMap-backed; safe to call twice). */
|
||||
detach(agent: Agent): void
|
||||
/** Drop this holder's reference (idempotent); the last release unregisters everything. */
|
||||
release(): void
|
||||
captured(): { value: unknown } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the per-root-context structured runtime, registering the capture tool
|
||||
* and the runtime's listeners on the FIRST acquisition. See the module doc
|
||||
* for the enforcement and lifetime design.
|
||||
* @param ctx - any context of the app; the runtime keys off `ctx.root`.
|
||||
* @returns this holder's handle (attach/captured/detach + idempotent release).
|
||||
* 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
|
||||
* 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).
|
||||
* @param schema - the isolation-cloned, already-asserted schema subset to
|
||||
* enforce (see `assertSupportedOutputSchema` in dsh-tools).
|
||||
* @returns the attachment handle (read `captured()` after the child settles).
|
||||
*/
|
||||
export function acquireStructuredRuntime(ctx: Context): StructuredAcquisition {
|
||||
const root: Context = ctx.root
|
||||
let runtime = runtimes.get(root)
|
||||
if (!runtime) {
|
||||
runtime = { refs: 0, states: new WeakMap(), disposers: [] }
|
||||
runtimes.set(root, runtime)
|
||||
registerRuntime(root, runtime)
|
||||
}
|
||||
runtime.refs += 1
|
||||
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
|
||||
/** A validated value staged by the capture tool body, awaiting ITS OWN call's post-execute verdict. */
|
||||
let pending: { callId: CallId; value: unknown } | undefined
|
||||
let captured: { value: unknown } | undefined
|
||||
|
||||
let released = false
|
||||
return {
|
||||
attach(agent: Agent, schema: StructuredOutputSchema): void {
|
||||
runtime.states.set(agent, { schema })
|
||||
},
|
||||
captured(agent: Agent): { value: unknown } | undefined {
|
||||
return runtime.states.get(agent)?.captured
|
||||
},
|
||||
detach(agent: Agent): void {
|
||||
runtime.states.delete(agent)
|
||||
},
|
||||
release(): void {
|
||||
if (released) return
|
||||
released = true
|
||||
runtime.refs -= 1
|
||||
if (runtime.refs > 0) return
|
||||
runtimes.delete(root)
|
||||
for (const dispose of runtime.disposers.splice(0)) dispose()
|
||||
},
|
||||
const schemaEntry: ToolSchema = {
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
description:
|
||||
'Report your final structured result. Call this exactly once, when your answer is complete; '
|
||||
+ 'the arguments must match this tool\'s parameter schema exactly.',
|
||||
// ToolSchema.parameters is the wire-level JSON Schema object; the
|
||||
// asserted subset type is structurally exactly that.
|
||||
parameters: schema as unknown as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the capture tool + the two listeners on the root context (first acquire). */
|
||||
function registerRuntime(root: Context, runtime: StructuredRuntime): void {
|
||||
// The registered parameters are a PLACEHOLDER: the request listener below
|
||||
// swaps in the run's real schema per child, and strips the tool entirely for
|
||||
// every agent without a structured run — so this shape is never model-visible.
|
||||
//
|
||||
// Registration does NOT ride on the acquiring backend's plugin-level
|
||||
// `inject`: a backend that waited on `tools` would apply later than it did
|
||||
// before this module existed, shifting when its PROVIDER registers — and the
|
||||
// delegation tool mirrors provider lifecycle, so that shift would reorder
|
||||
// the model-visible tool list of every existing prompt. Instead the capture
|
||||
// tool registers synchronously when `tools` is already live (the common
|
||||
// case), and through a scoped inject fiber when the Loader happens to start
|
||||
// the backend first. Either way the registration lands on root and is
|
||||
// disposed by the runtime's refcount; disposing the fiber also covers the
|
||||
// never-activated case.
|
||||
let disposeTool: (() => void) | undefined
|
||||
const registerCapture = (tools: Context['tools']): void => {
|
||||
disposeTool = tools.register({
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
description:
|
||||
'Report your final structured result. Call this exactly once, when your answer is complete; '
|
||||
+ 'the arguments must match this tool\'s parameter schema exactly.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||
const state = exec.agent ? runtime.states.get(exec.agent) : undefined
|
||||
if (!state) {
|
||||
// Reachable only if a non-structured agent somehow calls the tool (the
|
||||
// request listener strips it, so the model never sees it) — fail loud
|
||||
// rather than capture into nowhere.
|
||||
throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`)
|
||||
}
|
||||
const violations = validateStructuredValue(state.schema, args)
|
||||
// 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: the body only STAGES the value; the post-execute
|
||||
// listener below promotes it once the final decision accepts the call.
|
||||
state.pending = { value: args }
|
||||
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
||||
},
|
||||
})
|
||||
}
|
||||
const liveTools = root.get('tools')
|
||||
const toolsFiber = liveTools ? undefined : root.inject(['tools'], (childCtx: Context) => {
|
||||
registerCapture(childCtx.root.tools)
|
||||
})
|
||||
if (liveTools) registerCapture(liveTools)
|
||||
runtime.disposers.push(() => {
|
||||
disposeTool?.()
|
||||
void toolsFiber?.dispose()
|
||||
childCtx.tools.register({
|
||||
...schemaEntry,
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||
const violations = validateStructuredValue(schema, args)
|
||||
// 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 CALL: the body only stages; the
|
||||
// post-execute listener promotes exactly this call's entry when the
|
||||
// final decision accepts it.
|
||||
pending = { callId: exec.callId, value: args }
|
||||
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
||||
},
|
||||
})
|
||||
|
||||
// FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST
|
||||
// wrapper): post-process whatever the downstream listeners and the registry
|
||||
// produced, so a downstream listener returning a replacement assembly cannot
|
||||
// leak the tool to other agents or erase the child's schema. The loop logs
|
||||
// the rendered assembly as the step's request header, so the swap is
|
||||
// reconstructable log state, never a wire-only mutation.
|
||||
runtime.disposers.push(root.on('system-prompt/assemble', async function (
|
||||
this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>,
|
||||
childCtx.systemPrompt.section({
|
||||
name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
|
||||
order: 190,
|
||||
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
})
|
||||
|
||||
// FINAL-ASSEMBLY re-assert (prepend = outermost): scoped dispatch means this
|
||||
// fires only for the child's assemblies; `await next()` returns whatever the
|
||||
// downstream chain (and any replacement assembly) produced, and the capture
|
||||
// tool + instruction are re-asserted onto it if anything stripped them.
|
||||
childCtx.on('system-prompt/assemble', async function (
|
||||
this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise<PromptAssembly>,
|
||||
): Promise<PromptAssembly> {
|
||||
const final = await next()
|
||||
const state = context.agent ? runtime.states.get(context.agent) : undefined
|
||||
if (state) {
|
||||
const schemaEntry: ToolSchema = {
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
description:
|
||||
'Report your final structured result. Call this exactly once, when your answer is complete; '
|
||||
+ 'the arguments must match this tool\'s parameter schema exactly.',
|
||||
// ToolSchema.parameters is the wire-level JSON Schema object; the
|
||||
// asserted subset type is structurally exactly that.
|
||||
parameters: state.schema as unknown as Record<string, unknown>,
|
||||
}
|
||||
final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
|
||||
// The demand travels WITH the tool: a trailing section in the
|
||||
// tool-guidance order band, appended after next() so it renders last
|
||||
// (renderPrompt joins in array order).
|
||||
if (!final.tools.some(tool => tool.name === STRUCTURED_OUTPUT_TOOL)) {
|
||||
final.tools = [...final.tools, { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }]
|
||||
}
|
||||
if (!final.sections.some(section => section.name === `tool:${STRUCTURED_OUTPUT_TOOL}`)) {
|
||||
final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
|
||||
return final
|
||||
}
|
||||
// No structured run: strip the placeholder so it is never model-visible.
|
||||
// An empty tools array canonicalizes to an absent header/wire field
|
||||
// (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here.
|
||||
final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
|
||||
return final
|
||||
}, { prepend: true }))
|
||||
}, { prepend: true })
|
||||
|
||||
// Stop a structured child's turn once its output is captured: the default
|
||||
// "had tool calls ⇒ continue" would otherwise buy a wasted extra model step
|
||||
// after every successful capture. `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 already finished.
|
||||
runtime.disposers.push(root.on('agent/turn-continuation', function (
|
||||
this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
|
||||
// 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 (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' })
|
||||
if (captured) return Promise.resolve({ action: 'stop' })
|
||||
return next()
|
||||
}, { prepend: true }))
|
||||
}, { prepend: true })
|
||||
|
||||
// The capture COMMIT: promote the staged value only when the final
|
||||
// post-execute decision accepts the call. The capture tool's body cannot
|
||||
// decide — `tools/post-execute` runs after it, and a blocking listener (a
|
||||
// PostToolUse hook) turns the logged result into `isError` feedback; a value
|
||||
// committed at body time would make readResult report `structured` success
|
||||
// for a call whose result the model and session log saw fail. `prepend:
|
||||
// true` = outermost at registration time, so `await next()` returns the
|
||||
// COMPOSED downstream decision — the same final verdict the registry maps
|
||||
// onto the result. (A later-registered outer listener that blocks without
|
||||
// delegating skips this commit entirely: the staged value is dropped and the
|
||||
// run errors — failure-safe in the same direction.) The staging slot clears
|
||||
// on every path, including a rejecting downstream listener.
|
||||
runtime.disposers.push(root.on('tools/post-execute', async function (
|
||||
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
|
||||
): Promise<PostToolDecision> {
|
||||
const state = exec.agent ? runtime.states.get(exec.agent) : undefined
|
||||
if (!state || exec.name !== STRUCTURED_OUTPUT_TOOL || state.pending === undefined) return next()
|
||||
const pending = state.pending
|
||||
try {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'accept') state.captured = pending
|
||||
return decision
|
||||
} finally {
|
||||
delete state.pending
|
||||
}
|
||||
}, { prepend: true }))
|
||||
|
||||
// Terminal means terminal WITHIN the step, not only at its end: the
|
||||
// turn-continuation veto above runs after every call in the current model
|
||||
// response has executed, so a response that puts `structured_output` before
|
||||
// further tool calls would still perform those side effects after the final
|
||||
// answer was accepted. Deny every later call for a captured agent at the
|
||||
// allow/deny gate — dispatch is skipped and the model sees an `isError`
|
||||
// result naming the contract. 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 call. `prepend: true` for the
|
||||
// same reason as the continuation veto: no earlier-registered allow may
|
||||
// short-circuit past the terminal contract.
|
||||
runtime.disposers.push(root.on('tools/pre-execute', function (
|
||||
// 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 (exec.agent && runtime.states.get(exec.agent)?.captured) {
|
||||
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 }))
|
||||
}, { prepend: true })
|
||||
|
||||
// The capture COMMIT: promote the staged value only when the final
|
||||
// post-execute decision accepts THE SAME CALL that staged it. The staging
|
||||
// slot clears on every path for that call; a stale entry from an outer
|
||||
// short-circuited chain (its verdict never reached us) is dropped when any
|
||||
// later call reaches the commit, never promoted.
|
||||
childCtx.on('tools/post-execute', async function (
|
||||
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
|
||||
): Promise<PostToolDecision> {
|
||||
if (exec.name !== STRUCTURED_OUTPUT_TOOL || pending === undefined) return next()
|
||||
if (pending.callId !== exec.callId) {
|
||||
// A stale stage from a different call: an outer listener short-circuited
|
||||
// that call's post-execute chain past this commit, so its verdict never
|
||||
// reached us and the value must never be promoted — drop it.
|
||||
pending = undefined
|
||||
return next()
|
||||
}
|
||||
const staged = pending
|
||||
try {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'accept') captured = { value: staged.value }
|
||||
return decision
|
||||
} finally {
|
||||
if (pending === staged) pending = undefined
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
return { captured: () => captured }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
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'
|
||||
@@ -13,7 +13,6 @@ import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
import {
|
||||
acquireStructuredRuntime,
|
||||
STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
STRUCTURED_OUTPUT_TOOL,
|
||||
} from '../src/structured.ts'
|
||||
@@ -47,7 +46,7 @@ async function setup(script: Script) {
|
||||
await ctx.plugin(SubagentService)
|
||||
const disposeProvider = ctx.subagents.registerProvider({
|
||||
name: 'spawn',
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false },
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
|
||||
})
|
||||
@@ -175,31 +174,20 @@ describe('in-process structured output', () => {
|
||||
})
|
||||
|
||||
it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// Registered BEFORE the structured runtime exists — without prepend, this
|
||||
// goal-style listener would decide the turn first (returning WITHOUT
|
||||
// calling next()) and the veto would never run.
|
||||
// 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.
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
])
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
const agent = { id: AgentId('structured-child') } as unknown as Agent
|
||||
acquisition.attach(agent, SCHEMA)
|
||||
const captured = await ctx.tools.execute({
|
||||
callId: 'call-1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 1 },
|
||||
agent,
|
||||
})
|
||||
expect(captured.isError).toBeFalsy()
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/turn-continuation', agent, 1,
|
||||
{ action: 'continue' },
|
||||
() => Promise.resolve<ContinuationDecision>({ action: 'continue' }),
|
||||
)
|
||||
expect(decision).toEqual({ action: 'stop' })
|
||||
acquisition.detach(agent)
|
||||
acquisition.release()
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
|
||||
@@ -357,20 +345,14 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
describe('final-request enforcement (the prepend agent/request listener)', () => {
|
||||
it('a plain agent assembling while the runtime is LIVE gets the placeholder stripped', async () => {
|
||||
// Run-scoped acquisition means a plain deployment never registers the
|
||||
// tool at all; the strip branch exists for the CONCURRENT case — a plain
|
||||
// agent taking a turn while some structured child holds the runtime open.
|
||||
describe('scoped registration (each child owns its capture tool)', () => {
|
||||
it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
|
||||
const hold = acquireStructuredRuntime(ctx)
|
||||
parent.send([{ type: 'text', text: 'hello' }])
|
||||
await parent.whenIdle()
|
||||
// The placeholder IS in the registry during this turn; the assembly the
|
||||
// loop rendered must not carry it for an agent without a structured run.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
// Scoped registration: the global view has no capture tool, ever.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
hold.release()
|
||||
})
|
||||
|
||||
it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
|
||||
@@ -430,16 +412,21 @@ describe('in-process structured output', () => {
|
||||
await runB.dispose()
|
||||
})
|
||||
|
||||
it('wins against a downstream listener that REPLACES the assembly object', async () => {
|
||||
it('the re-assert wins against a downstream listener that REPLACES the assembly object', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
])
|
||||
// A downstream (non-prepend) listener that returns a brand-new assembly —
|
||||
// the composition caveat that erases cooperative mutations. Registered
|
||||
// AFTER the runtime's prepend listener, so it runs INSIDE it.
|
||||
// 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.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const replaced = await next()
|
||||
return { sections: [...replaced.sections], tools: [...replaced.tools], variables: { ...replaced.variables } }
|
||||
return {
|
||||
sections: replaced.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`),
|
||||
tools: replaced.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL),
|
||||
variables: { ...replaced.variables },
|
||||
}
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
@@ -447,140 +434,41 @@ describe('in-process structured output', () => {
|
||||
const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.parameters).toEqual(SCHEMA)
|
||||
const system = adapter.requests[0]!.system ?? ''
|
||||
expect(system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
|
||||
const { parent, adapter } = await setup([
|
||||
// The registry contributes the placeholder via prompt assembly, so
|
||||
// tools is an array in the raw request — but after stripping the
|
||||
// placeholder (its ONLY entry), the field must not be re-added as a
|
||||
// different shape.
|
||||
textResponse('plain'),
|
||||
])
|
||||
const { parent, adapter } = await setup([textResponse('plain')])
|
||||
parent.send([{ type: 'text', text: 'q' }])
|
||||
await parent.whenIdle()
|
||||
const request = adapter.requests[0]!
|
||||
expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
expect(request.tools).toBeUndefined()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
it('shapes a bare assembly on the waterfall: no-agent context strips the placeholder; a structured agent gains schema + trailing instruction section', async () => {
|
||||
// Drive ctx.systemPrompt.assemble directly — the enforcement listener
|
||||
// must tolerate a context with NO agent (a bare diagnostic assemble)
|
||||
// and shape a structured agent's assembly on the same path the loop
|
||||
// renders and logs as the request header.
|
||||
const { ctx, parent } = await setup([])
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
// Bare assemble WHILE the runtime is live: the no-agent branch must
|
||||
// strip the registered placeholder (before the acquisition there is
|
||||
// nothing to strip — run-scoped registration).
|
||||
const bare = await ctx.systemPrompt.assemble({})
|
||||
expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
|
||||
acquisition.attach(parent, SCHEMA)
|
||||
const shaped = await ctx.systemPrompt.assemble({ agent: parent })
|
||||
expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
expect(shaped.tools.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters).toEqual(SCHEMA)
|
||||
// The demand travels with the tool: the instruction renders LAST
|
||||
// (appended post-next(); renderPrompt joins in array order).
|
||||
expect(shaped.sections.at(-1)).toMatchObject({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, text: STRUCTURED_OUTPUT_INSTRUCTION })
|
||||
acquisition.detach(parent)
|
||||
acquisition.release()
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime lifetime (refcount: live structured runs)', () => {
|
||||
it('the runtime exists exactly while structured runs are live: nothing before, nothing after', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => {
|
||||
const { ctx, parent, disposeProvider } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
|
||||
])
|
||||
// No always-on global state: a context that has run no structured child
|
||||
// carries no capture tool.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// A backend hot-reload mid-run must not unregister the capture tool out
|
||||
// from under the live child: the registration rides the CHILD's fiber.
|
||||
disposeProvider()
|
||||
const result = await run.result
|
||||
// The capture succeeded — the registrations existed while the run lived.
|
||||
expect(result.structured).toEqual({ answer: 4 })
|
||||
// The run's settle released the last acquisition.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('concurrent structured runs share one runtime; the last settle disposes it', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 2 }),
|
||||
])
|
||||
const first = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const second = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const [a, b] = await Promise.all([first.result, second.result])
|
||||
expect([a.structured, b.structured].sort()).toEqual([{ answer: 1 }, { answer: 2 }].sort())
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
await first.dispose()
|
||||
await second.dispose()
|
||||
})
|
||||
|
||||
it('acquisition release is idempotent (double release cannot underflow the refcount)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const first = acquireStructuredRuntime(ctx)
|
||||
const second = acquireStructuredRuntime(ctx)
|
||||
first.release()
|
||||
first.release()
|
||||
// The second holder still keeps the tool registered.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
second.release()
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registers the capture tool through the scoped fiber when tools loads after the acquisition', async () => {
|
||||
// The Loader starts sibling plugins concurrently, so a backend can
|
||||
// acquire the runtime before dsh-tools has applied. The capture tool
|
||||
// must then register as soon as `tools` exists — via the inject fiber,
|
||||
// not by deferring the backend (which would reorder the prompt's tools).
|
||||
const ctx = new Context()
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// Fiber activation completes asynchronously after the service appears.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
acquisition.release()
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('releasing before tools ever loads disposes the pending fiber without registering', async () => {
|
||||
const ctx = new Context()
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
acquisition.release()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
// The disposed fiber never fires: nothing registers after the fact.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('attach/captured/detach manage per-agent state through the acquisition surface', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
expect(acquisition.captured(parent)).toBeUndefined()
|
||||
acquisition.attach(parent, SCHEMA)
|
||||
expect(acquisition.captured(parent)).toBeUndefined()
|
||||
acquisition.detach(parent)
|
||||
acquisition.detach(parent)
|
||||
acquisition.release()
|
||||
// That manual acquisition was the ONLY holder - release disposes.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
// Child disposed ⇒ its scoped registrations are gone.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => {
|
||||
it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// Hold the runtime open (run-scoped: nothing is registered otherwise) so
|
||||
// the call reaches the capture tool's own fail-loud guard, not UNKNOWN_TOOL.
|
||||
const hold = acquireStructuredRuntime(ctx)
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'x' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
@@ -588,19 +476,17 @@ describe('in-process structured output', () => {
|
||||
agent: parent,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(JSON.stringify(result.content)).toContain('only available to subagents')
|
||||
hold.release()
|
||||
expect(result.error?.code).toBe('UNKNOWN_TOOL')
|
||||
})
|
||||
|
||||
it('a structured_output call with NO calling agent at all is an isError', async () => {
|
||||
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const hold = acquireStructuredRuntime(ctx)
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'x' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 1 },
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
hold.release()
|
||||
expect(result.error?.code).toBe('UNKNOWN_TOOL')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,13 +43,14 @@ export const Config: z<Config> = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* The spawn provider. Supports `depthLimit` (it constructs the child, so it can
|
||||
* enforce a recursion cap) and `outputSchema` (via the shared in-process
|
||||
* structured runtime); NOT `toolFilter` in this cut — a request that needs it
|
||||
* is rejected by the service before `start` runs.
|
||||
* The spawn provider. Supports every start-time capability: `depthLimit` (it
|
||||
* constructs the child, so it can enforce a recursion cap), `outputSchema`
|
||||
* (the scoped structured runtime), and `toolFilter`/`persona` (scoped
|
||||
* `restrict()` and a scoped shadowing persona section, applied in the child's
|
||||
* creation window).
|
||||
*/
|
||||
class SpawnProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
|
||||
// Context contract: a spawned child starts fresh — it never sees the parent conversation.
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
|
||||
@@ -241,10 +241,10 @@ describe('dsh-subagent-spawn', () => {
|
||||
await parentHandle.dispose()
|
||||
})
|
||||
|
||||
it('advertises depthLimit and outputSchema but not toolFilter', async () => {
|
||||
it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const provider = ctx.subagents.getProvider('spawn')!
|
||||
expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
|
||||
expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
@@ -316,4 +316,65 @@ describe('dsh-subagent-spawn', () => {
|
||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
describe('persona and toolFilter (the scoped child world)', () => {
|
||||
it('a per-child persona shadows the deployment persona in the child request only', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
textResponse('parent answer'),
|
||||
textResponse('child answer'),
|
||||
])
|
||||
parent.send([{ type: 'text', text: 'hi' }])
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
persona: 'You are the tersest test runner.',
|
||||
})
|
||||
await run.result
|
||||
const childRequest = adapter.requests.at(-1)!
|
||||
expect(childRequest.system).toContain('You are the tersest test runner.')
|
||||
// The parent's earlier request carried no such persona.
|
||||
expect(adapter.requests[0]!.system ?? '').not.toContain('tersest test runner')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('toolFilter hides denied tools from the child prompt AND refuses their execution', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
// The child tries the denied tool anyway, then answers.
|
||||
toolCallResponse('c1', 'forbidden_tool', {}),
|
||||
textResponse('done'),
|
||||
])
|
||||
ctx.tools.register({
|
||||
name: 'forbidden_tool', description: 'global', parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['forbidden_tool'] },
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
// Not advertised…
|
||||
const childRequest = adapter.requests[0]!
|
||||
expect((childRequest.tools ?? []).map(t => t.name)).not.toContain('forbidden_tool')
|
||||
// …and the attempted call executed as UNKNOWN_TOOL (visible in the log).
|
||||
const child = ctx.agents.get(run.id)!
|
||||
const toolResult = child.session.events.find(e => e.type === 'tool/result')!
|
||||
expect(JSON.stringify(toolResult.data)).toContain('unknown tool')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
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', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['no_such_tool'] },
|
||||
})).toThrow(/unknown tool "no_such_tool"/)
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,9 +33,10 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
@@ -223,7 +224,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 })
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, request.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
|
||||
@@ -253,9 +254,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 } : {} })
|
||||
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: 'error' }) },
|
||||
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, request.parent) },
|
||||
)
|
||||
return run
|
||||
}
|
||||
@@ -280,14 +281,22 @@ export class SubagentService extends Service {
|
||||
* listener unwinds the yielded rollback — the same fail-loud register-time
|
||||
* semantics as the system-prompt registries.
|
||||
*/
|
||||
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo): void
|
||||
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo): void
|
||||
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
|
||||
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
|
||||
private emitLifecycle(name: 'subagent/provider-removed', info: string): void
|
||||
private emitLifecycle(
|
||||
name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed',
|
||||
info: SubagentRunInfo | SubagentRunEndInfo | string,
|
||||
parent?: Agent,
|
||||
): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', [name, info])) {
|
||||
// Run lifecycle events dispatch in the DELEGATING PARENT's scope (a
|
||||
// parent-scoped listener observes only its own delegations); the
|
||||
// provider-removed registry notification stays unfiltered. The carrier is
|
||||
// args[0] of the dispatch call, exactly as cordis' own emit spells it.
|
||||
const dispatchArgs: unknown[] = parent === undefined
|
||||
? [name, info]
|
||||
: [scopeTarget(this, parent), name, info]
|
||||
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
|
||||
try {
|
||||
callback(info)
|
||||
} catch (error: unknown) {
|
||||
@@ -306,6 +315,7 @@ export class SubagentService extends Service {
|
||||
{ when: request.outputSchema !== undefined, cap: 'outputSchema' },
|
||||
{ when: request.maxDepth !== undefined, cap: 'depthLimit' },
|
||||
{ when: request.toolFilter !== undefined, cap: 'toolFilter' },
|
||||
{ when: request.persona !== undefined, cap: 'persona' },
|
||||
]
|
||||
for (const { when, cap } of needs) {
|
||||
if (when && !provider.capabilities[cap]) {
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface SubagentCapabilities {
|
||||
depthLimit: boolean
|
||||
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
|
||||
toolFilter: boolean
|
||||
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
|
||||
persona: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,9 +75,20 @@ export interface SubagentStartRequest {
|
||||
maxDepth?: number
|
||||
/**
|
||||
* Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter};
|
||||
* rejected at start otherwise.
|
||||
* rejected at start otherwise. In-process backends apply it as a scoped
|
||||
* `tools.restrict()` in the child's creation window: the named tools vanish
|
||||
* from the child's prompt AND refuse to execute (one visibility), with loud
|
||||
* unknown-name validation.
|
||||
*/
|
||||
toolFilter?: { allow?: string[]; deny?: string[] }
|
||||
/**
|
||||
* Optional per-child persona. Requires {@link SubagentCapabilities.persona};
|
||||
* rejected at start otherwise. In-process backends register it as a scoped
|
||||
* `deployment:persona` section on the child, SHADOWING the deployment's
|
||||
* persona for this child alone — same template semantics as the deployment
|
||||
* persona (strict `{{…}}` interpolation against the registered variables).
|
||||
*/
|
||||
persona?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,8 +16,8 @@ function fakeParent(id = 'parent-1'): Agent {
|
||||
return { id: AgentId(id) } as unknown as Agent
|
||||
}
|
||||
|
||||
const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
|
||||
const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
|
||||
const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }
|
||||
const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
|
||||
|
||||
/** A scripted provider whose run settles immediately with a fixed result. */
|
||||
class StubProvider implements SubagentProvider {
|
||||
|
||||
@@ -54,11 +54,35 @@ export interface Config {
|
||||
toolName?: string
|
||||
/**
|
||||
* Default per-child agent options (model) applied to every spawned child.
|
||||
* Omitted fields fall back to the child loop's own defaults. There is no
|
||||
* per-child persona: the deployment persona (the system-prompt plugin's
|
||||
* `persona` config) is a context-wide section every agent shares.
|
||||
* Omitted fields fall back to the child loop's own defaults.
|
||||
*/
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Per-child persona applied to every child this tool spawns: a scoped
|
||||
* `deployment:persona` section shadowing the deployment's persona for the
|
||||
* child alone. Requires the bound provider's `persona` capability
|
||||
* (in-process backends support it; a request against one that doesn't is
|
||||
* rejected at start). Omitted ⇒ the child renders the deployment persona.
|
||||
*/
|
||||
persona?: string
|
||||
/**
|
||||
* Tool scoping applied to every child this tool spawns (see
|
||||
* `SubagentStartRequest.toolFilter`): the named global tools vanish from
|
||||
* the child's prompt AND refuse to execute. Requires the provider's
|
||||
* `toolFilter` capability. Unknown names fail the spawn loudly. Note the
|
||||
* child otherwise sees every global tool — including this delegation tool
|
||||
* itself; `deny`-listing it (or setting `maxDepth`) is how a deployment
|
||||
* bounds recursion.
|
||||
*/
|
||||
toolFilter?: { allow?: string[]; deny?: string[] }
|
||||
/**
|
||||
* Recursion cap applied to every child this tool spawns (see
|
||||
* `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
|
||||
* than this in the delegation tree is rejected. Requires the provider's
|
||||
* `depthLimit` capability. Omitted ⇒ unbounded (bound it in deployments
|
||||
* that expose this tool to children).
|
||||
*/
|
||||
maxDepth?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -67,6 +91,17 @@ export const Config: z<Config> = z.object({
|
||||
agentOptions: z.object({
|
||||
model: z.string(),
|
||||
}),
|
||||
persona: z.string(),
|
||||
// A schemastery object materializes {} (with [] for nested arrays) when the
|
||||
// key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e.
|
||||
// deny-everything, silently. Force the omitted key to stay absent (the same
|
||||
// shape discipline as SystemPrompt's toolOrder); the cast is needed because
|
||||
// .default() expects the object type.
|
||||
toolFilter: z.object({
|
||||
allow: z.array(z.string()),
|
||||
deny: z.array(z.string()),
|
||||
}).default(undefined as unknown as { allow: string[]; deny: string[] }),
|
||||
maxDepth: z.number(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -179,6 +214,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
|
||||
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
|
||||
}
|
||||
|
||||
const run: SubagentRun = ctx.subagents.start(config.provider, request)
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'weird',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
id: AgentId('weird-child'),
|
||||
@@ -137,7 +137,7 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
seen = request
|
||||
@@ -167,7 +167,7 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'bare',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
seen = request
|
||||
@@ -297,7 +297,7 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
id: AgentId('spy-child'),
|
||||
@@ -320,7 +320,7 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
id: AgentId('spy-child'),
|
||||
@@ -344,7 +344,7 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
|
||||
@@ -391,7 +391,7 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
|
||||
|
||||
Reference in New Issue
Block a user