Merge remote-tracking branch 'origin/master' into pr-265
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/rfc/INDEX.md # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl # packages/core/agent-loop/README.md # packages/core/agent-loop/src/loop.ts # packages/core/tools/README.md # packages/core/tools/src/index.ts # packages/core/tools/src/schema.ts # packages/ui/acp/src/index.ts # packages/ui/stdio-agent/README.md
This commit is contained in:
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* Result-time contextual-diff computation for the `write`/`edit` tools. Turns a
|
||||
* before/after pair of file texts into one {@link FileDiff} per applied hunk —
|
||||
* each hunk's `oldText`/`newText` reconstructed from the unified-diff lines with
|
||||
* ±{@link DIFF_CONTEXT} surrounding context lines, matching how claude-agent-acp
|
||||
* renders an editor inline diff.
|
||||
*
|
||||
* This is display-only presentation vocabulary (a UI concern), so it lives in
|
||||
* the model-facing tool, NOT the `dsh-fs` storage seam — the backend returns
|
||||
* only the raw before/after text (storage facts) and the tool computes the diff.
|
||||
*
|
||||
* Result-time contextual diff presentation for write and edit. Storage returns before/after
|
||||
* text; this model-facing layer derives one three-line-context card per applied hunk.
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/diff
|
||||
*/
|
||||
|
||||
@@ -29,18 +21,12 @@ export const DIFF_CONTEXT = 3
|
||||
export type FsDiffMeta = { diffs: FileDiff[] }
|
||||
|
||||
/**
|
||||
* Compute one {@link FileDiff} per hunk between `before` and `after`, each
|
||||
* carrying the applied change plus {@link DIFF_CONTEXT} context lines. Returns an
|
||||
* empty array when the texts are identical (no hunks). For a scattered
|
||||
* `replace_all` edit the patch yields multiple hunks, so multiple `FileDiff`s
|
||||
* come back — matching the editor rendering one diff block per site.
|
||||
* Compute one {@link FileDiff} per hunk between `before` and `after`, each carrying the
|
||||
* applied change plus {@link DIFF_CONTEXT} context lines. Pure insertions use `oldText: null`,
|
||||
* patch-only no-newline markers are omitted, and scattered replacements remain separate hunks.
|
||||
*
|
||||
* Each hunk's `oldText` is its `-` (removed) and context lines joined by `\n`;
|
||||
* `newText` is its `+` (added) and context lines. A hunk with no old lines
|
||||
* (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring
|
||||
* the call-time card's new-file convention. The unified-diff "\ No newline at end
|
||||
* of file" markers are dropped — they annotate the patch, not file content.
|
||||
* @param path - the path stamped on every produced diff (the model-facing `file_path`; the bridge relativizes it).
|
||||
* @param path - the path stamped on every produced diff (the model-facing `file_path`; the
|
||||
* bridge relativizes it).
|
||||
* @param before - the file text before the change (the backend's LF-normalized diff basis).
|
||||
* @param after - the file text after the change, on the same basis.
|
||||
* @returns one diff per applied hunk, in file order; empty when the texts are identical.
|
||||
@@ -81,14 +67,10 @@ function isFileDiff(value: unknown): value is FileDiff {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an opaque `tool/result` `meta` back to this tool's {@link FileDiff}
|
||||
* hunks, or `undefined` when it is absent/malformed. `presentResult` runs on
|
||||
* arbitrary logged `meta` (possibly from an older shape or a hand-edited log), so
|
||||
* it validates defensively rather than trusting the payload — a bad `meta` yields
|
||||
* `undefined`, and the caller decides the fallback (edit → the generic result
|
||||
* rendering; write → an args-derived whole-file diff), never a thrown presenter.
|
||||
* @param meta - the opaque `tool/result` meta payload (live or replayed from the session log).
|
||||
* @returns the validated non-empty hunk list, or undefined for an absent/empty/malformed payload.
|
||||
* Narrow opaque live or replayed result metadata to non-empty file diffs. Malformed metadata
|
||||
* returns `undefined` so presentation can fall back instead of throwing during replay.
|
||||
* @param meta - result metadata.
|
||||
* @returns validated hunks, or `undefined` for absent or malformed data.
|
||||
*/
|
||||
export function diffsFromMeta(meta: unknown): FileDiff[] | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
/**
|
||||
* The model-facing `edit` tool: update an existing UTF-8 text file by replacing
|
||||
* literal text, requiring a unique match by default. The tool is the executor:
|
||||
* it dispatches the `fs/edit-intent` waterfall to obtain the optional
|
||||
* version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The
|
||||
* default thunk returns `undefined` (unconditional edit of the current content
|
||||
* — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`)
|
||||
* occupies the single decision slot, returning `{ version: vObserved }` or
|
||||
* throwing `FS_NOT_OBSERVED` for an unread file. The tool stats ZERO times
|
||||
* either way; a missing target is reported by the provider as `FS_STALE_VERSION`.
|
||||
*
|
||||
* Model-facing literal edit, unique-match by default. It obtains an optional guard from the
|
||||
* single intent slot, calls `ctx.fs.editText` without a separate stat, then records the observed
|
||||
* version; no policy means an unconditional atomic edit.
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/edit
|
||||
*/
|
||||
|
||||
@@ -96,22 +89,16 @@ export function applyEditTool(ctx: Context): void {
|
||||
)
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// The result-time applied-hunk diff (before→after with context lines). An
|
||||
// edit always changes content (parseEditArgs requires old_string to differ
|
||||
// and editText matches at least once), so there is always at least one hunk.
|
||||
// The bridge renders these as an inline diff that supersedes the call-time
|
||||
// snippet; the display path is the model-facing `file_path` (the bridge
|
||||
// relativizes it).
|
||||
// An edit necessarily changes content, so result metadata carries at least one applied hunk.
|
||||
const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after)
|
||||
return {
|
||||
content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }],
|
||||
meta: { diffs },
|
||||
}
|
||||
},
|
||||
// Pure display: a diff card of the literal replacement (old_string →
|
||||
// new_string), derived from the call args. `oldText: old_string || null`
|
||||
// matches claude-agent-acp's Edit arm; new_string is a required arg here, so
|
||||
// it maps straight to newText. A follow-along location points at the file.
|
||||
// Pure display: a diff card of the literal replacement (old_string → new_string), derived
|
||||
// from the call args. `oldText: old_string || null` matches claude-agent-acp's Edit arm;
|
||||
// new_string is a required arg here, so it maps straight to newText.
|
||||
presentCall(args): DiffCallView {
|
||||
return {
|
||||
card: 'diff',
|
||||
@@ -120,10 +107,8 @@ export function applyEditTool(ctx: Context): void {
|
||||
locations: [{ path: args.file_path }],
|
||||
}
|
||||
},
|
||||
// Result-time display: the applied contextual-diff hunks carried on `meta`.
|
||||
// On success with diffs, a `diff` result card supersedes the call-time
|
||||
// snippet; on error (nothing applied) or malformed meta, fall through to the
|
||||
// generic "updated successfully" rendering.
|
||||
// Applied metadata replaces the call-time snippet; errors or malformed replay metadata use
|
||||
// the generic result rendering.
|
||||
presentResult(args, result: ToolResult): DiffResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const diffs = diffsFromMeta(result.meta)
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
/**
|
||||
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the
|
||||
* `ctx.fs` provider seam. This single plugin registers all three tools.
|
||||
*
|
||||
* ## The tool is the executor; policy is an event gate
|
||||
*
|
||||
* The tool reads/writes/edits through `ctx.fs` DIRECTLY and owns model-facing
|
||||
* concerns only — tool names, JSON schemas, argument validation, prompt
|
||||
* sections, read windowing, result formatting. It does NOT inject a policy
|
||||
* service. Instead, on each write/edit it dispatches a single-slot waterfall
|
||||
* (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and
|
||||
* after every read/write/edit it emits `fs/observed` with a plain (unguarded)
|
||||
* `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the
|
||||
* decision slot and listens for `fs/observed` to add observed-state +
|
||||
* read-before-edit + version-guarded write/edit; a deployment that loads these
|
||||
* tools is expected to also load it. With no policy plugin the waterfalls fall
|
||||
* through to their `undefined` default (the unconstrained bare provider) and
|
||||
* `fs/observed` is unheard — the tool still functions. This package never
|
||||
* imports `node:fs`, `node:path`, or an `@deepseek-ai/dsh-fs-local`
|
||||
* implementation.
|
||||
*
|
||||
* Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation,
|
||||
* read windows, formatting, and observation events, never a concrete provider. An optional
|
||||
* event policy supplies mutation guards; without one the tools use unconditional provider calls.
|
||||
* @module @deepseek-ai/dsh-tool-fs
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
/**
|
||||
* Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's
|
||||
* decoded text into a bounded, line-numbered window (offset/limit, byte cap,
|
||||
* per-line truncation) and format it as the model-facing text block. This is
|
||||
* the `read` tool's RENDERING detail — not a storage primitive, not freshness
|
||||
* policy — so it lives apart from the tool's I/O and event wiring as a pure,
|
||||
* independently-testable module (no cordis, no filesystem).
|
||||
*
|
||||
* The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
|
||||
* (UTF-8 validated, binary rejected); {@link buildWindow} only scans that text
|
||||
* for newlines and builds the requested window. A capped line buffer means a
|
||||
* newline-free giant line can never balloon memory even when streamed.
|
||||
* {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the
|
||||
* `<path>/<content>` envelope the model sees.
|
||||
*
|
||||
* Pure read presentation: turn provider-decoded text into a bounded, line-numbered window and
|
||||
* model-facing envelope. Chunk scanning caps the current line, so even one newline-free giant
|
||||
* line cannot grow memory without bound.
|
||||
* @module @deepseek-ai/dsh-tool-fs/read-render
|
||||
*/
|
||||
|
||||
@@ -113,12 +102,8 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bounded, line-numbered window from a file's decoded text chunks.
|
||||
* Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
|
||||
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
|
||||
* path serves both. Scans for newlines with a capped line buffer (a newline-free
|
||||
* giant line is truncated, never buffered past `request.maxLineLength`),
|
||||
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
|
||||
* Build one window from streamed or whole-file chunks, enforcing line and byte caps and throwing
|
||||
* `FS_NOT_FOUND` when the requested offset is past EOF.
|
||||
* @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
|
||||
* @param request - the resolved window; the caller has already applied its defaults and caps.
|
||||
* @param displayPath - the caller-facing path used in the offset-out-of-range error.
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* The model-facing `read` tool: inspect a UTF-8 text file and return
|
||||
* line-numbered content with pagination guidance. The tool is the executor — it
|
||||
* stats and reads through `ctx.fs` directly, builds the line window
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed`
|
||||
* so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With
|
||||
* no policy plugin the emit is simply unheard. This module owns the
|
||||
* model-facing schema, argument validation, and the read I/O; the rendering
|
||||
* (windowing + formatting) lives in `read-render.ts` and the
|
||||
* freshness/observation policy is not its concern.
|
||||
*
|
||||
* Model-facing UTF-8 read. It performs one provider stat for type, routing, and observed version,
|
||||
* streams large or size-unknown files, renders a bounded window, then emits the observation.
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/read
|
||||
*/
|
||||
|
||||
@@ -105,9 +97,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
|
||||
// One stat: type check + size routing + the version recorded as observed.
|
||||
// A writer racing between this stat and the read can at worst make a LATER
|
||||
// guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText
|
||||
// re-checks the version in its lock).
|
||||
// A concurrent write can only make a later guarded mutation fail stale and require reread.
|
||||
const info = await ctx.fs.stat(target, exec.signal)
|
||||
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
@@ -135,12 +125,10 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
ctx.emit('fs/observed', target, info.version, exec)
|
||||
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
// Pure display: a generic card titled by the file with the read window
|
||||
// appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along
|
||||
// location whose line is the read's offset (defaulting to 1). The window is
|
||||
// derived from the RAW args (offset/limit as the model passed them), NOT the
|
||||
// tool's defaulted 1/configured limit, so an unbounded read shows a bare
|
||||
// title (and the presenter stays a pure function of args, config-free).
|
||||
// Pure display: a generic card titled by the file with the read window appended (`Read
|
||||
// foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the
|
||||
// read's offset (defaulting to 1). The window reflects raw args, so an omitted limit keeps
|
||||
// the title bare instead of smuggling config into this pure presenter.
|
||||
presentCall(args): GenericCallView {
|
||||
const { offset, limit } = args
|
||||
const window = limit !== undefined && limit > 0
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
/**
|
||||
* Derive the working directory a filesystem tool resolves relative paths
|
||||
* against: the calling agent's per-session workspace
|
||||
* (`exec.agent.session.header.cwd`), so each ACP session's `read`/`write`/`edit`
|
||||
* act on ITS workspace, not the server's launch dir — mirroring how
|
||||
* Derive the working directory a filesystem tool resolves relative paths against: the calling
|
||||
* agent's per-session workspace (`exec.agent.session.header.cwd`), so each ACP session's
|
||||
* `read`/`write`/`edit` act on ITS workspace, not the server's launch dir — mirroring how
|
||||
* `dsh-tool-bash` defaults a bash `workdir` to the session cwd.
|
||||
*
|
||||
* The `agent` is optional-chained — a non-agent caller yields `undefined`, and
|
||||
* the tool then calls `ctx.fs.resolve(path)` with no base so the backend applies
|
||||
* its own configured default (preserving the non-ACP / no-session behavior).
|
||||
* `session`/`header` are non-optional on a real `Agent`, so only `agent` needs
|
||||
* the guard (mirroring `dsh-tool-bash`'s `resolveWorkdir`). Returning `undefined`
|
||||
* rather than reading `process.cwd()` here keeps the default in ONE place (the
|
||||
* provider), per the "explicit > implicit at seams" convention.
|
||||
*
|
||||
* Non-agent calls return `undefined`, leaving the fallback in the provider rather than reading
|
||||
* `process.cwd()` at the tool seam.
|
||||
* @module @deepseek-ai/dsh-tool-fs/session-cwd
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
/**
|
||||
* The model-facing `write` tool: create or fully replace a UTF-8 text file. The
|
||||
* tool is the executor: it dispatches the `fs/write-intent` waterfall to
|
||||
* obtain the optional version guard, calls `ctx.fs.writeText` directly, and
|
||||
* emits `fs/observed`. The default thunk returns `undefined` (unconditional
|
||||
* create-or-overwrite — the bare provider); a policy plugin
|
||||
* (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and
|
||||
* returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO
|
||||
* times either way.
|
||||
*
|
||||
* Model-facing full-file write. It obtains an optional intent from the single policy slot, calls
|
||||
* `ctx.fs.writeText` without a stat, then records the resulting version; no policy means an
|
||||
* unconditional atomic create-or-overwrite.
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/write
|
||||
*/
|
||||
|
||||
@@ -75,20 +69,17 @@ export function applyWriteTool(ctx: Context): void {
|
||||
const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal)
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version
|
||||
// exists). A create has no "before" — `outcome.before` is null — so it
|
||||
// carries no `meta`; `presentResult` then renders a whole-file diff from the
|
||||
// args, so the completed card is still a diff (never the result text).
|
||||
// Overwrites carry applied hunks. Creates have no prior text, so result presentation uses
|
||||
// the args-derived whole-file diff instead.
|
||||
const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : []
|
||||
return {
|
||||
content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }],
|
||||
...diffs.length > 0 ? { meta: { diffs } } : {},
|
||||
}
|
||||
},
|
||||
// Pure display: a diff card (an editor renders write as a new-file / full-
|
||||
// replace diff). `oldText: null` — a call-time presenter has no access to the
|
||||
// file's prior content, so even an overwrite renders new-file style, matching
|
||||
// claude-agent-acp. A follow-along location points at the written file.
|
||||
// Pure display: a diff card (an editor renders write as a new-file / full- replace diff).
|
||||
// `oldText: null` — a call-time presenter has no access to the file's prior content, so
|
||||
// even an overwrite renders new-file style, matching claude-agent-acp.
|
||||
presentCall(args): DiffCallView {
|
||||
return {
|
||||
card: 'diff',
|
||||
@@ -97,14 +88,10 @@ export function applyWriteTool(ctx: Context): void {
|
||||
locations: [{ path: args.file_path }],
|
||||
}
|
||||
},
|
||||
// Result-time display: a `diff` card so the completed `tool_call_update`
|
||||
// re-installs the diff rather than the model-facing result text (an ACP
|
||||
// `tool_call_update.content` REPLACES the call's content, so a text result
|
||||
// would clobber the pending diff card). An OVERWRITE uses the applied
|
||||
// contextual hunks on `meta`; a CREATE has no `meta` (no prior content), so
|
||||
// its whole-file new-file diff is derived from `args.content` (replay-safe,
|
||||
// matching the call-time card). An error falls through to generic rendering
|
||||
// so its message shows.
|
||||
// Result-time display: a `diff` card so the completed `tool_call_update` re-installs the
|
||||
// diff rather than the model-facing result text (an ACP `tool_call_update.content` REPLACES
|
||||
// the call's content, so a text result would clobber the pending diff card). Overwrites use
|
||||
// applied metadata; creates and identical overwrites use the replay-safe args fallback.
|
||||
presentResult(args, result: ToolResult): DiffResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const diffs = diffsFromMeta(result.meta)
|
||||
|
||||
Reference in New Issue
Block a user