@moxxy/mode-deep-research 0.26.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/dist/approval.d.ts +32 -0
  3. package/dist/approval.d.ts.map +1 -0
  4. package/dist/approval.js +84 -0
  5. package/dist/approval.js.map +1 -0
  6. package/dist/constants.d.ts +62 -0
  7. package/dist/constants.d.ts.map +1 -0
  8. package/dist/constants.js +141 -0
  9. package/dist/constants.js.map +1 -0
  10. package/dist/fanout-phase.d.ts +44 -0
  11. package/dist/fanout-phase.d.ts.map +1 -0
  12. package/dist/fanout-phase.js +133 -0
  13. package/dist/fanout-phase.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +18 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/parse-queries.d.ts +13 -0
  19. package/dist/parse-queries.d.ts.map +1 -0
  20. package/dist/parse-queries.js +71 -0
  21. package/dist/parse-queries.js.map +1 -0
  22. package/dist/query-phase.d.ts +15 -0
  23. package/dist/query-phase.d.ts.map +1 -0
  24. package/dist/query-phase.js +81 -0
  25. package/dist/query-phase.js.map +1 -0
  26. package/dist/research-loop.d.ts +18 -0
  27. package/dist/research-loop.d.ts.map +1 -0
  28. package/dist/research-loop.js +357 -0
  29. package/dist/research-loop.js.map +1 -0
  30. package/dist/synthesis-phase.d.ts +8 -0
  31. package/dist/synthesis-phase.d.ts.map +1 -0
  32. package/dist/synthesis-phase.js +21 -0
  33. package/dist/synthesis-phase.js.map +1 -0
  34. package/package.json +73 -0
  35. package/src/approval.test.ts +82 -0
  36. package/src/approval.ts +105 -0
  37. package/src/constants.ts +153 -0
  38. package/src/fanout-phase.ts +177 -0
  39. package/src/index.test.ts +575 -0
  40. package/src/index.ts +27 -0
  41. package/src/parse-queries.ts +72 -0
  42. package/src/query-phase.ts +123 -0
  43. package/src/research-loop.ts +414 -0
  44. package/src/synthesis-phase.ts +25 -0
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@moxxy/mode-deep-research",
3
+ "version": "0.26.0",
4
+ "description": "Deep-research loop strategy for moxxy: decompose the user question into parallel sub-queries, fan out via subagents, synthesize a cited writeup.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "mode",
9
+ "research",
10
+ "fan-out"
11
+ ],
12
+ "homepage": "https://moxxy.ai",
13
+ "bugs": {
14
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
19
+ "directory": "packages/mode-deep-research"
20
+ },
21
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "moxxy": {
40
+ "plugin": {
41
+ "entry": "./dist/index.js",
42
+ "kind": "mode"
43
+ },
44
+ "requirements": [
45
+ {
46
+ "kind": "plugin",
47
+ "name": "@moxxy/plugin-subagents",
48
+ "state": "registered",
49
+ "hint": "Enable @moxxy/plugin-subagents — deep-research fans out sub-queries via ctx.subagents."
50
+ }
51
+ ]
52
+ },
53
+ "dependencies": {
54
+ "@moxxy/sdk": "0.26.0",
55
+ "@moxxy/plugin-subagents": "0.26.0"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^22.10.0",
59
+ "typescript": "^5.7.3",
60
+ "vitest": "^2.1.8",
61
+ "zod": "^3.24.0",
62
+ "@moxxy/testing": "0.0.44",
63
+ "@moxxy/tsconfig": "0.0.0",
64
+ "@moxxy/core": "0.26.0",
65
+ "@moxxy/vitest-preset": "0.0.0"
66
+ },
67
+ "scripts": {
68
+ "build": "tsc -p tsconfig.json",
69
+ "typecheck": "tsc -p tsconfig.json --noEmit",
70
+ "test": "vitest run",
71
+ "clean": "rm -rf dist .turbo"
72
+ }
73
+ }
@@ -0,0 +1,82 @@
1
+ import type { ApprovalDecision, ApprovalRequest, ModeContext } from '@moxxy/sdk';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { runQueryApprovalGate, runSynthesisApprovalGate } from './approval.js';
5
+ import { MAX_REDRAFTS } from './constants.js';
6
+
7
+ /** Build a ModeContext whose only meaningful member is `approval`. */
8
+ function ctxWithApproval(
9
+ confirm: (req: ApprovalRequest) => Promise<ApprovalDecision>,
10
+ ): ModeContext {
11
+ return { approval: { name: 'fake', confirm } } as unknown as ModeContext;
12
+ }
13
+
14
+ const headlessCtx = {} as unknown as ModeContext;
15
+
16
+ describe('runQueryApprovalGate', () => {
17
+ it('auto-approves headlessly when no resolver is present', async () => {
18
+ const res = await runQueryApprovalGate(headlessCtx, 'plan', 3, 0);
19
+ expect(res).toEqual({ outcome: { kind: 'approve' }, redraftCount: 0 });
20
+ });
21
+
22
+ it('approve maps to an approve outcome and preserves redraftCount', async () => {
23
+ const ctx = ctxWithApproval(async () => ({ optionId: 'approve' }));
24
+ const res = await runQueryApprovalGate(ctx, 'plan', 2, 1);
25
+ expect(res).toEqual({ outcome: { kind: 'approve' }, redraftCount: 1 });
26
+ });
27
+
28
+ it('cancel maps to a cancel outcome and preserves redraftCount', async () => {
29
+ const ctx = ctxWithApproval(async () => ({ optionId: 'cancel' }));
30
+ const res = await runQueryApprovalGate(ctx, 'plan', 2, 1);
31
+ expect(res).toEqual({ outcome: { kind: 'cancel' }, redraftCount: 1 });
32
+ });
33
+
34
+ it('redraft carries feedback text and increments the count', async () => {
35
+ const ctx = ctxWithApproval(async () => ({ optionId: 'redraft', text: 'narrow scope' }));
36
+ const res = await runQueryApprovalGate(ctx, 'plan', 2, 0);
37
+ expect(res).toEqual({
38
+ outcome: { kind: 'redraft', feedback: 'narrow scope' },
39
+ redraftCount: 1,
40
+ });
41
+ });
42
+
43
+ it('redraft with no text yields null feedback', async () => {
44
+ const ctx = ctxWithApproval(async () => ({ optionId: 'redraft' }));
45
+ const res = await runQueryApprovalGate(ctx, 'plan', 2, 0);
46
+ expect(res.outcome).toEqual({ kind: 'redraft', feedback: null });
47
+ });
48
+
49
+ it('the (MAX_REDRAFTS+1)th redraft trips the cap, not before', async () => {
50
+ const ctx = ctxWithApproval(async () => ({ optionId: 'redraft' }));
51
+ // The last allowed redraft: redraftCount goes from MAX_REDRAFTS-1 to MAX_REDRAFTS.
52
+ const allowed = await runQueryApprovalGate(ctx, 'plan', 1, MAX_REDRAFTS - 1);
53
+ expect(allowed.outcome.kind).toBe('redraft');
54
+ expect(allowed.redraftCount).toBe(MAX_REDRAFTS);
55
+ // One more pushes nextCount past the cap.
56
+ const capped = await runQueryApprovalGate(ctx, 'plan', 1, MAX_REDRAFTS);
57
+ expect(capped.outcome.kind).toBe('redraft-cap-exceeded');
58
+ expect(capped.redraftCount).toBe(MAX_REDRAFTS + 1);
59
+ });
60
+ });
61
+
62
+ describe('runSynthesisApprovalGate', () => {
63
+ it('auto-synthesizes headlessly when no resolver is present', async () => {
64
+ const res = await runSynthesisApprovalGate(headlessCtx, 'digest');
65
+ expect(res).toEqual({ kind: 'synthesize' });
66
+ });
67
+
68
+ it('synthesize option maps to synthesize', async () => {
69
+ const confirm = vi.fn(async () => ({ optionId: 'synthesize' }));
70
+ const res = await runSynthesisApprovalGate(ctxWithApproval(confirm), 'digest');
71
+ expect(res).toEqual({ kind: 'synthesize' });
72
+ expect(confirm).toHaveBeenCalledOnce();
73
+ });
74
+
75
+ it('cancel option maps to cancel', async () => {
76
+ const res = await runSynthesisApprovalGate(
77
+ ctxWithApproval(async () => ({ optionId: 'cancel' })),
78
+ 'digest',
79
+ );
80
+ expect(res).toEqual({ kind: 'cancel' });
81
+ });
82
+ });
@@ -0,0 +1,105 @@
1
+ import type { ModeContext } from '@moxxy/sdk';
2
+
3
+ import { MAX_REDRAFTS } from './constants.js';
4
+
5
+ export type QueryGateOutcome =
6
+ | { kind: 'approve' }
7
+ | { kind: 'redraft'; feedback: string | null }
8
+ | { kind: 'cancel' }
9
+ | { kind: 'redraft-cap-exceeded' };
10
+
11
+ /**
12
+ * Optional approval gate after the query plan is produced. Headless
13
+ * contexts (no resolver) auto-approve. Approve / redraft-with-feedback /
14
+ * cancel option ids, with bounded redraft retries.
15
+ */
16
+ export async function runQueryApprovalGate(
17
+ ctx: ModeContext,
18
+ planText: string,
19
+ queryCount: number,
20
+ redraftCount: number,
21
+ ): Promise<{ outcome: QueryGateOutcome; redraftCount: number }> {
22
+ if (!ctx.approval) return { outcome: { kind: 'approve' }, redraftCount };
23
+
24
+ const decision = await ctx.approval.confirm({
25
+ title: 'Query plan ready — review before fan-out',
26
+ body: planText,
27
+ kind: 'deep-research.queries',
28
+ defaultOptionId: 'approve',
29
+ options: [
30
+ {
31
+ id: 'approve',
32
+ label: 'Approve and fan out',
33
+ hotkey: 'a',
34
+ description: `Spawn ${queryCount} parallel subagent${queryCount === 1 ? '' : 's'} to research these.`,
35
+ },
36
+ {
37
+ id: 'redraft',
38
+ label: 'Redraft with feedback',
39
+ hotkey: 'r',
40
+ requestsText: true,
41
+ textPrompt: 'What should change about the query plan?',
42
+ description: 'Send feedback to the planner and get a new query plan.',
43
+ },
44
+ {
45
+ id: 'cancel',
46
+ label: 'Cancel this turn',
47
+ hotkey: 'c',
48
+ danger: true,
49
+ },
50
+ ],
51
+ });
52
+
53
+ if (decision.optionId === 'cancel') return { outcome: { kind: 'cancel' }, redraftCount };
54
+ if (decision.optionId === 'redraft') {
55
+ const nextCount = redraftCount + 1;
56
+ if (nextCount > MAX_REDRAFTS) {
57
+ return { outcome: { kind: 'redraft-cap-exceeded' }, redraftCount: nextCount };
58
+ }
59
+ return {
60
+ outcome: { kind: 'redraft', feedback: decision.text ?? null },
61
+ redraftCount: nextCount,
62
+ };
63
+ }
64
+ return { outcome: { kind: 'approve' }, redraftCount };
65
+ }
66
+
67
+ export type SynthesisGateOutcome =
68
+ | { kind: 'synthesize' }
69
+ | { kind: 'cancel' };
70
+
71
+ /**
72
+ * Approval gate shown after fan-out, before the synthesis turn. Body is
73
+ * a short digest of each subagent's result so the user can sanity-check
74
+ * coverage before paying for synthesis tokens.
75
+ */
76
+ export async function runSynthesisApprovalGate(
77
+ ctx: ModeContext,
78
+ digest: string,
79
+ ): Promise<SynthesisGateOutcome> {
80
+ if (!ctx.approval) return { kind: 'synthesize' };
81
+
82
+ const decision = await ctx.approval.confirm({
83
+ title: 'Fan-out complete — synthesize the final writeup?',
84
+ body: digest,
85
+ kind: 'deep-research.synthesis',
86
+ defaultOptionId: 'synthesize',
87
+ options: [
88
+ {
89
+ id: 'synthesize',
90
+ label: 'Synthesize',
91
+ hotkey: 's',
92
+ description: 'Combine the findings into the final writeup.',
93
+ },
94
+ {
95
+ id: 'cancel',
96
+ label: 'Cancel this turn',
97
+ hotkey: 'c',
98
+ danger: true,
99
+ },
100
+ ],
101
+ });
102
+
103
+ if (decision.optionId === 'cancel') return { kind: 'cancel' };
104
+ return { kind: 'synthesize' };
105
+ }
@@ -0,0 +1,153 @@
1
+ import { asPluginId } from '@moxxy/sdk';
2
+
3
+ export const RESEARCH_MODE_NAME = 'research';
4
+
5
+ export const DEEP_RESEARCH_PLUGIN_ID = asPluginId('@moxxy/mode-deep-research');
6
+
7
+ /**
8
+ * Asked once in the planning phase. The subagents fan out IN PARALLEL —
9
+ * which means a query can never depend on another query's output. If we
10
+ * let the planner emit "compare side A and side B" or "what are the
11
+ * disputed claims" as separate queries, the subagent running them has
12
+ * no idea what was actually gathered and either invents content or
13
+ * gives up (e.g. "I can't determine what 'the disputed claims' refers
14
+ * to from the prompt alone"). The synthesis phase already sees every
15
+ * subagent's findings end-to-end and is where comparison + contradiction
16
+ * analysis belongs — the planner's job is purely to spread the raw
17
+ * gathering work across independent angles.
18
+ */
19
+ export const QUERY_PLAN_SYSTEM_PROMPT = `You are scoping a deep-research task. Produce 2-5 PARALLEL GATHERING queries — one per independent angle of raw evidence the synthesis phase will need.
20
+
21
+ HARD RULES (the loop refuses plans that break these):
22
+ - Each query MUST be independently answerable from web search + page reads ALONE — without seeing any other query's output. The subagents run in parallel and never see each other's findings.
23
+ - DO NOT emit comparison, contradiction, "differences", "disputed claims", "which side is right", "balanced timeline", "verify side X's claims", or other queries that require knowing what OTHER queries returned. Those are the synthesis phase's job, not gathering.
24
+ - DO NOT pad. If the user's question only needs 2 angles (e.g. "compare X side vs Y side"), emit exactly 2 queries — one per side. The synthesis turn does the comparison.
25
+ - Each query should be concrete and scoped ("What did <party> say about <topic> in the last 30 days?"), not an open theme ("Background on X").
26
+ - No two queries should be rephrasings of each other.
27
+
28
+ Reply with EXACTLY this format and nothing else:
29
+
30
+ QUERIES:
31
+ 1. <gathering query>
32
+ 2. <gathering query>
33
+ ...
34
+
35
+ Stop after the last numbered line.`;
36
+
37
+ /**
38
+ * System prompt for each fan-out subagent. They run in standard tool-use
39
+ * mode, but constrained (by allowedTools) to read-only web tools. The
40
+ * SOURCES: block is what the synthesis phase pulls citations from.
41
+ */
42
+ export const SUBAGENT_SYSTEM_PROMPT = `You are a focused research subagent. You have ONE sub-question to answer. Use web_search and web_fetch (and Read for any local files referenced) to gather evidence, then produce a concise writeup.
43
+
44
+ Stop tool-calling when you have enough material to answer. Reply with:
45
+
46
+ FINDINGS:
47
+ <2-4 short paragraphs answering the sub-question, citing sources inline like [1], [2] where claims come from a specific source>
48
+
49
+ SOURCES:
50
+ [1] <title> — <url>
51
+ [2] <title> — <url>
52
+ ...
53
+
54
+ Do NOT edit files. Do NOT run git. Stop after the last source line.`;
55
+
56
+ /**
57
+ * Asked between fan-out rounds. Given the findings from the prior
58
+ * round(s), the model decides whether MORE parallel gathering is
59
+ * needed to fill specific gaps before synthesis — and if so, what.
60
+ * This is the "agentic" half of the loop that the user explicitly
61
+ * asked for: round-1 gathers Iran-side + USA-side, round-2 spawns
62
+ * follow-ups armed with both, instead of asking blind round-1
63
+ * subagents to compare things they can't see.
64
+ */
65
+ export const FOLLOWUP_PLAN_SYSTEM_PROMPT = `You have the findings of the previous research round(s). Decide whether you need MORE parallel gathering to fill specific gaps the synthesis phase can't resolve on its own.
66
+
67
+ You will receive:
68
+ - The original user question.
69
+ - Each prior subagent's question + FINDINGS + SOURCES block.
70
+
71
+ Decision rules:
72
+ - ONLY emit follow-ups when there is a CONCRETE gap that needs more raw evidence — a contradiction begging for a third source, a claim that needs primary-document verification, a missing data point that one round can't synthesize around.
73
+ - The follow-up subagents WILL be given the prior findings as context in their prompt, so phrasing like "verify the casualty figure of X reported by Iranian state media against independent sources" is fine.
74
+ - DO NOT emit follow-ups that just rephrase prior queries or chase tangents.
75
+ - Each follow-up still runs in parallel with the others in this round, so follow-ups must be independent of EACH OTHER (just like round-1 queries).
76
+ - If the prior findings are sufficient for a good synthesis, emit none and stop. The synthesis turn handles cross-cutting comparison without help.
77
+
78
+ Reply with EXACTLY one of these formats and nothing else:
79
+
80
+ FOLLOWUPS:
81
+ 1. <follow-up gathering query>
82
+ 2. <follow-up gathering query>
83
+ ...
84
+
85
+ or
86
+
87
+ FOLLOWUPS: (none)
88
+
89
+ Stop after the last line.`;
90
+
91
+ /**
92
+ * Synthesis turn: consume the per-subagent findings + sources and
93
+ * produce a single structured writeup with renumbered citations.
94
+ */
95
+ export const SYNTHESIS_SYSTEM_PROMPT = `You are synthesizing a deep-research report from the findings of multiple subagents — possibly across multiple research rounds (an initial gathering round plus optional follow-up rounds that drilled into specific gaps the initial round surfaced).
96
+
97
+ You will receive the original question, each subagent's question + round number + FINDINGS + SOURCES block. Produce the final writeup with these sections, in this order:
98
+
99
+ ## Executive summary
100
+ <3-5 bullet points; the key takeaways.>
101
+
102
+ ## Key findings
103
+ <numbered paragraphs covering each substantive finding, citing sources inline as [n] referring to the unified Sources list below. Call out contradictions explicitly. Mark gaps as "Not covered: …". When findings from a follow-up round resolve or amplify a contradiction the initial round surfaced, say so.>
104
+
105
+ ## Sources
106
+ [1] <title> — <url>
107
+ [2] <title> — <url>
108
+ ...
109
+ (Renumber so the unified list is contiguous; each source appears once even if cited by multiple subagents.)
110
+
111
+ ## Open questions
112
+ <bullets describing what remains unresolved, or "None — the gathered evidence is sufficient.">
113
+
114
+ Be tight. Do not narrate your process.`;
115
+
116
+ /** Refuse plans larger than this in the initial round — guards against runaway fan-out cost. */
117
+ export const MAX_SUBAGENTS = 6;
118
+
119
+ /** Maximum follow-up rounds after the initial gathering. */
120
+ export const MAX_FOLLOWUP_ROUNDS = 2;
121
+
122
+ /** Maximum follow-up queries per round. */
123
+ export const MAX_FOLLOWUPS_PER_ROUND = 4;
124
+
125
+ export const MAX_REDRAFTS = 3;
126
+
127
+ export const SUBAGENT_MAX_ITERATIONS = 40;
128
+
129
+ /**
130
+ * Per-finding character cap when a prior round's findings are embedded as
131
+ * context into EACH follow-up subagent's prompt. The same blob is duplicated
132
+ * across every sibling subagent and re-sent every round, so an uncapped
133
+ * multi-KB finding grows the embedded context multiplicatively. The full
134
+ * untruncated text is still used for the single synthesis turn.
135
+ */
136
+ export const SUBAGENT_PRIOR_FINDING_MAX_CHARS = 1500;
137
+
138
+ /**
139
+ * Tools each subagent is allowed to call. Constrained to read-only web +
140
+ * filesystem reads — no edits, no shell, no git, so a runaway subagent
141
+ * can't damage the working tree. WebSearch / WebFetch may be exposed
142
+ * under different names depending on plugin configuration; the registry
143
+ * silently ignores unknown names, so listing both common spellings is
144
+ * harmless.
145
+ */
146
+ export const SUBAGENT_ALLOWED_TOOLS = [
147
+ 'WebSearch',
148
+ 'WebFetch',
149
+ 'web_search',
150
+ 'web_fetch',
151
+ 'Read',
152
+ 'read',
153
+ ] as const;
@@ -0,0 +1,177 @@
1
+ import type { ModeContext, SubagentResult, SubagentSpec } from '@moxxy/sdk';
2
+
3
+ import {
4
+ SUBAGENT_ALLOWED_TOOLS,
5
+ SUBAGENT_MAX_ITERATIONS,
6
+ SUBAGENT_PRIOR_FINDING_MAX_CHARS,
7
+ SUBAGENT_SYSTEM_PROMPT,
8
+ } from './constants.js';
9
+
10
+ export interface FanoutOutcome {
11
+ readonly results: ReadonlyArray<SubagentResult>;
12
+ readonly errored: ReadonlyArray<{ readonly index: number; readonly message: string }>;
13
+ }
14
+
15
+ /**
16
+ * Findings accumulated across rounds — each entry is one subagent's
17
+ * outcome plus the round it ran in. Synthesis and follow-up planning
18
+ * both work off this flat list.
19
+ */
20
+ export interface RoundFinding {
21
+ readonly round: number;
22
+ readonly question: string;
23
+ readonly text: string;
24
+ readonly error?: string;
25
+ }
26
+
27
+ export function flattenOutcome(
28
+ round: number,
29
+ queries: ReadonlyArray<string>,
30
+ outcome: FanoutOutcome,
31
+ ): RoundFinding[] {
32
+ return queries.map((question, i) => {
33
+ const r = outcome.results[i];
34
+ const err = outcome.errored.find((e) => e.index === i);
35
+ return {
36
+ round,
37
+ question,
38
+ text: (r?.text ?? '').trim(),
39
+ ...(err ? { error: err.message } : {}),
40
+ };
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Spawn one subagent per query in parallel, each constrained to web /
46
+ * read-only tools. When `priorFindings` is non-empty, the prior
47
+ * findings are embedded in each subagent's user prompt so follow-up
48
+ * agents see what earlier rounds already gathered (which is the whole
49
+ * point of multi-round research — round-2 follow-ups should not be
50
+ * blind to round-1's findings).
51
+ *
52
+ * Caller MUST have verified ctx.subagents is present.
53
+ */
54
+ export async function runFanout(
55
+ ctx: ModeContext,
56
+ queries: ReadonlyArray<string>,
57
+ priorFindings: ReadonlyArray<RoundFinding> = [],
58
+ ): Promise<FanoutOutcome> {
59
+ if (!ctx.subagents) {
60
+ throw new Error('deep-research: runFanout called without ctx.subagents — caller bug');
61
+ }
62
+
63
+ const specs: SubagentSpec[] = queries.map((q, i) => ({
64
+ prompt: buildSubagentPrompt(q, priorFindings),
65
+ systemPrompt: SUBAGENT_SYSTEM_PROMPT,
66
+ mode: 'default',
67
+ maxIterations: SUBAGENT_MAX_ITERATIONS,
68
+ allowedTools: [...SUBAGENT_ALLOWED_TOOLS],
69
+ label: `subagent-${i + 1}`,
70
+ }));
71
+
72
+ let results: ReadonlyArray<SubagentResult>;
73
+ try {
74
+ results = await ctx.subagents.spawnAll(specs);
75
+ } catch (err) {
76
+ // spawnAll is Promise.all under the hood (run-child.ts) — a single child's
77
+ // setup work (strategy/model resolution, log appends, tool-registry build)
78
+ // throwing OUTSIDE its own try/catch rejects the whole batch. Treat that as
79
+ // every-subagent-errored rather than letting the rejection crash the entire
80
+ // research turn and discard all prior-round findings. flattenOutcome turns
81
+ // these into RoundFinding rows the loop carries into synthesis.
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ return {
84
+ results: [],
85
+ errored: queries.map((_, index) => ({ index, message })),
86
+ };
87
+ }
88
+
89
+ const errored: Array<{ index: number; message: string }> = [];
90
+ results.forEach((r, i) => {
91
+ if (r.error) errored.push({ index: i, message: r.error.message });
92
+ });
93
+ return { results, errored };
94
+ }
95
+
96
+ function buildSubagentPrompt(
97
+ query: string,
98
+ priorFindings: ReadonlyArray<RoundFinding>,
99
+ ): string {
100
+ if (priorFindings.length === 0) return query;
101
+ const sections: string[] = [];
102
+ sections.push(
103
+ 'Earlier research rounds gathered the following findings — use them as context for the focused question at the bottom, but you still need to do your own search to answer the focused question.',
104
+ );
105
+ sections.push('');
106
+ for (const f of priorFindings) {
107
+ sections.push(`### Round ${f.round}: ${f.question}`);
108
+ if (f.error) {
109
+ sections.push(`(errored: ${f.error})`);
110
+ } else {
111
+ // Cap each embedded finding: the SAME prior-findings blob is duplicated
112
+ // into every sibling subagent's prompt every round, so unbounded text
113
+ // grows multiplicatively and can blow the subagent's context window. The
114
+ // full untruncated text is preserved only for the single synthesis turn.
115
+ sections.push(capFindingText(f.text) || '(empty response)');
116
+ }
117
+ sections.push('');
118
+ }
119
+ sections.push('---');
120
+ sections.push('');
121
+ sections.push(`Your focused follow-up question:\n${query}`);
122
+ return sections.join('\n');
123
+ }
124
+
125
+ /** Bound a prior finding's text before it is embedded into a subagent prompt. */
126
+ function capFindingText(text: string): string {
127
+ if (text.length <= SUBAGENT_PRIOR_FINDING_MAX_CHARS) return text;
128
+ return `${text.slice(0, SUBAGENT_PRIOR_FINDING_MAX_CHARS)}\n…[truncated]`;
129
+ }
130
+
131
+ /**
132
+ * Build a one-screen digest of subagent outcomes for the synthesis gate.
133
+ * Each entry shows the sub-question and a 200-char headline of the
134
+ * findings, plus a clear marker for any subagent that errored.
135
+ */
136
+ export function buildFanoutDigest(findings: ReadonlyArray<RoundFinding>): string {
137
+ const total = findings.length;
138
+ const errored = findings.filter((f) => f.error).length;
139
+ const ok = total - errored;
140
+ const head = `${ok} of ${total} subagent${total === 1 ? '' : 's'} returned`;
141
+ const errStub = errored > 0 ? `, ${errored} errored` : '';
142
+
143
+ const blocks = findings.map((f, i) => {
144
+ if (f.error) {
145
+ return `${i + 1}. [round ${f.round}] ${f.question}\n [errored: ${f.error}]`;
146
+ }
147
+ const headline = f.text.trim().slice(0, 200).replace(/\n+/g, ' ');
148
+ return `${i + 1}. [round ${f.round}] ${f.question}\n ${headline || '(empty response)'}`;
149
+ });
150
+
151
+ return `${head}${errStub}.\n\n${blocks.join('\n\n')}`;
152
+ }
153
+
154
+ /**
155
+ * Build the synthesis turn's user message: weaves the original question
156
+ * back together with each subagent's writeup so the synthesizer has the
157
+ * raw material to cite and combine.
158
+ */
159
+ export function buildSynthesisInput(
160
+ originalPrompt: string,
161
+ findings: ReadonlyArray<RoundFinding>,
162
+ ): string {
163
+ const parts: string[] = [];
164
+ parts.push(`Original question:\n${originalPrompt}`);
165
+ parts.push('');
166
+ parts.push('Sub-question findings:');
167
+ findings.forEach((f, i) => {
168
+ parts.push('');
169
+ parts.push(`### #${i + 1} (round ${f.round}): ${f.question}`);
170
+ if (f.error) {
171
+ parts.push(`(errored: ${f.error})`);
172
+ } else {
173
+ parts.push(f.text || '(empty response)');
174
+ }
175
+ });
176
+ return parts.join('\n');
177
+ }