@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,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);
|
|
@@ -3,6 +3,12 @@ import type { DriverEvent, UsageTotals } from "../events";
|
|
|
3
3
|
import { toolDiff, toolTitle } from "../tools";
|
|
4
4
|
import { TurnItems, type ToolStart } from "./turn-items";
|
|
5
5
|
import { logTurnEnd, needsSummary, turnOutcome } from "./turn-outcome";
|
|
6
|
+
import {
|
|
7
|
+
COMPACT_AT_PERCENT,
|
|
8
|
+
retryNotice,
|
|
9
|
+
TurnRetry,
|
|
10
|
+
type RetryDecision,
|
|
11
|
+
} from "./transient-retry";
|
|
6
12
|
|
|
7
13
|
type P = Record<string, unknown>;
|
|
8
14
|
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
@@ -63,10 +69,31 @@ export class PiMapper {
|
|
|
63
69
|
cacheWriteTokens: 0,
|
|
64
70
|
};
|
|
65
71
|
|
|
66
|
-
|
|
72
|
+
// Retrying a turn the model's server dropped, when Pi didn't.
|
|
73
|
+
readonly retry = new TurnRetry();
|
|
74
|
+
private agentRetried = false;
|
|
75
|
+
// A failed turn is waiting for its retry to go out.
|
|
76
|
+
retrying = false;
|
|
77
|
+
// The context's fill, as last reported.
|
|
78
|
+
contextPercent = 0;
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
private emit: (e: DriverEvent) => void,
|
|
82
|
+
// Called when a failed turn is to be tried again.
|
|
83
|
+
private onRetry: (
|
|
84
|
+
d: Extract<RetryDecision, { retry: true }>,
|
|
85
|
+
error: string
|
|
86
|
+
) => void = () => {}
|
|
87
|
+
) {
|
|
67
88
|
this.items = new TurnItems(emit);
|
|
68
89
|
}
|
|
69
90
|
|
|
91
|
+
// The reader sent a message: a new turn's retries start over.
|
|
92
|
+
userPrompt(): void {
|
|
93
|
+
this.retry.reset();
|
|
94
|
+
this.agentRetried = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
// The last answer in the run stopped because the reader stopped it.
|
|
71
98
|
get aborted(): boolean {
|
|
72
99
|
return this.stopReason === "aborted";
|
|
@@ -93,12 +120,31 @@ export class PiMapper {
|
|
|
93
120
|
str(e.toolCallId),
|
|
94
121
|
piTool(str(e.toolName), e.args)
|
|
95
122
|
);
|
|
123
|
+
case "tool_execution_update":
|
|
124
|
+
return this.items.progress(
|
|
125
|
+
str(e.toolCallId),
|
|
126
|
+
resultText(e.partialResult)
|
|
127
|
+
);
|
|
96
128
|
case "tool_execution_end":
|
|
97
129
|
return this.items.toolDone(
|
|
98
130
|
str(e.toolCallId),
|
|
99
131
|
e.isError ? "error" : "done",
|
|
100
132
|
resultText(e.result)
|
|
101
133
|
);
|
|
134
|
+
case "auto_retry_start":
|
|
135
|
+
this.emit({
|
|
136
|
+
type: "model_retry",
|
|
137
|
+
attempt: Number(e.attempt) || 1,
|
|
138
|
+
message: str(e.errorMessage) || undefined,
|
|
139
|
+
});
|
|
140
|
+
this.agentRetried = true;
|
|
141
|
+
return this.items.note(
|
|
142
|
+
`Pi is trying again (${str(e.attempt)} of ${str(e.maxAttempts)}): ${str(e.errorMessage).slice(0, 160)}`
|
|
143
|
+
);
|
|
144
|
+
case "auto_retry_end":
|
|
145
|
+
if (e.success === false && e.finalError && !this.error)
|
|
146
|
+
this.error = str(e.finalError);
|
|
147
|
+
return;
|
|
102
148
|
case "compaction_start":
|
|
103
149
|
return this.emit({ type: "compacting", active: true });
|
|
104
150
|
case "compaction_end":
|
|
@@ -163,6 +209,8 @@ export class PiMapper {
|
|
|
163
209
|
this.items.retext(`${this.message}:${fixed.index}`, str(fixed.text));
|
|
164
210
|
const u = (m.usage ?? {}) as P;
|
|
165
211
|
const n = (k: string) => Number(u[k]) || 0;
|
|
212
|
+
const prompt = n("input") + n("cacheRead") + n("cacheWrite");
|
|
213
|
+
if (prompt > 0) this.emit({ type: "prompt_tokens", tokens: prompt });
|
|
166
214
|
this.totals = {
|
|
167
215
|
costUsd: this.totals.costUsd + (Number((u.cost as P)?.total) || 0),
|
|
168
216
|
inputTokens: this.totals.inputTokens + n("input"),
|
|
@@ -184,9 +232,31 @@ export class PiMapper {
|
|
|
184
232
|
});
|
|
185
233
|
}
|
|
186
234
|
|
|
235
|
+
private retryTurn(): boolean {
|
|
236
|
+
const d = this.retry.next({
|
|
237
|
+
error: this.error,
|
|
238
|
+
harnessRetried: this.agentRetried,
|
|
239
|
+
contextHeavy: this.contextPercent >= COMPACT_AT_PERCENT,
|
|
240
|
+
});
|
|
241
|
+
if (!d.retry) return false;
|
|
242
|
+
this.items.note(retryNotice(d, this.error));
|
|
243
|
+
this.retrying = true;
|
|
244
|
+
this.onRetry(d, this.error);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The reader stopped the turn while it waited to retry.
|
|
249
|
+
stopRetrying(): void {
|
|
250
|
+
this.retrying = false;
|
|
251
|
+
this.emit({ type: "usage", totals: { ...this.totals } });
|
|
252
|
+
this.items.endTurn({ interrupted: true });
|
|
253
|
+
}
|
|
254
|
+
|
|
187
255
|
// The run is over: Pi has nothing left to do for it.
|
|
188
256
|
endTurn(): void {
|
|
189
257
|
if (!this.items.inTurn) return;
|
|
258
|
+
if (this.error && !this.aborted && this.retryTurn()) return;
|
|
259
|
+
this.retrying = false;
|
|
190
260
|
this.emit({ type: "usage", totals: { ...this.totals } });
|
|
191
261
|
const ended = this.aborted
|
|
192
262
|
? undefined
|
|
@@ -195,6 +265,9 @@ export class PiMapper {
|
|
|
195
265
|
text: this.last.text,
|
|
196
266
|
toolCalls: this.last.calls,
|
|
197
267
|
error: this.error || undefined,
|
|
268
|
+
retries: this.retry.tried,
|
|
269
|
+
agentRetried: this.agentRetried,
|
|
270
|
+
summary: this.items.summary(),
|
|
198
271
|
});
|
|
199
272
|
if (ended)
|
|
200
273
|
logTurnEnd({
|
package/lib/chat/drivers/pi.ts
CHANGED
|
@@ -6,12 +6,17 @@ import { AGENT_DEFAULT_MODEL } from "../../providers/registry";
|
|
|
6
6
|
import { PiAccess, PiDialogs, writeExtension } from "./pi-approvals";
|
|
7
7
|
import { TEXT_TOOLS_EXTENSION } from "./pi-text-tools";
|
|
8
8
|
import { PiMapper } from "./pi-mapper";
|
|
9
|
+
import { writePiEditGuard } from "./pi-edit-guard";
|
|
10
|
+
import { typescriptPath } from "./edit-guard";
|
|
11
|
+
import { continuePrompt, type RetryDecision } from "./transient-retry";
|
|
9
12
|
import { PiRpc } from "./pi-rpc";
|
|
10
13
|
import { RESULTS_BRIEF, SUMMARY_NUDGE } from "./turn-outcome";
|
|
11
14
|
|
|
12
15
|
type P = Record<string, unknown>;
|
|
13
16
|
|
|
14
17
|
const SETTLE_TRIES = 5;
|
|
18
|
+
// Compacting can take a while; the retry waits for it.
|
|
19
|
+
const COMPACT_MS = 300_000;
|
|
15
20
|
|
|
16
21
|
export function piArgs(o: ChatStartOptions, ...extensions: string[]): string[] {
|
|
17
22
|
const args = extensions.flatMap((e) => ["-e", e]);
|
|
@@ -42,6 +47,15 @@ const splitModel = (model: string) => {
|
|
|
42
47
|
: null;
|
|
43
48
|
};
|
|
44
49
|
|
|
50
|
+
// The edit guard extension, or none where it can't be written.
|
|
51
|
+
function guardExtension(): string[] {
|
|
52
|
+
try {
|
|
53
|
+
return [writePiEditGuard()];
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
export const piDriver: ChatDriver = {
|
|
46
60
|
id: "pi",
|
|
47
61
|
plan: false,
|
|
@@ -78,7 +92,11 @@ export const piDriver: ChatDriver = {
|
|
|
78
92
|
start(options): ChatConversation {
|
|
79
93
|
const out = new InputQueue<DriverEvent>();
|
|
80
94
|
const emit = (e: DriverEvent) => out.push(e);
|
|
81
|
-
|
|
95
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
96
|
+
// Bumped when a retry is cancelled, so one already under way stops.
|
|
97
|
+
let retryGen = 0;
|
|
98
|
+
let closed = false;
|
|
99
|
+
const mapper = new PiMapper(emit, (d, error) => scheduleRetry(d, error));
|
|
82
100
|
const dialogs = new PiDialogs(emit);
|
|
83
101
|
const access = new PiAccess(options.access);
|
|
84
102
|
// Sent now: goes once the stopped run has ended.
|
|
@@ -87,6 +105,7 @@ export const piDriver: ChatDriver = {
|
|
|
87
105
|
const rpc: PiRpc = new PiRpc(
|
|
88
106
|
piArgs(
|
|
89
107
|
options,
|
|
108
|
+
...guardExtension(),
|
|
90
109
|
writeExtension(),
|
|
91
110
|
writeExtension("agentos-text-tools.mjs", TEXT_TOOLS_EXTENSION)
|
|
92
111
|
),
|
|
@@ -96,6 +115,7 @@ export const piDriver: ChatDriver = {
|
|
|
96
115
|
...sessionBaseEnv(),
|
|
97
116
|
...options.env,
|
|
98
117
|
AGENTOS_PI_ACCESS_FILE: access.file,
|
|
118
|
+
...(typescriptPath() && { AGENTOS_TYPESCRIPT: typescriptPath() }),
|
|
99
119
|
PI_OFFLINE: "1",
|
|
100
120
|
},
|
|
101
121
|
},
|
|
@@ -110,6 +130,7 @@ export const piDriver: ChatDriver = {
|
|
|
110
130
|
});
|
|
111
131
|
},
|
|
112
132
|
exit(error) {
|
|
133
|
+
cancelRetry();
|
|
113
134
|
dialogs.pending.expireAll();
|
|
114
135
|
mapper.items.endTurn({ error });
|
|
115
136
|
access.remove();
|
|
@@ -150,6 +171,7 @@ export const piDriver: ChatDriver = {
|
|
|
150
171
|
.then((s) => {
|
|
151
172
|
const c = s.contextUsage;
|
|
152
173
|
if (c?.tokens == null || !c.contextWindow) return;
|
|
174
|
+
mapper.contextPercent = Number(c.percent) || 0;
|
|
153
175
|
emit({
|
|
154
176
|
type: "context",
|
|
155
177
|
context: {
|
|
@@ -164,8 +186,50 @@ export const piDriver: ChatDriver = {
|
|
|
164
186
|
.catch(() => {});
|
|
165
187
|
}
|
|
166
188
|
|
|
167
|
-
|
|
168
|
-
|
|
189
|
+
// The failed request goes again after its wait, on a compacted context
|
|
190
|
+
// for the last try. Stopping or closing cancels it.
|
|
191
|
+
function scheduleRetry(
|
|
192
|
+
d: Extract<RetryDecision, { retry: true }>,
|
|
193
|
+
error: string
|
|
194
|
+
) {
|
|
195
|
+
cancelRetry();
|
|
196
|
+
const gen = retryGen;
|
|
197
|
+
retryTimer = setTimeout(() => {
|
|
198
|
+
retryTimer = null;
|
|
199
|
+
void resume(d, error, gen);
|
|
200
|
+
}, d.delayMs);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Whether a retry was waiting or under way.
|
|
204
|
+
function cancelRetry(): boolean {
|
|
205
|
+
const pending = retryTimer !== null || mapper.retrying;
|
|
206
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
207
|
+
retryTimer = null;
|
|
208
|
+
retryGen++;
|
|
209
|
+
return pending;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function resume(
|
|
213
|
+
d: Extract<RetryDecision, { retry: true }>,
|
|
214
|
+
error: string,
|
|
215
|
+
gen: number
|
|
216
|
+
) {
|
|
217
|
+
const live = () => !closed && mapper.retrying && gen === retryGen;
|
|
218
|
+
if (!live()) return;
|
|
219
|
+
if (d.compactFirst) {
|
|
220
|
+
await rpc.request("compact", {}, COMPACT_MS).catch(() => {});
|
|
221
|
+
if (!live()) return;
|
|
222
|
+
}
|
|
223
|
+
mapper.retrying = false;
|
|
224
|
+
await prompt(continuePrompt(error), undefined, true);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const prompt = (text: string, images?: ChatImage[], retried = false) => {
|
|
228
|
+
if (!retried) {
|
|
229
|
+
cancelRetry();
|
|
230
|
+
mapper.userPrompt();
|
|
231
|
+
}
|
|
232
|
+
return rpc
|
|
169
233
|
.request("prompt", {
|
|
170
234
|
message: text,
|
|
171
235
|
images: piImages(images),
|
|
@@ -175,10 +239,22 @@ export const piDriver: ChatDriver = {
|
|
|
175
239
|
.catch((error: Error) => {
|
|
176
240
|
mapper.items.error(error.message);
|
|
177
241
|
});
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// A turn waiting to retry has nothing running to abort: it ends here.
|
|
245
|
+
function stopWaiting(): boolean {
|
|
246
|
+
if (!cancelRetry()) return false;
|
|
247
|
+
mapper.stopRetrying();
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
178
250
|
|
|
179
251
|
return {
|
|
180
252
|
send(text, images, sendOptions) {
|
|
181
253
|
if (sendOptions?.now && mapper.items.inTurn) {
|
|
254
|
+
if (stopWaiting()) {
|
|
255
|
+
void prompt(text, images);
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
182
258
|
held.push({ text, images });
|
|
183
259
|
// Abort answers once the run has stopped; what was held goes then.
|
|
184
260
|
void rpc
|
|
@@ -193,6 +269,7 @@ export const piDriver: ChatDriver = {
|
|
|
193
269
|
},
|
|
194
270
|
// Stops the run only: a message already sent still goes after it.
|
|
195
271
|
async interrupt() {
|
|
272
|
+
if (stopWaiting()) return;
|
|
196
273
|
await rpc.request("abort", {}, 0);
|
|
197
274
|
},
|
|
198
275
|
async setModel(model) {
|
|
@@ -211,6 +288,8 @@ export const piDriver: ChatDriver = {
|
|
|
211
288
|
return { canUndo: false, error: "Pi can't undo from chat", files: [] };
|
|
212
289
|
},
|
|
213
290
|
close() {
|
|
291
|
+
closed = true;
|
|
292
|
+
cancelRetry();
|
|
214
293
|
dialogs.pending.expireAll();
|
|
215
294
|
rpc.close();
|
|
216
295
|
out.end();
|