@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.
- package/LICENSE +21 -0
- package/dist/approval.d.ts +32 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +84 -0
- package/dist/approval.js.map +1 -0
- package/dist/constants.d.ts +62 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +141 -0
- package/dist/constants.js.map +1 -0
- package/dist/fanout-phase.d.ts +44 -0
- package/dist/fanout-phase.d.ts.map +1 -0
- package/dist/fanout-phase.js +133 -0
- package/dist/fanout-phase.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/parse-queries.d.ts +13 -0
- package/dist/parse-queries.d.ts.map +1 -0
- package/dist/parse-queries.js +71 -0
- package/dist/parse-queries.js.map +1 -0
- package/dist/query-phase.d.ts +15 -0
- package/dist/query-phase.d.ts.map +1 -0
- package/dist/query-phase.js +81 -0
- package/dist/query-phase.js.map +1 -0
- package/dist/research-loop.d.ts +18 -0
- package/dist/research-loop.d.ts.map +1 -0
- package/dist/research-loop.js +357 -0
- package/dist/research-loop.js.map +1 -0
- package/dist/synthesis-phase.d.ts +8 -0
- package/dist/synthesis-phase.d.ts.map +1 -0
- package/dist/synthesis-phase.js +21 -0
- package/dist/synthesis-phase.js.map +1 -0
- package/package.json +73 -0
- package/src/approval.test.ts +82 -0
- package/src/approval.ts +105 -0
- package/src/constants.ts +153 -0
- package/src/fanout-phase.ts +177 -0
- package/src/index.test.ts +575 -0
- package/src/index.ts +27 -0
- package/src/parse-queries.ts +72 -0
- package/src/query-phase.ts +123 -0
- package/src/research-loop.ts +414 -0
- 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
|
+
}
|