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:
Yichen Jiang
2026-07-13 15:57:17 +08:00
248 changed files with 14796 additions and 5140 deletions
+147 -59
View File
@@ -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()
}