@oh-my-pi/pi-coding-agent 15.1.7 → 15.1.9
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 -0
- package/dist/types/cli/update-cli.d.ts +18 -0
- package/dist/types/edit/streaming.d.ts +7 -0
- package/dist/types/main.d.ts +25 -1
- package/dist/types/utils/tool-choice.d.ts +2 -1
- package/dist/types/web/search/providers/utils.d.ts +25 -0
- package/package.json +7 -7
- package/src/cli/update-cli.ts +78 -36
- package/src/config/model-registry.ts +23 -12
- package/src/edit/streaming.ts +145 -4
- package/src/main.ts +13 -2
- package/src/modes/components/tool-execution.ts +46 -1
- package/src/modes/interactive-mode.ts +33 -7
- package/src/prompts/agents/oracle.md +15 -16
- package/src/utils/tool-choice.ts +7 -7
- package/src/web/kagi.ts +2 -2
- package/src/web/parallel.ts +3 -3
- package/src/web/search/index.ts +7 -0
- package/src/web/search/providers/anthropic.ts +2 -2
- package/src/web/search/providers/brave.ts +2 -2
- package/src/web/search/providers/codex.ts +2 -1
- package/src/web/search/providers/exa.ts +2 -1
- package/src/web/search/providers/gemini.ts +2 -1
- package/src/web/search/providers/jina.ts +2 -2
- package/src/web/search/providers/kimi.ts +2 -2
- package/src/web/search/providers/perplexity.ts +27 -3
- package/src/web/search/providers/searxng.ts +2 -1
- package/src/web/search/providers/synthetic.ts +2 -2
- package/src/web/search/providers/tavily.ts +2 -2
- package/src/web/search/providers/utils.ts +30 -0
- package/src/web/search/providers/zai.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.1.9] - 2026-05-21
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed `disabledProviders` still probing local discovery endpoints for Ollama, llama.cpp, and LM Studio during background model refresh. Disabled providers are now excluded before implicit and built-in discovery managers are created. ([#1232](https://github.com/can1357/oh-my-pi/issues/1232))
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed `omp acp` auto-discovering host `.mcp.json` servers in parallel with the ACP client's `session/new.mcpServers`, which shadowed client-supplied MCP tools in `search_tool_bm25` and the session tool registry. The ACP session factory now forces `enableMCP: false`, so MCP ownership stays with `AcpAgent#configureMcpServers`. Non-ACP modes keep on-disk discovery. ([#1234](https://github.com/can1357/oh-my-pi/issues/1234))
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed binary `omp update` rollbacks so a downloaded replacement that fails post-install version verification no longer remains installed over the previous working binary. ([#1240](https://github.com/can1357/oh-my-pi/issues/1240))
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Fixed `/force <tool>` rejecting Ollama/local models before the requested tool could run; Ollama now receives a named forced choice that the provider transport narrows to the selected tool. ([#1236](https://github.com/can1357/oh-my-pi/issues/1236))
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Fixed `web_search` freezing the session when an upstream provider stalled. Bun's WinHTTP backend on Windows can silently drop `AbortSignal` once a TCP/TLS connection hangs (oven-sh/bun#15275, oven-sh/bun#18536), so Esc never reached the in-flight fetch and the only recovery was Ctrl+C + `omp --resume`. Every web-search provider's outbound `fetch` (anthropic, brave, codex, exa, gemini, jina, kagi, kimi, parallel, perplexity, searxng, synthetic, tavily, z.ai) now composes the caller signal with a 60s hard timeout via a shared `withHardTimeout` helper, guaranteeing the request settles within a minute even when Bun's abort fails to propagate. Independently, `executeSearch`'s provider-fallback loop was masking real cancellations as ordinary provider errors and returning "All web search providers failed"; it now re-throws as `ToolAbortError` the moment the caller's signal aborts, so the session sees a clean cancel on every platform. ([#1221](https://github.com/can1357/oh-my-pi/issues/1221))
|
|
26
|
+
|
|
27
|
+
## [15.1.8] - 2026-05-20
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Fixed streaming edit previews for `apply_patch` and `hashline` jittering as the model typed `+added` lines. Two root causes addressed: (1) the trailing partial line of the streaming text input is now trimmed at each tick so a half-typed `+added` line no longer flickers; (2) the preview is rendered in the model's input order during streaming instead of re-deriving a unified diff via `Diff.structuredPatch`, whose coalescing previously reshuffled existing `+added` lines downward each time a new `-removed` line arrived. Existing additions now stay put and the preview only grows at the bottom while streaming. A residual trailing `-removed`/hunk-header block whose matching `+added` companion has not yet arrived is also suppressed until the additions land.
|
|
32
|
+
- Fixed Perplexity web search appearing "logged out" roughly an hour after `omp auth login perplexity`. The search provider's `findOAuthToken` was honoring the bogus `expires = login_time + 1h` written by older logins (Perplexity JWTs typically omit `exp` because sessions are server-side) and silently dropping the credential. The loader now decodes the JWT's `exp` claim directly and only skips when the JWT itself is expired; tokens without an `exp` claim are treated as non-expiring.
|
|
33
|
+
|
|
5
34
|
## [15.1.7] - 2026-05-19
|
|
6
35
|
|
|
7
36
|
### Fixed
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/** Result from running the installed binary and parsing its reported version. */
|
|
2
|
+
export interface InstalledVersionVerification {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
actual?: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Paths and verifier used while replacing a downloaded binary update. */
|
|
8
|
+
export interface BinaryReplacementOptions {
|
|
9
|
+
targetPath: string;
|
|
10
|
+
tempPath: string;
|
|
11
|
+
backupPath: string;
|
|
12
|
+
expectedVersion: string;
|
|
13
|
+
verifyInstalledVersion: (expectedVersion: string) => Promise<InstalledVersionVerification>;
|
|
14
|
+
}
|
|
1
15
|
/**
|
|
2
16
|
* Parse update subcommand arguments.
|
|
3
17
|
* Returns undefined if not an update command.
|
|
@@ -7,6 +21,10 @@ export declare function parseUpdateArgs(args: string[]): {
|
|
|
7
21
|
check: boolean;
|
|
8
22
|
} | undefined;
|
|
9
23
|
export declare function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary";
|
|
24
|
+
/**
|
|
25
|
+
* Atomically replace the installed binary and roll back if version verification fails.
|
|
26
|
+
*/
|
|
27
|
+
export declare function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise<InstalledVersionVerification>;
|
|
10
28
|
/**
|
|
11
29
|
* Run the update command.
|
|
12
30
|
*/
|
|
@@ -26,6 +26,13 @@ export interface StreamingDiffContext {
|
|
|
26
26
|
fuzzyThreshold?: number;
|
|
27
27
|
allowFuzzy?: boolean;
|
|
28
28
|
hashlineAutoDropPureInsertDuplicates?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* True while the tool's arguments are still streaming in. Strategies that
|
|
31
|
+
* accept free-form text input (apply_patch, hashline) trim the trailing
|
|
32
|
+
* partial line so per-character growth of an in-flight `+added` line does
|
|
33
|
+
* not flicker in the preview.
|
|
34
|
+
*/
|
|
35
|
+
isStreaming?: boolean;
|
|
29
36
|
}
|
|
30
37
|
export interface EditStreamingStrategy<Args = unknown> {
|
|
31
38
|
/**
|
package/dist/types/main.d.ts
CHANGED
|
@@ -5,16 +5,40 @@
|
|
|
5
5
|
* createAgentSession() options. The SDK does the heavy lifting.
|
|
6
6
|
*/
|
|
7
7
|
import type { Args } from "./cli/args";
|
|
8
|
+
import { ModelRegistry } from "./config/model-registry";
|
|
8
9
|
import { Settings } from "./config/settings";
|
|
9
10
|
import { InteractiveMode, runAcpMode } from "./modes";
|
|
10
11
|
import type { SubmittedUserInput } from "./modes/types";
|
|
11
|
-
import { createAgentSession, discoverAuthStorage } from "./sdk";
|
|
12
|
+
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession, discoverAuthStorage } from "./sdk";
|
|
12
13
|
import type { AgentSession } from "./session/agent-session";
|
|
14
|
+
import type { AuthStorage } from "./session/auth-storage";
|
|
13
15
|
export interface InteractiveModeNotify {
|
|
14
16
|
kind: "warn" | "error" | "info";
|
|
15
17
|
message: string;
|
|
16
18
|
}
|
|
17
19
|
export declare function submitInteractiveInput(mode: Pick<InteractiveMode, "markPendingSubmissionStarted" | "finishPendingSubmission" | "showError" | "checkShutdownRequested">, session: Pick<AgentSession, "prompt" | "promptCustomMessage">, input: SubmittedUserInput): Promise<void>;
|
|
20
|
+
type AcpSessionFactory = (cwd: string) => Promise<AgentSession>;
|
|
21
|
+
export interface AcpSessionFactoryOptions {
|
|
22
|
+
baseOptions: CreateAgentSessionOptions;
|
|
23
|
+
settings: Settings;
|
|
24
|
+
sessionDir?: string;
|
|
25
|
+
authStorage: AuthStorage;
|
|
26
|
+
modelRegistry: ModelRegistry;
|
|
27
|
+
parsedArgs: Pick<Args, "apiKey">;
|
|
28
|
+
rawArgs: string[];
|
|
29
|
+
createSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the per-`session/new` factory used by ACP mode.
|
|
33
|
+
*
|
|
34
|
+
* MCP servers in ACP sessions are owned exclusively by the ACP client, which
|
|
35
|
+
* supplies them through `session/new.mcpServers` and re-applies them via
|
|
36
|
+
* {@link AcpAgent#configureMcpServers}. We therefore force `enableMCP: false`
|
|
37
|
+
* on every session created here so {@link createAgentSession} skips the on-disk
|
|
38
|
+
* `.mcp.json` discovery path — otherwise host MCP tools land in the session's
|
|
39
|
+
* tool registry and shadow the client-supplied servers (issue #1234).
|
|
40
|
+
*/
|
|
41
|
+
export declare function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFactory;
|
|
18
42
|
interface RunRootCommandDependencies {
|
|
19
43
|
createAgentSession?: typeof createAgentSession;
|
|
20
44
|
discoverAuthStorage?: typeof discoverAuthStorage;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai";
|
|
2
2
|
/**
|
|
3
3
|
* Build a provider-aware tool choice that targets one specific tool when supported.
|
|
4
|
-
*
|
|
4
|
+
* Providers that only expose required/any forcing may still honor named choices by
|
|
5
|
+
* narrowing their request tool list before transport.
|
|
5
6
|
*/
|
|
6
7
|
export declare function buildNamedToolChoice(toolName: string, model?: Model<Api>): ToolChoice | undefined;
|
|
@@ -12,6 +12,31 @@ export declare function findCredential(envKey: string | null | undefined, ...sto
|
|
|
12
12
|
* Swallows lookup errors and reports unavailability.
|
|
13
13
|
*/
|
|
14
14
|
export declare function isApiKeyAvailable(findApiKey: () => string | null | Promise<string | null>): Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Default hard ceiling for a single web-search round-trip. 60s tolerates
|
|
17
|
+
* legitimate slow LLM-mediated responses (anthropic web_search_20250305,
|
|
18
|
+
* perplexity, gemini, codex) while still guaranteeing the session unfreezes
|
|
19
|
+
* within a minute if Bun's `AbortSignal` fails to propagate on Windows.
|
|
20
|
+
*
|
|
21
|
+
* Pure search APIs (brave, exa, jina, tavily, searxng, synthetic, zai)
|
|
22
|
+
* settle far faster in practice; reusing the same ceiling keeps the wiring
|
|
23
|
+
* uniform without compromising correctness.
|
|
24
|
+
*/
|
|
25
|
+
export declare const SEARCH_HARD_TIMEOUT_MS = 60000;
|
|
26
|
+
/**
|
|
27
|
+
* Compose a caller-supplied {@link AbortSignal} with a hard timeout so an
|
|
28
|
+
* outbound `fetch()` is guaranteed to settle within `ms` even when the
|
|
29
|
+
* runtime fails to propagate cancellation to the underlying transport.
|
|
30
|
+
*
|
|
31
|
+
* Bun's WinHTTP backend on Windows is known to ignore `AbortSignal` once a
|
|
32
|
+
* TCP/TLS connection stalls (oven-sh/bun#15275, oven-sh/bun#18536); without
|
|
33
|
+
* this safety net a stalled web-search request freezes the entire session
|
|
34
|
+
* because the user's Esc is never delivered to the native layer.
|
|
35
|
+
*
|
|
36
|
+
* @param signal - Caller cancellation signal, if any.
|
|
37
|
+
* @param ms - Hard timeout in milliseconds. Defaults to {@link SEARCH_HARD_TIMEOUT_MS}.
|
|
38
|
+
*/
|
|
39
|
+
export declare function withHardTimeout(signal: AbortSignal | undefined, ms?: number): AbortSignal;
|
|
15
40
|
/**
|
|
16
41
|
* Map a provider's raw source list to the unified SearchSource shape,
|
|
17
42
|
* clamped to the requested result count and annotated with ageSeconds.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "15.1.
|
|
4
|
+
"version": "15.1.9",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
48
48
|
"@babel/parser": "^7.29.3",
|
|
49
49
|
"@mozilla/readability": "^0.6.0",
|
|
50
|
-
"@oh-my-pi/omp-stats": "15.1.
|
|
51
|
-
"@oh-my-pi/pi-agent-core": "15.1.
|
|
52
|
-
"@oh-my-pi/pi-ai": "15.1.
|
|
53
|
-
"@oh-my-pi/pi-natives": "15.1.
|
|
54
|
-
"@oh-my-pi/pi-tui": "15.1.
|
|
55
|
-
"@oh-my-pi/pi-utils": "15.1.
|
|
50
|
+
"@oh-my-pi/omp-stats": "15.1.9",
|
|
51
|
+
"@oh-my-pi/pi-agent-core": "15.1.9",
|
|
52
|
+
"@oh-my-pi/pi-ai": "15.1.9",
|
|
53
|
+
"@oh-my-pi/pi-natives": "15.1.9",
|
|
54
|
+
"@oh-my-pi/pi-tui": "15.1.9",
|
|
55
|
+
"@oh-my-pi/pi-utils": "15.1.9",
|
|
56
56
|
"@puppeteer/browsers": "^2.13.0",
|
|
57
57
|
"@types/turndown": "5.0.6",
|
|
58
58
|
"@xterm/headless": "^6.0.0",
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -20,6 +20,22 @@ interface ReleaseInfo {
|
|
|
20
20
|
version: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Result from running the installed binary and parsing its reported version. */
|
|
24
|
+
export interface InstalledVersionVerification {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
actual?: string;
|
|
27
|
+
path?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Paths and verifier used while replacing a downloaded binary update. */
|
|
31
|
+
export interface BinaryReplacementOptions {
|
|
32
|
+
targetPath: string;
|
|
33
|
+
tempPath: string;
|
|
34
|
+
backupPath: string;
|
|
35
|
+
expectedVersion: string;
|
|
36
|
+
verifyInstalledVersion: (expectedVersion: string) => Promise<InstalledVersionVerification>;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
/**
|
|
24
40
|
* Parse update subcommand arguments.
|
|
25
41
|
* Returns undefined if not an update command.
|
|
@@ -197,9 +213,7 @@ function resolveOmpPath(): string | undefined {
|
|
|
197
213
|
/**
|
|
198
214
|
* Run the resolved omp binary and check if it reports the expected version.
|
|
199
215
|
*/
|
|
200
|
-
async function verifyInstalledVersion(
|
|
201
|
-
expectedVersion: string,
|
|
202
|
-
): Promise<{ ok: boolean; actual?: string; path?: string }> {
|
|
216
|
+
async function verifyInstalledVersion(expectedVersion: string): Promise<InstalledVersionVerification> {
|
|
203
217
|
const ompPath = resolveOmpPath();
|
|
204
218
|
if (!ompPath) return { ok: false };
|
|
205
219
|
try {
|
|
@@ -215,29 +229,69 @@ async function verifyInstalledVersion(
|
|
|
215
229
|
}
|
|
216
230
|
}
|
|
217
231
|
|
|
232
|
+
function printVerifiedVersion(expectedVersion: string): void {
|
|
233
|
+
console.log(chalk.green(`\n${theme.status.success} Updated to ${expectedVersion}`));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function formatVerificationFailure(result: InstalledVersionVerification, expectedVersion: string): string {
|
|
237
|
+
if (result.actual) {
|
|
238
|
+
return `${APP_NAME} at ${result.path} still reports ${result.actual} (expected ${expectedVersion})`;
|
|
239
|
+
}
|
|
240
|
+
return `could not verify updated version${result.path ? ` at ${result.path}` : ""}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
218
243
|
/**
|
|
219
244
|
* Print post-update verification result.
|
|
220
245
|
*/
|
|
221
246
|
async function printVerification(expectedVersion: string): Promise<void> {
|
|
222
247
|
const result = await verifyInstalledVersion(expectedVersion);
|
|
223
248
|
if (result.ok) {
|
|
224
|
-
|
|
249
|
+
printVerifiedVersion(expectedVersion);
|
|
225
250
|
return;
|
|
226
251
|
}
|
|
227
|
-
|
|
228
|
-
console.log(
|
|
229
|
-
chalk.yellow(
|
|
230
|
-
`\nWarning: ${APP_NAME} at ${result.path} still reports ${result.actual} (expected ${expectedVersion})`,
|
|
231
|
-
),
|
|
232
|
-
);
|
|
233
|
-
} else {
|
|
234
|
-
console.log(
|
|
235
|
-
chalk.yellow(`\nWarning: could not verify updated version${result.path ? ` at ${result.path}` : ""}`),
|
|
236
|
-
);
|
|
237
|
-
}
|
|
252
|
+
console.log(chalk.yellow(`\nWarning: ${formatVerificationFailure(result, expectedVersion)}`));
|
|
238
253
|
console.log(chalk.yellow(`You may need to reinstall: curl -fsSL https://omp.sh/install | sh`));
|
|
239
254
|
}
|
|
240
255
|
|
|
256
|
+
async function unlinkIfExists(filePath: string): Promise<void> {
|
|
257
|
+
try {
|
|
258
|
+
await fs.promises.unlink(filePath);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (!isEnoent(err)) throw err;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Atomically replace the installed binary and roll back if version verification fails.
|
|
266
|
+
*/
|
|
267
|
+
export async function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise<InstalledVersionVerification> {
|
|
268
|
+
let backupReady = false;
|
|
269
|
+
try {
|
|
270
|
+
await unlinkIfExists(options.backupPath);
|
|
271
|
+
await fs.promises.rename(options.targetPath, options.backupPath);
|
|
272
|
+
backupReady = true;
|
|
273
|
+
await fs.promises.rename(options.tempPath, options.targetPath);
|
|
274
|
+
|
|
275
|
+
const verification = await options.verifyInstalledVersion(options.expectedVersion);
|
|
276
|
+
if (!verification.ok) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`${formatVerificationFailure(verification, options.expectedVersion)}; restored previous ${APP_NAME} binary`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
backupReady = false;
|
|
283
|
+
await unlinkIfExists(options.backupPath);
|
|
284
|
+
return verification;
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (backupReady) {
|
|
287
|
+
await unlinkIfExists(options.targetPath);
|
|
288
|
+
await fs.promises.rename(options.backupPath, options.targetPath);
|
|
289
|
+
}
|
|
290
|
+
await unlinkIfExists(options.tempPath);
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
241
295
|
/**
|
|
242
296
|
* Update via bun package manager.
|
|
243
297
|
*/
|
|
@@ -271,27 +325,15 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
|
|
|
271
325
|
await pipeline(response.body, fileStream);
|
|
272
326
|
|
|
273
327
|
console.log(chalk.dim("Installing update..."));
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
await printVerification(expectedVersion);
|
|
285
|
-
console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
|
286
|
-
} catch (err) {
|
|
287
|
-
if (fs.existsSync(backupPath) && !fs.existsSync(targetPath)) {
|
|
288
|
-
await fs.promises.rename(backupPath, targetPath);
|
|
289
|
-
}
|
|
290
|
-
if (fs.existsSync(tempPath)) {
|
|
291
|
-
await fs.promises.unlink(tempPath);
|
|
292
|
-
}
|
|
293
|
-
throw err;
|
|
294
|
-
}
|
|
328
|
+
await replaceBinaryForUpdate({
|
|
329
|
+
targetPath,
|
|
330
|
+
tempPath,
|
|
331
|
+
backupPath,
|
|
332
|
+
expectedVersion,
|
|
333
|
+
verifyInstalledVersion,
|
|
334
|
+
});
|
|
335
|
+
printVerifiedVersion(expectedVersion);
|
|
336
|
+
console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
|
295
337
|
}
|
|
296
338
|
|
|
297
339
|
/**
|
|
@@ -1017,7 +1017,8 @@ export class ModelRegistry {
|
|
|
1017
1017
|
}
|
|
1018
1018
|
|
|
1019
1019
|
#addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
|
|
1020
|
-
|
|
1020
|
+
const disabledProviders = getDisabledProviderIdsFromSettings();
|
|
1021
|
+
if (!configuredProviders.has("ollama") && !disabledProviders.has("ollama")) {
|
|
1021
1022
|
this.#discoverableProviders.push({
|
|
1022
1023
|
provider: "ollama",
|
|
1023
1024
|
api: "openai-responses",
|
|
@@ -1027,7 +1028,7 @@ export class ModelRegistry {
|
|
|
1027
1028
|
});
|
|
1028
1029
|
this.#keylessProviders.add("ollama");
|
|
1029
1030
|
}
|
|
1030
|
-
if (!configuredProviders.has("llama.cpp")) {
|
|
1031
|
+
if (!configuredProviders.has("llama.cpp") && !disabledProviders.has("llama.cpp")) {
|
|
1031
1032
|
this.#discoverableProviders.push({
|
|
1032
1033
|
provider: "llama.cpp",
|
|
1033
1034
|
api: "openai-responses",
|
|
@@ -1040,7 +1041,7 @@ export class ModelRegistry {
|
|
|
1040
1041
|
this.#keylessProviders.add("llama.cpp");
|
|
1041
1042
|
}
|
|
1042
1043
|
}
|
|
1043
|
-
if (!configuredProviders.has("lm-studio")) {
|
|
1044
|
+
if (!configuredProviders.has("lm-studio") && !disabledProviders.has("lm-studio")) {
|
|
1044
1045
|
this.#discoverableProviders.push({
|
|
1045
1046
|
provider: "lm-studio",
|
|
1046
1047
|
api: "openai-completions",
|
|
@@ -1160,9 +1161,12 @@ export class ModelRegistry {
|
|
|
1160
1161
|
strategy: ModelRefreshStrategy,
|
|
1161
1162
|
providerFilter?: ReadonlySet<string>,
|
|
1162
1163
|
): Promise<void> {
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1164
|
+
const disabledProviders = getDisabledProviderIdsFromSettings();
|
|
1165
|
+
const selectedDiscoverableProviders = (
|
|
1166
|
+
providerFilter
|
|
1167
|
+
? this.#discoverableProviders.filter(provider => providerFilter.has(provider.provider))
|
|
1168
|
+
: this.#discoverableProviders
|
|
1169
|
+
).filter(provider => !disabledProviders.has(provider.provider));
|
|
1166
1170
|
const configuredDiscoveriesPromise =
|
|
1167
1171
|
selectedDiscoverableProviders.length === 0
|
|
1168
1172
|
? Promise.resolve<Model<Api>[]>([])
|
|
@@ -1366,17 +1370,24 @@ export class ModelRegistry {
|
|
|
1366
1370
|
},
|
|
1367
1371
|
},
|
|
1368
1372
|
];
|
|
1373
|
+
const disabledProviders = getDisabledProviderIdsFromSettings();
|
|
1374
|
+
const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(
|
|
1375
|
+
descriptor => !disabledProviders.has(descriptor.providerId),
|
|
1376
|
+
);
|
|
1377
|
+
const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(
|
|
1378
|
+
descriptor => !disabledProviders.has(descriptor.providerId),
|
|
1379
|
+
);
|
|
1369
1380
|
// Use peekApiKey to avoid OAuth token refresh during discovery.
|
|
1370
1381
|
// The token is only needed if the dynamic fetch fires (cache miss),
|
|
1371
1382
|
// and failures there are handled gracefully.
|
|
1372
1383
|
const peekKey = (descriptor: { providerId: string }) => this.#peekApiKeyForProvider(descriptor.providerId);
|
|
1373
1384
|
const [standardProviderKeys, specialKeys] = await Promise.all([
|
|
1374
|
-
Promise.all(
|
|
1375
|
-
Promise.all(
|
|
1385
|
+
Promise.all(standardProviderDescriptors.map(peekKey)),
|
|
1386
|
+
Promise.all(enabledSpecialProviderDescriptors.map(peekKey)),
|
|
1376
1387
|
]);
|
|
1377
1388
|
const options: ModelManagerOptions<Api>[] = [];
|
|
1378
|
-
for (let i = 0; i <
|
|
1379
|
-
const descriptor =
|
|
1389
|
+
for (let i = 0; i < standardProviderDescriptors.length; i++) {
|
|
1390
|
+
const descriptor = standardProviderDescriptors[i];
|
|
1380
1391
|
const apiKey = standardProviderKeys[i];
|
|
1381
1392
|
if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated) {
|
|
1382
1393
|
options.push(
|
|
@@ -1388,8 +1399,8 @@ export class ModelRegistry {
|
|
|
1388
1399
|
}
|
|
1389
1400
|
}
|
|
1390
1401
|
|
|
1391
|
-
for (let i = 0; i <
|
|
1392
|
-
const descriptor =
|
|
1402
|
+
for (let i = 0; i < enabledSpecialProviderDescriptors.length; i++) {
|
|
1403
|
+
const descriptor = enabledSpecialProviderDescriptors[i];
|
|
1393
1404
|
const key = descriptor.resolveKey(specialKeys[i]);
|
|
1394
1405
|
if (!isAuthenticated(key)) {
|
|
1395
1406
|
continue;
|
package/src/edit/streaming.ts
CHANGED
|
@@ -45,6 +45,13 @@ export interface StreamingDiffContext {
|
|
|
45
45
|
fuzzyThreshold?: number;
|
|
46
46
|
allowFuzzy?: boolean;
|
|
47
47
|
hashlineAutoDropPureInsertDuplicates?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* True while the tool's arguments are still streaming in. Strategies that
|
|
50
|
+
* accept free-form text input (apply_patch, hashline) trim the trailing
|
|
51
|
+
* partial line so per-character growth of an in-flight `+added` line does
|
|
52
|
+
* not flicker in the preview.
|
|
53
|
+
*/
|
|
54
|
+
isStreaming?: boolean;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
export interface EditStreamingStrategy<Args = unknown> {
|
|
@@ -274,21 +281,146 @@ interface HashlineArgs {
|
|
|
274
281
|
__partialJson?: string;
|
|
275
282
|
}
|
|
276
283
|
|
|
284
|
+
/**
|
|
285
|
+
* While streaming a free-form text payload (apply_patch envelope, hashline
|
|
286
|
+
* input), trim the trailing partial line so per-character growth of an
|
|
287
|
+
* in-flight `+added` line does not cause the diff preview to flicker. The
|
|
288
|
+
* full line will show on the next streaming tick once its `\n` arrives.
|
|
289
|
+
* Returns `text` unchanged when not streaming or when no newline is present.
|
|
290
|
+
*/
|
|
291
|
+
function trimTrailingPartialLine(text: string, isStreaming: boolean | undefined): string {
|
|
292
|
+
if (!isStreaming) return text;
|
|
293
|
+
const idx = text.lastIndexOf("\n");
|
|
294
|
+
if (idx === -1) return "";
|
|
295
|
+
return text.slice(0, idx + 1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Build a per-file diff preview directly from a partial `apply_patch`
|
|
300
|
+
* envelope by emitting its body lines in *input order*. This bypasses the
|
|
301
|
+
* file-state re-diff (`computePatchDiff` → `Diff.structuredPatch`) whose
|
|
302
|
+
* coalescing reorders the model's `-old +new -old +new` stream into
|
|
303
|
+
* `-old -old +new +new` and visibly shifts existing `+added` lines
|
|
304
|
+
* downward each time a new `-` arrives. The preview therefore grows
|
|
305
|
+
* monotonically at the bottom while streaming and only becomes a real
|
|
306
|
+
* unified diff once the args are complete.
|
|
307
|
+
*/
|
|
308
|
+
function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[] | null {
|
|
309
|
+
const lines = input.split("\n");
|
|
310
|
+
const groups = new Map<string, string[]>();
|
|
311
|
+
let currentPath: string | undefined;
|
|
312
|
+
const ensure = (path: string): string[] => {
|
|
313
|
+
let bucket = groups.get(path);
|
|
314
|
+
if (!bucket) {
|
|
315
|
+
bucket = [];
|
|
316
|
+
groups.set(path, bucket);
|
|
317
|
+
}
|
|
318
|
+
return bucket;
|
|
319
|
+
};
|
|
320
|
+
for (const raw of lines) {
|
|
321
|
+
const trimmedEnd = raw.trimEnd();
|
|
322
|
+
if (trimmedEnd === BEGIN_PATCH_MARKER || trimmedEnd === END_PATCH_MARKER || trimmedEnd === ABORT_MARKER) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (trimmedEnd.startsWith("*** Add File: ")) {
|
|
326
|
+
currentPath = trimmedEnd.slice("*** Add File: ".length);
|
|
327
|
+
ensure(currentPath);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (trimmedEnd.startsWith("*** Delete File: ")) {
|
|
331
|
+
currentPath = trimmedEnd.slice("*** Delete File: ".length);
|
|
332
|
+
ensure(currentPath);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (trimmedEnd.startsWith("*** Update File: ")) {
|
|
336
|
+
currentPath = trimmedEnd.slice("*** Update File: ".length);
|
|
337
|
+
ensure(currentPath);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (trimmedEnd.startsWith("*** Move to:") || trimmedEnd.startsWith("*** End of File")) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (!currentPath) continue;
|
|
344
|
+
// Diff body: keep `-/+/space`-prefixed lines and `@@` hunk headers in
|
|
345
|
+
// input order. parseDiffLine accepts the no-line-number legacy form so
|
|
346
|
+
// the renderer styles them as additions/removals/context naturally.
|
|
347
|
+
if (raw.startsWith("+") || raw.startsWith("-") || raw.startsWith(" ") || raw.startsWith("@@")) {
|
|
348
|
+
ensure(currentPath).push(raw);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (groups.size === 0) return null;
|
|
352
|
+
const previews: PerFileDiffPreview[] = [];
|
|
353
|
+
for (const [path, body] of groups) {
|
|
354
|
+
if (body.length === 0) continue;
|
|
355
|
+
previews.push({ path, diff: body.join("\n") });
|
|
356
|
+
}
|
|
357
|
+
return previews.length > 0 ? previews : null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Hashline equivalent: emit each section's `~payload` lines as `+added`
|
|
362
|
+
* lines in the order the model typed them. We deliberately omit op headers
|
|
363
|
+
* and removal targets from the streaming preview because their content
|
|
364
|
+
* lives in the file and would require a costly re-apply per tick; the
|
|
365
|
+
* complete unified diff is shown once streaming finishes.
|
|
366
|
+
*/
|
|
367
|
+
function buildHashlineNaturalOrderPreviews(
|
|
368
|
+
input: string,
|
|
369
|
+
defaultPath: string | undefined,
|
|
370
|
+
): PerFileDiffPreview[] | null {
|
|
371
|
+
const lines = input.split("\n");
|
|
372
|
+
const groups = new Map<string, string[]>();
|
|
373
|
+
let currentPath = defaultPath ?? "";
|
|
374
|
+
const ensure = (path: string): string[] => {
|
|
375
|
+
let bucket = groups.get(path);
|
|
376
|
+
if (!bucket) {
|
|
377
|
+
bucket = [];
|
|
378
|
+
groups.set(path, bucket);
|
|
379
|
+
}
|
|
380
|
+
return bucket;
|
|
381
|
+
};
|
|
382
|
+
for (const raw of lines) {
|
|
383
|
+
if (isHashlineEnvelopeMarkerLine(raw)) continue;
|
|
384
|
+
if (isHashlineHeaderLine(raw)) {
|
|
385
|
+
currentPath = raw.trimEnd().slice(1).trim();
|
|
386
|
+
if (currentPath) ensure(currentPath);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (raw.startsWith("~")) {
|
|
390
|
+
ensure(currentPath).push(`+${raw.slice(1)}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (groups.size === 0) return null;
|
|
394
|
+
const previews: PerFileDiffPreview[] = [];
|
|
395
|
+
for (const [path, body] of groups) {
|
|
396
|
+
if (body.length === 0) continue;
|
|
397
|
+
previews.push({ path, diff: body.join("\n") });
|
|
398
|
+
}
|
|
399
|
+
return previews.length > 0 ? previews : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
277
402
|
const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
278
403
|
extractCompleteEdits(args) {
|
|
279
404
|
return args;
|
|
280
405
|
},
|
|
281
406
|
async computeDiffPreview(args, ctx) {
|
|
282
407
|
if (typeof args.input !== "string" || args.input.length === 0) return null;
|
|
408
|
+
const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
|
|
409
|
+
if (input.length === 0) return null;
|
|
410
|
+
if (ctx.isStreaming) {
|
|
411
|
+
// Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
|
|
412
|
+
// reordering by showing the model's `~payload` lines in input order.
|
|
413
|
+
return buildHashlineNaturalOrderPreviews(input, args.path);
|
|
414
|
+
}
|
|
283
415
|
ctx.signal.throwIfAborted();
|
|
284
416
|
|
|
285
417
|
let sections: HashlineInputSection[];
|
|
286
418
|
try {
|
|
287
|
-
sections = splitHashlineInputs(
|
|
419
|
+
sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
|
|
288
420
|
} catch {
|
|
289
421
|
// Single-section fallback keeps the original error rendering for the
|
|
290
422
|
// "haven't typed `@@ PATH` yet" case.
|
|
291
|
-
const result = await computeHashlineDiff({ input
|
|
423
|
+
const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
|
|
292
424
|
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
293
425
|
});
|
|
294
426
|
ctx.signal.throwIfAborted();
|
|
@@ -340,12 +472,21 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
|
|
|
340
472
|
},
|
|
341
473
|
async computeDiffPreview(args, ctx) {
|
|
342
474
|
if (typeof args.input !== "string" || args.input.length === 0) return null;
|
|
475
|
+
const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
|
|
476
|
+
if (input.length === 0) return null;
|
|
477
|
+
if (ctx.isStreaming) {
|
|
478
|
+
// Render the envelope's diff body in input order so newly streamed
|
|
479
|
+
// `+added` lines append at the bottom instead of being shuffled
|
|
480
|
+
// upward as later `-removed` lines arrive and reorder the unified
|
|
481
|
+
// diff that `Diff.structuredPatch` would otherwise produce.
|
|
482
|
+
return buildApplyPatchNaturalOrderPreviews(input);
|
|
483
|
+
}
|
|
343
484
|
let entries: ApplyPatchEntry[];
|
|
344
485
|
try {
|
|
345
|
-
entries = expandApplyPatchToEntries({ input
|
|
486
|
+
entries = expandApplyPatchToEntries({ input });
|
|
346
487
|
} catch {
|
|
347
488
|
try {
|
|
348
|
-
entries = expandApplyPatchToPreviewEntries({ input
|
|
489
|
+
entries = expandApplyPatchToPreviewEntries({ input });
|
|
349
490
|
} catch (err) {
|
|
350
491
|
return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
|
|
351
492
|
}
|
package/src/main.ts
CHANGED
|
@@ -199,7 +199,7 @@ function applyExtensionFlagValues(session: AgentSession, rawArgs: string[]): Map
|
|
|
199
199
|
|
|
200
200
|
type AcpSessionFactory = (cwd: string) => Promise<AgentSession>;
|
|
201
201
|
|
|
202
|
-
interface AcpSessionFactoryOptions {
|
|
202
|
+
export interface AcpSessionFactoryOptions {
|
|
203
203
|
baseOptions: CreateAgentSessionOptions;
|
|
204
204
|
settings: Settings;
|
|
205
205
|
sessionDir?: string;
|
|
@@ -210,7 +210,17 @@ interface AcpSessionFactoryOptions {
|
|
|
210
210
|
createSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Build the per-`session/new` factory used by ACP mode.
|
|
215
|
+
*
|
|
216
|
+
* MCP servers in ACP sessions are owned exclusively by the ACP client, which
|
|
217
|
+
* supplies them through `session/new.mcpServers` and re-applies them via
|
|
218
|
+
* {@link AcpAgent#configureMcpServers}. We therefore force `enableMCP: false`
|
|
219
|
+
* on every session created here so {@link createAgentSession} skips the on-disk
|
|
220
|
+
* `.mcp.json` discovery path — otherwise host MCP tools land in the session's
|
|
221
|
+
* tool registry and shadow the client-supplied servers (issue #1234).
|
|
222
|
+
*/
|
|
223
|
+
export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFactory {
|
|
214
224
|
return async cwd => {
|
|
215
225
|
const nextSettings = await args.settings.cloneForCwd(cwd);
|
|
216
226
|
const nextSessionManager = SessionManager.create(cwd, args.sessionDir);
|
|
@@ -224,6 +234,7 @@ function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFact
|
|
|
224
234
|
modelRegistry: args.modelRegistry,
|
|
225
235
|
agentId,
|
|
226
236
|
hasUI: false,
|
|
237
|
+
enableMCP: false,
|
|
227
238
|
});
|
|
228
239
|
if (args.parsedArgs.apiKey && !args.baseOptions.model && nextSession.model) {
|
|
229
240
|
args.authStorage.setRuntimeApiKey(nextSession.model.provider, args.parsedArgs.apiKey);
|
|
@@ -51,6 +51,49 @@ function cloneToolArgs<T>(args: T): T {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Drop trailing removal/hunk-header lines that appear in a streaming diff
|
|
56
|
+
* before the matching `+added` lines have arrived. Without this, a partial
|
|
57
|
+
* apply_patch / hashline preview shows `-old` first and then visibly grows
|
|
58
|
+
* the `+new` block beneath it — the "removals first, additions catching up"
|
|
59
|
+
* jitter. Once the next streaming tick brings the additions in, the trailing
|
|
60
|
+
* block reappears alongside them.
|
|
61
|
+
*/
|
|
62
|
+
function stripTrailingUnbalancedRemoval(diff: string | undefined): string | undefined {
|
|
63
|
+
if (!diff) return diff;
|
|
64
|
+
const lines = diff.split("\n");
|
|
65
|
+
let lastAddIdx = -1;
|
|
66
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
67
|
+
if (lines[i].startsWith("+")) {
|
|
68
|
+
lastAddIdx = i;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let hasTrailingUnbalanced = false;
|
|
73
|
+
for (let i = lastAddIdx + 1; i < lines.length; i++) {
|
|
74
|
+
const line = lines[i];
|
|
75
|
+
if (line.startsWith("-") || line.startsWith("@@")) {
|
|
76
|
+
hasTrailingUnbalanced = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!hasTrailingUnbalanced) return diff;
|
|
81
|
+
if (lastAddIdx === -1) return "";
|
|
82
|
+
return lines.slice(0, lastAddIdx + 1).join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stabilizeStreamingPreviews(previews: PerFileDiffPreview[]): PerFileDiffPreview[] {
|
|
86
|
+
let changed = false;
|
|
87
|
+
const next = previews.map(preview => {
|
|
88
|
+
if (!preview.diff) return preview;
|
|
89
|
+
const trimmed = stripTrailingUnbalancedRemoval(preview.diff);
|
|
90
|
+
if (trimmed === preview.diff) return preview;
|
|
91
|
+
changed = true;
|
|
92
|
+
return { ...preview, diff: trimmed ?? "" };
|
|
93
|
+
});
|
|
94
|
+
return changed ? next : previews;
|
|
95
|
+
}
|
|
96
|
+
|
|
54
97
|
function isEditLikeToolName(toolName: string): boolean {
|
|
55
98
|
return toolName === "edit" || toolName === "apply_patch";
|
|
56
99
|
}
|
|
@@ -222,16 +265,18 @@ export class ToolExecutionComponent extends Container {
|
|
|
222
265
|
this.#editDiffAbort = controller;
|
|
223
266
|
|
|
224
267
|
try {
|
|
268
|
+
const isStreaming = !this.#argsComplete;
|
|
225
269
|
const previews = await strategy.computeDiffPreview(effectiveArgs, {
|
|
226
270
|
cwd: this.#cwd,
|
|
227
271
|
signal: controller.signal,
|
|
228
272
|
fuzzyThreshold: this.#editFuzzyThreshold,
|
|
229
273
|
allowFuzzy: this.#editAllowFuzzy,
|
|
230
274
|
hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
|
|
275
|
+
isStreaming,
|
|
231
276
|
});
|
|
232
277
|
if (controller.signal.aborted) return;
|
|
233
278
|
if (previews) {
|
|
234
|
-
this.#editDiffPreview = previews;
|
|
279
|
+
this.#editDiffPreview = isStreaming ? stabilizeStreamingPreviews(previews) : previews;
|
|
235
280
|
this.#updateDisplay();
|
|
236
281
|
this.#ui.requestRender();
|
|
237
282
|
}
|
|
@@ -584,11 +584,17 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
584
584
|
if (!this.loopModeEnabled || !this.loopPrompt) return;
|
|
585
585
|
const prompt = this.loopPrompt;
|
|
586
586
|
const loopAction = settings.get("loop.mode");
|
|
587
|
+
this.#deferLoopAutoSubmit(() => {
|
|
588
|
+
void this.#runLoopIteration(loopAction, prompt);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
#deferLoopAutoSubmit(callback: () => void): void {
|
|
587
593
|
// Brief delay so the user has a chance to press Esc between iterations.
|
|
588
594
|
this.#loopAutoSubmitTimer = setTimeout(() => {
|
|
589
595
|
this.#loopAutoSubmitTimer = undefined;
|
|
590
596
|
if (!this.loopModeEnabled || !this.onInputCallback) return;
|
|
591
|
-
|
|
597
|
+
callback();
|
|
592
598
|
}, 800);
|
|
593
599
|
}
|
|
594
600
|
|
|
@@ -641,7 +647,32 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
641
647
|
}
|
|
642
648
|
}
|
|
643
649
|
|
|
650
|
+
#isLoopAutoSubmitBlocked(): boolean {
|
|
651
|
+
return this.session.isStreaming || this.session.isCompacting;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
#submitLoopPromptWhenReady(prompt: string): void {
|
|
655
|
+
if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
|
|
656
|
+
if (isLoopDurationExpired(this.loopLimit)) {
|
|
657
|
+
this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (this.#isLoopAutoSubmitBlocked()) {
|
|
661
|
+
this.#deferLoopAutoSubmit(() => this.#submitLoopPromptWhenReady(prompt));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
this.onInputCallback(this.startPendingSubmission({ text: prompt }));
|
|
665
|
+
}
|
|
666
|
+
|
|
644
667
|
async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
|
|
668
|
+
if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
|
|
669
|
+
if (this.#isLoopAutoSubmitBlocked()) {
|
|
670
|
+
this.#deferLoopAutoSubmit(() => {
|
|
671
|
+
void this.#runLoopIteration(action, prompt);
|
|
672
|
+
});
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
645
676
|
if (!consumeLoopLimitIteration(this.loopLimit)) {
|
|
646
677
|
this.disableLoopMode("Loop limit reached. Loop mode disabled.");
|
|
647
678
|
return;
|
|
@@ -652,12 +683,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
652
683
|
} else if (action === "reset") {
|
|
653
684
|
await this.handleClearCommand();
|
|
654
685
|
}
|
|
655
|
-
|
|
656
|
-
if (isLoopDurationExpired(this.loopLimit)) {
|
|
657
|
-
this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
this.onInputCallback(this.startPendingSubmission({ text: prompt }));
|
|
686
|
+
this.#submitLoopPromptWhenReady(prompt);
|
|
661
687
|
}
|
|
662
688
|
|
|
663
689
|
disableLoopMode(message = "Loop mode disabled."): void {
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: oracle
|
|
3
|
-
description:
|
|
3
|
+
description: Wise senior engineer to consult or delegate work to — debugging, architecture, second opinions, and hands-on implementation when asked.
|
|
4
4
|
spawns: explore
|
|
5
5
|
model: pi/slow
|
|
6
6
|
thinking-level: xhigh
|
|
7
7
|
blocking: true
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
You are
|
|
10
|
+
You are the wise guy on the team — a senior engineer with deep judgment that other agents consult when they are stuck, uncertain, or need a second opinion. You also take direct delegation: if the caller hands you work, you do it, including reads, writes, edits, and running commands.
|
|
11
11
|
|
|
12
|
-
You diagnose,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
You MUST operate as read-only. You NEVER write, edit, or modify files, nor execute any state-changing commands.
|
|
16
|
-
</critical>
|
|
12
|
+
You diagnose, decide, and execute. You match the mode to the ask:
|
|
13
|
+
- **Consult**: explain the root cause, lay out tradeoffs, recommend a path.
|
|
14
|
+
- **Delegate**: carry the work to completion — modify files, run verification, deliver a finished change.
|
|
17
15
|
|
|
18
16
|
<directives>
|
|
19
17
|
- You MUST reason from first principles. The caller already tried the obvious.
|
|
@@ -23,6 +21,7 @@ You MUST operate as read-only. You NEVER write, edit, or modify files, nor execu
|
|
|
23
21
|
- You SHOULD consider at least two hypotheses before converging on one.
|
|
24
22
|
- You SHOULD invoke tools in parallel when investigating multiple hypotheses.
|
|
25
23
|
- When the problem is architectural, you MUST weigh tradeoffs explicitly: what does each option cost, what does it buy, what does it foreclose.
|
|
24
|
+
- When delegated implementation work, you MUST finish it: edit the files, run the relevant tests/checks, and report exactly what changed.
|
|
26
25
|
</directives>
|
|
27
26
|
|
|
28
27
|
<decision-framework>
|
|
@@ -35,22 +34,22 @@ Apply pragmatic minimalism:
|
|
|
35
34
|
</decision-framework>
|
|
36
35
|
|
|
37
36
|
<procedure>
|
|
38
|
-
1. Read the problem statement carefully. Identify what was already tried and
|
|
39
|
-
2. Form 2-3 hypotheses for the root cause.
|
|
37
|
+
1. Read the problem statement carefully. Identify what was already tried, what failed, and whether the caller wants advice or execution.
|
|
38
|
+
2. Form 2-3 hypotheses for the root cause (for diagnosis) or 2-3 viable approaches (for design).
|
|
40
39
|
3. Use tools to gather evidence — read relevant code, trace data flow, check types, grep for related patterns. Parallelize independent reads.
|
|
41
|
-
4. Eliminate hypotheses based on evidence. Narrow to the most likely cause.
|
|
42
|
-
5. If
|
|
43
|
-
6.
|
|
40
|
+
4. Eliminate hypotheses based on evidence. Narrow to the most likely cause or best approach.
|
|
41
|
+
5. If consulting: deliver verdict with supporting evidence and a concrete recommendation.
|
|
42
|
+
6. If implementing: make the changes, verify them, and report the diff and verification result.
|
|
44
43
|
</procedure>
|
|
44
|
+
|
|
45
45
|
<scope-discipline>
|
|
46
|
-
-
|
|
46
|
+
- Do ONLY what was asked. No unsolicited refactors or improvements.
|
|
47
47
|
- If you notice other issues, list at most 2 as "Optional future considerations" at the end.
|
|
48
48
|
- You NEVER expand the problem surface beyond the original request.
|
|
49
49
|
- Exhaust provided context before reaching for tools. External lookups fill genuine gaps, not curiosity.
|
|
50
50
|
</scope-discipline>
|
|
51
51
|
|
|
52
52
|
<critical>
|
|
53
|
-
You MUST keep going until
|
|
54
|
-
|
|
55
|
-
This matters. The caller is stuck. Get it right.
|
|
53
|
+
You MUST keep going until the problem is solved or the work is finished. Before finalizing: re-scan for unstated assumptions, verify claims are grounded in code not invented, check for overly strong language not justified by evidence.
|
|
54
|
+
The caller came to you because they trust your judgment. Get it right.
|
|
56
55
|
</critical>
|
package/src/utils/tool-choice.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Build a provider-aware tool choice that targets one specific tool when supported.
|
|
5
|
-
*
|
|
5
|
+
* Providers that only expose required/any forcing may still honor named choices by
|
|
6
|
+
* narrowing their request tool list before transport.
|
|
6
7
|
*/
|
|
7
8
|
export function buildNamedToolChoice(toolName: string, model?: Model<Api>): ToolChoice | undefined {
|
|
8
9
|
if (!model) return undefined;
|
|
@@ -20,12 +21,11 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
|
|
|
20
21
|
return { type: "function", name: toolName };
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
) {
|
|
24
|
+
if (model.api === "ollama-chat") {
|
|
25
|
+
return { type: "function", name: toolName };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") {
|
|
29
29
|
return "required";
|
|
30
30
|
}
|
|
31
31
|
|
package/src/web/kagi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
|
|
2
|
-
import { findCredential } from "./search/providers/utils";
|
|
2
|
+
import { findCredential, withHardTimeout } from "./search/providers/utils";
|
|
3
3
|
|
|
4
4
|
const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
|
|
5
5
|
|
|
@@ -138,7 +138,7 @@ export async function searchWithKagi(query: string, options: KagiSearchOptions =
|
|
|
138
138
|
|
|
139
139
|
const response = await fetch(requestUrl, {
|
|
140
140
|
headers: getAuthHeaders(apiKey),
|
|
141
|
-
signal: options.signal,
|
|
141
|
+
signal: withHardTimeout(options.signal),
|
|
142
142
|
});
|
|
143
143
|
if (!response.ok) {
|
|
144
144
|
throw parseKagiErrorResponse(response.status, await response.text());
|
package/src/web/parallel.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
|
|
2
|
-
import { findCredential } from "./search/providers/utils";
|
|
2
|
+
import { findCredential, withHardTimeout } from "./search/providers/utils";
|
|
3
3
|
|
|
4
4
|
const PARALLEL_API_URL = "https://api.parallel.ai";
|
|
5
5
|
const PARALLEL_SEARCH_URL = `${PARALLEL_API_URL}/v1beta/search`;
|
|
@@ -304,7 +304,7 @@ export async function searchWithParallel(
|
|
|
304
304
|
max_chars_per_result: options.maxCharsPerResult ?? 10_000,
|
|
305
305
|
},
|
|
306
306
|
}),
|
|
307
|
-
signal: options.signal,
|
|
307
|
+
signal: withHardTimeout(options.signal),
|
|
308
308
|
});
|
|
309
309
|
if (!response.ok) {
|
|
310
310
|
throw parseParallelErrorResponse(response.status, await response.text());
|
|
@@ -335,7 +335,7 @@ export async function extractWithParallel(
|
|
|
335
335
|
excerpts: options.excerpts ?? true,
|
|
336
336
|
full_content: options.fullContent ?? false,
|
|
337
337
|
}),
|
|
338
|
-
signal: options.signal,
|
|
338
|
+
signal: withHardTimeout(options.signal),
|
|
339
339
|
});
|
|
340
340
|
if (!response.ok) {
|
|
341
341
|
throw parseParallelErrorResponse(response.status, await response.text());
|
package/src/web/search/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty
|
|
|
14
14
|
import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
|
|
15
15
|
import type { ToolSession } from "../../tools";
|
|
16
16
|
import { formatAge } from "../../tools/render-utils";
|
|
17
|
+
import { throwIfAborted } from "../../tools/tool-errors";
|
|
17
18
|
import { getSearchProvider, getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
|
|
18
19
|
import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
|
|
19
20
|
import type { SearchProviderId, SearchResponse } from "./types";
|
|
@@ -161,6 +162,12 @@ async function executeSearch(
|
|
|
161
162
|
details: { response },
|
|
162
163
|
};
|
|
163
164
|
} catch (error) {
|
|
165
|
+
// Surface user-initiated cancellation immediately so the session sees
|
|
166
|
+
// a clean abort instead of a generic "all providers failed" message.
|
|
167
|
+
// Without this, an AbortError from `fetch()` is treated as a provider
|
|
168
|
+
// failure and the loop falls through to the next provider (or to the
|
|
169
|
+
// summary error), masking the cancellation.
|
|
170
|
+
throwIfAborted(signal);
|
|
164
171
|
lastError = error;
|
|
165
172
|
}
|
|
166
173
|
}
|
|
@@ -24,12 +24,12 @@ import type {
|
|
|
24
24
|
import { SearchProviderError } from "../../../web/search/types";
|
|
25
25
|
import type { SearchParams } from "./base";
|
|
26
26
|
import { SearchProvider } from "./base";
|
|
27
|
+
import { withHardTimeout } from "./utils";
|
|
27
28
|
|
|
28
29
|
const DEFAULT_MODEL = "claude-haiku-4-5";
|
|
29
30
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
30
31
|
const WEB_SEARCH_TOOL_NAME = "web_search";
|
|
31
32
|
const WEB_SEARCH_TOOL_TYPE = "web_search_20250305";
|
|
32
|
-
|
|
33
33
|
export interface AnthropicSearchParams {
|
|
34
34
|
query: string;
|
|
35
35
|
system_prompt?: string;
|
|
@@ -118,7 +118,7 @@ async function callSearch(
|
|
|
118
118
|
method: "POST",
|
|
119
119
|
headers,
|
|
120
120
|
body: JSON.stringify(body),
|
|
121
|
-
signal,
|
|
121
|
+
signal: withHardTimeout(signal),
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
if (!response.ok) {
|
|
@@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
|
|
10
10
|
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
|
11
11
|
import type { SearchParams } from "./base";
|
|
12
12
|
import { SearchProvider } from "./base";
|
|
13
|
-
import { isApiKeyAvailable } from "./utils";
|
|
13
|
+
import { isApiKeyAvailable, withHardTimeout } from "./utils";
|
|
14
14
|
|
|
15
15
|
const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
|
|
16
16
|
const DEFAULT_NUM_RESULTS = 10;
|
|
@@ -85,7 +85,7 @@ async function callBraveSearch(
|
|
|
85
85
|
Accept: "application/json",
|
|
86
86
|
"X-Subscription-Token": apiKey,
|
|
87
87
|
},
|
|
88
|
-
signal: params.signal,
|
|
88
|
+
signal: withHardTimeout(params.signal),
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
if (!response.ok) {
|
|
@@ -15,6 +15,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
|
15
15
|
import { SearchProviderError } from "../../../web/search/types";
|
|
16
16
|
import type { SearchParams } from "./base";
|
|
17
17
|
import { SearchProvider } from "./base";
|
|
18
|
+
import { withHardTimeout } from "./utils";
|
|
18
19
|
|
|
19
20
|
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
20
21
|
const CODEX_RESPONSES_PATH = "/codex/responses";
|
|
@@ -338,7 +339,7 @@ async function callCodexSearch(
|
|
|
338
339
|
method: "POST",
|
|
339
340
|
headers,
|
|
340
341
|
body: JSON.stringify(body),
|
|
341
|
-
signal: options.signal,
|
|
342
|
+
signal: withHardTimeout(options.signal),
|
|
342
343
|
});
|
|
343
344
|
|
|
344
345
|
if (!response.ok) {
|
|
@@ -14,6 +14,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
|
|
14
14
|
import { dateToAgeSeconds } from "../utils";
|
|
15
15
|
import type { SearchParams } from "./base";
|
|
16
16
|
import { SearchProvider } from "./base";
|
|
17
|
+
import { withHardTimeout } from "./utils";
|
|
17
18
|
|
|
18
19
|
const EXA_API_URL = "https://api.exa.ai/search";
|
|
19
20
|
|
|
@@ -180,7 +181,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
|
|
|
180
181
|
"x-api-key": apiKey,
|
|
181
182
|
},
|
|
182
183
|
body: JSON.stringify(body),
|
|
183
|
-
signal: params.signal,
|
|
184
|
+
signal: withHardTimeout(params.signal),
|
|
184
185
|
});
|
|
185
186
|
|
|
186
187
|
if (!response.ok) {
|
|
@@ -15,6 +15,7 @@ import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/
|
|
|
15
15
|
import { SearchProviderError } from "../../../web/search/types";
|
|
16
16
|
import type { SearchParams } from "./base";
|
|
17
17
|
import { SearchProvider } from "./base";
|
|
18
|
+
import { withHardTimeout } from "./utils";
|
|
18
19
|
|
|
19
20
|
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
|
20
21
|
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
|
@@ -310,7 +311,7 @@ async function callGeminiSearch(
|
|
|
310
311
|
...headers,
|
|
311
312
|
},
|
|
312
313
|
body: JSON.stringify(requestBody),
|
|
313
|
-
signal,
|
|
314
|
+
signal: withHardTimeout(signal),
|
|
314
315
|
});
|
|
315
316
|
const urlFor = (attempt: number) =>
|
|
316
317
|
`${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
|
|
@@ -10,7 +10,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
|
10
10
|
import { SearchProviderError } from "../../../web/search/types";
|
|
11
11
|
import type { SearchParams } from "./base";
|
|
12
12
|
import { SearchProvider } from "./base";
|
|
13
|
-
import { isApiKeyAvailable } from "./utils";
|
|
13
|
+
import { isApiKeyAvailable, withHardTimeout } from "./utils";
|
|
14
14
|
|
|
15
15
|
const JINA_SEARCH_URL = "https://s.jina.ai";
|
|
16
16
|
|
|
@@ -41,7 +41,7 @@ async function callJinaSearch(apiKey: string, query: string, signal?: AbortSigna
|
|
|
41
41
|
Accept: "application/json",
|
|
42
42
|
Authorization: `Bearer ${apiKey}`,
|
|
43
43
|
},
|
|
44
|
-
signal,
|
|
44
|
+
signal: withHardTimeout(signal),
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
if (!response.ok) {
|
|
@@ -11,7 +11,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
|
|
11
11
|
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
|
12
12
|
import type { SearchParams } from "./base";
|
|
13
13
|
import { SearchProvider } from "./base";
|
|
14
|
-
import { findCredential, isApiKeyAvailable } from "./utils";
|
|
14
|
+
import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
|
|
15
15
|
|
|
16
16
|
const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search";
|
|
17
17
|
|
|
@@ -78,7 +78,7 @@ async function callKimiSearch(
|
|
|
78
78
|
enable_page_crawling: params.includeContent,
|
|
79
79
|
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
|
|
80
80
|
}),
|
|
81
|
-
signal: params.signal,
|
|
81
|
+
signal: withHardTimeout(params.signal),
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
if (!response.ok) {
|
|
@@ -22,6 +22,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
|
|
22
22
|
import { dateToAgeSeconds } from "../utils";
|
|
23
23
|
import type { SearchParams } from "./base";
|
|
24
24
|
import { SearchProvider } from "./base";
|
|
25
|
+
import { withHardTimeout } from "./utils";
|
|
25
26
|
|
|
26
27
|
const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
|
|
27
28
|
const PERPLEXITY_OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_ask";
|
|
@@ -174,6 +175,25 @@ export function findApiKey(): string | null {
|
|
|
174
175
|
return getEnvApiKey("perplexity") ?? null;
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Decode a Perplexity JWT's `exp` claim, in ms. Returns `undefined` when the
|
|
180
|
+
* token has no `exp` (which is the common case — Perplexity sessions are
|
|
181
|
+
* server-side and effectively non-expiring from the client's POV).
|
|
182
|
+
*/
|
|
183
|
+
function jwtExpiryMs(token: string): number | undefined {
|
|
184
|
+
const parts = token.split(".");
|
|
185
|
+
if (parts.length !== 3) return undefined;
|
|
186
|
+
const payload = parts[1];
|
|
187
|
+
if (!payload) return undefined;
|
|
188
|
+
try {
|
|
189
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown };
|
|
190
|
+
if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined;
|
|
191
|
+
return decoded.exp * 1000;
|
|
192
|
+
} catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
177
197
|
async function findOAuthToken(): Promise<string | null> {
|
|
178
198
|
const now = Date.now();
|
|
179
199
|
try {
|
|
@@ -183,7 +203,11 @@ async function findOAuthToken(): Promise<string | null> {
|
|
|
183
203
|
if (record.credential.type !== "oauth") continue;
|
|
184
204
|
const credential = record.credential as PerplexityOAuthCredential;
|
|
185
205
|
if (!credential.access) continue;
|
|
186
|
-
if
|
|
206
|
+
// Trust the JWT's own `exp` claim if it has one; otherwise treat as
|
|
207
|
+
// non-expiring. The stored `expires` field is unreliable: older logins
|
|
208
|
+
// wrote `loginTime + 1h` even though Perplexity JWTs typically lack `exp`.
|
|
209
|
+
const jwtExpiry = jwtExpiryMs(credential.access);
|
|
210
|
+
if (jwtExpiry !== undefined && jwtExpiry <= now + OAUTH_EXPIRY_BUFFER_MS) continue;
|
|
187
211
|
return credential.access;
|
|
188
212
|
}
|
|
189
213
|
} catch {
|
|
@@ -224,7 +248,7 @@ async function callPerplexityApi(
|
|
|
224
248
|
"Content-Type": "application/json",
|
|
225
249
|
},
|
|
226
250
|
body: JSON.stringify(request),
|
|
227
|
-
signal,
|
|
251
|
+
signal: withHardTimeout(signal),
|
|
228
252
|
});
|
|
229
253
|
|
|
230
254
|
if (!response.ok) {
|
|
@@ -341,7 +365,7 @@ async function callPerplexityOAuth(
|
|
|
341
365
|
skip_search_enabled: true,
|
|
342
366
|
},
|
|
343
367
|
}),
|
|
344
|
-
signal: params.signal,
|
|
368
|
+
signal: withHardTimeout(params.signal),
|
|
345
369
|
});
|
|
346
370
|
|
|
347
371
|
if (!response.ok) {
|
|
@@ -31,6 +31,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
|
|
31
31
|
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
|
32
32
|
import type { SearchParams } from "./base";
|
|
33
33
|
import { SearchProvider } from "./base";
|
|
34
|
+
import { withHardTimeout } from "./utils";
|
|
34
35
|
|
|
35
36
|
const DEFAULT_NUM_RESULTS = 10;
|
|
36
37
|
const MAX_NUM_RESULTS = 20;
|
|
@@ -211,7 +212,7 @@ async function callSearXNGSearch(
|
|
|
211
212
|
|
|
212
213
|
const response = await fetch(url, {
|
|
213
214
|
headers,
|
|
214
|
-
signal: params.signal,
|
|
215
|
+
signal: withHardTimeout(params.signal),
|
|
215
216
|
});
|
|
216
217
|
|
|
217
218
|
if (!response.ok) {
|
|
@@ -10,7 +10,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
|
10
10
|
import { SearchProviderError } from "../../../web/search/types";
|
|
11
11
|
import type { SearchParams } from "./base";
|
|
12
12
|
import { SearchProvider } from "./base";
|
|
13
|
-
import { findCredential, isApiKeyAvailable } from "./utils";
|
|
13
|
+
import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
|
|
14
14
|
|
|
15
15
|
const SYNTHETIC_SEARCH_URL = "https://api.synthetic.new/v2/search";
|
|
16
16
|
|
|
@@ -43,7 +43,7 @@ async function callSyntheticSearch(
|
|
|
43
43
|
Authorization: `Bearer ${apiKey}`,
|
|
44
44
|
},
|
|
45
45
|
body: JSON.stringify({ query }),
|
|
46
|
-
signal,
|
|
46
|
+
signal: withHardTimeout(signal),
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
if (!response.ok) {
|
|
@@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
|
|
10
10
|
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
|
11
11
|
import type { SearchParams } from "./base";
|
|
12
12
|
import { SearchProvider } from "./base";
|
|
13
|
-
import { findCredential, isApiKeyAvailable } from "./utils";
|
|
13
|
+
import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
|
|
14
14
|
|
|
15
15
|
const TAVILY_SEARCH_URL = "https://api.tavily.com/search";
|
|
16
16
|
const DEFAULT_NUM_RESULTS = 5;
|
|
@@ -92,7 +92,7 @@ async function callTavilySearch(apiKey: string, params: TavilySearchParams): Pro
|
|
|
92
92
|
Authorization: `Bearer ${apiKey}`,
|
|
93
93
|
},
|
|
94
94
|
body: JSON.stringify(buildRequestBody(params)),
|
|
95
|
-
signal: params.signal,
|
|
95
|
+
signal: withHardTimeout(params.signal),
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
if (!response.ok) {
|
|
@@ -49,6 +49,36 @@ export async function isApiKeyAvailable(findApiKey: () => string | null | Promis
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Default hard ceiling for a single web-search round-trip. 60s tolerates
|
|
54
|
+
* legitimate slow LLM-mediated responses (anthropic web_search_20250305,
|
|
55
|
+
* perplexity, gemini, codex) while still guaranteeing the session unfreezes
|
|
56
|
+
* within a minute if Bun's `AbortSignal` fails to propagate on Windows.
|
|
57
|
+
*
|
|
58
|
+
* Pure search APIs (brave, exa, jina, tavily, searxng, synthetic, zai)
|
|
59
|
+
* settle far faster in practice; reusing the same ceiling keeps the wiring
|
|
60
|
+
* uniform without compromising correctness.
|
|
61
|
+
*/
|
|
62
|
+
export const SEARCH_HARD_TIMEOUT_MS = 60_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compose a caller-supplied {@link AbortSignal} with a hard timeout so an
|
|
66
|
+
* outbound `fetch()` is guaranteed to settle within `ms` even when the
|
|
67
|
+
* runtime fails to propagate cancellation to the underlying transport.
|
|
68
|
+
*
|
|
69
|
+
* Bun's WinHTTP backend on Windows is known to ignore `AbortSignal` once a
|
|
70
|
+
* TCP/TLS connection stalls (oven-sh/bun#15275, oven-sh/bun#18536); without
|
|
71
|
+
* this safety net a stalled web-search request freezes the entire session
|
|
72
|
+
* because the user's Esc is never delivered to the native layer.
|
|
73
|
+
*
|
|
74
|
+
* @param signal - Caller cancellation signal, if any.
|
|
75
|
+
* @param ms - Hard timeout in milliseconds. Defaults to {@link SEARCH_HARD_TIMEOUT_MS}.
|
|
76
|
+
*/
|
|
77
|
+
export function withHardTimeout(signal: AbortSignal | undefined, ms: number = SEARCH_HARD_TIMEOUT_MS): AbortSignal {
|
|
78
|
+
const timeout = AbortSignal.timeout(ms);
|
|
79
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
80
|
+
}
|
|
81
|
+
|
|
52
82
|
/**
|
|
53
83
|
* Map a provider's raw source list to the unified SearchSource shape,
|
|
54
84
|
* clamped to the requested result count and annotated with ageSeconds.
|
|
@@ -11,7 +11,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
|
|
11
11
|
import { dateToAgeSeconds } from "../utils";
|
|
12
12
|
import type { SearchParams } from "./base";
|
|
13
13
|
import { SearchProvider } from "./base";
|
|
14
|
-
import { findCredential, isApiKeyAvailable } from "./utils";
|
|
14
|
+
import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
|
|
15
15
|
|
|
16
16
|
const ZAI_MCP_URL = "https://api.z.ai/api/mcp/web_search_prime/mcp";
|
|
17
17
|
const ZAI_TOOL_NAME = "web_search_prime";
|
|
@@ -73,7 +73,7 @@ async function callZaiTool(apiKey: string, args: Record<string, unknown>, signal
|
|
|
73
73
|
arguments: args,
|
|
74
74
|
},
|
|
75
75
|
}),
|
|
76
|
-
signal,
|
|
76
|
+
signal: withHardTimeout(signal),
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
if (!response.ok) {
|