@saadnvd1/agent-os 0.3.738 → 0.3.743
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/demo.ts +42 -4
- package/lib/chat/drivers/claude-mapper.ts +10 -0
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/drivers/demo-script.ts +148 -0
- package/lib/chat/drivers/demo.ts +21 -4
- package/lib/chat/drivers/edit-guard.ts +311 -0
- package/lib/chat/drivers/index.ts +2 -2
- 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 +71 -0
- package/lib/chat/runner.ts +4 -70
- package/lib/chat/tools.ts +14 -0
- package/lib/security/demo.ts +8 -0
- package/lib/tasks/move-testing.ts +6 -1
- package/package.json +1 -1
- package/scripts/screenshots/run.ts +27 -3
- package/scripts/screenshots/running-turn.ts +54 -0
- package/scripts/screenshots/seed.ts +15 -3
- package/scripts/screenshots/shoot.ts +3 -1
- package/scripts/screenshots/shots.ts +5 -0
- package/server.ts +10 -2
|
@@ -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
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { writeEditGuard } from "./edit-guard";
|
|
2
|
+
|
|
3
|
+
// OpenCode's edit and write tools, with the edit guard beside them as a
|
|
4
|
+
// plugin. A throw in tool.execute.before becomes the tool's error, so a
|
|
5
|
+
// doomed edit never runs and the model reads why; tool.execute.after adds
|
|
6
|
+
// what a finished edit broke to the result. OpenCode runs every export of a
|
|
7
|
+
// plugin file as a plugin, so this has exactly one. Exported for its test.
|
|
8
|
+
export const OPENCODE_GUARD_PLUGIN = String.raw`import { createHash } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
FailureLog,
|
|
11
|
+
absolute,
|
|
12
|
+
failureMessage,
|
|
13
|
+
guardEdit,
|
|
14
|
+
readText,
|
|
15
|
+
syntaxReport,
|
|
16
|
+
} from "./agentos-edit-guard-lib.mjs";
|
|
17
|
+
|
|
18
|
+
const EDITS = new Set(["edit", "multiedit"]);
|
|
19
|
+
const hash = (t) => createHash("sha1").update(String(t ?? "")).digest("hex");
|
|
20
|
+
|
|
21
|
+
function oldTexts(args) {
|
|
22
|
+
if (Array.isArray(args?.edits)) return args.edits.map((e) => e?.oldString).filter((t) => typeof t === "string");
|
|
23
|
+
return typeof args?.oldString === "string" ? [args.oldString] : [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const AgentOSGuard = async (ctx) => {
|
|
27
|
+
const cwd = ctx?.directory || process.cwd();
|
|
28
|
+
const log = new FailureLog();
|
|
29
|
+
const before = new Map();
|
|
30
|
+
// Edits that passed the check but never reached tool.execute.after: the
|
|
31
|
+
// tool itself failed them.
|
|
32
|
+
const inflight = new Map();
|
|
33
|
+
return {
|
|
34
|
+
"tool.execute.before": async (input, output) => {
|
|
35
|
+
const tool = input?.tool;
|
|
36
|
+
const args = output?.args || {};
|
|
37
|
+
if (!EDITS.has(tool) && tool !== "write") return;
|
|
38
|
+
if (typeof args.filePath !== "string") return;
|
|
39
|
+
const file = absolute(args.filePath, cwd);
|
|
40
|
+
if (EDITS.has(tool)) {
|
|
41
|
+
const why = guardEdit(log, args.filePath, oldTexts(args), cwd);
|
|
42
|
+
if (why) throw new Error(why);
|
|
43
|
+
const old = oldTexts(args)[0];
|
|
44
|
+
const text = readText(file);
|
|
45
|
+
if (tool === "edit" && old !== undefined && text != null) {
|
|
46
|
+
const key = log.key(file, old);
|
|
47
|
+
for (const [id, e] of inflight) {
|
|
48
|
+
if (id === input.callID || e.key !== key || e.hash !== hash(text)) continue;
|
|
49
|
+
inflight.delete(id);
|
|
50
|
+
log.record(file, old);
|
|
51
|
+
throw new Error(failureMessage(args.filePath, old, text, log.record(file, old)));
|
|
52
|
+
}
|
|
53
|
+
inflight.set(input.callID, { key, hash: hash(text) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
before.set(input.callID, readText(file));
|
|
57
|
+
},
|
|
58
|
+
"tool.execute.after": async (input, output) => {
|
|
59
|
+
try {
|
|
60
|
+
const tool = input?.tool;
|
|
61
|
+
const args = input?.args || {};
|
|
62
|
+
if (!EDITS.has(tool) && tool !== "write") return;
|
|
63
|
+
if (typeof args.filePath !== "string") return;
|
|
64
|
+
const file = absolute(args.filePath, cwd);
|
|
65
|
+
const prior = before.get(input.callID);
|
|
66
|
+
before.delete(input.callID);
|
|
67
|
+
inflight.delete(input.callID);
|
|
68
|
+
log.clear(file);
|
|
69
|
+
const report = syntaxReport(args.filePath, prior, readText(file), cwd);
|
|
70
|
+
if (report && output) output.output = String(output.output ?? "") + "\n\n" + report;
|
|
71
|
+
} catch {}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
export const writeOpenCodeGuard = (folder?: string) =>
|
|
78
|
+
writeEditGuard("agentos-opencode-guard.mjs", OPENCODE_GUARD_PLUGIN, folder);
|
|
@@ -85,7 +85,12 @@ export class OpenCodeServer {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
// One JSON call. Errors carry OpenCode's message, never the URL.
|
|
88
|
-
async call<T>(
|
|
88
|
+
async call<T>(
|
|
89
|
+
method: string,
|
|
90
|
+
path: string,
|
|
91
|
+
body?: unknown,
|
|
92
|
+
timeoutMs = CALL_MS
|
|
93
|
+
): Promise<T> {
|
|
89
94
|
await this.ready;
|
|
90
95
|
const res = await fetch(this.endpoint(path), {
|
|
91
96
|
method,
|
|
@@ -94,7 +99,7 @@ export class OpenCodeServer {
|
|
|
94
99
|
...(body !== undefined && { "content-type": "application/json" }),
|
|
95
100
|
},
|
|
96
101
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
97
|
-
signal: AbortSignal.timeout(
|
|
102
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
98
103
|
});
|
|
99
104
|
const text = await res.text();
|
|
100
105
|
const data = text ? (JSON.parse(text) as T & P) : (undefined as T);
|
|
@@ -7,14 +7,52 @@ import { OpenCodeMapper } from "./opencode-mapper";
|
|
|
7
7
|
import { openCodeRules } from "./opencode-rules";
|
|
8
8
|
import { OpenCodeServer } from "./opencode-server";
|
|
9
9
|
import { OpenCodeApprovals } from "./opencode-approvals";
|
|
10
|
+
import { RESULTS_BRIEF, SUMMARY_NUDGE } from "./turn-outcome";
|
|
11
|
+
import { openCodeConfigContent } from "./opencode-config";
|
|
12
|
+
import { writeOpenCodeGuard } from "./opencode-plugin";
|
|
13
|
+
import { typescriptPath } from "./edit-guard";
|
|
14
|
+
import { continuePrompt, type RetryDecision } from "./transient-retry";
|
|
10
15
|
|
|
11
16
|
type P = Record<string, unknown>;
|
|
12
17
|
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
13
18
|
|
|
19
|
+
// Compacting can take a while; the retry waits for it.
|
|
20
|
+
const COMPACT_MS = 300_000;
|
|
21
|
+
|
|
22
|
+
// The server's environment for one conversation: the edit guard plugin, a
|
|
23
|
+
// longer header wait for its providers, and the TypeScript to check syntax
|
|
24
|
+
// with. Where any of it can't be set up the conversation runs without it.
|
|
25
|
+
function conversationEnv(
|
|
26
|
+
cwd: string,
|
|
27
|
+
model: string,
|
|
28
|
+
env: NodeJS.ProcessEnv
|
|
29
|
+
): NodeJS.ProcessEnv {
|
|
30
|
+
try {
|
|
31
|
+
const ts = typescriptPath();
|
|
32
|
+
const withTs = { ...env, ...(ts && { AGENTOS_TYPESCRIPT: ts }) };
|
|
33
|
+
return {
|
|
34
|
+
...withTs,
|
|
35
|
+
OPENCODE_CONFIG_CONTENT: openCodeConfigContent({
|
|
36
|
+
cwd,
|
|
37
|
+
env: withTs,
|
|
38
|
+
model,
|
|
39
|
+
pluginFile: writeOpenCodeGuard(),
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return env;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
14
47
|
// How long a prompt may go without the session turning busy before the
|
|
15
48
|
// turn is checked on.
|
|
16
49
|
const ADMIT_MS = 15_000;
|
|
17
50
|
|
|
51
|
+
// AgentOS's own brief, then the results line every session gets.
|
|
52
|
+
export function openCodeSystem(append?: string): string {
|
|
53
|
+
return [append, RESULTS_BRIEF].filter(Boolean).join("\n\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
18
56
|
export function openCodeModel(model: string) {
|
|
19
57
|
const slash = model.indexOf("/");
|
|
20
58
|
if (!model || model === AGENT_DEFAULT_MODEL || slash < 1) return undefined;
|
|
@@ -79,13 +117,23 @@ export const openCodeDriver: ChatDriver = {
|
|
|
79
117
|
start(options): ChatConversation {
|
|
80
118
|
const out = new InputQueue<DriverEvent>();
|
|
81
119
|
const emit = (e: DriverEvent) => out.push(e);
|
|
82
|
-
const server = new OpenCodeServer(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
120
|
+
const server = new OpenCodeServer(
|
|
121
|
+
options.cwd,
|
|
122
|
+
conversationEnv(options.cwd, options.model ?? "", {
|
|
123
|
+
...sessionBaseEnv(),
|
|
124
|
+
...options.env,
|
|
125
|
+
})
|
|
126
|
+
);
|
|
86
127
|
let sessionId: string | null = null;
|
|
87
128
|
const children = new Set<string>();
|
|
88
|
-
|
|
129
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
130
|
+
// Bumped when a retry is cancelled, so one already under way stops.
|
|
131
|
+
let retryGen = 0;
|
|
132
|
+
const mapper = new OpenCodeMapper(
|
|
133
|
+
() => sessionId,
|
|
134
|
+
emit,
|
|
135
|
+
(d, error) => scheduleRetry(d, error)
|
|
136
|
+
);
|
|
89
137
|
const approvals = new OpenCodeApprovals(emit, server, (e) => fail(e));
|
|
90
138
|
let access = options.access;
|
|
91
139
|
let plan = !!options.plan;
|
|
@@ -106,6 +154,7 @@ export const openCodeDriver: ChatDriver = {
|
|
|
106
154
|
if (ours(p.sessionID)) approvals.handle(e.type, p);
|
|
107
155
|
const wasInTurn = mapper.items.inTurn;
|
|
108
156
|
mapper.map(e);
|
|
157
|
+
if (mapper.takeNudge()) return void prompt(SUMMARY_NUDGE);
|
|
109
158
|
if (wasInTurn && !mapper.items.inTurn && held.length) {
|
|
110
159
|
const next = held.shift()!;
|
|
111
160
|
void prompt(next.text, next.images);
|
|
@@ -153,15 +202,59 @@ export const openCodeDriver: ChatDriver = {
|
|
|
153
202
|
});
|
|
154
203
|
}
|
|
155
204
|
|
|
156
|
-
|
|
157
|
-
|
|
205
|
+
// The failed request goes again after its wait, on a compacted context
|
|
206
|
+
// for the last try. Stopping or closing cancels it.
|
|
207
|
+
function scheduleRetry(
|
|
208
|
+
d: Extract<RetryDecision, { retry: true }>,
|
|
209
|
+
error: string
|
|
210
|
+
) {
|
|
211
|
+
cancelRetry();
|
|
212
|
+
const gen = retryGen;
|
|
213
|
+
retryTimer = setTimeout(() => {
|
|
214
|
+
retryTimer = null;
|
|
215
|
+
void resume(d, error, gen);
|
|
216
|
+
}, d.delayMs);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Whether a retry was waiting or under way.
|
|
220
|
+
function cancelRetry(): boolean {
|
|
221
|
+
const pending = retryTimer !== null || mapper.retrying;
|
|
222
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
223
|
+
retryTimer = null;
|
|
224
|
+
retryGen++;
|
|
225
|
+
return pending;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function resume(
|
|
229
|
+
d: Extract<RetryDecision, { retry: true }>,
|
|
230
|
+
error: string,
|
|
231
|
+
gen: number
|
|
232
|
+
) {
|
|
233
|
+
const live = () => !closed && mapper.items.inTurn && gen === retryGen;
|
|
234
|
+
if (!live()) return;
|
|
235
|
+
if (d.compactFirst && model) {
|
|
236
|
+
mapper.holdIdle(true);
|
|
237
|
+
await server
|
|
238
|
+
.call("POST", `/session/${sessionId}/summarize`, model, COMPACT_MS)
|
|
239
|
+
.catch(() => {});
|
|
240
|
+
mapper.holdIdle(false);
|
|
241
|
+
if (!live()) return;
|
|
242
|
+
}
|
|
243
|
+
await prompt(continuePrompt(error), undefined, true);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function prompt(text: string, images?: ChatImage[], retried = false) {
|
|
247
|
+
if (!retried) cancelRetry();
|
|
248
|
+
mapper.prompted(retried);
|
|
158
249
|
try {
|
|
159
250
|
await ready;
|
|
160
251
|
await server.call("POST", `/session/${sessionId}/prompt_async`, {
|
|
161
252
|
model,
|
|
162
253
|
agent: plan ? "plan" : "build",
|
|
163
|
-
system: options.systemAppend
|
|
164
|
-
parts:
|
|
254
|
+
system: openCodeSystem(options.systemAppend),
|
|
255
|
+
parts: retried
|
|
256
|
+
? [{ type: "text", text, synthetic: true }]
|
|
257
|
+
: openCodeParts(text, images),
|
|
165
258
|
});
|
|
166
259
|
} catch (error) {
|
|
167
260
|
fail(error as Error);
|
|
@@ -180,6 +273,11 @@ export const openCodeDriver: ChatDriver = {
|
|
|
180
273
|
}
|
|
181
274
|
|
|
182
275
|
async function abort() {
|
|
276
|
+
// A turn waiting to retry has nothing running to abort: it ends here.
|
|
277
|
+
if (cancelRetry()) {
|
|
278
|
+
mapper.stopRetrying();
|
|
279
|
+
for (const next of held.splice(0)) void prompt(next.text, next.images);
|
|
280
|
+
}
|
|
183
281
|
await ready;
|
|
184
282
|
for (const id of [sessionId, ...children])
|
|
185
283
|
await server.call("POST", `/session/${id}/abort`).catch(() => {});
|
|
@@ -188,6 +286,7 @@ export const openCodeDriver: ChatDriver = {
|
|
|
188
286
|
function close() {
|
|
189
287
|
if (closed) return;
|
|
190
288
|
closed = true;
|
|
289
|
+
cancelRetry();
|
|
191
290
|
approvals.pending.expireAll();
|
|
192
291
|
stream.abort();
|
|
193
292
|
server.close();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { writeEditGuard } from "./edit-guard";
|
|
2
|
+
|
|
3
|
+
// Pi's edit and write tools, with the edit guard beside them: a doomed edit
|
|
4
|
+
// is blocked before it runs (and before the approvals extension asks about
|
|
5
|
+
// it), and what a finished edit or write broke comes back in its result.
|
|
6
|
+
// Exported for its test.
|
|
7
|
+
export const PI_EDIT_GUARD_EXTENSION = String.raw`import {
|
|
8
|
+
FailureLog,
|
|
9
|
+
absolute,
|
|
10
|
+
closestMatch,
|
|
11
|
+
guardEdit,
|
|
12
|
+
readText,
|
|
13
|
+
syntaxReport,
|
|
14
|
+
} from "./agentos-edit-guard-lib.mjs";
|
|
15
|
+
|
|
16
|
+
const log = new FailureLog();
|
|
17
|
+
const before = new Map();
|
|
18
|
+
|
|
19
|
+
function oldTexts(input) {
|
|
20
|
+
let edits = input?.edits;
|
|
21
|
+
if (typeof edits === "string") {
|
|
22
|
+
try {
|
|
23
|
+
edits = JSON.parse(edits);
|
|
24
|
+
} catch {
|
|
25
|
+
edits = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(edits)) return edits.map((e) => e?.oldText).filter((t) => typeof t === "string");
|
|
29
|
+
return typeof input?.oldText === "string" ? [input.oldText] : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const textOf = (content) =>
|
|
33
|
+
(Array.isArray(content) ? content : []).map((c) => (typeof c?.text === "string" ? c.text : "")).join("\n");
|
|
34
|
+
|
|
35
|
+
export default function (pi) {
|
|
36
|
+
pi.on("tool_call", async (event) => {
|
|
37
|
+
try {
|
|
38
|
+
const tool = event.toolName;
|
|
39
|
+
const input = event.input || {};
|
|
40
|
+
if (typeof input.path !== "string") return;
|
|
41
|
+
const file = absolute(input.path, process.cwd());
|
|
42
|
+
if (tool === "read") return void log.clear(file);
|
|
43
|
+
if (tool !== "edit" && tool !== "write") return;
|
|
44
|
+
if (tool === "edit") {
|
|
45
|
+
const why = guardEdit(log, input.path, oldTexts(input), process.cwd());
|
|
46
|
+
if (why) return { block: true, reason: why };
|
|
47
|
+
}
|
|
48
|
+
before.set(event.toolCallId, readText(file));
|
|
49
|
+
} catch {}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.on("tool_result", async (event) => {
|
|
53
|
+
try {
|
|
54
|
+
const tool = event.toolName;
|
|
55
|
+
const input = event.input || {};
|
|
56
|
+
if ((tool !== "edit" && tool !== "write") || typeof input.path !== "string") return;
|
|
57
|
+
const file = absolute(input.path, process.cwd());
|
|
58
|
+
const prior = before.get(event.toolCallId);
|
|
59
|
+
before.delete(event.toolCallId);
|
|
60
|
+
const content = Array.isArray(event.content) ? event.content : [];
|
|
61
|
+
if (event.isError) {
|
|
62
|
+
const said = textOf(content);
|
|
63
|
+
if (tool !== "edit" || !/could not find|not find/i.test(said)) return;
|
|
64
|
+
const olds = oldTexts(input);
|
|
65
|
+
const named = /edits\[(\d+)\]/.exec(said);
|
|
66
|
+
const old = olds[named ? Number(named[1]) : 0];
|
|
67
|
+
const text = readText(file);
|
|
68
|
+
if (old === undefined || text == null) return;
|
|
69
|
+
const repeat = log.record(file, old);
|
|
70
|
+
const near = closestMatch(text, old);
|
|
71
|
+
const more = [
|
|
72
|
+
repeat >= 2 ? "This is the same edit that has already failed " + (repeat - 1) + " time(s). Do not send it again unchanged." : "",
|
|
73
|
+
near ? "The file's current text near the closest match (lines " + near.from + "-" + near.to + "):\n" + near.excerpt : "",
|
|
74
|
+
"Read " + input.path + " again and build the edit from what is there now.",
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
return { content: [...content, { type: "text", text: more.join("\n") }] };
|
|
77
|
+
}
|
|
78
|
+
log.clear(file);
|
|
79
|
+
const report = syntaxReport(input.path, prior, readText(file), process.cwd());
|
|
80
|
+
if (report) return { content: [...content, { type: "text", text: report }] };
|
|
81
|
+
} catch {}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
export const writePiEditGuard = (folder?: string) =>
|
|
87
|
+
writeEditGuard("agentos-pi-edit-guard.mjs", PI_EDIT_GUARD_EXTENSION, folder);
|