@lincoln504/pi-research 1.6.5 → 1.6.7
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/README.md +14 -9
- package/agent-skill/pi-research/README.md +5 -4
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +616 -153
- package/dist/pdf-extract-worker.mjs +1289 -0
- package/dist/prompts/research-tool-usage.md +2 -1
- package/dist/prompts/researcher.md +1 -1
- package/dist/prompts/system-coordinator.md +2 -2
- package/dist/prompts/system-lead-router.md +1 -1
- package/docs/AGENT-SKILL.md +6 -6
- package/docs/ARCHITECTURE.md +24 -22
- package/docs/CHANGELOG.md +73 -0
- package/docs/CONFIGURATION.md +3 -2
- package/docs/SDK.md +7 -12
- package/package.json +8 -5
- package/scripts/build.cjs +35 -0
- package/scripts/setup.cjs +12 -10
- package/src/cleanup/research-cleanup.ts +11 -5
- package/src/cli.ts +7 -5
- package/src/config.ts +9 -3
- package/src/core/interfaces/knowledge-interfaces.ts +8 -0
- package/src/core/interfaces/orchestration-interfaces.ts +1 -1
- package/src/core/interfaces/research-plan-types.ts +6 -0
- package/src/core/llm/agentic-repair.ts +40 -3
- package/src/core/llm/llm-utils.ts +93 -5
- package/src/core/llm/model-registry-factory.ts +2 -15
- package/src/core/pi-version.ts +1 -1
- package/src/core/planning-service.ts +110 -15
- package/src/core/planning-utils.ts +3 -0
- package/src/core/service-registry.ts +8 -6
- package/src/index.ts +8 -5
- package/src/infrastructure/browser/config.ts +6 -7
- package/src/infrastructure/browser/ensure-browser.ts +14 -7
- package/src/infrastructure/browser/thread-worker-browser.ts +18 -19
- package/src/knowledge/chunker.ts +22 -4
- package/src/knowledge/embedder-init.ts +41 -1
- package/src/knowledge/embedder-utils.ts +7 -1
- package/src/knowledge/store.ts +68 -1
- package/src/knowledge/transformers-loader.ts +58 -1
- package/src/observers/research-observer-impl.ts +4 -1
- package/src/orchestration/deep-research-orchestrator.ts +3 -3
- package/src/orchestration/headless-observer.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +17 -3
- package/src/orchestration/research-orchestration-service.ts +27 -4
- package/src/orchestration/session-state.ts +36 -7
- package/src/prompts/research-tool-usage.md +2 -1
- package/src/prompts/researcher.md +1 -1
- package/src/prompts/system-coordinator.md +2 -2
- package/src/prompts/system-lead-router.md +1 -1
- package/src/sdk.ts +12 -4
- package/src/tools/research-knowledge-search.ts +23 -7
- package/src/tools/research-tool-definition.ts +1 -1
- package/src/tools/scrape.ts +12 -9
- package/src/tools/search.ts +12 -6
- package/src/tools/security.ts +6 -1
- package/src/tools/stackexchange.ts +6 -1
- package/src/utils/cancellation.ts +34 -1
- package/src/utils/llm-usage.ts +8 -0
- package/src/utils/log-utils.ts +1 -1
- package/src/web-research/browser-search.ts +6 -5
- package/src/web-research/pdf-extract-worker.mjs +1289 -0
- package/src/web-research/pdf-extract-worker.ts +42 -0
- package/src/web-research/pdf-extraction.ts +392 -0
- package/src/web-research/web-scraper.ts +3 -111
|
@@ -88,11 +88,32 @@ function getPiState(piSessionId: string | undefined): PiSessionState {
|
|
|
88
88
|
return state;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Read-only lookup: returns the session's state WITHOUT creating one for an
|
|
93
|
+
* unknown/ended piSessionId. Read paths must use this — getPiState() on a
|
|
94
|
+
* diagnostic/late read for an already-ended session resurrects a full
|
|
95
|
+
* PiSessionState (four Maps, arrays, timer slots) that only endResearchSession
|
|
96
|
+
* or clearAllSessionState removes: a slow leak in a long-lived host, and a
|
|
97
|
+
* violation of the same peek-vs-create discipline ResearchSessionService and
|
|
98
|
+
* ResearchSynthesisService already enforce ("read/delete paths must never
|
|
99
|
+
* resurrect").
|
|
100
|
+
*/
|
|
101
|
+
function peekPiState(piSessionId: string | undefined): PiSessionState | null {
|
|
102
|
+
return piSessions.get(normalizeSessionId(piSessionId)) ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
/**
|
|
92
106
|
* Add a steering message to a Pi session
|
|
93
107
|
*/
|
|
94
108
|
export function addSteeringMessage(piSessionId: string | undefined, message: string): void {
|
|
95
109
|
const sid = normalizeSessionId(piSessionId);
|
|
110
|
+
if (!piSessionId) {
|
|
111
|
+
// Steering is scoped by piSessionId; a falsy one collapses to the shared
|
|
112
|
+
// 'default' sid, where two concurrent runs that both omitted it would
|
|
113
|
+
// consume and inject EACH OTHER's guidance. In-repo callers always pass a
|
|
114
|
+
// real id — make an SDK caller's contract violation loud at the boundary.
|
|
115
|
+
logger.warn('[session-state] addSteeringMessage called without a piSessionId — steering collapsed to the shared "default" scope and may cross-talk between concurrent runs.');
|
|
116
|
+
}
|
|
96
117
|
const state = getPiState(sid);
|
|
97
118
|
|
|
98
119
|
// Cap per-message length. All active steering is re-sent to the evaluator every
|
|
@@ -308,6 +329,10 @@ export function hasQueuedSteeringMessages(piSessionId: string | undefined): bool
|
|
|
308
329
|
*/
|
|
309
330
|
export function onSessionOrderChange(piSessionId: string | undefined, callback: () => void): () => void {
|
|
310
331
|
const sid = normalizeSessionId(piSessionId);
|
|
332
|
+
// Deliberately CREATE-on-read (the one exception to peekPiState): a
|
|
333
|
+
// subscription registered BEFORE the session's first write must survive to
|
|
334
|
+
// see it — peeking here would attach to nothing and silently never fire.
|
|
335
|
+
// The state object this creates is the same one later writers mutate.
|
|
311
336
|
const state = getPiState(sid);
|
|
312
337
|
state.subscribers.push(callback);
|
|
313
338
|
return () => {
|
|
@@ -397,7 +422,8 @@ export function hideMasterWidget(piSessionId: string | undefined): void {
|
|
|
397
422
|
*/
|
|
398
423
|
export function refreshAllSessions(piSessionId: string | undefined): void {
|
|
399
424
|
const sid = normalizeSessionId(piSessionId);
|
|
400
|
-
const state =
|
|
425
|
+
const state = peekPiState(sid);
|
|
426
|
+
if (!state) return; // nothing was ever created for this session — nothing to refresh
|
|
401
427
|
|
|
402
428
|
if (state.refreshTimeout) {
|
|
403
429
|
clearTimeout(state.refreshTimeout);
|
|
@@ -632,8 +658,11 @@ export function recordResearcherFailure(piSessionId: string | undefined, researc
|
|
|
632
658
|
*/
|
|
633
659
|
export function getResearcherFailureReasons(piSessionId: string | undefined, researchId: string): Record<string, string> {
|
|
634
660
|
const sid = normalizeSessionId(piSessionId);
|
|
635
|
-
|
|
636
|
-
|
|
661
|
+
// Peek, never create: this is a read path (getFailedResearchers below is the
|
|
662
|
+
// sibling). A read for an ended/never-created session must not resurrect a
|
|
663
|
+
// PiSessionState keyed by a random sessionId and leak it for process lifetime.
|
|
664
|
+
const state = peekPiState(sid);
|
|
665
|
+
const reasons = state?.failureReasons.get(researchId);
|
|
637
666
|
return reasons ? Object.fromEntries(reasons) : {};
|
|
638
667
|
}
|
|
639
668
|
|
|
@@ -642,8 +671,8 @@ export function getResearcherFailureReasons(piSessionId: string | undefined, res
|
|
|
642
671
|
*/
|
|
643
672
|
export function getFailedResearchers(piSessionId: string | undefined, researchId: string): string[] {
|
|
644
673
|
const sid = normalizeSessionId(piSessionId);
|
|
645
|
-
const state =
|
|
646
|
-
const failures = state
|
|
674
|
+
const state = peekPiState(sid);
|
|
675
|
+
const failures = state?.failures.get(researchId) || [];
|
|
647
676
|
return [...new Set(failures)];
|
|
648
677
|
}
|
|
649
678
|
|
|
@@ -680,8 +709,8 @@ export function getResearchStopMessage(piSessionId: string | undefined, research
|
|
|
680
709
|
// blocking.
|
|
681
710
|
// Lead with the RECORDED errors when we have them — the actual root cause
|
|
682
711
|
// (e.g. a provider 429) beats generic guidance every time.
|
|
683
|
-
const state =
|
|
684
|
-
const reasons = state
|
|
712
|
+
const state = peekPiState(sid);
|
|
713
|
+
const reasons = state?.failureReasons.get(researchId);
|
|
685
714
|
const recorded = reasons && reasons.size > 0
|
|
686
715
|
? ['Recorded errors:', ...[...reasons].map(([id, r]) => `• researcher ${id}: ${r}`), '']
|
|
687
716
|
: [];
|
|
@@ -85,7 +85,8 @@ The coordinator will plan as many researchers as needed (up to the max). You do
|
|
|
85
85
|
|
|
86
86
|
**Multiple Topics:**
|
|
87
87
|
- If you need to research multiple unrelated topics of **truly distinct scope**, you may emit multiple `research` calls **simultaneously in a single turn**. Each call gets its own isolated research run with its own coordinator and researchers.
|
|
88
|
-
- **Maximum 4 simultaneous calls** — Do NOT make more than 4 `research` calls in a single turn, even if you have more unrelated topics. Exceeding 4 simultaneous research runs can overload the system and degrade performance. If you have more than 4 truly distinct topics, they can likely be consolidated into fewer calls (group related subtopics, use broader queries, or batch across multiple turns).
|
|
88
|
+
- **Maximum 4 simultaneous calls** — Do NOT make more than 4 `research` calls in a single turn, even if you have more unrelated topics. Exceeding 4 simultaneous research runs can overload the system and degrade performance. If you have more than 4 truly distinct topics, they can likely be consolidated into fewer calls (group related subtopics, use broader queries, or batch across multiple turns). Treat the machine-wide run cap (next bullet) as the REAL ceiling: it may be lower than 4, and it always wins.
|
|
89
|
+
- **Simultaneous calls are also bounded by the machine-wide run cap (default 3, `PI_RESEARCH_MAX_CONCURRENT_RUNS`).** A call beyond the cap WAITS for a free slot and fails with a capacity error if the wait expires — so do not count on more than 3 running at once, and never rely on the 4-call maximum being available.
|
|
89
90
|
- Example: `research("bananas")` and `research("quantum computing")` in the same turn — these have zero overlap.
|
|
90
91
|
- **Group related topics**: If topics share ANY scope or could inform each other, combine them into a single `research` call. The internal coordinator will decompose them into parallel researcher agents. Do NOT split related sub-topics into separate calls.
|
|
91
92
|
- Example: `research("Python async performance and Rust async performance comparison")` — NOT separate calls for Python and Rust, since the comparison IS the research goal.
|
|
@@ -50,7 +50,7 @@ Write your report immediately after scraping is complete, or as soon as a tool r
|
|
|
50
50
|
- `youtube_transcript`: Read the captions/transcript of YouTube videos from your source list — it is the `scrape` for video. ONE call only, batching up to a few of the most relevant YouTube links. Cite these as `Source: YouTube Transcript`.
|
|
51
51
|
- `stackexchange`: Use ONLY for Stack Overflow or Stack Exchange URLs.
|
|
52
52
|
- `security_search`: Query NVD, CVE, OSV, CISA databases.
|
|
53
|
-
- `read
|
|
53
|
+
- `read`: Read files in the local codebase. Use ONLY if local codebase context is explicitly required.
|
|
54
54
|
{{extra_tool_guidelines}}
|
|
55
55
|
|
|
56
56
|
- **Specialized Tooling Directive**: Do NOT waste time with auxiliary tools (`stackexchange`, `security_search`) unless they are **specifically necessary** for your assigned goal. They are not for exploratory steps. Your primary workflow is to **scrape** authoritative web sources and **report** findings.
|
|
@@ -43,7 +43,7 @@ Before splitting the query into researchers, pin down its scope — exactly what
|
|
|
43
43
|
|
|
44
44
|
## Output Format
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Respond with your full team plan by CALLING the `submit_plan` tool when it is offered in the conversation — its arguments ARE the plan (primary, schema-exact channel). Only if you cannot call tools, return a JSON block instead. Use this general schema:
|
|
47
47
|
|
|
48
48
|
```json
|
|
49
49
|
{
|
|
@@ -72,4 +72,4 @@ Return ONLY a JSON block containing your full team plan. Use this general schema
|
|
|
72
72
|
- **Researcher IDs**: Use Round.Index format (e.g. **1.1**, **1.2**).
|
|
73
73
|
- **Citations**: Each researcher's queries should be designed to uncover sources that can be comprehensively cited. Prioritize queries that lead to authoritative references, data, and documented facts.
|
|
74
74
|
- **Title**: Set `"title"` to 1-2 words that best name the core topic (e.g. `"iPhone 15"`, `"React Performance"`, `"PostgreSQL Replication"`). Use proper nouns and brand names where applicable. Do NOT use generic words like "research", "overview", or "options".
|
|
75
|
-
- **Format**:
|
|
75
|
+
- **Format**: Call `submit_plan` with the plan as its arguments when the tool is offered; otherwise output valid JSON only. No markdown, no explanation outside the JSON block.
|
|
@@ -119,4 +119,4 @@ When delegating, ensure:
|
|
|
119
119
|
- **Researcher IDs**: Use Round.Index format — the current round number from the RUN CONTEXT block, then the researcher index. In round 3 that is **3.1**, **3.2**, and so on.
|
|
120
120
|
- **Query Budget**: Use the complexity-specific budget ({{query_budget}} per researcher). Fill each researcher's query budget completely.
|
|
121
121
|
- **Team Size**: Scale researcher count to match the gaps. Use up to {{max_team_size}} researchers when delegating, but a single well-targeted researcher is often sufficient for focused gaps. Don't pad the team when fewer researchers will cover the remaining gaps efficiently.
|
|
122
|
-
- **Format**:
|
|
122
|
+
- **Format**: Call `submit_routing_decision` with the updated plan as its arguments when the tool is offered; otherwise return ONLY valid JSON in a code block. Never return report prose.
|
package/src/sdk.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type {
|
|
|
30
30
|
import type { IPlanningService } from './core/interfaces/planning-interfaces.ts';
|
|
31
31
|
import type { ResearchObserver, HeadlessObserverOptions } from './core/interfaces/observer-interfaces.ts';
|
|
32
32
|
import type { ResearchPlan } from './core/interfaces/research-plan-types.ts';
|
|
33
|
-
import { HeadlessObserver } from './orchestration/headless-observer.ts';
|
|
33
|
+
import { HeadlessObserver, isHeadlessObserverBag } from './orchestration/headless-observer.ts';
|
|
34
34
|
import type { Model } from '@earendil-works/pi-ai';
|
|
35
35
|
import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
36
36
|
import { logger, createLogger, setLogger } from './logger.ts';
|
|
@@ -607,8 +607,10 @@ export async function runDeepResearch(
|
|
|
607
607
|
}
|
|
608
608
|
throw err;
|
|
609
609
|
} finally {
|
|
610
|
-
// Free the per-run PiSessionState that
|
|
611
|
-
//
|
|
610
|
+
// Free the per-run PiSessionState that startResearchSession()/registerSessionAbort()
|
|
611
|
+
// created for this run (keyed by this run's random sessionId; the read accessors —
|
|
612
|
+
// getFailedResearchers()/getResearcherFailureReasons()/shouldStopResearch() — only
|
|
613
|
+
// peek). A long-lived SDK/benchmark
|
|
612
614
|
// process doing thousands of runs would otherwise accumulate one never-freed entry
|
|
613
615
|
// (failures/aborts/panels) per run — only full shutdown reclaimed them before.
|
|
614
616
|
try { endResearchSession(sessionId, researchId); } catch { /* best-effort */ }
|
|
@@ -694,7 +696,7 @@ function wrapObserverForOutcomeCapture(
|
|
|
694
696
|
onPlanned: (count: number) => void,
|
|
695
697
|
): ResearchObserver {
|
|
696
698
|
const base: ResearchObserver | undefined =
|
|
697
|
-
observer &&
|
|
699
|
+
observer && isHeadlessObserverBag(observer)
|
|
698
700
|
? new HeadlessObserver(observer as HeadlessObserverOptions)
|
|
699
701
|
: (observer as ResearchObserver | undefined);
|
|
700
702
|
|
|
@@ -890,6 +892,12 @@ export async function searchKnowledge(
|
|
|
890
892
|
if (!Array.isArray(queries) || queries.length === 0) {
|
|
891
893
|
throw new Error('searchKnowledge requires a non-empty array of queries.');
|
|
892
894
|
}
|
|
895
|
+
if (queries.length > 5) {
|
|
896
|
+
// Fail-fast, matching the CLI's contract ('knowledge accepts at most 5
|
|
897
|
+
// queries.') — the previous silent slice(0, 5) dropped input without telling
|
|
898
|
+
// the caller, the exact advertised-vs-enforced drift this SDK avoids.
|
|
899
|
+
throw new Error('searchKnowledge accepts at most 5 queries.');
|
|
900
|
+
}
|
|
893
901
|
|
|
894
902
|
// Lazily import the tool factory so the SDK bundle stays lean when unused.
|
|
895
903
|
const { createResearchKnowledgeSearchTool } = await import('./tools/research-knowledge-search.ts');
|
|
@@ -34,7 +34,7 @@ import { Value } from 'typebox/value';
|
|
|
34
34
|
import { completeSimple } from '../core/llm/pi-ai-completion.ts';
|
|
35
35
|
import type { Model } from '@earendil-works/pi-ai';
|
|
36
36
|
import { recordLlmUsage } from '../utils/llm-usage.ts';
|
|
37
|
-
import { buildSafeOptions, validateAndExtractText } from '../core/llm/llm-utils.ts';
|
|
37
|
+
import { buildSafeOptions, validateAndExtractText, buildConstrainedSubmitTool, completeSimpleStructured } from '../core/llm/llm-utils.ts';
|
|
38
38
|
import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
|
|
39
39
|
import { withTimeout } from '../core/llm/llm-timeout.ts';
|
|
40
40
|
import { abortableDelay } from '../web-research/retry-utils.ts';
|
|
@@ -497,6 +497,15 @@ export async function runBackgroundExtraction(
|
|
|
497
497
|
const systemPrompt = user === null ? fill(system) : system;
|
|
498
498
|
const userMessage = user === null ? finalInstruction : `${fill(user)}\n\n${finalInstruction}`;
|
|
499
499
|
|
|
500
|
+
// Structured-output path, same pattern as the planner/router: offer the
|
|
501
|
+
// schema-constrained submit tool (the synthesis response IS a JSON object
|
|
502
|
+
// validated below), fall back to the text pipeline on a prose answer.
|
|
503
|
+
const submitSynthesisTool = buildConstrainedSubmitTool(
|
|
504
|
+
'submit_knowledge_synthesis',
|
|
505
|
+
'Submit the extracted answer in the required structured format.',
|
|
506
|
+
ResearchKnowledgeSynthesisResponseSchemaAsTSchema,
|
|
507
|
+
);
|
|
508
|
+
|
|
500
509
|
// Phase 4a: Stateless LLM call — no AgentSession, no side-effects.
|
|
501
510
|
// llmTimeout is resolved by the caller from the iface-aware Config so a
|
|
502
511
|
// per-interface overlay (pi.env / cli.env) is honored here.
|
|
@@ -509,26 +518,32 @@ export async function runBackgroundExtraction(
|
|
|
509
518
|
for (;;) {
|
|
510
519
|
attempt++;
|
|
511
520
|
try {
|
|
512
|
-
const
|
|
513
|
-
|
|
521
|
+
const structured = await withTimeout(
|
|
522
|
+
completeSimpleStructured(model, {
|
|
514
523
|
systemPrompt,
|
|
515
524
|
messages: [
|
|
516
|
-
{ role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
|
|
525
|
+
{ role: 'user', content: [{ type: 'text', text: userMessage + `\n\nRespond by calling the ${submitSynthesisTool.name} tool with the extraction as its arguments.` }], timestamp: Date.now() },
|
|
517
526
|
],
|
|
518
|
-
}, buildSafeOptions(model, {
|
|
527
|
+
}, submitSynthesisTool, buildSafeOptions(model, {
|
|
519
528
|
apiKey: auth.apiKey,
|
|
520
529
|
headers: auth.headers,
|
|
521
530
|
signal,
|
|
522
531
|
sessionId,
|
|
523
|
-
}, maxTokens, thinkingLevel)),
|
|
532
|
+
}, maxTokens, thinkingLevel), 'Knowledge Extraction'),
|
|
524
533
|
llmTimeout,
|
|
525
534
|
'knowledge-search-extraction',
|
|
526
535
|
);
|
|
536
|
+
const response = structured.response;
|
|
527
537
|
|
|
528
538
|
// Track token and cost metrics for the background synthesis call
|
|
529
539
|
recordLlmUsage(model, (response as any).usage, { component: 'knowledge_search' });
|
|
530
540
|
|
|
531
|
-
|
|
541
|
+
// Tool-call arguments are re-serialized so the SAME parse → coerce →
|
|
542
|
+
// validate pipeline below runs for both outcomes; text passes through
|
|
543
|
+
// byte-identical to the previous behavior.
|
|
544
|
+
responseText = structured.kind === 'toolCall'
|
|
545
|
+
? JSON.stringify(structured.args)
|
|
546
|
+
: structured.text;
|
|
532
547
|
break;
|
|
533
548
|
} catch (err) {
|
|
534
549
|
// A real cancellation propagates immediately — no retry, no fallback.
|
|
@@ -582,6 +597,7 @@ export async function runBackgroundExtraction(
|
|
|
582
597
|
// at repairJsonWithLlm's smaller default (16384). Mirrors the planning-service repairs.
|
|
583
598
|
maxTokens,
|
|
584
599
|
thinkingLevel,
|
|
600
|
+
timeoutMs: llmTimeout,
|
|
585
601
|
onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: 'knowledge_search' }),
|
|
586
602
|
},
|
|
587
603
|
);
|
|
@@ -282,7 +282,7 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
282
282
|
// replace it (validateAndSanitizeQuery below rejects an empty query anyway;
|
|
283
283
|
// failing here keeps the message clear and matches the CLI's up-front check).
|
|
284
284
|
if (!query) {
|
|
285
|
-
return { content: [{ type: 'text', text: 'Error: research requires a query; initialLinks
|
|
285
|
+
return { content: [{ type: 'text', text: 'Error: research requires a query; initialLinks seeds URLs for the query but cannot replace it.' }], details: {} };
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
// Each run gets its own isolated registry; session-level counter is incremented
|
package/src/tools/scrape.ts
CHANGED
|
@@ -78,7 +78,7 @@ export function createScrapeTool(options: {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
const ScrapeParamsSchema = Type.Object({
|
|
81
|
-
urls: Type.Array(Type.String({ description:
|
|
81
|
+
urls: Type.Array(Type.String({ description: `The URLs to scrape (only the first ${MAX_SCRAPE_URLS} are fetched per call; extras are reported under "Not Fetched" rather than silently dropped)` }), { minItems: 1, maxItems: 20 }),
|
|
82
82
|
maxConcurrency: Type.Optional(Type.Number({ default: config.MAX_CONCURRENT_SCRAPES, minimum: 1, maximum: 20 })),
|
|
83
83
|
});
|
|
84
84
|
|
|
@@ -110,6 +110,17 @@ export function createScrapeTool(options: {
|
|
|
110
110
|
async execute(_callId: string, params: unknown, signal: AbortSignal, _onUpdate: AgentToolUpdateCallback<any>): Promise<AgentToolResult<unknown>> {
|
|
111
111
|
const callStartTime = Date.now();
|
|
112
112
|
|
|
113
|
+
// Validate BEFORE spending the budget slot — the doctrine security.ts /
|
|
114
|
+
// stackexchange.ts / youtube-transcript.ts already follow (a rejected call
|
|
115
|
+
// must not consume a batch). The limit check used to run first, inverting it.
|
|
116
|
+
if (!Value.Check(ScrapeParamsSchema, params)) {
|
|
117
|
+
metrics.increment('tool_scrape_calls_total', 1, { status: 'invalid_params' });
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: 'text', text: 'Invalid parameters for scrape tool. Expected an array of URLs.' }],
|
|
120
|
+
details: { error: 'invalid_params' },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
// Rate-limit enforcement (only when tracker is present)
|
|
114
125
|
if (options.tracker) {
|
|
115
126
|
const callCount = options.tracker.getToolCallCount('scrape');
|
|
@@ -123,14 +134,6 @@ export function createScrapeTool(options: {
|
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
136
|
|
|
126
|
-
if (!Value.Check(ScrapeParamsSchema, params)) {
|
|
127
|
-
metrics.increment('tool_scrape_calls_total', 1, { status: 'invalid_params' });
|
|
128
|
-
return {
|
|
129
|
-
content: [{ type: 'text', text: 'Invalid parameters for scrape tool. Expected an array of URLs.' }],
|
|
130
|
-
details: { error: 'invalid_params' },
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
137
|
const p = params as ScrapeParams;
|
|
135
138
|
const rawUrls = p.urls.map(u => u.trim()).filter(u => u.length > 0);
|
|
136
139
|
|
package/src/tools/search.ts
CHANGED
|
@@ -29,7 +29,10 @@ export function createSearchTool(options: {
|
|
|
29
29
|
const SearchParamsSchema = Type.Object({
|
|
30
30
|
queries: Type.Array(Type.String(), {
|
|
31
31
|
minItems: 1,
|
|
32
|
-
|
|
32
|
+
// Honest schema: the runtime cap is 30 (hard-coded below), so the
|
|
33
|
+
// advertised max must be 30 — a host reading maxItems 50 would compose
|
|
34
|
+
// up to 50 queries and silently lose 20 of them at the cap.
|
|
35
|
+
maxItems: 30,
|
|
33
36
|
description: 'A list of 5-30 search queries to execute (minimum 1).'
|
|
34
37
|
}),
|
|
35
38
|
});
|
|
@@ -65,12 +68,10 @@ export function createSearchTool(options: {
|
|
|
65
68
|
let queries = p.queries;
|
|
66
69
|
metrics.increment('tool_search_queries_total', queries.length);
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
throw new Error(`Insufficient queries: ${queries.length}. Provide at least 1 highly specific queries.`);
|
|
71
|
-
}
|
|
72
|
-
|
|
71
|
+
// (No min-length check needed: Value.Check above enforces minItems: 1, so
|
|
72
|
+
// an under-length call is already rejected as invalid_parameters.)
|
|
73
73
|
// Hard cap at the documented 30-query maximum
|
|
74
|
+
let submittedQueries = queries.length;
|
|
74
75
|
if (queries.length > 30) {
|
|
75
76
|
logger.warn(`[search tool] Capping tool call queries: ${queries.length} → 30`);
|
|
76
77
|
metrics.increment('tool_search_capped_queries_total', queries.length - 30);
|
|
@@ -107,6 +108,11 @@ export function createSearchTool(options: {
|
|
|
107
108
|
|
|
108
109
|
let markdown = `# Web Search Results (${queries.length} queries)\n\n`;
|
|
109
110
|
markdown += `**Source: Web Search**\n\n`;
|
|
111
|
+
if (submittedQueries > queries.length) {
|
|
112
|
+
// In-band so the calling agent knows its input was truncated and can
|
|
113
|
+
// re-plan with a follow-up search — mirrors scrape.ts's over-cap note.
|
|
114
|
+
markdown += `> NOTE: ${submittedQueries - queries.length} of ${submittedQueries} submitted queries were dropped by the 30-query cap. Re-run a follow-up search with the remainder if those topics matter.\n\n`;
|
|
115
|
+
}
|
|
110
116
|
// Untrusted-data boundary: result titles/snippets below are attacker-influenceable (via a
|
|
111
117
|
// page's own <title>/meta). Remind the model in-band that they are data, not instructions —
|
|
112
118
|
// mirrors the scrape tool's banner; defense-in-depth alongside the researcher prompt.
|
package/src/tools/security.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { Value } from 'typebox/value';
|
|
|
10
10
|
import { searchSecurityDatabases } from '../security/index.ts';
|
|
11
11
|
import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
|
|
12
12
|
import { getMaxGatheringCalls } from '../constants.ts';
|
|
13
|
+
import type { Config } from '../config.ts';
|
|
13
14
|
import { metrics } from '../utils/metrics.ts';
|
|
14
15
|
import { isCancellation } from '../utils/cancellation.ts';
|
|
15
16
|
import type { SecuritySearchParams } from '../security/types.ts';
|
|
@@ -26,6 +27,10 @@ function truncateDescription(description: string): string {
|
|
|
26
27
|
export function createSecuritySearchTool(options: {
|
|
27
28
|
ctx: ExtensionContext;
|
|
28
29
|
tracker: ToolUsageTracker;
|
|
30
|
+
/** The run's resolved config — the gathering-call budget prompt must read the
|
|
31
|
+
* SAME overlay-resolved value the tracker enforces, not the ambient default
|
|
32
|
+
* (scrape.ts fixed this same advertised-vs-enforced drift). */
|
|
33
|
+
config?: Config;
|
|
29
34
|
}): ToolDefinition {
|
|
30
35
|
|
|
31
36
|
const SecuritySearchParamsSchema = Type.Object({
|
|
@@ -67,7 +72,7 @@ export function createSecuritySearchTool(options: {
|
|
|
67
72
|
'Available for looking up CVE IDs, package vulnerabilities, or security advisories.',
|
|
68
73
|
'Supports databases: NVD (340k+ CVEs), CISA KEV (actively exploited), GitHub Advisories (open source), OSV (packages).',
|
|
69
74
|
'Filter by severity, CVE ID, package name, or include only actively exploited vulnerabilities.',
|
|
70
|
-
`CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls()} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
|
|
75
|
+
`CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls(options.config)} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
|
|
71
76
|
],
|
|
72
77
|
parameters: SecuritySearchParamsSchema,
|
|
73
78
|
executionMode: 'parallel',
|
|
@@ -10,11 +10,16 @@ import { Value } from 'typebox/value';
|
|
|
10
10
|
import { stackexchangeCommand } from '../stackexchange/index.ts';
|
|
11
11
|
import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
|
|
12
12
|
import { getMaxGatheringCalls } from '../constants.ts';
|
|
13
|
+
import type { Config } from '../config.ts';
|
|
13
14
|
import { isCancellation } from '../utils/cancellation.ts';
|
|
14
15
|
|
|
15
16
|
export function createStackexchangeTool(options: {
|
|
16
17
|
ctx: ExtensionContext;
|
|
17
18
|
tracker: ToolUsageTracker;
|
|
19
|
+
/** The run's resolved config — the gathering-call budget prompt must read the
|
|
20
|
+
* SAME overlay-resolved value the tracker enforces, not the ambient default
|
|
21
|
+
* (scrape.ts fixed this same advertised-vs-enforced drift). */
|
|
22
|
+
config?: Config;
|
|
18
23
|
}): ToolDefinition {
|
|
19
24
|
const { tracker } = options;
|
|
20
25
|
|
|
@@ -64,7 +69,7 @@ export function createStackexchangeTool(options: {
|
|
|
64
69
|
'Anonymous access: 300 requests/day. Set STACKEXCHANGE_API_KEY env var for 10,000/day.',
|
|
65
70
|
'Use tags to filter by specific topics.',
|
|
66
71
|
'Use maxPages parameter to control pagination for search results (default: 5 pages).',
|
|
67
|
-
`CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls()} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
|
|
72
|
+
`CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls(options.config)} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
|
|
68
73
|
],
|
|
69
74
|
parameters: StackExchangeParamsSchema,
|
|
70
75
|
executionMode: 'parallel',
|
|
@@ -30,7 +30,40 @@ export function isCancellation(error: unknown, signal?: AbortSignal | null): boo
|
|
|
30
30
|
if (signal?.aborted) return true;
|
|
31
31
|
if (error instanceof Error && error.name === 'AbortError') return true;
|
|
32
32
|
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
-
return /^(aborted|the operation was aborted|research (aborted|cancelled|canceled)|operation cancelled|operation canceled)\.?$/i.test(
|
|
33
|
+
return /^(aborted|(?:the|this) operation was aborted|research (aborted|cancelled|canceled)|operation cancelled|operation canceled)\.?$/i.test(
|
|
34
34
|
message.trim(),
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Race `op` against `signal`, resolving `undefined` when the signal fires
|
|
40
|
+
* first. The LOSER keeps running in the background — this is an abandon, not
|
|
41
|
+
* a cancel: the op must be safe to leave draining (probes with their own
|
|
42
|
+
* timeouts, best-effort telemetry) and its eventual result or rejection is
|
|
43
|
+
* dropped. Callers treating abandonment as "skip the work" keep their abort
|
|
44
|
+
* handling at the next signal-aware await, exactly where the rest of the run's
|
|
45
|
+
* abort plumbing expects it.
|
|
46
|
+
*
|
|
47
|
+
* The listener is hoisted (defined once, removed in `finally`) so a settled
|
|
48
|
+
* race can never leak an `abort` listener onto a long-lived run signal — the
|
|
49
|
+
* same hygiene the orchestrators' capacity-wait races apply.
|
|
50
|
+
*/
|
|
51
|
+
export function raceWithSignal<T>(op: Promise<T>, signal?: AbortSignal | null): Promise<T | undefined> {
|
|
52
|
+
if (!signal) return op.then((v) => v);
|
|
53
|
+
if (signal.aborted) {
|
|
54
|
+
// The op was started eagerly and may still REJECT later; without a handler
|
|
55
|
+
// attached that rejection surfaces as an unhandledRejection even though the
|
|
56
|
+
// race already resolved. Swallow it — abandonment drops the outcome by
|
|
57
|
+
// contract (the op must be safe to leave draining).
|
|
58
|
+
void op.catch(() => {});
|
|
59
|
+
return Promise.resolve(undefined);
|
|
60
|
+
}
|
|
61
|
+
let onAbort: () => void;
|
|
62
|
+
const abandoned = new Promise<undefined>((resolve) => {
|
|
63
|
+
onAbort = () => resolve(undefined);
|
|
64
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
65
|
+
});
|
|
66
|
+
return Promise.race([op, abandoned]).finally(() => {
|
|
67
|
+
signal.removeEventListener('abort', onAbort!);
|
|
68
|
+
});
|
|
69
|
+
}
|
package/src/utils/llm-usage.ts
CHANGED
|
@@ -67,6 +67,14 @@ function warnIfUnpriced(model: Model<any>, tokens: number, cost: number): void {
|
|
|
67
67
|
* `tokens > 0 || cost > 0`, so a response whose usage is all-zero across the board
|
|
68
68
|
* emits nothing — harmless in practice because cache fields are summed into `tokens`,
|
|
69
69
|
* so a reported cache split implies a nonzero total.
|
|
70
|
+
*
|
|
71
|
+
* Accuracy note: for the openai-completions API family (which includes OpenRouter),
|
|
72
|
+
* pi-ai's usage normalization ALWAYS sets cacheRead/cacheWrite, defaulting missing
|
|
73
|
+
* upstream fields to 0 — so on those providers a route without prompt caching shows the
|
|
74
|
+
* counter at 0 rather than omitting it. The 0-vs-absent distinction only holds for APIs
|
|
75
|
+
* that omit the fields outright. A run showing llm_cache_read_tokens_total stuck at 0
|
|
76
|
+
* with nonzero input tokens is therefore most simply read as "this route reports no
|
|
77
|
+
* cached tokens", not "our prompts defeat the cache".
|
|
70
78
|
*/
|
|
71
79
|
function recordCacheTokens(parsed: Partial<TokenUsage>, labels: Record<string, string>): void {
|
|
72
80
|
if (typeof parsed.cacheRead === 'number') {
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function buildDefaultDebugLogPath(): string {
|
|
|
38
38
|
* Check whether debug/verbose logging is enabled.
|
|
39
39
|
*
|
|
40
40
|
* Reads PI_RESEARCH_DEBUG env var. Config SAVES sync config.DEBUG into this
|
|
41
|
-
* env var (see config.ts
|
|
41
|
+
* env var (see the DEBUG → PI_RESEARCH_DEBUG bridge in config.ts createConfig); plain config LOADS do not mutate
|
|
42
42
|
* process.env, so a DEBUG=true line in config.env enables verbose logging for
|
|
43
43
|
* a process only after a save has run in it — export PI_RESEARCH_DEBUG=true
|
|
44
44
|
* in the environment to guarantee it from process start.
|
|
@@ -331,11 +331,12 @@ export async function performSearch(
|
|
|
331
331
|
metrics.increment('browser_search_total_failures_total', 1, { cause: 'native_binding' });
|
|
332
332
|
throw new Error(
|
|
333
333
|
`Search completely failed: ${reason}, all with a missing native module. ` +
|
|
334
|
-
`This is an incomplete install, not a network problem:
|
|
335
|
-
`
|
|
336
|
-
`
|
|
337
|
-
`npm install -g @
|
|
338
|
-
`
|
|
334
|
+
`This is an incomplete install, not a network problem: better-sqlite3 13 ` +
|
|
335
|
+
`ships prebuilt bindings for every supported platform and loads them at ` +
|
|
336
|
+
`runtime, so a missing binding means an old or broken npm install. ` +
|
|
337
|
+
`Upgrade npm to ≥12 (npm install -g npm@12) and reinstall this package — ` +
|
|
338
|
+
`modern npm skips the needless recompile that fails on toolchain-less ` +
|
|
339
|
+
`machines, and the prebuilt binding then loads as-is.` +
|
|
339
340
|
(sampleWorkerError ? ` Last worker error: ${sampleWorkerError}` : '')
|
|
340
341
|
);
|
|
341
342
|
}
|