@lincoln504/pi-research 0.1.12 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning Service
|
|
3
|
+
*
|
|
4
|
+
* Coordinates the research planning and evaluation phases.
|
|
5
|
+
* Interacts with the LLM to generate research agendas and evaluate findings.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ServiceLifecycle } from './service-registry.ts';
|
|
9
|
+
import { ServiceNames } from './service-interfaces.ts';
|
|
10
|
+
import type {
|
|
11
|
+
IPlanningService,
|
|
12
|
+
ResearchPlan,
|
|
13
|
+
UpdatePlanOptions,
|
|
14
|
+
GenerateQueriesOptions,
|
|
15
|
+
ResearcherConfig,
|
|
16
|
+
GeneratePlanOptions
|
|
17
|
+
} from './service-interfaces.ts';
|
|
18
|
+
import { logger } from '../logger.ts';
|
|
19
|
+
import { completeSimple } from './llm/pi-ai-completion.ts';
|
|
20
|
+
import { injectCurrentDate } from './llm/inject-date.ts';
|
|
21
|
+
import { loadPrompt } from './llm/prompts.ts';
|
|
22
|
+
import { recordLlmUsage } from '../utils/llm-usage.ts';
|
|
23
|
+
import { normalizeCitations } from '../utils/citation-utils.ts';
|
|
24
|
+
import { repairJsonWithLlm } from './llm/agentic-repair.ts';
|
|
25
|
+
import { buildSafeOptions, validateAndExtractText } from './llm/llm-utils.ts';
|
|
26
|
+
import { safeGetApiKeyAndHeaders } from './llm/model-registry-factory.ts';
|
|
27
|
+
import { withTimeout } from './llm/llm-timeout.ts';
|
|
28
|
+
import { retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
|
|
29
|
+
import { getConfig } from '../config.ts';
|
|
30
|
+
import {
|
|
31
|
+
PlanningResponseSchemaAsTSchema,
|
|
32
|
+
EvaluationResponseSchemaAsTSchema,
|
|
33
|
+
} from './planning-constants.ts';
|
|
34
|
+
import {
|
|
35
|
+
getTeamSize as _getTeamSize,
|
|
36
|
+
getQueryBudget as _getQueryBudget,
|
|
37
|
+
getMaxRounds as _getMaxRounds,
|
|
38
|
+
getComplexityGuidance as _getComplexityGuidance,
|
|
39
|
+
getEvaluatorComplexityGuidance as _getEvaluatorComplexityGuidance,
|
|
40
|
+
getRoundPhaseGuidance as _getRoundPhaseGuidance,
|
|
41
|
+
parseJsonPlan as _parseJsonPlan,
|
|
42
|
+
buildFallbackCoordinatorPlan as _buildFallbackCoordinatorPlan,
|
|
43
|
+
capResearcherQueries as _capResearcherQueries,
|
|
44
|
+
generateResearchers as _generateResearchers,
|
|
45
|
+
} from './planning-utils.ts';
|
|
46
|
+
|
|
47
|
+
// The coordinator and evaluator LLM calls are single points of failure for a research
|
|
48
|
+
// run, yet (unlike researcher sub-agents) they had no retry — so one transient transport
|
|
49
|
+
// failure (an undici "terminated" mid-stream abort, ECONNRESET, 429, 5xx) aborted the whole
|
|
50
|
+
// run. Retry them a bounded number of times. Kept low so that a genuine provider outage
|
|
51
|
+
// (repeated full LLM_TIMEOUT_MS) still degrades to the deterministic fallback plan within a
|
|
52
|
+
// sane wall-clock budget rather than multiplying a 5-minute timeout many times over.
|
|
53
|
+
const LLM_MAX_RETRIES = 2;
|
|
54
|
+
const LLM_RETRY_INITIAL_DELAY_MS = 1000;
|
|
55
|
+
const LLM_RETRY_MAX_DELAY_MS = 8000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a failed coordinator/evaluator LLM call should be RETRIED. Retries only fast,
|
|
59
|
+
* transient transport failures: an undici mid-stream abort ("terminated"), a dropped socket,
|
|
60
|
+
* a 5xx / 429 / provider-overload, or an empty ("no text content") response.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately does NOT retry an app-level LLM timeout ("...timed out after Nms"): the attempt
|
|
63
|
+
* already ran the full LLM_TIMEOUT_MS, so an immediate re-run is unlikely to succeed, would
|
|
64
|
+
* leave the prior (still-running) stream orphaned and billed, and would multiply a multi-minute
|
|
65
|
+
* wait. A timeout instead degrades straight to the fallback plan (see the catch blocks, which
|
|
66
|
+
* treat "timed out" as degradable). A deliberate cancellation is never retried — detected by
|
|
67
|
+
* AbortSignal state / AbortError name, not by the message (withTimeout emits the same "cancelled
|
|
68
|
+
* or timed out" wording for a user-abort and a real timeout, and abortableDelay is the backstop).
|
|
69
|
+
*
|
|
70
|
+
* Exported for direct unit testing of the abort/timeout guards.
|
|
71
|
+
*/
|
|
72
|
+
export function isRetriableLlmError(error: unknown, signal?: AbortSignal): boolean {
|
|
73
|
+
if (signal?.aborted) return false;
|
|
74
|
+
if (error instanceof Error && error.name === 'AbortError') return false;
|
|
75
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
76
|
+
if (message.includes('timed out')) return false; // app timeout → degrade, don't retry
|
|
77
|
+
if (message.includes('no text content')) return true;
|
|
78
|
+
return isTransientError(error);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether an exhausted coordinator/evaluator failure should DEGRADE to the deterministic
|
|
83
|
+
* fallback plan rather than abort the run. Broader than the retry set: it also covers the
|
|
84
|
+
* app-level timeout (which is intentionally not retried). Only a genuinely non-transient error
|
|
85
|
+
* (bad request, missing auth) stays fatal — a fallback plan can't run without a working model.
|
|
86
|
+
*/
|
|
87
|
+
function isDegradableLlmError(error: unknown, signal?: AbortSignal): boolean {
|
|
88
|
+
if (signal?.aborted) return false;
|
|
89
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
90
|
+
if (message.includes('timed out')) return true;
|
|
91
|
+
return isRetriableLlmError(error, signal);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Salvage raw evaluator text as report content when JSON parse AND agentic repair
|
|
96
|
+
* both fail. A truncated/garbled JSON envelope must NOT be shipped verbatim as the
|
|
97
|
+
* final report (the user would see raw `{"action":"synthesize","content":"...` cut
|
|
98
|
+
* off mid-string). Returning '' here lets the orchestrator's reports-based fallback
|
|
99
|
+
* synthesis take over. Genuine prose (a model that answered in text instead of JSON)
|
|
100
|
+
* is preserved — only JSON-envelope-looking blobs and too-short scraps are rejected.
|
|
101
|
+
*/
|
|
102
|
+
export function salvageReportText(raw: string): string {
|
|
103
|
+
const trimmed = raw.trim();
|
|
104
|
+
if (trimmed.length <= 50) return '';
|
|
105
|
+
// Looks like a (broken) JSON envelope rather than a report — suppress it.
|
|
106
|
+
if (trimmed.startsWith('{') && /"(?:action|content|researchers)"\s*:/.test(trimmed)) {
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
return raw;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Planning Service Implementation
|
|
114
|
+
*/
|
|
115
|
+
export class PlanningService implements IPlanningService {
|
|
116
|
+
readonly name = ServiceNames.PLANNING;
|
|
117
|
+
lifecycle = ServiceLifecycle.UNINITIALIZED;
|
|
118
|
+
|
|
119
|
+
// State per research session
|
|
120
|
+
private currentPlans = new Map<string, ResearchPlan>();
|
|
121
|
+
private totalResearchersPlanned = new Map<string, number>();
|
|
122
|
+
private queryHistory = new Map<string, Set<string>>();
|
|
123
|
+
|
|
124
|
+
async initialize(): Promise<void> {
|
|
125
|
+
this.lifecycle = ServiceLifecycle.INITIALIZED;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async dispose(): Promise<void> {
|
|
129
|
+
this.currentPlans.clear();
|
|
130
|
+
this.totalResearchersPlanned.clear();
|
|
131
|
+
this.queryHistory.clear();
|
|
132
|
+
this.lifecycle = ServiceLifecycle.DISPOSED;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if the service is ready
|
|
137
|
+
*/
|
|
138
|
+
isReady(): boolean {
|
|
139
|
+
return this.lifecycle === ServiceLifecycle.INITIALIZED;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Clear planning state for a specific research ID
|
|
144
|
+
*/
|
|
145
|
+
clearPlanningState(researchId?: string): void {
|
|
146
|
+
if (researchId) {
|
|
147
|
+
this.currentPlans.delete(researchId);
|
|
148
|
+
this.totalResearchersPlanned.delete(researchId);
|
|
149
|
+
this.queryHistory.delete(researchId);
|
|
150
|
+
} else {
|
|
151
|
+
this.currentPlans.clear();
|
|
152
|
+
this.totalResearchersPlanned.clear();
|
|
153
|
+
this.queryHistory.clear();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get the current plan for a session
|
|
159
|
+
*/
|
|
160
|
+
getCurrentPlan(researchId: string): ResearchPlan | null {
|
|
161
|
+
return this.currentPlans.get(researchId) || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get total number of researchers planned across all rounds
|
|
166
|
+
*/
|
|
167
|
+
getTotalResearchersPlanned(researchId: string): number {
|
|
168
|
+
return this.totalResearchersPlanned.get(researchId) || 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Increment total number of researchers planned
|
|
173
|
+
*/
|
|
174
|
+
incrementTotalResearchersPlanned(researchId: string, count: number): void {
|
|
175
|
+
const current = this.getTotalResearchersPlanned(researchId);
|
|
176
|
+
this.totalResearchersPlanned.set(researchId, current + count);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get query history for a session
|
|
181
|
+
*/
|
|
182
|
+
getQueryHistory(researchId: string): string[] {
|
|
183
|
+
const history = this.queryHistory.get(researchId);
|
|
184
|
+
return history ? Array.from(history) : [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Record search queries to history to prevent duplication in later rounds
|
|
189
|
+
*/
|
|
190
|
+
addToQueryHistory(researchId: string, queries: string[]): void {
|
|
191
|
+
if (!this.queryHistory.has(researchId)) {
|
|
192
|
+
this.queryHistory.set(researchId, new Set());
|
|
193
|
+
}
|
|
194
|
+
const history = this.queryHistory.get(researchId)!;
|
|
195
|
+
for (const q of queries) {
|
|
196
|
+
if (q && q.trim()) {
|
|
197
|
+
history.add(q.toLowerCase().trim());
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Interface pass-throughs for planning utilities
|
|
204
|
+
*/
|
|
205
|
+
getTeamSize(complexity: 1 | 2 | 3): number {
|
|
206
|
+
return _getTeamSize(complexity);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
getQueryBudget(complexity: 1 | 2 | 3): number {
|
|
210
|
+
return _getQueryBudget(complexity);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
getComplexityGuidance(complexity: 1 | 2 | 3, maxTeamSize: number, queryBudget: number): string {
|
|
214
|
+
return _getComplexityGuidance(complexity, maxTeamSize, queryBudget);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
getEvaluatorComplexityGuidance(complexity: 1 | 2 | 3): string {
|
|
218
|
+
return _getEvaluatorComplexityGuidance(complexity);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
getRoundPhaseGuidance(currentRound: number, maxRounds: number, complexity: 1 | 2 | 3, maxTeamSize: number): string {
|
|
222
|
+
return _getRoundPhaseGuidance(currentRound, maxRounds, complexity, maxTeamSize);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
parseJsonPlan(text: string): ResearchPlan {
|
|
226
|
+
return _parseJsonPlan(text);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
buildFallbackCoordinatorPlan(rawText: string, query: string): ResearchPlan {
|
|
230
|
+
return _buildFallbackCoordinatorPlan('PlanningService', rawText, query);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
capResearcherQueries(plan: ResearchPlan, complexity: 1 | 2 | 3, serviceName: string): ResearchPlan {
|
|
234
|
+
return _capResearcherQueries(plan, complexity, serviceName);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
generateResearchers(plan: ResearchPlan, query: string, complexity: 1 | 2 | 3): ResearcherConfig[] {
|
|
238
|
+
return _generateResearchers(plan, query, complexity);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Helper to populate prompt templates with placeholders.
|
|
243
|
+
*/
|
|
244
|
+
private populatePrompt(template: string, replacements: Record<string, string | number>): string {
|
|
245
|
+
let result = template;
|
|
246
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
247
|
+
const placeholder = `{{${key}}}`;
|
|
248
|
+
result = result.split(placeholder).join(String(value));
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Generate initial research plan
|
|
255
|
+
*/
|
|
256
|
+
async generatePlan(options: GeneratePlanOptions): Promise<ResearchPlan> {
|
|
257
|
+
const { sessionId, query, complexity, model, signal, observer, steeringMessages, modelRegistry } = options;
|
|
258
|
+
|
|
259
|
+
logger.log(`[PlanningService] Generating initial plan for: "${query}" (Complexity: ${complexity})`);
|
|
260
|
+
|
|
261
|
+
const config = options.config ?? getConfig(options.cwd);
|
|
262
|
+
const promptTemplate = loadPrompt('system-coordinator');
|
|
263
|
+
|
|
264
|
+
const maxTeamSize = this.getTeamSize(complexity);
|
|
265
|
+
const queryBudget = this.getQueryBudget(complexity);
|
|
266
|
+
const complexityGuidance = this.getComplexityGuidance(complexity, maxTeamSize, queryBudget);
|
|
267
|
+
|
|
268
|
+
// Inject steering if present
|
|
269
|
+
let steeringSection = '';
|
|
270
|
+
if (steeringMessages && steeringMessages.length > 0) {
|
|
271
|
+
steeringSection = '\n\n### ADDITIONAL USER GUIDANCE (Apply these rules and instructions to your plan and decisions)\n' +
|
|
272
|
+
steeringMessages.map(m => `- ${m}`).join('\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const disabledToolsSection = options.excludeTools && options.excludeTools.length > 0
|
|
276
|
+
? `\n## DISABLED TOOLS\nThe following tools are DISABLED for this session: ${options.excludeTools.join(', ')}. Do NOT reference or attempt to use them.\n`
|
|
277
|
+
: '';
|
|
278
|
+
|
|
279
|
+
const systemPrompt = injectCurrentDate(promptTemplate, 'coordinator');
|
|
280
|
+
const populatedPrompt = this.populatePrompt(systemPrompt, {
|
|
281
|
+
root_query: query,
|
|
282
|
+
additional_considerations: steeringSection,
|
|
283
|
+
complexity_label: complexity === 1 ? 'Level 1 (Normal)' : complexity === 2 ? 'Level 2 (Deep)' : 'Level 3 (Ultra)',
|
|
284
|
+
max_team_size: maxTeamSize,
|
|
285
|
+
query_budget: queryBudget,
|
|
286
|
+
complexity_guidance: complexityGuidance,
|
|
287
|
+
disabled_tools_section: disabledToolsSection,
|
|
288
|
+
youtube_query_every_n: config.YOUTUBE_QUERY_EVERY_N,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const userMessage = `Generate the initial research plan for: "${query}"`;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const authResult = await safeGetApiKeyAndHeaders(modelRegistry, model);
|
|
295
|
+
if (!authResult.ok) {
|
|
296
|
+
throw new Error(`Failed to get API key for planning: ${authResult.error}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const llmTimeout = config.LLM_TIMEOUT_MS;
|
|
300
|
+
|
|
301
|
+
// Retry the transport call + response validation on transient failures. A mid-stream
|
|
302
|
+
// undici "terminated" abort surfaces as a stopReason:'error' response that only throws
|
|
303
|
+
// inside validateAndExtractText, so validation MUST sit inside the retried region. The
|
|
304
|
+
// agentic-repair / fallback path below stays OUTSIDE the retry — it handles present-but-
|
|
305
|
+
// malformed JSON (a different failure) and would otherwise be re-paid on every attempt.
|
|
306
|
+
const { response, responseText } = await retryWithBackoff(
|
|
307
|
+
async () => {
|
|
308
|
+
const response = await withTimeout(
|
|
309
|
+
completeSimple(model, {
|
|
310
|
+
systemPrompt: populatedPrompt,
|
|
311
|
+
messages: [
|
|
312
|
+
{ role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
|
|
313
|
+
],
|
|
314
|
+
}, buildSafeOptions(model, {
|
|
315
|
+
apiKey: authResult.apiKey || '',
|
|
316
|
+
headers: authResult.headers,
|
|
317
|
+
signal
|
|
318
|
+
}, config.PLANNING_MAX_TOKENS, config.LLM_THINKING_LEVEL)),
|
|
319
|
+
llmTimeout, 'coordinator-generatePlan',
|
|
320
|
+
);
|
|
321
|
+
const responseText = validateAndExtractText(response, 'Coordinator');
|
|
322
|
+
return { response, responseText };
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
maxRetries: LLM_MAX_RETRIES,
|
|
326
|
+
initialDelay: LLM_RETRY_INITIAL_DELAY_MS,
|
|
327
|
+
maxDelay: LLM_RETRY_MAX_DELAY_MS,
|
|
328
|
+
label: 'coordinator-generatePlan',
|
|
329
|
+
signal,
|
|
330
|
+
isTransientError: (err) => isRetriableLlmError(err, signal),
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// Track usage once, on the successful attempt (failed attempts throw before here).
|
|
335
|
+
recordLlmUsage(model, (response as any).usage, { component: 'coordinator', complexity, observer });
|
|
336
|
+
|
|
337
|
+
// Extract and validate JSON
|
|
338
|
+
let plan: ResearchPlan | null = null;
|
|
339
|
+
try {
|
|
340
|
+
plan = this.parseJsonPlan(responseText);
|
|
341
|
+
} catch {
|
|
342
|
+
logger.warn('[PlanningService] Initial plan JSON malformed, attempting agentic repair');
|
|
343
|
+
const repaired = await repairJsonWithLlm<ResearchPlan>(
|
|
344
|
+
responseText,
|
|
345
|
+
completeSimple,
|
|
346
|
+
{ apiKey: authResult.apiKey || '', headers: authResult.headers },
|
|
347
|
+
{
|
|
348
|
+
model,
|
|
349
|
+
schema: PlanningResponseSchemaAsTSchema,
|
|
350
|
+
context: `Research planning for: ${query}`,
|
|
351
|
+
serviceName: 'PlanningService',
|
|
352
|
+
signal,
|
|
353
|
+
maxTokens: config.PLANNING_MAX_TOKENS,
|
|
354
|
+
thinkingLevel: config.LLM_THINKING_LEVEL,
|
|
355
|
+
onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: 'coordinator', complexity, observer }),
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
if (repaired) {
|
|
359
|
+
// Validate repaired JSON
|
|
360
|
+
try {
|
|
361
|
+
plan = this.parseJsonPlan(JSON.stringify(repaired));
|
|
362
|
+
} catch {
|
|
363
|
+
plan = null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!plan) {
|
|
369
|
+
logger.warn('[PlanningService] Failed to generate valid plan, building fallback');
|
|
370
|
+
plan = this.buildFallbackCoordinatorPlan(responseText, query);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Final safety cap
|
|
374
|
+
plan = this.capResearcherQueries(plan, complexity, this.name);
|
|
375
|
+
// The coordinator (round 1) MUST yield runnable researchers. If the model emitted empty or
|
|
376
|
+
// absent query arrays, capResearcherQueries drops them all and force-synthesizes — but at
|
|
377
|
+
// round 1 there are zero reports, so that is a silent no-op run ("no summary generated",
|
|
378
|
+
// no error). buildFallbackCoordinatorPlan only fires on a null plan, so a parseable-but-
|
|
379
|
+
// empty plan slips through. Fall back to the single-researcher plan so the run investigates.
|
|
380
|
+
if (!plan.researchers || plan.researchers.length === 0) {
|
|
381
|
+
logger.warn('[PlanningService] Coordinator produced no runnable researchers (empty queries); using single-researcher fallback');
|
|
382
|
+
plan = this.capResearcherQueries(this.buildFallbackCoordinatorPlan(responseText, query), complexity, this.name);
|
|
383
|
+
}
|
|
384
|
+
if (plan.action !== 'synthesize') {
|
|
385
|
+
plan.action = 'delegate';
|
|
386
|
+
}
|
|
387
|
+
this.currentPlans.set(sessionId, plan);
|
|
388
|
+
|
|
389
|
+
return plan;
|
|
390
|
+
} catch (err) {
|
|
391
|
+
// A genuine cancellation must propagate so the orchestrator can abort cleanly.
|
|
392
|
+
// Log it at debug, not error — a quit-mid-run is a clean stop, and an ERROR
|
|
393
|
+
// line here previously read like an infrastructure failure.
|
|
394
|
+
if (signal?.aborted) {
|
|
395
|
+
logger.debug('[PlanningService] Plan generation aborted (external signal)');
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
398
|
+
// A transient coordinator failure — the call timed out, or the model returned an
|
|
399
|
+
// empty / thinking-only response — used to abort the entire run on the very first
|
|
400
|
+
// call (this path runs BEFORE the in-band JSON-parse fallback above). Degrade like
|
|
401
|
+
// updatePlanForRound: build the deterministic single-researcher plan so the run can
|
|
402
|
+
// still proceed. Hard failures (missing auth, an explicit provider error / rate
|
|
403
|
+
// limit) stay fatal — a fallback plan can't run without a working model anyway.
|
|
404
|
+
// After retries are exhausted, a still-transient failure (timeout, empty response, or a
|
|
405
|
+
// transport abort like "terminated") degrades to the deterministic fallback plan so the
|
|
406
|
+
// run proceeds; only a genuinely non-transient error (missing auth, explicit provider
|
|
407
|
+
// rejection) stays fatal — a fallback plan can't run without a working model anyway.
|
|
408
|
+
const isTransient = isDegradableLlmError(err, signal);
|
|
409
|
+
if (!isTransient) {
|
|
410
|
+
logger.error('[PlanningService] Failed to generate plan:', err);
|
|
411
|
+
throw err;
|
|
412
|
+
}
|
|
413
|
+
logger.warn('[PlanningService] Coordinator call failed transiently; building fallback plan so the run can proceed');
|
|
414
|
+
let plan = this.buildFallbackCoordinatorPlan('', query);
|
|
415
|
+
plan = this.capResearcherQueries(plan, complexity, this.name);
|
|
416
|
+
if (plan.action !== 'synthesize') {
|
|
417
|
+
plan.action = 'delegate';
|
|
418
|
+
}
|
|
419
|
+
this.currentPlans.set(sessionId, plan);
|
|
420
|
+
return plan;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Update plan / evaluate progress after a round
|
|
426
|
+
*/
|
|
427
|
+
async updatePlanForRound(options: UpdatePlanOptions): Promise<ResearchPlan> {
|
|
428
|
+
const { sessionId, query, complexity, round, model, reports, mustSynthesize, signal, observer, steeringMessages, modelRegistry } = options;
|
|
429
|
+
|
|
430
|
+
logger.log(`[PlanningService] Evaluating Round ${round} findings for: "${query}"`);
|
|
431
|
+
|
|
432
|
+
const config = options.config ?? getConfig(options.cwd);
|
|
433
|
+
const promptTemplate = loadPrompt('system-lead-evaluator');
|
|
434
|
+
|
|
435
|
+
const maxTeamSize = this.getTeamSize(complexity);
|
|
436
|
+
const queryBudget = this.getQueryBudget(complexity);
|
|
437
|
+
const maxRounds = _getMaxRounds(complexity);
|
|
438
|
+
const complexityGuidance = this.getEvaluatorComplexityGuidance(complexity);
|
|
439
|
+
const roundPhaseGuidance = this.getRoundPhaseGuidance(round, maxRounds, complexity, maxTeamSize);
|
|
440
|
+
|
|
441
|
+
// Inject steering if present
|
|
442
|
+
let steeringSection = '';
|
|
443
|
+
if (steeringMessages && steeringMessages.length > 0) {
|
|
444
|
+
steeringSection = '\n\n### ADDITIONAL USER GUIDANCE (Ensure findings follow these rules)\n' +
|
|
445
|
+
steeringMessages.map(m => `- ${m}`).join('\n');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const disabledToolsSection = options.excludeTools && options.excludeTools.length > 0
|
|
449
|
+
? `\n## DISABLED TOOLS\nThe following tools are DISABLED for this session: ${options.excludeTools.join(', ')}. Do NOT reference or attempt to use them.\n`
|
|
450
|
+
: '';
|
|
451
|
+
|
|
452
|
+
const previousPlan = options.previousPlan;
|
|
453
|
+
const initialAgendaSection = previousPlan && previousPlan.researchers && previousPlan.researchers.length > 0
|
|
454
|
+
? `\n## Initial Research Agenda\n${previousPlan.researchers.map(r => `- ${r.name}: ${r.goal}`).join('\n')}\n`
|
|
455
|
+
: '';
|
|
456
|
+
|
|
457
|
+
const previousQueries = this.getQueryHistory(sessionId);
|
|
458
|
+
const previousQueriesSection = previousQueries.length > 0
|
|
459
|
+
? `\n## Previously Executed Queries\n${previousQueries.map(q => `- ${q}`).join('\n')}\n`
|
|
460
|
+
: '';
|
|
461
|
+
|
|
462
|
+
const systemPrompt = injectCurrentDate(promptTemplate, 'evaluator');
|
|
463
|
+
const populatedPrompt = this.populatePrompt(systemPrompt, {
|
|
464
|
+
root_query: query,
|
|
465
|
+
round_number: round,
|
|
466
|
+
max_rounds: maxRounds,
|
|
467
|
+
complexity_label: complexity === 1 ? 'Level 1 (Normal)' : complexity === 2 ? 'Level 2 (Deep)' : 'Level 3 (Ultra)',
|
|
468
|
+
initial_agenda_section: initialAgendaSection,
|
|
469
|
+
previous_queries_section: previousQueriesSection,
|
|
470
|
+
additional_considerations: steeringSection,
|
|
471
|
+
disabled_tools_section: disabledToolsSection,
|
|
472
|
+
complexity_guidance: complexityGuidance,
|
|
473
|
+
round_phase_guidance: roundPhaseGuidance,
|
|
474
|
+
max_team_size: maxTeamSize,
|
|
475
|
+
query_budget: queryBudget,
|
|
476
|
+
youtube_query_every_n: config.YOUTUBE_QUERY_EVERY_N,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Normalize citations across all reports to ONE global numbering BEFORE the
|
|
480
|
+
// evaluator/synthesis LLM sees them, and hand it the matching Global Source
|
|
481
|
+
// List. The evaluator prompt promises exactly this ("reports have already
|
|
482
|
+
// been normalized to these global numbers"); doing it here is what makes the
|
|
483
|
+
// synthesized body's [N] line up with the final CITED LINKS list that
|
|
484
|
+
// ensureCitedLinks() regenerates from the same normalization.
|
|
485
|
+
const { normalizedReports, globalCitations } = normalizeCitations(reports);
|
|
486
|
+
const globalSourceList = globalCitations.length > 0
|
|
487
|
+
? 'GLOBAL SOURCE LIST (use these exact [N] numbers for every inline citation):\n' +
|
|
488
|
+
globalCitations
|
|
489
|
+
.map((c) => `[${c.id}] ${c.url}${c.source ? ` [Source: ${c.source}]` : ''}${c.description ? ` — ${c.description}` : ''}`)
|
|
490
|
+
.join('\n') +
|
|
491
|
+
'\n\n---\n\n'
|
|
492
|
+
: '';
|
|
493
|
+
const findings = globalSourceList + Array.from(normalizedReports.entries())
|
|
494
|
+
.map(([id, report]) => `### Researcher ${id}\n${report}`)
|
|
495
|
+
.join('\n\n');
|
|
496
|
+
|
|
497
|
+
const userMessage = mustSynthesize
|
|
498
|
+
? `Research budget exhausted. Synthesize final report now based on findings:\n\n${findings}`
|
|
499
|
+
: `Evaluate the following findings and decide next steps (delegate more researchers or synthesize final report):\n\n${findings}`;
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
const authResult = await safeGetApiKeyAndHeaders(modelRegistry, model);
|
|
503
|
+
if (!authResult.ok) {
|
|
504
|
+
throw new Error(`Failed to get API key for evaluation: ${authResult.error}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const llmTimeout = config.LLM_TIMEOUT_MS;
|
|
508
|
+
|
|
509
|
+
// Retry the transport call + validation on transient failures (see generatePlan for the
|
|
510
|
+
// rationale — a "terminated" abort only throws at validateAndExtractText, so validation
|
|
511
|
+
// must be inside the retried region; repair/fallback stays outside).
|
|
512
|
+
const { response, responseText } = await retryWithBackoff(
|
|
513
|
+
async () => {
|
|
514
|
+
const response = await withTimeout(
|
|
515
|
+
completeSimple(model, {
|
|
516
|
+
systemPrompt: populatedPrompt,
|
|
517
|
+
messages: [
|
|
518
|
+
{ role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
|
|
519
|
+
],
|
|
520
|
+
// The evaluator's response carries the final report whenever it synthesizes —
|
|
521
|
+
// which it may choose to do on ANY round (voluntary early finish), not just the
|
|
522
|
+
// forced final round. So every evaluator call gets the full synthesis budget; a
|
|
523
|
+
// small decision response simply uses a fraction of it. Clamped to the model.
|
|
524
|
+
}, buildSafeOptions(model, {
|
|
525
|
+
apiKey: authResult.apiKey || '',
|
|
526
|
+
headers: authResult.headers,
|
|
527
|
+
signal
|
|
528
|
+
}, config.SYNTHESIS_MAX_TOKENS, config.LLM_THINKING_LEVEL)),
|
|
529
|
+
llmTimeout, 'evaluator-updatePlanForRound',
|
|
530
|
+
);
|
|
531
|
+
const responseText = validateAndExtractText(response, 'Evaluator');
|
|
532
|
+
return { response, responseText };
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
maxRetries: LLM_MAX_RETRIES,
|
|
536
|
+
initialDelay: LLM_RETRY_INITIAL_DELAY_MS,
|
|
537
|
+
maxDelay: LLM_RETRY_MAX_DELAY_MS,
|
|
538
|
+
label: 'evaluator-updatePlanForRound',
|
|
539
|
+
signal,
|
|
540
|
+
isTransientError: (err) => isRetriableLlmError(err, signal),
|
|
541
|
+
},
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
// Track usage once, on the successful attempt (failed attempts throw before here).
|
|
545
|
+
recordLlmUsage(model, (response as any).usage, { component: 'evaluator', complexity, observer });
|
|
546
|
+
|
|
547
|
+
// Extract and validate JSON
|
|
548
|
+
let plan: ResearchPlan | null = null;
|
|
549
|
+
try {
|
|
550
|
+
plan = this.parseJsonPlan(responseText);
|
|
551
|
+
} catch {
|
|
552
|
+
logger.warn('[PlanningService] Evaluation JSON malformed, attempting agentic repair');
|
|
553
|
+
const repaired = await repairJsonWithLlm<ResearchPlan>(
|
|
554
|
+
responseText,
|
|
555
|
+
completeSimple,
|
|
556
|
+
{ apiKey: authResult.apiKey || '', headers: authResult.headers },
|
|
557
|
+
{
|
|
558
|
+
model,
|
|
559
|
+
schema: EvaluationResponseSchemaAsTSchema,
|
|
560
|
+
context: `Research evaluation for: ${query} (Round ${round})`,
|
|
561
|
+
serviceName: 'PlanningService',
|
|
562
|
+
signal,
|
|
563
|
+
maxTokens: config.SYNTHESIS_MAX_TOKENS,
|
|
564
|
+
thinkingLevel: config.LLM_THINKING_LEVEL,
|
|
565
|
+
onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: 'evaluator', complexity, observer }),
|
|
566
|
+
}
|
|
567
|
+
);
|
|
568
|
+
if (repaired) {
|
|
569
|
+
// Validate repaired JSON
|
|
570
|
+
try {
|
|
571
|
+
plan = this.parseJsonPlan(JSON.stringify(repaired));
|
|
572
|
+
} catch {
|
|
573
|
+
plan = null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (!plan) {
|
|
579
|
+
// A parse failure here used to ALWAYS default to synthesize, which prematurely
|
|
580
|
+
// ended a run on a single transient/garbled evaluator response (with rounds still
|
|
581
|
+
// remaining) — and put the raw, often-truncated text into the report body.
|
|
582
|
+
// Only finalize early when synthesis was already mandatory (final round); otherwise,
|
|
583
|
+
// if there is a prior agenda to continue, delegate another round rather than giving up.
|
|
584
|
+
// Round budget (maxRounds) still bounds this, so it cannot loop indefinitely.
|
|
585
|
+
if (mustSynthesize) {
|
|
586
|
+
logger.warn('[PlanningService] Failed to generate valid evaluation on the final round; synthesizing from raw text');
|
|
587
|
+
const safeContent = salvageReportText(responseText);
|
|
588
|
+
plan = { action: 'synthesize', content: safeContent, researchers: [] };
|
|
589
|
+
} else if (previousPlan?.researchers && previousPlan.researchers.length > 0) {
|
|
590
|
+
logger.warn('[PlanningService] Evaluation unparseable mid-research; continuing the prior agenda rather than synthesizing early');
|
|
591
|
+
plan = { action: 'delegate', content: '', researchers: previousPlan.researchers };
|
|
592
|
+
} else {
|
|
593
|
+
logger.warn('[PlanningService] Evaluation unparseable and no prior agenda to continue; falling back to synthesize');
|
|
594
|
+
const safeContent = salvageReportText(responseText);
|
|
595
|
+
plan = { action: 'synthesize', content: safeContent, researchers: [] };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const finalPlan = plan as ResearchPlan;
|
|
600
|
+
|
|
601
|
+
// Respect mustSynthesize flag
|
|
602
|
+
if (mustSynthesize) {
|
|
603
|
+
finalPlan.action = 'synthesize';
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Final safety cap if delegating
|
|
607
|
+
if (finalPlan.action === 'delegate') {
|
|
608
|
+
const capped = this.capResearcherQueries(finalPlan, complexity, this.name);
|
|
609
|
+
this.currentPlans.set(sessionId, capped);
|
|
610
|
+
return capped;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
this.currentPlans.set(sessionId, finalPlan);
|
|
614
|
+
return finalPlan;
|
|
615
|
+
} catch (err) {
|
|
616
|
+
// A genuine cancellation must propagate so the orchestrator can abort cleanly.
|
|
617
|
+
// Re-throw BEFORE logging so a quit-mid-run is a clean stop, not a red
|
|
618
|
+
// "Failed to update plan" line (matching generatePlan's abort hygiene).
|
|
619
|
+
if (signal?.aborted) throw err;
|
|
620
|
+
logger.error('[PlanningService] Failed to update plan:', err);
|
|
621
|
+
// Otherwise degrade gracefully: a transient evaluator failure (timeout, empty
|
|
622
|
+
// or provider error) reaches here BEFORE the JSON-parse fallback above and used
|
|
623
|
+
// to throw — aborting the whole run with no decision (looks like "the evaluator
|
|
624
|
+
// never came"). Mirror that fallback: continue the prior agenda mid-run, else
|
|
625
|
+
// synthesize from whatever reports were already collected. maxRounds still bounds it.
|
|
626
|
+
if (!mustSynthesize && previousPlan?.researchers && previousPlan.researchers.length > 0) {
|
|
627
|
+
logger.warn('[PlanningService] Evaluator call failed mid-research; continuing the prior agenda rather than aborting the run');
|
|
628
|
+
const fallback = this.capResearcherQueries(
|
|
629
|
+
{ action: 'delegate', content: '', researchers: previousPlan.researchers },
|
|
630
|
+
complexity,
|
|
631
|
+
this.name,
|
|
632
|
+
);
|
|
633
|
+
this.currentPlans.set(sessionId, fallback);
|
|
634
|
+
return fallback;
|
|
635
|
+
}
|
|
636
|
+
logger.warn('[PlanningService] Evaluator call failed with no prior agenda; synthesizing from collected reports');
|
|
637
|
+
const fallback: ResearchPlan = { action: 'synthesize', content: '', researchers: [] };
|
|
638
|
+
this.currentPlans.set(sessionId, fallback);
|
|
639
|
+
return fallback;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Generate queries for a researcher
|
|
645
|
+
*/
|
|
646
|
+
async generateQueries(options: GenerateQueriesOptions): Promise<string[]> {
|
|
647
|
+
return options.researcher.queries || [];
|
|
648
|
+
}
|
|
649
|
+
}
|