@sema-agent/client-core 0.13.0 → 0.14.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/README.md +1 -0
- package/dist/adapt/arms.d.ts +10 -1
- package/dist/adapt/arms.js +12 -2
- package/dist/adapt/panelTasks.d.ts +6 -1
- package/dist/adapt/textStream.d.ts +15 -1
- package/dist/adapt/turnFlags.d.ts +5 -1
- package/dist/adapt/wireShapes.d.ts +4 -1
- package/dist/adapt.d.ts +1 -1
- package/dist/adapt.js +7 -2
- package/dist/adapter/downstream/eventToSdkMessage.d.ts +4 -2
- package/dist/adapter/downstream/eventToSdkMessage.js +9 -1
- package/dist/adapter/downstream/terminalToSdkResult.js +4 -2
- package/dist/adapter/runStream.d.ts +8 -1
- package/dist/adapter/runStream.js +70 -4
- package/dist/adapter/types.d.ts +26 -0
- package/dist/hitl/approvalsFeed.d.ts +22 -1
- package/dist/hitl/approvalsFeed.js +76 -5
- package/dist/hitl/frameRouter.js +49 -8
- package/dist/hitl/gateLedger.d.ts +13 -1
- package/dist/hitl/gateLedger.js +2 -2
- package/dist/hitl/parkResolver.js +10 -1
- package/dist/hitl/planReviewWire.js +48 -20
- package/dist/hitl/toolApprovalWire.js +11 -1
- package/dist/hooksWireCaps.js +10 -2
- package/dist/liveQuestionStore.d.ts +13 -0
- package/dist/liveQuestionStore.js +15 -0
- package/dist/notifications.d.ts +15 -1
- package/dist/notifications.js +90 -10
- package/dist/printToolResultFrame.d.ts +12 -4
- package/dist/printToolResultFrame.js +11 -21
- package/dist/unrefTimer.d.ts +14 -3
- package/package.json +1 -1
package/dist/hitl/frameRouter.js
CHANGED
|
@@ -26,11 +26,17 @@ export function isAskTool(toolName) {
|
|
|
26
26
|
return isAskToolLoose(toolName);
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* 「这个**工具名**归 gate 管」的判据(#110 修,2026-08-02)。
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* ⚠️ 它只是 park 判定的**名字腿**对位件,不是全部 —— park 判定 `isToolApprovalGate` 还有一条
|
|
32
|
+
* 一等 kind 腿(`gate.kind==='tool_approval'`,放行任意 toolName),名字面上没有对位物。
|
|
33
|
+
* tool_end 手柄用的是下面的 `isGatedToolEnd`(名字腿 ∪ abort 标记腿),两者的集合关系与理由
|
|
34
|
+
* 写在那个函数的头注里([2393] hitl-F1)。本函数还被 `routeToolStart` 用来决定「要不要记 args /
|
|
35
|
+
* 要不要跟 `lastFsOrShellGatedCallId`」,那两件事本来就只对名字识别得出的族成立。
|
|
36
|
+
*
|
|
37
|
+
* 🔴 它必须与 park 判定的**名字腿**(`isAskTool` / `toolNameIsFsWrite` / `toolNameIsShellExec`)
|
|
38
|
+
* 覆盖同一个集合 —— 两边不同集就是 [paired-mechanisms-must-share-premise] 那种「park 认得出、
|
|
39
|
+
* HOLD 认不出」的半场病:[2150] S1 给 park 判定加了 `toolNameIsShellExec`(Bash/shell 类进 gate 了),但
|
|
34
40
|
* tool_end 手柄的 HOLD/REJECT 谓词还停在 `isAskTool || toolNameIsFsWrite` ——
|
|
35
41
|
* 于是 Bash 的 durable gate 走到这里就两件事同时出错:
|
|
36
42
|
* ① park 期的毒化帧(`tool_end{isError:true, output:"Operation aborted"}`)没被 HOLD,
|
|
@@ -47,6 +53,36 @@ function isGatedToolName(name) {
|
|
|
47
53
|
return false;
|
|
48
54
|
return toolNameIsFsWrite(name) || toolNameIsShellExec(name);
|
|
49
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* 「这一帧 tool_end 归 gate 管」的判据 —— 上面那条**名字腿**加一条 kind 腿的替身([2393] hitl-F1,
|
|
58
|
+
* 2026-08-02)。
|
|
59
|
+
*
|
|
60
|
+
* 🔴 为什么名字腿一条不够(上面那句「必须覆盖同一个集合」在 kind 腿上的反例):park 判定
|
|
61
|
+
* `isToolApprovalGate` 有**两条**腿 —— 名字族(ask / fs 写三件 / Bash)与**一等 kind**
|
|
62
|
+
* (`gate.kind === 'tool_approval'`,`toolApprovalWire.isFsApprovalGate` 首行)。后者放行**任意**
|
|
63
|
+
* toolName:server 对名单外的工具出一等 kind 审批 park 时,park 侧认、tool_end 侧的名字腿认不出,
|
|
64
|
+
* 于是 #110 那三件事在这类 gate 上原样重演一遍(毒化帧上屏 / `markEnded` 吃掉重放的真结果 /
|
|
65
|
+
* deny 的 CC 文案进不去)。
|
|
66
|
+
*
|
|
67
|
+
* 🔴 为什么不能在名字腿里补 kind:`tool_end` 帧上**根本没有 kind 位** —— gate 的 kind 只在
|
|
68
|
+
* `done{suspended}.checkpointGate` / `suspended.gate` 上,而那两帧**晚于**毒化 tool_end 到达
|
|
69
|
+
* (park 帧序:tool_start → tool_end(毒化)→ done{suspended})。「park 时按 callId 打标、
|
|
70
|
+
* tool_end 时读台账」这条路对**这一帧**来得太晚。帧面上唯一可读的信号是引擎铸的**确切 abort
|
|
71
|
+
* 标记** `ENGINE_ABORT_TOOL_RESULT`(见下方 ④ 臂头注:core 对被 gate/连坐 abort 的 call 恒铸
|
|
72
|
+
* 这一串),所以本判据 = 名字腿 ∪ abort 标记腿。
|
|
73
|
+
*
|
|
74
|
+
* 🔴 集合关系(说清楚,别让下一棒再以为两边逐字相等):本判据是 park 判定的**超集**(它还会盖到
|
|
75
|
+
* 被同 turn 连坐 abort 的非 gate call)。方向是有意的,且不对称成立 —— 多盖一帧的代价是那一帧
|
|
76
|
+
* **晚**上屏(HOLD 的帧在模型推进 / 终帧 / fail-soft 三处必被 flush,一帧都不会丢),而少盖一帧
|
|
77
|
+
* 的代价是用户按了 Yes 却只看到 `Error: Operation aborted`。顺带把连坐 abort 的 call 从
|
|
78
|
+
* `markEnded` 里救出来:它们本来也会在 resume 后被模型重发,而旧码已把它们记成已收口。
|
|
79
|
+
* ⚠️ 普通工具错(非 abort 标记)照旧**不进** HOLD —— [2084]①-b 的收窄没有被放宽,负控 F1-c 钉着。
|
|
80
|
+
*/
|
|
81
|
+
function isGatedToolEnd(ev) {
|
|
82
|
+
if (isGatedToolName(ev.toolName))
|
|
83
|
+
return true;
|
|
84
|
+
return ev.output === ENGINE_ABORT_TOOL_RESULT;
|
|
85
|
+
}
|
|
50
86
|
/** done 帧的 AskUserQuestion park 形状(结构性读;别的终态一律 false)。 */
|
|
51
87
|
function isAskGatePark(result) {
|
|
52
88
|
const r = result;
|
|
@@ -128,9 +164,11 @@ const TOOL_END_ARMS = [
|
|
|
128
164
|
{
|
|
129
165
|
// ② 同步帧腿 deny 且 callId 没关联上:下一张 gated 报错帧即该 gate 的收口帧,stamp REJECT 文案。
|
|
130
166
|
// #110:stamp 也要盖 shell —— 否则按 No 时用户看到的是引擎原文 `Operation aborted`。
|
|
167
|
+
// [2393] hitl-F1:改用 `isGatedToolEnd`(名字腿 ∪ abort 标记腿)—— 一等 kind gate 的
|
|
168
|
+
// 收口帧名字腿认不出,不盖它 = 用户按了 No 却看到引擎原文。
|
|
131
169
|
id: 'deny-stamp-next',
|
|
132
170
|
run(ev, callId, led) {
|
|
133
|
-
if (ev.isError !== true || !
|
|
171
|
+
if (ev.isError !== true || !isGatedToolEnd(ev) || led.isDenied(callId))
|
|
134
172
|
return undefined;
|
|
135
173
|
if (!led.takeDenyStamp())
|
|
136
174
|
return undefined;
|
|
@@ -143,7 +181,7 @@ const TOOL_END_ARMS = [
|
|
|
143
181
|
// ③ 三选卡 No:重放的报错帧 stamp REJECT 文案 → vendored 卡渲 `User rejected <op> to <path>`。
|
|
144
182
|
id: 'denied-call',
|
|
145
183
|
run(ev, callId, led) {
|
|
146
|
-
if (ev.isError !== true || !
|
|
184
|
+
if (ev.isError !== true || !isGatedToolEnd(ev))
|
|
147
185
|
return undefined;
|
|
148
186
|
if (!led.takeDenied(callId))
|
|
149
187
|
return undefined;
|
|
@@ -160,7 +198,7 @@ const TOOL_END_ARMS = [
|
|
|
160
198
|
// —— 收窄后它们零滞后直达转录(落到 ⑤ 通用收口),不再依赖「推进信号放行」。
|
|
161
199
|
id: 'hold-poison',
|
|
162
200
|
run(ev, callId, led) {
|
|
163
|
-
if (ev.isError !== true || !
|
|
201
|
+
if (ev.isError !== true || !isGatedToolEnd(ev))
|
|
164
202
|
return undefined;
|
|
165
203
|
if (ev.output !== ENGINE_ABORT_TOOL_RESULT)
|
|
166
204
|
return undefined;
|
|
@@ -288,7 +326,10 @@ function routeSuspended(ev, ctx) {
|
|
|
288
326
|
ctx.taskId.current) {
|
|
289
327
|
return { kind: 'park', gate: 'fs' };
|
|
290
328
|
}
|
|
291
|
-
|
|
329
|
+
// 其余 gate:透传。⚠️ ADAPTER-F8 注纠(2026-08-02):原文写「eventToSdkMessage 出 null」——
|
|
330
|
+
// 那个返回形已随 REF-CC-058 退役,今天它出的是 `EventProjection` 三态,永不是 null;本臂的
|
|
331
|
+
// 帧走到投影器时落 `none/hitl_out_of_slice`(HITL 登记是 run driver 的活,不在投影切片内)。
|
|
332
|
+
return { kind: 'yield', events: [ev] };
|
|
292
333
|
}
|
|
293
334
|
function routeDone(ev, ctx) {
|
|
294
335
|
const { led } = ctx;
|
|
@@ -99,7 +99,19 @@ export interface GateLedger {
|
|
|
99
99
|
* 「已解决」最多多读一轮、被 hop 预算兜底;误判成「真失败」烧会话)。
|
|
100
100
|
*/
|
|
101
101
|
markDecided(callId: string): void;
|
|
102
|
-
|
|
102
|
+
/**
|
|
103
|
+
* 一次性消费([2393] hitl-F2,2026-08-02):记号被一次 park 失败消费掉就**失效**。
|
|
104
|
+
*
|
|
105
|
+
* 🔴 为什么必须一次性:这条判据的正当性完全来自「durable re-attach 会把**那一次**已决断的 park
|
|
106
|
+
* 重放一遍」——重放窗口只有一次。而它的两个输入都是单调的:`lastFsOrShellGatedCallId()` 不随
|
|
107
|
+
* tool_end 清空(见上方声明处注,那是**故意**的,park 重放时没有新的 tool_start 可跟),
|
|
108
|
+
* `decidedGates` 又只增不删 ⇒ 只要这个 turn 里成功决断过一次,**之后每一次** park 失败都会命中
|
|
109
|
+
* 身份匹配,不论真因是什么。实测后果:`approvals.list` 网络失败这类真失败被连续 24 次判成
|
|
110
|
+
* 「已解决」,吃满 `MAX_GATE_HOPS` 才吐一句 `gate hop limit exceeded` —— 用户白等 24 轮往返,
|
|
111
|
+
* 拿到的还是一句与真因无关的话(真因被预算话术顶掉了)。
|
|
112
|
+
* 消费一次即失效之后:第一次(= 真的重放)照旧救回,第二次就是诚实的 fail-soft。
|
|
113
|
+
*/
|
|
114
|
+
takeDecided(callId: string): boolean;
|
|
103
115
|
/** 只给 debug 串用的规模位(`[decided so far: N]`)。 */
|
|
104
116
|
decidedCount(): number;
|
|
105
117
|
}
|
package/dist/hitl/gateLedger.js
CHANGED
|
@@ -103,8 +103,8 @@ export function createGateLedger() {
|
|
|
103
103
|
markDecided(callId) {
|
|
104
104
|
decidedGates.add(callId);
|
|
105
105
|
},
|
|
106
|
-
|
|
107
|
-
return decidedGates.
|
|
106
|
+
takeDecided(callId) {
|
|
107
|
+
return decidedGates.delete(callId);
|
|
108
108
|
},
|
|
109
109
|
decidedCount() {
|
|
110
110
|
return decidedGates.size;
|
|
@@ -200,7 +200,16 @@ export async function resolvePark(park, ctx) {
|
|
|
200
200
|
// `FsApprovalOutcome` 今天没有 `code`(toolApprovalWire.ts,C-bridge 域),失败原因若不是
|
|
201
201
|
// 那三个文案子串(例如 `approvals.list` 自身网络失败)就会漏判。已决断身份匹配是独立于
|
|
202
202
|
// 文案的第二判据 —— 两臂任一命中都按「已解决」处置,方向偏宽(见台账 `markDecided` 处注)。
|
|
203
|
-
|
|
203
|
+
//
|
|
204
|
+
// [2393] hitl-F2(2026-08-02):这条身份判据**一次性消费**(`takeDecided` 而不是 `isDecided`)。
|
|
205
|
+
// 它的正当性只覆盖「durable re-attach 把**那一次**已决断的 park 重放一遍」这一个窗口,而它的两个
|
|
206
|
+
// 输入都是单调的(`lastFsOrShellGatedCallId()` 故意不随 tool_end 清空 + `decidedGates` 只增),
|
|
207
|
+
// 不消费就等于:本 turn 只要成功决断过一次,之后**每一次** park 失败都被判「已解决」——
|
|
208
|
+
// `approvals.list` 网络失败这类真失败会连吃 24 个 hop,最后吐一句与真因无关的 `gate hop limit`。
|
|
209
|
+
// 消费点写在 `outcome.kind === 'failed'` 之内:决断成功的那一轮压根不该动这个记号。
|
|
210
|
+
const alreadyDecidedById = outcome.kind === 'failed' &&
|
|
211
|
+
candidateGatedCallId !== undefined &&
|
|
212
|
+
led.takeDecided(candidateGatedCallId);
|
|
204
213
|
if (outcome.kind === 'failed' && (isAlreadyResolvedFailure(outcome) || alreadyDecidedById)) {
|
|
205
214
|
const seq = led.lastSeq();
|
|
206
215
|
hostLog('debug', `liveHitlAskWire: gate already resolved (${outcome.reason}) — replayed park, re-attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn` +
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* 挂账(诚实边界):approve 后壳本地的 plan-mode footer 徽章不自动退(CC 会退)——退 mode 的
|
|
37
37
|
* app-state seam 不在本模块可达面,记 rc.47 接 REPL 层;engine 侧 handsReadOnly 已由 resume 处理。
|
|
38
38
|
*/
|
|
39
|
-
import { publishQuestionFrame, registerLocalQuestionResponder, } from '../liveQuestionStore.js';
|
|
39
|
+
import { publishQuestionFrame, registerLocalQuestionResponder, hasLocalQuestionResponder, } from '../liveQuestionStore.js';
|
|
40
40
|
import { hostLog } from '../host.js';
|
|
41
41
|
import { makeEngineWireClient } from '../engineWireSdk.js';
|
|
42
42
|
import { engineWireTarget } from '../engineWireTarget.js';
|
|
@@ -92,6 +92,28 @@ const armedPlanReviewTaskIds = new Set();
|
|
|
92
92
|
export function _resetArmedPlanReviewsForTest() {
|
|
93
93
|
armedPlanReviewTaskIds.clear();
|
|
94
94
|
}
|
|
95
|
+
/** 合成 questionId 的唯一铸口(短路臂与首次 arm 共用,别各铸各的)。 */
|
|
96
|
+
function planReviewQuestionId(taskId) {
|
|
97
|
+
return `plan-review:${taskId}`;
|
|
98
|
+
}
|
|
99
|
+
/** 审批卡的题面 —— 首次 arm 与「重放 arm 重新弹卡」共用同一份,两处不许各写一份。 */
|
|
100
|
+
function publishPlanReviewCard(taskId) {
|
|
101
|
+
publishQuestionFrame({
|
|
102
|
+
type: 'question',
|
|
103
|
+
questionId: planReviewQuestionId(taskId),
|
|
104
|
+
questions: [
|
|
105
|
+
{
|
|
106
|
+
header: 'Plan review',
|
|
107
|
+
question: 'The plan is ready for review. Approve it and start the implementation?',
|
|
108
|
+
options: [
|
|
109
|
+
{ label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' },
|
|
110
|
+
{ label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' },
|
|
111
|
+
],
|
|
112
|
+
multiSelect: false,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
95
117
|
/**
|
|
96
118
|
* Arm the approval card for a parked plan_review (call with the RAW done.result). Returns true when
|
|
97
119
|
* armed (live path + shape matched, INCLUDING the idempotent "already armed" short-circuit — see
|
|
@@ -107,13 +129,33 @@ export function armPlanReviewApproval(result) {
|
|
|
107
129
|
return false;
|
|
108
130
|
const { taskId } = result;
|
|
109
131
|
if (armedPlanReviewTaskIds.has(taskId)) {
|
|
110
|
-
// REF-CC-027:同一张卡还没消解就被重放的 arm
|
|
111
|
-
|
|
112
|
-
|
|
132
|
+
// REF-CC-027:同一张卡还没消解就被重放的 arm 撞上——幂等,**不二次注册** responder。
|
|
133
|
+
//
|
|
134
|
+
// [2393] hitl-F3(2026-08-02):但「不二次注册」不等于「什么都不做」。旧码在这里只打一行
|
|
135
|
+
// debug 就 `return true`,于是这条短路成了一堵挡死重开路径的墙:
|
|
136
|
+
// · `liveQuestionStore.ts` 的契约明写「dialog dismissed without answering ⇒ caller cleans
|
|
137
|
+
// up」,而本模块**没有** dismiss/abort 腿 —— 用户 Esc 关掉卡(不作答)之后,武装态
|
|
138
|
+
// 一直是 true,responder 也一直挂着;
|
|
139
|
+
// · 此后每一次 arm(durable re-attach 重放 / 409 撞上再开)都走这条短路:**一张卡都不弹**,
|
|
140
|
+
// 调用方拿到 true 于是也不渲终帧 —— 用户面静默挂住,plan 卡在 park 里没有任何出口。
|
|
141
|
+
// 修法按「还在不在」分两支,两支都不产生第二次 `registerLocalQuestionResponder`:
|
|
142
|
+
if (hasLocalQuestionResponder(planReviewQuestionId(taskId))) {
|
|
143
|
+
// responder 还绑着 ⇒ 这张卡仍然可作答,只是可能已经不在屏上了。**重新弹一次**
|
|
144
|
+
// (同一个 questionId、同一份题面、同一个 responder):卡还开着的宿主只是收到一帧同形
|
|
145
|
+
// 的重绘,卡已经被 dismiss 的宿主则拿回了它的重开路径。
|
|
146
|
+
hostLog('debug', `planReviewWire: arm replayed — re-presenting the still-armed card for task ${taskId}`);
|
|
147
|
+
publishPlanReviewCard(taskId);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
// responder 不在了(被别的注册顶掉后注销 / 被单方面清理)⇒ 武装态是**陈旧**的。
|
|
151
|
+
// 复用它 = 弹一张没有任何人能收答的卡(比不弹更坏:它看起来能点)。丢掉陈旧记号,
|
|
152
|
+
// 往下走整条重新 arm —— 这正是 REF-CC-027 头注里那个「悬空引用」形的收尾。
|
|
153
|
+
hostLog('debug', `planReviewWire: stale armed state for task ${taskId} (no local responder) — re-arming from scratch`);
|
|
154
|
+
armedPlanReviewTaskIds.delete(taskId);
|
|
113
155
|
}
|
|
114
156
|
armedPlanReviewTaskIds.add(taskId);
|
|
115
157
|
armedTaskId = taskId;
|
|
116
|
-
const questionId =
|
|
158
|
+
const questionId = planReviewQuestionId(taskId);
|
|
117
159
|
const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
|
|
118
160
|
// REF-CC-027:一次性 —— 首句就注销 + 清武装态(照 askGateWire 的 cleanup() 形)。
|
|
119
161
|
// liveQuestionStore.respondToQuestion 自身也是一次性取件(delete-before-invoke),这里是
|
|
@@ -130,21 +172,7 @@ export function armPlanReviewApproval(result) {
|
|
|
130
172
|
void decidePlanReview(taskId, decided);
|
|
131
173
|
return { ok: true };
|
|
132
174
|
});
|
|
133
|
-
|
|
134
|
-
type: 'question',
|
|
135
|
-
questionId,
|
|
136
|
-
questions: [
|
|
137
|
-
{
|
|
138
|
-
header: 'Plan review',
|
|
139
|
-
question: 'The plan is ready for review. Approve it and start the implementation?',
|
|
140
|
-
options: [
|
|
141
|
-
{ label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' },
|
|
142
|
-
{ label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' },
|
|
143
|
-
],
|
|
144
|
-
multiSelect: false,
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
});
|
|
175
|
+
publishPlanReviewCard(taskId);
|
|
148
176
|
hostLog('debug', `planReviewWire: approval card armed for parked plan (task ${taskId})`);
|
|
149
177
|
return true;
|
|
150
178
|
}
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
* 都返回 failed —— 调用方回退「flush + 原样终帧」的诚实红,绝不更糟。
|
|
62
62
|
* 🔴 UNTRUSTED:gate args 为模型作文(service 已 redact),只渲染绝不回喂;决断只带 decision 枚举。
|
|
63
63
|
*/
|
|
64
|
-
import { HitlBridge, findPendingForTask } from './hitlBridge.js';
|
|
64
|
+
import { HitlBridge, HitlSafetyError, findPendingForTask } from './hitlBridge.js';
|
|
65
65
|
import { hostLog } from '../host.js';
|
|
66
66
|
import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
|
|
67
67
|
import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
|
|
@@ -256,6 +256,16 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
|
|
|
256
256
|
return { kind: 'decided', gatedCallId };
|
|
257
257
|
}
|
|
258
258
|
catch (e) {
|
|
259
|
+
// 🔴 [2393] hitl-F4(2026-08-02):回退臂**只**兜「老 server 不识别 remember ⇒ 400 未知键」
|
|
260
|
+
// 这一形。`HitlSafetyError` 是 hitlBridge 的**安全信号**闭集(hitlBridge.ts:9 / decideTool
|
|
261
|
+
// 头注末句逐字:「A binding mismatch (409) is re-raised as a `HitlSafetyError`
|
|
262
|
+
// ('binding_mismatch') — the caller re-presents, NEVER auto-retries」),旧的 catch-all
|
|
263
|
+
// 把它一并吞了,然后**立刻自动重发**一次纯 approve —— 正是那条铁律禁的动作:
|
|
264
|
+
// binding 不匹配意味着「人看见的那一行在他决断期间被换掉了」,自动重试等于替人对一件
|
|
265
|
+
// 他没看过的事按了 Yes。`no_pending` 同族(那一行已经没了,重试同样只会再失败一次)。
|
|
266
|
+
// 上抛给外层 catch ⇒ typed `failed` ⇒ 调用方走 fail-soft 诚实红,由人重新决断。
|
|
267
|
+
if (e instanceof HitlSafetyError)
|
|
268
|
+
throw e;
|
|
259
269
|
hostLog('debug', `liveToolApprovalWire: decide(approve+remember) failed (${String(e)}) — falling back to plain approve`);
|
|
260
270
|
}
|
|
261
271
|
}
|
package/dist/hooksWireCaps.js
CHANGED
|
@@ -98,14 +98,22 @@ export function hooksForWire() {
|
|
|
98
98
|
return undefined;
|
|
99
99
|
// trust gate first (cheapest + broadest): untrusted interactive workspace ⇒ no hooks anywhere,
|
|
100
100
|
// the engine leg included — same invariant the local executor enforces per-fire.
|
|
101
|
+
// [2393] F-2(fix-outright):此前这条 catch 是 **fail-OPEN**(fall through),理由句写「端侧本地
|
|
102
|
+
// 执行器仍 per-fire 把门」—— 那句在本包语境下是**假话**,本文件头第三段自己写明:壳里那条
|
|
103
|
+
// catch 的前提是单进程本地执行器,**库里没有那个兜底**。后果是「未受信工作区的 hooks 绝不上
|
|
104
|
+
// wire」这条被文件头称作安全门的不变量,在 SettingsPort 抛错时整个失效(用户/项目 settings 里的
|
|
105
|
+
// `command` 型 hook 照投给引擎真执行)。而同一函数下方对**同类失败**(port 抛)的 policy 读已选
|
|
106
|
+
// fail-closed 并写明「治理策略读不出来时更安全的默认应是当作有限制」—— 两道门相邻、同类输入,
|
|
107
|
+
// 结论必须一致,更敏感的那道不许留在宽的一侧。改成 fail-closed:信任查不出来 = 当作未受信。
|
|
101
108
|
try {
|
|
102
109
|
if (settings.shouldSkipHookDueToTrust()) {
|
|
103
110
|
hostLog('debug', 'hooksWireCaps: workspace not trusted — no hooks projected to the engine');
|
|
104
111
|
return undefined;
|
|
105
112
|
}
|
|
106
113
|
}
|
|
107
|
-
catch {
|
|
108
|
-
|
|
114
|
+
catch (err) {
|
|
115
|
+
hostLog('debug', `hooksWireCaps: workspace trust check threw — fail-closed (no hooks projected to the engine): ${String(err)}`);
|
|
116
|
+
return undefined;
|
|
109
117
|
}
|
|
110
118
|
let policy = null;
|
|
111
119
|
// REF-CC-155(midband-03,fix-outright,行为面已裁 GO — 安全敏感面 [2374] 背书):policy 是
|
|
@@ -68,6 +68,19 @@ type RespondFn = (id: string, answer: QuestionAnswer, opts?: {
|
|
|
68
68
|
/** Register a one-shot local responder for a SYNTHETIC question frame (e.g. `plan-review:<taskId>`).
|
|
69
69
|
* Returns an unregister fn (dialog dismissed without answering ⇒ caller cleans up). */
|
|
70
70
|
export declare function registerLocalQuestionResponder(id: string, fn: RespondFn): () => void;
|
|
71
|
+
/**
|
|
72
|
+
* Is a local responder for this synthetic questionId still bound?([2393] hitl-F3,2026-08-02)
|
|
73
|
+
*
|
|
74
|
+
* 🔴 为什么这是**只读探询**而不是「谁注册谁自己记着就行」:上面那条契约把「对话框被 dismiss 却没
|
|
75
|
+
* 作答」的清理责任交给了 caller,而 caller(planReviewWire)自己那份武装态与这张表是**两个**容器 ——
|
|
76
|
+
* 两个容器各自都可能被单方面改动(`respondToQuestion` 的一次性 delete、别的注册把同 id 顶掉后再
|
|
77
|
+
* 注销),于是「我以为还武装着」和「表里还真有人收答」会漂开。漂开的后果不是多弹一张卡,是
|
|
78
|
+
* **一张都不弹**:去重短路认为卡还在,而实际上没有任何 responder 能收答([paired-mechanisms-must-
|
|
79
|
+
* share-premise])。给出这条探询口,让去重臂能问真相而不是问自己的记忆。
|
|
80
|
+
*
|
|
81
|
+
* 🔴 只回答布尔:responder 函数本身绝不出境(它闭包着 caller 的一次性状态,交出去就有第二个调用者)。
|
|
82
|
+
*/
|
|
83
|
+
export declare function hasLocalQuestionResponder(id: string): boolean;
|
|
71
84
|
/**
|
|
72
85
|
* Publish a demuxed live question frame (called from liveClient's demuxQuestionFrames on each
|
|
73
86
|
* `question`/`question_complete`). Best-effort: a UI handler error is swallowed so a broken overlay can NEVER
|
|
@@ -20,6 +20,21 @@ export function registerLocalQuestionResponder(id, fn) {
|
|
|
20
20
|
localResponders.delete(id);
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Is a local responder for this synthetic questionId still bound?([2393] hitl-F3,2026-08-02)
|
|
25
|
+
*
|
|
26
|
+
* 🔴 为什么这是**只读探询**而不是「谁注册谁自己记着就行」:上面那条契约把「对话框被 dismiss 却没
|
|
27
|
+
* 作答」的清理责任交给了 caller,而 caller(planReviewWire)自己那份武装态与这张表是**两个**容器 ——
|
|
28
|
+
* 两个容器各自都可能被单方面改动(`respondToQuestion` 的一次性 delete、别的注册把同 id 顶掉后再
|
|
29
|
+
* 注销),于是「我以为还武装着」和「表里还真有人收答」会漂开。漂开的后果不是多弹一张卡,是
|
|
30
|
+
* **一张都不弹**:去重短路认为卡还在,而实际上没有任何 responder 能收答([paired-mechanisms-must-
|
|
31
|
+
* share-premise])。给出这条探询口,让去重臂能问真相而不是问自己的记忆。
|
|
32
|
+
*
|
|
33
|
+
* 🔴 只回答布尔:responder 函数本身绝不出境(它闭包着 caller 的一次性状态,交出去就有第二个调用者)。
|
|
34
|
+
*/
|
|
35
|
+
export function hasLocalQuestionResponder(id) {
|
|
36
|
+
return localResponders.has(id);
|
|
37
|
+
}
|
|
23
38
|
/**
|
|
24
39
|
* Publish a demuxed live question frame (called from liveClient's demuxQuestionFrames on each
|
|
25
40
|
* `question`/`question_complete`). Best-effort: a UI handler error is swallowed so a broken overlay can NEVER
|
package/dist/notifications.d.ts
CHANGED
|
@@ -190,11 +190,21 @@ export declare function outstandingAbandonedCount(): number;
|
|
|
190
190
|
* 回归,复活周期会伪装成首周期落进这一格 —— 这个数异常增长就是 seq 又丢了的信号);
|
|
191
191
|
* · `stuckTickResets` = 在飞 tick 超过 STUCK_TICK_RESET_MS 被强制复位的次数,**恒应为 0**;
|
|
192
192
|
* >0 = 有 probe 连 PROBE_TIMEOUT_MS 都没能截断它。
|
|
193
|
+
*
|
|
194
|
+
* 🔴 [2393] F-1 补两格 —— 上面三格全是 **enqueue 内部**的记账,而 watcher 收摊臂在到达 enqueue
|
|
195
|
+
* **之前**就把台账条目删掉了:「复活周期被秒删」这条路径对上面三格 100% 不可见(立案时被点名当
|
|
196
|
+
* 信号的 `bgCrossChannelDropped` 正是这样对它零判别力的)。删除动作要挣得,也要看得见:
|
|
197
|
+
* · `bgWatchRevivalPromoted` = 收摊臂靠 probe 实证「任务又在跑」把条目升到新周期的次数
|
|
198
|
+
* —— 正是修前被静默秒删的那一类;>0 说明 wire 的 seq 没到货,壳在自己补周期号;
|
|
199
|
+
* · `bgWatchCollectedUnprobed` = probe 未装(mock/离线)时按裸 id 黑名单**盲摘**的次数,
|
|
200
|
+
* 这是唯一保留的无证据删除;>0 = 有完成周期在这类宿主上恒不可观察。
|
|
193
201
|
*/
|
|
194
202
|
export interface NotificationDropCounters {
|
|
195
203
|
bgDedupDropped: number;
|
|
196
204
|
bgCrossChannelDropped: number;
|
|
197
205
|
stuckTickResets: number;
|
|
206
|
+
bgWatchRevivalPromoted: number;
|
|
207
|
+
bgWatchCollectedUnprobed: number;
|
|
198
208
|
}
|
|
199
209
|
export declare function notificationDropCounters(): NotificationDropCounters;
|
|
200
210
|
/** liveClient 在构造会话 client 时注册(带正确 baseUrl/token/principal 的 workflows.get)。 */
|
|
@@ -215,7 +225,11 @@ export declare function installBgTaskStatusProbe(probe: BgTaskStatusProbe): void
|
|
|
215
225
|
* 首周期若确已送达,由 watcher 的收摊臂在下一拍摘除(见 `tickWatchInner` bg 半场),
|
|
216
226
|
* 代价是一次 no-op 遍历,而不是一整个完成周期的结构性缺席。
|
|
217
227
|
*
|
|
218
|
-
*
|
|
228
|
+
* 🔴 [2393] F-1:`seq` 缺席时这里仍然**按首周期解释,不在登记口猜周期号** —— 猜出来的周期号会
|
|
229
|
+
* 把「已终局任务的回执重放」也升成新周期,方向相反地造出重复通知。周期号只在 watcher 那侧、
|
|
230
|
+
* 拿到 probe 的「它又在跑」这个证据之后才前进(见 `tickWatchInner` 的收摊臂三档)。
|
|
231
|
+
*
|
|
232
|
+
* @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释,复活周期由 watcher 补。
|
|
219
233
|
*/
|
|
220
234
|
export declare function registerOutstandingBgTask(taskId: string, description: string, prompt?: string, seq?: number): void;
|
|
221
235
|
export declare function isOwnWorkflowRun(runId: string): boolean;
|
package/dist/notifications.js
CHANGED
|
@@ -255,6 +255,22 @@ export function _resetNotificationQueuePortForTest() {
|
|
|
255
255
|
// (the `<tool-use-id>` line is optional in CC's own template the same way).
|
|
256
256
|
// ══════════════════════════════════════════════════════════════════════════════════════════════
|
|
257
257
|
const notifiedRunIds = new Set();
|
|
258
|
+
const notifiedBgCycleHigh = new Map();
|
|
259
|
+
let ledgerOrder = 0;
|
|
260
|
+
const nextLedgerOrder = () => ++ledgerOrder;
|
|
261
|
+
/** 「已送达」两账的**唯一**写口(裸 id 维 + 周期维必须同时前进,否则收摊臂又回到没有周期证据)。 */
|
|
262
|
+
function markRunNotified(runId, cycle = BG_FIRST_SEQ) {
|
|
263
|
+
notifiedRunIds.add(runId);
|
|
264
|
+
const prev = notifiedBgCycleHigh.get(runId);
|
|
265
|
+
if (prev === undefined || cycle > prev.cycle) {
|
|
266
|
+
notifiedBgCycleHigh.set(runId, { cycle, order: nextLedgerOrder() });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** 「**这一个周期**已经送达过」——收摊臂唯一可用的删除证据。 */
|
|
270
|
+
function notifiedBgCycleAtLeast(taskId, cycle) {
|
|
271
|
+
const high = notifiedBgCycleHigh.get(taskId);
|
|
272
|
+
return high !== undefined && high.cycle >= cycle ? high : undefined;
|
|
273
|
+
}
|
|
258
274
|
/**
|
|
259
275
|
* B1(frame-lane-matrix 三节定谳)— Path A 回声到达时丢弃同 run 的 pending 队列条目。
|
|
260
276
|
* 双投机理:活跃 turn 内 Path B(workflow_complete/bg_notification/probe feeder →
|
|
@@ -314,7 +330,9 @@ export function dropQueuedNotificationsForRun(taskId) {
|
|
|
314
330
|
* steer-inject 的 task_notification 帧)即预标记,Channel A 对同 runId 的补发直接丢弃。
|
|
315
331
|
*/
|
|
316
332
|
export function markEngineWorkflowNotified(runId) {
|
|
317
|
-
|
|
333
|
+
// [2393] F-1:外部 mark 只证「首周期已送达」(它没有周期号可带),周期维按首周期记 —— 与本函数
|
|
334
|
+
// 既有的跨通道语义逐字一致(enqueueBgChildNotification 的跨通道臂同样只对首周期成立)。
|
|
335
|
+
markRunNotified(runId);
|
|
318
336
|
if (outstandingRuns.delete(runId))
|
|
319
337
|
notifyOutstanding();
|
|
320
338
|
}
|
|
@@ -414,8 +432,10 @@ export function outstandingAbandonedCount() {
|
|
|
414
432
|
let bgDedupDropped = 0;
|
|
415
433
|
let bgCrossChannelDropped = 0;
|
|
416
434
|
let stuckTickResets = 0;
|
|
435
|
+
let bgWatchRevivalPromoted = 0;
|
|
436
|
+
let bgWatchCollectedUnprobed = 0;
|
|
417
437
|
export function notificationDropCounters() {
|
|
418
|
-
return { bgDedupDropped, bgCrossChannelDropped, stuckTickResets };
|
|
438
|
+
return { bgDedupDropped, bgCrossChannelDropped, stuckTickResets, bgWatchRevivalPromoted, bgWatchCollectedUnprobed };
|
|
419
439
|
}
|
|
420
440
|
let statusProbe = null;
|
|
421
441
|
let watchTimer = null;
|
|
@@ -462,7 +482,11 @@ export function installBgTaskStatusProbe(probe) {
|
|
|
462
482
|
* 首周期若确已送达,由 watcher 的收摊臂在下一拍摘除(见 `tickWatchInner` bg 半场),
|
|
463
483
|
* 代价是一次 no-op 遍历,而不是一整个完成周期的结构性缺席。
|
|
464
484
|
*
|
|
465
|
-
*
|
|
485
|
+
* 🔴 [2393] F-1:`seq` 缺席时这里仍然**按首周期解释,不在登记口猜周期号** —— 猜出来的周期号会
|
|
486
|
+
* 把「已终局任务的回执重放」也升成新周期,方向相反地造出重复通知。周期号只在 watcher 那侧、
|
|
487
|
+
* 拿到 probe 的「它又在跑」这个证据之后才前进(见 `tickWatchInner` 的收摊臂三档)。
|
|
488
|
+
*
|
|
489
|
+
* @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释,复活周期由 watcher 补。
|
|
466
490
|
*/
|
|
467
491
|
export function registerOutstandingBgTask(taskId, description, prompt, seq) {
|
|
468
492
|
if (!taskId)
|
|
@@ -475,7 +499,13 @@ export function registerOutstandingBgTask(taskId, description, prompt, seq) {
|
|
|
475
499
|
const key = bgOutstandingKey(taskId, cycle);
|
|
476
500
|
if (outstandingBgTasks.has(key))
|
|
477
501
|
return;
|
|
478
|
-
outstandingBgTasks.set(key, {
|
|
502
|
+
outstandingBgTasks.set(key, {
|
|
503
|
+
taskId,
|
|
504
|
+
seq: cycle,
|
|
505
|
+
registeredAt: Date.now(),
|
|
506
|
+
registeredOrder: nextLedgerOrder(),
|
|
507
|
+
description,
|
|
508
|
+
});
|
|
479
509
|
ensureWatchTimer();
|
|
480
510
|
}
|
|
481
511
|
/** 本壳亲手启动过的 workflow run(process-lifetime,只增不摘——outstandingRuns 会随完成摘除,
|
|
@@ -630,10 +660,55 @@ async function tickWatchInner() {
|
|
|
630
660
|
// probe 未装=mock/离线,只等推送补发)
|
|
631
661
|
const bgProbe = bgStatusProbe;
|
|
632
662
|
for (const [key, meta] of [...outstandingBgTasks]) {
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
663
|
+
// 🔴 [2393] F-1:收摊臂的删除必须**挣得**。此前的判据是裸 taskId 黑名单
|
|
664
|
+
// (`meta.seq <= BG_FIRST_SEQ && notifiedRunIds.has(meta.taskId)`),它只证明「这个 id 曾经
|
|
665
|
+
// 送达过某次完成」;而 `structured.seq` 缺席时复活周期的 cycle 塌回首周期,于是这条臂拿
|
|
666
|
+
// **首周期的送达记录**把**第二周期**的观察条目在下一拍秒删,probe 一次不跑 —— 与 notif-02
|
|
667
|
+
// 修前的静默丢通知逐字相同,且删在 enqueue 之前 ⇒ 三个既有计数器一格都摸不到。
|
|
668
|
+
// 新判据分三档,每一档都拿得出理由:
|
|
669
|
+
// ① 这一周期确已送达(周期键台账)⇒ 才谈得上收摊;
|
|
670
|
+
// ①a 条目**登记在那次送达之前** ⇒ 它就是那次送达自己的观察条目,无歧义,零成本收摊
|
|
671
|
+
// (= 旧行为的正当那一半,常态路径,不多花一次探测);
|
|
672
|
+
// ①b 条目**登记在送达之后** ⇒ 有歧义:复活周期?还是已终局任务的回执重放?靠 probe 定夺:
|
|
673
|
+
// ② probe 说它**真终局** ⇒ 就是那个已送达的周期,收摊(重放回执落这里,不会二次喂模型);
|
|
674
|
+
// ③ probe 说它**又在跑** ⇒ 这按构造是一个**新的完成周期**(裸 id 黑名单对它零判别力),
|
|
675
|
+
// 把条目升到下一个周期号继续观察,并留痕。probe 未装 ⇒ 没有可挣得的证据,沿用旧的盲摘
|
|
676
|
+
// 但记账;probe 失败/答不上来 ⇒ 不删也不升(TTL 兜底),绝不拿探测失败当终局证据。
|
|
677
|
+
const delivered = notifiedBgCycleAtLeast(meta.taskId, meta.seq);
|
|
678
|
+
if (delivered !== undefined) {
|
|
679
|
+
if (meta.registeredOrder < delivered.order) {
|
|
680
|
+
outstandingBgTasks.delete(key); // ①a 推送/别的通道已送达本条目观察的那个周期 — 收摊
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (!bgProbe) {
|
|
684
|
+
outstandingBgTasks.delete(key);
|
|
685
|
+
bgWatchCollectedUnprobed++;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
let alive;
|
|
689
|
+
try {
|
|
690
|
+
const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
|
|
691
|
+
if (res === null || res === undefined)
|
|
692
|
+
continue; // 答不上来 ≠ 终局,留给下一拍/TTL
|
|
693
|
+
alive = !res.terminal;
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
continue; // 探测失败(含 ProbeDeadlineError)不构成删除证据
|
|
697
|
+
}
|
|
698
|
+
if (!alive) {
|
|
699
|
+
outstandingBgTasks.delete(key); // 真终局 = 已送达的那个周期,收摊
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
const promoted = Math.max(meta.seq, delivered.cycle) + 1;
|
|
703
|
+
outstandingBgTasks.delete(key);
|
|
704
|
+
const promotedKey = bgOutstandingKey(meta.taskId, promoted);
|
|
705
|
+
if (!outstandingBgTasks.has(promotedKey)) {
|
|
706
|
+
outstandingBgTasks.set(promotedKey, { ...meta, seq: promoted, registeredOrder: nextLedgerOrder() });
|
|
707
|
+
}
|
|
708
|
+
bgWatchRevivalPromoted++;
|
|
709
|
+
traceNotif(`bg task ${meta.taskId} is running again after cycle ${meta.seq} was already delivered — ` +
|
|
710
|
+
`promoting the watch entry to cycle ${promoted} (wire carried no seq; without this the entry ` +
|
|
711
|
+
'would be collected by the bare-taskId ledger and this completion would never reach the user)');
|
|
637
712
|
continue;
|
|
638
713
|
}
|
|
639
714
|
if (!bgProbe)
|
|
@@ -709,7 +784,8 @@ export function enqueueBgChildNotification(n) {
|
|
|
709
784
|
return;
|
|
710
785
|
}
|
|
711
786
|
bgNotifiedKeys.add(key);
|
|
712
|
-
|
|
787
|
+
// [2393] F-1:周期维必须跟着前进 —— 收摊臂删条目的唯一证据就是这一格。
|
|
788
|
+
markRunNotified(n.taskId, cycle);
|
|
713
789
|
cardEnqueuedRunIds.add(n.taskId);
|
|
714
790
|
// #6 通知-settle 边(合成半场):bg 子代行不再被 turn sweep 假结(session 常驻台账),真终态
|
|
715
791
|
// 唯二来源 = 推送帧(bridge task_notification 臂)与本合成链(probe/fleet bg_notification 收敛点)。
|
|
@@ -738,7 +814,8 @@ export function enqueueBgChildNotification(n) {
|
|
|
738
814
|
export function enqueueEngineWorkflowNotification(c) {
|
|
739
815
|
if (notifiedRunIds.has(c.runId))
|
|
740
816
|
return;
|
|
741
|
-
|
|
817
|
+
// [2393] F-1:workflow 侧没有周期概念,周期维按首周期记(与 markEngineWorkflowNotified 同理)。
|
|
818
|
+
markRunNotified(c.runId);
|
|
742
819
|
cardEnqueuedRunIds.add(c.runId);
|
|
743
820
|
const message = `<${TASK_NOTIFICATION_TAG}>
|
|
744
821
|
<${TASK_ID_TAG}>${escapeXml(c.runId)}</${TASK_ID_TAG}>
|
|
@@ -754,6 +831,7 @@ export function enqueueEngineWorkflowNotification(c) {
|
|
|
754
831
|
* 🔴 生产绝不调用 —— 台账是 process-lifetime 去重的唯一凭据,清了就会双投。 */
|
|
755
832
|
export function _resetEngineTaskNotificationForTest() {
|
|
756
833
|
notifiedRunIds.clear();
|
|
834
|
+
notifiedBgCycleHigh.clear();
|
|
757
835
|
cardEnqueuedRunIds.clear();
|
|
758
836
|
outstandingRuns.clear();
|
|
759
837
|
outstandingBgTasks.clear();
|
|
@@ -767,6 +845,8 @@ export function _resetEngineTaskNotificationForTest() {
|
|
|
767
845
|
bgDedupDropped = 0;
|
|
768
846
|
bgCrossChannelDropped = 0;
|
|
769
847
|
stuckTickResets = 0;
|
|
848
|
+
bgWatchRevivalPromoted = 0;
|
|
849
|
+
bgWatchCollectedUnprobed = 0;
|
|
770
850
|
probeTimeoutOverrideMs = null;
|
|
771
851
|
stuckTickResetOverrideMs = null;
|
|
772
852
|
if (watchTimer !== null) {
|
|
@@ -36,11 +36,19 @@
|
|
|
36
36
|
import type { SDKMessage } from '@sema-agent/agent-types';
|
|
37
37
|
/**
|
|
38
38
|
* Flatten the §E1 wire `output`(NON-UNIFORM: `string` | `(TextContent|ImageContent)[]`)into one
|
|
39
|
-
* plain-text blob
|
|
40
|
-
*
|
|
39
|
+
* plain-text blob。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
|
|
40
|
+
*
|
|
41
|
+
* ⇄ REF-CC-008 / dup-05 兑现(ADAPT-F1,2026-08-02):此处曾是 `flattenWireOutput` 的**逐字节副本**,
|
|
42
|
+
* 豁免理由写着「避免把重量级 REPL 桥拖进 `-p` 车道的 import 图」。A 族拆分把该函数从 1607 行的
|
|
43
|
+
* `adapt.ts` 搬进了 `adapt/wireShapes.ts`,而那个文件**唯一的 import 是 `import type {Frame}`**
|
|
44
|
+
* (type-only,编译后整段消失)—— 正是台账要的那片「零 import 叶」,拆分创造了退役条件。
|
|
45
|
+
* 副本随之删除,`flattenToolOutput` 收成对唯一实现的**具名再导出**(公面名不动:它在
|
|
46
|
+
* public-export-baseline 里,改名会当场红)。
|
|
41
47
|
*/
|
|
42
|
-
export
|
|
43
|
-
/** 内部 tool_end_result arm(eventToSdkMessage.ts
|
|
48
|
+
export { flattenWireOutput as flattenToolOutput } from './adapt/wireShapes.js';
|
|
49
|
+
/** 内部 tool_end_result arm(`eventToSdkMessage.ts` 的 `case 'tool_end'` 臂铸造)的防御性读形。
|
|
50
|
+
* ⚠️ ADAPTER-F8 注纠(2026-08-02):原文锚的是裸行号 `213-231`,A 族拆分后那段是 `case 'text'` /
|
|
51
|
+
* `case 'reasoning'`,真正的 tool_end 臂已挪位。锚换成符号名(REF-CC-063 同款,腐烂不了)。 */
|
|
44
52
|
export interface ToolEndResultArmLike {
|
|
45
53
|
type?: unknown;
|
|
46
54
|
toolCallId?: unknown;
|
|
@@ -1,26 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Flatten the §E1 wire `output`(NON-UNIFORM: `string` | `(TextContent|ImageContent)[]`)into one
|
|
3
|
-
* plain-text blob
|
|
4
|
-
*
|
|
3
|
+
* plain-text blob。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
|
|
4
|
+
*
|
|
5
|
+
* ⇄ REF-CC-008 / dup-05 兑现(ADAPT-F1,2026-08-02):此处曾是 `flattenWireOutput` 的**逐字节副本**,
|
|
6
|
+
* 豁免理由写着「避免把重量级 REPL 桥拖进 `-p` 车道的 import 图」。A 族拆分把该函数从 1607 行的
|
|
7
|
+
* `adapt.ts` 搬进了 `adapt/wireShapes.ts`,而那个文件**唯一的 import 是 `import type {Frame}`**
|
|
8
|
+
* (type-only,编译后整段消失)—— 正是台账要的那片「零 import 叶」,拆分创造了退役条件。
|
|
9
|
+
* 副本随之删除,`flattenToolOutput` 收成对唯一实现的**具名再导出**(公面名不动:它在
|
|
10
|
+
* public-export-baseline 里,改名会当场红)。
|
|
5
11
|
*/
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
return output;
|
|
9
|
-
if (Array.isArray(output)) {
|
|
10
|
-
const parts = [];
|
|
11
|
-
for (const block of output) {
|
|
12
|
-
if (block && typeof block === 'object') {
|
|
13
|
-
const b = block;
|
|
14
|
-
if (b.type === 'text' && typeof b.text === 'string')
|
|
15
|
-
parts.push(b.text);
|
|
16
|
-
else if (b.type === 'image')
|
|
17
|
-
parts.push('[image]');
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return parts.join('');
|
|
21
|
-
}
|
|
22
|
-
return '';
|
|
23
|
-
}
|
|
12
|
+
export { flattenWireOutput as flattenToolOutput } from './adapt/wireShapes.js';
|
|
13
|
+
import { flattenWireOutput } from './adapt/wireShapes.js';
|
|
24
14
|
/**
|
|
25
15
|
* Bash 臂的 is_error 派生(真行为实证,2026-07-16):引擎 tool_end.isError 语义 = 「工具本身
|
|
26
16
|
* 是否执行失败」——命令非零退出时工具照常返回模型面框架文本(core runShell `exit code: N\n---
|
|
@@ -50,7 +40,7 @@ function bashExitCodeFailed(toolName, text, structured) {
|
|
|
50
40
|
export function toolEndResultToUserFrame(arm) {
|
|
51
41
|
if (typeof arm.toolCallId !== 'string' || arm.toolCallId.length === 0)
|
|
52
42
|
return null;
|
|
53
|
-
const text =
|
|
43
|
+
const text = flattenWireOutput(arm.output);
|
|
54
44
|
return {
|
|
55
45
|
type: 'user',
|
|
56
46
|
message: {
|