tinker-agent 2.9.0 → 2.11.0
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/CHANGELOG.md +69 -1
- package/README.md +30 -1
- package/package.json +5 -3
- package/src/agent/loop.ts +50 -13
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/agent/runtime-provider-retry.ts +115 -0
- package/src/agent/runtime-session-contracts.ts +11 -0
- package/src/agent/runtime-session.ts +29 -0
- package/src/cli/command-line.ts +26 -2
- package/src/cli/connect-runner.tsx +26 -0
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +1 -1
- package/src/cli/public-cli-contract.ts +18 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/cli/tui-runner.tsx +1 -0
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/events/types.ts +8 -0
- package/src/image/abortable-file-open.ts +54 -0
- package/src/image/image-asset-store.ts +7 -1
- package/src/model/fake-model-client.ts +20 -1
- package/src/model/openai-model-utils.ts +45 -1
- package/src/model/openai-responses-mapping.ts +11 -0
- package/src/model/openai-responses-stream.ts +14 -0
- package/src/observation/observation-builder.ts +87 -37
- package/src/remote/client.ts +350 -0
- package/src/remote/config.ts +95 -0
- package/src/remote/http-server.ts +240 -0
- package/src/remote/protocol.ts +228 -0
- package/src/remote/service-store.ts +175 -0
- package/src/remote/service.ts +219 -0
- package/src/remote/sync-hub.ts +95 -0
- package/src/session/remote-history-reader.ts +143 -0
- package/src/session/scoped-query-database.ts +27 -0
- package/src/session/session-history-access.ts +4 -3
- package/src/session/session-store.ts +7 -3
- package/src/tools/bash-task.ts +26 -16
- package/src/tools/bash.ts +44 -2
- package/src/tools/glob.ts +107 -19
- package/src/tools/grep-output.ts +130 -0
- package/src/tools/grep-pagination.ts +73 -0
- package/src/tools/grep-path.ts +11 -0
- package/src/tools/grep-snippets.ts +111 -0
- package/src/tools/grep.ts +148 -155
- package/src/tools/read.ts +0 -9
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +30 -2
- package/src/tui/app.tsx +45 -3
- package/src/tui/components/ask-user.tsx +15 -8
- package/src/tui/components/prompt-input.tsx +14 -6
- package/src/tui/components/timeline.tsx +17 -9
- package/src/tui/event-store.ts +25 -2
- package/src/tui/file-mention.ts +29 -5
- package/src/tui/remote-app.tsx +210 -0
- package/src/tui/tui-projection-store.ts +5 -2
- package/src/tui/tui-session-controller.ts +8 -0
- package/src/tui/workspace-file-search.ts +21 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { RuntimeSession } from "./runtime-session";
|
|
3
|
+
import type {
|
|
4
|
+
AssistantTextDeltaSink,
|
|
5
|
+
AssistantTextDeltaUpdate,
|
|
6
|
+
} from "./assistant-text-delta";
|
|
7
|
+
import type { AgentEvent } from "../events/types";
|
|
8
|
+
import type { EventSink } from "../events/event-sink";
|
|
9
|
+
import {
|
|
10
|
+
RemoteHistoryReader,
|
|
11
|
+
type RemoteHistoryPage,
|
|
12
|
+
} from "../session/remote-history-reader";
|
|
13
|
+
import { parseSessionId } from "../ids/runtime-id";
|
|
14
|
+
import {
|
|
15
|
+
RemoteError,
|
|
16
|
+
type RemoteActivity,
|
|
17
|
+
type RemoteOperationInput,
|
|
18
|
+
type OperationReceipt,
|
|
19
|
+
type RemoteView,
|
|
20
|
+
} from "../remote/protocol";
|
|
21
|
+
import { type ManagedSessionRecord, RemoteServiceStore } from "../remote/service-store";
|
|
22
|
+
import { RemoteSyncHub } from "../remote/sync-hub";
|
|
23
|
+
|
|
24
|
+
export type HostedRuntimeFactory = (input: {
|
|
25
|
+
record: ManagedSessionRecord;
|
|
26
|
+
sink: EventSink & AssistantTextDeltaSink;
|
|
27
|
+
}) => Promise<{
|
|
28
|
+
runtime: RuntimeSession;
|
|
29
|
+
databasePath: string;
|
|
30
|
+
modelName: string;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
34
|
+
readonly name = "remote-view";
|
|
35
|
+
readonly hub: RemoteSyncHub;
|
|
36
|
+
private runtime?: RuntimeSession;
|
|
37
|
+
private reader?: RemoteHistoryReader;
|
|
38
|
+
private opening?: Promise<void>;
|
|
39
|
+
private readonly queue: OperationReceipt[] = [];
|
|
40
|
+
private active?: {
|
|
41
|
+
receipt: OperationReceipt;
|
|
42
|
+
controller: AbortController;
|
|
43
|
+
completion?: Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
private activity: Omit<RemoteActivity, "session" | "operations"> = {
|
|
46
|
+
status: "idle",
|
|
47
|
+
tools: [],
|
|
48
|
+
};
|
|
49
|
+
private lastOrdinal = 0;
|
|
50
|
+
private streamTimer?: ReturnType<typeof setTimeout>;
|
|
51
|
+
private stopping = false;
|
|
52
|
+
private unsubscribers: (() => void)[] = [];
|
|
53
|
+
private controlTail: Promise<void> = Promise.resolve();
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
private record: ManagedSessionRecord,
|
|
57
|
+
private readonly store: RemoteServiceStore,
|
|
58
|
+
epoch: string,
|
|
59
|
+
private readonly factory: HostedRuntimeFactory,
|
|
60
|
+
) {
|
|
61
|
+
this.hub = new RemoteSyncHub(epoch, () => this.view());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
open(): Promise<void> {
|
|
65
|
+
return (this.opening ??= this.initialize());
|
|
66
|
+
}
|
|
67
|
+
private async initialize(): Promise<void> {
|
|
68
|
+
try {
|
|
69
|
+
const opened = await this.factory({ record: this.record, sink: this });
|
|
70
|
+
this.runtime = opened.runtime;
|
|
71
|
+
this.reader = new RemoteHistoryReader(
|
|
72
|
+
opened.databasePath,
|
|
73
|
+
parseSessionId(this.record.id),
|
|
74
|
+
this.record.workspacePath,
|
|
75
|
+
);
|
|
76
|
+
this.record = {
|
|
77
|
+
...this.record,
|
|
78
|
+
initialized: true,
|
|
79
|
+
modelName: opened.modelName,
|
|
80
|
+
status: "idle",
|
|
81
|
+
};
|
|
82
|
+
this.store.saveSession(this.record);
|
|
83
|
+
const latest = this.reader.latestTurn();
|
|
84
|
+
if (latest && latest.status !== "open") {
|
|
85
|
+
this.activity.status = latest.status as RemoteActivity["status"];
|
|
86
|
+
this.activity.error = latest.error;
|
|
87
|
+
}
|
|
88
|
+
// Canonical terminal status wins over a crash between turn commit and receipt update.
|
|
89
|
+
for (const receipt of this.store.operations(this.record.id)) {
|
|
90
|
+
const status = receipt.turnId
|
|
91
|
+
? this.reader.turnStatus(receipt.turnId)
|
|
92
|
+
: undefined;
|
|
93
|
+
if (receipt.kind === "prompt" && status && status !== "open") {
|
|
94
|
+
this.store.update({
|
|
95
|
+
...receipt,
|
|
96
|
+
status: status as OperationReceipt["status"],
|
|
97
|
+
error: status === "interrupted" ? receipt.error : undefined,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const last = this.store
|
|
102
|
+
.operations(this.record.id)
|
|
103
|
+
.filter((op) => op.kind === "prompt")
|
|
104
|
+
.at(-1);
|
|
105
|
+
if (last?.status === "interrupted") {
|
|
106
|
+
this.activity.status = "interrupted";
|
|
107
|
+
this.activity.error = last.error;
|
|
108
|
+
}
|
|
109
|
+
this.unsubscribers = [
|
|
110
|
+
this.runtime.subscribeAskUser(() => this.updateInteraction()),
|
|
111
|
+
this.runtime.subscribeBashGuard(() => this.updateInteraction()),
|
|
112
|
+
];
|
|
113
|
+
this.publish();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
this.activity.status = "failed";
|
|
116
|
+
this.activity.error = errorMessage(error);
|
|
117
|
+
if (this.runtime)
|
|
118
|
+
await this.runtime
|
|
119
|
+
.dispose({ type: "runner_failed", error: errorMessage(error) })
|
|
120
|
+
.catch(() => undefined);
|
|
121
|
+
this.publish();
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
view(): RemoteView {
|
|
127
|
+
return { ...this.readActivity(), history: this.history() };
|
|
128
|
+
}
|
|
129
|
+
history(before?: number, limit?: number): RemoteHistoryPage {
|
|
130
|
+
return this.reader?.page(before, limit) ?? { messages: [], hasMore: false };
|
|
131
|
+
}
|
|
132
|
+
private readActivity(): RemoteActivity {
|
|
133
|
+
const { id, workspaceId, title, modelName, owner, updatedAt } = this.record;
|
|
134
|
+
const session = { id, workspaceId, title, modelName, owner, updatedAt };
|
|
135
|
+
return structuredClone({
|
|
136
|
+
...this.activity,
|
|
137
|
+
session: { ...session, status: this.activity.status },
|
|
138
|
+
operations: this.store.operations(this.record.id),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
validate(input: RemoteOperationInput): void {
|
|
143
|
+
if (this.stopping)
|
|
144
|
+
throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
|
|
145
|
+
if (input.kind === "prompt" && this.queue.length >= 8)
|
|
146
|
+
throw new RemoteError(
|
|
147
|
+
409,
|
|
148
|
+
"QUEUE_FULL",
|
|
149
|
+
"This session already has eight queued requests.",
|
|
150
|
+
);
|
|
151
|
+
if (input.kind === "answer" || input.kind === "confirm") {
|
|
152
|
+
const pending = this.activity.interaction;
|
|
153
|
+
if (
|
|
154
|
+
!pending ||
|
|
155
|
+
pending.id !== input.interactionId ||
|
|
156
|
+
pending.kind !== (input.kind === "answer" ? "question" : "confirmation")
|
|
157
|
+
)
|
|
158
|
+
throw new RemoteError(
|
|
159
|
+
409,
|
|
160
|
+
"STALE_INTERACTION",
|
|
161
|
+
"This question or confirmation is no longer pending.",
|
|
162
|
+
);
|
|
163
|
+
if (
|
|
164
|
+
input.kind === "answer" &&
|
|
165
|
+
input.selectedIndex !== null &&
|
|
166
|
+
pending.kind === "question" &&
|
|
167
|
+
!pending.options[input.selectedIndex]
|
|
168
|
+
)
|
|
169
|
+
throw new RemoteError(400, "INVALID_ANSWER", "Answer index is out of range.");
|
|
170
|
+
}
|
|
171
|
+
if (input.kind === "stop") {
|
|
172
|
+
const target = this.store.get(input.targetRequestId);
|
|
173
|
+
if (target.sessionId !== this.record.id || target.kind !== "prompt")
|
|
174
|
+
throw new RemoteError(
|
|
175
|
+
409,
|
|
176
|
+
"INVALID_STOP_TARGET",
|
|
177
|
+
"Stop must target a prompt in this session.",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
enqueue(receipt: OperationReceipt): void {
|
|
183
|
+
this.queue.push(receipt);
|
|
184
|
+
if (!this.active) this.activity.status = "accepted";
|
|
185
|
+
if (this.record.title === "New session" && receipt.prompt) {
|
|
186
|
+
this.record = {
|
|
187
|
+
...this.record,
|
|
188
|
+
title: receipt.prompt.replace(/\s+/g, " ").slice(0, 100),
|
|
189
|
+
};
|
|
190
|
+
this.store.saveSession(this.record);
|
|
191
|
+
}
|
|
192
|
+
this.publish();
|
|
193
|
+
this.pump();
|
|
194
|
+
}
|
|
195
|
+
private pump(): void {
|
|
196
|
+
if (this.active || this.stopping) return;
|
|
197
|
+
const receipt = this.queue.shift();
|
|
198
|
+
if (!receipt) return;
|
|
199
|
+
const active = {
|
|
200
|
+
receipt,
|
|
201
|
+
controller: new AbortController(),
|
|
202
|
+
completion: undefined as Promise<void> | undefined,
|
|
203
|
+
};
|
|
204
|
+
this.active = active;
|
|
205
|
+
active.completion = this.execute(active);
|
|
206
|
+
}
|
|
207
|
+
private async execute(active: NonNullable<HostedSession["active"]>): Promise<void> {
|
|
208
|
+
try {
|
|
209
|
+
await this.open();
|
|
210
|
+
if (active.controller.signal.aborted) {
|
|
211
|
+
this.updateReceipt(active.receipt, { status: "cancelled" });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.activity = {
|
|
215
|
+
status: "running",
|
|
216
|
+
activeRequestId: active.receipt.requestId,
|
|
217
|
+
tools: [],
|
|
218
|
+
};
|
|
219
|
+
this.updateReceipt(active.receipt, { status: "running" });
|
|
220
|
+
this.publish();
|
|
221
|
+
const accepted = await this.runtime!.admitTurn({
|
|
222
|
+
userMessage: { role: "user", content: active.receipt.prompt! },
|
|
223
|
+
signal: active.controller.signal,
|
|
224
|
+
});
|
|
225
|
+
active.receipt = this.updateReceipt(active.receipt, {
|
|
226
|
+
status: "running",
|
|
227
|
+
turnId: accepted.turnId,
|
|
228
|
+
});
|
|
229
|
+
this.activity.activeTurnId = accepted.turnId;
|
|
230
|
+
this.publish();
|
|
231
|
+
const result = await accepted.completion;
|
|
232
|
+
this.updateReceipt(active.receipt, {
|
|
233
|
+
status: result.status,
|
|
234
|
+
...(result.status === "failed" ? { error: result.error } : {}),
|
|
235
|
+
});
|
|
236
|
+
this.activity.status = result.status;
|
|
237
|
+
if (result.status === "failed") this.activity.error = result.error;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const status = active.controller.signal.aborted ? "cancelled" : "failed";
|
|
240
|
+
this.updateReceipt(active.receipt, {
|
|
241
|
+
status,
|
|
242
|
+
error: errorMessage(error),
|
|
243
|
+
});
|
|
244
|
+
this.activity.status = status;
|
|
245
|
+
this.activity.error = errorMessage(error);
|
|
246
|
+
} finally {
|
|
247
|
+
this.activity.streaming = undefined;
|
|
248
|
+
this.activity.interaction = undefined;
|
|
249
|
+
this.activity.activeRequestId = undefined;
|
|
250
|
+
this.activity.activeTurnId = undefined;
|
|
251
|
+
for (const tool of this.activity.tools) {
|
|
252
|
+
if (tool.status === "running")
|
|
253
|
+
tool.status = this.activity.status === "cancelled" ? "cancelled" : "failed";
|
|
254
|
+
}
|
|
255
|
+
this.active = undefined;
|
|
256
|
+
this.publish(true);
|
|
257
|
+
this.pump();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
control(input: RemoteOperationInput, receipt: OperationReceipt): void {
|
|
262
|
+
// Separate from prompt execution: answering a wait cannot wait for that turn.
|
|
263
|
+
this.controlTail = this.controlTail.then(async () => {
|
|
264
|
+
try {
|
|
265
|
+
await this.open();
|
|
266
|
+
this.validate(input);
|
|
267
|
+
if (input.kind === "stop") {
|
|
268
|
+
if (this.active?.receipt.requestId === input.targetRequestId)
|
|
269
|
+
this.active.controller.abort();
|
|
270
|
+
const index = this.queue.findIndex(
|
|
271
|
+
(op) => op.requestId === input.targetRequestId,
|
|
272
|
+
);
|
|
273
|
+
if (index !== -1)
|
|
274
|
+
this.updateReceipt(this.queue.splice(index, 1)[0], {
|
|
275
|
+
status: "cancelled",
|
|
276
|
+
});
|
|
277
|
+
} else if (input.kind === "answer") {
|
|
278
|
+
await this.runtime!.resolveAskUser(
|
|
279
|
+
input.selectedIndex === null
|
|
280
|
+
? { outcome: "dismissed" }
|
|
281
|
+
: { outcome: "selected", selectedIndex: input.selectedIndex },
|
|
282
|
+
);
|
|
283
|
+
} else if (input.kind === "confirm") {
|
|
284
|
+
await this.runtime!.resolveBashConfirmation(input.decision);
|
|
285
|
+
}
|
|
286
|
+
this.updateReceipt(receipt, { status: "completed" });
|
|
287
|
+
} catch (error) {
|
|
288
|
+
this.updateReceipt(receipt, {
|
|
289
|
+
status: "failed",
|
|
290
|
+
error: errorMessage(error),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
this.publish();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private updateReceipt(
|
|
298
|
+
receipt: OperationReceipt,
|
|
299
|
+
patch: Partial<OperationReceipt>,
|
|
300
|
+
): OperationReceipt {
|
|
301
|
+
const next = this.store.update({
|
|
302
|
+
...this.store.get(receipt.requestId),
|
|
303
|
+
...patch,
|
|
304
|
+
});
|
|
305
|
+
this.record = { ...this.record, updatedAt: next.updatedAt };
|
|
306
|
+
this.store.saveSession(this.record);
|
|
307
|
+
if (this.active?.receipt.requestId === receipt.requestId)
|
|
308
|
+
this.active.receipt = next;
|
|
309
|
+
return next;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private updateInteraction(): void {
|
|
313
|
+
const question = this.runtime?.askUser().pending;
|
|
314
|
+
const confirmation = this.runtime?.bashGuard().pending;
|
|
315
|
+
if (question || confirmation) {
|
|
316
|
+
if (!this.activity.interaction) {
|
|
317
|
+
this.activity.interaction = question
|
|
318
|
+
? { id: randomUUID(), kind: "question", ...question }
|
|
319
|
+
: { id: randomUUID(), kind: "confirmation", ...confirmation! };
|
|
320
|
+
}
|
|
321
|
+
this.activity.status = "waiting_input";
|
|
322
|
+
} else {
|
|
323
|
+
this.activity.interaction = undefined;
|
|
324
|
+
if (this.active) this.activity.status = "running";
|
|
325
|
+
}
|
|
326
|
+
if (this.active)
|
|
327
|
+
this.updateReceipt(this.active.receipt, {
|
|
328
|
+
status: this.activity.status as OperationReceipt["status"],
|
|
329
|
+
});
|
|
330
|
+
this.publish();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async append(event: AgentEvent): Promise<void> {
|
|
334
|
+
if (!this.reader) return;
|
|
335
|
+
switch (event.type) {
|
|
336
|
+
case "turn.started":
|
|
337
|
+
this.activity.activeTurnId = event.turnId;
|
|
338
|
+
if (this.active)
|
|
339
|
+
this.updateReceipt(this.active.receipt, { turnId: event.turnId });
|
|
340
|
+
break;
|
|
341
|
+
case "model.request.started":
|
|
342
|
+
this.activity.streaming = undefined;
|
|
343
|
+
break;
|
|
344
|
+
case "model.request.finished":
|
|
345
|
+
this.activity.streaming = undefined;
|
|
346
|
+
break;
|
|
347
|
+
case "tool.started":
|
|
348
|
+
this.activity.tools.push({
|
|
349
|
+
id: event.data.call.toolCallId,
|
|
350
|
+
name: event.data.call.name,
|
|
351
|
+
arguments: JSON.stringify(event.data.call.args),
|
|
352
|
+
status: "running",
|
|
353
|
+
});
|
|
354
|
+
this.activity.tools = this.activity.tools.slice(-100);
|
|
355
|
+
break;
|
|
356
|
+
case "tool.finished": {
|
|
357
|
+
const tool = this.activity.tools.find((tool) => tool.id === event.toolCallId);
|
|
358
|
+
if (tool) tool.status = event.data.ok ? "completed" : "failed";
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "tool.observation": {
|
|
362
|
+
const tool = this.activity.tools.find((tool) => tool.id === event.toolCallId);
|
|
363
|
+
if (tool) tool.detail = event.data.observation.displayText;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "turn.finished":
|
|
367
|
+
case "turn.failed":
|
|
368
|
+
case "turn.cancelled":
|
|
369
|
+
case "agent.iteration.finished":
|
|
370
|
+
case "turn.steering.applied":
|
|
371
|
+
break;
|
|
372
|
+
default:
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
this.publish();
|
|
376
|
+
}
|
|
377
|
+
updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
|
|
378
|
+
const previous = this.activity.streaming;
|
|
379
|
+
this.activity.streaming = {
|
|
380
|
+
iterationId: update.iterationId,
|
|
381
|
+
attempt: update.attemptNumber,
|
|
382
|
+
text:
|
|
383
|
+
previous?.iterationId === update.iterationId &&
|
|
384
|
+
previous.attempt === update.attemptNumber
|
|
385
|
+
? previous.text + update.content
|
|
386
|
+
: update.content,
|
|
387
|
+
};
|
|
388
|
+
if (!this.streamTimer)
|
|
389
|
+
this.streamTimer = setTimeout(() => {
|
|
390
|
+
this.streamTimer = undefined;
|
|
391
|
+
this.publish();
|
|
392
|
+
}, 50);
|
|
393
|
+
}
|
|
394
|
+
private publish(refreshTurn = false): void {
|
|
395
|
+
if (this.streamTimer) {
|
|
396
|
+
clearTimeout(this.streamTimer);
|
|
397
|
+
this.streamTimer = undefined;
|
|
398
|
+
}
|
|
399
|
+
const messages = this.reader?.after(this.lastOrdinal) ?? [];
|
|
400
|
+
if (messages.length) this.lastOrdinal = messages.at(-1)!.ordinal;
|
|
401
|
+
if (refreshTurn) {
|
|
402
|
+
const latest = this.reader?.latestTurn();
|
|
403
|
+
messages.push(
|
|
404
|
+
...(this.reader?.page().messages.filter((m) => m.turnId === latest?.id) ?? []),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
this.hub.publish({ activity: this.readActivity(), messages });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async close(): Promise<void> {
|
|
411
|
+
this.stopping = true;
|
|
412
|
+
for (const receipt of this.queue.splice(0))
|
|
413
|
+
this.updateReceipt(receipt, {
|
|
414
|
+
status: "interrupted",
|
|
415
|
+
error: "The service was stopped before this queued request began.",
|
|
416
|
+
});
|
|
417
|
+
if (this.opening) await this.opening.catch(() => undefined);
|
|
418
|
+
if (this.runtime)
|
|
419
|
+
await this.runtime.dispose({
|
|
420
|
+
type: "runner_failed",
|
|
421
|
+
error: "Remote service shutdown.",
|
|
422
|
+
});
|
|
423
|
+
await this.active?.completion;
|
|
424
|
+
await this.controlTail;
|
|
425
|
+
for (const unsubscribe of this.unsubscribers) unsubscribe();
|
|
426
|
+
if (this.streamTimer) clearTimeout(this.streamTimer);
|
|
427
|
+
this.hub.close();
|
|
428
|
+
this.reader?.close();
|
|
429
|
+
}
|
|
430
|
+
get pendingCount(): number {
|
|
431
|
+
return this.queue.length + (this.active ? 1 : 0);
|
|
432
|
+
}
|
|
433
|
+
receiptChanged(): void {
|
|
434
|
+
this.publish();
|
|
435
|
+
}
|
|
436
|
+
get initialized(): boolean {
|
|
437
|
+
return this.runtime !== undefined;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function errorMessage(error: unknown): string {
|
|
442
|
+
return error instanceof Error ? error.message : String(error);
|
|
443
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { AgentEventInput, ModelRequestFailedData } from "../events/types";
|
|
2
|
+
import type { IterationIdentity } from "./types";
|
|
3
|
+
import { cancellationError } from "./turn-cancellation";
|
|
4
|
+
|
|
5
|
+
export type ProviderRetryDecision = "retry" | "stop";
|
|
6
|
+
export type ProviderRetryRequest = {
|
|
7
|
+
readonly requestId: string;
|
|
8
|
+
readonly failure: ModelRequestFailedData;
|
|
9
|
+
};
|
|
10
|
+
export type ProviderRetrySnapshot = { readonly pending?: ProviderRetryRequest };
|
|
11
|
+
export const EMPTY_PROVIDER_RETRY: ProviderRetrySnapshot = Object.freeze({});
|
|
12
|
+
|
|
13
|
+
type PendingRetry = {
|
|
14
|
+
request: ProviderRetryRequest;
|
|
15
|
+
iteration: IterationIdentity;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
resolve: (decision: ProviderRetryDecision) => void;
|
|
19
|
+
reject: (error: unknown) => void;
|
|
20
|
+
removeAbortListener: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Process-local interaction; the original turn and model request remain open. */
|
|
24
|
+
export class RuntimeProviderRetry {
|
|
25
|
+
private snapshot: ProviderRetrySnapshot = EMPTY_PROVIDER_RETRY;
|
|
26
|
+
private pending?: PendingRetry;
|
|
27
|
+
private readonly listeners = new Set<() => void>();
|
|
28
|
+
|
|
29
|
+
constructor(private readonly append: (event: AgentEventInput) => Promise<void>) {}
|
|
30
|
+
|
|
31
|
+
read(): ProviderRetrySnapshot {
|
|
32
|
+
return this.snapshot;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscribe(listener: () => void): () => void {
|
|
36
|
+
this.listeners.add(listener);
|
|
37
|
+
return () => this.listeners.delete(listener);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async request(
|
|
41
|
+
iteration: IterationIdentity,
|
|
42
|
+
failure: ModelRequestFailedData,
|
|
43
|
+
signal: AbortSignal,
|
|
44
|
+
): Promise<ProviderRetryDecision> {
|
|
45
|
+
if (this.pending !== undefined) throw new Error("Provider retry already pending.");
|
|
46
|
+
if (signal.aborted) throw cancellationError(signal);
|
|
47
|
+
await this.append({ type: "model.retry.requested", ...iteration, data: failure });
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const request = Object.freeze({
|
|
50
|
+
requestId: `${iteration.iterationId}:${failure.attemptNumber}`,
|
|
51
|
+
failure: Object.freeze({ ...failure }),
|
|
52
|
+
});
|
|
53
|
+
const pending: PendingRetry = {
|
|
54
|
+
request,
|
|
55
|
+
iteration,
|
|
56
|
+
startedAt: Date.now(),
|
|
57
|
+
signal,
|
|
58
|
+
resolve,
|
|
59
|
+
reject,
|
|
60
|
+
removeAbortListener: () => signal.removeEventListener("abort", onAbort),
|
|
61
|
+
};
|
|
62
|
+
const onAbort = () => {
|
|
63
|
+
// settle rejects the waiting loop too; do not leave a detached rejection.
|
|
64
|
+
void this.settle(pending, "cancelled").catch(() => undefined);
|
|
65
|
+
};
|
|
66
|
+
this.pending = pending;
|
|
67
|
+
this.snapshot = Object.freeze({ pending: request });
|
|
68
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
if (signal.aborted) onAbort();
|
|
70
|
+
else this.notify();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async resolve(requestId: string, decision: ProviderRetryDecision): Promise<void> {
|
|
75
|
+
const pending = this.pending;
|
|
76
|
+
if (pending === undefined || pending.request.requestId !== requestId) {
|
|
77
|
+
throw new Error("Provider retry question is no longer pending.");
|
|
78
|
+
}
|
|
79
|
+
if (decision !== "retry" && decision !== "stop") {
|
|
80
|
+
throw new Error("Invalid provider retry decision.");
|
|
81
|
+
}
|
|
82
|
+
await this.settle(pending, decision);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async settle(
|
|
86
|
+
pending: PendingRetry,
|
|
87
|
+
decision: ProviderRetryDecision | "cancelled",
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (this.pending !== pending) return;
|
|
90
|
+
this.pending = undefined;
|
|
91
|
+
this.snapshot = EMPTY_PROVIDER_RETRY;
|
|
92
|
+
pending.removeAbortListener();
|
|
93
|
+
this.notify();
|
|
94
|
+
try {
|
|
95
|
+
await this.append({
|
|
96
|
+
type: "model.retry.resolved",
|
|
97
|
+
...pending.iteration,
|
|
98
|
+
data: {
|
|
99
|
+
attemptNumber: pending.request.failure.attemptNumber,
|
|
100
|
+
decision,
|
|
101
|
+
durationMs: Date.now() - pending.startedAt,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
if (decision === "cancelled") pending.reject(cancellationError(pending.signal));
|
|
105
|
+
else pending.resolve(decision);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
pending.reject(error);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private notify(): void {
|
|
113
|
+
for (const listener of this.listeners) listener();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProviderRetryDecision,
|
|
3
|
+
ProviderRetrySnapshot,
|
|
4
|
+
} from "./runtime-provider-retry";
|
|
1
5
|
import type { PublicToolingConfig } from "../cli/public-config-contract";
|
|
2
6
|
import type { ContextAutomationPolicy } from "../context/context-automation-policy";
|
|
3
7
|
import type {
|
|
@@ -118,6 +122,12 @@ export type RuntimeSession = {
|
|
|
118
122
|
subscribeBashGuard(listener: () => void): () => void;
|
|
119
123
|
setYoloMode(enabled: boolean): void;
|
|
120
124
|
resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
|
|
125
|
+
providerRetry(): ProviderRetrySnapshot;
|
|
126
|
+
subscribeProviderRetry(listener: () => void): () => void;
|
|
127
|
+
resolveProviderRetry(
|
|
128
|
+
requestId: string,
|
|
129
|
+
decision: ProviderRetryDecision,
|
|
130
|
+
): Promise<void>;
|
|
121
131
|
askUser(): AskUserSnapshot;
|
|
122
132
|
subscribeAskUser(listener: () => void): () => void;
|
|
123
133
|
resolveAskUser(response: AskUserResolution): Promise<void>;
|
|
@@ -251,6 +261,7 @@ export type CommonRuntimeSessionInput = {
|
|
|
251
261
|
completedTurnHook?: CompletedTurnHook;
|
|
252
262
|
enableTurnUndo?: boolean;
|
|
253
263
|
enableAskUser?: boolean;
|
|
264
|
+
enableProviderRetryPrompt?: boolean;
|
|
254
265
|
bashGuard?: {
|
|
255
266
|
readonly mode: "guard" | "yolo";
|
|
256
267
|
readonly source: Exclude<BashGuardSource, "session">;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RuntimeProviderRetry,
|
|
3
|
+
type ProviderRetryDecision,
|
|
4
|
+
} from "./runtime-provider-retry";
|
|
1
5
|
import path from "node:path";
|
|
2
6
|
import { assertContextMaintenanceCapabilities } from "./runtime-context-capabilities";
|
|
3
7
|
import { CompiledContextError } from "../context/compiled-context-validator";
|
|
@@ -221,6 +225,7 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
221
225
|
|
|
222
226
|
private readonly skillCatalog: SkillCatalogSnapshot;
|
|
223
227
|
private readonly interactions: RuntimeInteractions;
|
|
228
|
+
private readonly providerRetryInteraction: RuntimeProviderRetry;
|
|
224
229
|
private readonly scheduler: RuntimePromptScheduler;
|
|
225
230
|
private readonly contextMaintenance: RuntimeContextMaintenance;
|
|
226
231
|
private readonly runtimeSkills: RuntimeSkills;
|
|
@@ -234,6 +239,9 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
234
239
|
private readonly store: SessionStore,
|
|
235
240
|
private readonly assetStore: ImageAssetStore,
|
|
236
241
|
) {
|
|
242
|
+
this.providerRetryInteraction = new RuntimeProviderRetry((event) =>
|
|
243
|
+
this.append(event),
|
|
244
|
+
);
|
|
237
245
|
this.sessionId = input.selection.sessionId;
|
|
238
246
|
this.resumed = input.selection.mode === "resume";
|
|
239
247
|
this.scheduler = new RuntimePromptScheduler(
|
|
@@ -694,6 +702,21 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
694
702
|
return this.interactions.resolveBashConfirmation(decision);
|
|
695
703
|
}
|
|
696
704
|
|
|
705
|
+
providerRetry() {
|
|
706
|
+
return this.providerRetryInteraction.read();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
subscribeProviderRetry(listener: () => void): () => void {
|
|
710
|
+
return this.providerRetryInteraction.subscribe(listener);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
resolveProviderRetry(
|
|
714
|
+
requestId: string,
|
|
715
|
+
decision: ProviderRetryDecision,
|
|
716
|
+
): Promise<void> {
|
|
717
|
+
return this.providerRetryInteraction.resolve(requestId, decision);
|
|
718
|
+
}
|
|
719
|
+
|
|
697
720
|
askUser(): AskUserSnapshot {
|
|
698
721
|
return this.interactions.askUser();
|
|
699
722
|
}
|
|
@@ -1184,6 +1207,12 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
1184
1207
|
signal,
|
|
1185
1208
|
assetStore: this.assetStore,
|
|
1186
1209
|
initialRequest,
|
|
1210
|
+
...(this.input.enableProviderRetryPrompt === true
|
|
1211
|
+
? {
|
|
1212
|
+
requestProviderRetry: (iteration, failure, signal) =>
|
|
1213
|
+
this.providerRetryInteraction.request(iteration, failure, signal),
|
|
1214
|
+
}
|
|
1215
|
+
: {}),
|
|
1187
1216
|
});
|
|
1188
1217
|
} catch (error) {
|
|
1189
1218
|
if (error instanceof RuntimeEventAppendError) {
|
package/src/cli/command-line.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { CliUsageError, type CliCommandScope } from "./output";
|
|
|
6
6
|
export type CliCommand =
|
|
7
7
|
| { readonly type: "tui"; readonly profileName?: string }
|
|
8
8
|
| { readonly type: "update" }
|
|
9
|
+
| { readonly type: "serve"; readonly configPath: string }
|
|
10
|
+
| { readonly type: "connect"; readonly configPath: string }
|
|
9
11
|
| {
|
|
10
12
|
readonly type: "run";
|
|
11
13
|
readonly profileName?: string;
|
|
@@ -130,6 +132,21 @@ export async function parseCommandLine(
|
|
|
130
132
|
},
|
|
131
133
|
);
|
|
132
134
|
|
|
135
|
+
for (const type of ["serve", "connect"] as const) {
|
|
136
|
+
const command = contract[type];
|
|
137
|
+
program
|
|
138
|
+
.command(command.command)
|
|
139
|
+
.description(command.description)
|
|
140
|
+
.requiredOption(command.configOption.flags, command.configOption.description)
|
|
141
|
+
.allowExcessArguments(false)
|
|
142
|
+
.exitOverride()
|
|
143
|
+
.action((options: { config: string }) => {
|
|
144
|
+
if (!options.config.trim())
|
|
145
|
+
throw new CliUsageError("--config requires a non-empty path.", type);
|
|
146
|
+
selectedCommand = Object.freeze({ type, configPath: options.config });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
133
150
|
program
|
|
134
151
|
.command(contract.update.command)
|
|
135
152
|
.description(contract.update.description)
|
|
@@ -166,10 +183,13 @@ export async function parseCommandLine(
|
|
|
166
183
|
"run",
|
|
167
184
|
);
|
|
168
185
|
}
|
|
169
|
-
if (
|
|
186
|
+
if (
|
|
187
|
+
["update", "serve", "connect"].includes(selectedCommand.type) &&
|
|
188
|
+
topLevelProfile !== undefined
|
|
189
|
+
) {
|
|
170
190
|
throw new CliUsageError(
|
|
171
191
|
"The top-level --profile option only applies to the TUI.",
|
|
172
|
-
|
|
192
|
+
selectedCommand.type as CliCommandScope,
|
|
173
193
|
);
|
|
174
194
|
}
|
|
175
195
|
return Object.freeze({ type: "command", command: selectedCommand });
|
|
@@ -224,6 +244,10 @@ function preflightArgv(args: readonly string[]): CliCommandScope {
|
|
|
224
244
|
scope = "update";
|
|
225
245
|
continue;
|
|
226
246
|
}
|
|
247
|
+
if (token === "serve" || token === "connect") {
|
|
248
|
+
scope = token;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
227
251
|
if (token === "help") {
|
|
228
252
|
return "root";
|
|
229
253
|
}
|