min-agent 0.5.0 → 0.6.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 (156) hide show
  1. package/README.md +32 -18
  2. package/dist/cli.js +538 -12
  3. package/docs/API.md +25 -8
  4. package/package.json +6 -6
  5. package/skills/self-config/SKILL.md +7 -3
  6. package/skills/self-config/reference.md +8 -4
  7. package/dist/agent.js +0 -1342
  8. package/dist/assistant-stream.js +0 -131
  9. package/dist/cli/commands/chat.js +0 -13
  10. package/dist/cli/commands/exec.js +0 -35
  11. package/dist/cli/commands/history.js +0 -58
  12. package/dist/cli/commands/index.js +0 -241
  13. package/dist/cli/commands/init.js +0 -18
  14. package/dist/cli/commands/mcp.js +0 -173
  15. package/dist/cli/commands/memory.js +0 -87
  16. package/dist/cli/commands/models.js +0 -21
  17. package/dist/cli/commands/permission.js +0 -12
  18. package/dist/cli/commands/rules.js +0 -33
  19. package/dist/cli/commands/sandbox.js +0 -13
  20. package/dist/cli/commands/serve.js +0 -9
  21. package/dist/cli/commands/setup.js +0 -4
  22. package/dist/cli/commands/shared.js +0 -16
  23. package/dist/cli/commands/skills.js +0 -119
  24. package/dist/cli/commands/think.js +0 -12
  25. package/dist/cli/commands/update.js +0 -7
  26. package/dist/cli/commands/write-config.js +0 -52
  27. package/dist/cli/errors.js +0 -36
  28. package/dist/cli/exec-prompt.js +0 -26
  29. package/dist/cli/option-helpers.js +0 -65
  30. package/dist/cli/program.js +0 -217
  31. package/dist/clipboard.js +0 -142
  32. package/dist/code-mode.js +0 -184
  33. package/dist/compaction.js +0 -644
  34. package/dist/config.js +0 -470
  35. package/dist/confirm.js +0 -172
  36. package/dist/context-window.js +0 -246
  37. package/dist/doom-loop.js +0 -278
  38. package/dist/fetch-timeout.js +0 -152
  39. package/dist/http-approvals.js +0 -60
  40. package/dist/http.js +0 -119
  41. package/dist/instructions.js +0 -196
  42. package/dist/logger.js +0 -95
  43. package/dist/markdown.js +0 -259
  44. package/dist/mcp.js +0 -1036
  45. package/dist/memory-cli.js +0 -33
  46. package/dist/memory.js +0 -295
  47. package/dist/model-catalog.js +0 -285
  48. package/dist/output.js +0 -76
  49. package/dist/paste-handler.js +0 -41
  50. package/dist/permission-cli.js +0 -40
  51. package/dist/plugins.js +0 -131
  52. package/dist/pricing.js +0 -119
  53. package/dist/provider.js +0 -63
  54. package/dist/question-format.js +0 -60
  55. package/dist/reasoning-stream.js +0 -158
  56. package/dist/sandbox-cli.js +0 -79
  57. package/dist/sandbox.js +0 -403
  58. package/dist/save-throttle.js +0 -45
  59. package/dist/scope.js +0 -23
  60. package/dist/serve/common.js +0 -425
  61. package/dist/serve/routes-chat.js +0 -367
  62. package/dist/serve/routes-mcp.js +0 -212
  63. package/dist/serve/routes-memory.js +0 -95
  64. package/dist/serve/routes-meta.js +0 -233
  65. package/dist/serve/routes-sessions.js +0 -61
  66. package/dist/serve/routes-skills.js +0 -70
  67. package/dist/serve.js +0 -138
  68. package/dist/sessions.js +0 -265
  69. package/dist/skills.js +0 -599
  70. package/dist/structured-output.js +0 -29
  71. package/dist/synthetic.js +0 -7
  72. package/dist/think-cli.js +0 -36
  73. package/dist/thinking-wire.js +0 -228
  74. package/dist/thinking.js +0 -142
  75. package/dist/title-gen.js +0 -55
  76. package/dist/token-display.js +0 -39
  77. package/dist/tool-display.js +0 -178
  78. package/dist/tool-output.js +0 -126
  79. package/dist/tools/apply_patch.js +0 -265
  80. package/dist/tools/atomic-file.js +0 -35
  81. package/dist/tools/backend.js +0 -61
  82. package/dist/tools/bash.js +0 -231
  83. package/dist/tools/code_search.js +0 -98
  84. package/dist/tools/edit.js +0 -68
  85. package/dist/tools/explore.js +0 -232
  86. package/dist/tools/glob.js +0 -40
  87. package/dist/tools/grep.js +0 -174
  88. package/dist/tools/index.js +0 -34
  89. package/dist/tools/question.js +0 -54
  90. package/dist/tools/read.js +0 -109
  91. package/dist/tools/search-searxng.js +0 -223
  92. package/dist/tools/search-serper.js +0 -189
  93. package/dist/tools/task.js +0 -165
  94. package/dist/tools/todo.js +0 -213
  95. package/dist/tools/web_fetch.js +0 -195
  96. package/dist/tools/web_search.js +0 -228
  97. package/dist/tools/write.js +0 -55
  98. package/dist/tui/App.js +0 -126
  99. package/dist/tui/ConfirmBar.js +0 -66
  100. package/dist/tui/InputBar.js +0 -616
  101. package/dist/tui/MessageList.js +0 -718
  102. package/dist/tui/ModelPicker.js +0 -113
  103. package/dist/tui/QuestionBar.js +0 -136
  104. package/dist/tui/SessionPicker.js +0 -79
  105. package/dist/tui/Spinner.js +0 -6
  106. package/dist/tui/StatusBar.js +0 -22
  107. package/dist/tui/ThinkPicker.js +0 -77
  108. package/dist/tui/agent-runner.js +0 -223
  109. package/dist/tui/bracketed-paste.js +0 -37
  110. package/dist/tui/caret-pos.js +0 -179
  111. package/dist/tui/caret.js +0 -69
  112. package/dist/tui/click-count.js +0 -13
  113. package/dist/tui/diff-view.js +0 -61
  114. package/dist/tui/drag-state.js +0 -49
  115. package/dist/tui/hydrate.js +0 -129
  116. package/dist/tui/index.js +0 -258
  117. package/dist/tui/input-history.js +0 -125
  118. package/dist/tui/layout.js +0 -105
  119. package/dist/tui/mouse.js +0 -46
  120. package/dist/tui/overlay-input.js +0 -12
  121. package/dist/tui/paste-draft.js +0 -173
  122. package/dist/tui/prompt-queue.js +0 -24
  123. package/dist/tui/selection.js +0 -232
  124. package/dist/tui/session-switch.js +0 -28
  125. package/dist/tui/slash-commands.js +0 -123
  126. package/dist/tui/slash-handler.js +0 -589
  127. package/dist/tui/text-width.js +0 -113
  128. package/dist/tui/theme.js +0 -12
  129. package/dist/tui/token-info.js +0 -7
  130. package/dist/tui/tool-children.js +0 -19
  131. package/dist/tui/types.js +0 -1
  132. package/dist/tui/undo-stack.js +0 -14
  133. package/dist/tui/use-sgr-mouse.js +0 -29
  134. package/dist/tui-chat.js +0 -456
  135. package/dist/updater.js +0 -116
  136. package/dist/xml-search.js +0 -194
  137. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +0 -1510
  138. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +0 -2105
  139. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +0 -1595
  140. package/docs/superpowers/plans/2026-08-16-input-caret.md +0 -782
  141. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +0 -873
  142. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +0 -631
  143. package/docs/superpowers/plans/2026-08-23-input-paste-attachments.md +0 -475
  144. package/docs/superpowers/plans/2026-08-23-thinking-wire-profile.md +0 -450
  145. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +0 -183
  146. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +0 -220
  147. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +0 -196
  148. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +0 -63
  149. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +0 -116
  150. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +0 -47
  151. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +0 -37
  152. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +0 -68
  153. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +0 -273
  154. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +0 -165
  155. package/docs/superpowers/specs/2026-08-23-input-paste-attachments-design.md +0 -174
  156. package/docs/superpowers/specs/2026-08-23-thinking-wire-profile-design.md +0 -140
@@ -1,246 +0,0 @@
1
- import { readFileSync, mkdirSync, existsSync } from "fs";
2
- import path from "path";
3
- import { atomicWriteFileSync } from "./tools/atomic-file.js";
4
- import { getConfigDir, getEffectiveConfig, getActiveProvider } from "./config.js";
5
- import { getCachedModelCatalog, getModelCatalog } from "./model-catalog.js";
6
- /**
7
- * Auto-detect context window size for the current model.
8
- *
9
- * Resolution order:
10
- * 1. User config: provider.contextWindow (explicit override)
11
- * 2. Provider-specific API (OpenRouter, vLLM, Ollama, OpenAI-compatible /models)
12
- * 3. Lonae model catalog (context + thinking options)
13
- * 4. Fallback: 512000 (memory only — never persisted as a detected value)
14
- */
15
- export const DEFAULT_CONTEXT_WINDOW = 512000;
16
- const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
17
- const FALLBACK_MEMORY_TTL = 10 * 60 * 1000;
18
- function cacheFile() {
19
- return path.join(getConfigDir(), "context-window-cache.json");
20
- }
21
- const memoryCache = new Map();
22
- const inFlight = new Map();
23
- function loadDiskCache() {
24
- const file = cacheFile();
25
- if (!existsSync(file))
26
- return {};
27
- try {
28
- return JSON.parse(readFileSync(file, "utf-8"));
29
- }
30
- catch {
31
- return {};
32
- }
33
- }
34
- function saveCache(cache) {
35
- const file = cacheFile();
36
- mkdirSync(path.dirname(file), { recursive: true });
37
- atomicWriteFileSync(file, JSON.stringify(cache));
38
- }
39
- function cacheTtl(source) {
40
- return source === "fallback" ? FALLBACK_MEMORY_TTL : CACHE_TTL;
41
- }
42
- function getCached(modelId) {
43
- const catalog = getCachedModelCatalog(modelId);
44
- if (catalog?.contextWindow)
45
- return { tokens: catalog.contextWindow, source: "detected" };
46
- const memory = memoryCache.get(modelId);
47
- if (memory && Date.now() - memory.timestamp <= cacheTtl(memory.source)) {
48
- return { tokens: memory.contextWindow, source: memory.source };
49
- }
50
- const disk = loadDiskCache()[modelId];
51
- if (!disk || disk.source !== "detected")
52
- return null;
53
- if (Date.now() - disk.timestamp > CACHE_TTL)
54
- return null;
55
- memoryCache.set(modelId, { ...disk, source: "detected" });
56
- return { tokens: disk.contextWindow, source: "detected" };
57
- }
58
- function setCache(modelId, contextWindow, source) {
59
- const entry = { contextWindow, timestamp: Date.now(), source };
60
- memoryCache.set(modelId, entry);
61
- if (source !== "detected")
62
- return;
63
- const cache = loadDiskCache();
64
- cache[modelId] = entry;
65
- saveCache(cache);
66
- }
67
- export function extractModelContextLength(model) {
68
- if (!model || typeof model !== "object")
69
- return null;
70
- const rec = model;
71
- const nested = [rec, rec.meta, rec.limits, rec.limit, rec.model_info];
72
- const keys = [
73
- "context_length",
74
- "max_model_len",
75
- "max_model_length",
76
- "max_context_length",
77
- "context_window",
78
- "max_input_tokens",
79
- "max_total_tokens",
80
- "context",
81
- "general.context_length",
82
- ];
83
- for (const obj of nested) {
84
- if (!obj || typeof obj !== "object")
85
- continue;
86
- const bag = obj;
87
- for (const key of keys) {
88
- const n = bag[key];
89
- if (typeof n === "number" && Number.isFinite(n) && n >= 4096)
90
- return Math.floor(n);
91
- }
92
- }
93
- return null;
94
- }
95
- function matchListedModel(models, modelId) {
96
- const exact = models.find((m) => m && typeof m === "object" && m.id === modelId);
97
- if (exact)
98
- return exact;
99
- return models.find((m) => {
100
- if (!m || typeof m !== "object")
101
- return false;
102
- const id = m.id;
103
- return typeof id === "string" && (id.endsWith(`/${modelId}`) || modelId.endsWith(`/${id}`));
104
- });
105
- }
106
- /** Try OpenRouter: GET /api/v1/models returns context_length per model */
107
- async function tryOpenRouter(baseURL, apiKey, modelId) {
108
- if (!baseURL.includes("openrouter"))
109
- return null;
110
- try {
111
- const response = await fetch("https://openrouter.ai/api/v1/models", {
112
- headers: { Authorization: `Bearer ${apiKey}` },
113
- signal: AbortSignal.timeout(8000),
114
- });
115
- if (!response.ok)
116
- return null;
117
- const data = (await response.json());
118
- return extractModelContextLength(matchListedModel(data.data ?? [], modelId));
119
- }
120
- catch {
121
- return null;
122
- }
123
- }
124
- /** Try vLLM: GET /v1/models returns max_model_len */
125
- async function tryVllm(baseURL, apiKey, modelId) {
126
- if (!baseURL.includes("vllm"))
127
- return null;
128
- try {
129
- const url = `${baseURL.replace(/\/$/, "")}/models`;
130
- const response = await fetch(url, {
131
- headers: { Authorization: `Bearer ${apiKey}` },
132
- signal: AbortSignal.timeout(5000),
133
- });
134
- if (!response.ok)
135
- return null;
136
- const data = (await response.json());
137
- return extractModelContextLength(matchListedModel(data.data ?? [], modelId));
138
- }
139
- catch {
140
- return null;
141
- }
142
- }
143
- /** Any OpenAI-compatible /models that advertises context length (1M custom endpoints included). */
144
- async function tryProviderModels(baseURL, apiKey, modelId) {
145
- if (!baseURL)
146
- return null;
147
- try {
148
- const url = `${baseURL.replace(/\/$/, "")}/models`;
149
- const response = await fetch(url, {
150
- headers: { Authorization: `Bearer ${apiKey}` },
151
- signal: AbortSignal.timeout(8000),
152
- });
153
- if (!response.ok)
154
- return null;
155
- const data = (await response.json());
156
- const models = Array.isArray(data) ? data : (data.data ?? []);
157
- return extractModelContextLength(matchListedModel(models, modelId));
158
- }
159
- catch {
160
- return null;
161
- }
162
- }
163
- /** Try Ollama: POST /api/show returns model info with context length */
164
- async function tryOllama(baseURL, modelId) {
165
- if (!baseURL.includes("localhost") && !baseURL.includes("127.0.0.1") && !baseURL.includes("ollama"))
166
- return null;
167
- try {
168
- // Ollama's /api/show endpoint
169
- const ollamaBase = baseURL.replace(/\/v1\/?$/, "");
170
- const response = await fetch(`${ollamaBase}/api/show`, {
171
- method: "POST",
172
- headers: { "Content-Type": "application/json" },
173
- body: JSON.stringify({ name: modelId }),
174
- signal: AbortSignal.timeout(5000),
175
- });
176
- if (!response.ok)
177
- return null;
178
- const data = (await response.json());
179
- // Ollama returns model_info with context length
180
- const ctxLength = data.model_info?.["general.context_length"] ?? data.model_info?.context_length ?? data.parameters?.num_ctx;
181
- return typeof ctxLength === "number" ? ctxLength : null;
182
- }
183
- catch {
184
- return null;
185
- }
186
- }
187
- /**
188
- * Detect context window size for a model by probing all sources in parallel.
189
- * Returns the first non-null result, respecting the original precedence.
190
- */
191
- async function detectContextWindow(config, id) {
192
- const provider = getActiveProvider(config);
193
- const baseURL = provider?.baseURL ?? "";
194
- const apiKey = provider?.apiKey ?? "";
195
- const catalog = await getModelCatalog(id, baseURL);
196
- if (catalog?.contextWindow)
197
- return { tokens: catalog.contextWindow, source: "detected" };
198
- const results = await Promise.all([
199
- tryOpenRouter(baseURL, apiKey, id),
200
- tryOllama(baseURL, id),
201
- tryVllm(baseURL, apiKey, id),
202
- tryProviderModels(baseURL, apiKey, id),
203
- ]);
204
- const found = results.find((v) => v !== null);
205
- if (found) {
206
- setCache(id, found, "detected");
207
- return { tokens: found, source: "detected" };
208
- }
209
- setCache(id, DEFAULT_CONTEXT_WINDOW, "fallback");
210
- return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
211
- }
212
- /**
213
- * Get context window size and whether it was configured, detected, or guessed.
214
- * Fallback 512k is never written to disk as a detected value.
215
- */
216
- export async function getContextWindowInfo(modelId) {
217
- const config = getEffectiveConfig();
218
- const provider = getActiveProvider(config);
219
- if (provider?.contextWindow)
220
- return { tokens: provider.contextWindow, source: "config" };
221
- const id = modelId ?? provider?.defaultModel;
222
- if (!id)
223
- return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
224
- const cached = getCached(id);
225
- if (cached)
226
- return cached;
227
- const pending = inFlight.get(id);
228
- if (pending)
229
- return pending;
230
- const probing = detectContextWindow(config, id);
231
- inFlight.set(id, probing);
232
- try {
233
- return await probing;
234
- }
235
- finally {
236
- inFlight.delete(id);
237
- }
238
- }
239
- /**
240
- * Get context window size for the current model.
241
- * Tries multiple sources, caches detected results in memory and on disk.
242
- * Concurrent calls for the same model share a single probe.
243
- */
244
- export async function getContextWindow(modelId) {
245
- return (await getContextWindowInfo(modelId)).tokens;
246
- }
package/dist/doom-loop.js DELETED
@@ -1,278 +0,0 @@
1
- /**
2
- * Loop / stall detection for the agent run.
3
- *
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.
14
- *
15
- * The detector is owned by the outer run loop so auto-continue cannot reset it.
16
- */
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
- }
111
- export class DoomLoopDetector {
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). */
147
- record(toolName, input) {
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");
256
- }
257
- reset() {
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);
277
- }
278
- }
@@ -1,152 +0,0 @@
1
- import { log } from "./logger.js";
2
- /**
3
- * fetch wrapper for model requests.
4
- *
5
- * Providers can stall in two ways that a plain fetch never surfaces:
6
- * 1. the response never starts (no headers / first byte),
7
- * 2. the SSE stream opens and then goes quiet forever.
8
- * Both would hang the agent loop indefinitely, so each has its own timer. On
9
- * timeout the request is aborted with a marked error the loop recognises as a
10
- * transient provider failure (see isProviderStallError).
11
- *
12
- * The wrapper also carries optional request/response tracing (MIN_AGENT_TRACE),
13
- * which is the only way to see what a misbehaving gateway actually returned.
14
- */
15
- /** Marker embedded in stall errors so the agent loop can classify them. */
16
- export const PROVIDER_STALL_MARKER = "min-agent: provider stalled";
17
- export const DEFAULT_FIRST_BYTE_TIMEOUT_MS = 180_000;
18
- export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
19
- function stallError(kind, ms) {
20
- const err = new Error(`${PROVIDER_STALL_MARKER}: no ${kind} for ${ms}ms`);
21
- err.name = "ProviderStallError";
22
- return err;
23
- }
24
- /** Walk the error chain so a stall marker survives provider SDK wrapping. */
25
- export function describeError(err, depth = 4) {
26
- const parts = [];
27
- let current = err;
28
- for (let i = 0; i < depth && current != null; i++) {
29
- if (current instanceof Error) {
30
- parts.push(current.message);
31
- current = current.cause;
32
- continue;
33
- }
34
- parts.push(String(current));
35
- break;
36
- }
37
- return parts.join(" | ");
38
- }
39
- /** True when the failure is a provider stall we injected (safe to retry). */
40
- export function isProviderStallError(err) {
41
- return describeError(err).includes(PROVIDER_STALL_MARKER);
42
- }
43
- function traceRequestLine(url, init) {
44
- const body = typeof init?.body === "string" ? init.body : "";
45
- let summary = `bytes=${body.length}`;
46
- try {
47
- const parsed = JSON.parse(body || "{}");
48
- const tools = parsed.tools?.map((t) => t.function?.name ?? "?") ?? [];
49
- summary += ` model=${parsed.model ?? "?"} messages=${parsed.messages?.length ?? 0} tools=${tools.length} stream=${parsed.stream ?? false}`;
50
- }
51
- catch {
52
- /* not JSON — bytes only */
53
- }
54
- return `trace request ${url} ${summary}`;
55
- }
56
- /**
57
- * Wrap a response body so the idle timer resets on every chunk and fires when
58
- * the provider goes quiet mid-stream.
59
- */
60
- function guardBody(response, idleTimeoutMs, abort) {
61
- if (!response.body || idleTimeoutMs <= 0)
62
- return response;
63
- const source = response.body;
64
- const reader = source.getReader();
65
- let timer;
66
- const clear = () => {
67
- if (timer)
68
- clearTimeout(timer);
69
- timer = undefined;
70
- };
71
- const guarded = new ReadableStream({
72
- async pull(controller) {
73
- const arm = () => {
74
- clear();
75
- timer = setTimeout(() => abort(stallError("stream", idleTimeoutMs)), idleTimeoutMs);
76
- };
77
- arm();
78
- try {
79
- const { done, value } = await reader.read();
80
- clear();
81
- if (done) {
82
- controller.close();
83
- return;
84
- }
85
- controller.enqueue(value);
86
- }
87
- catch (err) {
88
- clear();
89
- controller.error(err);
90
- }
91
- },
92
- cancel(reason) {
93
- clear();
94
- return reader.cancel(reason);
95
- },
96
- });
97
- return new Response(guarded, {
98
- status: response.status,
99
- statusText: response.statusText,
100
- headers: response.headers,
101
- });
102
- }
103
- export function createTimeoutFetch(options = {}) {
104
- const firstByteTimeoutMs = options.firstByteTimeoutMs ?? DEFAULT_FIRST_BYTE_TIMEOUT_MS;
105
- const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
106
- const doFetch = options.fetchImpl ?? fetch;
107
- const now = options.now ?? Date.now;
108
- const trace = options.trace ?? false;
109
- return async (input, init) => {
110
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
111
- const controller = new AbortController();
112
- let stall;
113
- const abortWith = (err) => {
114
- stall = err;
115
- log("warn", `${err.message} (${url})`);
116
- controller.abort(err);
117
- };
118
- const external = init?.signal;
119
- const forward = () => controller.abort(external?.reason);
120
- if (external) {
121
- if (external.aborted)
122
- forward();
123
- else
124
- external.addEventListener("abort", forward, { once: true });
125
- }
126
- let firstByteTimer;
127
- if (firstByteTimeoutMs > 0) {
128
- firstByteTimer = setTimeout(() => abortWith(stallError("first byte", firstByteTimeoutMs)), firstByteTimeoutMs);
129
- }
130
- if (trace)
131
- log("info", traceRequestLine(url, init));
132
- const started = now();
133
- try {
134
- const response = await doFetch(input, { ...init, signal: controller.signal });
135
- if (firstByteTimer)
136
- clearTimeout(firstByteTimer);
137
- if (trace)
138
- log("info", `trace response ${url} status=${response.status} ttfb=${now() - started}ms`);
139
- return guardBody(response, idleTimeoutMs, abortWith);
140
- }
141
- catch (err) {
142
- if (firstByteTimer)
143
- clearTimeout(firstByteTimer);
144
- if (stall)
145
- throw stall;
146
- throw err;
147
- }
148
- finally {
149
- external?.removeEventListener("abort", forward);
150
- }
151
- };
152
- }