Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/tool-catalog.md # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/core/agent-core/tests/agent-core.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/index.ts # packages/core/agent/README.md # packages/core/agent/src/index.ts # packages/core/agent/tests/agent.spec.ts # packages/subagent/subagent/README.md # packages/subagent/subagent/src/index.ts # packages/subagent/tool-subagent/README.md # packages/subagent/tool-subagent/src/index.ts # pnpm-lock.yaml # scripts/doc-budgets.manifest.json
This commit is contained in:
@@ -11,10 +11,12 @@
|
||||
* — there is no provider/type parameter in the model-facing schema. The model
|
||||
* sees only `{ description, prompt }` (plus `run_in_background` when enabled).
|
||||
*
|
||||
* The tool DESCRIPTION is derived from the bound provider's context contract
|
||||
* ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the
|
||||
* standalone-prompt wording, an inheriting provider (fork) tells the model the
|
||||
* child already sees the conversation's completed turns. The tool MIRRORS the
|
||||
* The tool DESCRIPTION is derived from the bound provider's conversation-history
|
||||
* descriptor ({@link providerWording}): a fresh-conversation provider (spawn,
|
||||
* ACP) gets the standalone-prompt wording, while a seeded-conversation provider
|
||||
* (fork) tells the model the child already sees the conversation's completed
|
||||
* turns. This descriptor says nothing about Cordis scope, services, tools, or
|
||||
* authority. The tool MIRRORS the
|
||||
* provider's lifecycle via `subagent/provider-added`/`-removed` — it registers
|
||||
* when the provider is (or becomes) available and unregisters when the
|
||||
* provider goes away — so no load-order requirement exists and an HMR reload
|
||||
@@ -44,8 +46,9 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
@@ -74,20 +77,70 @@ export interface Config {
|
||||
enableRunInBackground?: boolean
|
||||
/**
|
||||
* 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?: {
|
||||
/** Global tool names the child keeps; everything else is removed. */
|
||||
allow?: string[]
|
||||
/** Global tool names removed from the child. */
|
||||
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. Must be a non-negative safe integer and is
|
||||
* validated when the plugin loads. Omitted ⇒ unbounded (bound it in
|
||||
* deployments that expose this tool to children).
|
||||
*/
|
||||
maxDepth?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
toolName: z.string().default('subagent'),
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
// Omitted-object discipline (see the toolFilter note below): without the
|
||||
// forced default an omitted `agentOptions` materializes `{}`, which reads as
|
||||
// present — the request would carry `agentOptions: {}` and the presence
|
||||
// check in execute() could never be false through config.
|
||||
agentOptions: z.object({
|
||||
model: z.string(),
|
||||
}),
|
||||
}).default(undefined as unknown as { model: 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.
|
||||
// The NESTED arrays get the same treatment as the object itself: a partial
|
||||
// filter ({deny: […]}) must not materialize allow: [] beside it — an empty
|
||||
// allow-list means deny-EVERYTHING, so the materialized default would turn
|
||||
// a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only
|
||||
// children) survives, since only the omitted key defaults to undefined.
|
||||
toolFilter: z.object({
|
||||
allow: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
deny: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
}).default(undefined as unknown as { allow: string[]; deny: string[] }),
|
||||
maxDepth: z.natural().max(Number.MAX_SAFE_INTEGER),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -134,20 +187,21 @@ function stopReasonError(result: SubagentResult): string | undefined {
|
||||
* @param result - the child's terminal result.
|
||||
* @returns the outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function runOutcome(result: SubagentResult): TaskOutcome { switch (result.stopReason) {
|
||||
case 'completed':
|
||||
return { status: 'completed', output: outputText(result.output) }
|
||||
case 'aborted':
|
||||
return { status: 'killed' }
|
||||
case 'error':
|
||||
case 'max-tokens':
|
||||
case 'refusal':
|
||||
return { status: 'failed', detail: result.stopReason }
|
||||
export function runOutcome(result: SubagentResult): TaskOutcome {
|
||||
switch (result.stopReason) {
|
||||
case 'completed':
|
||||
return { status: 'completed', output: outputText(result.output) }
|
||||
case 'aborted':
|
||||
return { status: 'killed' }
|
||||
case 'error':
|
||||
case 'max-tokens':
|
||||
case 'refusal':
|
||||
return { status: 'failed', detail: result.stopReason }
|
||||
// Merge-extensible union: an unknown terminal reason is a failure with
|
||||
// the raw reason as detail, never partial output as success.
|
||||
default:
|
||||
return { status: 'failed', detail: String(result.stopReason) }
|
||||
}
|
||||
default:
|
||||
return { status: 'failed', detail: String(result.stopReason) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,33 +209,42 @@ export function runOutcome(result: SubagentResult): TaskOutcome { switch (resul
|
||||
* dispose the run (the owned child agent/session is released on every path),
|
||||
* and only then report the mapped outcome — so the task registry's `done`,
|
||||
* and therefore owner-disposal cleanup, cannot resolve before the child is
|
||||
* actually gone. A rejected `run.result` (infrastructure fault — no
|
||||
* SubagentResult exists) reports `failed` with the error as detail rather
|
||||
* than rejecting the producer contract. Exported for tests.
|
||||
* actually gone. A rejected `run.result` or `run.dispose()` reports `failed`
|
||||
* with the error as detail rather than rejecting the producer contract; when
|
||||
* both fail, both independent failures are preserved. Exported for tests.
|
||||
* @param run - the live background run to settle and release.
|
||||
* @returns the task outcome, after the run's resources are released.
|
||||
*/
|
||||
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
|
||||
let outcome: TaskOutcome
|
||||
try {
|
||||
return runOutcome(await run.result)
|
||||
outcome = runOutcome(await run.result)
|
||||
} catch (error: unknown) {
|
||||
return { status: 'failed', detail: String(error) }
|
||||
} finally {
|
||||
await run.dispose()
|
||||
outcome = { status: 'failed', detail: String(error) }
|
||||
}
|
||||
try {
|
||||
await run.dispose()
|
||||
} catch (error: unknown) {
|
||||
const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; `
|
||||
return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` }
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}).
|
||||
* Model-facing wording from the provider's conversation-history descriptor
|
||||
* ({@link SubagentProvider.inheritsParentContext}).
|
||||
* A fresh child needs a standalone prompt; a forked child already sees the
|
||||
* conversation's completed turns — telling the model to restate everything
|
||||
* (or, worse, that the child "does not see this conversation") would be false
|
||||
* for a fork. Exported for tests.
|
||||
* @param inherits - the bound provider's context contract.
|
||||
* @param inheritsConversation - whether the child's conversation is seeded
|
||||
* with the parent's completed turns; this says nothing about tool, service,
|
||||
* scope, or authority inheritance.
|
||||
* @returns the tool `description` and the `prompt` parameter description.
|
||||
*/
|
||||
export function providerWording(inherits: boolean): { description: string; promptDescription: string } {
|
||||
if (inherits) {
|
||||
export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } {
|
||||
if (inheritsConversation) {
|
||||
return {
|
||||
description:
|
||||
'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all '
|
||||
@@ -207,7 +270,40 @@ export function providerWording(inherits: boolean): { description: string; promp
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the provider request shared by foreground and background execution. */
|
||||
function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest {
|
||||
return {
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
parent,
|
||||
signal,
|
||||
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
|
||||
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Settle a possibly-pending provider start through the task outcome contract. */
|
||||
async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Promise<TaskOutcome> {
|
||||
try {
|
||||
return await settleRun(await start)
|
||||
} catch (error: unknown) {
|
||||
return signal.aborted
|
||||
? { status: 'killed' }
|
||||
: { status: 'failed', detail: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Keep misconfiguration at plugin load even when a caller invokes apply()
|
||||
// directly and bypasses Schemastery's natural/max metadata.
|
||||
assertSubagentMaxDepth(config.maxDepth)
|
||||
// Misconfiguration fails loud AT LOAD (the check is self-contained): an
|
||||
// explicit `toolFilter: {}` would otherwise pass the capability gate and
|
||||
// kill every delegation later, in the child-setup `restrict({})` throw.
|
||||
if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
|
||||
throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
|
||||
}
|
||||
// The tool MIRRORS its provider's lifecycle instead of assuming load order:
|
||||
// the cordis Loader starts sibling entries concurrently, so "backend listed
|
||||
// first in cordis.yml" does not guarantee "provider registered first", and
|
||||
@@ -267,9 +363,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// A step already cancelled must not spawn a child. After the id is
|
||||
// returned the tool-call signal is deliberately NOT wired to the run
|
||||
// (the child outlives this step; cancellation belongs to task_kill
|
||||
// and owner-disposal cleanup), so the request carries NO signal.
|
||||
// returned the tool-call signal is deliberately NOT wired to the run;
|
||||
// an independent controller lets task_kill/owner disposal cancel both
|
||||
// a pending async start and a ready child through the seam's one
|
||||
// canonical cancellation channel.
|
||||
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
|
||||
// tasks.start preflights (surface fence, owner cleanup) BEFORE run()
|
||||
// spawns the child, and cannot fail after — a child can never start
|
||||
@@ -279,14 +376,16 @@ export function apply(ctx: Context, config: Config): void {
|
||||
label: args.description,
|
||||
owner: parent,
|
||||
run: () => {
|
||||
const run = ctx.subagents.start(config.provider, {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const start = ctx.subagents.start(
|
||||
config.provider,
|
||||
startRequest(config, args.prompt, parent, controller.signal),
|
||||
)
|
||||
return {
|
||||
cancel: (reason?: string) => { run.cancel(reason ?? 'background subagent task killed') },
|
||||
done: settleRun(run),
|
||||
cancel: (reason?: string) => {
|
||||
controller.abort(reason ?? 'background subagent task killed')
|
||||
},
|
||||
done: settleStart(start, controller.signal),
|
||||
// No readOutput: a subagent task is final-output-only — the
|
||||
// child session remains the detailed trace.
|
||||
}
|
||||
@@ -295,24 +394,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return [{ type: 'text', text: `started background subagent task ${id}` }]
|
||||
}
|
||||
|
||||
const request: SubagentStartRequest = {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
const request = startRequest(
|
||||
config,
|
||||
args.prompt,
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
}
|
||||
exec.signal ?? new AbortController().signal,
|
||||
)
|
||||
|
||||
const run: SubagentRun = ctx.subagents.start(config.provider, request)
|
||||
|
||||
// Bridge the tool's abort signal to the run: if the parent step is
|
||||
// aborted while the child is in flight, cancel the child too.
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
// `addEventListener` does NOT fire for a signal already aborted before this
|
||||
// line, so a step cancelled before the tool ran would never reach the
|
||||
// child. Cancel explicitly in that case — the bridge must honor an
|
||||
// already-aborted signal, not lean on each provider re-checking it.
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
|
||||
|
||||
try {
|
||||
const result = await run.result
|
||||
@@ -324,7 +413,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
return [{ type: 'text', text: outputText(result.output) }]
|
||||
} finally {
|
||||
exec.signal?.removeEventListener('abort', onAbort)
|
||||
// Always reach child quiescence — never leak a live idle child/session.
|
||||
await run.dispose()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user