dowafu 0.1.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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/adapters/anthropic-messages.js +145 -0
- package/dist/adapters/gemini-native.js +114 -0
- package/dist/adapters/responses.js +112 -0
- package/dist/audit.js +123 -0
- package/dist/cli-args.js +138 -0
- package/dist/cli.js +304 -0
- package/dist/cost.js +54 -0
- package/dist/dispatch-home.js +23 -0
- package/dist/dotenv-invariant.js +66 -0
- package/dist/error-classify.js +13 -0
- package/dist/gate.js +37 -0
- package/dist/gitignore-check.js +24 -0
- package/dist/json-output.js +80 -0
- package/dist/mask.js +83 -0
- package/dist/output.js +152 -0
- package/dist/pkg-info.js +35 -0
- package/dist/prompt.js +105 -0
- package/dist/providers.js +151 -0
- package/dist/rate-limit.js +27 -0
- package/dist/raw-integrity.js +49 -0
- package/dist/report.js +101 -0
- package/dist/runner.js +328 -0
- package/dist/secret-env.js +6 -0
- package/dist/semaphore.js +25 -0
- package/dist/ticket.js +156 -0
- package/dist/tool-call-audit.js +19 -0
- package/dist/types.js +11 -0
- package/dist/usage.js +224 -0
- package/dist/validate.js +100 -0
- package/dist/whitelist.js +38 -0
- package/package.json +60 -0
- package/providers.json +84 -0
- package/publish/.agents/skills/find-holes-external/SKILL.md +418 -0
- package/publish/.agents/skills/preflight/SKILL.md +126 -0
- package/publish/.agents/skills/wrap/SKILL.md +65 -0
- package/publish/.claude/agents/explore-haiku.md +8 -0
- package/publish/.claude/agents/hole-finder-cost.md +15 -0
- package/publish/.claude/agents/hole-finder-feasibility.md +15 -0
- package/publish/.claude/agents/hole-finder-safety.md +15 -0
- package/publish/.claude/agents/hole-finder.md +14 -0
- package/publish/.claude/skills/find-holes/SKILL.md +112 -0
- package/publish/.claude/skills/find-holes-external/SKILL.md +434 -0
- package/publish/.claude/skills/preflight/SKILL.md +196 -0
- package/publish/.claude/skills/wrap/SKILL.md +62 -0
- package/publish/README.md +75 -0
- package/publish/workflow_spec.md +65 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §5:providers.json 載入與驗證。fail closed——api 欄缺失或
|
|
2
|
+
// store:true 即中止(exit 2),不套用預設值繼續跑(§10 步驟 3)。
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { DispatchError } from "./types.js";
|
|
5
|
+
function parseReasoning(raw, providerName) {
|
|
6
|
+
if (!raw || typeof raw !== "object") {
|
|
7
|
+
return { style: null, allowed: [] };
|
|
8
|
+
}
|
|
9
|
+
const r = raw;
|
|
10
|
+
const style = r.style;
|
|
11
|
+
if (style !== undefined && style !== "openai" && style !== "deepseek" && style !== "gemini" && style !== "anthropic") {
|
|
12
|
+
throw new DispatchError(`providers.json: ${providerName}.reasoning.style 值不合法:"${String(style)}"`, 2);
|
|
13
|
+
}
|
|
14
|
+
const allowed = Array.isArray(r.allowed) ? r.allowed.filter((v) => typeof v === "string") : [];
|
|
15
|
+
const modelOverrides = r.modelOverrides && typeof r.modelOverrides === "object"
|
|
16
|
+
? r.modelOverrides
|
|
17
|
+
: undefined;
|
|
18
|
+
// §5:default 是「工單 effort 留白時實際送出的值」——不得回退到「不送參數」。
|
|
19
|
+
// allowed 非空時必填,且須在 allowed 內;allowed 為空時 default 不存在(該 provider
|
|
20
|
+
// 尚未驗證、不可用,連 default 都無從指定)。
|
|
21
|
+
let def;
|
|
22
|
+
if (r.default !== undefined) {
|
|
23
|
+
if (typeof r.default !== "string") {
|
|
24
|
+
throw new DispatchError(`providers.json: ${providerName}.reasoning.default 須為字串`, 2);
|
|
25
|
+
}
|
|
26
|
+
def = r.default;
|
|
27
|
+
}
|
|
28
|
+
if (allowed.length > 0 && def === undefined) {
|
|
29
|
+
throw new DispatchError(`providers.json: ${providerName}.reasoning.default 缺失——allowed 非空時必填,不得回退到「不送參數」`, 2);
|
|
30
|
+
}
|
|
31
|
+
if (def !== undefined && !allowed.includes(def)) {
|
|
32
|
+
throw new DispatchError(`providers.json: ${providerName}.reasoning.default "${def}" 不在 allowed 內(${allowed.length > 0 ? allowed.join(", ") : "(空)"})`, 2);
|
|
33
|
+
}
|
|
34
|
+
return { style: style ?? null, allowed, default: def, modelOverrides };
|
|
35
|
+
}
|
|
36
|
+
// plan_fixes_v1.0.md §4:pricing 為選填——缺席的 provider/模型無法估算成本(cost.ts
|
|
37
|
+
// 回傳 null,不是 0),不強制每個 provider 都要有價目。有填就驗完整,格式錯即中止
|
|
38
|
+
// (fail closed,與本檔其餘欄位一致),不悄悄忽略壞掉的價目導致成本估算安靜地錯。
|
|
39
|
+
function parsePositiveNumber(v, label) {
|
|
40
|
+
if (typeof v !== "number" || !(v > 0)) {
|
|
41
|
+
throw new DispatchError(`providers.json: ${label} 須為正數,實際為 ${JSON.stringify(v)}`, 2);
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
function parsePricing(raw, providerName) {
|
|
46
|
+
if (raw === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (!raw || typeof raw !== "object") {
|
|
49
|
+
throw new DispatchError(`providers.json: ${providerName}.pricing 不是物件`, 2);
|
|
50
|
+
}
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const [model, entry] of Object.entries(raw)) {
|
|
53
|
+
if (!entry || typeof entry !== "object") {
|
|
54
|
+
throw new DispatchError(`providers.json: ${providerName}.pricing.${model} 不是物件`, 2);
|
|
55
|
+
}
|
|
56
|
+
const e = entry;
|
|
57
|
+
out[model] = {
|
|
58
|
+
inputPerM: parsePositiveNumber(e.inputPerM, `${providerName}.pricing.${model}.inputPerM`),
|
|
59
|
+
outputPerM: parsePositiveNumber(e.outputPerM, `${providerName}.pricing.${model}.outputPerM`),
|
|
60
|
+
...(e.cachedInputPerM !== undefined
|
|
61
|
+
? { cachedInputPerM: parsePositiveNumber(e.cachedInputPerM, `${providerName}.pricing.${model}.cachedInputPerM`) }
|
|
62
|
+
: {}),
|
|
63
|
+
...(e.cacheWritePerM !== undefined
|
|
64
|
+
? { cacheWritePerM: parsePositiveNumber(e.cacheWritePerM, `${providerName}.pricing.${model}.cacheWritePerM`) }
|
|
65
|
+
: {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function parseProviderConfig(name, raw) {
|
|
71
|
+
if (!raw || typeof raw !== "object") {
|
|
72
|
+
throw new DispatchError(`providers.json: provider "${name}" 的設定不是物件`, 2);
|
|
73
|
+
}
|
|
74
|
+
const r = raw;
|
|
75
|
+
// §5:api 欄無預設值,介面選擇不容猜測——缺失即中止。
|
|
76
|
+
if (r.api !== "responses" && r.api !== "gemini-native" && r.api !== "anthropic-messages") {
|
|
77
|
+
throw new DispatchError(`providers.json: ${name}.api 缺失或不合法(須為 "responses"、"gemini-native" 或 "anthropic-messages"),實際為 ${JSON.stringify(r.api)}`, 2);
|
|
78
|
+
}
|
|
79
|
+
// §5:store 不可為 true——違反設計原則 6(零留存)即中止。
|
|
80
|
+
if (r.store === true) {
|
|
81
|
+
throw new DispatchError(`providers.json: ${name}.store 為 true,違反設計原則 6(零留存)。載入即中止,不得依賴伺服器端狀態。`, 2);
|
|
82
|
+
}
|
|
83
|
+
const store = r.store === null ? null : false; // 未填視為 false
|
|
84
|
+
if (typeof r.baseURL !== "string" || r.baseURL.length === 0) {
|
|
85
|
+
throw new DispatchError(`providers.json: ${name}.baseURL 缺失`, 2);
|
|
86
|
+
}
|
|
87
|
+
// §5:models 白名單。未填(或非陣列)視為空陣列 = 不做型號檢查。
|
|
88
|
+
const models = Array.isArray(r.models) ? r.models.filter((v) => typeof v === "string") : [];
|
|
89
|
+
// §5:charsPerToken。未填時用 CLI 全域值——保留 null,不在此處套 1.0(CLI 端才知道
|
|
90
|
+
// 使用者傳入的 --chars-per-token 是多少)。
|
|
91
|
+
if (r.charsPerToken !== undefined && (typeof r.charsPerToken !== "number" || !(r.charsPerToken > 0))) {
|
|
92
|
+
throw new DispatchError(`providers.json: ${name}.charsPerToken 須為正數`, 2);
|
|
93
|
+
}
|
|
94
|
+
const charsPerToken = typeof r.charsPerToken === "number" ? r.charsPerToken : null;
|
|
95
|
+
return {
|
|
96
|
+
baseURL: r.baseURL,
|
|
97
|
+
api: r.api,
|
|
98
|
+
store,
|
|
99
|
+
toolCalling: r.toolCalling === true,
|
|
100
|
+
reasoning: parseReasoning(r.reasoning, name),
|
|
101
|
+
models,
|
|
102
|
+
charsPerToken,
|
|
103
|
+
tpmLimit: typeof r.tpmLimit === "number" ? r.tpmLimit : null,
|
|
104
|
+
maxSpokeTokens: typeof r.maxSpokeTokens === "number" ? r.maxSpokeTokens : null,
|
|
105
|
+
pricing: parsePricing(r.pricing, name),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// plan_dispatch_v1.10.md §24.3:providers.json 隨工具出貨,不可覆寫(方案 D)。
|
|
109
|
+
// formatVersion 沿用 `_dispatch.md` 首行 `<!-- format: v1 -->` 的既有做法——版本檢查
|
|
110
|
+
// 在載入時做,不符即中止,防跨版本漂移(一份舊版 providers.json 寫著已證實不生效的
|
|
111
|
+
// 參數路徑,可能回顯、不報錯,行為完全不受控,facts_dispatch.md 2026-08-06 已有實例)。
|
|
112
|
+
export const PROVIDERS_FORMAT_VERSION = 1;
|
|
113
|
+
export function parseProvidersFile(json) {
|
|
114
|
+
if (!json || typeof json !== "object") {
|
|
115
|
+
throw new DispatchError("providers.json 格式不是物件", 2);
|
|
116
|
+
}
|
|
117
|
+
const { formatVersion, ...providerEntries } = json;
|
|
118
|
+
if (formatVersion !== PROVIDERS_FORMAT_VERSION) {
|
|
119
|
+
throw new DispatchError(`providers.json: formatVersion 不符(預期 ${PROVIDERS_FORMAT_VERSION},實際 ${JSON.stringify(formatVersion)})。` +
|
|
120
|
+
`這通常代表 --providers 指向了舊版或不相容的檔案。`, 2);
|
|
121
|
+
}
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const [name, raw] of Object.entries(providerEntries)) {
|
|
124
|
+
out[name] = parseProviderConfig(name, raw);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
export async function loadProviders(filePath) {
|
|
129
|
+
let text;
|
|
130
|
+
try {
|
|
131
|
+
text = await readFile(filePath, "utf8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
throw new DispatchError(`找不到 providers.json:${filePath}`, 2);
|
|
135
|
+
}
|
|
136
|
+
let json;
|
|
137
|
+
try {
|
|
138
|
+
json = JSON.parse(text);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
throw new DispatchError(`providers.json 不是合法 JSON:${err.message}`, 2);
|
|
142
|
+
}
|
|
143
|
+
return parseProvidersFile(json);
|
|
144
|
+
}
|
|
145
|
+
export function getProviderConfig(providers, name) {
|
|
146
|
+
const config = providers[name];
|
|
147
|
+
if (!config) {
|
|
148
|
+
throw new DispatchError(`providers.json 未定義 provider "${name}"`, 2);
|
|
149
|
+
}
|
|
150
|
+
return config;
|
|
151
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §13:429 等待秒數的三段 fallback。任一階段解析失敗即落入下一段,
|
|
2
|
+
// 不猜測。純函式,供單元測試(本節三段 fallback 完全未經實測,見 facts_dispatch.md)。
|
|
3
|
+
const BACKOFF_SECONDS = [2, 4, 8, 16, 32];
|
|
4
|
+
export function parseRetryAfter(headerValue, messageText, attemptIndex, // 第幾次撞牆(0-based),決定退避秒數
|
|
5
|
+
now = Date.now()) {
|
|
6
|
+
if (headerValue) {
|
|
7
|
+
const trimmed = headerValue.trim();
|
|
8
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
9
|
+
return { seconds: Number(trimmed), source: "header" };
|
|
10
|
+
}
|
|
11
|
+
const parsed = Date.parse(trimmed);
|
|
12
|
+
if (!Number.isNaN(parsed)) {
|
|
13
|
+
const diffSeconds = (parsed - now) / 1000;
|
|
14
|
+
if (diffSeconds > 0) {
|
|
15
|
+
return { seconds: diffSeconds, source: "header" };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (messageText) {
|
|
20
|
+
const match = messageText.match(/(\d+(?:\.\d+)?)\s*s(?:ec(?:onds?)?)?\b/i);
|
|
21
|
+
if (match) {
|
|
22
|
+
return { seconds: Number(match[1]), source: "message" };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const index = Math.max(0, Math.min(attemptIndex, BACKOFF_SECONDS.length - 1));
|
|
26
|
+
return { seconds: BACKOFF_SECONDS[index], source: "backoff" };
|
|
27
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// plan_dispatch_v1.5.md §8「Adapter 的自我檢查:raw 完整性」。
|
|
2
|
+
//
|
|
3
|
+
// 送出請求前,adapter 必須驗證 conversation 中每個 assistant turn 的 raw 都完整出現在
|
|
4
|
+
// 該請求裡;不符即拋錯,標為實作缺陷(非 API 錯誤)。理由:實測(facts Test C)證實
|
|
5
|
+
// 「漏帶整個 item」不會被 API 偵測——模型照樣完成,只是靜默失去推理連續性,唯一線索是
|
|
6
|
+
// reasoning_tokens 異常偏低,不會進 errors[]。竄改會 400,漏帶不會;而漏帶恰好是過濾條件
|
|
7
|
+
// 寫錯一行就會發生的那種錯誤。偵測責任因此不能放在 API 回饋上,必須是 adapter 自己主動驗證。
|
|
8
|
+
//
|
|
9
|
+
// 兩種 raw 形狀,對應兩個 adapter:
|
|
10
|
+
// - responses(openai/deepseek):assistant turn 的 raw 是「陣列」(該輪 response.output
|
|
11
|
+
// 整包),續接時逐一展開塞進 input 陣列——檢查「陣列中每個 item 是否都在 input 裡」。
|
|
12
|
+
// - gemini-native:assistant turn 的 raw 是「單一物件」({role:"model", parts:[...]}),
|
|
13
|
+
// 續接時整個物件原樣放回 contents 陣列——檢查「這個物件是否整個出現在 contents 裡」。
|
|
14
|
+
//
|
|
15
|
+
// 判準是參照相等(===),不是結構相等:兩個 adapter 目前都是直接把 turn.raw 原樣塞入
|
|
16
|
+
// 請求,不做任何轉換,故參照相等是正確且唯一有意義的比對方式——它驗證的是「真的是同一個
|
|
17
|
+
// 物件被傳過去」,而非「長得像的東西存在」,後者會掩蓋掉真正的 bug(例如重新建構出一個
|
|
18
|
+
// 內容相似但簽章欄位被序列化過程改寫的複製品)。若未來 adapter 需要對 raw 做任何正規化,
|
|
19
|
+
// 這個判準本身就需要重新設計——那是規格問題,不是這裡的實作可以自行決定的。
|
|
20
|
+
export class RawIntegrityError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "RawIntegrityError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function checkRawArrayIntegrity(conv, builtInput) {
|
|
27
|
+
for (const turn of conv.turns) {
|
|
28
|
+
if (turn.role !== "assistant")
|
|
29
|
+
continue;
|
|
30
|
+
if (!Array.isArray(turn.raw)) {
|
|
31
|
+
throw new RawIntegrityError("raw 完整性檢查失敗:assistant turn 的 raw 不是陣列(responses adapter 預期 raw 為上一輪 response.output 陣列)");
|
|
32
|
+
}
|
|
33
|
+
for (const item of turn.raw) {
|
|
34
|
+
if (!builtInput.includes(item)) {
|
|
35
|
+
const type = item?.type ?? "?";
|
|
36
|
+
throw new RawIntegrityError(`raw 完整性檢查失敗:assistant turn 有一個 item(type=${type})未以原樣出現在送出的請求中,疑似續接時被過濾掉`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function checkRawObjectIntegrity(conv, builtContents) {
|
|
42
|
+
for (const turn of conv.turns) {
|
|
43
|
+
if (turn.role !== "assistant")
|
|
44
|
+
continue;
|
|
45
|
+
if (!builtContents.includes(turn.raw)) {
|
|
46
|
+
throw new RawIntegrityError("raw 完整性檢查失敗:assistant turn 的 raw 未以原樣出現在送出的 contents 中,疑似續接時被重建或漏帶");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §11:派工報表(呼叫前)。模型錯了、effort 不如預期、介面走錯、
|
|
2
|
+
// 成本量級不對,在送出前就看得到。不使用 ✓/✗ 標記——TPM 是滑動窗,429 等待會動態改變
|
|
3
|
+
// 實際並行數,靜態估算無法精確預測。
|
|
4
|
+
export function effectiveCap(spoke, cli) {
|
|
5
|
+
return spoke.providerConfig.maxSpokeTokens ?? cli.maxSpokeTokens;
|
|
6
|
+
}
|
|
7
|
+
export function effectiveCharsPerToken(spoke, cli) {
|
|
8
|
+
return spoke.providerConfig.charsPerToken ?? cli.charsPerToken;
|
|
9
|
+
}
|
|
10
|
+
function formatProvidersSource(src) {
|
|
11
|
+
if (src.kind === "bundled")
|
|
12
|
+
return `出貨(formatVersion ${src.formatVersion})`;
|
|
13
|
+
return `外部檔 ${src.path}(formatVersion ${src.formatVersion})`;
|
|
14
|
+
}
|
|
15
|
+
// v1.10 §10:三態不得合併——「未忽略」是需要處理的狀態,「無法判定」不是,措辭須區分。
|
|
16
|
+
//
|
|
17
|
+
// hub 驗收(2026-08-06):措辭原寫「未被目標專案的 .gitignore 涵蓋」,但 v1.11 §24.5
|
|
18
|
+
// 已訂輸出目錄屬呼叫端 cwd,checkGitignore 檢查的也是 cwd 所屬的 repo——cwd 與
|
|
19
|
+
// --repo-root 指的目標專案是兩回事(v1.11 §10 修的正是這個基準不一致)。「目標專案」
|
|
20
|
+
// 這個詞在 cwd ≠ repoRoot 時會指錯對象,故改為不預設兩者相同的措辭。
|
|
21
|
+
function formatGitignoreWarning(status, outDir) {
|
|
22
|
+
if (status === "ignored")
|
|
23
|
+
return null;
|
|
24
|
+
if (status === "not_ignored") {
|
|
25
|
+
return ` ⚠ 輸出目錄 ${outDir} 未被輸出目錄所在的 git repo 忽略`;
|
|
26
|
+
}
|
|
27
|
+
return ` ℹ 無法判定輸出目錄 ${outDir} 是否被 .gitignore 涵蓋(非 git repo 或 git 不可用)`;
|
|
28
|
+
}
|
|
29
|
+
export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli, outDir, meta) {
|
|
30
|
+
const estByAgent = new Map(estimates.map((e) => [e.agent, e.estimatedTokens]));
|
|
31
|
+
const lines = [`即將派工 ${ticketId}:`];
|
|
32
|
+
// v1.10 §11:repoRoot 明列於報表,與 store 同性質的可見性保證——白名單是整套隔離的
|
|
33
|
+
// 唯一支點(§7),其邊界由「從哪個目錄下指令」決定,這件事在報表上必須看得見,否則
|
|
34
|
+
// 使用者無從發現自己在錯的目錄下跑。
|
|
35
|
+
lines.push(` repoRoot ${meta.repoRoot}`);
|
|
36
|
+
lines.push(` providers ${formatProvidersSource(meta.providersSource)}`);
|
|
37
|
+
for (const spoke of spokes) {
|
|
38
|
+
const est = estByAgent.get(spoke.agent) ?? 0;
|
|
39
|
+
const cap = effectiveCap(spoke, cli);
|
|
40
|
+
lines.push(` ${spoke.agent.padEnd(20)} → ${spoke.provider.padEnd(8)} / ${spoke.model.padEnd(20)} ` +
|
|
41
|
+
`[${spoke.providerConfig.api}] effort=${spoke.effort ?? "—"} lang=${spoke.lang} ` +
|
|
42
|
+
`store=${spoke.providerConfig.store === false ? "false" : "n/a"} ` +
|
|
43
|
+
`est. ${est.toLocaleString()} cap ${cap.toLocaleString()}`);
|
|
44
|
+
}
|
|
45
|
+
const totalEst = estimates.reduce((s, e) => s + e.estimatedTokens, 0);
|
|
46
|
+
const worstTotal = spokes.reduce((s, spoke) => s + effectiveCap(spoke, cli), 0);
|
|
47
|
+
// plan_fixes_v1.0.md §3:舊標籤「合計初始估算」被外部 hub 誤讀成整輪派工的成本估算——
|
|
48
|
+
// 這個數字只涵蓋 system prompt + buildFirstUserText() 產出的第一則訊息,不含工單與
|
|
49
|
+
// 允許清單本身(那些是 spoke 自己用 tool call 讀進去的)。實測差距 27 倍(2,593 vs
|
|
50
|
+
// 71,482)。標籤明講範圍,並緊接印出允許清單總量,讓兩個數字一起出現、不必自己去找。
|
|
51
|
+
lines.push(` 初始 prompt 估算 ${totalEst.toLocaleString()} tokens(僅 system prompt+首則訊息,不含工單與允許清單;本閘門的估算上限 ${cli.maxTokens.toLocaleString()})`);
|
|
52
|
+
// §14:獨立於閘門一之外,只呈現不設閘門——閘門一不含允許清單與工單內容(issue_log_v2.0.md
|
|
53
|
+
// 2026-08-07),此數字才是「若整個允許清單都被讀完」的量級參考,門檻待樣本累積後再定。
|
|
54
|
+
const totalAllowlistTokens = allowlistEstimates.reduce((s, e) => s + e.estimatedTokens, 0);
|
|
55
|
+
const totalAllowlistFiles = allowlistEstimates.reduce((s, e) => s + e.fileCount, 0);
|
|
56
|
+
lines.push(` 允許清單總量估算 ${totalAllowlistTokens.toLocaleString()} tokens(${totalAllowlistFiles} 檔)`);
|
|
57
|
+
// plan_dispatch_v2.4.md §14:口徑說明——避免此數字被誤讀為預期消耗。三件事:
|
|
58
|
+
// (1) 字元數的上限估計,不是預期消耗;(2) 不去重,同一檔出現在多支清單會重複計入
|
|
59
|
+
// (正確行為:兩支各讀一次就是兩份成本);(3) 實測程式碼素材約 3.5 字元/token,
|
|
60
|
+
// 故實際消耗通常遠低於此數(charsPerToken 假設 1.0,係數本身刻意不動,見 §14)。
|
|
61
|
+
lines.push(` └ 上限估計,不去重;實測程式碼素材約 3.5 字元/token,實際消耗通常遠低於此數`);
|
|
62
|
+
// issue_log_v2.1.md(第 8、9 次):多數模型一輪只叫一個檔,每輪重送全部歷史,故清單靠前
|
|
63
|
+
// 的檔會被重複計費多次。這一行是「順序造成的放大量」,也是唯一 hub 改得動的成本槓桿——
|
|
64
|
+
// 只印排序能省的部分,不印預估總量:總量還受「批次讀 vs 逐個讀」影響(模型決定,我們
|
|
65
|
+
// 控制不了),印出來會被當成預期值。省下的百分比則不受 charsPerToken 偏差影響(分子分母
|
|
66
|
+
// 抵銷),是這裡唯一可信的絕對數字。
|
|
67
|
+
const seqAsListed = allowlistEstimates.reduce((s, e) => s + e.sequential.asListed, 0);
|
|
68
|
+
const seqSorted = allowlistEstimates.reduce((s, e) => s + e.sequential.sorted, 0);
|
|
69
|
+
if (seqAsListed > 0) {
|
|
70
|
+
const savedPct = Math.round(((seqAsListed - seqSorted) / seqAsListed) * 100);
|
|
71
|
+
lines.push(` 逐個讀的順序放大量 ${seqAsListed.toLocaleString()} tokens(清單內容被重送的總量)`);
|
|
72
|
+
if (savedPct >= 5) {
|
|
73
|
+
lines.push(` └ ⚠ 大檔排清單最後可降至 ${seqSorted.toLocaleString()}(本項省 ${savedPct}%)`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
lines.push(` └ 目前順序已接近最佳(重排最多再省 ${savedPct}%)`);
|
|
77
|
+
}
|
|
78
|
+
lines.push(` 本項不含初始 prompt 與工單(不受排序影響),故總成本的節省比例低於此數`);
|
|
79
|
+
}
|
|
80
|
+
lines.push(` 最壞總消耗 ≈ ${worstTotal.toLocaleString()} tokens(各 spoke 之 cap 加總)`);
|
|
81
|
+
lines.push(` 並行度 ${cli.concurrency}`);
|
|
82
|
+
const byProvider = new Map();
|
|
83
|
+
for (const spoke of spokes) {
|
|
84
|
+
const est = estByAgent.get(spoke.agent) ?? 0;
|
|
85
|
+
const entry = byProvider.get(spoke.provider) ?? { tpmLimit: spoke.providerConfig.tpmLimit, peak: 0 };
|
|
86
|
+
entry.peak += est;
|
|
87
|
+
byProvider.set(spoke.provider, entry);
|
|
88
|
+
}
|
|
89
|
+
for (const [provider, { tpmLimit, peak }] of byProvider) {
|
|
90
|
+
if (tpmLimit === null)
|
|
91
|
+
continue;
|
|
92
|
+
lines.push(` ${provider} tpmLimit ${tpmLimit.toLocaleString()},靜態估算峰值 ${peak.toLocaleString()}`);
|
|
93
|
+
lines.push(` └ 僅為靜態指標,不預測執行中的 TPM 曲線(429 等待會改變實際並行數)`);
|
|
94
|
+
}
|
|
95
|
+
const allowedCount = spokes.reduce((s, spoke) => s + spoke.allowedReadsResolved.length, 0);
|
|
96
|
+
lines.push(` 允許讀取 ${allowedCount} 個檔案,輸出至 ${outDir}/`);
|
|
97
|
+
const gitignoreWarning = formatGitignoreWarning(meta.gitignoreStatus, outDir);
|
|
98
|
+
if (gitignoreWarning)
|
|
99
|
+
lines.push(gitignoreWarning);
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §13/§14:單一 spoke 的 tool-use 迴圈、runtime 閘門二(累積 token
|
|
2
|
+
// 上限)、429 等待、逾時、重試、收束呼叫。狀態機見 §13。
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { buildFinalizeUserText, buildFirstUserText, buildSystemPrompt } from "./prompt.js";
|
|
6
|
+
import { ALLOWLIST_REJECT_MESSAGE, checkAllowlist } from "./whitelist.js";
|
|
7
|
+
import { findUnknownUsageKeys, sumUsage, usageProviderKeyFor } from "./usage.js";
|
|
8
|
+
import { estimateCostUsd } from "./cost.js";
|
|
9
|
+
import { describeError, ProviderHttpError } from "./mask.js";
|
|
10
|
+
import { parseRetryAfter } from "./rate-limit.js";
|
|
11
|
+
import { RawIntegrityError } from "./raw-integrity.js";
|
|
12
|
+
import { classifyError } from "./error-classify.js";
|
|
13
|
+
const MAX_FILE_BYTES = 200 * 1024; // §13:單檔 200KB 上限,防「單輪讀入巨檔」異常
|
|
14
|
+
const MAX_ROUNDS = 60; // 安全上限,遠高於實務上的 --max-tool-calls,純防無窮迴圈 bug
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
async function executeToolCall(call, spoke, repoRoot) {
|
|
19
|
+
const requestedPath = typeof call.args.path === "string" ? call.args.path : "";
|
|
20
|
+
const startedAt = Date.now();
|
|
21
|
+
const check = checkAllowlist(spoke.allowSet, requestedPath, repoRoot);
|
|
22
|
+
if (!check.allowed) {
|
|
23
|
+
return {
|
|
24
|
+
resultText: ALLOWLIST_REJECT_MESSAGE,
|
|
25
|
+
log: { path: requestedPath, allowed: false, reason: check.reason, startedAt, durationMs: Date.now() - startedAt },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const buf = await readFile(check.realPath);
|
|
30
|
+
let content = buf.toString("utf8");
|
|
31
|
+
if (buf.byteLength > MAX_FILE_BYTES) {
|
|
32
|
+
content = buf.subarray(0, MAX_FILE_BYTES).toString("utf8") + "\n...(檔案超過 200KB 上限,內容已截斷)";
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
resultText: content,
|
|
36
|
+
log: { path: requestedPath, allowed: true, startedAt, durationMs: Date.now() - startedAt },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {
|
|
41
|
+
resultText: ALLOWLIST_REJECT_MESSAGE,
|
|
42
|
+
log: { path: requestedPath, allowed: false, reason: "not_found", startedAt, durationMs: Date.now() - startedAt },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function sendWithResilience(adapter, conv, sendOpts, cfg, ctx) {
|
|
47
|
+
let generalAttempt = 0;
|
|
48
|
+
let rateLimitAttempt = 0;
|
|
49
|
+
// §6:每次錯誤都記(不只終局那次)——中斷或失敗時完全沒有線索的問題,源頭是逐次錯誤
|
|
50
|
+
// 從未落過檔,不是只有最後一次。message/errorBody 已在 describeError 內遮蔽。
|
|
51
|
+
const recordError = (err, message, status, errorBody) => {
|
|
52
|
+
ctx.errors.push(message);
|
|
53
|
+
const request = err instanceof ProviderHttpError ? err.request : undefined;
|
|
54
|
+
ctx.rawErrors.push({ round: ctx.round, status, message, errorBody, request });
|
|
55
|
+
ctx.onEvent({ type: "round_error", agent: ctx.agent, round: ctx.round, status, message });
|
|
56
|
+
};
|
|
57
|
+
for (;;) {
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs);
|
|
60
|
+
try {
|
|
61
|
+
const result = await adapter.send(conv, { ...sendOpts, signal: controller.signal });
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
return { ok: true, result };
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
if (err instanceof RawIntegrityError) {
|
|
68
|
+
// §8:raw 完整性違反是實作缺陷,不是 API 錯誤——重試只會確定性地再次觸發同一個
|
|
69
|
+
// bug(下一輪的 conv 結構仍帶著同樣的漏洞),不消耗任何網路呼叫也不可能成功。
|
|
70
|
+
// 立即判定失敗,不進入一般失敗的重試計數。
|
|
71
|
+
recordError(err, `raw 完整性檢查失敗(實作缺陷,不重試):${err.message}`, null);
|
|
72
|
+
return { ok: false, kind: "failed" };
|
|
73
|
+
}
|
|
74
|
+
const info = describeError(err);
|
|
75
|
+
if (info.is429) {
|
|
76
|
+
rateLimitAttempt++;
|
|
77
|
+
if (rateLimitAttempt > cfg.rateLimitRetries) {
|
|
78
|
+
recordError(err, `429 撞牆次數超過 --rate-limit-retries (${cfg.rateLimitRetries})`, info.status ?? 429, info.errorBody);
|
|
79
|
+
return { ok: false, kind: "rate_limited" };
|
|
80
|
+
}
|
|
81
|
+
const { seconds, source } = parseRetryAfter(info.retryAfterHeader, info.message, rateLimitAttempt - 1);
|
|
82
|
+
if (seconds > cfg.maxRateWaitSec) {
|
|
83
|
+
recordError(err, `429 要求等待 ${seconds}s,超過 --max-rate-wait ${cfg.maxRateWaitSec}s`, info.status ?? 429, info.errorBody);
|
|
84
|
+
return { ok: false, kind: "rate_limited" };
|
|
85
|
+
}
|
|
86
|
+
ctx.rateLimitHits.push({ at: Date.now(), waitSeconds: seconds, source });
|
|
87
|
+
ctx.onEvent({ type: "rate_limit_wait", agent: ctx.agent, seconds, source });
|
|
88
|
+
// §13:429 等待期間釋放 --concurrency 名額,結束後重新取得
|
|
89
|
+
cfg.semaphore.release();
|
|
90
|
+
await sleep(seconds * 1000);
|
|
91
|
+
ctx.addWaitedMs(seconds * 1000);
|
|
92
|
+
await cfg.semaphore.acquire();
|
|
93
|
+
continue; // 不計入 retries
|
|
94
|
+
}
|
|
95
|
+
// §13:錯誤分類依 HTTP status code,不解析錯誤訊息字串。確定性錯誤(其他 4xx)
|
|
96
|
+
// 重試必然再撞同一個錯,不消耗 --retries、直接判定失敗。
|
|
97
|
+
if (classifyError(info.status) === "permanent") {
|
|
98
|
+
recordError(err, info.message ?? String(err), info.status ?? null, info.errorBody);
|
|
99
|
+
return { ok: false, kind: "failed" };
|
|
100
|
+
}
|
|
101
|
+
generalAttempt++;
|
|
102
|
+
recordError(err, info.message ?? String(err), info.status ?? null, info.errorBody);
|
|
103
|
+
if (generalAttempt > cfg.retries) {
|
|
104
|
+
return { ok: false, kind: "failed" };
|
|
105
|
+
}
|
|
106
|
+
await sleep(generalAttempt === 1 ? 1000 : 4000);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export async function runSpoke(spoke, adapter, ticketDir, options) {
|
|
111
|
+
const startedAt = Date.now();
|
|
112
|
+
const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
|
|
113
|
+
const firstUserText = buildFirstUserText(path.resolve(ticketDir), spoke.agent, spoke.allowedReadsRelative, options.repoRoot, spoke.lang);
|
|
114
|
+
const conv = { systemPrompt, turns: [{ role: "user", text: firstUserText }] };
|
|
115
|
+
let toolCallCount = 0;
|
|
116
|
+
let cumulativeUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, available: true };
|
|
117
|
+
const toolCalls = [];
|
|
118
|
+
const rateLimitHits = [];
|
|
119
|
+
const errors = [];
|
|
120
|
+
const rawRequests = [];
|
|
121
|
+
const rawResponses = [];
|
|
122
|
+
const rawErrors = [];
|
|
123
|
+
let waitedMs = 0;
|
|
124
|
+
let modelReturned = null;
|
|
125
|
+
let requestId = null;
|
|
126
|
+
let store = "unknown";
|
|
127
|
+
let finishReason = null;
|
|
128
|
+
let finishReasonRaw = null;
|
|
129
|
+
let finalText = null;
|
|
130
|
+
let status = "succeeded";
|
|
131
|
+
let budgetTrigger;
|
|
132
|
+
let finalizeMode = false;
|
|
133
|
+
let attempts = 0;
|
|
134
|
+
const unknownUsageKeys = [];
|
|
135
|
+
const seenUnknownUsageKeys = new Set();
|
|
136
|
+
// plan_dispatch_v2.7.md §29 規格七之二:改用 usage.ts 的窮盡式對照表,不再用三元運算子
|
|
137
|
+
// 猜——三元運算子的「其餘」分支會讓新 provider 靜默落到既有某一家的允許清單,型別檢查
|
|
138
|
+
// 抓不到(同一份 commit 之外的第二個「新增 provider 忘記同步」實例,見 createAdapterFor)。
|
|
139
|
+
const unknownUsageProviderKey = usageProviderKeyFor(spoke.providerConfig.api);
|
|
140
|
+
const estimatedPromptTokens = Math.ceil((systemPrompt.length + firstUserText.length) / options.charsPerToken);
|
|
141
|
+
options.onEvent({ type: "spoke_start", agent: spoke.agent, provider: spoke.provider, model: spoke.model });
|
|
142
|
+
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
|
143
|
+
attempts++;
|
|
144
|
+
const outcome = await sendWithResilience(adapter, conv, { model: spoke.model, effort: spoke.effort, enableTools: !finalizeMode }, {
|
|
145
|
+
timeoutMs: options.timeoutMs,
|
|
146
|
+
retries: finalizeMode ? 0 : options.retries, // §13:收束呼叫一般失敗不重試
|
|
147
|
+
rateLimitRetries: options.rateLimitRetries,
|
|
148
|
+
maxRateWaitSec: options.maxRateWaitSec,
|
|
149
|
+
semaphore: options.semaphore,
|
|
150
|
+
}, {
|
|
151
|
+
agent: spoke.agent,
|
|
152
|
+
round,
|
|
153
|
+
errors,
|
|
154
|
+
rawErrors,
|
|
155
|
+
rateLimitHits,
|
|
156
|
+
onEvent: options.onEvent,
|
|
157
|
+
addWaitedMs: (ms) => {
|
|
158
|
+
waitedMs += ms;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
if (!outcome.ok) {
|
|
162
|
+
const hasContent = rawResponses.length > 0;
|
|
163
|
+
if (outcome.kind === "rate_limited") {
|
|
164
|
+
status = hasContent ? "truncated:rate_limit" : "failed";
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// v1.8 §13:「終局」只指不再重試(finalizeMode 已在呼叫端把 retries 傳成 0),
|
|
168
|
+
// 不指狀態標記——狀態一律依判定原則(有無已付費取得的內容),與是否為收束呼叫
|
|
169
|
+
// 無關。v1.6 曾把兩者混為一談(!finalizeMode && hasContent),造成收束呼叫遇
|
|
170
|
+
// 一般失敗時,即使前面幾輪已有內容,也一律回 failed,與 429 路徑的對稱處置不一致。
|
|
171
|
+
status = hasContent ? "truncated:error" : "failed";
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
const { result } = outcome;
|
|
176
|
+
rawRequests.push(result.request);
|
|
177
|
+
rawResponses.push(result.response);
|
|
178
|
+
modelReturned = result.meta.modelReturned;
|
|
179
|
+
requestId = result.meta.requestId;
|
|
180
|
+
store = result.meta.store;
|
|
181
|
+
finishReason = result.meta.finishReason;
|
|
182
|
+
finishReasonRaw = result.meta.finishReasonRaw;
|
|
183
|
+
cumulativeUsage = sumUsage(cumulativeUsage, result.usage);
|
|
184
|
+
// v2.6 §26 規格六:偵測器不得有能力弄死主流程——包 try/catch,失敗時視同無新發現,
|
|
185
|
+
// 靜默跳過該輪(偵測器是輔助,不是派工能否成立的條件)。
|
|
186
|
+
try {
|
|
187
|
+
const detected = findUnknownUsageKeys(unknownUsageProviderKey, result.usageRaw);
|
|
188
|
+
const newKeys = detected.filter((k) => !seenUnknownUsageKeys.has(k));
|
|
189
|
+
if (newKeys.length > 0) {
|
|
190
|
+
newKeys.forEach((k) => seenUnknownUsageKeys.add(k));
|
|
191
|
+
unknownUsageKeys.push(...newKeys);
|
|
192
|
+
options.onEvent({ type: "unknown_usage_keys", agent: spoke.agent, keys: newKeys, round });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// 規格六:靜默跳過,不影響派工
|
|
197
|
+
}
|
|
198
|
+
const toolCallsThisRound = result.turn.role === "assistant" ? result.turn.toolCalls : [];
|
|
199
|
+
options.onEvent({
|
|
200
|
+
type: "round",
|
|
201
|
+
agent: spoke.agent,
|
|
202
|
+
round,
|
|
203
|
+
usage: result.usage,
|
|
204
|
+
hasToolCalls: toolCallsThisRound.length > 0,
|
|
205
|
+
});
|
|
206
|
+
conv.turns.push(result.turn);
|
|
207
|
+
if (!result.usage.available) {
|
|
208
|
+
status = "truncated:usage_unavailable";
|
|
209
|
+
errors.push(`round ${round}: usage 不可用(usageMissing),保守收束`);
|
|
210
|
+
finalText = result.meta.text ?? finalText;
|
|
211
|
+
if (toolCallsThisRound.length === 0 || finalizeMode)
|
|
212
|
+
break;
|
|
213
|
+
finalizeMode = true;
|
|
214
|
+
conv.turns.push({ role: "user", text: buildFinalizeUserText(spoke.lang) });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (toolCallsThisRound.length === 0) {
|
|
218
|
+
finalText = result.meta.text;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
if (finalizeMode) {
|
|
222
|
+
// 收束呼叫理論上不帶 tool,仍收到 tool call 屬異常,防禦性丟棄不執行
|
|
223
|
+
errors.push(`round ${round}: 收束呼叫仍回傳 tool call,忽略`);
|
|
224
|
+
finalText = result.meta.text;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
// §14:兩道推理上限,檢查順序為單輪尖峰 → 累積推理 → 累積總量。累積上限抓不到單點
|
|
228
|
+
// 尖峰(實測:第四段單輪 13,615 推理 token,累積上限 400,000 遠遠碰不到),故單輪
|
|
229
|
+
// 檢查優先;三者共用 truncated:budget 狀態,budgetTrigger 記來源供事後診斷。
|
|
230
|
+
const roundReasoningTokens = result.usage.reasoningTokens ?? 0;
|
|
231
|
+
if (options.maxRoundReasoningTokens !== null && roundReasoningTokens > options.maxRoundReasoningTokens) {
|
|
232
|
+
status = "truncated:budget";
|
|
233
|
+
budgetTrigger = "reasoning_round";
|
|
234
|
+
finalizeMode = true;
|
|
235
|
+
conv.turns.push({ role: "user", text: buildFinalizeUserText(spoke.lang) });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const cumulativeReasoningTokens = cumulativeUsage.reasoningTokens ?? 0;
|
|
239
|
+
if (cumulativeReasoningTokens > options.maxSpokeReasoningTokens) {
|
|
240
|
+
status = "truncated:budget";
|
|
241
|
+
budgetTrigger = "reasoning";
|
|
242
|
+
finalizeMode = true;
|
|
243
|
+
conv.turns.push({ role: "user", text: buildFinalizeUserText(spoke.lang) });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (cumulativeUsage.totalTokens >= options.maxSpokeTokens) {
|
|
247
|
+
status = "truncated:budget";
|
|
248
|
+
budgetTrigger = "total";
|
|
249
|
+
finalizeMode = true;
|
|
250
|
+
conv.turns.push({ role: "user", text: buildFinalizeUserText(spoke.lang) });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
let hitToolLimit = false;
|
|
254
|
+
for (const call of toolCallsThisRound) {
|
|
255
|
+
toolCallCount++;
|
|
256
|
+
if (toolCallCount > options.maxToolCalls) {
|
|
257
|
+
// §7:被拒的呼叫仍計入上限;觸限後不執行,直接改走收束。這次呼叫連白名單判定都
|
|
258
|
+
// 沒跑到,仍記入 toolCalls[](reason: tool_limit_exceeded)以求可觀測性——
|
|
259
|
+
// 「查 run.jsonl 就能重建它到底讀了什麼、幾次被拒」不該因觸限而漏一段。
|
|
260
|
+
hitToolLimit = true;
|
|
261
|
+
const log = {
|
|
262
|
+
path: typeof call.args.path === "string" ? call.args.path : "",
|
|
263
|
+
allowed: false,
|
|
264
|
+
reason: "tool_limit_exceeded",
|
|
265
|
+
startedAt: Date.now(),
|
|
266
|
+
durationMs: 0,
|
|
267
|
+
};
|
|
268
|
+
toolCalls.push(log);
|
|
269
|
+
options.onEvent({ type: "tool_call", agent: spoke.agent, path: log.path, allowed: false, reason: log.reason });
|
|
270
|
+
conv.turns.push({ role: "tool", callId: call.id, result: "已達 --max-tool-calls 上限,未執行" });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const { resultText, log } = await executeToolCall(call, spoke, options.repoRoot);
|
|
274
|
+
toolCalls.push(log);
|
|
275
|
+
options.onEvent({ type: "tool_call", agent: spoke.agent, path: log.path, allowed: log.allowed, reason: log.reason });
|
|
276
|
+
conv.turns.push({ role: "tool", callId: call.id, result: resultText });
|
|
277
|
+
}
|
|
278
|
+
if (hitToolLimit) {
|
|
279
|
+
status = "truncated:tool_limit";
|
|
280
|
+
finalizeMode = true;
|
|
281
|
+
conv.turns.push({ role: "user", text: buildFinalizeUserText(spoke.lang) });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const finishedAt = Date.now();
|
|
285
|
+
// plan_fixes_v1.0.md §4:以請求時的型號查價(spoke.model,非 modelReturned)——價目
|
|
286
|
+
// 是「配置了要付這個模型的錢」,與伺服器實際服務的模型無關。缺價目資料時回傳 null。
|
|
287
|
+
const costUsd = estimateCostUsd(cumulativeUsage, spoke.providerConfig.api, spoke.providerConfig.pricing?.[spoke.model]);
|
|
288
|
+
options.onEvent({
|
|
289
|
+
type: "spoke_end",
|
|
290
|
+
agent: spoke.agent,
|
|
291
|
+
status,
|
|
292
|
+
latencyMs: finishedAt - startedAt,
|
|
293
|
+
totalTokens: cumulativeUsage.totalTokens,
|
|
294
|
+
estimatedPromptTokens,
|
|
295
|
+
costUsd,
|
|
296
|
+
budgetTrigger,
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
agent: spoke.agent,
|
|
300
|
+
provider: spoke.provider,
|
|
301
|
+
api: spoke.providerConfig.api,
|
|
302
|
+
modelRequested: spoke.model,
|
|
303
|
+
modelReturned,
|
|
304
|
+
effort: spoke.effort,
|
|
305
|
+
store,
|
|
306
|
+
status,
|
|
307
|
+
budgetTrigger,
|
|
308
|
+
finalText,
|
|
309
|
+
usage: cumulativeUsage,
|
|
310
|
+
costUsd,
|
|
311
|
+
finishReason,
|
|
312
|
+
finishReasonRaw,
|
|
313
|
+
toolCalls,
|
|
314
|
+
rateLimitHits,
|
|
315
|
+
unknownUsageKeys,
|
|
316
|
+
attempts,
|
|
317
|
+
errors,
|
|
318
|
+
requestId,
|
|
319
|
+
startedAt,
|
|
320
|
+
finishedAt,
|
|
321
|
+
latencyMs: finishedAt - startedAt,
|
|
322
|
+
waitedMs,
|
|
323
|
+
estimatedPromptTokens,
|
|
324
|
+
rawRequests,
|
|
325
|
+
rawResponses,
|
|
326
|
+
rawErrors,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §12/plan_dispatch_v2.7.md §29 規格七:registerSecrets 的來源清單
|
|
2
|
+
// 是硬編的,不是自動掃描——API key 的解析(`${provider}_API_KEY` 慣例)會自動成立,但遮罩
|
|
3
|
+
// 不會。清單獨立成檔(而非留在 cli.ts 內聯宣告),是為了讓測試能直接匯入比對,不需要匯入
|
|
4
|
+
// 有 main() 副作用的 cli.ts 本體(cli-args.ts 拆分同一理由)。新增 provider 時漏加這裡,
|
|
5
|
+
// 該 provider 的金鑰就可能原樣出現在錯誤訊息、raw/*.request.json 或 run.jsonl 中。
|
|
6
|
+
export const SECRET_ENV_VARS = ["DEEPSEEK_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"];
|