tinker-agent 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/README.md +64 -6
- package/package.json +1 -1
- package/src/agent/loop.ts +13 -0
- package/src/agent/runtime-session.ts +165 -0
- package/src/agent/session-ledger.ts +20 -3
- package/src/cli/config.ts +11 -2
- package/src/cli/model-profiles.ts +58 -0
- package/src/cli/public-config-contract.ts +73 -7
- package/src/cli/run-runner.ts +4 -1
- package/src/cli/runner-dependencies.ts +28 -4
- package/src/cli/tui-memory.ts +4 -0
- package/src/cli/tui-runner.tsx +8 -1
- package/src/context/context-automation-policy.ts +22 -21
- package/src/context/context-manager.ts +91 -15
- package/src/context/context-policy.ts +0 -2
- package/src/context/context-swap-renderer.ts +1 -1
- package/src/context/prefix-retirement-planner.ts +58 -8
- package/src/context/recall-retirement-contract.ts +5 -4
- package/src/context/swap-planner.ts +33 -27
- package/src/model/fake-model-client.ts +26 -16
- package/src/model/model-api.ts +12 -0
- package/src/model/model-client.ts +9 -1
- package/src/model/moonshot-input-token-estimator.ts +5 -1
- package/src/model/openai-chat-mapping.ts +2 -24
- package/src/model/openai-chat-model-client.ts +18 -294
- package/src/model/openai-image-mapping.ts +20 -0
- package/src/model/openai-model-utils.ts +304 -0
- package/src/model/openai-responses-mapping.ts +532 -0
- package/src/model/openai-responses-model-client.ts +295 -0
- package/src/model/openai-responses-stream.ts +96 -0
- package/src/model/openai-responses-token-estimator.ts +155 -0
- package/src/model/reasoning-effort.ts +60 -0
- package/src/session/session-catalog.ts +2 -2
- package/src/session/session-history-reader.ts +6 -1
- package/src/session/session-schema.ts +268 -4
- package/src/session/session-store.ts +105 -26
- package/src/skills/skill-context.ts +2 -2
- package/src/tools/bounded-output-preview.ts +276 -0
- package/src/tools/recall.ts +67 -36
- package/src/tools/registry.ts +7 -2
- package/src/tools/task-output-snapshot.ts +6 -22
- package/src/tools/task-output.ts +23 -27
- package/src/tui/app.tsx +82 -5
- package/src/tui/components/prompt-input.tsx +9 -1
- package/src/tui/slash-commands.ts +20 -0
- package/src/tui/tui-session-controller.ts +7 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
|
|
3
|
+
export const MAX_PREVIEW_LINES = 200;
|
|
4
|
+
export const PREVIEW_EDGE_LINES = 100;
|
|
5
|
+
export const MAX_PREVIEW_BYTES = 32 * 1024;
|
|
6
|
+
export const MAX_PREVIEW_LINE_BYTES = 8 * 1024;
|
|
7
|
+
|
|
8
|
+
export type BoundedOutputPreview = {
|
|
9
|
+
preview: string;
|
|
10
|
+
truncated: boolean;
|
|
11
|
+
omittedLines?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type OutputPreviewSource =
|
|
15
|
+
| {
|
|
16
|
+
outputLines: number;
|
|
17
|
+
lines: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
outputLines: number;
|
|
21
|
+
firstLines: readonly string[];
|
|
22
|
+
lastLines: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type LineWindow = {
|
|
26
|
+
leadingLines: readonly string[];
|
|
27
|
+
trailingLines: readonly string[];
|
|
28
|
+
omittedLines: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type BoundedLine = {
|
|
32
|
+
text: string;
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function buildBoundedOutputPreview(
|
|
37
|
+
source: OutputPreviewSource,
|
|
38
|
+
): BoundedOutputPreview {
|
|
39
|
+
const window = selectLineWindow(source);
|
|
40
|
+
const leadingLines = window.leadingLines.map(boundLine);
|
|
41
|
+
const trailingLines = window.trailingLines.map(boundLine);
|
|
42
|
+
const lineContentTruncated = [...leadingLines, ...trailingLines].some(
|
|
43
|
+
(line) => line.truncated,
|
|
44
|
+
);
|
|
45
|
+
const lineWindowPreview = renderLineWindow({
|
|
46
|
+
leadingLines: leadingLines.map((line) => line.text),
|
|
47
|
+
trailingLines: trailingLines.map((line) => line.text),
|
|
48
|
+
outputLines: source.outputLines,
|
|
49
|
+
omittedLines: window.omittedLines,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (utf8Bytes(lineWindowPreview) <= MAX_PREVIEW_BYTES) {
|
|
53
|
+
return {
|
|
54
|
+
preview: lineWindowPreview,
|
|
55
|
+
truncated: lineContentTruncated || window.omittedLines > 0,
|
|
56
|
+
...(window.omittedLines > 0 ? { omittedLines: window.omittedLines } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const candidates = [...leadingLines, ...trailingLines].map((line) => line.text);
|
|
61
|
+
const totalWindow = renderTotalByteWindow(candidates);
|
|
62
|
+
const omittedLines = source.outputLines - totalWindow.retainedLines;
|
|
63
|
+
|
|
64
|
+
if (utf8Bytes(totalWindow.preview) > MAX_PREVIEW_BYTES) {
|
|
65
|
+
throw new Error("Bounded output preview exceeded its UTF-8 byte limit.");
|
|
66
|
+
}
|
|
67
|
+
if (omittedLines < 1) {
|
|
68
|
+
throw new Error("Bounded output preview byte truncation omitted no lines.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
preview: totalWindow.preview,
|
|
73
|
+
truncated: true,
|
|
74
|
+
omittedLines,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function takeUtf8Prefix(text: string, maxBytes: number): string {
|
|
79
|
+
if (maxBytes <= 0) {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let end = 0;
|
|
84
|
+
let bytes = 0;
|
|
85
|
+
for (const character of text) {
|
|
86
|
+
const characterBytes = utf8Bytes(character);
|
|
87
|
+
if (bytes + characterBytes > maxBytes) {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
bytes += characterBytes;
|
|
91
|
+
end += character.length;
|
|
92
|
+
}
|
|
93
|
+
return text.slice(0, end);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function takeUtf8Suffix(text: string, maxBytes: number): string {
|
|
97
|
+
if (maxBytes <= 0) {
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let start = text.length;
|
|
102
|
+
let bytes = 0;
|
|
103
|
+
while (start > 0) {
|
|
104
|
+
let characterStart = start - 1;
|
|
105
|
+
const lastCodeUnit = text.charCodeAt(characterStart);
|
|
106
|
+
if (
|
|
107
|
+
isLowSurrogate(lastCodeUnit) &&
|
|
108
|
+
characterStart > 0 &&
|
|
109
|
+
isHighSurrogate(text.charCodeAt(characterStart - 1))
|
|
110
|
+
) {
|
|
111
|
+
characterStart -= 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const character = text.slice(characterStart, start);
|
|
115
|
+
const characterBytes = utf8Bytes(character);
|
|
116
|
+
if (bytes + characterBytes > maxBytes) {
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
bytes += characterBytes;
|
|
120
|
+
start = characterStart;
|
|
121
|
+
}
|
|
122
|
+
return text.slice(start);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function selectLineWindow(source: OutputPreviewSource): LineWindow {
|
|
126
|
+
if ("lines" in source) {
|
|
127
|
+
if (source.lines.length !== source.outputLines) {
|
|
128
|
+
throw new Error("Complete output preview source has inconsistent line counts.");
|
|
129
|
+
}
|
|
130
|
+
if (source.outputLines <= MAX_PREVIEW_LINES) {
|
|
131
|
+
return {
|
|
132
|
+
leadingLines: source.lines,
|
|
133
|
+
trailingLines: [],
|
|
134
|
+
omittedLines: 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
leadingLines: source.lines.slice(0, PREVIEW_EDGE_LINES),
|
|
140
|
+
trailingLines: source.lines.slice(-PREVIEW_EDGE_LINES),
|
|
141
|
+
omittedLines: source.outputLines - MAX_PREVIEW_LINES,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (
|
|
146
|
+
source.outputLines <= MAX_PREVIEW_LINES ||
|
|
147
|
+
source.firstLines.length !== PREVIEW_EDGE_LINES ||
|
|
148
|
+
source.lastLines.length !== PREVIEW_EDGE_LINES
|
|
149
|
+
) {
|
|
150
|
+
throw new Error("Windowed output preview source has inconsistent line counts.");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
leadingLines: source.firstLines,
|
|
155
|
+
trailingLines: source.lastLines,
|
|
156
|
+
omittedLines: source.outputLines - MAX_PREVIEW_LINES,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function boundLine(text: string): BoundedLine {
|
|
161
|
+
const originalBytes = utf8Bytes(text);
|
|
162
|
+
if (originalBytes <= MAX_PREVIEW_LINE_BYTES) {
|
|
163
|
+
return { text, truncated: false };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let omittedBytes = originalBytes;
|
|
167
|
+
while (true) {
|
|
168
|
+
const marker = lineByteOmissionMarker(omittedBytes);
|
|
169
|
+
const remainingBytes = MAX_PREVIEW_LINE_BYTES - utf8Bytes(marker);
|
|
170
|
+
const prefix = takeUtf8Prefix(text, Math.floor(remainingBytes / 2));
|
|
171
|
+
const suffix = takeUtf8Suffix(
|
|
172
|
+
text,
|
|
173
|
+
remainingBytes - Math.floor(remainingBytes / 2),
|
|
174
|
+
);
|
|
175
|
+
const nextOmittedBytes = originalBytes - utf8Bytes(prefix) - utf8Bytes(suffix);
|
|
176
|
+
|
|
177
|
+
if (nextOmittedBytes !== omittedBytes) {
|
|
178
|
+
omittedBytes = nextOmittedBytes;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const bounded = `${prefix}${marker}${suffix}`;
|
|
183
|
+
if (utf8Bytes(bounded) > MAX_PREVIEW_LINE_BYTES) {
|
|
184
|
+
throw new Error("Bounded output line exceeded its UTF-8 byte limit.");
|
|
185
|
+
}
|
|
186
|
+
return { text: bounded, truncated: true };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderLineWindow(input: {
|
|
191
|
+
leadingLines: readonly string[];
|
|
192
|
+
trailingLines: readonly string[];
|
|
193
|
+
outputLines: number;
|
|
194
|
+
omittedLines: number;
|
|
195
|
+
}): string {
|
|
196
|
+
if (input.omittedLines === 0) {
|
|
197
|
+
return input.leadingLines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const omittedStartLine = input.leadingLines.length + 1;
|
|
201
|
+
const omittedEndLine = input.outputLines - input.trailingLines.length;
|
|
202
|
+
return [
|
|
203
|
+
...input.leadingLines,
|
|
204
|
+
`... output omitted: lines ${omittedStartLine}-${omittedEndLine} (${input.omittedLines} ${input.omittedLines === 1 ? "line" : "lines"}). Full output is available at outputFilePath.`,
|
|
205
|
+
...input.trailingLines,
|
|
206
|
+
].join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderTotalByteWindow(lines: readonly string[]): {
|
|
210
|
+
preview: string;
|
|
211
|
+
retainedLines: number;
|
|
212
|
+
} {
|
|
213
|
+
const marker = `... output omitted to fit the ${MAX_PREVIEW_BYTES}-byte preview limit. Full output is available at outputFilePath.`;
|
|
214
|
+
const contentBudget = MAX_PREVIEW_BYTES - utf8Bytes(marker) - 2;
|
|
215
|
+
const leadingBudget = Math.floor(contentBudget / 2);
|
|
216
|
+
const trailingBudget = contentBudget - leadingBudget;
|
|
217
|
+
const leadingCount = countLeadingLinesWithin(lines, leadingBudget);
|
|
218
|
+
const trailingCount = countTrailingLinesWithin(lines, trailingBudget, leadingCount);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
preview: [
|
|
222
|
+
...lines.slice(0, leadingCount),
|
|
223
|
+
marker,
|
|
224
|
+
...lines.slice(lines.length - trailingCount),
|
|
225
|
+
].join("\n"),
|
|
226
|
+
retainedLines: leadingCount + trailingCount,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function countLeadingLinesWithin(lines: readonly string[], maxBytes: number): number {
|
|
231
|
+
let count = 0;
|
|
232
|
+
let bytes = 0;
|
|
233
|
+
for (const line of lines) {
|
|
234
|
+
const nextBytes = utf8Bytes(line) + (count === 0 ? 0 : 1);
|
|
235
|
+
if (bytes + nextBytes > maxBytes) {
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
bytes += nextBytes;
|
|
239
|
+
count += 1;
|
|
240
|
+
}
|
|
241
|
+
return count;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function countTrailingLinesWithin(
|
|
245
|
+
lines: readonly string[],
|
|
246
|
+
maxBytes: number,
|
|
247
|
+
leadingCount: number,
|
|
248
|
+
): number {
|
|
249
|
+
let count = 0;
|
|
250
|
+
let bytes = 0;
|
|
251
|
+
for (let index = lines.length - 1; index >= leadingCount; index -= 1) {
|
|
252
|
+
const nextBytes = utf8Bytes(lines[index] ?? "") + (count === 0 ? 0 : 1);
|
|
253
|
+
if (bytes + nextBytes > maxBytes) {
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
bytes += nextBytes;
|
|
257
|
+
count += 1;
|
|
258
|
+
}
|
|
259
|
+
return count;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function lineByteOmissionMarker(omittedBytes: number): string {
|
|
263
|
+
return `... ${omittedBytes} UTF-8 bytes omitted from this line ...`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isHighSurrogate(codeUnit: number): boolean {
|
|
267
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isLowSurrogate(codeUnit: number): boolean {
|
|
271
|
+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function utf8Bytes(text: string): number {
|
|
275
|
+
return Buffer.byteLength(text, "utf8");
|
|
276
|
+
}
|
package/src/tools/recall.ts
CHANGED
|
@@ -22,15 +22,14 @@ import {
|
|
|
22
22
|
type ToolExecutor,
|
|
23
23
|
} from "./types";
|
|
24
24
|
|
|
25
|
-
export const
|
|
26
|
-
name: "
|
|
25
|
+
export const RECALL_SEARCH_TOOL_DEFINITION: ToolDefinition = Object.freeze({
|
|
26
|
+
name: "RecallSearch",
|
|
27
27
|
description:
|
|
28
|
-
"Search
|
|
28
|
+
"Search immutable model-visible history from the current session. Matches literal substrings: use a short distinctive anchor such as a path, symbol, project, command fragment, or error text, not a whole natural-language question. Results are historical snapshots and may differ from the current workspace. Use RecallGet with a returned source to retrieve exact content, and Read/Grep for current files.",
|
|
29
29
|
parameters: {
|
|
30
30
|
type: "object",
|
|
31
31
|
additionalProperties: false,
|
|
32
32
|
properties: {
|
|
33
|
-
mode: { type: "string", enum: ["search", "get"] },
|
|
34
33
|
query: { type: "string", maxLength: 1024 },
|
|
35
34
|
roles: {
|
|
36
35
|
type: "array",
|
|
@@ -50,6 +49,19 @@ export const RECALL_TOOL_DEFINITION: ToolDefinition = Object.freeze({
|
|
|
50
49
|
limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
|
|
51
50
|
offset: { type: "integer", minimum: 0, default: 0 },
|
|
52
51
|
snapshot_through_ordinal: { type: "integer", minimum: 1 },
|
|
52
|
+
},
|
|
53
|
+
required: ["query"],
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const RECALL_GET_TOOL_DEFINITION: ToolDefinition = Object.freeze({
|
|
58
|
+
name: "RecallGet",
|
|
59
|
+
description:
|
|
60
|
+
"Retrieve exact immutable model-visible historical content from the current session using a ctx://message/<UUID> source returned by RecallSearch. Results are historical snapshots and may differ from the current workspace; use Read/Grep for current files.",
|
|
61
|
+
parameters: {
|
|
62
|
+
type: "object",
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
properties: {
|
|
53
65
|
source: { type: "string" },
|
|
54
66
|
byte_offset: { type: "integer", minimum: 0, default: 0 },
|
|
55
67
|
byte_limit: {
|
|
@@ -59,10 +71,15 @@ export const RECALL_TOOL_DEFINITION: ToolDefinition = Object.freeze({
|
|
|
59
71
|
default: 12_000,
|
|
60
72
|
},
|
|
61
73
|
},
|
|
62
|
-
required: ["
|
|
74
|
+
required: ["source"],
|
|
63
75
|
},
|
|
64
76
|
});
|
|
65
77
|
|
|
78
|
+
export const RECALL_TOOL_DEFINITIONS: readonly ToolDefinition[] = Object.freeze([
|
|
79
|
+
RECALL_SEARCH_TOOL_DEFINITION,
|
|
80
|
+
RECALL_GET_TOOL_DEFINITION,
|
|
81
|
+
]);
|
|
82
|
+
|
|
66
83
|
type RecallSearchArgs = {
|
|
67
84
|
mode: "search";
|
|
68
85
|
query: string;
|
|
@@ -93,18 +110,32 @@ type ParseResult =
|
|
|
93
110
|
error: string;
|
|
94
111
|
};
|
|
95
112
|
|
|
96
|
-
export function
|
|
113
|
+
export function createRecallSearchToolExecutor(options: {
|
|
114
|
+
historyReader: SessionHistoryReader;
|
|
115
|
+
}): ToolExecutor {
|
|
116
|
+
return createRecallToolExecutor("search", options);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createRecallGetToolExecutor(options: {
|
|
97
120
|
historyReader: SessionHistoryReader;
|
|
98
121
|
}): ToolExecutor {
|
|
122
|
+
return createRecallToolExecutor("get", options);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createRecallToolExecutor(
|
|
126
|
+
mode: "search" | "get",
|
|
127
|
+
options: { historyReader: SessionHistoryReader },
|
|
128
|
+
): ToolExecutor {
|
|
99
129
|
return defineToolExecutor("recall", {
|
|
100
|
-
definition:
|
|
130
|
+
definition:
|
|
131
|
+
mode === "search" ? RECALL_SEARCH_TOOL_DEFINITION : RECALL_GET_TOOL_DEFINITION,
|
|
101
132
|
async execute(
|
|
102
133
|
args,
|
|
103
134
|
_call,
|
|
104
135
|
context: ToolExecutionContext,
|
|
105
136
|
): Promise<RecallRawResult> {
|
|
106
137
|
throwIfTurnCancelled(context.signal);
|
|
107
|
-
const parsed =
|
|
138
|
+
const parsed = mode === "search" ? parseSearchArgs(args) : parseGetArgs(args);
|
|
108
139
|
if (!parsed.ok) {
|
|
109
140
|
return recallFailure(parsed.mode, parsed.errorCode, parsed.error);
|
|
110
141
|
}
|
|
@@ -167,20 +198,11 @@ export function createRecallToolExecutor(options: {
|
|
|
167
198
|
});
|
|
168
199
|
}
|
|
169
200
|
|
|
170
|
-
function
|
|
201
|
+
function parseSearchArgs(args: unknown): ParseResult {
|
|
171
202
|
if (!isRecord(args)) {
|
|
172
|
-
return argsFailure("search", "
|
|
173
|
-
}
|
|
174
|
-
const mode = args.mode === "get" ? "get" : "search";
|
|
175
|
-
if (args.mode !== "search" && args.mode !== "get") {
|
|
176
|
-
return argsFailure(mode, 'Recall.mode must be "search" or "get".');
|
|
203
|
+
return argsFailure("search", "RecallSearch arguments must be an object.");
|
|
177
204
|
}
|
|
178
|
-
return args.mode === "search" ? parseSearchArgs(args) : parseGetArgs(args);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function parseSearchArgs(args: Record<string, unknown>): ParseResult {
|
|
182
205
|
const allowed = new Set([
|
|
183
|
-
"mode",
|
|
184
206
|
"query",
|
|
185
207
|
"roles",
|
|
186
208
|
"tool_names",
|
|
@@ -194,7 +216,7 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
|
|
|
194
216
|
if (unexpected !== undefined) {
|
|
195
217
|
return argsFailure(
|
|
196
218
|
"search",
|
|
197
|
-
`
|
|
219
|
+
`RecallSearch received unexpected field: ${unexpected}.`,
|
|
198
220
|
);
|
|
199
221
|
}
|
|
200
222
|
if (
|
|
@@ -204,7 +226,7 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
|
|
|
204
226
|
) {
|
|
205
227
|
return argsFailure(
|
|
206
228
|
"search",
|
|
207
|
-
"
|
|
229
|
+
"RecallSearch query must be non-empty and at most 1024 UTF-8 bytes.",
|
|
208
230
|
);
|
|
209
231
|
}
|
|
210
232
|
|
|
@@ -223,15 +245,15 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
|
|
|
223
245
|
) {
|
|
224
246
|
return argsFailure(
|
|
225
247
|
"search",
|
|
226
|
-
"
|
|
248
|
+
"RecallSearch.tool_names requires roles to be omitted or contain only tool.",
|
|
227
249
|
);
|
|
228
250
|
}
|
|
229
251
|
|
|
230
|
-
const turnFrom = optionalInteger(args.turn_from, "
|
|
252
|
+
const turnFrom = optionalInteger(args.turn_from, "RecallSearch.turn_from", 1);
|
|
231
253
|
if (!turnFrom.ok) {
|
|
232
254
|
return argsFailure("search", turnFrom.error);
|
|
233
255
|
}
|
|
234
|
-
const turnTo = optionalInteger(args.turn_to, "
|
|
256
|
+
const turnTo = optionalInteger(args.turn_to, "RecallSearch.turn_to", 1);
|
|
235
257
|
if (!turnTo.ok) {
|
|
236
258
|
return argsFailure("search", turnTo.error);
|
|
237
259
|
}
|
|
@@ -240,19 +262,19 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
|
|
|
240
262
|
turnTo.value !== undefined &&
|
|
241
263
|
turnFrom.value > turnTo.value
|
|
242
264
|
) {
|
|
243
|
-
return argsFailure("search", "
|
|
265
|
+
return argsFailure("search", "RecallSearch.turn_from must not exceed turn_to.");
|
|
244
266
|
}
|
|
245
|
-
const limit = optionalInteger(args.limit, "
|
|
267
|
+
const limit = optionalInteger(args.limit, "RecallSearch.limit", 1, 20);
|
|
246
268
|
if (!limit.ok) {
|
|
247
269
|
return argsFailure("search", limit.error);
|
|
248
270
|
}
|
|
249
|
-
const offset = optionalInteger(args.offset, "
|
|
271
|
+
const offset = optionalInteger(args.offset, "RecallSearch.offset", 0);
|
|
250
272
|
if (!offset.ok) {
|
|
251
273
|
return argsFailure("search", offset.error);
|
|
252
274
|
}
|
|
253
275
|
const snapshot = optionalInteger(
|
|
254
276
|
args.snapshot_through_ordinal,
|
|
255
|
-
"
|
|
277
|
+
"RecallSearch.snapshot_through_ordinal",
|
|
256
278
|
1,
|
|
257
279
|
);
|
|
258
280
|
if (!snapshot.ok) {
|
|
@@ -277,14 +299,17 @@ function parseSearchArgs(args: Record<string, unknown>): ParseResult {
|
|
|
277
299
|
};
|
|
278
300
|
}
|
|
279
301
|
|
|
280
|
-
function parseGetArgs(args:
|
|
281
|
-
|
|
302
|
+
function parseGetArgs(args: unknown): ParseResult {
|
|
303
|
+
if (!isRecord(args)) {
|
|
304
|
+
return argsFailure("get", "RecallGet arguments must be an object.");
|
|
305
|
+
}
|
|
306
|
+
const allowed = new Set(["source", "byte_offset", "byte_limit"]);
|
|
282
307
|
const unexpected = Object.keys(args).find((key) => !allowed.has(key));
|
|
283
308
|
if (unexpected !== undefined) {
|
|
284
|
-
return argsFailure("get", `
|
|
309
|
+
return argsFailure("get", `RecallGet received unexpected field: ${unexpected}.`);
|
|
285
310
|
}
|
|
286
311
|
if (typeof args.source !== "string") {
|
|
287
|
-
return sourceFailure("
|
|
312
|
+
return sourceFailure("RecallGet.source must be a string.");
|
|
288
313
|
}
|
|
289
314
|
let source: MessageSource;
|
|
290
315
|
try {
|
|
@@ -296,11 +321,16 @@ function parseGetArgs(args: Record<string, unknown>): ParseResult {
|
|
|
296
321
|
}
|
|
297
322
|
throw error;
|
|
298
323
|
}
|
|
299
|
-
const byteOffset = optionalInteger(args.byte_offset, "
|
|
324
|
+
const byteOffset = optionalInteger(args.byte_offset, "RecallGet.byte_offset", 0);
|
|
300
325
|
if (!byteOffset.ok) {
|
|
301
326
|
return argsFailure("get", byteOffset.error);
|
|
302
327
|
}
|
|
303
|
-
const byteLimit = optionalInteger(
|
|
328
|
+
const byteLimit = optionalInteger(
|
|
329
|
+
args.byte_limit,
|
|
330
|
+
"RecallGet.byte_limit",
|
|
331
|
+
256,
|
|
332
|
+
20_000,
|
|
333
|
+
);
|
|
304
334
|
if (!byteLimit.ok) {
|
|
305
335
|
return argsFailure("get", byteLimit.error);
|
|
306
336
|
}
|
|
@@ -332,7 +362,7 @@ function parseRoles(
|
|
|
332
362
|
return {
|
|
333
363
|
ok: false,
|
|
334
364
|
error:
|
|
335
|
-
"
|
|
365
|
+
"RecallSearch.roles must be a non-empty, unique list of user, assistant, or tool.",
|
|
336
366
|
};
|
|
337
367
|
}
|
|
338
368
|
return { ok: true, value: value as RecallRole[] };
|
|
@@ -353,7 +383,8 @@ function parseToolNames(
|
|
|
353
383
|
) {
|
|
354
384
|
return {
|
|
355
385
|
ok: false,
|
|
356
|
-
error:
|
|
386
|
+
error:
|
|
387
|
+
"RecallSearch.tool_names must contain 1 to 16 unique, non-empty tool names.",
|
|
357
388
|
};
|
|
358
389
|
}
|
|
359
390
|
return { ok: true, value: value as string[] };
|
package/src/tools/registry.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createEditToolExecutor } from "./edit";
|
|
|
6
6
|
import { createGlobToolExecutor } from "./glob";
|
|
7
7
|
import { createGrepToolExecutor } from "./grep";
|
|
8
8
|
import { createReadToolExecutor } from "./read";
|
|
9
|
-
import {
|
|
9
|
+
import { createRecallGetToolExecutor, createRecallSearchToolExecutor } from "./recall";
|
|
10
10
|
import { createTaskListToolExecutor } from "./task-list";
|
|
11
11
|
import { createTaskInputToolExecutor } from "./task-input";
|
|
12
12
|
import { createTaskOutputToolExecutor } from "./task-output-tool";
|
|
@@ -206,7 +206,12 @@ export function createDefaultTooling(options: {
|
|
|
206
206
|
maxContentBytes: options.maxReadContentBytes,
|
|
207
207
|
}),
|
|
208
208
|
);
|
|
209
|
-
registry.register(
|
|
209
|
+
registry.register(
|
|
210
|
+
createRecallSearchToolExecutor({ historyReader: options.historyReader }),
|
|
211
|
+
);
|
|
212
|
+
registry.register(
|
|
213
|
+
createRecallGetToolExecutor({ historyReader: options.historyReader }),
|
|
214
|
+
);
|
|
210
215
|
if (options.memorySearch !== undefined) {
|
|
211
216
|
registry.register(options.memorySearch);
|
|
212
217
|
}
|
|
@@ -1,35 +1,19 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
+
import { buildBoundedOutputPreview } from "./bounded-output-preview";
|
|
2
3
|
import type { TaskOutputSnapshot } from "./task-output";
|
|
3
4
|
|
|
4
|
-
const maxPreviewLines = 200;
|
|
5
|
-
const previewEdgeLines = 100;
|
|
6
|
-
|
|
7
5
|
export function buildOutputSnapshotFromText(bytes: Buffer): TaskOutputSnapshot {
|
|
8
6
|
const text = bytes.toString("utf8");
|
|
9
7
|
const lines = splitLines(text);
|
|
8
|
+
const bounded = buildBoundedOutputPreview({
|
|
9
|
+
outputLines: lines.length,
|
|
10
|
+
lines,
|
|
11
|
+
});
|
|
10
12
|
|
|
11
|
-
if (lines.length <= maxPreviewLines) {
|
|
12
|
-
return {
|
|
13
|
-
outputBytes: bytes.byteLength,
|
|
14
|
-
outputLines: lines.length,
|
|
15
|
-
preview: lines.join("\n"),
|
|
16
|
-
truncated: false,
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const omittedLines = lines.length - maxPreviewLines;
|
|
21
|
-
const omittedStartLine = previewEdgeLines + 1;
|
|
22
|
-
const omittedEndLine = lines.length - previewEdgeLines;
|
|
23
13
|
return {
|
|
24
14
|
outputBytes: bytes.byteLength,
|
|
25
15
|
outputLines: lines.length,
|
|
26
|
-
|
|
27
|
-
...lines.slice(0, previewEdgeLines),
|
|
28
|
-
`... output omitted: lines ${omittedStartLine}-${omittedEndLine} (${omittedLines} ${omittedLines === 1 ? "line" : "lines"}). Full output is available at outputFilePath.`,
|
|
29
|
-
...lines.slice(-previewEdgeLines),
|
|
30
|
-
].join("\n"),
|
|
31
|
-
truncated: true,
|
|
32
|
-
omittedLines,
|
|
16
|
+
...bounded,
|
|
33
17
|
};
|
|
34
18
|
}
|
|
35
19
|
|
package/src/tools/task-output.ts
CHANGED
|
@@ -2,6 +2,12 @@ import { mkdir } from "node:fs/promises";
|
|
|
2
2
|
import { createWriteStream, type WriteStream } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
|
+
import {
|
|
6
|
+
buildBoundedOutputPreview,
|
|
7
|
+
MAX_PREVIEW_LINES,
|
|
8
|
+
PREVIEW_EDGE_LINES,
|
|
9
|
+
type OutputPreviewSource,
|
|
10
|
+
} from "./bounded-output-preview";
|
|
5
11
|
|
|
6
12
|
export type TaskOutputSnapshot = {
|
|
7
13
|
outputBytes: number;
|
|
@@ -11,9 +17,6 @@ export type TaskOutputSnapshot = {
|
|
|
11
17
|
omittedLines?: number;
|
|
12
18
|
};
|
|
13
19
|
|
|
14
|
-
const maxPreviewLines = 200;
|
|
15
|
-
const previewEdgeLines = 100;
|
|
16
|
-
|
|
17
20
|
export class TaskOutput {
|
|
18
21
|
private readonly decoder = new StringDecoder("utf8");
|
|
19
22
|
private readonly stream: WriteStream;
|
|
@@ -68,30 +71,23 @@ export class TaskOutput {
|
|
|
68
71
|
|
|
69
72
|
snapshot(): TaskOutputSnapshot {
|
|
70
73
|
const outputLines = this.outputLines + (this.pendingLine === "" ? 0 : 1);
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
const source: OutputPreviewSource =
|
|
75
|
+
this.fullPreviewLines === undefined
|
|
76
|
+
? {
|
|
77
|
+
outputLines,
|
|
78
|
+
firstLines: this.firstLines,
|
|
79
|
+
lastLines: this.lastPreviewLines(),
|
|
80
|
+
}
|
|
81
|
+
: {
|
|
82
|
+
outputLines,
|
|
83
|
+
lines: this.previewLines(),
|
|
84
|
+
};
|
|
85
|
+
const bounded = buildBoundedOutputPreview(source);
|
|
81
86
|
|
|
82
|
-
const omittedLines = outputLines - maxPreviewLines;
|
|
83
|
-
const omittedStartLine = previewEdgeLines + 1;
|
|
84
|
-
const omittedEndLine = outputLines - previewEdgeLines;
|
|
85
87
|
return {
|
|
86
88
|
outputBytes: this.outputBytes,
|
|
87
89
|
outputLines,
|
|
88
|
-
|
|
89
|
-
...this.firstLines,
|
|
90
|
-
`... output omitted: lines ${omittedStartLine}-${omittedEndLine} (${omittedLines} ${omittedLines === 1 ? "line" : "lines"}). Full output is available at outputFilePath.`,
|
|
91
|
-
...this.lastPreviewLines(),
|
|
92
|
-
].join("\n"),
|
|
93
|
-
truncated: true,
|
|
94
|
-
omittedLines,
|
|
90
|
+
...bounded,
|
|
95
91
|
};
|
|
96
92
|
}
|
|
97
93
|
|
|
@@ -113,18 +109,18 @@ export class TaskOutput {
|
|
|
113
109
|
private pushLine(line: string): void {
|
|
114
110
|
this.outputLines += 1;
|
|
115
111
|
|
|
116
|
-
if (this.firstLines.length <
|
|
112
|
+
if (this.firstLines.length < PREVIEW_EDGE_LINES) {
|
|
117
113
|
this.firstLines.push(line);
|
|
118
114
|
}
|
|
119
115
|
|
|
120
116
|
this.lastLines.push(line);
|
|
121
|
-
if (this.lastLines.length >
|
|
117
|
+
if (this.lastLines.length > PREVIEW_EDGE_LINES) {
|
|
122
118
|
this.lastLines.shift();
|
|
123
119
|
}
|
|
124
120
|
|
|
125
121
|
if (this.fullPreviewLines !== undefined) {
|
|
126
122
|
this.fullPreviewLines.push(line);
|
|
127
|
-
if (this.fullPreviewLines.length >
|
|
123
|
+
if (this.fullPreviewLines.length > MAX_PREVIEW_LINES) {
|
|
128
124
|
this.fullPreviewLines = undefined;
|
|
129
125
|
}
|
|
130
126
|
}
|
|
@@ -150,7 +146,7 @@ export class TaskOutput {
|
|
|
150
146
|
lines.push(this.pendingLine);
|
|
151
147
|
}
|
|
152
148
|
|
|
153
|
-
return lines.slice(-
|
|
149
|
+
return lines.slice(-PREVIEW_EDGE_LINES);
|
|
154
150
|
}
|
|
155
151
|
}
|
|
156
152
|
|