min-agent 0.2.1 → 0.4.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 +242 -31
- package/dist/agent.js +1233 -485
- package/dist/assistant-stream.js +11 -7
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +7 -632
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +35 -17
- package/dist/compaction.js +457 -169
- package/dist/config.js +298 -38
- package/dist/confirm.js +105 -9
- package/dist/context-window.js +156 -75
- package/dist/doom-loop.js +268 -26
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/http.js +119 -0
- package/dist/instructions.js +72 -33
- package/dist/logger.js +95 -0
- package/dist/markdown.js +35 -50
- package/dist/mcp.js +847 -102
- package/dist/memory.js +128 -45
- package/dist/output.js +42 -31
- package/dist/paste-handler.js +3 -3
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +76 -11
- package/dist/pricing.js +119 -0
- package/dist/provider.js +34 -15
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +74 -635
- package/dist/sessions.js +197 -15
- package/dist/skills.js +531 -77
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +9 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +178 -0
- package/dist/tool-output.js +53 -46
- package/dist/tools/apply_patch.js +265 -0
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +186 -71
- package/dist/tools/code_search.js +13 -6
- package/dist/tools/edit.js +26 -9
- package/dist/tools/explore.js +144 -16
- package/dist/tools/glob.js +7 -3
- package/dist/tools/grep.js +153 -14
- package/dist/tools/index.js +9 -24
- package/dist/tools/question.js +31 -30
- package/dist/tools/read.js +77 -15
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +100 -33
- package/dist/tools/todo.js +178 -67
- package/dist/tools/web_fetch.js +158 -46
- package/dist/tools/web_search.js +217 -29
- package/dist/tools/write.js +34 -11
- package/dist/tui/App.js +89 -6
- package/dist/tui/ConfirmBar.js +57 -4
- package/dist/tui/InputBar.js +504 -44
- package/dist/tui/MessageList.js +674 -20
- package/dist/tui/ModelPicker.js +113 -0
- package/dist/tui/QuestionBar.js +136 -0
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +14 -12
- package/dist/tui/agent-runner.js +223 -0
- package/dist/tui/caret-pos.js +177 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +49 -0
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +189 -31
- package/dist/tui/input-history.js +125 -0
- package/dist/tui/layout.js +88 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +226 -0
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +106 -0
- package/dist/tui/slash-handler.js +545 -0
- package/dist/tui/text-width.js +113 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +29 -0
- package/dist/tui-chat.js +346 -330
- package/dist/updater.js +116 -0
- package/dist/xml-search.js +194 -0
- package/docs/API.md +410 -32
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +12 -8
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
package/dist/context-window.js
CHANGED
|
@@ -1,46 +1,104 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, mkdirSync, existsSync } from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { atomicWriteFileSync } from "./tools/atomic-file.js";
|
|
4
|
+
import { getConfigDir, getEffectiveConfig, getActiveProvider } from "./config.js";
|
|
4
5
|
/**
|
|
5
6
|
* Auto-detect context window size for the current model.
|
|
6
7
|
*
|
|
7
8
|
* Resolution order:
|
|
8
9
|
* 1. User config: provider.contextWindow (explicit override)
|
|
9
|
-
* 2. Provider-specific API (OpenRouter, vLLM, Ollama)
|
|
10
|
+
* 2. Provider-specific API (OpenRouter, vLLM, Ollama, OpenAI-compatible /models)
|
|
10
11
|
* 3. models.dev API lookup
|
|
11
|
-
* 4. Fallback:
|
|
12
|
+
* 4. Fallback: 512000 (memory only — never persisted as a detected value)
|
|
12
13
|
*/
|
|
13
|
-
const DEFAULT_CONTEXT_WINDOW =
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
function
|
|
17
|
-
|
|
14
|
+
export const DEFAULT_CONTEXT_WINDOW = 512000;
|
|
15
|
+
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
const FALLBACK_MEMORY_TTL = 10 * 60 * 1000;
|
|
17
|
+
function cacheFile() {
|
|
18
|
+
return path.join(getConfigDir(), "context-window-cache.json");
|
|
19
|
+
}
|
|
20
|
+
const memoryCache = new Map();
|
|
21
|
+
const inFlight = new Map();
|
|
22
|
+
function loadDiskCache() {
|
|
23
|
+
const file = cacheFile();
|
|
24
|
+
if (!existsSync(file))
|
|
18
25
|
return {};
|
|
19
26
|
try {
|
|
20
|
-
return JSON.parse(readFileSync(
|
|
27
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
21
28
|
}
|
|
22
29
|
catch {
|
|
23
30
|
return {};
|
|
24
31
|
}
|
|
25
32
|
}
|
|
26
33
|
function saveCache(cache) {
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
const file = cacheFile();
|
|
35
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
atomicWriteFileSync(file, JSON.stringify(cache));
|
|
37
|
+
}
|
|
38
|
+
function cacheTtl(source) {
|
|
39
|
+
return source === "fallback" ? FALLBACK_MEMORY_TTL : CACHE_TTL;
|
|
29
40
|
}
|
|
30
41
|
function getCached(modelId) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
const memory = memoryCache.get(modelId);
|
|
43
|
+
if (memory && Date.now() - memory.timestamp <= cacheTtl(memory.source)) {
|
|
44
|
+
return { tokens: memory.contextWindow, source: memory.source };
|
|
45
|
+
}
|
|
46
|
+
const disk = loadDiskCache()[modelId];
|
|
47
|
+
if (!disk || disk.source !== "detected")
|
|
34
48
|
return null;
|
|
35
|
-
if (Date.now() -
|
|
49
|
+
if (Date.now() - disk.timestamp > CACHE_TTL)
|
|
36
50
|
return null;
|
|
37
|
-
|
|
51
|
+
memoryCache.set(modelId, { ...disk, source: "detected" });
|
|
52
|
+
return { tokens: disk.contextWindow, source: "detected" };
|
|
38
53
|
}
|
|
39
|
-
function setCache(modelId, contextWindow) {
|
|
40
|
-
const
|
|
41
|
-
|
|
54
|
+
function setCache(modelId, contextWindow, source) {
|
|
55
|
+
const entry = { contextWindow, timestamp: Date.now(), source };
|
|
56
|
+
memoryCache.set(modelId, entry);
|
|
57
|
+
if (source !== "detected")
|
|
58
|
+
return;
|
|
59
|
+
const cache = loadDiskCache();
|
|
60
|
+
cache[modelId] = entry;
|
|
42
61
|
saveCache(cache);
|
|
43
62
|
}
|
|
63
|
+
export function extractModelContextLength(model) {
|
|
64
|
+
if (!model || typeof model !== "object")
|
|
65
|
+
return null;
|
|
66
|
+
const rec = model;
|
|
67
|
+
const nested = [rec, rec.meta, rec.limits, rec.limit, rec.model_info];
|
|
68
|
+
const keys = [
|
|
69
|
+
"context_length",
|
|
70
|
+
"max_model_len",
|
|
71
|
+
"max_model_length",
|
|
72
|
+
"max_context_length",
|
|
73
|
+
"context_window",
|
|
74
|
+
"max_input_tokens",
|
|
75
|
+
"max_total_tokens",
|
|
76
|
+
"context",
|
|
77
|
+
"general.context_length",
|
|
78
|
+
];
|
|
79
|
+
for (const obj of nested) {
|
|
80
|
+
if (!obj || typeof obj !== "object")
|
|
81
|
+
continue;
|
|
82
|
+
const bag = obj;
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
const n = bag[key];
|
|
85
|
+
if (typeof n === "number" && Number.isFinite(n) && n >= 4096)
|
|
86
|
+
return Math.floor(n);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function matchListedModel(models, modelId) {
|
|
92
|
+
const exact = models.find((m) => m && typeof m === "object" && m.id === modelId);
|
|
93
|
+
if (exact)
|
|
94
|
+
return exact;
|
|
95
|
+
return models.find((m) => {
|
|
96
|
+
if (!m || typeof m !== "object")
|
|
97
|
+
return false;
|
|
98
|
+
const id = m.id;
|
|
99
|
+
return typeof id === "string" && (id.endsWith(`/${modelId}`) || modelId.endsWith(`/${id}`));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
44
102
|
/** Try OpenRouter: GET /api/v1/models returns context_length per model */
|
|
45
103
|
async function tryOpenRouter(baseURL, apiKey, modelId) {
|
|
46
104
|
if (!baseURL.includes("openrouter"))
|
|
@@ -53,8 +111,7 @@ async function tryOpenRouter(baseURL, apiKey, modelId) {
|
|
|
53
111
|
if (!response.ok)
|
|
54
112
|
return null;
|
|
55
113
|
const data = (await response.json());
|
|
56
|
-
|
|
57
|
-
return model?.context_length ?? null;
|
|
114
|
+
return extractModelContextLength(matchListedModel(data.data ?? [], modelId));
|
|
58
115
|
}
|
|
59
116
|
catch {
|
|
60
117
|
return null;
|
|
@@ -62,6 +119,8 @@ async function tryOpenRouter(baseURL, apiKey, modelId) {
|
|
|
62
119
|
}
|
|
63
120
|
/** Try vLLM: GET /v1/models returns max_model_len */
|
|
64
121
|
async function tryVllm(baseURL, apiKey, modelId) {
|
|
122
|
+
if (!baseURL.includes("vllm"))
|
|
123
|
+
return null;
|
|
65
124
|
try {
|
|
66
125
|
const url = `${baseURL.replace(/\/$/, "")}/models`;
|
|
67
126
|
const response = await fetch(url, {
|
|
@@ -71,9 +130,27 @@ async function tryVllm(baseURL, apiKey, modelId) {
|
|
|
71
130
|
if (!response.ok)
|
|
72
131
|
return null;
|
|
73
132
|
const data = (await response.json());
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
133
|
+
return extractModelContextLength(matchListedModel(data.data ?? [], modelId));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Any OpenAI-compatible /models that advertises context length (1M custom endpoints included). */
|
|
140
|
+
async function tryProviderModels(baseURL, apiKey, modelId) {
|
|
141
|
+
if (!baseURL)
|
|
142
|
+
return null;
|
|
143
|
+
try {
|
|
144
|
+
const url = `${baseURL.replace(/\/$/, "")}/models`;
|
|
145
|
+
const response = await fetch(url, {
|
|
146
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
147
|
+
signal: AbortSignal.timeout(8000),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok)
|
|
150
|
+
return null;
|
|
151
|
+
const data = (await response.json());
|
|
152
|
+
const models = Array.isArray(data) ? data : (data.data ?? []);
|
|
153
|
+
return extractModelContextLength(matchListedModel(models, modelId));
|
|
77
154
|
}
|
|
78
155
|
catch {
|
|
79
156
|
return null;
|
|
@@ -96,9 +173,7 @@ async function tryOllama(baseURL, modelId) {
|
|
|
96
173
|
return null;
|
|
97
174
|
const data = (await response.json());
|
|
98
175
|
// Ollama returns model_info with context length
|
|
99
|
-
const ctxLength = data.model_info?.["general.context_length"] ??
|
|
100
|
-
data.model_info?.context_length ??
|
|
101
|
-
data.parameters?.num_ctx;
|
|
176
|
+
const ctxLength = data.model_info?.["general.context_length"] ?? data.model_info?.context_length ?? data.parameters?.num_ctx;
|
|
102
177
|
return typeof ctxLength === "number" ? ctxLength : null;
|
|
103
178
|
}
|
|
104
179
|
catch {
|
|
@@ -114,15 +189,7 @@ async function tryModelsDev(modelId) {
|
|
|
114
189
|
if (!response.ok)
|
|
115
190
|
return null;
|
|
116
191
|
const providers = (await response.json());
|
|
117
|
-
//
|
|
118
|
-
for (const provider of Object.values(providers)) {
|
|
119
|
-
if (!provider.models)
|
|
120
|
-
continue;
|
|
121
|
-
const model = provider.models[modelId];
|
|
122
|
-
if (model?.limit?.context)
|
|
123
|
-
return model.limit.context;
|
|
124
|
-
}
|
|
125
|
-
// Try partial match (some providers prefix model IDs)
|
|
192
|
+
// Single pass: exact match or partial match (some providers prefix model IDs)
|
|
126
193
|
for (const provider of Object.values(providers)) {
|
|
127
194
|
if (!provider.models)
|
|
128
195
|
continue;
|
|
@@ -140,46 +207,60 @@ async function tryModelsDev(modelId) {
|
|
|
140
207
|
}
|
|
141
208
|
}
|
|
142
209
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
210
|
+
* Detect context window size for a model by probing all sources in parallel.
|
|
211
|
+
* Returns the first non-null result, respecting the original precedence.
|
|
145
212
|
*/
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
213
|
+
async function detectContextWindow(config, id) {
|
|
214
|
+
const provider = getActiveProvider(config);
|
|
215
|
+
const baseURL = provider?.baseURL ?? "";
|
|
216
|
+
const apiKey = provider?.apiKey ?? "";
|
|
217
|
+
const results = await Promise.all([
|
|
218
|
+
tryOpenRouter(baseURL, apiKey, id),
|
|
219
|
+
tryOllama(baseURL, id),
|
|
220
|
+
tryVllm(baseURL, apiKey, id),
|
|
221
|
+
tryProviderModels(baseURL, apiKey, id),
|
|
222
|
+
tryModelsDev(id),
|
|
223
|
+
]);
|
|
224
|
+
const found = results.find((v) => v !== null);
|
|
225
|
+
if (found) {
|
|
226
|
+
setCache(id, found, "detected");
|
|
227
|
+
return { tokens: found, source: "detected" };
|
|
228
|
+
}
|
|
229
|
+
setCache(id, DEFAULT_CONTEXT_WINDOW, "fallback");
|
|
230
|
+
return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Get context window size and whether it was configured, detected, or guessed.
|
|
234
|
+
* Fallback 512k is never written to disk as a detected value.
|
|
235
|
+
*/
|
|
236
|
+
export async function getContextWindowInfo(modelId) {
|
|
237
|
+
const config = getEffectiveConfig();
|
|
238
|
+
const provider = getActiveProvider(config);
|
|
239
|
+
if (provider?.contextWindow)
|
|
240
|
+
return { tokens: provider.contextWindow, source: "config" };
|
|
241
|
+
const id = modelId ?? provider?.defaultModel;
|
|
152
242
|
if (!id)
|
|
153
|
-
return DEFAULT_CONTEXT_WINDOW;
|
|
154
|
-
// 2. Check cache
|
|
243
|
+
return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
|
|
155
244
|
const cached = getCached(id);
|
|
156
|
-
if (cached
|
|
245
|
+
if (cached)
|
|
157
246
|
return cached;
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const modelsDev = await tryModelsDev(id);
|
|
178
|
-
if (modelsDev) {
|
|
179
|
-
setCache(id, modelsDev);
|
|
180
|
-
return modelsDev;
|
|
181
|
-
}
|
|
182
|
-
// 5. Fallback
|
|
183
|
-
setCache(id, DEFAULT_CONTEXT_WINDOW);
|
|
184
|
-
return DEFAULT_CONTEXT_WINDOW;
|
|
247
|
+
const pending = inFlight.get(id);
|
|
248
|
+
if (pending)
|
|
249
|
+
return pending;
|
|
250
|
+
const probing = detectContextWindow(config, id);
|
|
251
|
+
inFlight.set(id, probing);
|
|
252
|
+
try {
|
|
253
|
+
return await probing;
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
inFlight.delete(id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Get context window size for the current model.
|
|
261
|
+
* Tries multiple sources, caches detected results in memory and on disk.
|
|
262
|
+
* Concurrent calls for the same model share a single probe.
|
|
263
|
+
*/
|
|
264
|
+
export async function getContextWindow(modelId) {
|
|
265
|
+
return (await getContextWindowInfo(modelId)).tokens;
|
|
185
266
|
}
|
package/dist/doom-loop.js
CHANGED
|
@@ -1,36 +1,278 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Loop / stall detection for the agent run.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 1. Exact identity: the same tool with the same (normalized) arguments,
|
|
5
|
+
* THRESHOLD times in a row → halt. Non-consecutive repeats of the same call
|
|
6
|
+
* are counted too (a parallel batch used to flush the old sliding window),
|
|
7
|
+
* with a higher threshold.
|
|
8
|
+
* 2. Web research productivity: a research call is judged by what came back.
|
|
9
|
+
* Rounds that bring new URLs / domains are free — gathering data about many
|
|
10
|
+
* entities legitimately needs many searches. Rounds that bring nothing new
|
|
11
|
+
* build an "unproductive" streak that first steers the model to write, then
|
|
12
|
+
* removes the research tools for the rest of the turn. A hard per-turn cap
|
|
13
|
+
* still applies.
|
|
7
14
|
*
|
|
8
|
-
*
|
|
15
|
+
* The detector is owned by the outer run loop so auto-continue cannot reset it.
|
|
9
16
|
*/
|
|
10
|
-
|
|
17
|
+
/** Same tool + same args, back to back. */
|
|
18
|
+
export const EXACT_LOOP_THRESHOLD = 3;
|
|
19
|
+
/** Same tool + same args anywhere in the turn (survives parallel batches). */
|
|
20
|
+
export const REPEAT_TOTAL_THRESHOLD = 5;
|
|
21
|
+
/** Research rounds without new sources before steering the model to deliver. */
|
|
22
|
+
export const RESEARCH_STEER_AFTER = 3;
|
|
23
|
+
/** Research rounds without new sources before the research tools are dropped. */
|
|
24
|
+
export const RESEARCH_STOP_AFTER = 5;
|
|
25
|
+
/** Hard ceiling on research calls in a single turn. */
|
|
26
|
+
export const RESEARCH_TOTAL_CAP = 24;
|
|
27
|
+
export const WEB_RESEARCH_TOOLS = new Set(["search_web", "web_fetch"]);
|
|
28
|
+
const ARTIFACT_TOOLS = new Set(["write", "edit", "apply_patch"]);
|
|
29
|
+
export const STEER_PROMPT = "Your last few searches and page fetches returned nothing new. Stop calling search_web and web_fetch. Using only what you already have, produce the user's requested deliverable now (files, code, or a complete answer). If some fields are uncertain, mark them and proceed — do not search again.";
|
|
30
|
+
export const DELIVER_PROMPT = "The research tools have been removed for the rest of this turn. Do not try to search again. Using the information already in this conversation, produce the user's requested deliverable now (write files with the write/edit tools). If a fact is missing, note the gap and still write the output.";
|
|
31
|
+
export const RESEARCH_STUB_RESULT = "Web search and page fetch are disabled for the rest of this turn. Produce the user's requested deliverable from information already in this conversation. If a fact is missing, note the gap and still write the output.";
|
|
32
|
+
export const LOOP_HALT_MESSAGE = "同一操作连续重复了多次,已停止以免空转。发送消息可继续。";
|
|
33
|
+
function positiveInt(value, fallback) {
|
|
34
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1)
|
|
35
|
+
return fallback;
|
|
36
|
+
return Math.floor(value);
|
|
37
|
+
}
|
|
38
|
+
/** Deterministic serialization so equivalent objects compare equal regardless of key order. */
|
|
39
|
+
function serializeInput(input) {
|
|
40
|
+
try {
|
|
41
|
+
if (input === null || typeof input !== "object")
|
|
42
|
+
return JSON.stringify(input);
|
|
43
|
+
if (Array.isArray(input))
|
|
44
|
+
return JSON.stringify(input.map(serializeInput));
|
|
45
|
+
const sorted = {};
|
|
46
|
+
for (const key of Object.keys(input).sort()) {
|
|
47
|
+
sorted[key] = serializeInput(input[key]);
|
|
48
|
+
}
|
|
49
|
+
return JSON.stringify(sorted);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return String(input);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function asRecord(input) {
|
|
56
|
+
if (input && typeof input === "object" && !Array.isArray(input))
|
|
57
|
+
return input;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function normalizeQuery(raw) {
|
|
61
|
+
if (typeof raw !== "string")
|
|
62
|
+
return "";
|
|
63
|
+
return raw.toLowerCase().replace(/\s+/g, " ").trim();
|
|
64
|
+
}
|
|
65
|
+
function hostOf(raw) {
|
|
66
|
+
try {
|
|
67
|
+
return new URL(raw.trim()).hostname.toLowerCase().replace(/^www\./, "");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function normalizeUrl(raw) {
|
|
74
|
+
if (typeof raw !== "string")
|
|
75
|
+
return "";
|
|
76
|
+
try {
|
|
77
|
+
const u = new URL(raw.trim());
|
|
78
|
+
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
|
79
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
80
|
+
return `${u.protocol}//${host}${path}${u.search}`;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return raw.trim().toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Identity used for exact-repeat detection (search queries / URLs are normalized). */
|
|
87
|
+
export function identityPayload(toolName, input) {
|
|
88
|
+
const rec = asRecord(input);
|
|
89
|
+
if (toolName === "search_web" && rec)
|
|
90
|
+
return serializeInput({ query: normalizeQuery(rec.query) });
|
|
91
|
+
if (toolName === "web_fetch" && rec)
|
|
92
|
+
return serializeInput({ url: normalizeUrl(rec.url) });
|
|
93
|
+
return serializeInput(input);
|
|
94
|
+
}
|
|
95
|
+
const URL_IN_TEXT = /https?:\/\/[^\s"'<>)\]}]+/g;
|
|
96
|
+
const RESULT_ERROR = /^(error|search error|fetch error)\b/i;
|
|
97
|
+
/** New URLs / domains found in a research result, ignoring ones already seen. */
|
|
98
|
+
export function extractSources(text) {
|
|
99
|
+
const urls = new Set();
|
|
100
|
+
const hosts = new Set();
|
|
101
|
+
for (const raw of text.match(URL_IN_TEXT) ?? []) {
|
|
102
|
+
const url = normalizeUrl(raw);
|
|
103
|
+
if (url)
|
|
104
|
+
urls.add(url);
|
|
105
|
+
const host = hostOf(raw);
|
|
106
|
+
if (host)
|
|
107
|
+
hosts.add(host);
|
|
108
|
+
}
|
|
109
|
+
return { urls: [...urls], hosts: [...hosts] };
|
|
110
|
+
}
|
|
11
111
|
export class DoomLoopDetector {
|
|
12
|
-
|
|
13
|
-
|
|
112
|
+
lastCall = { key: "", count: 0 };
|
|
113
|
+
callCounts = new Map();
|
|
114
|
+
webTotal = 0;
|
|
115
|
+
/** Research calls issued but whose results have not been seen yet. */
|
|
116
|
+
inFlight = 0;
|
|
117
|
+
/** Did the current batch of research calls bring anything new? */
|
|
118
|
+
batchProductive = false;
|
|
119
|
+
unproductiveRounds = 0;
|
|
120
|
+
steered = false;
|
|
121
|
+
capped = false;
|
|
122
|
+
delivered = false;
|
|
123
|
+
seenUrls = new Set();
|
|
124
|
+
seenHosts = new Set();
|
|
125
|
+
steerAfter;
|
|
126
|
+
stopAfter;
|
|
127
|
+
totalCap;
|
|
128
|
+
constructor(opts) {
|
|
129
|
+
this.steerAfter = positiveInt(opts?.steerAfter, RESEARCH_STEER_AFTER);
|
|
130
|
+
this.stopAfter = Math.max(positiveInt(opts?.stopAfter, RESEARCH_STOP_AFTER), this.steerAfter + 1);
|
|
131
|
+
this.totalCap = positiveInt(opts?.totalCap, RESEARCH_TOTAL_CAP);
|
|
132
|
+
}
|
|
133
|
+
get webResearchCount() {
|
|
134
|
+
return this.webTotal;
|
|
135
|
+
}
|
|
136
|
+
get researchCapped() {
|
|
137
|
+
return this.capped;
|
|
138
|
+
}
|
|
139
|
+
get producedArtifact() {
|
|
140
|
+
return this.delivered;
|
|
141
|
+
}
|
|
142
|
+
/** Research rounds in a row that returned no new sources. */
|
|
143
|
+
get unproductiveStreak() {
|
|
144
|
+
return this.unproductiveRounds;
|
|
145
|
+
}
|
|
146
|
+
/** Record a tool call. Returns true if the run should halt (exact identity loop). */
|
|
14
147
|
record(toolName, input) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
148
|
+
return this.observe(toolName, input) === "halt";
|
|
149
|
+
}
|
|
150
|
+
/** A tool call is starting. */
|
|
151
|
+
observe(toolName, input) {
|
|
152
|
+
const key = `${toolName}\u0000${identityPayload(toolName, input)}`;
|
|
153
|
+
if (this.lastCall.key === key)
|
|
154
|
+
this.lastCall.count++;
|
|
155
|
+
else
|
|
156
|
+
this.lastCall = { key, count: 1 };
|
|
157
|
+
const total = (this.callCounts.get(key) ?? 0) + 1;
|
|
158
|
+
this.callCounts.set(key, total);
|
|
159
|
+
if (this.lastCall.count >= EXACT_LOOP_THRESHOLD || total >= REPEAT_TOTAL_THRESHOLD) {
|
|
160
|
+
this.lastCall = { key: "", count: 0 };
|
|
161
|
+
this.callCounts.delete(key);
|
|
162
|
+
return "halt";
|
|
163
|
+
}
|
|
164
|
+
if (WEB_RESEARCH_TOOLS.has(toolName)) {
|
|
165
|
+
this.webTotal++;
|
|
166
|
+
if (this.inFlight === 0)
|
|
167
|
+
this.batchProductive = false;
|
|
168
|
+
this.inFlight++;
|
|
169
|
+
if (!this.capped && this.webTotal >= this.totalCap) {
|
|
170
|
+
this.markCapped();
|
|
171
|
+
return "cap";
|
|
172
|
+
}
|
|
173
|
+
return "ok";
|
|
174
|
+
}
|
|
175
|
+
if (ARTIFACT_TOOLS.has(toolName)) {
|
|
176
|
+
// Real progress: forgive the research history so far.
|
|
177
|
+
this.delivered = true;
|
|
178
|
+
this.unproductiveRounds = 0;
|
|
179
|
+
}
|
|
180
|
+
// Other tools neither help nor hurt the research budget.
|
|
181
|
+
return "ok";
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* A tool result came back. Research productivity is judged here, once the
|
|
185
|
+
* whole parallel batch has landed, so four fresh searches in one step are not
|
|
186
|
+
* mistaken for a loop.
|
|
187
|
+
*/
|
|
188
|
+
observeResult(toolName, output) {
|
|
189
|
+
if (!WEB_RESEARCH_TOOLS.has(toolName))
|
|
190
|
+
return "ok";
|
|
191
|
+
const text = typeof output === "string" ? output : safeString(output);
|
|
192
|
+
if (!RESULT_ERROR.test(text.trim())) {
|
|
193
|
+
const { urls, hosts } = extractSources(text);
|
|
194
|
+
let fresh = 0;
|
|
195
|
+
for (const url of urls) {
|
|
196
|
+
if (!this.seenUrls.has(url)) {
|
|
197
|
+
this.seenUrls.add(url);
|
|
198
|
+
fresh++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const host of hosts) {
|
|
202
|
+
if (!this.seenHosts.has(host)) {
|
|
203
|
+
this.seenHosts.add(host);
|
|
204
|
+
fresh++;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (fresh > 0)
|
|
208
|
+
this.batchProductive = true;
|
|
209
|
+
}
|
|
210
|
+
if (this.inFlight > 0)
|
|
211
|
+
this.inFlight--;
|
|
212
|
+
if (this.inFlight > 0)
|
|
213
|
+
return "ok";
|
|
214
|
+
if (this.batchProductive) {
|
|
215
|
+
this.unproductiveRounds = 0;
|
|
216
|
+
return "ok";
|
|
217
|
+
}
|
|
218
|
+
this.unproductiveRounds++;
|
|
219
|
+
if (this.capped)
|
|
220
|
+
return "ok";
|
|
221
|
+
if (this.unproductiveRounds >= this.stopAfter) {
|
|
222
|
+
this.markCapped();
|
|
223
|
+
return "cap";
|
|
224
|
+
}
|
|
225
|
+
if (!this.steered && this.unproductiveRounds >= this.steerAfter) {
|
|
226
|
+
this.steered = true;
|
|
227
|
+
return "steer";
|
|
228
|
+
}
|
|
229
|
+
return "ok";
|
|
230
|
+
}
|
|
231
|
+
markCapped() {
|
|
232
|
+
this.capped = true;
|
|
233
|
+
this.steered = true;
|
|
234
|
+
}
|
|
235
|
+
promptHint() {
|
|
236
|
+
if (this.capped) {
|
|
237
|
+
return [
|
|
238
|
+
"## Research budget",
|
|
239
|
+
"search_web and web_fetch have been removed for the rest of this turn.",
|
|
240
|
+
"Produce the requested deliverable from information already in the conversation.",
|
|
241
|
+
].join("\n");
|
|
242
|
+
}
|
|
243
|
+
if (this.webTotal < 4)
|
|
244
|
+
return "";
|
|
245
|
+
const lines = [
|
|
246
|
+
"## Research budget",
|
|
247
|
+
`Research calls this turn: ${this.webTotal}/${this.totalCap}; rounds without new sources: ${this.unproductiveRounds}/${this.stopAfter}.`,
|
|
248
|
+
];
|
|
249
|
+
if (this.steered || this.unproductiveRounds >= this.steerAfter) {
|
|
250
|
+
lines.push("Recent searches added nothing new. Stop searching and produce the requested deliverable; mark uncertain fields instead of looking for more.");
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
lines.push("Keep queries targeted: fetch the best sources, then write the output.");
|
|
254
|
+
}
|
|
255
|
+
return lines.join("\n");
|
|
32
256
|
}
|
|
33
257
|
reset() {
|
|
34
|
-
this.
|
|
258
|
+
this.lastCall = { key: "", count: 0 };
|
|
259
|
+
this.callCounts.clear();
|
|
260
|
+
this.webTotal = 0;
|
|
261
|
+
this.inFlight = 0;
|
|
262
|
+
this.batchProductive = false;
|
|
263
|
+
this.unproductiveRounds = 0;
|
|
264
|
+
this.steered = false;
|
|
265
|
+
this.capped = false;
|
|
266
|
+
this.delivered = false;
|
|
267
|
+
this.seenUrls.clear();
|
|
268
|
+
this.seenHosts.clear();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function safeString(value) {
|
|
272
|
+
try {
|
|
273
|
+
return JSON.stringify(value) ?? String(value);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return String(value);
|
|
35
277
|
}
|
|
36
278
|
}
|