@saadnvd1/agent-os 0.3.738 → 0.3.742
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +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 +68 -0
- package/lib/chat/runner.ts +4 -70
- package/lib/chat/tools.ts +14 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ModelWait } from "./events";
|
|
2
|
+
import type { WorkerEvent } from "./worker/protocol";
|
|
3
|
+
|
|
4
|
+
// Follows a running turn's events to know when it is waiting on a model
|
|
5
|
+
// call: no tool running, nothing streaming, not compacting. Only what a
|
|
6
|
+
// driver measured is kept as the prompt size; nothing is guessed.
|
|
7
|
+
export class ModelWaitTracker {
|
|
8
|
+
wait: ModelWait | null = null;
|
|
9
|
+
private lastPrompt: number | undefined;
|
|
10
|
+
private running = new Set<string>();
|
|
11
|
+
private active = false;
|
|
12
|
+
|
|
13
|
+
// Returns whether `wait` changed.
|
|
14
|
+
on(e: WorkerEvent, now = Date.now()): boolean {
|
|
15
|
+
const before = this.wait;
|
|
16
|
+
switch (e.type) {
|
|
17
|
+
case "state":
|
|
18
|
+
if (e.state === "running") {
|
|
19
|
+
if (!this.active) {
|
|
20
|
+
this.active = true;
|
|
21
|
+
this.begin(now);
|
|
22
|
+
}
|
|
23
|
+
} else this.end();
|
|
24
|
+
break;
|
|
25
|
+
case "item": {
|
|
26
|
+
const i = e.item;
|
|
27
|
+
if (i.kind === "turn_end") this.end();
|
|
28
|
+
else if (i.kind === "compacted") this.lastPrompt = undefined;
|
|
29
|
+
else if (i.kind === "tool") {
|
|
30
|
+
if (i.status === "running") {
|
|
31
|
+
this.running.add(i.id);
|
|
32
|
+
this.wait = null;
|
|
33
|
+
} else {
|
|
34
|
+
this.running.delete(i.id);
|
|
35
|
+
if (this.active && !this.running.size) this.begin(now);
|
|
36
|
+
}
|
|
37
|
+
} else if (i.kind === "assistant" || i.kind === "reasoning") {
|
|
38
|
+
if (i.streaming) this.wait = null;
|
|
39
|
+
else if (this.active && !this.running.size && !this.wait)
|
|
40
|
+
this.begin(now);
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case "delta":
|
|
45
|
+
this.wait = null;
|
|
46
|
+
break;
|
|
47
|
+
case "compacting":
|
|
48
|
+
if (e.compacting) {
|
|
49
|
+
this.wait = null;
|
|
50
|
+
this.lastPrompt = undefined;
|
|
51
|
+
} else if (this.active && !this.running.size) this.begin(now);
|
|
52
|
+
break;
|
|
53
|
+
case "prompt_tokens":
|
|
54
|
+
this.lastPrompt = e.tokens;
|
|
55
|
+
break;
|
|
56
|
+
case "context":
|
|
57
|
+
this.lastPrompt = e.context.usedTokens || this.lastPrompt;
|
|
58
|
+
break;
|
|
59
|
+
case "model_retry":
|
|
60
|
+
if (this.active)
|
|
61
|
+
this.wait = {
|
|
62
|
+
...(this.wait ?? this.fresh(now)),
|
|
63
|
+
retry: { attempt: e.attempt, message: e.message },
|
|
64
|
+
};
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
before !== this.wait &&
|
|
69
|
+
JSON.stringify(before) !== JSON.stringify(this.wait)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private fresh(now: number): ModelWait {
|
|
74
|
+
return this.lastPrompt
|
|
75
|
+
? { since: now, promptTokens: this.lastPrompt }
|
|
76
|
+
: { since: now };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private begin(now: number) {
|
|
80
|
+
this.wait = this.fresh(now);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private end() {
|
|
84
|
+
this.active = false;
|
|
85
|
+
this.wait = null;
|
|
86
|
+
this.running.clear();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { ChatItem, ChatState, Compacting, ModelWait } from "./events";
|
|
2
|
+
import { formatTokens } from "./context";
|
|
3
|
+
|
|
4
|
+
type Tool = Extract<ChatItem, { kind: "tool" }>;
|
|
5
|
+
|
|
6
|
+
export type Phase = "planning" | "coding" | "testing" | "error";
|
|
7
|
+
|
|
8
|
+
// A wait on one model call this long gets a hint that it may be normal.
|
|
9
|
+
export const LONG_WAIT_MS = 2 * 60_000;
|
|
10
|
+
export const LONG_WAIT_HINT =
|
|
11
|
+
"Still waiting on the model; large prompts on local models can take minutes";
|
|
12
|
+
|
|
13
|
+
const TEST_RE =
|
|
14
|
+
/\b(test|tests|vitest|jest|pytest|mocha|playwright|tsc|typecheck|lint|eslint|build|check)\b|cargo (test|build|check)|go (test|build|vet)/;
|
|
15
|
+
const EDIT_TOOLS = new Set([
|
|
16
|
+
"Edit",
|
|
17
|
+
"Write",
|
|
18
|
+
"MultiEdit",
|
|
19
|
+
"NotebookEdit",
|
|
20
|
+
"apply_patch",
|
|
21
|
+
]);
|
|
22
|
+
const VERBS: Record<string, string> = {
|
|
23
|
+
Read: "Reading",
|
|
24
|
+
Edit: "Editing",
|
|
25
|
+
Write: "Writing",
|
|
26
|
+
MultiEdit: "Editing",
|
|
27
|
+
NotebookEdit: "Editing",
|
|
28
|
+
Search: "Searching",
|
|
29
|
+
Find: "Finding",
|
|
30
|
+
Fetch: "Fetching",
|
|
31
|
+
List: "Listing",
|
|
32
|
+
Run: "Running",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
36
|
+
const input = (t: Tool) => (t.input ?? {}) as Record<string, unknown>;
|
|
37
|
+
const command = (t: Tool) => str(input(t).command);
|
|
38
|
+
|
|
39
|
+
// "ollama/qwen3-coder:30b" -> "qwen3-coder:30b"; nothing known -> "the model".
|
|
40
|
+
export function shortModel(label: string | undefined): string {
|
|
41
|
+
let m = (label ?? "").trim().split("/").at(-1) ?? "";
|
|
42
|
+
m = m.replace(/:latest$/, "").replace(/-\d{8}$/, "");
|
|
43
|
+
if (!m || m.toLowerCase() === "default") return "the model";
|
|
44
|
+
return m.length > 24 ? `${m.slice(0, 23)}…` : m;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tail = (path: string) => path.split("/").slice(-3).join("/");
|
|
48
|
+
const clip = (s: string, n = 60) =>
|
|
49
|
+
s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
50
|
+
|
|
51
|
+
// A tool call in plain words: "Running npm test", "Editing src/a/b.ts".
|
|
52
|
+
export function stepLabel(t: Tool): string {
|
|
53
|
+
const cmd = command(t);
|
|
54
|
+
if (cmd) return `Running ${clip(cmd.replace(/\s+/g, " ").trim())}`;
|
|
55
|
+
const path = str(input(t).file_path);
|
|
56
|
+
if (path && EDIT_TOOLS.has(t.name)) {
|
|
57
|
+
const verb = VERBS[t.name] ?? "Editing";
|
|
58
|
+
return `${verb} ${tail(path)}`;
|
|
59
|
+
}
|
|
60
|
+
const [first, ...rest] = t.title.split(" ");
|
|
61
|
+
return VERBS[first] && rest.length
|
|
62
|
+
? `${VERBS[first]} ${rest.join(" ")}`
|
|
63
|
+
: t.title;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function turnTools(items: ChatItem[]): Tool[] {
|
|
67
|
+
let start = 0;
|
|
68
|
+
items.forEach((i, n) => {
|
|
69
|
+
if (i.kind === "user" && !i.midTurn) start = n + 1;
|
|
70
|
+
});
|
|
71
|
+
return items.slice(start).filter((i): i is Tool => i.kind === "tool");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const edited = (t: Tool) => EDIT_TOOLS.has(t.name) || !!t.diff;
|
|
75
|
+
|
|
76
|
+
// Planning, coding, testing or error, from the turn's tools: the current
|
|
77
|
+
// step (running, else the last one) decides error and testing; having
|
|
78
|
+
// edited anything in the turn makes the rest coding.
|
|
79
|
+
export function phaseOf(tools: Tool[]): Phase {
|
|
80
|
+
const step = tools.findLast((t) => t.status === "running") ?? tools.at(-1);
|
|
81
|
+
if (!step) return "planning";
|
|
82
|
+
if (step.status === "error") return "error";
|
|
83
|
+
if (TEST_RE.test(command(step))) return "testing";
|
|
84
|
+
return tools.some(edited) ? "coding" : "planning";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface Activity {
|
|
88
|
+
label: string;
|
|
89
|
+
since?: number;
|
|
90
|
+
compacting?: boolean;
|
|
91
|
+
kind: "answer" | "compacting" | "tool" | "writing" | "waiting" | "retry";
|
|
92
|
+
// The last measured prompt, when a driver reported one.
|
|
93
|
+
tokens?: number;
|
|
94
|
+
phase?: Phase;
|
|
95
|
+
steps: number;
|
|
96
|
+
// The last finished step, while no tool runs.
|
|
97
|
+
lastStep?: string;
|
|
98
|
+
turnSince?: number;
|
|
99
|
+
hint?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// What the turn is doing now and for how long, from its items.
|
|
103
|
+
export function currentActivity(
|
|
104
|
+
items: ChatItem[],
|
|
105
|
+
state: ChatState,
|
|
106
|
+
compacting: Compacting | null = null,
|
|
107
|
+
modelWait: ModelWait | null = null,
|
|
108
|
+
model?: string,
|
|
109
|
+
now: number = Date.now()
|
|
110
|
+
): Activity | null {
|
|
111
|
+
if (state === "waiting")
|
|
112
|
+
return { label: "Waiting for your answer", kind: "answer", steps: 0 };
|
|
113
|
+
if (state !== "running") return null;
|
|
114
|
+
if (compacting)
|
|
115
|
+
return {
|
|
116
|
+
label: `Compacting context…${compacting.before ? ` ${formatTokens(compacting.before)} tokens` : ""}`,
|
|
117
|
+
since: compacting.since,
|
|
118
|
+
compacting: true,
|
|
119
|
+
kind: "compacting",
|
|
120
|
+
steps: 0,
|
|
121
|
+
};
|
|
122
|
+
const lastUser = items.findLast((i) => i.kind === "user" && !i.midTurn);
|
|
123
|
+
const tools = turnTools(items);
|
|
124
|
+
const base = {
|
|
125
|
+
steps: tools.length,
|
|
126
|
+
turnSince: lastUser?.createdAt,
|
|
127
|
+
phase: tools.length ? phaseOf(tools) : undefined,
|
|
128
|
+
};
|
|
129
|
+
const tool = tools.findLast((t) => t.status === "running");
|
|
130
|
+
if (tool)
|
|
131
|
+
return {
|
|
132
|
+
...base,
|
|
133
|
+
label: stepLabel(tool),
|
|
134
|
+
since: tool.createdAt,
|
|
135
|
+
kind: "tool",
|
|
136
|
+
};
|
|
137
|
+
const last = items.at(-1);
|
|
138
|
+
if (
|
|
139
|
+
last &&
|
|
140
|
+
(last.kind === "assistant" || last.kind === "reasoning") &&
|
|
141
|
+
last.streaming
|
|
142
|
+
)
|
|
143
|
+
return {
|
|
144
|
+
...base,
|
|
145
|
+
label: last.kind === "assistant" ? "Writing" : "Thinking",
|
|
146
|
+
since: lastUser?.createdAt,
|
|
147
|
+
kind: "writing",
|
|
148
|
+
};
|
|
149
|
+
const done = tools.findLast((t) => t.status !== "running");
|
|
150
|
+
const since =
|
|
151
|
+
modelWait?.since ?? done?.endedAt ?? done?.createdAt ?? lastUser?.createdAt;
|
|
152
|
+
const retry = modelWait?.retry;
|
|
153
|
+
return {
|
|
154
|
+
...base,
|
|
155
|
+
label: retry
|
|
156
|
+
? `Retrying model request (attempt ${retry.attempt})`
|
|
157
|
+
: `Waiting for ${shortModel(model)}`,
|
|
158
|
+
since,
|
|
159
|
+
kind: retry ? "retry" : "waiting",
|
|
160
|
+
tokens: modelWait?.promptTokens,
|
|
161
|
+
lastStep: done
|
|
162
|
+
? `${stepLabel(done)}${done.status === "error" ? " failed" : ""}`
|
|
163
|
+
: undefined,
|
|
164
|
+
hint:
|
|
165
|
+
since !== undefined && now - since > LONG_WAIT_MS
|
|
166
|
+
? LONG_WAIT_HINT
|
|
167
|
+
: undefined,
|
|
168
|
+
};
|
|
169
|
+
}
|
package/lib/chat/registry.ts
CHANGED
|
@@ -10,9 +10,11 @@ import type {
|
|
|
10
10
|
ChatServerMessage,
|
|
11
11
|
ChatState,
|
|
12
12
|
Compacting,
|
|
13
|
+
ModelWait,
|
|
13
14
|
} from "./events";
|
|
14
15
|
import { markStatusDirty } from "../status/dirty";
|
|
15
16
|
import { FLUSH_MS } from "./store";
|
|
17
|
+
import type { ModelWaitTracker } from "./model-wait";
|
|
16
18
|
|
|
17
19
|
// Long enough for the worker's buffered write of an item to land.
|
|
18
20
|
const EMIT_SETTLE_MS = 2 * FLUSH_MS;
|
|
@@ -23,6 +25,9 @@ export interface Live {
|
|
|
23
25
|
state: ChatState;
|
|
24
26
|
// Set while its agent compacts the context, over a "running" state.
|
|
25
27
|
compacting?: Compacting | null;
|
|
28
|
+
// The model call the turn is waiting on, if any (lib/chat/model-wait).
|
|
29
|
+
modelWait?: ModelWait | null;
|
|
30
|
+
waitTracker?: ModelWaitTracker;
|
|
26
31
|
// Streamed items carry their latest text here, ahead of SQLite.
|
|
27
32
|
streaming: Map<string, ChatItem>;
|
|
28
33
|
// What the turn is doing right now, for the session list.
|