tinker-agent 2.10.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 +29 -1
- package/README.md +13 -0
- package/package.json +4 -3
- package/src/agent/loop.ts +50 -13
- 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/tui-runner.tsx +1 -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/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/grep.ts +12 -4
- 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 +10 -0
- package/src/tui/file-mention.ts +29 -5
- 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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [2.11.0] - 2026-09-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Offer directories in the TUI `@` file-mention popup. Ancestor directories of
|
|
13
|
+
workspace files now appear alongside files, ranked after matching files, and
|
|
14
|
+
selecting a directory inserts its path directly instead of attempting an
|
|
15
|
+
image import.
|
|
16
|
+
- Offer a retry selection in the TUI after provider retries are exhausted, so a
|
|
17
|
+
failed model request can be retried without re-entering the prompt.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Cache ESLint results for `check:fast` and split the fast test tier into
|
|
22
|
+
twelve independent shards, shortening local iteration while keeping the
|
|
23
|
+
serial full test run as the final gate.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Align `Grep` context-line precedence and remove an unsupported regex hint
|
|
28
|
+
from its documentation.
|
|
29
|
+
- Warm up the fake ripgrep binary in tests to absorb the macOS first-execution
|
|
30
|
+
validation delay.
|
|
31
|
+
- Cancel pending image file opens without leaking file handles.
|
|
32
|
+
- Emphasize TUI timeline labels and add spacing between messages.
|
|
33
|
+
- Finalize scoped SQLite queries before closing the session database.
|
|
34
|
+
|
|
8
35
|
## [2.10.0] - 2026-09-06
|
|
9
36
|
|
|
10
37
|
### Added
|
|
@@ -437,7 +464,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
437
464
|
- First formal npm release under the `tinker-agent` package name with the `tinker`
|
|
438
465
|
executable.
|
|
439
466
|
|
|
440
|
-
[Unreleased]: https://github.com/ishowshao/tinker/compare/v2.
|
|
467
|
+
[Unreleased]: https://github.com/ishowshao/tinker/compare/v2.11.0...HEAD
|
|
468
|
+
[2.11.0]: https://github.com/ishowshao/tinker/releases/tag/v2.11.0
|
|
441
469
|
[2.10.0]: https://github.com/ishowshao/tinker/releases/tag/v2.10.0
|
|
442
470
|
[2.9.0]: https://github.com/ishowshao/tinker/releases/tag/v2.9.0
|
|
443
471
|
[2.8.0]: https://github.com/ishowshao/tinker/releases/tag/v2.8.0
|
package/README.md
CHANGED
|
@@ -34,6 +34,19 @@ This does not pretend that any model has infinite tokens or guarantee that it wi
|
|
|
34
34
|
explicit model and context limits. Actual provider support must be established
|
|
35
35
|
by a qualification matrix; transport compatibility alone is not a guarantee.
|
|
36
36
|
|
|
37
|
+
### Provider retry selection
|
|
38
|
+
|
|
39
|
+
When automatic retries for a provider request are exhausted, the TUI offers
|
|
40
|
+
**Retry again** or **End this turn**. Retrying reuses the same request within the
|
|
41
|
+
same turn and iteration, with a fresh automatic retry allowance. Ending the turn
|
|
42
|
+
(or pressing Esc in the selection) records the original failure. The selection
|
|
43
|
+
adds no conversation message and applies only to the current running process.
|
|
44
|
+
Retryable failures include HTTP 429/500/502/503/504, structured `server_error`
|
|
45
|
+
and `rate_limit_exceeded` errors inside provider streams or failed Responses,
|
|
46
|
+
and transient connection failures, including socket resets during streaming.
|
|
47
|
+
Tool failures and non-retryable errors keep their existing behavior. One-shot
|
|
48
|
+
and remote service runs do not wait for this TUI selection.
|
|
49
|
+
|
|
37
50
|
### PTY screen size
|
|
38
51
|
|
|
39
52
|
`Bash` accepts optional `cols` and `rows` for the initial PTY screen size:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tinker-agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "A personal coding agent with an interactive TUI and one-shot CLI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -66,14 +66,15 @@
|
|
|
66
66
|
"docs:check": "bun scripts/render-public-contract-docs.ts --check",
|
|
67
67
|
"tinker": "bun src/cli/index.ts",
|
|
68
68
|
"check": "bun run check:source-lines && bun run typecheck && bun run format:check && bun run lint && bun run docs:check && bun run test && bun run bench:smoke",
|
|
69
|
-
"check:fast": "bun run check:source-lines && bun run typecheck && bun run format:check && bun run lint && bun run test:fast",
|
|
69
|
+
"check:fast": "bun run check:source-lines && bun run typecheck && bun run format:check && bun run lint:fast && bun run test:fast",
|
|
70
70
|
"check:source-lines": "bun scripts/check-source-lines.ts",
|
|
71
71
|
"format": "biome format --write .",
|
|
72
72
|
"format:check": "biome format .",
|
|
73
73
|
"lint": "eslint \"bin/**/*.js\" \"src/**/*.{ts,tsx}\" \"scripts/**/*.ts\" \"packages/**/*.ts\" --max-warnings=0",
|
|
74
|
+
"lint:fast": "bun run lint --cache --cache-strategy content --cache-location .cache/eslint-fast/",
|
|
74
75
|
"lint:fix": "eslint \"bin/**/*.js\" \"src/**/*.{ts,tsx}\" \"scripts/**/*.ts\" \"packages/**/*.ts\" --fix",
|
|
75
76
|
"test": "FORCE_COLOR=0 bun test",
|
|
76
|
-
"test:fast": "
|
|
77
|
+
"test:fast": "bun scripts/run-test-shards.ts --path-ignore-patterns='**/*pty*.test.ts' --path-ignore-patterns='**/native-host-integration.test.ts'",
|
|
77
78
|
"test:e2e": "FORCE_COLOR=0 bun test src/__tests__/cli-pty src/__tests__/pty-tui-harness packages/tinker-chrome/__tests__/native-host-integration",
|
|
78
79
|
"typecheck": "tsc --noEmit"
|
|
79
80
|
},
|
package/src/agent/loop.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ModelRequestFailedData } from "../events/types";
|
|
2
|
+
import type { ProviderRetryDecision } from "./runtime-provider-retry";
|
|
1
3
|
import {
|
|
2
4
|
materializeModelRequest,
|
|
3
5
|
type MaterializedModelRequest,
|
|
@@ -74,6 +76,11 @@ export type RunAgentInput = {
|
|
|
74
76
|
usage: ContextUsageSnapshot;
|
|
75
77
|
};
|
|
76
78
|
transientRetryDelaysMs?: readonly number[];
|
|
79
|
+
requestProviderRetry?: (
|
|
80
|
+
iteration: IterationIdentity,
|
|
81
|
+
failure: ModelRequestFailedData,
|
|
82
|
+
signal: AbortSignal,
|
|
83
|
+
) => Promise<ProviderRetryDecision>;
|
|
77
84
|
};
|
|
78
85
|
|
|
79
86
|
export class FatalAgentTurnError extends Error {
|
|
@@ -196,12 +203,13 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
196
203
|
return failedResult(error, iteration);
|
|
197
204
|
}
|
|
198
205
|
|
|
206
|
+
let maxAttempts = MODEL_REQUEST_MAX_ATTEMPTS;
|
|
199
207
|
await input.runtimeSession.append({
|
|
200
208
|
type: "model.request.started",
|
|
201
209
|
...iteration,
|
|
202
210
|
data: {
|
|
203
211
|
attemptNumber: 1,
|
|
204
|
-
maxAttempts
|
|
212
|
+
maxAttempts,
|
|
205
213
|
},
|
|
206
214
|
});
|
|
207
215
|
|
|
@@ -240,7 +248,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
240
248
|
...iteration,
|
|
241
249
|
data: {
|
|
242
250
|
attemptNumber,
|
|
243
|
-
maxAttempts
|
|
251
|
+
maxAttempts,
|
|
244
252
|
},
|
|
245
253
|
});
|
|
246
254
|
}
|
|
@@ -289,19 +297,47 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
289
297
|
transientRetries,
|
|
290
298
|
transientRetryDelaysMs,
|
|
291
299
|
});
|
|
300
|
+
const failure = modelRequestFailureData({
|
|
301
|
+
error,
|
|
302
|
+
request,
|
|
303
|
+
attemptNumber,
|
|
304
|
+
maxAttempts,
|
|
305
|
+
retryDisposition: decision.disposition,
|
|
306
|
+
...(decision.disposition === "scheduled" && decision.delayMs > 0
|
|
307
|
+
? { retryDelayMs: decision.delayMs }
|
|
308
|
+
: {}),
|
|
309
|
+
});
|
|
292
310
|
await input.runtimeSession.append({
|
|
293
311
|
type: "model.request.failed",
|
|
294
312
|
...iteration,
|
|
295
|
-
data:
|
|
296
|
-
error,
|
|
297
|
-
request,
|
|
298
|
-
attemptNumber,
|
|
299
|
-
retryDisposition: decision.disposition,
|
|
300
|
-
...(decision.disposition === "scheduled" && decision.delayMs > 0
|
|
301
|
-
? { retryDelayMs: decision.delayMs }
|
|
302
|
-
: {}),
|
|
303
|
-
}),
|
|
313
|
+
data: failure,
|
|
304
314
|
});
|
|
315
|
+
if (decision.disposition === "exhausted" && input.requestProviderRetry) {
|
|
316
|
+
let retry: ProviderRetryDecision;
|
|
317
|
+
try {
|
|
318
|
+
retry = await input.requestProviderRetry(iteration, failure, input.signal);
|
|
319
|
+
} catch (retryError) {
|
|
320
|
+
if (input.signal.aborted) {
|
|
321
|
+
return cancelledResult(
|
|
322
|
+
cancellation(input.signal, iteration, "model_request"),
|
|
323
|
+
iteration,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
throw retryError;
|
|
327
|
+
}
|
|
328
|
+
if (input.signal.aborted) {
|
|
329
|
+
return cancelledResult(
|
|
330
|
+
cancellation(input.signal, iteration, "model_request"),
|
|
331
|
+
iteration,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (retry === "retry") {
|
|
335
|
+
reasoningOnlyRetries = 0;
|
|
336
|
+
transientRetries = 0;
|
|
337
|
+
maxAttempts = attemptNumber + MODEL_REQUEST_MAX_ATTEMPTS;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
305
341
|
|
|
306
342
|
if (decision.disposition === "scheduled") {
|
|
307
343
|
if (decision.kind === "reasoning_only") {
|
|
@@ -349,7 +385,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
349
385
|
...iteration,
|
|
350
386
|
data: {
|
|
351
387
|
attemptNumber: successfulAttempt,
|
|
352
|
-
maxAttempts
|
|
388
|
+
maxAttempts,
|
|
353
389
|
output: modelOutput,
|
|
354
390
|
},
|
|
355
391
|
});
|
|
@@ -823,6 +859,7 @@ function modelRequestFailureData(input: {
|
|
|
823
859
|
error: unknown;
|
|
824
860
|
request: PreparedModelRequest;
|
|
825
861
|
attemptNumber: number;
|
|
862
|
+
maxAttempts: number;
|
|
826
863
|
retryDisposition: "scheduled" | "not_retryable" | "exhausted";
|
|
827
864
|
retryDelayMs?: number;
|
|
828
865
|
}) {
|
|
@@ -830,7 +867,7 @@ function modelRequestFailureData(input: {
|
|
|
830
867
|
input.error instanceof ProviderResponseError ? input.error : undefined;
|
|
831
868
|
return {
|
|
832
869
|
attemptNumber: input.attemptNumber,
|
|
833
|
-
maxAttempts:
|
|
870
|
+
maxAttempts: input.maxAttempts,
|
|
834
871
|
code: providerError?.code ?? ("provider_request_error" as const),
|
|
835
872
|
retryDisposition: input.retryDisposition,
|
|
836
873
|
...(input.retryDelayMs === undefined ? {} : { retryDelayMs: input.retryDelayMs }),
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { AgentEventInput, ModelRequestFailedData } from "../events/types";
|
|
2
|
+
import type { IterationIdentity } from "./types";
|
|
3
|
+
import { cancellationError } from "./turn-cancellation";
|
|
4
|
+
|
|
5
|
+
export type ProviderRetryDecision = "retry" | "stop";
|
|
6
|
+
export type ProviderRetryRequest = {
|
|
7
|
+
readonly requestId: string;
|
|
8
|
+
readonly failure: ModelRequestFailedData;
|
|
9
|
+
};
|
|
10
|
+
export type ProviderRetrySnapshot = { readonly pending?: ProviderRetryRequest };
|
|
11
|
+
export const EMPTY_PROVIDER_RETRY: ProviderRetrySnapshot = Object.freeze({});
|
|
12
|
+
|
|
13
|
+
type PendingRetry = {
|
|
14
|
+
request: ProviderRetryRequest;
|
|
15
|
+
iteration: IterationIdentity;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
resolve: (decision: ProviderRetryDecision) => void;
|
|
19
|
+
reject: (error: unknown) => void;
|
|
20
|
+
removeAbortListener: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Process-local interaction; the original turn and model request remain open. */
|
|
24
|
+
export class RuntimeProviderRetry {
|
|
25
|
+
private snapshot: ProviderRetrySnapshot = EMPTY_PROVIDER_RETRY;
|
|
26
|
+
private pending?: PendingRetry;
|
|
27
|
+
private readonly listeners = new Set<() => void>();
|
|
28
|
+
|
|
29
|
+
constructor(private readonly append: (event: AgentEventInput) => Promise<void>) {}
|
|
30
|
+
|
|
31
|
+
read(): ProviderRetrySnapshot {
|
|
32
|
+
return this.snapshot;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscribe(listener: () => void): () => void {
|
|
36
|
+
this.listeners.add(listener);
|
|
37
|
+
return () => this.listeners.delete(listener);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async request(
|
|
41
|
+
iteration: IterationIdentity,
|
|
42
|
+
failure: ModelRequestFailedData,
|
|
43
|
+
signal: AbortSignal,
|
|
44
|
+
): Promise<ProviderRetryDecision> {
|
|
45
|
+
if (this.pending !== undefined) throw new Error("Provider retry already pending.");
|
|
46
|
+
if (signal.aborted) throw cancellationError(signal);
|
|
47
|
+
await this.append({ type: "model.retry.requested", ...iteration, data: failure });
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const request = Object.freeze({
|
|
50
|
+
requestId: `${iteration.iterationId}:${failure.attemptNumber}`,
|
|
51
|
+
failure: Object.freeze({ ...failure }),
|
|
52
|
+
});
|
|
53
|
+
const pending: PendingRetry = {
|
|
54
|
+
request,
|
|
55
|
+
iteration,
|
|
56
|
+
startedAt: Date.now(),
|
|
57
|
+
signal,
|
|
58
|
+
resolve,
|
|
59
|
+
reject,
|
|
60
|
+
removeAbortListener: () => signal.removeEventListener("abort", onAbort),
|
|
61
|
+
};
|
|
62
|
+
const onAbort = () => {
|
|
63
|
+
// settle rejects the waiting loop too; do not leave a detached rejection.
|
|
64
|
+
void this.settle(pending, "cancelled").catch(() => undefined);
|
|
65
|
+
};
|
|
66
|
+
this.pending = pending;
|
|
67
|
+
this.snapshot = Object.freeze({ pending: request });
|
|
68
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
if (signal.aborted) onAbort();
|
|
70
|
+
else this.notify();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async resolve(requestId: string, decision: ProviderRetryDecision): Promise<void> {
|
|
75
|
+
const pending = this.pending;
|
|
76
|
+
if (pending === undefined || pending.request.requestId !== requestId) {
|
|
77
|
+
throw new Error("Provider retry question is no longer pending.");
|
|
78
|
+
}
|
|
79
|
+
if (decision !== "retry" && decision !== "stop") {
|
|
80
|
+
throw new Error("Invalid provider retry decision.");
|
|
81
|
+
}
|
|
82
|
+
await this.settle(pending, decision);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async settle(
|
|
86
|
+
pending: PendingRetry,
|
|
87
|
+
decision: ProviderRetryDecision | "cancelled",
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (this.pending !== pending) return;
|
|
90
|
+
this.pending = undefined;
|
|
91
|
+
this.snapshot = EMPTY_PROVIDER_RETRY;
|
|
92
|
+
pending.removeAbortListener();
|
|
93
|
+
this.notify();
|
|
94
|
+
try {
|
|
95
|
+
await this.append({
|
|
96
|
+
type: "model.retry.resolved",
|
|
97
|
+
...pending.iteration,
|
|
98
|
+
data: {
|
|
99
|
+
attemptNumber: pending.request.failure.attemptNumber,
|
|
100
|
+
decision,
|
|
101
|
+
durationMs: Date.now() - pending.startedAt,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
if (decision === "cancelled") pending.reject(cancellationError(pending.signal));
|
|
105
|
+
else pending.resolve(decision);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
pending.reject(error);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private notify(): void {
|
|
113
|
+
for (const listener of this.listeners) listener();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProviderRetryDecision,
|
|
3
|
+
ProviderRetrySnapshot,
|
|
4
|
+
} from "./runtime-provider-retry";
|
|
1
5
|
import type { PublicToolingConfig } from "../cli/public-config-contract";
|
|
2
6
|
import type { ContextAutomationPolicy } from "../context/context-automation-policy";
|
|
3
7
|
import type {
|
|
@@ -118,6 +122,12 @@ export type RuntimeSession = {
|
|
|
118
122
|
subscribeBashGuard(listener: () => void): () => void;
|
|
119
123
|
setYoloMode(enabled: boolean): void;
|
|
120
124
|
resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
|
|
125
|
+
providerRetry(): ProviderRetrySnapshot;
|
|
126
|
+
subscribeProviderRetry(listener: () => void): () => void;
|
|
127
|
+
resolveProviderRetry(
|
|
128
|
+
requestId: string,
|
|
129
|
+
decision: ProviderRetryDecision,
|
|
130
|
+
): Promise<void>;
|
|
121
131
|
askUser(): AskUserSnapshot;
|
|
122
132
|
subscribeAskUser(listener: () => void): () => void;
|
|
123
133
|
resolveAskUser(response: AskUserResolution): Promise<void>;
|
|
@@ -251,6 +261,7 @@ export type CommonRuntimeSessionInput = {
|
|
|
251
261
|
completedTurnHook?: CompletedTurnHook;
|
|
252
262
|
enableTurnUndo?: boolean;
|
|
253
263
|
enableAskUser?: boolean;
|
|
264
|
+
enableProviderRetryPrompt?: boolean;
|
|
254
265
|
bashGuard?: {
|
|
255
266
|
readonly mode: "guard" | "yolo";
|
|
256
267
|
readonly source: Exclude<BashGuardSource, "session">;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RuntimeProviderRetry,
|
|
3
|
+
type ProviderRetryDecision,
|
|
4
|
+
} from "./runtime-provider-retry";
|
|
1
5
|
import path from "node:path";
|
|
2
6
|
import { assertContextMaintenanceCapabilities } from "./runtime-context-capabilities";
|
|
3
7
|
import { CompiledContextError } from "../context/compiled-context-validator";
|
|
@@ -221,6 +225,7 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
221
225
|
|
|
222
226
|
private readonly skillCatalog: SkillCatalogSnapshot;
|
|
223
227
|
private readonly interactions: RuntimeInteractions;
|
|
228
|
+
private readonly providerRetryInteraction: RuntimeProviderRetry;
|
|
224
229
|
private readonly scheduler: RuntimePromptScheduler;
|
|
225
230
|
private readonly contextMaintenance: RuntimeContextMaintenance;
|
|
226
231
|
private readonly runtimeSkills: RuntimeSkills;
|
|
@@ -234,6 +239,9 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
234
239
|
private readonly store: SessionStore,
|
|
235
240
|
private readonly assetStore: ImageAssetStore,
|
|
236
241
|
) {
|
|
242
|
+
this.providerRetryInteraction = new RuntimeProviderRetry((event) =>
|
|
243
|
+
this.append(event),
|
|
244
|
+
);
|
|
237
245
|
this.sessionId = input.selection.sessionId;
|
|
238
246
|
this.resumed = input.selection.mode === "resume";
|
|
239
247
|
this.scheduler = new RuntimePromptScheduler(
|
|
@@ -694,6 +702,21 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
694
702
|
return this.interactions.resolveBashConfirmation(decision);
|
|
695
703
|
}
|
|
696
704
|
|
|
705
|
+
providerRetry() {
|
|
706
|
+
return this.providerRetryInteraction.read();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
subscribeProviderRetry(listener: () => void): () => void {
|
|
710
|
+
return this.providerRetryInteraction.subscribe(listener);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
resolveProviderRetry(
|
|
714
|
+
requestId: string,
|
|
715
|
+
decision: ProviderRetryDecision,
|
|
716
|
+
): Promise<void> {
|
|
717
|
+
return this.providerRetryInteraction.resolve(requestId, decision);
|
|
718
|
+
}
|
|
719
|
+
|
|
697
720
|
askUser(): AskUserSnapshot {
|
|
698
721
|
return this.interactions.askUser();
|
|
699
722
|
}
|
|
@@ -1184,6 +1207,12 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
1184
1207
|
signal,
|
|
1185
1208
|
assetStore: this.assetStore,
|
|
1186
1209
|
initialRequest,
|
|
1210
|
+
...(this.input.enableProviderRetryPrompt === true
|
|
1211
|
+
? {
|
|
1212
|
+
requestProviderRetry: (iteration, failure, signal) =>
|
|
1213
|
+
this.providerRetryInteraction.request(iteration, failure, signal),
|
|
1214
|
+
}
|
|
1215
|
+
: {}),
|
|
1187
1216
|
});
|
|
1188
1217
|
} catch (error) {
|
|
1189
1218
|
if (error instanceof RuntimeEventAppendError) {
|
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,
|
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
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Own every query statement until a short-lived connection closes. Bun only
|
|
5
|
+
* caches its first 20 queries; later statements otherwise survive close() until
|
|
6
|
+
* GC, leaving a zombie SQLite connection behind. Do not use this for resident
|
|
7
|
+
* stores: retaining every query is intentionally bounded by the operation.
|
|
8
|
+
*/
|
|
9
|
+
export class ScopedQueryDatabase extends Database {
|
|
10
|
+
private readonly queries = new Set<{ finalize(): void }>();
|
|
11
|
+
|
|
12
|
+
override query<Result, Params extends SQLQueryBindings | SQLQueryBindings[]>(
|
|
13
|
+
sql: string,
|
|
14
|
+
) {
|
|
15
|
+
const statement = super.query<Result, Params>(sql);
|
|
16
|
+
this.queries.add(statement);
|
|
17
|
+
return statement;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
override close(): void {
|
|
21
|
+
for (const statement of this.queries) statement.finalize();
|
|
22
|
+
this.queries.clear();
|
|
23
|
+
// Also finalizes Bun's transaction statements and rejects any other live
|
|
24
|
+
// resources instead of silently deferring the underlying connection close.
|
|
25
|
+
super.close(true);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { ScopedQueryDatabase } from "./scoped-query-database";
|
|
2
3
|
import { lstat, readdir, realpath } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
@@ -67,7 +68,7 @@ export function createSessionHistoryAccess(input: {
|
|
|
67
68
|
throwIfTurnCancelled(signal);
|
|
68
69
|
await validateHistoryFiles(location.databasePath, sessionId);
|
|
69
70
|
throwIfTurnCancelled(signal);
|
|
70
|
-
const database = new
|
|
71
|
+
const database = new ScopedQueryDatabase(location.databasePath, {
|
|
71
72
|
readonly: true,
|
|
72
73
|
strict: true,
|
|
73
74
|
safeIntegers: true,
|
|
@@ -100,7 +101,7 @@ export function createSessionHistoryAccess(input: {
|
|
|
100
101
|
throwIfTurnCancelled(signal);
|
|
101
102
|
return result;
|
|
102
103
|
} finally {
|
|
103
|
-
database.close(
|
|
104
|
+
database.close();
|
|
104
105
|
}
|
|
105
106
|
} catch (error) {
|
|
106
107
|
throwIfTurnCancelled(signal);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { ScopedQueryDatabase } from "./scoped-query-database";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { chmod, mkdir, open, readdir, rename, rmdir } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
@@ -1327,7 +1328,7 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
1327
1328
|
await chmod(stagingDatabasePath, 0o600);
|
|
1328
1329
|
input.faultInjector?.("after_snapshot");
|
|
1329
1330
|
|
|
1330
|
-
stagingDatabase = openWritableDatabase(stagingDatabasePath);
|
|
1331
|
+
stagingDatabase = openWritableDatabase(stagingDatabasePath, ScopedQueryDatabase);
|
|
1331
1332
|
verifySessionSchema(stagingDatabase, this.sessionId);
|
|
1332
1333
|
dropSessionCloneTriggers(stagingDatabase);
|
|
1333
1334
|
input.faultInjector?.("after_trigger_drop");
|
|
@@ -1542,8 +1543,11 @@ export async function resolveSessionDatabasePath(
|
|
|
1542
1543
|
);
|
|
1543
1544
|
}
|
|
1544
1545
|
|
|
1545
|
-
function openWritableDatabase(
|
|
1546
|
-
|
|
1546
|
+
function openWritableDatabase(
|
|
1547
|
+
databasePath: string,
|
|
1548
|
+
DatabaseType: typeof Database = Database,
|
|
1549
|
+
): Database {
|
|
1550
|
+
const database = new DatabaseType(databasePath, {
|
|
1547
1551
|
create: false,
|
|
1548
1552
|
readwrite: true,
|
|
1549
1553
|
strict: true,
|
package/src/tools/grep.ts
CHANGED
|
@@ -72,7 +72,7 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
|
|
|
72
72
|
path: {
|
|
73
73
|
type: "string",
|
|
74
74
|
description:
|
|
75
|
-
"Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd. For directories, ripgrep runs in that directory with . as its search path; explicitly selecting an excluded directory allows searching inside it. Files are passed as absolute paths: explicit files bypass ignore/glob/type filtering, and explicit symlink files are followed. Explicit binary files may yield matches but are not guaranteed to be searched completely.",
|
|
75
|
+
"Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd. For directories, ripgrep runs in that directory with . as its search path; explicitly selecting an excluded directory allows searching inside it. Files are passed as absolute paths: explicit files bypass ignore/glob/type filtering, and explicit symlink files are followed. Explicit binary files may yield matches but are not guaranteed to be searched completely, and reported line numbers may be inaccurate.",
|
|
76
76
|
},
|
|
77
77
|
glob: {
|
|
78
78
|
type: "string",
|
|
@@ -202,7 +202,7 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
|
|
|
202
202
|
absoluteSearchPath,
|
|
203
203
|
mode,
|
|
204
204
|
truncated: rg.truncated ? true : undefined,
|
|
205
|
-
error: rg.error ?? "ripgrep failed.",
|
|
205
|
+
error: omitUnsupportedRegexHint(rg.error ?? "ripgrep failed."),
|
|
206
206
|
});
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -368,11 +368,19 @@ export function buildRipgrepArgs(
|
|
|
368
368
|
function resolveGrepContext(input: GrepArgs) {
|
|
369
369
|
const both = input.context ?? input.contextAlias;
|
|
370
370
|
return {
|
|
371
|
-
before:
|
|
372
|
-
after:
|
|
371
|
+
before: input.before ?? both ?? 0,
|
|
372
|
+
after: input.after ?? both ?? 0,
|
|
373
373
|
};
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
function omitUnsupportedRegexHint(error: string): string {
|
|
377
|
+
// Grep does not expose rg's PCRE2 flag; preserve the diagnostic itself.
|
|
378
|
+
return error.replace(
|
|
379
|
+
/\n+Consider enabling PCRE2 with the --pcre2 flag, which can handle backreferences\s+and look-around\.\s*$/,
|
|
380
|
+
"",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
376
384
|
type ParsedGrepArgs =
|
|
377
385
|
| { ok: true; value: GrepArgs }
|
|
378
386
|
| { ok: false; error: string; pattern?: string; mode?: GrepOutputMode };
|
package/src/tui/app.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EMPTY_PROVIDER_RETRY } from "../agent/runtime-provider-retry";
|
|
1
2
|
import { Box, Static, Text, useApp, useInput, useStdout, useWindowSize } from "ink";
|
|
2
3
|
import {
|
|
3
4
|
useCallback,
|
|
@@ -143,6 +144,12 @@ export function App(props: AppProps) {
|
|
|
143
144
|
() => binding.bashGuard(),
|
|
144
145
|
() => binding.bashGuard(),
|
|
145
146
|
);
|
|
147
|
+
const providerRetry = useSyncExternalStore(
|
|
148
|
+
(listener) => binding.subscribeProviderRetry?.(listener) ?? (() => undefined),
|
|
149
|
+
() => binding.providerRetry?.() ?? EMPTY_PROVIDER_RETRY,
|
|
150
|
+
() => EMPTY_PROVIDER_RETRY,
|
|
151
|
+
);
|
|
152
|
+
const pendingProviderRetry = providerRetry.pending;
|
|
146
153
|
const askUser = useSyncExternalStore(
|
|
147
154
|
(listener) => binding.subscribeAskUser(listener),
|
|
148
155
|
() => binding.askUser(),
|
|
@@ -282,7 +289,12 @@ export function App(props: AppProps) {
|
|
|
282
289
|
setIsCancelling(true);
|
|
283
290
|
setNotice("Cancelling current turn...");
|
|
284
291
|
},
|
|
285
|
-
{
|
|
292
|
+
{
|
|
293
|
+
isActive:
|
|
294
|
+
executionRunning &&
|
|
295
|
+
askUser.pending === undefined &&
|
|
296
|
+
pendingProviderRetry === undefined,
|
|
297
|
+
},
|
|
286
298
|
);
|
|
287
299
|
|
|
288
300
|
const closeResumePicker = () => {
|
|
@@ -912,7 +924,8 @@ export function App(props: AppProps) {
|
|
|
912
924
|
status={
|
|
913
925
|
isCancelling
|
|
914
926
|
? "cancelling"
|
|
915
|
-
: askUser.pending !== undefined
|
|
927
|
+
: askUser.pending !== undefined ||
|
|
928
|
+
pendingProviderRetry !== undefined
|
|
916
929
|
? "waiting_for_answer"
|
|
917
930
|
: executionRunning
|
|
918
931
|
? "running"
|
|
@@ -924,7 +937,35 @@ export function App(props: AppProps) {
|
|
|
924
937
|
/>
|
|
925
938
|
</Box>
|
|
926
939
|
<Box marginTop={1} flexDirection="column" flexShrink={0}>
|
|
927
|
-
{
|
|
940
|
+
{pendingProviderRetry !== undefined ? (
|
|
941
|
+
<AskUser
|
|
942
|
+
key={pendingProviderRetry.requestId}
|
|
943
|
+
title="Provider request failed"
|
|
944
|
+
question={`Automatic retries exhausted. ${pendingProviderRetry.failure.error.slice(0, 500)}`}
|
|
945
|
+
options={[
|
|
946
|
+
{ description: "Retry again" },
|
|
947
|
+
{ description: "End this turn" },
|
|
948
|
+
]}
|
|
949
|
+
dismissLabel="end this turn"
|
|
950
|
+
onSelect={(index) => {
|
|
951
|
+
void binding
|
|
952
|
+
.resolveProviderRetry?.(
|
|
953
|
+
pendingProviderRetry.requestId,
|
|
954
|
+
index === 0 ? "retry" : "stop",
|
|
955
|
+
)
|
|
956
|
+
.catch((error: unknown) =>
|
|
957
|
+
setNotice(`Retry selection failed: ${errorMessage(error)}`),
|
|
958
|
+
);
|
|
959
|
+
}}
|
|
960
|
+
onDismiss={() => {
|
|
961
|
+
void binding
|
|
962
|
+
.resolveProviderRetry?.(pendingProviderRetry.requestId, "stop")
|
|
963
|
+
.catch((error: unknown) =>
|
|
964
|
+
setNotice(`Retry selection failed: ${errorMessage(error)}`),
|
|
965
|
+
);
|
|
966
|
+
}}
|
|
967
|
+
/>
|
|
968
|
+
) : askUser.pending !== undefined ? (
|
|
928
969
|
<AskUser
|
|
929
970
|
question={askUser.pending.question}
|
|
930
971
|
options={askUser.pending.options}
|
|
@@ -977,6 +1018,7 @@ export function App(props: AppProps) {
|
|
|
977
1018
|
isCopying ||
|
|
978
1019
|
isCancelling ||
|
|
979
1020
|
askUser.pending !== undefined ||
|
|
1021
|
+
pendingProviderRetry !== undefined ||
|
|
980
1022
|
bashGuard.pending !== undefined
|
|
981
1023
|
}
|
|
982
1024
|
history={props.history}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Box, Text, useInput } from "ink";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
export type AskUserProps = {
|
|
5
5
|
question: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
dismissLabel?: string;
|
|
6
8
|
options: readonly { readonly description: string }[];
|
|
7
9
|
onSelect(selectedIndex: number): void;
|
|
8
10
|
onDismiss(): void;
|
|
@@ -10,6 +12,12 @@ export type AskUserProps = {
|
|
|
10
12
|
|
|
11
13
|
export function AskUser(props: AskUserProps) {
|
|
12
14
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
15
|
+
const selection = useRef(0);
|
|
16
|
+
const moveSelection = (offset: number) => {
|
|
17
|
+
selection.current =
|
|
18
|
+
(selection.current + offset + props.options.length) % props.options.length;
|
|
19
|
+
setSelectedIndex(selection.current);
|
|
20
|
+
};
|
|
13
21
|
|
|
14
22
|
useInput((input, key) => {
|
|
15
23
|
if (key.escape) {
|
|
@@ -17,17 +25,15 @@ export function AskUser(props: AskUserProps) {
|
|
|
17
25
|
return;
|
|
18
26
|
}
|
|
19
27
|
if (key.upArrow) {
|
|
20
|
-
|
|
21
|
-
current === 0 ? props.options.length - 1 : current - 1,
|
|
22
|
-
);
|
|
28
|
+
moveSelection(-1);
|
|
23
29
|
return;
|
|
24
30
|
}
|
|
25
31
|
if (key.downArrow) {
|
|
26
|
-
|
|
32
|
+
moveSelection(1);
|
|
27
33
|
return;
|
|
28
34
|
}
|
|
29
35
|
if (key.return) {
|
|
30
|
-
props.onSelect(
|
|
36
|
+
props.onSelect(selection.current);
|
|
31
37
|
return;
|
|
32
38
|
}
|
|
33
39
|
if (/^[1-6]$/.test(input)) {
|
|
@@ -41,7 +47,7 @@ export function AskUser(props: AskUserProps) {
|
|
|
41
47
|
return (
|
|
42
48
|
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
|
43
49
|
<Text color="cyan" bold>
|
|
44
|
-
Tinker asks
|
|
50
|
+
{props.title ?? "Tinker asks"}
|
|
45
51
|
</Text>
|
|
46
52
|
<Text>{props.question}</Text>
|
|
47
53
|
<Box flexDirection="column" marginTop={1}>
|
|
@@ -54,7 +60,8 @@ export function AskUser(props: AskUserProps) {
|
|
|
54
60
|
))}
|
|
55
61
|
</Box>
|
|
56
62
|
<Text dimColor>
|
|
57
|
-
↑/↓ select · 1-{props.options.length} choose · Enter confirm · Esc
|
|
63
|
+
↑/↓ select · 1-{props.options.length} choose · Enter confirm · Esc{" "}
|
|
64
|
+
{props.dismissLabel ?? "skip"}
|
|
58
65
|
</Text>
|
|
59
66
|
</Box>
|
|
60
67
|
);
|
|
@@ -41,7 +41,11 @@ import {
|
|
|
41
41
|
type PromptDraft,
|
|
42
42
|
} from "../prompt-draft";
|
|
43
43
|
import { matchSlashCommands, type SlashCommand } from "../slash-commands";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
listWorkspaceFiles,
|
|
46
|
+
listWorkspaceFilesAndDirectories,
|
|
47
|
+
type WorkspaceFileLister,
|
|
48
|
+
} from "../workspace-file-search";
|
|
45
49
|
|
|
46
50
|
export type PromptSubmission = {
|
|
47
51
|
readonly draft: PromptDraft;
|
|
@@ -183,7 +187,10 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
183
187
|
() =>
|
|
184
188
|
fileQuery === undefined || fileCatalog.status !== "ready"
|
|
185
189
|
? []
|
|
186
|
-
: rankWorkspaceFiles(
|
|
190
|
+
: rankWorkspaceFiles(
|
|
191
|
+
listWorkspaceFilesAndDirectories(fileCatalog.files),
|
|
192
|
+
fileQuery,
|
|
193
|
+
),
|
|
187
194
|
[fileCatalog, fileQuery],
|
|
188
195
|
);
|
|
189
196
|
const suggestions =
|
|
@@ -223,13 +230,14 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
223
230
|
});
|
|
224
231
|
};
|
|
225
232
|
|
|
226
|
-
const selectFile = (
|
|
233
|
+
const selectFile = (match: FileMentionMatch) => {
|
|
234
|
+
const filePath = match.path;
|
|
227
235
|
const mention = findFileMention(state.draft.editor);
|
|
228
236
|
if (mention === undefined || locked) {
|
|
229
237
|
return;
|
|
230
238
|
}
|
|
231
239
|
const importImage = props.importImage;
|
|
232
|
-
if (importImage === undefined) {
|
|
240
|
+
if (importImage === undefined || match.kind === "directory") {
|
|
233
241
|
insertFilePath(filePath);
|
|
234
242
|
return;
|
|
235
243
|
}
|
|
@@ -616,7 +624,7 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
616
624
|
const selectedCommand = suggestions[selectedIndex];
|
|
617
625
|
if (key.return) {
|
|
618
626
|
if (filePopupActive && selectedFile !== undefined) {
|
|
619
|
-
selectFile(selectedFile
|
|
627
|
+
selectFile(selectedFile);
|
|
620
628
|
} else if (selectedCommand !== undefined) {
|
|
621
629
|
submitDraft(createPromptDraft(`/${selectedCommand.name}`));
|
|
622
630
|
} else {
|
|
@@ -629,7 +637,7 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
629
637
|
if (selectedFile === undefined) {
|
|
630
638
|
setState((current) => ({ ...current, suggestionsDismissed: true }));
|
|
631
639
|
} else {
|
|
632
|
-
selectFile(selectedFile
|
|
640
|
+
selectFile(selectedFile);
|
|
633
641
|
}
|
|
634
642
|
} else if (selectedCommand !== undefined) {
|
|
635
643
|
setState(createPromptInputState(`/${selectedCommand.name} `));
|
|
@@ -26,16 +26,16 @@ export function TimelineRow(props: { item: TimelineItem }) {
|
|
|
26
26
|
if (item.label !== undefined) {
|
|
27
27
|
if (item.label === "assistant") {
|
|
28
28
|
return (
|
|
29
|
-
<
|
|
30
|
-
<
|
|
29
|
+
<Box flexDirection="column" marginTop={1}>
|
|
30
|
+
<TimelineLabel label={item.label} />
|
|
31
31
|
<AssistantMarkdown text={item.text} />
|
|
32
|
-
</
|
|
32
|
+
</Box>
|
|
33
33
|
);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
return (
|
|
37
|
-
<
|
|
38
|
-
<
|
|
37
|
+
<Box flexDirection="column" marginY={1}>
|
|
38
|
+
<TimelineLabel label={item.label} />
|
|
39
39
|
{item.userPrompt === undefined ? (
|
|
40
40
|
<Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
|
|
41
41
|
) : (
|
|
@@ -44,7 +44,7 @@ export function TimelineRow(props: { item: TimelineItem }) {
|
|
|
44
44
|
{renderItemBash(item)}
|
|
45
45
|
{renderItemDiff(item)}
|
|
46
46
|
{renderItemPlan(item)}
|
|
47
|
-
</
|
|
47
|
+
</Box>
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -60,10 +60,18 @@ export function TimelineRow(props: { item: TimelineItem }) {
|
|
|
60
60
|
|
|
61
61
|
export function AssistantStreamSectionRow(props: { item: AssistantStreamSectionItem }) {
|
|
62
62
|
return (
|
|
63
|
-
<
|
|
64
|
-
{props.item.showAssistantLabel ? <
|
|
63
|
+
<Box flexDirection="column" marginTop={props.item.showAssistantLabel ? 1 : 0}>
|
|
64
|
+
{props.item.showAssistantLabel ? <TimelineLabel label="assistant" /> : null}
|
|
65
65
|
<AssistantMarkdown text={props.item.markdown} />
|
|
66
|
-
</
|
|
66
|
+
</Box>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function TimelineLabel(props: { label: string }) {
|
|
71
|
+
return (
|
|
72
|
+
<Text color="cyan" bold>
|
|
73
|
+
- {props.label}
|
|
74
|
+
</Text>
|
|
67
75
|
);
|
|
68
76
|
}
|
|
69
77
|
|
package/src/tui/event-store.ts
CHANGED
|
@@ -205,6 +205,16 @@ export function reduceTuiProjection(
|
|
|
205
205
|
status: "running",
|
|
206
206
|
})),
|
|
207
207
|
);
|
|
208
|
+
case "model.retry.requested":
|
|
209
|
+
return updateActiveTurn(state, event, policy, (turn) =>
|
|
210
|
+
updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
|
|
211
|
+
...item,
|
|
212
|
+
text: `model iteration ${event.iterationNumber} · waiting for retry selection`,
|
|
213
|
+
status: "running",
|
|
214
|
+
})),
|
|
215
|
+
);
|
|
216
|
+
case "model.retry.resolved":
|
|
217
|
+
return state;
|
|
208
218
|
case "model.request.finished":
|
|
209
219
|
return updateActiveTurn(state, event, policy, (turn) =>
|
|
210
220
|
updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
|
package/src/tui/file-mention.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type FileMentionMatch = {
|
|
|
12
12
|
path: string;
|
|
13
13
|
indices: readonly number[];
|
|
14
14
|
score: number;
|
|
15
|
+
kind: "file" | "directory";
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export function findFileMention(editor: LineEditorState): FileMention | undefined {
|
|
@@ -76,8 +77,13 @@ export function rankWorkspaceFiles(
|
|
|
76
77
|
): FileMentionMatch[] {
|
|
77
78
|
if (query === "") {
|
|
78
79
|
return files
|
|
79
|
-
.map((filePath) => ({
|
|
80
|
-
|
|
80
|
+
.map((filePath) => ({
|
|
81
|
+
path: filePath,
|
|
82
|
+
indices: [],
|
|
83
|
+
score: 0,
|
|
84
|
+
kind: fileMentionKind(filePath),
|
|
85
|
+
}))
|
|
86
|
+
.sort((left, right) => compareShallowPaths(left, right))
|
|
81
87
|
.slice(0, limit);
|
|
82
88
|
}
|
|
83
89
|
|
|
@@ -104,6 +110,7 @@ function fuzzyMatchPath(filePath: string, query: string): FileMentionMatch | und
|
|
|
104
110
|
path: filePath,
|
|
105
111
|
indices: basenameMatch.indices,
|
|
106
112
|
score: basenameMatch.score + 200,
|
|
113
|
+
kind: fileMentionKind(filePath),
|
|
107
114
|
};
|
|
108
115
|
}
|
|
109
116
|
|
|
@@ -115,6 +122,7 @@ function fuzzyMatchPath(filePath: string, query: string): FileMentionMatch | und
|
|
|
115
122
|
path: filePath,
|
|
116
123
|
indices: fullPathMatch.indices,
|
|
117
124
|
score: fullPathMatch.score,
|
|
125
|
+
kind: fileMentionKind(filePath),
|
|
118
126
|
};
|
|
119
127
|
}
|
|
120
128
|
|
|
@@ -167,6 +175,11 @@ function compareFileMatches(left: FileMentionMatch, right: FileMentionMatch): nu
|
|
|
167
175
|
return right.score - left.score;
|
|
168
176
|
}
|
|
169
177
|
|
|
178
|
+
const kindDifference = left.kind === right.kind ? 0 : left.kind === "file" ? -1 : 1;
|
|
179
|
+
if (kindDifference !== 0) {
|
|
180
|
+
return kindDifference;
|
|
181
|
+
}
|
|
182
|
+
|
|
170
183
|
const depthDifference = pathDepth(left.path) - pathDepth(right.path);
|
|
171
184
|
if (depthDifference !== 0) {
|
|
172
185
|
return depthDifference;
|
|
@@ -178,9 +191,16 @@ function compareFileMatches(left: FileMentionMatch, right: FileMentionMatch): nu
|
|
|
178
191
|
: lengthDifference;
|
|
179
192
|
}
|
|
180
193
|
|
|
181
|
-
function compareShallowPaths(left:
|
|
182
|
-
const
|
|
183
|
-
|
|
194
|
+
function compareShallowPaths(left: FileMentionMatch, right: FileMentionMatch): number {
|
|
195
|
+
const kindDifference = left.kind === right.kind ? 0 : left.kind === "file" ? -1 : 1;
|
|
196
|
+
if (kindDifference !== 0) {
|
|
197
|
+
return kindDifference;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const depthDifference = pathDepth(left.path) - pathDepth(right.path);
|
|
201
|
+
return depthDifference === 0
|
|
202
|
+
? comparePathText(left.path, right.path)
|
|
203
|
+
: depthDifference;
|
|
184
204
|
}
|
|
185
205
|
|
|
186
206
|
function comparePathText(left: string, right: string): number {
|
|
@@ -196,6 +216,10 @@ function comparePathText(left: string, right: string): number {
|
|
|
196
216
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
197
217
|
}
|
|
198
218
|
|
|
219
|
+
function fileMentionKind(filePath: string): FileMentionMatch["kind"] {
|
|
220
|
+
return filePath.endsWith("/") || filePath.endsWith("\\") ? "directory" : "file";
|
|
221
|
+
}
|
|
222
|
+
|
|
199
223
|
function pathDepth(filePath: string): number {
|
|
200
224
|
return [...filePath].filter((char) => char === "/" || char === "\\").length;
|
|
201
225
|
}
|
|
@@ -205,12 +205,15 @@ export class TuiProjectionStore implements EventSink, AssistantTextDeltaSink {
|
|
|
205
205
|
return false;
|
|
206
206
|
}
|
|
207
207
|
this.assistantStreamAttempt = undefined;
|
|
208
|
-
if (
|
|
208
|
+
if (attempt.sectionCount === 0) {
|
|
209
209
|
return false;
|
|
210
210
|
}
|
|
211
211
|
this.appendCommitted({
|
|
212
212
|
id: `assistant-stream-retry-${attempt.iterationId}-${attempt.attemptNumber}`,
|
|
213
|
-
text:
|
|
213
|
+
text:
|
|
214
|
+
event.data.retryDisposition === "scheduled"
|
|
215
|
+
? "assistant response interrupted · retrying"
|
|
216
|
+
: "assistant response interrupted",
|
|
214
217
|
status: "info",
|
|
215
218
|
});
|
|
216
219
|
return true;
|
|
@@ -54,6 +54,9 @@ export type TuiSessionBinding = {
|
|
|
54
54
|
subscribeBashGuard(listener: () => void): () => void;
|
|
55
55
|
setYoloMode(enabled: boolean): void;
|
|
56
56
|
resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
|
|
57
|
+
providerRetry?: RuntimeSession["providerRetry"];
|
|
58
|
+
subscribeProviderRetry?: RuntimeSession["subscribeProviderRetry"];
|
|
59
|
+
resolveProviderRetry?: RuntimeSession["resolveProviderRetry"];
|
|
57
60
|
askUser(): AskUserSnapshot;
|
|
58
61
|
subscribeAskUser(listener: () => void): () => void;
|
|
59
62
|
resolveAskUser(response: AskUserResolution): Promise<void>;
|
|
@@ -267,6 +270,11 @@ export function managedTuiBinding(input: {
|
|
|
267
270
|
setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),
|
|
268
271
|
resolveBashConfirmation: (decision) =>
|
|
269
272
|
input.runtimeSession.resolveBashConfirmation(decision),
|
|
273
|
+
providerRetry: () => input.runtimeSession.providerRetry(),
|
|
274
|
+
subscribeProviderRetry: (listener) =>
|
|
275
|
+
input.runtimeSession.subscribeProviderRetry(listener),
|
|
276
|
+
resolveProviderRetry: (requestId, decision) =>
|
|
277
|
+
input.runtimeSession.resolveProviderRetry(requestId, decision),
|
|
270
278
|
askUser: () => input.runtimeSession.askUser(),
|
|
271
279
|
subscribeAskUser: (listener) => input.runtimeSession.subscribeAskUser(listener),
|
|
272
280
|
resolveAskUser: (response) => input.runtimeSession.resolveAskUser(response),
|
|
@@ -93,6 +93,27 @@ export function createWorkspaceFileLister(
|
|
|
93
93
|
|
|
94
94
|
export const listWorkspaceFiles = createWorkspaceFileLister();
|
|
95
95
|
|
|
96
|
+
export function deriveWorkspaceDirectories(files: readonly string[]): string[] {
|
|
97
|
+
const directories = new Set<string>();
|
|
98
|
+
|
|
99
|
+
for (const filePath of files) {
|
|
100
|
+
for (let index = 0; index < filePath.length; index += 1) {
|
|
101
|
+
const char = filePath[index];
|
|
102
|
+
if ((char === "/" || char === "\\") && index > 0) {
|
|
103
|
+
directories.add(filePath.slice(0, index + 1));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return [...directories];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function listWorkspaceFilesAndDirectories(
|
|
112
|
+
files: readonly string[],
|
|
113
|
+
): readonly string[] {
|
|
114
|
+
return [...files, ...deriveWorkspaceDirectories(files)];
|
|
115
|
+
}
|
|
116
|
+
|
|
96
117
|
function splitPaths(stdout: string): string[] {
|
|
97
118
|
return stdout
|
|
98
119
|
.split("\n")
|