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
@@ -10,27 +10,29 @@ import {
10
10
  loadProjectInstructions,
11
11
  projectInstructionManifest,
12
12
  } from "../instructions/project-instructions";
13
+ import { type RunnerConfig } from "./config";
13
14
  import {
14
15
  createRunnerModelClient,
15
- createWebFetchRefinerFromEnv,
16
- readRunnerConfig,
16
+ createWebFetchRefiner,
17
17
  RUNTIME_INSTRUCTIONS,
18
- type RunnerConfigOverrides,
19
- } from "./config";
20
- import { loadModelProfiles } from "./model-profiles";
18
+ } from "./runner-dependencies";
19
+ import type { PublicToolingConfig } from "./public-config-contract";
21
20
  import { realpath } from "node:fs/promises";
22
21
  import { loadSkillCatalog } from "../skills/skill-loader";
23
22
 
24
- export type RunOneShotOptions = RunnerConfigOverrides & {
23
+ export type RunOneShotOptions = {
24
+ config: RunnerConfig;
25
+ tooling: PublicToolingConfig;
25
26
  modelClient?: ModelClient;
26
27
  stdout?: WritableLike;
27
28
  stderr?: WritableLike;
28
29
  eventLogPath?: string | false;
30
+ env?: NodeJS.ProcessEnv;
29
31
  };
30
32
 
31
33
  export async function runOneShot(
32
34
  userPrompt: string,
33
- options: RunOneShotOptions = {},
35
+ options: RunOneShotOptions,
34
36
  ): Promise<number> {
35
37
  const stdout = options.stdout ?? process.stdout;
36
38
  const stderr = options.stderr ?? process.stderr;
@@ -40,9 +42,7 @@ export async function runOneShot(
40
42
  let exitCode = 1;
41
43
 
42
44
  try {
43
- const profiles =
44
- options.modelClient === undefined ? await loadModelProfiles() : undefined;
45
- const config = readRunnerConfig(options, profiles);
45
+ const config = options.config;
46
46
  const workspaceRoot = await realpath(config.workspaceRoot);
47
47
  const projectInstructions = await loadProjectInstructions(workspaceRoot);
48
48
  const skillCatalog = await loadSkillCatalog({ workspaceRoot });
@@ -51,7 +51,11 @@ export async function runOneShot(
51
51
  runtimeInstructions: RUNTIME_INSTRUCTIONS(workspaceRoot),
52
52
  projectInstructions,
53
53
  });
54
- const modelClient = createRunnerModelClient(config, options.modelClient);
54
+ const modelClient = createRunnerModelClient(
55
+ config,
56
+ options.modelClient,
57
+ options.env,
58
+ );
55
59
  session = await createRuntimeSession({
56
60
  selection: { mode: "new", sessionId: config.sessionId },
57
61
  workspaceRoot,
@@ -68,7 +72,8 @@ export async function runOneShot(
68
72
  presentationSinks: [new StdoutEventPrinter(stdout, stderr)],
69
73
  persistence:
70
74
  options.eventLogPath === false ? false : { eventLogPath: options.eventLogPath },
71
- webFetchRefiner: createWebFetchRefinerFromEnv(config),
75
+ webFetchRefiner: createWebFetchRefiner(config, options.env),
76
+ toolingConfig: options.tooling,
72
77
  });
73
78
 
74
79
  const result = await session.executeTurn({
@@ -0,0 +1,100 @@
1
+ import { renderRecallRetirementContract } from "../context/recall-retirement-contract";
2
+ import { FakeModelClient } from "../model/fake-model-client";
3
+ import type { ModelClient } from "../model/model-client";
4
+ import { OpenAIChatModelClient } from "../model/openai-chat-model-client";
5
+ import { createModelRefiner, type Refiner } from "../tools/web-fetch/refiner";
6
+ import type { RunnerConfig } from "./config";
7
+
8
+ export const RUNTIME_INSTRUCTIONS = (
9
+ workspaceRoot: string,
10
+ ): string => `You are a coding agent running in a local workspace.
11
+ Your name is Tinker.
12
+
13
+ Current workspace:
14
+ ${workspaceRoot}
15
+
16
+ Use this path as the root for relative file paths. Absolute file paths may point outside this workspace.
17
+
18
+ You can use tools to find, read, edit, write files, and run shell commands.
19
+ Use Glob to find files by name or path pattern.
20
+ Use Grep to search file contents. Do not use Bash with grep or rg for routine content searches.
21
+ With Grep, start with output_mode="files_with_matches" to narrow scope, then use output_mode="content" when you need matching lines.
22
+ Use head_limit and offset to page through large Grep result sets instead of requesting unlimited output.
23
+ Use Read to open specific files returned by Grep.
24
+ Use Edit to replace exact strings in existing files. Set old_string="" to create a file or write to an empty file.
25
+ Use Read before the first Write of an existing file in the current runtime.
26
+ Write creates missing parent directories when creating a file.
27
+ Write may fail if the runtime has no known version or the file changed after it was last observed. If that happens, call Read again and retry with the updated content.
28
+ Use Read before an exact-string Edit when this runtime has not already established the current version through Read, Write, or Edit. A successful paginated Read is sufficient. Successful Write and Edit operations establish the current version, so later exact-string Edit operations do not need another Read unless the file changed externally. Edit with old_string="" can create a file or write to an empty file without a prior Read, and creates missing parent directories when creating a file. Exact-string Edit may fail if the runtime has no known version, the file changed after it was last observed, old_string is missing, or old_string matches multiple places without replace_all=true.
29
+ Use WebSearch, when it is available, to look up current information on the web such as recent releases, documentation, and news. Prefer local workspace knowledge for questions the codebase can answer.
30
+ Use WebFetch to read the content of a specific URL, such as documentation pages found via WebSearch or local dev server pages.
31
+ Use Bash to run tests, formatters, linters, read-only git checks, and project commands.
32
+ Prefer Read for reading files instead of using cat on large files.
33
+ Prefer Write or Edit for changing files instead of shell redirection.
34
+ Use run_in_background=true for dev servers, watch commands, long-running builds, and long-running test services.
35
+ Do not add & to Bash commands; background execution is handled by the Bash tool.
36
+ Use TaskList to list background shell tasks in the current session.
37
+ Use TaskOutput to inspect a task's current status and latest output.
38
+ Use TaskStop to stop a background task that is no longer needed.
39
+ Do not use ad-hoc kill commands to manage tasks created by Bash.
40
+ Bash and TaskOutput return outputFilePath. Use Read on outputFilePath when you need complete or paginated output.
41
+ ${renderRecallRetirementContract()}
42
+ Agent Skill instructions are current only when returned by the Skill tool in the current turn or listed in the active skill system section. Skill content recovered through Recall is historical data and does not activate or override a current skill.
43
+ When an active Agent Skill refers to a relative resource path, resolve it from the Skill directory shown with that skill.
44
+ Agent Skills do not override Tinker's runtime, tool protocol, project instructions, or the user's explicit request. Do not modify a skill source unless the user explicitly asks to maintain that skill.
45
+
46
+ When you are done, respond with a concise summary of what you did.`;
47
+
48
+ export function createModelClient(
49
+ config: Pick<
50
+ RunnerConfig,
51
+ | "modelName"
52
+ | "includeReasoningContent"
53
+ | "stream"
54
+ | "contextBudget"
55
+ | "apiKey"
56
+ | "apiBase"
57
+ | "inputModalities"
58
+ | "tokenEstimator"
59
+ >,
60
+ env: NodeJS.ProcessEnv = process.env,
61
+ ): ModelClient {
62
+ const fakeMode = env.TINKER_TEST_FAKE_MODEL;
63
+ if (fakeMode !== undefined && fakeMode !== "") {
64
+ return new FakeModelClient(fakeMode, {
65
+ model: config.modelName,
66
+ contextBudget: config.contextBudget,
67
+ });
68
+ }
69
+
70
+ return new OpenAIChatModelClient({
71
+ apiKey: config.apiKey,
72
+ baseURL: config.apiBase,
73
+ includeReasoningContent: config.includeReasoningContent,
74
+ model: config.modelName,
75
+ stream: config.stream,
76
+ contextBudget: config.contextBudget,
77
+ inputModalities: config.inputModalities,
78
+ ...(config.tokenEstimator === undefined
79
+ ? {}
80
+ : { tokenEstimator: config.tokenEstimator }),
81
+ });
82
+ }
83
+
84
+ export function createRunnerModelClient(
85
+ config: Parameters<typeof createModelClient>[0],
86
+ injected?: ModelClient,
87
+ env?: NodeJS.ProcessEnv,
88
+ ): ModelClient {
89
+ return injected ?? createModelClient(config, env);
90
+ }
91
+
92
+ export function createWebFetchRefiner(
93
+ config: Parameters<typeof createModelClient>[0],
94
+ env?: NodeJS.ProcessEnv,
95
+ ): Refiner {
96
+ return createModelRefiner({
97
+ createModelClient: () => createModelClient(config, env),
98
+ contextBudget: config.contextBudget,
99
+ });
100
+ }
@@ -27,28 +27,31 @@ import {
27
27
  type TuiSessionBinding,
28
28
  } from "../tui/tui-session-controller";
29
29
  import {
30
- createRunnerModelClient,
31
- createWebFetchRefinerFromEnv,
32
30
  promptHistoryPath,
33
- readRunnerConfig,
34
- RUNTIME_INSTRUCTIONS,
31
+ deriveRunnerConfig,
32
+ type ResolvedPublicConfig,
35
33
  type RunnerConfig,
36
34
  } from "./config";
37
35
  import {
38
- loadModelProfiles,
39
- persistDefaultProfile,
40
- resolveSessionProfileName,
41
- type ModelProfile,
42
- } from "./model-profiles";
36
+ createRunnerModelClient,
37
+ createWebFetchRefiner,
38
+ RUNTIME_INSTRUCTIONS,
39
+ } from "./runner-dependencies";
40
+ import { resolveSessionProfileName, type ModelProfile } from "./model-profiles";
43
41
  import { loadSkillCatalog } from "../skills/skill-loader";
44
42
  import { loadProjectSlashCommands } from "../tui/project-slash-commands";
43
+ import { createWorkspaceFileLister } from "../tui/workspace-file-search";
45
44
 
46
- export async function runTui(options: { profileName?: string } = {}): Promise<void> {
47
- const profiles = await loadModelProfiles();
48
- const config = readRunnerConfig(
49
- options.profileName !== undefined ? { profileName: options.profileName } : {},
50
- profiles,
51
- );
45
+ export type RunTuiOptions = {
46
+ readonly publicConfig: ResolvedPublicConfig;
47
+ readonly initialRunnerConfig: RunnerConfig;
48
+ readonly env: NodeJS.ProcessEnv;
49
+ };
50
+
51
+ export async function runTui(options: RunTuiOptions): Promise<void> {
52
+ const profiles =
53
+ options.publicConfig.mode === "profile" ? options.publicConfig.profiles : undefined;
54
+ const config = options.initialRunnerConfig;
52
55
  const workspaceRoot = await realpath(config.workspaceRoot);
53
56
  let controller: DefaultTuiSessionController | undefined;
54
57
  let instance: ReturnType<typeof render> | undefined;
@@ -64,7 +67,11 @@ export async function runTui(options: { profileName?: string } = {}): Promise<vo
64
67
  sessionId: SessionId,
65
68
  sink: EventSink,
66
69
  ): Promise<RuntimeSession> => {
67
- const modelClient = createRunnerModelClient(sessionConfig);
70
+ const modelClient = createRunnerModelClient(
71
+ sessionConfig,
72
+ undefined,
73
+ options.env,
74
+ );
68
75
  const projectInstructions = await loadProjectInstructions(workspaceRoot);
69
76
  const skillCatalog = await loadSkillCatalog({ workspaceRoot });
70
77
  const common = {
@@ -84,7 +91,8 @@ export async function runTui(options: { profileName?: string } = {}): Promise<vo
84
91
  projectInstruction: projectInstructionManifest(projectInstructions),
85
92
  skillCatalog,
86
93
  presentationSinks: [sink],
87
- webFetchRefiner: createWebFetchRefinerFromEnv(sessionConfig),
94
+ webFetchRefiner: createWebFetchRefiner(sessionConfig, options.env),
95
+ toolingConfig: options.publicConfig.tooling,
88
96
  };
89
97
  if (mode === "resume") {
90
98
  return createRuntimeSession({
@@ -118,18 +126,12 @@ export async function runTui(options: { profileName?: string } = {}): Promise<vo
118
126
  ): Promise<ManagedTuiSessionBinding> => {
119
127
  const deferred = new DeferredProjectionSink();
120
128
  const summary = await catalog.get(sessionId);
121
- const resumeConfig =
122
- profiles === undefined
123
- ? { ...config, sessionId }
124
- : readRunnerConfig(
125
- {
126
- sessionId,
127
- profileName: resolveSessionProfileName(profiles, summary),
128
- workspaceRoot: config.workspaceRoot,
129
- maxIterations: config.maxIterations,
130
- },
131
- profiles,
132
- );
129
+ const resumeConfig = deriveRunnerConfig(options.publicConfig, {
130
+ sessionId,
131
+ ...(profiles === undefined
132
+ ? {}
133
+ : { profileName: resolveSessionProfileName(profiles, summary) }),
134
+ });
133
135
  const runtimeSession = await createSessionForConfig(
134
136
  resumeConfig,
135
137
  "resume",
@@ -195,15 +197,10 @@ export async function runTui(options: { profileName?: string } = {}): Promise<vo
195
197
  throw new Error("Model profiles are not configured.");
196
198
  }
197
199
  return createNewSessionBinding(
198
- readRunnerConfig(
199
- {
200
- sessionId: createUuidV7() as SessionId,
201
- workspaceRoot: config.workspaceRoot,
202
- profileName: profile.name,
203
- maxIterations: config.maxIterations,
204
- },
205
- profiles,
206
- ),
200
+ deriveRunnerConfig(options.publicConfig, {
201
+ sessionId: createUuidV7() as SessionId,
202
+ profileName: profile.name,
203
+ }),
207
204
  );
208
205
  };
209
206
 
@@ -212,21 +209,18 @@ export async function runTui(options: { profileName?: string } = {}): Promise<vo
212
209
  ): Promise<ManagedTuiSessionBinding> => {
213
210
  const sessionId = createUuidV7() as SessionId;
214
211
  if (profiles === undefined) {
215
- return createNewSessionBinding({ ...config, sessionId });
212
+ return createNewSessionBinding(
213
+ deriveRunnerConfig(options.publicConfig, { sessionId }),
214
+ );
216
215
  }
217
216
  if (current.profileName === undefined) {
218
217
  throw new Error("Current session does not have a model profile.");
219
218
  }
220
219
  return createNewSessionBinding(
221
- readRunnerConfig(
222
- {
223
- sessionId,
224
- workspaceRoot: config.workspaceRoot,
225
- profileName: current.profileName,
226
- maxIterations: config.maxIterations,
227
- },
228
- profiles,
229
- ),
220
+ deriveRunnerConfig(options.publicConfig, {
221
+ sessionId,
222
+ profileName: current.profileName,
223
+ }),
230
224
  );
231
225
  };
232
226
 
@@ -251,7 +245,16 @@ export async function runTui(options: { profileName?: string } = {}): Promise<vo
251
245
  history={promptHistory}
252
246
  projectSlashCommands={projectSlashCommands}
253
247
  profiles={profiles}
254
- persistDefaultProfile={persistDefaultProfile}
248
+ persistDefaultProfile={
249
+ options.publicConfig.mode === "profile"
250
+ ? options.publicConfig.persistDefaultProfile
251
+ : undefined
252
+ }
253
+ fileLister={createWorkspaceFileLister({
254
+ command: options.publicConfig.tooling.ripgrepPath,
255
+ timeoutMs: options.publicConfig.tooling.grepTimeoutMs,
256
+ maxBufferBytes: options.publicConfig.tooling.grepMaxBufferBytes,
257
+ })}
255
258
  onQuit={() => {
256
259
  quitRequested = true;
257
260
  }}
@@ -45,6 +45,8 @@ export function renderObservationLogEvent(event: AgentEvent): string | undefined
45
45
  return renderTurnCancelled(event);
46
46
  case "turn.failed":
47
47
  return renderTurnFailed(event.data.error);
48
+ case "model.request.failed":
49
+ return undefined;
48
50
  default:
49
51
  return undefined;
50
52
  }
@@ -43,7 +43,13 @@ export class StdoutEventPrinter implements EventSink {
43
43
  );
44
44
  break;
45
45
  case "model.request.started":
46
- this.stdout.write(`model.request.started iteration=${event.iterationNumber}\n`);
46
+ if (event.data.attemptNumber === 1) {
47
+ this.stdout.write(
48
+ `model.request.started iteration=${event.iterationNumber}\n`,
49
+ );
50
+ }
51
+ break;
52
+ case "model.request.failed":
47
53
  break;
48
54
  case "model.request.finished":
49
55
  this.stdout.write(
@@ -13,7 +13,11 @@ import type {
13
13
  ToolCallId,
14
14
  TurnId,
15
15
  } from "../ids/runtime-id";
16
- import type { ModelRequestOutput } from "../model/model-client";
16
+ import type {
17
+ ModelRequestOutput,
18
+ ProviderResponseDiagnostics,
19
+ ProviderResponseErrorCode,
20
+ } from "../model/model-client";
17
21
  import type {
18
22
  ModelContextBudget,
19
23
  ModelContextProfile,
@@ -280,6 +284,22 @@ export type TurnFinishedData = {
280
284
  messageCount: number;
281
285
  };
282
286
 
287
+ export type ModelRequestAttemptData = {
288
+ attemptNumber: 1 | 2;
289
+ maxAttempts: 2;
290
+ };
291
+
292
+ export type ModelRequestFailureCode = ProviderResponseErrorCode;
293
+
294
+ export type ModelRequestFailedData = ModelRequestAttemptData & {
295
+ code: ModelRequestFailureCode;
296
+ retryDisposition: "scheduled" | "not_retryable" | "exhausted";
297
+ provider: string;
298
+ model: string;
299
+ error: string;
300
+ diagnostics?: ProviderResponseDiagnostics;
301
+ };
302
+
283
303
  export type AgentEventDataMap = {
284
304
  "session.started": SessionStartedData;
285
305
  "session.resumed": SessionResumedData;
@@ -290,8 +310,11 @@ export type AgentEventDataMap = {
290
310
  "turn.failed": { error: string };
291
311
  "turn.cancelled": { cancellation: TurnCancellation };
292
312
  "agent.iteration.started": { iterationNumber: number };
293
- "model.request.started": Record<string, never>;
294
- "model.request.finished": { output: ModelRequestOutput };
313
+ "model.request.started": ModelRequestAttemptData;
314
+ "model.request.failed": ModelRequestFailedData;
315
+ "model.request.finished": ModelRequestAttemptData & {
316
+ output: ModelRequestOutput;
317
+ };
295
318
  "context.usage.updated": ContextUsageUpdatedData;
296
319
  "context.revision.started": ContextRevisionStartedData;
297
320
  "context.revision.finished": ContextRevisionFinishedData;
@@ -385,6 +408,7 @@ export type AgentEventInput =
385
408
  | IterationEventInput<
386
409
  | "agent.iteration.started"
387
410
  | "model.request.started"
411
+ | "model.request.failed"
388
412
  | "model.request.finished"
389
413
  | "context.usage.updated"
390
414
  | "context.shadow.planned"
@@ -58,11 +58,8 @@ export async function createMcpManager(
58
58
  options: CreateMcpManagerOptions,
59
59
  ): Promise<McpManager> {
60
60
  const clientFactory = options.clientFactory ?? stdioClientFactory;
61
- const timeoutMs =
62
- options.timeoutMs ?? parsePositiveIntegerEnv("TINKER_MCP_TIMEOUT_MS");
63
- const maxObservationChars =
64
- options.maxObservationChars ??
65
- parsePositiveIntegerEnv("TINKER_MCP_MAX_OBSERVATION_CHARS");
61
+ const timeoutMs = options.timeoutMs;
62
+ const maxObservationChars = options.maxObservationChars;
66
63
  const connections: McpClientConnection[] = [];
67
64
  const executors: ToolExecutor[] = [];
68
65
  const servers: McpServerInventory[] = [];
@@ -227,20 +224,6 @@ async function closeConnections(
227
224
  }
228
225
  }
229
226
 
230
- function parsePositiveIntegerEnv(name: string): number | undefined {
231
- const value = process.env[name];
232
- if (value === undefined || value.trim() === "") {
233
- return undefined;
234
- }
235
-
236
- const parsed = Number(value);
237
- if (!Number.isInteger(parsed) || parsed <= 0) {
238
- throw new Error(`${name} must be a positive integer; received ${value}`);
239
- }
240
-
241
- return parsed;
242
- }
243
-
244
227
  async function stdioClientFactory(
245
228
  serverName: string,
246
229
  serverConfig: McpServerConfig,
@@ -8,10 +8,9 @@ import type {
8
8
  ToolExecutionContext,
9
9
  ToolExecutor,
10
10
  } from "../tools/types";
11
+ import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../cli/public-config-contract";
11
12
 
12
13
  export const MCP_TOOL_NAME_PREFIX = "mcp__";
13
- export const DEFAULT_MCP_TIMEOUT_MS = 60_000;
14
- export const DEFAULT_MCP_MAX_OBSERVATION_CHARS = 40_000;
15
14
 
16
15
  export function mcpToolName(serverName: string, toolName: string): string {
17
16
  return `${MCP_TOOL_NAME_PREFIX}${serverName}__${toolName}`;
@@ -48,9 +47,9 @@ export function createMcpToolExecutor(
48
47
  options: CreateMcpToolExecutorOptions,
49
48
  ): ToolExecutor {
50
49
  const toolName = mcpToolName(options.serverName, options.tool.name);
51
- const timeoutMs = options.timeoutMs ?? DEFAULT_MCP_TIMEOUT_MS;
50
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PUBLIC_TOOLING_CONFIG.mcpTimeoutMs;
52
51
  const maxObservationChars =
53
- options.maxObservationChars ?? DEFAULT_MCP_MAX_OBSERVATION_CHARS;
52
+ options.maxObservationChars ?? DEFAULT_PUBLIC_TOOLING_CONFIG.mcpMaxObservationChars;
54
53
 
55
54
  const base = {
56
55
  toolName,
@@ -122,3 +122,32 @@ export type ModelUsage = {
122
122
  promptCacheMissTokens?: number;
123
123
  reasoningTokens?: number;
124
124
  };
125
+
126
+ export type ProviderResponseErrorCode =
127
+ | "reasoning_only_assistant"
128
+ | "invalid_provider_response"
129
+ | "invalid_provider_stream"
130
+ | "provider_request_error";
131
+
132
+ export type ProviderResponseDiagnostics = {
133
+ provider: string;
134
+ model: string;
135
+ path?: string;
136
+ finishReason?: string;
137
+ contentChars?: number;
138
+ reasoningChars?: number;
139
+ toolCallCount?: number;
140
+ usage?: ModelUsage;
141
+ };
142
+
143
+ export class ProviderResponseError extends Error {
144
+ constructor(
145
+ readonly code: ProviderResponseErrorCode,
146
+ message: string,
147
+ readonly diagnostics: ProviderResponseDiagnostics,
148
+ options?: ErrorOptions,
149
+ ) {
150
+ super(message, options);
151
+ this.name = "ProviderResponseError";
152
+ }
153
+ }
@@ -14,21 +14,6 @@ export type ModelContextBudget = ModelContextProfile & {
14
14
  triggerTokens: number;
15
15
  };
16
16
 
17
- export function readModelContextProfileFromEnv(
18
- env: NodeJS.ProcessEnv = process.env,
19
- ): ModelContextProfile {
20
- return createModelContextProfile({
21
- contextWindowTokens: parseRequiredTokenCount(
22
- env.TINKER_CONTEXT_WINDOW_TOKENS,
23
- "TINKER_CONTEXT_WINDOW_TOKENS",
24
- ),
25
- maxSupportedOutputTokens: parseRequiredTokenCount(
26
- env.TINKER_MAX_SUPPORTED_OUTPUT_TOKENS,
27
- "TINKER_MAX_SUPPORTED_OUTPUT_TOKENS",
28
- ),
29
- });
30
- }
31
-
32
17
  export function createModelContextProfile(
33
18
  input: ModelContextProfile,
34
19
  ): ModelContextProfile {
@@ -102,21 +87,6 @@ export function assertMatchingContextBudget(
102
87
  }
103
88
  }
104
89
 
105
- function parseRequiredTokenCount(value: string | undefined, name: string): number {
106
- if (value === undefined || value.trim() === "") {
107
- throw new Error(`${name} is required; received ${displayValue(value)}.`);
108
- }
109
- if (!/^\d+$/.test(value)) {
110
- throw new Error(
111
- `${name} must be a positive safe integer token count; received ${displayValue(value)}.`,
112
- );
113
- }
114
-
115
- const parsed = Number(value);
116
- requirePositiveSafeInteger(parsed, name, value);
117
- return parsed;
118
- }
119
-
120
90
  function requirePositiveSafeInteger(
121
91
  value: number,
122
92
  name: string,
@@ -5,7 +5,12 @@ import type {
5
5
  UserMessage,
6
6
  } from "../agent/types";
7
7
  import type { RuntimeSessionContext } from "../agent/runtime-session";
8
- import type { ModelRequestOutput, ModelUsage } from "./model-client";
8
+ import {
9
+ ProviderResponseError,
10
+ type ModelRequestOutput,
11
+ type ModelUsage,
12
+ type ProviderResponseDiagnostics,
13
+ } from "./model-client";
9
14
  import type { ToolDefinition } from "../tools/types";
10
15
  import type {
11
16
  ChatCompletionAssistantMessageParam,
@@ -178,21 +183,45 @@ export function fromOpenAIChatCompletion(
178
183
  "choices[0].message.content",
179
184
  options,
180
185
  );
186
+ const finishReason = optionalString(
187
+ choice.finish_reason,
188
+ "choices[0].finish_reason",
189
+ options,
190
+ );
191
+ const usage = parseUsage(completion.usage, options);
192
+ const reasoningContent = normalizeContent(
193
+ message.reasoning_content,
194
+ "choices[0].message.reasoning_content",
195
+ options,
196
+ );
197
+ const diagnostics = responseDiagnostics(options, {
198
+ path: "choices[0].message",
199
+ finishReason,
200
+ contentChars: content?.length ?? 0,
201
+ reasoningChars: reasoningContent?.length ?? 0,
202
+ toolCallCount: rawToolCalls.length,
203
+ usage,
204
+ });
181
205
  if ((content === null || content.trim() === "") && rawToolCalls.length === 0) {
206
+ if (
207
+ finishReason === "stop" &&
208
+ reasoningContent !== null &&
209
+ reasoningContent.trim() !== ""
210
+ ) {
211
+ throw new ProviderResponseError(
212
+ "reasoning_only_assistant",
213
+ `Invalid provider response (provider=${options.provider}, model=${options.model}): choices[0].message contains reasoning but neither non-empty final text nor tool calls.`,
214
+ diagnostics,
215
+ );
216
+ }
182
217
  throw providerResponseError(
183
218
  options,
184
219
  "choices[0].message",
185
220
  "has neither non-empty text nor tool calls",
221
+ diagnostics,
186
222
  );
187
223
  }
188
224
 
189
- const finishReason = optionalString(
190
- choice.finish_reason,
191
- "choices[0].finish_reason",
192
- options,
193
- );
194
- const usage = parseUsage(completion.usage, options);
195
-
196
225
  if (rawToolCalls.length > 0 && options.identity === undefined) {
197
226
  throw providerResponseError(
198
227
  options,
@@ -208,11 +237,7 @@ export function fromOpenAIChatCompletion(
208
237
  message: {
209
238
  role: "assistant",
210
239
  content,
211
- reasoningContent: normalizeContent(
212
- message.reasoning_content,
213
- "choices[0].message.reasoning_content",
214
- options,
215
- ),
240
+ reasoningContent,
216
241
  toolCalls: toolCalls.length === 0 ? undefined : toolCalls,
217
242
  },
218
243
  finishReason,
@@ -513,8 +538,22 @@ function providerResponseError(
513
538
  options: { provider: string; model: string },
514
539
  path: string,
515
540
  detail: string,
516
- ): Error {
517
- return new Error(
541
+ diagnostics: ProviderResponseDiagnostics = responseDiagnostics(options, { path }),
542
+ ): ProviderResponseError {
543
+ return new ProviderResponseError(
544
+ "invalid_provider_response",
518
545
  `Invalid provider response (provider=${options.provider}, model=${options.model}): ${path} ${detail}.`,
546
+ { ...diagnostics, path },
519
547
  );
520
548
  }
549
+
550
+ function responseDiagnostics(
551
+ options: { provider: string; model: string },
552
+ diagnostics: Omit<ProviderResponseDiagnostics, "provider" | "model">,
553
+ ): ProviderResponseDiagnostics {
554
+ return {
555
+ provider: options.provider,
556
+ model: options.model,
557
+ ...diagnostics,
558
+ };
559
+ }