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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user