tinker-agent 1.5.0 → 1.6.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 +48 -1
- package/README.md +13 -5
- package/package.json +7 -5
- package/src/agent/assistant-text-delta.ts +10 -0
- package/src/agent/loop.ts +116 -22
- package/src/agent/runtime-session.ts +248 -1
- package/src/cli/command-line.ts +9 -1
- package/src/cli/config.ts +17 -4
- package/src/cli/main.ts +1 -0
- package/src/cli/public-cli-contract.ts +4 -0
- package/src/cli/public-config-contract.ts +25 -1
- package/src/cli/run-runner.ts +5 -0
- package/src/cli/tui-runner.tsx +21 -2
- package/src/events/observation-text-log.ts +21 -0
- package/src/events/stdout-event-printer.ts +11 -0
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +190 -0
- package/src/model/model-client.ts +3 -0
- package/src/model/openai-chat-model-client.ts +54 -15
- package/src/model/openai-chat-stream.ts +95 -72
- package/src/observation/observation-builder.ts +11 -0
- package/src/session/session-store.ts +1 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash.ts +31 -0
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +47 -3
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +13 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +301 -134
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/assistant-markdown.tsx +27 -26
- package/src/tui/components/background-tasks.tsx +7 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/file-viewer.tsx +2 -2
- package/src/tui/components/footer.tsx +9 -12
- package/src/tui/components/memory-browser.tsx +1 -1
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +3 -1
- package/src/tui/components/timeline.tsx +19 -11
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +75 -3
- package/src/tui/shiki-highlighter.ts +104 -0
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +277 -5
- package/src/tui/tui-session-controller.ts +32 -8
package/src/events/types.ts
CHANGED
|
@@ -285,8 +285,8 @@ export type TurnFinishedData = {
|
|
|
285
285
|
};
|
|
286
286
|
|
|
287
287
|
export type ModelRequestAttemptData = {
|
|
288
|
-
attemptNumber:
|
|
289
|
-
maxAttempts:
|
|
288
|
+
attemptNumber: number;
|
|
289
|
+
maxAttempts: number;
|
|
290
290
|
};
|
|
291
291
|
|
|
292
292
|
export type ModelRequestFailureCode = ProviderResponseErrorCode;
|
|
@@ -294,6 +294,7 @@ export type ModelRequestFailureCode = ProviderResponseErrorCode;
|
|
|
294
294
|
export type ModelRequestFailedData = ModelRequestAttemptData & {
|
|
295
295
|
code: ModelRequestFailureCode;
|
|
296
296
|
retryDisposition: "scheduled" | "not_retryable" | "exhausted";
|
|
297
|
+
retryDelayMs?: number;
|
|
297
298
|
provider: string;
|
|
298
299
|
model: string;
|
|
299
300
|
error: string;
|
|
@@ -328,6 +329,16 @@ export type AgentEventDataMap = {
|
|
|
328
329
|
"tool.raw_result": { call: ToolCall; raw: ToolRawResult };
|
|
329
330
|
"tool.finished": { call: ToolCall; ok: boolean };
|
|
330
331
|
"tool.observation": { call: ToolCall; observation: ToolObservation };
|
|
332
|
+
"tool.confirmation.requested": {
|
|
333
|
+
command: string;
|
|
334
|
+
reason: string;
|
|
335
|
+
};
|
|
336
|
+
"tool.confirmation.resolved": {
|
|
337
|
+
command: string;
|
|
338
|
+
reason: string;
|
|
339
|
+
decision: "allow" | "deny" | "cancelled";
|
|
340
|
+
durationMs: number;
|
|
341
|
+
};
|
|
331
342
|
"agent.iteration.finished": {
|
|
332
343
|
outcome: "continue" | "completed";
|
|
333
344
|
toolCallCount: number;
|
|
@@ -419,6 +430,7 @@ export type AgentEventInput =
|
|
|
419
430
|
| ToolEventInput<
|
|
420
431
|
"tool.started" | "tool.raw_result" | "tool.finished" | "tool.observation"
|
|
421
432
|
>
|
|
433
|
+
| ToolEventInput<"tool.confirmation.requested" | "tool.confirmation.resolved">
|
|
422
434
|
| ToolEventInput<
|
|
423
435
|
"bash.task.backgrounded" | "bash.task.stopping" | "bash.task.finished"
|
|
424
436
|
>;
|
|
@@ -226,12 +226,24 @@ export class FakeModelClient implements ModelClient {
|
|
|
226
226
|
if (this.mode === "pty-echo-history") {
|
|
227
227
|
return this.ptyEchoHistory(input, prepared);
|
|
228
228
|
}
|
|
229
|
+
if (this.mode === "pty-static-history") {
|
|
230
|
+
return this.ptyStaticHistory(input, prepared, options);
|
|
231
|
+
}
|
|
232
|
+
if (this.mode === "pty-incremental-output") {
|
|
233
|
+
return this.ptyIncrementalOutput(input, prepared, options);
|
|
234
|
+
}
|
|
235
|
+
if (this.mode === "pty-resume-layout") {
|
|
236
|
+
return this.ptyResumeLayout(input, prepared, options);
|
|
237
|
+
}
|
|
229
238
|
if (this.mode === "pty-cancel-then-echo") {
|
|
230
239
|
return this.ptyCancelThenEcho(input, prepared, options);
|
|
231
240
|
}
|
|
232
241
|
if (this.mode === "pty-tool-chain") {
|
|
233
242
|
return this.ptyToolChain(input, prepared, options);
|
|
234
243
|
}
|
|
244
|
+
if (this.mode === "pty-turn-undo") {
|
|
245
|
+
return this.ptyTurnUndo(input, prepared, options);
|
|
246
|
+
}
|
|
235
247
|
if (this.mode === "pty-background-task") {
|
|
236
248
|
return this.ptyBackgroundTask(input, prepared, options);
|
|
237
249
|
}
|
|
@@ -307,6 +319,130 @@ export class FakeModelClient implements ModelClient {
|
|
|
307
319
|
throw new Error(`Unexpected pty-echo-history prompt: ${JSON.stringify(prompt)}.`);
|
|
308
320
|
}
|
|
309
321
|
|
|
322
|
+
private async ptyStaticHistory(
|
|
323
|
+
input: ModelRequestInput,
|
|
324
|
+
prepared: PreparedModelRequest,
|
|
325
|
+
options: ModelRequestOptions,
|
|
326
|
+
): Promise<ModelRequestOutput> {
|
|
327
|
+
const prompt = lastUserMessage(input.messages);
|
|
328
|
+
const historyMatch = /^PTY_STATIC_HISTORY_([1-4])$/u.exec(prompt);
|
|
329
|
+
if (historyMatch !== null) {
|
|
330
|
+
const turn = historyMatch[1];
|
|
331
|
+
return textOutput(
|
|
332
|
+
prepared,
|
|
333
|
+
[
|
|
334
|
+
turn === "1"
|
|
335
|
+
? "PTY_STATIC_HISTORY_EARLY_SENTINEL"
|
|
336
|
+
: `PTY_STATIC_HISTORY_${turn}`,
|
|
337
|
+
...Array.from(
|
|
338
|
+
{ length: 8 },
|
|
339
|
+
(_, index) => `- settled PTY history ${turn}.${index + 1}`,
|
|
340
|
+
),
|
|
341
|
+
`PTY_STATIC_HISTORY_${turn}_DONE`,
|
|
342
|
+
].join("\n"),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (prompt !== "PTY_STATIC_LIVE") {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`Unexpected pty-static-history prompt: ${JSON.stringify(prompt)}.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
requireTools(input, ["Bash"]);
|
|
352
|
+
const bash = toolMessagesAfterLastUser(input.messages).find(
|
|
353
|
+
(message) => message.name === "Bash",
|
|
354
|
+
);
|
|
355
|
+
await Bun.sleep(200);
|
|
356
|
+
options.signal.throwIfAborted();
|
|
357
|
+
if (bash === undefined) {
|
|
358
|
+
return toolCallOutput(prepared, options, "Bash", {
|
|
359
|
+
command:
|
|
360
|
+
'index=1; while [ "$index" -le 20 ]; do printf \'PTY_STATIC_LIVE_LINE_%s\\n\' "$index"; index=$((index + 1)); done; sleep 0.2',
|
|
361
|
+
description: "Exercise static history live tail",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (!bash.content.includes("PTY_STATIC_LIVE_LINE_20")) {
|
|
365
|
+
throw new Error("PTY static-history Bash output was incomplete.");
|
|
366
|
+
}
|
|
367
|
+
return textOutput(prepared, "PTY_STATIC_LIVE_DONE");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private async ptyIncrementalOutput(
|
|
371
|
+
input: ModelRequestInput,
|
|
372
|
+
prepared: PreparedModelRequest,
|
|
373
|
+
options: ModelRequestOptions,
|
|
374
|
+
): Promise<ModelRequestOutput> {
|
|
375
|
+
const prompt = lastUserMessage(input.messages);
|
|
376
|
+
if (prompt !== "PTY_INCREMENTAL_OUTPUT") {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Unexpected pty-incremental-output prompt: ${JSON.stringify(prompt)}.`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const chunks = [
|
|
383
|
+
"## PTY incremental first\nPTY_INCREMENTAL_EARLY_SENTINEL\n\n## PTY incre",
|
|
384
|
+
"mental second\n",
|
|
385
|
+
"PTY_INCREMENTAL_SECOND_BODY\n\n## PTY incremental final\n",
|
|
386
|
+
"PTY_INCREMENTAL_FINAL_SENTINEL",
|
|
387
|
+
] as const;
|
|
388
|
+
options.onTextDelta?.(chunks[0]);
|
|
389
|
+
await Bun.sleep(50);
|
|
390
|
+
options.signal.throwIfAborted();
|
|
391
|
+
options.onTextDelta?.(chunks[1]);
|
|
392
|
+
await Bun.sleep(700);
|
|
393
|
+
options.signal.throwIfAborted();
|
|
394
|
+
options.onTextDelta?.(chunks[2]);
|
|
395
|
+
await Bun.sleep(100);
|
|
396
|
+
options.signal.throwIfAborted();
|
|
397
|
+
options.onTextDelta?.(chunks[3]);
|
|
398
|
+
await Bun.sleep(100);
|
|
399
|
+
options.signal.throwIfAborted();
|
|
400
|
+
return textOutput(prepared, chunks.join(""));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private ptyResumeLayout(
|
|
404
|
+
input: ModelRequestInput,
|
|
405
|
+
prepared: PreparedModelRequest,
|
|
406
|
+
options: ModelRequestOptions,
|
|
407
|
+
): ModelRequestOutput {
|
|
408
|
+
const prompt = lastUserMessage(input.messages);
|
|
409
|
+
if (/^PTY_RESUME_LAYOUT_PAD_\d+$/u.test(prompt)) {
|
|
410
|
+
return textOutput(prepared, `${prompt}_DONE`);
|
|
411
|
+
}
|
|
412
|
+
const match = /^PTY_RESUME_LAYOUT_([1-3])$/u.exec(prompt);
|
|
413
|
+
if (match === null) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`Unexpected pty-resume-layout prompt: ${JSON.stringify(prompt)}.`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
requireTools(input, ["Read"]);
|
|
420
|
+
const turn = Number(match[1]);
|
|
421
|
+
const targetToolCount = [8, 17, 4][turn - 1];
|
|
422
|
+
const finalLineCount = [31, 47, 8][turn - 1];
|
|
423
|
+
if (targetToolCount === undefined || finalLineCount === undefined) {
|
|
424
|
+
throw new Error(`Invalid pty-resume-layout turn: ${turn}.`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const completedReads = toolMessagesAfterLastUser(input.messages).filter(
|
|
428
|
+
(message) => message.name === "Read",
|
|
429
|
+
).length;
|
|
430
|
+
if (completedReads < targetToolCount) {
|
|
431
|
+
return toolCallOutput(prepared, options, "Read", {
|
|
432
|
+
file_path: "resume-layout.txt",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return textOutput(
|
|
437
|
+
prepared,
|
|
438
|
+
Array.from(
|
|
439
|
+
{ length: finalLineCount },
|
|
440
|
+
(_, index) =>
|
|
441
|
+
`PTY_RESUME_LAYOUT_${turn}_FINAL_${String(index + 1).padStart(2, "0")}`,
|
|
442
|
+
).join("\n"),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
310
446
|
private ptyCancelThenEcho(
|
|
311
447
|
input: ModelRequestInput,
|
|
312
448
|
prepared: PreparedModelRequest,
|
|
@@ -407,6 +543,60 @@ export class FakeModelClient implements ModelClient {
|
|
|
407
543
|
return textOutput(prepared, "PTY_TOOL_CHAIN_DONE");
|
|
408
544
|
}
|
|
409
545
|
|
|
546
|
+
private ptyTurnUndo(
|
|
547
|
+
input: ModelRequestInput,
|
|
548
|
+
prepared: PreparedModelRequest,
|
|
549
|
+
options: ModelRequestOptions,
|
|
550
|
+
): ModelRequestOutput {
|
|
551
|
+
requireTools(input, ["Read", "Write", "Delete"]);
|
|
552
|
+
const prompt = lastUserMessage(input.messages);
|
|
553
|
+
if (prompt !== "PTY_UNDO_MUTATE") {
|
|
554
|
+
throw new Error(`Unexpected pty-turn-undo prompt: ${JSON.stringify(prompt)}.`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const tools = toolMessagesAfterLastUser(input.messages);
|
|
558
|
+
const read = tools.find((message) => message.name === "Read");
|
|
559
|
+
if (read === undefined) {
|
|
560
|
+
return toolCallOutput(prepared, options, "Read", {
|
|
561
|
+
file_path: "pty-undo-modified.txt",
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
if (!read.content.includes("Read succeeded")) {
|
|
565
|
+
throw new Error("PTY undo Read tool did not succeed.");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const writes = tools.filter((message) => message.name === "Write");
|
|
569
|
+
if (writes.length === 0) {
|
|
570
|
+
return toolCallOutput(prepared, options, "Write", {
|
|
571
|
+
file_path: "pty-undo-modified.txt",
|
|
572
|
+
content: "after undo turn\n",
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
if (!writes[0]?.content.includes("Write succeeded")) {
|
|
576
|
+
throw new Error("PTY undo modifying Write did not succeed.");
|
|
577
|
+
}
|
|
578
|
+
if (writes.length === 1) {
|
|
579
|
+
return toolCallOutput(prepared, options, "Write", {
|
|
580
|
+
file_path: "pty-undo-created/nested.txt",
|
|
581
|
+
content: "created by undo turn\n",
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
if (!writes[1]?.content.includes("Write succeeded")) {
|
|
585
|
+
throw new Error("PTY undo creating Write did not succeed.");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const deletion = tools.find((message) => message.name === "Delete");
|
|
589
|
+
if (deletion === undefined) {
|
|
590
|
+
return toolCallOutput(prepared, options, "Delete", {
|
|
591
|
+
file_path: "pty-undo-deleted.bin",
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
if (!deletion.content.includes("Delete succeeded")) {
|
|
595
|
+
throw new Error("PTY undo Delete tool did not succeed.");
|
|
596
|
+
}
|
|
597
|
+
return textOutput(prepared, "PTY_UNDO_MUTATIONS_DONE");
|
|
598
|
+
}
|
|
599
|
+
|
|
410
600
|
private ptyBackgroundTask(
|
|
411
601
|
input: ModelRequestInput,
|
|
412
602
|
prepared: PreparedModelRequest,
|
|
@@ -36,6 +36,7 @@ export type ModelMessageProtocol = {
|
|
|
36
36
|
|
|
37
37
|
export type ModelRequestOptions = {
|
|
38
38
|
signal: AbortSignal;
|
|
39
|
+
onTextDelta?: (content: string) => void;
|
|
39
40
|
identity?: {
|
|
40
41
|
iteration: IterationIdentity;
|
|
41
42
|
runtimeSession: RuntimeSessionContext;
|
|
@@ -127,6 +128,8 @@ export type ProviderResponseErrorCode =
|
|
|
127
128
|
| "reasoning_only_assistant"
|
|
128
129
|
| "invalid_provider_response"
|
|
129
130
|
| "invalid_provider_stream"
|
|
131
|
+
| "provider_rate_limited"
|
|
132
|
+
| "provider_unavailable"
|
|
130
133
|
| "provider_request_error";
|
|
131
134
|
|
|
132
135
|
export type ProviderResponseDiagnostics = {
|
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
|
|
12
12
|
import type { ModelContextBudget } from "./model-context-profile";
|
|
13
13
|
import type { InputTokenEstimator } from "./input-token-estimator";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
ModelRequestMediaAggregateError,
|
|
16
|
+
ProviderResponseError,
|
|
17
|
+
type ProviderResponseErrorCode,
|
|
18
|
+
} from "./model-client";
|
|
15
19
|
import type {
|
|
16
20
|
MaterializedModelRequest,
|
|
17
21
|
ModelClient,
|
|
@@ -30,7 +34,7 @@ import {
|
|
|
30
34
|
toOpenAIChatMessages,
|
|
31
35
|
toOpenAIChatTools,
|
|
32
36
|
} from "./openai-chat-mapping";
|
|
33
|
-
import {
|
|
37
|
+
import { OpenAIChatCompletionStreamAccumulator } from "./openai-chat-stream";
|
|
34
38
|
import { MoonshotInputTokenEstimator } from "./moonshot-input-token-estimator";
|
|
35
39
|
import { sha256, stableJsonStringify } from "./model-request-preflight";
|
|
36
40
|
|
|
@@ -86,7 +90,9 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
86
90
|
apiKey: options.apiKey,
|
|
87
91
|
baseURL: options.baseURL,
|
|
88
92
|
timeout: options.timeoutMs ?? OPENAI_CHAT_TIMEOUT_MS,
|
|
89
|
-
|
|
93
|
+
// Retries are orchestrated by the agent loop so they surface as
|
|
94
|
+
// cancellable, observable runtime events instead of hidden SDK waits.
|
|
95
|
+
maxRetries: 0,
|
|
90
96
|
fetch: options.fetch,
|
|
91
97
|
});
|
|
92
98
|
if (options.tokenEstimator !== undefined) {
|
|
@@ -235,10 +241,7 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
235
241
|
}
|
|
236
242
|
|
|
237
243
|
const response = this.stream
|
|
238
|
-
?
|
|
239
|
-
await this.collectStreamingChunks(prepared, options.signal),
|
|
240
|
-
{ provider: this.provider, model: this.options.model },
|
|
241
|
-
)
|
|
244
|
+
? await this.requestStreaming(prepared, options)
|
|
242
245
|
: await this.requestNonStreaming(prepared, options.signal);
|
|
243
246
|
|
|
244
247
|
return fromOpenAIChatCompletion(response, {
|
|
@@ -248,21 +251,30 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
248
251
|
});
|
|
249
252
|
}
|
|
250
253
|
|
|
251
|
-
private async
|
|
254
|
+
private async requestStreaming(
|
|
252
255
|
prepared: PreparedModelRequest,
|
|
253
|
-
|
|
254
|
-
): Promise<unknown
|
|
256
|
+
options: ModelRequestOptions,
|
|
257
|
+
): Promise<Record<string, unknown>> {
|
|
258
|
+
const accumulator = new OpenAIChatCompletionStreamAccumulator({
|
|
259
|
+
provider: this.provider,
|
|
260
|
+
model: this.options.model,
|
|
261
|
+
});
|
|
255
262
|
try {
|
|
256
263
|
const stream = await this.client.chat.completions.create(
|
|
257
264
|
prepared.payload as ChatCompletionCreateParamsStreaming,
|
|
258
|
-
{ signal },
|
|
265
|
+
{ signal: options.signal },
|
|
259
266
|
);
|
|
260
|
-
const chunks: unknown[] = [];
|
|
261
267
|
for await (const chunk of stream) {
|
|
262
|
-
|
|
268
|
+
const content = accumulator.push(chunk);
|
|
269
|
+
if (content !== undefined && content !== "") {
|
|
270
|
+
options.onTextDelta?.(content);
|
|
271
|
+
}
|
|
263
272
|
}
|
|
264
|
-
return
|
|
273
|
+
return accumulator.finish();
|
|
265
274
|
} catch (error) {
|
|
275
|
+
if (error instanceof ProviderResponseError) {
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
266
278
|
throw sanitizedProviderError(error, this.provider, this.options.model);
|
|
267
279
|
}
|
|
268
280
|
}
|
|
@@ -531,9 +543,36 @@ function sanitizedProviderError(
|
|
|
531
543
|
)
|
|
532
544
|
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+/giu, "Bearer [redacted]");
|
|
533
545
|
return new ProviderResponseError(
|
|
534
|
-
|
|
546
|
+
providerErrorCode(error),
|
|
535
547
|
sanitized,
|
|
536
548
|
{ provider, model },
|
|
537
549
|
{ cause: error },
|
|
538
550
|
);
|
|
539
551
|
}
|
|
552
|
+
|
|
553
|
+
function providerErrorCode(error: unknown): ProviderResponseErrorCode {
|
|
554
|
+
const status = providerErrorStatus(error);
|
|
555
|
+
if (status === 429) {
|
|
556
|
+
return "provider_rate_limited";
|
|
557
|
+
}
|
|
558
|
+
if (status === 500 || status === 502 || status === 503 || status === 504) {
|
|
559
|
+
return "provider_unavailable";
|
|
560
|
+
}
|
|
561
|
+
if (status === undefined && isProviderConnectionError(error)) {
|
|
562
|
+
return "provider_unavailable";
|
|
563
|
+
}
|
|
564
|
+
return "provider_request_error";
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function providerErrorStatus(error: unknown): number | undefined {
|
|
568
|
+
if (typeof error !== "object" || error === null || !("status" in error)) {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
const status = (error as { status?: unknown }).status;
|
|
572
|
+
return typeof status === "number" ? status : undefined;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function isProviderConnectionError(error: unknown): boolean {
|
|
576
|
+
// APIConnectionTimeoutError extends APIConnectionError in the SDK.
|
|
577
|
+
return error instanceof OpenAI.APIConnectionError;
|
|
578
|
+
}
|
|
@@ -14,62 +14,61 @@ type ToolCallAccumulator = {
|
|
|
14
14
|
* chat.completion-shaped object so the result can be validated and mapped by
|
|
15
15
|
* fromOpenAIChatCompletion exactly like a non-streaming response.
|
|
16
16
|
*/
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
export class OpenAIChatCompletionStreamAccumulator {
|
|
18
|
+
private chunkCount = 0;
|
|
19
|
+
private role: "assistant" | undefined;
|
|
20
|
+
private content: string | undefined;
|
|
21
|
+
private reasoningContent: string | undefined;
|
|
22
|
+
private finishReason: string | undefined;
|
|
23
|
+
private resolvedModel: string | undefined;
|
|
24
|
+
private usage: Record<string, unknown> | undefined;
|
|
25
|
+
private readonly toolCalls: ToolCallAccumulator[] = [];
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
let content: string | undefined;
|
|
27
|
-
let reasoningContent: string | undefined;
|
|
28
|
-
let finishReason: string | undefined;
|
|
29
|
-
let resolvedModel: string | undefined;
|
|
30
|
-
let usage: Record<string, unknown> | undefined;
|
|
31
|
-
const toolCalls: ToolCallAccumulator[] = [];
|
|
27
|
+
constructor(private readonly options: ProviderContext) {}
|
|
32
28
|
|
|
33
|
-
|
|
29
|
+
push(chunk: unknown): string | undefined {
|
|
30
|
+
const chunkIndex = this.chunkCount;
|
|
31
|
+
this.chunkCount += 1;
|
|
34
32
|
const path = `chunk[${chunkIndex}]`;
|
|
35
|
-
const record = requireRecord(chunk, path, options);
|
|
33
|
+
const record = requireRecord(chunk, path, this.options);
|
|
34
|
+
let chunkContent: string | undefined;
|
|
36
35
|
|
|
37
36
|
if (record.model !== undefined && record.model !== null) {
|
|
38
37
|
if (typeof record.model !== "string" || record.model.trim() === "") {
|
|
39
|
-
throw providerStreamError(options, `${path}.model`, "must be a string");
|
|
38
|
+
throw providerStreamError(this.options, `${path}.model`, "must be a string");
|
|
40
39
|
}
|
|
41
|
-
if (resolvedModel !== undefined && resolvedModel !== record.model) {
|
|
40
|
+
if (this.resolvedModel !== undefined && this.resolvedModel !== record.model) {
|
|
42
41
|
throw providerStreamError(
|
|
43
|
-
options,
|
|
42
|
+
this.options,
|
|
44
43
|
`${path}.model`,
|
|
45
|
-
`conflicts with previously streamed model ${JSON.stringify(resolvedModel)}`,
|
|
44
|
+
`conflicts with previously streamed model ${JSON.stringify(this.resolvedModel)}`,
|
|
46
45
|
);
|
|
47
46
|
}
|
|
48
|
-
resolvedModel = record.model;
|
|
47
|
+
this.resolvedModel = record.model;
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
if (record.usage !== undefined && record.usage !== null) {
|
|
52
|
-
usage = requireRecord(record.usage, `${path}.usage`, options);
|
|
51
|
+
this.usage = requireRecord(record.usage, `${path}.usage`, this.options);
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
if (record.choices === undefined || record.choices === null) {
|
|
56
|
-
return;
|
|
55
|
+
return chunkContent;
|
|
57
56
|
}
|
|
58
57
|
if (!Array.isArray(record.choices)) {
|
|
59
|
-
throw providerStreamError(options, `${path}.choices`, "must be an array");
|
|
58
|
+
throw providerStreamError(this.options, `${path}.choices`, "must be an array");
|
|
60
59
|
}
|
|
61
60
|
// The usage-only final chunk from stream_options.include_usage has empty choices.
|
|
62
61
|
for (const [choiceIndex, rawChoice] of record.choices.entries()) {
|
|
63
62
|
const choicePath = `${path}.choices[${choiceIndex}]`;
|
|
64
|
-
const choice = requireRecord(rawChoice, choicePath, options);
|
|
63
|
+
const choice = requireRecord(rawChoice, choicePath, this.options);
|
|
65
64
|
if (choice.index !== 0) {
|
|
66
|
-
throw providerStreamError(options, `${choicePath}.index`, "must be 0");
|
|
65
|
+
throw providerStreamError(this.options, `${choicePath}.index`, "must be 0");
|
|
67
66
|
}
|
|
68
67
|
if (typeof choice.finish_reason === "string") {
|
|
69
|
-
finishReason = choice.finish_reason;
|
|
68
|
+
this.finishReason = choice.finish_reason;
|
|
70
69
|
} else if (choice.finish_reason !== undefined && choice.finish_reason !== null) {
|
|
71
70
|
throw providerStreamError(
|
|
72
|
-
options,
|
|
71
|
+
this.options,
|
|
73
72
|
`${choicePath}.finish_reason`,
|
|
74
73
|
"must be a string or null",
|
|
75
74
|
);
|
|
@@ -79,82 +78,106 @@ export function accumulateOpenAIChatCompletionChunks(
|
|
|
79
78
|
continue;
|
|
80
79
|
}
|
|
81
80
|
const deltaPath = `${choicePath}.delta`;
|
|
82
|
-
const delta = asRecord(choice.delta, deltaPath, options);
|
|
81
|
+
const delta = asRecord(choice.delta, deltaPath, this.options);
|
|
83
82
|
if (delta.role !== undefined && delta.role !== null) {
|
|
84
83
|
if (delta.role !== "assistant") {
|
|
85
84
|
throw providerStreamError(
|
|
86
|
-
options,
|
|
85
|
+
this.options,
|
|
87
86
|
`${deltaPath}.role`,
|
|
88
87
|
'must be "assistant"',
|
|
89
88
|
);
|
|
90
89
|
}
|
|
91
|
-
role = delta.role;
|
|
90
|
+
this.role = delta.role;
|
|
92
91
|
}
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const contentDelta = appendOptionalText(
|
|
93
|
+
undefined,
|
|
95
94
|
delta.content,
|
|
96
95
|
`${deltaPath}.content`,
|
|
97
|
-
options,
|
|
96
|
+
this.options,
|
|
98
97
|
);
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
if (contentDelta !== undefined) {
|
|
99
|
+
this.content = (this.content ?? "") + contentDelta;
|
|
100
|
+
chunkContent = (chunkContent ?? "") + contentDelta;
|
|
101
|
+
}
|
|
102
|
+
this.reasoningContent = appendOptionalText(
|
|
103
|
+
this.reasoningContent,
|
|
101
104
|
delta.reasoning_content,
|
|
102
105
|
`${deltaPath}.reasoning_content`,
|
|
103
|
-
options,
|
|
106
|
+
this.options,
|
|
104
107
|
);
|
|
105
108
|
if (delta.tool_calls === undefined || delta.tool_calls === null) {
|
|
106
109
|
continue;
|
|
107
110
|
}
|
|
108
111
|
if (!Array.isArray(delta.tool_calls)) {
|
|
109
112
|
throw providerStreamError(
|
|
110
|
-
options,
|
|
113
|
+
this.options,
|
|
111
114
|
`${deltaPath}.tool_calls`,
|
|
112
115
|
"must be an array",
|
|
113
116
|
);
|
|
114
117
|
}
|
|
115
118
|
for (const [fragmentIndex, rawFragment] of delta.tool_calls.entries()) {
|
|
116
119
|
mergeToolCallFragment(
|
|
117
|
-
toolCalls,
|
|
120
|
+
this.toolCalls,
|
|
118
121
|
rawFragment,
|
|
119
122
|
`${deltaPath}.tool_calls[${fragmentIndex}]`,
|
|
120
|
-
options,
|
|
123
|
+
this.options,
|
|
121
124
|
);
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
|
-
|
|
127
|
+
return chunkContent;
|
|
128
|
+
}
|
|
125
129
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
content: content ?? null,
|
|
131
|
-
...(reasoningContent === undefined ? {} : { reasoning_content: reasoningContent }),
|
|
132
|
-
...(toolCalls.length === 0
|
|
133
|
-
? {}
|
|
134
|
-
: {
|
|
135
|
-
tool_calls: toolCalls.map((call) => ({
|
|
136
|
-
...(call.id === undefined ? {} : { id: call.id }),
|
|
137
|
-
...(call.type === undefined ? {} : { type: call.type }),
|
|
138
|
-
function: {
|
|
139
|
-
...(call.name === undefined ? {} : { name: call.name }),
|
|
140
|
-
arguments: call.arguments,
|
|
141
|
-
},
|
|
142
|
-
})),
|
|
143
|
-
}),
|
|
144
|
-
};
|
|
130
|
+
finish(): Record<string, unknown> {
|
|
131
|
+
if (this.chunkCount === 0) {
|
|
132
|
+
throw providerStreamError(this.options, "chunks", "must not be empty");
|
|
133
|
+
}
|
|
145
134
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
{
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
135
|
+
// Only fields the provider actually streamed are emitted; the strict
|
|
136
|
+
// non-streaming mapper rejects a missing role, id, type, or name.
|
|
137
|
+
const message: Record<string, unknown> = {
|
|
138
|
+
...(this.role === undefined ? {} : { role: this.role }),
|
|
139
|
+
content: this.content ?? null,
|
|
140
|
+
...(this.reasoningContent === undefined
|
|
141
|
+
? {}
|
|
142
|
+
: { reasoning_content: this.reasoningContent }),
|
|
143
|
+
...(this.toolCalls.length === 0
|
|
144
|
+
? {}
|
|
145
|
+
: {
|
|
146
|
+
tool_calls: this.toolCalls.map((call) => ({
|
|
147
|
+
...(call.id === undefined ? {} : { id: call.id }),
|
|
148
|
+
...(call.type === undefined ? {} : { type: call.type }),
|
|
149
|
+
function: {
|
|
150
|
+
...(call.name === undefined ? {} : { name: call.name }),
|
|
151
|
+
arguments: call.arguments,
|
|
152
|
+
},
|
|
153
|
+
})),
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
object: "chat.completion",
|
|
159
|
+
choices: [
|
|
160
|
+
{
|
|
161
|
+
index: 0,
|
|
162
|
+
message,
|
|
163
|
+
finish_reason: this.finishReason ?? null,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
...(this.usage === undefined ? {} : { usage: this.usage }),
|
|
167
|
+
...(this.resolvedModel === undefined ? {} : { model: this.resolvedModel }),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function accumulateOpenAIChatCompletionChunks(
|
|
173
|
+
chunks: readonly unknown[],
|
|
174
|
+
options: ProviderContext,
|
|
175
|
+
): Record<string, unknown> {
|
|
176
|
+
const accumulator = new OpenAIChatCompletionStreamAccumulator(options);
|
|
177
|
+
for (const chunk of chunks) {
|
|
178
|
+
accumulator.push(chunk);
|
|
179
|
+
}
|
|
180
|
+
return accumulator.finish();
|
|
158
181
|
}
|
|
159
182
|
|
|
160
183
|
function mergeToolCallFragment(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolCall } from "../agent/types";
|
|
2
2
|
import type {
|
|
3
3
|
BashRawResult,
|
|
4
|
+
DeleteFileRawResult,
|
|
4
5
|
EditFileRawResult,
|
|
5
6
|
GenericToolRawResult,
|
|
6
7
|
GlobRawResult,
|
|
@@ -42,6 +43,8 @@ export class ObservationBuilder {
|
|
|
42
43
|
return { content: renderWriteObservation(input.raw) };
|
|
43
44
|
case "edit":
|
|
44
45
|
return { content: renderEditObservation(input.raw) };
|
|
46
|
+
case "delete":
|
|
47
|
+
return { content: renderDeleteObservation(input.raw) };
|
|
45
48
|
case "bash":
|
|
46
49
|
return { content: renderBashObservation(input.raw) };
|
|
47
50
|
case "task_list":
|
|
@@ -308,6 +311,14 @@ function renderEditObservation(raw: EditFileRawResult): string {
|
|
|
308
311
|
].join("\n");
|
|
309
312
|
}
|
|
310
313
|
|
|
314
|
+
function renderDeleteObservation(raw: DeleteFileRawResult): string {
|
|
315
|
+
if (!raw.ok) {
|
|
316
|
+
return `Delete failed for ${raw.filePath || "(unknown path)"}: ${raw.error ?? "Unknown error."}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return `Delete succeeded for ${raw.filePath}.`;
|
|
320
|
+
}
|
|
321
|
+
|
|
311
322
|
function renderBashObservation(raw: BashRawResult): string {
|
|
312
323
|
if (raw.taskId === "" && !raw.ok) {
|
|
313
324
|
return `Bash failed: ${raw.error ?? "Unknown error."}`;
|