tinker-agent 1.4.0 → 1.5.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 +23 -1
- package/README.md +54 -0
- package/package.json +5 -1
- package/src/agent/runtime-session.ts +79 -0
- package/src/cli/config.ts +29 -0
- package/src/cli/model-profiles.ts +84 -1
- package/src/cli/public-config-contract.ts +82 -0
- package/src/cli/runner-dependencies.ts +8 -0
- package/src/cli/tui-memory.ts +67 -0
- package/src/cli/tui-runner.tsx +27 -0
- package/src/context/context-policy.ts +2 -2
- package/src/events/stdout-event-printer.ts +1 -0
- package/src/memory/contracts.ts +148 -0
- package/src/memory/embedding-client.ts +105 -0
- package/src/memory/memory-coordinator.ts +556 -0
- package/src/memory/memory-extractor.ts +231 -0
- package/src/memory/memory-log.ts +88 -0
- package/src/memory/memory-search-tool.ts +100 -0
- package/src/memory/memory-store.ts +687 -0
- package/src/memory/vector.ts +153 -0
- package/src/model/fake-model-client.ts +971 -3
- package/src/observation/observation-builder.ts +20 -0
- package/src/session/session-store.ts +123 -0
- package/src/tools/registry.ts +4 -0
- package/src/tools/types.ts +16 -0
- package/src/tui/app.tsx +69 -2
- package/src/tui/clipboard.ts +22 -0
- package/src/tui/components/footer.tsx +9 -4
- package/src/tui/components/memory-browser.tsx +151 -0
- package/src/tui/event-store.ts +9 -2
- package/src/tui/slash-commands.ts +12 -0
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
1
3
|
import type { AgentMessage, AssistantMessage } from "../agent/types";
|
|
2
4
|
import { cancellationError } from "../agent/turn-cancellation";
|
|
5
|
+
import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
|
|
6
|
+
import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
|
|
7
|
+
import type { InputTokenEstimator } from "./input-token-estimator";
|
|
3
8
|
import type { ModelContextBudget } from "./model-context-profile";
|
|
4
9
|
import type {
|
|
10
|
+
MaterializedModelRequest,
|
|
5
11
|
ModelClient,
|
|
12
|
+
ModelMaterializeOptions,
|
|
6
13
|
ModelMessageProtocol,
|
|
7
14
|
ModelRequestInput,
|
|
8
15
|
ModelRequestOptions,
|
|
9
16
|
ModelRequestOutput,
|
|
17
|
+
PreparedMediaDescriptor,
|
|
10
18
|
PreparedModelRequest,
|
|
11
19
|
PreparedPromptSegment,
|
|
12
20
|
} from "./model-client";
|
|
@@ -14,21 +22,71 @@ import { sha256, stableJsonStringify } from "./model-request-preflight";
|
|
|
14
22
|
import { estimatePromptSegments } from "./token-estimator";
|
|
15
23
|
|
|
16
24
|
export class FakeModelClient implements ModelClient {
|
|
17
|
-
readonly inputModalities
|
|
25
|
+
readonly inputModalities: readonly ("text" | "image")[];
|
|
26
|
+
readonly inputTokenEstimator?: InputTokenEstimator;
|
|
18
27
|
readonly messageProtocol: ModelMessageProtocol = Object.freeze({
|
|
19
28
|
adapter: "fake",
|
|
20
29
|
serializationVersion: "fake-v1",
|
|
21
30
|
});
|
|
22
31
|
private steps = 0;
|
|
23
32
|
private readonly preparedInputs = new WeakMap<object, ModelRequestInput>();
|
|
33
|
+
private readonly materializedRequests = new WeakSet<object>();
|
|
24
34
|
|
|
25
35
|
constructor(
|
|
26
36
|
private readonly mode: string,
|
|
27
37
|
private readonly options: {
|
|
28
38
|
model: string;
|
|
29
39
|
contextBudget: ModelContextBudget;
|
|
40
|
+
inputModalities?: readonly ("text" | "image")[];
|
|
41
|
+
requestLogPath?: string;
|
|
42
|
+
tokenEstimator?: {
|
|
43
|
+
kind: "moonshot-estimate-token-count-v1";
|
|
44
|
+
model: string;
|
|
45
|
+
apiBase: string;
|
|
46
|
+
timeoutMs: number;
|
|
47
|
+
maxRetries: 0;
|
|
48
|
+
};
|
|
30
49
|
},
|
|
31
|
-
) {
|
|
50
|
+
) {
|
|
51
|
+
this.inputModalities = Object.freeze([
|
|
52
|
+
...(options.inputModalities ?? (["text"] as const)),
|
|
53
|
+
]);
|
|
54
|
+
if (!this.inputModalities.includes("text")) {
|
|
55
|
+
throw new Error('Fake model input modalities must include "text".');
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
this.inputModalities.includes("image") &&
|
|
59
|
+
options.tokenEstimator === undefined
|
|
60
|
+
) {
|
|
61
|
+
throw new Error("Image-capable fake model requires a token estimator.");
|
|
62
|
+
}
|
|
63
|
+
if (options.tokenEstimator !== undefined) {
|
|
64
|
+
const estimator = options.tokenEstimator;
|
|
65
|
+
const endpoint = tokenEstimatorEndpoint(estimator.apiBase);
|
|
66
|
+
this.inputTokenEstimator = Object.freeze({
|
|
67
|
+
kind: estimator.kind,
|
|
68
|
+
compatibility: Object.freeze({
|
|
69
|
+
kind: estimator.kind,
|
|
70
|
+
coverageVersion: "full-request-v1",
|
|
71
|
+
model: estimator.model,
|
|
72
|
+
endpoint,
|
|
73
|
+
timeoutMs: estimator.timeoutMs,
|
|
74
|
+
maxRetries: estimator.maxRetries,
|
|
75
|
+
}),
|
|
76
|
+
async estimate(
|
|
77
|
+
request: MaterializedModelRequest,
|
|
78
|
+
estimateOptions: { signal: AbortSignal },
|
|
79
|
+
) {
|
|
80
|
+
estimateOptions.signal.throwIfAborted();
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
inputTokens: estimatePromptSegments(request.promptSegments).totalTokens,
|
|
83
|
+
source: "provider_estimated" as const,
|
|
84
|
+
coverage: "full_request" as const,
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
32
90
|
|
|
33
91
|
prepare(input: ModelRequestInput): PreparedModelRequest {
|
|
34
92
|
const toolSegments = input.tools.map(
|
|
@@ -38,6 +96,10 @@ export class FakeModelClient implements ModelClient {
|
|
|
38
96
|
}),
|
|
39
97
|
);
|
|
40
98
|
const messageSegments = input.messages.map(toPromptSegment);
|
|
99
|
+
const mediaOccurrenceCount = messageSegments.reduce(
|
|
100
|
+
(total, segment) => total + (segment.media?.length ?? 0),
|
|
101
|
+
0,
|
|
102
|
+
);
|
|
41
103
|
const requestConfigHash = sha256(
|
|
42
104
|
stableJsonStringify({
|
|
43
105
|
adapter: this.messageProtocol.adapter,
|
|
@@ -45,6 +107,10 @@ export class FakeModelClient implements ModelClient {
|
|
|
45
107
|
mode: this.mode,
|
|
46
108
|
model: this.options.model,
|
|
47
109
|
requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
|
|
110
|
+
inputModalities: this.inputModalities,
|
|
111
|
+
...(this.inputTokenEstimator === undefined
|
|
112
|
+
? {}
|
|
113
|
+
: { tokenEstimator: this.inputTokenEstimator.compatibility }),
|
|
48
114
|
}),
|
|
49
115
|
);
|
|
50
116
|
const prepared: PreparedModelRequest = Object.freeze({
|
|
@@ -61,7 +127,7 @@ export class FakeModelClient implements ModelClient {
|
|
|
61
127
|
toolSegments.map((segment) => segment.normalizedText).join("\n"),
|
|
62
128
|
),
|
|
63
129
|
requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
|
|
64
|
-
mediaOccurrenceCount
|
|
130
|
+
mediaOccurrenceCount,
|
|
65
131
|
assistantReplaySegments: (message: AssistantMessage) => [
|
|
66
132
|
toPromptSegment(message),
|
|
67
133
|
],
|
|
@@ -73,6 +139,59 @@ export class FakeModelClient implements ModelClient {
|
|
|
73
139
|
return prepared;
|
|
74
140
|
}
|
|
75
141
|
|
|
142
|
+
async materialize(
|
|
143
|
+
prepared: PreparedModelRequest,
|
|
144
|
+
options: ModelMaterializeOptions,
|
|
145
|
+
): Promise<MaterializedModelRequest> {
|
|
146
|
+
const input = this.preparedInputs.get(prepared);
|
|
147
|
+
if (input === undefined) {
|
|
148
|
+
throw new Error("Fake model request was not prepared by this client.");
|
|
149
|
+
}
|
|
150
|
+
options.signal.throwIfAborted();
|
|
151
|
+
if (prepared.mediaOccurrenceCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Fake model request has ${prepared.mediaOccurrenceCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (prepared.mediaOccurrenceCount > 0 && !this.inputModalities.includes("image")) {
|
|
157
|
+
throw new Error("Current fake model profile does not support image input.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const assets = distinctPreparedAssets(prepared.promptSegments);
|
|
161
|
+
const materializedAssets: Array<{
|
|
162
|
+
readonly assetId: ImageAssetId;
|
|
163
|
+
readonly byteLength: number;
|
|
164
|
+
readonly bytesSha256: string;
|
|
165
|
+
}> = [];
|
|
166
|
+
for (const asset of assets.values()) {
|
|
167
|
+
options.signal.throwIfAborted();
|
|
168
|
+
const bytes = await options.assetStore.readVerified(asset, {
|
|
169
|
+
signal: options.signal,
|
|
170
|
+
});
|
|
171
|
+
materializedAssets.push(
|
|
172
|
+
Object.freeze({
|
|
173
|
+
assetId: asset.assetId,
|
|
174
|
+
byteLength: bytes.byteLength,
|
|
175
|
+
bytesSha256: createHash("sha256").update(bytes).digest("hex"),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
options.signal.throwIfAborted();
|
|
180
|
+
|
|
181
|
+
const payload = Object.freeze({
|
|
182
|
+
...(prepared.payload as Record<string, unknown>),
|
|
183
|
+
materializedAssets: Object.freeze(materializedAssets),
|
|
184
|
+
});
|
|
185
|
+
const materialized = Object.freeze({
|
|
186
|
+
...prepared,
|
|
187
|
+
payload,
|
|
188
|
+
bodyBytes: Buffer.byteLength(stableJsonStringify(payload), "utf8"),
|
|
189
|
+
});
|
|
190
|
+
this.preparedInputs.set(materialized, input);
|
|
191
|
+
this.materializedRequests.add(materialized);
|
|
192
|
+
return materialized;
|
|
193
|
+
}
|
|
194
|
+
|
|
76
195
|
async request(
|
|
77
196
|
prepared: PreparedModelRequest,
|
|
78
197
|
options: ModelRequestOptions,
|
|
@@ -82,6 +201,18 @@ export class FakeModelClient implements ModelClient {
|
|
|
82
201
|
throw new Error("Fake model request was not prepared by this client.");
|
|
83
202
|
}
|
|
84
203
|
this.steps += 1;
|
|
204
|
+
if (this.options.requestLogPath !== undefined) {
|
|
205
|
+
await appendFile(
|
|
206
|
+
this.options.requestLogPath,
|
|
207
|
+
`${stableJsonStringify({
|
|
208
|
+
mode: this.mode,
|
|
209
|
+
model: this.options.model,
|
|
210
|
+
prompt: lastUserMessage(input.messages),
|
|
211
|
+
requestNumber: this.steps,
|
|
212
|
+
})}\n`,
|
|
213
|
+
"utf8",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
85
216
|
|
|
86
217
|
if (this.mode === "write-notes") {
|
|
87
218
|
return this.writeNotes(input, prepared, options);
|
|
@@ -92,6 +223,63 @@ export class FakeModelClient implements ModelClient {
|
|
|
92
223
|
if (this.mode === "recall-smoke") {
|
|
93
224
|
return this.recallSmoke(input, prepared, options);
|
|
94
225
|
}
|
|
226
|
+
if (this.mode === "pty-echo-history") {
|
|
227
|
+
return this.ptyEchoHistory(input, prepared);
|
|
228
|
+
}
|
|
229
|
+
if (this.mode === "pty-cancel-then-echo") {
|
|
230
|
+
return this.ptyCancelThenEcho(input, prepared, options);
|
|
231
|
+
}
|
|
232
|
+
if (this.mode === "pty-tool-chain") {
|
|
233
|
+
return this.ptyToolChain(input, prepared, options);
|
|
234
|
+
}
|
|
235
|
+
if (this.mode === "pty-background-task") {
|
|
236
|
+
return this.ptyBackgroundTask(input, prepared, options);
|
|
237
|
+
}
|
|
238
|
+
if (this.mode === "pty-resume") {
|
|
239
|
+
return this.ptyResume(input, prepared, options);
|
|
240
|
+
}
|
|
241
|
+
if (this.mode === "pty-interrupted-tool") {
|
|
242
|
+
return this.ptyInterruptedTool(input, prepared, options);
|
|
243
|
+
}
|
|
244
|
+
if (this.mode === "pty-fail-once") {
|
|
245
|
+
return this.ptyFailOnce(input, prepared);
|
|
246
|
+
}
|
|
247
|
+
if (this.mode === "pty-prompt-input") {
|
|
248
|
+
return this.ptyPromptInput(input, prepared);
|
|
249
|
+
}
|
|
250
|
+
if (this.mode === "pty-file-command") {
|
|
251
|
+
return this.ptyFileCommand(input, prepared);
|
|
252
|
+
}
|
|
253
|
+
if (this.mode === "pty-clear") {
|
|
254
|
+
return this.ptyClear(input, prepared);
|
|
255
|
+
}
|
|
256
|
+
if (this.mode === "pty-fork") {
|
|
257
|
+
return this.ptyFork(input, prepared, options);
|
|
258
|
+
}
|
|
259
|
+
if (this.mode === "pty-model-switch") {
|
|
260
|
+
return this.ptyModelSwitch(input, prepared);
|
|
261
|
+
}
|
|
262
|
+
if (this.mode === "pty-viewer") {
|
|
263
|
+
return this.ptyViewer(input, prepared);
|
|
264
|
+
}
|
|
265
|
+
if (this.mode === "pty-copy") {
|
|
266
|
+
return this.ptyCopy(input, prepared);
|
|
267
|
+
}
|
|
268
|
+
if (this.mode === "pty-context-heavy") {
|
|
269
|
+
return this.ptyContextHeavy(input, prepared, options);
|
|
270
|
+
}
|
|
271
|
+
if (this.mode === "pty-image") {
|
|
272
|
+
return this.ptyImage(input, prepared);
|
|
273
|
+
}
|
|
274
|
+
if (this.mode === "pty-local-panels") {
|
|
275
|
+
return this.ptyLocalPanels(input, prepared);
|
|
276
|
+
}
|
|
277
|
+
if (this.mode === "pty-skill-activate") {
|
|
278
|
+
return this.ptySkillActivate(input, prepared, options);
|
|
279
|
+
}
|
|
280
|
+
if (this.mode === "pty-mcp-call") {
|
|
281
|
+
return this.ptyMcpCall(input, prepared, options);
|
|
282
|
+
}
|
|
95
283
|
|
|
96
284
|
return outputWithUsage(
|
|
97
285
|
prepared,
|
|
@@ -103,6 +291,538 @@ export class FakeModelClient implements ModelClient {
|
|
|
103
291
|
);
|
|
104
292
|
}
|
|
105
293
|
|
|
294
|
+
private ptyEchoHistory(
|
|
295
|
+
input: ModelRequestInput,
|
|
296
|
+
prepared: PreparedModelRequest,
|
|
297
|
+
): ModelRequestOutput {
|
|
298
|
+
const prompt = lastUserMessage(input.messages);
|
|
299
|
+
if (prompt === "PTY_FIRST") {
|
|
300
|
+
return textOutput(prepared, "PTY_TURN_ONE_DONE");
|
|
301
|
+
}
|
|
302
|
+
if (prompt === "PTY_SECOND") {
|
|
303
|
+
requireMessage(input.messages, "user", "PTY_FIRST");
|
|
304
|
+
requireMessage(input.messages, "assistant", "PTY_TURN_ONE_DONE");
|
|
305
|
+
return textOutput(prepared, "PTY_TURN_TWO_DONE");
|
|
306
|
+
}
|
|
307
|
+
throw new Error(`Unexpected pty-echo-history prompt: ${JSON.stringify(prompt)}.`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private ptyCancelThenEcho(
|
|
311
|
+
input: ModelRequestInput,
|
|
312
|
+
prepared: PreparedModelRequest,
|
|
313
|
+
options: ModelRequestOptions,
|
|
314
|
+
): Promise<ModelRequestOutput> | ModelRequestOutput {
|
|
315
|
+
const prompt = lastUserMessage(input.messages);
|
|
316
|
+
if (prompt === "PTY_CANCEL_BLOCK") {
|
|
317
|
+
return waitForCancellation(options.signal);
|
|
318
|
+
}
|
|
319
|
+
if (prompt === "PTY_AFTER_CANCEL") {
|
|
320
|
+
requireMessage(input.messages, "user", "PTY_CANCEL_BLOCK");
|
|
321
|
+
return textOutput(prepared, "PTY_AFTER_CANCEL_DONE");
|
|
322
|
+
}
|
|
323
|
+
throw new Error(
|
|
324
|
+
`Unexpected pty-cancel-then-echo prompt: ${JSON.stringify(prompt)}.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private ptyToolChain(
|
|
329
|
+
input: ModelRequestInput,
|
|
330
|
+
prepared: PreparedModelRequest,
|
|
331
|
+
options: ModelRequestOptions,
|
|
332
|
+
): ModelRequestOutput {
|
|
333
|
+
requireTools(input, ["Write", "Edit", "Bash"]);
|
|
334
|
+
const prompt = lastUserMessage(input.messages);
|
|
335
|
+
if (prompt === "PTY_TOOL_FAILURE") {
|
|
336
|
+
const bash = toolMessagesAfterLastUser(input.messages).find(
|
|
337
|
+
(message) => message.name === "Bash",
|
|
338
|
+
);
|
|
339
|
+
if (bash === undefined) {
|
|
340
|
+
return toolCallOutput(prepared, options, "Bash", {
|
|
341
|
+
command: "printf 'PTY_TOOL_FAILURE_OUTPUT\\n' >&2; exit 7",
|
|
342
|
+
description: "Produce expected PTY failure",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (
|
|
346
|
+
!bash.content.includes("Bash failed") ||
|
|
347
|
+
!bash.content.includes("exitCode=7") ||
|
|
348
|
+
!bash.content.includes("PTY_TOOL_FAILURE_OUTPUT")
|
|
349
|
+
) {
|
|
350
|
+
throw new Error("PTY Bash failure branch returned an unexpected result.");
|
|
351
|
+
}
|
|
352
|
+
return textOutput(prepared, "PTY_TOOL_FAILURE_HANDLED");
|
|
353
|
+
}
|
|
354
|
+
if (prompt === "PTY_AFTER_TOOL_FAILURE") {
|
|
355
|
+
requireToolMessage(input.messages, "Bash", "exitCode=7");
|
|
356
|
+
requireMessage(input.messages, "assistant", "PTY_TOOL_FAILURE_HANDLED");
|
|
357
|
+
return textOutput(prepared, "PTY_AFTER_TOOL_FAILURE_DONE");
|
|
358
|
+
}
|
|
359
|
+
if (prompt === "PTY_TOOL_CHAIN_VERIFY") {
|
|
360
|
+
requireToolMessage(input.messages, "Write", "Write succeeded");
|
|
361
|
+
requireToolMessage(input.messages, "Edit", "Edit succeeded");
|
|
362
|
+
requireToolMessage(input.messages, "Bash", "PTY_BASH_OK:beta");
|
|
363
|
+
requireMessage(input.messages, "assistant", "PTY_TOOL_CHAIN_DONE");
|
|
364
|
+
return textOutput(prepared, "PTY_TOOL_CHAIN_VERIFIED");
|
|
365
|
+
}
|
|
366
|
+
if (prompt !== "PTY_TOOL_CHAIN_START") {
|
|
367
|
+
throw new Error(`Unexpected pty-tool-chain prompt: ${JSON.stringify(prompt)}.`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const tools = toolMessagesAfterLastUser(input.messages);
|
|
371
|
+
const write = tools.find((message) => message.name === "Write");
|
|
372
|
+
if (write === undefined) {
|
|
373
|
+
return toolCallOutput(prepared, options, "Write", {
|
|
374
|
+
file_path: "pty-tool-chain.txt",
|
|
375
|
+
content: "alpha\n",
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (!write.content.includes("Write succeeded")) {
|
|
379
|
+
throw new Error("PTY Write tool did not succeed.");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const edit = tools.find((message) => message.name === "Edit");
|
|
383
|
+
if (edit === undefined) {
|
|
384
|
+
return toolCallOutput(prepared, options, "Edit", {
|
|
385
|
+
file_path: "pty-tool-chain.txt",
|
|
386
|
+
old_string: "alpha",
|
|
387
|
+
new_string: "beta",
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
if (!edit.content.includes("Edit succeeded")) {
|
|
391
|
+
throw new Error("PTY Edit tool did not succeed.");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const bash = tools.find((message) => message.name === "Bash");
|
|
395
|
+
if (bash === undefined) {
|
|
396
|
+
return toolCallOutput(prepared, options, "Bash", {
|
|
397
|
+
command: "printf 'PTY_BASH_OK:%s\\n' \"$(cat pty-tool-chain.txt)\"",
|
|
398
|
+
description: "Verify edited PTY fixture",
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (
|
|
402
|
+
!bash.content.includes("Bash completed") ||
|
|
403
|
+
!bash.content.includes("PTY_BASH_OK:beta")
|
|
404
|
+
) {
|
|
405
|
+
throw new Error("PTY Bash tool did not verify the edited file.");
|
|
406
|
+
}
|
|
407
|
+
return textOutput(prepared, "PTY_TOOL_CHAIN_DONE");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private ptyBackgroundTask(
|
|
411
|
+
input: ModelRequestInput,
|
|
412
|
+
prepared: PreparedModelRequest,
|
|
413
|
+
options: ModelRequestOptions,
|
|
414
|
+
): ModelRequestOutput {
|
|
415
|
+
requireTools(input, ["Bash", "TaskOutput", "TaskStop"]);
|
|
416
|
+
const prompt = lastUserMessage(input.messages);
|
|
417
|
+
if (prompt !== "PTY_BACKGROUND_STOP" && prompt !== "PTY_BACKGROUND_QUIT") {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`Unexpected pty-background-task prompt: ${JSON.stringify(prompt)}.`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const tools = toolMessagesAfterLastUser(input.messages);
|
|
424
|
+
const bash = tools.find((message) => message.name === "Bash");
|
|
425
|
+
if (bash === undefined) {
|
|
426
|
+
return toolCallOutput(prepared, options, "Bash", {
|
|
427
|
+
command:
|
|
428
|
+
"printf '%s\\n' \"$$\" > pty-background.pid; printf 'PTY_BACKGROUND_READY\\n'; while :; do sleep 1; done",
|
|
429
|
+
description: "Run PTY background fixture",
|
|
430
|
+
run_in_background: true,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (!bash.content.includes("Bash command is running in background")) {
|
|
434
|
+
throw new Error("PTY Bash task did not enter the background.");
|
|
435
|
+
}
|
|
436
|
+
const taskId = requireObservationValue(bash.content, "taskId");
|
|
437
|
+
|
|
438
|
+
if (prompt === "PTY_BACKGROUND_QUIT") {
|
|
439
|
+
return textOutput(prepared, "PTY_BACKGROUND_RUNNING");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const outputs = tools.filter((message) => message.name === "TaskOutput");
|
|
443
|
+
const output = outputs.at(-1);
|
|
444
|
+
if (output === undefined || !output.content.includes("PTY_BACKGROUND_READY")) {
|
|
445
|
+
if (outputs.length >= 20) {
|
|
446
|
+
throw new Error("PTY background task did not produce its ready marker.");
|
|
447
|
+
}
|
|
448
|
+
return toolCallOutput(prepared, options, "TaskOutput", {
|
|
449
|
+
task_id: taskId,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (!output.content.includes(`taskId=${taskId}`)) {
|
|
453
|
+
throw new Error("PTY TaskOutput returned the wrong task.");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const stop = tools.find((message) => message.name === "TaskStop");
|
|
457
|
+
if (stop === undefined) {
|
|
458
|
+
return toolCallOutput(prepared, options, "TaskStop", {
|
|
459
|
+
task_id: taskId,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (
|
|
463
|
+
!stop.content.includes(`taskId=${taskId}`) ||
|
|
464
|
+
!stop.content.includes("status=killed")
|
|
465
|
+
) {
|
|
466
|
+
throw new Error("PTY TaskStop did not kill the background task.");
|
|
467
|
+
}
|
|
468
|
+
return textOutput(prepared, "PTY_BACKGROUND_STOPPED");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private ptyResume(
|
|
472
|
+
input: ModelRequestInput,
|
|
473
|
+
prepared: PreparedModelRequest,
|
|
474
|
+
options: ModelRequestOptions,
|
|
475
|
+
): ModelRequestOutput {
|
|
476
|
+
requireTools(input, ["Write"]);
|
|
477
|
+
const prompt = lastUserMessage(input.messages);
|
|
478
|
+
if (prompt === "PTY_RESUME_CONTINUE") {
|
|
479
|
+
requireMessage(input.messages, "user", "PTY_RESUME_SEED");
|
|
480
|
+
requireMessage(input.messages, "assistant", "PTY_RESUME_SEED_DONE");
|
|
481
|
+
requireToolMessage(input.messages, "Write", "Write succeeded");
|
|
482
|
+
return textOutput(prepared, "PTY_RESUME_CONTINUED");
|
|
483
|
+
}
|
|
484
|
+
if (prompt !== "PTY_RESUME_SEED") {
|
|
485
|
+
throw new Error(`Unexpected pty-resume prompt: ${JSON.stringify(prompt)}.`);
|
|
486
|
+
}
|
|
487
|
+
const write = toolMessagesAfterLastUser(input.messages).find(
|
|
488
|
+
(message) => message.name === "Write",
|
|
489
|
+
);
|
|
490
|
+
if (write === undefined) {
|
|
491
|
+
return toolCallOutput(prepared, options, "Write", {
|
|
492
|
+
file_path: "pty-resume.txt",
|
|
493
|
+
content: "PTY_RESUME_SIDE_EFFECT\n",
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (!write.content.includes("Write succeeded")) {
|
|
497
|
+
throw new Error("PTY resume seed Write did not succeed.");
|
|
498
|
+
}
|
|
499
|
+
return textOutput(prepared, "PTY_RESUME_SEED_DONE");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private ptyInterruptedTool(
|
|
503
|
+
input: ModelRequestInput,
|
|
504
|
+
prepared: PreparedModelRequest,
|
|
505
|
+
options: ModelRequestOptions,
|
|
506
|
+
): Promise<ModelRequestOutput> | ModelRequestOutput {
|
|
507
|
+
requireTools(input, ["Write"]);
|
|
508
|
+
const prompt = lastUserMessage(input.messages);
|
|
509
|
+
if (prompt === "PTY_INTERRUPT_RECOVER") {
|
|
510
|
+
requireMessage(input.messages, "user", "PTY_INTERRUPT_START");
|
|
511
|
+
requireToolMessage(input.messages, "Write", "Write succeeded");
|
|
512
|
+
return textOutput(prepared, "PTY_INTERRUPT_RECOVERED");
|
|
513
|
+
}
|
|
514
|
+
if (prompt !== "PTY_INTERRUPT_START") {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`Unexpected pty-interrupted-tool prompt: ${JSON.stringify(prompt)}.`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const write = toolMessagesAfterLastUser(input.messages).find(
|
|
520
|
+
(message) => message.name === "Write",
|
|
521
|
+
);
|
|
522
|
+
if (write === undefined) {
|
|
523
|
+
return toolCallOutput(prepared, options, "Write", {
|
|
524
|
+
file_path: "pty-interrupted.txt",
|
|
525
|
+
content: "PTY_INTERRUPT_SIDE_EFFECT\n",
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
if (!write.content.includes("Write succeeded")) {
|
|
529
|
+
throw new Error("PTY interrupted Write did not succeed.");
|
|
530
|
+
}
|
|
531
|
+
return waitForCancellation(options.signal);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private ptyFailOnce(
|
|
535
|
+
input: ModelRequestInput,
|
|
536
|
+
prepared: PreparedModelRequest,
|
|
537
|
+
): ModelRequestOutput {
|
|
538
|
+
const prompt = lastUserMessage(input.messages);
|
|
539
|
+
if (prompt === "PTY_FAIL_FIRST") {
|
|
540
|
+
throw new Error("PTY_FAKE_PROVIDER_FAILURE");
|
|
541
|
+
}
|
|
542
|
+
if (prompt === "PTY_FAIL_RECOVER") {
|
|
543
|
+
requireMessage(input.messages, "user", "PTY_FAIL_FIRST");
|
|
544
|
+
return textOutput(prepared, "PTY_FAIL_RECOVERED");
|
|
545
|
+
}
|
|
546
|
+
throw new Error(`Unexpected pty-fail-once prompt: ${JSON.stringify(prompt)}.`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private ptyPromptInput(
|
|
550
|
+
input: ModelRequestInput,
|
|
551
|
+
prepared: PreparedModelRequest,
|
|
552
|
+
): ModelRequestOutput {
|
|
553
|
+
const prompt = lastUserMessage(input.messages);
|
|
554
|
+
if (prompt === "first\n>second\n中文<") {
|
|
555
|
+
return textOutput(prepared, "PTY_PROMPT_FIRST_DONE");
|
|
556
|
+
}
|
|
557
|
+
if (prompt === "草稿-恢复") {
|
|
558
|
+
requireExactMessage(input.messages, "user", "first\n>second\n中文<");
|
|
559
|
+
requireExactMessage(input.messages, "assistant", "PTY_PROMPT_FIRST_DONE");
|
|
560
|
+
return textOutput(prepared, "PTY_PROMPT_DRAFT_DONE");
|
|
561
|
+
}
|
|
562
|
+
if (prompt === "草稿-恢复-重提") {
|
|
563
|
+
requireExactMessage(input.messages, "user", "草稿-恢复");
|
|
564
|
+
requireExactMessage(input.messages, "assistant", "PTY_PROMPT_DRAFT_DONE");
|
|
565
|
+
return textOutput(prepared, "PTY_PROMPT_HISTORY_DONE");
|
|
566
|
+
}
|
|
567
|
+
throw new Error(`Unexpected pty-prompt-input prompt: ${JSON.stringify(prompt)}.`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private ptyFileCommand(
|
|
571
|
+
input: ModelRequestInput,
|
|
572
|
+
prepared: PreparedModelRequest,
|
|
573
|
+
): ModelRequestOutput {
|
|
574
|
+
const prompt = lastUserMessage(input.messages);
|
|
575
|
+
if (prompt === "open src/index.ts now") {
|
|
576
|
+
return textOutput(prepared, "PTY_FILE_SELECTION_DONE");
|
|
577
|
+
}
|
|
578
|
+
if (prompt === "Review shallow and deep files.\nReturn exact marker.") {
|
|
579
|
+
requireExactMessage(input.messages, "user", "open src/index.ts now");
|
|
580
|
+
requireExactMessage(input.messages, "assistant", "PTY_FILE_SELECTION_DONE");
|
|
581
|
+
return textOutput(prepared, "PTY_PROJECT_COMMAND_DONE");
|
|
582
|
+
}
|
|
583
|
+
throw new Error(`Unexpected pty-file-command prompt: ${JSON.stringify(prompt)}.`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private ptyClear(
|
|
587
|
+
input: ModelRequestInput,
|
|
588
|
+
prepared: PreparedModelRequest,
|
|
589
|
+
): ModelRequestOutput {
|
|
590
|
+
const prompt = lastUserMessage(input.messages);
|
|
591
|
+
if (prompt === "PTY_CLEAR_SEED") {
|
|
592
|
+
return textOutput(prepared, "PTY_CLEAR_SEED_DONE");
|
|
593
|
+
}
|
|
594
|
+
if (prompt === "PTY_CLEAR_CONTINUE") {
|
|
595
|
+
requireExactMessage(input.messages, "user", "PTY_CLEAR_SEED");
|
|
596
|
+
requireExactMessage(input.messages, "assistant", "PTY_CLEAR_SEED_DONE");
|
|
597
|
+
return textOutput(prepared, "PTY_CLEAR_CONTINUED");
|
|
598
|
+
}
|
|
599
|
+
throw new Error(`Unexpected pty-clear prompt: ${JSON.stringify(prompt)}.`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private ptyFork(
|
|
603
|
+
input: ModelRequestInput,
|
|
604
|
+
prepared: PreparedModelRequest,
|
|
605
|
+
options: ModelRequestOptions,
|
|
606
|
+
): ModelRequestOutput {
|
|
607
|
+
requireTools(input, ["Write"]);
|
|
608
|
+
const prompt = lastUserMessage(input.messages);
|
|
609
|
+
if (prompt === "PTY_FORK_SEED") {
|
|
610
|
+
const write = toolMessagesAfterLastUser(input.messages).find(
|
|
611
|
+
(message) => message.name === "Write",
|
|
612
|
+
);
|
|
613
|
+
if (write === undefined) {
|
|
614
|
+
return toolCallOutput(prepared, options, "Write", {
|
|
615
|
+
file_path: "pty-fork-shared.txt",
|
|
616
|
+
content: "PTY_FORK_SHARED_HISTORY\n",
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
if (!write.content.includes("Write succeeded")) {
|
|
620
|
+
throw new Error("PTY fork seed Write did not succeed.");
|
|
621
|
+
}
|
|
622
|
+
return textOutput(prepared, "PTY_FORK_SEED_DONE");
|
|
623
|
+
}
|
|
624
|
+
if (prompt === "CLONE_ONLY") {
|
|
625
|
+
requireForkSeed(input.messages);
|
|
626
|
+
requireNoMessage(input.messages, "user", "SOURCE_ONLY");
|
|
627
|
+
return textOutput(prepared, "PTY_CLONE_ONLY_DONE");
|
|
628
|
+
}
|
|
629
|
+
if (prompt === "SOURCE_ONLY") {
|
|
630
|
+
requireForkSeed(input.messages);
|
|
631
|
+
requireNoMessage(input.messages, "user", "CLONE_ONLY");
|
|
632
|
+
return textOutput(prepared, "PTY_SOURCE_ONLY_DONE");
|
|
633
|
+
}
|
|
634
|
+
throw new Error(`Unexpected pty-fork prompt: ${JSON.stringify(prompt)}.`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private ptyModelSwitch(
|
|
638
|
+
input: ModelRequestInput,
|
|
639
|
+
prepared: PreparedModelRequest,
|
|
640
|
+
): ModelRequestOutput {
|
|
641
|
+
const prompt = lastUserMessage(input.messages);
|
|
642
|
+
if (prompt !== "PTY_MODEL_ALPHA_TURN") {
|
|
643
|
+
throw new Error(`Unexpected pty-model-switch prompt: ${JSON.stringify(prompt)}.`);
|
|
644
|
+
}
|
|
645
|
+
if (this.options.model !== "alpha-model") {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`PTY model switch dispatched to ${JSON.stringify(this.options.model)}.`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
return textOutput(prepared, "PTY_MODEL_ALPHA_DONE");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private ptyViewer(
|
|
654
|
+
input: ModelRequestInput,
|
|
655
|
+
prepared: PreparedModelRequest,
|
|
656
|
+
): ModelRequestOutput {
|
|
657
|
+
const prompt = lastUserMessage(input.messages);
|
|
658
|
+
if (prompt === "PTY_VIEW_SEED") {
|
|
659
|
+
return textOutput(prepared, "PTY_VIEW_SEED_DONE");
|
|
660
|
+
}
|
|
661
|
+
if (prompt === "PTY_VIEW_CONTINUE") {
|
|
662
|
+
requireExactMessage(input.messages, "user", "PTY_VIEW_SEED");
|
|
663
|
+
requireExactMessage(input.messages, "assistant", "PTY_VIEW_SEED_DONE");
|
|
664
|
+
return textOutput(prepared, "PTY_VIEW_CONTINUED");
|
|
665
|
+
}
|
|
666
|
+
throw new Error(`Unexpected pty-viewer prompt: ${JSON.stringify(prompt)}.`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private ptyCopy(
|
|
670
|
+
input: ModelRequestInput,
|
|
671
|
+
prepared: PreparedModelRequest,
|
|
672
|
+
): ModelRequestOutput {
|
|
673
|
+
const prompt = lastUserMessage(input.messages);
|
|
674
|
+
if (prompt !== "PTY_COPY_MARKDOWN") {
|
|
675
|
+
throw new Error(`Unexpected pty-copy prompt: ${JSON.stringify(prompt)}.`);
|
|
676
|
+
}
|
|
677
|
+
return textOutput(prepared, ptyCopyMarkdownResponse());
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private ptyContextHeavy(
|
|
681
|
+
input: ModelRequestInput,
|
|
682
|
+
prepared: PreparedModelRequest,
|
|
683
|
+
options: ModelRequestOptions,
|
|
684
|
+
): ModelRequestOutput {
|
|
685
|
+
requireTools(input, ["Read", "Recall"]);
|
|
686
|
+
const prompt = lastUserMessage(input.messages);
|
|
687
|
+
if (prompt === "PTY_CONTEXT_HEAVY") {
|
|
688
|
+
const read = toolMessagesAfterLastUser(input.messages).find(
|
|
689
|
+
(message) => message.name === "Read",
|
|
690
|
+
);
|
|
691
|
+
if (read === undefined) {
|
|
692
|
+
return toolCallOutput(prepared, options, "Read", {
|
|
693
|
+
file_path: "context-heavy.txt",
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
if (!read.content.includes("PTY_CONTEXT_ORIGINAL_MARKER")) {
|
|
697
|
+
throw new Error("PTY context Read did not return the original marker.");
|
|
698
|
+
}
|
|
699
|
+
return textOutput(prepared, "PTY_CONTEXT_HEAVY_DONE");
|
|
700
|
+
}
|
|
701
|
+
if (/^PTY_CONTEXT_PAD_[1-9]$/u.test(prompt)) {
|
|
702
|
+
return textOutput(prepared, `${prompt}_DONE`);
|
|
703
|
+
}
|
|
704
|
+
if (prompt === "PTY_CONTEXT_RECALL") {
|
|
705
|
+
return recallMarker(
|
|
706
|
+
input,
|
|
707
|
+
prepared,
|
|
708
|
+
options,
|
|
709
|
+
"PTY_CONTEXT_ORIGINAL_MARKER",
|
|
710
|
+
"PTY_CONTEXT_RECALLED",
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
throw new Error(`Unexpected pty-context-heavy prompt: ${JSON.stringify(prompt)}.`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private ptyImage(
|
|
717
|
+
input: ModelRequestInput,
|
|
718
|
+
prepared: PreparedModelRequest,
|
|
719
|
+
): ModelRequestOutput {
|
|
720
|
+
const prompt = lastUserMessage(input.messages);
|
|
721
|
+
if (prompt !== "[Image #1] describe fixture") {
|
|
722
|
+
throw new Error(`Unexpected pty-image prompt: ${JSON.stringify(prompt)}.`);
|
|
723
|
+
}
|
|
724
|
+
const user = [...input.messages]
|
|
725
|
+
.reverse()
|
|
726
|
+
.find(
|
|
727
|
+
(message): message is Extract<AgentMessage, { role: "user" }> =>
|
|
728
|
+
message.role === "user",
|
|
729
|
+
);
|
|
730
|
+
const attachment = user?.attachments?.[0];
|
|
731
|
+
if (
|
|
732
|
+
user?.attachments?.length !== 1 ||
|
|
733
|
+
attachment === undefined ||
|
|
734
|
+
attachment.label !== "[Image #1]" ||
|
|
735
|
+
attachment.originalName !== "fixture.png"
|
|
736
|
+
) {
|
|
737
|
+
throw new Error("PTY image request has unexpected canonical attachment data.");
|
|
738
|
+
}
|
|
739
|
+
const payload = prepared.payload as {
|
|
740
|
+
readonly materializedAssets?: readonly {
|
|
741
|
+
readonly assetId: ImageAssetId;
|
|
742
|
+
readonly byteLength: number;
|
|
743
|
+
readonly bytesSha256: string;
|
|
744
|
+
}[];
|
|
745
|
+
};
|
|
746
|
+
const materialized = payload.materializedAssets?.[0];
|
|
747
|
+
if (
|
|
748
|
+
!this.materializedRequests.has(prepared) ||
|
|
749
|
+
prepared.mediaOccurrenceCount !== 1 ||
|
|
750
|
+
payload.materializedAssets?.length !== 1 ||
|
|
751
|
+
materialized?.assetId !== attachment.assetId ||
|
|
752
|
+
materialized.byteLength !== attachment.byteLength ||
|
|
753
|
+
!/^[0-9a-f]{64}$/u.test(materialized.bytesSha256)
|
|
754
|
+
) {
|
|
755
|
+
throw new Error("PTY image request was not materialized from the asset store.");
|
|
756
|
+
}
|
|
757
|
+
return textOutput(prepared, "PTY_IMAGE_DONE");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private ptyLocalPanels(
|
|
761
|
+
input: ModelRequestInput,
|
|
762
|
+
prepared: PreparedModelRequest,
|
|
763
|
+
): ModelRequestOutput {
|
|
764
|
+
const prompt = lastUserMessage(input.messages);
|
|
765
|
+
if (prompt !== "PTY_LOCAL_AFTER_PANELS") {
|
|
766
|
+
throw new Error(`Unexpected pty-local-panels prompt: ${JSON.stringify(prompt)}.`);
|
|
767
|
+
}
|
|
768
|
+
requireTools(input, ["Skill", "mcp__fixture__echo"]);
|
|
769
|
+
return textOutput(prepared, "PTY_LOCAL_AFTER_PANELS_DONE");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private ptySkillActivate(
|
|
773
|
+
input: ModelRequestInput,
|
|
774
|
+
prepared: PreparedModelRequest,
|
|
775
|
+
options: ModelRequestOptions,
|
|
776
|
+
): ModelRequestOutput {
|
|
777
|
+
requireTools(input, ["Skill"]);
|
|
778
|
+
const prompt = lastUserMessage(input.messages);
|
|
779
|
+
if (prompt === "PTY_SKILL_START") {
|
|
780
|
+
const skill = toolMessagesAfterLastUser(input.messages).find(
|
|
781
|
+
(message) => message.name === "Skill",
|
|
782
|
+
);
|
|
783
|
+
if (skill === undefined) {
|
|
784
|
+
return toolCallOutput(prepared, options, "Skill", {
|
|
785
|
+
name: "pty-review",
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
if (!skill.content.includes("PTY_SKILL_INSTRUCTIONS")) {
|
|
789
|
+
throw new Error("PTY Skill result did not contain the fixture instructions.");
|
|
790
|
+
}
|
|
791
|
+
return textOutput(prepared, "PTY_SKILL_DONE");
|
|
792
|
+
}
|
|
793
|
+
if (prompt === "PTY_SKILL_AFTER_RESUME") {
|
|
794
|
+
requireExactMessage(input.messages, "user", "PTY_SKILL_START");
|
|
795
|
+
requireExactMessage(input.messages, "assistant", "PTY_SKILL_DONE");
|
|
796
|
+
requireSystemContent(input.messages, "PTY_SKILL_INSTRUCTIONS");
|
|
797
|
+
return textOutput(prepared, "PTY_SKILL_RESUMED");
|
|
798
|
+
}
|
|
799
|
+
throw new Error(`Unexpected pty-skill-activate prompt: ${JSON.stringify(prompt)}.`);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
private ptyMcpCall(
|
|
803
|
+
input: ModelRequestInput,
|
|
804
|
+
prepared: PreparedModelRequest,
|
|
805
|
+
options: ModelRequestOptions,
|
|
806
|
+
): ModelRequestOutput {
|
|
807
|
+
requireTools(input, ["mcp__fixture__echo"]);
|
|
808
|
+
const prompt = lastUserMessage(input.messages);
|
|
809
|
+
if (prompt !== "PTY_MCP_START") {
|
|
810
|
+
throw new Error(`Unexpected pty-mcp-call prompt: ${JSON.stringify(prompt)}.`);
|
|
811
|
+
}
|
|
812
|
+
const echo = toolMessagesAfterLastUser(input.messages).find(
|
|
813
|
+
(message) => message.name === "mcp__fixture__echo",
|
|
814
|
+
);
|
|
815
|
+
if (echo === undefined) {
|
|
816
|
+
return toolCallOutput(prepared, options, "mcp__fixture__echo", {
|
|
817
|
+
message: "PTY_MCP_PAYLOAD",
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
if (!echo.content.includes("echo: PTY_MCP_PAYLOAD")) {
|
|
821
|
+
throw new Error("PTY MCP echo returned unexpected content.");
|
|
822
|
+
}
|
|
823
|
+
return textOutput(prepared, "PTY_MCP_DONE\n\necho: PTY_MCP_PAYLOAD");
|
|
824
|
+
}
|
|
825
|
+
|
|
106
826
|
private writeNotes(
|
|
107
827
|
input: ModelRequestInput,
|
|
108
828
|
prepared: PreparedModelRequest,
|
|
@@ -245,6 +965,47 @@ function outputWithUsage(
|
|
|
245
965
|
};
|
|
246
966
|
}
|
|
247
967
|
|
|
968
|
+
function textOutput(
|
|
969
|
+
prepared: PreparedModelRequest,
|
|
970
|
+
content: string,
|
|
971
|
+
): ModelRequestOutput {
|
|
972
|
+
return outputWithUsage(
|
|
973
|
+
prepared,
|
|
974
|
+
{
|
|
975
|
+
role: "assistant",
|
|
976
|
+
content,
|
|
977
|
+
},
|
|
978
|
+
"stop",
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function toolCallOutput(
|
|
983
|
+
prepared: PreparedModelRequest,
|
|
984
|
+
options: ModelRequestOptions,
|
|
985
|
+
name: string,
|
|
986
|
+
args: Readonly<Record<string, unknown>>,
|
|
987
|
+
): ModelRequestOutput {
|
|
988
|
+
if (options.identity === undefined) {
|
|
989
|
+
throw new Error(`Fake ${name} call requires an iteration identity context.`);
|
|
990
|
+
}
|
|
991
|
+
const identity = options.identity;
|
|
992
|
+
return outputWithUsage(
|
|
993
|
+
prepared,
|
|
994
|
+
{
|
|
995
|
+
role: "assistant",
|
|
996
|
+
toolCalls: [
|
|
997
|
+
{
|
|
998
|
+
...identity.runtimeSession.createToolCall(identity.iteration, 1),
|
|
999
|
+
providerToolCallId: `fake-${name.toLowerCase()}-${identity.iteration.iterationNumber}`,
|
|
1000
|
+
name,
|
|
1001
|
+
args,
|
|
1002
|
+
},
|
|
1003
|
+
],
|
|
1004
|
+
},
|
|
1005
|
+
"tool_calls",
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
248
1009
|
function waitForCancellation(signal: AbortSignal): Promise<ModelRequestOutput> {
|
|
249
1010
|
return new Promise((_resolve, reject) => {
|
|
250
1011
|
const abort = () => reject(cancellationError(signal));
|
|
@@ -263,6 +1024,110 @@ function lastUserMessage(messages: AgentMessage[]): string {
|
|
|
263
1024
|
return users.at(-1)?.content ?? "";
|
|
264
1025
|
}
|
|
265
1026
|
|
|
1027
|
+
function toolMessagesAfterLastUser(
|
|
1028
|
+
messages: AgentMessage[],
|
|
1029
|
+
): Array<Extract<AgentMessage, { role: "tool" }>> {
|
|
1030
|
+
return messages
|
|
1031
|
+
.slice(lastMessageIndex(messages, "user") + 1)
|
|
1032
|
+
.filter(
|
|
1033
|
+
(message): message is Extract<AgentMessage, { role: "tool" }> =>
|
|
1034
|
+
message.role === "tool",
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function requireMessage(
|
|
1039
|
+
messages: AgentMessage[],
|
|
1040
|
+
role: "user" | "assistant",
|
|
1041
|
+
content: string,
|
|
1042
|
+
): void {
|
|
1043
|
+
const found = messages.some(
|
|
1044
|
+
(message) =>
|
|
1045
|
+
message.role === role &&
|
|
1046
|
+
typeof message.content === "string" &&
|
|
1047
|
+
message.content.includes(content),
|
|
1048
|
+
);
|
|
1049
|
+
if (!found) {
|
|
1050
|
+
throw new Error(`Fake PTY context is missing ${role} content ${content}.`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function requireExactMessage(
|
|
1055
|
+
messages: AgentMessage[],
|
|
1056
|
+
role: "user" | "assistant",
|
|
1057
|
+
content: string,
|
|
1058
|
+
): void {
|
|
1059
|
+
const found = messages.some(
|
|
1060
|
+
(message) => message.role === role && message.content === content,
|
|
1061
|
+
);
|
|
1062
|
+
if (!found) {
|
|
1063
|
+
throw new Error(
|
|
1064
|
+
`Fake PTY context is missing exact ${role} content ${JSON.stringify(content)}.`,
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function requireNoMessage(
|
|
1070
|
+
messages: AgentMessage[],
|
|
1071
|
+
role: "user" | "assistant",
|
|
1072
|
+
content: string,
|
|
1073
|
+
): void {
|
|
1074
|
+
const found = messages.some(
|
|
1075
|
+
(message) => message.role === role && message.content === content,
|
|
1076
|
+
);
|
|
1077
|
+
if (found) {
|
|
1078
|
+
throw new Error(
|
|
1079
|
+
`Fake PTY context unexpectedly contains ${role} content ${JSON.stringify(content)}.`,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function requireSystemContent(messages: AgentMessage[], content: string): void {
|
|
1085
|
+
const found = messages.some(
|
|
1086
|
+
(message) => message.role === "system" && message.content.includes(content),
|
|
1087
|
+
);
|
|
1088
|
+
if (!found) {
|
|
1089
|
+
throw new Error(`Fake PTY system surface is missing ${content}.`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function requireForkSeed(messages: AgentMessage[]): void {
|
|
1094
|
+
requireExactMessage(messages, "user", "PTY_FORK_SEED");
|
|
1095
|
+
requireExactMessage(messages, "assistant", "PTY_FORK_SEED_DONE");
|
|
1096
|
+
requireToolMessage(messages, "Write", "Write succeeded");
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function requireToolMessage(
|
|
1100
|
+
messages: AgentMessage[],
|
|
1101
|
+
name: string,
|
|
1102
|
+
content: string,
|
|
1103
|
+
): void {
|
|
1104
|
+
const found = messages.some(
|
|
1105
|
+
(message) =>
|
|
1106
|
+
message.role === "tool" &&
|
|
1107
|
+
message.name === name &&
|
|
1108
|
+
message.content.includes(content),
|
|
1109
|
+
);
|
|
1110
|
+
if (!found) {
|
|
1111
|
+
throw new Error(`Fake PTY context is missing ${name} tool content ${content}.`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function requireTools(input: ModelRequestInput, names: readonly string[]): void {
|
|
1116
|
+
const available = new Set(input.tools.map((tool) => tool.name));
|
|
1117
|
+
const missing = names.filter((name) => !available.has(name));
|
|
1118
|
+
if (missing.length > 0) {
|
|
1119
|
+
throw new Error(`Fake PTY model is missing tools: ${missing.join(", ")}.`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function requireObservationValue(content: string, name: string): string {
|
|
1124
|
+
const value = content.match(new RegExp(`^${name}=(.+)$`, "m"))?.[1]?.trim();
|
|
1125
|
+
if (value === undefined || value === "") {
|
|
1126
|
+
throw new Error(`Fake PTY tool observation is missing ${name}.`);
|
|
1127
|
+
}
|
|
1128
|
+
return value;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
266
1131
|
function lastMessageIndex(
|
|
267
1132
|
messages: AgentMessage[],
|
|
268
1133
|
role: AgentMessage["role"],
|
|
@@ -276,6 +1141,26 @@ function lastMessageIndex(
|
|
|
276
1141
|
}
|
|
277
1142
|
|
|
278
1143
|
function toPromptSegment(message: AgentMessage): PreparedPromptSegment {
|
|
1144
|
+
if (message.role === "user" && message.attachments !== undefined) {
|
|
1145
|
+
const media = message.attachments.map(
|
|
1146
|
+
(attachment): PreparedMediaDescriptor =>
|
|
1147
|
+
Object.freeze({
|
|
1148
|
+
assetId: attachment.assetId,
|
|
1149
|
+
label: attachment.label,
|
|
1150
|
+
range: Object.freeze({ ...attachment.range }),
|
|
1151
|
+
mimeType: attachment.mimeType,
|
|
1152
|
+
byteLength: attachment.byteLength,
|
|
1153
|
+
width: attachment.width,
|
|
1154
|
+
height: attachment.height,
|
|
1155
|
+
planningTokens: IMAGE_INPUT_POLICY.planningTokensPerImage,
|
|
1156
|
+
}),
|
|
1157
|
+
);
|
|
1158
|
+
return Object.freeze({
|
|
1159
|
+
kind: "user",
|
|
1160
|
+
normalizedText: message.content,
|
|
1161
|
+
media: Object.freeze(media),
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
279
1164
|
return {
|
|
280
1165
|
kind:
|
|
281
1166
|
message.role === "system"
|
|
@@ -286,3 +1171,86 @@ function toPromptSegment(message: AgentMessage): PreparedPromptSegment {
|
|
|
286
1171
|
normalizedText: stableJsonStringify(message),
|
|
287
1172
|
};
|
|
288
1173
|
}
|
|
1174
|
+
|
|
1175
|
+
function distinctPreparedAssets(
|
|
1176
|
+
segments: readonly PreparedPromptSegment[],
|
|
1177
|
+
): Map<ImageAssetId, ImageAssetRef> {
|
|
1178
|
+
const assets = new Map<ImageAssetId, ImageAssetRef>();
|
|
1179
|
+
for (const segment of segments) {
|
|
1180
|
+
for (const media of segment.media ?? []) {
|
|
1181
|
+
const asset = Object.freeze({
|
|
1182
|
+
assetId: media.assetId,
|
|
1183
|
+
mimeType: media.mimeType,
|
|
1184
|
+
byteLength: media.byteLength,
|
|
1185
|
+
width: media.width,
|
|
1186
|
+
height: media.height,
|
|
1187
|
+
});
|
|
1188
|
+
const existing = assets.get(media.assetId);
|
|
1189
|
+
if (
|
|
1190
|
+
existing !== undefined &&
|
|
1191
|
+
stableJsonStringify(existing) !== stableJsonStringify(asset)
|
|
1192
|
+
) {
|
|
1193
|
+
throw new Error(`Conflicting fake image descriptors for ${media.assetId}.`);
|
|
1194
|
+
}
|
|
1195
|
+
assets.set(media.assetId, asset);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return assets;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function tokenEstimatorEndpoint(apiBase: string): string {
|
|
1202
|
+
const base = new URL(apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
|
|
1203
|
+
base.username = "";
|
|
1204
|
+
base.password = "";
|
|
1205
|
+
base.search = "";
|
|
1206
|
+
base.hash = "";
|
|
1207
|
+
return new URL("tokenizers/estimate-token-count", base).toString();
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function recallMarker(
|
|
1211
|
+
input: ModelRequestInput,
|
|
1212
|
+
prepared: PreparedModelRequest,
|
|
1213
|
+
options: ModelRequestOptions,
|
|
1214
|
+
marker: string,
|
|
1215
|
+
finalText: string,
|
|
1216
|
+
): ModelRequestOutput {
|
|
1217
|
+
const latestRecallResult = toolMessagesAfterLastUser(input.messages)
|
|
1218
|
+
.filter((message) => message.name === "Recall")
|
|
1219
|
+
.at(-1);
|
|
1220
|
+
if (latestRecallResult === undefined) {
|
|
1221
|
+
return toolCallOutput(prepared, options, "Recall", {
|
|
1222
|
+
mode: "search",
|
|
1223
|
+
query: marker,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
if (latestRecallResult.content.startsWith("Recall searched")) {
|
|
1227
|
+
const source = latestRecallResult.content.match(
|
|
1228
|
+
/^source=(ctx:\/\/message\/[0-9a-f-]+)$/m,
|
|
1229
|
+
)?.[1];
|
|
1230
|
+
if (source === undefined) {
|
|
1231
|
+
throw new Error("Fake PTY Recall search did not return a source.");
|
|
1232
|
+
}
|
|
1233
|
+
return toolCallOutput(prepared, options, "Recall", {
|
|
1234
|
+
mode: "get",
|
|
1235
|
+
source,
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
if (!latestRecallResult.content.includes(marker)) {
|
|
1239
|
+
throw new Error(`Fake PTY Recall get did not recover ${marker}.`);
|
|
1240
|
+
}
|
|
1241
|
+
return textOutput(prepared, finalText);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
export function ptyCopyMarkdownResponse(): string {
|
|
1245
|
+
return [
|
|
1246
|
+
"# PTY canonical Markdown",
|
|
1247
|
+
"",
|
|
1248
|
+
"```ts",
|
|
1249
|
+
'export const marker = "PTY_COPY_CODE";',
|
|
1250
|
+
"```",
|
|
1251
|
+
"",
|
|
1252
|
+
Array.from({ length: 240 }, (_, index) => `long-${index}`).join(" "),
|
|
1253
|
+
"",
|
|
1254
|
+
"PTY_COPY_END",
|
|
1255
|
+
].join("\n");
|
|
1256
|
+
}
|