tinker-agent 1.5.1 → 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 +31 -1
- package/README.md +13 -5
- package/package.json +6 -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 +17 -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 +93 -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 +161 -45
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/footer.tsx +8 -5
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/timeline.tsx +10 -0
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +62 -3
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +246 -7
- package/src/tui/tui-session-controller.ts +18 -0
|
@@ -229,6 +229,9 @@ export class FakeModelClient implements ModelClient {
|
|
|
229
229
|
if (this.mode === "pty-static-history") {
|
|
230
230
|
return this.ptyStaticHistory(input, prepared, options);
|
|
231
231
|
}
|
|
232
|
+
if (this.mode === "pty-incremental-output") {
|
|
233
|
+
return this.ptyIncrementalOutput(input, prepared, options);
|
|
234
|
+
}
|
|
232
235
|
if (this.mode === "pty-resume-layout") {
|
|
233
236
|
return this.ptyResumeLayout(input, prepared, options);
|
|
234
237
|
}
|
|
@@ -238,6 +241,9 @@ export class FakeModelClient implements ModelClient {
|
|
|
238
241
|
if (this.mode === "pty-tool-chain") {
|
|
239
242
|
return this.ptyToolChain(input, prepared, options);
|
|
240
243
|
}
|
|
244
|
+
if (this.mode === "pty-turn-undo") {
|
|
245
|
+
return this.ptyTurnUndo(input, prepared, options);
|
|
246
|
+
}
|
|
241
247
|
if (this.mode === "pty-background-task") {
|
|
242
248
|
return this.ptyBackgroundTask(input, prepared, options);
|
|
243
249
|
}
|
|
@@ -361,6 +367,39 @@ export class FakeModelClient implements ModelClient {
|
|
|
361
367
|
return textOutput(prepared, "PTY_STATIC_LIVE_DONE");
|
|
362
368
|
}
|
|
363
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
|
+
|
|
364
403
|
private ptyResumeLayout(
|
|
365
404
|
input: ModelRequestInput,
|
|
366
405
|
prepared: PreparedModelRequest,
|
|
@@ -504,6 +543,60 @@ export class FakeModelClient implements ModelClient {
|
|
|
504
543
|
return textOutput(prepared, "PTY_TOOL_CHAIN_DONE");
|
|
505
544
|
}
|
|
506
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
|
+
|
|
507
600
|
private ptyBackgroundTask(
|
|
508
601
|
input: ModelRequestInput,
|
|
509
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."}`;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export type BashRisk =
|
|
4
|
+
| { readonly dangerous: false }
|
|
5
|
+
| { readonly dangerous: true; readonly reason: string };
|
|
6
|
+
|
|
7
|
+
export type BashRiskContext = {
|
|
8
|
+
readonly workspaceRoot?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const SAFE: BashRisk = Object.freeze({ dangerous: false });
|
|
12
|
+
|
|
13
|
+
export function classifyBashRisk(
|
|
14
|
+
command: string,
|
|
15
|
+
context: BashRiskContext = {},
|
|
16
|
+
): BashRisk {
|
|
17
|
+
const normalized = command.trim();
|
|
18
|
+
if (normalized === "") {
|
|
19
|
+
return SAFE;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(normalized)) {
|
|
23
|
+
return dangerous("fork bomb");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const segment of shellSegments(normalized)) {
|
|
27
|
+
const words = shellWords(segment);
|
|
28
|
+
const commandIndex = commandWordIndex(words);
|
|
29
|
+
if (commandIndex === -1) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const name = basename(words[commandIndex] ?? "");
|
|
33
|
+
const args = words.slice(commandIndex + 1);
|
|
34
|
+
|
|
35
|
+
if (["shutdown", "reboot", "halt", "poweroff"].includes(name)) {
|
|
36
|
+
return dangerous(`system power command ${name}`);
|
|
37
|
+
}
|
|
38
|
+
if (name === "wipefs" || name.startsWith("mkfs.")) {
|
|
39
|
+
return dangerous(`block-device command ${name}`);
|
|
40
|
+
}
|
|
41
|
+
if (name === "dd" && args.some((word) => /^of=\/dev\/[^/]/.test(word))) {
|
|
42
|
+
return dangerous("dd writes directly to a device");
|
|
43
|
+
}
|
|
44
|
+
if ((name === "chmod" || name === "chown") && hasRecursiveFlag(args)) {
|
|
45
|
+
const operands = args.filter((word) => !word.startsWith("-"));
|
|
46
|
+
if (operands.at(-1) === "/") {
|
|
47
|
+
return dangerous(`${name} recursively targets the filesystem root`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (name === "rm" && hasRecursiveFlag(args) && hasForceFlag(args)) {
|
|
51
|
+
const operands = args.filter((word) => !word.startsWith("-"));
|
|
52
|
+
if (
|
|
53
|
+
operands.some((target) => isDestructiveRmTarget(target, context.workspaceRoot))
|
|
54
|
+
) {
|
|
55
|
+
return dangerous("recursive forced removal targets a protected root");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return SAFE;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dangerous(reason: string): BashRisk {
|
|
64
|
+
return Object.freeze({ dangerous: true, reason });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function shellSegments(command: string): string[] {
|
|
68
|
+
return command.split(/(?:&&|\|\||[;|\n])/);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function shellWords(segment: string): string[] {
|
|
72
|
+
return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g)?.map(unquote) ?? [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unquote(word: string): string {
|
|
76
|
+
if (
|
|
77
|
+
(word.startsWith('"') && word.endsWith('"')) ||
|
|
78
|
+
(word.startsWith("'") && word.endsWith("'"))
|
|
79
|
+
) {
|
|
80
|
+
return word.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
return word;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function commandWordIndex(words: readonly string[]): number {
|
|
86
|
+
let index = 0;
|
|
87
|
+
while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? "")) {
|
|
88
|
+
index += 1;
|
|
89
|
+
}
|
|
90
|
+
if (basename(words[index] ?? "") === "sudo") {
|
|
91
|
+
index += 1;
|
|
92
|
+
while ((words[index] ?? "").startsWith("-")) {
|
|
93
|
+
index += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return index < words.length ? index : -1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function basename(word: string): string {
|
|
100
|
+
return word.slice(word.lastIndexOf("/") + 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hasRecursiveFlag(args: readonly string[]): boolean {
|
|
104
|
+
return args.some((word) => /^-[^-]*[rR]/.test(word) || word === "--recursive");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function hasForceFlag(args: readonly string[]): boolean {
|
|
108
|
+
return args.some((word) => /^-[^-]*f/.test(word) || word === "--force");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isDestructiveRmTarget(
|
|
112
|
+
target: string,
|
|
113
|
+
workspaceRoot: string | undefined,
|
|
114
|
+
): boolean {
|
|
115
|
+
if (target === "/" || target === "/*" || target === "~" || target === "~/*") {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
target === "$HOME" ||
|
|
120
|
+
target === "${HOME}" ||
|
|
121
|
+
target === "$HOME/*" ||
|
|
122
|
+
target === "${HOME}/*"
|
|
123
|
+
) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (workspaceRoot === undefined || !path.isAbsolute(target)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const normalizedTarget = path.resolve(target.replace(/\/\*$/, ""));
|
|
130
|
+
return normalizedTarget === path.resolve(workspaceRoot);
|
|
131
|
+
}
|