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:
Tianyi Cui
2026-07-09 02:10:06 +08:00
parent 67cb9a591d
commit 15f4d1cd03
15 changed files with 398 additions and 475 deletions

View File

@@ -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]) {

View File

@@ -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
}
/**

View File

@@ -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 {