feat: add canonical typed tool outputs
This commit is contained in:
@@ -8,10 +8,9 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
@@ -90,7 +89,26 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
|
||||
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
|
||||
},
|
||||
async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: 'string', required: true },
|
||||
before: { type: 'string', required: true },
|
||||
after: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (args, value) => [{
|
||||
type: 'text',
|
||||
text: formatEditOutput(value.path, args.replace_all ?? false),
|
||||
}],
|
||||
presentationMeta: (args, value) => ({
|
||||
diffs: computeHunkDiffs(args.file_path, value.before, value.after)
|
||||
.map(({ path, oldText, newText }) => ({ path, oldText, newText })),
|
||||
}),
|
||||
},
|
||||
async execute(args: EditToolArgs, exec) {
|
||||
const input = parseEditArgs(args)
|
||||
// Resolve the per-call sandbox mode (escalation grant > session override
|
||||
// > backend default) BEFORE anything executes.
|
||||
@@ -115,11 +133,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// 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 },
|
||||
path: target.displayPath,
|
||||
before: outcome.before,
|
||||
after: outcome.after,
|
||||
}
|
||||
},
|
||||
// Pure display: a diff card of the literal replacement (old_string → new_string), derived
|
||||
|
||||
@@ -77,6 +77,7 @@ function lineByteSize(line: string, currentLineCount: number): number {
|
||||
|
||||
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
|
||||
acc.totalLines += 1
|
||||
if (acc.done) return
|
||||
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
|
||||
const text = truncateLine(rawLine, request.maxLineLength)
|
||||
@@ -137,7 +138,6 @@ export async function buildWindow(
|
||||
appendToLineBuffer(chunk.slice(startPos, newlinePos))
|
||||
flushLine()
|
||||
startPos = newlinePos + 1
|
||||
if (acc.done) return finish(acc, request, displayPath)
|
||||
}
|
||||
appendToLineBuffer(chunk.slice(startPos))
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { buildWindow, formatReadOutput } from './read-render.ts'
|
||||
import type { FileReadOutcome } from './read-render.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
|
||||
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
|
||||
@@ -84,9 +82,46 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
||||
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: 'string', required: true },
|
||||
offset: { type: 'integer', required: true },
|
||||
lines: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
number: { type: 'integer', required: true },
|
||||
text: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
totalLines: { type: 'integer', required: true },
|
||||
},
|
||||
},
|
||||
render: (args, value) => {
|
||||
const input = parseReadArgs(args, caps.limit)
|
||||
const endLine = value.lines.at(-1)?.number ?? Math.max(0, value.offset - 1)
|
||||
const truncatedByBytes = value.lines.length < input.limit && endLine < value.totalLines
|
||||
return [{
|
||||
type: 'text',
|
||||
text: formatReadOutput(value.path, {
|
||||
offset: value.offset,
|
||||
lines: value.lines,
|
||||
totalLines: value.totalLines,
|
||||
...truncatedByBytes ? { truncatedByBytes: true } : {},
|
||||
}),
|
||||
}]
|
||||
},
|
||||
},
|
||||
// Observation races fail closed because guarded mutations re-check the version in-lock.
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
async execute(args, exec) {
|
||||
const input = parseReadArgs(args, caps.limit)
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
|
||||
|
||||
@@ -107,17 +142,17 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
target.displayPath,
|
||||
)
|
||||
|
||||
const outcome: FileReadOutcome = {
|
||||
const outcome = {
|
||||
path: target.displayPath,
|
||||
offset: input.offset,
|
||||
lines: window.lines,
|
||||
totalLines: window.totalLines,
|
||||
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens). The
|
||||
// read already succeeded; an fs/observed listener is contractually a
|
||||
// synchronous, side-effect-only recorder.
|
||||
ctx.emit('fs/observed', target, info.version, exec)
|
||||
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
|
||||
return 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
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
@@ -33,7 +32,7 @@ export function parseWriteArgs(args: { file_path: string; content: string }): {
|
||||
* @param outcome - the write outcome; its `operation` selects the Created/Updated wording.
|
||||
* @returns the model-facing confirmation envelope (no file content is echoed back).
|
||||
*/
|
||||
export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
|
||||
export function formatWriteOutput(displayPath: string, outcome: Pick<FsWriteOutcome, 'operation'>): string {
|
||||
const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
|
||||
return `<path>${displayPath}</path>
|
||||
<type>file</type>
|
||||
@@ -74,7 +73,32 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
|
||||
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
|
||||
},
|
||||
async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: 'string', required: true },
|
||||
operation: { type: 'string', required: true, enum: ['create', 'update'] },
|
||||
before: {
|
||||
required: true,
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'null' },
|
||||
],
|
||||
},
|
||||
after: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatWriteOutput(value.path, value) }],
|
||||
presentationMeta: (args, value) => ({
|
||||
diffs: value.before === null
|
||||
? []
|
||||
: computeHunkDiffs(args.file_path, value.before, value.after)
|
||||
.map(({ path, oldText, newText }) => ({ path, oldText, newText })),
|
||||
}),
|
||||
},
|
||||
async execute(args: WriteToolArgs, exec) {
|
||||
const input = parseWriteArgs(args)
|
||||
// Resolve the per-call sandbox mode (escalation grant > session override
|
||||
// > backend default) BEFORE anything executes; an escalating call
|
||||
@@ -94,12 +118,11 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// 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 } } : {},
|
||||
path: target.displayPath,
|
||||
operation: outcome.operation,
|
||||
before: outcome.before,
|
||||
after: outcome.after,
|
||||
}
|
||||
},
|
||||
// Pure display: a diff card (an editor renders write as a new-file / full- replace diff).
|
||||
|
||||
Reference in New Issue
Block a user