@saadnvd1/agent-os 0.3.737 → 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.
Files changed (38) 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 +16 -3
  4. package/components/Chat/LiveOutput.test.ts +36 -0
  5. package/components/Chat/Messages.test.ts +13 -1
  6. package/components/Chat/Messages.tsx +19 -2
  7. package/components/Chat/StepOutcomes.tsx +66 -0
  8. package/components/Chat/Tools.test.ts +52 -0
  9. package/components/Chat/Tools.tsx +63 -9
  10. package/data/chat/useChat.ts +10 -1
  11. package/lib/chat/drivers/claude-mapper.ts +10 -0
  12. package/lib/chat/drivers/claude.ts +9 -1
  13. package/lib/chat/drivers/edit-guard.ts +311 -0
  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 +91 -2
  21. package/lib/chat/drivers/pi.ts +95 -4
  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 +73 -1
  25. package/lib/chat/events.ts +17 -1
  26. package/lib/chat/group.ts +23 -3
  27. package/lib/chat/model-wait-live.ts +19 -0
  28. package/lib/chat/model-wait.ts +88 -0
  29. package/lib/chat/page.ts +3 -0
  30. package/lib/chat/progress.ts +169 -0
  31. package/lib/chat/registry.ts +5 -0
  32. package/lib/chat/runner-watch.ts +68 -0
  33. package/lib/chat/runner.ts +4 -70
  34. package/lib/chat/tool-outcome.ts +45 -0
  35. package/lib/chat/tools.ts +14 -0
  36. package/package.json +1 -1
  37. package/scripts/screenshots/chat-verify.ts +68 -0
  38. package/scripts/screenshots/seed.ts +9 -0
@@ -2,7 +2,13 @@ import { randomUUID } from "crypto";
2
2
  import type { DriverEvent, UsageTotals } from "../events";
3
3
  import { toolDiff, toolTitle } from "../tools";
4
4
  import { TurnItems, type ToolStart } from "./turn-items";
5
- import { logTurnEnd, turnOutcome } from "./turn-outcome";
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 : "");
@@ -53,6 +59,7 @@ export class PiMapper {
53
59
  private error = "";
54
60
  // The last answer's text, its tool calls and who gave it, for the notice
55
61
  // when a turn ends without doing anything.
62
+ private toolsRun = 0;
56
63
  private last = { text: "", calls: 0, provider: "", model: "", api: "" };
57
64
  totals: UsageTotals = {
58
65
  costUsd: 0,
@@ -62,10 +69,31 @@ export class PiMapper {
62
69
  cacheWriteTokens: 0,
63
70
  };
64
71
 
65
- 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
+ ) {
66
88
  this.items = new TurnItems(emit);
67
89
  }
68
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
+
69
97
  // The last answer in the run stopped because the reader stopped it.
70
98
  get aborted(): boolean {
71
99
  return this.stopReason === "aborted";
@@ -74,6 +102,8 @@ export class PiMapper {
74
102
  map(e: P): void {
75
103
  switch (e.type) {
76
104
  case "agent_start":
105
+ // A nudged run continues the same turn, so its tools still count.
106
+ if (!this.items.inTurn) this.toolsRun = 0;
77
107
  this.stopReason = this.error = "";
78
108
  this.last = { text: "", calls: 0, provider: "", model: "", api: "" };
79
109
  return this.items.startTurn();
@@ -85,16 +115,36 @@ export class PiMapper {
85
115
  case "message_end":
86
116
  return this.ended(e.message as P);
87
117
  case "tool_execution_start":
118
+ this.toolsRun++;
88
119
  return this.items.tool(
89
120
  str(e.toolCallId),
90
121
  piTool(str(e.toolName), e.args)
91
122
  );
123
+ case "tool_execution_update":
124
+ return this.items.progress(
125
+ str(e.toolCallId),
126
+ resultText(e.partialResult)
127
+ );
92
128
  case "tool_execution_end":
93
129
  return this.items.toolDone(
94
130
  str(e.toolCallId),
95
131
  e.isError ? "error" : "done",
96
132
  resultText(e.result)
97
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;
98
148
  case "compaction_start":
99
149
  return this.emit({ type: "compacting", active: true });
100
150
  case "compaction_end":
@@ -159,6 +209,8 @@ export class PiMapper {
159
209
  this.items.retext(`${this.message}:${fixed.index}`, str(fixed.text));
160
210
  const u = (m.usage ?? {}) as P;
161
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 });
162
214
  this.totals = {
163
215
  costUsd: this.totals.costUsd + (Number((u.cost as P)?.total) || 0),
164
216
  inputTokens: this.totals.inputTokens + n("input"),
@@ -168,9 +220,43 @@ export class PiMapper {
168
220
  };
169
221
  }
170
222
 
223
+ // The run ran tools and ended without saying what they found.
224
+ needsSummary(): boolean {
225
+ return needsSummary({
226
+ toolsRun: this.toolsRun,
227
+ text: this.last.text,
228
+ toolCalls: this.last.calls,
229
+ finishReason: this.stopReason,
230
+ error: this.error || undefined,
231
+ aborted: this.aborted,
232
+ });
233
+ }
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
+
171
255
  // The run is over: Pi has nothing left to do for it.
172
256
  endTurn(): void {
173
257
  if (!this.items.inTurn) return;
258
+ if (this.error && !this.aborted && this.retryTurn()) return;
259
+ this.retrying = false;
174
260
  this.emit({ type: "usage", totals: { ...this.totals } });
175
261
  const ended = this.aborted
176
262
  ? undefined
@@ -179,6 +265,9 @@ export class PiMapper {
179
265
  text: this.last.text,
180
266
  toolCalls: this.last.calls,
181
267
  error: this.error || undefined,
268
+ retries: this.retry.tried,
269
+ agentRetried: this.agentRetried,
270
+ summary: this.items.summary(),
182
271
  });
183
272
  if (ended)
184
273
  logTurnEnd({
@@ -6,18 +6,27 @@ 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";
13
+ import { RESULTS_BRIEF, SUMMARY_NUDGE } from "./turn-outcome";
10
14
 
11
15
  type P = Record<string, unknown>;
12
16
 
13
17
  const SETTLE_TRIES = 5;
18
+ // Compacting can take a while; the retry waits for it.
19
+ const COMPACT_MS = 300_000;
14
20
 
15
21
  export function piArgs(o: ChatStartOptions, ...extensions: string[]): string[] {
16
22
  const args = extensions.flatMap((e) => ["-e", e]);
17
23
  // A path, so a conversation started elsewhere never makes Pi ask about it.
18
24
  if (o.resumeId) args.push("--session", o.resumeId);
19
25
  if (o.model && o.model !== AGENT_DEFAULT_MODEL) args.push("--model", o.model);
20
- if (o.systemAppend) args.push("--append-system-prompt", o.systemAppend);
26
+ args.push(
27
+ "--append-system-prompt",
28
+ [o.systemAppend, RESULTS_BRIEF].filter(Boolean).join("\n\n")
29
+ );
21
30
  return args;
22
31
  }
23
32
 
@@ -38,6 +47,15 @@ const splitModel = (model: string) => {
38
47
  : null;
39
48
  };
40
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
+
41
59
  export const piDriver: ChatDriver = {
42
60
  id: "pi",
43
61
  plan: false,
@@ -74,14 +92,20 @@ export const piDriver: ChatDriver = {
74
92
  start(options): ChatConversation {
75
93
  const out = new InputQueue<DriverEvent>();
76
94
  const emit = (e: DriverEvent) => out.push(e);
77
- 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));
78
100
  const dialogs = new PiDialogs(emit);
79
101
  const access = new PiAccess(options.access);
80
102
  // Sent now: goes once the stopped run has ended.
103
+ let nudged = false;
81
104
  const held: { text: string; images?: ChatImage[] }[] = [];
82
105
  const rpc: PiRpc = new PiRpc(
83
106
  piArgs(
84
107
  options,
108
+ ...guardExtension(),
85
109
  writeExtension(),
86
110
  writeExtension("agentos-text-tools.mjs", TEXT_TOOLS_EXTENSION)
87
111
  ),
@@ -91,6 +115,7 @@ export const piDriver: ChatDriver = {
91
115
  ...sessionBaseEnv(),
92
116
  ...options.env,
93
117
  AGENTOS_PI_ACCESS_FILE: access.file,
118
+ ...(typescriptPath() && { AGENTOS_TYPESCRIPT: typescriptPath() }),
94
119
  PI_OFFLINE: "1",
95
120
  },
96
121
  },
@@ -105,6 +130,7 @@ export const piDriver: ChatDriver = {
105
130
  });
106
131
  },
107
132
  exit(error) {
133
+ cancelRetry();
108
134
  dialogs.pending.expireAll();
109
135
  mapper.items.endTurn({ error });
110
136
  access.remove();
@@ -132,12 +158,20 @@ export const piDriver: ChatDriver = {
132
158
  if (!s.isCompacting) break;
133
159
  await new Promise((r) => setTimeout(r, 200));
134
160
  }
161
+ if (!nudged && mapper.needsSummary()) {
162
+ nudged = true;
163
+ console.info("[chat] asked the model for its results summary");
164
+ void prompt(SUMMARY_NUDGE);
165
+ return;
166
+ }
167
+ nudged = false;
135
168
  mapper.endTurn();
136
169
  void rpc
137
170
  .request<{ contextUsage?: P }>("get_session_stats")
138
171
  .then((s) => {
139
172
  const c = s.contextUsage;
140
173
  if (c?.tokens == null || !c.contextWindow) return;
174
+ mapper.contextPercent = Number(c.percent) || 0;
141
175
  emit({
142
176
  type: "context",
143
177
  context: {
@@ -152,8 +186,50 @@ export const piDriver: ChatDriver = {
152
186
  .catch(() => {});
153
187
  }
154
188
 
155
- const prompt = (text: string, images?: ChatImage[]) =>
156
- 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
157
233
  .request("prompt", {
158
234
  message: text,
159
235
  images: piImages(images),
@@ -163,10 +239,22 @@ export const piDriver: ChatDriver = {
163
239
  .catch((error: Error) => {
164
240
  mapper.items.error(error.message);
165
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
+ }
166
250
 
167
251
  return {
168
252
  send(text, images, sendOptions) {
169
253
  if (sendOptions?.now && mapper.items.inTurn) {
254
+ if (stopWaiting()) {
255
+ void prompt(text, images);
256
+ return undefined;
257
+ }
170
258
  held.push({ text, images });
171
259
  // Abort answers once the run has stopped; what was held goes then.
172
260
  void rpc
@@ -181,6 +269,7 @@ export const piDriver: ChatDriver = {
181
269
  },
182
270
  // Stops the run only: a message already sent still goes after it.
183
271
  async interrupt() {
272
+ if (stopWaiting()) return;
184
273
  await rpc.request("abort", {}, 0);
185
274
  },
186
275
  async setModel(model) {
@@ -199,6 +288,8 @@ export const piDriver: ChatDriver = {
199
288
  return { canUndo: false, error: "Pi can't undo from chat", files: [] };
200
289
  },
201
290
  close() {
291
+ closed = true;
292
+ cancelRetry();
202
293
  dialogs.pending.expireAll();
203
294
  rpc.close();
204
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))
@@ -106,3 +153,28 @@ export function logTurnEnd(o: {
106
153
  })
107
154
  );
108
155
  }
156
+
157
+ // Asked of the model once when its turn ran tools but ended without the
158
+ // report it set out to give.
159
+ export const SUMMARY_NUDGE =
160
+ "You ran checks or tools but haven't reported the results. Reply now with a short summary: what worked, what failed (with the exit code or HTTP status) and what you didn't check. Don't run more tools.";
161
+
162
+ // Appended to the system prompt of agents AgentOS drives itself.
163
+ export const RESULTS_BRIEF =
164
+ "After running checks, probes or discovery, end your turn with a results summary: what worked, what failed (exit code or HTTP status) and what you didn't check. Never end on a lead-in such as 'Here's what I found:' without the list.";
165
+
166
+ // Whether a turn that ran tools ended without telling what they found: an
167
+ // empty last reply, or one that only promises the report.
168
+ export function needsSummary(r: {
169
+ toolsRun: number;
170
+ text: string;
171
+ toolCalls: number;
172
+ finishReason?: string;
173
+ error?: string;
174
+ aborted?: boolean;
175
+ }): boolean {
176
+ if (r.toolsRun === 0 || r.toolCalls > 0 || r.aborted || r.error) return false;
177
+ if (r.finishReason === "length") return false;
178
+ const text = r.text.trim();
179
+ return !text || announcesAction(text) || text.endsWith(":");
180
+ }