feat: add canonical typed tool outputs

This commit is contained in:
Tianyi Cui
2026-07-21 03:08:35 +08:00
parent 8500974fd4
commit 66c36e7325
173 changed files with 3298 additions and 954 deletions
+39 -15
View File
@@ -12,7 +12,6 @@
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 { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
@@ -20,6 +19,7 @@ import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
* Default cap on paths retained inline by one `glob` call (the `globMaxResults`
@@ -117,6 +117,14 @@ export function formatGlobOutput(retained: RetainedItems<string>, spillRef: Spil
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
}
/** Retain and format one canonical path list for the Native surface. */
function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string {
if (paths.length === 0) return 'No files found'
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
return formatGlobOutput(retainer.finish(), spillRef)
}
/**
* Pending-call presentation: a search card titled by the pattern (and root).
*
@@ -142,7 +150,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.',
})
ctx.tools.register(defineTool({
const tool = defineTool({
name: 'glob',
description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, '
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
@@ -152,28 +160,44 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' },
},
timeoutMs: caps.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
paths: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }],
},
async execute(args, exec) {
const input = parseGlobArgs(args)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return [{ type: 'text', text: 'No files found' }]
if (run.noMatches) return { paths: [] }
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: caps.maxResults })
const all: string[] = []
for (const line of run.stdout.split('\n')) {
if (line.length === 0) continue
const displayPath = toWorkdirRelative(line, run.workdir)
all.push(displayPath)
retainer.push(displayPath)
}
const retained = retainer.finish()
// The complete sorted list is the recovery artifact; save it only when
// the inline page omitted paths (an uncapped result needs no spill file).
const spillRef = retained.truncated
? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n'))
: undefined
return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }]
return { paths: all }
},
presentCall: presentGlobCall,
}))
})
ctx.tools.register(tool)
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined
if (value === undefined) return decision
const paths = value.paths
if (paths.length <= caps.maxResults) return decision
const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n'))
return {
kind: 'accept',
content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})
}
+69 -22
View File
@@ -13,7 +13,6 @@
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 { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
@@ -21,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
* Default cap on flat matches retained inline by one `grep` call (the
@@ -241,6 +241,20 @@ export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: S
return `${header}\n\n${body}\n\n(${recovery})`
}
/** Apply the Native per-line preview budget without changing the canonical matches. */
function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] {
return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) }))
}
/** Retain and format one canonical match list for the Native surface. */
function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string {
if (matches.length === 0) return 'No matches found'
const previewed = previewGrepMatches(matches, maxLineBytes)
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
for (const match of previewed) retainer.push(match)
return formatGrepOutput(retainer.finish(), spillRef)
}
/**
* Pending-call presentation: a search card titled by the pattern (and target /
* include filter).
@@ -268,7 +282,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.',
})
ctx.tools.register(defineTool({
const tool = defineTool({
name: 'grep',
description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. '
+ `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. `
@@ -279,37 +293,70 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' },
},
timeoutMs: caps.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
matches: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
lineNumber: { type: 'integer', required: true },
line: { type: 'string', required: true },
},
},
},
},
},
render: (_args, value) => [{
type: 'text',
text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes),
}],
},
async execute(args, exec) {
const input = parseGrepArgs(args)
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return [{ type: 'text', text: 'No matches found' }]
if (run.noMatches) return { matches: [] }
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: caps.maxMatches })
const all: GrepMatch[] = []
for (const raw of parseGrepMatches(run.stdout)) {
const match: GrepMatch = {
path: toWorkdirRelative(raw.path, run.workdir),
lineNumber: raw.lineNumber,
line: previewLine(raw.line, caps.maxLineBytes),
line: raw.line,
}
all.push(match)
retainer.push(match)
}
const retained = retainer.finish()
// The spill file stores the FULL formatted match list (same grouped,
// per-line-previewed shape the model saw), so read offset/limit pages the
// same logical result; save only when the inline page omitted matches.
const spillRef = retained.truncated
? await trySaveFormattedResult(
ctx,
exec,
'grep-results.txt',
`Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`,
)
: undefined
return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }]
return { matches: all }
},
presentCall: presentGrepCall,
}))
})
ctx.tools.register(tool)
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { matches: GrepMatch[] } | undefined
if (value === undefined) return decision
const matches = value.matches
if (matches.length <= caps.maxMatches) return decision
const spillRef = await trySaveFormattedResult(
ctx,
exec,
'grep-results.txt',
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`,
)
return {
kind: 'accept',
content: [{
type: 'text',
text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef),
}],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})
}
+27
View File
@@ -0,0 +1,27 @@
/** Shared surface-only post-policy selection for search result spill. @module dsh-tool-fs-search/surface */
import type { Context } from 'cordis'
import type { JsonValue, PostToolDecision, ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
/**
* Return the accepted canonical value only when this tool still owns a direct
* successful surface call and no downstream policy replaced either projection.
* @param ctx - the tool plugin context used to resolve the live scoped owner.
* @param tool - the exact registered definition whose value may be projected.
* @param exec - the completed execution identity.
* @param result - the canonical result before post-policy decisions are applied.
* @param decision - the composed downstream post-policy decision.
* @returns the canonical value to project, or `undefined` when spill must defer.
*/
export function acceptedSurfaceValue(
ctx: Context,
tool: ToolDefinition,
exec: ToolExecution,
result: ToolExecutionResult,
decision: PostToolDecision,
): JsonValue | undefined {
if (decision.kind !== 'accept' || decision.content !== undefined || Object.hasOwn(decision, 'value')
|| exec.parent !== undefined || exec.name !== tool.name || result.isError
|| ctx.tools.get(exec.name, exec.agent) !== tool) return undefined
return result.value
}