@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.
- package/components/Chat/Activity.test.ts +2 -1
- package/components/Chat/Activity.tsx +102 -64
- package/components/Chat/ChatPanel.tsx +16 -3
- package/components/Chat/LiveOutput.test.ts +36 -0
- package/components/Chat/Messages.test.ts +13 -1
- package/components/Chat/Messages.tsx +19 -2
- package/components/Chat/StepOutcomes.tsx +66 -0
- package/components/Chat/Tools.test.ts +52 -0
- package/components/Chat/Tools.tsx +63 -9
- 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 +91 -2
- package/lib/chat/drivers/pi.ts +95 -4
- 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 +73 -1
- package/lib/chat/events.ts +17 -1
- package/lib/chat/group.ts +23 -3
- package/lib/chat/model-wait-live.ts +19 -0
- package/lib/chat/model-wait.ts +88 -0
- package/lib/chat/page.ts +3 -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/tool-outcome.ts +45 -0
- package/lib/chat/tools.ts +14 -0
- package/package.json +1 -1
- package/scripts/screenshots/chat-verify.ts +68 -0
- 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
|
-
|
|
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({
|
package/lib/chat/drivers/pi.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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:
|
|
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
|
+
}
|