fix(scope): align trust and input boundaries

Rewrite the agent-scope RFC with executable examples and an explicit security non-goal. Harden subagent scalar and depth validation, and pin live tool-filter semantics across code, tests, and generated docs.
This commit is contained in:
Tianyi Cui
2026-07-12 11:17:57 +08:00
parent d427478c44
commit cb03c8c284
29 changed files with 545 additions and 105 deletions

View File

@@ -29,7 +29,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored.
- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path.
Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; the model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it.
Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). “Context” here means conversation history only; it says nothing about tool registrations, injected services, or authority inheritance. The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; the model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it.
## Run lifecycle

View File

@@ -69,9 +69,10 @@ export interface SubagentStartRequest {
*/
outputSchema?: StructuredOutputSchema
/**
* Optional recursion cap (max delegation depth below this child). Must be a
* non-negative safe integer. Requires {@link SubagentCapabilities.depthLimit};
* rejected at start otherwise.
* Optional absolute delegation-depth cap for the child being started: its
* computed depth must be less than or equal to this non-negative safe
* integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at
* start otherwise.
*/
maxDepth?: number
/**
@@ -195,13 +196,15 @@ export interface SubagentProvider {
/** The start-time features this provider supports (see {@link SubagentCapabilities}). */
readonly capabilities: SubagentCapabilities
/**
* The provider's context contract: `true` when a child SEES the parent
* The provider's conversation-history descriptor: `true` when a child SEES the parent
* conversation (fork — the child is seeded with the parent's completed-turn
* prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact,
* not a start-time capability: the service validates nothing against it —
* the model-facing consumer (`dsh-tool-subagent`) derives truthful tool
* wording from it, so a tool bound to a fork provider stops telling the
* model the child "does not see this conversation".
* model the child "does not see this conversation". This descriptor concerns
* conversation history only; it says nothing about tool registrations,
* injected services, or authority inheritance.
*/
readonly inheritsParentContext: boolean
/**

View File

@@ -226,7 +226,7 @@ describe('SubagentService', () => {
patch: { capabilities: { ...NO_CAPS, persona: 'yes' } },
message: 'capability "persona" must be a boolean',
},
{ label: 'a non-boolean context descriptor', patch: { inheritsParentContext: 'yes' }, message: 'inheritsParentContext must be a boolean' },
{ label: 'a non-boolean conversation-history descriptor', patch: { inheritsParentContext: 'yes' }, message: 'inheritsParentContext must be a boolean' },
{ label: 'a non-callable start field', patch: { start: 42 }, message: 'start must be a function' },
])('rejects a provider registration with $label before entering the registry', async ({ patch, message }) => {
const ctx = new Context()
@@ -431,6 +431,7 @@ describe('SubagentService', () => {
})
it.each([
{ label: 'null', value: null as unknown as number },
{ label: 'a string', value: '1' as unknown as number },
{ label: 'NaN', value: Number.NaN },
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },