cs-bots 0.1.1 → 0.7.5
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 +539 -0
- package/README.md +65 -10
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
- package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
- package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
- package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
- package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
- package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
- package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
- package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
- package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
- package/package.json +4 -2
- package/src/admission.js +365 -0
- package/src/api.js +4097 -420
- package/src/board.js +883 -14
- package/src/botprofile.js +190 -3
- package/src/client.js +6596 -1082
- package/src/cycle.js +516 -0
- package/src/delivery-watch.js +255 -0
- package/src/dirbrowse.js +20 -0
- package/src/discipline.js +1188 -0
- package/src/fence.js +142 -0
- package/src/index.js +387 -4
- package/src/kernel.js +636 -24
- package/src/library.js +1224 -11
- package/src/memory.js +617 -37
- package/src/metrics.js +339 -0
- package/src/prompts.js +1318 -0
- package/src/reviewer.js +709 -0
- package/src/room.js +655 -14
- package/src/roster.js +346 -26
- package/src/routing/complexity.js +320 -0
- package/src/routing/hints.js +707 -0
- package/src/scratch.js +477 -0
- package/src/settings.js +97 -0
- package/src/stage.js +263 -0
- package/src/store.js +133 -1
- package/src/subscription.js +571 -0
- package/src/suggest.js +1203 -0
- package/src/teams.js +527 -0
- package/src/todo.js +1301 -0
- package/src/tools.js +734 -10
- package/src/verify.js +356 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots 复杂度路由 hint 注入 —— **唯一的宿主触点在 `../kernel.js`(`onLlmStream`)**。
|
|
3
|
+
*
|
|
4
|
+
* 契约:`design/17-并发感知路由层-接口契约-v1.md` §3(hint 形状)+ §6(注入点)。
|
|
5
|
+
*
|
|
6
|
+
* ── 为什么必须走旁路注册表(不要试图改 options)────────────────────────────────
|
|
7
|
+
* 宿主 `dsh-agent-loop` 在 dispatch 前把请求对象 `Object.freeze(...)` 后交给 adapter,
|
|
8
|
+
* 再经 `ctx.waterfall(this, 'llm/stream', options, ...)` 瀑布(证据:
|
|
9
|
+
* `dsh-llm/lib/index.js:2397`,签名 `'llm/stream'(this, options: GenerateOptions,
|
|
10
|
+
* next: () => AsyncIterable<StreamChunk>)`,官方插件用法见
|
|
11
|
+
* `dsh-session-checkpoint-policy/lib/index.js:61`)。
|
|
12
|
+
* ⇒ `options` **只读**。任何「给请求加个 metadata 字段」的做法在运行时都会失败,
|
|
13
|
+
* 所以 hint 只能写进跨插件的旁路注册表:
|
|
14
|
+
* `globalThis[Symbol.for('cs-myfree.routing.hints.v1')] = { version, api }`
|
|
15
|
+
* 由 cs-myfree 读取(`options.sessionId` → hint)。
|
|
16
|
+
*
|
|
17
|
+
* ── 三条硬纪律 ──────────────────────────────────────────────────────────────
|
|
18
|
+
* 1. **fail-open**:`llm/stream` 的 handler **永远** `return next()`。本模块任何
|
|
19
|
+
* 异常(注册表缺失、cs-myfree 内部抛错、hint 形状校验失败)都只记统计,
|
|
20
|
+
* 绝不让一条对话请求失败 —— 路由优化不值得拿可用性换。
|
|
21
|
+
* 2. **只碰自己那一格**:仅当 `options.provider === 'cs-myfree'` 时写 hint。
|
|
22
|
+
* 官方 deepseek 渠道、其它 adapter 一律不写(写了也没人读,纯污染)。
|
|
23
|
+
* 3. **迟到也能装**:注册表在**每次请求时**动态解析(而不是安装时缓存),
|
|
24
|
+
* 因此 cs-myfree 比 cs-bots 晚装载也能生效;缺失时静默跳过。
|
|
25
|
+
*
|
|
26
|
+
* 本文件**不 import 任何宿主包**(`test/ci-zero-dep.sh` 检查 1 机械强制)。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
billingTierFor,
|
|
31
|
+
classifyComplexity,
|
|
32
|
+
normalizeRole,
|
|
33
|
+
normalizeSubagentMode,
|
|
34
|
+
normalizeTier,
|
|
35
|
+
} from './complexity.js'
|
|
36
|
+
|
|
37
|
+
/** 跨插件唯一入口(与 cs-myfree 的 `adapter/routing-hints.js` 必须逐字一致)。 */
|
|
38
|
+
export const HINTS_SYMBOL = Symbol.for('cs-myfree.routing.hints.v1')
|
|
39
|
+
/**
|
|
40
|
+
* 本模块写入的 hint 版本。
|
|
41
|
+
*
|
|
42
|
+
* v1(`design/17` §3)= 六键;v2(`design/22` 契约 E1/E4)= 六键 + 两个**可选**键
|
|
43
|
+
* `follow_parent_routing` / `pinned_model`。
|
|
44
|
+
*
|
|
45
|
+
* 🔴 交叉兼容纪律(两个仓库的版本必然错开升级):
|
|
46
|
+
* · **v1 六键 hint 仍然合法**(本模块未声明策略时**照旧只发六键**,形状不变);
|
|
47
|
+
* · **未知键必须被丢弃而不是报错** —— 旧 cs-myfree 读新 hint、新 cs-myfree 读旧
|
|
48
|
+
* hint 都必须能跑;报错会让整条授权链路静默消失(比缺功能更难查)。
|
|
49
|
+
*/
|
|
50
|
+
export const HINTS_VERSION = 2
|
|
51
|
+
/** 请求级 hint 的来源标识(可审计:谁写的)。 */
|
|
52
|
+
export const SOURCE_LLM_STREAM = 'cs-bots/llm-stream'
|
|
53
|
+
/** 任务板 claim/wake 时写 hint 的来源标识。 */
|
|
54
|
+
export const SOURCE_TASK_BOARD = 'cs-bots/task-board'
|
|
55
|
+
/**
|
|
56
|
+
* hint 有效期(ms)—— 与 cs-myfree 注册表默认 `ttlMs` 对齐(契约 §3:30 分钟)。
|
|
57
|
+
* 用于 `mergeHint` 判定「任务授权是否还能延续」,避免一条旧的 paid_allowed 永久生效。
|
|
58
|
+
*/
|
|
59
|
+
export const HINT_TTL_MS = 30 * 60 * 1000
|
|
60
|
+
|
|
61
|
+
/** cs-myfree 的 provider 路由名(只有它读 hint)。 */
|
|
62
|
+
const MYFREE_PROVIDER = 'cs-myfree'
|
|
63
|
+
/** token 粗估:字符数 / 4(英文代码约 3.5–4 字符/token,中文约 1.5–2;取 4 保守)。 */
|
|
64
|
+
const CHARS_PER_TOKEN = 4
|
|
65
|
+
|
|
66
|
+
// ── 请求信号提取(纯函数)───────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** 单条消息的文本长度(兼容 string content / parts 数组 / 裸 text 字段)。 */
|
|
69
|
+
function messageChars(message) {
|
|
70
|
+
if (!message || typeof message !== 'object') return 0
|
|
71
|
+
let total = 0
|
|
72
|
+
const { content, text } = message
|
|
73
|
+
if (typeof content === 'string') total += content.length
|
|
74
|
+
else if (Array.isArray(content)) {
|
|
75
|
+
for (const part of content) {
|
|
76
|
+
if (!part) continue
|
|
77
|
+
if (typeof part === 'string') total += part.length
|
|
78
|
+
else if (typeof part.text === 'string') total += part.text.length
|
|
79
|
+
else if (typeof part.content === 'string') total += part.content.length
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (typeof text === 'string') total += text.length
|
|
83
|
+
return total
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 粗估上下文 token 数(字符 / 4,向上取整)。 */
|
|
87
|
+
export function estimateContextTokens(messages) {
|
|
88
|
+
if (!Array.isArray(messages) || messages.length === 0) return 0
|
|
89
|
+
let chars = 0
|
|
90
|
+
for (const m of messages) chars += messageChars(m)
|
|
91
|
+
return Math.ceil(chars / CHARS_PER_TOKEN)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 请求是否带工具(工具声明 / 工具历史 / 历史里的工具调用与结果都算)。 */
|
|
95
|
+
export function detectToolUse(options = {}) {
|
|
96
|
+
const { tools, toolHistory, messages } = options ?? {}
|
|
97
|
+
if (Array.isArray(tools) && tools.length > 0) return true
|
|
98
|
+
if (Array.isArray(toolHistory) && toolHistory.length > 0) return true
|
|
99
|
+
if (toolHistory && typeof toolHistory === 'object' && !Array.isArray(toolHistory)) {
|
|
100
|
+
if (Object.keys(toolHistory).length > 0) return true
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(messages)) {
|
|
103
|
+
for (const m of messages) {
|
|
104
|
+
if (!m || typeof m !== 'object') continue
|
|
105
|
+
if (m.role === 'tool') return true
|
|
106
|
+
const calls = m.toolCalls ?? m.tool_calls
|
|
107
|
+
if (Array.isArray(calls) && calls.length > 0) return true
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 从宿主请求对象提取分类器需要的信号。
|
|
115
|
+
*
|
|
116
|
+
* ⚠️ 只读:`options` 在宿主里是冻结对象,这里**绝不写回**。
|
|
117
|
+
*
|
|
118
|
+
* @param {object} options 宿主 `llm/stream` 的 GenerateOptions(可含 provider/model/sessionId/messages/tools/signal)
|
|
119
|
+
* @param {{ role?: string, requiresPaid?: boolean, explicitComplexity?: string,
|
|
120
|
+
* contextTokens?: number } } [extra] 平台侧已知信息(角色 / 任务标注 / 已测 token)
|
|
121
|
+
* @returns {{ contextTokens: number, historyDepth: number, hasToolCalls: boolean,
|
|
122
|
+
* agentRole: string, explicitComplexity: string|null }}
|
|
123
|
+
*/
|
|
124
|
+
export function signalsFromRequest(options = {}, extra = {}) {
|
|
125
|
+
const messages = Array.isArray(options?.messages) ? options.messages : []
|
|
126
|
+
const measured = typeof extra?.contextTokens === 'number' && Number.isFinite(extra.contextTokens)
|
|
127
|
+
? extra.contextTokens
|
|
128
|
+
: undefined
|
|
129
|
+
return {
|
|
130
|
+
contextTokens: measured ?? estimateContextTokens(messages),
|
|
131
|
+
historyDepth: messages.length,
|
|
132
|
+
hasToolCalls: detectToolUse(options),
|
|
133
|
+
agentRole: normalizeRole(extra?.role),
|
|
134
|
+
explicitComplexity: normalizeTier(extra?.explicitComplexity),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── hint 构造(纯函数,离线可测)──────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/** v1 六键(`design/17` §3,语义冻结不许改)。 */
|
|
141
|
+
export const HINT_KEYS_V1 = Object.freeze([
|
|
142
|
+
'billing_tier', 'complexity_tier', 'agent_role', 'requires_paid', 'source', 'at',
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
/** v2 新增的两个**可选**键(`design/22` 契约 E1)。 */
|
|
146
|
+
export const HINT_KEYS_V2 = Object.freeze(['follow_parent_routing', 'pinned_model'])
|
|
147
|
+
|
|
148
|
+
/** 本模块认得的全部键(白名单:其余一律丢弃)。 */
|
|
149
|
+
export const KNOWN_HINT_KEYS = Object.freeze([...HINT_KEYS_V1, ...HINT_KEYS_V2])
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 白名单过滤 hint:**只保留认得的键**(未知键丢弃,绝不抛错)。
|
|
153
|
+
*
|
|
154
|
+
* 用于「结果出口」而不是「入口校验」:cs-myfree 侧 `normalizeHint` 也是白名单重建,
|
|
155
|
+
* 两边都丢弃未知键 ⇒ 任一仓库先升级都不会把对方搞挂(契约 E4 的交叉兼容要求)。
|
|
156
|
+
*
|
|
157
|
+
* @param {unknown} hint
|
|
158
|
+
* @returns {object} 只含已知键的新对象(原对象不动;`null`/非对象 ⇒ `{}`)
|
|
159
|
+
*/
|
|
160
|
+
export function pickHintKeys(hint) {
|
|
161
|
+
const out = {}
|
|
162
|
+
if (!hint || typeof hint !== 'object') return out
|
|
163
|
+
for (const key of KNOWN_HINT_KEYS) {
|
|
164
|
+
if (hint[key] !== undefined) out[key] = hint[key]
|
|
165
|
+
}
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 归一化 `follow_parent_routing`:只认布尔,其它 ⇒ `undefined`(= **不出现在 hint 里**)。
|
|
171
|
+
*
|
|
172
|
+
* 为什么是 `undefined` 而不是 `null`:cs-bots 是 hint 的**生产方**,
|
|
173
|
+
* 「没有声明」就应该连键都不发(保持 v1 六键形状不变 ⇒ 旧 cs-myfree 零感知)。
|
|
174
|
+
* cs-myfree 是消费方,它那边才把「缺键」归一成 `null`。
|
|
175
|
+
*
|
|
176
|
+
* @param {unknown} value
|
|
177
|
+
* @returns {boolean|undefined}
|
|
178
|
+
*/
|
|
179
|
+
export function normalizeFollowParentRouting(value) {
|
|
180
|
+
return typeof value === 'boolean' ? value : undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 归一化 `pinned_model`(`'provider/model'` 或裸模型名):非法 ⇒ `undefined`(不发该键)。
|
|
185
|
+
* @param {unknown} value
|
|
186
|
+
* @returns {string|undefined}
|
|
187
|
+
*/
|
|
188
|
+
export function normalizePinnedModel(value) {
|
|
189
|
+
if (typeof value !== 'string') return undefined
|
|
190
|
+
const s = value.trim()
|
|
191
|
+
if (!s) return undefined
|
|
192
|
+
if (/[\s\u0000-\u001F\u007F]/.test(s)) return undefined
|
|
193
|
+
return s.slice(0, 128)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 由 `subagentRouting`(或显式声明)推导两个 v2 键(纯函数)。
|
|
198
|
+
*
|
|
199
|
+
* 映射(契约 E3 三选项,逐条对应,不自创):
|
|
200
|
+
* · mode='follow' ⇒ `follow_parent_routing: true`
|
|
201
|
+
* · mode='pinned' ⇒ `follow_parent_routing: false` + `pinned_model: model`(无 model 则不发该键)
|
|
202
|
+
* · mode='auto' ⇒ `follow_parent_routing: false`(明确不跟随,由 myfree 自动选路)
|
|
203
|
+
* · 未配置(null)⇒ **两个键都不发**(保持 v1 六键形状 ⇒ 零回归)
|
|
204
|
+
*
|
|
205
|
+
* 显式 `followParentRouting` / `pinnedModel` 优先级高于 `subagentRouting.mode`(便于
|
|
206
|
+
* 任务板按任务覆盖)。
|
|
207
|
+
*
|
|
208
|
+
* @param {{ subagentRouting?: object|null, followParentRouting?: boolean,
|
|
209
|
+
* pinnedModel?: string|null }} [deps]
|
|
210
|
+
* @returns {{ follow?: boolean, pinned?: string }}
|
|
211
|
+
*/
|
|
212
|
+
export function subagentRoutingKeys(deps = {}) {
|
|
213
|
+
const out = {}
|
|
214
|
+
const explicitFollow = normalizeFollowParentRouting(deps?.followParentRouting)
|
|
215
|
+
const explicitPinned = normalizePinnedModel(deps?.pinnedModel)
|
|
216
|
+
const mode = normalizeSubagentMode(deps?.subagentRouting?.mode)
|
|
217
|
+
|
|
218
|
+
if (explicitFollow !== undefined) out.follow = explicitFollow
|
|
219
|
+
else if (mode === 'follow') out.follow = true
|
|
220
|
+
else if (mode === 'pinned' || mode === 'auto') out.follow = false
|
|
221
|
+
|
|
222
|
+
if (explicitPinned !== undefined) out.pinned = explicitPinned
|
|
223
|
+
else if (mode === 'pinned') {
|
|
224
|
+
const pinned = normalizePinnedModel(deps?.subagentRouting?.model)
|
|
225
|
+
if (pinned !== undefined) out.pinned = pinned
|
|
226
|
+
}
|
|
227
|
+
return out
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 造一个契约形状的 hint。
|
|
232
|
+
*
|
|
233
|
+
* v1 六键**永远**都在(值域不变);v2 两键是**可选**的 —— 传了才出现,
|
|
234
|
+
* 没传就不出现(这是「v1 客户端/旧配置 ⇒ 形状与今天逐字一致」的落地方式)。
|
|
235
|
+
*
|
|
236
|
+
* @param {{ billing_tier: 'free_only'|'paid_allowed', complexity_tier: string|null,
|
|
237
|
+
* agent_role: 'main'|'subagent'|'unknown', requires_paid: boolean,
|
|
238
|
+
* source: string, at: number,
|
|
239
|
+
* follow_parent_routing?: boolean|null, pinned_model?: string|null }} parts
|
|
240
|
+
* @returns {object}
|
|
241
|
+
*/
|
|
242
|
+
function buildHint(parts) {
|
|
243
|
+
const hint = {
|
|
244
|
+
billing_tier: parts.billing_tier,
|
|
245
|
+
complexity_tier: parts.complexity_tier ?? null,
|
|
246
|
+
agent_role: parts.agent_role,
|
|
247
|
+
requires_paid: parts.requires_paid === true,
|
|
248
|
+
source: String(parts.source),
|
|
249
|
+
at: parts.at,
|
|
250
|
+
}
|
|
251
|
+
const follow = normalizeFollowParentRouting(parts.follow_parent_routing)
|
|
252
|
+
if (follow !== undefined) hint.follow_parent_routing = follow
|
|
253
|
+
const pinned = normalizePinnedModel(parts.pinned_model)
|
|
254
|
+
if (pinned !== undefined) hint.pinned_model = pinned
|
|
255
|
+
return hint
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 判定一条既有 hint 是否为「任务级付费授权态」(`mergeHint` 用)。
|
|
260
|
+
*
|
|
261
|
+
* 两个来源都算:`SOURCE_TASK_BOARD`(claim/wake 首次授权)与 `SOURCE_LLM_STREAM`
|
|
262
|
+
* (本 handler 合并后的续期形态)—— 只认前者会让授权在第二条请求就衰减(真 bug)。
|
|
263
|
+
* 以 `requires_paid === true` 作为授权态的自描述标记(hint 形状 v1 冻结,不能加 `granted_at`)。
|
|
264
|
+
*
|
|
265
|
+
* @param {unknown} hint
|
|
266
|
+
* @returns {boolean}
|
|
267
|
+
*/
|
|
268
|
+
function isTaskGrant(hint) {
|
|
269
|
+
if (!hint || typeof hint !== 'object') return false
|
|
270
|
+
if (hint.billing_tier !== 'paid_allowed') return false
|
|
271
|
+
if (hint.requires_paid !== true) return false
|
|
272
|
+
return hint.source === SOURCE_TASK_BOARD || hint.source === SOURCE_LLM_STREAM
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 合并「已存在的 hint」与「本次请求算出的 hint」—— **只升不降**(纯函数)。
|
|
277
|
+
*
|
|
278
|
+
* 这是 P3-fix 的核心(2026-11-04 缺陷):`applyTaskHint` 在 claim/wake 时写入的
|
|
279
|
+
* `paid_allowed` 是**任务级授权**,下一条 llm/stream 请求算出的 `free_only`(子智能体默认)
|
|
280
|
+
* 不能把它踩掉;否则「requires_paid:true 的子任务获付费」永远失效。
|
|
281
|
+
*
|
|
282
|
+
* 规则(严格按此,不做别的推断):
|
|
283
|
+
* 1. `next.billing_tier === 'paid_allowed'` ⇒ 用 next(平台已判定本次就该付费);
|
|
284
|
+
* 2. 否则,若已存在一条**未过期**的「任务授权态」hint(`billing_tier === 'paid_allowed'`
|
|
285
|
+
* 且 `requires_paid === true`,来源为 `SOURCE_TASK_BOARD` 或本模块)⇒ 保留 paid_allowed,
|
|
286
|
+
* 但 `complexity_tier` / `at` / `source` 用 next 的(新鲜度),`requires_paid` 保持 true;
|
|
287
|
+
* 3. 其余 ⇒ 用 next(即正常降回 free_only;任务授权过期/来源不可信时不做特殊处理)。
|
|
288
|
+
*
|
|
289
|
+
* ⚠️ 为什么授权态要认 `SOURCE_LLM_STREAM` 这一来源(P3-fix 第二轮,实测抓到):
|
|
290
|
+
* 合并结果由本 handler 写回,其 `source` 是 `'cs-bots/llm-stream'`。若只认
|
|
291
|
+
* `SOURCE_TASK_BOARD`,则**第二条请求**就会把授权判成「来源不成立」而降级
|
|
292
|
+
* —— 缺陷只是从「第一条」推迟到「第二条」,仍然失效。hint 形状 v1 冻结(六个键)
|
|
293
|
+
* 不许多存一个 `granted_at`,故用 `requires_paid === true` 作为「授权态」的自描述标记。
|
|
294
|
+
* 代价:只要该会话持续发请求,授权就持续有效;**静默 30 分钟(`HINT_TTL_MS`)后失效**
|
|
295
|
+
* (`at` 每次刷新 ⇒ 滚动 TTL,这是与 cs-myfree 注册表 TTL 对齐后的语义)。
|
|
296
|
+
*
|
|
297
|
+
* @param {object|null|undefined} existing 注册表里已有的 hint(可能为 null)
|
|
298
|
+
* @param {object} next 本次请求算出的 hint
|
|
299
|
+
* @param {{ now?: number, ttlMs?: number }} [opts]
|
|
300
|
+
* @returns {object} 要写入的 hint(只含已知键;未声明策略时**恰好六键**,与契约 §3 一致)
|
|
301
|
+
*/
|
|
302
|
+
export function mergeHint(existing, next, opts = {}) {
|
|
303
|
+
if (!next || typeof next !== 'object') return next
|
|
304
|
+
const merged = mergeHintCore(existing, next, opts)
|
|
305
|
+
return pickHintKeys(merged)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `mergeHint` 的实体(`pickHintKeys` 只做出口白名单,逻辑全在这里)。
|
|
310
|
+
*
|
|
311
|
+
* v2 两键的合并规则(与 billing_tier 的"只升不降"**不同**,刻意分开):
|
|
312
|
+
* · 它们是**声明式策略**,不是授权:本次请求声明了就以本次为准;
|
|
313
|
+
* · 本次没声明(缺键)时**继承既有** —— 否则「claim 时写了 follow,下一条
|
|
314
|
+
* llm/stream 请求没带策略」会把跟随关系抹掉(策略抖动 ⇒ 选路抖动);
|
|
315
|
+
* · 既有的也不带 ⇒ 两键都不出现(v1 六键形状)。
|
|
316
|
+
*/
|
|
317
|
+
function mergeHintCore(existing, next, opts = {}) {
|
|
318
|
+
if (next.billing_tier === 'paid_allowed') return next
|
|
319
|
+
const base = { ...next }
|
|
320
|
+
const carryStrategy = () => {
|
|
321
|
+
for (const key of HINT_KEYS_V2) {
|
|
322
|
+
if (base[key] === undefined && existing && typeof existing === 'object' && existing[key] !== undefined) {
|
|
323
|
+
base[key] = existing[key]
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!existing || typeof existing !== 'object') {
|
|
329
|
+
carryStrategy() // existing 缺失时无需继承,但走同一路径保证形状一致
|
|
330
|
+
return base
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!isTaskGrant(existing)) {
|
|
334
|
+
carryStrategy()
|
|
335
|
+
return base
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const issuedAt = typeof existing.at === 'number' && Number.isFinite(existing.at) ? existing.at : null
|
|
339
|
+
if (issuedAt === null) {
|
|
340
|
+
carryStrategy()
|
|
341
|
+
return base
|
|
342
|
+
}
|
|
343
|
+
const now = typeof opts.now === 'number' && Number.isFinite(opts.now)
|
|
344
|
+
? opts.now
|
|
345
|
+
: (typeof next.at === 'number' && Number.isFinite(next.at) ? next.at : null)
|
|
346
|
+
if (now === null) {
|
|
347
|
+
carryStrategy()
|
|
348
|
+
return base
|
|
349
|
+
}
|
|
350
|
+
const ttl = typeof opts.ttlMs === 'number' && Number.isFinite(opts.ttlMs) && opts.ttlMs > 0
|
|
351
|
+
? opts.ttlMs
|
|
352
|
+
: HINT_TTL_MS
|
|
353
|
+
if (now - issuedAt >= ttl) {
|
|
354
|
+
carryStrategy()
|
|
355
|
+
return base
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
carryStrategy()
|
|
359
|
+
return { ...base, billing_tier: 'paid_allowed', requires_paid: true }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 由一次请求解析出应该写入的 hint(纯函数 —— 便于离线表驱动测试)。
|
|
364
|
+
*
|
|
365
|
+
* 返回 `null` 的全部情形(**都算正常**,不是错误):
|
|
366
|
+
* - `options.provider !== 'cs-myfree'`:别的渠道不读 hint;
|
|
367
|
+
* - 没有 `sessionId`:注册表按 sessionId 建索引,无键可写。
|
|
368
|
+
*
|
|
369
|
+
* @param {object} options
|
|
370
|
+
* @param {{ role?: string, requiresPaid?: boolean, explicitComplexity?: string,
|
|
371
|
+
* source?: string, now?: number, contextTokens?: number,
|
|
372
|
+
* subagentRouting?: { mode?: string, model?: string|null }|null,
|
|
373
|
+
* parentBillingTier?: string, followParentRouting?: boolean,
|
|
374
|
+
* pinnedModel?: string|null }} [deps]
|
|
375
|
+
* `subagentRouting` —— 子智能体路由跟随策略(`design/22` 契约 E3/E4);
|
|
376
|
+
* 缺省 ⇒ **两个 v2 键都不写**(v1 六键形状不变,零回归)。
|
|
377
|
+
* `parentBillingTier` —— 上级会话的授权标签,仅在 mode='follow' 时用于继承。
|
|
378
|
+
* @returns {object|null} hint
|
|
379
|
+
*/
|
|
380
|
+
export function resolveHint(options, deps = {}) {
|
|
381
|
+
if (!options || typeof options !== 'object') return null
|
|
382
|
+
if (options.provider !== MYFREE_PROVIDER) return null
|
|
383
|
+
const sessionId = options.sessionId
|
|
384
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null
|
|
385
|
+
|
|
386
|
+
const role = normalizeRole(deps?.role)
|
|
387
|
+
const requiresPaid = deps?.requiresPaid === true
|
|
388
|
+
const explicitComplexity = normalizeTier(deps?.explicitComplexity)
|
|
389
|
+
const signals = signalsFromRequest(options, { role, explicitComplexity, contextTokens: deps?.contextTokens })
|
|
390
|
+
const classified = classifyComplexity(signals)
|
|
391
|
+
const at = typeof deps?.now === 'number' && Number.isFinite(deps.now) ? deps.now : Date.now()
|
|
392
|
+
// v2 策略两键:未配置 ⇒ `{}`(⇒ buildHint 不写这两键,形状与 v1 逐字一致)
|
|
393
|
+
const strategy = subagentRoutingKeys(deps)
|
|
394
|
+
|
|
395
|
+
return buildHint({
|
|
396
|
+
billing_tier: billingTierFor({
|
|
397
|
+
tier: classified.tier,
|
|
398
|
+
role,
|
|
399
|
+
requiresPaid,
|
|
400
|
+
subagentRouting: deps?.subagentRouting,
|
|
401
|
+
parentBillingTier: deps?.parentBillingTier,
|
|
402
|
+
}),
|
|
403
|
+
complexity_tier: classified.tier,
|
|
404
|
+
agent_role: role,
|
|
405
|
+
requires_paid: requiresPaid,
|
|
406
|
+
source: deps?.source ?? SOURCE_LLM_STREAM,
|
|
407
|
+
at,
|
|
408
|
+
follow_parent_routing: strategy.follow,
|
|
409
|
+
pinned_model: strategy.pinned,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── 注册表发现 ──────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 读 cs-myfree 的旁路注册表。
|
|
417
|
+
*
|
|
418
|
+
* **未安装 ⇒ 返回 null ⇒ 调用方静默跳过**(这是契约要求的降级路径,不是错误)。
|
|
419
|
+
* 逐个字段防御式取值:`api.setRequestHint` 不是函数就当作未安装,
|
|
420
|
+
* 免得 cs-myfree 改版后 cs-bots 直接把请求搞挂。
|
|
421
|
+
*
|
|
422
|
+
* @param {object} [scope] 便于测试替换(默认 globalThis)
|
|
423
|
+
* @returns {{ version: number|string|null, api: { setRequestHint: Function,
|
|
424
|
+
* getRequestHint?: Function, clearRequestHint?: Function } } | null}
|
|
425
|
+
*/
|
|
426
|
+
export function getGlobalHintsRegistry(scope = globalThis) {
|
|
427
|
+
try {
|
|
428
|
+
const entry = scope?.[HINTS_SYMBOL]
|
|
429
|
+
if (!entry || typeof entry !== 'object') return null
|
|
430
|
+
const api = entry.api
|
|
431
|
+
if (!api || typeof api.setRequestHint !== 'function') return null
|
|
432
|
+
return { version: entry.version ?? null, api }
|
|
433
|
+
} catch {
|
|
434
|
+
// Symbol 访问本身被代理陷阱拒绝时也不能炸
|
|
435
|
+
return null
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── 注入安装 ────────────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 装 `llm/stream` 拦截:**每条请求前**把复杂度 hint 写进 cs-myfree 的旁路注册表。
|
|
443
|
+
*
|
|
444
|
+
* 返回语义(契约 §6 + Lead 的 DoD 口径):
|
|
445
|
+
* - `hooked` —— `llm/stream` 拦截是否已注册成功(kernel 窄接口是否可用)
|
|
446
|
+
* - `installed` —— `hooked && 当前能解析到 cs-myfree 注册表`
|
|
447
|
+
* ⇒ cs-myfree 未安装时是 `{ installed:false, reason:'cs-myfree-registry-missing' }`,
|
|
448
|
+
* **但 handler 仍然会被调用并 `return next()`**(请求照常进行,只是不写 hint)。
|
|
449
|
+
* 「先装钩子、运行时再发现注册表」是刻意的:cs-myfree 后装载也能立刻生效。
|
|
450
|
+
* - `handler` / `stats` —— 便于离线测试与诊断(不对外暴露给模型)。
|
|
451
|
+
*
|
|
452
|
+
* @param {object} ctx 宿主上下文
|
|
453
|
+
* @param {{ onLlmStream?: Function }} kernel `../kernel.js` 的窄接口集合
|
|
454
|
+
* @param {{ warn?: Function, role?: string, requiresPaid?: boolean,
|
|
455
|
+
* explicitComplexity?: string, source?: string, now?: Function,
|
|
456
|
+
* getRegistry?: Function,
|
|
457
|
+
* roleOf?: (sessionId: string) => 'main'|'subagent'|'unknown',
|
|
458
|
+
* subagentRouting?: object|null,
|
|
459
|
+
* subagentRoutingOf?: (sessionId: string) => object|null,
|
|
460
|
+
* parentBillingTier?: string,
|
|
461
|
+
* followParentRouting?: boolean, pinnedModel?: string|null,
|
|
462
|
+
* hintTtlMs?: number }} [opts]
|
|
463
|
+
* `roleOf` —— **每次请求按 sessionId 动态解析角色**(P3-fix):主对话 ⇒ `main`(⇒ paid_allowed),
|
|
464
|
+
* 注册表里的 Bot 会话 ⇒ `subagent`(⇒ free_only)。抛错/缺省 ⇒ `unknown`(fail-closed)。
|
|
465
|
+
* `subagentRouting` / `subagentRoutingOf` —— v2 子智能体路由策略(`design/22` 契约 E4,
|
|
466
|
+
* `{ mode:'auto'|'pinned'|'follow', model, fallback }`);**缺省 ⇒ hint 只含六键**
|
|
467
|
+
* (v1 形状不变 ⇒ 零回归)。`Of` 形式按 sessionId 逐请求解析(与 `roleOf` 同理)。
|
|
468
|
+
* `hintTtlMs` —— 任务级授权 hint 的延续上限(缺省 30 分钟,见 `HINT_TTL_MS`)。
|
|
469
|
+
* @returns {{ installed: boolean, hooked: boolean, reason?: string,
|
|
470
|
+
* handler: Function, stats: object }}
|
|
471
|
+
*/
|
|
472
|
+
export function installLlmStreamHint(ctx, kernel, opts = {}) {
|
|
473
|
+
const warn = typeof opts?.warn === 'function' ? opts.warn : () => {}
|
|
474
|
+
const now = typeof opts?.now === 'function' ? opts.now : () => Date.now()
|
|
475
|
+
const getRegistry = typeof opts?.getRegistry === 'function' ? opts.getRegistry : getGlobalHintsRegistry
|
|
476
|
+
const source = opts?.source ?? SOURCE_LLM_STREAM
|
|
477
|
+
|
|
478
|
+
/** 运行期统计(诊断用:为什么这条请求没拿到 hint)。 */
|
|
479
|
+
const stats = {
|
|
480
|
+
calls: 0,
|
|
481
|
+
injected: 0,
|
|
482
|
+
skippedProvider: 0,
|
|
483
|
+
skippedNoSession: 0,
|
|
484
|
+
skippedNoRegistry: 0,
|
|
485
|
+
errors: 0,
|
|
486
|
+
/** `opts.roleOf` 抛错的次数(每次都会降级为 unknown ⇒ free_only)。 */
|
|
487
|
+
roleOfErrors: 0,
|
|
488
|
+
/** 注册表不提供 `getRequestHint` 的次数(无法读旧 hint ⇒ 按无 existing 处理)。 */
|
|
489
|
+
existingUnavailable: 0,
|
|
490
|
+
/** 读旧 hint 抛错的次数(fail-open:照常写 next)。 */
|
|
491
|
+
existingReadErrors: 0,
|
|
492
|
+
/** 本次 next 只是 free_only、但因任务授权而保留 paid_allowed 的次数(P3-fix 的关键指标)。 */
|
|
493
|
+
carriedTaskAuth: 0,
|
|
494
|
+
/** `opts.subagentRoutingOf` 抛错的次数(每次都会降级为「未配置」⇒ v1 六键形状)。 */
|
|
495
|
+
subagentRoutingErrors: 0,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 解析**这条请求**的子智能体路由策略(`design/22` 契约 E4)。
|
|
500
|
+
*
|
|
501
|
+
* `opts.subagentRoutingOf` 未提供时退回安装时的静态 `opts.subagentRouting`;
|
|
502
|
+
* 抛错/返回垃圾 ⇒ `null`(= 未配置 ⇒ 只写 v1 六键,行为与今天一致)。
|
|
503
|
+
*/
|
|
504
|
+
function safeStrategyOf(strategyOf, sessionId) {
|
|
505
|
+
try {
|
|
506
|
+
const v = strategyOf(sessionId)
|
|
507
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : null
|
|
508
|
+
} catch (error) {
|
|
509
|
+
stats.subagentRoutingErrors += 1
|
|
510
|
+
try {
|
|
511
|
+
warn(`[cs-bots] subagentRoutingOf 解析失败(按「未配置」处理 ⇒ 六键 hint):${error?.message ?? error}`)
|
|
512
|
+
} catch {
|
|
513
|
+
/* 静默 */
|
|
514
|
+
}
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 解析**这条请求**的角色(每次请求都重新算,不缓存 —— session 的角色可能变,
|
|
521
|
+
* 缓存会让「Bot 会话先按 main 拿到付费」这种错误永久化)。
|
|
522
|
+
*
|
|
523
|
+
* `opts.roleOf` 未提供时退回安装时静态 `opts.role`(向后兼容);
|
|
524
|
+
* 抛错/返回垃圾值 ⇒ `'unknown'`(⇒ free_only,fail-closed)。
|
|
525
|
+
*/
|
|
526
|
+
function resolveRequestRole(sessionId) {
|
|
527
|
+
const roleOf = opts?.roleOf
|
|
528
|
+
if (typeof roleOf !== 'function') return normalizeRole(opts?.role)
|
|
529
|
+
try {
|
|
530
|
+
return normalizeRole(roleOf(sessionId))
|
|
531
|
+
} catch (error) {
|
|
532
|
+
stats.roleOfErrors += 1
|
|
533
|
+
try {
|
|
534
|
+
warn(`[cs-bots] roleOf 解析失败(按 unknown ⇒ free_only 处理):${error?.message ?? error}`)
|
|
535
|
+
} catch {
|
|
536
|
+
/* 静默 */
|
|
537
|
+
}
|
|
538
|
+
return 'unknown'
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** 读注册表里已存在的 hint;无该方法/读失败一律返回 null(fail-open,不拦请求)。 */
|
|
543
|
+
function readExistingHint(registry, sessionId) {
|
|
544
|
+
const getter = registry?.api?.getRequestHint
|
|
545
|
+
if (typeof getter !== 'function') {
|
|
546
|
+
stats.existingUnavailable += 1
|
|
547
|
+
return null
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
return getter(sessionId) ?? null
|
|
551
|
+
} catch {
|
|
552
|
+
stats.existingReadErrors += 1
|
|
553
|
+
return null
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* 核心 handler —— **永远**返回 `next()`。
|
|
559
|
+
* 刻意写成同步函数:`llm/stream` 的 `next()` 返回 `AsyncIterable<StreamChunk>`,
|
|
560
|
+
* 包成 async 会把它变成 Promise<AsyncIterable>,与瀑布签名不符。
|
|
561
|
+
*/
|
|
562
|
+
function handler(options, next) {
|
|
563
|
+
stats.calls += 1
|
|
564
|
+
try {
|
|
565
|
+
if (options?.provider !== MYFREE_PROVIDER) {
|
|
566
|
+
stats.skippedProvider += 1
|
|
567
|
+
} else if (typeof options.sessionId !== 'string' || options.sessionId.length === 0) {
|
|
568
|
+
stats.skippedNoSession += 1
|
|
569
|
+
} else {
|
|
570
|
+
const registry = getRegistry()
|
|
571
|
+
if (!registry) {
|
|
572
|
+
stats.skippedNoRegistry += 1
|
|
573
|
+
} else {
|
|
574
|
+
const sessionId = options.sessionId
|
|
575
|
+
const hint = resolveHint(options, {
|
|
576
|
+
role: resolveRequestRole(sessionId),
|
|
577
|
+
requiresPaid: opts?.requiresPaid,
|
|
578
|
+
explicitComplexity: opts?.explicitComplexity,
|
|
579
|
+
source,
|
|
580
|
+
now: now(),
|
|
581
|
+
// v2 策略(`design/22` 契约 E4):缺省不传 ⇒ hint 只有六键(零回归)
|
|
582
|
+
subagentRouting: typeof opts?.subagentRoutingOf === 'function'
|
|
583
|
+
? safeStrategyOf(opts.subagentRoutingOf, sessionId, stats, warn)
|
|
584
|
+
: opts?.subagentRouting,
|
|
585
|
+
parentBillingTier: opts?.parentBillingTier,
|
|
586
|
+
followParentRouting: opts?.followParentRouting,
|
|
587
|
+
pinnedModel: opts?.pinnedModel,
|
|
588
|
+
})
|
|
589
|
+
if (hint) {
|
|
590
|
+
// ⚠️ P3-fix:写入前先与已有 hint 合并(**只升不降**),否则 claim 写的
|
|
591
|
+
// paid_allowed 会被下一条请求踩成 free_only,「requires_paid 子任务获付费」失效。
|
|
592
|
+
const existing = readExistingHint(registry, sessionId)
|
|
593
|
+
const effective = mergeHint(existing, hint, { now: hint.at, ttlMs: opts?.hintTtlMs })
|
|
594
|
+
if (
|
|
595
|
+
effective?.billing_tier === 'paid_allowed'
|
|
596
|
+
&& hint.billing_tier !== 'paid_allowed'
|
|
597
|
+
) {
|
|
598
|
+
stats.carriedTaskAuth += 1
|
|
599
|
+
}
|
|
600
|
+
registry.api.setRequestHint(sessionId, effective)
|
|
601
|
+
stats.injected += 1
|
|
602
|
+
} else {
|
|
603
|
+
stats.skippedProvider += 1
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} catch (error) {
|
|
608
|
+
stats.errors += 1
|
|
609
|
+
// fail-open:写 hint 失败绝不影响请求
|
|
610
|
+
try {
|
|
611
|
+
warn(`[cs-bots] 写复杂度路由 hint 失败(已忽略,请求照常):${error?.message ?? error}`)
|
|
612
|
+
} catch {
|
|
613
|
+
/* 连日志都失败 → 完全静默 */
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return typeof next === 'function' ? next() : undefined
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let hooked = false
|
|
620
|
+
try {
|
|
621
|
+
hooked = typeof kernel?.onLlmStream === 'function' ? kernel.onLlmStream(ctx, handler) === true : false
|
|
622
|
+
} catch (error) {
|
|
623
|
+
hooked = false
|
|
624
|
+
try {
|
|
625
|
+
warn(`[cs-bots] 注册 llm/stream 拦截失败(降级:不注入 hint):${error?.message ?? error}`)
|
|
626
|
+
} catch {
|
|
627
|
+
/* 静默 */
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (!hooked) {
|
|
632
|
+
return { installed: false, hooked: false, reason: 'llm-stream-hook-unavailable', handler, stats }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let registry = null
|
|
636
|
+
try {
|
|
637
|
+
registry = getRegistry()
|
|
638
|
+
} catch {
|
|
639
|
+
registry = null
|
|
640
|
+
}
|
|
641
|
+
if (!registry) {
|
|
642
|
+
return { installed: false, hooked: true, reason: 'cs-myfree-registry-missing', handler, stats }
|
|
643
|
+
}
|
|
644
|
+
return { installed: true, hooked: true, registryVersion: registry.version, handler, stats }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ── 任务板 → 会话 hint ──────────────────────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* 用**任务字段**给 owner 会话写 hint(claim / wake 时调用)。
|
|
651
|
+
*
|
|
652
|
+
* 契约 §6 末段:「子任务被 claim/wake 时,平台用该任务字段给 owner 会话写 hint
|
|
653
|
+
* (`requiresPaid` ⇒ paid_allowed)」。所以:
|
|
654
|
+
* - `task.expectedComplexity`(T0–T3)→ `complexity_tier`
|
|
655
|
+
* - `task.requiresPaid === true` → `billing_tier = paid_allowed`,否则 `free_only`
|
|
656
|
+
* - owner 默认按 `subagent` 记账(窄执行者),可用 `opts.role` 覆盖(如主 Agent 自领)
|
|
657
|
+
*
|
|
658
|
+
* 无 cs-myfree 注册表 ⇒ `{ ok:false, reason:'cs-myfree-registry-missing' }`(静默跳过,
|
|
659
|
+
* **不抛**:hint 只是优化,任务板照样要能干活)。`setRequestHint` 抛错也一并吞掉并回报原因。
|
|
660
|
+
*
|
|
661
|
+
* @param {object} task BoardService 的 Task
|
|
662
|
+
* @param {string} sessionId 目标会话(claim 时的 owner / wake 时的 target)
|
|
663
|
+
* @param {{ role?: string, source?: string, now?: number, getRegistry?: Function,
|
|
664
|
+
* subagentRouting?: object|null, followParentRouting?: boolean,
|
|
665
|
+
* pinnedModel?: string|null, parentBillingTier?: string }} [opts]
|
|
666
|
+
* v2 策略键(`subagentRouting` / `followParentRouting` / `pinnedModel`)缺省时
|
|
667
|
+
* **不写入**两个新键 ⇒ hint 仍是 v1 六键形状(零回归)。
|
|
668
|
+
* @returns {{ ok: boolean, hint?: object, reason?: string }}
|
|
669
|
+
*/
|
|
670
|
+
export function applyTaskHint(task, sessionId, opts = {}) {
|
|
671
|
+
try {
|
|
672
|
+
if (!task || typeof task !== 'object') return { ok: false, reason: 'no-task' }
|
|
673
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return { ok: false, reason: 'no-session' }
|
|
674
|
+
const getRegistry = typeof opts?.getRegistry === 'function' ? opts.getRegistry : getGlobalHintsRegistry
|
|
675
|
+
const registry = getRegistry()
|
|
676
|
+
if (!registry) return { ok: false, reason: 'cs-myfree-registry-missing' }
|
|
677
|
+
|
|
678
|
+
const tier = normalizeTier(task.expectedComplexity)
|
|
679
|
+
const requiresPaid = task.requiresPaid === true
|
|
680
|
+
const role = normalizeRole(opts?.role ?? 'subagent')
|
|
681
|
+
const at = typeof opts?.now === 'number' && Number.isFinite(opts.now) ? opts.now : Date.now()
|
|
682
|
+
// 任务级策略覆盖(`design/22` 契约 E4):缺省 ⇒ 不写 v2 两键(六键零回归)
|
|
683
|
+
const strategy = subagentRoutingKeys(opts)
|
|
684
|
+
const hint = buildHint({
|
|
685
|
+
billing_tier: billingTierFor({
|
|
686
|
+
tier: tier ?? undefined,
|
|
687
|
+
role,
|
|
688
|
+
requiresPaid,
|
|
689
|
+
subagentRouting: opts?.subagentRouting,
|
|
690
|
+
parentBillingTier: opts?.parentBillingTier,
|
|
691
|
+
}),
|
|
692
|
+
complexity_tier: tier,
|
|
693
|
+
agent_role: role,
|
|
694
|
+
requires_paid: requiresPaid,
|
|
695
|
+
source: opts?.source ?? SOURCE_TASK_BOARD,
|
|
696
|
+
at,
|
|
697
|
+
follow_parent_routing: strategy.follow,
|
|
698
|
+
pinned_model: strategy.pinned,
|
|
699
|
+
})
|
|
700
|
+
registry.api.setRequestHint(sessionId, hint)
|
|
701
|
+
return { ok: true, hint }
|
|
702
|
+
} catch (error) {
|
|
703
|
+
return { ok: false, reason: `hint-write-failed:${error?.message ?? error}` }
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export default installLlmStreamHint
|