@thincoder/core 0.10.8 → 0.10.9

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 (72) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/agent/family-tools.mjs +2 -2
  3. package/agent/helpers.mjs +6 -6
  4. package/agent/post-turn.mjs +34 -0
  5. package/agent/run-stages.mjs +43 -9
  6. package/agent/setup.mjs +10 -2
  7. package/agent/turn-loop.mjs +6 -0
  8. package/agent-tools/audit-block.mjs +4 -2
  9. package/agent-tools/batch-gate.mjs +198 -0
  10. package/agent-tools/batch-lifecycle.mjs +216 -36
  11. package/agent-tools/batch-skeleton.mjs +87 -0
  12. package/agent-tools/batch.mjs +11 -3
  13. package/agent-tools/context.mjs +28 -5
  14. package/agent-tools/design-slots.mjs +7 -33
  15. package/agent-tools/doc-check.mjs +44 -6
  16. package/agent-tools/spawn-gates.mjs +100 -41
  17. package/agent-tools/ssh-session.mjs +108 -61
  18. package/agent-tools/ssh-transport.mjs +17 -4
  19. package/agent-tools/ssh-vault.mjs +291 -0
  20. package/agent-tools/subagent-spawn.mjs +18 -11
  21. package/agent-tools/subagent.mjs +13 -7
  22. package/agent-tools/timer.mjs +2 -2
  23. package/config-defaults.mjs +92 -0
  24. package/config-normalize.mjs +109 -0
  25. package/config.mjs +88 -197
  26. package/doc-check/anchors.mjs +54 -12
  27. package/doc-check/index.mjs +161 -12
  28. package/doc-check/version.mjs +84 -0
  29. package/doc-check/width.mjs +5 -4
  30. package/i18n.mjs +2 -0
  31. package/ledger-cmd.mjs +314 -61
  32. package/ledger-db.mjs +22 -10
  33. package/ledger-migrate.mjs +15 -9
  34. package/ledger-tools.mjs +115 -69
  35. package/ledger.mjs +13 -10
  36. package/log.mjs +2 -2
  37. package/manifest-schema.mjs +15 -1
  38. package/package.json +1 -1
  39. package/permission.mjs +7 -1
  40. package/prompts/advisor-design.md +21 -21
  41. package/prompts/advisor-round1.md +18 -18
  42. package/prompts/advisor-round2.md +22 -22
  43. package/prompts/advisor-round3.md +24 -24
  44. package/prompts/common.md +108 -93
  45. package/prompts/consult-base.md +8 -8
  46. package/prompts/discipline-engineering.md +68 -65
  47. package/prompts/discipline-normal.md +69 -68
  48. package/prompts/persona-coder.md +6 -6
  49. package/prompts/persona-eng-coder.md +19 -18
  50. package/prompts/persona-eng-designer.md +36 -32
  51. package/prompts/persona-engineering.md +114 -98
  52. package/prompts/persona-explore.md +2 -2
  53. package/prompts/persona-normal.md +16 -16
  54. package/prompts/persona-plan.md +7 -7
  55. package/prompts/subagent-base.md +4 -4
  56. package/provider/anthropic.mjs +4 -2
  57. package/provider/core.mjs +4 -2
  58. package/provider/google.mjs +5 -1
  59. package/provider/responses-request.mjs +3 -2
  60. package/session-lifecycle.mjs +6 -6
  61. package/team.mjs +2 -1
  62. package/token-window.mjs +1 -1
  63. package/tool-docs/batch.md +1 -1
  64. package/tool-docs/doc_check.md +30 -7
  65. package/tool-docs/fetch.md +3 -2
  66. package/tool-docs/ledger.md +7 -5
  67. package/tool-docs/ssh.md +23 -6
  68. package/tool-docs/subagent.md +6 -11
  69. package/tool-docs/websearch.md +3 -1
  70. package/tools/ssh.mjs +24 -11
  71. package/tools/web-deepseek.mjs +172 -0
  72. package/tools/web.mjs +29 -4
package/tools/ssh.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * tools/ssh.mjs — SSH 会话工具面(薄接线):schema ∥ 参数校验 ∥ `isReadonlyAction` ∥ 回执/错误形
3
- * (设计 = `docs/core/design/TOOLS.md` §6.22 —— D-TO17/D-TO18 · 台账 #1436;机制单点 =
4
- * `agent-tools/ssh-session.mjs`;描述档 = `tool-docs/ssh.md`)。
3
+ * (设计 = `docs/core/design/TOOLS.md` §6.22 —— D-TO17/D-TO18 · 台账 #1436;保存面 D-TO19/D-TO20 · 台账 #1602;
4
+ * 机制单点 = `agent-tools/ssh-session.mjs`(保存件 = `agent-tools/ssh-vault.mjs`);描述档 = `tool-docs/ssh.md`)。
5
5
  *
6
- * 分类钩子(KD-2 ∥ KD-11 同式——动作级,工具级只读位恒 false):免审 = `list` ∥ `read`
7
- * (只读豁免,NS3 判据);过门 = `connect` ∥ `send` ∥ `secret_input` ∥ `close`——**携密动作仍属
6
+ * 分类钩子(KD-2 ∥ KD-11 同式——动作级,工具级只读位恒 false):免审 = `list` ∥ `read` ∥ `credentials`
7
+ * (只读豁免,NS3 判据);过门 = `connect` ∥ `send` ∥ `secret_input` ∥ `close` ∥ `forget`——**携密动作仍属
8
8
  * 写动作**,门不放松;携密径审批文案 = 掩码占位(`permission.mjs`,值不回显 ⇒ 不入上下文 / 审批记录)。
9
9
  *
10
10
  * 失败回执**返回**(不 throw)——首行 `Error: …` = 模型面失败形(`bash` / `browser` 既例);
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { DESC } from "./shared.mjs"
16
16
 
17
- export const SSH_ACTIONS = ["connect", "send", "read", "secret_input", "close", "list"]
17
+ export const SSH_ACTIONS = ["connect", "send", "read", "secret_input", "close", "list", "credentials", "forget"]
18
18
 
19
19
  /** 参数校验(缺必填 / 形错 ⇒ `Error: …`——不执行;§6.22 动作表逐动作必填面)。 */
20
20
  export function validateArgs(args) {
@@ -29,7 +29,14 @@ export function validateArgs(args) {
29
29
  }
30
30
  return null
31
31
  }
32
- if (action === "list") return null
32
+ if (action === "list" || action === "credentials") return null
33
+ if (action === "forget") {
34
+ if (typeof args.host !== "string" || args.host.trim() === "") return "Error: forget requires host"
35
+ if (args.port !== undefined && args.port !== null && args.port !== "" && !Number.isFinite(Number(args.port))) {
36
+ return "Error: forget port must be a number"
37
+ }
38
+ return null
39
+ }
33
40
  if (typeof args.id !== "string" || args.id.trim() === "") {
34
41
  return `Error: ${action} requires id (the session id from connect, form "ssh#N")`
35
42
  }
@@ -43,10 +50,10 @@ export const sshTool = {
43
50
  parameters: {
44
51
  type: "object",
45
52
  properties: {
46
- action: { type: "string", enum: SSH_ACTIONS, description: "Action to run (one per call): connect | send | read | secret_input | close | list." },
47
- host: { type: "string", description: "connect: target host (IP or name)." },
53
+ action: { type: "string", enum: SSH_ACTIONS, description: "Action to run (one per call): connect | send | read | secret_input | close | list | credentials | forget." },
54
+ host: { type: "string", description: "connect / forget: target host (IP or name)." },
48
55
  user: { type: "string", description: "connect: login user (optional: your local ssh config decides, else the user recorded for this host locally)." },
49
- port: { type: "number", description: "connect: TCP port (optional, default 22)." },
56
+ port: { type: "number", description: "connect / forget: TCP port (optional, default 22)." },
50
57
  label: { type: "string", description: "connect: short human label for this session (optional); secret_input: label for the value being sent." },
51
58
  promptPattern: { type: "string", description: "connect: regex that marks the shell prompt as ready (optional): send/read then settle the moment the prompt appears instead of waiting out the silence window." },
52
59
  id: { type: "string", description: "send / read / secret_input / close: session id from connect (`ssh#N`)." },
@@ -54,13 +61,14 @@ export const sshTool = {
54
61
  settle: { type: "boolean", description: "send: wait for the ready verdict before returning (default true); false returns right after the bytes are written." },
55
62
  timeoutMs: { type: "number", description: "send / read: wait budget override (default 10000) — on expiry the call returns partial output with a readable line." },
56
63
  maxChars: { type: "number", description: "read: return cap override (default 8000; the full output stays in the session log)." },
64
+ identityFile: { type: "string", description: "connect: private-key path to offer (`-i`, `~` expands); remembered for this host and reused when omitted (optional)." },
57
65
  },
58
66
  required: ["action"],
59
67
  },
60
- // 非工具级只读:动作级分类钩子接管(§6.22 NS3——`list` / `read` 免审);也因此不进只读角色的子代工具表。
68
+ // 非工具级只读:动作级分类钩子接管(§6.22 NS3——`list` / `read` / `credentials` 免审);也因此不进只读角色的子代工具表。
61
69
  readonly: false,
62
70
  isReadonlyAction(args) {
63
- return args?.action === "list" || args?.action === "read"
71
+ return args?.action === "list" || args?.action === "read" || args?.action === "credentials"
64
72
  },
65
73
  async execute(args, ctx) {
66
74
  const invalid = validateArgs(args)
@@ -74,6 +82,7 @@ export const sshTool = {
74
82
  res = await mod.connectSsh(parent, ctx, {
75
83
  host: args.host, user: args.user ?? null, port: args.port ?? null,
76
84
  label: args.label ?? null, promptPattern: args.promptPattern ?? null,
85
+ identityFile: args.identityFile ?? null,
77
86
  })
78
87
  } else if (action === "send") {
79
88
  res = await mod.sendSsh(parent, ctx, { id: args.id, data: args.data, settle: args.settle !== false, timeoutMs: args.timeoutMs })
@@ -83,6 +92,10 @@ export const sshTool = {
83
92
  res = await mod.secretInputSsh(parent, ctx, { id: args.id, label: args.label ?? null })
84
93
  } else if (action === "close") {
85
94
  res = await mod.closeSsh(parent, ctx, { id: args.id })
95
+ } else if (action === "credentials") {
96
+ res = mod.credentialsSsh()
97
+ } else if (action === "forget") {
98
+ res = mod.forgetSsh({ host: args.host, port: args.port ?? null })
86
99
  } else {
87
100
  res = mod.listSsh(parent)
88
101
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * web-deepseek.mjs — DeepSeek Search / Fetch backend for the `websearch` & `fetch` tools
3
+ * (design = `docs/core/design/TOOLS.md` §6.23 · ledger #1605).
4
+ *
5
+ * Key source = the first `providers[]` entry whose baseURL host is the official
6
+ * `api.deepseek.com` and whose apiKey resolves non-empty (`${env:VAR}` resolved at use
7
+ * time — an unresolved reference makes that entry ineligible, never throws). Zero new
8
+ * config: a configured DeepSeek provider is enough.
9
+ *
10
+ * The tools never fail because of this backend: every export returns a pure value
11
+ * (`{ status: "ok" | "empty" | "failed" | "unavailable" }`) and never throws.
12
+ * 401/402/403/404 trip a per-agent breaker (WeakSet); 408/429/5xx retry once (500 ms);
13
+ * everything else falls back to the caller's existing chain.
14
+ */
15
+ import { URL } from "node:url"
16
+ import { truncate, isPrivateHost } from "./shared.mjs"
17
+ import { resolveProviderSecrets } from "../env-ref.mjs"
18
+ import { proxyFetch } from "../proxy.mjs"
19
+ import { logEvent } from "../log.mjs"
20
+
21
+ // D-WB2: endpoints are fixed constants — `baseURL` only decides eligibility (a user's
22
+ // `/v1` suffix or prefix must not build a doubled path, and the key only goes to the
23
+ // official host).
24
+ const SEARCH_URL = "https://api.deepseek.com/v1/tools/search"
25
+ const FETCH_URL = "https://api.deepseek.com/v1/tools/fetch"
26
+ const DEEPSEEK_HOST = "api.deepseek.com"
27
+ const TIMEOUT_MS = 15_000
28
+ const RETRY_DELAY_MS = 500 // D-WB5: fixed single backoff (no Retry-After parsing)
29
+ const COST_PER_CALL = 0.00075 // $0.75 / 1000 successful requests — `count` does not change the price
30
+ const TRIP_STATUS = new Set([401, 402, 403, 404])
31
+ const RETRY_STATUS = (() => {
32
+ const codes = new Set([408, 429])
33
+ for (let code = 500; code <= 599; code++) codes.add(code)
34
+ return codes
35
+ })()
36
+
37
+ let _transport = proxyFetch
38
+ let _breaker = new WeakSet()
39
+
40
+ /** Test seam (CONFIG.md §6.2 ③ — paired): swap the transport; reset also clears the breaker. */
41
+ export function _setDeepseekTransportForTest(fn) { _transport = typeof fn === "function" ? fn : proxyFetch }
42
+ export function _resetDeepseekTransportForTest() { _transport = proxyFetch; _breaker = new WeakSet() }
43
+
44
+ /** `local` skips the DeepSeek backend; anything else (missing ∥ misspelled ∥ non-string) = `auto` (fail-open). */
45
+ export function backendMode(cfg, key) {
46
+ return cfg?.[key]?.backend === "local" ? "local" : "auto"
47
+ }
48
+
49
+ /** First qualifying channel in stored order → `{ apiKey }` | null. Never throws. */
50
+ function deepseekChannel(cfg) {
51
+ const list = Array.isArray(cfg?.providersList) ? cfg.providersList
52
+ : Array.isArray(cfg?.providers) ? cfg.providers : []
53
+ for (const entry of list) {
54
+ if (entry === null || typeof entry !== "object") continue
55
+ let url
56
+ try { url = new URL(String(entry.baseURL ?? "")) } catch { continue }
57
+ if (url.protocol !== "https:") continue
58
+ const host = url.hostname.toLowerCase().replace(/\.$/, "") // trailing-dot host still official
59
+ if (host !== DEEPSEEK_HOST) continue // subdomains never qualify (x.api.deepseek.com)
60
+ if (url.port !== "" && url.port !== "443") continue // non-standard port could impersonate
61
+ let apiKey
62
+ try { apiKey = resolveProviderSecrets(entry).apiKey } catch { continue } // unset env ref ⇒ ineligible
63
+ if (typeof apiKey !== "string" || apiKey.trim() === "") continue
64
+ return { apiKey: apiKey.trim() }
65
+ }
66
+ return null
67
+ }
68
+
69
+ const tripped = (agent) => agent !== null && typeof agent === "object" && _breaker.has(agent)
70
+
71
+ /** Break the backend for this agent identity (single domain — search ∥ fetch share it, D-WB3). */
72
+ function trip(agent) {
73
+ if (agent !== null && typeof agent === "object") _breaker.add(agent)
74
+ }
75
+
76
+ /** One POST. Returns `{ kind: "ok", data }` | `{ kind: "http", status }` | `{ kind: "net", timeout }` | `{ kind: "json" }`. */
77
+ async function postOnce(url, body, apiKey, proxyUri) {
78
+ const ctrl = new AbortController()
79
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
80
+ try {
81
+ const res = await _transport(url, {
82
+ method: "POST",
83
+ // Same header shape as the Tavily leg (`web.mjs` bearer precedent).
84
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
85
+ body: JSON.stringify(body),
86
+ signal: ctrl.signal,
87
+ }, proxyUri)
88
+ if (!res || res.ok !== true) {
89
+ try { await res?.body?.cancel?.() } catch { /* body already closed */ }
90
+ return { kind: "http", status: Number(res?.status ?? 0) }
91
+ }
92
+ try { return { kind: "ok", data: await res.json() } } catch { return { kind: "json" } }
93
+ } catch (e) {
94
+ return { kind: "net", timeout: e?.name === "AbortError" }
95
+ } finally { clearTimeout(timer) }
96
+ }
97
+
98
+ /** One logical call: retry once (408/429/5xx), trip on 401/402/403/404, otherwise fall back. */
99
+ async function request(url, body, apiKey, proxyUri, tool, agent) {
100
+ const started = Date.now()
101
+ let r = await postOnce(url, body, apiKey, proxyUri)
102
+ if (r.kind === "http" && RETRY_STATUS.has(r.status)) {
103
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
104
+ r = await postOnce(url, body, apiKey, proxyUri)
105
+ }
106
+ if (r.kind === "ok") return { ok: true, data: r.data }
107
+ if (r.kind === "http" && TRIP_STATUS.has(r.status) && agent !== null && typeof agent === "object") {
108
+ trip(agent)
109
+ // Tripping line only (transient fallbacks stay quiet — ⑤).
110
+ logEvent("web:deepseek", { tool, status: r.status, tripped: true, ms: Date.now() - started })
111
+ }
112
+ const reason = r.kind === "http" ? `http ${r.status}`
113
+ : r.kind === "net" ? (r.timeout ? "timeout" : "network")
114
+ : "malformed json"
115
+ return { ok: false, reason }
116
+ }
117
+
118
+ /** Response → tool text: `[deepseek]` block per result, same numbered shape as the Tavily ∥ Bing legs. */
119
+ function renderSearch(data, limit) {
120
+ const list = Array.isArray(data?.results) ? data.results : []
121
+ const results = list.slice(0, limit)
122
+ if (results.length === 0) return { n: 0, text: "" }
123
+ const blocks = results.map((r, i) => {
124
+ const meta = []
125
+ if (typeof r?.date === "string" && r.date.trim() !== "") meta.push(r.date)
126
+ if (Number.isFinite(r?.authority) && r.authority >= 0) meta.push(`authority ${r.authority}`) // -1 = unknown ⇒ omitted
127
+ if (Number.isFinite(r?.rerank_score)) meta.push(`rerank ${r.rerank_score}`)
128
+ const summary = typeof r?.summary === "string" && r.summary.trim() !== ""
129
+ ? r.summary
130
+ : (typeof r?.snippet === "string" ? r.snippet : "")
131
+ const lines = [`${i + 1}. [deepseek] ${r?.title ?? ""}`, ` ${r?.url ?? ""}`]
132
+ if (meta.length > 0) lines.push(` ${meta.join(" · ")}`)
133
+ if (summary !== "") lines.push(` ${summary}`)
134
+ return lines.join("\n")
135
+ })
136
+ return { n: results.length, text: truncate(blocks.join("\n\n")) }
137
+ }
138
+
139
+ /** search — `count` = the tool-face limit (`web.mjs` already clamps it to 20); `< 1` ⇒ 1. Never throws. */
140
+ export async function deepseekSearch({ cfg, query, limit = 8, proxy = null, agent = null } = {}) {
141
+ const channel = deepseekChannel(cfg)
142
+ if (channel === null) return { status: "unavailable" }
143
+ if (tripped(agent)) return { status: "failed", reason: "breaker open" }
144
+ const count = Math.min(50, Math.max(1, Number.isFinite(limit) ? Math.floor(limit) : 8))
145
+ const started = Date.now()
146
+ const r = await request(SEARCH_URL, { query, count, need_content: false }, channel.apiKey, proxy, "search", agent)
147
+ if (!r.ok) return { status: "failed", reason: r.reason }
148
+ const { n, text } = renderSearch(r.data, count)
149
+ logEvent("web:deepseek", { tool: "search", n, ms: Date.now() - started, cost: n > 0 ? COST_PER_CALL : 0 })
150
+ return n > 0 ? { status: "ok", text, n } : { status: "empty" }
151
+ }
152
+
153
+ /** fetch — title + Markdown; empty `content` ⇒ `empty` (caller falls back to the local fetch). Never throws. */
154
+ export async function deepseekFetch({ cfg, url, proxy = null, agent = null } = {}) {
155
+ const channel = deepseekChannel(cfg)
156
+ if (channel === null) return { status: "unavailable" }
157
+ if (tripped(agent)) return { status: "failed", reason: "breaker open" }
158
+ // `web.mjs` runs the SSRF gate before calling; keep a second one here so a direct
159
+ // caller can never send a private/metadata URL to a third party either.
160
+ let target
161
+ try { target = new URL(url) } catch { return { status: "failed", reason: "invalid url" } }
162
+ if (isPrivateHost(target.hostname)) return { status: "failed", reason: "private url" }
163
+ const started = Date.now()
164
+ const r = await request(FETCH_URL, { url }, channel.apiKey, proxy, "fetch", agent)
165
+ if (!r.ok) return { status: "failed", reason: r.reason }
166
+ const title = typeof r.data?.title === "string" ? r.data.title : ""
167
+ const content = typeof r.data?.content === "string" ? r.data.content : ""
168
+ const n = content.trim() === "" ? 0 : content.length
169
+ logEvent("web:deepseek", { tool: "fetch", n, ms: Date.now() - started, cost: n > 0 ? COST_PER_CALL : 0 })
170
+ if (n === 0) return { status: "empty" }
171
+ return { status: "ok", text: truncate(`[deepseek] ${title}\n\n${content}`), n }
172
+ }
package/tools/web.mjs CHANGED
@@ -2,11 +2,17 @@ import { DESC, truncate, stripTags, htmlToText, isPrivateHost } from "./shared.m
2
2
  import { URL } from "node:url";
3
3
  import { proxyFetch } from "../proxy.mjs";
4
4
  import { resolveEnvRefs } from "../env-ref.mjs";
5
+ import { backendMode, deepseekSearch, deepseekFetch } from "./web-deepseek.mjs";
5
6
 
6
7
  export const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
7
8
  const FETCH_TIMEOUT = 15_000
8
9
  // D-TF3:网络失败错误文本追加 proxy 提示——纯文本提示,不自动路由(2026-08-31 裁定:proxy 显式传不自动应用)
9
10
  const PROXY_HINT = "network failure — retry with proxy: 'http://host:port' if the target is blocked"
11
+ // WEB-BACKEND-DEEPSEEK(TOOLS.md §6.23 ④):DeepSeek 未出结果 ⇒ 回落腿承接后结果末尾恰一行(按返回态分叉)。
12
+ // fetch 腿两态零提示(D-WB6);`unavailable` ∥ `local` 零提示(零回归)。
13
+ const deepseekNote = (status, leg) => status === "empty"
14
+ ? `(deepseek returned no results — results from ${leg})`
15
+ : `(deepseek backend unavailable — results from ${leg})`
10
16
 
11
17
  // ── Web search (Bing; direct by default, through proxy when configured and web toggle on) ──
12
18
 
@@ -107,18 +113,29 @@ export const websearchTool = {
107
113
  // 2026-08-31 ruling: proxy is a PER-CALL decision (args.proxy), never the config.json
108
114
  // fixed configuration. Model picks by target: github/foreign → pass proxy; gitee/domestic → omit.
109
115
  const proxyUri = args.proxy ?? null
116
+ // DeepSeek first when a qualifying provider is configured (TOOLS.md §6.23 ②) — `local`,
117
+ // no channel, or a query over 128 chars ⇒ zero calls, and the chain below runs verbatim.
118
+ let dsNote = null
119
+ if (backendMode(ctx?.agent?.config, "websearch") !== "local"
120
+ && typeof args.query === "string" && args.query.length <= 128) {
121
+ const ds = await deepseekSearch({ cfg: ctx?.agent?.config, query: args.query, limit, proxy: proxyUri, agent: ctx?.agent })
122
+ if (ds.status === "ok") return ds.text
123
+ if (ds.status === "empty" || ds.status === "failed") dsNote = ds.status
124
+ }
125
+ // A DeepSeek outcome only adds a line once a fallback leg actually carries results.
126
+ const withNote = (text, leg) => (dsNote ? `${text}\n\n${deepseekNote(dsNote, leg)}` : text)
110
127
  // Structured search first when a Tavily key is configured — stable, dated,
111
128
  // no HTML scraping. Falls back to Bing silently.
112
129
  const tavily = await fetchTavily(args.query, limit, ctx, proxyUri)
113
130
  if (tavily && tavily.results.length > 0) {
114
- return truncate(tavily.results.slice(0, limit).map((r, i) => `${i + 1}. [tavily] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
131
+ return withNote(truncate(tavily.results.slice(0, limit).map((r, i) => `${i + 1}. [tavily] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n")), "tavily")
115
132
  }
116
133
  if (args.engine) {
117
134
  const engine = ENGINES.find(e => e.name === args.engine)
118
135
  if (!engine) return `Unknown engine '${args.engine}'. Available: ${ENGINE_NAMES.join(", ")}`
119
136
  const fetched = await fetchEngine(engine, args.query, page, proxyUri)
120
137
  if (!fetched || fetched.results.length === 0) return "(no results)"
121
- return truncate(fetched.results.slice(0, limit).map((r, i) => `${i + 1}. [${engine.label}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
138
+ return withNote(truncate(fetched.results.slice(0, limit).map((r, i) => `${i + 1}. [${engine.label}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n")), engine.name)
122
139
  }
123
140
  const promises = ENGINES.map(e => fetchEngine(e, args.query, 1, proxyUri))
124
141
  const fetched = (await Promise.all(promises)).filter(Boolean)
@@ -135,7 +152,9 @@ export const websearchTool = {
135
152
  }
136
153
  }
137
154
  }
138
- return truncate(merged.slice(0, limit).map((r, i) => `${i + 1}. [${r._engine}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
155
+ // Note only when a leg actually carried results — an empty merge keeps today's output.
156
+ const mergedText = truncate(merged.slice(0, limit).map((r, i) => `${i + 1}. [${r._engine}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
157
+ return merged.length > 0 ? withNote(mergedText, fetched[0].engine) : mergedText
139
158
  },
140
159
  }
141
160
 
@@ -193,9 +212,15 @@ export const fetchTool = {
193
212
  required: ["url"],
194
213
  },
195
214
  readonly: true,
196
- async execute(args, _ctx) {
215
+ async execute(args, ctx) {
197
216
  if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
198
217
  if (isPrivateUrl(args.url)) throw new Error("fetch blocked: internal/private/metadata addresses are not allowed")
218
+ // DeepSeek returns Markdown (W3); `empty` ∥ `failed` fall through to the local fetch,
219
+ // both without a result note (D-WB6 — the content face stays verbatim).
220
+ if (backendMode(ctx?.agent?.config, "fetch") !== "local") {
221
+ const ds = await deepseekFetch({ cfg: ctx?.agent?.config, url: args.url, proxy: args.proxy ?? null, agent: ctx?.agent })
222
+ if (ds.status === "ok") return ds.text
223
+ }
199
224
  try {
200
225
  // 2026-08-31 ruling: per-call decision — args.proxy when passed, direct otherwise.
201
226
  const proxyUri = args.proxy ?? null