tinker-agent 2.6.0 → 2.7.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.
@@ -0,0 +1,100 @@
1
+ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
2
+ import {
3
+ defineToolExecutor,
4
+ type AskUserRawResult,
5
+ type AskUserRequest,
6
+ type ToolExecutionContext,
7
+ type ToolExecutor,
8
+ } from "./types";
9
+
10
+ export function createAskUserToolExecutor(): ToolExecutor {
11
+ return defineToolExecutor("ask_user", {
12
+ definition: {
13
+ name: "AskUser",
14
+ description:
15
+ "Ask the user one multiple-choice question when a material ambiguity prevents correct progress. Investigate the conversation and workspace first. Provide 2-6 options, each as a complete answer the user can select. The user may select one option or dismiss the question. If dismissed, use your own judgment and do not immediately repeat the same question. Call AskUser alone, without other tool calls in the same response.",
16
+ parameters: {
17
+ type: "object",
18
+ properties: {
19
+ question: {
20
+ type: "string",
21
+ description: "The question shown to the user.",
22
+ },
23
+ options: {
24
+ type: "array",
25
+ minItems: 2,
26
+ maxItems: 6,
27
+ items: {
28
+ type: "object",
29
+ properties: {
30
+ description: {
31
+ type: "string",
32
+ description: "An answer the user can select.",
33
+ },
34
+ },
35
+ required: ["description"],
36
+ },
37
+ },
38
+ },
39
+ required: ["question", "options"],
40
+ },
41
+ },
42
+ async execute(
43
+ args,
44
+ _call,
45
+ context: ToolExecutionContext,
46
+ ): Promise<AskUserRawResult> {
47
+ throwIfTurnCancelled(context.signal);
48
+ const parsed = parseAskUserArgs(args);
49
+ if (!parsed.ok) {
50
+ return parsed;
51
+ }
52
+ if (context.askUser === undefined) {
53
+ return { ok: false, error: "AskUser interaction is unavailable." };
54
+ }
55
+ const response = await context.askUser(parsed.request);
56
+ throwIfTurnCancelled(context.signal);
57
+ return response.outcome === "selected"
58
+ ? { ok: true, outcome: "selected", answer: response.answer }
59
+ : { ok: true, outcome: "dismissed" };
60
+ },
61
+ });
62
+ }
63
+
64
+ function parseAskUserArgs(
65
+ args: unknown,
66
+ ): { ok: true; request: AskUserRequest } | { ok: false; error: string } {
67
+ if (!isRecord(args)) {
68
+ return { ok: false, error: "AskUser arguments must be an object." };
69
+ }
70
+ if (typeof args.question !== "string") {
71
+ return { ok: false, error: "AskUser question must be a string." };
72
+ }
73
+ if (!Array.isArray(args.options)) {
74
+ return { ok: false, error: "AskUser options must be an array." };
75
+ }
76
+ if (args.options.length < 2 || args.options.length > 6) {
77
+ return { ok: false, error: "AskUser options must contain between 2 and 6 items." };
78
+ }
79
+ const options: { description: string }[] = [];
80
+ for (const [index, option] of args.options.entries()) {
81
+ if (!isRecord(option) || typeof option.description !== "string") {
82
+ return {
83
+ ok: false,
84
+ error: `AskUser option ${index + 1} must be an object with a string description.`,
85
+ };
86
+ }
87
+ options.push({ description: option.description });
88
+ }
89
+ return {
90
+ ok: true,
91
+ request: Object.freeze({
92
+ question: args.question,
93
+ options: Object.freeze(options.map((option) => Object.freeze(option))),
94
+ }),
95
+ };
96
+ }
97
+
98
+ function isRecord(value: unknown): value is Record<string, unknown> {
99
+ return typeof value === "object" && value !== null && !Array.isArray(value);
100
+ }
@@ -1,3 +1,4 @@
1
+ import { createAskUserToolExecutor } from "./ask-user";
1
2
  import { createBashToolExecutor } from "./bash";
2
3
  import { ShellTaskManager } from "./bash-task";
3
4
  import { createCwdState } from "./cwd-state";
@@ -86,6 +87,11 @@ export class ToolRuntime {
86
87
  ): Promise<"allow" | "deny">;
87
88
  },
88
89
  private readonly contextMaintenance?: ContextMaintenanceHandle,
90
+ private readonly askUser?: (
91
+ call: ToolCall,
92
+ request: Parameters<NonNullable<ToolExecutionContext["askUser"]>>[0],
93
+ signal: AbortSignal,
94
+ ) => ReturnType<NonNullable<ToolExecutionContext["askUser"]>>,
89
95
  ) {}
90
96
 
91
97
  async execute(call: ToolCall, context: ToolExecutionContext): Promise<ToolRawResult> {
@@ -117,6 +123,9 @@ export class ToolRuntime {
117
123
  ...(this.contextMaintenance === undefined
118
124
  ? {}
119
125
  : { contextMaintenance: this.contextMaintenance }),
126
+ ...(this.askUser === undefined
127
+ ? {}
128
+ : { askUser: (request) => this.askUser!(call, request, context.signal) }),
120
129
  ...(this.bashGuard === undefined
121
130
  ? {}
122
131
  : {
@@ -173,9 +182,17 @@ export function createDefaultTooling(options: {
173
182
  toolingConfig?: PublicToolingConfig;
174
183
  memorySearch?: ToolExecutor;
175
184
  memoryGet?: ToolExecutor;
185
+ memoryCreate?: ToolExecutor;
186
+ memoryUpdate?: ToolExecutor;
187
+ memoryDelete?: ToolExecutor;
176
188
  enableTurnUndo?: boolean;
177
189
  imageAssetStore?: ImageAssetStore;
178
190
  supportsViewImage?: boolean;
191
+ askUser?: (
192
+ call: ToolCall,
193
+ request: Parameters<NonNullable<ToolExecutionContext["askUser"]>>[0],
194
+ signal: AbortSignal,
195
+ ) => ReturnType<NonNullable<ToolExecutionContext["askUser"]>>;
179
196
  bashGuard?: {
180
197
  readonly surface: "tui" | "one-shot";
181
198
  confirm(
@@ -201,6 +218,9 @@ export function createDefaultTooling(options: {
201
218
  ...(options.homeRoot === undefined ? {} : { homeRoot: options.homeRoot }),
202
219
  });
203
220
 
221
+ if (options.askUser !== undefined) {
222
+ registry.register(createAskUserToolExecutor());
223
+ }
204
224
  registry.register(
205
225
  createGlobToolExecutor({
206
226
  workspaceRoot: options.workspaceRoot,
@@ -247,6 +267,15 @@ export function createDefaultTooling(options: {
247
267
  if (options.memoryGet !== undefined) {
248
268
  registry.register(options.memoryGet);
249
269
  }
270
+ if (options.memoryCreate !== undefined) {
271
+ registry.register(options.memoryCreate);
272
+ }
273
+ if (options.memoryUpdate !== undefined) {
274
+ registry.register(options.memoryUpdate);
275
+ }
276
+ if (options.memoryDelete !== undefined) {
277
+ registry.register(options.memoryDelete);
278
+ }
250
279
  if (options.skillCatalog !== undefined) {
251
280
  if (options.skillCatalog.skills.size === 0) {
252
281
  throw new Error("An empty Agent Skill catalog must not register tooling.");
@@ -321,6 +350,7 @@ export function createDefaultTooling(options: {
321
350
  registry,
322
351
  options.bashGuard,
323
352
  options.runtimeSession.contextMaintenance,
353
+ options.askUser,
324
354
  ),
325
355
  snapshots,
326
356
  taskManager,
@@ -428,6 +428,58 @@ export type MemoryGetRawResult =
428
428
  error: string;
429
429
  };
430
430
 
431
+ export type MemoryCreateRawResult =
432
+ | {
433
+ ok: true;
434
+ status: "created" | "already_exists";
435
+ memoryId: string;
436
+ createdAt: string;
437
+ }
438
+ | {
439
+ ok: false;
440
+ error: string;
441
+ };
442
+
443
+ export type MemoryUpdateRawResult =
444
+ | {
445
+ ok: true;
446
+ status: "updated";
447
+ memoryId: string;
448
+ }
449
+ | {
450
+ ok: false;
451
+ code: "memory_not_found";
452
+ error: string;
453
+ }
454
+ | {
455
+ ok: false;
456
+ code: "memory_duplicate";
457
+ conflictMemoryId: string;
458
+ error: string;
459
+ }
460
+ | {
461
+ ok: false;
462
+ code?: undefined;
463
+ error: string;
464
+ };
465
+
466
+ export type MemoryDeleteRawResult =
467
+ | {
468
+ ok: true;
469
+ status: "deleted";
470
+ memoryId: string;
471
+ }
472
+ | {
473
+ ok: false;
474
+ code: "memory_not_found";
475
+ error: string;
476
+ }
477
+ | {
478
+ ok: false;
479
+ code?: undefined;
480
+ error: string;
481
+ };
482
+
431
483
  export type SkillRawResult =
432
484
  | {
433
485
  ok: true;
@@ -488,6 +540,20 @@ export type WaitRawResult =
488
540
  error: string;
489
541
  };
490
542
 
543
+ export type AskUserRequest = {
544
+ readonly question: string;
545
+ readonly options: readonly { readonly description: string }[];
546
+ };
547
+
548
+ export type AskUserResponse =
549
+ | { readonly outcome: "selected"; readonly answer: string }
550
+ | { readonly outcome: "dismissed" };
551
+
552
+ export type AskUserRawResult =
553
+ | { ok: true; outcome: "selected"; answer: string }
554
+ | { ok: true; outcome: "dismissed" }
555
+ | { ok: false; error: string };
556
+
491
557
  export type ToolRawResultByKind = {
492
558
  read: ReadFileRawResult;
493
559
  view_image: ViewImageRawResult;
@@ -508,7 +574,11 @@ export type ToolRawResultByKind = {
508
574
  context_maintenance: ContextMaintenanceRawResult;
509
575
  memory_search: MemorySearchRawResult;
510
576
  memory_get: MemoryGetRawResult;
577
+ memory_create: MemoryCreateRawResult;
578
+ memory_update: MemoryUpdateRawResult;
579
+ memory_delete: MemoryDeleteRawResult;
511
580
  wait: WaitRawResult;
581
+ ask_user: AskUserRawResult;
512
582
  skill: SkillRawResult;
513
583
  mcp: McpToolRawResult;
514
584
  generic: GenericToolRawResult;
@@ -553,6 +623,7 @@ export function defineToolExecutor<TKind extends ToolRawResultKind>(
553
623
 
554
624
  export type ToolExecutionContext = {
555
625
  signal: AbortSignal;
626
+ askUser?: (request: AskUserRequest) => Promise<AskUserResponse>;
556
627
  contextMaintenance?: ContextMaintenanceHandle;
557
628
  confirmBashCommand?: (request: {
558
629
  command: string;
package/src/tui/app.tsx CHANGED
@@ -25,6 +25,7 @@ import { Footer } from "./components/footer";
25
25
  import { AssistantMarkdownProvider } from "./components/assistant-markdown";
26
26
  import { ContextStatus } from "./components/context-status";
27
27
  import { BackgroundTasks } from "./components/background-tasks";
28
+ import { AskUser } from "./components/ask-user";
28
29
  import { BashConfirmation } from "./components/bash-confirmation";
29
30
  import { Header } from "./components/header";
30
31
  import { ModelPicker } from "./components/model-picker";
@@ -142,6 +143,11 @@ export function App(props: AppProps) {
142
143
  () => binding.bashGuard(),
143
144
  () => binding.bashGuard(),
144
145
  );
146
+ const askUser = useSyncExternalStore(
147
+ (listener) => binding.subscribeAskUser(listener),
148
+ () => binding.askUser(),
149
+ () => binding.askUser(),
150
+ );
145
151
  const promptScheduler = useSyncExternalStore(
146
152
  (listener) => binding.subscribePromptScheduler?.(listener) ?? (() => undefined),
147
153
  () => binding.promptScheduler?.() ?? IDLE_PROMPT_SCHEDULER,
@@ -276,7 +282,7 @@ export function App(props: AppProps) {
276
282
  setIsCancelling(true);
277
283
  setNotice("Cancelling current turn...");
278
284
  },
279
- { isActive: executionRunning },
285
+ { isActive: executionRunning && askUser.pending === undefined },
280
286
  );
281
287
 
282
288
  const closeResumePicker = () => {
@@ -906,9 +912,11 @@ export function App(props: AppProps) {
906
912
  status={
907
913
  isCancelling
908
914
  ? "cancelling"
909
- : executionRunning
910
- ? "running"
911
- : state.status
915
+ : askUser.pending !== undefined
916
+ ? "waiting_for_answer"
917
+ : executionRunning
918
+ ? "running"
919
+ : state.status
912
920
  }
913
921
  workedForMs={state.workedForMs}
914
922
  yolo={bashGuard.mode === "yolo"}
@@ -916,7 +924,26 @@ export function App(props: AppProps) {
916
924
  />
917
925
  </Box>
918
926
  <Box marginTop={1} flexDirection="column" flexShrink={0}>
919
- {bashGuard.pending !== undefined ? (
927
+ {askUser.pending !== undefined ? (
928
+ <AskUser
929
+ question={askUser.pending.question}
930
+ options={askUser.pending.options}
931
+ onSelect={(selectedIndex) => {
932
+ void binding
933
+ .resolveAskUser({ outcome: "selected", selectedIndex })
934
+ .catch((error: unknown) =>
935
+ setNotice(`Answer failed: ${errorMessage(error)}`),
936
+ );
937
+ }}
938
+ onDismiss={() => {
939
+ void binding
940
+ .resolveAskUser({ outcome: "dismissed" })
941
+ .catch((error: unknown) =>
942
+ setNotice(`Dismiss failed: ${errorMessage(error)}`),
943
+ );
944
+ }}
945
+ />
946
+ ) : bashGuard.pending !== undefined ? (
920
947
  <BashConfirmation
921
948
  command={bashGuard.pending.command}
922
949
  reason={bashGuard.pending.reason}
@@ -949,6 +976,7 @@ export function App(props: AppProps) {
949
976
  isSessionOperation ||
950
977
  isCopying ||
951
978
  isCancelling ||
979
+ askUser.pending !== undefined ||
952
980
  bashGuard.pending !== undefined
953
981
  }
954
982
  history={props.history}
@@ -0,0 +1,61 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import { useState } from "react";
3
+
4
+ export type AskUserProps = {
5
+ question: string;
6
+ options: readonly { readonly description: string }[];
7
+ onSelect(selectedIndex: number): void;
8
+ onDismiss(): void;
9
+ };
10
+
11
+ export function AskUser(props: AskUserProps) {
12
+ const [selectedIndex, setSelectedIndex] = useState(0);
13
+
14
+ useInput((input, key) => {
15
+ if (key.escape) {
16
+ props.onDismiss();
17
+ return;
18
+ }
19
+ if (key.upArrow) {
20
+ setSelectedIndex((current) =>
21
+ current === 0 ? props.options.length - 1 : current - 1,
22
+ );
23
+ return;
24
+ }
25
+ if (key.downArrow) {
26
+ setSelectedIndex((current) => (current + 1) % props.options.length);
27
+ return;
28
+ }
29
+ if (key.return) {
30
+ props.onSelect(selectedIndex);
31
+ return;
32
+ }
33
+ if (/^[1-6]$/.test(input)) {
34
+ const index = Number(input) - 1;
35
+ if (index < props.options.length) {
36
+ props.onSelect(index);
37
+ }
38
+ }
39
+ });
40
+
41
+ return (
42
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
43
+ <Text color="cyan" bold>
44
+ Tinker asks
45
+ </Text>
46
+ <Text>{props.question}</Text>
47
+ <Box flexDirection="column" marginTop={1}>
48
+ {props.options.map((option, index) => (
49
+ <Text key={`${index}:${option.description}`}>
50
+ <Text color={index === selectedIndex ? "cyan" : undefined}>
51
+ {index === selectedIndex ? "❯" : " "} {index + 1}. {option.description}
52
+ </Text>
53
+ </Text>
54
+ ))}
55
+ </Box>
56
+ <Text dimColor>
57
+ ↑/↓ select · 1-{props.options.length} choose · Enter confirm · Esc skip
58
+ </Text>
59
+ </Box>
60
+ );
61
+ }
@@ -1,7 +1,14 @@
1
1
  import { Spinner, StatusMessage } from "@inkjs/ui";
2
2
 
3
3
  export type FooterProps = {
4
- status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
4
+ status:
5
+ | "idle"
6
+ | "running"
7
+ | "waiting_for_answer"
8
+ | "cancelling"
9
+ | "cancelled"
10
+ | "done"
11
+ | "failed";
5
12
  workedForMs?: number;
6
13
  yolo?: boolean;
7
14
  pendingFollowUps?: number;
@@ -26,6 +33,12 @@ export function Footer(props: FooterProps) {
26
33
  return <StatusMessage variant="error">failed{suffix}</StatusMessage>;
27
34
  }
28
35
 
36
+ if (props.status === "waiting_for_answer") {
37
+ return (
38
+ <StatusMessage variant="info">Waiting for your selection{suffix}</StatusMessage>
39
+ );
40
+ }
41
+
29
42
  if (props.status === "running") {
30
43
  const queued =
31
44
  props.pendingFollowUps === undefined || props.pendingFollowUps === 0