@oh-my-pi/pi-coding-agent 17.0.8 → 17.0.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 +33 -1
- package/dist/cli.js +4850 -4774
- package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +27 -0
- package/dist/types/config/settings-schema.d.ts +34 -3
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
- package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/mcp/manager.d.ts +6 -2
- package/dist/types/mcp/render.d.ts +2 -2
- package/dist/types/mcp/tool-bridge.d.ts +7 -3
- package/dist/types/mcp/types.d.ts +6 -0
- package/dist/types/modes/components/oauth-selector.d.ts +8 -0
- package/dist/types/modes/components/settings-defs.d.ts +1 -0
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
- package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +8 -1
- package/dist/types/session/session-loader.d.ts +6 -4
- package/dist/types/task/types.d.ts +14 -0
- package/dist/types/tools/hub/jobs.d.ts +1 -1
- package/dist/types/tools/index.d.ts +3 -0
- package/dist/types/tools/report-tool-issue.d.ts +24 -9
- package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +13 -12
- package/scripts/legacy-pi-virtual-module.ts +1 -1
- package/src/cli/grievances-cli.ts +2 -2
- package/src/cli/usage-cli.ts +1 -1
- package/src/config/__tests__/model-registry.test.ts +147 -0
- package/src/config/model-registry.ts +40 -0
- package/src/config/model-resolver.ts +0 -1
- package/src/config/settings-schema.ts +41 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
- package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
- package/src/internal-urls/registry-helpers.ts +40 -0
- package/src/lsp/client.ts +23 -0
- package/src/lsp/clients/biome-client.ts +3 -4
- package/src/lsp/index.ts +25 -7
- package/src/main.ts +1 -0
- package/src/mcp/manager.ts +39 -8
- package/src/mcp/render.ts +94 -35
- package/src/mcp/tool-bridge.ts +107 -11
- package/src/mcp/types.ts +7 -0
- package/src/modes/components/agent-hub.ts +26 -9
- package/src/modes/components/oauth-selector.ts +24 -7
- package/src/modes/components/settings-defs.ts +5 -2
- package/src/modes/components/settings-selector.ts +9 -5
- package/src/modes/components/tool-execution.test.ts +63 -2
- package/src/modes/controllers/command-controller.ts +14 -7
- package/src/modes/controllers/mcp-command-controller.ts +70 -40
- package/src/modes/interactive-mode.ts +3 -0
- package/src/modes/rpc/rpc-client.ts +94 -3
- package/src/modes/rpc/rpc-frame.ts +164 -4
- package/src/modes/rpc/rpc-messages.ts +127 -0
- package/src/modes/rpc/rpc-mode.ts +79 -7
- package/src/modes/rpc/rpc-types.ts +34 -2
- package/src/modes/setup-wizard/scenes/model.ts +5 -7
- package/src/modes/setup-wizard/scenes/providers.ts +3 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
- package/src/modes/setup-wizard/scenes/theme.ts +13 -3
- package/src/modes/setup-wizard/scenes/types.ts +9 -1
- package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/prompts/goals/guided-goal-system.md +24 -3
- package/src/prompts/tools/task.md +12 -2
- package/src/sdk.ts +45 -3
- package/src/session/agent-session.ts +63 -32
- package/src/session/session-context.test.ts +224 -1
- package/src/session/session-context.ts +41 -2
- package/src/session/session-loader.ts +10 -5
- package/src/slash-commands/helpers/usage-report.ts +3 -1
- package/src/task/executor.ts +3 -0
- package/src/task/index.ts +52 -8
- package/src/task/structured-subagent.ts +3 -1
- package/src/task/types.ts +16 -0
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +67 -8
- package/src/tools/index.ts +3 -0
- package/src/tools/report-tool-issue.ts +79 -28
- package/src/web/search/providers/firecrawl.ts +46 -13
- package/src/web/search/types.ts +5 -1
package/src/tools/hub/jobs.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { settings } from "../../config/settings";
|
|
|
12
12
|
import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
|
|
13
13
|
import { shimmerEnabled, shimmerText } from "../../modes/theme/shimmer";
|
|
14
14
|
import type { Theme } from "../../modes/theme/theme";
|
|
15
|
+
import { USER_INTERRUPT_LABEL } from "../../session/messages";
|
|
15
16
|
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../../tui";
|
|
16
17
|
import type { ToolSession } from "..";
|
|
17
18
|
import {
|
|
@@ -304,26 +305,38 @@ export function nothingToWaitForResult(session: ToolSession): AgentToolResult<Co
|
|
|
304
305
|
}
|
|
305
306
|
|
|
306
307
|
/** `cancel`: kill the named jobs; returns immediately with outcomes + snapshots. */
|
|
307
|
-
export function executeCancel(
|
|
308
|
+
export async function executeCancel(
|
|
308
309
|
session: ToolSession,
|
|
309
310
|
manager: AsyncJobManager,
|
|
310
311
|
ownerId: string | undefined,
|
|
311
312
|
ids: string[],
|
|
312
|
-
): AgentToolResult<CoordinationDetails
|
|
313
|
+
): Promise<AgentToolResult<CoordinationDetails>> {
|
|
313
314
|
const ownerFilter = ownerId ? { ownerId } : undefined;
|
|
314
315
|
const cancelOutcomes: CancelOutcome[] = [];
|
|
315
316
|
for (const id of ids) {
|
|
316
317
|
const existing = manager.getJob(id);
|
|
317
318
|
if (!existing || (ownerId && existing.ownerId !== ownerId)) {
|
|
318
|
-
|
|
319
|
+
// No job by this id (or it belongs to another agent): a budget-aborted
|
|
320
|
+
// keep-alive subagent lives on as a jobless registration long after its
|
|
321
|
+
// job row is reaped, so let cancel reach the agent registration too.
|
|
322
|
+
cancelOutcomes.push(await cancelAgentRegistration(session, ownerId, id));
|
|
319
323
|
continue;
|
|
320
324
|
}
|
|
321
325
|
if (existing.status !== "running") {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
326
|
+
// The job row settled but may still be inside the retention window.
|
|
327
|
+
// The agent registration behind it (job id == agent id for task
|
|
328
|
+
// spawns) can outlive the row as an idle/parked zombie — try the
|
|
329
|
+
// registration kill before reporting the row as already done.
|
|
330
|
+
const regOutcome = await cancelAgentRegistration(session, ownerId, id);
|
|
331
|
+
cancelOutcomes.push(
|
|
332
|
+
regOutcome.status === "cancelled"
|
|
333
|
+
? regOutcome
|
|
334
|
+
: {
|
|
335
|
+
id,
|
|
336
|
+
status: "already_completed",
|
|
337
|
+
message: `Background job ${id} is already ${existing.status}.`,
|
|
338
|
+
},
|
|
339
|
+
);
|
|
327
340
|
continue;
|
|
328
341
|
}
|
|
329
342
|
const cancelled = manager.cancel(id, ownerFilter);
|
|
@@ -336,6 +349,52 @@ export function executeCancel(
|
|
|
336
349
|
return buildJobResult(session, manager, "cancel", visibleJobs(manager, ids, ownerId), cancelOutcomes);
|
|
337
350
|
}
|
|
338
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Kill a non-job-backed agent registration named by `id`: abort any in-flight
|
|
354
|
+
* turn, then release it from the lifecycle (dispose session + unregister). This
|
|
355
|
+
* is the only kill path for a keep-alive subagent that was budget-aborted, went
|
|
356
|
+
* `idle`/`parked`, and outlived its job row — otherwise it is unstoppable short
|
|
357
|
+
* of a broker restart (issue #6315). Scoped to the caller's own descendants so
|
|
358
|
+
* cross-agent kills stay impossible; a bare test/SDK caller (no owner id) may
|
|
359
|
+
* target any sub. Never touches Main, the caller, or advisor transcripts.
|
|
360
|
+
*/
|
|
361
|
+
async function cancelAgentRegistration(
|
|
362
|
+
session: ToolSession,
|
|
363
|
+
ownerId: string | undefined,
|
|
364
|
+
id: string,
|
|
365
|
+
): Promise<CancelOutcome> {
|
|
366
|
+
const registry = session.agentRegistry;
|
|
367
|
+
const ref = registry?.get(id);
|
|
368
|
+
if (ref?.kind !== "sub") {
|
|
369
|
+
return { id, status: "not_found", message: `Background job not found: ${id}` };
|
|
370
|
+
}
|
|
371
|
+
if (id === ownerId) {
|
|
372
|
+
return { id, status: "not_found", message: `Cannot cancel yourself (${id}).` };
|
|
373
|
+
}
|
|
374
|
+
if (ownerId && ref.parentId !== ownerId) {
|
|
375
|
+
return { id, status: "not_found", message: `Agent ${id} was not spawned by you and cannot be cancelled.` };
|
|
376
|
+
}
|
|
377
|
+
const lifecycle = session.agentLifecycle?.();
|
|
378
|
+
try {
|
|
379
|
+
if (ref.status === "running" && ref.session) {
|
|
380
|
+
await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
|
|
381
|
+
}
|
|
382
|
+
if (lifecycle) {
|
|
383
|
+
await lifecycle.release(id);
|
|
384
|
+
} else {
|
|
385
|
+
await ref.session?.dispose();
|
|
386
|
+
registry?.unregister(id);
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
return {
|
|
390
|
+
id,
|
|
391
|
+
status: "already_completed",
|
|
392
|
+
message: `Agent ${id} could not be fully cancelled: ${error instanceof Error ? error.message : String(error)}.`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return { id, status: "cancelled", message: `Cancelled agent ${id} (killed session, dropped registration).` };
|
|
396
|
+
}
|
|
397
|
+
|
|
339
398
|
/** `jobs`: read-only snapshot of every job plus the jobless running-agent roster. */
|
|
340
399
|
export function executeJobsSnapshot(
|
|
341
400
|
session: ToolSession,
|
package/src/tools/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { LspTool } from "../lsp";
|
|
|
20
20
|
import type { MCPManager } from "../mcp";
|
|
21
21
|
import type { MnemopiSessionState } from "../mnemopi/state";
|
|
22
22
|
import type { PlanModeState } from "../plan-mode/state";
|
|
23
|
+
import type { AgentLifecycleManager } from "../registry/agent-lifecycle";
|
|
23
24
|
import type { AgentRegistry } from "../registry/agent-registry";
|
|
24
25
|
import type { ArtifactManager } from "../session/artifacts";
|
|
25
26
|
import type { ClientBridge } from "../session/client-bridge";
|
|
@@ -238,6 +239,8 @@ export interface ToolSession {
|
|
|
238
239
|
xdevRegistry?: XdevRegistry;
|
|
239
240
|
/** Agent registry for IRC routing across live sessions. */
|
|
240
241
|
agentRegistry?: AgentRegistry;
|
|
242
|
+
/** Idle→parked→revive lifecycle owner; lets the hub kill a non-job-backed agent registration. Default: AgentLifecycleManager.global(). */
|
|
243
|
+
agentLifecycle?: () => AgentLifecycleManager;
|
|
241
244
|
/** Get artifacts directory for artifact:// URLs */
|
|
242
245
|
getArtifactsDir?: () => string | null;
|
|
243
246
|
/** Get the ArtifactManager backing this session (shared across parent + subagents). */
|
|
@@ -5,16 +5,21 @@
|
|
|
5
5
|
* `xd://report_issue`, and the system prompt tells the model to write
|
|
6
6
|
* `<tool>: <concise description>` there when auto-QA is enabled.
|
|
7
7
|
*
|
|
8
|
-
* Enabled by default
|
|
9
|
-
*
|
|
8
|
+
* Enabled by default (`dev.autoqa` defaults to true); `PI_AUTO_QA=0` or an
|
|
9
|
+
* explicit `dev.autoqa: false` short-circuits injection entirely. When the
|
|
10
|
+
* user is only enabled by default (never configured `dev.autoqa` themselves),
|
|
11
|
+
* a persisted `dev.autoqaConsent: "denied"` also disables injection so a "No"
|
|
12
|
+
* in the consent dialog fully turns the feature off.
|
|
10
13
|
* Records grievances to a local SQLite database; never throws from the device
|
|
11
14
|
* dispatch path.
|
|
12
15
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Nothing is written until consent resolves. If the user has never been asked
|
|
17
|
+
* (`dev.autoqaConsent === "unset"`) the process-global consent handler —
|
|
18
|
+
* wired by `InteractiveMode` to a Yes/No popup — is invoked exactly once and
|
|
19
|
+
* the decision is persisted; a denial (or dismissal) drops the pending report
|
|
20
|
+
* without touching the database. Subsequent calls (including from subagents)
|
|
21
|
+
* read the cached decision without prompting. `PI_AUTO_QA_PUSH=1` bypasses
|
|
22
|
+
* the dialog for headless environments.
|
|
18
23
|
*
|
|
19
24
|
* When the user grants consent, push is automatically active against the
|
|
20
25
|
* bundled endpoint (`dev.autoqaPush.endpoint`, default `qa.omp.sh`). Each
|
|
@@ -24,11 +29,13 @@
|
|
|
24
29
|
* the network and never throws.
|
|
25
30
|
*/
|
|
26
31
|
import { Database } from "bun:sqlite";
|
|
32
|
+
import * as fs from "node:fs";
|
|
33
|
+
import * as path from "node:path";
|
|
27
34
|
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
28
35
|
import type { FetchImpl } from "@oh-my-pi/pi-ai";
|
|
29
36
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
30
37
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
31
|
-
import { $env, $flag,
|
|
38
|
+
import { $env, $flag, getAutoQaDbPath, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils";
|
|
32
39
|
import type { Settings } from "..";
|
|
33
40
|
import type { Theme } from "../modes/theme/theme";
|
|
34
41
|
import { renderStatusLine, truncateToWidth } from "../tui";
|
|
@@ -88,8 +95,23 @@ function parseReportIssueBody(text: string): { tool: string; report: string } {
|
|
|
88
95
|
throw new ToolError(`Invalid report format. ${reportIssueDeviceUsage()}`);
|
|
89
96
|
}
|
|
90
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Whether Auto-QA is active for this session.
|
|
100
|
+
*
|
|
101
|
+
* Precedence: `PI_AUTO_QA` env flag > explicit `dev.autoqa` setting >
|
|
102
|
+
* default-on unless the user previously denied consent. The denial veto only
|
|
103
|
+
* applies to the default: explicitly configuring `dev.autoqa: true` re-enables
|
|
104
|
+
* injection (recording still no-ops until consent is granted).
|
|
105
|
+
*/
|
|
91
106
|
export function isAutoQaEnabled(settings?: Settings): boolean {
|
|
92
|
-
|
|
107
|
+
let fallback = false;
|
|
108
|
+
if (settings) {
|
|
109
|
+
const enabled = !!settings.get("dev.autoqa");
|
|
110
|
+
fallback = settings.isConfigured("dev.autoqa")
|
|
111
|
+
? enabled
|
|
112
|
+
: enabled && settings.get("dev.autoqaConsent") !== "denied";
|
|
113
|
+
}
|
|
114
|
+
return $flag("PI_AUTO_QA", fallback);
|
|
93
115
|
}
|
|
94
116
|
|
|
95
117
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -233,15 +255,18 @@ let cachedDb: Database | null = null;
|
|
|
233
255
|
|
|
234
256
|
/**
|
|
235
257
|
* Open (or return the cached handle for) the auto-QA SQLite database at
|
|
236
|
-
* `~/.omp/
|
|
237
|
-
*
|
|
258
|
+
* `~/.omp/autoqa.db` (XDG: `$XDG_DATA_HOME/omp/autoqa.db`), creating the
|
|
259
|
+
* schema lazily. Returns `null` when the path cannot be resolved or opened.
|
|
238
260
|
*/
|
|
239
261
|
export function openAutoQaDb(): Database | null {
|
|
240
262
|
if (cachedDb) return cachedDb;
|
|
241
|
-
const
|
|
242
|
-
if (!
|
|
263
|
+
const dbPath = getAutoQaDbPath();
|
|
264
|
+
if (!dbPath) return null;
|
|
243
265
|
try {
|
|
244
|
-
|
|
266
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
267
|
+
const db = new Database(dbPath, { create: true });
|
|
268
|
+
// Install the busy handler BEFORE any lock-taking statement. See #2421.
|
|
269
|
+
db.run("PRAGMA busy_timeout = 5000");
|
|
245
270
|
db.exec(`
|
|
246
271
|
CREATE TABLE IF NOT EXISTS grievances (
|
|
247
272
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -252,6 +277,17 @@ export function openAutoQaDb(): Database | null {
|
|
|
252
277
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
253
278
|
pushed INTEGER NOT NULL DEFAULT 0
|
|
254
279
|
);
|
|
280
|
+
`);
|
|
281
|
+
// Legacy DBs (May 2026) predate `created_at`. ALTER TABLE only accepts
|
|
282
|
+
// constant defaults, so add it empty and backfill before the index below.
|
|
283
|
+
const hasCreatedAt = db.prepare("SELECT 1 FROM pragma_table_info('grievances') WHERE name = 'created_at'").get();
|
|
284
|
+
if (!hasCreatedAt) {
|
|
285
|
+
db.exec(`
|
|
286
|
+
ALTER TABLE grievances ADD COLUMN created_at TEXT NOT NULL DEFAULT '';
|
|
287
|
+
UPDATE grievances SET created_at = CURRENT_TIMESTAMP WHERE created_at = '';
|
|
288
|
+
`);
|
|
289
|
+
}
|
|
290
|
+
db.exec(`
|
|
255
291
|
CREATE INDEX IF NOT EXISTS grievances_pushed_created_at_idx
|
|
256
292
|
ON grievances (pushed, created_at, id);
|
|
257
293
|
`);
|
|
@@ -471,23 +507,38 @@ export async function flushGrievances(
|
|
|
471
507
|
}
|
|
472
508
|
}
|
|
473
509
|
|
|
474
|
-
/**
|
|
475
|
-
|
|
510
|
+
/**
|
|
511
|
+
* Most recently scheduled record pipeline. Never rejects (the pipeline
|
|
512
|
+
* swallows its own errors); retained so tests can await the fire-and-forget
|
|
513
|
+
* work deterministically via {@link __awaitAutoQaRecordPipelineForTests}.
|
|
514
|
+
*/
|
|
515
|
+
let lastRecordPipeline: Promise<void> = Promise.resolve();
|
|
516
|
+
|
|
517
|
+
/** Test-only: await the last consent → insert → flush pipeline. */
|
|
518
|
+
export function __awaitAutoQaRecordPipelineForTests(): Promise<void> {
|
|
519
|
+
return lastRecordPipeline;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Queue a grievance for recording. The consent → insert → flush pipeline is
|
|
524
|
+
* fire-and-forget: nothing is written until the user grants consent (or
|
|
525
|
+
* `PI_AUTO_QA_PUSH=1` forces headless recording), and the device result
|
|
526
|
+
* returns immediately so the model never waits on the dialog or the network.
|
|
527
|
+
*/
|
|
528
|
+
function recordToolIssue(session: ToolSession, tool: string, report: string): void {
|
|
476
529
|
const canonicalTool = tool.startsWith("proxy_") ? tool.slice("proxy_".length) : tool;
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
db.prepare("INSERT INTO grievances (model, version, tool, report) VALUES (?, ?, ?, ?)").run(
|
|
480
|
-
session.getActiveModelString?.() ?? "unknown",
|
|
481
|
-
VERSION,
|
|
482
|
-
canonicalTool,
|
|
483
|
-
report,
|
|
484
|
-
);
|
|
485
|
-
void (async () => {
|
|
530
|
+
const model = session.getActiveModelString?.() ?? "unknown";
|
|
531
|
+
lastRecordPipeline = (async () => {
|
|
486
532
|
try {
|
|
487
|
-
await resolveAutoQaConsent(session.settings);
|
|
533
|
+
if (!$flag("PI_AUTO_QA_PUSH") && !(await resolveAutoQaConsent(session.settings))) return;
|
|
534
|
+
const db = openAutoQaDb();
|
|
535
|
+
if (!db) return;
|
|
536
|
+
db.prepare(
|
|
537
|
+
"INSERT INTO grievances (model, version, tool, report, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
|
538
|
+
).run(model, VERSION, canonicalTool, report);
|
|
488
539
|
await flushGrievances(db, session.settings);
|
|
489
540
|
} catch (error) {
|
|
490
|
-
logger.debug("autoqa
|
|
541
|
+
logger.debug("autoqa consent pipeline failed", { error: String(error) });
|
|
491
542
|
}
|
|
492
543
|
})();
|
|
493
544
|
}
|
|
@@ -504,7 +555,7 @@ export async function dispatchReportIssueDevice(
|
|
|
504
555
|
try {
|
|
505
556
|
if (isAutoQaEnabled(session.settings)) {
|
|
506
557
|
const { tool, report } = parseReportIssueBody(text);
|
|
507
|
-
|
|
558
|
+
recordToolIssue(session, tool, report);
|
|
508
559
|
}
|
|
509
560
|
} catch (error) {
|
|
510
561
|
if (error instanceof ToolError) throw error;
|
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
* Calls Firecrawl's search API and maps web results into the unified
|
|
5
5
|
* SearchResponse shape used by the web search tool.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type AuthStorage,
|
|
9
|
+
type FetchImpl,
|
|
10
|
+
getEnvApiKey,
|
|
11
|
+
resolveApiKeyOnce,
|
|
12
|
+
seedApiKeyResolver,
|
|
13
|
+
withAuth,
|
|
14
|
+
} from "@oh-my-pi/pi-ai";
|
|
8
15
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
9
16
|
import { SearchProviderError } from "../../../web/search/types";
|
|
10
17
|
import { clampNumResults } from "../utils";
|
|
@@ -66,13 +73,19 @@ function buildRequestBody(params: FirecrawlSearchParams): Record<string, unknown
|
|
|
66
73
|
return body;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
async function callFirecrawlSearch(
|
|
76
|
+
async function callFirecrawlSearch(
|
|
77
|
+
apiKey: string | undefined,
|
|
78
|
+
params: FirecrawlSearchParams,
|
|
79
|
+
): Promise<FirecrawlSearchResponse> {
|
|
80
|
+
const headers: Record<string, string> = {
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
};
|
|
83
|
+
if (apiKey) {
|
|
84
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
85
|
+
}
|
|
70
86
|
const response = await (params.fetch ?? fetch)(FIRECRAWL_SEARCH_URL, {
|
|
71
87
|
method: "POST",
|
|
72
|
-
headers
|
|
73
|
-
"Content-Type": "application/json",
|
|
74
|
-
Authorization: `Bearer ${apiKey}`,
|
|
75
|
-
},
|
|
88
|
+
headers,
|
|
76
89
|
body: JSON.stringify(buildRequestBody(params)),
|
|
77
90
|
signal: withHardTimeout(params.signal),
|
|
78
91
|
});
|
|
@@ -100,16 +113,24 @@ export async function searchFirecrawl(params: SearchParams): Promise<SearchRespo
|
|
|
100
113
|
signal: params.signal,
|
|
101
114
|
fetch: params.fetch,
|
|
102
115
|
};
|
|
103
|
-
const
|
|
116
|
+
const keyResolver = params.authStorage.resolver("firecrawl", {
|
|
104
117
|
sessionId: params.sessionId,
|
|
105
118
|
});
|
|
106
119
|
const numResults = clampNumResults(firecrawlParams.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
|
107
120
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
const resolvedKey = await resolveApiKeyOnce(keyResolver, params.signal);
|
|
122
|
+
let data: FirecrawlSearchResponse;
|
|
123
|
+
if (resolvedKey) {
|
|
124
|
+
// Reuse the preflight credential for the initial authenticated attempt.
|
|
125
|
+
const seededResolver = seedApiKeyResolver(resolvedKey, keyResolver);
|
|
126
|
+
data = await withAuth(seededResolver, key => callFirecrawlSearch(key, firecrawlParams), {
|
|
127
|
+
signal: params.signal,
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
// Keyless mode — omit Authorization header
|
|
131
|
+
data = await callFirecrawlSearch(undefined, firecrawlParams);
|
|
132
|
+
}
|
|
133
|
+
|
|
113
134
|
const sources: SearchSource[] = [];
|
|
114
135
|
|
|
115
136
|
for (const result of data.data?.web ?? []) {
|
|
@@ -125,7 +146,7 @@ export async function searchFirecrawl(params: SearchParams): Promise<SearchRespo
|
|
|
125
146
|
provider: "firecrawl",
|
|
126
147
|
sources: sources.slice(0, numResults),
|
|
127
148
|
requestId: data.id ?? undefined,
|
|
128
|
-
authMode: "api_key",
|
|
149
|
+
authMode: resolvedKey ? "api_key" : "keyless",
|
|
129
150
|
};
|
|
130
151
|
}
|
|
131
152
|
|
|
@@ -134,10 +155,22 @@ export class FirecrawlProvider extends SearchProvider {
|
|
|
134
155
|
readonly id = "firecrawl";
|
|
135
156
|
readonly label = "Firecrawl";
|
|
136
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Auto-chain admission: requires a credential so an unconfigured Firecrawl
|
|
160
|
+
* doesn't displace other providers that the user has set up with API keys.
|
|
161
|
+
*/
|
|
137
162
|
isAvailable(authStorage: AuthStorage): boolean {
|
|
138
163
|
return authStorage.hasAuth("firecrawl") || !!getEnvApiKey("firecrawl");
|
|
139
164
|
}
|
|
140
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Firecrawl supports keyless mode, so an explicit user selection
|
|
168
|
+
* (`webSearch: firecrawl`) works without any credential configured.
|
|
169
|
+
*/
|
|
170
|
+
isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
141
174
|
search(params: SearchParams): Promise<SearchResponse> {
|
|
142
175
|
return searchFirecrawl(params);
|
|
143
176
|
}
|
package/src/web/search/types.ts
CHANGED
|
@@ -37,7 +37,11 @@ export const SEARCH_PROVIDER_OPTIONS = [
|
|
|
37
37
|
{ value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
|
|
38
38
|
{ value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
|
|
39
39
|
{ value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" },
|
|
40
|
-
{
|
|
40
|
+
{
|
|
41
|
+
value: "firecrawl",
|
|
42
|
+
label: "Firecrawl",
|
|
43
|
+
description: "Uses Firecrawl API when FIRECRAWL_API_KEY is set; falls back to keyless mode",
|
|
44
|
+
},
|
|
41
45
|
{ value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
|
|
42
46
|
{
|
|
43
47
|
value: "kimi",
|