feat: add canonical typed tool outputs
This commit is contained in:
@@ -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 } : {},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 } : {},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user