@gajae-code/coding-agent 0.7.10 → 0.7.11
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 +25 -0
- package/README.md +1 -1
- package/dist/types/config/keybindings.d.ts +11 -5
- package/dist/types/defaults/gjc-defaults.d.ts +7 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +3 -2
- package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +1 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +2 -1
- package/dist/types/modes/components/skill-message.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/input-controller.d.ts +4 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/prompt-action-autocomplete.d.ts +2 -0
- package/dist/types/modes/slash-command-visibility.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +8 -5
- package/dist/types/slash-commands/types.d.ts +2 -0
- package/dist/types/utils/cmux-workspace.d.ts +30 -1
- package/dist/types/web/search/index.d.ts +3 -1
- package/dist/types/web/search/provider.d.ts +9 -0
- package/dist/types/web/search/providers/utils.d.ts +42 -13
- package/package.json +7 -7
- package/src/cli/update-cli.ts +24 -0
- package/src/cli/web-search-cli.ts +2 -5
- package/src/cli.ts +1 -1
- package/src/commands/launch.ts +1 -1
- package/src/commands/ralplan.ts +1 -0
- package/src/config/keybindings.ts +14 -5
- package/src/config/model-profiles.ts +16 -0
- package/src/coordinator-mcp/server.ts +9 -3
- package/src/defaults/gjc/skills/ralplan/SKILL.md +16 -14
- package/src/defaults/gjc-defaults.ts +16 -0
- package/src/extensibility/extensions/runner.ts +2 -1
- package/src/gjc-runtime/launch-tmux.ts +164 -42
- package/src/gjc-runtime/ralplan-runtime.ts +22 -6
- package/src/gjc-runtime/restricted-role-agent-bash.ts +1 -0
- package/src/gjc-runtime/state-runtime.ts +8 -5
- package/src/gjc-runtime/state-writer.ts +5 -5
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/main.ts +2 -2
- package/src/migrate/executor.ts +23 -6
- package/src/modes/components/custom-editor.ts +8 -0
- package/src/modes/components/model-selector.ts +6 -2
- package/src/modes/components/settings-selector.ts +0 -2
- package/src/modes/components/skill-message.ts +18 -10
- package/src/modes/controllers/command-controller.ts +23 -0
- package/src/modes/controllers/input-controller.ts +41 -9
- package/src/modes/interactive-mode.ts +16 -7
- package/src/modes/prompt-action-autocomplete.ts +23 -1
- package/src/modes/slash-command-visibility.ts +9 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/hotkeys-markdown.ts +5 -2
- package/src/modes/utils/ui-helpers.ts +76 -3
- package/src/notifications/telegram-daemon.ts +4 -4
- package/src/prompts/agents/architect.md +7 -4
- package/src/prompts/agents/critic.md +15 -6
- package/src/prompts/agents/planner.md +8 -3
- package/src/prompts/tools/bash.md +1 -1
- package/src/sdk.ts +3 -6
- package/src/session/agent-session.ts +60 -17
- package/src/slash-commands/builtin-registry.ts +30 -10
- package/src/slash-commands/types.ts +2 -0
- package/src/tools/bash.ts +24 -8
- package/src/utils/cmux-workspace.ts +134 -7
- package/src/utils/title-generator.ts +1 -1
- package/src/web/kagi.ts +1 -1
- package/src/web/parallel.ts +2 -2
- package/src/web/search/index.ts +118 -45
- package/src/web/search/provider.ts +65 -0
- package/src/web/search/providers/anthropic.ts +1 -1
- package/src/web/search/providers/brave.ts +1 -1
- package/src/web/search/providers/codex.ts +1 -1
- package/src/web/search/providers/duckduckgo.ts +1 -1
- package/src/web/search/providers/exa.ts +1 -1
- package/src/web/search/providers/gemini.ts +7 -3
- package/src/web/search/providers/jina.ts +1 -1
- package/src/web/search/providers/kimi.ts +6 -1
- package/src/web/search/providers/openai-compatible.ts +1 -1
- package/src/web/search/providers/parallel.ts +1 -1
- package/src/web/search/providers/perplexity.ts +2 -2
- package/src/web/search/providers/searxng.ts +1 -1
- package/src/web/search/providers/synthetic.ts +1 -1
- package/src/web/search/providers/tavily.ts +1 -1
- package/src/web/search/providers/utils.ts +67 -17
- package/src/web/search/providers/xai.ts +1 -1
- package/src/web/search/providers/zai.ts +1 -1
|
@@ -2,14 +2,25 @@ import { logger } from "@gajae-code/utils";
|
|
|
2
2
|
|
|
3
3
|
const CMUX_COMMAND = "cmux";
|
|
4
4
|
const CMUX_WORKSPACE_ID_ENV = "CMUX_WORKSPACE_ID";
|
|
5
|
+
const CMUX_NO_RENAME_ENV = "GJC_NO_CMUX_RENAME";
|
|
5
6
|
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
7
|
+
const CMUX_WORKSPACE_TITLE_PREFIX = "GJC: ";
|
|
6
8
|
const CMUX_WORKSPACE_RENAME_TIMEOUT_MS = 1500;
|
|
9
|
+
const CMUX_WORKSPACE_LIST_TIMEOUT_MS = 1500;
|
|
7
10
|
|
|
8
11
|
export interface CmuxWorkspaceRenameCommand {
|
|
9
12
|
command: string;
|
|
10
13
|
args: string[];
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
/** Current ownership state of a cmux workspace, read back from `cmux workspace list`. */
|
|
17
|
+
export interface CmuxWorkspaceOwnership {
|
|
18
|
+
/** cmux marks a workspace `has_custom_title` once an explicit title is set (by the user or by GJC). */
|
|
19
|
+
hasCustomTitle: boolean;
|
|
20
|
+
/** The workspace's current display title. */
|
|
21
|
+
title: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export interface CmuxWorkspaceRenameProcess {
|
|
14
25
|
exited: Promise<number>;
|
|
15
26
|
kill(): void;
|
|
@@ -24,6 +35,12 @@ export interface CmuxWorkspaceTitleSyncOptions {
|
|
|
24
35
|
command: string[],
|
|
25
36
|
options: { env: NodeJS.ProcessEnv; stdin: "ignore"; stdout: "ignore"; stderr: "ignore" },
|
|
26
37
|
) => CmuxWorkspaceRenameProcess;
|
|
38
|
+
/** Reads the current ownership state of `workspaceId`. Injectable for tests. */
|
|
39
|
+
readOwnership?: (
|
|
40
|
+
cmuxCommand: string,
|
|
41
|
+
workspaceId: string,
|
|
42
|
+
env: NodeJS.ProcessEnv,
|
|
43
|
+
) => Promise<CmuxWorkspaceOwnership | null>;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
function defaultSpawn(
|
|
@@ -33,12 +50,24 @@ function defaultSpawn(
|
|
|
33
50
|
return Bun.spawn(command, options);
|
|
34
51
|
}
|
|
35
52
|
|
|
53
|
+
function isEnvSet(value: string | undefined): boolean {
|
|
54
|
+
if (!value) return false;
|
|
55
|
+
const normalized = value.trim().toLowerCase();
|
|
56
|
+
return normalized !== "" && normalized !== "0" && normalized !== "false";
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
export function sanitizeCmuxWorkspaceTitle(title: string | undefined): string | undefined {
|
|
37
60
|
if (!title) return undefined;
|
|
38
61
|
const sanitized = title.replace(CONTROL_CHARS, " ").replace(/\s+/g, " ").trim();
|
|
39
62
|
return sanitized || undefined;
|
|
40
63
|
}
|
|
41
64
|
|
|
65
|
+
export function formatCmuxWorkspaceTitle(title: string | undefined): string | undefined {
|
|
66
|
+
const sanitized = sanitizeCmuxWorkspaceTitle(title);
|
|
67
|
+
if (!sanitized) return undefined;
|
|
68
|
+
return sanitized.startsWith(CMUX_WORKSPACE_TITLE_PREFIX) ? sanitized : `${CMUX_WORKSPACE_TITLE_PREFIX}${sanitized}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
export function buildCmuxWorkspaceRenameCommand(
|
|
43
72
|
sessionName: string | undefined,
|
|
44
73
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -46,7 +75,7 @@ export function buildCmuxWorkspaceRenameCommand(
|
|
|
46
75
|
const workspaceId = env[CMUX_WORKSPACE_ID_ENV]?.trim();
|
|
47
76
|
if (!workspaceId) return null;
|
|
48
77
|
|
|
49
|
-
const title =
|
|
78
|
+
const title = formatCmuxWorkspaceTitle(sessionName);
|
|
50
79
|
if (!title) return null;
|
|
51
80
|
|
|
52
81
|
return {
|
|
@@ -55,27 +84,125 @@ export function buildCmuxWorkspaceRenameCommand(
|
|
|
55
84
|
};
|
|
56
85
|
}
|
|
57
86
|
|
|
58
|
-
|
|
87
|
+
/** Parse `cmux workspace list --json --id-format both` output and return the
|
|
88
|
+
* ownership state of the workspace matching `workspaceId` (by UUID `id` or `ref`). */
|
|
89
|
+
export function parseCmuxWorkspaceOwnership(jsonText: string, workspaceId: string): CmuxWorkspaceOwnership | null {
|
|
90
|
+
const target = workspaceId.trim().toLowerCase();
|
|
91
|
+
if (!target) return null;
|
|
92
|
+
|
|
93
|
+
let parsed: unknown;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(jsonText);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const workspaces = (parsed as { workspaces?: unknown }).workspaces;
|
|
101
|
+
if (!Array.isArray(workspaces)) return null;
|
|
102
|
+
|
|
103
|
+
for (const entry of workspaces) {
|
|
104
|
+
if (!entry || typeof entry !== "object") continue;
|
|
105
|
+
const record = entry as Record<string, unknown>;
|
|
106
|
+
const id = typeof record.id === "string" ? record.id.toLowerCase() : "";
|
|
107
|
+
const ref = typeof record.ref === "string" ? record.ref.toLowerCase() : "";
|
|
108
|
+
if (id !== target && ref !== target) continue;
|
|
109
|
+
return {
|
|
110
|
+
hasCustomTitle: record.has_custom_title === true,
|
|
111
|
+
title: typeof record.title === "string" ? record.title : "",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Only rename when GJC owns the name:
|
|
118
|
+
* - unknown ownership (read failed) → skip (fail safe, never clobber)
|
|
119
|
+
* - already the desired title → skip (no-op)
|
|
120
|
+
* - workspace still on its default title → rename
|
|
121
|
+
* - any custom title (user- or peer-set) → skip
|
|
122
|
+
* This makes GJC name a fresh workspace once and then leave it alone, so it
|
|
123
|
+
* never overwrites a user-pinned name and multiple sessions sharing one
|
|
124
|
+
* CMUX_WORKSPACE_ID do not thrash the workspace title. */
|
|
125
|
+
export function shouldRenameCmuxWorkspace(ownership: CmuxWorkspaceOwnership | null, desiredTitle: string): boolean {
|
|
126
|
+
if (!ownership) return false;
|
|
127
|
+
if ((sanitizeCmuxWorkspaceTitle(ownership.title) ?? "") === desiredTitle) return false;
|
|
128
|
+
return !ownership.hasCustomTitle;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function defaultReadOwnership(
|
|
132
|
+
cmuxCommand: string,
|
|
133
|
+
workspaceId: string,
|
|
134
|
+
env: NodeJS.ProcessEnv,
|
|
135
|
+
): Promise<CmuxWorkspaceOwnership | null> {
|
|
136
|
+
try {
|
|
137
|
+
const proc = Bun.spawn([cmuxCommand, "workspace", "list", "--json", "--id-format", "both"], {
|
|
138
|
+
env: { ...env, CMUX_QUIET: "1" },
|
|
139
|
+
stdin: "ignore",
|
|
140
|
+
stdout: "pipe",
|
|
141
|
+
stderr: "ignore",
|
|
142
|
+
});
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
try {
|
|
145
|
+
proc.kill();
|
|
146
|
+
} catch {}
|
|
147
|
+
}, CMUX_WORKSPACE_LIST_TIMEOUT_MS);
|
|
148
|
+
timer.unref?.();
|
|
149
|
+
const text = await new Response(proc.stdout).text();
|
|
150
|
+
await proc.exited;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
return parseCmuxWorkspaceOwnership(text, workspaceId);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
logger.debug("cmux workspace list failed", { error: String(error) });
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Best-effort sync of the containing cmux workspace title to the current GJC
|
|
161
|
+
* session name. Ownership-guarded: GJC reads the current workspace title and
|
|
162
|
+
* only renames a workspace that still has its default title, so it never
|
|
163
|
+
* overwrites a name the user pinned or a name a peer session (sharing the same
|
|
164
|
+
* CMUX_WORKSPACE_ID) set. Opt out with GJC_NO_CMUX_RENAME.
|
|
165
|
+
*/
|
|
166
|
+
export async function syncCmuxWorkspaceTitle(
|
|
59
167
|
sessionName: string | undefined,
|
|
60
168
|
options: CmuxWorkspaceTitleSyncOptions = {},
|
|
61
|
-
): void {
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
const env = options.env ?? process.env;
|
|
171
|
+
if (isEnvSet(env[CMUX_NO_RENAME_ENV])) return;
|
|
172
|
+
|
|
62
173
|
const isTty = options.isTty ?? process.stdout.isTTY === true;
|
|
63
174
|
if (!isTty) return;
|
|
64
175
|
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
176
|
+
const workspaceId = env[CMUX_WORKSPACE_ID_ENV]?.trim();
|
|
177
|
+
if (!workspaceId) return;
|
|
178
|
+
|
|
179
|
+
const desired = formatCmuxWorkspaceTitle(sessionName);
|
|
180
|
+
if (!desired) return;
|
|
68
181
|
|
|
69
182
|
const which = options.which ?? Bun.which;
|
|
70
183
|
let resolvedCommand: string | null;
|
|
71
184
|
try {
|
|
72
|
-
resolvedCommand = which(
|
|
185
|
+
resolvedCommand = which(CMUX_COMMAND);
|
|
73
186
|
} catch (error) {
|
|
74
187
|
logger.debug("cmux workspace rename command lookup failed", { error: String(error) });
|
|
75
188
|
return;
|
|
76
189
|
}
|
|
77
190
|
if (!resolvedCommand) return;
|
|
78
191
|
|
|
192
|
+
let ownership: CmuxWorkspaceOwnership | null;
|
|
193
|
+
try {
|
|
194
|
+
const readOwnership = options.readOwnership ?? defaultReadOwnership;
|
|
195
|
+
ownership = await readOwnership(resolvedCommand, workspaceId, env);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logger.debug("cmux workspace ownership read failed", { error: String(error) });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!shouldRenameCmuxWorkspace(ownership, desired)) return;
|
|
202
|
+
|
|
203
|
+
const plan = buildCmuxWorkspaceRenameCommand(sessionName, env);
|
|
204
|
+
if (!plan) return;
|
|
205
|
+
|
|
79
206
|
const spawn = options.spawn ?? defaultSpawn;
|
|
80
207
|
try {
|
|
81
208
|
const proc = spawn([resolvedCommand, ...plan.args], {
|
|
@@ -219,7 +219,7 @@ export function setTerminalTitle(title: string): void {
|
|
|
219
219
|
|
|
220
220
|
export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void {
|
|
221
221
|
setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd));
|
|
222
|
-
syncCmuxWorkspaceTitle(sessionName);
|
|
222
|
+
void syncCmuxWorkspaceTitle(sessionName);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
/**
|
package/src/web/kagi.ts
CHANGED
|
@@ -147,7 +147,7 @@ export async function searchWithKagi(
|
|
|
147
147
|
|
|
148
148
|
const response = await fetch(requestUrl, {
|
|
149
149
|
headers: getAuthHeaders(apiKey),
|
|
150
|
-
signal: withHardTimeout(options.signal),
|
|
150
|
+
signal: withHardTimeout(options.signal, "api"),
|
|
151
151
|
});
|
|
152
152
|
if (!response.ok) {
|
|
153
153
|
throw parseKagiErrorResponse(response.status, await response.text());
|
package/src/web/parallel.ts
CHANGED
|
@@ -306,7 +306,7 @@ export async function searchWithParallel(
|
|
|
306
306
|
max_chars_per_result: options.maxCharsPerResult ?? 10_000,
|
|
307
307
|
},
|
|
308
308
|
}),
|
|
309
|
-
signal: withHardTimeout(options.signal),
|
|
309
|
+
signal: withHardTimeout(options.signal, "api"),
|
|
310
310
|
});
|
|
311
311
|
if (!response.ok) {
|
|
312
312
|
throw parseParallelErrorResponse(response.status, await response.text());
|
|
@@ -338,7 +338,7 @@ export async function extractWithParallel(
|
|
|
338
338
|
excerpts: options.excerpts ?? true,
|
|
339
339
|
full_content: options.fullContent ?? false,
|
|
340
340
|
}),
|
|
341
|
-
signal: withHardTimeout(options.signal),
|
|
341
|
+
signal: withHardTimeout(options.signal, "api"),
|
|
342
342
|
});
|
|
343
343
|
if (!response.ok) {
|
|
344
344
|
throw parseParallelErrorResponse(response.status, await response.text());
|
package/src/web/search/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { discoverAuthStorage } from "../../sdk";
|
|
|
16
16
|
import type { ToolSession } from "../../tools";
|
|
17
17
|
import { formatAge } from "../../tools/render-utils";
|
|
18
18
|
import { throwIfAborted } from "../../tools/tool-errors";
|
|
19
|
-
import { getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
|
|
19
|
+
import { getSearchProviderLabel, prewarmSearchProviders, resolveProviderChain, type SearchProvider } from "./provider";
|
|
20
20
|
import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
|
|
21
21
|
import type { ActiveSearchModelContext, SearchProviderId, SearchResponse } from "./types";
|
|
22
22
|
import { SearchProviderError } from "./types";
|
|
@@ -148,6 +148,22 @@ interface ExecuteSearchOptions {
|
|
|
148
148
|
activeModelContext?: ActiveSearchModelContext;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Delay before the keyless DuckDuckGo hedge fires while a slower primary is
|
|
153
|
+
* still in flight. Chosen to be comfortably above DDG's typical ~1-2s
|
|
154
|
+
* round-trip cost budget yet far below LLM-mediated primary latency, so a
|
|
155
|
+
* failing primary can fall back to an already-completed hedge result instead
|
|
156
|
+
* of paying primary-failure time plus DDG time sequentially.
|
|
157
|
+
*/
|
|
158
|
+
const DDG_HEDGE_DELAY_MS = 3_000;
|
|
159
|
+
|
|
160
|
+
let ddgHedgeDelayMs = DDG_HEDGE_DELAY_MS;
|
|
161
|
+
|
|
162
|
+
/** Override the hedge delay (tests). Non-finite/non-positive resets to default. */
|
|
163
|
+
export function setDdgHedgeDelayMs(ms: number | undefined): void {
|
|
164
|
+
ddgHedgeDelayMs = typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : DDG_HEDGE_DELAY_MS;
|
|
165
|
+
}
|
|
166
|
+
|
|
151
167
|
/** Execute web search */
|
|
152
168
|
async function executeSearch(
|
|
153
169
|
_toolCallId: string,
|
|
@@ -166,52 +182,91 @@ async function executeSearch(
|
|
|
166
182
|
activeModelContext,
|
|
167
183
|
});
|
|
168
184
|
|
|
185
|
+
const baseSearchParams = {
|
|
186
|
+
query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL
|
|
187
|
+
limit: params.limit,
|
|
188
|
+
recency: params.recency,
|
|
189
|
+
systemPrompt: webSearchSystemPrompt,
|
|
190
|
+
maxOutputTokens: params.max_tokens,
|
|
191
|
+
numSearchResults: params.num_search_results,
|
|
192
|
+
temperature: params.temperature,
|
|
193
|
+
xaiSearchMode: params.xai_search_mode,
|
|
194
|
+
allowedDomains: params.allowed_domains,
|
|
195
|
+
excludedDomains: params.excluded_domains,
|
|
196
|
+
allowedXHandles: params.allowed_x_handles,
|
|
197
|
+
excludedXHandles: params.excluded_x_handles,
|
|
198
|
+
fromDate: params.from_date,
|
|
199
|
+
toDate: params.to_date,
|
|
200
|
+
enableImageUnderstanding: params.enable_image_understanding,
|
|
201
|
+
enableImageSearch: params.enable_image_search,
|
|
202
|
+
enableVideoUnderstanding: params.enable_video_understanding,
|
|
203
|
+
noInlineCitations: params.no_inline_citations,
|
|
204
|
+
authStorage,
|
|
205
|
+
sessionId,
|
|
206
|
+
activeModelContext,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Hedged fallback: when DuckDuckGo (keyless, cheap) is a non-primary member
|
|
210
|
+
// of the chain, start it in the background after a short delay. If the
|
|
211
|
+
// primary succeeds first the hedge is aborted; if the primary fails, the
|
|
212
|
+
// hedge result is typically already available, collapsing the fallback
|
|
213
|
+
// latency from `t(primary failure) + t(ddg)` to `max(t(primary failure), t(ddg))`.
|
|
214
|
+
const ddgIndex = providers.findIndex(p => p.id === "duckduckgo");
|
|
215
|
+
const hedgeCtl = new AbortController();
|
|
216
|
+
const onUpstreamAbort = () => hedgeCtl.abort();
|
|
217
|
+
signal?.addEventListener("abort", onUpstreamAbort, { once: true });
|
|
218
|
+
let hedgePromise: Promise<SearchResponse> | undefined;
|
|
219
|
+
let hedgeTimer: NodeJS.Timeout | undefined;
|
|
220
|
+
if (ddgIndex > 0) {
|
|
221
|
+
const ddg = providers[ddgIndex]!;
|
|
222
|
+
hedgeTimer = setTimeout(() => {
|
|
223
|
+
hedgePromise = ddg.search({ ...baseSearchParams, signal: hedgeCtl.signal });
|
|
224
|
+
// Failures are consumed when (and if) the loop reaches DuckDuckGo;
|
|
225
|
+
// never let a losing hedge become an unhandled rejection.
|
|
226
|
+
hedgePromise.catch(() => {});
|
|
227
|
+
}, ddgHedgeDelayMs);
|
|
228
|
+
}
|
|
229
|
+
const cancelHedge = () => {
|
|
230
|
+
if (hedgeTimer !== undefined) clearTimeout(hedgeTimer);
|
|
231
|
+
hedgeCtl.abort();
|
|
232
|
+
signal?.removeEventListener("abort", onUpstreamAbort);
|
|
233
|
+
};
|
|
234
|
+
|
|
169
235
|
const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
|
|
170
236
|
let lastProvider = providers[0];
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
content: [{ type: "text" as const, text: warning ? `Warning: ${warning}\n\n${text}` : text }],
|
|
204
|
-
details: { response, ...(warning ? { warning } : {}) },
|
|
205
|
-
};
|
|
206
|
-
} catch (error) {
|
|
207
|
-
// Surface user-initiated cancellation immediately so the session sees
|
|
208
|
-
// a clean abort instead of a generic "all providers failed" message.
|
|
209
|
-
// Without this, an AbortError from `fetch()` is treated as a provider
|
|
210
|
-
// failure and the loop falls through to the next provider (or to the
|
|
211
|
-
// summary error), masking the cancellation.
|
|
212
|
-
throwIfAborted(signal);
|
|
213
|
-
failures.push({ provider, error });
|
|
237
|
+
try {
|
|
238
|
+
for (const provider of providers) {
|
|
239
|
+
lastProvider = provider;
|
|
240
|
+
try {
|
|
241
|
+
let response: SearchResponse;
|
|
242
|
+
if (provider.id === "duckduckgo" && hedgePromise) {
|
|
243
|
+
// Reuse the in-flight (often already-settled) hedge request.
|
|
244
|
+
if (hedgeTimer !== undefined) clearTimeout(hedgeTimer);
|
|
245
|
+
response = await hedgePromise;
|
|
246
|
+
} else {
|
|
247
|
+
if (provider.id === "duckduckgo" && hedgeTimer !== undefined) clearTimeout(hedgeTimer);
|
|
248
|
+
response = await provider.search({ ...baseSearchParams, signal });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const text = formatForLLM(response);
|
|
252
|
+
const warning = failures.length > 0 ? formatFallbackWarning(failures, provider) : undefined;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: "text" as const, text: warning ? `Warning: ${warning}\n\n${text}` : text }],
|
|
256
|
+
details: { response, ...(warning ? { warning } : {}) },
|
|
257
|
+
};
|
|
258
|
+
} catch (error) {
|
|
259
|
+
// Surface user-initiated cancellation immediately so the session sees
|
|
260
|
+
// a clean abort instead of a generic "all providers failed" message.
|
|
261
|
+
// Without this, an AbortError from `fetch()` is treated as a provider
|
|
262
|
+
// failure and the loop falls through to the next provider (or to the
|
|
263
|
+
// summary error), masking the cancellation.
|
|
264
|
+
throwIfAborted(signal);
|
|
265
|
+
failures.push({ provider, error });
|
|
266
|
+
}
|
|
214
267
|
}
|
|
268
|
+
} finally {
|
|
269
|
+
cancelHedge();
|
|
215
270
|
}
|
|
216
271
|
|
|
217
272
|
const lastFailure = failures[failures.length - 1];
|
|
@@ -273,6 +328,24 @@ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRe
|
|
|
273
328
|
constructor(session: ToolSession) {
|
|
274
329
|
this.#session = session;
|
|
275
330
|
this.description = prompt.render(webSearchDescription);
|
|
331
|
+
// Prewarm: resolve the provider chain once in the background so the
|
|
332
|
+
// provider modules are imported and the chain cache is primed before
|
|
333
|
+
// the first user-visible search. Best-effort only.
|
|
334
|
+
const authStorage = session.authStorage;
|
|
335
|
+
if (authStorage) {
|
|
336
|
+
try {
|
|
337
|
+
const activeModelContext = session.model
|
|
338
|
+
? session.modelRegistry?.getActiveSearchModelContext(session.model)
|
|
339
|
+
: undefined;
|
|
340
|
+
prewarmSearchProviders({
|
|
341
|
+
authStorage,
|
|
342
|
+
sessionId: session.getSessionId?.() ?? undefined,
|
|
343
|
+
activeModelContext,
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
// Never let prewarming break tool construction.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
276
349
|
}
|
|
277
350
|
|
|
278
351
|
async execute(
|
|
@@ -334,6 +407,6 @@ export {
|
|
|
334
407
|
setPreferredSearchProvider,
|
|
335
408
|
setSearchFallbackProviders,
|
|
336
409
|
} from "./provider";
|
|
337
|
-
export { setSearchHardTimeoutMs } from "./providers/utils";
|
|
410
|
+
export { applyConfiguredSearchTimeout, setSearchHardTimeoutMs } from "./providers/utils";
|
|
338
411
|
export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
|
|
339
412
|
export { isConfigurableSearchProviderId, isSearchProviderPreference } from "./types";
|
|
@@ -141,12 +141,63 @@ const MODEL_PROVIDER_TO_SEARCH: Record<string, SearchProviderId> = {
|
|
|
141
141
|
let preferredProvId: SearchProviderId | "auto" = "auto";
|
|
142
142
|
let fallbackProvIds: SearchProviderId[] = [];
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Resolved-chain cache. Chain resolution probes provider availability
|
|
146
|
+
* (credential lookups, potentially an OAuth refresh through the broker) on
|
|
147
|
+
* every web_search call even though the result only changes when the active
|
|
148
|
+
* model, provider preference, or credentials change. Cache the resolved
|
|
149
|
+
* provider-id list per AuthStorage instance for a short TTL.
|
|
150
|
+
*
|
|
151
|
+
* Keyed by AuthStorage identity (WeakMap) so tests and short-lived CLI calls
|
|
152
|
+
* with fresh storage handles never see another handle's chain. The TTL keeps
|
|
153
|
+
* a login/logout mid-session from being masked for more than a minute.
|
|
154
|
+
*/
|
|
155
|
+
const CHAIN_CACHE_TTL_MS = 60_000;
|
|
156
|
+
let chainCache = new WeakMap<AuthStorage, Map<string, { ids: SearchProviderId[]; expires: number }>>();
|
|
157
|
+
|
|
158
|
+
/** Drop every cached resolved chain (settings changed, tests). */
|
|
159
|
+
export function clearResolvedChainCache(): void {
|
|
160
|
+
chainCache = new WeakMap();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function chainCacheKey(
|
|
164
|
+
preferred: SearchProviderId | "auto" | undefined,
|
|
165
|
+
fallbacks: readonly SearchProviderId[],
|
|
166
|
+
ctx: ActiveSearchModelContext | undefined,
|
|
167
|
+
authStorage: AuthStorage,
|
|
168
|
+
): string {
|
|
169
|
+
// Include the AuthStorage generation so credential changes (login/logout,
|
|
170
|
+
// key rotation) invalidate cached chains immediately instead of being
|
|
171
|
+
// masked for up to the TTL. Stubs without getGeneration() key as 0.
|
|
172
|
+
const generation = (authStorage as { getGeneration?: () => number }).getGeneration?.() ?? 0;
|
|
173
|
+
return JSON.stringify([
|
|
174
|
+
generation,
|
|
175
|
+
preferred ?? null,
|
|
176
|
+
fallbacks,
|
|
177
|
+
ctx
|
|
178
|
+
? [ctx.provider, ctx.modelId, ctx.wireModelId ?? null, ctx.api, ctx.baseUrl ?? null, ctx.webSearch ?? null]
|
|
179
|
+
: null,
|
|
180
|
+
]);
|
|
181
|
+
}
|
|
182
|
+
|
|
144
183
|
export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): void {
|
|
145
184
|
preferredProvId = provider;
|
|
185
|
+
clearResolvedChainCache();
|
|
146
186
|
}
|
|
147
187
|
|
|
148
188
|
export function setSearchFallbackProviders(ids: readonly string[]): void {
|
|
149
189
|
fallbackProvIds = ids.filter(isConfigurableSearchProviderId);
|
|
190
|
+
clearResolvedChainCache();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fire-and-forget warm-up: resolve the provider chain once so the provider
|
|
195
|
+
* modules are imported and the chain cache is primed before the first
|
|
196
|
+
* user-visible search. Errors are intentionally swallowed — prewarming must
|
|
197
|
+
* never surface a failure the real search path would handle itself.
|
|
198
|
+
*/
|
|
199
|
+
export function prewarmSearchProviders(options: ResolveProviderChainOptions): void {
|
|
200
|
+
void resolveProviderChain(options).catch(() => {});
|
|
150
201
|
}
|
|
151
202
|
|
|
152
203
|
export interface SearchProviderPreferenceSource {
|
|
@@ -357,6 +408,16 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
|
|
|
357
408
|
activeModelContext,
|
|
358
409
|
fallbackProviders = fallbackProvIds,
|
|
359
410
|
} = options;
|
|
411
|
+
|
|
412
|
+
const cacheKey = chainCacheKey(preferredProvider, fallbackProviders, activeModelContext, authStorage);
|
|
413
|
+
const perStorage = chainCache.get(authStorage);
|
|
414
|
+
const cached = perStorage?.get(cacheKey);
|
|
415
|
+
if (cached && cached.expires > Date.now()) {
|
|
416
|
+
const cachedProviders: SearchProvider[] = [];
|
|
417
|
+
for (const id of cached.ids) cachedProviders.push(await getSearchProvider(id));
|
|
418
|
+
return cachedProviders;
|
|
419
|
+
}
|
|
420
|
+
|
|
360
421
|
const chain: SearchProviderId[] = [];
|
|
361
422
|
|
|
362
423
|
// A forced primary is honored only when it is a user-configurable provider.
|
|
@@ -392,6 +453,10 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
|
|
|
392
453
|
}
|
|
393
454
|
appendDeduped(chain, "duckduckgo");
|
|
394
455
|
|
|
456
|
+
const storageEntry = chainCache.get(authStorage) ?? new Map<string, { ids: SearchProviderId[]; expires: number }>();
|
|
457
|
+
storageEntry.set(cacheKey, { ids: [...chain], expires: Date.now() + CHAIN_CACHE_TTL_MS });
|
|
458
|
+
chainCache.set(authStorage, storageEntry);
|
|
459
|
+
|
|
395
460
|
const providers: SearchProvider[] = [];
|
|
396
461
|
for (const id of chain) providers.push(await getSearchProvider(id));
|
|
397
462
|
return providers;
|
|
@@ -196,7 +196,7 @@ async function fetchAndParse(
|
|
|
196
196
|
"Accept-Language": "en-US,en;q=0.9",
|
|
197
197
|
},
|
|
198
198
|
body,
|
|
199
|
-
signal: withHardTimeout(signal),
|
|
199
|
+
signal: withHardTimeout(signal, "api"),
|
|
200
200
|
});
|
|
201
201
|
|
|
202
202
|
// DuckDuckGo signals soft blocks with 202 (which is still response.ok).
|
|
@@ -115,7 +115,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
|
|
|
115
115
|
"x-api-key": apiKey,
|
|
116
116
|
},
|
|
117
117
|
body: JSON.stringify(body),
|
|
118
|
-
signal: withHardTimeout(params.signal),
|
|
118
|
+
signal: withHardTimeout(params.signal, "api"),
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
if (!response.ok) {
|
|
@@ -29,7 +29,11 @@ const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_
|
|
|
29
29
|
const DEFAULT_MODEL = "gemini-2.5-flash";
|
|
30
30
|
const MAX_RETRIES = 3;
|
|
31
31
|
const BASE_DELAY_MS = 1000;
|
|
32
|
-
|
|
32
|
+
// Retry-delay ceiling for 429/5xx. The web_search chain always terminates in
|
|
33
|
+
// the keyless DuckDuckGo fallback, so a rate-limited Gemini should fail
|
|
34
|
+
// through in seconds instead of parking the chain behind long server-provided
|
|
35
|
+
// retry delays (previously a 5-minute budget).
|
|
36
|
+
const RATE_LIMIT_BUDGET_MS = 30 * 1000;
|
|
33
37
|
|
|
34
38
|
const GEMINI_PROVIDERS = ["google-gemini-cli", "google-antigravity"] as const;
|
|
35
39
|
type GeminiProviderId = (typeof GEMINI_PROVIDERS)[number];
|
|
@@ -230,7 +234,7 @@ async function callGeminiSearch(
|
|
|
230
234
|
...headers,
|
|
231
235
|
},
|
|
232
236
|
body: JSON.stringify(requestBody),
|
|
233
|
-
signal: withHardTimeout(signal),
|
|
237
|
+
signal: withHardTimeout(signal, "llm"),
|
|
234
238
|
});
|
|
235
239
|
const urlFor = (attempt: number) =>
|
|
236
240
|
`${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
|
|
@@ -466,7 +470,7 @@ async function searchGeminiViaGenerativeLanguage(params: SearchParams): Promise<
|
|
|
466
470
|
method: "POST",
|
|
467
471
|
headers: { ...(ctx.headers ?? {}), "x-goog-api-key": apiKey, "Content-Type": "application/json" },
|
|
468
472
|
body: JSON.stringify(body),
|
|
469
|
-
signal: withHardTimeout(params.signal),
|
|
473
|
+
signal: withHardTimeout(params.signal, "llm"),
|
|
470
474
|
});
|
|
471
475
|
const text = await response.text();
|
|
472
476
|
if (!response.ok) {
|
|
@@ -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: withHardTimeout(signal),
|
|
44
|
+
signal: withHardTimeout(signal, "api"),
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
if (!response.ok) {
|
|
@@ -19,6 +19,11 @@ const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search";
|
|
|
19
19
|
const DEFAULT_NUM_RESULTS = 10;
|
|
20
20
|
const MAX_NUM_RESULTS = 20;
|
|
21
21
|
const DEFAULT_TIMEOUT_SECONDS = 30;
|
|
22
|
+
// Local hard ceiling aligned with the upstream `timeout_seconds` request budget
|
|
23
|
+
// (plus transport headroom) instead of the generic 15s "api" class: Kimi
|
|
24
|
+
// legitimately runs search + page crawling up to its advertised 30s window,
|
|
25
|
+
// and aborting earlier would discard valid in-budget responses.
|
|
26
|
+
const KIMI_HARD_TIMEOUT_MS = (DEFAULT_TIMEOUT_SECONDS + 5) * 1000;
|
|
22
27
|
|
|
23
28
|
export interface KimiSearchParams {
|
|
24
29
|
query: string;
|
|
@@ -87,7 +92,7 @@ async function callKimiSearch(
|
|
|
87
92
|
enable_page_crawling: params.includeContent,
|
|
88
93
|
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
|
|
89
94
|
}),
|
|
90
|
-
signal: withHardTimeout(params.signal),
|
|
95
|
+
signal: withHardTimeout(params.signal, KIMI_HARD_TIMEOUT_MS),
|
|
91
96
|
});
|
|
92
97
|
|
|
93
98
|
if (!response.ok) {
|
|
@@ -134,7 +134,7 @@ export class OpenAICompatibleSearchProvider extends SearchProvider {
|
|
|
134
134
|
method: "POST",
|
|
135
135
|
headers,
|
|
136
136
|
body: JSON.stringify(payload),
|
|
137
|
-
signal: withHardTimeout(params.signal),
|
|
137
|
+
signal: withHardTimeout(params.signal, "llm"),
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
// Web search is a Responses-API capability: many OpenAI-compatible
|
|
@@ -139,7 +139,7 @@ async function searchWithAuthStorage(
|
|
|
139
139
|
max_chars_per_result: 10_000,
|
|
140
140
|
},
|
|
141
141
|
}),
|
|
142
|
-
signal: withHardTimeout(params.signal),
|
|
142
|
+
signal: withHardTimeout(params.signal, "api"),
|
|
143
143
|
});
|
|
144
144
|
if (!response.ok) {
|
|
145
145
|
throw parseParallelErrorResponse(response.status, await response.text());
|