tinker-agent 2.0.0 → 2.2.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 +44 -1
- package/README.md +27 -2
- package/package.json +2 -1
- package/src/agent/context-meter.ts +2 -4
- package/src/agent/runtime-session.ts +9 -2
- package/src/agent/session-ledger.ts +12 -5
- package/src/agent/tool-result-content.ts +76 -0
- package/src/agent/types.ts +14 -2
- package/src/cli/config.ts +4 -0
- package/src/cli/model-profiles.ts +41 -2
- package/src/cli/public-config-contract.ts +30 -7
- package/src/cli/runner-dependencies.ts +5 -0
- package/src/cli/tui-memory.ts +1 -0
- package/src/cli/tui-runner.tsx +4 -0
- package/src/context/compiled-context-hash.ts +2 -1
- package/src/context/compiled-context-validator.ts +13 -4
- package/src/context/context-protocol-validator.ts +33 -2
- package/src/context/context-revision-compiler.ts +2 -1
- package/src/context/context-revision.ts +8 -2
- package/src/context/context-swap-renderer.ts +46 -12
- package/src/context/prefix-retirement-planner.ts +13 -9
- package/src/context/protocol-frame.ts +74 -7
- package/src/context/swap-planner.ts +19 -14
- package/src/events/observation-text-log.ts +1 -1
- package/src/events/stdout-event-printer.ts +6 -0
- package/src/image/image-asset-store.ts +32 -3
- package/src/memory/contracts.ts +63 -3
- package/src/memory/memory-coordinator.ts +319 -49
- package/src/memory/memory-extractor.ts +48 -48
- package/src/memory/memory-get-tool.ts +86 -0
- package/src/memory/memory-search-tool.ts +122 -33
- package/src/memory/memory-store.ts +227 -20
- package/src/model/fake-model-client.ts +129 -76
- package/src/model/model-client.ts +62 -11
- package/src/model/openai-chat-mapping.ts +2 -1
- package/src/model/openai-chat-model-client.ts +22 -10
- package/src/model/openai-model-utils.ts +61 -30
- package/src/model/openai-responses-mapping.ts +25 -1
- package/src/model/openai-responses-model-client.ts +27 -11
- package/src/model/token-estimator.ts +10 -0
- package/src/observation/observation-builder.ts +100 -25
- package/src/session/session-history-reader.ts +128 -5
- package/src/session/session-schema.ts +59 -9
- package/src/session/session-store.ts +343 -196
- package/src/tools/registry.ts +18 -0
- package/src/tools/types.ts +46 -0
- package/src/tools/view-image.ts +89 -0
- package/src/tools/wait.ts +85 -0
- package/src/tui/components/memory-browser.tsx +3 -0
- package/src/tui/components/prompt-input.tsx +48 -25
- package/src/tui/event-store.ts +61 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { appendFile } from "node:fs/promises";
|
|
3
3
|
import type { AgentMessage, AssistantMessage } from "../agent/types";
|
|
4
|
+
import { toolResultDisplayText } from "../agent/tool-result-content";
|
|
4
5
|
import { cancellationError } from "../agent/turn-cancellation";
|
|
5
6
|
import {
|
|
6
7
|
IMAGE_INPUT_POLICY,
|
|
@@ -19,15 +20,17 @@ import type {
|
|
|
19
20
|
ModelRequestInput,
|
|
20
21
|
ModelRequestOptions,
|
|
21
22
|
ModelRequestOutput,
|
|
22
|
-
|
|
23
|
+
PreparedMediaOccurrence,
|
|
23
24
|
PreparedModelRequest,
|
|
24
25
|
PreparedPromptSegment,
|
|
25
26
|
} from "./model-client";
|
|
27
|
+
import { validateModelModalities } from "./model-client";
|
|
26
28
|
import { sha256, stableJsonStringify } from "./model-request-preflight";
|
|
27
29
|
import { estimatePromptSegments } from "./token-estimator";
|
|
28
30
|
|
|
29
31
|
export class FakeModelClient implements ModelClient {
|
|
30
32
|
readonly inputModalities: readonly ("text" | "image")[];
|
|
33
|
+
readonly toolResultModalities: readonly ("text" | "image")[];
|
|
31
34
|
readonly reasoningEffort?: ReasoningEffortController;
|
|
32
35
|
readonly messageProtocol: ModelMessageProtocol = Object.freeze({
|
|
33
36
|
adapter: "fake",
|
|
@@ -43,17 +46,20 @@ export class FakeModelClient implements ModelClient {
|
|
|
43
46
|
model: string;
|
|
44
47
|
contextBudget: ModelContextBudget;
|
|
45
48
|
inputModalities?: readonly ("text" | "image")[];
|
|
49
|
+
toolResultModalities?: readonly ("text" | "image")[];
|
|
46
50
|
reasoningEffort?: ReasoningEffortController;
|
|
47
51
|
requestLogPath?: string;
|
|
48
52
|
},
|
|
49
53
|
) {
|
|
50
54
|
this.reasoningEffort = options.reasoningEffort;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
55
|
+
const modalities = validateModelModalities({
|
|
56
|
+
adapter: this.messageProtocol.adapter,
|
|
57
|
+
inputModalities: options.inputModalities ?? ["text"],
|
|
58
|
+
toolResultModalities: options.toolResultModalities ?? ["text"],
|
|
59
|
+
adapterToolResultModalities: ["text", "image"],
|
|
60
|
+
});
|
|
61
|
+
this.inputModalities = modalities.inputModalities;
|
|
62
|
+
this.toolResultModalities = modalities.toolResultModalities;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
prepare(input: ModelRequestInput): PreparedModelRequest {
|
|
@@ -63,7 +69,9 @@ export class FakeModelClient implements ModelClient {
|
|
|
63
69
|
normalizedText: stableJsonStringify(tool),
|
|
64
70
|
}),
|
|
65
71
|
);
|
|
66
|
-
const messageSegments = input.messages.map(
|
|
72
|
+
const messageSegments = input.messages.map((message, index) =>
|
|
73
|
+
toPromptSegment(message, index + 1),
|
|
74
|
+
);
|
|
67
75
|
const mediaOccurrenceCount = messageSegments.reduce(
|
|
68
76
|
(total, segment) => total + (segment.media?.length ?? 0),
|
|
69
77
|
0,
|
|
@@ -77,6 +85,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
77
85
|
model: this.options.model,
|
|
78
86
|
requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
|
|
79
87
|
inputModalities: this.inputModalities,
|
|
88
|
+
toolResultModalities: this.toolResultModalities,
|
|
80
89
|
}),
|
|
81
90
|
);
|
|
82
91
|
const prepared: PreparedModelRequest = Object.freeze({
|
|
@@ -348,7 +357,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
348
357
|
description: "Exercise static history live tail",
|
|
349
358
|
});
|
|
350
359
|
}
|
|
351
|
-
if (!bash.
|
|
360
|
+
if (!toolMessageText(bash).includes("PTY_STATIC_LIVE_LINE_20")) {
|
|
352
361
|
throw new Error("PTY static-history Bash output was incomplete.");
|
|
353
362
|
}
|
|
354
363
|
return textOutput(prepared, "PTY_STATIC_LIVE_DONE");
|
|
@@ -492,9 +501,9 @@ export class FakeModelClient implements ModelClient {
|
|
|
492
501
|
});
|
|
493
502
|
}
|
|
494
503
|
if (
|
|
495
|
-
!bash.
|
|
496
|
-
!bash.
|
|
497
|
-
!bash.
|
|
504
|
+
!toolMessageText(bash).includes("Bash failed") ||
|
|
505
|
+
!toolMessageText(bash).includes("exitCode=7") ||
|
|
506
|
+
!toolMessageText(bash).includes("PTY_TOOL_FAILURE_OUTPUT")
|
|
498
507
|
) {
|
|
499
508
|
throw new Error("PTY Bash failure branch returned an unexpected result.");
|
|
500
509
|
}
|
|
@@ -524,7 +533,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
524
533
|
content: "alpha\n",
|
|
525
534
|
});
|
|
526
535
|
}
|
|
527
|
-
if (!write.
|
|
536
|
+
if (!toolMessageText(write).includes("Write succeeded")) {
|
|
528
537
|
throw new Error("PTY Write tool did not succeed.");
|
|
529
538
|
}
|
|
530
539
|
|
|
@@ -536,7 +545,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
536
545
|
new_string: "beta",
|
|
537
546
|
});
|
|
538
547
|
}
|
|
539
|
-
if (!edit.
|
|
548
|
+
if (!toolMessageText(edit).includes("Edit succeeded")) {
|
|
540
549
|
throw new Error("PTY Edit tool did not succeed.");
|
|
541
550
|
}
|
|
542
551
|
|
|
@@ -548,8 +557,8 @@ export class FakeModelClient implements ModelClient {
|
|
|
548
557
|
});
|
|
549
558
|
}
|
|
550
559
|
if (
|
|
551
|
-
!bash.
|
|
552
|
-
!bash.
|
|
560
|
+
!toolMessageText(bash).includes("Bash completed") ||
|
|
561
|
+
!toolMessageText(bash).includes("PTY_BASH_OK:beta")
|
|
553
562
|
) {
|
|
554
563
|
throw new Error("PTY Bash tool did not verify the edited file.");
|
|
555
564
|
}
|
|
@@ -574,7 +583,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
574
583
|
file_path: "pty-undo-modified.txt",
|
|
575
584
|
});
|
|
576
585
|
}
|
|
577
|
-
if (!read.
|
|
586
|
+
if (!toolMessageText(read).includes("Read succeeded")) {
|
|
578
587
|
throw new Error("PTY undo Read tool did not succeed.");
|
|
579
588
|
}
|
|
580
589
|
|
|
@@ -585,7 +594,10 @@ export class FakeModelClient implements ModelClient {
|
|
|
585
594
|
content: "after undo turn\n",
|
|
586
595
|
});
|
|
587
596
|
}
|
|
588
|
-
if (
|
|
597
|
+
if (
|
|
598
|
+
writes[0] === undefined ||
|
|
599
|
+
!toolMessageText(writes[0]).includes("Write succeeded")
|
|
600
|
+
) {
|
|
589
601
|
throw new Error("PTY undo modifying Write did not succeed.");
|
|
590
602
|
}
|
|
591
603
|
if (writes.length === 1) {
|
|
@@ -594,7 +606,10 @@ export class FakeModelClient implements ModelClient {
|
|
|
594
606
|
content: "created by undo turn\n",
|
|
595
607
|
});
|
|
596
608
|
}
|
|
597
|
-
if (
|
|
609
|
+
if (
|
|
610
|
+
writes[1] === undefined ||
|
|
611
|
+
!toolMessageText(writes[1]).includes("Write succeeded")
|
|
612
|
+
) {
|
|
598
613
|
throw new Error("PTY undo creating Write did not succeed.");
|
|
599
614
|
}
|
|
600
615
|
|
|
@@ -604,7 +619,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
604
619
|
file_path: "pty-undo-deleted.bin",
|
|
605
620
|
});
|
|
606
621
|
}
|
|
607
|
-
if (!deletion.
|
|
622
|
+
if (!toolMessageText(deletion).includes("Delete succeeded")) {
|
|
608
623
|
throw new Error("PTY undo Delete tool did not succeed.");
|
|
609
624
|
}
|
|
610
625
|
return textOutput(prepared, "PTY_UNDO_MUTATIONS_DONE");
|
|
@@ -633,10 +648,10 @@ export class FakeModelClient implements ModelClient {
|
|
|
633
648
|
run_in_background: true,
|
|
634
649
|
});
|
|
635
650
|
}
|
|
636
|
-
if (!bash.
|
|
651
|
+
if (!toolMessageText(bash).includes("Bash command is running in background")) {
|
|
637
652
|
throw new Error("PTY Bash task did not enter the background.");
|
|
638
653
|
}
|
|
639
|
-
const taskId = requireObservationValue(bash
|
|
654
|
+
const taskId = requireObservationValue(toolMessageText(bash), "taskId");
|
|
640
655
|
|
|
641
656
|
if (prompt === "PTY_BACKGROUND_QUIT") {
|
|
642
657
|
return textOutput(prepared, "PTY_BACKGROUND_RUNNING");
|
|
@@ -644,7 +659,10 @@ export class FakeModelClient implements ModelClient {
|
|
|
644
659
|
|
|
645
660
|
const outputs = tools.filter((message) => message.name === "TaskOutput");
|
|
646
661
|
const output = outputs.at(-1);
|
|
647
|
-
if (
|
|
662
|
+
if (
|
|
663
|
+
output === undefined ||
|
|
664
|
+
!toolMessageText(output).includes("PTY_BACKGROUND_READY")
|
|
665
|
+
) {
|
|
648
666
|
if (outputs.length >= 20) {
|
|
649
667
|
throw new Error("PTY background task did not produce its ready marker.");
|
|
650
668
|
}
|
|
@@ -652,7 +670,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
652
670
|
task_id: taskId,
|
|
653
671
|
});
|
|
654
672
|
}
|
|
655
|
-
if (!output.
|
|
673
|
+
if (!toolMessageText(output).includes(`taskId=${taskId}`)) {
|
|
656
674
|
throw new Error("PTY TaskOutput returned the wrong task.");
|
|
657
675
|
}
|
|
658
676
|
|
|
@@ -663,8 +681,8 @@ export class FakeModelClient implements ModelClient {
|
|
|
663
681
|
});
|
|
664
682
|
}
|
|
665
683
|
if (
|
|
666
|
-
!stop.
|
|
667
|
-
!stop.
|
|
684
|
+
!toolMessageText(stop).includes(`taskId=${taskId}`) ||
|
|
685
|
+
!toolMessageText(stop).includes("status=killed")
|
|
668
686
|
) {
|
|
669
687
|
throw new Error("PTY TaskStop did not kill the background task.");
|
|
670
688
|
}
|
|
@@ -698,14 +716,17 @@ export class FakeModelClient implements ModelClient {
|
|
|
698
716
|
timeout: 25,
|
|
699
717
|
});
|
|
700
718
|
}
|
|
701
|
-
if (
|
|
719
|
+
if (
|
|
720
|
+
!toolMessageText(bash).includes("taskId=") ||
|
|
721
|
+
!toolMessageText(bash).includes("tty=true")
|
|
722
|
+
) {
|
|
702
723
|
throw new Error("PTY Bash task did not return an interactive task ID.");
|
|
703
724
|
}
|
|
704
|
-
const taskId = requireObservationValue(bash
|
|
725
|
+
const taskId = requireObservationValue(toolMessageText(bash), "taskId");
|
|
705
726
|
|
|
706
727
|
const outputs = tools.filter((message) => message.name === "TaskOutput");
|
|
707
728
|
const output = outputs.at(-1);
|
|
708
|
-
if (output === undefined || !output.
|
|
729
|
+
if (output === undefined || !toolMessageText(output).includes(">>>")) {
|
|
709
730
|
if (outputs.length >= 20) {
|
|
710
731
|
throw new Error("Interactive Python fixture did not show its prompt.");
|
|
711
732
|
}
|
|
@@ -728,7 +749,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
728
749
|
|
|
729
750
|
const latestInput = inputs.at(-1);
|
|
730
751
|
const expected = prompt === "PTY_INTERACTIVE_QUIT" ? "PTY_INTERACTIVE_PID=" : "42";
|
|
731
|
-
if (!latestInput
|
|
752
|
+
if (latestInput === undefined || !toolMessageText(latestInput).includes(expected)) {
|
|
732
753
|
if (inputs.length >= 20) {
|
|
733
754
|
throw new Error(`Interactive Python fixture did not show ${expected}.`);
|
|
734
755
|
}
|
|
@@ -742,7 +763,9 @@ export class FakeModelClient implements ModelClient {
|
|
|
742
763
|
if (prompt === "PTY_INTERACTIVE_QUIT") {
|
|
743
764
|
return textOutput(prepared, "PTY_INTERACTIVE_RUNNING");
|
|
744
765
|
}
|
|
745
|
-
if (
|
|
766
|
+
if (
|
|
767
|
+
!inputs.some((message) => toolMessageText(message).includes("status=completed"))
|
|
768
|
+
) {
|
|
746
769
|
return toolCallOutput(prepared, options, "TaskInput", {
|
|
747
770
|
task_id: taskId,
|
|
748
771
|
chars: "exit()\n",
|
|
@@ -777,7 +800,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
777
800
|
content: "PTY_RESUME_SIDE_EFFECT\n",
|
|
778
801
|
});
|
|
779
802
|
}
|
|
780
|
-
if (!write.
|
|
803
|
+
if (!toolMessageText(write).includes("Write succeeded")) {
|
|
781
804
|
throw new Error("PTY resume seed Write did not succeed.");
|
|
782
805
|
}
|
|
783
806
|
return textOutput(prepared, "PTY_RESUME_SEED_DONE");
|
|
@@ -809,7 +832,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
809
832
|
content: "PTY_INTERRUPT_SIDE_EFFECT\n",
|
|
810
833
|
});
|
|
811
834
|
}
|
|
812
|
-
if (!write.
|
|
835
|
+
if (!toolMessageText(write).includes("Write succeeded")) {
|
|
813
836
|
throw new Error("PTY interrupted Write did not succeed.");
|
|
814
837
|
}
|
|
815
838
|
return waitForCancellation(options.signal);
|
|
@@ -900,7 +923,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
900
923
|
content: "PTY_FORK_SHARED_HISTORY\n",
|
|
901
924
|
});
|
|
902
925
|
}
|
|
903
|
-
if (!write.
|
|
926
|
+
if (!toolMessageText(write).includes("Write succeeded")) {
|
|
904
927
|
throw new Error("PTY fork seed Write did not succeed.");
|
|
905
928
|
}
|
|
906
929
|
return textOutput(prepared, "PTY_FORK_SEED_DONE");
|
|
@@ -977,7 +1000,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
977
1000
|
file_path: "context-heavy.txt",
|
|
978
1001
|
});
|
|
979
1002
|
}
|
|
980
|
-
if (!read.
|
|
1003
|
+
if (!toolMessageText(read).includes("PTY_CONTEXT_ORIGINAL_MARKER")) {
|
|
981
1004
|
throw new Error("PTY context Read did not return the original marker.");
|
|
982
1005
|
}
|
|
983
1006
|
return textOutput(prepared, "PTY_CONTEXT_HEAVY_DONE");
|
|
@@ -1069,7 +1092,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
1069
1092
|
name: "pty-review",
|
|
1070
1093
|
});
|
|
1071
1094
|
}
|
|
1072
|
-
if (!skill.
|
|
1095
|
+
if (!toolMessageText(skill).includes("PTY_SKILL_INSTRUCTIONS")) {
|
|
1073
1096
|
throw new Error("PTY Skill result did not contain the fixture instructions.");
|
|
1074
1097
|
}
|
|
1075
1098
|
return textOutput(prepared, "PTY_SKILL_DONE");
|
|
@@ -1101,7 +1124,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
1101
1124
|
message: "PTY_MCP_PAYLOAD",
|
|
1102
1125
|
});
|
|
1103
1126
|
}
|
|
1104
|
-
if (!echo.
|
|
1127
|
+
if (!toolMessageText(echo).includes("echo: PTY_MCP_PAYLOAD")) {
|
|
1105
1128
|
throw new Error("PTY MCP echo returned unexpected content.");
|
|
1106
1129
|
}
|
|
1107
1130
|
return textOutput(prepared, "PTY_MCP_DONE\n\necho: PTY_MCP_PAYLOAD");
|
|
@@ -1189,10 +1212,9 @@ export class FakeModelClient implements ModelClient {
|
|
|
1189
1212
|
"tool_calls",
|
|
1190
1213
|
);
|
|
1191
1214
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
)?.[1];
|
|
1215
|
+
const recallText = toolMessageText(latestRecallResult);
|
|
1216
|
+
if (recallText.startsWith("Recall searched")) {
|
|
1217
|
+
const source = recallText.match(/^source=(ctx:\/\/message\/[0-9a-f-]+)$/m)?.[1];
|
|
1196
1218
|
if (source === undefined) {
|
|
1197
1219
|
throw new Error("Fake RecallSearch did not return a source.");
|
|
1198
1220
|
}
|
|
@@ -1215,7 +1237,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
1215
1237
|
"tool_calls",
|
|
1216
1238
|
);
|
|
1217
1239
|
}
|
|
1218
|
-
if (!
|
|
1240
|
+
if (!recallText.includes("recall-smoke-marker")) {
|
|
1219
1241
|
throw new Error("Fake RecallGet did not recover the expected marker.");
|
|
1220
1242
|
}
|
|
1221
1243
|
return outputWithUsage(
|
|
@@ -1320,6 +1342,10 @@ function toolMessagesAfterLastUser(
|
|
|
1320
1342
|
);
|
|
1321
1343
|
}
|
|
1322
1344
|
|
|
1345
|
+
function toolMessageText(message: Extract<AgentMessage, { role: "tool" }>): string {
|
|
1346
|
+
return toolResultDisplayText(message.content);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1323
1349
|
function requireMessage(
|
|
1324
1350
|
messages: AgentMessage[],
|
|
1325
1351
|
role: "user" | "assistant",
|
|
@@ -1390,7 +1416,7 @@ function requireToolMessage(
|
|
|
1390
1416
|
(message) =>
|
|
1391
1417
|
message.role === "tool" &&
|
|
1392
1418
|
message.name === name &&
|
|
1393
|
-
message.
|
|
1419
|
+
toolMessageText(message).includes(content),
|
|
1394
1420
|
);
|
|
1395
1421
|
if (!found) {
|
|
1396
1422
|
throw new Error(`Fake PTY context is missing ${name} tool content ${content}.`);
|
|
@@ -1425,29 +1451,61 @@ function lastMessageIndex(
|
|
|
1425
1451
|
return -1;
|
|
1426
1452
|
}
|
|
1427
1453
|
|
|
1428
|
-
function toPromptSegment(
|
|
1454
|
+
function toPromptSegment(
|
|
1455
|
+
message: AgentMessage,
|
|
1456
|
+
messageOrdinal = 0,
|
|
1457
|
+
): PreparedPromptSegment {
|
|
1429
1458
|
if (message.role === "user" && message.attachments !== undefined) {
|
|
1430
|
-
const media = message.attachments.map(
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1459
|
+
const media = message.attachments.map(
|
|
1460
|
+
(attachment, blockPosition): PreparedMediaOccurrence => {
|
|
1461
|
+
const dimensions = providerImageDimensions(attachment.width, attachment.height);
|
|
1462
|
+
return Object.freeze({
|
|
1463
|
+
asset: Object.freeze({
|
|
1464
|
+
assetId: attachment.assetId,
|
|
1465
|
+
mimeType: attachment.mimeType,
|
|
1466
|
+
byteLength: attachment.byteLength,
|
|
1467
|
+
width: attachment.width,
|
|
1468
|
+
height: attachment.height,
|
|
1469
|
+
}),
|
|
1470
|
+
source: "user_attachment",
|
|
1471
|
+
messageOrdinal,
|
|
1472
|
+
blockPosition,
|
|
1473
|
+
width: dimensions.width,
|
|
1474
|
+
height: dimensions.height,
|
|
1475
|
+
planningTokens: imagePlanningTokens(dimensions.width, dimensions.height),
|
|
1476
|
+
});
|
|
1477
|
+
},
|
|
1478
|
+
);
|
|
1445
1479
|
return Object.freeze({
|
|
1446
1480
|
kind: "user",
|
|
1447
1481
|
normalizedText: message.content,
|
|
1448
1482
|
media: Object.freeze(media),
|
|
1449
1483
|
});
|
|
1450
1484
|
}
|
|
1485
|
+
if (message.role === "tool") {
|
|
1486
|
+
const media = message.content.flatMap((block, blockPosition) => {
|
|
1487
|
+
if (block.type !== "image") {
|
|
1488
|
+
return [];
|
|
1489
|
+
}
|
|
1490
|
+
const dimensions = providerImageDimensions(block.asset.width, block.asset.height);
|
|
1491
|
+
return [
|
|
1492
|
+
Object.freeze<PreparedMediaOccurrence>({
|
|
1493
|
+
asset: Object.freeze({ ...block.asset }),
|
|
1494
|
+
source: "tool_result",
|
|
1495
|
+
messageOrdinal,
|
|
1496
|
+
blockPosition,
|
|
1497
|
+
width: dimensions.width,
|
|
1498
|
+
height: dimensions.height,
|
|
1499
|
+
planningTokens: imagePlanningTokens(dimensions.width, dimensions.height),
|
|
1500
|
+
}),
|
|
1501
|
+
];
|
|
1502
|
+
});
|
|
1503
|
+
return Object.freeze({
|
|
1504
|
+
kind: "tool",
|
|
1505
|
+
normalizedText: stableJsonStringify(message),
|
|
1506
|
+
...(media.length === 0 ? {} : { media: Object.freeze(media) }),
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1451
1509
|
return {
|
|
1452
1510
|
kind:
|
|
1453
1511
|
message.role === "system"
|
|
@@ -1465,21 +1523,15 @@ function distinctPreparedAssets(
|
|
|
1465
1523
|
const assets = new Map<ImageAssetId, ImageAssetRef>();
|
|
1466
1524
|
for (const segment of segments) {
|
|
1467
1525
|
for (const media of segment.media ?? []) {
|
|
1468
|
-
const asset =
|
|
1469
|
-
|
|
1470
|
-
mimeType: media.mimeType,
|
|
1471
|
-
byteLength: media.byteLength,
|
|
1472
|
-
width: media.sourceWidth,
|
|
1473
|
-
height: media.sourceHeight,
|
|
1474
|
-
});
|
|
1475
|
-
const existing = assets.get(media.assetId);
|
|
1526
|
+
const asset = media.asset;
|
|
1527
|
+
const existing = assets.get(asset.assetId);
|
|
1476
1528
|
if (
|
|
1477
1529
|
existing !== undefined &&
|
|
1478
1530
|
stableJsonStringify(existing) !== stableJsonStringify(asset)
|
|
1479
1531
|
) {
|
|
1480
|
-
throw new Error(`Conflicting fake image descriptors for ${
|
|
1532
|
+
throw new Error(`Conflicting fake image descriptors for ${asset.assetId}.`);
|
|
1481
1533
|
}
|
|
1482
|
-
assets.set(
|
|
1534
|
+
assets.set(asset.assetId, asset);
|
|
1483
1535
|
}
|
|
1484
1536
|
}
|
|
1485
1537
|
return assets;
|
|
@@ -1503,9 +1555,11 @@ function materializedFakePromptSegments(
|
|
|
1503
1555
|
...segment,
|
|
1504
1556
|
media: Object.freeze(
|
|
1505
1557
|
segment.media.map((media) => {
|
|
1506
|
-
const image = byId.get(media.assetId);
|
|
1558
|
+
const image = byId.get(media.asset.assetId);
|
|
1507
1559
|
if (image === undefined) {
|
|
1508
|
-
throw new Error(
|
|
1560
|
+
throw new Error(
|
|
1561
|
+
`Fake image ${media.asset.assetId} was not materialized.`,
|
|
1562
|
+
);
|
|
1509
1563
|
}
|
|
1510
1564
|
return Object.freeze({
|
|
1511
1565
|
...media,
|
|
@@ -1537,10 +1591,9 @@ function recallMarker(
|
|
|
1537
1591
|
query: marker,
|
|
1538
1592
|
});
|
|
1539
1593
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
)?.[1];
|
|
1594
|
+
const recallText = toolMessageText(latestRecallResult);
|
|
1595
|
+
if (recallText.startsWith("Recall searched")) {
|
|
1596
|
+
const source = recallText.match(/^source=(ctx:\/\/message\/[0-9a-f-]+)$/m)?.[1];
|
|
1544
1597
|
if (source === undefined) {
|
|
1545
1598
|
throw new Error("Fake PTY RecallSearch did not return a source.");
|
|
1546
1599
|
}
|
|
@@ -1548,7 +1601,7 @@ function recallMarker(
|
|
|
1548
1601
|
source,
|
|
1549
1602
|
});
|
|
1550
1603
|
}
|
|
1551
|
-
if (!
|
|
1604
|
+
if (!recallText.includes(marker)) {
|
|
1552
1605
|
throw new Error(`Fake PTY RecallGet did not recover ${marker}.`);
|
|
1553
1606
|
}
|
|
1554
1607
|
return textOutput(prepared, finalText);
|
|
@@ -2,12 +2,13 @@ import type { AgentMessage, AssistantMessage, IterationIdentity } from "../agent
|
|
|
2
2
|
import type { RuntimeSessionContext } from "../agent/runtime-session";
|
|
3
3
|
import type { ToolDefinition } from "../tools/types";
|
|
4
4
|
import type { ImageAssetStore } from "../image/image-asset-store";
|
|
5
|
-
import type {
|
|
5
|
+
import type { ImageAssetRef } from "../image/image-types";
|
|
6
6
|
import type { ReasoningEffortController } from "./reasoning-effort";
|
|
7
7
|
|
|
8
8
|
export interface ModelClient {
|
|
9
9
|
readonly messageProtocol: ModelMessageProtocol;
|
|
10
|
-
readonly inputModalities
|
|
10
|
+
readonly inputModalities: readonly ModelInputModality[];
|
|
11
|
+
readonly toolResultModalities: readonly ToolResultModality[];
|
|
11
12
|
readonly reasoningEffort?: ReasoningEffortController;
|
|
12
13
|
prepare(input: ModelRequestInput): PreparedModelRequest;
|
|
13
14
|
materialize?(
|
|
@@ -20,6 +21,59 @@ export interface ModelClient {
|
|
|
20
21
|
): Promise<ModelRequestOutput>;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
export type ModelInputModality = "text" | "image";
|
|
25
|
+
export type ToolResultModality = "text" | "image";
|
|
26
|
+
|
|
27
|
+
export function validateModelModalities(input: {
|
|
28
|
+
readonly profileName?: string;
|
|
29
|
+
readonly adapter: ModelMessageProtocol["adapter"];
|
|
30
|
+
readonly inputModalities: readonly ModelInputModality[];
|
|
31
|
+
readonly toolResultModalities: readonly ToolResultModality[];
|
|
32
|
+
readonly adapterToolResultModalities: readonly ToolResultModality[];
|
|
33
|
+
}): {
|
|
34
|
+
readonly inputModalities: readonly ModelInputModality[];
|
|
35
|
+
readonly toolResultModalities: readonly ToolResultModality[];
|
|
36
|
+
} {
|
|
37
|
+
const inputModalities = normalizeModalities(input.inputModalities, "model input");
|
|
38
|
+
const toolResultModalities = normalizeModalities(
|
|
39
|
+
input.toolResultModalities,
|
|
40
|
+
"tool result",
|
|
41
|
+
);
|
|
42
|
+
if (toolResultModalities.includes("image") && !inputModalities.includes("image")) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'Image tool results require "image" in the model input modalities.',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const unsupported = toolResultModalities.find(
|
|
48
|
+
(modality) => !input.adapterToolResultModalities.includes(modality),
|
|
49
|
+
);
|
|
50
|
+
if (unsupported !== undefined) {
|
|
51
|
+
const subject =
|
|
52
|
+
input.profileName === undefined
|
|
53
|
+
? "Model configuration"
|
|
54
|
+
: `Profile ${JSON.stringify(input.profileName)}`;
|
|
55
|
+
throw new Error(
|
|
56
|
+
`${subject} declares ${unsupported} tool results, but adapter ${JSON.stringify(input.adapter)} does not support them.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return Object.freeze({ inputModalities, toolResultModalities });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeModalities(
|
|
63
|
+
modalities: readonly (ModelInputModality | ToolResultModality)[],
|
|
64
|
+
label: string,
|
|
65
|
+
): readonly ("text" | "image")[] {
|
|
66
|
+
if (
|
|
67
|
+
modalities.length === 0 ||
|
|
68
|
+
modalities.some((modality) => modality !== "text" && modality !== "image") ||
|
|
69
|
+
new Set(modalities).size !== modalities.length ||
|
|
70
|
+
!modalities.includes("text")
|
|
71
|
+
) {
|
|
72
|
+
throw new Error(`${label} modalities must be unique and include "text".`);
|
|
73
|
+
}
|
|
74
|
+
return Object.freeze(modalities.includes("image") ? ["text", "image"] : ["text"]);
|
|
75
|
+
}
|
|
76
|
+
|
|
23
77
|
export class ModelRequestMediaAggregateError extends Error {
|
|
24
78
|
readonly code = "MODEL_REQUEST_MEDIA_AGGREGATE_LIMIT";
|
|
25
79
|
|
|
@@ -65,17 +119,14 @@ export type PreparedPromptSegmentKind =
|
|
|
65
119
|
export type PreparedPromptSegment = {
|
|
66
120
|
kind: PreparedPromptSegmentKind;
|
|
67
121
|
normalizedText: string;
|
|
68
|
-
media?: readonly
|
|
122
|
+
media?: readonly PreparedMediaOccurrence[];
|
|
69
123
|
};
|
|
70
124
|
|
|
71
|
-
export type
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
byteLength: number;
|
|
77
|
-
sourceWidth: number;
|
|
78
|
-
sourceHeight: number;
|
|
125
|
+
export type PreparedMediaOccurrence = {
|
|
126
|
+
readonly asset: ImageAssetRef;
|
|
127
|
+
readonly source: "user_attachment" | "tool_result";
|
|
128
|
+
readonly messageOrdinal: number;
|
|
129
|
+
readonly blockPosition: number;
|
|
79
130
|
width: number;
|
|
80
131
|
height: number;
|
|
81
132
|
planningTokens: number;
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
} from "openai/resources/chat/completions";
|
|
22
22
|
import { validateUserMessage, type ImageAssetId } from "../image/image-types";
|
|
23
23
|
import { imageAssetUrlMarker } from "./openai-image-mapping";
|
|
24
|
+
import { toolResultText } from "../agent/tool-result-content";
|
|
24
25
|
|
|
25
26
|
type DeepSeekAssistantMessageParam = ChatCompletionAssistantMessageParam & {
|
|
26
27
|
reasoning_content?: string | null;
|
|
@@ -57,7 +58,7 @@ export function toOpenAIChatMessages(
|
|
|
57
58
|
return {
|
|
58
59
|
role: "tool",
|
|
59
60
|
tool_call_id: message.providerToolCallId,
|
|
60
|
-
content: message.content,
|
|
61
|
+
content: toolResultText(message.content),
|
|
61
62
|
};
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
IMAGE_INPUT_POLICY_VERSION,
|
|
10
10
|
} from "../image/image-input-policy";
|
|
11
11
|
import type { ModelContextBudget } from "./model-context-profile";
|
|
12
|
-
import { ProviderResponseError } from "./model-client";
|
|
12
|
+
import { ProviderResponseError, validateModelModalities } from "./model-client";
|
|
13
13
|
import type {
|
|
14
14
|
MaterializedModelRequest,
|
|
15
15
|
ModelClient,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { OpenAIChatCompletionStreamAccumulator } from "./openai-chat-stream";
|
|
30
30
|
import {
|
|
31
31
|
deepFreeze,
|
|
32
|
+
imageToolSegment,
|
|
32
33
|
imageUserSegment,
|
|
33
34
|
materializeOpenAIRequest,
|
|
34
35
|
normalizedEndpointPolicy,
|
|
@@ -53,6 +54,7 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
53
54
|
private readonly provider: string;
|
|
54
55
|
private readonly stream: boolean;
|
|
55
56
|
readonly inputModalities: readonly ("text" | "image")[];
|
|
57
|
+
readonly toolResultModalities: readonly ("text" | "image")[];
|
|
56
58
|
|
|
57
59
|
constructor(
|
|
58
60
|
private readonly options: {
|
|
@@ -61,6 +63,8 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
61
63
|
baseURL?: string;
|
|
62
64
|
includeReasoningContent?: boolean;
|
|
63
65
|
inputModalities?: readonly ("text" | "image")[];
|
|
66
|
+
toolResultModalities?: readonly ("text" | "image")[];
|
|
67
|
+
profileName?: string;
|
|
64
68
|
model: string;
|
|
65
69
|
providerName?: string;
|
|
66
70
|
reasoningEffort?: ReasoningEffortController;
|
|
@@ -72,10 +76,15 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
72
76
|
this.provider = options.providerName ?? "openai-compatible";
|
|
73
77
|
this.stream = options.stream ?? true;
|
|
74
78
|
this.reasoningEffort = options.reasoningEffort;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
const modalities = validateModelModalities({
|
|
80
|
+
profileName: options.profileName,
|
|
81
|
+
adapter: this.messageProtocol.adapter,
|
|
82
|
+
inputModalities: options.inputModalities ?? ["text"],
|
|
83
|
+
toolResultModalities: options.toolResultModalities ?? ["text"],
|
|
84
|
+
adapterToolResultModalities: ["text"],
|
|
85
|
+
});
|
|
86
|
+
this.inputModalities = modalities.inputModalities;
|
|
87
|
+
this.toolResultModalities = modalities.toolResultModalities;
|
|
79
88
|
this.client = new OpenAI({
|
|
80
89
|
apiKey: options.apiKey,
|
|
81
90
|
baseURL: options.baseURL,
|
|
@@ -115,11 +124,13 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
115
124
|
const messageSegments = input.messages.map(
|
|
116
125
|
(message, index): PreparedPromptSegment =>
|
|
117
126
|
message.role === "user" && message.attachments !== undefined
|
|
118
|
-
? imageUserSegment(message)
|
|
119
|
-
:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
? imageUserSegment(message, index + 1)
|
|
128
|
+
: message.role === "tool"
|
|
129
|
+
? imageToolSegment(message, index + 1, stableJsonStringify(messages[index]))
|
|
130
|
+
: {
|
|
131
|
+
kind: segmentKind(message.role),
|
|
132
|
+
normalizedText: stableJsonStringify(messages[index]),
|
|
133
|
+
},
|
|
123
134
|
);
|
|
124
135
|
const mediaOccurrenceCount = messageSegments.reduce(
|
|
125
136
|
(total, segment) => total + (segment.media?.length ?? 0),
|
|
@@ -135,6 +146,7 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
135
146
|
includeReasoningContent: this.options.includeReasoningContent === true,
|
|
136
147
|
stream: this.stream,
|
|
137
148
|
inputModalities: this.inputModalities,
|
|
149
|
+
toolResultModalities: this.toolResultModalities,
|
|
138
150
|
requestPolicy: { toolChoice: "auto" },
|
|
139
151
|
imagePolicy: {
|
|
140
152
|
version: IMAGE_INPUT_POLICY_VERSION,
|