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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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'] }>()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user