min-agent 0.3.0 → 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 (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
@@ -0,0 +1,223 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { truncateToolOutput } from "../tool-output.js";
3
+ import { configHint, resolveBackendBase } from "./backend.js";
4
+ import { LANGUAGE_RE, MAX_BODY_BYTES, VALID_TIME_RANGES, clamp, collapse, dedupeByUrl, emptyResponse, fetchJsonOutcome, formatResults, isString, resolveMaxResults, } from "./web_search.js";
5
+ /** SearXNG is used when explicitly configured (webSearchURL / MIN_AGENT_SEARXNG_URL). */
6
+ export const SEARXNG_BACKEND = {
7
+ envName: "MIN_AGENT_SEARXNG_URL",
8
+ configKey: "webSearchURL",
9
+ fallback: "https://searxng.xc.lonae.com",
10
+ };
11
+ const SEARCH_TIMEOUT_MS = 20000;
12
+ const DEFAULT_FALLBACK_ENGINES = "google,bing";
13
+ export function searxngConfigured() {
14
+ const env = process.env[SEARXNG_BACKEND.envName];
15
+ if (env?.trim())
16
+ return true;
17
+ const configured = loadConfig().webSearchURL;
18
+ return !!configured?.trim();
19
+ }
20
+ function asResult(r) {
21
+ if (typeof r !== "object" || r === null)
22
+ return null;
23
+ const o = r;
24
+ if (!isString(o.url))
25
+ return null;
26
+ return {
27
+ title: isString(o.title) ? o.title : "",
28
+ url: o.url,
29
+ content: isString(o.content) ? o.content : "",
30
+ engine: isString(o.engine) ? o.engine : "",
31
+ score: typeof o.score === "number" ? o.score : 0,
32
+ publishedDate: isString(o.publishedDate) ? o.publishedDate : "",
33
+ imgSrc: isString(o.img_src) ? o.img_src : isString(o.thumbnail) ? o.thumbnail : "",
34
+ };
35
+ }
36
+ function parseResponse(data) {
37
+ if (typeof data !== "object" || data === null)
38
+ return emptyResponse();
39
+ const o = data;
40
+ const results = (Array.isArray(o.results) ? o.results : [])
41
+ .map(asResult)
42
+ .filter((r) => r !== null);
43
+ // Some engines return answers as objects ({ answer, url }) rather than plain strings.
44
+ const answers = (Array.isArray(o.answers) ? o.answers : [])
45
+ .map((a) => {
46
+ if (isString(a))
47
+ return a;
48
+ if (typeof a === "object" && a !== null) {
49
+ const rec = a;
50
+ if (isString(rec.answer))
51
+ return rec.answer;
52
+ if (isString(rec.content))
53
+ return rec.content;
54
+ }
55
+ return "";
56
+ })
57
+ .filter((a) => a !== "");
58
+ const suggestions = Array.isArray(o.suggestions) ? o.suggestions.filter(isString) : [];
59
+ const corrections = Array.isArray(o.corrections) ? o.corrections.filter(isString) : [];
60
+ const infoboxes = (Array.isArray(o.infoboxes) ? o.infoboxes : [])
61
+ .map((b) => {
62
+ if (typeof b !== "object" || b === null)
63
+ return null;
64
+ const box = b;
65
+ return {
66
+ infobox: isString(box.infobox) ? box.infobox : "",
67
+ content: isString(box.content) ? box.content : "",
68
+ };
69
+ })
70
+ .filter((b) => b !== null && (b.infobox !== "" || b.content !== ""));
71
+ const unresponsive = (Array.isArray(o.unresponsive_engines) ? o.unresponsive_engines : [])
72
+ .map((e) => {
73
+ if (!Array.isArray(e) || !isString(e[0]))
74
+ return null;
75
+ return [e[0], isString(e[1]) ? e[1] : ""];
76
+ })
77
+ .filter((e) => e !== null);
78
+ return { results, answers, suggestions, corrections, infoboxes, unresponsive_engines: unresponsive };
79
+ }
80
+ async function searchOnce(base, params) {
81
+ return fetchJsonOutcome(`${base}/search?${params}`, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS) }, parseResponse);
82
+ }
83
+ function renderFailure(o) {
84
+ const hint = configHint(SEARXNG_BACKEND);
85
+ switch (o.kind) {
86
+ case "network":
87
+ return `Search error: could not reach the search backend (${o.message}). The instance may be down or the network may be unavailable — this is not a problem with the query. ${hint}`;
88
+ case "invalid-json":
89
+ if (o.bodyTooLarge) {
90
+ return `Search error: the search backend response exceeded ${MAX_BODY_BYTES / 1024 / 1024} MB and could not be parsed. Narrow the query or request fewer engines.`;
91
+ }
92
+ return `Search error: the search backend returned a non-JSON response${o.sample ? ` (starts with: ${o.sample})` : ""}. The instance most likely has the JSON output format disabled. ${hint}`;
93
+ case "http": {
94
+ const detail = o.backendMessage ? ` Backend message: ${o.backendMessage}.` : "";
95
+ if (o.status === 429) {
96
+ return `Search error: the search backend is rate-limiting (HTTP 429).${detail} Wait a few seconds before retrying, or use a different instance. ${hint}`;
97
+ }
98
+ if (o.status === 401 || o.status === 403) {
99
+ return `Search error: the search backend rejected the request (HTTP ${o.status}).${detail} The instance likely requires authentication or has the JSON output format disabled. ${hint}`;
100
+ }
101
+ if (o.status === 400 || o.status === 422) {
102
+ return `Search error: the search backend rejected these parameters (HTTP ${o.status}).${detail} Fix the arguments and retry; retrying with the same values will fail again.`;
103
+ }
104
+ if (o.status >= 500) {
105
+ return `Search error: the search backend failed with HTTP ${o.status}.${detail} It may be temporarily overloaded — retry once, then try a different instance. ${hint}`;
106
+ }
107
+ return `Search error: HTTP ${o.status} from the search backend.${detail} ${hint}`;
108
+ }
109
+ }
110
+ }
111
+ function fallbackEngines() {
112
+ const env = process.env.MIN_AGENT_SEARXNG_FALLBACK_ENGINES;
113
+ if (env !== undefined)
114
+ return env.trim();
115
+ const configured = loadConfig().webSearchFallbackEngines;
116
+ if (configured !== undefined)
117
+ return configured.trim();
118
+ return DEFAULT_FALLBACK_ENGINES;
119
+ }
120
+ /** Validate/normalize the model-supplied arguments, collecting notes for anything ignored. */
121
+ function buildParams(input, notes) {
122
+ const params = new URLSearchParams({ q: input.query, format: "json", categories: "general" });
123
+ const lang = (input.language ?? "").trim();
124
+ if (lang) {
125
+ if (LANGUAGE_RE.test(lang))
126
+ params.set("language", lang.replace("_", "-"));
127
+ else
128
+ notes.push(`ignored invalid language "${lang}"; use a code like "zh", "en" or "zh-CN"`);
129
+ }
130
+ const range = (input.time_range ?? "").trim().toLowerCase();
131
+ if (range) {
132
+ if (VALID_TIME_RANGES.has(range))
133
+ params.set("time_range", range);
134
+ else
135
+ notes.push(`ignored invalid time_range "${range}"; valid values: day, week, month, year`);
136
+ }
137
+ return params;
138
+ }
139
+ /**
140
+ * SearXNG reports the instance-wide unresponsive list regardless of which
141
+ * engines a request used, so narrow it to the requested set when there is one
142
+ * and say so otherwise.
143
+ */
144
+ function unresponsiveHint(engines, requested) {
145
+ let relevant = engines;
146
+ if (requested && requested.length > 0) {
147
+ const wanted = new Set(requested.map((e) => e.toLowerCase()));
148
+ relevant = engines.filter(([name]) => wanted.has(name.toLowerCase()));
149
+ }
150
+ if (relevant.length === 0)
151
+ return "";
152
+ const detail = relevant.map(([name, reason]) => (reason ? `${name} (${reason})` : name)).join(", ");
153
+ const scope = requested
154
+ ? "The requested engines are unavailable"
155
+ : "The backend reports these engines as unavailable (instance-wide list, may include engines this query did not use)";
156
+ return `${scope}: ${detail}. Try a different query, or pass "engines" explicitly (e.g. "google,bing").`;
157
+ }
158
+ export async function runSearxng(input, query, notes) {
159
+ const resolved = resolveBackendBase(SEARXNG_BACKEND, loadConfig().webSearchURL);
160
+ if (!resolved.ok)
161
+ return `Search error: ${resolved.error}. ${configHint(SEARXNG_BACKEND)}`;
162
+ const maxResults = resolveMaxResults(input.max_results, notes);
163
+ const params = buildParams({ ...input, query }, notes);
164
+ let outcome = await searchOnce(resolved.base, params);
165
+ // The default engine set on a given instance can be entirely blocked
166
+ // (CAPTCHA / rate limits), which looks identical to "no such page exists".
167
+ // A single retry against known-good engines distinguishes the two. The
168
+ // unresponsive list is instance-wide and therefore not a reliable trigger.
169
+ let usedFallback = null;
170
+ if (outcome.kind === "ok" && outcome.data.results.length === 0) {
171
+ const fallback = fallbackEngines();
172
+ if (fallback) {
173
+ const retryParams = new URLSearchParams(params);
174
+ retryParams.set("engines", fallback);
175
+ const retry = await searchOnce(resolved.base, retryParams);
176
+ if (retry.kind === "ok" && retry.data.results.length > 0) {
177
+ outcome = retry;
178
+ usedFallback = fallback;
179
+ }
180
+ else if (retry.kind !== "ok") {
181
+ notes.push(`the fallback engines (${fallback}) also failed: ${renderFailure(retry).replace(/^Search error: /, "")}`);
182
+ }
183
+ }
184
+ }
185
+ if (outcome.kind !== "ok")
186
+ return renderFailure(outcome);
187
+ const data = outcome.data;
188
+ const results = dedupeByUrl(data.results).slice(0, maxResults);
189
+ const noteBlock = notes.map((n) => `Note: ${n}`).join("\n");
190
+ if (results.length === 0) {
191
+ const parts = [`No search results found for "${query}".`];
192
+ if (noteBlock)
193
+ parts.push(noteBlock);
194
+ const hint = unresponsiveHint(data.unresponsive_engines, usedFallback ? usedFallback.split(",") : null);
195
+ if (hint)
196
+ parts.push(hint);
197
+ if (data.corrections.length > 0)
198
+ parts.push(`Did you mean: ${data.corrections.join(", ")}`);
199
+ if (data.suggestions.length > 0)
200
+ parts.push(`Related queries: ${data.suggestions.slice(0, 8).join(", ")}`);
201
+ if (!hint && data.corrections.length === 0 && data.suggestions.length === 0) {
202
+ parts.push("Try a different query or a broader wording.");
203
+ }
204
+ return parts.join("\n\n");
205
+ }
206
+ // Direct answers and infoboxes carry the highest information density, so
207
+ // they precede the result list.
208
+ const sections = [];
209
+ if (noteBlock)
210
+ sections.push(noteBlock);
211
+ for (const a of data.answers)
212
+ sections.push(`Answer: ${collapse(a)}`);
213
+ for (const b of data.infoboxes) {
214
+ sections.push(`Infobox${b.infobox ? ` (${collapse(b.infobox)})` : ""}: ${clamp(collapse(b.content), 600)}`);
215
+ }
216
+ sections.push(`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${query}"${usedFallback ? ` via fallback engines ${usedFallback}` : ""}:`);
217
+ sections.push(formatResults(results));
218
+ if (data.corrections.length > 0)
219
+ sections.push(`Did you mean: ${data.corrections.join(", ")}`);
220
+ if (data.suggestions.length > 0)
221
+ sections.push(`Related queries: ${data.suggestions.slice(0, 8).join(", ")}`);
222
+ return truncateToolOutput(sections.join("\n\n"), { direction: "head" }).content;
223
+ }
@@ -0,0 +1,189 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { truncateToolOutput } from "../tool-output.js";
3
+ import { LANGUAGE_RE, MAX_BODY_BYTES, VALID_TIME_RANGES, clamp, collapse, dedupeByUrl, emptyResponse, fetchJsonOutcome, formatResults, hostOf, isString, resolveMaxResults, } from "./web_search.js";
4
+ const SERPER_KEY_ENV = "MIN_AGENT_SERPER_API_KEY";
5
+ const SERPER_API = "https://google.serper.dev/search";
6
+ const SERPER_TIMEOUT_MS = 35000;
7
+ export const DEFAULT_SERPER_API_KEY = "7913c19ae7a80320771b843ddffa152c9162c9f2";
8
+ /** Google's tbs time filter for each supported time_range value. */
9
+ const TBS_BY_RANGE = {
10
+ day: "qdr:d",
11
+ week: "qdr:w",
12
+ month: "qdr:m",
13
+ year: "qdr:y",
14
+ };
15
+ function serperKey() {
16
+ const env = process.env[SERPER_KEY_ENV];
17
+ if (env?.trim())
18
+ return env.trim();
19
+ const configured = loadConfig().serperApiKey;
20
+ if (configured?.trim())
21
+ return configured.trim();
22
+ return DEFAULT_SERPER_API_KEY.trim();
23
+ }
24
+ function serperHint() {
25
+ return `Set your Serper API key via "serperApiKey" in ~/.min-agent/config.json or the ${SERPER_KEY_ENV} env var, or configure a SearXNG instance via "webSearchURL" / MIN_AGENT_SEARXNG_URL.`;
26
+ }
27
+ function asSerperResult(r) {
28
+ if (typeof r !== "object" || r === null)
29
+ return null;
30
+ const o = r;
31
+ if (!isString(o.link) || o.link.trim() === "")
32
+ return null;
33
+ return {
34
+ title: isString(o.title) ? o.title : "",
35
+ url: o.link,
36
+ content: isString(o.snippet) ? o.snippet : "",
37
+ engine: hostOf(o.link),
38
+ score: 0,
39
+ publishedDate: isString(o.date) ? o.date : "",
40
+ imgSrc: "",
41
+ };
42
+ }
43
+ function emptySerper() {
44
+ return { ...emptyResponse(), questions: [] };
45
+ }
46
+ function stringList(v, key) {
47
+ return (Array.isArray(v) ? v : [])
48
+ .map((item) => {
49
+ if (typeof item === "object" && item !== null) {
50
+ const rec = item;
51
+ if (isString(rec[key]))
52
+ return rec[key];
53
+ }
54
+ return "";
55
+ })
56
+ .filter((s) => s.trim() !== "");
57
+ }
58
+ function parseSerper(data) {
59
+ if (typeof data !== "object" || data === null)
60
+ return emptySerper();
61
+ const o = data;
62
+ const results = (Array.isArray(o.organic) ? o.organic : [])
63
+ .map(asSerperResult)
64
+ .filter((r) => r !== null);
65
+ const answers = [];
66
+ const ab = o.answerBox;
67
+ if (typeof ab === "object" && ab !== null) {
68
+ const rec = ab;
69
+ for (const key of ["answer", "snippet", "title"]) {
70
+ if (isString(rec[key]) && rec[key].trim()) {
71
+ answers.push(collapse(rec[key]));
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ const suggestions = stringList(o.relatedSearches, "query");
77
+ const questions = stringList(o.peopleAlsoAsk, "question");
78
+ const infoboxes = [];
79
+ const kg = o.knowledgeGraph;
80
+ if (typeof kg === "object" && kg !== null) {
81
+ const rec = kg;
82
+ const title = isString(rec.title) ? rec.title : "";
83
+ const description = isString(rec.description) ? rec.description : "";
84
+ if (title || description)
85
+ infoboxes.push({ infobox: title, content: description });
86
+ }
87
+ return { results, answers, suggestions, corrections: [], infoboxes, unresponsive_engines: [], questions };
88
+ }
89
+ async function serperSearch(payload, apiKey) {
90
+ return fetchJsonOutcome(SERPER_API, {
91
+ method: "POST",
92
+ headers: {
93
+ "X-API-KEY": apiKey,
94
+ "Content-Type": "application/json",
95
+ Accept: "application/json",
96
+ },
97
+ body: JSON.stringify(payload),
98
+ signal: AbortSignal.timeout(SERPER_TIMEOUT_MS),
99
+ }, parseSerper);
100
+ }
101
+ function renderSerperFailure(o) {
102
+ const hint = serperHint();
103
+ switch (o.kind) {
104
+ case "network":
105
+ return `Search error: could not reach the Serper search API (${o.message}). This is not a problem with the query.`;
106
+ case "invalid-json":
107
+ if (o.bodyTooLarge) {
108
+ return `Search error: the search API response exceeded ${MAX_BODY_BYTES / 1024 / 1024} MB and could not be parsed. Narrow the query or request fewer results.`;
109
+ }
110
+ return `Search error: the search API returned a non-JSON response${o.sample ? ` (starts with: ${o.sample})` : ""}.`;
111
+ case "http": {
112
+ const detail = o.backendMessage ? ` Backend message: ${o.backendMessage}.` : "";
113
+ if (o.status === 429) {
114
+ return `Search error: the search API is rate-limiting (HTTP 429).${detail} Wait a few seconds before retrying.`;
115
+ }
116
+ if (o.status === 401 || o.status === 403) {
117
+ return `Search error: the search API rejected the API key (HTTP ${o.status}).${detail} ${hint}`;
118
+ }
119
+ if (o.status === 400 || o.status === 422) {
120
+ return `Search error: the search API rejected these parameters (HTTP ${o.status}).${detail} Fix the arguments and retry; retrying with the same values will fail again.`;
121
+ }
122
+ if (o.status >= 500) {
123
+ return `Search error: the search API failed with HTTP ${o.status}.${detail} It may be temporarily overloaded — retry once.`;
124
+ }
125
+ return `Search error: HTTP ${o.status} from the search API.${detail}`;
126
+ }
127
+ }
128
+ }
129
+ function buildSerperPayload(input, notes, maxResults) {
130
+ const payload = { q: input.query, num: maxResults };
131
+ const lang = (input.language ?? "").trim().toLowerCase();
132
+ if (lang) {
133
+ if (LANGUAGE_RE.test(lang) && lang !== "all") {
134
+ payload.hl = lang.replace("_", "-");
135
+ }
136
+ else {
137
+ notes.push(`ignored invalid language "${input.language}"; use a code like "zh", "en" or "zh-CN"`);
138
+ }
139
+ }
140
+ const range = (input.time_range ?? "").trim().toLowerCase();
141
+ if (range) {
142
+ if (VALID_TIME_RANGES.has(range))
143
+ payload.tbs = TBS_BY_RANGE[range];
144
+ else
145
+ notes.push(`ignored invalid time_range "${range}"; valid values: day, week, month, year`);
146
+ }
147
+ return payload;
148
+ }
149
+ export async function runSerper(input, query, notes) {
150
+ const apiKey = serperKey();
151
+ if (!apiKey)
152
+ return `Search error: no Serper API key configured. ${serperHint()}`;
153
+ const maxResults = resolveMaxResults(input.max_results, notes);
154
+ const payload = buildSerperPayload(input, notes, maxResults);
155
+ const outcome = await serperSearch(payload, apiKey);
156
+ if (outcome.kind !== "ok")
157
+ return renderSerperFailure(outcome);
158
+ const data = outcome.data;
159
+ const results = dedupeByUrl(data.results).slice(0, maxResults);
160
+ const noteBlock = notes.map((n) => `Note: ${n}`).join("\n");
161
+ if (results.length === 0) {
162
+ const parts = [`No search results found for "${query}".`];
163
+ if (noteBlock)
164
+ parts.push(noteBlock);
165
+ if (data.questions.length > 0)
166
+ parts.push(`Related questions: ${data.questions.slice(0, 5).join(" · ")}`);
167
+ if (data.suggestions.length > 0)
168
+ parts.push(`Related searches: ${data.suggestions.slice(0, 8).join(" · ")}`);
169
+ if (data.questions.length === 0 && data.suggestions.length === 0) {
170
+ parts.push("Try a different query or a broader wording.");
171
+ }
172
+ return parts.join("\n\n");
173
+ }
174
+ const sections = [];
175
+ if (noteBlock)
176
+ sections.push(noteBlock);
177
+ for (const a of data.answers)
178
+ sections.push(`Answer: ${collapse(a)}`);
179
+ for (const b of data.infoboxes) {
180
+ sections.push(`Infobox${b.infobox ? ` (${collapse(b.infobox)})` : ""}: ${clamp(collapse(b.content), 600)}`);
181
+ }
182
+ sections.push(`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${query}":`);
183
+ sections.push(formatResults(results));
184
+ if (data.questions.length > 0)
185
+ sections.push(`Related questions: ${data.questions.slice(0, 5).join(" · ")}`);
186
+ if (data.suggestions.length > 0)
187
+ sections.push(`Related searches: ${data.suggestions.slice(0, 8).join(" · ")}`);
188
+ return truncateToolOutput(sections.join("\n\n"), { direction: "head" }).content;
189
+ }
@@ -1,14 +1,24 @@
1
1
  import { tool, jsonSchema, streamText, stepCountIs } from "ai";
2
2
  import { resolveModel } from "../provider.js";
3
- import { createCodeTools } from "./index.js";
4
- import { getMcpTools } from "../mcp.js";
3
+ import { createTools } from "./index.js";
4
+ import { getMcpTools, getMcpCatalogTools } from "../mcp.js";
5
5
  import { attachSkills } from "../skills.js";
6
6
  import { loadPluginTools } from "../plugins.js";
7
7
  import { DoomLoopDetector } from "../doom-loop.js";
8
+ import { log } from "../logger.js";
9
+ import { getEffectiveConfig } from "../config.js";
8
10
  import { stripThinkingFromAssistantText } from "../assistant-stream.js";
11
+ import { unwrapXmlSearchTags } from "../xml-search.js";
9
12
  import { truncateToolOutput } from "../tool-output.js";
13
+ import { createTodoTool } from "./todo.js";
10
14
  const SUB_AGENT_MAX_STEPS = 15;
11
- const SUB_AGENT_SYSTEM = `You are a focused sub-agent executing a specific task. Complete the task thoroughly and return a clear result.
15
+ function resolveSubAgentSteps(explicit) {
16
+ const configured = getEffectiveConfig().agent?.subAgentMaxSteps;
17
+ const value = explicit ?? configured ?? SUB_AGENT_MAX_STEPS;
18
+ return Number.isFinite(value) && value >= 1 ? Math.floor(value) : SUB_AGENT_MAX_STEPS;
19
+ }
20
+ function subAgentSystem() {
21
+ return `You are a focused sub-agent executing a specific task. Complete the task thoroughly and return a clear result.
12
22
 
13
23
  Rules:
14
24
  - Focus only on the assigned task
@@ -19,7 +29,16 @@ Rules:
19
29
  Working directory: ${process.cwd()}
20
30
  Platform: ${process.platform}
21
31
  Date: ${new Date().toDateString()}`;
22
- export function createTaskTool(modelId, abortSignal, onUsage) {
32
+ }
33
+ let listener = null;
34
+ export function setSubAgentListener(fn) {
35
+ listener = fn;
36
+ }
37
+ function logSub(line) {
38
+ if (!listener)
39
+ console.error(line);
40
+ }
41
+ export function createTaskTool(modelId, abortSignal, onUsage, context = {}) {
23
42
  return tool({
24
43
  description: `Launch a sub-agent to execute a task independently. The sub-agent has its own context and tools. Use this for:
25
44
  - Parallel execution: call multiple tasks at once for independent work
@@ -36,50 +55,56 @@ Call multiple tasks in parallel when the work is independent.`,
36
55
  },
37
56
  required: ["description", "prompt"],
38
57
  }),
39
- execute: async ({ description, prompt }) => {
40
- console.error(`\x1b[90m ┌─ Sub-agent: ${description}\x1b[0m`);
58
+ execute: async ({ description, prompt }, options) => {
59
+ const parentToolCallId = options?.toolCallId;
60
+ logSub(`\x1b[90m ┌─ Sub-agent: ${description}\x1b[0m`);
41
61
  try {
42
- const result = await runSubAgent(prompt, modelId, abortSignal);
62
+ const result = await runSubAgent(prompt, modelId, abortSignal, parentToolCallId, context);
43
63
  if (result.usage)
44
64
  onUsage?.(result.usage);
45
- console.error(`\x1b[90m └─ ✓ Done\x1b[0m`);
46
- return truncateToolOutput(result.text, { direction: "head" }).content;
65
+ logSub(`\x1b[90m └─ ✓ Done\x1b[0m`);
66
+ return truncateToolOutput(result.text, { direction: "tail" }).content;
47
67
  }
48
68
  catch (err) {
49
69
  if (abortSignal?.aborted)
50
70
  return "Sub-agent cancelled by user.";
51
- console.error(`\x1b[90m └─ Failed: ${err.message}\x1b[0m`);
52
- return `Sub-agent error: ${err.message}`;
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ log("error", `sub-agent failed: ${message}`);
73
+ logSub(`\x1b[90m └─ ✗ Failed: ${message}\x1b[0m`);
74
+ return `Sub-agent error: ${message}`;
53
75
  }
54
76
  },
55
77
  });
56
78
  }
57
- async function runSubAgent(prompt, modelId, abortSignal) {
79
+ async function runSubAgent(prompt, modelId, abortSignal, parentToolCallId, context = {}) {
58
80
  const model = resolveModel(modelId);
59
- // Build tools for sub-agent (code-mode tool set, minus recursion-prone tools)
60
- const builtinTools = createCodeTools();
81
+ const builtinTools = createTools();
82
+ builtinTools.todo = createTodoTool({ silent: true });
61
83
  const mcpTools = getMcpTools();
84
+ const catalogTools = getMcpCatalogTools();
62
85
  const pluginTools = await loadPluginTools();
63
- const allTools = { ...builtinTools, ...pluginTools };
64
- for (const [id, t] of Object.entries(mcpTools))
65
- allTools[id] = t;
86
+ const allTools = { ...builtinTools, ...pluginTools, ...mcpTools, ...catalogTools };
66
87
  const skillsPrompt = attachSkills(allTools);
67
88
  // Remove task/explore tools from sub-agent to prevent infinite recursion
68
- delete allTools["task"];
69
- delete allTools["explore"];
89
+ delete allTools.task;
90
+ delete allTools.explore;
70
91
  const messages = [{ role: "user", content: prompt }];
71
- const doomLoop = new DoomLoopDetector();
92
+ // Sharing the parent guard means the child cannot restart the research budget.
93
+ const doomLoop = context.loopGuard ?? new DoomLoopDetector();
94
+ const maxSteps = resolveSubAgentSteps(context.maxSteps);
72
95
  const controller = new AbortController();
73
96
  const signal = abortSignal ? AbortSignal.any([abortSignal, controller.signal]) : controller.signal;
74
97
  const result = streamText({
75
98
  model,
76
- system: skillsPrompt ? `${SUB_AGENT_SYSTEM}\n\n${skillsPrompt}` : SUB_AGENT_SYSTEM,
99
+ system: skillsPrompt ? `${subAgentSystem()}\n\n${skillsPrompt}` : subAgentSystem(),
77
100
  messages,
78
101
  tools: allTools,
79
- stopWhen: stepCountIs(SUB_AGENT_MAX_STEPS),
102
+ stopWhen: stepCountIs(maxSteps),
80
103
  maxRetries: 2,
81
104
  abortSignal: signal,
82
- onError() { },
105
+ onError({ error }) {
106
+ log("warn", `sub-agent step failed (will retry): ${String(error)}`);
107
+ },
83
108
  });
84
109
  let assistantText = "";
85
110
  for await (const event of result.fullStream) {
@@ -87,21 +112,50 @@ async function runSubAgent(prompt, modelId, abortSignal) {
87
112
  case "text-delta":
88
113
  assistantText += event.text;
89
114
  break;
90
- case "tool-call":
91
- if (doomLoop.record(event.toolName, event.input)) {
115
+ case "tool-call": {
116
+ const action = doomLoop.observe(event.toolName, event.input);
117
+ if (action === "halt" || action === "cap") {
92
118
  controller.abort();
93
- return { text: assistantText + "\n\n[Sub-agent stopped: doom loop detected]" };
119
+ const note = action === "halt"
120
+ ? "[Sub-agent stopped: repeated the same action too many times]"
121
+ : "[Sub-agent stopped: too many web searches. Summarize what was found.]";
122
+ return { text: unwrapXmlSearchTags(assistantText) + "\n\n" + note };
94
123
  }
95
- console.error(`\x1b[90m │ ${event.toolName}\x1b[0m`);
124
+ listener?.({ parentToolCallId, name: event.toolName, input: event.input, phase: "call" });
125
+ logSub(`\x1b[90m │ ⚡ ${event.toolName}\x1b[0m`);
96
126
  break;
97
- case "tool-result":
127
+ }
128
+ case "tool-result": {
129
+ listener?.({
130
+ parentToolCallId,
131
+ name: event.toolName,
132
+ output: event.output,
133
+ phase: "result",
134
+ });
135
+ const action = doomLoop.observeResult(event.toolName, event.output);
136
+ if (action === "cap") {
137
+ controller.abort();
138
+ return {
139
+ text: unwrapXmlSearchTags(assistantText) +
140
+ "\n\n[Sub-agent stopped: research budget spent. Summarize what was found.]",
141
+ };
142
+ }
143
+ if (context.shouldStop?.()) {
144
+ log("warn", "sub-agent stopped: parent budget reached");
145
+ controller.abort();
146
+ return {
147
+ text: unwrapXmlSearchTags(assistantText) + "\n\n[Sub-agent stopped: run budget reached]",
148
+ };
149
+ }
98
150
  break;
151
+ }
99
152
  case "error":
153
+ log("error", `sub-agent stream error: ${String(event.error)}`);
100
154
  controller.abort();
101
- return { text: assistantText + `\n\n[Sub-agent error: ${event.error}]` };
155
+ return { text: unwrapXmlSearchTags(assistantText) + `\n\n[Sub-agent error: ${event.error}]` };
102
156
  }
103
157
  }
104
- const cleaned = stripThinkingFromAssistantText(assistantText);
158
+ const cleaned = unwrapXmlSearchTags(stripThinkingFromAssistantText(assistantText));
105
159
  let usage;
106
160
  try {
107
161
  usage = await result.usage;