tinker-agent 2.9.0 → 2.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 (61) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/README.md +30 -1
  3. package/package.json +5 -3
  4. package/src/agent/loop.ts +50 -13
  5. package/src/agent/runtime-hosted-session.ts +443 -0
  6. package/src/agent/runtime-provider-retry.ts +115 -0
  7. package/src/agent/runtime-session-contracts.ts +11 -0
  8. package/src/agent/runtime-session.ts +29 -0
  9. package/src/cli/command-line.ts +26 -2
  10. package/src/cli/connect-runner.tsx +26 -0
  11. package/src/cli/main.ts +26 -0
  12. package/src/cli/output.ts +1 -1
  13. package/src/cli/public-cli-contract.ts +18 -0
  14. package/src/cli/public-config-contract.ts +1 -1
  15. package/src/cli/serve-runner.ts +45 -0
  16. package/src/cli/serve-runtime.ts +100 -0
  17. package/src/cli/tui-runner.tsx +1 -0
  18. package/src/context/context-swap-renderer.ts +14 -0
  19. package/src/events/types.ts +8 -0
  20. package/src/image/abortable-file-open.ts +54 -0
  21. package/src/image/image-asset-store.ts +7 -1
  22. package/src/model/fake-model-client.ts +20 -1
  23. package/src/model/openai-model-utils.ts +45 -1
  24. package/src/model/openai-responses-mapping.ts +11 -0
  25. package/src/model/openai-responses-stream.ts +14 -0
  26. package/src/observation/observation-builder.ts +87 -37
  27. package/src/remote/client.ts +350 -0
  28. package/src/remote/config.ts +95 -0
  29. package/src/remote/http-server.ts +240 -0
  30. package/src/remote/protocol.ts +228 -0
  31. package/src/remote/service-store.ts +175 -0
  32. package/src/remote/service.ts +219 -0
  33. package/src/remote/sync-hub.ts +95 -0
  34. package/src/session/remote-history-reader.ts +143 -0
  35. package/src/session/scoped-query-database.ts +27 -0
  36. package/src/session/session-history-access.ts +4 -3
  37. package/src/session/session-store.ts +7 -3
  38. package/src/tools/bash-task.ts +26 -16
  39. package/src/tools/bash.ts +44 -2
  40. package/src/tools/glob.ts +107 -19
  41. package/src/tools/grep-output.ts +130 -0
  42. package/src/tools/grep-pagination.ts +73 -0
  43. package/src/tools/grep-path.ts +11 -0
  44. package/src/tools/grep-snippets.ts +111 -0
  45. package/src/tools/grep.ts +148 -155
  46. package/src/tools/read.ts +0 -9
  47. package/src/tools/ripgrep.ts +19 -26
  48. package/src/tools/shell-process.ts +30 -4
  49. package/src/tools/task-stop.ts +2 -1
  50. package/src/tools/terminal-screen.ts +11 -2
  51. package/src/tools/types.ts +30 -2
  52. package/src/tui/app.tsx +45 -3
  53. package/src/tui/components/ask-user.tsx +15 -8
  54. package/src/tui/components/prompt-input.tsx +14 -6
  55. package/src/tui/components/timeline.tsx +17 -9
  56. package/src/tui/event-store.ts +25 -2
  57. package/src/tui/file-mention.ts +29 -5
  58. package/src/tui/remote-app.tsx +210 -0
  59. package/src/tui/tui-projection-store.ts +5 -2
  60. package/src/tui/tui-session-controller.ts +8 -0
  61. package/src/tui/workspace-file-search.ts +21 -0
@@ -0,0 +1,26 @@
1
+ import { render } from "ink";
2
+ import { RemoteClient, loadRemoteClientConfig } from "../remote/client";
3
+ import { RemoteApp } from "../tui/remote-app";
4
+ import type { CliOutputWriter } from "./output";
5
+
6
+ export async function runConnect(input: {
7
+ configPath: string;
8
+ env: NodeJS.ProcessEnv;
9
+ stdout: CliOutputWriter;
10
+ }): Promise<number> {
11
+ if (!process.stdin.isTTY)
12
+ throw new Error("tinker connect requires an interactive terminal.");
13
+ const client = new RemoteClient(await loadRemoteClientConfig(input.configPath));
14
+ let instance: ReturnType<typeof render> | undefined;
15
+ try {
16
+ await client.initialize();
17
+ instance = render(<RemoteApp client={client} />, { incrementalRendering: true });
18
+ await instance.waitUntilExit();
19
+ return 0;
20
+ } finally {
21
+ instance?.unmount();
22
+ await client.close();
23
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
24
+ process.stdin.pause();
25
+ }
26
+ }
package/src/cli/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SessionId } from "../ids/runtime-id";
2
+ import path from "node:path";
2
3
  import { createUuidV7 } from "../ids/uuid-v7";
3
4
  import { parseCommandLine, type CommandLineResult } from "./command-line";
4
5
  import type {
@@ -91,6 +92,8 @@ export type MainDependencies = {
91
92
  readonly loadTuiRunner: () => Promise<TuiRunner>;
92
93
  readonly loadOneShotRunner: () => Promise<OneShotRunner>;
93
94
  readonly loadUpdateRunner: () => Promise<UpdateRunner>;
95
+ readonly loadServeRunner: () => Promise<typeof import("./serve-runner")>;
96
+ readonly loadConnectRunner: () => Promise<typeof import("./connect-runner")>;
94
97
  };
95
98
 
96
99
  const DEFAULT_DEPENDENCIES: MainDependencies = {
@@ -102,6 +105,8 @@ const DEFAULT_DEPENDENCIES: MainDependencies = {
102
105
  loadTuiRunner: () => import("./tui-runner"),
103
106
  loadOneShotRunner: () => import("./run-runner"),
104
107
  loadUpdateRunner: () => import("./update-runner"),
108
+ loadServeRunner: () => import("./serve-runner"),
109
+ loadConnectRunner: () => import("./connect-runner"),
105
110
  };
106
111
 
107
112
  export async function main(
@@ -160,6 +165,27 @@ export async function main(
160
165
  }
161
166
  }
162
167
 
168
+ if (parsed.command.type === "serve" || parsed.command.type === "connect") {
169
+ const options = {
170
+ configPath: path.resolve(cwd, parsed.command.configPath),
171
+ env,
172
+ stdout: input.stdout,
173
+ };
174
+ try {
175
+ const exitCode =
176
+ parsed.command.type === "serve"
177
+ ? await (await dependencies.loadServeRunner()).runServe(options)
178
+ : await (await dependencies.loadConnectRunner()).runConnect(options);
179
+ return finish(exitCode);
180
+ } catch (error) {
181
+ await writeCliOutput(
182
+ input.stderr,
183
+ renderCliFailure("Remote operation failed", error),
184
+ );
185
+ return finish(1);
186
+ }
187
+ }
188
+
163
189
  let configBoundary: ConfigBoundary;
164
190
  let publicConfig: ResolvedPublicConfig;
165
191
  let runnerConfig: RunnerConfig;
package/src/cli/output.ts CHANGED
@@ -4,7 +4,7 @@ const TRUNCATION_MARKER = "...[truncated]";
4
4
  const ESCAPE = String.fromCharCode(27);
5
5
  const ANSI_CSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-?]*[ -/]*[@-~]`, "g");
6
6
 
7
- export type CliCommandScope = "root" | "run" | "update";
7
+ export type CliCommandScope = "root" | "run" | "update" | "serve" | "connect";
8
8
 
9
9
  export interface CliOutputWriter {
10
10
  write(chunk: string): boolean | void;
@@ -47,6 +47,24 @@ export const PUBLIC_CLI_CONTRACT = Object.freeze({
47
47
  description: "Start the interactive terminal interface.",
48
48
  profileOption: PROFILE_OPTION,
49
49
  }),
50
+ serve: Object.freeze({
51
+ command: "serve",
52
+ description: "Run the local daemon for paired remote clients.",
53
+ configOption: Object.freeze({
54
+ flags: "--config <path>",
55
+ description: "Read the service JSON configuration.",
56
+ valueName: "path",
57
+ } satisfies PublicCliOption),
58
+ }),
59
+ connect: Object.freeze({
60
+ command: "connect",
61
+ description: "Attach a terminal client to a service; exiting detaches only.",
62
+ configOption: Object.freeze({
63
+ flags: "--config <path>",
64
+ description: "Read the paired client JSON configuration.",
65
+ valueName: "path",
66
+ } satisfies PublicCliOption),
67
+ }),
50
68
  run: Object.freeze({
51
69
  command: "run [prompt]",
52
70
  description: "Run one prompt non-interactively.",
@@ -174,7 +174,7 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
174
174
  valueKind: "positive-integer",
175
175
  requiredIn: "never",
176
176
  appliesIn: "always",
177
- defaultValue: 5_000,
177
+ defaultValue: 60_000,
178
178
  secret: false,
179
179
  section: "tooling",
180
180
  description: "Default Bash foreground timeout in milliseconds.",
@@ -0,0 +1,45 @@
1
+ import { loadRemoteConfig } from "../remote/config";
2
+ import { startRemoteHttpServer } from "../remote/http-server";
3
+ import { RemoteService } from "../remote/service";
4
+ import { RemoteServiceStore } from "../remote/service-store";
5
+ import { defaultHomeRoot } from "../session/workspace-storage";
6
+ import { createHostedRuntimeFactory } from "./serve-runtime";
7
+ import { writeCliOutput, type CliOutputWriter } from "./output";
8
+
9
+ export async function runServe(input: {
10
+ configPath: string;
11
+ env: NodeJS.ProcessEnv;
12
+ stdout: CliOutputWriter;
13
+ }): Promise<number> {
14
+ const config = await loadRemoteConfig(input.configPath);
15
+ const homeRoot = defaultHomeRoot(input.env);
16
+ const store = await RemoteServiceStore.open(config.stateDirectory);
17
+ const service = new RemoteService(
18
+ store,
19
+ config.workspaces,
20
+ createHostedRuntimeFactory(config.workspaces, input.env, homeRoot),
21
+ homeRoot,
22
+ );
23
+ let transport: ReturnType<typeof startRemoteHttpServer> | undefined;
24
+ try {
25
+ await service.initialize();
26
+ transport = startRemoteHttpServer(service, config);
27
+ await writeCliOutput(
28
+ input.stdout,
29
+ `Tinker service listening on https://${config.hostname}:${transport.port}; ${config.workspaces.length} workspace(s).\nClient disconnects detach only. Stop the process to shut down hosted sessions.\n`,
30
+ );
31
+ await new Promise<void>((resolve) => {
32
+ const stop = () => {
33
+ process.off("SIGINT", stop);
34
+ process.off("SIGTERM", stop);
35
+ resolve();
36
+ };
37
+ process.once("SIGINT", stop);
38
+ process.once("SIGTERM", stop);
39
+ });
40
+ return 0;
41
+ } finally {
42
+ await transport?.stopTransport();
43
+ await service.close();
44
+ }
45
+ }
@@ -0,0 +1,100 @@
1
+ import { createRuntimeSession } from "../agent/runtime-session";
2
+ import { parseSessionId } from "../ids/runtime-id";
3
+ import {
4
+ buildSystemPrompt,
5
+ loadProjectInstructions,
6
+ projectInstructionManifest,
7
+ } from "../instructions/project-instructions";
8
+ import { createReasoningEffortController } from "../model/reasoning-effort";
9
+ import { resolveSessionDatabasePath } from "../session/session-store";
10
+ import { SessionCatalog } from "../session/session-catalog";
11
+ import { loadSkillCatalog } from "../skills/skill-loader";
12
+ import type { RemoteWorkspaceConfig } from "../remote/config";
13
+ import type { HostedRuntimeFactory } from "../agent/runtime-hosted-session";
14
+ import { deriveRunnerConfig, resolvePublicConfig } from "./config";
15
+ import { resolveSessionProfileName } from "./model-profiles";
16
+ import {
17
+ createRunnerModelClient,
18
+ createWebFetchRefiner,
19
+ RUNTIME_INSTRUCTIONS,
20
+ } from "./runner-dependencies";
21
+
22
+ /** Service composition reuses the existing configuration/provider/runtime contracts. */
23
+ export function createHostedRuntimeFactory(
24
+ workspaces: readonly RemoteWorkspaceConfig[],
25
+ env: NodeJS.ProcessEnv,
26
+ homeRoot?: string,
27
+ ): HostedRuntimeFactory {
28
+ return async ({ record, sink }) => {
29
+ const workspace = workspaces.find((entry) => entry.id === record.workspaceId);
30
+ if (!workspace || workspace.path !== record.workspacePath)
31
+ throw new Error("Managed workspace configuration changed.");
32
+ const sessionId = parseSessionId(record.id);
33
+ const publicConfig = await resolvePublicConfig({
34
+ env: { ...env, TINKER_WORKSPACE: workspace.path },
35
+ cwd: workspace.path,
36
+ });
37
+ let profileName = workspace.profile;
38
+ if (record.initialized && publicConfig.mode === "profile") {
39
+ const summary = await new SessionCatalog({
40
+ workspaceRoot: workspace.path,
41
+ homeRoot,
42
+ }).get(sessionId);
43
+ profileName = resolveSessionProfileName(publicConfig.profiles, summary);
44
+ }
45
+ const config = deriveRunnerConfig(publicConfig, {
46
+ sessionId,
47
+ ...(profileName ? { profileName } : {}),
48
+ });
49
+ const reasoning = createReasoningEffortController(config.reasoning);
50
+ const projectInstructions = await loadProjectInstructions(workspace.path);
51
+ const runtime = await createRuntimeSession({
52
+ workspaceRoot: workspace.path,
53
+ ...(homeRoot === undefined ? {} : { homeRoot }),
54
+ ...(record.initialized
55
+ ? { selection: { mode: "resume" as const, sessionId } }
56
+ : { selection: { mode: "new" as const, sessionId } }),
57
+ modelName: config.modelName,
58
+ profileName: config.profileName,
59
+ maxIterations: config.maxIterations,
60
+ includeReasoningContent: config.includeReasoningContent,
61
+ contextProfile: config.contextProfile,
62
+ contextBudget: config.contextBudget,
63
+ modelClient: createRunnerModelClient(config, undefined, env, reasoning),
64
+ systemPrompt: buildSystemPrompt({
65
+ workspaceRoot: workspace.path,
66
+ runtimeInstructions: RUNTIME_INSTRUCTIONS(workspace.path),
67
+ projectInstructions,
68
+ }),
69
+ projectInstruction: projectInstructionManifest(projectInstructions),
70
+ skillCatalog: await loadSkillCatalog({ workspaceRoot: workspace.path }),
71
+ presentationSinks: [sink],
72
+ assistantTextDeltaSink: sink,
73
+ toolingConfig: publicConfig.tooling,
74
+ webFetchRefiner: createWebFetchRefiner(config, env, reasoning),
75
+ enableAskUser: true,
76
+ bashGuard: {
77
+ mode: config.bashGuardMode,
78
+ source: config.bashGuardSource,
79
+ surface: "tui",
80
+ },
81
+ });
82
+ try {
83
+ return {
84
+ runtime,
85
+ databasePath: await resolveSessionDatabasePath(
86
+ workspace.path,
87
+ sessionId,
88
+ homeRoot,
89
+ ),
90
+ modelName: config.modelName,
91
+ };
92
+ } catch (error) {
93
+ await runtime.dispose({
94
+ type: "initialization_failed",
95
+ error: "Cannot open remote history reader.",
96
+ });
97
+ throw error;
98
+ }
99
+ };
100
+ }
@@ -119,6 +119,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
119
119
  toolingConfig: options.publicConfig.tooling,
120
120
  enableTurnUndo: true,
121
121
  enableAskUser: true,
122
+ enableProviderRetryPrompt: true,
122
123
  bashGuard: {
123
124
  mode: sessionConfig.bashGuardMode,
124
125
  source: sessionConfig.bashGuardSource,
@@ -193,6 +193,11 @@ function metadataEntries(
193
193
  ["pattern", raw.pattern],
194
194
  ["searchPath", raw.searchPath],
195
195
  ["matchCount", raw.matchCount],
196
+ ["totalMatches", raw.totalMatches],
197
+ ["returnedCount", raw.returnedCount],
198
+ ["appliedOffset", raw.appliedOffset],
199
+ ["hasMore", raw.hasMore],
200
+ ["nextOffset", raw.nextOffset],
196
201
  ];
197
202
  case "grep":
198
203
  return [
@@ -200,6 +205,15 @@ function metadataEntries(
200
205
  ["searchPath", raw.searchPath],
201
206
  ["mode", raw.mode],
202
207
  ["numMatches", raw.numMatches],
208
+ ["numLines", raw.numLines],
209
+ ["paginationUnit", raw.paginationUnit],
210
+ ["totalResults", raw.totalResults],
211
+ ["returnedResults", raw.returnedResults],
212
+ ["appliedOffset", raw.appliedOffset],
213
+ ["hasMore", raw.hasMore],
214
+ ["nextOffset", raw.nextOffset],
215
+ ["searchIncomplete", raw.searchIncomplete],
216
+ ["contextMayBeIncomplete", raw.contextMayBeIncomplete],
203
217
  ["truncated", raw.truncated],
204
218
  ];
205
219
  case "bash":
@@ -337,6 +337,12 @@ export type AgentEventDataMap = {
337
337
  "agent.iteration.started": { iterationNumber: number };
338
338
  "model.request.started": ModelRequestAttemptData;
339
339
  "model.request.failed": ModelRequestFailedData;
340
+ "model.retry.requested": ModelRequestFailedData;
341
+ "model.retry.resolved": {
342
+ attemptNumber: number;
343
+ decision: "retry" | "stop" | "cancelled";
344
+ durationMs: number;
345
+ };
340
346
  "model.request.finished": ModelRequestAttemptData & {
341
347
  output: ModelRequestOutput;
342
348
  };
@@ -457,6 +463,8 @@ export type AgentEventInput =
457
463
  | "agent.iteration.started"
458
464
  | "model.request.started"
459
465
  | "model.request.failed"
466
+ | "model.retry.requested"
467
+ | "model.retry.resolved"
460
468
  | "model.request.finished"
461
469
  | "context.usage.updated"
462
470
  | "context.shadow.planned"
@@ -0,0 +1,54 @@
1
+ import type { FileHandle } from "node:fs/promises";
2
+
3
+ // An OS permission prompt can leave open() pending without accepting a signal.
4
+ // Cancellation releases the caller; ownership of a late handle stays here.
5
+ export function abortableFileOpen(
6
+ openFile: () => Promise<FileHandle>,
7
+ signal?: AbortSignal,
8
+ onWarning?: (message: string) => void,
9
+ ): Promise<FileHandle> {
10
+ signal?.throwIfAborted();
11
+ if (signal === undefined) return openFile();
12
+
13
+ return new Promise<FileHandle>((resolve, reject) => {
14
+ let cancelled = false;
15
+ const onAbort = () => {
16
+ cancelled = true;
17
+ signal.removeEventListener("abort", onAbort);
18
+ reject(asError(signal.reason));
19
+ };
20
+ signal.addEventListener("abort", onAbort, { once: true });
21
+ // Also handle a synchronous failure from the opener without leaking a listener.
22
+ Promise.resolve()
23
+ .then(() => {
24
+ signal.throwIfAborted();
25
+ return openFile();
26
+ })
27
+ .then(async (handle) => {
28
+ signal.removeEventListener("abort", onAbort);
29
+ if (cancelled) {
30
+ try {
31
+ await handle.close();
32
+ } catch (error) {
33
+ onWarning?.(
34
+ `Failed to close cancelled image file: ${error instanceof Error ? error.message : String(error)}.`,
35
+ );
36
+ }
37
+ } else {
38
+ resolve(handle);
39
+ }
40
+ })
41
+ .catch((error: unknown) => {
42
+ signal.removeEventListener("abort", onAbort);
43
+ if (cancelled) {
44
+ // Late open failures must not become unhandled rejections.
45
+ return;
46
+ }
47
+ reject(asError(error));
48
+ });
49
+ });
50
+ }
51
+
52
+ function asError(error: unknown): Error {
53
+ return error instanceof Error ? error : new Error(String(error), { cause: error });
54
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import path from "node:path";
14
14
  import { createUuidV7 } from "../ids/uuid-v7";
15
15
  import { IMAGE_INPUT_POLICY } from "./image-input-policy";
16
+ import { abortableFileOpen } from "./abortable-file-open";
16
17
  import { probeImageBytes } from "./image-probe";
17
18
  import {
18
19
  normalizeOriginalImageName,
@@ -116,9 +117,14 @@ export class ImageAssetStore {
116
117
  assertContained(this.workspaceRoot, canonicalSource, "Image source realpath");
117
118
  }
118
119
 
119
- const handle = await open(canonicalSource, constants.O_RDONLY | noFollowFlag());
120
+ const handle = await abortableFileOpen(
121
+ () => open(canonicalSource, constants.O_RDONLY | noFollowFlag()),
122
+ options.signal,
123
+ this.onWarning,
124
+ );
120
125
  let bytes: Buffer;
121
126
  try {
127
+ throwIfAborted(options.signal);
122
128
  const handleStat = await handle.stat();
123
129
  if (
124
130
  !handleStat.isFile() ||
@@ -25,7 +25,7 @@ import type {
25
25
  PreparedModelRequest,
26
26
  PreparedPromptSegment,
27
27
  } from "./model-client";
28
- import { validateModelModalities } from "./model-client";
28
+ import { ProviderResponseError, validateModelModalities } from "./model-client";
29
29
  import { sha256, stableJsonStringify } from "./model-request-preflight";
30
30
  import { estimatePromptSegments } from "./token-estimator";
31
31
 
@@ -212,6 +212,25 @@ export class FakeModelClient implements ModelClient {
212
212
  );
213
213
  }
214
214
 
215
+ if (this.mode === "pty-provider-retry") {
216
+ if (lastUserMessage(input.messages) === "PTY_RETRY_NEXT") {
217
+ return textOutput(prepared, "PTY_RETRY_NEXT_DONE");
218
+ }
219
+ if (this.steps <= 4) {
220
+ options.onTextDelta?.(
221
+ `## Attempt ${this.steps}\nPartial response\n\n## Unfinished\nDRAFT_ONLY`,
222
+ );
223
+ throw new ProviderResponseError(
224
+ "reasoning_only_assistant",
225
+ "PTY_PROVIDER_FAILURE",
226
+ {
227
+ provider: prepared.provider,
228
+ model: prepared.model,
229
+ },
230
+ );
231
+ }
232
+ return textOutput(prepared, "PTY_RETRY_DONE");
233
+ }
215
234
  if (this.mode === "write-notes") {
216
235
  return this.writeNotes(input, prepared, options);
217
236
  }
@@ -358,12 +358,56 @@ function providerErrorCode(error: unknown): ProviderResponseErrorCode {
358
358
  if (status === 500 || status === 502 || status === 503 || status === 504) {
359
359
  return "provider_unavailable";
360
360
  }
361
- if (status === undefined && error instanceof OpenAI.APIConnectionError) {
361
+ // Explicit HTTP failures take precedence over payload or transport hints.
362
+ if (status !== undefined) return "provider_request_error";
363
+ const code = errorField(error, "code");
364
+ if (code === "server_error") return "provider_unavailable";
365
+ if (code === "rate_limit_exceeded") return "provider_rate_limited";
366
+ if (error instanceof OpenAI.APIConnectionError || isTransportFailure(error)) {
362
367
  return "provider_unavailable";
363
368
  }
364
369
  return "provider_request_error";
365
370
  }
366
371
 
372
+ const TRANSIENT_TRANSPORT_CODES = new Set([
373
+ "ECONNRESET",
374
+ "EPIPE",
375
+ "ETIMEDOUT",
376
+ "ERR_STREAM_PREMATURE_CLOSE",
377
+ "UND_ERR_SOCKET",
378
+ "UND_ERR_CONNECT_TIMEOUT",
379
+ "UND_ERR_HEADERS_TIMEOUT",
380
+ "UND_ERR_BODY_TIMEOUT",
381
+ ]);
382
+
383
+ function isTransportFailure(error: unknown): boolean {
384
+ const seen = new Set<unknown>();
385
+ // Fetch may wrap a socket error in TypeError.cause after headers arrive.
386
+ for (let depth = 0; error !== undefined && depth < 8; depth += 1) {
387
+ if (seen.has(error)) return false;
388
+ seen.add(error);
389
+ if (
390
+ errorField(error, "name") === "AbortError" ||
391
+ error instanceof OpenAI.APIUserAbortError ||
392
+ providerErrorStatus(error) !== undefined
393
+ ) {
394
+ return false;
395
+ }
396
+ const code = errorField(error, "code");
397
+ if (code !== undefined) {
398
+ return typeof code === "string" && TRANSIENT_TRANSPORT_CODES.has(code);
399
+ }
400
+ error = errorField(error, "cause");
401
+ }
402
+ return false;
403
+ }
404
+
405
+ function errorField(error: unknown, key: string): unknown {
406
+ return typeof error === "object" && error !== null
407
+ ? (error as Record<string, unknown>)[key]
408
+ : undefined;
409
+ }
410
+
367
411
  function providerErrorStatus(error: unknown): number | undefined {
368
412
  if (typeof error !== "object" || error === null || !("status" in error)) {
369
413
  return undefined;
@@ -28,6 +28,7 @@ import {
28
28
  type ProviderResponseDiagnostics,
29
29
  } from "./model-client";
30
30
  import { imageAssetUrlMarker } from "./openai-image-mapping";
31
+ import { sanitizedProviderError } from "./openai-model-utils";
31
32
 
32
33
  export type OpenAIResponsesMappingOptions = {
33
34
  materializedImages?: ReadonlyMap<ImageAssetId, string>;
@@ -160,6 +161,16 @@ export function fromOpenAIResponse(
160
161
  ): ModelRequestOutput {
161
162
  const root = requireRecord(response, "response", options);
162
163
  const status = requireString(root.status, "status", options);
164
+ if (status === "failed") {
165
+ const error = requireRecord(root.error, "error", options);
166
+ const code = requireString(error.code, "error.code", options);
167
+ const message = requireString(error.message, "error.message", options);
168
+ throw sanitizedProviderError(
169
+ Object.assign(new Error(message), { code }),
170
+ options.provider,
171
+ options.model,
172
+ );
173
+ }
163
174
  if (status !== "completed" && status !== "incomplete") {
164
175
  throw providerResponseError(
165
176
  options,
@@ -2,6 +2,7 @@ import {
2
2
  ProviderResponseError,
3
3
  type ProviderResponseDiagnostics,
4
4
  } from "./model-client";
5
+ import { sanitizedProviderError } from "./openai-model-utils";
5
6
 
6
7
  export class OpenAIResponsesStreamAccumulator {
7
8
  private eventCount = 0;
@@ -20,6 +21,19 @@ export class OpenAIResponsesStreamAccumulator {
20
21
  const type = requireString(record.type, `${path}.type`, this.options);
21
22
  this.eventCount += 1;
22
23
 
24
+ if (type === "error") {
25
+ const message = requireString(record.message, `${path}.message`, this.options);
26
+ const code =
27
+ record.code === null
28
+ ? null
29
+ : requireString(record.code, `${path}.code`, this.options);
30
+ throw sanitizedProviderError(
31
+ Object.assign(new Error(message), { code }),
32
+ this.options.provider,
33
+ this.options.model,
34
+ );
35
+ }
36
+
23
37
  if (type === "response.output_text.delta") {
24
38
  return requireString(record.delta, `${path}.delta`, this.options);
25
39
  }