Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop

# Conflicts:
#	packages/ui/acp/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-19 02:21:21 +08:00
7 changed files with 86 additions and 300 deletions

View File

@@ -20,20 +20,14 @@ export interface AcpConfig {
provider?: string
/** Model name for created agents (must have a registered adapter). */
model?: string
/**
* Transport stream override. Production omits this (the plugin wires
* `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
* in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive
* the bridge without a subprocess. Not part of the schemastery `Config` —
* it is a runtime-only seam, never set from a `cordis.yml`.
*/
/** Runtime-only transport override; production uses stdio. */
stream?: Stream
}
```
Depends on: `Stream` (`@agentclientprotocol/sdk`)
Source: [`packages/ui/acp/src/index.ts:247`](../packages/ui/acp/src/index.ts)
Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-demo`

View File

@@ -49,39 +49,39 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin
#### The seam: mechanism and policy split
After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome` `allowed-once` / `rejected` / `cancelled` / `unavailable`. `ApprovalRequest` is a readonly same-process contract, so the service borrows its routing identity and cancellation signal instead of copying the record or capturing a parallel callback bundle. It dispatches the `approval/request` waterfall, races the request signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the request agent's session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design.
After validation and a successful `approval/asked` append, the service resolves the `approval/request` waterfall to `allowed-once`, `rejected`, `cancelled`, or `unavailable`. It borrows the readonly request identity and signal, treats abort as `cancelled`, contains answerer failures and invalid returns as `unavailable`, discards late answers, and appends the paired `approval/decided` event. Pre-commit audit failures reject; post-append observer failures cannot undo an authoritative event. `allowed-once` authorizes only the asked action, and `request()` rejects outside an open turn so the audit pair remains inside the durable commit boundary.
Answerers are the policy, and they are `approval/request` waterfall listeners. The waterfall buys exactly what the seam needs: with zero listeners the dispatch falls through to the caller-supplied default — `unavailable`, so fail-closed needs no configuration and no code in any deployment; a listener that recognizes the request's agent answers by returning an outcome without calling `next()` (the decision slot is single-occupancy, first answer wins — the same documented semantics as the `fs/write-intent` gate); a listener that does not recognize the agent MUST delegate via `next()` so another answerer or the default gets the question; and listeners dispose with their owning fiber, so an unloaded UI plugin degrades the next ask to `unavailable` instead of leaving a dangling channel. Registration order across sibling plugins is not load-order deterministic (the loader starts siblings concurrently), so a deployment composes ONE terminal answerer and reserves `prepend` listeners for decide-or-delegate gates.
Answerers are `approval/request` waterfall listeners. Zero listeners fall through to `unavailable`; a recognizing listener occupies the first-wins decision slot, while an unrecognized agent must delegate with `next()`. Listeners dispose with their fibers, so an unloaded channel fails closed. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and reserves `prepend` for decide-or-delegate gates.
`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller retains ownership and honors the readonly contract for the duration of `request()`. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call.
`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `CallId` brand without importing `dsh-tools`, which depends on this seam. Tool arguments stay on the already-streamed call that a UI references by `callId`.
#### Ask routing in dsh-tools
`ToolRegistry.execute()` resolves an `ask` decision through the seam before the shared deny path: `allowed-once` proceeds to guards and dispatch, and the three non-grants deny with distinct reasons — "the user rejected…", "…was cancelled", "…no approval channel is available" — so the model can tell a human "no" from an absent channel. The seam is consumed opportunistically (`ctx.get('approval')`, the `tool-bash`/`agent-loop` pattern), not statically injected: with no ApprovalService, or after one unmounts, the next ask fails closed without gating the registry's fiber. An agent-less execution also fails closed — without an agent there is no session to audit to and no UI to route to.
`ToolRegistry.execute()` resolves `ask` before dispatch: `allowed-once` proceeds, while rejection, cancellation, and channel absence produce distinct deny reasons. Opportunistic `ctx.get('approval')` consumption lets an absent or unmounted service fail closed without gating the registry fiber. Agent-less execution also fails closed because it has neither an audit session nor a UI owner.
#### The per-session policy tier
The seam also owns the session-scoped approval policy — the approval knob of the two-knob per-session switching design ([the sandbox RFC](2026-07-06-sandbox.md) § Per-session modes is the pattern's home: one log-only event per knob, a pure fold, THE write path, ACP config-option advertisement, and turn-anchoring). `ApprovalPolicy` is `'ask' | 'never'`, and `effectiveApprovalPolicy(events) ?? Config.policy` (default `'ask'`) decides every request BEFORE any interactive answerer: the service resolves a `'never'` session to `'rejected'` INSIDE `request()`, before dispatching the waterfall at all — no listener registration, including a later `prepend`, can sit ahead of it — while `'ask'` dispatches unchanged and falls through to fail-closed `'unavailable'` when nobody answers. Visibility follows the switching design's two layers with one asymmetry: the prompt section states ONLY `'never'` (deterministic, availability-independent — "you will be prompted" would overclaim in a composition with no answerer, and absence under a logged header is exactly how the narrator reads `'ask'` back), the narrator injects at most one coalesced notice per switch, and the audit pair still lands on every ask, including the policy's auto-rejections.
The seam also owns the session-scoped `'ask' | 'never'` policy described by [the sandbox RFC](2026-07-06-sandbox.md). Effective policy is folded from logged switches over the deployment default. `'never'` resolves to `rejected` inside `request()` before any answerer can run; `'ask'` dispatches and otherwise falls through to `unavailable`. The prompt states only deterministic `'never'`, switch narration is coalesced, and every request still records the audit pair.
#### The ACP answerer
The bridge registers the first real answerer: it resolves the owning session through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, issues `session/request_permission` with the request's `callId` as the `toolCall` reference and the one-shot options `allow_once`/`reject_once`, and maps the response — selected `allow-once``allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled``cancelled`. A request for a foreign agent — or one without a `callId`, since the protocol prompt must attach to a tool call — delegates via `next()`. A rejected RPC (client gone mid-prompt) propagates to the service, which contains it as `unavailable`. Whether a call ASKS at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment.
The ACP bridge answers only for an exact agent object owned by its forward session map. It attaches `session/request_permission` to the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all.
The answerer routes through the bridge's exact-agent ownership check described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md).
#### Audit, and what the model sees
`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. Successful request completion commits one `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. An idle request appends neither event; a pre-commit append failure rejects, and failure of the second append can leave the already-committed `asked` without a `decided`.
`approval/asked` and `approval/decided` are durable log-only events; the model sees only the ordinary tool result derived from the outcome. Successful completion commits one `decided` per `asked`, including cancellation and contained answerer failure. Idle requests append neither event; a pre-commit failure rejects, while failure of the second append can leave an already-committed `asked` unmatched.
#### Entities and dependencies
One package, no cycles: `dsh-user-approval` peers on `cordis`, `dsh-session` (event-map merge + append), `dsh-agent` (the `Agent` type), `dsh-llm` (`CallId`, via `dsh-brand`). `dsh-tools` and `dsh-acp` each peer on it; the escalation phase's asker lives in `dsh-tool-bash` (see [the sandbox RFC](2026-07-06-sandbox.md) § Escalation), so the sandbox family keeps its ZERO-edge relation (the executor contributes the per-call override mechanism, and transport seams never ask humans questions). The seam is one package, not the capability-seam three: the service body (dispatch + audit) has no replaceable implementation — the replaceable part is the answerer listeners, and those live with their owners (the bridge; future terminal UIs; test scripts). `@cordisjs/plugin-capability` stays orthogonal (a static grant registry answers "is this already authorized", not "ask the user now"), and `subagent-acp`'s child-side `permission` auto-answer is untouched — routing a child's approvals to the parent session is deferred (§ Deferred).
`dsh-user-approval` depends on Cordis plus the session, agent, and branded-call contracts; `dsh-tools` and `dsh-acp` consume it. The sandbox executor stays independent because `dsh-tool-bash` owns escalation requests. The fixed dispatch-and-audit service remains one package; replaceable answerers live with their channel owners. Static capability grants and `subagent-acp` child-side permission answers remain separate concerns.
### Testing
Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), scoped routing, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`.
Unit tests pin outcomes, first-wins delegation, containment, cancellation, scoped routing, audit pairing, the unbypassable `'never'` policy, tool deny reasons, and ACP ownership/outcome mapping through a real scripted bridge.
Snapshot tier: the harness accepts scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed). The seam's wire is recorded end to end in the sandbox example's suite: both escalation branches drive `session/request_permission` through this seam over scripted answers (grant and rejection), and the recorded `mode-switching` scenario pins the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing).
Snapshots record allowed and rejected sandbox escalation through `session/request_permission`, plus the `'never'` prompt and policy-switch notice. Unscripted permission prompts cancel and fail closed.
## Deferred
@@ -102,12 +102,10 @@ Snapshot tier: the harness accepts scripted permission answers (`permissionAnswe
The implemented contract is pinned by the suites in Testing:
- With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason.
- A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)).
- Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection.
- Every successful `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; idle and pre-commit failures reject, while post-append observer failures cannot split the pair.
- Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor.
- A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request.
- `allowed-once` dispatches one action; every other outcome denies with a distinct reason, and `'never'` rejects before prompting.
- Missing, foreign, agent-less, throwing, invalid, and disconnected answer paths fail closed.
- Successful requests route by exact agent ownership and append one replayable, model-invisible audit pair; idle and pre-commit failures reject.
- ACP ownership keeps prompts inside their session, while a deployment without the service emits no prompt or audit events.
Costs and accepted limits:
@@ -117,8 +115,6 @@ Costs and accepted limits:
## FAQ
Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that.
- **What happens in a deployment with no answerer at all (headless, CI)?** Every ask falls through the empty waterfall to `unavailable` and the tool call denies with the "no approval channel is available" reason. Fail-closed is the zero-listener default, not a configuration.
- **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred).
- **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel.

View File

@@ -28,7 +28,7 @@ The config-driven path keeps `agents[].id` as a stable configuration label, not
- Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place.
- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state.
- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend mints its lifecycle id in the parent namespace because a child server's returned session id is only server-local; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC caches only local disposable-child parent lineage while leaving remote runs outside its local-session notification pair.
- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend mints its lifecycle id in the parent namespace because a child server's returned session id is only server-local; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC forwards only lifecycle events whose service-snapshotted `local` flag is true, obtains the delegating parent from the scoped event carrier, and keeps no child identity or lineage cache.
- The config-driven resume-or-create policy is explicit and covered across a durable restart.
- A production listener search kept `agent/created`/`agent/disposed` and their publication semantics.
- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass.