@lincoln504/pi-research 0.1.13 → 1.0.1

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.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -1,848 +1,599 @@
1
1
  /**
2
2
  * Deep Research Orchestrator
3
3
  *
4
- * Manages the decentralized deep research lifecycle:
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 { join } from 'node:path';
13
- import { readFileSync } from 'node:fs';
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 { ensureAssistantResponse } from '../utils/text-utils.ts';
46
- import { addSlice, activateSlice, completeSlice, removeSlice, flashSlice, updateSliceTokens, type ResearchPanelState } from '../tui/research-panel.ts';
47
- import { getDisplayNumber, getResearcherRoleContext } from './id-utils.ts';
48
-
49
- const __filename = fileURLToPath(import.meta.url);
50
- const __dirname = dirname(__filename);
51
-
52
- /**
53
- * Strict schema for evaluator delegation response
54
- */
55
- interface EvaluatorDelegateDecision {
56
- action: 'delegate';
57
- queries: string[];
58
- }
59
-
60
- /**
61
- * Strict schema for evaluator synthesis decision (implicit)
62
- * Synthesis is any non-delegation response, so no explicit schema needed.
63
- */
64
-
65
- type EvalDecision =
66
- | { action: 'delegate'; queries: string[] }
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
- onTokens: (n: number) => void;
165
- onUpdate: () => void;
166
- searxngUrl: string;
167
- panelState: ResearchPanelState;
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 state: SystemResearchState;
172
- private activeSessions = new Map<string, AgentSession>();
173
- private resolveCompletion: (result: string) => void = () => {};
174
- private rejectCompletion: (err: Error) => void = () => {};
175
- private completionResolved = false; // Track if we've already resolved to prevent duplicate resolutions
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.state = {
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 (this.options.panelState.progress) {
211
- // Snap progress to 100% to ensure clean completion
212
- this.options.panelState.progress.made = this.options.panelState.progress.expected;
213
- this.options.onUpdate();
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
- private updateState(event: Parameters<typeof deepResearchReducer>[1]) {
240
- this.state = deepResearchReducer(this.state, event);
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 calculateUsageCost(usage: Partial<import('../types/llm').TokenUsage>): number {
244
- if (!usage || !this.options.model?.cost) return 0;
245
- const modelCost = this.options.model.cost;
246
- const inputCost = (modelCost.input / 1_000_000) * (usage.input ?? 0);
247
- const outputCost = (modelCost.output / 1_000_000) * (usage.output ?? 0);
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 kickoff(signal?: AbortSignal) {
254
- logger.log(`[deep-research] Kickoff: status=${this.state.status}`);
255
- if (this.state.status === 'planning') await this.doPlanning(signal);
256
- if (this.state.status === 'researching') await this.startRound(signal);
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 getInitialSiblingCount(complexity: 1 | 2 | 3): number {
264
- switch(complexity) {
265
- case 1: return INITIAL_RESEARCHERS_LEVEL_1;
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
- private getMaxRounds(complexity: 1 | 2 | 3): number {
272
- switch(complexity) {
273
- case 1: return MAX_ROUNDS_LEVEL_1;
274
- case 2: return MAX_ROUNDS_LEVEL_2;
275
- case 3: return MAX_ROUNDS_LEVEL_3;
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
- private async doPlanning(signal?: AbortSignal) {
280
- this.options.panelState.statusMessage = 'planning...';
281
- this.options.onUpdate();
160
+ const MAX_WAIT_RETRIES = 5;
161
+ let waitRetryCount = 0;
162
+ let loopSynthesisPlan: ResearchPlan | null = null;
282
163
 
283
164
  try {
284
- // Get full parent conversation context for's coordinator (emulates fork mode)
285
- const branch = this.options.ctx.sessionManager.getBranch();
286
- const sessionContext = buildSessionContext(branch);
287
- const parentMessages = sessionContext.messages;
288
-
289
- logger.log(`[deep-research] Planning with ${parentMessages.length} parent messages (fork mode emulation)`);
290
-
291
- const coordinatorPromptRaw = readFileSync(join(__dirname, '..', 'prompts', 'system-coordinator.md'), 'utf-8');
292
- const plannerPrompt = coordinatorPromptRaw + `\n\nQUERY: ${this.state.rootQuery}`;
293
-
294
- const auth = await this.options.ctx.modelRegistry.getApiKeyAndHeaders(this.options.model);
295
- if (!auth.ok) throw new Error(`Failed to get API credentials: ${auth.error}`);
296
-
297
- // Convert AgentMessages to LLM-compatible Messages
298
- const llmMessages = await convertToLlm(parentMessages);
299
-
300
- const response = await complete(this.options.model, {
301
- messages: [
302
- // Include full parent conversation history (converted) + current query
303
- ...llmMessages,
304
- { role: 'user', content: [{ type: 'text', text: plannerPrompt }], timestamp: Date.now() }
305
- ]
306
- }, { apiKey: auth.apiKey!, headers: auth.headers, signal });
307
-
308
- const usage = response.usage;
309
- if (usage) {
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
- // Log any warnings
335
- if (normalized.warnings.length > 0) {
336
- logger.debug('[deep-research] Coordinator query normalization warnings:',
337
- normalized.warnings.join('; '));
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
- logger.debug('[deep-research] Could not parse agenda JSON:', result.error ?? 'unknown error');
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
- } catch (err) {
344
- logger.error('[deep-research] Failed to parse planning agenda JSON:', err);
345
- }
346
-
347
- if (agenda.length === 0) {
348
- agenda = [this.state.rootQuery];
349
- logger.warn('[deep-research] No valid agenda items found, using root query as fallback');
350
- }
351
-
352
- const initialCount = this.getInitialSiblingCount(this.state.complexity);
353
- logger.log(`[deep-research] Planning complete: ${agenda.length} agenda items. Initial count: ${initialCount}`);
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
- this.options.onUpdate();
399
- }
400
-
401
- const allowedToLaunch = Math.max(0, MAX_CONCURRENT_RESEARCHERS - runningAspects.length);
402
-
403
- if (allowedToLaunch > 0) {
404
- const toLaunch = pendingAspects.slice(0, allowedToLaunch);
405
- logger.log(`[deep-research] Round ${currentRound}: Launching ${toLaunch.length} siblings (Concurrency limit: ${MAX_CONCURRENT_RESEARCHERS}).`);
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
- if (roundAspects.length > 0 && isRoundComplete(this.state, currentRound)) {
421
- if (!this.state.promotedId && !this.state.finalSynthesis) {
422
- const allCompletedWithReports = roundAspects.filter(a => a.status === 'completed' && a.report);
423
- if (allCompletedWithReports.length === 0) {
424
- const allFailed = roundAspects.filter(a => a.status === 'failed');
425
- const errors = allFailed.map(a => `• Researcher ${getDisplayNumber(this.state, a.id)}: ${a.error}`).join('\n');
426
- this.rejectCompletion(new Error(`Research failed. All researchers encountered errors:\n\n${errors}`));
427
- return;
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
- addSlice(this.options.panelState, aspect.id, displayNum, false);
453
- activateSlice(this.options.panelState, aspect.id);
454
- this.options.onUpdate();
297
+ // Reset wait retry counter on successful actions
298
+ waitRetryCount = 0;
455
299
 
456
- const researcherPromptRaw = readFileSync(join(__dirname, '..', 'prompts', 'researcher.md'), 'utf-8');
457
- const sharedLinksMarkdown = formatSharedLinksFromState(this.state.aspects);
458
- const allFindingsMarkdown = this.buildAllFindingsContext();
459
- const roleContextMarkdown = this.buildRoleContext(aspect, roleContext);
460
- const researcherPrompt = injectCurrentDate(researcherPromptRaw, 'researcher')
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
- let session: AgentSession | undefined;
473
- try {
474
- session = await createResearcherSession({
475
- cwd: this.options.ctx.cwd,
476
- ctxModel: this.options.model,
477
- modelRegistry: this.options.ctx.modelRegistry,
478
- settingsManager: (this.options.ctx as any).settingsManager,
479
- systemPrompt: researcherPrompt,
480
- searxngUrl: this.options.searxngUrl,
481
- extensionCtx: this.options.ctx,
482
- getGlobalState: () => this.state,
483
- updateGlobalLinks: (links: string[]) => {
484
- this.updateState({ type: 'LINKS_SCRAPED', links });
485
- },
486
- getTokensUsed,
487
- contextWindowSize: this.contextWindowSize,
488
- });
489
-
490
- this.activeSessions.set(aspect.id, session);
491
-
492
- const subscription = session.subscribe((event: ExtendedAgentSessionEvent) => {
493
- if (event.type === 'message_end') {
494
- const usage = parseTokenUsage(event.message?.usage);
495
- const tokens = calculateTotalTokens(usage);
496
- const cost = this.calculateUsageCost(usage);
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
- } else if (event.type === 'tool_execution_end') {
527
- const isError = event.isError ?? false;
528
- const color = isError ? 'red' : 'green';
529
- const duration = isError ? 1000 : 500;
530
- flashSlice(this.options.panelState, aspect.id, color, duration, this.options.onUpdate);
531
-
532
- if (this.options.panelState.progress) {
533
- const current = this.progressCredits.get(aspect.id) ?? 0;
534
- if (current < 8) {
535
- const newVal = current + 1;
536
- this.progressCredits.set(aspect.id, newVal);
537
- this.options.panelState.progress.made += 1;
538
- this.options.onUpdate();
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
- const queryText = typeof aspect.query === 'string' ? aspect.query : String(aspect.query);
545
- await session.prompt(queryText);
546
-
547
- if (signal?.aborted) {
548
- if (typeof subscription === 'function') subscription();
549
- this.updateState({ type: 'SIBLING_FAILED', id: aspect.id, error: 'Aborted' });
550
- return;
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
- const report = ensureAssistantResponse(session, aspect.id);
554
- if (typeof subscription === 'function') subscription();
555
-
556
- this.updateState({ type: 'SIBLING_COMPLETED', id: aspect.id, report });
557
-
558
- const currentRound = this.state.currentRound;
559
- const isLastStanding = isRoundComplete(this.state, currentRound);
560
-
561
- completeSlice(this.options.panelState, aspect.id);
562
-
563
- if (this.options.panelState.progress) {
564
- const alreadyCredited = this.progressCredits.get(aspect.id) ?? 0;
565
- const topUp = DeepResearchOrchestrator.UNITS_PER_RESEARCHER - alreadyCredited;
566
- if (topUp > 0) {
567
- this.options.panelState.progress.made += topUp;
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
- if (isLastStanding) {
574
- await this.handleSiblingCompletion(aspect, signal);
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
- await this.injectFindingsIntoRunningSiblings(aspect);
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
- } catch (err) {
580
- if (signal?.aborted) return;
581
- const errorMsg = String(err);
582
- logger.error(`[deep-research] Sibling ${aspect.id} failed:`, err);
583
- this.updateState({ type: 'SIBLING_FAILED', id: aspect.id, error: errorMsg });
584
- flashSlice(this.options.panelState, aspect.id, 'red', 1000, this.options.onUpdate);
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
- const currentRound = this.state.currentRound;
598
- if (isRoundComplete(this.state, currentRound)) {
599
- await this.handleSiblingCompletion(aspect, signal);
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
- private async injectFindingsIntoRunningSiblings(finished: ResearchSibling) {
608
- if (!finished.report) return;
609
- const currentRound = this.state.currentRound;
610
- const allInRound = Object.values(this.state.aspects).filter(a => a.id.startsWith(`${currentRound}.`));
611
- const runningSiblings = allInRound.filter(s => s.status === 'running' && s.id !== finished.id);
612
-
613
- for (const target of runningSiblings) {
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
- const promotionPrompt = leadPrompt + '\n\n' + allReportsContext + agendaSection;
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
- try {
710
- this.updateState({ type: 'PROMOTION_STARTED', id: coordId });
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
- const auth = await this.options.ctx.modelRegistry.getApiKeyAndHeaders(this.options.model);
713
- if (!auth.ok) throw new Error(`Failed to get API credentials: ${auth.error}`);
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 heartbeat = setInterval(() => this.options.onUpdate(), 500);
716
- let response: Awaited<ReturnType<typeof complete>>;
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
- response = await complete(this.options.model, {
719
- messages: [{ role: 'user', content: [{ type: 'text', text: promotionPrompt }], timestamp: Date.now() }]
720
- }, { apiKey: auth.apiKey!, headers: auth.headers, signal });
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
- const decision = response.content
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
- const evalDecision = parseEvaluatorResponse(decision);
738
-
739
- logger.log(`[deep-research] Evaluator decision for Round ${evaluatedRound}: ${
740
- evalDecision.action === 'synthesize'
741
- ? 'SYNTHESIS - completing research'
742
- : `DELEGATION - spawning ${evalDecision.queries.length} new researchers in Round ${evaluatedRound + 1}`
743
- }`);
744
-
745
- this.updateState({
746
- type: 'PROMOTION_DECISION',
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
- completeSlice(this.options.panelState, coordId);
768
- this.options.onUpdate();
769
-
770
- // CRITICAL: Must trigger next step after evaluator completes
771
- // The evaluator does NOT run through executeSibling(), so there's no
772
- // finally block to call startRound(). We must call it explicitly.
773
-
774
- // Give state a moment to propagate the update
775
- await new Promise(resolve => setTimeout(resolve, STATE_PROPAGATION_DELAY_MS));
776
-
777
- if (evalDecision.action === 'synthesize') {
778
- logger.log('[deep-research] Evaluator chose SYNTHESIS - resolving research');
779
- if (evalDecision.content) {
780
- this.resolveResult(evalDecision.content);
781
- } else {
782
- logger.error('[deep-research] Evaluator synthesis was empty - falling back to historical findings');
783
- this.resolveResult(this.buildAllFindingsContext());
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
- } else if (isAtHardLimit) {
786
- // Evaluator returned delegation despite the mandatory synthesis instruction.
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
- } catch (err) {
797
- const error = err instanceof Error ? err : new Error(String(err));
798
- logger.error(`[deep-research] Coordinator evaluation failed for round ${evaluatedRound}:`, error);
799
- this.rejectCompletion(error);
800
- }
801
- }
802
-
803
- private buildRoleContext(aspect: ResearchSibling, roleContext: ReturnType<typeof getResearcherRoleContext>): string {
804
- const { displayNumber, roundNumber, totalInRound } = roleContext;
805
- const siblingNumInRound = parseInt(aspect.id.split('.')[1] ?? '1');
806
- return `## Your Role
807
-
808
- You are **Researcher ${displayNumber}** (${siblingNumInRound} of ${totalInRound} in Round ${roundNumber}).
809
- - **Topic**: ${aspect.query}
810
- - **Round**: ${roundNumber} of ${this.getMaxRounds(this.state.complexity)}
811
- - **Tool Usage**: 4 gathering calls + up to 3 context-gated scrape batches (Batch 1 ≤3 URLs, Batch 2 ≤2 URLs, Batch 3 ≤3 URLs)`;
812
- }
813
-
814
- private buildAllFindingsContext(): string {
815
- const allCompleted = Object.values(this.state.aspects)
816
- .filter(a => a.status === 'completed' && a.report)
817
- .sort((a, b) => {
818
- const aRound = parseInt(a.id.split('.')[0] ?? '0');
819
- const bRound = parseInt(b.id.split('.')[0] ?? '0');
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
  }