Reshape the agent's interception surface so every seam returns a small, typed
Decision union, and the set covers the hook points a CC/Codex bridge (and a
native plugin) needs. "Native hooks" are not a package — a native hook is just a
cordis plugin on these canonical events; the bridges (a later PR) only translate
an external protocol onto the same surface.
dsh-agent:
- NEW agent/session-start(agent, source) emit (once before turn 1; SessionStartSource
startup|resume|clear|compact) — a pure notification, seeds context via inject().
- NEW agent/prompt-submit waterfall → PromptDecision (allow, optionally rewriting the
prompt or attaching additionalContext, or block).
- RESHAPE agent/turn-continuation boolean → ContinuationDecision ({action:'stop'} |
{action:'continue', reason?}; a continue reason is recorded as next-step steering).
- New HookContext envelope (required source — inject() would mislabel a missing one).
dsh-tools: split the single tools/execute waterfall into tools/pre-execute
(PreToolDecision allow/deny/ask gate) and tools/post-execute (PostToolDecision
accept/block, optionally replacing content or attaching additionalContext). Core
dispatch sits between as plain code; the tool body keeps its inner try/catch so a
thrown tool still reaches post-execute as an isError. ToolExecutionResult gains
additionalContext (ferried to the loop's per-step buffer). Input rewrite is
deliberately NOT offered (a proposed RFC designs it consistently).
dsh-session: new `rejected` TurnEndReason — a turn whose whole prompt batch was
blocked by prompt-submit.
agent-loop firing points: session-start emitted at create (source threaded —
startup for create/fork, resume for resume()); prompt-submit per drained message
with the always-open-turn rule (a fully-blocked batch is a zero-step rejected
turn); the continuation reshape; post-tool additionalContext buffered and appended
after all tool/results (adjacency). ACP codec maps rejected→cancelled.
A worked native-plugin example (interception.spec.ts) proves all four seams compose
end-to-end through the real loop with NO hook/* events (those belong to the bridge
lib). All existing tools/execute + turn-continuation tests migrated. The
tool-subagent abort test now aborts after a microtask so it still exercises the
live onAbort bridge (execute() awaits pre-execute before the body runs).
RFCs: implemented/feature/2026-06-30-interception-seams.md (the reshape) +
proposed/feature/2026-06-30-pre-tool-input-rewrite.md (the deferred rewrite design).
115 lines
5.0 KiB
TypeScript
115 lines
5.0 KiB
TypeScript
/**
|
|
* Pure translation between harness vocabulary and ACP wire types. No I/O, no
|
|
* Cordis context — every function here is total and unit-testable in isolation.
|
|
* Keeping the mapping pure is deliberate: the SDK rejects an unknown
|
|
* `stopReason`, so the {@link turnEndToStopReason} total function (with its
|
|
* exhaustive test over every `TurnEndReason` kind) is the guard that a turn
|
|
* always settles to a legal wire value.
|
|
*
|
|
* @module @deepseek-ai/dsh-acp/codec
|
|
*/
|
|
|
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
|
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
|
|
|
|
/**
|
|
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
|
|
*
|
|
* The mapping is total over the kinds the loop actually produces today
|
|
* (`completed`/`aborted`/`error`/`disposed`/`max-tokens`). `TurnEndReason` is
|
|
* merge-extensible, so an unknown future kind falls through to `end_turn` —
|
|
* the safest default (the turn DID end; we just lack a more specific wire
|
|
* reason) — rather than throwing into the SDK, which would reject an unknown
|
|
* `stopReason` and break the prompt RPC. When a new kind gains a dedicated ACP
|
|
* reason (e.g. a future `refusal` → `refusal`), add an explicit case here.
|
|
*
|
|
* - `completed` → `end_turn` (the model chose to stop)
|
|
* - `max-tokens` → `max_tokens` (cut off at the output-token ceiling)
|
|
* - `aborted` → `cancelled` (a step abort or a queue-aware `agent.cancel()`, e.g. from `session/cancel`)
|
|
* - `error` → `end_turn` (defensive fallback only: the bridge REJECTS the
|
|
* `session/prompt` RPC on an error turn BEFORE calling this, so
|
|
* a client sees a JSON-RPC error, not a stop reason — see
|
|
* `rejectPrompt` in index.ts. This case keeps the function total
|
|
* for any non-bridge caller / property test.)
|
|
* - `disposed` → `cancelled` (the agent was torn down mid-turn — closest to a
|
|
* cancellation from the client's perspective)
|
|
* - `rejected` → `cancelled` (the prompt was blocked by an `agent/prompt-submit`
|
|
* hook before any step ran — ACP has no "rejected" reason, and a
|
|
* blocked prompt is, from the client's view, the prompt not being
|
|
* carried out; `cancelled` is the closest legal wire reason)
|
|
*/
|
|
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
|
switch (reason.kind) {
|
|
case 'completed':
|
|
return 'end_turn'
|
|
case 'max-tokens':
|
|
return 'max_tokens'
|
|
case 'aborted':
|
|
return 'cancelled'
|
|
case 'disposed':
|
|
return 'cancelled'
|
|
case 'rejected':
|
|
return 'cancelled'
|
|
case 'error':
|
|
return 'end_turn'
|
|
// Merge-extensible: an unknown future TurnEndReason kind still has to
|
|
// produce a legal wire value (the SDK rejects unknown stopReason), so
|
|
// default to end_turn rather than assertNever. Add an explicit case when a
|
|
// new kind gains a dedicated ACP reason.
|
|
default:
|
|
return 'end_turn'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Translate a harness {@link ContentBlock} from a prompt into ACP content for
|
|
* replay, or `undefined` for block kinds the bridge does not surface to the
|
|
* client as message content. Today only `text` maps; `resource_link` is an
|
|
* ACP prompt-only input rendered into text by {@link acpPromptToText};
|
|
* `reasoning` is surfaced via `agent_thought_chunk`
|
|
* streaming rather than as a message block, and `tool-call`/`tool-result`/
|
|
* `image` are handled by the tool-call update path or not advertised.
|
|
*/
|
|
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
|
|
switch (block.type) {
|
|
case 'text':
|
|
return { type: 'text', text: block.text }
|
|
// reasoning → streamed as agent_thought_chunk, not a message block
|
|
// tool-call / tool-result → the tool_call / tool_call_update path
|
|
// image → not advertised
|
|
default:
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract plain text from an ACP prompt's content blocks. Text blocks are
|
|
* concatenated verbatim; resource links become explicit textual references so
|
|
* baseline ACP clients can point at files without the bridge silently dropping
|
|
* that context.
|
|
*/
|
|
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
|
|
return prompt
|
|
.flatMap((block): string[] => {
|
|
switch (block.type) {
|
|
case 'text':
|
|
return [block.text]
|
|
case 'resource_link':
|
|
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
|
|
default:
|
|
return []
|
|
}
|
|
})
|
|
.join('')
|
|
}
|
|
|
|
/**
|
|
* Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP
|
|
* requires `text` and `resource_link`; richer inline payloads (`resource`,
|
|
* image, audio, …) are rejected rather than silently dropped.
|
|
*/
|
|
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
|
|
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')
|
|
}
|