@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.
Files changed (41) hide show
  1. package/components/Chat/Activity.test.ts +2 -1
  2. package/components/Chat/Activity.tsx +102 -64
  3. package/components/Chat/ChatPanel.tsx +4 -0
  4. package/components/Chat/LiveOutput.test.ts +36 -0
  5. package/components/Chat/Tools.tsx +42 -5
  6. package/data/chat/useChat.ts +10 -1
  7. package/lib/chat/demo.ts +42 -4
  8. package/lib/chat/drivers/claude-mapper.ts +10 -0
  9. package/lib/chat/drivers/claude.ts +9 -1
  10. package/lib/chat/drivers/demo-script.ts +148 -0
  11. package/lib/chat/drivers/demo.ts +21 -4
  12. package/lib/chat/drivers/edit-guard.ts +311 -0
  13. package/lib/chat/drivers/index.ts +2 -2
  14. package/lib/chat/drivers/opencode-config.ts +111 -0
  15. package/lib/chat/drivers/opencode-mapper.ts +125 -13
  16. package/lib/chat/drivers/opencode-plugin.ts +78 -0
  17. package/lib/chat/drivers/opencode-server.ts +7 -2
  18. package/lib/chat/drivers/opencode.ts +108 -9
  19. package/lib/chat/drivers/pi-edit-guard.ts +87 -0
  20. package/lib/chat/drivers/pi-mapper.ts +74 -1
  21. package/lib/chat/drivers/pi.ts +82 -3
  22. package/lib/chat/drivers/transient-retry.ts +87 -0
  23. package/lib/chat/drivers/turn-items.ts +74 -1
  24. package/lib/chat/drivers/turn-outcome.ts +48 -1
  25. package/lib/chat/events.ts +15 -1
  26. package/lib/chat/model-wait-live.ts +19 -0
  27. package/lib/chat/model-wait.ts +88 -0
  28. package/lib/chat/progress.ts +169 -0
  29. package/lib/chat/registry.ts +5 -0
  30. package/lib/chat/runner-watch.ts +71 -0
  31. package/lib/chat/runner.ts +4 -70
  32. package/lib/chat/tools.ts +14 -0
  33. package/lib/security/demo.ts +8 -0
  34. package/lib/tasks/move-testing.ts +6 -1
  35. package/package.json +1 -1
  36. package/scripts/screenshots/run.ts +27 -3
  37. package/scripts/screenshots/running-turn.ts +54 -0
  38. package/scripts/screenshots/seed.ts +15 -3
  39. package/scripts/screenshots/shoot.ts +3 -1
  40. package/scripts/screenshots/shots.ts +5 -0
  41. package/server.ts +10 -2
@@ -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();
@@ -0,0 +1,87 @@
1
+ // Retrying a turn the model's server dropped. OpenCode and Pi retry some
2
+ // failures themselves (and say so); this covers the rest, with a hard limit
3
+ // so nothing loops: a few tries with growing waits, the last one on a
4
+ // smaller context, then the turn ends with a report.
5
+
6
+ export type ErrorKind = "timeout" | "transient";
7
+
8
+ export const MAX_RETRIES = 3;
9
+ const BASE_MS = 2_000;
10
+ // A context this full (percent of the window) is compacted before the last
11
+ // try: a request that took too long to start is the one that shrinks.
12
+ export const COMPACT_AT_PERCENT = 50;
13
+
14
+ const NEVER =
15
+ /context (length|window)|maximum context|too many tokens|prompt is too long|\b40[0-4]\b|unauthori[sz]ed|invalid api key|authentication|forbidden|abort/i;
16
+ const TIMEOUT =
17
+ /headers timed out|timed out|timeout|UND_ERR_(HEADERS|BODY|CONNECT)_TIMEOUT|ETIMEDOUT|ResponseStreamError/i;
18
+ const TRANSIENT =
19
+ /fetch failed|socket hang up|ECONNRESET|ECONNREFUSED|EPIPE|terminated|connection (lost|closed|reset)|ended without|overloaded|\b(429|500|502|503|504|524)\b|unavailable|rate limit/i;
20
+
21
+ // Whether a provider error is worth another try, and which kind it is.
22
+ export function classifyProviderError(message: string): ErrorKind | undefined {
23
+ if (!message || NEVER.test(message)) return undefined;
24
+ if (TIMEOUT.test(message)) return "timeout";
25
+ if (TRANSIENT.test(message)) return "transient";
26
+ return undefined;
27
+ }
28
+
29
+ export function backoffMs(attempt: number, jitter = true): number {
30
+ const base = BASE_MS * 2 ** (attempt - 1);
31
+ return jitter ? Math.round(base * (0.8 + Math.random() * 0.4)) : base;
32
+ }
33
+
34
+ export type RetryDecision =
35
+ | { retry: true; attempt: number; delayMs: number; compactFirst: boolean }
36
+ | { retry: false; attempts: number; kind?: ErrorKind };
37
+
38
+ // One per conversation. A turn that sends the user's message starts it over;
39
+ // a retry never does.
40
+ export class TurnRetry {
41
+ private attempts = 0;
42
+
43
+ constructor(private jitter = true) {}
44
+
45
+ reset(): void {
46
+ this.attempts = 0;
47
+ }
48
+
49
+ // Retries made in this turn.
50
+ get tried(): number {
51
+ return this.attempts;
52
+ }
53
+
54
+ next(o: {
55
+ error: string;
56
+ // The agent already retried this failure itself.
57
+ harnessRetried?: boolean;
58
+ // The context is big enough that compacting it is worth a try.
59
+ contextHeavy?: boolean;
60
+ }): RetryDecision {
61
+ const kind = classifyProviderError(o.error);
62
+ if (!kind || o.harnessRetried || this.attempts >= MAX_RETRIES)
63
+ return { retry: false, attempts: this.attempts, kind };
64
+ this.attempts++;
65
+ return {
66
+ retry: true,
67
+ attempt: this.attempts,
68
+ delayMs: backoffMs(this.attempts, this.jitter),
69
+ compactFirst: this.attempts === MAX_RETRIES && !!o.contextHeavy,
70
+ };
71
+ }
72
+ }
73
+
74
+ // The message that resumes a turn after a failure: the steps that ran are
75
+ // already in the conversation.
76
+ export function continuePrompt(error: string): string {
77
+ const why = error.replace(/\s+/g, " ").trim().slice(0, 200);
78
+ return `The previous request to the model failed (${why}). Continue the task from where it stopped. The tool steps above already ran: don't redo them.`;
79
+ }
80
+
81
+ export function retryNotice(
82
+ d: Extract<RetryDecision, { retry: true }>,
83
+ error: string
84
+ ): string {
85
+ const why = error.replace(/\s+/g, " ").trim().slice(0, 160);
86
+ return `The model's server didn't answer (${why}). Trying again in ${Math.round(d.delayMs / 1000)}s (${d.attempt} of ${MAX_RETRIES})${d.compactFirst ? ", after compacting the conversation" : ""}.`;
87
+ }
@@ -6,12 +6,14 @@ import type {
6
6
  ToolStatus,
7
7
  TurnEndReason,
8
8
  } from "../events";
9
- import { clipOutput } from "../tools";
9
+ import { clipOutput, tailOutput } from "../tools";
10
10
 
11
11
  type Tool = Extract<ChatItem, { kind: "tool" }>;
12
12
  type Text = Extract<ChatItem, { kind: "assistant" | "reasoning" }>;
13
13
  type Todo = Extract<ChatItem, { kind: "todos" }>["todos"][number];
14
14
 
15
+ const PROGRESS_MS = 250;
16
+
15
17
  export interface ToolStart {
16
18
  name: string;
17
19
  title: string;
@@ -19,6 +21,18 @@ export interface ToolStart {
19
21
  diff?: FileDiff;
20
22
  }
21
23
 
24
+ export interface TurnSummary {
25
+ steps: number;
26
+ failed: number;
27
+ files: { path: string; ok: number; failed: number }[];
28
+ }
29
+
30
+ const newStats = () => ({
31
+ steps: 0,
32
+ failed: 0,
33
+ files: new Map<string, { ok: number; failed: number }>(),
34
+ });
35
+
22
36
  // An API's error as its message: providers wrap JSON in JSON.
23
37
  export function readableError(text: string): string {
24
38
  let message = text.trim();
@@ -50,6 +64,9 @@ export class TurnItems {
50
64
  // Texts that finished in this turn, so one can be corrected afterwards.
51
65
  private done = new Map<string, Text>();
52
66
  private startedAt = 0;
67
+ // What the turn has done so far, for the report when it stops short.
68
+ private stats = newStats();
69
+ private progressAt = new Map<string, number>();
53
70
  inTurn = false;
54
71
 
55
72
  constructor(private emit: (e: DriverEvent) => void) {}
@@ -58,6 +75,7 @@ export class TurnItems {
58
75
  if (this.inTurn) return;
59
76
  this.inTurn = true;
60
77
  this.startedAt = Date.now();
78
+ this.stats = newStats();
61
79
  this.emit({ type: "turn_start" });
62
80
  }
63
81
 
@@ -121,6 +139,20 @@ export class TurnItems {
121
139
  this.emit({ type: "item", item });
122
140
  }
123
141
 
142
+ // A running step's output so far (the whole of it each time, not a
143
+ // delta). Sent at most every PROGRESS_MS; the end replaces it.
144
+ progress(id: string, output: string): void {
145
+ const tool = this.tools.get(id);
146
+ if (!tool || !output) return;
147
+ const tail = tailOutput(output);
148
+ if (tail === tool.output) return;
149
+ const now = Date.now();
150
+ if (now - (this.progressAt.get(id) ?? 0) < PROGRESS_MS) return;
151
+ this.progressAt.set(id, now);
152
+ tool.output = tail;
153
+ this.emit({ type: "item", item: { ...tool } });
154
+ }
155
+
124
156
  hasText(key: string): boolean {
125
157
  return this.texts.has(key);
126
158
  }
@@ -138,6 +170,8 @@ export class TurnItems {
138
170
  const tool = this.tools.get(id);
139
171
  if (!tool) return;
140
172
  this.tools.delete(id);
173
+ this.count(tool, update, status);
174
+ this.progressAt.delete(id);
141
175
  this.emit({
142
176
  type: "item",
143
177
  item: {
@@ -153,6 +187,31 @@ export class TurnItems {
153
187
  });
154
188
  }
155
189
 
190
+ private count(
191
+ tool: Tool,
192
+ update: Partial<ToolStart> | undefined,
193
+ status: ToolStatus
194
+ ): void {
195
+ if (status === "stopped") return;
196
+ this.stats.steps++;
197
+ if (status === "error") this.stats.failed++;
198
+ const input = (update?.input ?? tool.input) as { file_path?: unknown };
199
+ if (!/^(Edit|Write|MultiEdit)$/.test(tool.name)) return;
200
+ if (typeof input?.file_path !== "string") return;
201
+ const file = this.stats.files.get(input.file_path) ?? { ok: 0, failed: 0 };
202
+ file[status === "error" ? "failed" : "ok"]++;
203
+ this.stats.files.set(input.file_path, file);
204
+ }
205
+
206
+ // The turn so far: tool steps run, how many failed, files edited.
207
+ summary(): TurnSummary {
208
+ return {
209
+ steps: this.stats.steps,
210
+ failed: this.stats.failed,
211
+ files: [...this.stats.files].map(([path, n]) => ({ path, ...n })),
212
+ };
213
+ }
214
+
156
215
  todos(todos: Todo[]): void {
157
216
  this.emit({
158
217
  type: "item",
@@ -160,6 +219,20 @@ export class TurnItems {
160
219
  });
161
220
  }
162
221
 
222
+ // A line in the conversation that isn't the agent's: a retry under way.
223
+ note(text: string): void {
224
+ this.emit({
225
+ type: "item",
226
+ item: {
227
+ id: randomUUID(),
228
+ kind: "note",
229
+ text,
230
+ tone: "note",
231
+ createdAt: Date.now(),
232
+ },
233
+ });
234
+ }
235
+
163
236
  error(message: string): void {
164
237
  this.emit({
165
238
  type: "item",
@@ -1,5 +1,7 @@
1
1
  import { redact } from "../../orchestrator/untrusted";
2
2
  import type { TurnEndReason } from "../events";
3
+ import type { TurnSummary } from "./turn-items";
4
+ import { classifyProviderError } from "./transient-retry";
3
5
 
4
6
  export interface TurnEnded {
5
7
  reason: TurnEndReason;
@@ -43,11 +45,49 @@ export function hasToolMarkup(text: string): boolean {
43
45
  );
44
46
  }
45
47
 
48
+ const plural = (n: number, one: string, many = `${one}s`) =>
49
+ `${n} ${n === 1 ? one : many}`;
50
+
51
+ // Where a turn stopped when the model's server kept failing: what the model
52
+ // got done, what it didn't, and what to do next. The error text is scrubbed.
53
+ export function failureReport(o: {
54
+ error: string;
55
+ retries: number;
56
+ // The agent retried it before AgentOS saw the error.
57
+ agentRetried?: boolean;
58
+ summary?: TurnSummary;
59
+ }): string {
60
+ const kind = classifyProviderError(o.error);
61
+ const why = redact(o.error.replace(/\s+/g, " ").trim()).slice(0, 240);
62
+ const tries = o.retries
63
+ ? ` and ${plural(o.retries, "retry", "retries")} didn't get through`
64
+ : o.agentRetried
65
+ ? " even after the agent retried it"
66
+ : "";
67
+ const s = o.summary;
68
+ const done = s?.steps
69
+ ? `Finished before it stopped: ${plural(s.steps, "tool step")}${s.failed ? ` (${s.failed} failed)` : ""}${
70
+ s.files.length
71
+ ? `; edits to ${s.files
72
+ .map(
73
+ (f) =>
74
+ `${f.path}${f.failed ? ` (${f.failed} failed${f.ok ? `, ${f.ok} applied` : ""})` : ""}`
75
+ )
76
+ .join(", ")}`
77
+ : ""
78
+ }.`
79
+ : "Nothing had finished yet.";
80
+ return `The model's server ${kind === "timeout" ? "timed out" : "kept failing"} (${why})${tries}. ${done} Not finished: the model's next reply, so the task is incomplete. Send "continue" to resume; the finished steps are kept.`;
81
+ }
82
+
46
83
  export function turnOutcome(r: {
47
84
  finishReason?: string;
48
85
  text: string;
49
86
  toolCalls: number;
50
87
  error?: string;
88
+ retries?: number;
89
+ agentRetried?: boolean;
90
+ summary?: TurnSummary;
51
91
  }): TurnEnded | undefined {
52
92
  if (r.finishReason === "length")
53
93
  return {
@@ -58,7 +98,14 @@ export function turnOutcome(r: {
58
98
  if (r.error)
59
99
  return {
60
100
  reason: "error",
61
- notice: "The model's server returned an error, shown above.",
101
+ notice: classifyProviderError(r.error)
102
+ ? failureReport({
103
+ error: r.error,
104
+ retries: r.retries ?? 0,
105
+ agentRetried: r.agentRetried,
106
+ summary: r.summary,
107
+ })
108
+ : "The model's server returned an error, shown above.",
62
109
  };
63
110
  if (r.toolCalls > 0) return undefined;
64
111
  if (hasToolMarkup(r.text))
@@ -322,6 +322,14 @@ export interface Compacting {
322
322
  before?: number;
323
323
  }
324
324
 
325
+ // A model call the turn is waiting on: since when, the last measured prompt
326
+ // size (a floor: the next prompt is at least that big), and a retry under way.
327
+ export interface ModelWait {
328
+ since: number;
329
+ promptTokens?: number;
330
+ retry?: { attempt: number; message?: string };
331
+ }
332
+
325
333
  export interface UndoPreview {
326
334
  canUndo: boolean;
327
335
  error?: string;
@@ -347,7 +355,11 @@ export type DriverEvent =
347
355
  | { type: "at_rest" } // it has run every message it was given
348
356
  | { type: "state"; state: ChatState }
349
357
  // The agent started (true) or finished (false) compacting its context.
350
- | { type: "compacting"; active: boolean };
358
+ | { type: "compacting"; active: boolean }
359
+ // One model response's measured prompt (input plus cache), in tokens.
360
+ | { type: "prompt_tokens"; tokens: number }
361
+ // The agent is retrying a failed model request.
362
+ | { type: "model_retry"; attempt: number; message?: string };
351
363
 
352
364
  // A tool call's output and diff, loaded when it's opened.
353
365
  export interface ToolBody {
@@ -368,6 +380,7 @@ export type ChatServerMessage =
368
380
  tasks: ChatItem[];
369
381
  state: ChatState;
370
382
  compacting?: Compacting | null;
383
+ modelWait?: ModelWait | null;
371
384
  queue: QueuedMessage[];
372
385
  suggestion: string | null;
373
386
  }
@@ -393,6 +406,7 @@ export type ChatServerMessage =
393
406
  | { type: "delta"; id: string; text: string }
394
407
  | { type: "state"; state: ChatState }
395
408
  | { type: "compacting"; compacting: Compacting | null }
409
+ | { type: "model_wait"; wait: ModelWait | null }
396
410
  | {
397
411
  type: "capabilities";
398
412
  commands: ChatCommand[];
@@ -0,0 +1,19 @@
1
+ import { emit, type Live } from "./registry";
2
+ import { ModelWaitTracker } from "./model-wait";
3
+ import type { WorkerEvent } from "./worker/protocol";
4
+
5
+ // Keeps the model call the turn waits on current; the events that only feed
6
+ // it (prompt size, a retry) go no further.
7
+ export function trackModelWait(
8
+ sessionId: string,
9
+ live: Live,
10
+ e: WorkerEvent
11
+ ): boolean {
12
+ const tracker = (live.waitTracker ??= new ModelWaitTracker());
13
+ const changed = tracker.on(e);
14
+ if (changed) {
15
+ live.modelWait = tracker.wait;
16
+ emit(sessionId, { type: "model_wait", wait: tracker.wait });
17
+ }
18
+ return e.type === "prompt_tokens" || e.type === "model_retry";
19
+ }