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.
- package/README.md +111 -28
- package/dist/agent.js +1119 -256
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +5 -888
- package/dist/code-mode.js +32 -14
- package/dist/compaction.js +347 -160
- package/dist/config.js +119 -10
- package/dist/confirm.js +56 -9
- package/dist/context-window.js +107 -39
- package/dist/doom-loop.js +264 -29
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/instructions.js +21 -0
- package/dist/logger.js +33 -4
- package/dist/markdown.js +37 -11
- package/dist/mcp.js +328 -30
- package/dist/memory.js +97 -56
- package/dist/output.js +7 -5
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +46 -8
- package/dist/pricing.js +4 -4
- package/dist/provider.js +23 -6
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +33 -883
- package/dist/sessions.js +53 -9
- package/dist/skills.js +82 -18
- package/dist/title-gen.js +8 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +5 -0
- package/dist/tool-output.js +1 -3
- package/dist/tools/apply_patch.js +85 -11
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +2 -2
- package/dist/tools/bash.js +57 -19
- package/dist/tools/code_search.js +7 -1
- package/dist/tools/edit.js +11 -10
- package/dist/tools/explore.js +74 -14
- package/dist/tools/glob.js +4 -0
- package/dist/tools/grep.js +17 -10
- package/dist/tools/index.js +6 -21
- package/dist/tools/question.js +28 -9
- package/dist/tools/read.js +6 -4
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +84 -30
- package/dist/tools/todo.js +120 -19
- package/dist/tools/web_fetch.js +11 -3
- package/dist/tools/web_search.js +66 -556
- package/dist/tools/write.js +23 -6
- package/dist/tui/App.js +63 -14
- package/dist/tui/ConfirmBar.js +45 -13
- package/dist/tui/InputBar.js +150 -35
- package/dist/tui/MessageList.js +266 -125
- package/dist/tui/ModelPicker.js +8 -3
- package/dist/tui/QuestionBar.js +51 -19
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +8 -14
- package/dist/tui/agent-runner.js +142 -22
- package/dist/tui/caret-pos.js +48 -5
- package/dist/tui/caret.js +1 -1
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/drag-state.js +8 -3
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +42 -13
- package/dist/tui/input-history.js +92 -11
- package/dist/tui/layout.js +75 -4
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +113 -21
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +22 -6
- package/dist/tui/slash-handler.js +233 -58
- package/dist/tui/text-width.js +38 -16
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +1 -1
- package/dist/tui/use-sgr-mouse.js +3 -1
- package/dist/tui-chat.js +276 -40
- package/dist/updater.js +88 -29
- package/dist/xml-search.js +194 -0
- package/docs/API.md +257 -25
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +6 -1
- package/skills/self-config/SKILL.md +90 -0
- 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
|
+
}
|
package/dist/tools/task.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import { tool, jsonSchema, streamText, stepCountIs } from "ai";
|
|
2
2
|
import { resolveModel } from "../provider.js";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
return truncateToolOutput(result.text, { direction: "
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
69
|
-
delete allTools
|
|
89
|
+
delete allTools.task;
|
|
90
|
+
delete allTools.explore;
|
|
70
91
|
const messages = [{ role: "user", content: prompt }];
|
|
71
|
-
|
|
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 ? `${
|
|
99
|
+
system: skillsPrompt ? `${subAgentSystem()}\n\n${skillsPrompt}` : subAgentSystem(),
|
|
77
100
|
messages,
|
|
78
101
|
tools: allTools,
|
|
79
|
-
stopWhen: stepCountIs(
|
|
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
|
-
|
|
115
|
+
case "tool-call": {
|
|
116
|
+
const action = doomLoop.observe(event.toolName, event.input);
|
|
117
|
+
if (action === "halt" || action === "cap") {
|
|
92
118
|
controller.abort();
|
|
93
|
-
|
|
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
|
-
|
|
124
|
+
listener?.({ parentToolCallId, name: event.toolName, input: event.input, phase: "call" });
|
|
125
|
+
logSub(`\x1b[90m │ ⚡ ${event.toolName}\x1b[0m`);
|
|
96
126
|
break;
|
|
97
|
-
|
|
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;
|