Merge remote-tracking branch 'origin/master' into codex/trim-ai-prose

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md
#	docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md
#	examples/coding-agent/tests/keyless-smoke.e2e.ts
#	packages/ui/acp/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-13 23:44:44 +08:00
22 changed files with 153 additions and 103 deletions

View File

@@ -2,6 +2,9 @@
* Model-facing `bash`, `bash_output`, and `bash_kill` tools over the executor
* seam. Background tasks are fenced by owning session, completion injects a
* durable notice, and confining executors add one-shot approval-based escalation.
* Notices do not wake idle agents. Ownership is stored with the executor task so
* it survives this plugin's reload; per-call authority is escalation grant,
* session override, then executor default. See the package README for the tool contract.
* @module @deepseek-ai/dsh-tool-bash
*/
@@ -104,7 +107,9 @@ const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
/**
* The bash tool's static description.
* The bash tool's byte-stable base description. Escalation guidance is added
* only when the mounted executor can honor it, as the one exception to the
* ordinary no-retry guidance.
*/
function bashDescription(escalationModes: readonly SandboxMode[]): string {
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
@@ -190,7 +195,7 @@ export function renderResult(
return body + markers.join('\n')
}
// UI presentation (tool-owned).
// Pure tool-owned presentation used for both live events and replay.
/**
* Present foreground calls as terminals and background starts as generic cards.
@@ -337,6 +342,8 @@ export function apply(ctx: Context): void {
})
// The escalation surface exists whenever the mounted executor confines.
// Advertise the closed target vocabulary globally, then enforce strict
// widening against each call's effective session mode.
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
@@ -433,9 +440,8 @@ export function apply(ctx: Context): void {
},
async execute(args: BashToolArgs, exec) {
validateBashArgs(args)
// `description` is display/logging metadata only (surfaced to UIs via the tool/call
// session event); it is intentionally not forwarded to ctx.bash and has no effect on
// execution.
// `description` is display/logging metadata only. Escalation approval
// completes before execution; grant > session override > executor default.
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)

View File

@@ -26,9 +26,10 @@ declare module 'cordis' {
}
/**
* Abstract compaction service. Subclass implement the two abstract methods, and load the
* subclass as a plugin — it registers as `ctx.compact` (one implementation per context;
* loading a second throws, which is cordis' standard duplicate-service behavior).
* Abstract compaction service. Implementations own token estimation, retention,
* and summarization, but a successful run must replace the selected surface span
* with one summary node and prevent concurrent compaction of the same session.
* Load one implementation per context as `ctx.compact`.
*/
export abstract class CompactService extends Service {
constructor(ctx: Context) {
@@ -37,12 +38,17 @@ export abstract class CompactService extends Service {
/**
* Check token pressure and compact if the conversation is too large.
* Estimate the next request, including its session prefix, derived history,
* and system prompt. Above threshold, compact a head-anchored range ending at
* a balanced tool boundary and reconsolidate any prior automatic checkpoint.
* Return `null` when no compaction is needed or an open tail leaves no safe
* cutoff. A single oversized retained unit or prefix cannot be repaired here.
*
* @param agent - agent context owning the session surface and model options.
* @param fullSystemPrompt - assembled system prompt, counted toward the estimate.
* @param sessionPrefix - the instance's composed session prefix, counted toward the
* estimate.
* @param signal - cancellation signal.
* @param signal - cancellation signal; model-backed implementations must forward it.
* @returns the compaction result, or `null` if no compaction was needed.
*/
abstract compactIfNeeded(
@@ -54,13 +60,18 @@ export abstract class CompactService extends Service {
/**
* Forcibly compact a range of surface nodes into a single summary node.
* `start` and `end` name an inclusive span by surface position, not numeric seq
* order; replacements can make visible seqs non-monotonic. Both edges must be
* balanced so assistant tool calls remain paired with their results. A model-
* backed implementation forwards cancellation and rejects active, missing,
* reversed, or unbalanced ranges.
*
* @param session - session to mutate.
* @param start - first surface seq, inclusive.
* @param end - last surface seq, inclusive.
* @param agent - summarizer context.
* @param signal - optional cancellation; model-backed implementations must forward it.
* @throws when compaction is active or the range is invalid or unbalanced.
* @throws when compaction is active or the range is missing, reversed, or unbalanced.
* @returns the replaced range and summary.
*/
abstract compactRegion(

View File

@@ -258,7 +258,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/pre-step',
mode: 'serial',
signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void',
summary: 'Awaited checkpoint before `step/start` for outside-step surface mutations.',
summary: 'Awaited serial checkpoint after prompt assembly and before `step/start`.',
},
{
name: 'agent/prompt-submit',
@@ -282,7 +282,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/session-prefix',
mode: 'waterfall',
signature: '\'agent/session-prefix\'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>',
summary: 'Compose the frozen session-stable request prefix once per loop instance.',
summary: 'Compose request-only messages placed before derived history.',
},
{
name: 'agent/session-start',

View File

@@ -132,6 +132,8 @@ interface FactorySlot {
*/
export class AgentRegistry extends Service {
private store = new Map<AgentId, AgentEntry>()
// TODO(agent-entry-mirror): derive exact-object checks from store.get(agent.id)
// plus entry.agent identity; this WeakMap mirrors the authoritative id map.
private entries = new WeakMap<Agent, AgentEntry>()
private factory: FactorySlot | undefined

View File

@@ -104,14 +104,17 @@ export interface Agent {
/**
* Append model-facing context without running the model. Idle injection uses
* a one-shot turn and durability checkpoint; disposal awaits that checkpoint,
* and flush failures are reported through `agent/error`.
* a one-shot turn and durability checkpoint, while injection during an open
* turn joins it at the current log position. Disposal awaits idle checkpoints;
* flush failures are reported through `agent/error`, not thrown to the caller.
*/
inject(content: ContentBlock[], options?: SendOptions): void
/**
* Clear queued and steering work, including work waiting to start, and abort
* the active step. Idle cancellation is a no-op and does not arm a later cancel.
* the active step. The supplied reason is preserved across pre-step and active
* cancellation windows, and `whenIdle()` resolves after cancellation reaches
* quiescence. Idle cancellation is a no-op and does not arm a later cancel.
*/
cancel(reason?: string): void
@@ -165,7 +168,9 @@ declare module 'cordis' {
// ---- session lifecycle (emit) ----
/**
* The session lifecycle began, once before the first turn. Use
* `agent.inject()` to seed model-facing context.
* `agent.inject()` to seed model-facing context. This is a notification, not
* a veto; disposal requested by a lifecycle owner is rechecked before the
* driver starts.
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
@@ -177,7 +182,11 @@ declare module 'cordis' {
// ---- step/request extension seams (serial + waterfall) ----
/**
* Awaited checkpoint before `step/start` for outside-step surface mutations.
* Awaited serial checkpoint after prompt assembly and before `step/start`.
* Listeners may mutate the session surface outside the pending step; the loop
* derives history once afterward, so compaction records and replacements are
* included without rewriting an assembled request. The prompt and prefix are
* the exact pressure inputs for that request, and `signal` cancels listener work.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - the agent opening the step.
* @param turn - the open turn number.
@@ -212,8 +221,11 @@ declare module 'cordis' {
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Compose the frozen session-stable request prefix once per loop instance.
* Interrupted composition is discarded; changing context belongs in history.
* Compose request-only messages placed before derived history. The frozen
* result is computed once per loop instance, logged on its anchoring request
* header, and reused so the provider prefix remains stable. Interrupted
* composition is discarded. Changing context belongs in history; contributors
* should prepend to `await next()` to preserve registration order.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - the agent whose session prefix is being composed.
* @param prefix - the frozen seed; return an extended replacement.

View File

@@ -17,6 +17,9 @@ export function SessionId(id: string): SessionId {
* The on-disk session format version, stamped into every newly-written {@link SessionHeader}
* and enforced by every persistence backend on load. The single source of truth for the
* version — write sites and the load-time check all read it.
* While the harness is unreleased it is pinned at `0`: no compatibility is
* implied, incompatible logs are rejected, and no migration is provided. A
* monotonic version policy starts with the first tagged release.
*/
export const SESSION_FORMAT_VERSION = 0

View File

@@ -65,6 +65,8 @@ export interface PromptSection {
export interface AssembledSection {
/** The contributing section's unique name. */
name: string
// TODO(assembled-section-order): drop this output field; registry order has
// already sorted the array, and no production renderer/listener reads it.
/** The contributing section's order (sections arrive sorted ascending). */
order: number
/** The resolved (but not yet interpolated) section text. */

View File

@@ -174,6 +174,8 @@ export function apply(ctx: Context, config: Config): void {
throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`)
}
// TODO(agent-keyed-repeat-chain): key a WeakMap by the Agent itself; that
// removes the disposal-only status listener and cannot collide on id reuse.
const chains = new Map<AgentId, Chain>()
/** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */

View File

@@ -51,6 +51,8 @@ export const Config: z<Config> = z.object({
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
// TODO(single-default-literal): share this schema default and the defensive
// apply() fallback through one named constant while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
skills: agentCore.SkillConfigSchema,
})

View File

@@ -1,7 +1,8 @@
/**
* Multi-session ACP server bridge over JSON-RPC stdio. Creates or resumes
* agents, routes their events, settles prompts by turn, and answers approvals.
* Stdout is reserved for protocol frames.
* Each session keeps independent presentation and prompt-correlation state so
* concurrent streams cannot cross. Stdout is reserved for protocol frames.
* @module @deepseek-ai/dsh-acp
*/
@@ -72,7 +73,8 @@ import {
} from './codec.ts'
export const name = 'acp'
// Interface services required by advertised load, presentation, and interaction capabilities.
// Interface services back advertised loading, tool-owned presentation with a generic fallback, and interaction.
// TODO(acp-session-inject): remove `sessions`; the bridge never reads it, and ownership is already behind `agents`.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
/** Build an ACP invalid-params error with visible human detail. */
@@ -227,7 +229,10 @@ interface SessionRecord {
reject: (error: Error) => void
turn: number | undefined
} | undefined
/** Idle config changes awaiting a turn-enclosed log anchor; last write wins. */
/**
* Idle config changes awaiting a turn-enclosed log anchor; last write wins.
* Responses overlay them, but a restart before the next turn restores the logged fold.
*/
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
}
@@ -238,7 +243,7 @@ interface SessionRecord {
* correlation in a `finally` so presentation failure cannot starve settlement.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// Capture injected services while executing inside this plugin's fiber.
// Handlers run later outside this injection scope, so capture services now.
const agents = ctx.agents
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
@@ -247,7 +252,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
// Presenter failures are logged and contained per session or replay.
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
// Keep forward and reverse session indexes in lockstep.
// TODO(derive-acp-session-id): derive event ids from `agent.session`, verify ownership, then remove the reverse map.
// Agent events currently carry only the Agent, so retain `SessionRecord.sessionId` and update both indexes together.
// Dropping the forward record lets the weak reverse entry expire.
const sessions = new Map<SessionId, SessionRecord>()
const bySession = new WeakMap<Agent, SessionId>()
// Reserve ids across asynchronous resume; distinct ids still load concurrently.
@@ -1025,6 +1032,7 @@ export function streamSessionEventUpdate(
/**
* Map a whole harness todo list to an ACP plan, assigning medium priority.
* Statuses map directly and ACP replaces its whole plan on each update.
* @param todos - the harness todo list (the whole list, not a diff).
* @returns the ACP plan body, one entry per todo.
*/
@@ -1045,6 +1053,7 @@ const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined
/**
* Resolve tool-owned call/result views with generic fallbacks. Per-session
* call-id state supplies the tool name and arguments omitted from result events.
* Each entry is consumed by its result; any remainder dies with the session.
*/
export class ToolPresenter {
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()

View File

@@ -64,6 +64,8 @@ export const Config: z<Config> = z.object({
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
// TODO(single-default-literal): share these schema defaults and defensive
// apply() fallbacks through named constants while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
welcome: z.string().default('ready.'),
skills: agentCore.SkillConfigSchema,

View File

@@ -26,6 +26,8 @@ export const inject = ['agents', 'userInteraction']
export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */
welcome?: string
// TODO(fixed-stdio-agent): this app-internal plugin is mounted only for the
// precreated `main` agent; remove configurability and its config-only test.
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
agent?: string
}

View File

@@ -4,7 +4,7 @@ The model-facing **`workflow` tool**: run a JavaScript orchestration script that
## What the model sees
Two parameters: `script` (required — the full `export const meta = {...}` + body text; the tool DESCRIPTION carries the complete authoring contract: hooks, semantics, the supported schema subset) and `args` (optional JSON object exposed to the script as the `args` global; a bare list is wrapped as a field, a deliberate deviation from Claude Code's any-JSON `args` so the wire schema stays honest). The plugin also contributes a `tool:<toolName>` system-prompt section carrying the usage policy — use the tool only on an explicit user ask for a workflow / large orchestration; prefer plain subagent calls for one or two delegations — per the convention that tool guidance ships with the tool plugin, never in the deployment persona.
Three parameters: `meta` (required identity data: `name`, `description`, and optional progress annotations), `script` (required plain JavaScript body — no `export const meta` statement; the tool description carries the complete authoring contract), and `args` (optional JSON object exposed to the script as the `args` global; wrap a bare list in a field so the wire schema stays honest). The plugin also contributes a `tool:<toolName>` system-prompt section carrying the usage policy — use the tool only on an explicit user ask for a workflow / large orchestration; prefer plain subagent calls for one or two delegations — per the convention that tool guidance ships with the tool plugin, never in the deployment persona.
## Lifecycle
@@ -12,7 +12,7 @@ Collection is SYNCHRONOUS this cut (like [`dsh-tool-subagent`](../../subagent/to
## Render intent
Decided up front (per the [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: <meta.name>`, the name sniffed TEXTUALLY from `args.script` (presentation must be a pure function of args, so it cannot ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card.
Decided up front (per the [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: <meta.name>`, read directly from `args.meta.name` (presentation is a pure function of args and does not ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card.
## Config

View File

@@ -152,7 +152,9 @@ export function isFatalWorkflowError(error: unknown): boolean {
/**
* Workflow execution seam. Invalid requests throw before publication; a live
* run is holder-owned, its result never rejects, cancellation and disposal are
* bounded, and disposal waits for child cleanup within that bound.
* bounded, and disposal waits for child cleanup within that bound. Lifecycle
* listener failures are contained, and `workflow/end` fires exactly once as the
* result settles.
*/
export abstract class WorkflowService extends Service {
constructor(ctx: Context) {