tinker-agent 1.2.1 → 1.4.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 (39) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +217 -72
  3. package/bin/tinker.js +75 -25
  4. package/package.json +8 -3
  5. package/src/agent/loop.ts +115 -11
  6. package/src/agent/runtime-session.ts +34 -15
  7. package/src/cli/command-line.ts +291 -0
  8. package/src/cli/config.ts +131 -264
  9. package/src/cli/index.ts +33 -21
  10. package/src/cli/main.ts +213 -0
  11. package/src/cli/model-profiles.ts +143 -72
  12. package/src/cli/output.ts +113 -0
  13. package/src/cli/package-metadata.ts +36 -0
  14. package/src/cli/prompt-source.ts +229 -0
  15. package/src/cli/public-cli-contract.ts +69 -0
  16. package/src/cli/public-config-contract.ts +650 -0
  17. package/src/cli/run-runner.ts +17 -12
  18. package/src/cli/runner-dependencies.ts +100 -0
  19. package/src/cli/tui-runner.tsx +52 -49
  20. package/src/events/observation-text-log.ts +2 -0
  21. package/src/events/stdout-event-printer.ts +7 -1
  22. package/src/events/types.ts +27 -3
  23. package/src/mcp/mcp-manager.ts +2 -19
  24. package/src/mcp/mcp-tool-executor.ts +3 -4
  25. package/src/model/model-client.ts +29 -0
  26. package/src/model/model-context-profile.ts +0 -30
  27. package/src/model/openai-chat-mapping.ts +54 -15
  28. package/src/model/openai-chat-model-client.ts +46 -27
  29. package/src/model/openai-chat-stream.ts +6 -2
  30. package/src/tools/bash.ts +8 -25
  31. package/src/tools/grep.ts +9 -1
  32. package/src/tools/registry.ts +15 -1
  33. package/src/tools/ripgrep.ts +24 -27
  34. package/src/tools/web-fetch/index.ts +2 -15
  35. package/src/tui/app.tsx +3 -0
  36. package/src/tui/components/prompt-input.tsx +6 -3
  37. package/src/tui/event-store.ts +24 -7
  38. package/src/tui/slash-commands.ts +76 -24
  39. package/src/tui/workspace-file-search.ts +78 -71
package/src/agent/loop.ts CHANGED
@@ -2,7 +2,9 @@ import {
2
2
  materializeModelRequest,
3
3
  type MaterializedModelRequest,
4
4
  type ModelClient,
5
+ type ModelRequestOutput,
5
6
  type PreparedModelRequest,
7
+ ProviderResponseError,
6
8
  } from "../model/model-client";
7
9
  import type { ImageAssetStore } from "../image/image-asset-store";
8
10
  import {
@@ -83,6 +85,8 @@ export class FatalAgentTurnError extends Error {
83
85
  }
84
86
  }
85
87
 
88
+ const MODEL_REQUEST_MAX_ATTEMPTS = 2 as const;
89
+
86
90
  export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
87
91
  let lastIteration: IterationIdentity | undefined;
88
92
  const committedPrefixAuditor =
@@ -202,21 +206,15 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
202
206
  await input.runtimeSession.append({
203
207
  type: "model.request.started",
204
208
  ...iteration,
205
- data: {},
209
+ data: {
210
+ attemptNumber: 1,
211
+ maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
212
+ },
206
213
  });
207
214
 
208
- let modelOutput;
209
215
  try {
210
216
  throwIfTurnCancelled(input.signal);
211
217
  input.runtimeSession.prepareModelDispatch?.({ iteration, built });
212
- modelOutput = await input.model.request(request, {
213
- signal: input.signal,
214
- identity: {
215
- iteration,
216
- runtimeSession: input.runtimeSession,
217
- },
218
- });
219
- throwIfTurnCancelled(input.signal);
220
218
  } catch (error) {
221
219
  if (input.signal.aborted) {
222
220
  return cancelledResult(
@@ -228,6 +226,82 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
228
226
  return failedResult(error, iteration);
229
227
  }
230
228
 
229
+ const requestOptions = {
230
+ signal: input.signal,
231
+ identity: {
232
+ iteration,
233
+ runtimeSession: input.runtimeSession,
234
+ },
235
+ };
236
+ let modelOutput: ModelRequestOutput | undefined;
237
+ let successfulAttempt: 1 | 2 | undefined;
238
+ for (const attemptNumber of [1, 2] as const) {
239
+ if (input.signal.aborted) {
240
+ return cancelledResult(
241
+ cancellation(input.signal, iteration, "model_request"),
242
+ iteration,
243
+ );
244
+ }
245
+ if (attemptNumber === 2) {
246
+ await input.runtimeSession.append({
247
+ type: "model.request.started",
248
+ ...iteration,
249
+ data: {
250
+ attemptNumber,
251
+ maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
252
+ },
253
+ });
254
+ }
255
+
256
+ try {
257
+ throwIfTurnCancelled(input.signal);
258
+ modelOutput = await input.model.request(request, requestOptions);
259
+ throwIfTurnCancelled(input.signal);
260
+ successfulAttempt = attemptNumber;
261
+ break;
262
+ } catch (error) {
263
+ if (input.signal.aborted) {
264
+ return cancelledResult(
265
+ cancellation(input.signal, iteration, "model_request"),
266
+ iteration,
267
+ );
268
+ }
269
+
270
+ const reasoningOnly = isReasoningOnlyProviderError(error);
271
+ const retryDisposition = reasoningOnly
272
+ ? attemptNumber === 1
273
+ ? "scheduled"
274
+ : "exhausted"
275
+ : "not_retryable";
276
+ await input.runtimeSession.append({
277
+ type: "model.request.failed",
278
+ ...iteration,
279
+ data: modelRequestFailureData({
280
+ error,
281
+ request,
282
+ attemptNumber,
283
+ retryDisposition,
284
+ }),
285
+ });
286
+
287
+ if (retryDisposition === "scheduled") {
288
+ continue;
289
+ }
290
+ if (retryDisposition === "exhausted") {
291
+ return failedResult(
292
+ new Error(
293
+ `Provider returned reasoning without final text or tool calls in both attempts (provider=${request.provider}, model=${request.model}).`,
294
+ ),
295
+ iteration,
296
+ );
297
+ }
298
+ return failedResult(error, iteration);
299
+ }
300
+ }
301
+ if (modelOutput === undefined || successfulAttempt === undefined) {
302
+ throw new Error("Model request attempt state completed without an output.");
303
+ }
304
+
231
305
  try {
232
306
  input.ledger.appendAssistant({
233
307
  iteration,
@@ -245,7 +319,11 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
245
319
  await input.runtimeSession.append({
246
320
  type: "model.request.finished",
247
321
  ...iteration,
248
- data: { output: modelOutput },
322
+ data: {
323
+ attemptNumber: successfulAttempt,
324
+ maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
325
+ output: modelOutput,
326
+ },
249
327
  });
250
328
  const measured = input.contextMeter.recordProviderUsage(request, modelOutput);
251
329
  await input.runtimeSession.append({
@@ -617,6 +695,32 @@ function failedResult(
617
695
  };
618
696
  }
619
697
 
698
+ function isReasoningOnlyProviderError(error: unknown): error is ProviderResponseError {
699
+ return (
700
+ error instanceof ProviderResponseError && error.code === "reasoning_only_assistant"
701
+ );
702
+ }
703
+
704
+ function modelRequestFailureData(input: {
705
+ error: unknown;
706
+ request: PreparedModelRequest;
707
+ attemptNumber: 1 | 2;
708
+ retryDisposition: "scheduled" | "not_retryable" | "exhausted";
709
+ }) {
710
+ const providerError =
711
+ input.error instanceof ProviderResponseError ? input.error : undefined;
712
+ return {
713
+ attemptNumber: input.attemptNumber,
714
+ maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
715
+ code: providerError?.code ?? ("provider_request_error" as const),
716
+ retryDisposition: input.retryDisposition,
717
+ provider: input.request.provider,
718
+ model: input.request.model,
719
+ error: errorMessage(input.error),
720
+ ...(providerError === undefined ? {} : { diagnostics: providerError.diagnostics }),
721
+ };
722
+ }
723
+
620
724
  function errorMessage(error: unknown): string {
621
725
  return error instanceof Error ? error.message : String(error);
622
726
  }
@@ -92,6 +92,7 @@ import { TurnCancelledError } from "./turn-cancellation";
92
92
  import type { ToolCompletionInput } from "../context/protocol-frame";
93
93
  import type { BuiltContextRequest } from "../context/context-revision";
94
94
  import type { CommittedToolCompletion } from "./session-ledger";
95
+ import type { PublicToolingConfig } from "../cli/public-config-contract";
95
96
  import type {
96
97
  IterationIdentity,
97
98
  RunAgentResult,
@@ -231,6 +232,7 @@ type CommonRuntimeSessionInput = {
231
232
  observationLogPath?: string;
232
233
  };
233
234
  webFetchRefiner?: Refiner;
235
+ toolingConfig?: PublicToolingConfig;
234
236
  };
235
237
 
236
238
  type CreateNewRuntimeSessionInput = CommonRuntimeSessionInput & {
@@ -531,6 +533,7 @@ class DefaultRuntimeSession implements RuntimeSession {
531
533
  runtimeSession: session.context,
532
534
  historyReader: store.historyReader(),
533
535
  webFetchRefiner: input.webFetchRefiner,
536
+ toolingConfig: input.toolingConfig,
534
537
  ...(session.skillCatalog.skills.size === 0
535
538
  ? {}
536
539
  : {
@@ -544,6 +547,8 @@ class DefaultRuntimeSession implements RuntimeSession {
544
547
  session.mcpManager = await dependencies.createMcpManager({
545
548
  config: mcpConfig,
546
549
  runtimeSession: session.context,
550
+ timeoutMs: input.toolingConfig?.mcpTimeoutMs,
551
+ maxObservationChars: input.toolingConfig?.mcpMaxObservationChars,
547
552
  });
548
553
  for (const executor of session.mcpManager.executors) {
549
554
  session.tooling.registry.register(executor, "MCP");
@@ -870,7 +875,7 @@ class DefaultRuntimeSession implements RuntimeSession {
870
875
  return this.input.modelClient.inputModalities?.includes("image") === true;
871
876
  }
872
877
 
873
- importImage(
878
+ async importImage(
874
879
  sourcePath: string,
875
880
  signal: AbortSignal,
876
881
  prospectiveMessageImageCount: number,
@@ -878,9 +883,6 @@ class DefaultRuntimeSession implements RuntimeSession {
878
883
  if (this.state !== "ready") {
879
884
  throw new Error(`Cannot import an image while RuntimeSession is ${this.state}.`);
880
885
  }
881
- if (!this.supportsImageInput()) {
882
- throw new Error("Current model profile does not support image input.");
883
- }
884
886
  if (
885
887
  !Number.isSafeInteger(prospectiveMessageImageCount) ||
886
888
  prospectiveMessageImageCount < 1 ||
@@ -888,18 +890,35 @@ class DefaultRuntimeSession implements RuntimeSession {
888
890
  ) {
889
891
  throw new Error("Prospective Prompt image count is invalid.");
890
892
  }
891
- const activeImageCount = this.input.modelClient.prepare(
892
- this.requireLedger().buildCommittedModelRequest(
893
- this.requireTooling().registry.definitions(),
894
- ).request,
895
- ).mediaOccurrenceCount;
896
- const aggregateImageCount = activeImageCount + prospectiveMessageImageCount;
897
- if (aggregateImageCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
898
- throw new ModelRequestMediaAggregateError(
899
- `Model request would have ${aggregateImageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
900
- );
893
+ const assertImageAllowed = () => {
894
+ if (this.state !== "ready") {
895
+ throw new Error(
896
+ `Cannot import an image while RuntimeSession is ${this.state}.`,
897
+ );
898
+ }
899
+ if (!this.supportsImageInput()) {
900
+ throw new Error("Current model profile does not support image input.");
901
+ }
902
+ const activeImageCount = this.input.modelClient.prepare(
903
+ this.requireLedger().buildCommittedModelRequest(
904
+ this.requireTooling().registry.definitions(),
905
+ ).request,
906
+ ).mediaOccurrenceCount;
907
+ const aggregateImageCount = activeImageCount + prospectiveMessageImageCount;
908
+ if (aggregateImageCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
909
+ throw new ModelRequestMediaAggregateError(
910
+ `Model request would have ${aggregateImageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
911
+ );
912
+ }
913
+ };
914
+
915
+ if (this.supportsImageInput()) {
916
+ assertImageAllowed();
901
917
  }
902
- return this.assetStore.importWorkspaceFile(sourcePath, { signal });
918
+ return this.assetStore.importWorkspaceFile(sourcePath, {
919
+ signal,
920
+ accept: assertImageAllowed,
921
+ });
903
922
  }
904
923
 
905
924
  async verifyImageAssets(
@@ -0,0 +1,291 @@
1
+ import { Command, CommanderError } from "commander";
2
+ import { PUBLIC_CLI_CONTRACT } from "./public-cli-contract";
3
+ import type { PromptSource } from "./prompt-source";
4
+ import { CliUsageError, type CliCommandScope } from "./output";
5
+
6
+ export type CliCommand =
7
+ | { readonly type: "tui"; readonly profileName?: string }
8
+ | {
9
+ readonly type: "run";
10
+ readonly profileName?: string;
11
+ readonly promptSource: PromptSource;
12
+ };
13
+
14
+ export type CommandLineResult =
15
+ | { readonly type: "command"; readonly command: CliCommand }
16
+ | {
17
+ readonly type: "terminal";
18
+ readonly stdout: string;
19
+ readonly stderr: string;
20
+ };
21
+
22
+ const SUCCESSFUL_TERMINAL_CODES = new Set([
23
+ "commander.help",
24
+ "commander.helpDisplayed",
25
+ "commander.version",
26
+ ]);
27
+
28
+ export async function parseCommandLine(
29
+ args: readonly string[],
30
+ packageVersion: string,
31
+ ): Promise<CommandLineResult> {
32
+ const scopeHint = preflightArgv(args);
33
+ let stdout = "";
34
+ let stderr = "";
35
+ let selectedCommand: CliCommand | undefined;
36
+ const contract = PUBLIC_CLI_CONTRACT;
37
+
38
+ const program = new Command()
39
+ .name(contract.name)
40
+ .description(contract.description)
41
+ .helpOption(contract.helpFlags)
42
+ .version(packageVersion, contract.versionFlags)
43
+ .option(contract.tui.profileOption.flags, contract.tui.profileOption.description)
44
+ .helpCommand(contract.helpCommand.command, contract.helpCommand.description)
45
+ .showHelpAfterError('Run "tinker --help" for usage.')
46
+ .showSuggestionAfterError(false)
47
+ .allowExcessArguments(false)
48
+ .enablePositionalOptions()
49
+ .exitOverride()
50
+ .configureOutput({
51
+ writeOut: (value) => {
52
+ stdout += value;
53
+ },
54
+ writeErr: (value) => {
55
+ stderr += value;
56
+ },
57
+ });
58
+
59
+ program.action(() => {
60
+ const { profile } = program.opts<{ profile?: string }>();
61
+ selectedCommand = Object.freeze({
62
+ type: "tui",
63
+ ...(profile === undefined
64
+ ? {}
65
+ : { profileName: validateProfile(profile, "root") }),
66
+ });
67
+ });
68
+
69
+ program
70
+ .command(contract.run.command)
71
+ .description(contract.run.description)
72
+ .option(contract.run.profileOption.flags, contract.run.profileOption.description)
73
+ .option(contract.run.stdinOption.flags, contract.run.stdinOption.description)
74
+ .option(contract.run.fileOption.flags, contract.run.fileOption.description)
75
+ .addHelpText("after", `\n${contract.run.helpAfter}\n`)
76
+ .showHelpAfterError('Run "tinker run --help" for usage.')
77
+ .showSuggestionAfterError(false)
78
+ .allowExcessArguments(false)
79
+ .exitOverride()
80
+ .action(
81
+ (
82
+ prompt: string | undefined,
83
+ options: { profile?: string; stdin?: boolean; file?: string },
84
+ ) => {
85
+ const sources: PromptSource[] = [];
86
+ if (prompt !== undefined) {
87
+ sources.push({ kind: "argument", value: prompt });
88
+ }
89
+ if (options.stdin === true) {
90
+ sources.push({ kind: "stdin" });
91
+ }
92
+ if (options.file !== undefined) {
93
+ if (options.file.length === 0) {
94
+ throw new CliUsageError("--file requires a non-empty path.", "run");
95
+ }
96
+ sources.push({ kind: "file", filePath: options.file });
97
+ }
98
+ if (sources.length === 0) {
99
+ throw new CliUsageError(
100
+ "Exactly one prompt source is required: [prompt], --stdin, or --file <path>.",
101
+ "run",
102
+ );
103
+ }
104
+ if (sources.length > 1) {
105
+ throw new CliUsageError(
106
+ "Prompt sources are mutually exclusive: use [prompt], --stdin, or --file <path>.",
107
+ "run",
108
+ );
109
+ }
110
+ const promptSource = sources[0];
111
+ if (promptSource === undefined) {
112
+ throw new CliUsageError("A prompt source is required.", "run");
113
+ }
114
+ selectedCommand = Object.freeze({
115
+ type: "run",
116
+ ...(options.profile === undefined
117
+ ? {}
118
+ : { profileName: validateProfile(options.profile, "run") }),
119
+ promptSource,
120
+ });
121
+ },
122
+ );
123
+
124
+ try {
125
+ await program.parseAsync([...args], { from: "user" });
126
+ } catch (error) {
127
+ if (error instanceof CliUsageError) {
128
+ throw error;
129
+ }
130
+ if (error instanceof CommanderError) {
131
+ if (SUCCESSFUL_TERMINAL_CODES.has(error.code)) {
132
+ return Object.freeze({ type: "terminal", stdout, stderr });
133
+ }
134
+ throw new CliUsageError(commanderErrorDetail(error), scopeHint);
135
+ }
136
+ throw error;
137
+ }
138
+
139
+ if (selectedCommand === undefined) {
140
+ throw new Error("Commander completed without selecting a command.");
141
+ }
142
+ const topLevelProfile = program.opts<{ profile?: string }>().profile;
143
+ if (selectedCommand.type === "run" && topLevelProfile !== undefined) {
144
+ throw new CliUsageError(
145
+ "The top-level --profile option only applies to the TUI; place --profile after run.",
146
+ "run",
147
+ );
148
+ }
149
+ return Object.freeze({ type: "command", command: selectedCommand });
150
+ }
151
+
152
+ function preflightArgv(args: readonly string[]): CliCommandScope {
153
+ let scope: CliCommandScope = "root";
154
+ let rootBlocked = false;
155
+ let topProfileOccurrences = 0;
156
+ let runProfileOccurrences = 0;
157
+ let stdinOccurrences = 0;
158
+ let fileOccurrences = 0;
159
+
160
+ for (let index = 0; index < args.length; index += 1) {
161
+ const token = args[index];
162
+ if (token === undefined || token === "--") {
163
+ break;
164
+ }
165
+
166
+ if (
167
+ (!rootBlocked && scope === "root" && isRootTerminalOption(token)) ||
168
+ (scope === "run" && isRunTerminalOption(token))
169
+ ) {
170
+ return scope;
171
+ }
172
+
173
+ if (scope === "root" && !rootBlocked) {
174
+ const profile = readOptionOccurrence(
175
+ args,
176
+ index,
177
+ token,
178
+ "--profile",
179
+ "root",
180
+ "-p",
181
+ );
182
+ if (profile !== undefined) {
183
+ topProfileOccurrences += 1;
184
+ assertSingleOccurrence("--profile", topProfileOccurrences, "root");
185
+ validateProfile(profile.value, "root");
186
+ index += profile.consumedNext ? 1 : 0;
187
+ continue;
188
+ }
189
+ if (token.startsWith("-")) {
190
+ rootBlocked = true;
191
+ continue;
192
+ }
193
+ if (token === "run") {
194
+ scope = "run";
195
+ continue;
196
+ }
197
+ if (token === "help") {
198
+ return "root";
199
+ }
200
+ throw new CliUsageError(`unknown command '${token}'`, "root");
201
+ }
202
+
203
+ if (scope !== "run") {
204
+ continue;
205
+ }
206
+ const profile = readOptionOccurrence(args, index, token, "--profile", "run", "-p");
207
+ if (profile !== undefined) {
208
+ runProfileOccurrences += 1;
209
+ assertSingleOccurrence("--profile", runProfileOccurrences, "run");
210
+ validateProfile(profile.value, "run");
211
+ index += profile.consumedNext ? 1 : 0;
212
+ continue;
213
+ }
214
+ const file = readOptionOccurrence(args, index, token, "--file", "run");
215
+ if (file !== undefined) {
216
+ fileOccurrences += 1;
217
+ assertSingleOccurrence("--file", fileOccurrences, "run");
218
+ if (file.value.length === 0) {
219
+ throw new CliUsageError("--file requires a non-empty path.", "run");
220
+ }
221
+ index += file.consumedNext ? 1 : 0;
222
+ continue;
223
+ }
224
+ if (token === "--stdin") {
225
+ stdinOccurrences += 1;
226
+ assertSingleOccurrence("--stdin", stdinOccurrences, "run");
227
+ }
228
+ }
229
+ return scope;
230
+ }
231
+
232
+ function readOptionOccurrence(
233
+ args: readonly string[],
234
+ index: number,
235
+ token: string,
236
+ longName: string,
237
+ scope: CliCommandScope,
238
+ shortName?: string,
239
+ ): { readonly value: string; readonly consumedNext: boolean } | undefined {
240
+ if (token === longName || token === shortName) {
241
+ const next = args[index + 1];
242
+ if (next === undefined || next.startsWith("-")) {
243
+ throw new CliUsageError(`option '${token}' argument missing`, scope);
244
+ }
245
+ return { value: next, consumedNext: true };
246
+ }
247
+ const longPrefix = `${longName}=`;
248
+ if (token.startsWith(longPrefix)) {
249
+ return { value: token.slice(longPrefix.length), consumedNext: false };
250
+ }
251
+ if (
252
+ shortName !== undefined &&
253
+ token.startsWith(shortName) &&
254
+ token !== shortName &&
255
+ !token.startsWith("--")
256
+ ) {
257
+ return { value: token.slice(shortName.length), consumedNext: false };
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ function validateProfile(value: string, scope: CliCommandScope): string {
263
+ if (value.trim() === "") {
264
+ throw new CliUsageError("--profile requires a non-empty value.", scope);
265
+ }
266
+ return value;
267
+ }
268
+
269
+ function assertSingleOccurrence(
270
+ option: string,
271
+ count: number,
272
+ scope: CliCommandScope,
273
+ ): void {
274
+ if (count > 1) {
275
+ throw new CliUsageError(`${option} may only be specified once.`, scope);
276
+ }
277
+ }
278
+
279
+ function isRootTerminalOption(token: string): boolean {
280
+ return (
281
+ token === "--help" || token === "-h" || token === "--version" || token === "-V"
282
+ );
283
+ }
284
+
285
+ function isRunTerminalOption(token: string): boolean {
286
+ return token === "--help" || token === "-h";
287
+ }
288
+
289
+ function commanderErrorDetail(error: CommanderError): string {
290
+ return error.message.replace(/^error:\s*/u, "");
291
+ }