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.
- package/CHANGELOG.md +69 -1
- package/README.md +30 -1
- package/package.json +5 -3
- package/src/agent/loop.ts +50 -13
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/agent/runtime-provider-retry.ts +115 -0
- package/src/agent/runtime-session-contracts.ts +11 -0
- package/src/agent/runtime-session.ts +29 -0
- package/src/cli/command-line.ts +26 -2
- package/src/cli/connect-runner.tsx +26 -0
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +1 -1
- package/src/cli/public-cli-contract.ts +18 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/cli/tui-runner.tsx +1 -0
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/events/types.ts +8 -0
- package/src/image/abortable-file-open.ts +54 -0
- package/src/image/image-asset-store.ts +7 -1
- package/src/model/fake-model-client.ts +20 -1
- package/src/model/openai-model-utils.ts +45 -1
- package/src/model/openai-responses-mapping.ts +11 -0
- package/src/model/openai-responses-stream.ts +14 -0
- package/src/observation/observation-builder.ts +87 -37
- package/src/remote/client.ts +350 -0
- package/src/remote/config.ts +95 -0
- package/src/remote/http-server.ts +240 -0
- package/src/remote/protocol.ts +228 -0
- package/src/remote/service-store.ts +175 -0
- package/src/remote/service.ts +219 -0
- package/src/remote/sync-hub.ts +95 -0
- package/src/session/remote-history-reader.ts +143 -0
- package/src/session/scoped-query-database.ts +27 -0
- package/src/session/session-history-access.ts +4 -3
- package/src/session/session-store.ts +7 -3
- package/src/tools/bash-task.ts +26 -16
- package/src/tools/bash.ts +44 -2
- package/src/tools/glob.ts +107 -19
- package/src/tools/grep-output.ts +130 -0
- package/src/tools/grep-pagination.ts +73 -0
- package/src/tools/grep-path.ts +11 -0
- package/src/tools/grep-snippets.ts +111 -0
- package/src/tools/grep.ts +148 -155
- package/src/tools/read.ts +0 -9
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +30 -2
- package/src/tui/app.tsx +45 -3
- package/src/tui/components/ask-user.tsx +15 -8
- package/src/tui/components/prompt-input.tsx +14 -6
- package/src/tui/components/timeline.tsx +17 -9
- package/src/tui/event-store.ts +25 -2
- package/src/tui/file-mention.ts +29 -5
- package/src/tui/remote-app.tsx +210 -0
- package/src/tui/tui-projection-store.ts +5 -2
- package/src/tui/tui-session-controller.ts +8 -0
- 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:
|
|
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
|
+
}
|
package/src/cli/tui-runner.tsx
CHANGED
|
@@ -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":
|
package/src/events/types.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|