dsh-compact-relay 0.5.0 → 0.5.2
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 +380 -0
- package/README.en.md +4 -3
- package/README.md +23 -5
- package/TUTORIAL.md +31 -5
- package/lib/actions/force-archive.js +113 -0
- package/lib/actions/handoff.js +58 -43
- package/lib/actions/pin.js +7 -6
- package/lib/actions/relay-chain.js +17 -0
- package/lib/actions/state.js +138 -16
- package/lib/adapters/http.js +88 -2
- package/lib/client.js +1496 -178
- package/lib/core/clip.js +92 -0
- package/lib/core/config-schema.js +2 -2
- package/lib/core/errors.js +3 -1
- package/lib/core/native-line.js +27 -0
- package/lib/core/native-pin-risk.js +106 -0
- package/lib/core/pin-anchors.js +3 -1
- package/lib/core/pin-health.js +112 -0
- package/lib/core/relay-record.js +128 -0
- package/lib/dsh/compaction-log.js +16 -9
- package/lib/dsh/compaction-pair.js +27 -1
- package/lib/dsh/diag-counters.js +4 -1
- package/lib/dsh/diagnostics.js +3 -1
- package/lib/dsh/handoff-archive.js +47 -1
- package/lib/dsh/handoff-writer.js +1 -1
- package/lib/dsh/model-task.js +14 -10
- package/lib/dsh/native-compaction.js +60 -4
- package/lib/dsh/relay-chain.js +238 -0
- package/lib/dsh/relay.js +108 -4
- package/lib/dsh/route.js +19 -0
- package/lib/dsh/session-view.js +16 -10
- package/lib/dsh/sessions.js +19 -12
- package/package.json +2 -2
|
@@ -151,6 +151,41 @@ export async function archiveHandoffSource(ctx, sessionId, { log, attempts = 2,
|
|
|
151
151
|
return failed(isActiveError(lastError) ? 'active' : 'failed', activityKindsOf(lastError));
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* 宿主侧栏置顶集合(0.5.1 批 2)。
|
|
156
|
+
*
|
|
157
|
+
* 宿主实证(`@deepseek-ai/dsh-workspace/lib/index.js`):`WorkspaceRegistry`
|
|
158
|
+
* - `get pinnedSessionIds()`:registry 全局置顶集合,pin 顺序(最近置顶在前);
|
|
159
|
+
* - `pinSession(id)` / `unpinSession(id)`;
|
|
160
|
+
* - 置顶与归档互斥:`archiveSession` 在**同一次写盘**里把该 id 从 pinnedSessionIds 移除
|
|
161
|
+
* (源码注释「Archiving drops the session's pin in the same durable write」)。
|
|
162
|
+
* 所以「归档会取消置顶」不是本插件的推测,是宿主真实行为;这里只读判断,
|
|
163
|
+
* 不替用户 unpin/pin。拿不到/形状异常 → null(不猜 false)。
|
|
164
|
+
* @param {object} ctx
|
|
165
|
+
* @returns {string[]|null}
|
|
166
|
+
*/
|
|
167
|
+
export function pinnedSessionIdsOf(ctx) {
|
|
168
|
+
try {
|
|
169
|
+
const registry = ctx?.get?.('workspaceRegistry');
|
|
170
|
+
const ids = registry?.pinnedSessionIds;
|
|
171
|
+
return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 旧会话当前是否在侧栏置顶。
|
|
179
|
+
* @param {object} ctx
|
|
180
|
+
* @param {string} sessionId
|
|
181
|
+
* @returns {boolean|null} null = 宿主没提供可读的置顶集合
|
|
182
|
+
*/
|
|
183
|
+
export function isSessionPinned(ctx, sessionId) {
|
|
184
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
|
|
185
|
+
const ids = pinnedSessionIdsOf(ctx);
|
|
186
|
+
return ids === null ? null : ids.includes(sessionId);
|
|
187
|
+
}
|
|
188
|
+
|
|
154
189
|
/**
|
|
155
190
|
* 归档结果 → 交接完成 message 里的一行(成功/失败都不丢信息)。
|
|
156
191
|
* 成功时明确告诉用户恢复入口;失败时固定写「归档失败,不影响交接」。
|
|
@@ -160,11 +195,22 @@ export async function archiveHandoffSource(ctx, sessionId, { log, attempts = 2,
|
|
|
160
195
|
export function handoffArchiveNoteOf(result) {
|
|
161
196
|
const status = result !== null && typeof result === 'object' ? result.status : 'failed';
|
|
162
197
|
if (status === 'archived') {
|
|
163
|
-
|
|
198
|
+
// 0.5.1 DCR-04:指向宿主真实的视图标签/动作文案(实测 dsh-client-ui-workspace:
|
|
199
|
+
// viewOptions 三选一里是「全部对话(显示已归档)」,会话行动作是「取消归档」;
|
|
200
|
+
// 宿主没有「全部会话」这个视图)。
|
|
201
|
+
return '旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回;侧栏视图选「全部对话(显示已归档)」,再对该会话点「取消归档」)。';
|
|
164
202
|
}
|
|
165
203
|
if (status === 'already') {
|
|
166
204
|
return '旧会话此前已在归档中(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回)。';
|
|
167
205
|
}
|
|
206
|
+
// 0.5.1 批 2:用户显式关掉归档(handoff archive:false)。
|
|
207
|
+
if (status === 'skipped') {
|
|
208
|
+
return '按你的选择,旧会话未归档(仍在列表里;侧栏置顶保持不变)。';
|
|
209
|
+
}
|
|
210
|
+
// 0.5.1 批 2:旧会话在侧栏置顶,未经确认不归档(宿主归档会同时取消置顶)。
|
|
211
|
+
if (status === 'pinned') {
|
|
212
|
+
return '旧会话已置顶,本次未自动归档(归档会取消置顶;如要归档,可在交接确认里选「交接并归档(取消置顶)」,或先取消置顶)。';
|
|
213
|
+
}
|
|
168
214
|
const kinds = Array.isArray(result?.activityKinds) && result.activityKinds.length > 0 ? result.activityKinds.join('/') : null;
|
|
169
215
|
if (status === 'active') {
|
|
170
216
|
return `旧会话归档失败,不影响交接(仍有任务在跑:${kinds ?? 'active'};不自动停止任务,旧会话仍在列表里)。`;
|
|
@@ -49,7 +49,7 @@ export async function generateHandoffDoc({ ctx, engine, agent, maxTokens, inputT
|
|
|
49
49
|
const prefix = surfaceMessages(session);
|
|
50
50
|
if (prefix.length === 0) throw actionError('handoff', '这个会话还没有可交接的内容(没有模型可见的消息)');
|
|
51
51
|
|
|
52
|
-
//
|
|
52
|
+
// reasoningEffort 由 prepareModelCall 决定:优先沿用主请求档位(cache 对齐);拿不到 header 才回退「支持 off 就 off」。
|
|
53
53
|
const controls = cacheAlignedControls(session, target);
|
|
54
54
|
let lastProblems = [];
|
|
55
55
|
|
package/lib/dsh/model-task.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* 两个任务不是同一种产物(一个压成六段摘要、一个写九段交接单,校验/重试规则
|
|
6
6
|
* 也不同),所以**不合并重试循环**;但下面四件事完全一样、以前各写一遍:
|
|
7
7
|
* 1. 前置:会话存在性 → 解析 provider/model 路由 → 窗口预算预检 → 推理档位;
|
|
8
|
-
* 2. cache 对齐参数:复用上次请求的 temperature/stop
|
|
9
|
-
*
|
|
8
|
+
* 2. cache 对齐参数:复用上次请求的 temperature/stop;reasoningEffort 由
|
|
9
|
+
* prepareModelCall 显式决定(2026-10-27 起优先沿用主请求的档位,见下);
|
|
10
10
|
* 3. 单次流式调用 + finishProblem 归一(超限/工具调用/中断);
|
|
11
11
|
* 4. 成功结果的公共元信息(provider/model/maxTokens/attempts/usage)。
|
|
12
12
|
*
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { actionError } from '../core/errors.js';
|
|
16
16
|
import { assertBudget, callStream, reasoningEffortFor } from './llm-call.js';
|
|
17
|
-
import { cacheControls, routeOf } from './route.js';
|
|
17
|
+
import { cacheControls, cacheEffortOf, routeOf } from './route.js';
|
|
18
18
|
import { finishProblem } from './stream.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -33,7 +33,7 @@ import { finishProblem } from './stream.js';
|
|
|
33
33
|
* @param {string} input.code 动作错误码
|
|
34
34
|
* @param {string} input.noSessionMessage 会话不存在时的用户可见错误文案
|
|
35
35
|
* @param {string} input.noTargetMessage 拿不到 provider/model 时的用户可见错误文案
|
|
36
|
-
* @returns {Promise<{target:{provider:string, model:string}, reasoningEffort:
|
|
36
|
+
* @returns {Promise<{target:{provider:string, model:string}, reasoningEffort:string|undefined}>}
|
|
37
37
|
*/
|
|
38
38
|
export async function prepareModelCall({
|
|
39
39
|
ctx,
|
|
@@ -53,18 +53,22 @@ export async function prepareModelCall({
|
|
|
53
53
|
|
|
54
54
|
// 架构 A2:调模型前先做窗口预算预检;超窗直接报 budget,不白等一次失败请求。
|
|
55
55
|
await assertBudget({ ctx, target, inputTokens, maxTokens, label });
|
|
56
|
-
//
|
|
57
|
-
|
|
56
|
+
// 2026-10-27 P1 取证:reasoningEffort 会切换 provider 的 thinking 开关,是缓存前缀的
|
|
57
|
+
// 断点。摘要/交接单回放的是主请求的前缀,所以默认**沿用主请求的档位**(cache 对齐);
|
|
58
|
+
// 只有拿不到 header(无请求头 / 模型已切走)时,才退回旧策略:
|
|
59
|
+
// P2-5 的「模型目录支持 off 就 off」(省思考 token;这是从 P1 之前的行为,保留作兜底)。
|
|
60
|
+
const headerEffort = cacheEffortOf(session, target);
|
|
61
|
+
const reasoningEffort = headerEffort !== undefined ? headerEffort : await reasoningEffortFor(ctx, target);
|
|
58
62
|
|
|
59
63
|
return { target, reasoningEffort };
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
63
|
-
* cache 对齐参数:复用上次请求的 temperature/stop
|
|
67
|
+
* cache 对齐参数:复用上次请求的 temperature/stop;reasoningEffort 不从这里拿。
|
|
64
68
|
*
|
|
65
|
-
* 2026-10-
|
|
66
|
-
*
|
|
67
|
-
*
|
|
69
|
+
* reasoningEffort 由 prepareModelCall 决定(2026-10-27 起优先与主请求一致,保证缓存命中)。
|
|
70
|
+
* 2026-10-19 的旧坑(max 思考把 maxTokens 吃光)已由「summary/handoff.maxTokens 提到
|
|
71
|
+
* 16K + 保留截断重试」兜住,不再是「一律 off」的理由。
|
|
68
72
|
*
|
|
69
73
|
* @param {object} session
|
|
70
74
|
* @param {{provider:string, model:string}} target
|
|
@@ -71,6 +71,9 @@ const CONFIG_RE = /^(\s*)config\s*:\s*(?:#.*)?$/;
|
|
|
71
71
|
const AUTO_RE = /^auto\s*:\s*(true|false)\s*(?:#.*)?$/;
|
|
72
72
|
const RATIO_RE = /^thresholdRatio\s*:\s*['"]?([0-9.]+)['"]?\s*(?:#.*)?$/;
|
|
73
73
|
const HEADROOM_RE = /^headroomTokens\s*:\s*['"]?(\d+)['"]?\s*(?:#.*)?$/;
|
|
74
|
+
/** 0.5.1 批 2:保留近期尾部的两种写法(compaction-basic 的 retainRatio / retainTokens)。 */
|
|
75
|
+
const RETAIN_RATIO_RE = /^retainRatio\s*:\s*['"]?([0-9.]+)['"]?\s*(?:#.*)?$/;
|
|
76
|
+
const RETAIN_TOKENS_RE = /^retainTokens\s*:\s*['"]?(\d+)['"]?\s*(?:#.*)?$/;
|
|
74
77
|
/** modelPolicies 列表项里的键(- provider: x / model: y / thresholdRatio: 0.5 / headroomTokens: 4096)。 */
|
|
75
78
|
const POLICY_PROVIDER_RE = /^provider\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/;
|
|
76
79
|
const POLICY_MODEL_RE = /^model\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/;
|
|
@@ -196,6 +199,23 @@ export function parseCompactionOverride(text) {
|
|
|
196
199
|
if (Number.isInteger(value) && value >= 0) override.headroomTokens = value;
|
|
197
200
|
}
|
|
198
201
|
}
|
|
202
|
+
// 0.5.1 批 2:原生压缩保留尾部预算(预判「会覆盖哪些 pin」用;不改原生行为)。
|
|
203
|
+
const retainRatioIndex = findDirectChild(lines, entry, config, RETAIN_RATIO_RE);
|
|
204
|
+
if (retainRatioIndex >= 0) {
|
|
205
|
+
const match = RETAIN_RATIO_RE.exec(lines[retainRatioIndex].trim());
|
|
206
|
+
if (match !== null) {
|
|
207
|
+
const value = Number(match[1]);
|
|
208
|
+
if (Number.isFinite(value) && value >= 0 && value < 1) override.retainRatio = value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const retainTokensIndex = findDirectChild(lines, entry, config, RETAIN_TOKENS_RE);
|
|
212
|
+
if (retainTokensIndex >= 0) {
|
|
213
|
+
const match = RETAIN_TOKENS_RE.exec(lines[retainTokensIndex].trim());
|
|
214
|
+
if (match !== null) {
|
|
215
|
+
const value = Number(match[1]);
|
|
216
|
+
if (Number.isInteger(value) && value >= 0) override.retainTokens = value;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
199
219
|
return override;
|
|
200
220
|
}
|
|
201
221
|
|
|
@@ -230,7 +250,14 @@ export function parseModelPolicies(text) {
|
|
|
230
250
|
let current = null;
|
|
231
251
|
const flush = () => {
|
|
232
252
|
if (current === null) return;
|
|
233
|
-
if (
|
|
253
|
+
if (
|
|
254
|
+
current.thresholdRatio !== undefined ||
|
|
255
|
+
current.headroomTokens !== undefined ||
|
|
256
|
+
current.retainRatio !== undefined ||
|
|
257
|
+
current.retainTokens !== undefined
|
|
258
|
+
) {
|
|
259
|
+
policies.push(current);
|
|
260
|
+
}
|
|
234
261
|
current = null;
|
|
235
262
|
};
|
|
236
263
|
for (let index = start + 1; index < entry.end; index += 1) {
|
|
@@ -253,9 +280,18 @@ export function parseModelPolicies(text) {
|
|
|
253
280
|
const trimmed = line.trim();
|
|
254
281
|
const ratioMatch = RATIO_RE.exec(trimmed);
|
|
255
282
|
const headMatch = HEADROOM_RE.exec(trimmed);
|
|
283
|
+
const retainRatioMatch = RETAIN_RATIO_RE.exec(trimmed);
|
|
284
|
+
const retainTokensMatch = RETAIN_TOKENS_RE.exec(trimmed);
|
|
256
285
|
const providerMatch = POLICY_PROVIDER_RE.exec(trimmed);
|
|
257
286
|
const modelMatch = POLICY_MODEL_RE.exec(trimmed);
|
|
258
|
-
if (
|
|
287
|
+
if (
|
|
288
|
+
ratioMatch !== null ||
|
|
289
|
+
headMatch !== null ||
|
|
290
|
+
retainRatioMatch !== null ||
|
|
291
|
+
retainTokensMatch !== null ||
|
|
292
|
+
providerMatch !== null ||
|
|
293
|
+
modelMatch !== null
|
|
294
|
+
) {
|
|
259
295
|
if (current === null) current = { provider: null, model: null };
|
|
260
296
|
if (ratioMatch !== null) {
|
|
261
297
|
const value = Number(ratioMatch[1]);
|
|
@@ -263,6 +299,12 @@ export function parseModelPolicies(text) {
|
|
|
263
299
|
} else if (headMatch !== null) {
|
|
264
300
|
const value = Number(headMatch[1]);
|
|
265
301
|
if (Number.isInteger(value) && value >= 0) current.headroomTokens = value;
|
|
302
|
+
} else if (retainRatioMatch !== null) {
|
|
303
|
+
const value = Number(retainRatioMatch[1]);
|
|
304
|
+
if (Number.isFinite(value) && value >= 0 && value < 1) current.retainRatio = value;
|
|
305
|
+
} else if (retainTokensMatch !== null) {
|
|
306
|
+
const value = Number(retainTokensMatch[1]);
|
|
307
|
+
if (Number.isInteger(value) && value >= 0) current.retainTokens = value;
|
|
266
308
|
} else if (providerMatch !== null) current.provider = providerMatch[1];
|
|
267
309
|
else current.model = modelMatch[1];
|
|
268
310
|
continue;
|
|
@@ -286,8 +328,13 @@ export function parseModelPolicies(text) {
|
|
|
286
328
|
export function resolveNativePolicy(nativeCfg, route) {
|
|
287
329
|
const globalRatio = Number.isFinite(nativeCfg?.thresholdRatio) ? nativeCfg.thresholdRatio : NATIVE_DEFAULTS.thresholdRatio;
|
|
288
330
|
const globalHeadroom = Number.isFinite(nativeCfg?.headroomTokens) ? nativeCfg.headroomTokens : NATIVE_HEADROOM_DEFAULT;
|
|
331
|
+
// 0.5.1 批 2:保留尾部预算(不配置 = undefined,由 core/native-line 按宿主默认比值算)。
|
|
332
|
+
const globalRetain = {
|
|
333
|
+
...(Number.isFinite(nativeCfg?.retainRatio) ? { retainRatio: nativeCfg.retainRatio } : {}),
|
|
334
|
+
...(Number.isInteger(nativeCfg?.retainTokens) ? { retainTokens: nativeCfg.retainTokens } : {}),
|
|
335
|
+
};
|
|
289
336
|
if (route === null || route === undefined || typeof route.provider !== 'string' || typeof route.model !== 'string') {
|
|
290
|
-
return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false };
|
|
337
|
+
return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false, ...globalRetain };
|
|
291
338
|
}
|
|
292
339
|
const list = Array.isArray(nativeCfg?.modelPolicies) ? nativeCfg.modelPolicies : [];
|
|
293
340
|
const hit = list.find(
|
|
@@ -297,11 +344,17 @@ export function resolveNativePolicy(nativeCfg, route) {
|
|
|
297
344
|
(policy.model === route.model || policy.model === `${route.provider}/${route.model}` || policy.model === null || policy.model === undefined) &&
|
|
298
345
|
(policy.provider === undefined || policy.provider === null || policy.provider === route.provider),
|
|
299
346
|
);
|
|
300
|
-
if (hit === undefined) return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false };
|
|
347
|
+
if (hit === undefined) return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false, ...globalRetain };
|
|
348
|
+
const routeRetain = {
|
|
349
|
+
...(Number.isFinite(hit.retainRatio) ? { retainRatio: hit.retainRatio } : {}),
|
|
350
|
+
...(Number.isInteger(hit.retainTokens) ? { retainTokens: hit.retainTokens } : {}),
|
|
351
|
+
};
|
|
301
352
|
return {
|
|
302
353
|
thresholdRatio: Number.isFinite(hit.thresholdRatio) ? hit.thresholdRatio : globalRatio,
|
|
303
354
|
headroomTokens: Number.isFinite(hit.headroomTokens) ? hit.headroomTokens : globalHeadroom,
|
|
304
355
|
overridden: true,
|
|
356
|
+
...globalRetain,
|
|
357
|
+
...routeRetain,
|
|
305
358
|
};
|
|
306
359
|
}
|
|
307
360
|
|
|
@@ -331,6 +384,9 @@ export function readNativeCompaction(profileDir = resolveProfileDir()) {
|
|
|
331
384
|
thresholdRatio: override.thresholdRatio ?? NATIVE_DEFAULTS.thresholdRatio,
|
|
332
385
|
// 0.4 P2-3:真实触发线要用的余量与路由级覆盖(additive 字段,老调用方不受影响)。
|
|
333
386
|
headroomTokens: override.headroomTokens ?? NATIVE_HEADROOM_DEFAULT,
|
|
387
|
+
// 0.5.1 批 2:保留尾部预算(未配置时 undefined,由 nativeRetainTokensOf 按宿主默认比值算)。
|
|
388
|
+
...(override.retainRatio === undefined ? {} : { retainRatio: override.retainRatio }),
|
|
389
|
+
...(override.retainTokens === undefined ? {} : { retainTokens: override.retainTokens }),
|
|
334
390
|
modelPolicies: parseModelPolicies(text),
|
|
335
391
|
override,
|
|
336
392
|
file,
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 完整交接链的服务端读取(0.5.1 DCR-05)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:0.5.1 批 3 的「完整交接链」由客户端逐级 GET `/api/compact-relay-state?sessionId=父id`。
|
|
5
|
+
* 宿主对**没加载进内存**的会话返回 `sessionId:null`(readState 的 target 为 null),
|
|
6
|
+
* 客户端只能停在断点。重启后祖先会话通常都没加载,因此「只显示 1 层」是常态而非例外。
|
|
7
|
+
*
|
|
8
|
+
* 宿主实证(DeepSeek Harness 0.2.1-rc.3 app.asar):
|
|
9
|
+
* - `@deepseek-ai/dsh-session-persistence`:根服务 `ctx.sessionPersistence`
|
|
10
|
+
* - `stat(id)` → `{header, revision, sizeBytes?}`,**不读事件日志**;
|
|
11
|
+
* - `open(id, 'read')` → 只读 handle(不创建 live Session、不写任何状态),
|
|
12
|
+
* `handle.read()` → `{events, eventState}`,`handle.close()` 释放。
|
|
13
|
+
* - `@deepseek-ai/dsh-session-persistence-jsonl` 对**已归档**会话同样可读:归档只改
|
|
14
|
+
* Workspace registry 的集合,不动持久化文件。
|
|
15
|
+
*
|
|
16
|
+
* 所以整条链可以完全在服务端按「header + 日志尾部」读出来,不需要加载祖先会话。
|
|
17
|
+
* 这里只是只读读取:不 append、不 open 写句柄、不改归档/置顶/标题。
|
|
18
|
+
*
|
|
19
|
+
* 链接形状与客户端原逐级逻辑一致:
|
|
20
|
+
* - 每一行(祖先)的 ordinal/baseTitle/scheme 来自**子会话的入站 relay 记录**;
|
|
21
|
+
* - 交接单 path 优先用这条入站记录自己的 path,缺了再向父会话日志里找
|
|
22
|
+
* `childId===子id && ordinal===该跳序号` 的出站记录补(0.3/0.4 老记录没写 path);
|
|
23
|
+
* - 父会话自己的入站记录(或 DCR-02 老链的 header+标题兜底)继续往更早走。
|
|
24
|
+
*/
|
|
25
|
+
import { RELAY_RECORD_TYPE } from '../core/relay.js';
|
|
26
|
+
import { legacyRelayChainOf, normalizeRelayRecord, relayChainOf, relayInboundRecordOf } from './relay.js';
|
|
27
|
+
import { liveSessionOf, sessionLog } from './session-view.js';
|
|
28
|
+
import { sourceTitleOf } from './sessions.js';
|
|
29
|
+
|
|
30
|
+
/** 最多回看 8 层(与客户端 MAX_CHAIN_HOPS 同口径:防成环、防无限链)。 */
|
|
31
|
+
export const MAX_CHAIN_HOPS = 8;
|
|
32
|
+
|
|
33
|
+
/** 取宿主 sessionPersistence 服务;旧宿主/headless 测试没有 → null。 */
|
|
34
|
+
function sessionPersistenceOf(ctx) {
|
|
35
|
+
try {
|
|
36
|
+
const service =
|
|
37
|
+
typeof ctx?.get === 'function' ? ctx.get('sessionPersistence') : ctx?.sessionPersistence;
|
|
38
|
+
if (service === null || service === undefined) return null;
|
|
39
|
+
if (typeof service.open !== 'function') return null;
|
|
40
|
+
return service;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isNotFoundError(error) {
|
|
47
|
+
return (
|
|
48
|
+
error !== null &&
|
|
49
|
+
typeof error === 'object' &&
|
|
50
|
+
(error.name === 'SessionPersistenceNotFoundError' || error.code === 'session/persistence-not-found')
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 只读打开一个**未加载**的会话;返回 header + 完整事件日志(不进入 ctx.sessions)。 */
|
|
55
|
+
async function readStoredSession(ctx, sessionId) {
|
|
56
|
+
const persistence = sessionPersistenceOf(ctx);
|
|
57
|
+
if (persistence === null) return { unsupported: true };
|
|
58
|
+
let handle = null;
|
|
59
|
+
try {
|
|
60
|
+
let snapshot;
|
|
61
|
+
if (typeof persistence.stat === 'function') {
|
|
62
|
+
snapshot = await persistence.stat(sessionId);
|
|
63
|
+
if (snapshot === null || snapshot === undefined) return { missing: true };
|
|
64
|
+
}
|
|
65
|
+
handle = await persistence.open(sessionId, 'read');
|
|
66
|
+
const result = await handle.read();
|
|
67
|
+
const events = Array.isArray(result?.events) ? result.events : [];
|
|
68
|
+
const header =
|
|
69
|
+
snapshot !== null && typeof snapshot === 'object' && snapshot.header !== null && typeof snapshot.header === 'object'
|
|
70
|
+
? snapshot.header
|
|
71
|
+
: handle?.header;
|
|
72
|
+
return { id: sessionId, header: header ?? null, events, live: false };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (isNotFoundError(error)) return { missing: true };
|
|
75
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
76
|
+
} finally {
|
|
77
|
+
if (handle !== null) {
|
|
78
|
+
try {
|
|
79
|
+
await handle.close();
|
|
80
|
+
} catch {
|
|
81
|
+
/* 只读句柄释放失败不影响读取结果 */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 会话的链读取素材:优先 live(内存里最新,刚写入未落盘的记录也在),
|
|
89
|
+
* 否则只读持久化日志。
|
|
90
|
+
* @returns {Promise<{id:string, header:object|null, events:Array, live:boolean, session?:object}|{missing:true}|{error:string}|{unsupported:true}>}
|
|
91
|
+
*/
|
|
92
|
+
async function sessionInfoOf(ctx, sessionId) {
|
|
93
|
+
const live = liveSessionOf(ctx, sessionId);
|
|
94
|
+
if (live !== undefined && live !== null) {
|
|
95
|
+
return {
|
|
96
|
+
id: sessionId,
|
|
97
|
+
header: live.header !== null && typeof live.header === 'object' ? live.header : null,
|
|
98
|
+
events: sessionLog(live),
|
|
99
|
+
live: true,
|
|
100
|
+
session: live,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return readStoredSession(ctx, sessionId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 会话当前标题(老链兜底合成用):live 走标题服务,持久化日志取最后一条 session/title。 */
|
|
107
|
+
function titleOf(ctx, info) {
|
|
108
|
+
if (info.live === true && info.session !== undefined) {
|
|
109
|
+
const title = sourceTitleOf(ctx, info.session);
|
|
110
|
+
if (title !== undefined) return title;
|
|
111
|
+
}
|
|
112
|
+
const events = Array.isArray(info.events) ? info.events : [];
|
|
113
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
114
|
+
const event = events[index];
|
|
115
|
+
if (event?.type === 'session/title' && typeof event.data?.title === 'string' && event.data.title.length > 0) {
|
|
116
|
+
return event.data.title;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 这一层会话「自己的入站链」:优先 relay 记录;0.5.0 之前的老链没有记录时,
|
|
124
|
+
* 用 header.parentSession + 标题(「交接第 N 次」/「历史 N」)兜底合成。
|
|
125
|
+
* 合成条件与 readState 完全一致(排除 subagent / fork / 干净标题),见 relay.js。
|
|
126
|
+
*/
|
|
127
|
+
function chainCursorOf(ctx, info) {
|
|
128
|
+
const inbound = relayInboundRecordOf({ id: info.id, log: info.events });
|
|
129
|
+
if (inbound !== null) return relayChainOf(inbound);
|
|
130
|
+
const legacy = legacyRelayChainOf({
|
|
131
|
+
session: { id: info.id, header: info.header },
|
|
132
|
+
title: titleOf(ctx, info),
|
|
133
|
+
});
|
|
134
|
+
return legacy === null ? null : relayChainOf(legacy);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 父会话日志里「交给 childId 的那一跳」的出站记录(用于补交接单 path)。
|
|
139
|
+
* 必须同时校验 childId + ordinal:父会话后来可能又交接给了下一代。
|
|
140
|
+
*/
|
|
141
|
+
function outboundRecordFor(info, childId, ordinal) {
|
|
142
|
+
const events = Array.isArray(info.events) ? info.events : [];
|
|
143
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
144
|
+
const event = events[index];
|
|
145
|
+
if (event?.type !== RELAY_RECORD_TYPE) continue;
|
|
146
|
+
const record = normalizeRelayRecord(event.data);
|
|
147
|
+
if (record !== null && record.childId === childId && record.ordinal === ordinal) return record;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 行投影:只下发客户端画一行需要的字段(与旧逐级 GET 的 row 形状一致)。 */
|
|
153
|
+
function rowOf(sessionId, cursor, path) {
|
|
154
|
+
return {
|
|
155
|
+
sessionId,
|
|
156
|
+
ordinal: cursor.ordinal,
|
|
157
|
+
baseTitle: cursor.baseTitle ?? null,
|
|
158
|
+
scheme: cursor.scheme ?? null,
|
|
159
|
+
path: path ?? null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 一次读出整条交接链(最多 8 层)。
|
|
165
|
+
*
|
|
166
|
+
* 不加载任何祖先会话:未加载的会话从持久化日志读 header/事件;已加载的直接用内存日志。
|
|
167
|
+
* 读不到某一层时返回**已读到的行** + `unreadable`(不是把整块清空),客户端据此显示
|
|
168
|
+
* 「上一层会话未打开」+「打开并继续」入口。
|
|
169
|
+
*
|
|
170
|
+
* @param {object} ctx 根上下文
|
|
171
|
+
* @param {{sessionId?:string, maxHops?:number}} [options]
|
|
172
|
+
* @returns {Promise<{
|
|
173
|
+
* ok:true,
|
|
174
|
+
* sessionId:string,
|
|
175
|
+
* rows:Array<{sessionId:string, ordinal:number|null, baseTitle:string|null, scheme:string|null, path:string|null}>,
|
|
176
|
+
* done:boolean,
|
|
177
|
+
* capped:boolean,
|
|
178
|
+
* unreadable:{sessionId:string, reason:'not-found'|'error'}|null
|
|
179
|
+
* } | {ok:false, code:string, message:string}>}
|
|
180
|
+
*/
|
|
181
|
+
export async function readRelayChain(ctx, { sessionId, maxHops = MAX_CHAIN_HOPS } = {}) {
|
|
182
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
183
|
+
return { ok: false, code: 'bad-session', message: '缺少 sessionId' };
|
|
184
|
+
}
|
|
185
|
+
if (sessionPersistenceOf(ctx) === null) {
|
|
186
|
+
return { ok: false, code: 'unsupported', message: '当前宿主没有 sessionPersistence,无法在不加载会话的情况下读取接力链' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const start = await sessionInfoOf(ctx, sessionId);
|
|
190
|
+
if (start?.unsupported === true) {
|
|
191
|
+
return { ok: false, code: 'unsupported', message: '当前宿主没有 sessionPersistence,无法在不加载会话的情况下读取接力链' };
|
|
192
|
+
}
|
|
193
|
+
if (start?.missing === true) {
|
|
194
|
+
return { ok: false, code: 'not-found', message: '宿主持久化里找不到这个会话' };
|
|
195
|
+
}
|
|
196
|
+
if (start?.error !== undefined) {
|
|
197
|
+
return { ok: false, code: 'read-failed', message: `读取会话日志失败:${start.error}` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const limit = Number.isInteger(maxHops) && maxHops > 0 ? Math.min(MAX_CHAIN_HOPS, maxHops) : MAX_CHAIN_HOPS;
|
|
201
|
+
let cursor = chainCursorOf(ctx, start);
|
|
202
|
+
let childId = sessionId;
|
|
203
|
+
const rows = [];
|
|
204
|
+
const seen = new Set([sessionId]);
|
|
205
|
+
const partial = (unreadable) => ({
|
|
206
|
+
ok: true,
|
|
207
|
+
sessionId,
|
|
208
|
+
rows,
|
|
209
|
+
done: false,
|
|
210
|
+
capped: false,
|
|
211
|
+
unreadable,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
while (cursor !== null && cursor.parentId !== null) {
|
|
215
|
+
if (rows.length >= limit) {
|
|
216
|
+
return { ok: true, sessionId, rows, done: true, capped: true, unreadable: null };
|
|
217
|
+
}
|
|
218
|
+
const parentId = cursor.parentId;
|
|
219
|
+
// 链路成环/自指:去重后停在最后一条真实会话,不再重复列同一层。
|
|
220
|
+
if (seen.has(parentId)) {
|
|
221
|
+
return { ok: true, sessionId, rows, done: true, capped: false, unreadable: null };
|
|
222
|
+
}
|
|
223
|
+
const info = await sessionInfoOf(ctx, parentId);
|
|
224
|
+
if (info?.missing === true) return partial({ sessionId: parentId, reason: 'not-found' });
|
|
225
|
+
if (info?.error !== undefined) return partial({ sessionId: parentId, reason: 'error' });
|
|
226
|
+
if (info?.unsupported === true) {
|
|
227
|
+
return { ok: false, code: 'unsupported', message: '当前宿主没有 sessionPersistence,无法在不加载会话的情况下读取接力链' };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const outbound = outboundRecordFor(info, childId, cursor.ordinal);
|
|
231
|
+
rows.push(rowOf(parentId, cursor, cursor.path ?? outbound?.path ?? null));
|
|
232
|
+
seen.add(parentId);
|
|
233
|
+
childId = parentId;
|
|
234
|
+
cursor = chainCursorOf(ctx, info);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { ok: true, sessionId, rows, done: true, capped: false, unreadable: null };
|
|
238
|
+
}
|
package/lib/dsh/relay.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 写:engine.appendPluginRecord(和压缩判断记录/pin 同一个宿主 API)。
|
|
6
6
|
* 记录缺失/损坏一律当作没有(旧会话兼容),不阻断交接。
|
|
7
7
|
*/
|
|
8
|
-
import { RELAY_RECORD_TYPE } from '../core/relay.js';
|
|
8
|
+
import { RELAY_RECORD_TYPE, splitHandoffTitle } from '../core/relay.js';
|
|
9
9
|
|
|
10
10
|
/** relay 记录里允许的标题方案(0.5.0 命名反转的两种新标题;legacy 不写进记录)。 */
|
|
11
11
|
const RECORD_SCHEMES = new Set(['handoff-n', 'history-n']);
|
|
@@ -60,22 +60,126 @@ export function normalizeRelayRecord(data) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
63
|
+
* 从日志尾部向前找第一条满足条件的 relay 记录(没有返回 null)。
|
|
64
|
+
*
|
|
65
|
+
* 拆出来给入站/出站两个读取器共用:只扫一遍日志,判定由调用方给(DCR-01)。
|
|
64
66
|
* @param {object|undefined} session
|
|
67
|
+
* @param {(record:object)=>boolean} predicate
|
|
65
68
|
* @returns {object|null}
|
|
66
69
|
*/
|
|
67
|
-
|
|
70
|
+
function latestRelayRecordWhere(session, predicate) {
|
|
68
71
|
const log = session?.log;
|
|
69
72
|
if (!Array.isArray(log)) return null;
|
|
70
73
|
for (let index = log.length - 1; index >= 0; index -= 1) {
|
|
71
74
|
const event = log[index];
|
|
72
75
|
if (event?.type !== RELAY_RECORD_TYPE) continue;
|
|
73
76
|
const record = normalizeRelayRecord(event.data);
|
|
74
|
-
if (record !== null) return record;
|
|
77
|
+
if (record !== null && predicate(record)) return record;
|
|
75
78
|
}
|
|
76
79
|
return null;
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
/**
|
|
83
|
+
* 会话里最近一条接力链记录(没有返回 null)。
|
|
84
|
+
*
|
|
85
|
+
* **语义保持不变**(0.5.1 DCR-01 有意不改):这是「最近一条记录」,出站/入站都可能。
|
|
86
|
+
* `readState.relay` 与 `handoffTitlePlanOf` 的链号递增都依赖它(下一次交接要看最近
|
|
87
|
+
* 一跳,可能是本会话交接出去的那条)。
|
|
88
|
+
* @param {object|undefined} session
|
|
89
|
+
* @returns {object|null}
|
|
90
|
+
*/
|
|
91
|
+
export function relayRecordOf(session) {
|
|
92
|
+
return latestRelayRecordWhere(session, () => true);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 本会话的**入站**接力记录:`childId === session.id` 的最新一条(没有返回 null)。
|
|
97
|
+
*
|
|
98
|
+
* 0.5.1 DCR-01:祖先会话再次交接后,日志最新一条变成出站记录(childId=下一代),
|
|
99
|
+
* 旧实现把它当链信息下发给客户端 → 客户端的「是不是本会话的入站记录」判定不成立
|
|
100
|
+
* → 接力条消失。接力条要看的永远是「我是被谁交接出来的」,所以按 childId 认。
|
|
101
|
+
* @param {object|undefined} session
|
|
102
|
+
* @returns {object|null}
|
|
103
|
+
*/
|
|
104
|
+
export function relayInboundRecordOf(session) {
|
|
105
|
+
const id = session?.id;
|
|
106
|
+
if (typeof id !== 'string' || id.length === 0) return null;
|
|
107
|
+
return latestRelayRecordWhere(session, (record) => record.childId === id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 本会话的**出站**接力记录:`childId !== session.id` 的最新一条(没有返回 null)。
|
|
112
|
+
*
|
|
113
|
+
* 0.5.1 DCR-01:作为 `readState.next` 下发(只加不改),供「父→子」入口与
|
|
114
|
+
* 「子会话向父会话补交接单 path」使用;接力条本身不看它。
|
|
115
|
+
* @param {object|undefined} session
|
|
116
|
+
* @returns {object|null}
|
|
117
|
+
*/
|
|
118
|
+
export function relayOutboundRecordOf(session) {
|
|
119
|
+
const id = session?.id;
|
|
120
|
+
if (typeof id !== 'string' || id.length === 0) return null;
|
|
121
|
+
return latestRelayRecordWhere(session, (record) => record.childId !== id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 会话头是否具备「可能是一条老链」的血缘(DCR-02 的前置条件,不看标题)。
|
|
126
|
+
*
|
|
127
|
+
* 单独导出是为了让 `readState` 在**没有入站记录**时先做这道便宜的判断,只有可能
|
|
128
|
+
* 是老链才去读标题服务(普通根会话占多数,不该为每条状态读多打一次标题查找)。
|
|
129
|
+
*
|
|
130
|
+
* @param {object|undefined} session
|
|
131
|
+
* @returns {string|null} `header.parentSession`(合法时),否则 null
|
|
132
|
+
*/
|
|
133
|
+
export function legacyChainParentIdOf(session) {
|
|
134
|
+
const header = session?.header;
|
|
135
|
+
if (header === null || typeof header !== 'object') return null;
|
|
136
|
+
const parentId = typeof header.parentSession === 'string' ? header.parentSession : '';
|
|
137
|
+
if (parentId.length === 0) return null;
|
|
138
|
+
// subagent 会话有 parentSession(指向派它的父会话),但它不在人看的接力链上。
|
|
139
|
+
if (header.origin === 'subagent') return null;
|
|
140
|
+
// fork(isSeeded=true)继承的是前缀快照,血缘也不是「交接」。
|
|
141
|
+
if (header.isSeeded === true) return null;
|
|
142
|
+
return parentId;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 0.5.1 DCR-02:0.5.0 之前生成的老链没有 relay record —— 用会话头 + 标题合成只读 chain。
|
|
147
|
+
*
|
|
148
|
+
* 为什么需要:0.4.x 只在**父会话**日志里写记录,子会话自己没有记录;升级到 0.5.0 后
|
|
149
|
+
* 这些老链会话 `chain=null`,接力条永远不显示。这里只做**只读兜底**(不写日志、
|
|
150
|
+
* 不改任何记录),条件全部满足才合成:
|
|
151
|
+
* 1. `header.parentSession` 是非空字符串(宿主血缘字段);
|
|
152
|
+
* 2. `header.origin !== 'subagent'`(subagent 会话不是交接链);
|
|
153
|
+
* 3. `header.isSeeded !== true`(宿主 `isSeeded` = fork 继承前缀,不是交接出来的);
|
|
154
|
+
* 4. 会话标题能解析出链号(`splitHandoffTitle(...).handedOff > 0`)——
|
|
155
|
+
* 老链子会话标题是「交接第 N 次:X」;干净标题说明不是交接出来的会话,不猜。
|
|
156
|
+
*
|
|
157
|
+
* 合成链不带 `path`:老链的交接单路径只写在父会话记录里,客户端缺 path 时会按
|
|
158
|
+
* 入站记录向父会话只读回查(`loadParentPath`)。带 `synthesized:true` 只是给
|
|
159
|
+
* 调试/测试一个明确标记(客户端投影忽略未知字段)。
|
|
160
|
+
*
|
|
161
|
+
* @param {{session?:object, title?:string}} input session 会话;title 当前标题(读不到传 undefined)
|
|
162
|
+
* @returns {{ordinal:number, baseTitle:string, parentId:string, childId:string, scheme:'handoff-n'|'history-n', synthesized:true}|null}
|
|
163
|
+
*/
|
|
164
|
+
export function legacyRelayChainOf({ session, title } = {}) {
|
|
165
|
+
const parentId = legacyChainParentIdOf(session);
|
|
166
|
+
if (parentId === null) return null;
|
|
167
|
+
const childId = typeof session?.id === 'string' && session.id.length > 0 ? session.id : null;
|
|
168
|
+
if (childId === null) return null;
|
|
169
|
+
const split = splitHandoffTitle(title);
|
|
170
|
+
if (!(split.handedOff > 0)) return null;
|
|
171
|
+
return {
|
|
172
|
+
ordinal: split.handedOff,
|
|
173
|
+
baseTitle: split.baseTitle,
|
|
174
|
+
parentId,
|
|
175
|
+
childId,
|
|
176
|
+
// 老链只有「交接第 N 次」;标题是「历史 N」时仍是新链形态(record 丢了)——
|
|
177
|
+
// legacy 堆叠(「交接:」)按旧链展示(客户端文案只区分 history-n / 其它)。
|
|
178
|
+
scheme: split.scheme === 'history-n' ? 'history-n' : 'handoff-n',
|
|
179
|
+
synthesized: true,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
79
183
|
/**
|
|
80
184
|
* 0.5.0 F4:把一条 relay 记录投影成只读的「接力链节点」(交给 readState 下发)。
|
|
81
185
|
*
|
package/lib/dsh/route.js
CHANGED
|
@@ -51,6 +51,25 @@ export function routeOf(ctx, session, agent) {
|
|
|
51
51
|
return asRoute(agent?.options);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 复用「上一条真实请求」的推理档位(cache 对齐用)。
|
|
57
|
+
*
|
|
58
|
+
* 2026-10-27 P1 取证:摘要/交接单回放的是上一条主请求的前缀,但 reasoningEffort
|
|
59
|
+
* 会切换 provider 的 thinking 开关;一旦与主请求不同,缓存前缀会在第一条
|
|
60
|
+
* assistant 消息的渲染处断开(生产实测命中 2–5%,隔离 A/B:off 74.0% vs max 96.9%)。
|
|
61
|
+
* 所以只要 header 里的 provider/model 与本次目标一致,就原样沿用它的 reasoningEffort。
|
|
62
|
+
*
|
|
63
|
+
* @param {object} session
|
|
64
|
+
* @param {{provider: string, model: string}} target
|
|
65
|
+
* @returns {string|undefined}
|
|
66
|
+
*/
|
|
67
|
+
export function cacheEffortOf(session, target) {
|
|
68
|
+
const config = session?.requestHeader?.()?.config;
|
|
69
|
+
if (config === undefined || config.provider !== target.provider || config.model !== target.model) return undefined;
|
|
70
|
+
return typeof config.reasoningEffort === 'string' && config.reasoningEffort.length > 0 ? config.reasoningEffort : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
/**
|
|
55
74
|
* 复用上次真实请求的采样控制参数,让附带调用尽量贴近它的前缀缓存。
|
|
56
75
|
* @param {object} session
|