@cjhyy/code-shell-arena 0.8.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 (102) hide show
  1. package/README.md +46 -0
  2. package/dist/arena.d.ts +45 -0
  3. package/dist/arena.js +362 -0
  4. package/dist/capability.d.ts +43 -0
  5. package/dist/capability.js +453 -0
  6. package/dist/context/context-tools.d.ts +16 -0
  7. package/dist/context/context-tools.js +272 -0
  8. package/dist/context/within-root.d.ts +7 -0
  9. package/dist/context/within-root.js +15 -0
  10. package/dist/detect-mode.d.ts +20 -0
  11. package/dist/detect-mode.js +78 -0
  12. package/dist/digest-builder.d.ts +25 -0
  13. package/dist/digest-builder.js +120 -0
  14. package/dist/index.d.ts +25 -0
  15. package/dist/index.js +23 -0
  16. package/dist/index.runtime.d.ts +13 -0
  17. package/dist/index.runtime.js +11 -0
  18. package/dist/iterate/convergence.d.ts +25 -0
  19. package/dist/iterate/convergence.js +103 -0
  20. package/dist/iterate/formats/index.d.ts +22 -0
  21. package/dist/iterate/formats/index.js +283 -0
  22. package/dist/iterate/index.d.ts +11 -0
  23. package/dist/iterate/index.js +9 -0
  24. package/dist/iterate/iterative-arena.d.ts +31 -0
  25. package/dist/iterate/iterative-arena.js +252 -0
  26. package/dist/iterate/parse.d.ts +42 -0
  27. package/dist/iterate/parse.js +123 -0
  28. package/dist/iterate/phases/argue.d.ts +22 -0
  29. package/dist/iterate/phases/argue.js +165 -0
  30. package/dist/iterate/phases/revise.d.ts +16 -0
  31. package/dist/iterate/phases/revise.js +62 -0
  32. package/dist/iterate/phases/tournament.d.ts +34 -0
  33. package/dist/iterate/phases/tournament.js +113 -0
  34. package/dist/iterate/tools/web-tools.d.ts +13 -0
  35. package/dist/iterate/tools/web-tools.js +54 -0
  36. package/dist/iterate/types.d.ts +152 -0
  37. package/dist/iterate/types.js +8 -0
  38. package/dist/ledger.d.ts +47 -0
  39. package/dist/ledger.js +159 -0
  40. package/dist/lenses/architecture.d.ts +5 -0
  41. package/dist/lenses/architecture.js +22 -0
  42. package/dist/lenses/engineering.d.ts +5 -0
  43. package/dist/lenses/engineering.js +22 -0
  44. package/dist/lenses/general.d.ts +5 -0
  45. package/dist/lenses/general.js +20 -0
  46. package/dist/lenses/index.d.ts +16 -0
  47. package/dist/lenses/index.js +47 -0
  48. package/dist/lenses/product.d.ts +5 -0
  49. package/dist/lenses/product.js +22 -0
  50. package/dist/model-presets.d.ts +23 -0
  51. package/dist/model-presets.js +44 -0
  52. package/dist/phases/adjudication.d.ts +25 -0
  53. package/dist/phases/adjudication.js +142 -0
  54. package/dist/phases/build-consensus.d.ts +30 -0
  55. package/dist/phases/build-consensus.js +85 -0
  56. package/dist/phases/claim-registry.d.ts +26 -0
  57. package/dist/phases/claim-registry.js +60 -0
  58. package/dist/phases/cross-review.d.ts +47 -0
  59. package/dist/phases/cross-review.js +224 -0
  60. package/dist/phases/debate-rounds.d.ts +28 -0
  61. package/dist/phases/debate-rounds.js +161 -0
  62. package/dist/phases/participant-research.d.ts +39 -0
  63. package/dist/phases/participant-research.js +322 -0
  64. package/dist/phases/planning-detail-expansion.d.ts +39 -0
  65. package/dist/phases/planning-detail-expansion.js +120 -0
  66. package/dist/planner.d.ts +28 -0
  67. package/dist/planner.js +312 -0
  68. package/dist/providers/docs.d.ts +7 -0
  69. package/dist/providers/docs.js +111 -0
  70. package/dist/providers/git.d.ts +8 -0
  71. package/dist/providers/git.js +174 -0
  72. package/dist/providers/index.d.ts +32 -0
  73. package/dist/providers/index.js +132 -0
  74. package/dist/providers/none.d.ts +7 -0
  75. package/dist/providers/none.js +11 -0
  76. package/dist/providers/repo.d.ts +7 -0
  77. package/dist/providers/repo.js +258 -0
  78. package/dist/render/session.d.ts +17 -0
  79. package/dist/render/session.js +190 -0
  80. package/dist/render/terminal.d.ts +34 -0
  81. package/dist/render/terminal.js +286 -0
  82. package/dist/strategies/discussion.d.ts +25 -0
  83. package/dist/strategies/discussion.js +143 -0
  84. package/dist/strategies/index.d.ts +15 -0
  85. package/dist/strategies/index.js +28 -0
  86. package/dist/strategies/language-wrapper.d.ts +17 -0
  87. package/dist/strategies/language-wrapper.js +102 -0
  88. package/dist/strategies/lens-wrapper.d.ts +16 -0
  89. package/dist/strategies/lens-wrapper.js +236 -0
  90. package/dist/strategies/planning.d.ts +30 -0
  91. package/dist/strategies/planning.js +225 -0
  92. package/dist/strategies/review.d.ts +26 -0
  93. package/dist/strategies/review.js +168 -0
  94. package/dist/strategies/utils.d.ts +36 -0
  95. package/dist/strategies/utils.js +603 -0
  96. package/dist/tools/selector.d.ts +17 -0
  97. package/dist/tools/selector.js +61 -0
  98. package/dist/transitions.d.ts +53 -0
  99. package/dist/transitions.js +97 -0
  100. package/dist/types.d.ts +518 -0
  101. package/dist/types.js +27 -0
  102. package/package.json +45 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * IterativeArena — multi-model authoring loop.
3
+ *
4
+ * Pipeline:
5
+ * 1. v1: tournament (default) or single-author
6
+ * 2. for each round 2..maxRounds:
7
+ * a. argue (parallel critics)
8
+ * b. checkpoint (optional human pause)
9
+ * c. convergence check
10
+ * d. if not converged: revise → next round
11
+ * 3. final draft = last round's draft
12
+ */
13
+ import { logger } from "@cjhyy/code-shell-core/extension";
14
+ import { defaultConvergence, diffRatio } from "./convergence.js";
15
+ import { getFormat } from "./formats/index.js";
16
+ import { runArgueRound } from "./phases/argue.js";
17
+ import { runRevise } from "./phases/revise.js";
18
+ import { mergeCandidatesToV1, runTournamentCandidates, singleAuthorV1 } from "./phases/tournament.js";
19
+ const DEFAULT_MAX_ROUNDS = 5;
20
+ export class IterativeArena {
21
+ config;
22
+ constructor(config) {
23
+ if (!config.author) {
24
+ throw new Error("IterativeArena requires an `author` participant");
25
+ }
26
+ if (!config.critics || config.critics.length === 0) {
27
+ throw new Error("IterativeArena requires at least 1 critic");
28
+ }
29
+ const allNames = [config.author.name, ...config.critics.map((c) => c.name)];
30
+ if (new Set(allNames).size !== allNames.length) {
31
+ throw new Error("Author and critics must have unique names");
32
+ }
33
+ this.config = config;
34
+ }
35
+ async run() {
36
+ const start = Date.now();
37
+ const { config } = this;
38
+ const format = getFormat(config.format);
39
+ const maxRounds = config.maxRounds ?? DEFAULT_MAX_ROUNDS;
40
+ const minDraftLength = config.minDraftLength ?? (config.format === "code" ? 200 : 800);
41
+ const signal = config.signal;
42
+ const rounds = [];
43
+ // ─── Phase: v1 ────────────────────────────────────────────
44
+ let currentDraft;
45
+ try {
46
+ if ((config.v1Strategy ?? "tournament") === "tournament") {
47
+ const candidates = await runTournamentCandidates({
48
+ subject: config.subject,
49
+ format,
50
+ participants: [config.author, ...config.critics],
51
+ minDraftLength,
52
+ signal,
53
+ onProgress: config.onProgress,
54
+ });
55
+ currentDraft = await mergeCandidatesToV1({
56
+ subject: config.subject,
57
+ format,
58
+ author: config.author,
59
+ candidates,
60
+ minDraftLength,
61
+ signal,
62
+ onProgress: config.onProgress,
63
+ });
64
+ }
65
+ else {
66
+ currentDraft = await singleAuthorV1({
67
+ subject: config.subject,
68
+ format,
69
+ author: config.author,
70
+ minDraftLength,
71
+ signal,
72
+ });
73
+ }
74
+ }
75
+ catch (err) {
76
+ logger.error("arena.iterate.v1_failed", { error: err.message });
77
+ throw err;
78
+ }
79
+ // Author rotation state. We pick the next author from the pool of
80
+ // {author, critics} according to the rotation policy.
81
+ let currentAuthor = config.author;
82
+ const pool = [config.author, ...config.critics];
83
+ // ─── Phase: argue/revise loop ────────────────────────────
84
+ let previousDraft;
85
+ for (let r = 1; r <= maxRounds; r++) {
86
+ signal?.throwIfAborted();
87
+ // Argue against the current draft.
88
+ const critiques = await runArgueRound({
89
+ subject: config.subject,
90
+ format,
91
+ draft: currentDraft,
92
+ // Pass the author of THIS round's draft, not the round number: the
93
+ // draft under review was written by `currentAuthor`.
94
+ critics: this.criticsForRound(currentAuthor),
95
+ round: r,
96
+ enableWebSearch: config.enableWebSearch,
97
+ maxToolRounds: config.maxArgueToolRounds,
98
+ signal,
99
+ onProgress: config.onProgress,
100
+ });
101
+ // Convergence check.
102
+ const convergence = this.checkConvergence({
103
+ round: r,
104
+ critiques,
105
+ draft: currentDraft,
106
+ previousDraft,
107
+ });
108
+ const round = {
109
+ round: r,
110
+ draft: currentDraft,
111
+ critiques,
112
+ convergence,
113
+ };
114
+ // Optional human checkpoint.
115
+ let userAction;
116
+ if (config.humanCheckpoint) {
117
+ config.onProgress?.({ type: "checkpoint_pause", round: r });
118
+ userAction = await config.humanCheckpoint({
119
+ round: r,
120
+ draft: currentDraft,
121
+ critiques,
122
+ convergence,
123
+ });
124
+ }
125
+ rounds.push(round);
126
+ config.onProgress?.({
127
+ type: "round_done",
128
+ round: r,
129
+ data: {
130
+ critiques: critiques.length,
131
+ shouldStop: convergence.shouldStop,
132
+ userAction,
133
+ },
134
+ });
135
+ if (userAction === "stop") {
136
+ return this.finalize(rounds, "user_stop", start);
137
+ }
138
+ if (userAction !== "force-continue" && convergence.shouldStop) {
139
+ return this.finalize(rounds, "converged", start);
140
+ }
141
+ if (r === maxRounds) {
142
+ return this.finalize(rounds, "max_rounds", start);
143
+ }
144
+ // Revise → v(r+1)
145
+ previousDraft = currentDraft;
146
+ currentAuthor = this.pickNextAuthor(currentAuthor, critiques, pool, r);
147
+ currentDraft = await runRevise({
148
+ subject: config.subject,
149
+ format,
150
+ previous: previousDraft,
151
+ critiques,
152
+ author: currentAuthor,
153
+ minDraftLength,
154
+ signal,
155
+ onProgress: config.onProgress,
156
+ });
157
+ }
158
+ return this.finalize(rounds, "max_rounds", start);
159
+ }
160
+ /**
161
+ * Critics for a round = the whole pool minus whoever wrote the draft under
162
+ * review, so nobody critiques their own text.
163
+ *
164
+ * This used to return `config.critics` verbatim, ignoring rotation. Once
165
+ * `round-robin`/`best-critic` promoted critic B to author, B kept reviewing
166
+ * the draft B had just written, while the original author A never entered the
167
+ * critic pool at all — the exact opposite of the intent documented here.
168
+ */
169
+ criticsForRound(currentAuthor) {
170
+ const pool = [this.config.author, ...this.config.critics];
171
+ const critics = pool.filter((p) => p.name !== currentAuthor.name);
172
+ // Degenerate config (single participant, or every critic named like the
173
+ // author): fall back to the configured critics so a round still has
174
+ // reviewers rather than silently running with none.
175
+ return critics.length > 0 ? critics : this.config.critics;
176
+ }
177
+ checkConvergence(args) {
178
+ if (this.config.convergenceStrategy && this.config.convergenceStrategy !== "default") {
179
+ const stop = this.config.convergenceStrategy(args);
180
+ const blockerCount = args.critiques.filter((c) => c.severity === "blocker").length;
181
+ const majorCount = args.critiques.filter((c) => c.severity === "major").length;
182
+ const diffFromPrevious = args.previousDraft
183
+ ? diffRatio(args.previousDraft.content, args.draft.content)
184
+ : 1;
185
+ return {
186
+ blockerCount,
187
+ majorCount,
188
+ totalCritiques: args.critiques.length,
189
+ diffFromPrevious,
190
+ shouldStop: stop,
191
+ reason: stop ? "all_minor_or_praise" : "running",
192
+ };
193
+ }
194
+ return defaultConvergence(args);
195
+ }
196
+ pickNextAuthor(current, critiques, pool, round) {
197
+ const policy = this.config.authorRotation ?? "fixed";
198
+ if (policy === "fixed")
199
+ return this.config.author;
200
+ if (policy === "round-robin") {
201
+ const idx = round % pool.length;
202
+ return pool[idx];
203
+ }
204
+ if (policy === "best-critic") {
205
+ // Score critics by sum of severities they raised; tie → most critiques.
206
+ const sevWeight = {
207
+ blocker: 5, major: 3, minor: 1, nit: 0.5, praise: 0,
208
+ };
209
+ const scores = new Map();
210
+ for (const c of critiques) {
211
+ scores.set(c.critic, (scores.get(c.critic) ?? 0) + (sevWeight[c.severity] ?? 0));
212
+ }
213
+ let best = current.name;
214
+ let bestScore = -1;
215
+ for (const [name, score] of scores) {
216
+ if (score > bestScore) {
217
+ bestScore = score;
218
+ best = name;
219
+ }
220
+ }
221
+ const winner = pool.find((p) => p.name === best);
222
+ return winner ?? current;
223
+ }
224
+ return current;
225
+ }
226
+ finalize(rounds, stoppedAt, start) {
227
+ const finalDraft = rounds.at(-1).draft;
228
+ const result = {
229
+ subject: this.config.subject,
230
+ format: this.config.format,
231
+ rounds,
232
+ finalDraft,
233
+ stoppedAt,
234
+ durationMs: Date.now() - start,
235
+ };
236
+ this.config.onProgress?.({
237
+ type: "iterate_complete",
238
+ data: {
239
+ stoppedAt,
240
+ rounds: rounds.length,
241
+ finalLength: finalDraft.content.length,
242
+ },
243
+ });
244
+ logger.info("arena.iterate.complete", {
245
+ stoppedAt,
246
+ rounds: rounds.length,
247
+ finalVersion: finalDraft.version,
248
+ finalLength: finalDraft.content.length,
249
+ });
250
+ return result;
251
+ }
252
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Parsing helpers for LLM responses in iterate mode.
3
+ *
4
+ * We use literal XML-ish markers (<v1_content>…</v1_content>) instead of
5
+ * pure JSON because LLMs are far more reliable at "write 5000 chars of
6
+ * markdown then a small JSON block at the end" than at producing one
7
+ * giant well-formed JSON document with a 5000-char string field.
8
+ */
9
+ import type { Critique } from "./types.js";
10
+ /**
11
+ * Extract content between `<tag>` and `</tag>`.
12
+ *
13
+ * Forgiving in two ways:
14
+ * - Case-insensitive on the tag name.
15
+ * - If the closing tag is missing (LLM hit max_tokens mid-output), we still
16
+ * return everything after the opening tag, stopping at the next sibling
17
+ * tag (e.g. the `<merge_rationale>` block) if present, otherwise EOF.
18
+ */
19
+ export declare function extractTag(text: string, tag: string): string | null;
20
+ /** Parse merge response: extract v1 content + rationale. Falls back to whole text if markers are missing. */
21
+ export declare function parseMergeResponse(text: string): {
22
+ content: string;
23
+ rationale?: string;
24
+ };
25
+ export interface ReviseMeta {
26
+ acceptedCritiques?: string[];
27
+ rejectedCritiques?: Array<{
28
+ id: string;
29
+ reason: string;
30
+ }>;
31
+ changelog?: string;
32
+ }
33
+ /** Parse revise response: extract content + meta JSON. */
34
+ export declare function parseReviseResponse(text: string): {
35
+ content: string;
36
+ meta: ReviseMeta;
37
+ };
38
+ /**
39
+ * Parse critique JSON from an argue-phase response.
40
+ * Tolerant: handles bare objects, fenced JSON, leading prose, trailing prose.
41
+ */
42
+ export declare function parseCritiquesResponse(text: string, criticName: string, idPrefix: string): Critique[];
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Parsing helpers for LLM responses in iterate mode.
3
+ *
4
+ * We use literal XML-ish markers (<v1_content>…</v1_content>) instead of
5
+ * pure JSON because LLMs are far more reliable at "write 5000 chars of
6
+ * markdown then a small JSON block at the end" than at producing one
7
+ * giant well-formed JSON document with a 5000-char string field.
8
+ */
9
+ /**
10
+ * Extract content between `<tag>` and `</tag>`.
11
+ *
12
+ * Forgiving in two ways:
13
+ * - Case-insensitive on the tag name.
14
+ * - If the closing tag is missing (LLM hit max_tokens mid-output), we still
15
+ * return everything after the opening tag, stopping at the next sibling
16
+ * tag (e.g. the `<merge_rationale>` block) if present, otherwise EOF.
17
+ */
18
+ export function extractTag(text, tag) {
19
+ // First try the well-formed case (open + close).
20
+ const closed = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "i");
21
+ const closedMatch = text.match(closed);
22
+ if (closedMatch)
23
+ return closedMatch[1].trim();
24
+ // Fall back to "open tag without close" — find <tag> and return content
25
+ // until the next opening tag of any kind, or EOF.
26
+ const opener = new RegExp(`<${tag}>`, "i");
27
+ const openMatch = opener.exec(text);
28
+ if (!openMatch)
29
+ return null;
30
+ const after = text.slice(openMatch.index + openMatch[0].length);
31
+ const nextTag = after.search(/<[a-z_][\w]*>/i);
32
+ const body = nextTag >= 0 ? after.slice(0, nextTag) : after;
33
+ return body.trim();
34
+ }
35
+ /** Parse merge response: extract v1 content + rationale. Falls back to whole text if markers are missing. */
36
+ export function parseMergeResponse(text) {
37
+ const content = extractTag(text, "v1_content");
38
+ const rationale = extractTag(text, "merge_rationale");
39
+ if (content) {
40
+ return { content, rationale: rationale ?? undefined };
41
+ }
42
+ // Fallback: no markers — treat whole response as content (with a warning sentinel).
43
+ return { content: text.trim(), rationale: "[merge markers not found; using full response]" };
44
+ }
45
+ /** Parse revise response: extract content + meta JSON. */
46
+ export function parseReviseResponse(text) {
47
+ const content = extractTag(text, "v_next_content");
48
+ const metaRaw = extractTag(text, "v_next_meta");
49
+ let meta = {};
50
+ if (metaRaw) {
51
+ // Strip surrounding code fences if present.
52
+ const cleaned = metaRaw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
53
+ try {
54
+ meta = JSON.parse(cleaned);
55
+ }
56
+ catch {
57
+ meta = { changelog: `[meta JSON parse failed; raw text below]\n${metaRaw}` };
58
+ }
59
+ }
60
+ return {
61
+ content: content ?? text.trim(),
62
+ meta,
63
+ };
64
+ }
65
+ /**
66
+ * Parse critique JSON from an argue-phase response.
67
+ * Tolerant: handles bare objects, fenced JSON, leading prose, trailing prose.
68
+ */
69
+ export function parseCritiquesResponse(text, criticName, idPrefix) {
70
+ const cleaned = text.replace(/^[^{[]*/, "").replace(/[^}\]]*$/, "").trim();
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(cleaned);
74
+ }
75
+ catch {
76
+ // Try to find the largest JSON object/array in the text.
77
+ const jsonMatch = text.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
78
+ if (!jsonMatch)
79
+ return [];
80
+ try {
81
+ parsed = JSON.parse(jsonMatch[0]);
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ }
87
+ // Accept either {critiques: [...]} or [...] directly.
88
+ const arr = Array.isArray(parsed)
89
+ ? parsed
90
+ : parsed.critiques;
91
+ if (!Array.isArray(arr))
92
+ return [];
93
+ const out = [];
94
+ for (let i = 0; i < arr.length; i++) {
95
+ const c = arr[i];
96
+ if (!c.anchor || !c.comment)
97
+ continue;
98
+ let evidence;
99
+ if (Array.isArray(c.evidence)) {
100
+ evidence = c.evidence
101
+ .filter((e) => {
102
+ if (typeof e !== "object" || e === null)
103
+ return false;
104
+ const obj = e;
105
+ return typeof obj.url === "string";
106
+ })
107
+ .map((e) => ({ url: e.url, snippet: e.snippet }));
108
+ if (evidence.length === 0)
109
+ evidence = undefined;
110
+ }
111
+ out.push({
112
+ id: `${idPrefix}-${criticName}-${i + 1}`,
113
+ critic: criticName,
114
+ anchor: String(c.anchor),
115
+ severity: c.severity ?? "minor",
116
+ category: c.category ?? "other",
117
+ comment: String(c.comment),
118
+ suggestion: c.suggestion ? String(c.suggestion) : undefined,
119
+ evidence,
120
+ });
121
+ }
122
+ return out;
123
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Argue phase — critics read a draft and produce critiques in parallel.
3
+ *
4
+ * If `enableWebSearch` is on, each critic runs a tool-use loop and may call
5
+ * web_search / web_fetch up to `maxArgueToolRounds` times before producing
6
+ * the final critiques JSON. This is the deep-research path that catches
7
+ * fabricated claims.
8
+ */
9
+ import type { ArenaParticipant } from "../../types.js";
10
+ import type { FormatPack } from "../formats/index.js";
11
+ import type { Critique, Draft, IterateProgressEvent, IterateSubject } from "../types.js";
12
+ export declare function runArgueRound(args: {
13
+ subject: IterateSubject;
14
+ format: FormatPack;
15
+ draft: Draft;
16
+ critics: ArenaParticipant[];
17
+ round: number;
18
+ enableWebSearch?: boolean;
19
+ maxToolRounds?: number;
20
+ signal?: AbortSignal;
21
+ onProgress?: (e: IterateProgressEvent) => void;
22
+ }): Promise<Critique[]>;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Argue phase — critics read a draft and produce critiques in parallel.
3
+ *
4
+ * If `enableWebSearch` is on, each critic runs a tool-use loop and may call
5
+ * web_search / web_fetch up to `maxArgueToolRounds` times before producing
6
+ * the final critiques JSON. This is the deep-research path that catches
7
+ * fabricated claims.
8
+ */
9
+ import { createLLMClient } from "@cjhyy/code-shell-core/extension";
10
+ import { logger } from "@cjhyy/code-shell-core/extension";
11
+ import { parseCritiquesResponse } from "../parse.js";
12
+ import { ITERATE_WEB_TOOLS, executeIterateWebTool, hasWebSearchProvider } from "../tools/web-tools.js";
13
+ const DEFAULT_MAX_TOOL_ROUNDS = 8;
14
+ export async function runArgueRound(args) {
15
+ const { subject, format, draft, critics, round, enableWebSearch, maxToolRounds, signal, onProgress } = args;
16
+ onProgress?.({ type: "argue_start", round, data: { criticCount: critics.length } });
17
+ // Web search is only useful if a provider is actually configured. Silently
18
+ // downgrade if not — log a warning so the user knows.
19
+ const wantWeb = Boolean(enableWebSearch);
20
+ const webAvailable = wantWeb && hasWebSearchProvider();
21
+ if (wantWeb && !webAvailable) {
22
+ logger.warn("arena.iterate.argue_web_unavailable", {
23
+ reason: "No SERPER_API_KEY / TAVILY_API_KEY / SEARXNG_URL configured",
24
+ });
25
+ }
26
+ const idPrefix = `r${round}`;
27
+ const tasks = critics.map(async (c) => {
28
+ signal?.throwIfAborted();
29
+ try {
30
+ if (webAvailable) {
31
+ return await argueWithToolLoop({
32
+ critic: c,
33
+ subject,
34
+ format,
35
+ draft,
36
+ idPrefix,
37
+ maxToolRounds: maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS,
38
+ signal,
39
+ });
40
+ }
41
+ return await argueSingleShot({ critic: c, subject, format, draft, idPrefix, signal });
42
+ }
43
+ catch (err) {
44
+ logger.warn("arena.iterate.argue_failed", {
45
+ round,
46
+ critic: c.name,
47
+ error: err.message,
48
+ });
49
+ return [];
50
+ }
51
+ });
52
+ const results = await Promise.all(tasks);
53
+ const allCritiques = results.flat();
54
+ onProgress?.({
55
+ type: "argue_done",
56
+ round,
57
+ data: { critiqueCount: allCritiques.length, webEnabled: webAvailable },
58
+ });
59
+ return allCritiques;
60
+ }
61
+ // ─── Single-shot argue (no tools) ─────────────────────────────────────
62
+ async function argueSingleShot(args) {
63
+ const { critic, subject, format, draft, idPrefix, signal } = args;
64
+ const client = await createLLMClient(critic.llm, critic.clientDefaults);
65
+ const resp = await client.createMessage({
66
+ systemPrompt: format.argueSystem(format.format),
67
+ messages: [{ role: "user", content: format.argueUser(subject, draft) }],
68
+ tools: [],
69
+ signal,
70
+ });
71
+ const critiques = parseCritiquesResponse(resp.text, critic.name, idPrefix);
72
+ logger.info("arena.iterate.argue_singleshot", {
73
+ critic: critic.name,
74
+ critiqueCount: critiques.length,
75
+ });
76
+ return critiques;
77
+ }
78
+ // ─── Tool-use argue (with web search) ─────────────────────────────────
79
+ async function argueWithToolLoop(args) {
80
+ const { critic, subject, format, draft, idPrefix, maxToolRounds, signal } = args;
81
+ const client = await createLLMClient(critic.llm, critic.clientDefaults);
82
+ const messages = [
83
+ { role: "user", content: format.argueUser(subject, draft) },
84
+ ];
85
+ let toolRounds = 0;
86
+ let finalText = "";
87
+ while (toolRounds < maxToolRounds) {
88
+ signal?.throwIfAborted();
89
+ const resp = await client.createMessage({
90
+ systemPrompt: format.argueSystem(format.format),
91
+ messages,
92
+ tools: ITERATE_WEB_TOOLS,
93
+ signal,
94
+ });
95
+ if (resp.text)
96
+ finalText = resp.text;
97
+ if (!resp.toolCalls || resp.toolCalls.length === 0) {
98
+ // Done — final response with critiques JSON.
99
+ break;
100
+ }
101
+ // Append assistant message with tool_use blocks
102
+ messages.push({
103
+ role: "assistant",
104
+ content: [
105
+ ...(resp.text ? [{ type: "text", text: resp.text }] : []),
106
+ ...resp.toolCalls.map((tc) => ({
107
+ type: "tool_use",
108
+ id: tc.id,
109
+ name: tc.toolName,
110
+ input: tc.args,
111
+ })),
112
+ ],
113
+ });
114
+ // Execute tools and append tool_result blocks
115
+ const toolResults = await Promise.all(resp.toolCalls.map(async (tc) => {
116
+ try {
117
+ const result = await executeIterateWebTool(tc.toolName, tc.args);
118
+ return { id: tc.id, content: result };
119
+ }
120
+ catch (err) {
121
+ return { id: tc.id, content: `Error: ${err.message}` };
122
+ }
123
+ }));
124
+ messages.push({
125
+ role: "user",
126
+ content: toolResults.map((tr) => ({
127
+ type: "tool_result",
128
+ tool_use_id: tr.id,
129
+ content: tr.content,
130
+ })),
131
+ });
132
+ toolRounds++;
133
+ }
134
+ // If we ran out of rounds with no final text, prod the model once more.
135
+ if (!finalText && toolRounds >= maxToolRounds) {
136
+ const resp = await client.createMessage({
137
+ systemPrompt: format.argueSystem(format.format),
138
+ messages: [
139
+ ...messages,
140
+ {
141
+ role: "user",
142
+ content: "You have used the maximum number of tool rounds. Produce your " +
143
+ "critiques JSON now, based on what you have already gathered. " +
144
+ "Do not request any more tools.",
145
+ },
146
+ ],
147
+ tools: [],
148
+ signal,
149
+ });
150
+ finalText = resp.text;
151
+ }
152
+ if (!finalText) {
153
+ // Both the tool loop and the force-conclude fallback produced no text
154
+ // (e.g. repeated max-token truncation). Log it — parseCritiquesResponse
155
+ // will just yield no critiques rather than silently parsing "".
156
+ logger.warn("arena.iterate.argue_empty_response", { critic: critic.name, toolRounds });
157
+ }
158
+ const critiques = parseCritiquesResponse(finalText, critic.name, idPrefix);
159
+ logger.info("arena.iterate.argue_toolloop", {
160
+ critic: critic.name,
161
+ critiqueCount: critiques.length,
162
+ toolRounds,
163
+ });
164
+ return critiques;
165
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Revise phase — author rewrites the draft addressing critiques.
3
+ */
4
+ import type { ArenaParticipant } from "../../types.js";
5
+ import type { FormatPack } from "../formats/index.js";
6
+ import type { Critique, Draft, IterateProgressEvent, IterateSubject } from "../types.js";
7
+ export declare function runRevise(args: {
8
+ subject: IterateSubject;
9
+ format: FormatPack;
10
+ previous: Draft;
11
+ critiques: Critique[];
12
+ author: ArenaParticipant;
13
+ minDraftLength: number;
14
+ signal?: AbortSignal;
15
+ onProgress?: (e: IterateProgressEvent) => void;
16
+ }): Promise<Draft>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Revise phase — author rewrites the draft addressing critiques.
3
+ */
4
+ import { createLLMClient } from "@cjhyy/code-shell-core/extension";
5
+ import { logger } from "@cjhyy/code-shell-core/extension";
6
+ import { parseReviseResponse } from "../parse.js";
7
+ export async function runRevise(args) {
8
+ const { subject, format, previous, critiques, author, minDraftLength, signal, onProgress } = args;
9
+ onProgress?.({ type: "revise_start", round: previous.version + 1, participant: author.name });
10
+ const client = await createLLMClient(author.llm, author.clientDefaults);
11
+ const prompt = format.revisePrompt(subject, previous, critiques, minDraftLength);
12
+ const resp = await client.createMessage({
13
+ systemPrompt: prompt,
14
+ messages: [{ role: "user", content: `Produce v${previous.version + 1} now.` }],
15
+ tools: [],
16
+ maxTokens: 32_000,
17
+ signal,
18
+ });
19
+ const { content, meta } = parseReviseResponse(resp.text);
20
+ // Defensive: refuse to accept a revision that's drastically shorter than v(N) — that's
21
+ // usually the model collapsing into a summary rather than rewriting.
22
+ // Accept if shrinkage < 30% OR new content is still above the minimum length.
23
+ const acceptedContent = content;
24
+ const shrinkage = previous.content.length > 0
25
+ ? 1 - acceptedContent.length / previous.content.length
26
+ : 0;
27
+ if (shrinkage > 0.3 && acceptedContent.length < minDraftLength) {
28
+ logger.warn("arena.iterate.revise_too_short", {
29
+ version: previous.version + 1,
30
+ previousLength: previous.content.length,
31
+ newLength: acceptedContent.length,
32
+ minDraftLength,
33
+ });
34
+ // Don't retry automatically — keep going but flag in changelog.
35
+ meta.changelog =
36
+ `[WARNING: revision is ${Math.round(shrinkage * 100)}% shorter than v${previous.version}; the author may have summarized instead of rewriting]\n\n` +
37
+ (meta.changelog ?? "");
38
+ }
39
+ const next = {
40
+ version: previous.version + 1,
41
+ author: author.name,
42
+ format: format.format,
43
+ content: acceptedContent,
44
+ acceptedCritiques: meta.acceptedCritiques,
45
+ rejectedCritiques: meta.rejectedCritiques,
46
+ changelog: meta.changelog,
47
+ };
48
+ onProgress?.({
49
+ type: "revise_done",
50
+ round: next.version,
51
+ participant: author.name,
52
+ data: { length: acceptedContent.length, accepted: meta.acceptedCritiques?.length ?? 0 },
53
+ });
54
+ logger.info("arena.iterate.revise", {
55
+ version: next.version,
56
+ author: author.name,
57
+ length: acceptedContent.length,
58
+ accepted: meta.acceptedCritiques?.length ?? 0,
59
+ rejected: meta.rejectedCritiques?.length ?? 0,
60
+ });
61
+ return next;
62
+ }