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.
Files changed (137) hide show
  1. package/README.md +242 -31
  2. package/dist/agent.js +1233 -485
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli/commands/chat.js +10 -0
  5. package/dist/cli/commands/exec.js +32 -0
  6. package/dist/cli/commands/history.js +58 -0
  7. package/dist/cli/commands/index.js +224 -0
  8. package/dist/cli/commands/init.js +18 -0
  9. package/dist/cli/commands/mcp.js +173 -0
  10. package/dist/cli/commands/memory.js +69 -0
  11. package/dist/cli/commands/models.js +21 -0
  12. package/dist/cli/commands/permission.js +12 -0
  13. package/dist/cli/commands/rules.js +33 -0
  14. package/dist/cli/commands/sandbox.js +13 -0
  15. package/dist/cli/commands/serve.js +9 -0
  16. package/dist/cli/commands/setup.js +4 -0
  17. package/dist/cli/commands/shared.js +16 -0
  18. package/dist/cli/commands/skills.js +119 -0
  19. package/dist/cli/commands/update.js +7 -0
  20. package/dist/cli/commands/write-config.js +30 -0
  21. package/dist/cli/errors.js +36 -0
  22. package/dist/cli/exec-prompt.js +26 -0
  23. package/dist/cli/option-helpers.js +53 -0
  24. package/dist/cli/program.js +180 -0
  25. package/dist/cli.js +7 -632
  26. package/dist/clipboard.js +59 -23
  27. package/dist/code-mode.js +35 -17
  28. package/dist/compaction.js +457 -169
  29. package/dist/config.js +298 -38
  30. package/dist/confirm.js +105 -9
  31. package/dist/context-window.js +156 -75
  32. package/dist/doom-loop.js +268 -26
  33. package/dist/fetch-timeout.js +152 -0
  34. package/dist/http-approvals.js +60 -0
  35. package/dist/http.js +119 -0
  36. package/dist/instructions.js +72 -33
  37. package/dist/logger.js +95 -0
  38. package/dist/markdown.js +35 -50
  39. package/dist/mcp.js +847 -102
  40. package/dist/memory.js +128 -45
  41. package/dist/output.js +42 -31
  42. package/dist/paste-handler.js +3 -3
  43. package/dist/permission-cli.js +43 -0
  44. package/dist/plugins.js +76 -11
  45. package/dist/pricing.js +119 -0
  46. package/dist/provider.js +34 -15
  47. package/dist/question-format.js +60 -0
  48. package/dist/sandbox-cli.js +82 -0
  49. package/dist/sandbox.js +403 -0
  50. package/dist/save-throttle.js +45 -0
  51. package/dist/serve/common.js +404 -0
  52. package/dist/serve/routes-chat.js +347 -0
  53. package/dist/serve/routes-mcp.js +212 -0
  54. package/dist/serve/routes-memory.js +66 -0
  55. package/dist/serve/routes-meta.js +205 -0
  56. package/dist/serve/routes-sessions.js +61 -0
  57. package/dist/serve/routes-skills.js +70 -0
  58. package/dist/serve.js +74 -635
  59. package/dist/sessions.js +197 -15
  60. package/dist/skills.js +531 -77
  61. package/dist/synthetic.js +7 -0
  62. package/dist/title-gen.js +9 -2
  63. package/dist/token-display.js +36 -0
  64. package/dist/tool-display.js +178 -0
  65. package/dist/tool-output.js +53 -46
  66. package/dist/tools/apply_patch.js +265 -0
  67. package/dist/tools/atomic-file.js +35 -0
  68. package/dist/tools/backend.js +61 -0
  69. package/dist/tools/bash.js +186 -71
  70. package/dist/tools/code_search.js +13 -6
  71. package/dist/tools/edit.js +26 -9
  72. package/dist/tools/explore.js +144 -16
  73. package/dist/tools/glob.js +7 -3
  74. package/dist/tools/grep.js +153 -14
  75. package/dist/tools/index.js +9 -24
  76. package/dist/tools/question.js +31 -30
  77. package/dist/tools/read.js +77 -15
  78. package/dist/tools/search-searxng.js +223 -0
  79. package/dist/tools/search-serper.js +189 -0
  80. package/dist/tools/task.js +100 -33
  81. package/dist/tools/todo.js +178 -67
  82. package/dist/tools/web_fetch.js +158 -46
  83. package/dist/tools/web_search.js +217 -29
  84. package/dist/tools/write.js +34 -11
  85. package/dist/tui/App.js +89 -6
  86. package/dist/tui/ConfirmBar.js +57 -4
  87. package/dist/tui/InputBar.js +504 -44
  88. package/dist/tui/MessageList.js +674 -20
  89. package/dist/tui/ModelPicker.js +113 -0
  90. package/dist/tui/QuestionBar.js +136 -0
  91. package/dist/tui/SessionPicker.js +79 -0
  92. package/dist/tui/StatusBar.js +14 -12
  93. package/dist/tui/agent-runner.js +223 -0
  94. package/dist/tui/caret-pos.js +177 -0
  95. package/dist/tui/caret.js +69 -0
  96. package/dist/tui/click-count.js +13 -0
  97. package/dist/tui/diff-view.js +61 -0
  98. package/dist/tui/drag-state.js +49 -0
  99. package/dist/tui/hydrate.js +129 -0
  100. package/dist/tui/index.js +189 -31
  101. package/dist/tui/input-history.js +125 -0
  102. package/dist/tui/layout.js +88 -0
  103. package/dist/tui/mouse.js +46 -0
  104. package/dist/tui/prompt-queue.js +24 -0
  105. package/dist/tui/selection.js +226 -0
  106. package/dist/tui/session-switch.js +28 -0
  107. package/dist/tui/slash-commands.js +106 -0
  108. package/dist/tui/slash-handler.js +545 -0
  109. package/dist/tui/text-width.js +113 -0
  110. package/dist/tui/theme.js +12 -0
  111. package/dist/tui/token-info.js +7 -0
  112. package/dist/tui/tool-children.js +19 -0
  113. package/dist/tui/undo-stack.js +14 -0
  114. package/dist/tui/use-sgr-mouse.js +29 -0
  115. package/dist/tui-chat.js +346 -330
  116. package/dist/updater.js +116 -0
  117. package/dist/xml-search.js +194 -0
  118. package/docs/API.md +410 -32
  119. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  120. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  121. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  122. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  123. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  124. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  125. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  126. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  127. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  128. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  129. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  130. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  131. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  132. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  133. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  134. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  135. package/package.json +12 -8
  136. package/skills/self-config/SKILL.md +90 -0
  137. package/skills/self-config/reference.md +149 -0
@@ -1,46 +1,104 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
1
+ import { readFileSync, mkdirSync, existsSync } from "fs";
2
2
  import path from "path";
3
- import { getConfigDir, loadConfig } from "./config.js";
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: 128000
12
+ * 4. Fallback: 512000 (memory only — never persisted as a detected value)
12
13
  */
13
- const DEFAULT_CONTEXT_WINDOW = 128000;
14
- const CACHE_FILE = path.join(getConfigDir(), "context-window-cache.json");
15
- const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
16
- function loadCache() {
17
- if (!existsSync(CACHE_FILE))
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(CACHE_FILE, "utf-8"));
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
- mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
28
- writeFileSync(CACHE_FILE, JSON.stringify(cache), "utf-8");
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 cache = loadCache();
32
- const entry = cache[modelId];
33
- if (!entry)
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() - entry.timestamp > CACHE_TTL)
49
+ if (Date.now() - disk.timestamp > CACHE_TTL)
36
50
  return null;
37
- return entry.contextWindow;
51
+ memoryCache.set(modelId, { ...disk, source: "detected" });
52
+ return { tokens: disk.contextWindow, source: "detected" };
38
53
  }
39
- function setCache(modelId, contextWindow) {
40
- const cache = loadCache();
41
- cache[modelId] = { contextWindow, timestamp: Date.now() };
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
- const model = data.data?.find((m) => m.id === modelId);
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
- const model = (data.data ?? []).find((m) => m.id === modelId);
75
- // vLLM exposes max_model_len on the model object
76
- return model?.max_model_len ?? model?.max_model_length ?? null;
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
- // Search all providers for the model ID
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
- * Get context window size for the current model.
144
- * Tries multiple sources, caches the result.
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
- export async function getContextWindow(modelId) {
147
- const config = loadConfig();
148
- // 1. Explicit user config
149
- if (config.provider?.contextWindow)
150
- return config.provider.contextWindow;
151
- const id = modelId ?? config.provider?.defaultModel;
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 !== null)
245
+ if (cached)
157
246
  return cached;
158
- const baseURL = config.provider?.baseURL ?? "";
159
- const apiKey = config.provider?.apiKey ?? "";
160
- // 3. Provider-specific APIs
161
- const openRouter = await tryOpenRouter(baseURL, apiKey, id);
162
- if (openRouter) {
163
- setCache(id, openRouter);
164
- return openRouter;
165
- }
166
- const ollama = await tryOllama(baseURL, id);
167
- if (ollama) {
168
- setCache(id, ollama);
169
- return ollama;
170
- }
171
- const vllm = await tryVllm(baseURL, apiKey, id);
172
- if (vllm) {
173
- setCache(id, vllm);
174
- return vllm;
175
- }
176
- // 4. models.dev lookup
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
- * Doom loop detection.
2
+ * Loop / stall detection for the agent run.
3
3
  *
4
- * Detects when the agent calls the same tool with the same arguments
5
- * repeatedly (indicating it's stuck in a loop). After THRESHOLD consecutive
6
- * identical calls, the loop is broken and the agent is informed.
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
- * Based on opencode's processor.ts doom loop detection.
15
+ * The detector is owned by the outer run loop so auto-continue cannot reset it.
9
16
  */
10
- const THRESHOLD = 3;
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
- recentCalls = [];
13
- /** Record a tool call. Returns true if a doom loop is detected. */
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
- const serialized = JSON.stringify(input);
16
- this.recentCalls.push({ toolName, input: serialized });
17
- // Only check the last THRESHOLD calls
18
- if (this.recentCalls.length < THRESHOLD)
19
- return false;
20
- const recent = this.recentCalls.slice(-THRESHOLD);
21
- const allSame = recent.every((call) => call.toolName === recent[0].toolName && call.input === recent[0].input);
22
- if (allSame) {
23
- // Reset to prevent repeated warnings
24
- this.recentCalls = [];
25
- return true;
26
- }
27
- // Keep only last THRESHOLD entries to bound memory
28
- if (this.recentCalls.length > THRESHOLD * 2) {
29
- this.recentCalls = this.recentCalls.slice(-THRESHOLD);
30
- }
31
- return false;
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.recentCalls = [];
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
  }