tinker-agent 2.10.0 → 2.12.0
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 +47 -1
- package/README.md +44 -67
- package/package.json +4 -3
- package/src/agent/loop.ts +50 -13
- package/src/agent/runtime-provider-retry.ts +115 -0
- package/src/agent/runtime-session-contracts.ts +13 -29
- package/src/agent/runtime-session.ts +42 -67
- package/src/cli/config.ts +0 -29
- package/src/cli/model-profiles.ts +0 -80
- package/src/cli/public-config-contract.ts +1 -82
- package/src/cli/tui-runner.tsx +3 -41
- package/src/events/observation-text-log.ts +7 -0
- package/src/events/types.ts +8 -0
- package/src/image/abortable-file-open.ts +54 -0
- package/src/image/image-asset-store.ts +7 -1
- package/src/memory/contracts.ts +0 -256
- package/src/memory/memory-create-tool.ts +3 -5
- package/src/memory/memory-files.ts +145 -0
- package/src/memory/memory-search-output.ts +23 -0
- package/src/memory/memory-search-tool.ts +79 -175
- package/src/memory/memory-search.ts +115 -0
- package/src/model/fake-model-client.ts +20 -1
- package/src/model/openai-model-utils.ts +45 -1
- package/src/model/openai-responses-mapping.ts +11 -0
- package/src/model/openai-responses-stream.ts +14 -0
- package/src/observation/observation-builder.ts +5 -1
- package/src/session/scoped-query-database.ts +27 -0
- package/src/session/session-history-access.ts +4 -3
- package/src/session/session-store-contracts.ts +0 -23
- package/src/session/session-store.ts +9 -106
- package/src/tools/grep.ts +12 -4
- package/src/tools/registry.ts +15 -18
- package/src/tools/types.ts +12 -0
- package/src/tui/app.tsx +51 -7
- package/src/tui/components/ask-user.tsx +15 -8
- package/src/tui/components/prompt-input.tsx +14 -6
- package/src/tui/components/timeline.tsx +17 -9
- package/src/tui/event-store.ts +13 -1
- package/src/tui/file-mention.ts +29 -5
- package/src/tui/tui-projection-store.ts +5 -2
- package/src/tui/tui-session-controller.ts +8 -0
- package/src/tui/workspace-file-search.ts +21 -0
- package/src/cli/tui-memory.ts +0 -69
- package/src/memory/embedding-client.ts +0 -105
- package/src/memory/memory-coordinator.ts +0 -1274
- package/src/memory/memory-delete-tool.ts +0 -88
- package/src/memory/memory-extractor.ts +0 -252
- package/src/memory/memory-get-tool.ts +0 -86
- package/src/memory/memory-log.ts +0 -88
- package/src/memory/memory-store.ts +0 -1133
- package/src/memory/memory-update-tool.ts +0 -142
- package/src/memory/vector.ts +0 -153
|
@@ -1,189 +1,93 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
type ToolDefinition,
|
|
6
|
-
type ToolExecutor,
|
|
7
|
-
} from "../tools/types";
|
|
8
|
-
import {
|
|
9
|
-
MAX_MEMORY_KEYWORD_BYTES,
|
|
10
|
-
MAX_MEMORY_KEYWORDS,
|
|
11
|
-
MAX_MEMORY_QUERY_BYTES,
|
|
12
|
-
MEMORY_SEARCH_TOOL_NAME,
|
|
13
|
-
} from "./contracts";
|
|
14
|
-
|
|
15
|
-
export const MEMORY_SEARCH_TOOL_DEFINITION: ToolDefinition = Object.freeze({
|
|
16
|
-
name: MEMORY_SEARCH_TOOL_NAME,
|
|
17
|
-
description:
|
|
18
|
-
"Search memories retained from historical conversation turns across sessions and workspaces. Use this proactively when prior user preferences, project decisions, environment facts, or verified solutions may help. Put exact-match terms in `keywords`, put a concise semantic description in `query`, and provide both for hybrid recall. Each result is a historical record containing a one-line index text, a possibly truncated detailed summary, a `memoryId`, and a `sourceSessionId`. Results may be stale or incorrect, so verify current workspace facts with current tools. To read the full stored record for a result, call `MemoryGet` with its `memoryId`.",
|
|
19
|
-
parameters: {
|
|
20
|
-
type: "object",
|
|
21
|
-
additionalProperties: false,
|
|
22
|
-
properties: {
|
|
23
|
-
query: {
|
|
24
|
-
type: "string",
|
|
25
|
-
minLength: 1,
|
|
26
|
-
maxLength: MAX_MEMORY_QUERY_BYTES,
|
|
27
|
-
description:
|
|
28
|
-
"A concise semantic description of the fact, preference, decision, or solution to recall. Drives vector similarity search.",
|
|
29
|
-
},
|
|
30
|
-
keywords: {
|
|
31
|
-
type: "array",
|
|
32
|
-
maxItems: MAX_MEMORY_KEYWORDS,
|
|
33
|
-
items: {
|
|
34
|
-
type: "string",
|
|
35
|
-
minLength: 1,
|
|
36
|
-
maxLength: MAX_MEMORY_KEYWORD_BYTES,
|
|
37
|
-
},
|
|
38
|
-
description:
|
|
39
|
-
"Exact terms to match literally, such as identifiers, error strings, paths, or project names. Drives keyword search. Keywords shorter than 3 characters cannot be matched.",
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
required: [],
|
|
43
|
-
},
|
|
44
|
-
});
|
|
1
|
+
import { defineToolExecutor, type ToolExecutor } from "../tools/types";
|
|
2
|
+
import { boundedMemoryError } from "./contracts";
|
|
3
|
+
import { ensureMemoryDirectory } from "./memory-files";
|
|
4
|
+
import { searchMemoryFiles, type MemorySearchInput } from "./memory-search";
|
|
45
5
|
|
|
46
6
|
export function createMemorySearchToolExecutor(options: {
|
|
47
|
-
|
|
48
|
-
query: string | null,
|
|
49
|
-
keywords: readonly string[],
|
|
50
|
-
signal: AbortSignal,
|
|
51
|
-
) => Promise<MemorySearchRawResult>;
|
|
52
|
-
readonly recordInvalidCall: (input: {
|
|
53
|
-
readonly queryBytes: number;
|
|
54
|
-
readonly keywordCount: number;
|
|
55
|
-
}) => Promise<void>;
|
|
7
|
+
homeRoot?: string;
|
|
56
8
|
}): ToolExecutor {
|
|
57
9
|
return defineToolExecutor("memory_search", {
|
|
58
|
-
definition:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
10
|
+
definition: {
|
|
11
|
+
name: "MemorySearch",
|
|
12
|
+
description:
|
|
13
|
+
"Search historical session records and saved notes across workspaces when earlier decisions, preferences, or work may help. Matches any keyword literally, ignoring case, within individual lines. Returns passages grouped by file with line numbers; > marks matching lines and … separates passages. Use Read with offset and limit to expand a passage. Continue using nextOffset with the same keywords and context.",
|
|
14
|
+
parameters: {
|
|
15
|
+
type: "object",
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
properties: {
|
|
18
|
+
keywords: {
|
|
19
|
+
type: "array",
|
|
20
|
+
minItems: 1,
|
|
21
|
+
items: { type: "string", minLength: 1 },
|
|
22
|
+
description:
|
|
23
|
+
"Literal keywords; any one can match. No regular expressions or multiline keywords. A line matching multiple keywords counts once.",
|
|
24
|
+
},
|
|
25
|
+
context: {
|
|
26
|
+
type: "integer",
|
|
27
|
+
minimum: 0,
|
|
28
|
+
description:
|
|
29
|
+
"Lines before and after each matching line. Defaults to 3. Overlapping passages are merged.",
|
|
30
|
+
},
|
|
31
|
+
limit: {
|
|
32
|
+
type: "integer",
|
|
33
|
+
minimum: 1,
|
|
34
|
+
description:
|
|
35
|
+
"Maximum selected matching lines, excluding context. Defaults to 20.",
|
|
36
|
+
},
|
|
37
|
+
offset: {
|
|
38
|
+
type: "integer",
|
|
39
|
+
minimum: 0,
|
|
40
|
+
description:
|
|
41
|
+
"Matching lines to skip. Defaults to 0. Use nextOffset to continue; file changes between searches can affect pagination.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ["keywords"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
async execute(args, _call, context) {
|
|
48
|
+
context.signal.throwIfAborted();
|
|
49
|
+
try {
|
|
50
|
+
const input = parseArgs(args);
|
|
51
|
+
const directory = await ensureMemoryDirectory(options.homeRoot);
|
|
52
|
+
return await searchMemoryFiles(directory, input, context.signal);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
context.signal.throwIfAborted();
|
|
55
|
+
return { ok: false, error: boundedMemoryError(error) };
|
|
68
56
|
}
|
|
69
|
-
const result = await options.search(
|
|
70
|
-
parsed.query,
|
|
71
|
-
parsed.keywords,
|
|
72
|
-
context.signal,
|
|
73
|
-
);
|
|
74
|
-
throwIfTurnCancelled(context.signal);
|
|
75
|
-
return result;
|
|
76
57
|
},
|
|
77
58
|
});
|
|
78
59
|
}
|
|
79
60
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
readonly keywords: readonly string[];
|
|
85
|
-
}
|
|
86
|
-
| {
|
|
87
|
-
readonly ok: false;
|
|
88
|
-
readonly queryBytes: number;
|
|
89
|
-
readonly keywordCount: number;
|
|
90
|
-
readonly error: string;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
function invalidMemorySearchArgs(
|
|
94
|
-
args: Record<string, unknown>,
|
|
95
|
-
error: string,
|
|
96
|
-
queryBytes?: number,
|
|
97
|
-
): ParsedMemorySearchArgs {
|
|
98
|
-
return {
|
|
99
|
-
ok: false,
|
|
100
|
-
queryBytes:
|
|
101
|
-
queryBytes ??
|
|
102
|
-
(typeof args.query === "string" ? Buffer.byteLength(args.query, "utf8") : 0),
|
|
103
|
-
keywordCount: Array.isArray(args.keywords) ? args.keywords.length : 0,
|
|
104
|
-
error,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function parseMemorySearchArgs(args: unknown): ParsedMemorySearchArgs {
|
|
109
|
-
if (!isRecord(args)) {
|
|
110
|
-
return {
|
|
111
|
-
ok: false,
|
|
112
|
-
queryBytes: 0,
|
|
113
|
-
keywordCount: 0,
|
|
114
|
-
error:
|
|
115
|
-
"MemorySearch arguments must be an object containing only query and keywords.",
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
const unexpected = Object.keys(args).find(
|
|
119
|
-
(key) => key !== "query" && key !== "keywords",
|
|
120
|
-
);
|
|
121
|
-
if (unexpected !== undefined) {
|
|
122
|
-
return invalidMemorySearchArgs(
|
|
123
|
-
args,
|
|
124
|
-
`MemorySearch received unexpected field: ${unexpected}.`,
|
|
61
|
+
function parseArgs(args: unknown): MemorySearchInput {
|
|
62
|
+
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
63
|
+
throw new Error(
|
|
64
|
+
"MemorySearch requires keywords, with optional context, limit and offset.",
|
|
125
65
|
);
|
|
66
|
+
const value = args as Record<string, unknown>;
|
|
67
|
+
for (const name of Object.keys(value)) {
|
|
68
|
+
if (!["keywords", "context", "limit", "offset"].includes(name))
|
|
69
|
+
throw new Error(`MemorySearch received unexpected field: ${name}.`);
|
|
126
70
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (typeof
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
71
|
+
if (!Array.isArray(value.keywords) || value.keywords.length === 0)
|
|
72
|
+
throw new Error("MemorySearch.keywords must be a non-empty array of strings.");
|
|
73
|
+
const keywords = value.keywords.map((word: unknown) => {
|
|
74
|
+
if (typeof word !== "string" || word.trim() === "" || /[\r\n]/.test(word))
|
|
75
|
+
throw new Error("MemorySearch keywords must be non-empty single-line strings.");
|
|
76
|
+
return word.trim();
|
|
77
|
+
});
|
|
78
|
+
const integers = { context: 3, limit: 20, offset: 0 };
|
|
79
|
+
for (const name of ["context", "limit", "offset"] as const) {
|
|
80
|
+
const number = value[name];
|
|
81
|
+
if (number === undefined) continue;
|
|
82
|
+
if (
|
|
83
|
+
typeof number !== "number" ||
|
|
84
|
+
!Number.isSafeInteger(number) ||
|
|
85
|
+
number < (name === "limit" ? 1 : 0)
|
|
86
|
+
)
|
|
87
|
+
throw new Error(
|
|
88
|
+
`MemorySearch.${name} must be a safe integer >= ${name === "limit" ? 1 : 0}.`,
|
|
140
89
|
);
|
|
141
|
-
|
|
142
|
-
query = trimmed;
|
|
90
|
+
integers[name] = number;
|
|
143
91
|
}
|
|
144
|
-
|
|
145
|
-
const keywords: string[] = [];
|
|
146
|
-
if (args.keywords !== undefined) {
|
|
147
|
-
if (!Array.isArray(args.keywords)) {
|
|
148
|
-
return invalidMemorySearchArgs(
|
|
149
|
-
args,
|
|
150
|
-
"MemorySearch.keywords must be an array of strings.",
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
if (args.keywords.length > MAX_MEMORY_KEYWORDS) {
|
|
154
|
-
return invalidMemorySearchArgs(
|
|
155
|
-
args,
|
|
156
|
-
`MemorySearch.keywords may contain at most ${MAX_MEMORY_KEYWORDS} entries.`,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
for (const entry of args.keywords) {
|
|
160
|
-
if (typeof entry !== "string") {
|
|
161
|
-
return invalidMemorySearchArgs(
|
|
162
|
-
args,
|
|
163
|
-
"Every MemorySearch keyword must be a string.",
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
const trimmed = entry.trim();
|
|
167
|
-
const bytes = Buffer.byteLength(trimmed, "utf8");
|
|
168
|
-
if (bytes < 1 || bytes > MAX_MEMORY_KEYWORD_BYTES) {
|
|
169
|
-
return invalidMemorySearchArgs(
|
|
170
|
-
args,
|
|
171
|
-
`Every MemorySearch keyword must be 1 to ${MAX_MEMORY_KEYWORD_BYTES} UTF-8 bytes after trimming.`,
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
keywords.push(trimmed);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (query === null && keywords.length === 0) {
|
|
179
|
-
return invalidMemorySearchArgs(
|
|
180
|
-
args,
|
|
181
|
-
"MemorySearch requires a non-empty query or at least one keyword.",
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
return { ok: true, query, keywords: Object.freeze(keywords) };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
188
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
92
|
+
return { keywords, ...integers };
|
|
189
93
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import type { MemoryTextSearchResult, MemorySearchLine } from "../tools/types";
|
|
6
|
+
|
|
7
|
+
export type MemorySearchInput = {
|
|
8
|
+
keywords: string[];
|
|
9
|
+
context: number;
|
|
10
|
+
limit: number;
|
|
11
|
+
offset: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function searchMemoryFiles(
|
|
15
|
+
directory: string,
|
|
16
|
+
input: MemorySearchInput,
|
|
17
|
+
signal: AbortSignal,
|
|
18
|
+
): Promise<MemoryTextSearchResult> {
|
|
19
|
+
const paths = await markdownFiles(directory, signal);
|
|
20
|
+
const keywords = input.keywords.map((word) => word.toLowerCase());
|
|
21
|
+
const files: MemoryTextSearchResult["files"][number][] = [];
|
|
22
|
+
let seen = 0;
|
|
23
|
+
let returnedResults = 0;
|
|
24
|
+
let hasMore = false;
|
|
25
|
+
for (const filePath of paths) {
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
const stream = createReadStream(filePath, { encoding: "utf8", signal });
|
|
28
|
+
const reader = createInterface({ input: stream, crlfDelay: Infinity });
|
|
29
|
+
const before: MemorySearchLine[] = [];
|
|
30
|
+
const selected = new Map<number, MemorySearchLine>();
|
|
31
|
+
let lineNumber = 0;
|
|
32
|
+
let through = 0;
|
|
33
|
+
try {
|
|
34
|
+
for await (const text of reader) {
|
|
35
|
+
signal.throwIfAborted();
|
|
36
|
+
lineNumber++;
|
|
37
|
+
const folded = text.toLowerCase();
|
|
38
|
+
let matchAt = -1;
|
|
39
|
+
for (const keyword of keywords) {
|
|
40
|
+
const position = folded.indexOf(keyword);
|
|
41
|
+
if (position >= 0 && (matchAt < 0 || position < matchAt)) matchAt = position;
|
|
42
|
+
}
|
|
43
|
+
const match = matchAt >= 0;
|
|
44
|
+
const line = { lineNumber, match, text: excerpt(text, matchAt) };
|
|
45
|
+
if (match) {
|
|
46
|
+
if (seen >= input.offset && returnedResults < input.limit) {
|
|
47
|
+
returnedResults++;
|
|
48
|
+
for (const previous of before) selected.set(previous.lineNumber, previous);
|
|
49
|
+
through = lineNumber + input.context;
|
|
50
|
+
} else if (returnedResults === input.limit) {
|
|
51
|
+
hasMore = true;
|
|
52
|
+
}
|
|
53
|
+
seen++;
|
|
54
|
+
}
|
|
55
|
+
if (lineNumber <= through) selected.set(lineNumber, line);
|
|
56
|
+
if (hasMore && lineNumber >= through) break;
|
|
57
|
+
if (input.context > 0) {
|
|
58
|
+
before.push(line);
|
|
59
|
+
if (before.length > input.context) before.shift();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
reader.close();
|
|
64
|
+
stream.destroy();
|
|
65
|
+
}
|
|
66
|
+
if (selected.size > 0)
|
|
67
|
+
files.push({
|
|
68
|
+
filePath,
|
|
69
|
+
lines: [...selected.values()].sort((a, b) => a.lineNumber - b.lineNumber),
|
|
70
|
+
});
|
|
71
|
+
if (hasMore) break;
|
|
72
|
+
}
|
|
73
|
+
signal.throwIfAborted();
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
format: "text",
|
|
77
|
+
files,
|
|
78
|
+
returnedResults,
|
|
79
|
+
hasMore,
|
|
80
|
+
...(hasMore ? { nextOffset: input.offset + returnedResults } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function markdownFiles(
|
|
85
|
+
directory: string,
|
|
86
|
+
signal: AbortSignal,
|
|
87
|
+
): Promise<string[]> {
|
|
88
|
+
signal.throwIfAborted();
|
|
89
|
+
const files: string[] = [];
|
|
90
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
91
|
+
signal.throwIfAborted();
|
|
92
|
+
const filePath = path.join(directory, entry.name);
|
|
93
|
+
if (entry.isDirectory()) files.push(...(await markdownFiles(filePath, signal)));
|
|
94
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
|
|
95
|
+
files.push(filePath);
|
|
96
|
+
}
|
|
97
|
+
return files.sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Keep up to 500 original code points around the earliest literal match. */
|
|
101
|
+
function excerpt(text: string, foldedMatchOffset: number): string {
|
|
102
|
+
const points = [...text];
|
|
103
|
+
if (points.length <= 500) return text;
|
|
104
|
+
let matchPoint = 0;
|
|
105
|
+
let foldedOffset = 0;
|
|
106
|
+
if (foldedMatchOffset >= 0) {
|
|
107
|
+
while (matchPoint < points.length && foldedOffset < foldedMatchOffset) {
|
|
108
|
+
foldedOffset += points[matchPoint].toLowerCase().length;
|
|
109
|
+
matchPoint++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const start = Math.max(0, matchPoint - 100);
|
|
113
|
+
const end = Math.min(points.length, start + 500);
|
|
114
|
+
return `${start > 0 ? `[... ${start} code points omitted ...]` : ""}${points.slice(start, end).join("")}${end < points.length ? `[... ${points.length - end} code points omitted ...]` : ""}`;
|
|
115
|
+
}
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
PreparedModelRequest,
|
|
26
26
|
PreparedPromptSegment,
|
|
27
27
|
} from "./model-client";
|
|
28
|
-
import { validateModelModalities } from "./model-client";
|
|
28
|
+
import { ProviderResponseError, validateModelModalities } from "./model-client";
|
|
29
29
|
import { sha256, stableJsonStringify } from "./model-request-preflight";
|
|
30
30
|
import { estimatePromptSegments } from "./token-estimator";
|
|
31
31
|
|
|
@@ -212,6 +212,25 @@ export class FakeModelClient implements ModelClient {
|
|
|
212
212
|
);
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
if (this.mode === "pty-provider-retry") {
|
|
216
|
+
if (lastUserMessage(input.messages) === "PTY_RETRY_NEXT") {
|
|
217
|
+
return textOutput(prepared, "PTY_RETRY_NEXT_DONE");
|
|
218
|
+
}
|
|
219
|
+
if (this.steps <= 4) {
|
|
220
|
+
options.onTextDelta?.(
|
|
221
|
+
`## Attempt ${this.steps}\nPartial response\n\n## Unfinished\nDRAFT_ONLY`,
|
|
222
|
+
);
|
|
223
|
+
throw new ProviderResponseError(
|
|
224
|
+
"reasoning_only_assistant",
|
|
225
|
+
"PTY_PROVIDER_FAILURE",
|
|
226
|
+
{
|
|
227
|
+
provider: prepared.provider,
|
|
228
|
+
model: prepared.model,
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return textOutput(prepared, "PTY_RETRY_DONE");
|
|
233
|
+
}
|
|
215
234
|
if (this.mode === "write-notes") {
|
|
216
235
|
return this.writeNotes(input, prepared, options);
|
|
217
236
|
}
|
|
@@ -358,12 +358,56 @@ function providerErrorCode(error: unknown): ProviderResponseErrorCode {
|
|
|
358
358
|
if (status === 500 || status === 502 || status === 503 || status === 504) {
|
|
359
359
|
return "provider_unavailable";
|
|
360
360
|
}
|
|
361
|
-
|
|
361
|
+
// Explicit HTTP failures take precedence over payload or transport hints.
|
|
362
|
+
if (status !== undefined) return "provider_request_error";
|
|
363
|
+
const code = errorField(error, "code");
|
|
364
|
+
if (code === "server_error") return "provider_unavailable";
|
|
365
|
+
if (code === "rate_limit_exceeded") return "provider_rate_limited";
|
|
366
|
+
if (error instanceof OpenAI.APIConnectionError || isTransportFailure(error)) {
|
|
362
367
|
return "provider_unavailable";
|
|
363
368
|
}
|
|
364
369
|
return "provider_request_error";
|
|
365
370
|
}
|
|
366
371
|
|
|
372
|
+
const TRANSIENT_TRANSPORT_CODES = new Set([
|
|
373
|
+
"ECONNRESET",
|
|
374
|
+
"EPIPE",
|
|
375
|
+
"ETIMEDOUT",
|
|
376
|
+
"ERR_STREAM_PREMATURE_CLOSE",
|
|
377
|
+
"UND_ERR_SOCKET",
|
|
378
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
379
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
380
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
function isTransportFailure(error: unknown): boolean {
|
|
384
|
+
const seen = new Set<unknown>();
|
|
385
|
+
// Fetch may wrap a socket error in TypeError.cause after headers arrive.
|
|
386
|
+
for (let depth = 0; error !== undefined && depth < 8; depth += 1) {
|
|
387
|
+
if (seen.has(error)) return false;
|
|
388
|
+
seen.add(error);
|
|
389
|
+
if (
|
|
390
|
+
errorField(error, "name") === "AbortError" ||
|
|
391
|
+
error instanceof OpenAI.APIUserAbortError ||
|
|
392
|
+
providerErrorStatus(error) !== undefined
|
|
393
|
+
) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
const code = errorField(error, "code");
|
|
397
|
+
if (code !== undefined) {
|
|
398
|
+
return typeof code === "string" && TRANSIENT_TRANSPORT_CODES.has(code);
|
|
399
|
+
}
|
|
400
|
+
error = errorField(error, "cause");
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function errorField(error: unknown, key: string): unknown {
|
|
406
|
+
return typeof error === "object" && error !== null
|
|
407
|
+
? (error as Record<string, unknown>)[key]
|
|
408
|
+
: undefined;
|
|
409
|
+
}
|
|
410
|
+
|
|
367
411
|
function providerErrorStatus(error: unknown): number | undefined {
|
|
368
412
|
if (typeof error !== "object" || error === null || !("status" in error)) {
|
|
369
413
|
return undefined;
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type ProviderResponseDiagnostics,
|
|
29
29
|
} from "./model-client";
|
|
30
30
|
import { imageAssetUrlMarker } from "./openai-image-mapping";
|
|
31
|
+
import { sanitizedProviderError } from "./openai-model-utils";
|
|
31
32
|
|
|
32
33
|
export type OpenAIResponsesMappingOptions = {
|
|
33
34
|
materializedImages?: ReadonlyMap<ImageAssetId, string>;
|
|
@@ -160,6 +161,16 @@ export function fromOpenAIResponse(
|
|
|
160
161
|
): ModelRequestOutput {
|
|
161
162
|
const root = requireRecord(response, "response", options);
|
|
162
163
|
const status = requireString(root.status, "status", options);
|
|
164
|
+
if (status === "failed") {
|
|
165
|
+
const error = requireRecord(root.error, "error", options);
|
|
166
|
+
const code = requireString(error.code, "error.code", options);
|
|
167
|
+
const message = requireString(error.message, "error.message", options);
|
|
168
|
+
throw sanitizedProviderError(
|
|
169
|
+
Object.assign(new Error(message), { code }),
|
|
170
|
+
options.provider,
|
|
171
|
+
options.model,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
163
174
|
if (status !== "completed" && status !== "incomplete") {
|
|
164
175
|
throw providerResponseError(
|
|
165
176
|
options,
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
ProviderResponseError,
|
|
3
3
|
type ProviderResponseDiagnostics,
|
|
4
4
|
} from "./model-client";
|
|
5
|
+
import { sanitizedProviderError } from "./openai-model-utils";
|
|
5
6
|
|
|
6
7
|
export class OpenAIResponsesStreamAccumulator {
|
|
7
8
|
private eventCount = 0;
|
|
@@ -20,6 +21,19 @@ export class OpenAIResponsesStreamAccumulator {
|
|
|
20
21
|
const type = requireString(record.type, `${path}.type`, this.options);
|
|
21
22
|
this.eventCount += 1;
|
|
22
23
|
|
|
24
|
+
if (type === "error") {
|
|
25
|
+
const message = requireString(record.message, `${path}.message`, this.options);
|
|
26
|
+
const code =
|
|
27
|
+
record.code === null
|
|
28
|
+
? null
|
|
29
|
+
: requireString(record.code, `${path}.code`, this.options);
|
|
30
|
+
throw sanitizedProviderError(
|
|
31
|
+
Object.assign(new Error(message), { code }),
|
|
32
|
+
this.options.provider,
|
|
33
|
+
this.options.model,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
if (type === "response.output_text.delta") {
|
|
24
38
|
return requireString(record.delta, `${path}.delta`, this.options);
|
|
25
39
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderMemoryTextSearch } from "../memory/memory-search-output";
|
|
1
2
|
import type { ToolCall } from "../agent/types";
|
|
2
3
|
import type { ToolResultContent } from "../agent/types";
|
|
3
4
|
import {
|
|
@@ -410,6 +411,7 @@ function renderMemorySearchObservation(raw: MemorySearchRawResult): string {
|
|
|
410
411
|
if (!raw.ok) {
|
|
411
412
|
return `MemorySearch unavailable: ${raw.error}`;
|
|
412
413
|
}
|
|
414
|
+
if ("format" in raw) return renderMemoryTextSearch(raw);
|
|
413
415
|
const degradedNote =
|
|
414
416
|
raw.degraded === "vector"
|
|
415
417
|
? " vector search unavailable; keyword results only."
|
|
@@ -467,7 +469,9 @@ function renderMemoryCreateObservation(
|
|
|
467
469
|
}
|
|
468
470
|
const text = memoryMutationText(call);
|
|
469
471
|
const result = `MemoryCreate ${raw.status} memory=${raw.memoryId} created_at=${raw.createdAt}.`;
|
|
470
|
-
|
|
472
|
+
const location =
|
|
473
|
+
raw.filePath === undefined ? result : `${result}\nPath: ${raw.filePath}`;
|
|
474
|
+
return text === undefined ? location : `${location}\ntext: ${text}`;
|
|
471
475
|
}
|
|
472
476
|
|
|
473
477
|
function renderMemoryUpdateObservation(
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Own every query statement until a short-lived connection closes. Bun only
|
|
5
|
+
* caches its first 20 queries; later statements otherwise survive close() until
|
|
6
|
+
* GC, leaving a zombie SQLite connection behind. Do not use this for resident
|
|
7
|
+
* stores: retaining every query is intentionally bounded by the operation.
|
|
8
|
+
*/
|
|
9
|
+
export class ScopedQueryDatabase extends Database {
|
|
10
|
+
private readonly queries = new Set<{ finalize(): void }>();
|
|
11
|
+
|
|
12
|
+
override query<Result, Params extends SQLQueryBindings | SQLQueryBindings[]>(
|
|
13
|
+
sql: string,
|
|
14
|
+
) {
|
|
15
|
+
const statement = super.query<Result, Params>(sql);
|
|
16
|
+
this.queries.add(statement);
|
|
17
|
+
return statement;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
override close(): void {
|
|
21
|
+
for (const statement of this.queries) statement.finalize();
|
|
22
|
+
this.queries.clear();
|
|
23
|
+
// Also finalizes Bun's transaction statements and rejects any other live
|
|
24
|
+
// resources instead of silently deferring the underlying connection close.
|
|
25
|
+
super.close(true);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { ScopedQueryDatabase } from "./scoped-query-database";
|
|
2
3
|
import { lstat, readdir, realpath } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
@@ -67,7 +68,7 @@ export function createSessionHistoryAccess(input: {
|
|
|
67
68
|
throwIfTurnCancelled(signal);
|
|
68
69
|
await validateHistoryFiles(location.databasePath, sessionId);
|
|
69
70
|
throwIfTurnCancelled(signal);
|
|
70
|
-
const database = new
|
|
71
|
+
const database = new ScopedQueryDatabase(location.databasePath, {
|
|
71
72
|
readonly: true,
|
|
72
73
|
strict: true,
|
|
73
74
|
safeIntegers: true,
|
|
@@ -100,7 +101,7 @@ export function createSessionHistoryAccess(input: {
|
|
|
100
101
|
throwIfTurnCancelled(signal);
|
|
101
102
|
return result;
|
|
102
103
|
} finally {
|
|
103
|
-
database.close(
|
|
104
|
+
database.close();
|
|
104
105
|
}
|
|
105
106
|
} catch (error) {
|
|
106
107
|
throwIfTurnCancelled(signal);
|
|
@@ -28,29 +28,6 @@ export type SessionMediaCompatibility = {
|
|
|
28
28
|
readonly toolResultModalities: readonly ("text" | "image")[];
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
export type CompletedTurnMessageSnapshot =
|
|
32
|
-
| {
|
|
33
|
-
readonly ordinal: number;
|
|
34
|
-
readonly role: "user";
|
|
35
|
-
readonly content: string;
|
|
36
|
-
}
|
|
37
|
-
| {
|
|
38
|
-
readonly ordinal: number;
|
|
39
|
-
readonly role: "assistant";
|
|
40
|
-
readonly content: string | null;
|
|
41
|
-
readonly reasoningContent?: string | null;
|
|
42
|
-
}
|
|
43
|
-
| {
|
|
44
|
-
readonly ordinal: number;
|
|
45
|
-
readonly role: "tool";
|
|
46
|
-
readonly name: string;
|
|
47
|
-
readonly content: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export type CompletedTurnSnapshot = {
|
|
51
|
-
readonly messages: readonly CompletedTurnMessageSnapshot[];
|
|
52
|
-
};
|
|
53
|
-
|
|
54
31
|
export type SessionCompatibilityContract = {
|
|
55
32
|
modelName: string;
|
|
56
33
|
profileName?: string;
|