@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
@@ -0,0 +1,123 @@
1
+ import {
2
+ buildSystemPromptWithSkills,
3
+ runSingleShotTurn,
4
+ type ModeContext,
5
+ type ProviderMessage,
6
+ } from '@moxxy/sdk';
7
+
8
+ import {
9
+ FOLLOWUP_PLAN_SYSTEM_PROMPT,
10
+ QUERY_PLAN_SYSTEM_PROMPT,
11
+ } from './constants.js';
12
+ import type { RoundFinding } from './fanout-phase.js';
13
+
14
+ /**
15
+ * Drive the initial query-planning turn. Single-shot stream — the
16
+ * planner shouldn't be web-searching, that's the subagents' job.
17
+ * Returns the raw text, or null on provider error (already emitted).
18
+ */
19
+ export async function collectQueryPlan(
20
+ ctx: ModeContext,
21
+ redraftFeedback: string | null,
22
+ ): Promise<string | null> {
23
+ const messages = buildPlannerMessages(
24
+ ctx,
25
+ QUERY_PLAN_SYSTEM_PROMPT,
26
+ buildInitialUserMessages(ctx, redraftFeedback),
27
+ );
28
+ return runSingleShotTurn(ctx, messages, { maxTokens: 800 });
29
+ }
30
+
31
+ /**
32
+ * Drive the follow-up planning turn between gather rounds. The model
33
+ * sees the prior round(s)' findings as context and decides whether to
34
+ * spawn more parallel research or move to synthesis.
35
+ */
36
+ export async function collectFollowupPlan(
37
+ ctx: ModeContext,
38
+ originalPrompt: string,
39
+ priorFindings: ReadonlyArray<RoundFinding>,
40
+ ): Promise<string | null> {
41
+ const messages = buildPlannerMessages(
42
+ ctx,
43
+ FOLLOWUP_PLAN_SYSTEM_PROMPT,
44
+ buildFollowupUserMessages(originalPrompt, priorFindings),
45
+ );
46
+ return runSingleShotTurn(ctx, messages, { maxTokens: 800 });
47
+ }
48
+
49
+ function buildPlannerMessages(
50
+ ctx: ModeContext,
51
+ systemPrompt: string,
52
+ userMessages: ProviderMessage[],
53
+ ): ProviderMessage[] {
54
+ const systemWithSkills =
55
+ buildSystemPromptWithSkills(ctx.systemPrompt, ctx.skills.list()) ?? '';
56
+ return [
57
+ {
58
+ role: 'system',
59
+ content: [
60
+ {
61
+ type: 'text',
62
+ text: systemPrompt + (systemWithSkills ? `\n\n${systemWithSkills}` : ''),
63
+ },
64
+ ],
65
+ },
66
+ ...userMessages,
67
+ ];
68
+ }
69
+
70
+ function buildInitialUserMessages(
71
+ ctx: ModeContext,
72
+ redraftFeedback: string | null,
73
+ ): ProviderMessage[] {
74
+ const out: ProviderMessage[] = [];
75
+ for (const e of ctx.log.slice()) {
76
+ if (e.type === 'user_prompt') {
77
+ out.push({ role: 'user', content: [{ type: 'text', text: e.text }] });
78
+ }
79
+ }
80
+ if (redraftFeedback) {
81
+ out.push({
82
+ role: 'user',
83
+ content: [
84
+ {
85
+ type: 'text',
86
+ text:
87
+ `The previous query plan needs to be redrafted. Feedback from the user: ${redraftFeedback}\n\n` +
88
+ `Produce a new QUERIES block addressing this feedback.`,
89
+ },
90
+ ],
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function buildFollowupUserMessages(
97
+ originalPrompt: string,
98
+ priorFindings: ReadonlyArray<RoundFinding>,
99
+ ): ProviderMessage[] {
100
+ const sections: string[] = [];
101
+ sections.push(`Original question:\n${originalPrompt}`);
102
+ sections.push('');
103
+ sections.push('Prior research findings:');
104
+ for (const f of priorFindings) {
105
+ sections.push('');
106
+ sections.push(`### Round ${f.round}, sub-question: ${f.question}`);
107
+ if (f.error) {
108
+ sections.push(`(errored: ${f.error})`);
109
+ } else {
110
+ sections.push(f.text.trim() || '(empty response)');
111
+ }
112
+ }
113
+ sections.push('');
114
+ sections.push(
115
+ 'Decide whether more parallel research is needed. Reply with a FOLLOWUPS: block as instructed.',
116
+ );
117
+ return [
118
+ {
119
+ role: 'user',
120
+ content: [{ type: 'text', text: sections.join('\n') }],
121
+ },
122
+ ];
123
+ }
@@ -0,0 +1,414 @@
1
+ import type { ModeContext, MoxxyEvent } from '@moxxy/sdk';
2
+
3
+ import {
4
+ runQueryApprovalGate,
5
+ runSynthesisApprovalGate,
6
+ } from './approval.js';
7
+ import {
8
+ RESEARCH_MODE_NAME,
9
+ DEEP_RESEARCH_PLUGIN_ID,
10
+ MAX_FOLLOWUPS_PER_ROUND,
11
+ MAX_FOLLOWUP_ROUNDS,
12
+ MAX_REDRAFTS,
13
+ MAX_SUBAGENTS,
14
+ } from './constants.js';
15
+ import {
16
+ buildFanoutDigest,
17
+ buildSynthesisInput,
18
+ flattenOutcome,
19
+ runFanout,
20
+ type RoundFinding,
21
+ } from './fanout-phase.js';
22
+ import { parseFollowups, parseQueries } from './parse-queries.js';
23
+ import { collectFollowupPlan, collectQueryPlan } from './query-phase.js';
24
+ import { collectSynthesis } from './synthesis-phase.js';
25
+
26
+ /**
27
+ * Deep-research loop:
28
+ * 1. Plan QUERIES: block (with redraft-able approval gate)
29
+ * 2. Fan out (round 1) — one subagent per gathering query, in parallel
30
+ * 3. Up to MAX_FOLLOWUP_ROUNDS of:
31
+ * a. Ask model "given findings so far, do you need follow-ups?"
32
+ * b. If yes, fan out follow-ups with the prior findings as
33
+ * context in each subagent's prompt.
34
+ * c. If no, break out.
35
+ * 4. Synthesis approval gate (digest of all rounds)
36
+ * 5. Synthesize the final cited writeup.
37
+ *
38
+ * Headless contexts auto-approve both gates. Missing ctx.subagents is
39
+ * fatal — fan-out is the whole point of this mode.
40
+ */
41
+ export async function* runDeepResearchMode(ctx: ModeContext): AsyncIterable<MoxxyEvent> {
42
+ if (ctx.signal.aborted) {
43
+ yield await ctx.emit({
44
+ type: 'abort',
45
+ sessionId: ctx.sessionId,
46
+ turnId: ctx.turnId,
47
+ source: 'system',
48
+ reason: 'aborted before deep-research start',
49
+ });
50
+ return;
51
+ }
52
+
53
+ if (!ctx.subagents) {
54
+ yield await ctx.emit({
55
+ type: 'error',
56
+ sessionId: ctx.sessionId,
57
+ turnId: ctx.turnId,
58
+ source: 'system',
59
+ kind: 'fatal',
60
+ message:
61
+ 'research: ctx.subagents is unavailable (the @moxxy/plugin-subagents plugin appears disabled). ' +
62
+ 'Re-enable it, or switch to the default mode for a serial alternative.',
63
+ });
64
+ return;
65
+ }
66
+
67
+ yield await ctx.emit({
68
+ type: 'mode_iteration',
69
+ sessionId: ctx.sessionId,
70
+ turnId: ctx.turnId,
71
+ source: 'system',
72
+ strategy: RESEARCH_MODE_NAME,
73
+ iteration: 0,
74
+ routing: 'unresolved',
75
+ });
76
+
77
+ // Phase 1: query plan (with redraftable gate).
78
+ const planning = yield* runPlanningPhase(ctx);
79
+ if (planning === null) return;
80
+ const { queries, originalPrompt } = planning;
81
+
82
+ // Phase 2: round-1 fan out.
83
+ const allFindings: RoundFinding[] = [];
84
+ const round1 = yield* runRound(ctx, 1, queries, []);
85
+ if (round1 === null) return;
86
+ allFindings.push(...round1);
87
+
88
+ // Phase 3: follow-up rounds, capped to MAX_FOLLOWUP_ROUNDS.
89
+ for (let round = 2; round <= MAX_FOLLOWUP_ROUNDS + 1; round += 1) {
90
+ if (ctx.signal.aborted) return;
91
+ const followups = yield* runFollowupPlan(ctx, originalPrompt, allFindings, round);
92
+ if (followups === null) return;
93
+ if (followups.length === 0) {
94
+ yield await ctx.emit({
95
+ type: 'plugin_event',
96
+ sessionId: ctx.sessionId,
97
+ turnId: ctx.turnId,
98
+ source: 'plugin',
99
+ pluginId: DEEP_RESEARCH_PLUGIN_ID,
100
+ subtype: 'deep_research_followups_none',
101
+ payload: { round },
102
+ });
103
+ break;
104
+ }
105
+ const nextRound = yield* runRound(ctx, round, followups, allFindings);
106
+ if (nextRound === null) return;
107
+ allFindings.push(...nextRound);
108
+ }
109
+
110
+ if (ctx.signal.aborted) return;
111
+
112
+ // Phase 4: synthesis approval gate.
113
+ const digest = buildFanoutDigest(allFindings);
114
+ const gate = await runSynthesisApprovalGate(ctx, digest);
115
+ if (gate.kind === 'cancel') {
116
+ yield await ctx.emit({
117
+ type: 'abort',
118
+ sessionId: ctx.sessionId,
119
+ turnId: ctx.turnId,
120
+ source: 'user',
121
+ reason: 'synthesis cancelled by user',
122
+ });
123
+ return;
124
+ }
125
+
126
+ // Phase 5: synthesize.
127
+ const synthesisInput = buildSynthesisInput(originalPrompt, allFindings);
128
+ const synthesisText = await collectSynthesis(ctx, synthesisInput);
129
+ if (synthesisText === null) return;
130
+
131
+ // Synthesis is a multi-second provider call; if the user aborted while it ran,
132
+ // emit an abort rather than the finished report — matching the abort discipline
133
+ // every other phase in this loop follows (see the ctx.signal.aborted guards above).
134
+ if (ctx.signal.aborted) {
135
+ yield await ctx.emit({
136
+ type: 'abort',
137
+ sessionId: ctx.sessionId,
138
+ turnId: ctx.turnId,
139
+ source: 'system',
140
+ reason: 'aborted during synthesis',
141
+ });
142
+ return;
143
+ }
144
+
145
+ yield await ctx.emit({
146
+ type: 'assistant_message',
147
+ sessionId: ctx.sessionId,
148
+ turnId: ctx.turnId,
149
+ source: 'model',
150
+ content: synthesisText,
151
+ stopReason: 'end_turn',
152
+ });
153
+
154
+ yield await ctx.emit({
155
+ type: 'plugin_event',
156
+ sessionId: ctx.sessionId,
157
+ turnId: ctx.turnId,
158
+ source: 'plugin',
159
+ pluginId: DEEP_RESEARCH_PLUGIN_ID,
160
+ subtype: 'deep_research_synthesis_completed',
161
+ payload: {
162
+ totalFindings: allFindings.length,
163
+ rounds: Math.max(...allFindings.map((f) => f.round), 1),
164
+ errored: allFindings.filter((f) => f.error).length,
165
+ },
166
+ });
167
+ }
168
+
169
+ /**
170
+ * One round of parallel fan-out — emits start/complete events around
171
+ * runFanout. Returns the per-round findings, or null on abort.
172
+ */
173
+ async function* runRound(
174
+ ctx: ModeContext,
175
+ round: number,
176
+ queries: ReadonlyArray<string>,
177
+ priorFindings: ReadonlyArray<RoundFinding>,
178
+ ): AsyncGenerator<MoxxyEvent, RoundFinding[] | null, unknown> {
179
+ yield await ctx.emit({
180
+ type: 'plugin_event',
181
+ sessionId: ctx.sessionId,
182
+ turnId: ctx.turnId,
183
+ source: 'plugin',
184
+ pluginId: DEEP_RESEARCH_PLUGIN_ID,
185
+ subtype: 'deep_research_fanout_started',
186
+ payload: { round, queries: queries.length },
187
+ });
188
+
189
+ const outcome = await runFanout(ctx, queries, priorFindings);
190
+
191
+ yield await ctx.emit({
192
+ type: 'plugin_event',
193
+ sessionId: ctx.sessionId,
194
+ turnId: ctx.turnId,
195
+ source: 'plugin',
196
+ pluginId: DEEP_RESEARCH_PLUGIN_ID,
197
+ subtype: 'deep_research_fanout_completed',
198
+ payload: {
199
+ round,
200
+ total: queries.length,
201
+ errored: outcome.errored.length,
202
+ },
203
+ });
204
+
205
+ if (ctx.signal.aborted) return null;
206
+ return flattenOutcome(round, queries, outcome);
207
+ }
208
+
209
+ /**
210
+ * Ask the model whether to fan out more queries, given everything
211
+ * gathered so far. Returns the parsed follow-up queries (may be empty)
212
+ * or null on a fatal error.
213
+ */
214
+ async function* runFollowupPlan(
215
+ ctx: ModeContext,
216
+ originalPrompt: string,
217
+ priorFindings: ReadonlyArray<RoundFinding>,
218
+ round: number,
219
+ ): AsyncGenerator<MoxxyEvent, string[] | null, unknown> {
220
+ yield await ctx.emit({
221
+ type: 'plugin_event',
222
+ sessionId: ctx.sessionId,
223
+ turnId: ctx.turnId,
224
+ source: 'plugin',
225
+ pluginId: DEEP_RESEARCH_PLUGIN_ID,
226
+ subtype: 'deep_research_followups_planning',
227
+ payload: { round, basedOn: priorFindings.length },
228
+ });
229
+
230
+ const text = await collectFollowupPlan(ctx, originalPrompt, priorFindings);
231
+ if (text === null) return null;
232
+ const followups = parseFollowups(text);
233
+
234
+ // Apply cap and surface the trim as an event — but only when there's
235
+ // something to draft. The empty case is signalled by the caller via
236
+ // `deep_research_followups_none`; emitting a "drafted with kept=0"
237
+ // event here would be a duplicate "nothing to do" signal.
238
+ const trimmed = followups.slice(0, MAX_FOLLOWUPS_PER_ROUND);
239
+ if (trimmed.length === 0) return [];
240
+
241
+ yield await ctx.emit({
242
+ type: 'plugin_event',
243
+ sessionId: ctx.sessionId,
244
+ turnId: ctx.turnId,
245
+ source: 'plugin',
246
+ pluginId: DEEP_RESEARCH_PLUGIN_ID,
247
+ subtype: 'deep_research_followups_drafted',
248
+ payload: {
249
+ round,
250
+ proposed: followups.length,
251
+ kept: trimmed.length,
252
+ queries: trimmed,
253
+ },
254
+ });
255
+
256
+ // Materialize the follow-up plan as an assistant_message so the user
257
+ // sees the queries that are about to run, even without an approval
258
+ // gate. (We deliberately skip a per-round approval gate to keep the
259
+ // user-facing checkpoints lightweight — the synthesis gate at the
260
+ // end is the final go/no-go.)
261
+ yield await ctx.emit({
262
+ type: 'assistant_message',
263
+ sessionId: ctx.sessionId,
264
+ turnId: ctx.turnId,
265
+ source: 'model',
266
+ content:
267
+ `Follow-up round ${round} — spawning ${trimmed.length} more subagent${trimmed.length === 1 ? '' : 's'}:\n` +
268
+ trimmed.map((q, i) => `${i + 1}. ${q}`).join('\n'),
269
+ stopReason: 'end_turn',
270
+ });
271
+
272
+ return trimmed;
273
+ }
274
+
275
+ async function* runPlanningPhase(
276
+ ctx: ModeContext,
277
+ ): AsyncGenerator<
278
+ MoxxyEvent,
279
+ { queries: string[]; originalPrompt: string } | null,
280
+ unknown
281
+ > {
282
+ // Capture the original prompt from the log so we can re-include it in
283
+ // synthesis context. Use the LAST user_prompt — the one that triggered this
284
+ // turn — to stay consistent with how the runner scopes a turn (a multi-turn
285
+ // session's earlier prompts would otherwise anchor synthesis on a stale
286
+ // question). null = no user prompt at all (empty log).
287
+ const originalPrompt = (() => {
288
+ const events = ctx.log.slice();
289
+ for (let i = events.length - 1; i >= 0; i -= 1) {
290
+ const e = events[i]!;
291
+ if (e.type === 'user_prompt') return e.text;
292
+ }
293
+ return null;
294
+ })();
295
+
296
+ if (originalPrompt === null) {
297
+ yield await ctx.emit({
298
+ type: 'error',
299
+ sessionId: ctx.sessionId,
300
+ turnId: ctx.turnId,
301
+ source: 'system',
302
+ kind: 'fatal',
303
+ message:
304
+ 'deep-research: no user prompt found in the session log — nothing to research.',
305
+ });
306
+ return null;
307
+ }
308
+
309
+ let redraftFeedback: string | null = null;
310
+ let redraftCount = 0;
311
+
312
+ while (true) {
313
+ if (ctx.signal.aborted) {
314
+ yield await ctx.emit({
315
+ type: 'abort',
316
+ sessionId: ctx.sessionId,
317
+ turnId: ctx.turnId,
318
+ source: 'system',
319
+ reason: 'aborted during planning',
320
+ });
321
+ return null;
322
+ }
323
+
324
+ const planText = await collectQueryPlan(ctx, redraftFeedback);
325
+ if (planText === null) return null;
326
+ const queries = parseQueries(planText);
327
+
328
+ yield await ctx.emit({
329
+ type: 'plugin_event',
330
+ sessionId: ctx.sessionId,
331
+ turnId: ctx.turnId,
332
+ source: 'plugin',
333
+ pluginId: DEEP_RESEARCH_PLUGIN_ID,
334
+ subtype: 'deep_research_queries_drafted',
335
+ payload: { text: planText, queries, redraft: redraftCount },
336
+ });
337
+
338
+ if (queries.length === 0) {
339
+ yield await ctx.emit({
340
+ type: 'assistant_message',
341
+ sessionId: ctx.sessionId,
342
+ turnId: ctx.turnId,
343
+ source: 'model',
344
+ content: planText,
345
+ stopReason: 'end_turn',
346
+ });
347
+ return null;
348
+ }
349
+
350
+ if (queries.length > MAX_SUBAGENTS) {
351
+ // Over-production is a normal, recoverable failure mode ("be exhaustive"
352
+ // prompts trip it easily). When an interactive resolver is present and we
353
+ // still have redraft budget, auto-redraft asking the planner to narrow,
354
+ // rather than killing the whole turn. Headless (no approval) or once the
355
+ // redraft cap is exhausted, fall through to the fatal abort.
356
+ const nextCount = redraftCount + 1;
357
+ if (ctx.approval && nextCount <= MAX_REDRAFTS) {
358
+ redraftCount = nextCount;
359
+ redraftFeedback =
360
+ `You produced ${queries.length} queries; the cap is ${MAX_SUBAGENTS}. ` +
361
+ `Return at most ${MAX_SUBAGENTS} parallel gathering queries.`;
362
+ continue;
363
+ }
364
+ yield await ctx.emit({
365
+ type: 'error',
366
+ sessionId: ctx.sessionId,
367
+ turnId: ctx.turnId,
368
+ source: 'system',
369
+ kind: 'fatal',
370
+ message: `deep-research: refusing a ${queries.length}-query plan (cap is ${MAX_SUBAGENTS}). Narrow scope or rephrase.`,
371
+ });
372
+ return null;
373
+ }
374
+
375
+ const gate = await runQueryApprovalGate(ctx, planText, queries.length, redraftCount);
376
+ redraftCount = gate.redraftCount;
377
+
378
+ if (gate.outcome.kind === 'cancel') {
379
+ yield await ctx.emit({
380
+ type: 'abort',
381
+ sessionId: ctx.sessionId,
382
+ turnId: ctx.turnId,
383
+ source: 'user',
384
+ reason: 'query plan rejected by user',
385
+ });
386
+ return null;
387
+ }
388
+ if (gate.outcome.kind === 'redraft-cap-exceeded') {
389
+ yield await ctx.emit({
390
+ type: 'error',
391
+ sessionId: ctx.sessionId,
392
+ turnId: ctx.turnId,
393
+ source: 'system',
394
+ kind: 'fatal',
395
+ message: `deep-research: redrafted ${MAX_REDRAFTS}× without approval; aborting.`,
396
+ });
397
+ return null;
398
+ }
399
+ if (gate.outcome.kind === 'redraft') {
400
+ redraftFeedback = gate.outcome.feedback;
401
+ continue;
402
+ }
403
+
404
+ yield await ctx.emit({
405
+ type: 'assistant_message',
406
+ sessionId: ctx.sessionId,
407
+ turnId: ctx.turnId,
408
+ source: 'model',
409
+ content: planText,
410
+ stopReason: 'end_turn',
411
+ });
412
+ return { queries, originalPrompt };
413
+ }
414
+ }
@@ -0,0 +1,25 @@
1
+ import { runSingleShotTurn, type ModeContext, type ProviderMessage } from '@moxxy/sdk';
2
+
3
+ import { SYNTHESIS_SYSTEM_PROMPT } from './constants.js';
4
+
5
+ /**
6
+ * Run the synthesis turn: single-shot stream that consumes the
7
+ * per-subagent findings and produces the final structured writeup.
8
+ * Returns the assembled text or null on error.
9
+ */
10
+ export async function collectSynthesis(
11
+ ctx: ModeContext,
12
+ inputBody: string,
13
+ ): Promise<string | null> {
14
+ const messages: ProviderMessage[] = [
15
+ {
16
+ role: 'system',
17
+ content: [{ type: 'text', text: SYNTHESIS_SYSTEM_PROMPT }],
18
+ },
19
+ {
20
+ role: 'user',
21
+ content: [{ type: 'text', text: inputBody }],
22
+ },
23
+ ];
24
+ return runSingleShotTurn(ctx, messages, { maxTokens: 4096 });
25
+ }