@thincoder/core 0.10.8 → 0.10.10
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/CHANGELOG.md +6 -0
- package/advisor/messages.mjs +4 -5
- package/advisor/project-context.mjs +69 -44
- package/agent/family-tools.mjs +11 -9
- package/agent/helpers.mjs +12 -6
- package/agent/post-turn.mjs +34 -0
- package/agent/run-stages.mjs +43 -9
- package/agent/run-start.mjs +1 -0
- package/agent/setup.mjs +10 -2
- package/agent/turn-loop.mjs +23 -13
- package/agent-tools/audit-block.mjs +4 -2
- package/agent-tools/batch-gate.mjs +197 -0
- package/agent-tools/batch-lifecycle.mjs +216 -36
- package/agent-tools/batch-skeleton.mjs +87 -0
- package/agent-tools/batch.mjs +11 -3
- package/agent-tools/checkpoint.mjs +11 -6
- package/agent-tools/context.mjs +28 -5
- package/agent-tools/design-slots.mjs +7 -33
- package/agent-tools/doc-check.mjs +50 -7
- package/agent-tools/manifest.mjs +44 -12
- package/agent-tools/spawn-gates.mjs +100 -41
- package/agent-tools/ssh-session.mjs +108 -61
- package/agent-tools/ssh-transport.mjs +17 -4
- package/agent-tools/ssh-vault.mjs +291 -0
- package/agent-tools/subagent-spawn.mjs +52 -30
- package/agent-tools/subagent.mjs +14 -8
- package/agent-tools/timer.mjs +2 -2
- package/agent-tools.mjs +1 -1
- package/config-defaults.mjs +92 -0
- package/config-normalize.mjs +109 -0
- package/config.mjs +88 -197
- package/declaration.mjs +5 -12
- package/doc-check/anchors.mjs +68 -34
- package/doc-check/index.mjs +143 -32
- package/doc-check/version.mjs +84 -0
- package/doc-check/width.mjs +10 -54
- package/i18n.mjs +7 -0
- package/ledger-cmd.mjs +314 -61
- package/ledger-db.mjs +22 -10
- package/ledger-migrate.mjs +15 -9
- package/ledger-tools.mjs +115 -69
- package/ledger.mjs +13 -10
- package/log.mjs +2 -2
- package/manifest-schema.mjs +7 -21
- package/manifest.mjs +4 -4
- package/memory/code-search.mjs +5 -14
- package/memory/docs.mjs +5 -14
- package/memory/read-gate.mjs +26 -0
- package/package.json +1 -1
- package/permission.mjs +7 -1
- package/prompts/advisor-design.md +95 -44
- package/prompts/advisor-round1.md +75 -27
- package/prompts/advisor-round2.md +68 -31
- package/prompts/advisor-round3.md +77 -33
- package/prompts/common.md +283 -113
- package/prompts/consult-base.md +16 -9
- package/prompts/discipline-engineering.md +213 -83
- package/prompts/discipline-normal.md +137 -85
- package/prompts/persona-coder.md +8 -6
- package/prompts/persona-eng-coder.md +42 -21
- package/prompts/persona-eng-designer.md +101 -43
- package/prompts/persona-engineering.md +376 -127
- package/prompts/persona-explore.md +3 -3
- package/prompts/persona-normal.md +29 -21
- package/prompts/persona-plan.md +7 -7
- package/prompts/subagent-base.md +4 -4
- package/provider/anthropic.mjs +4 -2
- package/provider/core.mjs +4 -2
- package/provider/google.mjs +5 -1
- package/provider/responses-request.mjs +3 -2
- package/session-lifecycle.mjs +6 -6
- package/team.mjs +2 -1
- package/token-window.mjs +1 -1
- package/tool-docs/batch.md +1 -1
- package/tool-docs/doc_check.md +47 -12
- package/tool-docs/fetch.md +3 -2
- package/tool-docs/ledger.md +7 -5
- package/tool-docs/manifest-read.md +8 -0
- package/tool-docs/manifest.md +9 -8
- package/tool-docs/ssh.md +23 -6
- package/tool-docs/subagent.md +7 -12
- package/tool-docs/websearch.md +3 -1
- package/tools/ssh.mjs +24 -11
- package/tools/web-deepseek.mjs +172 -0
- package/tools/web.mjs +29 -4
package/tool-docs/subagent.md
CHANGED
|
@@ -7,9 +7,8 @@ ONE tool, NINE actions — pick by what you need:
|
|
|
7
7
|
- action:'escalate' (飞刀): hand an implementation task to a STRONGER model from your consult models; it gets WRITE access and does the work itself, then returns a post-op report (what changed, why, verification). Escalate EARLY, when the task calls for stronger hands (complex refactors, intractable bugs, intricate algorithms — or beyond your comfortable ability). Not available in engineering mode (implementation goes through eng-coder). Default-async at depth 0: ack {id, role:'escalate', status:'running'} returns immediately; the report arrives automatically with its mutations merged into your bookkeeping (pooled with the other role-domain spawns).
|
|
8
8
|
- action:'cancel': STOP ONE background subagent — the id from the spawn return is REQUIRED (a blanket cancel is unsupported; Ctrl+C stops everything). Running aborts immediately ({id, status:'cancelled'}); queued is removed ({id, status:'cancelled', was:'queued'}; later positions shift). Other children and the session keep running. Cancel is a last resort: verify alarming signals with reliable checks (git/node — not guesses) first, and prefer scoped recovery — a running child's in-flight work dies with it, partial changes stay unmerged and unaudited. Background advisor reviews cancel the same way (pass the review id — no token is issued for a cancelled review).
|
|
9
9
|
- action:'consume-design' (engineering mode, parent side): chain-terminal token consumption — after the delivery is verified and the chain closes out, consume this design's token slot. Selectors — pass exactly ONE: designId (a single slot; optional for a single-design session), designIds (an explicit non-empty list), expired:true (every expired slot), or olderThanDays:N (every slot older than N days). A consumed slot mechanically rejects further spawns for the same designId — new work (deviation fixes included) needs a fresh design review and token. Idempotent (unknown/already-consumed = a no-op notice); batch calls report per-slot lines + a `consumed N` count line. Do NOT call while the chain is still open — fix rounds reuse the same slot.
|
|
10
|
-
- action:'design-slots' (engineering mode, parent side): READ-ONLY inventory of this session's approved design token slots — one line per slot (designId, live|expired, ageDays, expiresAt, batch), oldest first, ending with a total/live/expired count line. Nothing is written. Use it to see what still holds a slot before consuming; the expired / olderThanDays selectors act on the same set.
|
|
11
|
-
|
|
12
|
-
One slot at a time — never a bulk dump; expired / malformed slots are masked as designToken=—, a non-boolean reveal is refused, and the recovered designId + designToken go verbatim onto the eng-coder spawn (the gate checks exact equality).
|
|
10
|
+
- action:'design-slots' (engineering mode, parent side): READ-ONLY inventory of this session's approved design token slots — one line per slot (designId, live|expired, ageDays, expiresAt, batch, designToken), oldest first, ending with a total/live/expired count line. Nothing is written. Use it to see what still holds a slot before consuming; the expired / olderThanDays selectors act on the same set.
|
|
11
|
+
Every row carries its designToken value (expired ones included — status marks them; a malformed record shows designToken=—). Pass designId to filter to a single slot (trimmed exact match; unknown = refused with the held id list; empty string = no filter). Take designId + designToken verbatim onto the eng-coder spawn — the gate checks exact equality.
|
|
13
12
|
- action:'panel': DIAGNOSE + fix the subagent panel (the collapsible blocks the user sees; headless/VS Code degrade to a 'no panel' pool view). view (default): the live blocks (key, role, status: running|done|awaitingDigest) exactly as the user sees them — digested-stuck entries carry digested:true. freeze: pass a digested-stuck block key (e.g. 'eng-coder#9' — {action:'panel', freeze:'role#N'}) to reclaim it into the conversation — passes ONLY for awaitingDigest blocks with no live pool entry and no pending report (a still-pending report would break the digestion order — refused).
|
|
14
13
|
|
|
15
14
|
Why delegate? The child's noise never reaches your window — delegation keeps your context lean and the child focused; parallel children save wall-clock time, and coder/eng-coder children self-review (verify + advisor) before their reports arrive.
|
|
@@ -18,18 +17,14 @@ Available roles (exposed per mode; the role is REQUIRED for action:'spawn' — a
|
|
|
18
17
|
- explore — read-only search & analysis (read/search family); no git context — evidence comes from reads and the task book; its report must list what it searched and what it did NOT find. Say quick / medium / thorough (default medium).
|
|
19
18
|
- plan — read-only planning; NEVER edits; returns a step-by-step plan.
|
|
20
19
|
- coder — full implementation; the parent's complete read/write/execute toolset plus verify and advisor self-review. Its report carries a delivery table, one row per requirement (Done / Simplified / Not done — no deferred column). Normal mode: may optionally carry `batchDoc` — binds this spawn's §5 record-write channel (unbound ⇒ the main agent records §5 on its behalf); when provided the path must resolve to a readable file.
|
|
21
|
-
- eng-coder — engineering mode only (replaces coder): same toolset plus the design-driven methodology overlay; its delivery protocol (implementation → audit → self-fix → advisor re-review → converged delivery) runs fully inside the child. REQUIRES a valid designToken from a passed advisor(type='design') review (its reply also echoes a designId — pass it: required to pick between concurrent designs, optional for one; echo it back in the delivery report). REQUIRES a batchDoc — the batch record path, the task book this spawn implements (refused without it or when it does not resolve to a readable file).
|
|
20
|
+
- eng-coder — engineering mode only (replaces coder): same toolset plus the design-driven methodology overlay; its delivery protocol (implementation → audit → self-fix → advisor re-review → converged delivery) runs fully inside the child. REQUIRES a valid designToken from a passed advisor(type='design') review — pass the exact value from that review, or copy it verbatim from the action:'design-slots' row (the text after designToken=): missing or mismatched = the spawn is refused, and an expired token is refused with expired named (its reply also echoes a designId — pass it: required to pick between concurrent designs, optional for one; echo it back in the delivery report). REQUIRES a batchDoc — the batch record path, the task book this spawn implements (refused without it or when it does not resolve to a readable file).
|
|
22
21
|
- eng-designer — engineering mode only: the SOLE author of the requirements/design documents and of the batch record's design section. Writes no implementation code, does not edit prompts, does not fire reviews, needs NO designToken. Surveys on its own; may only spawn read-only explore children (sync, ≤6 per batch). REQUIRES a batchDoc (same gate as eng-coder).
|
|
23
22
|
|
|
24
|
-
Task book (
|
|
23
|
+
Task book (every spawn role): four core structured parameters — goal (goal & why), knownFacts (the facts you already verified), acceptance (machine-verifiable criteria), reportFormat (delivery-report format) — plus ONE fifth parameter named by role: designPoints (design points & forbidden scope) for eng-coder/eng-designer, scope (scope & forbidden scope — what to read / what not to touch) for explore/plan/coder. All five are required: a missing, blank or non-string value is refused with the parameter named, and one role's fifth name never substitutes for the other's. They are composed into the child's brief in that order. A free-text task is an optional supplement, never the whole brief; round (initial|fix) is mandatory for eng-coder/eng-designer. An eng-coder spawn also carries designToken — the write credential (the exact value from the review, or copied verbatim from the action:'design-slots' row, the text after designToken=): missing or mismatched = the spawn is refused.
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
**已知事实**:…
|
|
30
|
-
**设计要点与禁止范围**:…
|
|
31
|
-
**验收标准**:…
|
|
32
|
-
**交付报告格式**:…
|
|
25
|
+
Pass one parameter per field — contents only (the runtime adds the labels):
|
|
26
|
+
subagent(role="eng-coder", round="initial", goal="…", knownFacts="…", designPoints="…", acceptance="…", reportFormat="…", batchDoc="<batch record>", designToken="…")
|
|
27
|
+
subagent(role="explore", goal="…", knownFacts="…", scope="…", acceptance="…", reportFormat="…")
|
|
33
28
|
|
|
34
29
|
Mode filtering: normal mode exposes explore/plan/coder; engineering mode exposes explore/eng-designer/eng-coder — off-mode roles are refused mechanically (enums filtered at assembly; the spawn gate re-checks).
|
|
35
30
|
|
package/tool-docs/websearch.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Search the web
|
|
1
|
+
Search the web (DeepSeek Search when a DeepSeek provider is configured; otherwise Tavily with a key, else Bing). Returns result titles, URLs, and snippets — DeepSeek results also carry a date · authority (0–4) · rerank (0–3) line and a long summary. Use for looking up current information, docs, error messages.
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
4
|
- query (required): Search query
|
|
@@ -14,3 +14,5 @@ Notes:
|
|
|
14
14
|
- Follow up with `fetch` to read full pages from the results
|
|
15
15
|
- **Weak engine warning**: Bing's index is noisy for technical queries — if a first websearch returns irrelevant/townhall-grade results, DO NOT retry the same query. Configure a search MCP tool (e.g. `glm-websearch` via the MCP config) for technical lookups; websearch is the fallback.
|
|
16
16
|
- Proxy: NOT auto-applied from config.json (2026-08-31 ruling). Pass `proxy: "http://host:port"` explicitly when the target needs one; omit for domestic targets.
|
|
17
|
+
- Backend priority: DeepSeek (auto — a providers[] entry with baseURL host api.deepseek.com and a key) → Tavily → Bing. websearch.backend: "local" disables the DeepSeek backend.
|
|
18
|
+
- DeepSeek query operators: site:, lang:, after:/before:, and quoted phrases pass through as-is; limit maps to count (1–20).
|
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
|
|
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
|
-
|
|
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,
|
|
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
|