@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
|
@@ -1,848 +1,599 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deep Research Orchestrator
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1. Initial Planning (One-shot) - exhaustive agenda creation
|
|
6
|
-
* 2. Parallel Sibling Execution - with mid-flight report injection
|
|
7
|
-
* 3. Last-Man-Standing Promotion - the final sibling handles evaluation/synthesis
|
|
8
|
-
*
|
|
9
|
-
* State transitions are handled by the pure deepResearchReducer.
|
|
4
|
+
* Coordinates multi-round research using specialized services.
|
|
10
5
|
*/
|
|
11
6
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { dirname } from 'node:path';
|
|
16
|
-
import type { ExtensionContext, AgentSession } from '@mariozechner/pi-coding-agent';
|
|
17
|
-
import { convertToLlm } from '@mariozechner/pi-coding-agent';
|
|
18
|
-
import type { ExtendedAgentSessionEvent } from '../types/extension-context.ts';
|
|
19
|
-
import { complete, type Model } from '@mariozechner/pi-ai';
|
|
20
|
-
import { deepResearchReducer, isRoundComplete } from './deep-research-reducer.ts';
|
|
21
|
-
import type { SystemResearchState, ResearchSibling } from './deep-research-types.ts';
|
|
22
|
-
import { TextContentBlock, isTextContentBlock, parseTokenUsage, calculateTotalTokens } from '../types/llm.ts';
|
|
23
|
-
import {
|
|
24
|
-
INITIAL_RESEARCHERS_LEVEL_1,
|
|
25
|
-
INITIAL_RESEARCHERS_LEVEL_2,
|
|
26
|
-
INITIAL_RESEARCHERS_LEVEL_3,
|
|
27
|
-
MAX_ROUNDS_LEVEL_1,
|
|
28
|
-
MAX_ROUNDS_LEVEL_2,
|
|
29
|
-
MAX_ROUNDS_LEVEL_3,
|
|
30
|
-
MAX_CONCURRENT_RESEARCHERS,
|
|
31
|
-
MAX_REPORT_LENGTH,
|
|
32
|
-
DEFAULT_MODEL_CONTEXT_WINDOW,
|
|
33
|
-
MAX_EXTRA_ROUNDS,
|
|
34
|
-
MAX_EVALUATOR_REPORT_LENGTH,
|
|
35
|
-
RESEARCHER_LAUNCH_DELAY_MS,
|
|
36
|
-
STATE_PROPAGATION_DELAY_MS,
|
|
37
|
-
STREAMING_UPDATE_THRESHOLD_CHARS,
|
|
38
|
-
} from '../constants.ts';
|
|
39
|
-
import { extractJson, extractJsonFromCodeBlocks, extractJsonObject, normalizeStringArrayDetailed } from '../utils/json-utils.ts';
|
|
40
|
-
import { createResearcherSession } from './researcher.ts';
|
|
41
|
-
import { injectCurrentDate } from '../utils/inject-date.ts';
|
|
42
|
-
import { buildSessionContext } from '@mariozechner/pi-coding-agent';
|
|
43
|
-
import { formatSharedLinksFromState } from '../utils/shared-links.ts';
|
|
7
|
+
import type { ExtensionContext, AgentToolResult } from '@earendil-works/pi-coding-agent';
|
|
8
|
+
import { type Model } from '@earendil-works/pi-ai';
|
|
44
9
|
import { logger } from '../logger.ts';
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
type
|
|
66
|
-
|
|
67
|
-
| { action: 'synthesize'; content: string };
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Parse the lead evaluator's response into a typed decision.
|
|
71
|
-
*
|
|
72
|
-
* Delegation format (the response IS the JSON, nothing else):
|
|
73
|
-
* {"action": "delegate", "queries": ["q1", "q2"]}
|
|
74
|
-
*
|
|
75
|
-
* Synthesis format: any markdown text.
|
|
76
|
-
* Synthesis is the safe fallback — ambiguous or unparseable output is treated
|
|
77
|
-
* as synthesis to avoid prematurely extending research.
|
|
78
|
-
*
|
|
79
|
-
* Extraction order (most → least reliable):
|
|
80
|
-
* 1. Direct JSON.parse of the trimmed response (clean model output)
|
|
81
|
-
* 2. Fenced ```json code block (model wrapped it)
|
|
82
|
-
* 3. Raw brace extraction — only when '{' appears within the first 200 chars,
|
|
83
|
-
* meaning the JSON is at the start of the response. JSON buried deep in a
|
|
84
|
-
* long document is almost certainly a synthesis with an inline example and
|
|
85
|
-
* must not be extracted as delegation.
|
|
86
|
-
*/
|
|
87
|
-
function parseEvaluatorResponse(text: string): EvalDecision {
|
|
88
|
-
const trimmed = text.trim();
|
|
89
|
-
|
|
90
|
-
const tryDelegate = (parsed: unknown): EvalDecision | null => {
|
|
91
|
-
if (
|
|
92
|
-
parsed !== null &&
|
|
93
|
-
typeof parsed === 'object' &&
|
|
94
|
-
(parsed as EvaluatorDelegateDecision).action === 'delegate' &&
|
|
95
|
-
Array.isArray((parsed as EvaluatorDelegateDecision).queries) &&
|
|
96
|
-
(parsed as EvaluatorDelegateDecision).queries.length > 0
|
|
97
|
-
) {
|
|
98
|
-
const normalized = normalizeStringArrayDetailed((parsed as EvaluatorDelegateDecision).queries);
|
|
99
|
-
if (normalized.warnings.length > 0) {
|
|
100
|
-
logger.debug('[deep-research] Evaluator query normalization warnings:', normalized.warnings.join('; '));
|
|
101
|
-
}
|
|
102
|
-
if (normalized.strings.length > 0) {
|
|
103
|
-
return { action: 'delegate', queries: normalized.strings };
|
|
104
|
-
}
|
|
105
|
-
logger.warn('[deep-research] Evaluator delegation had no valid queries after normalization');
|
|
106
|
-
}
|
|
107
|
-
return null;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
// 1. Fast path: response IS the JSON object
|
|
111
|
-
if (trimmed.startsWith('{')) {
|
|
112
|
-
try {
|
|
113
|
-
const decision = tryDelegate(JSON.parse(trimmed));
|
|
114
|
-
if (decision) return decision;
|
|
115
|
-
} catch { /* not pure JSON */ }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// 2. Fenced code block (```json ... ```)
|
|
119
|
-
const codeBlockResult = extractJsonFromCodeBlocks<EvaluatorDelegateDecision>(trimmed);
|
|
120
|
-
if (codeBlockResult.success && codeBlockResult.value) {
|
|
121
|
-
const decision = tryDelegate(codeBlockResult.value);
|
|
122
|
-
if (decision) return decision;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// 3. Raw brace extraction — only if '{' is near the start (model added prose prefix
|
|
126
|
-
// but still intended to delegate). JSON buried deep in a long synthesis is ignored.
|
|
127
|
-
const bracePos = trimmed.indexOf('{');
|
|
128
|
-
if (bracePos !== -1 && bracePos < 200) {
|
|
129
|
-
const objectResult = extractJsonObject<EvaluatorDelegateDecision>(trimmed);
|
|
130
|
-
if (objectResult.success && objectResult.value) {
|
|
131
|
-
const decision = tryDelegate(objectResult.value);
|
|
132
|
-
if (decision) {
|
|
133
|
-
logger.debug('[deep-research] Extracted delegation from prose-wrapped response (brace at pos', bracePos, ')');
|
|
134
|
-
return decision;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
logger.debug('[deep-research] No valid delegation found — treating response as synthesis');
|
|
140
|
-
return { action: 'synthesize', content: text };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Truncate a researcher report body to maxChars while preserving the CITED LINKS
|
|
145
|
-
* and SCRAPE CANDIDATES sections that appear at the end of the document.
|
|
146
|
-
* Without this, a naive slice cuts off the very URLs the evaluator needs to see.
|
|
147
|
-
*/
|
|
148
|
-
function truncatePreservingLinks(report: string, maxChars: number): string {
|
|
149
|
-
if (report.length <= maxChars) return report;
|
|
150
|
-
const linkSectionMatch = report.match(/\n###\s*CITED\s*LINKS[\s\S]*/i);
|
|
151
|
-
const linkSection = linkSectionMatch ? linkSectionMatch[0] : '';
|
|
152
|
-
const availableForBody = maxChars - linkSection.length - 40;
|
|
153
|
-
if (availableForBody <= 0) {
|
|
154
|
-
return report.slice(0, maxChars) + '\n... (truncated) ...';
|
|
155
|
-
}
|
|
156
|
-
return report.slice(0, availableForBody) + '\n\n... (body truncated for length) ...' + linkSection;
|
|
157
|
-
}
|
|
10
|
+
import { safeUnref } from '../utils/safe-unref.ts';
|
|
11
|
+
import { metrics } from '../utils/metrics.ts';
|
|
12
|
+
import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages } from './session-state.ts';
|
|
13
|
+
import { MAX_EXTRA_ROUNDS_WITH_STEERING } from '../constants.ts';
|
|
14
|
+
import {
|
|
15
|
+
getMaxRounds,
|
|
16
|
+
} from '../core/planning-utils.ts';
|
|
17
|
+
import type {
|
|
18
|
+
ResearchObserver,
|
|
19
|
+
} from '../core/interfaces/observer-interfaces.ts';
|
|
20
|
+
import { HeadlessObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
21
|
+
import {
|
|
22
|
+
ServiceNames,
|
|
23
|
+
type IResearchOrchestration,
|
|
24
|
+
type ResearchPlan,
|
|
25
|
+
type IKnowledgeStoreService,
|
|
26
|
+
type IResearchSynthesisService,
|
|
27
|
+
} from '../core/service-interfaces.ts';
|
|
28
|
+
import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
|
|
29
|
+
import { getConfig } from '../config.ts';
|
|
30
|
+
import type { Config } from '../config.ts';
|
|
31
|
+
import type { PlanningService } from '../core/planning-service.ts';
|
|
158
32
|
|
|
159
33
|
export interface DeepResearchOrchestratorOptions {
|
|
160
34
|
ctx: ExtensionContext;
|
|
161
35
|
model: Model<any>;
|
|
162
36
|
query: string;
|
|
163
37
|
complexity: 1 | 2 | 3;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
38
|
+
sessionId: string;
|
|
39
|
+
researchId: string;
|
|
40
|
+
observer?: ResearchObserver | HeadlessObserverOptions;
|
|
41
|
+
onUpdate?: (update: AgentToolResult<any>) => void;
|
|
42
|
+
config?: Config;
|
|
43
|
+
orchestrationService?: IResearchOrchestration;
|
|
44
|
+
excludeTools?: string[];
|
|
45
|
+
initialLinks?: string[];
|
|
168
46
|
}
|
|
169
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Deep Research Orchestrator
|
|
50
|
+
*
|
|
51
|
+
* Coordinates multi-round research using specialized services.
|
|
52
|
+
*/
|
|
170
53
|
export class DeepResearchOrchestrator {
|
|
171
|
-
private
|
|
172
|
-
private
|
|
173
|
-
private
|
|
174
|
-
private
|
|
175
|
-
private
|
|
176
|
-
|
|
177
|
-
/** Per-sibling current context occupation (overwritten on every message_end). */
|
|
178
|
-
private siblingTokens = new Map<string, number>();
|
|
179
|
-
/** Tracks progress units already credited per sibling/evaluator to ensure full budget is met. */
|
|
180
|
-
private progressCredits = new Map<string, number>();
|
|
181
|
-
/** Resolved context window size for the selected model. */
|
|
182
|
-
private contextWindowSize: number;
|
|
183
|
-
|
|
184
|
-
private static readonly UNITS_PER_RESEARCHER = 10;
|
|
185
|
-
private static readonly LEAD_EVAL_UNITS = 5;
|
|
54
|
+
private currentRound = 0;
|
|
55
|
+
private observer: ResearchObserver | undefined;
|
|
56
|
+
private startTime: number = Date.now();
|
|
57
|
+
private config: Config;
|
|
58
|
+
private readonly sessionStart: number = Date.now();
|
|
59
|
+
private orchestrationService: IResearchOrchestration | null = null;
|
|
186
60
|
|
|
187
61
|
constructor(private options: DeepResearchOrchestratorOptions) {
|
|
188
|
-
this.
|
|
189
|
-
version: 1,
|
|
190
|
-
rootQuery: options.query,
|
|
191
|
-
complexity: options.complexity,
|
|
192
|
-
currentRound: 1,
|
|
193
|
-
status: 'planning',
|
|
194
|
-
lastUpdated: Date.now(),
|
|
195
|
-
initialAgenda: [],
|
|
196
|
-
allScrapedLinks: [],
|
|
197
|
-
aspects: {},
|
|
198
|
-
};
|
|
199
|
-
this.contextWindowSize = (options.model as any)?.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
private resolveResult(result: string) {
|
|
203
|
-
// Prevent duplicate resolutions
|
|
204
|
-
if (this.completionResolved) {
|
|
205
|
-
logger.warn('[deep-research] Attempting to resolve already completed research, ignoring');
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
this.completionResolved = true;
|
|
62
|
+
this.config = options.config || getConfig(options.ctx.cwd);
|
|
209
63
|
|
|
210
|
-
if
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
64
|
+
// Resolve observer: if options were provided instead of an instance, create the instance
|
|
65
|
+
if (options.observer && typeof (options.observer as any).onProgress === 'function' && !(options.observer instanceof HeadlessObserver)) {
|
|
66
|
+
this.observer = new HeadlessObserver(options.observer as HeadlessObserverOptions);
|
|
67
|
+
} else {
|
|
68
|
+
this.observer = options.observer as ResearchObserver | undefined;
|
|
214
69
|
}
|
|
215
|
-
const final = result.length > MAX_REPORT_LENGTH
|
|
216
|
-
? result.slice(0, MAX_REPORT_LENGTH) + '\n\n... (final report truncated for length) ...'
|
|
217
|
-
: result;
|
|
218
|
-
logger.log('[deep-research] Research resolved with result of length:', final.length);
|
|
219
|
-
this.resolveCompletion(final);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async run(signal?: AbortSignal): Promise<string> {
|
|
223
|
-
return new Promise((resolve, reject) => {
|
|
224
|
-
this.resolveCompletion = resolve;
|
|
225
|
-
this.rejectCompletion = (err: Error) => {
|
|
226
|
-
if (this.completionResolved) {
|
|
227
|
-
logger.warn('[deep-research] Attempting to reject already completed research, ignoring');
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
this.completionResolved = true;
|
|
231
|
-
reject(err);
|
|
232
|
-
};
|
|
233
|
-
if (signal?.aborted) return reject(new Error('Research aborted'));
|
|
234
|
-
signal?.addEventListener('abort', () => reject(new Error('Research aborted')));
|
|
235
|
-
this.kickoff(signal).catch(reject);
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
70
|
|
|
239
|
-
|
|
240
|
-
|
|
71
|
+
if (options.orchestrationService) {
|
|
72
|
+
this.orchestrationService = options.orchestrationService;
|
|
73
|
+
}
|
|
74
|
+
// Validate that ctx is available
|
|
75
|
+
if (!options.ctx) {
|
|
76
|
+
throw new Error('DeepResearchOrchestrator requires ExtensionContext (ctx) to be provided');
|
|
77
|
+
}
|
|
241
78
|
}
|
|
242
79
|
|
|
243
|
-
private
|
|
244
|
-
if (
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const cacheReadCost = (modelCost.cacheRead / 1_000_000) * (usage.cacheRead ?? 0);
|
|
249
|
-
const cacheWriteCost = (modelCost.cacheWrite / 1_000_000) * (usage.cacheWrite ?? 0);
|
|
250
|
-
return inputCost + outputCost + cacheReadCost + cacheWriteCost;
|
|
80
|
+
private async getOrchestrationService(): Promise<IResearchOrchestration> {
|
|
81
|
+
if (this.orchestrationService) return this.orchestrationService;
|
|
82
|
+
const container = tryGetServiceContainerFromCtx(this.options.ctx);
|
|
83
|
+
this.orchestrationService = await getService<IResearchOrchestration>(ServiceNames.RESEARCH_ORCHESTRATION, this.options.ctx, container);
|
|
84
|
+
return this.orchestrationService;
|
|
251
85
|
}
|
|
252
86
|
|
|
253
|
-
private async
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (this.state.status === 'completed') {
|
|
258
|
-
logger.log('[deep-research] Already in completed state, resolving with synthesis');
|
|
259
|
-
this.resolveResult(this.state.finalSynthesis || '');
|
|
260
|
-
}
|
|
87
|
+
private async getPlanningService(): Promise<PlanningService> {
|
|
88
|
+
const container = tryGetServiceContainerFromCtx(this.options.ctx);
|
|
89
|
+
// Pass ctx to ensure PlanningService has access to modelRegistry
|
|
90
|
+
return await getService<PlanningService>(ServiceNames.PLANNING, this.options.ctx, container);
|
|
261
91
|
}
|
|
262
92
|
|
|
263
|
-
private
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
case 2: return INITIAL_RESEARCHERS_LEVEL_2;
|
|
267
|
-
case 3: return INITIAL_RESEARCHERS_LEVEL_3;
|
|
268
|
-
}
|
|
93
|
+
private elapsed(): string {
|
|
94
|
+
const s = Math.round((Date.now() - this.startTime) / 1000);
|
|
95
|
+
return `+${s}s`;
|
|
269
96
|
}
|
|
270
97
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Run the multi-round research loop
|
|
100
|
+
*/
|
|
101
|
+
async run(signal?: AbortSignal): Promise<string> {
|
|
102
|
+
const { model, query, complexity, researchId, ctx } = this.options;
|
|
103
|
+
const observer = this.observer;
|
|
104
|
+
const container = tryGetServiceContainerFromCtx(ctx);
|
|
105
|
+
|
|
106
|
+
// RESEARCH_MODEL contract (config.ts): the coordinator and evaluator always
|
|
107
|
+
// use the caller's model (ctx.model); RESEARCH_MODEL overrides only the
|
|
108
|
+
// researcher sub-agents. `model` is the resolved research model — it equals
|
|
109
|
+
// ctx.model on the SDK/CLI path, but on the pi-tool path it is the
|
|
110
|
+
// RESEARCH_MODEL override, so planning must key off ctx.model explicitly.
|
|
111
|
+
// Fall back to `model` when the host exposes no ctx.model.
|
|
112
|
+
const coordinatorModel = (ctx.model as Model<any> | undefined) ?? model;
|
|
113
|
+
|
|
114
|
+
const orchestrationService = await this.getOrchestrationService();
|
|
115
|
+
const planningService = await this.getPlanningService();
|
|
116
|
+
|
|
117
|
+
// Reset services for this specific research run ID to ensure fresh state.
|
|
118
|
+
// Pass ctx so the reset targets the SAME (ctx-scoped) container the run resolves
|
|
119
|
+
// its services from — otherwise it clears state on, and runs the knowledge-store
|
|
120
|
+
// FTS/optimize pass against, the global container instead (see the finally below,
|
|
121
|
+
// which already passes ctx).
|
|
122
|
+
await orchestrationService.cleanupResearchServices(undefined, researchId, ctx, this.config);
|
|
123
|
+
|
|
124
|
+
logger.log(`[DeepOrchestrator] Starting multi-round research (complexity ${complexity}) for: "${query}" (Run: ${researchId})`);
|
|
125
|
+
metrics.increment('research_sessions_total', 1, { mode: 'deep', complexity: String(complexity) });
|
|
126
|
+
|
|
127
|
+
// Fire onStart observer event
|
|
128
|
+
observer?.onStart?.(query, complexity);
|
|
129
|
+
|
|
130
|
+
// Consume all queued messages at the start of the research run. The
|
|
131
|
+
// returned list is exactly the messages that were 'queued' for THIS run
|
|
132
|
+
// (transitioned queued->active here); use its length below rather than
|
|
133
|
+
// getSteeringMessages(), which also counts 'active' messages left over
|
|
134
|
+
// from prior runs and would inflate the steering round bonus.
|
|
135
|
+
const consumedAtStart = consumeQueuedMessages(this.options.sessionId);
|
|
136
|
+
|
|
137
|
+
// The base round budget for this complexity level, plus extra room
|
|
138
|
+
// for any queued steering messages that arrived before this run
|
|
139
|
+
// started (and were just consumed above). Each consumed steering
|
|
140
|
+
// message unlocks one extra round, capped by
|
|
141
|
+
// MAX_EXTRA_ROUNDS_WITH_STEERING. This keeps the round budget
|
|
142
|
+
// concept simple (one number drives the loop) while letting the
|
|
143
|
+
// user push research deeper via Alt+Enter when they have queued
|
|
144
|
+
// guidance waiting to be applied.
|
|
145
|
+
const baseMaxRounds = getMaxRounds(complexity);
|
|
146
|
+
const queuedAtStart = consumedAtStart.length;
|
|
147
|
+
const steeringBonusRounds = Math.min(queuedAtStart, MAX_EXTRA_ROUNDS_WITH_STEERING);
|
|
148
|
+
let maxRounds = baseMaxRounds + steeringBonusRounds;
|
|
149
|
+
// Track total steering-driven extra rounds already granted.
|
|
150
|
+
// New steering consumed mid-run can add more budget up to the cap.
|
|
151
|
+
let totalSteeringExtraRounds = steeringBonusRounds;
|
|
152
|
+
if (steeringBonusRounds > 0) {
|
|
153
|
+
logger.log(
|
|
154
|
+
`[DeepOrchestrator] Extending round budget from ${baseMaxRounds} to ${maxRounds} ` +
|
|
155
|
+
`(${steeringBonusRounds} extra round(s) for ${queuedAtStart} queued steering message(s), ` +
|
|
156
|
+
`cap ${MAX_EXTRA_ROUNDS_WITH_STEERING})`
|
|
157
|
+
);
|
|
276
158
|
}
|
|
277
|
-
}
|
|
278
159
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
160
|
+
const MAX_WAIT_RETRIES = 5;
|
|
161
|
+
let waitRetryCount = 0;
|
|
162
|
+
let loopSynthesisPlan: ResearchPlan | null = null;
|
|
282
163
|
|
|
283
164
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const tokens = usage.totalTokens || (usage.input || 0) + (usage.output || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0);
|
|
311
|
-
if (tokens > 0) {
|
|
312
|
-
this.options.onTokens(tokens);
|
|
313
|
-
this.options.onUpdate();
|
|
165
|
+
while (this.currentRound < maxRounds) {
|
|
166
|
+
if (signal?.aborted) throw new Error('Research aborted');
|
|
167
|
+
this.currentRound++;
|
|
168
|
+
this.startTime = Date.now();
|
|
169
|
+
|
|
170
|
+
// Refresh steering messages and consume any new queued ones at round start
|
|
171
|
+
const beforeConsume = getSteeringMessages(this.options.sessionId).filter(m => m.status === 'active').length;
|
|
172
|
+
consumeQueuedMessages(this.options.sessionId);
|
|
173
|
+
const steeringMessages = getSteeringMessages(this.options.sessionId);
|
|
174
|
+
const steeringTexts = steeringMessages.map(m => m.text);
|
|
175
|
+
|
|
176
|
+
// Dynamically extend round budget when new steering messages are consumed mid-run.
|
|
177
|
+
// The initial budget only accounts for messages present at research start;
|
|
178
|
+
// messages entered mid-research need fresh budget so the evaluator can act on them.
|
|
179
|
+
const newlyConsumed = steeringMessages.filter(m => m.status === 'active').length - beforeConsume;
|
|
180
|
+
if (newlyConsumed > 0 && totalSteeringExtraRounds < MAX_EXTRA_ROUNDS_WITH_STEERING) {
|
|
181
|
+
const additionalRounds = Math.min(newlyConsumed, MAX_EXTRA_ROUNDS_WITH_STEERING - totalSteeringExtraRounds);
|
|
182
|
+
maxRounds += additionalRounds;
|
|
183
|
+
totalSteeringExtraRounds += additionalRounds;
|
|
184
|
+
if (additionalRounds > 0) {
|
|
185
|
+
logger.log(
|
|
186
|
+
`[DeepOrchestrator] Extended round budget from ${maxRounds - additionalRounds} to ${maxRounds} ` +
|
|
187
|
+
`(${additionalRounds} extra round(s) for ${newlyConsumed} newly consumed steering message(s), ` +
|
|
188
|
+
`total steering extra: ${totalSteeringExtraRounds}/${MAX_EXTRA_ROUNDS_WITH_STEERING})`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
314
191
|
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const text = response.content
|
|
318
|
-
.filter((c): c is TextContentBlock => isTextContentBlock(c))
|
|
319
|
-
.map(c => c.text)
|
|
320
|
-
.join('');
|
|
321
|
-
let agenda: string[] = [];
|
|
322
|
-
try {
|
|
323
|
-
// Extract JSON array from coordinator response
|
|
324
|
-
const result = extractJson<string[]>(text, 'array');
|
|
325
|
-
|
|
326
|
-
if (result.success && result.value && result.value.length > 0) {
|
|
327
|
-
// Normalize queries with detailed feedback
|
|
328
|
-
const normalized = normalizeStringArrayDetailed(result.value);
|
|
329
|
-
|
|
330
|
-
if (normalized.strings.length > 0) {
|
|
331
|
-
agenda = normalized.strings;
|
|
332
|
-
logger.debug(`[deep-research] Parsed ${agenda.length} agenda items from coordinator response`);
|
|
333
192
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
193
|
+
const roundLabel = this.currentRound > baseMaxRounds
|
|
194
|
+
? `Round ${this.currentRound}/${maxRounds} (extra, steering-driven, base=${baseMaxRounds})`
|
|
195
|
+
: `Round ${this.currentRound}/${maxRounds}`;
|
|
196
|
+
logger.log(`[DeepOrchestrator] ${roundLabel} ${this.elapsed()}`);
|
|
197
|
+
observer?.onRoundStart?.(this.currentRound);
|
|
198
|
+
|
|
199
|
+
// Check infrastructure health (advisory: logs status, never aborts the run).
|
|
200
|
+
await orchestrationService.checkHealth(this.currentRound, researchId, ctx);
|
|
201
|
+
|
|
202
|
+
// 1. Update/Generate Plan
|
|
203
|
+
let plan: ResearchPlan;
|
|
204
|
+
if (this.currentRound === 1) {
|
|
205
|
+
observer?.onPlanningStart?.(1);
|
|
206
|
+
observer?.onPlanningProgress?.('planning');
|
|
207
|
+
plan = await planningService.generatePlan({
|
|
208
|
+
sessionId: researchId,
|
|
209
|
+
query,
|
|
210
|
+
complexity,
|
|
211
|
+
model: coordinatorModel,
|
|
212
|
+
modelRegistry: ctx.modelRegistry,
|
|
213
|
+
cwd: ctx.cwd,
|
|
214
|
+
config: this.config,
|
|
215
|
+
signal,
|
|
216
|
+
observer,
|
|
217
|
+
excludeTools: this.options.excludeTools,
|
|
218
|
+
steeringMessages: steeringTexts,
|
|
219
|
+
});
|
|
340
220
|
} else {
|
|
341
|
-
|
|
221
|
+
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
222
|
+
observer?.onEvaluationStart?.(this.currentRound);
|
|
223
|
+
observer?.onEvaluationProgress?.('evaluating');
|
|
224
|
+
plan = await planningService.updatePlanForRound({
|
|
225
|
+
sessionId: researchId,
|
|
226
|
+
query: query,
|
|
227
|
+
complexity,
|
|
228
|
+
round: this.currentRound,
|
|
229
|
+
model: coordinatorModel,
|
|
230
|
+
modelRegistry: ctx.modelRegistry,
|
|
231
|
+
cwd: ctx.cwd,
|
|
232
|
+
config: this.config,
|
|
233
|
+
reports: synthesisService.getAllReports(researchId),
|
|
234
|
+
previousPlan: planningService.getCurrentPlan(researchId),
|
|
235
|
+
totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
|
|
236
|
+
signal,
|
|
237
|
+
observer,
|
|
238
|
+
excludeTools: this.options.excludeTools,
|
|
239
|
+
steeringMessages: steeringTexts,
|
|
240
|
+
});
|
|
342
241
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
this.updateState({
|
|
356
|
-
type: 'PLANNING_COMPLETE',
|
|
357
|
-
agenda,
|
|
358
|
-
initialCount
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
// Initialise progress budget — start with only round 1 + one lead-eval; expand dynamically
|
|
362
|
-
// when the lead evaluator spawns additional rounds in promoteToLead.
|
|
363
|
-
const round1Count = Math.min(initialCount, agenda.length);
|
|
364
|
-
const initialExpected = round1Count * DeepResearchOrchestrator.UNITS_PER_RESEARCHER
|
|
365
|
-
+ DeepResearchOrchestrator.LEAD_EVAL_UNITS;
|
|
366
|
-
|
|
367
|
-
this.options.panelState.progress = { expected: initialExpected, made: 0, extended: false };
|
|
368
|
-
this.options.onUpdate();
|
|
369
|
-
|
|
370
|
-
} catch (err) {
|
|
371
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
372
|
-
logger.error('[deep-research] Planning failed:', err);
|
|
373
|
-
this.rejectCompletion(new Error(`Research planning failed: ${errorMsg}`));
|
|
374
|
-
} finally {
|
|
375
|
-
this.options.panelState.statusMessage = undefined;
|
|
376
|
-
this.options.onUpdate();
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
private async startRound(signal?: AbortSignal) {
|
|
381
|
-
const currentRound = this.state.currentRound;
|
|
382
|
-
const roundAspects = Object.values(this.state.aspects).filter(a => a.id.startsWith(`${currentRound}.`));
|
|
383
|
-
const runningAspects = roundAspects.filter(a => a.status === 'running');
|
|
384
|
-
const pendingAspects = roundAspects.filter(a => a.status === 'pending');
|
|
385
|
-
|
|
386
|
-
if (pendingAspects.length > 0) {
|
|
387
|
-
// TUI Optimization: If we are starting a fresh round (generation 1),
|
|
388
|
-
// remove slices from previous rounds to prevent horizontal crowding.
|
|
389
|
-
const isFirstGenOfRound = roundAspects.length === pendingAspects.length;
|
|
390
|
-
if (isFirstGenOfRound && currentRound > 1) {
|
|
391
|
-
const prevRoundPrefix = `${currentRound - 1}.`;
|
|
392
|
-
const prevCoordId = `coord.${currentRound - 1}`;
|
|
393
|
-
const toRemove = Array.from(this.options.panelState.slices.keys())
|
|
394
|
-
.filter(id => id.startsWith(prevRoundPrefix) || id === prevCoordId);
|
|
395
|
-
for (const id of toRemove) {
|
|
396
|
-
removeSlice(this.options.panelState, id);
|
|
242
|
+
|
|
243
|
+
if (plan.action === 'delegate') {
|
|
244
|
+
if (this.currentRound === 1) {
|
|
245
|
+
observer?.onPlanningSuccess?.(plan);
|
|
246
|
+
} else if (this.currentRound < maxRounds) {
|
|
247
|
+
// Only announce a delegation that will actually run. At the round cap
|
|
248
|
+
// the loop breaks to synthesis below regardless of this decision, so
|
|
249
|
+
// emitting it here would grow the progress bar's expected units for
|
|
250
|
+
// researchers that never execute — the bar then visibly regresses.
|
|
251
|
+
observer?.onEvaluationDecision?.('delegate', plan, this.currentRound);
|
|
252
|
+
}
|
|
397
253
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
for (let i = 0; i < toLaunch.length; i++) {
|
|
408
|
-
if (i > 0) await new Promise(resolve => setTimeout(resolve, RESEARCHER_LAUNCH_DELAY_MS));
|
|
409
|
-
if (signal?.aborted) break;
|
|
410
|
-
this.executeSibling(toLaunch[i]!, signal);
|
|
254
|
+
|
|
255
|
+
if (plan.action === 'synthesize' || this.currentRound >= maxRounds) {
|
|
256
|
+
logger.log(`[DeepOrchestrator] Synthesis phase reached at Round ${this.currentRound} ${this.elapsed()}`);
|
|
257
|
+
if (plan.action === 'synthesize') {
|
|
258
|
+
loopSynthesisPlan = plan;
|
|
259
|
+
observer?.onEvaluationDecision?.('synthesize', plan, this.currentRound);
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
411
262
|
}
|
|
412
|
-
}
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (runningAspects.length > 0) {
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
263
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
264
|
+
if (plan.action === 'wait') {
|
|
265
|
+
waitRetryCount++;
|
|
266
|
+
observer?.onPlanningProgress?.('planning');
|
|
267
|
+
if (waitRetryCount > MAX_WAIT_RETRIES) {
|
|
268
|
+
// Don't throw away everything gathered so far. Break to final synthesis
|
|
269
|
+
// (mustSynthesize) so the user still gets a report from collected reports
|
|
270
|
+
// instead of a hard error that discards the whole run.
|
|
271
|
+
logger.warn(`[DeepOrchestrator] Max wait retries (${MAX_WAIT_RETRIES}) exceeded at Round ${this.currentRound}; synthesizing from collected reports`);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
logger.debug(`[DeepOrchestrator] AI requested wait, retrying Round ${this.currentRound} in 5s (retry ${waitRetryCount}/${MAX_WAIT_RETRIES})...`);
|
|
275
|
+
|
|
276
|
+
// Abortable sleep
|
|
277
|
+
if (signal?.aborted) {
|
|
278
|
+
throw new Error('Research cancelled');
|
|
279
|
+
}
|
|
280
|
+
await new Promise<void>((resolve, reject) => {
|
|
281
|
+
const onAbort = () => {
|
|
282
|
+
clearTimeout(timeout);
|
|
283
|
+
reject(new Error('Research cancelled'));
|
|
284
|
+
};
|
|
285
|
+
const timeout = setTimeout(() => {
|
|
286
|
+
signal?.removeEventListener('abort', onAbort);
|
|
287
|
+
resolve();
|
|
288
|
+
}, 5000);
|
|
289
|
+
safeUnref(timeout);
|
|
290
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
this.currentRound--; // Retry this round
|
|
294
|
+
continue;
|
|
428
295
|
}
|
|
429
|
-
this.runCoordinatorEval(signal);
|
|
430
|
-
return;
|
|
431
|
-
} else if (this.state.promotedId) {
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (this.state.status !== 'completed') {
|
|
437
|
-
if (this.state.finalSynthesis) {
|
|
438
|
-
this.resolveResult(this.state.finalSynthesis);
|
|
439
|
-
} else {
|
|
440
|
-
this.state.status = 'completed';
|
|
441
|
-
this.resolveResult('Research ended.');
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
private async executeSibling(aspect: ResearchSibling, signal?: AbortSignal) {
|
|
447
|
-
this.updateState({ type: 'SIBLING_STARTED', id: aspect.id });
|
|
448
|
-
|
|
449
|
-
const displayNum = getDisplayNumber(this.state, aspect.id);
|
|
450
|
-
const roleContext = getResearcherRoleContext(this.state, aspect.id);
|
|
451
296
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
this.options.onUpdate();
|
|
297
|
+
// Reset wait retry counter on successful actions
|
|
298
|
+
waitRetryCount = 0;
|
|
455
299
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
+ '\n\n' + roleContextMarkdown
|
|
462
|
-
+ '\n\n' + allFindingsMarkdown
|
|
463
|
-
+ '\n\n' + sharedLinksMarkdown;
|
|
464
|
-
|
|
465
|
-
const initialPromptEstimate = Math.ceil(researcherPrompt.length / 4);
|
|
466
|
-
this.siblingTokens.set(aspect.id, initialPromptEstimate);
|
|
467
|
-
const getTokensUsed = () => this.siblingTokens.get(aspect.id) ?? 0;
|
|
468
|
-
|
|
469
|
-
updateSliceTokens(this.options.panelState, aspect.id, initialPromptEstimate, 0);
|
|
470
|
-
this.options.onUpdate();
|
|
300
|
+
// Track how many researchers were planned this round
|
|
301
|
+
// updatePlanForRound has a comment "We'll update totalResearchersPlanned in the orchestrator"
|
|
302
|
+
if (plan.action === 'delegate' && plan.researchers && plan.researchers.length > 0) {
|
|
303
|
+
planningService.incrementTotalResearchersPlanned(researchId, plan.researchers.length);
|
|
304
|
+
}
|
|
471
305
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
if (tokens > 0) {
|
|
499
|
-
if (event.message?.role === 'assistant') {
|
|
500
|
-
this.siblingTokens.set(aspect.id, tokens);
|
|
501
|
-
this.options.onTokens(tokens);
|
|
502
|
-
updateSliceTokens(this.options.panelState, aspect.id, tokens, cost);
|
|
503
|
-
this.updateState({ type: 'SIBLING_TOKENS', id: aspect.id, tokens, cost });
|
|
504
|
-
this.options.onUpdate();
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
} else if (event.type === 'message_update') {
|
|
508
|
-
// Estimate context growth from streaming text length during model generation.
|
|
509
|
-
// Cost is NOT updated here (requires exact usage from message_end).
|
|
510
|
-
// Tokens are SET (not accumulated), so we pass cost=0 to leave cost unchanged.
|
|
511
|
-
const updateMsg = event.message;
|
|
512
|
-
if (updateMsg?.role === 'assistant') {
|
|
513
|
-
// Content may be in the message object but not typed in ExtendedAgentSessionEvent
|
|
514
|
-
const content = (updateMsg as any).content;
|
|
515
|
-
if (Array.isArray(content)) {
|
|
516
|
-
const textLen = content
|
|
517
|
-
.filter(isTextContentBlock)
|
|
518
|
-
.reduce((sum, b) => sum + b.text.length, 0);
|
|
519
|
-
if (textLen > STREAMING_UPDATE_THRESHOLD_CHARS) {
|
|
520
|
-
const base = this.siblingTokens.get(aspect.id) ?? initialPromptEstimate;
|
|
521
|
-
updateSliceTokens(this.options.panelState, aspect.id, base + Math.ceil(textLen / 4), 0);
|
|
522
|
-
this.options.onUpdate();
|
|
306
|
+
// Record all queries used this round for cross-round deduplication
|
|
307
|
+
if (plan.allQueries && plan.allQueries.length > 0) {
|
|
308
|
+
planningService.addToQueryHistory(researchId, plan.allQueries);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 2. Search Phase + per-researcher store queries (run in parallel)
|
|
312
|
+
let researcherLinks: Map<string, string[]> | undefined;
|
|
313
|
+
let storeLinks: Map<string, { url: string; description: string }[]> | undefined;
|
|
314
|
+
|
|
315
|
+
const searchTask = (plan.allQueries && plan.allQueries.length > 0)
|
|
316
|
+
? (async () => {
|
|
317
|
+
observer?.onSearchStart?.(plan.allQueries!);
|
|
318
|
+
const results = await orchestrationService.runSearchBurst(plan.allQueries!, this.config, signal, (links: number) => {
|
|
319
|
+
observer?.onSearchProgress?.(links);
|
|
320
|
+
}, ctx);
|
|
321
|
+
observer?.onSearchComplete?.(results.reduce((sum, r) => sum + (r.results?.length || 0), 0));
|
|
322
|
+
researcherLinks = await orchestrationService.distributeSearchResults(plan, results);
|
|
323
|
+
|
|
324
|
+
// Merge initialLinks into Round 1 researchers if provided
|
|
325
|
+
if (this.currentRound === 1 && this.options.initialLinks && this.options.initialLinks.length > 0) {
|
|
326
|
+
if (!researcherLinks) researcherLinks = new Map();
|
|
327
|
+
for (const researcher of plan.researchers || []) {
|
|
328
|
+
const id = String(researcher.id);
|
|
329
|
+
const existing = researcherLinks.get(id) || [];
|
|
330
|
+
researcherLinks.set(id, [...new Set([...existing, ...this.options.initialLinks!])]);
|
|
331
|
+
}
|
|
523
332
|
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
333
|
+
})()
|
|
334
|
+
: (async () => {
|
|
335
|
+
// Even if no queries were generated, if we have initialLinks in Round 1, distribute them
|
|
336
|
+
if (this.currentRound === 1 && this.options.initialLinks && this.options.initialLinks.length > 0) {
|
|
337
|
+
researcherLinks = new Map();
|
|
338
|
+
for (const researcher of plan.researchers || []) {
|
|
339
|
+
researcherLinks.set(String(researcher.id), [...this.options.initialLinks!]);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})();
|
|
343
|
+
|
|
344
|
+
const storeTask = (this.config.KNOWLEDGE_STORE_MODE !== 'none' && plan.researchers && plan.researchers.length > 0)
|
|
345
|
+
? (async () => {
|
|
346
|
+
try {
|
|
347
|
+
const ksService = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
|
|
348
|
+
if (!ksService.isReady()) {
|
|
349
|
+
logger.debug('[DeepOrchestrator] Knowledge store service not ready, skipping per-researcher store queries');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const store = await ksService.getStore();
|
|
353
|
+
if (store && typeof store.findRelevantUrls === 'function') {
|
|
354
|
+
const storeMap = new Map<string, { url: string; description: string }[]>();
|
|
355
|
+
await Promise.all((plan.researchers ?? []).map(async (researcher) => {
|
|
356
|
+
const entries = await store.findRelevantUrls(researcher.goal, { limit: 4 });
|
|
357
|
+
if (entries.length > 0) {
|
|
358
|
+
storeMap.set(String(researcher.id), entries);
|
|
359
|
+
logger.debug(`[DeepOrchestrator] Researcher ${researcher.id}: ${entries.length} store URL(s) from goal query`);
|
|
360
|
+
}
|
|
361
|
+
}));
|
|
362
|
+
if (storeMap.size > 0) storeLinks = storeMap;
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
logger.warn('[DeepOrchestrator] Per-researcher store queries failed (non-fatal):', err);
|
|
366
|
+
}
|
|
367
|
+
})()
|
|
368
|
+
: Promise.resolve();
|
|
369
|
+
|
|
370
|
+
await Promise.all([searchTask, storeTask]);
|
|
371
|
+
|
|
372
|
+
// 3. Researcher Phase
|
|
373
|
+
if (plan.researchers && plan.researchers.length > 0) {
|
|
374
|
+
// Pass this.config (the resolved Config) rather than this.options (where
|
|
375
|
+
// options.config may be undefined). runResearcher accesses researchConfig.RESEARCHER_MAX_RETRIES
|
|
376
|
+
// and similar fields — passing undefined crashes immediately.
|
|
377
|
+
// Default excludeTools to ['grep'] if not explicitly set — mirrors QuickResearchOrchestrator
|
|
378
|
+
// so that high-capability models don't waste time on developer-only tools.
|
|
379
|
+
await orchestrationService.runResearchers({
|
|
380
|
+
plan,
|
|
381
|
+
options: {
|
|
382
|
+
...this.options,
|
|
383
|
+
config: this.config,
|
|
384
|
+
excludeTools: this.options.excludeTools || ['grep'],
|
|
385
|
+
observer: this.observer,
|
|
386
|
+
},
|
|
387
|
+
currentRound: this.currentRound,
|
|
388
|
+
signal
|
|
389
|
+
}, researcherLinks, storeLinks);
|
|
541
390
|
}
|
|
542
|
-
});
|
|
543
391
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
392
|
+
// Esc/abort responsiveness: runResearchers returns NORMALLY when the signal
|
|
393
|
+
// fires (researchers are individually aborted), so without this check a
|
|
394
|
+
// cancelled run would still perform the full post-round embedding pass
|
|
395
|
+
// (storeLinkDescriptions → writer.drain) before the loop-top guard throws.
|
|
396
|
+
// Throw here instead — the catch below returns the partial fallback report.
|
|
397
|
+
// Skipping the store pass is safe: nothing has been enqueued for this round
|
|
398
|
+
// yet, and the FTS rebuild is an optimization pass (see cleanup below), not
|
|
399
|
+
// required for durability of previously drained rows.
|
|
400
|
+
if (signal?.aborted) throw new Error('Research aborted');
|
|
401
|
+
|
|
402
|
+
// 4. Store synthesized descriptions for semantic search
|
|
403
|
+
// Show embedding indicator in eval box while embedding runs
|
|
404
|
+
observer?.onEvaluationProgress?.('embedding');
|
|
405
|
+
await orchestrationService.storeLinkDescriptions(researchId, this.currentRound, researchId, this.config, ctx);
|
|
406
|
+
|
|
407
|
+
// 5. Evaluation Phase
|
|
408
|
+
observer?.onEvaluationProgress?.('evaluating');
|
|
551
409
|
}
|
|
552
410
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
this.progressCredits.set(aspect.id, DeepResearchOrchestrator.UNITS_PER_RESEARCHER);
|
|
569
|
-
}
|
|
411
|
+
// Final Synthesis — reuse the plan from the loop if the evaluator already
|
|
412
|
+
// decided to synthesize (avoids a second full LLM call). Only call the
|
|
413
|
+
// evaluator again when the loop ended because maxRounds was hit without
|
|
414
|
+
// the evaluator explicitly choosing to synthesize.
|
|
415
|
+
logger.log(`[DeepOrchestrator] Final synthesis ${this.elapsed()}`);
|
|
416
|
+
// Only spin up the evaluation-phase UI when we still have to CALL the evaluator
|
|
417
|
+
// (loop ended on maxRounds). If the evaluator already chose to synthesize inside
|
|
418
|
+
// the loop, its eval slice was completed at that point (onEvaluationDecision) and
|
|
419
|
+
// its decision is final — re-adding a fresh 'eval' box here would leave a
|
|
420
|
+
// perpetually-"evaluating" slice that nothing ever completes, and re-emit
|
|
421
|
+
// start/progress after the decision. Symmetric with the decision guard below.
|
|
422
|
+
if (loopSynthesisPlan === null) {
|
|
423
|
+
observer?.onRoundStart?.(maxRounds + 1); // Progress indicator for synthesis
|
|
424
|
+
observer?.onEvaluationStart?.(maxRounds);
|
|
425
|
+
observer?.onEvaluationProgress?.('evaluating');
|
|
570
426
|
}
|
|
571
|
-
this.options.onUpdate();
|
|
572
427
|
|
|
573
|
-
|
|
574
|
-
|
|
428
|
+
// Final synthesis is beginning: flip steering OFF *before* the final drain
|
|
429
|
+
// below. Ordering matters — there is no next round, so a message queued after
|
|
430
|
+
// the drain would show the affirmative "will steer the next research round"
|
|
431
|
+
// toast and then be destroyed at teardown. With the flag off first (this and
|
|
432
|
+
// the consume run synchronously — no interleaving window), any later steer
|
|
433
|
+
// takes the input handler's fall-through path (forwarded to pi, accurate
|
|
434
|
+
// toast). On the evaluator-chose-synthesize path this is idempotent: the
|
|
435
|
+
// observer already flipped the flag in onEvaluationDecision('synthesize').
|
|
436
|
+
// NOTE: this must also come after the onEvaluationStart above, which re-sets
|
|
437
|
+
// the flag to true (correct for in-loop evaluations, wrong for this final one).
|
|
438
|
+
observer?.onSynthesisStart?.();
|
|
439
|
+
|
|
440
|
+
// One final check for new steering messages before synthesis. Done for BOTH
|
|
441
|
+
// exit paths: a message arriving after the evaluator chose 'synthesize' (the
|
|
442
|
+
// loopSynthesisPlan path) was previously never consumed nor appended — fully
|
|
443
|
+
// dropped. Consuming here marks it active so appendSteeringGuidance includes it.
|
|
444
|
+
consumeQueuedMessages(this.options.sessionId);
|
|
445
|
+
const finalSteeringTexts = getSteeringMessages(this.options.sessionId).map(m => m.text);
|
|
446
|
+
|
|
447
|
+
let finalReport: ResearchPlan;
|
|
448
|
+
if (loopSynthesisPlan !== null) {
|
|
449
|
+
finalReport = loopSynthesisPlan;
|
|
575
450
|
} else {
|
|
576
|
-
|
|
451
|
+
const synthesisServiceFinal = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
452
|
+
finalReport = await planningService.updatePlanForRound({
|
|
453
|
+
sessionId: researchId,
|
|
454
|
+
query: query,
|
|
455
|
+
complexity,
|
|
456
|
+
round: maxRounds,
|
|
457
|
+
model: coordinatorModel,
|
|
458
|
+
modelRegistry: ctx.modelRegistry,
|
|
459
|
+
cwd: ctx.cwd,
|
|
460
|
+
config: this.config,
|
|
461
|
+
reports: synthesisServiceFinal.getAllReports(researchId),
|
|
462
|
+
previousPlan: planningService.getCurrentPlan(researchId),
|
|
463
|
+
totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
|
|
464
|
+
mustSynthesize: true,
|
|
465
|
+
signal,
|
|
466
|
+
observer,
|
|
467
|
+
excludeTools: this.options.excludeTools,
|
|
468
|
+
steeringMessages: finalSteeringTexts,
|
|
469
|
+
});
|
|
577
470
|
}
|
|
578
471
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
completeSlice(this.options.panelState, aspect.id);
|
|
586
|
-
|
|
587
|
-
if (this.options.panelState.progress) {
|
|
588
|
-
const alreadyCredited = this.progressCredits.get(aspect.id) ?? 0;
|
|
589
|
-
const topUp = DeepResearchOrchestrator.UNITS_PER_RESEARCHER - alreadyCredited;
|
|
590
|
-
if (topUp > 0) {
|
|
591
|
-
this.options.panelState.progress.made += topUp;
|
|
592
|
-
this.progressCredits.set(aspect.id, DeepResearchOrchestrator.UNITS_PER_RESEARCHER);
|
|
593
|
-
}
|
|
472
|
+
// Only emit the synthesis decision here for the FORCED-synthesis path. When the
|
|
473
|
+
// evaluator itself chose to synthesize inside the loop (loopSynthesisPlan set),
|
|
474
|
+
// onEvaluationDecision('synthesize', ...) was already fired at that point; emitting
|
|
475
|
+
// again would double-count synthesis for observers (e.g. the TUI progress bar).
|
|
476
|
+
if (loopSynthesisPlan === null) {
|
|
477
|
+
observer?.onEvaluationDecision?.('synthesize', finalReport, maxRounds);
|
|
594
478
|
}
|
|
595
|
-
this.options.onUpdate();
|
|
596
479
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
480
|
+
let result = finalReport.content || '';
|
|
481
|
+
|
|
482
|
+
// If content is empty but we have reports, build a fallback synthesis
|
|
483
|
+
if (!result.trim()) {
|
|
484
|
+
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
485
|
+
if (synthesisService.hasReports(researchId)) {
|
|
486
|
+
logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService.getReportCount(researchId)} reports`);
|
|
487
|
+
result = synthesisService.buildFallbackSynthesis(researchId, this.currentRound);
|
|
488
|
+
} else {
|
|
489
|
+
result = 'Research completed but no summary was generated.';
|
|
490
|
+
}
|
|
600
491
|
}
|
|
601
|
-
} finally {
|
|
602
|
-
this.activeSessions.delete(aspect.id);
|
|
603
|
-
this.startRound(signal).catch(() => {});
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
492
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const targetSession = this.activeSessions.get(target.id);
|
|
615
|
-
if (targetSession) {
|
|
616
|
-
const finishedDisplayNum = getDisplayNumber(this.state, finished.id);
|
|
617
|
-
const truncatedReport = truncatePreservingLinks(finished.report, MAX_EVALUATOR_REPORT_LENGTH);
|
|
618
|
-
const injectionMessage = `## UPDATE: Sibling ${finishedDisplayNum} Completed Research\n\n${truncatedReport}`;
|
|
619
|
-
try {
|
|
620
|
-
await targetSession.steer(injectionMessage);
|
|
621
|
-
} catch (err) {
|
|
622
|
-
logger.warn(`[deep-research] Failed to inject findings from ${finished.id} into ${target.id}:`, err);
|
|
493
|
+
// Guard: if the LLM returned the full JSON envelope as its response text and JSON
|
|
494
|
+
// parsing failed upstream, finalReport.content may be the raw JSON string rather
|
|
495
|
+
// than the extracted markdown. Unwrap it here so docs never show the raw JSON.
|
|
496
|
+
try {
|
|
497
|
+
const parsed: unknown = JSON.parse(result);
|
|
498
|
+
if (parsed && typeof parsed === 'object' && typeof (parsed as Record<string, unknown>)['content'] === 'string') {
|
|
499
|
+
result = (parsed as Record<string, unknown>)['content'] as string;
|
|
623
500
|
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
private async handleSiblingCompletion(_finished: ResearchSibling, signal?: AbortSignal) {
|
|
629
|
-
if (signal?.aborted) return;
|
|
630
|
-
if (this.state.promotedId || this.state.finalSynthesis) return;
|
|
631
|
-
|
|
632
|
-
const currentRound = this.state.currentRound;
|
|
633
|
-
const roundAspects = Object.values(this.state.aspects).filter(a => a.id.startsWith(`${currentRound}.`));
|
|
634
|
-
const allCompletedWithReports = roundAspects.filter(a => a.status === 'completed' && a.report);
|
|
635
|
-
|
|
636
|
-
if (allCompletedWithReports.length === 0) {
|
|
637
|
-
const allFailed = roundAspects.filter(a => a.status === 'failed');
|
|
638
|
-
const errors = allFailed.map(a => `• Researcher ${getDisplayNumber(this.state, a.id)}: ${a.error}`).join('\n');
|
|
639
|
-
this.rejectCompletion(new Error(`Research failed. All researchers encountered errors:\n\n${errors}`));
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
await this.runCoordinatorEval(signal);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
private async runCoordinatorEval(signal?: AbortSignal) {
|
|
647
|
-
const evaluatedRound = this.state.currentRound;
|
|
648
|
-
const coordId = `coord.${evaluatedRound}`;
|
|
649
|
-
|
|
650
|
-
// Add evaluator slice AFTER all previous round slices are complete (grey)
|
|
651
|
-
// The evaluator does not count toward the 3 max concurrent researchers limit
|
|
652
|
-
addSlice(this.options.panelState, coordId, 'Eval', false);
|
|
653
|
-
activateSlice(this.options.panelState, coordId);
|
|
654
|
-
this.options.onUpdate();
|
|
655
|
-
|
|
656
|
-
// An agenda item leaves remainingAgenda once assigned to a non-failed aspect.
|
|
657
|
-
// - pending/running/completed: considered in-progress or done — drop from list
|
|
658
|
-
// - failed: NOT excluded — evaluator must be able to re-delegate failed topics
|
|
659
|
-
// This also fixes orphaned items (beyond initialCount): they remain visible until
|
|
660
|
-
// the evaluator delegates them as exact-string queries, at which point they become
|
|
661
|
-
// pending aspects and drop off.
|
|
662
|
-
const assignedAspectQueries = new Set(
|
|
663
|
-
Object.values(this.state.aspects)
|
|
664
|
-
.filter(a => a.status !== 'failed')
|
|
665
|
-
.map(a => a.query)
|
|
666
|
-
);
|
|
667
|
-
const remainingAgenda = this.state.initialAgenda.filter(q => !assignedAspectQueries.has(q));
|
|
668
|
-
const targetRounds = this.getMaxRounds(this.state.complexity);
|
|
669
|
-
const hardLimit = targetRounds + MAX_EXTRA_ROUNDS;
|
|
670
|
-
const isAtTarget = this.state.currentRound >= targetRounds;
|
|
671
|
-
const isAtHardLimit = this.state.currentRound >= hardLimit;
|
|
672
|
-
|
|
673
|
-
// Provide ALL completed findings (including the lead's own) — fresh context via complete()
|
|
674
|
-
// This includes ALL researchers from ALL rounds that have completed with reports
|
|
675
|
-
const allCompleted = Object.values(this.state.aspects).filter(a => a.status === 'completed' && a.report);
|
|
676
|
-
|
|
677
|
-
logger.log(`[deep-research] Evaluator Round ${evaluatedRound}: Analyzing ${allCompleted.length} completed researchers from all rounds`);
|
|
678
|
-
|
|
679
|
-
const allReportsContext = allCompleted.length > 0
|
|
680
|
-
? `## All Research Findings:\n\n` +
|
|
681
|
-
allCompleted.map(a => {
|
|
682
|
-
const report = a.report || '';
|
|
683
|
-
const truncated = truncatePreservingLinks(report, MAX_EVALUATOR_REPORT_LENGTH);
|
|
684
|
-
const roundNum = a.id.split('.')[0] ?? '?';
|
|
685
|
-
return `### Researcher ${getDisplayNumber(this.state, a.id)} (Round ${roundNum}): ${a.query}\n${truncated}`;
|
|
686
|
-
}).join('\n\n---\n\n')
|
|
687
|
-
: '';
|
|
688
|
-
|
|
689
|
-
const leadPromptRaw = readFileSync(join(__dirname, '..', 'prompts', 'system-lead-evaluator.md'), 'utf-8');
|
|
690
|
-
const originalAgendaStr = this.state.initialAgenda.map(q => `• ${q}`).join('\n');
|
|
691
|
-
let leadPrompt = leadPromptRaw
|
|
692
|
-
.replace('{ROOT_QUERY}', this.state.rootQuery)
|
|
693
|
-
.replace('{ORIGINAL_AGENDA}', originalAgendaStr)
|
|
694
|
-
.replace('{ROUND_NUMBER}', this.state.currentRound.toString())
|
|
695
|
-
.replace('{MAX_ROUNDS}', targetRounds.toString());
|
|
696
|
-
|
|
697
|
-
if (isAtHardLimit) {
|
|
698
|
-
leadPrompt += '\n\n⚠️ **CRITICAL: ABSOLUTE MAXIMUM REACHED.** You MUST provide a FINAL SYNTHESIS in Markdown format. Do NOT return a JSON delegation object.';
|
|
699
|
-
} else if (isAtTarget) {
|
|
700
|
-
leadPrompt += '\n\n⚠️ **NOTICE: TARGET DEPTH REACHED.** Synthesize if the agenda is substantially covered. Only delegate if CRITICAL GAPS remain unexplored.';
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
const agendaSection = remainingAgenda.length > 0
|
|
704
|
-
? `\n### Unfulfilled Agenda Items\n${remainingAgenda.map(q => `- ${q}`).join('\n')}`
|
|
705
|
-
: '\n### All Agenda Items Covered\nAll initial research agenda items have been addressed.';
|
|
501
|
+
} catch { /* result is not JSON — use as-is */ }
|
|
706
502
|
|
|
707
|
-
|
|
503
|
+
// Final post-processing: Ensure CITED LINKS section is accurate and consistent
|
|
504
|
+
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
505
|
+
result = synthesisService.ensureCitedLinks(researchId, result);
|
|
708
506
|
|
|
709
|
-
|
|
710
|
-
|
|
507
|
+
// Append steering guidance — all consumed messages throughout the session
|
|
508
|
+
const finalSteeringMessages = getActiveSteeringMessages(this.options.sessionId);
|
|
509
|
+
if (finalSteeringMessages.length > 0) {
|
|
510
|
+
logger.debug(`[DeepOrchestrator] Appending guidance from ${finalSteeringMessages.length} steering messages to final report`);
|
|
511
|
+
result = synthesisService.appendSteeringGuidance(result, finalSteeringMessages);
|
|
512
|
+
}
|
|
711
513
|
|
|
712
|
-
|
|
713
|
-
|
|
514
|
+
// NOTE: Model metadata (appendMetadata) is now applied at the very end
|
|
515
|
+
// of the result chain in research-tool-definition.ts (or sdk.ts for SDK
|
|
516
|
+
// users) so it always appears after metrics/summaries.
|
|
714
517
|
|
|
715
|
-
const
|
|
716
|
-
|
|
518
|
+
const sessionDuration = Date.now() - this.sessionStart;
|
|
519
|
+
metrics.observe('research_session_duration_ms', sessionDuration, { mode: 'deep', complexity: String(complexity), status: 'success' });
|
|
520
|
+
|
|
521
|
+
// Fire onComplete observer event. A throwing user observer must NOT divert
|
|
522
|
+
// control into the catch below — that would fire onComplete AGAIN with a
|
|
523
|
+
// fallback payload (double terminal callback) AND silently replace the
|
|
524
|
+
// full report with the fallback synthesis. Isolate it, mirroring the
|
|
525
|
+
// researcher-executor's safeObserve pattern.
|
|
717
526
|
try {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
} finally {
|
|
722
|
-
clearInterval(heartbeat);
|
|
527
|
+
observer?.onComplete?.(result);
|
|
528
|
+
} catch (obsErr) {
|
|
529
|
+
logger.debug('[DeepOrchestrator] onComplete observer threw:', obsErr);
|
|
723
530
|
}
|
|
724
531
|
|
|
725
|
-
|
|
726
|
-
.filter((c): c is TextContentBlock => isTextContentBlock(c))
|
|
727
|
-
.map(c => c.text)
|
|
728
|
-
.join('');
|
|
729
|
-
const usage = parseTokenUsage(response.usage);
|
|
730
|
-
const tokens = calculateTotalTokens(usage);
|
|
731
|
-
const cost = this.calculateUsageCost(usage);
|
|
732
|
-
if (tokens > 0) {
|
|
733
|
-
this.options.onTokens(tokens);
|
|
734
|
-
updateSliceTokens(this.options.panelState, coordId, tokens, cost);
|
|
735
|
-
}
|
|
532
|
+
return result;
|
|
736
533
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
finalSynthesis: evalDecision.action === 'synthesize' ? evalDecision.content : undefined,
|
|
748
|
-
nextQueries: evalDecision.action === 'delegate' ? evalDecision.queries : [],
|
|
749
|
-
maxRounds: hardLimit
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
// Credit the evaluator work using the round number captured BEFORE the state transition
|
|
753
|
-
if (this.options.panelState.progress) {
|
|
754
|
-
const evalKey = `eval.${evaluatedRound}`;
|
|
755
|
-
if (!this.progressCredits.has(evalKey)) {
|
|
756
|
-
this.options.panelState.progress.made += DeepResearchOrchestrator.LEAD_EVAL_UNITS;
|
|
757
|
-
this.progressCredits.set(evalKey, DeepResearchOrchestrator.LEAD_EVAL_UNITS);
|
|
758
|
-
}
|
|
759
|
-
// Extend the progress budget if the evaluator spawned a new round
|
|
760
|
-
if (evalDecision.action === 'delegate' && evalDecision.queries.length > 0) {
|
|
761
|
-
this.options.panelState.progress.expected +=
|
|
762
|
-
evalDecision.queries.length * DeepResearchOrchestrator.UNITS_PER_RESEARCHER
|
|
763
|
-
+ DeepResearchOrchestrator.LEAD_EVAL_UNITS;
|
|
764
|
-
}
|
|
534
|
+
} catch (error) {
|
|
535
|
+
const sessionDuration = Date.now() - this.sessionStart;
|
|
536
|
+
const aborted = signal?.aborted === true;
|
|
537
|
+
metrics.observe('research_session_duration_ms', sessionDuration, { mode: 'deep', complexity: String(complexity), status: aborted ? 'cancelled' : 'error' });
|
|
538
|
+
// A user cancellation is a clean stop, not an error — log it at debug so a
|
|
539
|
+
// quit-mid-run doesn't surface a red error line that reads like a failure.
|
|
540
|
+
if (aborted) {
|
|
541
|
+
logger.debug(`[DeepOrchestrator] Research cancelled: ${error instanceof Error ? error.message : String(error)}`);
|
|
542
|
+
} else {
|
|
543
|
+
logger.error(`[DeepOrchestrator] Research failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
765
544
|
}
|
|
766
545
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
this.
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
546
|
+
// Terminal-callback contract (shared with QuickResearchOrchestrator): fire
|
|
547
|
+
// EXACTLY ONE of onComplete / onError. A returned report — full above, or a
|
|
548
|
+
// partial fallback synthesised from whatever was collected (the natural
|
|
549
|
+
// outcome of a mid-run cancel) — is a completion (onComplete). Only a run
|
|
550
|
+
// with nothing to return is a failure (onError + throw). Previously this
|
|
551
|
+
// fired onError AND THEN onComplete on the fallback path, signalling both
|
|
552
|
+
// "errored" and "completed" for one run.
|
|
553
|
+
try {
|
|
554
|
+
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
555
|
+
if (synthesisService.hasReports(researchId)) {
|
|
556
|
+
let fallback = synthesisService.buildFallbackSynthesis(researchId, this.currentRound);
|
|
557
|
+
fallback = synthesisService.ensureCitedLinks(researchId, fallback);
|
|
558
|
+
logger.log(`[DeepOrchestrator] Returning ${aborted ? 'partial (cancelled)' : 'fallback'} synthesis (${fallback.length} chars) from ${synthesisService.getReportCount(researchId)} collected reports`);
|
|
559
|
+
const finalSteeringMessages = getActiveSteeringMessages(this.options.sessionId);
|
|
560
|
+
const result = synthesisService.appendSteeringGuidance(fallback, finalSteeringMessages);
|
|
561
|
+
try {
|
|
562
|
+
observer?.onComplete?.(result);
|
|
563
|
+
} catch (obsErr) {
|
|
564
|
+
logger.debug('[DeepOrchestrator] fallback onComplete observer threw:', obsErr);
|
|
565
|
+
}
|
|
566
|
+
return result;
|
|
784
567
|
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// Enforce completion by falling back to concatenated researcher findings.
|
|
788
|
-
logger.warn('[deep-research] Evaluator returned delegation at hard limit — forcing synthesis from findings');
|
|
789
|
-
this.resolveResult(this.buildAllFindingsContext());
|
|
790
|
-
} else {
|
|
791
|
-
// Evaluator delegated more researchers - start next round
|
|
792
|
-
logger.log(`[deep-research] Evaluator chose DELEGATION - starting Round ${evaluatedRound + 1}`);
|
|
793
|
-
await this.startRound(signal);
|
|
568
|
+
} catch (fallbackErr) {
|
|
569
|
+
logger.warn(`[DeepOrchestrator] Fallback synthesis also failed:`, fallbackErr);
|
|
794
570
|
}
|
|
795
571
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
if (aRound !== bRound) return aRound - bRound;
|
|
821
|
-
const aNum = parseInt(a.id.split('.')[1] ?? '0');
|
|
822
|
-
const bNum = parseInt(b.id.split('.')[1] ?? '0');
|
|
823
|
-
return aNum - bNum;
|
|
824
|
-
});
|
|
825
|
-
|
|
826
|
-
if (allCompleted.length === 0) {
|
|
827
|
-
return '## Research Findings So Far\n\n*No earlier researchers have completed their findings yet.*';
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
let output = '## Research Findings So Far\n\n';
|
|
831
|
-
let currentRoundNum = 0;
|
|
832
|
-
|
|
833
|
-
for (const completed of allCompleted) {
|
|
834
|
-
const round = parseInt(completed.id.split('.')[0] ?? '0');
|
|
835
|
-
if (round !== currentRoundNum) {
|
|
836
|
-
currentRoundNum = round;
|
|
837
|
-
output += `\n### Round ${round}\n\n`;
|
|
572
|
+
// Nothing to return — this run genuinely failed (or was cancelled with no
|
|
573
|
+
// collected work). Fire onError and propagate, matching QuickOrchestrator.
|
|
574
|
+
try {
|
|
575
|
+
observer?.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
576
|
+
} catch (obsErr) {
|
|
577
|
+
logger.debug('[DeepOrchestrator] onError observer threw:', obsErr);
|
|
578
|
+
}
|
|
579
|
+
throw error;
|
|
580
|
+
} finally {
|
|
581
|
+
// Guard cleanup: getOrchestrationService()/cleanup can throw when the
|
|
582
|
+
// container is disposing (e.g. SIGTERM mid-run). An unguarded throw here
|
|
583
|
+
// would replace the in-flight error or discard a successful return value.
|
|
584
|
+
// Mirrors QuickResearchOrchestrator's guarded cleanup.
|
|
585
|
+
try {
|
|
586
|
+
const orch = await this.getOrchestrationService();
|
|
587
|
+
// On user abort, skip the post-run FTS rebuild + optimize: they are
|
|
588
|
+
// non-signal-aware optimization passes (durability lives in the LanceDB
|
|
589
|
+
// commits already made) and would block Esc from returning promptly.
|
|
590
|
+
// Any rows the run committed are picked up by the next run's start-of-run
|
|
591
|
+
// cleanup (which performs the same rebuild) or the next completed run.
|
|
592
|
+
await orch.cleanupResearchServices(undefined, researchId, ctx, this.config,
|
|
593
|
+
{ skipStoreMaintenance: signal?.aborted === true });
|
|
594
|
+
} catch (cleanupErr) {
|
|
595
|
+
logger.warn('[DeepOrchestrator] Failed to cleanup research services:', cleanupErr);
|
|
838
596
|
}
|
|
839
|
-
const displayNum = getDisplayNumber(this.state, completed.id);
|
|
840
|
-
const report = completed.report || '';
|
|
841
|
-
const truncatedReport = truncatePreservingLinks(report, MAX_EVALUATOR_REPORT_LENGTH);
|
|
842
|
-
|
|
843
|
-
output += `#### Researcher ${displayNum}: ${completed.query}\n\n${truncatedReport}\n\n`;
|
|
844
597
|
}
|
|
845
|
-
|
|
846
|
-
return output;
|
|
847
598
|
}
|
|
848
599
|
}
|