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