Merge remote-tracking branch 'origin/master' into codex/pr224-rfc-rewrite
# Conflicts: # docs/architecture.md # docs/capability-seams.md # docs/config-catalog.md # docs/cookbook/extension-cookbook.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/rfc/implemented/feature/2026-06-30-interception-seams.md # docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md # docs/tool-execution-pipeline.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-core/README.md # packages/core/agent-loop/README.md # packages/core/tools/README.md # packages/core/tools/src/index.ts # packages/core/tools/tests/tools.spec.ts # packages/core/tools/tsconfig.json # packages/ui/acp/src/index.ts # scripts/doc-budgets.manifest.json # scripts/gen-cordis-catalog.ts # scripts/gen-doc-graphs.ts
This commit is contained in:
@@ -21,11 +21,14 @@ import z from 'schemastery'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
|
||||
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
|
||||
import { renderToolsSdk } from './ts-types.ts'
|
||||
@@ -86,12 +89,12 @@ declare module 'cordis' {
|
||||
* or return a {@link PreToolDecision} without calling `next()` to
|
||||
* short-circuit. A `deny` skips dispatch and yields an `isError` result; the
|
||||
* tool body never runs. Input rewrite is deliberately NOT offered here (see
|
||||
* {@link PreToolDecision}); `ask` degrades to deny until the permission
|
||||
* system lands (`FIXME(permissions)`).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||
* `exec.agent` — a listener registered through `agent.ctx` fires only for
|
||||
* that agent's calls; a plain plugin listener fires for every call
|
||||
* (including agent-less ones, which dispatch subject-less).
|
||||
* {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam
|
||||
* when one is mounted, and degrades to deny otherwise.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a
|
||||
* listener registered through `agent.ctx` fires only for that agent's
|
||||
* calls, while a plain plugin listener fires for every call (including
|
||||
* agent-less ones, which dispatch subject-less).
|
||||
* @param exec - the pending call (name, parsed arguments, caller agent).
|
||||
* @mode waterfall
|
||||
*/
|
||||
@@ -340,8 +343,9 @@ export interface ToolExecutionResult {
|
||||
* would desync the UI from what RAN. That consistency redesign is its own
|
||||
* `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
|
||||
* - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
|
||||
* - `ask` is the permission-prompt intent; until the permission system exists it
|
||||
* degrades to `deny` (`FIXME(permissions)`).
|
||||
* - `ask` is the permission-prompt intent: serviced as a one-shot decision by
|
||||
* the `ctx.approval` seam when one is mounted (`allowed-once` proceeds to
|
||||
* dispatch; every other outcome denies), degrading to `deny` when none is.
|
||||
*/
|
||||
export type PreToolDecision =
|
||||
| { kind: 'allow' }
|
||||
@@ -891,7 +895,7 @@ export class ToolRegistry extends Service {
|
||||
* Execute one tool call through the `tools/pre-execute` → guards →
|
||||
* `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result`
|
||||
* pipeline. `pre-execute` is the extensible gate
|
||||
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
|
||||
* (allow/deny/ask), `tools/execute` wraps core dispatch (a timeout/retry/metrics
|
||||
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
|
||||
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
|
||||
* wrapped in one outer try/catch so a throwing listener (in any waterfall)
|
||||
@@ -993,24 +997,22 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
|
||||
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
|
||||
// until the permission system lands) skips dispatch entirely. The
|
||||
// carrier keys the dispatch by exec.agent, so an `agent.ctx` listener
|
||||
// gates only its own agent's calls (agent-less calls are subject-less).
|
||||
// --- Gate: tools/pre-execute. An `ask` resolves through the optional
|
||||
// approval seam (or degrades to deny) before the monotonic guards run. The
|
||||
// carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only
|
||||
// its own agent's calls (agent-less calls are subject-less).
|
||||
const carrier = scopeTarget(this, exec.agent)
|
||||
const decision = await this.ctx.waterfall(
|
||||
const gate = await this.ctx.waterfall(
|
||||
carrier, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
|
||||
const denialReason = decision.kind === 'allow'
|
||||
? this.guardReason(exec)
|
||||
: decision.kind === 'deny'
|
||||
? decision.reason
|
||||
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
|
||||
: decision.reason
|
||||
if (denialReason !== undefined) {
|
||||
// deny → isError. ask has no permission UI yet, so degrade to deny
|
||||
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
|
||||
// a real prompt; today it is the conservative "not allowed".
|
||||
// Every non-grant, including a failed/unavailable approval request, takes
|
||||
// the same deny path and still reaches post-policy plus result observers.
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${denialReason}` }],
|
||||
@@ -1074,6 +1076,44 @@ export class ToolRegistry extends Service {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `ask` decision to allow/deny through the approval seam. The
|
||||
* seam is consumed opportunistically with `ctx.get('approval')` — a
|
||||
* deployment that composes no ApprovalService keeps the historical degrade
|
||||
* to deny, and an unmount mid-session degrades the same way on the next ask.
|
||||
* An agent-less execution also degrades: without an agent there is no
|
||||
* session to audit to and no UI to route to. Otherwise the outcome maps
|
||||
* one-to-one — `allowed-once` proceeds; the three non-grants deny with
|
||||
* distinct reasons so the model can tell a human "no" from an absent
|
||||
* approval channel.
|
||||
*/
|
||||
private async serviceAsk(
|
||||
exec: ToolExecution,
|
||||
ask: Extract<PreToolDecision, { kind: 'ask' }>,
|
||||
): Promise<Extract<PreToolDecision, { kind: 'allow' | 'deny' }>> {
|
||||
const approval = this.ctx.get('approval')
|
||||
if (approval === undefined) {
|
||||
return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }
|
||||
}
|
||||
if (exec.agent === undefined) {
|
||||
return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }
|
||||
}
|
||||
const outcome = await approval.request({
|
||||
agent: exec.agent,
|
||||
toolName: exec.name,
|
||||
callId: exec.callId,
|
||||
...ask.reason !== undefined ? { reason: ask.reason } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
case 'allowed-once': return { kind: 'allow' }
|
||||
case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }
|
||||
case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }
|
||||
case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }
|
||||
default: return assertNever(outcome, 'ApprovalOutcome')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
|
||||
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
|
||||
|
||||
Reference in New Issue
Block a user