@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.
- package/README.md +46 -0
- package/dist/arena.d.ts +45 -0
- package/dist/arena.js +362 -0
- package/dist/capability.d.ts +43 -0
- package/dist/capability.js +453 -0
- package/dist/context/context-tools.d.ts +16 -0
- package/dist/context/context-tools.js +272 -0
- package/dist/context/within-root.d.ts +7 -0
- package/dist/context/within-root.js +15 -0
- package/dist/detect-mode.d.ts +20 -0
- package/dist/detect-mode.js +78 -0
- package/dist/digest-builder.d.ts +25 -0
- package/dist/digest-builder.js +120 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +23 -0
- package/dist/index.runtime.d.ts +13 -0
- package/dist/index.runtime.js +11 -0
- package/dist/iterate/convergence.d.ts +25 -0
- package/dist/iterate/convergence.js +103 -0
- package/dist/iterate/formats/index.d.ts +22 -0
- package/dist/iterate/formats/index.js +283 -0
- package/dist/iterate/index.d.ts +11 -0
- package/dist/iterate/index.js +9 -0
- package/dist/iterate/iterative-arena.d.ts +31 -0
- package/dist/iterate/iterative-arena.js +252 -0
- package/dist/iterate/parse.d.ts +42 -0
- package/dist/iterate/parse.js +123 -0
- package/dist/iterate/phases/argue.d.ts +22 -0
- package/dist/iterate/phases/argue.js +165 -0
- package/dist/iterate/phases/revise.d.ts +16 -0
- package/dist/iterate/phases/revise.js +62 -0
- package/dist/iterate/phases/tournament.d.ts +34 -0
- package/dist/iterate/phases/tournament.js +113 -0
- package/dist/iterate/tools/web-tools.d.ts +13 -0
- package/dist/iterate/tools/web-tools.js +54 -0
- package/dist/iterate/types.d.ts +152 -0
- package/dist/iterate/types.js +8 -0
- package/dist/ledger.d.ts +47 -0
- package/dist/ledger.js +159 -0
- package/dist/lenses/architecture.d.ts +5 -0
- package/dist/lenses/architecture.js +22 -0
- package/dist/lenses/engineering.d.ts +5 -0
- package/dist/lenses/engineering.js +22 -0
- package/dist/lenses/general.d.ts +5 -0
- package/dist/lenses/general.js +20 -0
- package/dist/lenses/index.d.ts +16 -0
- package/dist/lenses/index.js +47 -0
- package/dist/lenses/product.d.ts +5 -0
- package/dist/lenses/product.js +22 -0
- package/dist/model-presets.d.ts +23 -0
- package/dist/model-presets.js +44 -0
- package/dist/phases/adjudication.d.ts +25 -0
- package/dist/phases/adjudication.js +142 -0
- package/dist/phases/build-consensus.d.ts +30 -0
- package/dist/phases/build-consensus.js +85 -0
- package/dist/phases/claim-registry.d.ts +26 -0
- package/dist/phases/claim-registry.js +60 -0
- package/dist/phases/cross-review.d.ts +47 -0
- package/dist/phases/cross-review.js +224 -0
- package/dist/phases/debate-rounds.d.ts +28 -0
- package/dist/phases/debate-rounds.js +161 -0
- package/dist/phases/participant-research.d.ts +39 -0
- package/dist/phases/participant-research.js +322 -0
- package/dist/phases/planning-detail-expansion.d.ts +39 -0
- package/dist/phases/planning-detail-expansion.js +120 -0
- package/dist/planner.d.ts +28 -0
- package/dist/planner.js +312 -0
- package/dist/providers/docs.d.ts +7 -0
- package/dist/providers/docs.js +111 -0
- package/dist/providers/git.d.ts +8 -0
- package/dist/providers/git.js +174 -0
- package/dist/providers/index.d.ts +32 -0
- package/dist/providers/index.js +132 -0
- package/dist/providers/none.d.ts +7 -0
- package/dist/providers/none.js +11 -0
- package/dist/providers/repo.d.ts +7 -0
- package/dist/providers/repo.js +258 -0
- package/dist/render/session.d.ts +17 -0
- package/dist/render/session.js +190 -0
- package/dist/render/terminal.d.ts +34 -0
- package/dist/render/terminal.js +286 -0
- package/dist/strategies/discussion.d.ts +25 -0
- package/dist/strategies/discussion.js +143 -0
- package/dist/strategies/index.d.ts +15 -0
- package/dist/strategies/index.js +28 -0
- package/dist/strategies/language-wrapper.d.ts +17 -0
- package/dist/strategies/language-wrapper.js +102 -0
- package/dist/strategies/lens-wrapper.d.ts +16 -0
- package/dist/strategies/lens-wrapper.js +236 -0
- package/dist/strategies/planning.d.ts +30 -0
- package/dist/strategies/planning.js +225 -0
- package/dist/strategies/review.d.ts +26 -0
- package/dist/strategies/review.js +168 -0
- package/dist/strategies/utils.d.ts +36 -0
- package/dist/strategies/utils.js +603 -0
- package/dist/tools/selector.d.ts +17 -0
- package/dist/tools/selector.js +61 -0
- package/dist/transitions.d.ts +53 -0
- package/dist/transitions.js +97 -0
- package/dist/types.d.ts +518 -0
- package/dist/types.js +27 -0
- package/package.json +45 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Arena tool — multi-model collaborative analysis.
|
|
3
|
+
*
|
|
4
|
+
* Launches an arena session where multiple LLM participants independently
|
|
5
|
+
* research a topic, cross-review each other's findings, and build consensus.
|
|
6
|
+
*
|
|
7
|
+
* The LLM config (baseUrl, apiKey) is injected at runtime by the Engine via
|
|
8
|
+
* setArenaLLMConfig(), following the same pattern as setAskUserFn() and
|
|
9
|
+
* setSubAgentConfig().
|
|
10
|
+
*/
|
|
11
|
+
import { SettingsManager, } from "@cjhyy/code-shell-core/extension";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { Arena } from "./arena.js";
|
|
14
|
+
import { MODEL_PRESETS, getMaxOutputTokens } from "./model-presets.js";
|
|
15
|
+
import { createProgressRenderer } from "./render/terminal.js";
|
|
16
|
+
import { formatArenaResultForSession } from "./render/session.js";
|
|
17
|
+
export const arenaToolDef = {
|
|
18
|
+
name: "Arena",
|
|
19
|
+
description: "Launch a multi-model collaborative analysis session. " +
|
|
20
|
+
"Arena automatically detects the mode, analysis perspective (lens), and evidence sources from the user's natural language request. " +
|
|
21
|
+
"Supports: code review, PRD/document review, architecture planning, product discussions, open-ended debates, and more. " +
|
|
22
|
+
"Multiple LLM participants independently research the topic, cross-review findings, and produce structured consensus. " +
|
|
23
|
+
"Use model preset names (e.g. 'claude', 'gpt', 'gemini', 'deepseek') or full model paths.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
topic: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "The topic or question to analyze in natural language. Arena auto-detects mode/lens/sources. " +
|
|
30
|
+
"Examples: 'review my latest changes', 'review 这个 PRD 是否完整', '讨论 arena 的产品定位', " +
|
|
31
|
+
"'规划通用化重构方案', 'discuss whether we should productize arena'",
|
|
32
|
+
},
|
|
33
|
+
mode: {
|
|
34
|
+
type: "string",
|
|
35
|
+
enum: ["review", "discussion", "planning"],
|
|
36
|
+
description: "Arena mode override. Usually auto-detected from topic. 'review' for structured review, 'discussion' for open analysis, 'planning' for roadmaps.",
|
|
37
|
+
},
|
|
38
|
+
participants: {
|
|
39
|
+
type: "array",
|
|
40
|
+
items: { type: "string" },
|
|
41
|
+
description: "Model preset names or full model paths for participants (min 2). " +
|
|
42
|
+
"Preset names: " +
|
|
43
|
+
Object.keys(MODEL_PRESETS).join(", ") +
|
|
44
|
+
". " +
|
|
45
|
+
"Default: ['claude', 'gpt'] if omitted.",
|
|
46
|
+
},
|
|
47
|
+
concluder: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Which participant builds the final consensus (by name). Default: first participant.",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ["topic"],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
// ─── Runtime LLM config — read from ctx, not module singletons ──
|
|
56
|
+
/**
|
|
57
|
+
* Snapshot of last-known Arena config, kept for the protocol-level
|
|
58
|
+
* /arena-status query. The query is not tied to an in-flight tool
|
|
59
|
+
* invocation, so it can't read ctx; instead, ctx-bearing tool calls
|
|
60
|
+
* publish their config here for the status endpoint to read back.
|
|
61
|
+
*
|
|
62
|
+
* This is a read-only cache used purely for diagnostics — Arena's
|
|
63
|
+
* actual execution path always reads from the per-call ctx.
|
|
64
|
+
*/
|
|
65
|
+
let _lastSeenLLMConfig;
|
|
66
|
+
let _lastSeenModelPool;
|
|
67
|
+
export function getArenaStatus() {
|
|
68
|
+
const baseUrl = _lastSeenLLMConfig?.baseUrl;
|
|
69
|
+
const entries = _lastSeenModelPool?.list() ?? [];
|
|
70
|
+
const fromSettings = readSettingsParticipants();
|
|
71
|
+
// Mirror arenaTool's resolution: settings is authoritative when
|
|
72
|
+
// present; otherwise fall back to the pool-derived preview so users
|
|
73
|
+
// can still see *something* in /arena-status before they configure.
|
|
74
|
+
const defaults = fromSettings.length >= 2 ? fromSettings : resolveDefaultParticipantNames(entries);
|
|
75
|
+
const probed = defaults.map((name) => {
|
|
76
|
+
const r = probeParticipant(name, entries, _lastSeenLLMConfig);
|
|
77
|
+
return {
|
|
78
|
+
name,
|
|
79
|
+
model: r.modelPath,
|
|
80
|
+
source: r.source,
|
|
81
|
+
compatible: r.compatible,
|
|
82
|
+
...(r.reason ? { reason: r.reason } : {}),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
endpoint: baseUrl,
|
|
87
|
+
defaultParticipants: probed,
|
|
88
|
+
poolSize: entries.length,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a preset name or model path into an ArenaParticipant,
|
|
93
|
+
* inheriting baseUrl/apiKey from the engine's LLM config.
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* Arena uses a lower temperature than the default conversation model
|
|
97
|
+
* to produce more deterministic, evidence-grounded analysis.
|
|
98
|
+
*/
|
|
99
|
+
const ARENA_TEMPERATURE = 0.3;
|
|
100
|
+
/**
|
|
101
|
+
* Resolution priority:
|
|
102
|
+
* 1. ModelPool key (the user's own configured models — uses each
|
|
103
|
+
* entry's own baseUrl/apiKey, so e.g. a "claude" pool entry
|
|
104
|
+
* pointing at OpenRouter still works while the active session
|
|
105
|
+
* runs on DeepSeek direct).
|
|
106
|
+
* 2. MODEL_PRESETS — only if the active endpoint can accept the
|
|
107
|
+
* preset's model namespace; otherwise we throw rather than
|
|
108
|
+
* silently miswire credentials.
|
|
109
|
+
* 3. Raw model path against the active endpoint.
|
|
110
|
+
*/
|
|
111
|
+
function resolveParticipant(nameOrPath, llmConfig, pool) {
|
|
112
|
+
const fallbackBaseUrl = llmConfig.baseUrl;
|
|
113
|
+
const fallbackApiKey = llmConfig.apiKey;
|
|
114
|
+
// (1) ModelPool entry — preferred path
|
|
115
|
+
const poolEntry = pool?.get(nameOrPath);
|
|
116
|
+
if (poolEntry)
|
|
117
|
+
return participantFromPool(poolEntry, llmConfig);
|
|
118
|
+
// (2) Built-in preset
|
|
119
|
+
const preset = MODEL_PRESETS[nameOrPath];
|
|
120
|
+
if (preset) {
|
|
121
|
+
assertEndpointAcceptsModel(fallbackBaseUrl, preset.model, nameOrPath);
|
|
122
|
+
return {
|
|
123
|
+
name: nameOrPath,
|
|
124
|
+
llm: {
|
|
125
|
+
provider: preset.provider,
|
|
126
|
+
model: preset.model,
|
|
127
|
+
maxTokens: preset.maxOutputTokens,
|
|
128
|
+
baseUrl: fallbackBaseUrl,
|
|
129
|
+
apiKey: fallbackApiKey,
|
|
130
|
+
},
|
|
131
|
+
clientDefaults: { temperature: ARENA_TEMPERATURE },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// (3) Raw model path
|
|
135
|
+
assertEndpointAcceptsModel(fallbackBaseUrl, nameOrPath, nameOrPath);
|
|
136
|
+
return {
|
|
137
|
+
name: nameOrPath.split("/").pop() ?? nameOrPath,
|
|
138
|
+
llm: {
|
|
139
|
+
provider: "openai",
|
|
140
|
+
model: nameOrPath,
|
|
141
|
+
maxTokens: getMaxOutputTokens(nameOrPath),
|
|
142
|
+
baseUrl: fallbackBaseUrl,
|
|
143
|
+
apiKey: fallbackApiKey,
|
|
144
|
+
},
|
|
145
|
+
clientDefaults: { temperature: ARENA_TEMPERATURE },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function participantFromPool(entry, llmConfig) {
|
|
149
|
+
// Pool entries carry their own baseUrl/apiKey — never paper over
|
|
150
|
+
// them with the active session's credentials, which is exactly the
|
|
151
|
+
// bug that caused 401/400s when Arena ran on a DeepSeek-only
|
|
152
|
+
// session but the pool had OpenRouter-keyed entries.
|
|
153
|
+
return {
|
|
154
|
+
name: entry.key,
|
|
155
|
+
llm: {
|
|
156
|
+
provider: entry.provider,
|
|
157
|
+
model: entry.model,
|
|
158
|
+
maxTokens: entry.maxOutputTokens ?? getMaxOutputTokens(entry.model),
|
|
159
|
+
baseUrl: entry.baseUrl ?? llmConfig.baseUrl,
|
|
160
|
+
apiKey: entry.apiKey ?? llmConfig.apiKey,
|
|
161
|
+
},
|
|
162
|
+
clientDefaults: { temperature: ARENA_TEMPERATURE },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Non-throwing variant of resolveParticipant used by /arena status —
|
|
167
|
+
* we want to *display* what would happen, not bail.
|
|
168
|
+
*/
|
|
169
|
+
function probeParticipant(nameOrPath, poolEntries, llmConfig) {
|
|
170
|
+
const fromPool = poolEntries.find((e) => e.key === nameOrPath);
|
|
171
|
+
if (fromPool) {
|
|
172
|
+
return { modelPath: fromPool.model, source: "pool", compatible: true };
|
|
173
|
+
}
|
|
174
|
+
const preset = MODEL_PRESETS[nameOrPath];
|
|
175
|
+
const modelPath = preset?.model ?? nameOrPath;
|
|
176
|
+
const source = preset ? "preset" : "raw";
|
|
177
|
+
const baseUrl = llmConfig?.baseUrl;
|
|
178
|
+
const endpointVendor = baseUrl ? inferEndpointVendor(baseUrl) : null;
|
|
179
|
+
const modelVendor = modelPath.includes("/") ? modelPath.split("/")[0].toLowerCase() : null;
|
|
180
|
+
if (endpointVendor && modelVendor && modelVendor !== endpointVendor) {
|
|
181
|
+
return {
|
|
182
|
+
modelPath,
|
|
183
|
+
source,
|
|
184
|
+
compatible: false,
|
|
185
|
+
reason: `endpoint serves "${endpointVendor}" only`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return { modelPath, source, compatible: true };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Quick, non-throwing predicate: would `nameOrPath` resolve to a
|
|
192
|
+
* working participant on the current endpoint? Used to decide whether
|
|
193
|
+
* the model's chosen `participants` list is honorable, or whether we
|
|
194
|
+
* should silently swap in the pool defaults (single-vendor endpoint
|
|
195
|
+
* with cross-vendor preset names is the common bad case).
|
|
196
|
+
*/
|
|
197
|
+
function participantWouldResolve(nameOrPath, poolEntries, baseUrl) {
|
|
198
|
+
return probeParticipant(nameOrPath, poolEntries, baseUrl ? { baseUrl } : undefined)
|
|
199
|
+
.compatible;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Read the user's configured arena participants from settings.json.
|
|
203
|
+
* Returns the list of string-form pool keys (the most common shape).
|
|
204
|
+
* Object-form entries (full name/model/provider tuples) are currently
|
|
205
|
+
* only honored by the CLI entry point and are skipped here.
|
|
206
|
+
*
|
|
207
|
+
* Returns an empty array if no settings file exists or the field is empty.
|
|
208
|
+
*/
|
|
209
|
+
function readSettingsParticipants(cwd) {
|
|
210
|
+
// A4: prefer Engine cwd when caller provides one; fall back to host
|
|
211
|
+
// process cwd for non-tool callers like getArenaStatus().
|
|
212
|
+
try {
|
|
213
|
+
const settings = new SettingsManager(cwd ?? process.cwd(), "full").get();
|
|
214
|
+
const raw = settings.capabilities?.arena?.participants ?? settings.arena?.participants ?? [];
|
|
215
|
+
const parsed = ArenaCapabilitySettingsSchema.shape.participants.safeParse(raw);
|
|
216
|
+
return parsed.success ? parsed.data : [];
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Persist the capability-owned settings namespace; legacy `arena.*` remains read-only. */
|
|
223
|
+
export function saveArenaSettingsByKeys(keys) {
|
|
224
|
+
const participants = ArenaCapabilitySettingsSchema.shape.participants.parse(keys);
|
|
225
|
+
new SettingsManager(process.cwd(), "full").saveUserSetting("capabilities.arena.participants", participants);
|
|
226
|
+
}
|
|
227
|
+
/** Package-owned schema for the optional capability settings namespace. */
|
|
228
|
+
export const ArenaCapabilitySettingsSchema = z
|
|
229
|
+
.object({
|
|
230
|
+
participants: z.array(z.string().trim().min(1)).default([]),
|
|
231
|
+
})
|
|
232
|
+
.passthrough();
|
|
233
|
+
/**
|
|
234
|
+
* If the user didn't explicitly pass `participants`, default to every
|
|
235
|
+
* pool entry. With <2 entries, fall back to pre-existing behavior so
|
|
236
|
+
* we don't regress installs that haven't filled out the pool yet.
|
|
237
|
+
*
|
|
238
|
+
* NOTE: this is now only used by getArenaStatus() for the diagnostic
|
|
239
|
+
* /arena-status preview. The actual execution path in arenaTool() reads
|
|
240
|
+
* from settings (readSettingsParticipants) and refuses to silently
|
|
241
|
+
* grab the whole pool — we hit a bug where the pool included a
|
|
242
|
+
* gemini-3.1-pro entry that the active endpoint couldn't serve, and
|
|
243
|
+
* the user's curated settings.arena.participants list was bypassed.
|
|
244
|
+
*/
|
|
245
|
+
function resolveDefaultParticipantNames(poolEntries) {
|
|
246
|
+
if (poolEntries.length >= 2) {
|
|
247
|
+
return poolEntries.map((e) => e.key);
|
|
248
|
+
}
|
|
249
|
+
if (poolEntries.length === 1) {
|
|
250
|
+
// Single-model pool: use it twice — Arena requires ≥2. Same model
|
|
251
|
+
// running twice still gives a useful self-review pass.
|
|
252
|
+
const onlyKey = poolEntries[0].key;
|
|
253
|
+
return [onlyKey, onlyKey];
|
|
254
|
+
}
|
|
255
|
+
// Empty pool: legacy fallback. The endpoint check downstream will
|
|
256
|
+
// turn an obviously-wrong combo (e.g. claude preset on DeepSeek
|
|
257
|
+
// direct) into a fast, descriptive error.
|
|
258
|
+
return ["claude", "gpt"];
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Arena presets use OpenRouter-style namespaced model paths
|
|
262
|
+
* (`anthropic/claude-opus-4.6`, `openai/gpt-5.4`, etc.). Those only
|
|
263
|
+
* resolve correctly on a multi-provider gateway like OpenRouter — a
|
|
264
|
+
* vendor's direct endpoint will reject any model name that isn't its
|
|
265
|
+
* own and we'd burn tens of seconds on retries before the user sees
|
|
266
|
+
* a useful error. Fail fast instead, naming the conflict.
|
|
267
|
+
*/
|
|
268
|
+
function assertEndpointAcceptsModel(baseUrl, modelPath, participantLabel) {
|
|
269
|
+
if (!baseUrl)
|
|
270
|
+
return;
|
|
271
|
+
const endpointVendor = inferEndpointVendor(baseUrl);
|
|
272
|
+
if (!endpointVendor)
|
|
273
|
+
return; // unknown gateway — assume permissive (e.g. OpenRouter, custom)
|
|
274
|
+
const modelVendor = modelPath.includes("/") ? modelPath.split("/")[0].toLowerCase() : null;
|
|
275
|
+
// Direct endpoint, no vendor prefix → caller must have used the
|
|
276
|
+
// exact model name the endpoint expects. Trust it.
|
|
277
|
+
if (modelVendor === null)
|
|
278
|
+
return;
|
|
279
|
+
if (modelVendor !== endpointVendor) {
|
|
280
|
+
throw new Error(`Arena participant "${participantLabel}" maps to model "${modelPath}", ` +
|
|
281
|
+
`but the active endpoint (${baseUrl}) only serves "${endpointVendor}" models. ` +
|
|
282
|
+
`Either switch to an aggregator endpoint (e.g. OpenRouter) or pick participants ` +
|
|
283
|
+
`whose models are native to this provider (e.g. "deepseek-v4-pro").`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function inferEndpointVendor(baseUrl) {
|
|
287
|
+
const u = baseUrl.toLowerCase();
|
|
288
|
+
if (u.includes("api.deepseek.com"))
|
|
289
|
+
return "deepseek";
|
|
290
|
+
if (u.includes("api.openai.com"))
|
|
291
|
+
return "openai";
|
|
292
|
+
if (u.includes("api.anthropic.com"))
|
|
293
|
+
return "anthropic";
|
|
294
|
+
if (u.includes("generativelanguage.googleapis.com"))
|
|
295
|
+
return "google";
|
|
296
|
+
// OpenRouter, Together, custom proxies, etc. — assume multi-vendor.
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
function formatStartupBanner(participants, llmConfig, source) {
|
|
300
|
+
const endpoint = llmConfig.baseUrl ?? "(none)";
|
|
301
|
+
const lines = [];
|
|
302
|
+
lines.push(`Arena: endpoint=${endpoint}`);
|
|
303
|
+
const sourceTag = source === "explicit-coerced-to-settings"
|
|
304
|
+
? "settings (auto-corrected from incompatible user picks)"
|
|
305
|
+
: source === "explicit"
|
|
306
|
+
? "user-specified"
|
|
307
|
+
: "settings";
|
|
308
|
+
lines.push(`Arena: participants=${participants.join(", ")} (${sourceTag})`);
|
|
309
|
+
return lines.join("\n");
|
|
310
|
+
}
|
|
311
|
+
/** Strip ANSI escape codes from chalk-styled text */
|
|
312
|
+
function stripAnsi(text) {
|
|
313
|
+
// eslint-disable-next-line no-control-regex
|
|
314
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
315
|
+
}
|
|
316
|
+
export async function arenaTool(args, ctx) {
|
|
317
|
+
const topic = args.topic;
|
|
318
|
+
if (!topic)
|
|
319
|
+
return "Error: topic is required";
|
|
320
|
+
const signal = args.__signal;
|
|
321
|
+
if (signal?.aborted)
|
|
322
|
+
return "Arena aborted before starting.";
|
|
323
|
+
if (!ctx?.llmConfig) {
|
|
324
|
+
return "Error: Arena LLM config not initialized. This is a bug — the engine should inject it via ToolContext.";
|
|
325
|
+
}
|
|
326
|
+
const llmConfig = ctx.llmConfig;
|
|
327
|
+
const pool = ctx.modelPool;
|
|
328
|
+
// Update the diagnostic snapshot used by /arena-status
|
|
329
|
+
_lastSeenLLMConfig = llmConfig;
|
|
330
|
+
_lastSeenModelPool = pool;
|
|
331
|
+
const explicitParticipants = args.participants;
|
|
332
|
+
const poolEntries = pool?.list() ?? [];
|
|
333
|
+
// Participant resolution order:
|
|
334
|
+
// 1. Explicit `participants` arg from the tool call — honored only
|
|
335
|
+
// when every entry resolves on the current endpoint; otherwise
|
|
336
|
+
// we coerce to settings (preferred) or pool defaults.
|
|
337
|
+
// 2. settings.arena.participants — the user's curated list. This
|
|
338
|
+
// is the authoritative config; we never silently grab the whole
|
|
339
|
+
// pool, because the pool can contain models that the active
|
|
340
|
+
// endpoint can't serve (e.g. gemini-3.1-pro on Google direct).
|
|
341
|
+
// 3. No settings configured → return an actionable error pointing
|
|
342
|
+
// the user at where to configure it. Previously we silently
|
|
343
|
+
// defaulted to the entire pool, which caused 404/400 storms
|
|
344
|
+
// when pool entries didn't match the active endpoint.
|
|
345
|
+
let participantNames;
|
|
346
|
+
let participantSource;
|
|
347
|
+
if (explicitParticipants && explicitParticipants.length > 0) {
|
|
348
|
+
const allCompatible = explicitParticipants.every((name) => participantWouldResolve(name, poolEntries, llmConfig.baseUrl));
|
|
349
|
+
if (allCompatible) {
|
|
350
|
+
participantNames = explicitParticipants;
|
|
351
|
+
participantSource = "explicit";
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const fromSettings = readSettingsParticipants(ctx?.cwd);
|
|
355
|
+
if (fromSettings.length >= 2) {
|
|
356
|
+
participantNames = fromSettings;
|
|
357
|
+
participantSource = "explicit-coerced-to-settings";
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
return ("Error: Arena participants from the call are incompatible with the active endpoint, " +
|
|
361
|
+
`and no fallback is configured. Set "capabilities.arena.participants" (≥2 model names) in ` +
|
|
362
|
+
"~/.code-shell/settings.json, or pass a compatible `participants` array.");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
const fromSettings = readSettingsParticipants(ctx?.cwd);
|
|
368
|
+
if (fromSettings.length >= 2) {
|
|
369
|
+
participantNames = fromSettings;
|
|
370
|
+
participantSource = "settings";
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
return ("Error: Arena requires at least 2 participants but none are configured. " +
|
|
374
|
+
`Add a "capabilities": { "arena": { "participants": ["model-a", "model-b", ...] } } block to ` +
|
|
375
|
+
"~/.code-shell/settings.json (each entry is a model pool key, e.g. " +
|
|
376
|
+
'"deepseek-v4-pro", "openrouter-claude-opus-4.7-fast"), or pass an explicit ' +
|
|
377
|
+
"`participants` array in the tool call.");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (participantNames.length < 2) {
|
|
381
|
+
return "Error: Arena requires at least 2 participants. Provide 2 or more model names.";
|
|
382
|
+
}
|
|
383
|
+
const mode = args.mode;
|
|
384
|
+
const concluder = args.concluder;
|
|
385
|
+
// Collect progress for inclusion in the final tool result.
|
|
386
|
+
// Strip ANSI codes from progress since tool results are plain text/markdown.
|
|
387
|
+
const progressLog = [];
|
|
388
|
+
const progressRenderer = createProgressRenderer((text) => {
|
|
389
|
+
progressLog.push(stripAnsi(text));
|
|
390
|
+
});
|
|
391
|
+
// Surface the resolved configuration up front so the user can see
|
|
392
|
+
// exactly which models will be polled and against which endpoint —
|
|
393
|
+
// previously this was invisible until something blew up.
|
|
394
|
+
const configLine = formatStartupBanner(participantNames, llmConfig, participantSource);
|
|
395
|
+
progressLog.push(configLine);
|
|
396
|
+
try {
|
|
397
|
+
// resolveParticipant() may throw on endpoint/model mismatch — keep
|
|
398
|
+
// it inside the try so the error returns as a tool-result string
|
|
399
|
+
// rather than crashing the engine.
|
|
400
|
+
const participants = participantNames.map((n) => resolveParticipant(n, llmConfig, pool));
|
|
401
|
+
const arena = new Arena({
|
|
402
|
+
participants,
|
|
403
|
+
mode,
|
|
404
|
+
concluder,
|
|
405
|
+
enableContextTools: true,
|
|
406
|
+
onProgress: progressRenderer,
|
|
407
|
+
signal,
|
|
408
|
+
});
|
|
409
|
+
const result = await arena.run(topic);
|
|
410
|
+
ctx.recordBilledUsage?.(result.usage);
|
|
411
|
+
// Use session renderer (markdown, no ANSI) for tool results.
|
|
412
|
+
// Terminal renderer (chalk) is only for direct stdout printing.
|
|
413
|
+
const formattedResult = formatArenaResultForSession(result);
|
|
414
|
+
// Prepend a condensed progress summary so the LLM can see what was done
|
|
415
|
+
const progressSummary = progressLog.length > 0 ? "── Arena Progress ──\n" + progressLog.join("\n") + "\n\n" : "";
|
|
416
|
+
// Cap total output to prevent context overflow
|
|
417
|
+
const MAX_TOOL_OUTPUT = 30_000;
|
|
418
|
+
const full = progressSummary + formattedResult;
|
|
419
|
+
if (full.length > MAX_TOOL_OUTPUT) {
|
|
420
|
+
return (full.slice(0, MAX_TOOL_OUTPUT) + "\n\n... (truncated, total " + full.length + " chars)");
|
|
421
|
+
}
|
|
422
|
+
return full;
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
if (signal?.aborted)
|
|
426
|
+
return "Arena aborted.";
|
|
427
|
+
// err may be a non-Error (string/null); `(err as Error).message` would be
|
|
428
|
+
// undefined → "Arena error: undefined", masking the real cause.
|
|
429
|
+
return `Arena error: ${err instanceof Error ? err.message : String(err)}`;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/** Create the explicit Arena product capability. Core does not import this package. */
|
|
433
|
+
export function createArenaCapability() {
|
|
434
|
+
return {
|
|
435
|
+
id: "arena",
|
|
436
|
+
tools: [
|
|
437
|
+
{
|
|
438
|
+
definition: {
|
|
439
|
+
...arenaToolDef,
|
|
440
|
+
source: "builtin",
|
|
441
|
+
permissionDefault: "ask",
|
|
442
|
+
isReadOnly: true,
|
|
443
|
+
isConcurrencySafe: false,
|
|
444
|
+
timeoutMs: 1_800_000,
|
|
445
|
+
},
|
|
446
|
+
execute: arenaTool,
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
queries: {
|
|
450
|
+
arena_status: () => getArenaStatus(),
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context tools — read-only tools that arena participants can use
|
|
3
|
+
* to fetch additional source context during research.
|
|
4
|
+
*
|
|
5
|
+
* Security: All shell commands use execFileSync with argument arrays
|
|
6
|
+
* to prevent command injection. File paths are validated against the
|
|
7
|
+
* repository boundary to prevent path traversal.
|
|
8
|
+
*/
|
|
9
|
+
import type { ToolDefinition, ToolCall } from "@cjhyy/code-shell-core/extension";
|
|
10
|
+
export declare const MAX_TOOL_RESULT = 15000;
|
|
11
|
+
export declare const MAX_TOOL_ROUNDS = 3;
|
|
12
|
+
export declare const CONTEXT_TOOLS: ToolDefinition[];
|
|
13
|
+
/**
|
|
14
|
+
* Execute a context tool call and return the result string.
|
|
15
|
+
*/
|
|
16
|
+
export declare function executeContextTool(tc: ToolCall): string;
|