docs: rename core-data-structures/ to subsystems/
The folder is becoming the home of one-doc-per-subsystem pages (intro + data structures + cordis services/events), so the name must describe the whole contract, not just the type-vocabulary third of it. Mechanical rename rebuilt on current master: every inbound Markdown link, generator constant, website route, type-equiv manifest path, and spec expectation moves together; the zh sides of the notes whose prose names the folder are aligned (子系统) in the same change; touched bilingual pairs re-recorded; translation-prompt snapshot re-recorded (its example embeds development.md). Historical Agent Note slugs keep their dated filenames.
This commit is contained in:
89
docs/subsystems/approval.md
Normal file
89
docs/subsystems/approval.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# User Approval
|
||||
|
||||
English | [中文](approval.zh.md)
|
||||
|
||||
The user-approval seam of [dsh-user-approval](../../packages/interaction/user-approval) answers one question: may this specific action proceed? It owns the shared request/outcome vocabulary, the `ctx.approval` dispatch service, the `approval/request` answerer waterfall, the log-only audit pair, and the per-session `ask`/`never` policy. UI channels may provide human answerers; the [ACP automation bridge](../../packages/acp/acp) provides one-shot machine decisions for its own agents. Callers such as [dsh-tools](../../packages/core/tools) and [dsh-tool-bash](../../packages/bash/tool-bash) consume the closed outcome and fail closed unless it is `allowed-once`.
|
||||
|
||||
Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts)
|
||||
|
||||
## Identity and outcome
|
||||
|
||||
Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call or agent/session ids.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Pairs one `approval/asked` audit event with its `approval/decided`.
|
||||
* Service-issued (one fresh id per {@link ApprovalService.request} call).
|
||||
*/
|
||||
type ApprovalRequestId = Branded<'ApprovalRequestId'>
|
||||
```
|
||||
|
||||
`ApprovalOutcome` is closed and fail-closed. `allowed-once` grants only the asked-about action; callers deny on `rejected`, `cancelled`, and `unavailable`. A missing, non-owning, throwing, or non-conforming answerer becomes `unavailable` rather than opening the gate.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
|
||||
* request, or unavailable answerer. Callers fail closed on `unavailable`.
|
||||
*/
|
||||
type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
```
|
||||
|
||||
## Per-session policy
|
||||
|
||||
`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* A session's approval policy — what happens to an {@link ApprovalService}
|
||||
* ask BEFORE any interactive answerer sees it:
|
||||
*
|
||||
* - `'ask'` (the default) — delegate to the composed answerers; with none
|
||||
* composed the chain falls through to the fail-closed `'unavailable'`
|
||||
* (exactly today's behavior).
|
||||
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
|
||||
* deterministically. The strict headless stance (CI, unattended runs) and
|
||||
* the policy whose outcome is knowable without asking.
|
||||
*/
|
||||
type ApprovalPolicy = 'ask' | 'never'
|
||||
```
|
||||
|
||||
Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot. The sourced `user/message` is the durable model-visible input; changing approval state appends a new full snapshot after retained history without rewriting the request header's system prompt.
|
||||
|
||||
## Approval request
|
||||
|
||||
`ApprovalRequest` identifies the agent and tool action closely enough to route and audit the question. It deliberately omits tool arguments: an answerer attaches the prompt to the already-streamed tool call through `callId` instead of rendering a second copy that could drift.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Readonly same-process permission question. `callId` links to an already
|
||||
* presented tool call, so arguments are not duplicated here.
|
||||
*/
|
||||
interface ApprovalRequest {
|
||||
/**
|
||||
* The agent on whose behalf the question is asked. Routes the question (a
|
||||
* UI answerer only answers for agents it owns) and receives the audit
|
||||
* events on its session log.
|
||||
*/
|
||||
readonly agent: Agent
|
||||
/** The tool the question is about (presentation and audit). */
|
||||
readonly toolName: string
|
||||
/**
|
||||
* The exact tool call being decided, when the asker has one — lets a UI
|
||||
* attach the prompt to the tool call it already streamed.
|
||||
*/
|
||||
readonly callId?: CallId
|
||||
/** The asker's human-readable explanation of WHY it is asking. */
|
||||
readonly reason?: string
|
||||
/**
|
||||
* Aborting withdraws the question: the request settles `'cancelled'`
|
||||
* immediately and a late answer from a still-pending answerer is discarded.
|
||||
*/
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
```
|
||||
|
||||
## Dispatch and audit
|
||||
|
||||
`ctx.approval.request(req)` requires the requesting session to be inside an open turn. It appends `approval/asked`, obtains one outcome, appends the matching `approval/decided`, and resolves with that outcome. The `never` policy is enforced inside the service before waterfall dispatch, so even an answerer registered later with `prepend` cannot bypass it. Answerers return an outcome when they own the request or call `next()` to delegate; the first answer occupies the single decision slot.
|
||||
|
||||
The audit events are log-only and do not enter the model transcript. Model-visible behavior is the caller's derived tool result plus the current runtime-context snapshot. Service disposal removes its context contribution; answerer listeners are independently effect-bound to their owning plugins.
|
||||
Reference in New Issue
Block a user