@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.
@@ -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>(method: string, path: string, body?: unknown): Promise<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(CALL_MS),
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(options.cwd, {
83
- ...sessionBaseEnv(),
84
- ...options.env,
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
- const mapper = new OpenCodeMapper(() => sessionId, emit);
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
- async function prompt(text: string, images?: ChatImage[]) {
157
- mapper.prompted();
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 || undefined,
164
- parts: openCodeParts(text, images),
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
- constructor(private emit: (e: DriverEvent) => void) {
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({
@@ -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
- const mapper = new PiMapper(emit);
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
- const prompt = (text: string, images?: ChatImage[]) =>
168
- rpc
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();