tinker-agent 1.6.0 → 1.8.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.
@@ -202,6 +202,7 @@ function formatToolRawResult(call: ToolCall, raw: ToolRawResult): string[] {
202
202
  return optionalLine(formatDiff(call, raw));
203
203
  case "bash":
204
204
  case "task_output":
205
+ case "task_input":
205
206
  return optionalLine(formatBashResult(call, raw));
206
207
  case "task_list":
207
208
  case "task_stop":
@@ -278,7 +279,7 @@ function formatDiff(call: ToolCall, raw: ToolRawResult): string | undefined {
278
279
  }
279
280
 
280
281
  function formatBashResult(_call: ToolCall, raw: ToolRawResult): string | undefined {
281
- if (raw.kind !== "bash" && raw.kind !== "task_output") {
282
+ if (raw.kind !== "bash" && raw.kind !== "task_output" && raw.kind !== "task_input") {
282
283
  return undefined;
283
284
  }
284
285
 
@@ -348,7 +349,11 @@ function formatToolLine(prefix: string, call: ToolCall): string {
348
349
  return `${prefix} name=${call.name}\n`;
349
350
  }
350
351
 
351
- if (call.name === "TaskOutput" || call.name === "TaskStop") {
352
+ if (
353
+ call.name === "TaskOutput" ||
354
+ call.name === "TaskInput" ||
355
+ call.name === "TaskStop"
356
+ ) {
352
357
  const taskId = toolTaskId(call);
353
358
  return `${prefix} name=${call.name}${taskId === undefined ? "" : ` task=${taskId}`}\n`;
354
359
  }
@@ -1,15 +1,24 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { pathToFileURL } from "node:url";
2
6
  import {
3
7
  StdioClientTransport,
4
8
  getDefaultEnvironment,
5
9
  } from "@modelcontextprotocol/sdk/client/stdio.js";
6
10
  import type { RuntimeSessionContext } from "../agent/runtime-session";
11
+ import { ListRootsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7
12
  import type { ToolExecutor } from "../tools/types";
8
13
  import type { McpConfig, McpServerConfig } from "./mcp-config";
9
14
  import { createMcpToolExecutor } from "./mcp-tool-executor";
10
15
 
11
16
  const STDERR_TAIL_MAX_CHARS = 2_000;
12
17
 
18
+ function temporaryDirectoryEnvironment(): Record<string, string> {
19
+ return process.platform === "win32" ? { TEMP: tmpdir() } : { TMPDIR: tmpdir() };
20
+ }
21
+
13
22
  export type McpClientConnection = {
14
23
  client: Client;
15
24
  close(): Promise<void>;
@@ -18,6 +27,7 @@ export type McpClientConnection = {
18
27
  export type McpClientFactory = (
19
28
  serverName: string,
20
29
  serverConfig: McpServerConfig,
30
+ workspaceRoot: string,
21
31
  ) => Promise<McpClientConnection>;
22
32
 
23
33
  export type McpManager = {
@@ -37,6 +47,7 @@ export type McpServerInventory = {
37
47
 
38
48
  export type CreateMcpManagerOptions = {
39
49
  config: McpConfig;
50
+ workspaceRoot: string;
40
51
  runtimeSession: RuntimeSessionContext;
41
52
  clientFactory?: McpClientFactory;
42
53
  timeoutMs?: number;
@@ -70,7 +81,11 @@ export async function createMcpManager(
70
81
  let tools;
71
82
 
72
83
  try {
73
- connection = await clientFactory(serverName, serverConfig);
84
+ connection = await clientFactory(
85
+ serverName,
86
+ serverConfig,
87
+ options.workspaceRoot,
88
+ );
74
89
  } catch (error) {
75
90
  await options.runtimeSession.append({
76
91
  type: "mcp.server.failed",
@@ -227,11 +242,16 @@ async function closeConnections(
227
242
  async function stdioClientFactory(
228
243
  serverName: string,
229
244
  serverConfig: McpServerConfig,
245
+ workspaceRoot: string,
230
246
  ): Promise<McpClientConnection> {
231
247
  const transport = new StdioClientTransport({
232
248
  command: serverConfig.command,
233
249
  args: serverConfig.args,
234
- env: { ...getDefaultEnvironment(), ...serverConfig.env },
250
+ env: {
251
+ ...getDefaultEnvironment(),
252
+ ...temporaryDirectoryEnvironment(),
253
+ ...serverConfig.env,
254
+ },
235
255
  cwd: serverConfig.cwd,
236
256
  stderr: "pipe",
237
257
  });
@@ -241,7 +261,18 @@ async function stdioClientFactory(
241
261
  stderrTail = (stderrTail + chunk.toString("utf8")).slice(-STDERR_TAIL_MAX_CHARS);
242
262
  });
243
263
 
244
- const client = new Client({ name: "tinker", version: "0.1.0" });
264
+ const client = new Client(
265
+ { name: "tinker", version: "0.1.0" },
266
+ { capabilities: { roots: { listChanged: false } } },
267
+ );
268
+ client.setRequestHandler(ListRootsRequestSchema, () => ({
269
+ roots: [
270
+ {
271
+ uri: pathToFileURL(workspaceRoot).href,
272
+ name: path.basename(workspaceRoot),
273
+ },
274
+ ],
275
+ }));
245
276
 
246
277
  try {
247
278
  await client.connect(transport);
@@ -247,6 +247,9 @@ export class FakeModelClient implements ModelClient {
247
247
  if (this.mode === "pty-background-task") {
248
248
  return this.ptyBackgroundTask(input, prepared, options);
249
249
  }
250
+ if (this.mode === "pty-interactive-terminal") {
251
+ return this.ptyInteractiveTerminal(input, prepared, options);
252
+ }
250
253
  if (this.mode === "pty-resume") {
251
254
  return this.ptyResume(input, prepared, options);
252
255
  }
@@ -658,6 +661,87 @@ export class FakeModelClient implements ModelClient {
658
661
  return textOutput(prepared, "PTY_BACKGROUND_STOPPED");
659
662
  }
660
663
 
664
+ private ptyInteractiveTerminal(
665
+ input: ModelRequestInput,
666
+ prepared: PreparedModelRequest,
667
+ options: ModelRequestOptions,
668
+ ): ModelRequestOutput {
669
+ requireTools(input, ["Bash", "TaskOutput", "TaskInput"]);
670
+ const prompt = lastUserMessage(input.messages);
671
+ if (prompt === "PTY_INTERACTIVE_FOLLOWUP") {
672
+ requireMessage(input.messages, "assistant", "PTY_INTERACTIVE_DONE");
673
+ return textOutput(prepared, "PTY_INTERACTIVE_FOLLOWUP_DONE");
674
+ }
675
+ if (prompt !== "PTY_INTERACTIVE_TERMINAL" && prompt !== "PTY_INTERACTIVE_QUIT") {
676
+ throw new Error(
677
+ `Unexpected pty-interactive-terminal prompt: ${JSON.stringify(prompt)}.`,
678
+ );
679
+ }
680
+
681
+ const tools = toolMessagesAfterLastUser(input.messages);
682
+ const bash = tools.find((message) => message.name === "Bash");
683
+ if (bash === undefined) {
684
+ return toolCallOutput(prepared, options, "Bash", {
685
+ command: "python3 -q",
686
+ description: "Start interactive Python fixture",
687
+ tty: true,
688
+ timeout: 25,
689
+ });
690
+ }
691
+ if (!bash.content.includes("taskId=") || !bash.content.includes("tty=true")) {
692
+ throw new Error("PTY Bash task did not return an interactive task ID.");
693
+ }
694
+ const taskId = requireObservationValue(bash.content, "taskId");
695
+
696
+ const outputs = tools.filter((message) => message.name === "TaskOutput");
697
+ const output = outputs.at(-1);
698
+ if (output === undefined || !output.content.includes(">>>")) {
699
+ if (outputs.length >= 20) {
700
+ throw new Error("Interactive Python fixture did not show its prompt.");
701
+ }
702
+ return toolCallOutput(prepared, options, "TaskOutput", {
703
+ task_id: taskId,
704
+ });
705
+ }
706
+
707
+ const inputs = tools.filter((message) => message.name === "TaskInput");
708
+ if (inputs.length === 0) {
709
+ return toolCallOutput(prepared, options, "TaskInput", {
710
+ task_id: taskId,
711
+ chars:
712
+ prompt === "PTY_INTERACTIVE_QUIT"
713
+ ? "import os; print('PTY_INTERACTIVE_PID=' + str(os.getpid()))\n"
714
+ : "print(6 * 7)\n",
715
+ wait_ms: 250,
716
+ });
717
+ }
718
+
719
+ const latestInput = inputs.at(-1);
720
+ const expected = prompt === "PTY_INTERACTIVE_QUIT" ? "PTY_INTERACTIVE_PID=" : "42";
721
+ if (!latestInput?.content.includes(expected)) {
722
+ if (inputs.length >= 20) {
723
+ throw new Error(`Interactive Python fixture did not show ${expected}.`);
724
+ }
725
+ return toolCallOutput(prepared, options, "TaskInput", {
726
+ task_id: taskId,
727
+ chars: "",
728
+ wait_ms: 250,
729
+ });
730
+ }
731
+
732
+ if (prompt === "PTY_INTERACTIVE_QUIT") {
733
+ return textOutput(prepared, "PTY_INTERACTIVE_RUNNING");
734
+ }
735
+ if (!inputs.some((message) => message.content.includes("status=completed"))) {
736
+ return toolCallOutput(prepared, options, "TaskInput", {
737
+ task_id: taskId,
738
+ chars: "exit()\n",
739
+ wait_ms: 500,
740
+ });
741
+ }
742
+ return textOutput(prepared, "PTY_INTERACTIVE_DONE");
743
+ }
744
+
661
745
  private ptyResume(
662
746
  input: ModelRequestInput,
663
747
  prepared: PreparedModelRequest,
@@ -11,6 +11,7 @@ import type {
11
11
  ReadFileRawResult,
12
12
  RecallRawResult,
13
13
  SkillRawResult,
14
+ TaskInputRawResult,
14
15
  TaskListRawResult,
15
16
  TaskOutputRawResult,
16
17
  TaskStopRawResult,
@@ -51,6 +52,8 @@ export class ObservationBuilder {
51
52
  return { content: renderTaskListObservation(input.raw) };
52
53
  case "task_output":
53
54
  return { content: renderTaskOutputObservation(input.raw) };
55
+ case "task_input":
56
+ return { content: renderTaskInputObservation(input.raw) };
54
57
  case "task_stop":
55
58
  return { content: renderTaskStopObservation(input.raw) };
56
59
  case "web_search":
@@ -331,8 +334,11 @@ function renderBashObservation(raw: BashRawResult): string {
331
334
  `timeoutMs=${raw.timeoutMs ?? 0}`,
332
335
  `command=${raw.command}`,
333
336
  `cwd=${raw.cwd}`,
337
+ `tty=${raw.tty}`,
334
338
  `outputFilePath=${raw.outputFilePath}`,
335
- "Use Read on outputFilePath to inspect current output.",
339
+ raw.tty
340
+ ? "Use TaskOutput to inspect the current terminal screen and TaskInput to interact."
341
+ : "Use TaskOutput to inspect current output.",
336
342
  ].join("\n");
337
343
  }
338
344
 
@@ -342,8 +348,11 @@ function renderBashObservation(raw: BashRawResult): string {
342
348
  `taskId=${raw.taskId}`,
343
349
  `command=${raw.command}`,
344
350
  `cwd=${raw.cwd}`,
351
+ `tty=${raw.tty}`,
345
352
  `outputFilePath=${raw.outputFilePath}`,
346
- "Use Read on outputFilePath to inspect current output.",
353
+ raw.tty
354
+ ? "Use TaskOutput to inspect the current terminal screen and TaskInput to interact."
355
+ : "Use TaskOutput to inspect current output.",
347
356
  ].join("\n");
348
357
  }
349
358
 
@@ -353,6 +362,7 @@ function renderBashObservation(raw: BashRawResult): string {
353
362
  `exitCode=${raw.exitCode ?? "null"}`,
354
363
  `status=${raw.status}`,
355
364
  `cwd=${raw.cwd}`,
365
+ `tty=${raw.tty}`,
356
366
  `outputFilePath=${raw.outputFilePath}`,
357
367
  `outputBytes=${raw.outputBytes}`,
358
368
  `outputLines=${raw.outputLines}`,
@@ -362,8 +372,9 @@ function renderBashObservation(raw: BashRawResult): string {
362
372
  ? undefined
363
373
  : `returnCodeInterpretation=${raw.returnCodeInterpretation}`,
364
374
  raw.error === undefined ? undefined : `error=${raw.error}`,
365
- "preview:",
366
- raw.preview,
375
+ raw.tty ? `screen=${raw.screenColumns ?? 80}x${raw.screenRows ?? 24}` : undefined,
376
+ raw.tty ? "current screen:" : "preview:",
377
+ raw.tty ? (raw.screen ?? "") : raw.preview,
367
378
  ]
368
379
  .filter((line): line is string => line !== undefined)
369
380
  .join("\n");
@@ -387,23 +398,48 @@ function renderTaskOutputObservation(raw: TaskOutputRawResult): string {
387
398
  return `TaskOutput failed for ${raw.taskId || "(unknown task ID)"}: ${raw.error ?? "Unknown error."}`;
388
399
  }
389
400
 
401
+ const terminalScreen = raw.task.tty;
390
402
  return [
391
403
  "Task output retrieved.",
392
404
  `taskId=${raw.taskId}`,
393
405
  `status=${raw.task.status}`,
394
406
  `command=${raw.task.command}`,
407
+ `tty=${terminalScreen}`,
395
408
  `outputFilePath=${raw.outputFilePath}`,
396
409
  `outputBytes=${raw.outputBytes ?? 0}`,
397
410
  `outputLines=${raw.outputLines ?? 0}`,
398
411
  `truncated=${raw.truncated ?? false}`,
399
412
  raw.omittedLines === undefined ? undefined : `omittedLines=${raw.omittedLines}`,
400
- "preview:",
401
- raw.preview ?? "",
413
+ terminalScreen
414
+ ? `screen=${raw.screenColumns ?? 80}x${raw.screenRows ?? 24}`
415
+ : undefined,
416
+ terminalScreen ? "current screen:" : "preview:",
417
+ terminalScreen ? (raw.screen ?? "") : (raw.preview ?? ""),
402
418
  ]
403
419
  .filter((line): line is string => line !== undefined)
404
420
  .join("\n");
405
421
  }
406
422
 
423
+ function renderTaskInputObservation(raw: TaskInputRawResult): string {
424
+ if (!raw.ok) {
425
+ return `TaskInput failed for ${raw.taskId || "(unknown task ID)"}: ${raw.error}`;
426
+ }
427
+
428
+ return [
429
+ "Terminal input sent.",
430
+ `taskId=${raw.taskId}`,
431
+ `status=${raw.status}`,
432
+ `writtenBytes=${raw.writtenBytes}`,
433
+ `waitedMs=${raw.waitedMs}`,
434
+ `screen=${raw.screenColumns}x${raw.screenRows}`,
435
+ `outputFilePath=${raw.outputFilePath}`,
436
+ `outputBytes=${raw.outputBytes}`,
437
+ `outputLines=${raw.outputLines}`,
438
+ "current screen:",
439
+ raw.screen,
440
+ ].join("\n");
441
+ }
442
+
407
443
  function renderTaskStopObservation(raw: TaskStopRawResult): string {
408
444
  if (!raw.ok || raw.task === undefined) {
409
445
  return `TaskStop failed for ${raw.taskId || "(unknown task ID)"}: ${raw.error ?? "Unknown error."}`;
@@ -428,6 +464,7 @@ function renderTaskSummary(task: TaskListRawResult["tasks"][number]): string {
428
464
  `taskId=${task.taskId}`,
429
465
  `description=${task.description}`,
430
466
  `status=${task.status}`,
467
+ `tty=${task.tty}`,
431
468
  `startedAt=${task.startedAt}`,
432
469
  task.endedAt === undefined ? undefined : `endedAt=${task.endedAt}`,
433
470
  task.exitCode === undefined ? undefined : `exitCode=${task.exitCode}`,
@@ -34,6 +34,15 @@ export class SessionCatalog {
34
34
  }
35
35
 
36
36
  async list(currentSessionId?: SessionId): Promise<readonly SessionSummary[]> {
37
+ const summaries = await this.scan(currentSessionId);
38
+ return Object.freeze(summaries.slice(0, this.input.limit ?? 20));
39
+ }
40
+
41
+ async listAll(currentSessionId?: SessionId): Promise<readonly SessionSummary[]> {
42
+ return Object.freeze(await this.scan(currentSessionId));
43
+ }
44
+
45
+ private async scan(currentSessionId?: SessionId): Promise<SessionSummary[]> {
37
46
  const workspaceRoot = await this.workspaceRootPromise;
38
47
  const sessionsRoot = path.join(workspaceRoot, ".tinker", "sessions");
39
48
  let entries;
@@ -63,17 +72,14 @@ export class SessionCatalog {
63
72
  );
64
73
  }
65
74
 
66
- return Object.freeze(
67
- summaries
68
- .filter(
69
- (summary) =>
70
- summary.turnCount > 0 ||
71
- summary.status === "incomplete" ||
72
- summary.status === "unavailable",
73
- )
74
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
75
- .slice(0, this.input.limit ?? 20),
76
- );
75
+ return summaries
76
+ .filter(
77
+ (summary) =>
78
+ summary.turnCount > 0 ||
79
+ summary.status === "incomplete" ||
80
+ summary.status === "unavailable",
81
+ )
82
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
77
83
  }
78
84
 
79
85
  async get(
@@ -4844,6 +4844,7 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
4844
4844
  "bash",
4845
4845
  "task_list",
4846
4846
  "task_output",
4847
+ "task_input",
4847
4848
  "task_stop",
4848
4849
  "web_search",
4849
4850
  "web_fetch",