actoviq-agent-sdk 0.4.1 → 0.4.2

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.
@@ -101,567 +101,125 @@ async function mapWithConcurrency(items, limit, fn) {
101
101
  return results;
102
102
  }
103
103
  // ═══════════════════════════════════════════════════════════════════
104
- // Panel Mode — multi-round autonomous deliberation
104
+ // Reviewer Mode — single read-only ReAct reviewer the main agent invokes
105
105
  // ═══════════════════════════════════════════════════════════════════
106
- async function runPanelMode(prompt, definition, signal) {
107
- const startedAt = Date.now();
108
- const primaryApi = await createMemberApi(definition.primary);
109
- const memberApis = await Promise.all(definition.members.map((m) => createMemberApi(m)));
110
- let rounds = 0;
111
- const allResponses = [];
112
- const modelSet = new Set(definition.members.map((m) => m.model));
113
- modelSet.add(definition.primary.model);
114
- const perModelTokens = new Map();
115
- let totalInput = 0;
116
- let totalOutput = 0;
117
- // Round 1: parallel panel
118
- rounds++;
119
- const round1Start = Date.now();
120
- const pool = getGlobalAgentPool();
121
- const panelResults = await mapWithConcurrency(definition.members, definition.maxParallel ?? definition.members.length, async (member, i) => {
122
- let slot;
123
- try {
124
- slot = await pool.acquire(definition.timeoutMs);
125
- const result = await singleModelCall(memberApis[i], prompt, member.systemPrompt, memberSignal(signal, definition.timeoutMs));
126
- const resp = {
127
- round: 1,
128
- model: member.model,
129
- content: result.content,
130
- tokens: { input: result.inputTokens, output: result.outputTokens },
131
- durationMs: result.durationMs,
132
- };
133
- // Track tokens
134
- const existing = perModelTokens.get(member.model) ?? { input: 0, output: 0 };
135
- existing.input += result.inputTokens;
136
- existing.output += result.outputTokens;
137
- perModelTokens.set(member.model, existing);
138
- totalInput += result.inputTokens;
139
- totalOutput += result.outputTokens;
140
- return resp;
141
- }
142
- catch {
143
- // Graceful degradation: return error marker
144
- return {
145
- round: 1,
146
- model: member.model,
147
- content: `[ERROR: ${member.model} failed to respond]`,
148
- tokens: { input: 0, output: 0 },
149
- durationMs: Date.now() - round1Start,
150
- };
151
- }
152
- finally {
153
- slot?.release();
154
- }
155
- });
156
- allResponses.push(...panelResults);
157
- // Primary model analyzes and decides
158
- const panelSummary = allResponses
159
- .map((r) => `### ${r.model}\n${r.content}`)
160
- .join('\n\n---\n\n');
161
- const analysisPrompt = [
162
- 'You are the primary decision-maker in a multi-model panel.',
163
- 'Below are responses from multiple models to the same question.',
164
- '',
165
- 'Your task:',
166
- '1. Analyze the responses — note agreements, contradictions, unique insights',
167
- '2. Decide whether the answer is complete or needs another round',
168
- '3. If complete, synthesize a comprehensive final answer',
169
- '4. If not complete, specify what needs further exploration',
170
- '',
171
- 'Original question:',
172
- prompt,
173
- '',
174
- 'Panel responses:',
175
- panelSummary,
176
- '',
177
- 'Respond in one of these formats:',
178
- '',
179
- 'To FINALIZE (answer is complete):',
180
- 'FINALIZE',
181
- '<your comprehensive synthesized answer>',
182
- '',
183
- 'To CONTINUE (need another round):',
184
- 'CONTINUE',
185
- '<refined question for the next round>',
186
- '<specific aspects to explore>',
187
- ].join('\n');
188
- const primaryResult = await singleModelCall(primaryApi, analysisPrompt, definition.primary?.systemPrompt, memberSignal(signal, definition.timeoutMs));
189
- totalInput += primaryResult.inputTokens;
190
- totalOutput += primaryResult.outputTokens;
191
- const primaryModel = definition.primary.model;
192
- const existing = perModelTokens.get(primaryModel) ?? { input: 0, output: 0 };
193
- existing.input += primaryResult.inputTokens;
194
- existing.output += primaryResult.outputTokens;
195
- perModelTokens.set(primaryModel, existing);
196
- // Parse primary decision
197
- const isContinue = primaryResult.content.trim().startsWith('CONTINUE');
198
- let finalAnswer;
199
- if (isContinue) {
200
- // Additional rounds — primary model continues to deliberate
201
- const refinedQuestion = primaryResult.content.replace(/^CONTINUE\s*/i, '').trim();
202
- let currentQuestion = refinedQuestion || prompt;
203
- let converged = false;
204
- // Carry the most recent round's responses forward for panel members.
205
- let previousContext = panelSummary;
206
- // Full deliberation history visible to the primary every round: all prior
207
- // panel responses AND the primary's own prior analyses (plan: the primary
208
- // accumulates full context across rounds to deliberate, not just round 1).
209
- const deliberationLog = [
210
- `## Round 1 — Panel responses\n${panelSummary}`,
211
- `## Round 1 — Primary analysis\n${primaryResult.content}`,
212
- ];
213
- const maxRounds = definition.maxRounds ?? 100;
214
- while (!converged && rounds < maxRounds) { // Safety cap, but primary decides convergence
215
- rounds++;
216
- const roundStart = Date.now();
217
- const roundResults = await mapWithConcurrency(definition.members, definition.maxParallel ?? definition.members.length, async (member, i) => {
218
- let slot;
219
- try {
220
- slot = await pool.acquire(definition.timeoutMs);
221
- const result = await singleModelCall(memberApis[i], `[Round ${rounds}] ${currentQuestion}\n\nPrevious round context:\n${previousContext}`, member.systemPrompt, memberSignal(signal, definition.timeoutMs));
222
- const resp = {
223
- round: rounds,
224
- model: member.model,
225
- content: result.content,
226
- tokens: { input: result.inputTokens, output: result.outputTokens },
227
- durationMs: Date.now() - roundStart,
228
- };
229
- const ex = perModelTokens.get(member.model) ?? { input: 0, output: 0 };
230
- ex.input += result.inputTokens;
231
- ex.output += result.outputTokens;
232
- perModelTokens.set(member.model, ex);
233
- totalInput += result.inputTokens;
234
- totalOutput += result.outputTokens;
235
- return resp;
236
- }
237
- catch {
238
- return {
239
- round: rounds,
240
- model: member.model,
241
- content: `[ERROR: ${member.model} failed]`,
242
- tokens: { input: 0, output: 0 },
243
- durationMs: Date.now() - roundStart,
244
- };
245
- }
246
- finally {
247
- slot?.release();
248
- }
249
- });
250
- allResponses.push(...roundResults);
251
- const roundSummary = roundResults
252
- .map((r) => `### ${r.model}\n${r.content}`)
253
- .join('\n\n---\n\n');
254
- previousContext = roundSummary;
255
- const followUpPrompt = [
256
- 'You are the primary decision-maker in a multi-model panel.',
257
- `This is round ${rounds}. Use the full deliberation history below (all prior rounds' panel responses and your own prior analyses), then weigh this round's new responses.`,
258
- '',
259
- 'Original question:',
260
- prompt,
261
- '',
262
- 'Full deliberation history:',
263
- deliberationLog.join('\n\n---\n\n'),
264
- '',
265
- `Round ${rounds} new panel responses to "${currentQuestion}":`,
266
- roundSummary,
267
- '',
268
- 'Decide: FINALIZE (then give the comprehensive synthesized answer) or CONTINUE (then give a refined question and the specific aspects to explore).',
269
- ].join('\n');
270
- const followUpResult = await singleModelCall(primaryApi, followUpPrompt, definition.primary?.systemPrompt, memberSignal(signal, definition.timeoutMs));
271
- totalInput += followUpResult.inputTokens;
272
- totalOutput += followUpResult.outputTokens;
273
- const pex = perModelTokens.get(primaryModel) ?? { input: 0, output: 0 };
274
- pex.input += followUpResult.inputTokens;
275
- pex.output += followUpResult.outputTokens;
276
- perModelTokens.set(primaryModel, pex);
277
- if (followUpResult.content.trim().startsWith('FINALIZE')) {
278
- finalAnswer = followUpResult.content.replace(/^FINALIZE\s*/i, '').trim();
279
- converged = true;
280
- }
281
- else {
282
- currentQuestion = followUpResult.content.replace(/^CONTINUE\s*/i, '').trim() || currentQuestion;
283
- }
284
- deliberationLog.push(`## Round ${rounds} — Panel responses\n${roundSummary}`);
285
- deliberationLog.push(`## Round ${rounds} — Primary decision\n${followUpResult.content}`);
286
- }
287
- if (!converged) {
288
- finalAnswer = primaryResult.content;
289
- }
290
- }
291
- else {
292
- finalAnswer = primaryResult.content.replace(/^FINALIZE\s*/i, '').trim();
293
- }
294
- const cost = computeCost([...modelSet], totalInput, totalOutput, perModelTokens);
295
- return {
296
- answer: finalAnswer,
297
- mode: 'panel',
298
- rounds,
299
- panelResponses: allResponses,
300
- cost,
301
- durationMs: Date.now() - startedAt,
302
- };
106
+ /** Read-only tool set for expert/reviewer agents (no write/edit/bash/delegation). */
107
+ async function buildReadOnlyExpertTools(cwd) {
108
+ const { createActoviqFileTools } = await import('../tools/actoviqFileTools.js');
109
+ const { createActoviqWebTools } = await import('../tools/actoviqWebTools.js');
110
+ const { createTavilySearchTool } = await import('../tools/tavilySearch.js');
111
+ const READ_ONLY_FILE_TOOLS = new Set(['Read', 'Glob', 'Grep']);
112
+ return [
113
+ ...createActoviqFileTools({ cwd }).filter((t) => READ_ONLY_FILE_TOOLS.has(t.name)),
114
+ ...createActoviqWebTools().filter((t) => t.name === 'WebFetch'),
115
+ createTavilySearchTool(),
116
+ ];
303
117
  }
304
- // ═══════════════════════════════════════════════════════════════════
305
- // Router Mode user-configured dispatch
306
- // ═══════════════════════════════════════════════════════════════════
307
- async function runRouterMode(prompt, definition, signal) {
118
+ /**
119
+ * A single read-only ReAct agent (Read/Glob/Grep + TavilySearch/WebFetch) that
120
+ * inspects the project and returns confirmed issues. The main agent (the
121
+ * "executor") invokes it as a tool, decides what `context` to inject — what it
122
+ * did and the results it obtained, placed in the reviewer's system prompt — and
123
+ * keeps final authority over the findings. The reviewer is told to scrutinize
124
+ * hard but confirm ONLY issues it can actually verify: no speculation, no
125
+ * fabricated or padded findings.
126
+ */
127
+ async function runReviewerMode(task, definition, signal, context, workDir) {
308
128
  const startedAt = Date.now();
309
- const routerApi = await createMemberApi(definition.router);
310
- const specialistApis = new Map();
311
- for (const [key, spec] of Object.entries(definition.specialists ?? {})) {
312
- specialistApis.set(key, await createMemberApi(spec));
313
- }
314
- const fallbackApi = definition.fallback ? await createMemberApi(definition.fallback) : null;
315
- // Build classification prompt
316
- const specialistDescriptions = Object.entries(definition.specialists ?? {})
317
- .map(([key, spec]) => `- ${key}: ${spec.description ?? 'General tasks'}`)
318
- .join('\n');
319
- const classificationPrompt = definition.classificationPrompt ?? [
320
- 'You are a task classifier. Based on the user\'s request, determine which specialist should handle it.',
321
- '',
322
- 'Available specialists:',
323
- specialistDescriptions,
324
- definition.fallback ? `- fallback: ${definition.fallback.description ?? 'General assistance'}` : null,
325
- '',
326
- 'User request:',
327
- prompt,
328
- '',
329
- 'Return ONLY the specialist name (one word, lowercase).',
129
+ const reviewer = definition.reviewer;
130
+ // Lazy import avoids a circular dependency (agentClient imports modelTeam).
131
+ const { createAgentSdk } = await import('../runtime/agentClient.js');
132
+ const cwd = workDir ?? process.cwd();
133
+ const reviewerFraming = [
134
+ 'You are a meticulous reviewer on a multi-model team.',
135
+ 'Inspect the project using ONLY your read-only tools (Read/Glob/Grep) and the web',
136
+ '(TavilySearch/WebFetch). You cannot modify files, write code, or run commands.',
137
+ 'Scrutinize as thoroughly as you can and surface every genuine problem — bugs, broken',
138
+ 'logic, security holes, missed edge cases, violated requirements — each with concrete',
139
+ 'file:line evidence. Critically: confirm ONLY issues you can actually verify from the',
140
+ 'code or files. Do not speculate, invent, or pad the list; if something is uncertain,',
141
+ 'say so explicitly instead of asserting it. If you find no real issues, say so plainly.',
142
+ ].join(' ');
143
+ const systemPrompt = [
144
+ reviewerFraming,
145
+ context ? `\n## Context from the requesting agent (what it did and obtained)\n${context}` : '',
146
+ reviewer.systemPrompt ? `\n${reviewer.systemPrompt}` : '',
330
147
  ].filter(Boolean).join('\n');
331
- const classifyResult = await singleModelCall(routerApi, classificationPrompt, definition.router?.systemPrompt, memberSignal(signal, definition.timeoutMs));
332
- const specialistKey = classifyResult.content.trim().toLowerCase();
333
- const validKeys = Object.keys(definition.specialists ?? {});
334
- let chosenApi;
335
- let chosenKey;
336
- let chosenMember;
337
- if (validKeys.includes(specialistKey)) {
338
- chosenApi = specialistApis.get(specialistKey);
339
- chosenKey = specialistKey;
340
- chosenMember = definition.specialists[specialistKey];
341
- }
342
- else if (fallbackApi) {
343
- chosenApi = fallbackApi;
344
- chosenKey = 'fallback';
345
- chosenMember = definition.fallback;
346
- }
347
- else {
348
- // No fallback, pick first specialist
349
- chosenKey = validKeys[0] ?? 'default';
350
- chosenApi = specialistApis.get(chosenKey);
351
- chosenMember = definition.specialists[chosenKey];
352
- }
353
- const result = await singleModelCall(chosenApi, prompt, chosenMember.systemPrompt, memberSignal(signal, definition.timeoutMs));
354
- const modelSet = new Set();
355
- modelSet.add(definition.router.model);
356
- modelSet.add(chosenMember.model);
357
- const perModelTokens = new Map();
358
- perModelTokens.set(definition.router.model, { input: classifyResult.inputTokens, output: classifyResult.outputTokens });
359
- perModelTokens.set(chosenMember.model, { input: result.inputTokens, output: result.outputTokens });
360
- const cost = computeCost([...modelSet], classifyResult.inputTokens + result.inputTokens, classifyResult.outputTokens + result.outputTokens, perModelTokens);
361
- return {
362
- answer: result.content,
363
- mode: 'router',
364
- specialist: chosenKey,
365
- classification: classifyResult.content,
366
- cost,
367
- durationMs: Date.now() - startedAt,
368
- };
369
- }
370
- // ═══════════════════════════════════════════════════════════════════
371
- // Discussion Mode — roundtable for hard problems
372
- // ═══════════════════════════════════════════════════════════════════
373
- async function runDiscussionMode(prompt, definition, signal) {
374
- const startedAt = Date.now();
375
- const primaryApi = await createMemberApi(definition.primary);
376
- const memberApis = await Promise.all(definition.members.map((m) => createMemberApi(m)));
377
- const facilitatorApi = definition.facilitator
378
- ? await createMemberApi(definition.facilitator)
379
- : primaryApi; // fallback: primary acts as facilitator
380
- let rounds = 0;
381
- let converged = false;
382
- let finalAnswer = '';
383
- const facilitatorVerdicts = [];
384
- const modelSet = new Set();
385
- definition.members.forEach((m) => modelSet.add(m.model));
386
- modelSet.add(definition.primary.model);
387
- if (definition.facilitator)
388
- modelSet.add(definition.facilitator.model);
389
148
  const perModelTokens = new Map();
390
149
  let totalInput = 0;
391
150
  let totalOutput = 0;
392
- let discussionTranscript = `# Discussion Topic\n${prompt}\n\n`;
393
- const maxRounds = definition.maxRounds ?? 100;
394
- while (!converged && rounds < maxRounds) {
395
- rounds++;
396
- // Sequential speaking: each member sees prior speakers
397
- for (let i = 0; i < definition.members.length; i++) {
398
- const member = definition.members[i];
399
- const speakPrompt = [
400
- `## Discussion Round ${rounds}`,
401
- '',
402
- `Topic: ${prompt}`,
403
- '',
404
- 'Previous discussion:',
405
- discussionTranscript,
406
- '',
407
- `You are ${member.model}. Please contribute your perspective. ` +
408
- 'Consider what previous speakers have said. Build on agreements, address disagreements.',
409
- ].join('\n');
410
- const result = await singleModelCall(memberApis[i], speakPrompt, member.systemPrompt, memberSignal(signal, definition.timeoutMs));
411
- discussionTranscript += `\n### ${member.model} (Round ${rounds})\n${result.content}\n`;
412
- const ex = perModelTokens.get(member.model) ?? { input: 0, output: 0 };
413
- ex.input += result.inputTokens;
414
- ex.output += result.outputTokens;
415
- perModelTokens.set(member.model, ex);
416
- totalInput += result.inputTokens;
417
- totalOutput += result.outputTokens;
418
- }
419
- // Facilitator subagent rules after the round
420
- const facilitatorPrompt = [
421
- 'You are a discussion facilitator. Review the roundtable transcript and provide:',
422
- '',
423
- '1. **Summary**: Key points raised, areas of agreement and disagreement',
424
- '2. **Progress Assessment**: Is the discussion converging? Are there blockers?',
425
- '3. **Verdict**: Should discussion CONTINUE or FINALIZE?',
426
- '',
427
- 'Respond in this format:',
428
- '',
429
- 'SUMMARY:',
430
- '<summary>',
431
- '',
432
- 'VERDICT: CONTINUE|FINALIZE',
433
- '',
434
- 'If CONTINUE, add:',
435
- 'NEXT_TOPIC: <what the next round should focus on>',
436
- '',
437
- 'Discussion transcript:',
438
- discussionTranscript,
439
- ].join('\n');
440
- const facilitatorResult = await singleModelCall(facilitatorApi, facilitatorPrompt, definition.facilitator?.systemPrompt, memberSignal(signal, definition.timeoutMs));
441
- if (definition.facilitator) {
442
- const fex = perModelTokens.get(definition.facilitator.model) ?? { input: 0, output: 0 };
443
- fex.input += facilitatorResult.inputTokens;
444
- fex.output += facilitatorResult.outputTokens;
445
- perModelTokens.set(definition.facilitator.model, fex);
446
- }
447
- totalInput += facilitatorResult.inputTokens;
448
- totalOutput += facilitatorResult.outputTokens;
449
- const summaryMatch = facilitatorResult.content.match(/SUMMARY:\s*\n([\s\S]*?)(?=\nVERDICT:|$)/i);
450
- const verdictMatch = facilitatorResult.content.match(/VERDICT:\s*(CONTINUE|FINALIZE)/i);
451
- const nextTopicMatch = facilitatorResult.content.match(/NEXT_TOPIC:\s*(.+)/i);
452
- const verdict = verdictMatch?.[1]?.toUpperCase() === 'FINALIZE' ? 'finalize' : 'continue';
453
- facilitatorVerdicts.push({
454
- round: rounds,
455
- summary: summaryMatch?.[1]?.trim() ?? facilitatorResult.content,
456
- verdict,
151
+ let report;
152
+ let toolCalls = 0;
153
+ const pool = getGlobalAgentPool();
154
+ let slot;
155
+ let sdk;
156
+ try {
157
+ slot = await pool.acquire(definition.timeoutMs);
158
+ sdk = await createAgentSdk({
159
+ model: reviewer.model,
160
+ provider: reviewer.provider,
161
+ baseURL: reviewer.baseURL,
162
+ authToken: resolveApiKey(reviewer.apiKey),
163
+ maxTokens: reviewer.maxTokens ?? 32000,
164
+ workDir: cwd,
165
+ tools: await buildReadOnlyExpertTools(cwd),
166
+ permissionMode: 'bypassPermissions',
167
+ maxToolIterations: definition.maxIterations ?? 16,
168
+ systemPrompt,
457
169
  });
458
- // Primary model makes final decision
459
- const primaryDecisionPrompt = [
460
- 'As the primary decision-maker, review the facilitator\'s report and the full discussion.',
461
- '',
462
- 'Facilitator recommendation:',
463
- facilitatorResult.content,
464
- '',
465
- 'Full discussion transcript:',
466
- discussionTranscript,
467
- '',
468
- 'You may override the facilitator\'s recommendation. Decide:',
469
- '- FINALIZE: synthesize the solution and deliver it',
470
- '- CONTINUE: specify what the next round should address',
471
- '',
472
- 'Respond with FINALIZE or CONTINUE followed by your reasoning/output.',
473
- ].join('\n');
474
- const primaryResult = await singleModelCall(primaryApi, primaryDecisionPrompt, definition.primary?.systemPrompt, memberSignal(signal, definition.timeoutMs));
475
- const pex = perModelTokens.get(definition.primary.model) ?? { input: 0, output: 0 };
476
- pex.input += primaryResult.inputTokens;
477
- pex.output += primaryResult.outputTokens;
478
- perModelTokens.set(definition.primary.model, pex);
479
- totalInput += primaryResult.inputTokens;
480
- totalOutput += primaryResult.outputTokens;
481
- if (primaryResult.content.trim().startsWith('FINALIZE')) {
482
- finalAnswer = primaryResult.content.replace(/^FINALIZE\s*/i, '').trim();
483
- converged = true;
484
- }
485
- else {
486
- const nextTopic = nextTopicMatch?.[1] ?? primaryResult.content.replace(/^CONTINUE\s*/i, '').trim();
487
- discussionTranscript += `\n### Facilitator (Round ${rounds})\nVerdict: CONTINUE\nNext topic: ${nextTopic}\n`;
488
- }
170
+ const result = await sdk.run(task, { signal: memberSignal(signal, definition.timeoutMs) });
171
+ report = result.text;
172
+ toolCalls = result.toolCalls.length;
173
+ const input = result.requests.reduce((s, r) => s + (r.usage?.input_tokens ?? 0), 0);
174
+ const output = result.requests.reduce((s, r) => s + (r.usage?.output_tokens ?? 0), 0);
175
+ perModelTokens.set(reviewer.model, { input, output });
176
+ totalInput = input;
177
+ totalOutput = output;
489
178
  }
490
- if (!converged) {
491
- finalAnswer = discussionTranscript;
179
+ catch (err) {
180
+ const message = err instanceof Error ? err.message : String(err);
181
+ report = `[ERROR: reviewer ${reviewer.model} failed — ${message}]`;
492
182
  }
493
- const cost = computeCost([...modelSet], totalInput, totalOutput, perModelTokens);
494
- return {
495
- answer: finalAnswer,
496
- mode: 'discussion',
497
- rounds,
498
- facilitatorVerdicts,
499
- cost,
500
- durationMs: Date.now() - startedAt,
501
- };
502
- }
503
- // ═══════════════════════════════════════════════════════════════════
504
- // Executor-Reviewer Mode — advisory critique loop
505
- // ═══════════════════════════════════════════════════════════════════
506
- async function runExecutorReviewerMode(prompt, definition, signal) {
507
- const startedAt = Date.now();
508
- const executorApi = await createMemberApi(definition.executor);
509
- const reviewerApi = await createMemberApi(definition.reviewer);
510
- const decisions = [];
511
- const reviews = [];
512
- const modelSet = new Set([definition.executor.model, definition.reviewer.model]);
513
- const perModelTokens = new Map();
514
- let totalInput = 0;
515
- let totalOutput = 0;
516
- let iterations = 0;
517
- let converged = false;
518
- let finalAnswer = '';
519
- let currentOutput = '';
520
- const maxIterations = definition.maxIterations ?? 100;
521
- // Full history visible to both roles each iteration (plan: prevents the
522
- // reviewer from repeating already-addressed or rejected suggestions).
523
- const transcript = [];
524
- // Iteration 1: Executor produces initial output
525
- iterations++;
526
- const execResult = await singleModelCall(executorApi, prompt, definition.executor?.systemPrompt, memberSignal(signal, definition.timeoutMs));
527
- currentOutput = execResult.content;
528
- transcript.push(`## Iteration 1 — Executor output\n${currentOutput}`);
529
- perModelTokens.set(definition.executor.model, { input: execResult.inputTokens, output: execResult.outputTokens });
530
- totalInput += execResult.inputTokens;
531
- totalOutput += execResult.outputTokens;
532
- while (!converged && iterations < maxIterations) {
533
- // Reviewer critiques
534
- const reviewPrompt = [
535
- 'You are an experienced reviewer providing constructive feedback.',
536
- 'The history below shows prior outputs, your earlier reviews, and the executor\'s decisions (including which suggestions were rejected and why). Do NOT repeat suggestions already addressed or explicitly rejected — focus on new, unaddressed improvements.',
537
- '',
538
- 'Original request:',
539
- prompt,
540
- '',
541
- 'History so far:',
542
- transcript.join('\n\n---\n\n'),
543
- '',
544
- 'Latest output to review:',
545
- currentOutput,
546
- '',
547
- 'Provide specific, actionable feedback. You are an advisor — the executor makes final decisions.',
548
- ].join('\n');
549
- const reviewResult = await singleModelCall(reviewerApi, reviewPrompt, definition.reviewer?.systemPrompt, memberSignal(signal, definition.timeoutMs));
550
- reviews.push({ iteration: iterations, feedback: reviewResult.content });
551
- transcript.push(`## Iteration ${iterations} — Reviewer feedback\n${reviewResult.content}`);
552
- const rex = perModelTokens.get(definition.reviewer.model) ?? { input: 0, output: 0 };
553
- rex.input += reviewResult.inputTokens;
554
- rex.output += reviewResult.outputTokens;
555
- perModelTokens.set(definition.reviewer.model, rex);
556
- totalInput += reviewResult.inputTokens;
557
- totalOutput += reviewResult.outputTokens;
558
- // Executor decides: accept/reject/partial/finalize
559
- const decisionPrompt = [
560
- 'You are the executor with final authority. Review the feedback and decide.',
561
- '',
562
- 'Original request:',
563
- prompt,
564
- '',
565
- 'History so far (prior outputs, reviews, and your past decisions):',
566
- transcript.join('\n\n---\n\n'),
567
- '',
568
- 'Your current output:',
569
- currentOutput,
570
- '',
571
- 'Latest reviewer feedback:',
572
- reviewResult.content,
573
- '',
574
- 'Decide (respond with exactly one action keyword followed by explanation/output):',
575
- '- ACCEPT: incorporate the feedback and provide revised output',
576
- '- REJECT: explain why the suggestion is not accepted',
577
- '- PARTIAL: accept some suggestions, reject others, provide revised output',
578
- '- FINALIZE: the work is done, deliver final output',
579
- '',
580
- 'Format: ACTION_KEYWORD',
581
- '<explanation or revised output>',
582
- ].join('\n');
583
- const decisionResult = await singleModelCall(executorApi, decisionPrompt, definition.executor?.systemPrompt, memberSignal(signal, definition.timeoutMs));
584
- const dex = perModelTokens.get(definition.executor.model) ?? { input: 0, output: 0 };
585
- dex.input += decisionResult.inputTokens;
586
- dex.output += decisionResult.outputTokens;
587
- perModelTokens.set(definition.executor.model, dex);
588
- totalInput += decisionResult.inputTokens;
589
- totalOutput += decisionResult.outputTokens;
590
- // Parse decision
591
- const lines = decisionResult.content.trim().split('\n');
592
- const actionLine = lines[0]?.trim().toUpperCase() ?? '';
593
- const explanation = lines.slice(1).join('\n').trim();
594
- let action;
595
- if (actionLine.startsWith('FINALIZE')) {
596
- action = 'finalize';
597
- finalAnswer = explanation || currentOutput;
598
- converged = true;
599
- }
600
- else if (actionLine.startsWith('ACCEPT')) {
601
- action = 'accept';
602
- currentOutput = explanation || decisionResult.content;
603
- }
604
- else if (actionLine.startsWith('REJECT')) {
605
- action = 'reject';
606
- // Keep current output, note rejection
607
- }
608
- else if (actionLine.startsWith('PARTIAL')) {
609
- action = 'partial';
610
- currentOutput = explanation || decisionResult.content;
611
- }
612
- else {
613
- // Default: treat as finalize
614
- action = 'finalize';
615
- finalAnswer = decisionResult.content;
616
- converged = true;
617
- }
618
- decisions.push({
619
- iteration: iterations,
620
- action,
621
- explanation: explanation || decisionResult.content,
622
- });
623
- transcript.push(`## Iteration ${iterations} — Executor decision (${action})\n${explanation || decisionResult.content}`);
624
- iterations++;
625
- }
626
- if (!converged) {
627
- finalAnswer = currentOutput;
183
+ finally {
184
+ if (sdk)
185
+ await sdk.close();
186
+ slot?.release();
628
187
  }
629
- const cost = computeCost([...modelSet], totalInput, totalOutput, perModelTokens);
188
+ const cost = computeCost([...perModelTokens.keys()], totalInput, totalOutput, perModelTokens);
630
189
  return {
631
- answer: finalAnswer,
632
- mode: 'executor-reviewer',
633
- iterations,
634
- decisions,
635
- reviews,
190
+ answer: report,
191
+ mode: 'reviewer',
192
+ report,
193
+ toolCalls,
636
194
  cost,
637
195
  durationMs: Date.now() - startedAt,
638
196
  };
639
197
  }
640
198
  // ═══════════════════════════════════════════════════════════════════
641
- // Analysis Mode — read-only ReAct expert panel (advisory)
199
+ // Panel-Analysis Mode — read-only ReAct expert panel + optional convergence
642
200
  // ═══════════════════════════════════════════════════════════════════
643
201
  /**
644
- * Each member is an independent, read-only ReAct agent (Read/Glob/Grep +
645
- * TavilySearch/WebFetch — no write/edit/bash/delegation) that investigates the
646
- * task and the project in parallel and returns a findings report. No synthesis,
647
- * no convergence: the caller (the main agent) decides what to do with the
648
- * reports. This is the autonomous "expert panel" the main model invokes as a
649
- * tool to assist analysis on large/complex tasks; it only analyzes and reports.
202
+ * Unified expert panel. Each member is an independent, read-only ReAct agent
203
+ * (Read/Glob/Grep + TavilySearch/WebFetch — no write/edit/bash/delegation) that
204
+ * investigates the task in parallel and returns a findings report.
205
+ *
206
+ * - No `primary` single-pass advisory: the caller (the main agent) decides
207
+ * what to do with the concatenated reports (the original `analysis` behavior).
208
+ * - With a `primary` → multi-round convergence: after each round the primary
209
+ * synthesizes the findings and decides FINALIZE or CONTINUE; on CONTINUE the
210
+ * panel re-investigates a refined question with prior findings as context.
211
+ * Harness principle preserved: the primary decides convergence; `maxRounds`
212
+ * (default 100) is only a safety cap.
213
+ *
214
+ * `analysis` and `panel-analysis` both route here; the result `mode` echoes the
215
+ * requested alias.
650
216
  */
651
- async function runAnalysisMode(prompt, definition, signal) {
217
+ async function runPanelAnalysisMode(prompt, definition, signal, workDir) {
652
218
  const startedAt = Date.now();
653
219
  // Lazy imports avoid a circular dependency (agentClient imports modelTeam).
654
220
  const { createAgentSdk } = await import('../runtime/agentClient.js');
655
- const { createActoviqFileTools } = await import('../tools/actoviqFileTools.js');
656
- const { createActoviqWebTools } = await import('../tools/actoviqWebTools.js');
657
- const { createTavilySearchTool } = await import('../tools/tavilySearch.js');
658
- const cwd = process.cwd();
659
- const READ_ONLY_FILE_TOOLS = new Set(['Read', 'Glob', 'Grep']);
660
- const buildReadOnlyTools = () => [
661
- ...createActoviqFileTools({ cwd }).filter((t) => READ_ONLY_FILE_TOOLS.has(t.name)),
662
- ...createActoviqWebTools().filter((t) => t.name === 'WebFetch'),
663
- createTavilySearchTool(),
664
- ];
221
+ const cwd = workDir ?? process.cwd();
222
+ const readOnlyTools = await buildReadOnlyExpertTools(cwd);
665
223
  // Bounded ReAct depth per member so a panel can't run away (configurable).
666
224
  const memberMaxIterations = definition.maxIterations ?? 16;
667
225
  const pool = getGlobalAgentPool();
@@ -676,69 +234,167 @@ async function runAnalysisMode(prompt, definition, signal) {
676
234
  'sources), risks, blind spots, concrete recommendations, and anything the main agent should',
677
235
  'verify. Be specific and decision-useful.',
678
236
  ].join(' ');
679
- const reports = await mapWithConcurrency(definition.members, definition.maxParallel ?? definition.members.length, async (member) => {
680
- const start = Date.now();
681
- let slot;
682
- let sdk;
683
- try {
684
- slot = await pool.acquire();
685
- sdk = await createAgentSdk({
686
- model: member.model,
687
- provider: member.provider,
688
- baseURL: member.baseURL,
689
- authToken: resolveApiKey(member.apiKey),
690
- maxTokens: member.maxTokens ?? 32000,
691
- workDir: cwd,
692
- tools: buildReadOnlyTools(),
693
- permissionMode: 'bypassPermissions',
694
- maxToolIterations: memberMaxIterations,
695
- systemPrompt: member.systemPrompt
696
- ? `${analysisFraming}\n\n${member.systemPrompt}`
697
- : analysisFraming,
698
- });
699
- const result = await sdk.run(prompt, {
700
- signal: memberSignal(signal, definition.timeoutMs),
701
- });
702
- const input = result.requests.reduce((s, r) => s + (r.usage?.input_tokens ?? 0), 0);
703
- const output = result.requests.reduce((s, r) => s + (r.usage?.output_tokens ?? 0), 0);
704
- const ex = perModelTokens.get(member.model) ?? { input: 0, output: 0 };
705
- ex.input += input;
706
- ex.output += output;
707
- perModelTokens.set(member.model, ex);
708
- totalInput += input;
709
- totalOutput += output;
710
- return {
711
- model: member.model,
712
- report: result.text,
713
- toolCalls: result.toolCalls.length,
714
- durationMs: Date.now() - start,
715
- };
716
- }
717
- catch (err) {
718
- const message = err instanceof Error ? err.message : String(err);
719
- return {
720
- model: member.model,
721
- report: `[ERROR: ${member.model} analysis failed — ${message}]`,
722
- toolCalls: 0,
723
- durationMs: Date.now() - start,
724
- };
725
- }
726
- finally {
727
- if (sdk)
728
- await sdk.close();
729
- slot?.release();
730
- }
237
+ // One investigation round: every member investigates `question` in parallel as
238
+ // a read-only ReAct agent. `priorContext` (earlier rounds' findings) lets
239
+ // members build on the deliberation instead of starting cold.
240
+ const investigate = async (round, question, priorContext) => {
241
+ const memberPrompt = priorContext
242
+ ? `${question}\n\n## Prior panel findings (build on these; don't repeat established points)\n${priorContext}`
243
+ : question;
244
+ return mapWithConcurrency(definition.members, definition.maxParallel ?? definition.members.length, async (member) => {
245
+ const start = Date.now();
246
+ let slot;
247
+ let sdk;
248
+ try {
249
+ slot = await pool.acquire();
250
+ sdk = await createAgentSdk({
251
+ model: member.model,
252
+ provider: member.provider,
253
+ baseURL: member.baseURL,
254
+ authToken: resolveApiKey(member.apiKey),
255
+ maxTokens: member.maxTokens ?? 32000,
256
+ workDir: cwd,
257
+ tools: readOnlyTools,
258
+ permissionMode: 'bypassPermissions',
259
+ maxToolIterations: memberMaxIterations,
260
+ systemPrompt: member.systemPrompt
261
+ ? `${analysisFraming}\n\n${member.systemPrompt}`
262
+ : analysisFraming,
263
+ });
264
+ const result = await sdk.run(memberPrompt, {
265
+ signal: memberSignal(signal, definition.timeoutMs),
266
+ });
267
+ const input = result.requests.reduce((s, r) => s + (r.usage?.input_tokens ?? 0), 0);
268
+ const output = result.requests.reduce((s, r) => s + (r.usage?.output_tokens ?? 0), 0);
269
+ const ex = perModelTokens.get(member.model) ?? { input: 0, output: 0 };
270
+ ex.input += input;
271
+ ex.output += output;
272
+ perModelTokens.set(member.model, ex);
273
+ totalInput += input;
274
+ totalOutput += output;
275
+ return {
276
+ model: member.model,
277
+ report: result.text,
278
+ toolCalls: result.toolCalls.length,
279
+ durationMs: Date.now() - start,
280
+ round,
281
+ };
282
+ }
283
+ catch (err) {
284
+ const message = err instanceof Error ? err.message : String(err);
285
+ return {
286
+ model: member.model,
287
+ report: `[ERROR: ${member.model} analysis failed — ${message}]`,
288
+ toolCalls: 0,
289
+ durationMs: Date.now() - start,
290
+ round,
291
+ };
292
+ }
293
+ finally {
294
+ if (sdk)
295
+ await sdk.close();
296
+ slot?.release();
297
+ }
298
+ });
299
+ };
300
+ const resultMode = definition.mode === 'analysis' ? 'analysis' : 'panel-analysis';
301
+ // With a primary, build the decision callback that synthesizes findings and
302
+ // votes FINALIZE/CONTINUE each round; without one, the panel is single-pass.
303
+ let decide;
304
+ if (definition.primary) {
305
+ const primaryApi = await createMemberApi(definition.primary);
306
+ const primaryModel = definition.primary.model;
307
+ const primarySystem = definition.primary.systemPrompt;
308
+ decide = async (deliberationLog) => {
309
+ const decisionPrompt = [
310
+ 'You are the primary synthesizer over a panel of expert analysts; each investigated the task with read-only tools and reported findings.',
311
+ 'Review the full panel findings, then decide whether they are sufficient to answer the task.',
312
+ '',
313
+ 'Original task:',
314
+ prompt,
315
+ '',
316
+ 'Full panel findings so far:',
317
+ deliberationLog,
318
+ '',
319
+ 'Respond in one of these formats:',
320
+ '',
321
+ 'To FINALIZE (findings are sufficient):',
322
+ 'FINALIZE',
323
+ '<your comprehensive synthesized answer, grounded in the findings>',
324
+ '',
325
+ 'To CONTINUE (deeper investigation needed):',
326
+ 'CONTINUE',
327
+ '<a refined question and the specific aspects the panel should investigate next>',
328
+ ].join('\n');
329
+ const decision = await singleModelCall(primaryApi, decisionPrompt, primarySystem, memberSignal(signal, definition.timeoutMs));
330
+ totalInput += decision.inputTokens;
331
+ totalOutput += decision.outputTokens;
332
+ const pex = perModelTokens.get(primaryModel) ?? { input: 0, output: 0 };
333
+ pex.input += decision.inputTokens;
334
+ pex.output += decision.outputTokens;
335
+ perModelTokens.set(primaryModel, pex);
336
+ return decision.content;
337
+ };
338
+ }
339
+ const { answer, rounds, reports } = await orchestratePanel({
340
+ prompt,
341
+ maxRounds: definition.maxRounds ?? 100,
342
+ investigate,
343
+ decide,
731
344
  });
732
- const answer = reports.map((r) => `### ${r.model}\n${r.report}`).join('\n\n---\n\n');
733
345
  const cost = computeCost([...perModelTokens.keys()], totalInput, totalOutput, perModelTokens);
734
346
  return {
735
347
  answer,
736
- mode: 'analysis',
348
+ mode: resultMode,
737
349
  reports,
350
+ rounds,
738
351
  cost,
739
352
  durationMs: Date.now() - startedAt,
740
353
  };
741
354
  }
355
+ /**
356
+ * Orchestrate the panel over an injectable `investigate` (one round of member
357
+ * reports) and optional `decide` (the primary's synthesize-or-continue call).
358
+ *
359
+ * - No `decide` → single-pass advisory: round 1 only; answer = labeled reports.
360
+ * - With `decide` → multi-round convergence: after each round the primary votes
361
+ * FINALIZE (synthesize) or CONTINUE (refined question); loops until FINALIZE
362
+ * or `maxRounds` (safety cap). The keyword match is case-insensitive, and any
363
+ * decision that is not a leading CONTINUE finalizes (graceful default).
364
+ *
365
+ * Exported so the convergence logic is unit-testable without real model calls.
366
+ */
367
+ export async function orchestratePanel(opts) {
368
+ const label = (rs) => rs.map((r) => `### ${r.model}\n${r.report}`).join('\n\n---\n\n');
369
+ const allReports = [];
370
+ let rounds = 1;
371
+ let currentReports = await opts.investigate(1, opts.prompt);
372
+ allReports.push(...currentReports);
373
+ // No primary → single-pass advisory (original `analysis` behavior).
374
+ if (!opts.decide) {
375
+ return { answer: label(currentReports), rounds, reports: allReports };
376
+ }
377
+ const deliberationLog = [`## Round 1 — Panel findings\n${label(currentReports)}`];
378
+ while (true) {
379
+ const content = await opts.decide(deliberationLog.join('\n\n---\n\n'), rounds);
380
+ const wantsContinue = content.trim().toUpperCase().startsWith('CONTINUE');
381
+ if (wantsContinue && rounds < opts.maxRounds) {
382
+ const refined = content.replace(/^CONTINUE\s*/i, '').trim() || opts.prompt;
383
+ deliberationLog.push(`## Round ${rounds} — Primary decision\nCONTINUE: ${refined}`);
384
+ rounds++;
385
+ currentReports = await opts.investigate(rounds, refined, label(currentReports));
386
+ allReports.push(...currentReports);
387
+ deliberationLog.push(`## Round ${rounds} — Panel findings\n${label(currentReports)}`);
388
+ }
389
+ else {
390
+ // FINALIZE, or the safety cap was reached mid-deliberation.
391
+ const answer = wantsContinue
392
+ ? label(currentReports) // cap hit: hand back the findings unsynthesized
393
+ : content.replace(/^FINALIZE\s*/i, '').trim() || label(currentReports);
394
+ return { answer, rounds, reports: allReports };
395
+ }
396
+ }
397
+ }
742
398
  // ═══════════════════════════════════════════════════════════════════
743
399
  // ModelTeam class
744
400
  // ═══════════════════════════════════════════════════════════════════
@@ -750,23 +406,19 @@ export class ModelTeam {
750
406
  this.definition = definition;
751
407
  }
752
408
  /**
753
- * Ask the team a question. Returns the synthesized answer.
754
- * Panel/Discussion: primary model decides when to converge.
755
- * Router: classifies and dispatches.
756
- * Executor-Reviewer: executor decides when done.
409
+ * Ask the team. `opts.context` is injected into the reviewer's system prompt
410
+ * (reviewer mode only what the main agent did and obtained). Returns the
411
+ * mode-specific result.
757
412
  */
758
- async ask(prompt, signal) {
413
+ async ask(prompt, signal, opts) {
759
414
  switch (this.definition.mode) {
760
- case 'panel':
761
- return runPanelMode(prompt, this.definition, signal);
762
- case 'router':
763
- return runRouterMode(prompt, this.definition, signal);
764
- case 'discussion':
765
- return runDiscussionMode(prompt, this.definition, signal);
766
- case 'executor-reviewer':
767
- return runExecutorReviewerMode(prompt, this.definition, signal);
415
+ case 'reviewer':
416
+ case 'executor-reviewer': // alias: the retired executor loop now runs the single reviewer
417
+ return runReviewerMode(prompt, this.definition, signal, opts?.context, opts?.workDir);
418
+ case 'panel': // alias: retired pure-text panel now runs the unified engine
768
419
  case 'analysis':
769
- return runAnalysisMode(prompt, this.definition, signal);
420
+ case 'panel-analysis':
421
+ return runPanelAnalysisMode(prompt, this.definition, signal, opts?.workDir);
770
422
  default:
771
423
  throw new Error(`Unknown team mode: ${this.definition.mode}`);
772
424
  }
@@ -786,35 +438,18 @@ function validateTeamDefinition(def) {
786
438
  if (!def.mode)
787
439
  throw new Error('Team definition must specify a mode.');
788
440
  switch (def.mode) {
789
- case 'panel':
790
- if (!def.primary)
791
- throw new Error('Panel mode requires a primary member.');
792
- if (!def.members || def.members.length === 0)
793
- throw new Error('Panel mode requires at least one panel member.');
794
- if (def.members.length > 8)
795
- throw new Error('Panel mode supports at most 8 members.');
796
- break;
797
- case 'router':
798
- if (!def.router)
799
- throw new Error('Router mode requires a router member.');
800
- if (!def.specialists || Object.keys(def.specialists).length === 0)
801
- throw new Error('Router mode requires at least one specialist.');
802
- break;
803
- case 'discussion':
804
- if (!def.primary)
805
- throw new Error('Discussion mode requires a primary member.');
806
- if (!def.members || def.members.length < 2)
807
- throw new Error('Discussion mode requires at least 2 members.');
808
- break;
809
- case 'executor-reviewer':
810
- if (!def.executor)
811
- throw new Error('Executor-Reviewer mode requires an executor.');
441
+ case 'reviewer':
442
+ case 'executor-reviewer': // retired alias: needs only a reviewer now
812
443
  if (!def.reviewer)
813
- throw new Error('Executor-Reviewer mode requires a reviewer.');
444
+ throw new Error('Reviewer mode requires a reviewer member.');
814
445
  break;
446
+ case 'panel': // retired alias of panel-analysis (primary now optional)
815
447
  case 'analysis':
448
+ case 'panel-analysis':
816
449
  if (!def.members || def.members.length === 0)
817
- throw new Error('Analysis mode requires at least one panel member.');
450
+ throw new Error('Panel-analysis mode requires at least one panel member.');
451
+ if (def.members.length > 8)
452
+ throw new Error('Panel-analysis mode supports at most 8 members.');
818
453
  break;
819
454
  }
820
455
  }
@@ -824,31 +459,55 @@ function validateTeamDefinition(def) {
824
459
  */
825
460
  export function createTeamTool(definition) {
826
461
  const team = createModelTeam(definition);
462
+ const isReviewer = definition.mode === 'reviewer' || definition.mode === 'executor-reviewer';
463
+ const isPanel = definition.mode === 'analysis' || definition.mode === 'panel-analysis';
827
464
  return {
828
465
  kind: 'local',
829
466
  name: definition.name,
830
467
  description: definition.description ??
831
- (definition.mode === 'analysis'
832
- ? 'Expert panel: independent read-only multi-model analysis (advisory). You decide what to do with the findings.'
833
- : `Multi-model team (${definition.mode} mode)`),
468
+ (isReviewer
469
+ ? 'Reviewer: a single read-only agent inspects the project and reports only genuine, verifiable issues. Pass { task } (what to check) and optional { context } (what you did + the results). It advises; you decide.'
470
+ : isPanel
471
+ ? 'Expert panel: independent read-only multi-model analysis (advisory; optional primary-driven convergence). You decide what to do with the findings.'
472
+ : `Multi-model team (${definition.mode} mode)`),
834
473
  inputSchema: {
835
474
  parse: (input) => input,
836
475
  _type: undefined,
837
476
  },
838
- inputJsonSchema: {
839
- type: 'object',
840
- properties: {
841
- prompt: { type: 'string', description: 'The question or task for the team' },
477
+ inputJsonSchema: isReviewer
478
+ ? {
479
+ type: 'object',
480
+ properties: {
481
+ task: { type: 'string', description: 'What the reviewer should scrutinize' },
482
+ context: { type: 'string', description: 'What you did and the results you obtained (injected into the reviewer system prompt)' },
483
+ },
484
+ required: ['task'],
485
+ additionalProperties: true,
486
+ }
487
+ : {
488
+ type: 'object',
489
+ properties: {
490
+ prompt: { type: 'string', description: 'The question or task for the team' },
491
+ },
492
+ required: ['prompt'],
493
+ // Tolerate extra fields the model may add — a strict schema here caused
494
+ // intermittent "schema error" rejections that silently skipped the panel.
495
+ additionalProperties: true,
842
496
  },
843
- required: ['prompt'],
844
- // Tolerate extra fields the model may add — a strict schema here caused
845
- // intermittent "schema error" rejections that silently skipped the panel.
846
- additionalProperties: true,
847
- },
848
497
  async execute(input) {
849
- // Accept the prompt however the model phrases the call (bare string or any
850
- // of a few common keys) so a formatting quirk never drops the panel.
498
+ // Accept the call however the model phrases it (bare string or any of a few
499
+ // common keys) so a formatting quirk never drops the invocation.
851
500
  const obj = (input ?? {});
501
+ if (isReviewer) {
502
+ const taskCandidate = typeof input === 'string' ? input : obj.task ?? obj.prompt ?? obj.question ?? obj.input;
503
+ const task = typeof taskCandidate === 'string' ? taskCandidate.trim() : '';
504
+ if (!task) {
505
+ throw new Error(`Reviewer "${definition.name}" requires a non-empty "task" string.`);
506
+ }
507
+ const context = typeof obj.context === 'string' ? obj.context : undefined;
508
+ const result = await team.ask(task, undefined, { context });
509
+ return JSON.stringify({ answer: result.answer, mode: result.mode, cost: result.cost });
510
+ }
852
511
  const candidate = typeof input === 'string'
853
512
  ? input
854
513
  : obj.prompt ?? obj.query ?? obj.question ?? obj.task ?? obj.input;
@@ -869,10 +528,14 @@ export function createTeamTool(definition) {
869
528
  `## ${definition.name} (Model Team: ${definition.mode})`,
870
529
  definition.description ?? '',
871
530
  '',
872
- definition.mode === 'analysis'
873
- ? 'Call this tool with a { prompt } to have an expert panel of independent read-only agents investigate (read local code + search the web) and each return a findings report. They only analyze and advise you keep full control and decide what to do with their input. Use it to assist analysis on large or complex tasks.'
874
- : 'Call this tool with a { prompt } to get a multi-model synthesized answer.',
875
- `Members: ${definition.members?.map((m) => m.model).join(', ') ?? 'configured via definition'}`,
531
+ isReviewer
532
+ ? 'Call this tool with { task } (what to scrutinize) and optional { context } (what you did and the results you got). A single read-only agent inspects the code/web and reports only issues it can verify no speculation. You keep final authority; re-invoke after changes to re-check.'
533
+ : isPanel
534
+ ? 'Call this tool with a { prompt } to have an expert panel of independent read-only agents investigate (read local code + search the web) and each return a findings report. They only analyze and advise — you keep full control and decide what to do with their input. With a configured primary the panel also converges over multiple rounds into a synthesized answer. Use it to assist analysis on large or complex tasks.'
535
+ : 'Call this tool with a { prompt } to get a multi-model synthesized answer.',
536
+ isReviewer
537
+ ? `Reviewer: ${definition.reviewer?.model ?? 'configured via definition'}`
538
+ : `Members: ${definition.members?.map((m) => m.model).join(', ') ?? 'configured via definition'}`,
876
539
  ].join('\n'),
877
540
  };
878
541
  }