tinker-agent 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +64 -6
  3. package/package.json +1 -1
  4. package/src/agent/loop.ts +17 -0
  5. package/src/agent/runtime-session.ts +341 -1
  6. package/src/agent/session-ledger.ts +100 -3
  7. package/src/cli/config.ts +11 -2
  8. package/src/cli/model-profiles.ts +58 -0
  9. package/src/cli/public-config-contract.ts +73 -7
  10. package/src/cli/run-runner.ts +4 -1
  11. package/src/cli/runner-dependencies.ts +28 -4
  12. package/src/cli/tui-memory.ts +4 -0
  13. package/src/cli/tui-runner.tsx +8 -1
  14. package/src/context/context-automation-policy.ts +22 -21
  15. package/src/context/context-manager.ts +91 -15
  16. package/src/context/context-policy.ts +0 -2
  17. package/src/context/context-swap-renderer.ts +1 -1
  18. package/src/context/prefix-retirement-planner.ts +58 -8
  19. package/src/context/recall-retirement-contract.ts +5 -4
  20. package/src/context/swap-planner.ts +33 -27
  21. package/src/events/observation-text-log.ts +4 -0
  22. package/src/events/stdout-event-printer.ts +5 -0
  23. package/src/events/types.ts +5 -1
  24. package/src/model/fake-model-client.ts +55 -16
  25. package/src/model/model-api.ts +12 -0
  26. package/src/model/model-client.ts +9 -1
  27. package/src/model/moonshot-input-token-estimator.ts +5 -1
  28. package/src/model/openai-chat-mapping.ts +2 -24
  29. package/src/model/openai-chat-model-client.ts +18 -294
  30. package/src/model/openai-image-mapping.ts +20 -0
  31. package/src/model/openai-model-utils.ts +304 -0
  32. package/src/model/openai-responses-mapping.ts +532 -0
  33. package/src/model/openai-responses-model-client.ts +295 -0
  34. package/src/model/openai-responses-stream.ts +96 -0
  35. package/src/model/openai-responses-token-estimator.ts +155 -0
  36. package/src/model/reasoning-effort.ts +60 -0
  37. package/src/session/session-catalog.ts +2 -2
  38. package/src/session/session-history-reader.ts +6 -1
  39. package/src/session/session-schema.ts +268 -4
  40. package/src/session/session-store.ts +134 -26
  41. package/src/skills/skill-context.ts +2 -2
  42. package/src/tools/bounded-output-preview.ts +276 -0
  43. package/src/tools/recall.ts +67 -36
  44. package/src/tools/registry.ts +7 -2
  45. package/src/tools/task-output-snapshot.ts +6 -22
  46. package/src/tools/task-output.ts +23 -27
  47. package/src/tui/app.tsx +153 -11
  48. package/src/tui/components/footer.tsx +6 -1
  49. package/src/tui/components/prompt-input.tsx +9 -1
  50. package/src/tui/event-store.ts +15 -0
  51. package/src/tui/slash-commands.ts +20 -0
  52. package/src/tui/tui-session-controller.ts +14 -0
@@ -0,0 +1,532 @@
1
+ import type {
2
+ EasyInputMessage,
3
+ FunctionTool,
4
+ ResponseInputItem,
5
+ ResponseInputMessageContentList,
6
+ } from "openai/resources/responses/responses";
7
+ import type {
8
+ AgentMessage,
9
+ IterationIdentity,
10
+ ToolCall,
11
+ UserMessage,
12
+ } from "../agent/types";
13
+ import type { RuntimeSessionContext } from "../agent/runtime-session";
14
+ import {
15
+ parseImageAssetId,
16
+ validateUserMessage,
17
+ type ImageAssetId,
18
+ } from "../image/image-types";
19
+ import type { ToolDefinition } from "../tools/types";
20
+ import {
21
+ ProviderResponseError,
22
+ type ModelRequestOutput,
23
+ type ModelUsage,
24
+ type ProviderResponseDiagnostics,
25
+ } from "./model-client";
26
+ import { imageAssetUrlMarker } from "./openai-image-mapping";
27
+
28
+ export type OpenAIResponsesMappingOptions = {
29
+ materializedImages?: ReadonlyMap<ImageAssetId, string>;
30
+ };
31
+
32
+ export function toOpenAIResponsesInput(
33
+ messages: readonly AgentMessage[],
34
+ options: OpenAIResponsesMappingOptions = {},
35
+ ): ResponseInputItem[] {
36
+ return messages.flatMap((message) => toOpenAIResponsesItems(message, options));
37
+ }
38
+
39
+ export function toOpenAIResponsesItems(
40
+ message: AgentMessage,
41
+ options: OpenAIResponsesMappingOptions = {},
42
+ ): ResponseInputItem[] {
43
+ if (message.role === "assistant") {
44
+ const items: ResponseInputItem[] = [];
45
+ if (message.content !== undefined && message.content !== null) {
46
+ items.push({
47
+ type: "message",
48
+ role: "assistant",
49
+ content: message.content,
50
+ } satisfies EasyInputMessage);
51
+ }
52
+ for (const call of message.toolCalls ?? []) {
53
+ items.push({
54
+ type: "function_call",
55
+ call_id: call.providerToolCallId,
56
+ name: call.name,
57
+ arguments: toolArguments(call),
58
+ });
59
+ }
60
+ return items;
61
+ }
62
+
63
+ if (message.role === "tool") {
64
+ return [
65
+ {
66
+ type: "function_call_output",
67
+ call_id: message.providerToolCallId,
68
+ output: message.content,
69
+ },
70
+ ];
71
+ }
72
+
73
+ if (message.role === "user") {
74
+ return [
75
+ {
76
+ type: "message",
77
+ role: "user",
78
+ content: toOpenAIResponsesUserContent(message, options.materializedImages),
79
+ },
80
+ ];
81
+ }
82
+
83
+ return [{ type: "message", role: "system", content: message.content }];
84
+ }
85
+
86
+ export function toOpenAIResponsesUserContent(
87
+ message: UserMessage,
88
+ materializedImages?: ReadonlyMap<ImageAssetId, string>,
89
+ ): string | ResponseInputMessageContentList {
90
+ validateUserMessage(message);
91
+ const attachments = message.attachments;
92
+ if (attachments === undefined) {
93
+ return message.content;
94
+ }
95
+ return [
96
+ ...attachments.flatMap(
97
+ (attachment): ResponseInputMessageContentList => [
98
+ { type: "input_text", text: `<image name=${attachment.label}>` },
99
+ {
100
+ type: "input_image",
101
+ detail: "auto",
102
+ image_url:
103
+ materializedImages === undefined
104
+ ? (imageAssetUrlMarker(attachment.assetId) as unknown as string)
105
+ : requireMaterializedImage(materializedImages, attachment.assetId),
106
+ },
107
+ { type: "input_text", text: "</image>" },
108
+ ],
109
+ ),
110
+ { type: "input_text", text: message.content },
111
+ ];
112
+ }
113
+
114
+ export function toOpenAIResponsesTools(
115
+ tools: readonly ToolDefinition[],
116
+ ): FunctionTool[] {
117
+ return tools.map((tool) => ({
118
+ type: "function",
119
+ name: tool.name,
120
+ description: tool.description,
121
+ parameters: tool.parameters,
122
+ strict: false,
123
+ }));
124
+ }
125
+
126
+ export function fromOpenAIResponse(
127
+ response: unknown,
128
+ options: {
129
+ identity?: {
130
+ iteration: IterationIdentity;
131
+ runtimeSession: RuntimeSessionContext;
132
+ };
133
+ provider: string;
134
+ model: string;
135
+ },
136
+ ): ModelRequestOutput {
137
+ const root = requireRecord(response, "response", options);
138
+ const status = requireString(root.status, "status", options);
139
+ if (status !== "completed" && status !== "incomplete") {
140
+ throw providerResponseError(
141
+ options,
142
+ "status",
143
+ `must be a terminal success status, received ${JSON.stringify(status)}`,
144
+ );
145
+ }
146
+ if (!Array.isArray(root.output)) {
147
+ throw providerResponseError(options, "output", "must be an array");
148
+ }
149
+
150
+ const contentParts: string[] = [];
151
+ const reasoningParts: string[] = [];
152
+ const rawToolCalls: { raw: unknown; path: string }[] = [];
153
+ for (let outputIndex = 0; outputIndex < root.output.length; outputIndex += 1) {
154
+ const path = `output[${outputIndex}]`;
155
+ const item = requireRecord(root.output[outputIndex], path, options);
156
+ const type = requireString(item.type, `${path}.type`, options);
157
+ if (type === "message") {
158
+ parseOutputMessage(item, path, contentParts, options);
159
+ } else if (type === "function_call") {
160
+ rawToolCalls.push({ raw: item, path });
161
+ } else if (type === "reasoning") {
162
+ parseReasoningItem(item, path, reasoningParts, options);
163
+ }
164
+ }
165
+
166
+ const content = contentParts.length === 0 ? null : contentParts.join("");
167
+ const reasoningContent =
168
+ reasoningParts.length === 0 ? null : reasoningParts.join("\n\n");
169
+ const usage = parseUsage(root.usage, options);
170
+ const incompleteReason =
171
+ status === "incomplete"
172
+ ? optionalIncompleteReason(root.incomplete_details, options)
173
+ : undefined;
174
+ const finishReason =
175
+ rawToolCalls.length > 0 ? "tool_calls" : (incompleteReason ?? "stop");
176
+ const diagnostics = responseDiagnostics(options, {
177
+ path: "output",
178
+ finishReason,
179
+ contentChars: content?.length ?? 0,
180
+ reasoningChars: reasoningContent?.length ?? 0,
181
+ toolCallCount: rawToolCalls.length,
182
+ usage,
183
+ });
184
+
185
+ if ((content === null || content.trim() === "") && rawToolCalls.length === 0) {
186
+ if (reasoningContent !== null && reasoningContent.trim() !== "") {
187
+ throw new ProviderResponseError(
188
+ "reasoning_only_assistant",
189
+ `Invalid provider response (provider=${options.provider}, model=${options.model}): output contains reasoning but neither non-empty final text nor function calls.`,
190
+ diagnostics,
191
+ );
192
+ }
193
+ throw providerResponseError(
194
+ options,
195
+ "output",
196
+ "has neither non-empty text nor function calls",
197
+ diagnostics,
198
+ );
199
+ }
200
+
201
+ if (rawToolCalls.length > 0 && options.identity === undefined) {
202
+ throw providerResponseError(
203
+ options,
204
+ "output",
205
+ "contains function calls but has no iteration identity context",
206
+ diagnostics,
207
+ );
208
+ }
209
+ const toolCalls = rawToolCalls.map(({ raw, path }, index) =>
210
+ parseToolCall(raw, path, index, options.identity!, options),
211
+ );
212
+
213
+ return {
214
+ message: {
215
+ role: "assistant",
216
+ content,
217
+ reasoningContent,
218
+ toolCalls: toolCalls.length === 0 ? undefined : toolCalls,
219
+ },
220
+ finishReason,
221
+ usage,
222
+ rawResponse: response,
223
+ };
224
+ }
225
+
226
+ function parseOutputMessage(
227
+ item: Record<string, unknown>,
228
+ path: string,
229
+ parts: string[],
230
+ options: { provider: string; model: string },
231
+ ): void {
232
+ if (item.role !== "assistant") {
233
+ throw providerResponseError(options, `${path}.role`, 'must be "assistant"');
234
+ }
235
+ if (!Array.isArray(item.content)) {
236
+ throw providerResponseError(options, `${path}.content`, "must be an array");
237
+ }
238
+ for (let index = 0; index < item.content.length; index += 1) {
239
+ const contentPath = `${path}.content[${index}]`;
240
+ const content = requireRecord(item.content[index], contentPath, options);
241
+ const type = requireString(content.type, `${contentPath}.type`, options);
242
+ if (type === "output_text") {
243
+ parts.push(requireString(content.text, `${contentPath}.text`, options));
244
+ } else if (type === "refusal") {
245
+ parts.push(requireString(content.refusal, `${contentPath}.refusal`, options));
246
+ } else {
247
+ throw providerResponseError(
248
+ options,
249
+ `${contentPath}.type`,
250
+ `is unsupported: ${JSON.stringify(type)}`,
251
+ );
252
+ }
253
+ }
254
+ }
255
+
256
+ function parseReasoningItem(
257
+ item: Record<string, unknown>,
258
+ path: string,
259
+ parts: string[],
260
+ options: { provider: string; model: string },
261
+ ): void {
262
+ const contentParts = parseReasoningParts(
263
+ item.content,
264
+ `${path}.content`,
265
+ "reasoning_text",
266
+ options,
267
+ );
268
+ if (contentParts.length > 0) {
269
+ parts.push(...contentParts);
270
+ return;
271
+ }
272
+ parts.push(
273
+ ...parseReasoningParts(item.summary, `${path}.summary`, "summary_text", options),
274
+ );
275
+ }
276
+
277
+ function parseReasoningParts(
278
+ value: unknown,
279
+ path: string,
280
+ expectedType: "reasoning_text" | "summary_text",
281
+ options: { provider: string; model: string },
282
+ ): string[] {
283
+ if (value === undefined || value === null) {
284
+ return [];
285
+ }
286
+ if (!Array.isArray(value)) {
287
+ throw providerResponseError(options, path, "must be an array");
288
+ }
289
+ return value.map((raw, index) => {
290
+ const partPath = `${path}[${index}]`;
291
+ const part = requireRecord(raw, partPath, options);
292
+ if (part.type !== expectedType) {
293
+ throw providerResponseError(
294
+ options,
295
+ `${partPath}.type`,
296
+ `must be ${JSON.stringify(expectedType)}`,
297
+ );
298
+ }
299
+ return requireString(part.text, `${partPath}.text`, options);
300
+ });
301
+ }
302
+
303
+ function parseToolCall(
304
+ raw: unknown,
305
+ path: string,
306
+ index: number,
307
+ context: {
308
+ iteration: IterationIdentity;
309
+ runtimeSession: RuntimeSessionContext;
310
+ },
311
+ options: { provider: string; model: string },
312
+ ): ToolCall {
313
+ const item = requireRecord(raw, path, options);
314
+ const providerToolCallId = requireNonEmptyString(
315
+ item.call_id,
316
+ `${path}.call_id`,
317
+ options,
318
+ );
319
+ const name = requireNonEmptyString(item.name, `${path}.name`, options);
320
+ const rawArgs = requireString(item.arguments, `${path}.arguments`, options);
321
+ let args: unknown = {};
322
+ let argsParseError: string | undefined;
323
+ if (rawArgs.trim() !== "") {
324
+ try {
325
+ args = JSON.parse(rawArgs);
326
+ } catch (error) {
327
+ argsParseError = error instanceof Error ? error.message : String(error);
328
+ }
329
+ }
330
+ return {
331
+ ...context.runtimeSession.createToolCall(context.iteration, index + 1),
332
+ providerToolCallId,
333
+ name,
334
+ args,
335
+ rawArgs,
336
+ argsParseError,
337
+ };
338
+ }
339
+
340
+ function parseUsage(
341
+ value: unknown,
342
+ options: { provider: string; model: string },
343
+ ): ModelUsage {
344
+ const usage = requireRecord(value, "usage", options);
345
+ const promptTokens = requireNonNegativeInteger(
346
+ usage.input_tokens,
347
+ "usage.input_tokens",
348
+ options,
349
+ );
350
+ const completionTokens = requireNonNegativeInteger(
351
+ usage.output_tokens,
352
+ "usage.output_tokens",
353
+ options,
354
+ );
355
+ const totalTokens = requireNonNegativeInteger(
356
+ usage.total_tokens,
357
+ "usage.total_tokens",
358
+ options,
359
+ );
360
+ if (totalTokens !== promptTokens + completionTokens) {
361
+ throw providerResponseError(
362
+ options,
363
+ "usage.total_tokens",
364
+ `must equal usage.input_tokens + usage.output_tokens (${promptTokens + completionTokens})`,
365
+ );
366
+ }
367
+ const inputDetails = optionalRecord(
368
+ usage.input_tokens_details,
369
+ "usage.input_tokens_details",
370
+ options,
371
+ );
372
+ const outputDetails = optionalRecord(
373
+ usage.output_tokens_details,
374
+ "usage.output_tokens_details",
375
+ options,
376
+ );
377
+ const cachedTokens = optionalNonNegativeInteger(
378
+ inputDetails?.cached_tokens,
379
+ "usage.input_tokens_details.cached_tokens",
380
+ options,
381
+ );
382
+ const reasoningTokens = optionalNonNegativeInteger(
383
+ outputDetails?.reasoning_tokens,
384
+ "usage.output_tokens_details.reasoning_tokens",
385
+ options,
386
+ );
387
+ if (cachedTokens !== undefined && cachedTokens > promptTokens) {
388
+ throw providerResponseError(
389
+ options,
390
+ "usage.input_tokens_details.cached_tokens",
391
+ "must not exceed usage.input_tokens",
392
+ );
393
+ }
394
+ if (reasoningTokens !== undefined && reasoningTokens > completionTokens) {
395
+ throw providerResponseError(
396
+ options,
397
+ "usage.output_tokens_details.reasoning_tokens",
398
+ "must not exceed usage.output_tokens",
399
+ );
400
+ }
401
+ return {
402
+ promptTokens,
403
+ completionTokens,
404
+ totalTokens,
405
+ ...(cachedTokens === undefined
406
+ ? {}
407
+ : {
408
+ promptCacheHitTokens: cachedTokens,
409
+ promptCacheMissTokens: promptTokens - cachedTokens,
410
+ }),
411
+ ...(reasoningTokens === undefined ? {} : { reasoningTokens }),
412
+ };
413
+ }
414
+
415
+ function optionalIncompleteReason(
416
+ value: unknown,
417
+ options: { provider: string; model: string },
418
+ ): string | undefined {
419
+ if (value === undefined || value === null) {
420
+ return "incomplete";
421
+ }
422
+ const details = requireRecord(value, "incomplete_details", options);
423
+ return requireNonEmptyString(details.reason, "incomplete_details.reason", options);
424
+ }
425
+
426
+ function toolArguments(call: ToolCall): string {
427
+ return (
428
+ call.rawArgs ??
429
+ (typeof call.args === "string" ? call.args : JSON.stringify(call.args))
430
+ );
431
+ }
432
+
433
+ function requireMaterializedImage(
434
+ materializedImages: ReadonlyMap<ImageAssetId, string>,
435
+ assetId: ImageAssetId,
436
+ ): string {
437
+ parseImageAssetId(assetId);
438
+ const dataUrl = materializedImages.get(assetId);
439
+ if (dataUrl === undefined) {
440
+ throw new Error(`Image asset ${assetId.slice(0, 12)}… was not materialized.`);
441
+ }
442
+ return dataUrl;
443
+ }
444
+
445
+ function requireRecord(
446
+ value: unknown,
447
+ path: string,
448
+ options: { provider: string; model: string },
449
+ ): Record<string, unknown> {
450
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
451
+ throw providerResponseError(options, path, "must be an object");
452
+ }
453
+ return value as Record<string, unknown>;
454
+ }
455
+
456
+ function optionalRecord(
457
+ value: unknown,
458
+ path: string,
459
+ options: { provider: string; model: string },
460
+ ): Record<string, unknown> | undefined {
461
+ if (value === undefined || value === null) {
462
+ return undefined;
463
+ }
464
+ return requireRecord(value, path, options);
465
+ }
466
+
467
+ function requireString(
468
+ value: unknown,
469
+ path: string,
470
+ options: { provider: string; model: string },
471
+ ): string {
472
+ if (typeof value !== "string") {
473
+ throw providerResponseError(options, path, "must be a string");
474
+ }
475
+ return value;
476
+ }
477
+
478
+ function requireNonEmptyString(
479
+ value: unknown,
480
+ path: string,
481
+ options: { provider: string; model: string },
482
+ ): string {
483
+ const string = requireString(value, path, options);
484
+ if (string.trim() === "") {
485
+ throw providerResponseError(options, path, "must be non-empty");
486
+ }
487
+ return string;
488
+ }
489
+
490
+ function requireNonNegativeInteger(
491
+ value: unknown,
492
+ path: string,
493
+ options: { provider: string; model: string },
494
+ ): number {
495
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
496
+ throw providerResponseError(options, path, "must be a non-negative integer");
497
+ }
498
+ return value as number;
499
+ }
500
+
501
+ function optionalNonNegativeInteger(
502
+ value: unknown,
503
+ path: string,
504
+ options: { provider: string; model: string },
505
+ ): number | undefined {
506
+ if (value === undefined || value === null) {
507
+ return undefined;
508
+ }
509
+ return requireNonNegativeInteger(value, path, options);
510
+ }
511
+
512
+ function providerResponseError(
513
+ options: { provider: string; model: string },
514
+ path: string,
515
+ detail: string,
516
+ diagnostics: ProviderResponseDiagnostics = responseDiagnostics(options, {
517
+ path,
518
+ }),
519
+ ): ProviderResponseError {
520
+ return new ProviderResponseError(
521
+ "invalid_provider_response",
522
+ `Invalid provider response (provider=${options.provider}, model=${options.model}): ${path} ${detail}.`,
523
+ diagnostics,
524
+ );
525
+ }
526
+
527
+ function responseDiagnostics(
528
+ options: { provider: string; model: string },
529
+ diagnostics: Omit<ProviderResponseDiagnostics, "provider" | "model">,
530
+ ): ProviderResponseDiagnostics {
531
+ return { provider: options.provider, model: options.model, ...diagnostics };
532
+ }