Merge remote-tracking branch 'origin/master' into codex/pr48-repo-hardening-rfcs

# Conflicts:
#	docs/adr/README.md
#	docs/rfc/009-session-persistence-and-resumability.md
#	docs/rfc/README.md
#	docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md
#	docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md
#	examples/acp-agent/tests/acp.e2e.ts
#	packages/acp/README.md
#	packages/acp/src/index.ts
#	packages/acp/tests/stream-update.spec.ts
#	packages/agent-loop/src/loop.ts
#	packages/tools/src/index.ts
This commit is contained in:
Tianyi Cui
2026-06-18 23:41:14 +08:00
104 files changed
+2586 -590

No files matched your search

+124 -5
View File
@@ -39,6 +39,7 @@
import type { Context } from 'cordis'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
@@ -47,10 +48,11 @@ export const inject = ['tools', 'bash']
/**
* Validate the constraints the SchemaSpec can't express. `defineTool` now
* validates parsed args against the SchemaSpec before `execute` runs (RFC 005
* → ADR 0011), so type/required/enum checks are already done and `args` is
* the validated `InferArgs` shape here. What remains are value constraints the
* DSL has no vocabulary for: non-empty strings and a positive, finite timeout.
* validates parsed args against the SchemaSpec before `execute` runs (the
* arg-validation RFC), so type/required/enum checks are already done and `args`
* is the validated `InferArgs` shape here. What remains are value constraints
* the DSL has no vocabulary for: non-empty strings and a positive, finite
* timeout.
*/
function validateBashArgs(args: {
command: string
@@ -72,7 +74,7 @@ function validateBashArgs(args: {
/**
* Reject an empty `task_id`. Type and presence are guaranteed by the
* SchemaSpec validation (ADR 0011); only the non-empty constraint, which the
* SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
* DSL can't express, is left to check here.
*/
function validateTaskId(value: string): string {
@@ -123,6 +125,119 @@ export function renderResult(result: BashRunResult): string {
return body + markers.join('\n')
}
// ---------------------------------------------------------------------------
// UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge)
// renders a bash call's pending and completed states. They are display-only and
// pure — a UI may call them during live streaming AND a session-log replay.
// ---------------------------------------------------------------------------
/**
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
* = !is_terminal_tool`), so the command must BE the title to be seen. This
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
* use the bare command as an execute tool's title. The model-written
* `description` (a readable summary) rides as a `content` text block shown ABOVE
* the card. (Note: claude-agent-acp DROPS the description in terminal mode and
* shows only the card; surfacing it as a content block is a deliberate
* divergence here — we keep the human summary visible alongside the card.)
* `rawInput` still carries the bare command for non-execute UIs that DO render it.
*
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
* immediately (it never streams a terminal; its output is polled via
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
* execute card. For a foreground run the `terminal.cwd` (header) is the model
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
* against the session cwd; when omitted the bridge fills the session workspace
* cwd (this PURE presenter, args only, can't see it).
*/
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
function presentBashCall(args: BashCallArgs): ToolCallPresentation {
const base = {
title: args.command,
kind: 'execute' as const,
rawInput: args.command,
content: [{ type: 'text' as const, text: args.description }],
}
// A background start is not an interactive terminal — no terminal card.
if (args.run_in_background === true) return base
return { ...base, terminal: args.workdir !== undefined ? { cwd: args.workdir } : {} }
}
/**
* Completed-state presentation for a `bash` call. Two parallel renderings of the
* same output: `terminal.output` for a UI that shows a terminal card (the run's
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
* newlines preserved, since a terminal renderer relies on exact bytes), and a
* fenced ```console `content` block as the fallback for a UI without terminal
* support (the fences are a UI-only affordance, so they live here, not in the
* model-facing result; the fenced body is trimmed of trailing blank lines for a
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
* / `terminal.signal`, parsed from the status markers `renderResult` appended.
*
* Terminal output/exit is suppressed for results that are NOT a finished
* foreground run: a `run_in_background` start (`isBackground` — the text is a
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
* abort — there is no real process exit to pill, and the body is an error
* message, not `renderResult` output, so parsing it would be meaningless). Those
* fall back to the fenced `content` block with no terminal metadata. The bridge's
* orphan guard also drops a result terminal when the call wasn't terminal, so a
* background call (not marked terminal in `presentBashCall`) is doubly safe.
* A non-text result (unexpected for bash) falls through to `undefined`.
*/
function presentBashResult(args: unknown, result: ToolResult): ToolResultPresentation | undefined {
const block = result.content.length === 1 ? result.content[0] : undefined
if (block === undefined || block.type !== 'text') return undefined
const raw = block.text
const fenced = raw.replace(/\n+$/, '')
const content = [{ type: 'text' as const, text: `\`\`\`console\n${fenced}\n\`\`\`` }]
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
// No exit pill / terminal output for a background ack or an errored run.
if (isBackground || result.isError) return { content }
return { content, terminal: { output: raw, ...parseExitStatus(raw) } }
}
/**
* Recover the structured exit status from a rendered `renderResult` string — the
* inverse of the status markers it appends. A `[killed by signal: SIG]` marker
* yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`;
* absent both we report `{exitCode:0}` (a clean run appends no marker — and a
* trapped-timeout run that exits 0 also has none and is accurately exit 0).
*
* Why parse rendered text at all: `presentResult` is replay-safe and on a
* `session/load` the ONLY thing persisted is this content text — the structured
* `BashRunResult` is long gone — so unless the exit were added to the persisted
* event schema (deliberately NOT done; see the terminal-rendering RFC), parsing
* is the only channel. The match is anchored to a LEADING newline + end-of-string
* because `renderResult` always inserts a `\n` before the marker (line ~124) onto
* a non-empty body: a real marker is therefore always its own final line. That
* defeats the common spoof (program output that simply ENDS in `[exit code: 5]`
* with no trailing newline — a clean exit 0 — no longer reads as a failure).
*
* KNOWN RESIDUAL (inherent to the replay-only-sees-text design): a clean exit 0
* whose body's FINAL line is itself exactly the marker text — `[exit code: N]`
* or `[killed by signal: SIG]`, printed by the program with nothing after — is
* still indistinguishable from a real marker and would show a wrong pill. This is
* display-only (execution and the model-facing text are unaffected) and narrow;
* the complete fix is to persist a structured exit on the result event, which the
* RFC names as the escape hatch.
*/
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
}
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
function presentTaskCall(verb: string, args: { task_id: string }): ToolCallPresentation {
return { title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
}
/**
* Resolve the working directory for a bash call. Precedence: an explicit model
* `workdir` wins; otherwise default to the calling agent's session cwd
@@ -241,6 +356,8 @@ export function apply(ctx: Context): void {
if (result.aborted) throw new Error('command aborted')
return [{ type: 'text', text: renderResult(result) }]
},
presentCall: presentBashCall,
presentResult: presentBashResult,
}))
ctx.tools.register(defineTool({
@@ -265,6 +382,7 @@ export function apply(ctx: Context): void {
text += `\n${statusLine(read.task)}`
return Promise.resolve([{ type: 'text', text }])
},
presentCall: args => presentTaskCall('Read output from', args),
}))
ctx.tools.register(defineTool({
@@ -282,5 +400,6 @@ export function apply(ctx: Context): void {
text: killed ? `killed background task ${id}` : `task ${id} had already finished`,
}])
},
presentCall: args => presentTaskCall('Kill', args),
}))
}