@saadnvd1/agent-os 0.3.738 → 0.3.742
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/components/Chat/Activity.test.ts +2 -1
- package/components/Chat/Activity.tsx +102 -64
- package/components/Chat/ChatPanel.tsx +4 -0
- package/components/Chat/LiveOutput.test.ts +36 -0
- package/components/Chat/Tools.tsx +42 -5
- package/data/chat/useChat.ts +10 -1
- package/lib/chat/drivers/claude-mapper.ts +10 -0
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/drivers/edit-guard.ts +311 -0
- package/lib/chat/drivers/opencode-config.ts +111 -0
- package/lib/chat/drivers/opencode-mapper.ts +125 -13
- package/lib/chat/drivers/opencode-plugin.ts +78 -0
- package/lib/chat/drivers/opencode-server.ts +7 -2
- package/lib/chat/drivers/opencode.ts +108 -9
- package/lib/chat/drivers/pi-edit-guard.ts +87 -0
- package/lib/chat/drivers/pi-mapper.ts +74 -1
- package/lib/chat/drivers/pi.ts +82 -3
- package/lib/chat/drivers/transient-retry.ts +87 -0
- package/lib/chat/drivers/turn-items.ts +74 -1
- package/lib/chat/drivers/turn-outcome.ts +48 -1
- package/lib/chat/events.ts +15 -1
- package/lib/chat/model-wait-live.ts +19 -0
- package/lib/chat/model-wait.ts +88 -0
- package/lib/chat/progress.ts +169 -0
- package/lib/chat/registry.ts +5 -0
- package/lib/chat/runner-watch.ts +68 -0
- package/lib/chat/runner.ts +4 -70
- package/lib/chat/tools.ts +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { agentosHome } from "../../agentos-home";
|
|
5
|
+
|
|
6
|
+
// What a model needs after an edit goes wrong, for Pi and OpenCode alike
|
|
7
|
+
// (their edit tools are theirs, so this runs beside them as an extension and
|
|
8
|
+
// a plugin):
|
|
9
|
+
//
|
|
10
|
+
// - an edit whose old text isn't in the file is refused before it runs, with
|
|
11
|
+
// the file's current text around the closest match, and a repeat of an edit
|
|
12
|
+
// that already failed is called out;
|
|
13
|
+
// - after an edit or write to a TypeScript or JavaScript file, new syntax
|
|
14
|
+
// errors and block-scoped names declared twice come back in the tool result.
|
|
15
|
+
//
|
|
16
|
+
// Plain ESM with no imports beyond node's, so Node (Pi) and Bun (OpenCode)
|
|
17
|
+
// both run it. It never throws and never blocks on anything it can't read.
|
|
18
|
+
// Written as a string, like the other extensions, and exported for its test.
|
|
19
|
+
export const EDIT_GUARD_LIB = String.raw`import { createHash } from "node:crypto";
|
|
20
|
+
import { readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { extname, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
const SOURCE = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
25
|
+
const MAX_BYTES = 1048576;
|
|
26
|
+
const MAX_REPORTED = 10;
|
|
27
|
+
const EXCERPT_LINES = 40;
|
|
28
|
+
const EXCERPT_CHARS = 3000;
|
|
29
|
+
|
|
30
|
+
const norm = (s) => String(s).replace(/\r\n/g, "\n");
|
|
31
|
+
const trimmed = (s) => norm(s).split("\n").map((l) => l.trim());
|
|
32
|
+
const collapse = (s) => norm(s).replace(/\s+/g, " ").trim();
|
|
33
|
+
|
|
34
|
+
export function readText(file) {
|
|
35
|
+
try {
|
|
36
|
+
if (statSync(file).size > MAX_BYTES) return null;
|
|
37
|
+
return readFileSync(file, "utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const absolute = (file, cwd) =>
|
|
44
|
+
resolve(cwd || process.cwd(), String(file).replace(/^@/, ""));
|
|
45
|
+
|
|
46
|
+
function needLines(old) {
|
|
47
|
+
const need = trimmed(old);
|
|
48
|
+
while (need.length && need[need.length - 1] === "") need.pop();
|
|
49
|
+
while (need.length && need[0] === "") need.shift();
|
|
50
|
+
return need;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Whether any of the harnesses' matchers could find this text: exact, line by
|
|
54
|
+
// line without indentation, with whitespace collapsed, or by its first and
|
|
55
|
+
// last lines. Only text none of them find is refused, so an edit a harness
|
|
56
|
+
// would have accepted is never blocked here.
|
|
57
|
+
export function isFound(text, old) {
|
|
58
|
+
if (typeof old !== "string" || !old.trim()) return true;
|
|
59
|
+
if (text.includes(old) || norm(text).includes(norm(old))) return true;
|
|
60
|
+
const hay = trimmed(text);
|
|
61
|
+
const need = needLines(old);
|
|
62
|
+
if (!need.length) return true;
|
|
63
|
+
for (let i = 0; i + need.length <= hay.length; i++) {
|
|
64
|
+
let same = true;
|
|
65
|
+
for (let j = 0; j < need.length && same; j++) same = hay[i + j] === need[j];
|
|
66
|
+
if (same) return true;
|
|
67
|
+
}
|
|
68
|
+
if (collapse(text).includes(collapse(old))) return true;
|
|
69
|
+
if (need.length >= 3) {
|
|
70
|
+
const last = need[need.length - 1];
|
|
71
|
+
for (let i = 0; i < hay.length; i++) {
|
|
72
|
+
if (hay[i] !== need[0]) continue;
|
|
73
|
+
const to = Math.min(hay.length, i + need.length + 10);
|
|
74
|
+
for (let k = i + 2; k < to; k++) if (hay[k] === last) return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The file's lines around where the old text most likely was, numbered, or
|
|
81
|
+
// null when nothing in the file resembles it.
|
|
82
|
+
export function closestMatch(text, old) {
|
|
83
|
+
const lines = norm(text).split("\n");
|
|
84
|
+
const hay = lines.map((l) => l.trim());
|
|
85
|
+
const need = trimmed(old);
|
|
86
|
+
const real = need.filter((l) => l.length > 2);
|
|
87
|
+
if (!real.length) return null;
|
|
88
|
+
const anchor = real.reduce((a, b) => (b.length > a.length ? b : a));
|
|
89
|
+
const offset = need.indexOf(anchor);
|
|
90
|
+
let hits = [];
|
|
91
|
+
hay.forEach((l, i) => l === anchor && hits.push(i));
|
|
92
|
+
if (!hits.length) hay.forEach((l, i) => l.length > 2 && (l.includes(anchor) || anchor.includes(l)) && hits.push(i));
|
|
93
|
+
if (!hits.length) {
|
|
94
|
+
const words = (l) => new Set(l.toLowerCase().split(/[^a-z0-9_$]+/).filter(Boolean));
|
|
95
|
+
const want = words(anchor);
|
|
96
|
+
let top = 0.5;
|
|
97
|
+
hay.forEach((l, i) => {
|
|
98
|
+
const have = words(l);
|
|
99
|
+
let shared = 0;
|
|
100
|
+
for (const w of have) if (want.has(w)) shared++;
|
|
101
|
+
const dice = (2 * shared) / (want.size + have.size || 1);
|
|
102
|
+
if (dice > top) {
|
|
103
|
+
top = dice;
|
|
104
|
+
hits = [i];
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (!hits.length) return null;
|
|
109
|
+
let best = { score: -1, start: 0 };
|
|
110
|
+
for (const h of hits.slice(0, 200)) {
|
|
111
|
+
const start = Math.max(0, h - offset);
|
|
112
|
+
let score = 0;
|
|
113
|
+
for (let j = 0; j < need.length; j++) if (hay[start + j] === need[j] && need[j]) score++;
|
|
114
|
+
if (score > best.score) best = { score, start };
|
|
115
|
+
}
|
|
116
|
+
const from = Math.max(0, best.start - 3);
|
|
117
|
+
const to = Math.min(lines.length, best.start + need.length + 3);
|
|
118
|
+
let shown = lines.slice(from, to);
|
|
119
|
+
if (shown.length > EXCERPT_LINES) shown = shown.slice(0, EXCERPT_LINES);
|
|
120
|
+
let excerpt = shown.map((l, i) => (from + i + 1) + "\t" + l).join("\n");
|
|
121
|
+
if (excerpt.length > EXCERPT_CHARS) excerpt = excerpt.slice(0, EXCERPT_CHARS) + "\n…";
|
|
122
|
+
return { from: from + 1, to: from + shown.length, excerpt };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function failureMessage(file, old, text, repeat) {
|
|
126
|
+
const near = closestMatch(text, old);
|
|
127
|
+
const out = [
|
|
128
|
+
"The old text was not found in " + file + ". The file is not what this edit assumes: it changed since you last read it, or the text is not exactly what is there.",
|
|
129
|
+
];
|
|
130
|
+
if (repeat >= 2)
|
|
131
|
+
out.push("This is the same edit (same file, same old text) that has already failed " + (repeat - 1) + " time" + (repeat > 2 ? "s" : "") + ". Do not send it again unchanged.");
|
|
132
|
+
if (near) out.push("The file's current text near the closest match (lines " + near.from + "-" + near.to + "):", near.excerpt);
|
|
133
|
+
else out.push("Nothing in the file resembles that text (" + norm(text).split("\n").length + " lines).");
|
|
134
|
+
out.push("Read " + file + " again and build the edit from what is there now.");
|
|
135
|
+
return out.join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Failed edits by file and old text, so a repeat can be named. A read, a
|
|
139
|
+
// successful edit or a write of the file starts it over.
|
|
140
|
+
export class FailureLog {
|
|
141
|
+
constructor() {
|
|
142
|
+
this.counts = new Map();
|
|
143
|
+
}
|
|
144
|
+
key(file, old) {
|
|
145
|
+
return file + "\0" + createHash("sha1").update(String(old)).digest("hex");
|
|
146
|
+
}
|
|
147
|
+
record(file, old) {
|
|
148
|
+
const key = this.key(file, old);
|
|
149
|
+
const n = (this.counts.get(key) || 0) + 1;
|
|
150
|
+
this.counts.set(key, n);
|
|
151
|
+
return n;
|
|
152
|
+
}
|
|
153
|
+
clear(file) {
|
|
154
|
+
for (const key of [...this.counts.keys()]) if (key.startsWith(file + "\0")) this.counts.delete(key);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The refusal for an edit whose old text isn't in the file, or null to let
|
|
159
|
+
// it run. Several edits to one file: the first missing one is named.
|
|
160
|
+
export function guardEdit(log, file, oldTexts, cwd) {
|
|
161
|
+
try {
|
|
162
|
+
const path = absolute(file, cwd);
|
|
163
|
+
const text = readText(path);
|
|
164
|
+
if (text == null) return null;
|
|
165
|
+
const missing = oldTexts.find((o) => !isFound(text, o));
|
|
166
|
+
if (missing === undefined) return null;
|
|
167
|
+
return failureMessage(file, missing, text, log.record(path, missing));
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const tsByRoot = new Map();
|
|
174
|
+
function loadTs(cwd) {
|
|
175
|
+
const root = resolve(cwd || process.cwd());
|
|
176
|
+
const cacheKey = root + "|" + (process.env.AGENTOS_TYPESCRIPT || "");
|
|
177
|
+
if (tsByRoot.has(cacheKey)) return tsByRoot.get(cacheKey);
|
|
178
|
+
let found = null;
|
|
179
|
+
const tries = [
|
|
180
|
+
() => createRequire(resolve(root, "package.json"))("typescript"),
|
|
181
|
+
() => (process.env.AGENTOS_TYPESCRIPT ? createRequire(import.meta.url)(process.env.AGENTOS_TYPESCRIPT) : null),
|
|
182
|
+
];
|
|
183
|
+
for (const t of tries) {
|
|
184
|
+
try {
|
|
185
|
+
const m = t();
|
|
186
|
+
if (m && typeof m.createSourceFile === "function") {
|
|
187
|
+
found = m;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
tsByRoot.set(cacheKey, found);
|
|
193
|
+
return found;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function bindingNames(ts, name, out) {
|
|
197
|
+
if (ts.isIdentifier(name)) out.push(name);
|
|
198
|
+
else for (const el of name.elements) if (!ts.isOmittedExpression(el)) bindingNames(ts, el.name, out);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function duplicates(ts, sf) {
|
|
202
|
+
const found = [];
|
|
203
|
+
const check = (statements) => {
|
|
204
|
+
const lexical = new Map();
|
|
205
|
+
const hoisted = new Map();
|
|
206
|
+
const add = (map, id, kind) => {
|
|
207
|
+
const line = sf.getLineAndCharacterOfPosition(id.getStart(sf)).line + 1;
|
|
208
|
+
if (!map.has(id.text)) map.set(id.text, []);
|
|
209
|
+
map.get(id.text).push({ kind, line });
|
|
210
|
+
};
|
|
211
|
+
for (const st of statements) {
|
|
212
|
+
if (ts.isVariableStatement(st)) {
|
|
213
|
+
const flags = st.declarationList.flags;
|
|
214
|
+
const kind = flags & ts.NodeFlags.Const ? "const" : flags & ts.NodeFlags.Let ? "let" : "var";
|
|
215
|
+
const ids = [];
|
|
216
|
+
for (const d of st.declarationList.declarations) bindingNames(ts, d.name, ids);
|
|
217
|
+
for (const id of ids) add(kind === "var" ? hoisted : lexical, id, kind);
|
|
218
|
+
} else if (ts.isClassDeclaration(st) && st.name) add(lexical, st.name, "class");
|
|
219
|
+
else if (ts.isFunctionDeclaration(st) && st.name) add(hoisted, st.name, "function");
|
|
220
|
+
}
|
|
221
|
+
for (const [name, at] of lexical) {
|
|
222
|
+
const all = at.concat(hoisted.get(name) || []);
|
|
223
|
+
if (all.length > 1) found.push({ name, kind: at[0].kind, lines: all.map((a) => a.line).sort((a, b) => a - b) });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const visit = (node) => {
|
|
227
|
+
if (ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node)) check(node.statements);
|
|
228
|
+
else if (ts.isCaseBlock(node)) check(node.clauses.flatMap((c) => [...c.statements]));
|
|
229
|
+
ts.forEachChild(node, visit);
|
|
230
|
+
};
|
|
231
|
+
visit(sf);
|
|
232
|
+
return found;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function problems(ts, file, text) {
|
|
236
|
+
const out = [];
|
|
237
|
+
const t = ts.transpileModule(text, {
|
|
238
|
+
fileName: file,
|
|
239
|
+
reportDiagnostics: true,
|
|
240
|
+
compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, isolatedModules: true },
|
|
241
|
+
});
|
|
242
|
+
for (const d of t.diagnostics || []) {
|
|
243
|
+
const at = d.file && d.start != null ? d.file.getLineAndCharacterOfPosition(d.start) : null;
|
|
244
|
+
const message = ts.flattenDiagnosticMessageText(d.messageText, " ");
|
|
245
|
+
out.push({ key: message, text: (at ? (at.line + 1) + ":" + (at.character + 1) + " " : "") + message });
|
|
246
|
+
}
|
|
247
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
248
|
+
for (const d of duplicates(ts, sf))
|
|
249
|
+
out.push({
|
|
250
|
+
key: "duplicate " + d.name,
|
|
251
|
+
text: d.kind + " " + d.name + " is declared more than once in the same block (lines " + d.lines.join(", ") + ")",
|
|
252
|
+
});
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// What an edit broke in a source file: the problems it has now that it didn't
|
|
257
|
+
// before, as text for the tool result, or "" when there are none, the file
|
|
258
|
+
// isn't source, or TypeScript can't be loaded. Syntax only, never types.
|
|
259
|
+
export function syntaxReport(file, before, after, cwd) {
|
|
260
|
+
try {
|
|
261
|
+
if (!SOURCE.has(extname(file).toLowerCase()) || typeof after !== "string" || after.length > MAX_BYTES) return "";
|
|
262
|
+
const ts = loadTs(cwd);
|
|
263
|
+
if (!ts) return "";
|
|
264
|
+
const had = new Map();
|
|
265
|
+
for (const p of before == null || before.length > MAX_BYTES ? [] : problems(ts, file, before)) had.set(p.key, (had.get(p.key) || 0) + 1);
|
|
266
|
+
const fresh = [];
|
|
267
|
+
for (const p of problems(ts, file, after)) {
|
|
268
|
+
const n = had.get(p.key) || 0;
|
|
269
|
+
if (n > 0) had.set(p.key, n - 1);
|
|
270
|
+
else fresh.push(p);
|
|
271
|
+
}
|
|
272
|
+
if (!fresh.length) return "";
|
|
273
|
+
const shown = fresh.slice(0, MAX_REPORTED).map((p) => "- " + p.text);
|
|
274
|
+
if (fresh.length > shown.length) shown.push("- and " + (fresh.length - shown.length) + " more");
|
|
275
|
+
return "[AgentOS syntax check] This change left problems in " + file + " that were not there before. Fix them now:\n" + shown.join("\n");
|
|
276
|
+
} catch {
|
|
277
|
+
return "";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
const dir = () => agentosHome("edit-guard");
|
|
283
|
+
|
|
284
|
+
// Owner-only, like the other extension folders.
|
|
285
|
+
export function writeEditGuard(
|
|
286
|
+
name: string,
|
|
287
|
+
source: string,
|
|
288
|
+
folder = dir()
|
|
289
|
+
): string {
|
|
290
|
+
fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
291
|
+
fs.chmodSync(folder, 0o700);
|
|
292
|
+
for (const [file, text] of [
|
|
293
|
+
["agentos-edit-guard-lib.mjs", EDIT_GUARD_LIB],
|
|
294
|
+
[name, source],
|
|
295
|
+
]) {
|
|
296
|
+
const target = path.join(folder, file);
|
|
297
|
+
if (!fs.existsSync(target) || fs.readFileSync(target, "utf8") !== text)
|
|
298
|
+
fs.writeFileSync(target, text);
|
|
299
|
+
}
|
|
300
|
+
return path.join(folder, name);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The TypeScript this server runs on, for projects that have none of their
|
|
304
|
+
// own, or undefined where there isn't one.
|
|
305
|
+
export function typescriptPath(): string | undefined {
|
|
306
|
+
try {
|
|
307
|
+
return createRequire(import.meta.url).resolve("typescript");
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
|
|
6
|
+
type P = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
// How long OpenCode waits for a model server's response headers (its default
|
|
9
|
+
// is 5 minutes). A local model chewing through a long context sends nothing
|
|
10
|
+
// until it has read all of it, and a retry at the same limit fails the same
|
|
11
|
+
// way. The wait between chunks of a streamed reply keeps OpenCode's own 5
|
|
12
|
+
// minute limit, so a stream that started is judged on idle time and a server
|
|
13
|
+
// that never answers still ends the request.
|
|
14
|
+
export const HEADER_TIMEOUT_MS = 1_200_000;
|
|
15
|
+
|
|
16
|
+
const isObject = (v: unknown): v is P =>
|
|
17
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
18
|
+
|
|
19
|
+
// JSON with comments and trailing commas (OpenCode's config allows both),
|
|
20
|
+
// parsed without touching what is inside strings. Null when it isn't JSON.
|
|
21
|
+
export function parseJsonc(text: string): P | null {
|
|
22
|
+
let out = "";
|
|
23
|
+
for (let i = 0; i < text.length; i++) {
|
|
24
|
+
const c = text[i];
|
|
25
|
+
if (c === '"') {
|
|
26
|
+
let j = i + 1;
|
|
27
|
+
while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
|
|
28
|
+
out += text.slice(i, j + 1);
|
|
29
|
+
i = j;
|
|
30
|
+
} else if (c === "/" && text[i + 1] === "/") {
|
|
31
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
32
|
+
out += "\n";
|
|
33
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
34
|
+
const end = text.indexOf("*/", i + 2);
|
|
35
|
+
i = end < 0 ? text.length : end + 1;
|
|
36
|
+
} else out += c;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const v: unknown = JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
40
|
+
return isObject(v) ? v : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function read(file: string): P | null {
|
|
47
|
+
try {
|
|
48
|
+
return parseJsonc(fs.readFileSync(file, "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function userConfigs(cwd: string, env: NodeJS.ProcessEnv): P[] {
|
|
55
|
+
const home = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
56
|
+
const files = [
|
|
57
|
+
...["opencode.json", "opencode.jsonc", "config.json"].map((f) =>
|
|
58
|
+
path.join(home, "opencode", f)
|
|
59
|
+
),
|
|
60
|
+
...(env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : []),
|
|
61
|
+
...["opencode.json", "opencode.jsonc"].flatMap((f) => [
|
|
62
|
+
path.join(cwd, f),
|
|
63
|
+
path.join(cwd, ".opencode", f),
|
|
64
|
+
]),
|
|
65
|
+
];
|
|
66
|
+
return files.map(read).filter((c): c is P => !!c);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The OPENCODE_CONFIG_CONTENT for one conversation: the user's own value
|
|
70
|
+
// (never replaced, their providers are in it) plus the edit guard plugin and
|
|
71
|
+
// a longer header wait for each provider in their config and for the
|
|
72
|
+
// conversation's model, unless they set one themselves.
|
|
73
|
+
export function openCodeConfigContent(o: {
|
|
74
|
+
cwd: string;
|
|
75
|
+
env: NodeJS.ProcessEnv;
|
|
76
|
+
// "provider/model", the conversation's.
|
|
77
|
+
model?: string;
|
|
78
|
+
pluginFile?: string;
|
|
79
|
+
}): string {
|
|
80
|
+
const given = o.env.OPENCODE_CONFIG_CONTENT;
|
|
81
|
+
const base = given ? parseJsonc(given) : {};
|
|
82
|
+
if (!base) return given as string;
|
|
83
|
+
|
|
84
|
+
const own = new Set<string>();
|
|
85
|
+
const set = new Set<string>();
|
|
86
|
+
const slash = o.model?.indexOf("/") ?? -1;
|
|
87
|
+
if (o.model && slash > 0) set.add(o.model.slice(0, slash));
|
|
88
|
+
for (const c of [...userConfigs(o.cwd, o.env), base]) {
|
|
89
|
+
if (!isObject(c.provider)) continue;
|
|
90
|
+
for (const [id, p] of Object.entries(c.provider)) {
|
|
91
|
+
set.add(id);
|
|
92
|
+
const options = isObject(p) && isObject(p.options) ? p.options : {};
|
|
93
|
+
if (options.headerTimeout !== undefined) own.add(id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const provider: P = isObject(base.provider) ? { ...base.provider } : {};
|
|
98
|
+
for (const id of set) {
|
|
99
|
+
if (own.has(id)) continue;
|
|
100
|
+
const p = isObject(provider[id]) ? (provider[id] as P) : {};
|
|
101
|
+
const options = isObject(p.options) ? p.options : {};
|
|
102
|
+
provider[id] = {
|
|
103
|
+
...p,
|
|
104
|
+
options: { ...options, headerTimeout: HEADER_TIMEOUT_MS },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const plugin = Array.isArray(base.plugin) ? [...base.plugin] : [];
|
|
109
|
+
if (o.pluginFile) plugin.push(pathToFileURL(o.pluginFile).href);
|
|
110
|
+
return JSON.stringify({ ...base, provider, plugin });
|
|
111
|
+
}
|
|
@@ -2,12 +2,16 @@ import { randomUUID } from "crypto";
|
|
|
2
2
|
import type { DriverEvent, UsageTotals } from "../events";
|
|
3
3
|
import { openCodeTool } from "./opencode-rules";
|
|
4
4
|
import { TurnItems } from "./turn-items";
|
|
5
|
-
import { logTurnEnd, turnOutcome } from "./turn-outcome";
|
|
5
|
+
import { logTurnEnd, needsSummary, turnOutcome } from "./turn-outcome";
|
|
6
|
+
import { retryNotice, TurnRetry, type RetryDecision } from "./transient-retry";
|
|
6
7
|
|
|
7
8
|
type P = Record<string, unknown>;
|
|
8
9
|
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
9
10
|
const num = (v: unknown) => Number(v) || 0;
|
|
10
11
|
|
|
12
|
+
// A prompt this big (tokens) is worth compacting before the last retry.
|
|
13
|
+
const HEAVY_TOKENS = 16_000;
|
|
14
|
+
|
|
11
15
|
const TODO = new Set(["pending", "in_progress", "completed"]);
|
|
12
16
|
|
|
13
17
|
// OpenCode's event stream as chat items, for one session (its subagents'
|
|
@@ -37,6 +41,10 @@ export class OpenCodeMapper {
|
|
|
37
41
|
calls: new Set<string>(),
|
|
38
42
|
};
|
|
39
43
|
private aborted = false;
|
|
44
|
+
// Tool calls this turn, kept through the nudged run that continues it.
|
|
45
|
+
private toolIds = new Set<string>();
|
|
46
|
+
private nudged = false;
|
|
47
|
+
private nudgeDue = false;
|
|
40
48
|
// Idle from before the prompt is stale until the session goes busy.
|
|
41
49
|
private admitted = false;
|
|
42
50
|
totals: UsageTotals = {
|
|
@@ -47,15 +55,34 @@ export class OpenCodeMapper {
|
|
|
47
55
|
cacheWriteTokens: 0,
|
|
48
56
|
};
|
|
49
57
|
|
|
58
|
+
// Retrying a turn the model's server dropped.
|
|
59
|
+
readonly retry = new TurnRetry();
|
|
60
|
+
// OpenCode retried the failure itself: AgentOS adds nothing on top.
|
|
61
|
+
private agentRetried = false;
|
|
62
|
+
// The last answer's prompt size, to judge whether compacting would help.
|
|
63
|
+
lastInputTokens = 0;
|
|
64
|
+
// Compacting between retries makes the session busy and idle: not a turn.
|
|
65
|
+
private holding = false;
|
|
66
|
+
|
|
50
67
|
constructor(
|
|
51
68
|
private sessionId: () => string | null,
|
|
52
|
-
private emit: (e: DriverEvent) => void
|
|
69
|
+
private emit: (e: DriverEvent) => void,
|
|
70
|
+
// Called when a failed turn is to be tried again.
|
|
71
|
+
private onRetry: (
|
|
72
|
+
d: Extract<RetryDecision, { retry: true }>,
|
|
73
|
+
error: string
|
|
74
|
+
) => void = () => {}
|
|
53
75
|
) {
|
|
54
76
|
this.items = new TurnItems(emit);
|
|
55
77
|
}
|
|
56
78
|
|
|
57
|
-
// A prompt went out: its turn starts now.
|
|
58
|
-
prompted(): void {
|
|
79
|
+
// A prompt went out: its turn starts now. A retry is the same turn.
|
|
80
|
+
prompted(retried = false): void {
|
|
81
|
+
this.retrying = false;
|
|
82
|
+
if (!retried) {
|
|
83
|
+
this.retry.reset();
|
|
84
|
+
this.agentRetried = false;
|
|
85
|
+
}
|
|
59
86
|
this.admitted = false;
|
|
60
87
|
this.items.startTurn();
|
|
61
88
|
}
|
|
@@ -66,9 +93,18 @@ export class OpenCodeMapper {
|
|
|
66
93
|
switch (e.type) {
|
|
67
94
|
case "session.status": {
|
|
68
95
|
const type = str((p.status as P)?.type);
|
|
96
|
+
if (type === "retry") this.agentRetried = true;
|
|
69
97
|
if (type === "busy" || type === "retry") {
|
|
70
98
|
this.admitted = true;
|
|
71
99
|
this.items.startTurn();
|
|
100
|
+
if (type === "retry") {
|
|
101
|
+
const st = p.status as P;
|
|
102
|
+
this.emit({
|
|
103
|
+
type: "model_retry",
|
|
104
|
+
attempt: num(st.attempt) || 1,
|
|
105
|
+
message: str(st.message) || undefined,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
72
108
|
} else if (type === "idle") this.idle();
|
|
73
109
|
return;
|
|
74
110
|
}
|
|
@@ -125,6 +161,12 @@ export class OpenCodeMapper {
|
|
|
125
161
|
this.counted.add(id);
|
|
126
162
|
const t = (info.tokens ?? {}) as P;
|
|
127
163
|
const cache = (t.cache ?? {}) as P;
|
|
164
|
+
this.lastInputTokens = num(t.input) + num(cache.read);
|
|
165
|
+
if (this.lastInputTokens + num(cache.write) > 0)
|
|
166
|
+
this.emit({
|
|
167
|
+
type: "prompt_tokens",
|
|
168
|
+
tokens: this.lastInputTokens + num(cache.write),
|
|
169
|
+
});
|
|
128
170
|
this.totals = {
|
|
129
171
|
costUsd: this.totals.costUsd + num(info.cost),
|
|
130
172
|
inputTokens: this.totals.inputTokens + num(t.input),
|
|
@@ -157,6 +199,7 @@ export class OpenCodeMapper {
|
|
|
157
199
|
return;
|
|
158
200
|
}
|
|
159
201
|
if (part.type === "tool") {
|
|
202
|
+
this.toolIds.add(id);
|
|
160
203
|
if (this.last.id === str(part.messageID)) this.last.calls.add(id);
|
|
161
204
|
this.tool(id, part);
|
|
162
205
|
}
|
|
@@ -183,7 +226,9 @@ export class OpenCodeMapper {
|
|
|
183
226
|
if (tool === "todowrite" || status === "pending") return;
|
|
184
227
|
const start = openCodeTool(tool, state.input);
|
|
185
228
|
if (!this.items.hasTool(id)) this.items.tool(id, start);
|
|
186
|
-
if (status === "
|
|
229
|
+
if (status === "running")
|
|
230
|
+
this.items.progress(id, str((state.metadata as P | undefined)?.output));
|
|
231
|
+
else if (status === "completed")
|
|
187
232
|
this.items.toolDone(id, "done", str(state.output), start);
|
|
188
233
|
else if (status === "error")
|
|
189
234
|
this.items.toolDone(id, "error", str(state.error), start);
|
|
@@ -197,10 +242,72 @@ export class OpenCodeMapper {
|
|
|
197
242
|
str((error.data as P)?.message) || str(error.name) || "OpenCode failed";
|
|
198
243
|
}
|
|
199
244
|
|
|
245
|
+
// A turn that failed on a dropped request goes on in place: the notice,
|
|
246
|
+
// then the driver sends the prompt again. False when it should end.
|
|
247
|
+
private retryTurn(): boolean {
|
|
248
|
+
const error = this.error ?? "";
|
|
249
|
+
const d = this.retry.next({
|
|
250
|
+
error,
|
|
251
|
+
harnessRetried: this.agentRetried,
|
|
252
|
+
contextHeavy: this.lastInputTokens >= HEAVY_TOKENS,
|
|
253
|
+
});
|
|
254
|
+
if (!d.retry) return false;
|
|
255
|
+
this.items.note(retryNotice(d, error));
|
|
256
|
+
this.clearTurn();
|
|
257
|
+
this.retrying = true;
|
|
258
|
+
this.onRetry(d, error);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private clearTurn(): void {
|
|
263
|
+
this.error = null;
|
|
264
|
+
this.last.id = "";
|
|
265
|
+
this.toolIds.clear();
|
|
266
|
+
this.aborted = false;
|
|
267
|
+
this.admitted = false;
|
|
268
|
+
for (const id of this.current) this.past.add(id);
|
|
269
|
+
this.current.clear();
|
|
270
|
+
this.parts.clear();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// A failed turn is waiting for its retry to go out.
|
|
274
|
+
retrying = false;
|
|
275
|
+
|
|
276
|
+
// The reader stopped the turn while it waited to retry.
|
|
277
|
+
stopRetrying(): void {
|
|
278
|
+
this.retrying = false;
|
|
279
|
+
this.holding = false;
|
|
280
|
+
this.emit({ type: "usage", totals: { ...this.totals } });
|
|
281
|
+
this.items.endTurn({ interrupted: true });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
holdIdle(on: boolean): void {
|
|
285
|
+
this.holding = on;
|
|
286
|
+
}
|
|
287
|
+
|
|
200
288
|
private idle(): void {
|
|
201
|
-
if (!this.admitted || !this.items.inTurn) return;
|
|
289
|
+
if (this.holding || !this.admitted || !this.items.inTurn) return;
|
|
290
|
+
if (this.error && !this.aborted && this.retryTurn()) return;
|
|
202
291
|
this.emit({ type: "usage", totals: { ...this.totals } });
|
|
203
292
|
const text = [...this.last.text.values()].join("\n");
|
|
293
|
+
if (
|
|
294
|
+
!this.nudged &&
|
|
295
|
+
needsSummary({
|
|
296
|
+
toolsRun: this.toolIds.size,
|
|
297
|
+
text,
|
|
298
|
+
toolCalls: this.last.calls.size,
|
|
299
|
+
finishReason: this.last.finish,
|
|
300
|
+
error: this.error ?? undefined,
|
|
301
|
+
aborted: this.aborted,
|
|
302
|
+
})
|
|
303
|
+
) {
|
|
304
|
+
// The turn stays open: the driver asks for the summary, once.
|
|
305
|
+
this.nudged = this.nudgeDue = true;
|
|
306
|
+
this.last.id = "";
|
|
307
|
+
this.admitted = false;
|
|
308
|
+
console.info("[chat] asked the model for its results summary");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
204
311
|
const ended =
|
|
205
312
|
this.aborted || !this.last.id
|
|
206
313
|
? undefined
|
|
@@ -209,6 +316,9 @@ export class OpenCodeMapper {
|
|
|
209
316
|
text,
|
|
210
317
|
toolCalls: this.last.calls.size,
|
|
211
318
|
error: this.error ?? undefined,
|
|
319
|
+
retries: this.retry.tried,
|
|
320
|
+
agentRetried: this.agentRetried,
|
|
321
|
+
summary: this.items.summary(),
|
|
212
322
|
});
|
|
213
323
|
if (ended)
|
|
214
324
|
logTurnEnd({
|
|
@@ -225,13 +335,15 @@ export class OpenCodeMapper {
|
|
|
225
335
|
finishReason: this.last.finish || undefined,
|
|
226
336
|
ended,
|
|
227
337
|
});
|
|
228
|
-
this.
|
|
229
|
-
this.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
this.
|
|
338
|
+
this.clearTurn();
|
|
339
|
+
this.nudged = this.nudgeDue = false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// True once after a turn ended without its results: send SUMMARY_NUDGE.
|
|
343
|
+
takeNudge(): boolean {
|
|
344
|
+
const due = this.nudgeDue;
|
|
345
|
+
this.nudgeDue = false;
|
|
346
|
+
return due;
|
|
235
347
|
}
|
|
236
348
|
|
|
237
349
|
// The prompt never reached a busy session (it failed, or finished before
|