@sema-agent/client-core 0.12.2 → 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 +15 -2
- package/dist/abortableSleep.d.ts +29 -0
- package/dist/abortableSleep.js +43 -0
- package/dist/adapt/arms.d.ts +67 -0
- package/dist/adapt/arms.js +623 -0
- package/dist/adapt/ids.d.ts +22 -0
- package/dist/adapt/ids.js +34 -0
- package/dist/adapt/instanceLedger.d.ts +36 -0
- package/dist/adapt/instanceLedger.js +50 -0
- package/dist/adapt/panelTasks.d.ts +59 -0
- package/dist/adapt/panelTasks.js +193 -0
- package/dist/adapt/textStream.d.ts +63 -0
- package/dist/adapt/textStream.js +141 -0
- package/dist/adapt/toolCards.d.ts +65 -0
- package/dist/adapt/toolCards.js +100 -0
- package/dist/adapt/turnFlags.d.ts +37 -0
- package/dist/adapt/turnFlags.js +55 -0
- package/dist/adapt/wireShapes.d.ts +96 -0
- package/dist/adapt/wireShapes.js +167 -0
- package/dist/adapt.d.ts +33 -60
- package/dist/adapt.js +84 -1207
- package/dist/adapter/downstream/eventToSdkMessage.d.ts +57 -13
- package/dist/adapter/downstream/eventToSdkMessage.js +174 -106
- package/dist/adapter/downstream/terminalToSdkResult.js +151 -160
- package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +36 -0
- package/dist/adapter/downstream/turnUsageToModelUsage.js +34 -6
- package/dist/adapter/runStream.d.ts +36 -4
- package/dist/adapter/runStream.js +195 -13
- package/dist/adapter/types.d.ts +28 -2
- package/dist/agentSession/backgroundView.js +4 -15
- package/dist/agentsWireCaps.d.ts +10 -6
- package/dist/agentsWireCaps.js +21 -7
- package/dist/argvFlagValue.d.ts +41 -0
- package/dist/argvFlagValue.js +69 -0
- package/dist/attachmentsWireCaps.d.ts +3 -2
- package/dist/attachmentsWireCaps.js +5 -3
- package/dist/classifierVerdictWire.d.ts +8 -0
- package/dist/classifierVerdictWire.js +8 -0
- package/dist/cloudConfigWireCaps.d.ts +28 -1
- package/dist/cloudConfigWireCaps.js +51 -11
- package/dist/controlRouter.d.ts +14 -8
- package/dist/controlRouter.js +15 -21
- package/dist/detachWire.d.ts +15 -5
- package/dist/detachWire.js +17 -7
- package/dist/effortWire.d.ts +0 -21
- package/dist/effortWire.js +6 -20
- package/dist/engineInlineTaskStats.d.ts +12 -6
- package/dist/engineWireSdk.d.ts +12 -0
- package/dist/env/localeGeo.js +2 -1
- package/dist/envFlag.d.ts +39 -0
- package/dist/envFlag.js +51 -0
- package/dist/finalVerifyWire.d.ts +7 -5
- package/dist/finalVerifyWire.js +6 -5
- package/dist/fleet/fleetLedger.d.ts +32 -9
- package/dist/fleet/fleetLedger.js +119 -34
- package/dist/fleet/fleetProjection.d.ts +44 -6
- package/dist/fleet/fleetProjection.js +52 -10
- package/dist/fleetAgentPanelProjection.d.ts +18 -1
- package/dist/fleetAgentPanelProjection.js +61 -14
- package/dist/forkWireCaps.d.ts +2 -1
- package/dist/forkWireCaps.js +5 -11
- package/dist/goalStopHook.d.ts +142 -0
- package/dist/goalStopHook.js +258 -0
- package/dist/headlessPermissionModeWire.d.ts +10 -0
- package/dist/headlessPermissionModeWire.js +24 -21
- package/dist/headlessReconnectWire.d.ts +7 -1
- package/dist/headlessReconnectWire.js +20 -2
- package/dist/hitl/approvalsFeed.d.ts +22 -1
- package/dist/hitl/approvalsFeed.js +103 -9
- package/dist/hitl/askGateWire.d.ts +27 -96
- package/dist/hitl/askGateWire.js +69 -618
- package/dist/hitl/frameRouter.d.ts +86 -0
- package/dist/hitl/frameRouter.js +383 -0
- package/dist/hitl/gateLedger.d.ts +119 -0
- package/dist/hitl/gateLedger.js +113 -0
- package/dist/hitl/hitlBridge.d.ts +75 -15
- package/dist/hitl/hitlBridge.js +94 -22
- package/dist/hitl/hitlHostSurface.d.ts +49 -0
- package/dist/hitl/hitlHostSurface.js +155 -0
- package/dist/hitl/parkResolver.d.ts +74 -0
- package/dist/hitl/parkResolver.js +250 -0
- package/dist/hitl/planReviewWire.d.ts +60 -2
- package/dist/hitl/planReviewWire.js +197 -91
- package/dist/hitl/toolApprovalWire.d.ts +67 -4
- package/dist/hitl/toolApprovalWire.js +129 -31
- package/dist/hooksWireCaps.d.ts +1 -82
- package/dist/hooksWireCaps.js +44 -237
- package/dist/host.d.ts +16 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +15 -1
- package/dist/interactiveToolsWire.d.ts +15 -4
- package/dist/interactiveToolsWire.js +24 -26
- package/dist/limitsWire.js +7 -40
- package/dist/liveInitToolFace.d.ts +51 -6
- package/dist/liveQuestionStore.d.ts +18 -6
- package/dist/liveQuestionStore.js +15 -0
- package/dist/model/providerPresets.js +11 -1
- package/dist/notifications.d.ts +62 -2
- package/dist/notifications.js +306 -49
- package/dist/printToolResultFrame.d.ts +12 -4
- package/dist/printToolResultFrame.js +11 -21
- package/dist/retainBackgroundWireCaps.d.ts +3 -2
- package/dist/retainBackgroundWireCaps.js +5 -9
- package/dist/sandboxWire.d.ts +9 -31
- package/dist/sandboxWire.js +51 -50
- package/dist/scenarioWire.d.ts +1 -1
- package/dist/scenarioWire.js +25 -36
- package/dist/seam.d.ts +23 -6
- package/dist/seam.js +40 -30
- package/dist/seatContract.d.ts +369 -83
- package/dist/seatContract.js +585 -198
- package/dist/selfOrchestrationWireCaps.d.ts +6 -5
- package/dist/selfOrchestrationWireCaps.js +8 -12
- package/dist/sessionSlot.d.ts +8 -0
- package/dist/sessionSlot.js +1 -0
- package/dist/steering.js +2 -2
- package/dist/subagent/engineTaskHandleWire.d.ts +3 -0
- package/dist/subagent/engineTaskHandleWire.js +17 -2
- package/dist/subagentContentStore.d.ts +5 -5
- package/dist/toolResult.d.ts +89 -8
- package/dist/toolResult.js +99 -30
- package/dist/typePins.d.ts +17 -0
- package/dist/typePins.js +1 -0
- package/dist/ultracodeWireCaps.js +5 -6
- package/dist/unrefTimer.d.ts +30 -0
- package/dist/unrefTimer.js +5 -0
- package/dist/workflow.d.ts +3 -2
- package/dist/workflow.js +3 -2
- package/dist/workflowClient.d.ts +7 -0
- package/dist/workflowClient.js +47 -12
- package/package.json +3 -3
package/dist/hitl/askGateWire.js
CHANGED
|
@@ -1,648 +1,99 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function hitlHostSurfaceMisses() {
|
|
24
|
-
return hitlHostSurfaceMissesFor(DEFAULT_SESSION_KEY);
|
|
25
|
-
}
|
|
26
|
-
/** W1 带 key 变体(miss 计数逐键)。 */
|
|
27
|
-
export function hitlHostSurfaceMissesFor(sessionKey) {
|
|
28
|
-
return hostSurfaceMissesByKey.get(sessionKey) ?? 0;
|
|
29
|
-
}
|
|
30
|
-
/** W1 读端:某会话的 HITL 宿主面(未装 = null;**不计 miss** —— miss 只在副作用真发生时计)。 */
|
|
31
|
-
export function hitlHostSurfaceFor(sessionKey) {
|
|
32
|
-
return hostSurfaceByKey.get(sessionKey) ?? null;
|
|
33
|
-
}
|
|
34
|
-
/** 测试钩:卸口 + 清计数。 */
|
|
35
|
-
export function _resetHitlHostSurfaceForTest() {
|
|
36
|
-
hostSurfaceByKey.clear();
|
|
37
|
-
hostSurfaceMissesByKey.clear();
|
|
38
|
-
}
|
|
39
|
-
function surface() {
|
|
40
|
-
const hostSurface = hitlHostSurfaceFor(DEFAULT_SESSION_KEY);
|
|
41
|
-
if (hostSurface === null) {
|
|
42
|
-
hostSurfaceMissesByKey.set(DEFAULT_SESSION_KEY, (hostSurfaceMissesByKey.get(DEFAULT_SESSION_KEY) ?? 0) + 1);
|
|
43
|
-
}
|
|
44
|
-
return hostSurface;
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* CC `utils/messages.ts` 的 `REJECT_MESSAGE` **逐字**(搬迁差分 2)。deny 后重放 tool_end 的
|
|
48
|
-
* render 面 stamp 用 —— vendored `renderToolUseRejectedMessage` 渲 `User rejected <op> to <path>`。
|
|
49
|
-
* 🔴 pure 门 B7 段:包内冻结字面量(无条件)+ 壳树全树扫描「每一处声明都逐字节相同」(壳树缺席=跳过)。
|
|
50
|
-
*/
|
|
51
|
-
export const HITL_REJECT_MESSAGE = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
|
|
52
|
-
function rejectMessageForRender() {
|
|
53
|
-
return HITL_REJECT_MESSAGE;
|
|
54
|
-
}
|
|
55
|
-
/** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
|
|
56
|
-
const MAX_GATE_HOPS = 24;
|
|
57
|
-
/** core 对被 gate/连坐 abort 的 call 铸的 tool_end 载体(逐字;desktop session-host 真引擎实测
|
|
58
|
-
* 同款)——HOLD 谓词锚它做**精确等值**,普通工具错的输出是各自错误文案,永不进 HOLD。 */
|
|
59
|
-
export const ENGINE_ABORT_TOOL_RESULT = 'Operation aborted';
|
|
60
|
-
/**
|
|
61
|
-
* 「这个工具名归 gate 管」的**统一**判据(#110 修,2026-08-02)。
|
|
62
|
-
*
|
|
63
|
-
* 🔴 它必须与 park 判定(`isAskTool` / `isToolApprovalGate`)覆盖**同一个集合** ——
|
|
64
|
-
* 两边不同集就是 [paired-mechanisms-must-share-premise] 那种「park 认得出、HOLD 认不出」的
|
|
65
|
-
* 半场病:[2150] S1 给 park 判定加了 `toolNameIsShellExec`(Bash/shell 类进 gate 了),但
|
|
66
|
-
* 本文件 tool_end 手柄的 HOLD/REJECT 谓词还停在 `isAskTool || toolNameIsFsWrite` ——
|
|
67
|
-
* 于是 Bash 的 durable gate 走到这里就两件事同时出错:
|
|
68
|
-
* ① park 期的毒化帧(`tool_end{isError:true, output:"Operation aborted"}`)没被 HOLD,
|
|
69
|
-
* **当场上屏**;用户按了 Yes、命令也真跑了,屏幕上却永远停在 `Error: Operation aborted`;
|
|
70
|
-
* ② 更狠的是紧接着的 `endedCalls.add(toolCallId)` —— resume 后引擎重放的**真** tool_end
|
|
71
|
-
* 被手柄开头的 `if (endedCalls.has(...)) continue` 静默去重,真结果一帧都上不了屏。
|
|
72
|
-
* 附带第三件:deny 腿的 `User rejected …` 文案对 Bash 同样进不去,用户看到的是引擎原文。
|
|
73
|
-
* 证据链见 cli 仓 design/cli-164 T4-A(硬 oracle=磁盘状态:按 Yes 后 printf +504ms 真落盘)。
|
|
74
|
-
*/
|
|
75
|
-
/**
|
|
76
|
-
* decide 的 `failed` 原因是不是「这个 gate 早就被解决了」(#110 缺陷② c 的判据)。
|
|
77
|
-
*
|
|
78
|
-
* 判据锚在**语义词**而不是整句:上游改文案是常事,而 `no pending checkpoint` / `resolved` /
|
|
79
|
-
* `already` 三者任一在场就足以把「重放的已决断 park」与真失败(网络断、决断超时、hop 超限)分开。
|
|
80
|
-
* 🔴 方向:这条判**错**的后果不对称 —— 误判成「已解决」最多让流多读一轮(hop 预算兜底),
|
|
81
|
-
* 误判成「真失败」会把一个已经 completed 的 run 谎报成失败并烧掉整条会话转录。所以宁可宽。
|
|
82
|
-
*/
|
|
83
|
-
function isAlreadyResolvedGateReason(reason) {
|
|
84
|
-
if (typeof reason !== 'string')
|
|
85
|
-
return false;
|
|
86
|
-
const r = reason.toLowerCase();
|
|
87
|
-
return r.includes('no pending checkpoint') || r.includes('resolved') || r.includes('already');
|
|
88
|
-
}
|
|
89
|
-
function isGatedToolName(name) {
|
|
90
|
-
if (isAskTool(name))
|
|
91
|
-
return true;
|
|
92
|
-
if (typeof name !== 'string')
|
|
93
|
-
return false;
|
|
94
|
-
return toolNameIsFsWrite(name) || toolNameIsShellExec(name);
|
|
95
|
-
}
|
|
96
|
-
function isAskTool(name) {
|
|
97
|
-
return typeof name === 'string' && name.replace(/[\s_-]+/g, '').toLowerCase() === 'askuserquestion';
|
|
98
|
-
}
|
|
99
|
-
/** done 帧的 AskUserQuestion park 形状(结构性读;别的终态一律 false)。 */
|
|
100
|
-
function isAskGatePark(result) {
|
|
101
|
-
const r = result;
|
|
102
|
-
return (!!r &&
|
|
103
|
-
r.status === 'suspended' &&
|
|
104
|
-
isAskTool(r.checkpointGate?.toolName) &&
|
|
105
|
-
typeof r.taskId === 'string' &&
|
|
106
|
-
r.taskId.length > 0);
|
|
107
|
-
}
|
|
108
|
-
/** done 帧的工具审批 park 形状([816] 放宽腿 + [2150] Bash 半场):status suspended +
|
|
109
|
-
* checkpointGate 指 fs 写三件 / Bash / 一等 kind==='tool_approval'(超集谓词 isToolApprovalGate,
|
|
110
|
-
* decide 腿对两族同构、卡形归壳分发)。Ask gate 恒先判(问答 overlay 原路)。 */
|
|
111
|
-
function isFsApprovalPark(result) {
|
|
112
|
-
const r = result;
|
|
113
|
-
return (!!r &&
|
|
114
|
-
r.status === 'suspended' &&
|
|
115
|
-
typeof r.taskId === 'string' &&
|
|
116
|
-
r.taskId.length > 0 &&
|
|
117
|
-
!isAskTool(r.checkpointGate?.toolName) &&
|
|
118
|
-
isToolApprovalGate(r.checkpointGate));
|
|
119
|
-
}
|
|
120
|
-
/** 把 wire 答案({answers:[{header,selected,note?}]})折回 CC 卡片的 Record<question,string> 形状
|
|
121
|
-
* (multiSelect 与对话框同款 ", " lossy join;note → annotations.notes)。 */
|
|
122
|
-
export function toAnsweredOutput(questions, answer) {
|
|
123
|
-
const answers = {};
|
|
124
|
-
const annotations = {};
|
|
125
|
-
for (const entry of answer.answers ?? []) {
|
|
126
|
-
const q = questions.find(qq => qq.header === entry.header);
|
|
127
|
-
const key = typeof q?.question === 'string' ? q.question : entry.header;
|
|
128
|
-
answers[key] = (entry.selected ?? []).join(', ');
|
|
129
|
-
if (entry.note)
|
|
130
|
-
annotations[key] = { notes: entry.note };
|
|
131
|
-
}
|
|
132
|
-
return {
|
|
133
|
-
type: 'ask-user-question',
|
|
134
|
-
questions,
|
|
135
|
-
answers,
|
|
136
|
-
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
// ── 件3(中断事故修复批 G,2026-07-15)—— 中断 deny 的有界观察 ─────────────────────────────────
|
|
140
|
-
/** cancel-by-deny 的后台 settle 预算。decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测
|
|
141
|
-
* 4-5s+),但 DENY-abort 语义上引擎收到即终结 run;2s 内连收都没收到 ⇒ 按丢失警示(晚到成功
|
|
142
|
-
* 只是多一行良性 warn,比锁死无线索诚实)。 */
|
|
143
|
-
export const CANCEL_DENY_BUDGET_MS = 2000;
|
|
144
|
-
/** warn 行文案(测试锁字面)。 */
|
|
145
|
-
export const CANCEL_DENY_WARN_TEXT = 'could not cancel the pending question — the session may stay locked; the run may need engine-side recovery';
|
|
146
|
-
const CANCEL_DENY_WARN_KEY = 'hitl-cancel-deny-warn';
|
|
147
|
-
const CANCEL_DENY_WARN_TIMEOUT_MS = 10_000;
|
|
148
|
-
/** 失败 warn 上屏:借 footer notifications 通道。壳原文经 `appStateRef` 直写 store
|
|
149
|
-
* (immediate 语义:立即顶到 current + 到点自清「仅当仍是本 warn 时」);本包把那两次
|
|
150
|
-
* setState 拆成宿主口的两个动词,**时序纪律(2s 预算 / 10s 自清)留在库内**——
|
|
151
|
-
* [paired-mechanisms-must-share-premise]:只给「显示」不给「清」,端各写各的清法必然漂。
|
|
152
|
-
* 无口(print/headless 没屏可上)⇒ 静默,与壳原文 `if (!store) return` 同语义。 */
|
|
153
|
-
function surfaceCancelDenyWarn() {
|
|
154
|
-
try {
|
|
155
|
-
const s = surface();
|
|
156
|
-
if (!s)
|
|
157
|
-
return;
|
|
158
|
-
s.showNotice({
|
|
159
|
-
key: CANCEL_DENY_WARN_KEY,
|
|
160
|
-
text: CANCEL_DENY_WARN_TEXT,
|
|
161
|
-
color: 'warning',
|
|
162
|
-
priority: 'immediate',
|
|
163
|
-
timeoutMs: CANCEL_DENY_WARN_TIMEOUT_MS,
|
|
164
|
-
});
|
|
165
|
-
const timer = setTimeout(() => {
|
|
166
|
-
try {
|
|
167
|
-
s.clearNoticeIfCurrent(CANCEL_DENY_WARN_KEY);
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
/* fail-soft */
|
|
171
|
-
}
|
|
172
|
-
}, CANCEL_DENY_WARN_TIMEOUT_MS);
|
|
173
|
-
timer.unref?.();
|
|
174
|
-
}
|
|
175
|
-
catch {
|
|
176
|
-
/* fail-soft:上屏失败绝不再伤害 abort 流 */
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面)。
|
|
181
|
-
* 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
|
|
182
|
-
* - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
|
|
183
|
-
* - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
|
|
184
|
-
* 撞 409;配合件2b 的专属文案,用户知道现场 + 出路);
|
|
185
|
-
* - HitlSafetyError code==='no_pending' ⇒ 良性静默(pending 已被别处消解/过期 —— run 没锁;
|
|
186
|
-
* hitlBridge decideTool 的同款语义,那里的静默维持不动)。
|
|
187
|
-
*/
|
|
188
|
-
export function observeCancelByDeny(settle, taskId) {
|
|
189
|
-
let timer;
|
|
190
|
-
const budget = new Promise((_, reject) => {
|
|
191
|
-
timer = setTimeout(() => reject(new Error(`cancel-by-deny did not settle within ${CANCEL_DENY_BUDGET_MS}ms`)), CANCEL_DENY_BUDGET_MS);
|
|
192
|
-
timer.unref?.();
|
|
193
|
-
});
|
|
194
|
-
void Promise.race([settle, budget])
|
|
195
|
-
.then(() => {
|
|
196
|
-
hostLog('debug', `[hitl-ask] cancel-by-deny settled for run ${taskId} (session unlocked)`);
|
|
197
|
-
})
|
|
198
|
-
.catch((e) => {
|
|
199
|
-
// ⚠️ 这里的 `.code` 是 **client-core 自家 HitlSafetyError 的契约属性**(hitlBridge.ts,词表
|
|
200
|
-
// binding_mismatch|no_pending|wrong_gate|bad_plan_edit)—— 不是 SDK 错误的 `errorCode`。
|
|
201
|
-
// 2026-07-30 `.code` 普查曾把本处误判成 SDK 属性名病改成 errorCode,pure 门 B7 腿当场拦下
|
|
202
|
-
// (反向修复);判 `.code` 读点的病与否,先看**谁抛的**,别按属性名模式匹配。
|
|
203
|
-
if (e?.code === 'no_pending') {
|
|
204
|
-
hostLog('debug', `[hitl-ask] cancel-by-deny no_pending for run ${taskId} (benign — already resolved)`);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
hostLog('error', `[hitl-ask] cancel-by-deny FAILED for run ${taskId}: ${String(e)}`);
|
|
208
|
-
surfaceCancelDenyWarn();
|
|
209
|
-
})
|
|
210
|
-
.finally(() => {
|
|
211
|
-
if (timer !== undefined)
|
|
212
|
-
clearTimeout(timer);
|
|
213
|
-
});
|
|
214
|
-
// 超时后原 promise 迟到 reject 不得变成 unhandledRejection。
|
|
215
|
-
void settle.catch(() => { });
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* 弹既有 AskUserQuestion overlay → 等作答 → 经 HitlBridge decide(答案/拒答)。
|
|
219
|
-
* decide 成功即引擎已把 run 驱到下一状态(sync);调用方随后 attach runs.events 续流。
|
|
220
|
-
*/
|
|
221
|
-
async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
|
|
222
|
-
if (!hasQuestionOverlay()) {
|
|
223
|
-
return { kind: 'failed', reason: 'no question overlay mounted (print/non-REPL mode)' };
|
|
224
|
-
}
|
|
225
|
-
// pending 行 = 问题 payload 的权威源 + D-1 绑定的唯一 surface(PendingCheckpoint.input)。
|
|
226
|
-
let pending;
|
|
227
|
-
try {
|
|
228
|
-
const rows = (await deps.client.approvals.list(signal ? { signal } : undefined)).pending;
|
|
229
|
-
pending =
|
|
230
|
-
rows.find(r => r.taskId === taskId && isAskTool(r.toolName ?? undefined)) ??
|
|
231
|
-
rows.find(r => r.taskId === taskId);
|
|
232
|
-
}
|
|
233
|
-
catch (e) {
|
|
234
|
-
return { kind: 'failed', reason: `approvals.list failed: ${String(e)}` };
|
|
235
|
-
}
|
|
236
|
-
if (!pending)
|
|
237
|
-
return { kind: 'failed', reason: 'no pending checkpoint for this run (resolved/expired?)' };
|
|
238
|
-
const gatedCallId = pending.toolCallId ?? pending.boundCallId ?? undefined;
|
|
239
|
-
// 问题 payload:优先流上 tool_start.args(零额外语义),缺则 pending.input(service 已 redact + 限长)。
|
|
240
|
-
const fromArgs = gatedCallId
|
|
241
|
-
? askArgsByCall.get(gatedCallId)?.questions
|
|
242
|
-
: undefined;
|
|
243
|
-
const fromPending = pending.input?.questions;
|
|
244
|
-
const questions = Array.isArray(fromArgs) && fromArgs.length > 0 ? fromArgs : fromPending;
|
|
245
|
-
if (!Array.isArray(questions) || questions.length === 0) {
|
|
246
|
-
// [1543]②:input 超引擎入参帽(MAX_TOOL_INPUT_CHARS,preview 富文本题易中)时 server 降级为
|
|
247
|
-
// {truncated:true} ——无题可渲不是引擎代差,是 payload 被截。分形出诚实理由(fail-soft 链
|
|
248
|
-
// 会把 reason 记入 SEMA_DEBUG,别再误导成 pre-CHANNEL[56] 老引擎)。
|
|
249
|
-
const truncated = pending.input?.truncated === true;
|
|
250
|
-
return {
|
|
251
|
-
kind: 'failed',
|
|
252
|
-
gatedCallId,
|
|
253
|
-
reason: truncated
|
|
254
|
-
? 'question payload exceeded the engine input cap (input={truncated:true}) — dialog cannot be re-rendered; run stays suspended for out-of-band decide'
|
|
255
|
-
: 'gate has no question payload (pre-CHANNEL[56] engine?)',
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
// overlay 往返:合成 frame(id 独占命名空间 hitl-ask:)→ local responder 一次性收答。
|
|
259
|
-
const questionId = `hitl-ask:${gatedCallId ?? taskId}`;
|
|
260
|
-
const answer = await new Promise(resolve => {
|
|
261
|
-
const unregister = registerLocalQuestionResponder(questionId, async (_id, a) => {
|
|
262
|
-
cleanup();
|
|
263
|
-
resolve(a);
|
|
264
|
-
return { ok: true };
|
|
265
|
-
});
|
|
266
|
-
const onAbort = () => {
|
|
267
|
-
cleanup();
|
|
268
|
-
// 撤下还开着的对话框(question_complete = overlay 的既有 dismiss 语义)
|
|
269
|
-
publishQuestionFrame({ type: 'question_complete', questionId });
|
|
270
|
-
resolve(null);
|
|
271
|
-
};
|
|
272
|
-
const cleanup = () => {
|
|
273
|
-
unregister();
|
|
274
|
-
signal?.removeEventListener('abort', onAbort);
|
|
275
|
-
};
|
|
276
|
-
if (signal?.aborted) {
|
|
277
|
-
onAbort();
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
281
|
-
publishQuestionFrame({ type: 'question', questionId, questions });
|
|
282
|
-
});
|
|
283
|
-
const bridge = new HitlBridge(deps.client, taskId);
|
|
284
|
-
if (answer === null) {
|
|
285
|
-
// turn 被中断(Esc/Ctrl+C):cancel-by-deny(contract/04 §2.4 —— suspended run 不 runs.cancel)。
|
|
286
|
-
// 件3(中断事故修复批 G,2026-07-15,症状1 壳侧配套):此前 .catch(()=>{}) 全吞 = deny 丢失时
|
|
287
|
-
// run 永卡 suspended,session 锁死,用户下一条消息撞 409「active run」还全无线索。改为有界观察
|
|
288
|
-
// (observeCancelByDeny,2s 预算):abort 仍立即返回用户控制(不 await,交互时序不变),后台
|
|
289
|
-
// settle 失败/超时上屏一行 warn + SEMA_DEBUG 记失败原因。引擎侧解锁腿([866] server:cancel
|
|
290
|
-
// suspended 改语义 + reapSuspended TTL)到货前,这是壳能做的最诚实半场。
|
|
291
|
-
observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId), taskId);
|
|
292
|
-
return { kind: 'aborted', gatedCallId };
|
|
293
|
-
}
|
|
294
|
-
try {
|
|
295
|
-
const entries = (answer.answers ?? []);
|
|
296
|
-
const hasContent = entries.some(a => (a.selected?.length ?? 0) > 0 || (a.note?.length ?? 0) > 0);
|
|
297
|
-
if (hasContent) {
|
|
298
|
-
await bridge.answerQuestion(entries, gatedCallId, signal ? { signal } : undefined);
|
|
299
|
-
return { kind: 'decided', gatedCallId, answered: toAnsweredOutput(questions, answer) };
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
// 用户拒答(overlay reject/dismiss 送空 answers)= 诚实 deny;模型收 denied 结果自续。
|
|
303
|
-
await bridge.decideTool({ decision: 'deny', reason: 'User declined to answer' }, gatedCallId, signal ? { signal } : undefined);
|
|
304
|
-
}
|
|
305
|
-
return { kind: 'decided', gatedCallId };
|
|
306
|
-
}
|
|
307
|
-
catch (e) {
|
|
308
|
-
return { kind: 'failed', gatedCallId, reason: `decide failed: ${String(e)}` };
|
|
309
|
-
}
|
|
310
|
-
}
|
|
1
|
+
import { createGateLedger } from './gateLedger.js';
|
|
2
|
+
import { isHostProgressFrame, routeFrame, } from './frameRouter.js';
|
|
3
|
+
import { resolvePark } from './parkResolver.js';
|
|
4
|
+
// ── 宿主面口(搬迁差分 3)────────────────────────────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// 壳里这三件都是 `getAppStateStoreRef()` 直写 `AppState` + `require('../utils/autoModeDenials.js')`。
|
|
7
|
+
// `AppState.notifications` 与 Recent Denials 面板都是**端的状态形状**(web/桌面各有各的通知系统),
|
|
8
|
+
// 所以做成口。🔴 缺席 = 那一行 warn 不上屏 —— 壳原文在无 provider(print/headless)时**本来就是
|
|
9
|
+
// 静默**(`if (!store) return`),所以缺席语义与原文一致;但**漏装**与「真的没屏」看起来一样,
|
|
10
|
+
// 于是计 miss(`hitlHostSurfaceMisses()` 宿主自检恒应为 0)。
|
|
11
|
+
// REF-CC-023(2026-08-02,P3 wave2 卡C-bridge):口本体(`HitlHostSurface` 接口 / install·misses·for
|
|
12
|
+
// 读写口 / `surfaceForCurrentSession`)与 cancel-by-deny 有界观察(`observeCancelByDeny`)
|
|
13
|
+
// 已整条搬进 `./hitlHostSurface.js` —— `toolApprovalWire.ts` 的中断-deny 臂也要用同一份观察器,
|
|
14
|
+
// 而 `toolApprovalWire.ts` 是本文件的上游 import,反向 import 会成环;单源到一个两边都能到达的
|
|
15
|
+
// 独立模块解环。REF-CC-032 之后这两处的消费点在 `frameRouter.ts` / `parkResolver.ts`。
|
|
16
|
+
// ── 公开面(REF-CC-032 三刀后原位 re-export;`index.ts` 的 `export *` 经本文件出去)──────────
|
|
17
|
+
//
|
|
18
|
+
// 🔴 名字与语义一个都没动:值四个(`HITL_REJECT_MESSAGE` / `ENGINE_ABORT_TOOL_RESULT` /
|
|
19
|
+
// `toAnsweredOutput` / 下方 `bridgeAskUserQuestionGates`)、类型四个。定义搬去了实现所在的
|
|
20
|
+
// 那一刀,本文件是它们对外的**唯一门牌**——下游 import 路径不变,public-export 基线不变。
|
|
21
|
+
export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
|
|
22
|
+
export { toAnsweredOutput, isAlreadyResolvedGateReason } from './parkResolver.js';
|
|
311
23
|
/**
|
|
312
24
|
* 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
|
|
313
25
|
* 其它事件原样透传;非 AskUserQuestion 的 gate 保持现状。fail-soft:任何桥内失败回退为
|
|
314
26
|
* 「flush 毒化 tool_end + 原样终帧」(= 修复前行为)。
|
|
315
27
|
*
|
|
28
|
+
* 双层循环(REF-CC-032 后只剩驱动骨架):
|
|
29
|
+
* · 内环 `for await` 读流 —— 每帧先记 seq、再判扣留帧放行、再交 `routeFrame` 定处置;
|
|
30
|
+
* `park` 动作 break 出内环。
|
|
31
|
+
* · 外环 `while` —— `resolvePark` 决断:`reattach` 就换一条 `runs.events` 流接着读
|
|
32
|
+
* (hop 预算在 resolver 内按 `hops` 判),`failsoft` 就吐完 events 收场。
|
|
33
|
+
*
|
|
316
34
|
* @param source 上游 AgentEvent 流(tasks.stream 或 runs.events,已过 demuxQuestionFrames)。
|
|
317
35
|
* @param opts.taskId runs.events 消费时已知的 run handle(sync leg 从 suspended done 捕获)。
|
|
318
36
|
*/
|
|
319
37
|
export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
* renderToolUseRejectedMessage 渲 `User rejected <op> to <path>`,CC 逐字)。 */
|
|
329
|
-
const deniedCalls = new Set();
|
|
330
|
-
/** started-未-ended 的 fs 写 call(顺序保留)——同步 tool_approval 帧的 callId 关联面
|
|
331
|
-
* (帧契约不带 toolCallId;引擎阻塞在帧上,最后一张未收口 fs 写 call 即被 gate 的 call)。 */
|
|
332
|
-
const pendingFsCalls = [];
|
|
333
|
-
/** 同步帧腿 deny 但 callId 关联不上(引擎在 tool_start 前出帧的形状):stamp 下一张 fs 写报错帧。 */
|
|
334
|
-
let denyStampNextFsEnd = false;
|
|
335
|
-
/** 已作答的 gate(toolCallId → CC 卡片形状的真实答案)——续流重放的解答 tool_end 无 output,
|
|
336
|
-
* 不补就会渲成结果不可用;这里 stamp `structured` 让卡片渲真实选择。 */
|
|
337
|
-
const resolvedAnswers = new Map();
|
|
338
|
-
let lastSeq;
|
|
339
|
-
/**
|
|
340
|
-
* 已经决断过的 gate 身份(#110 缺陷② 的**承重**修,2026-08-02)。
|
|
341
|
-
*
|
|
342
|
-
* durable re-attach 会把「已经决断过的 park」重放一遍(边界上必然发生:`lastEventId` 再准也
|
|
343
|
-
* 只能精确到帧,park 帧本身就在续流的重放窗口里)。重放的 park 走到下面会去
|
|
344
|
-
* `surfaceFsApprovalAndDecide` 取 pending approval —— 那一行早已 resolved ⇒ 拿不到 ⇒
|
|
345
|
-
* `failed` ⇒ 合成 `hitl_unanswered` 终帧。而引擎侧 run 其实已经 `completed`:壳谎报失败,
|
|
346
|
-
* 连带 CC 转录 0 行写出、同 session `--resume` 报 `No conversation found` —— **会话废掉**。
|
|
347
|
-
* 与 `startedCalls`/`endedCalls` 同款的身份去重是这条的正解:决断过的 gate 不再决断第二次。
|
|
348
|
-
*/
|
|
349
|
-
const decidedGates = new Set();
|
|
350
|
-
let realTaskId = opts?.taskId;
|
|
38
|
+
const led = createGateLedger();
|
|
39
|
+
const taskId = { current: opts?.taskId };
|
|
40
|
+
const ctx = {
|
|
41
|
+
led,
|
|
42
|
+
deps,
|
|
43
|
+
taskId,
|
|
44
|
+
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
45
|
+
};
|
|
351
46
|
let stream = source;
|
|
352
47
|
let hops = 0;
|
|
353
|
-
function* flushHeld() {
|
|
354
|
-
for (const [callId, held] of heldAskEnds) {
|
|
355
|
-
endedCalls.add(callId);
|
|
356
|
-
yield held;
|
|
357
|
-
}
|
|
358
|
-
heldAskEnds.clear();
|
|
359
|
-
}
|
|
360
48
|
while (true) {
|
|
361
|
-
/** park 请求:遇 AskUserQuestion /
|
|
49
|
+
/** park 请求:遇 AskUserQuestion / 工具审批 gate 时置位后 break 内环。 */
|
|
362
50
|
let park = null;
|
|
363
51
|
for await (const ev of stream) {
|
|
364
|
-
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
lastSeq = String(rawSeq);
|
|
372
|
-
// ── [2084]① 扣留帧「模型推进即放行」(2026-07-30;同日 web 复审纠偏收窄触发集)──────────
|
|
373
|
-
// HOLD 只护 park 窗口。放行触发集 = host-lane **模型推进**帧:text_delta/reasoning_delta/
|
|
374
|
-
// tool_start —— park 路径上这三种在 done{suspended} 之前不会出现,真错路径上下一轮必来其一。
|
|
375
|
-
// 🔴 turn_end 明确**不在**触发集(初版在,web 复审真机轨迹逮红):core agent-loop 的 turn_end
|
|
376
|
-
// emit 在 executeToolCalls 之后**无 abort/suspend 守卫**(邻臂都有,唯此一处没有)⇒ 真 park
|
|
377
|
-
// 的帧序是 tool_end(毒化)→ message_committed → turn_end → done{suspended},turn_end 若放行
|
|
378
|
-
// 会把毒化帧漏上屏,且 flushHeld 的 endedCalls 记账让 decide 后重放的解答帧被当已收口丢弃
|
|
379
|
-
// (真实答案卡永不出现)。代价 = turn 末尾无后续推进帧的 abort 形帧等到终帧 flush,与修前同。
|
|
380
|
-
// parentToolCallId 在场 = sub-agent 行帧,park 期间孙代照常说话,不算 host 推进(负控①C)。
|
|
381
|
-
// (普通工具错自 [2084]①-b 谓词收窄后根本不进 HOLD,零滞后不依赖本触发集。)
|
|
382
|
-
if (heldAskEnds.size > 0 &&
|
|
383
|
-
(ev.type === 'text_delta' || ev.type === 'reasoning_delta' || ev.type === 'tool_start') &&
|
|
384
|
-
ev.parentToolCallId === undefined) {
|
|
385
|
-
yield* flushHeld();
|
|
386
|
-
}
|
|
387
|
-
// ── server 1.191 同步帧腿([830]①):`tool_approval` 帧 → 三选卡 → respond ────────────────
|
|
388
|
-
// 引擎此刻同步阻塞在帧上(fail-closed:无 respond/TTL 5min/abort ⇒ deny),await 卡决断安全。
|
|
389
|
-
// 帧是 HITL 词汇非 AgentEvent arm——两种帧都不外泄到渲染管道。
|
|
390
|
-
if (isToolApprovalFrame(ev)) {
|
|
391
|
-
// #51: `ToolApprovalFrame` frames are documented as NOT part of the
|
|
392
|
-
// `AgentEvent` union (a separate SSE wire vocabulary riding the same
|
|
393
|
-
// stream) — intersecting the type-predicate's asserted type with the
|
|
394
|
-
// unrelated `AgentEvent` union collapses every arm to `never`. Local
|
|
395
|
-
// rebind restores the real (already-checked) shape.
|
|
396
|
-
const frame = ev;
|
|
397
|
-
if (frame.type === 'tool_approval_complete')
|
|
398
|
-
continue; // 收口帧:卡已由决断路径撤下,吞掉
|
|
399
|
-
if (!deps.respondToolApproval) {
|
|
400
|
-
hostLog('debug', `liveHitlAskWire: tool_approval frame ${frame.approvalId} but no respond wire — engine self-settles (TTL deny)`);
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
// 补验批 askq 案定谳(2026-07-23,黑板 [1534],引擎 dist 逐坐标读定):core 的 durable
|
|
404
|
-
// question policy 对 AskUserQuestion 出 action:"ask",本意让 durable gate park(suspended
|
|
405
|
-
// → 本函数下方 bridge 弹卡收答);但 core suspendAsk 的退位条件(safety 缺席 + onAsk 在场)
|
|
406
|
-
// 把问题门也抢进了同步审批帧腿(壳为 fs 写权限三选卡开的 TOOL_APPROVAL_ENABLED)——同步帧
|
|
407
|
-
// 二值 respond 装不下「答案」,链路死锁:deny=模型误信问题已展示原地等;allow=执行工具
|
|
408
|
-
// 本体炸 durable 装配的 onQuestion 占位(QUESTION_AWAITS_RESUME)。此处取两害之轻恒 allow:
|
|
409
|
-
// 模型收到明确错误后退化成正文提问,用户尚可下一条消息作答(deny 形连这条退化路都没有)。
|
|
410
|
-
// 承重域收敛史([1543]§一 cli 注意):core 1.375 修 question gate 恒 park(健康路),
|
|
411
|
-
// 1.376 又断了降级路(checkpoint pre-commit/put 失败 fallback 改 typed refusal)与继承链
|
|
412
|
-
// 两臂——1.376+ 下 ask 工具在任何路径都不再出 tool_approval 帧,本分支唯一剩余承重
|
|
413
|
-
// = 旧引擎(<1.375)兼容,askq pty fence @1.375+ 已四门全绿实证([1542])。
|
|
414
|
-
if (isAskTool(ev.toolName)) {
|
|
415
|
-
try {
|
|
416
|
-
await deps.respondToolApproval(frame.approvalId, 'allow', {
|
|
417
|
-
...(opts?.signal && !opts.signal.aborted ? { signal: opts.signal } : {}),
|
|
418
|
-
});
|
|
419
|
-
hostLog('debug', `liveHitlAskWire: ask-tool approval frame ${frame.approvalId} → allow(问题门放行,候 park 弹卡)`);
|
|
420
|
-
}
|
|
421
|
-
catch (e) {
|
|
422
|
-
hostLog('debug', `liveHitlAskWire: ask-tool approval allow failed for ${frame.approvalId}: ${String(e)} — engine self-settles (TTL)`);
|
|
423
|
-
}
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
// [1535]/server 1.258:子代的审批帧(broker 腿浮到宿主流,判别=fromSubagent 显式键
|
|
427
|
-
// core 1.378 优先/sourceTaskId 在场性兜底)——子代的 tool_start 不在宿主行流上,绝不把
|
|
428
|
-
// 宿主自己的 pending fs call 关联给它(args 会错配成宿主的 diff、deny 会误标宿主 call 的
|
|
429
|
-
// REJECT 收口)。args 从帧上取,deny 收口归子代 lane 自理。
|
|
430
|
-
const fromSubagent = isFromSubagent(ev);
|
|
431
|
-
hostLog('debug', `liveHitlAskWire: tool_approval frame ${frame.approvalId} tool=${String(ev.toolName)}${fromSubagent ? ` from-subagent=${String(ev.sourceTaskId ?? ev.sourceAgentName ?? 'explicit')}` : ''}`);
|
|
432
|
-
const gatedCallId = !fromSubagent && pendingFsCalls.length > 0 ? pendingFsCalls[pendingFsCalls.length - 1] : undefined;
|
|
433
|
-
const decision = await surfaceToolApprovalFrameAndRespond(ev, deps.respondToolApproval, gatedCallId !== undefined ? askArgsByCall.get(gatedCallId) : undefined, opts?.signal);
|
|
434
|
-
if (decision === 'deny' && !fromSubagent) {
|
|
435
|
-
// 该 call 的报错收口帧 stamp REJECT 文案(vendored `User rejected <op> to <path>` 卡)。
|
|
436
|
-
if (gatedCallId !== undefined)
|
|
437
|
-
deniedCalls.add(gatedCallId);
|
|
438
|
-
else
|
|
439
|
-
denyStampNextFsEnd = true;
|
|
440
|
-
}
|
|
52
|
+
led.noteSeq(ev);
|
|
53
|
+
// HOLD 只护 park 窗口:host lane 的模型推进帧一到就放行扣留帧(触发集的来龙去脉见
|
|
54
|
+
// `frameRouter.isHostProgressFrame` 上方长注)。
|
|
55
|
+
if (led.heldCount() > 0 && isHostProgressFrame(ev))
|
|
56
|
+
yield* led.flushHeld();
|
|
57
|
+
const action = await routeFrame(ev, ctx);
|
|
58
|
+
if (action.kind === 'skip')
|
|
441
59
|
continue;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
continue; // durable 重放的已渲 call
|
|
446
|
-
startedCalls.add(ev.toolCallId);
|
|
447
|
-
if (isGatedToolName(ev.toolName)) {
|
|
448
|
-
// #110:Bash 也要记 args —— 卡片渲染要拿到 `command`,不记则 shell gate 的卡面空着。
|
|
449
|
-
askArgsByCall.set(ev.toolCallId, ev.args);
|
|
450
|
-
if (typeof ev.toolName === 'string' && toolNameIsFsWrite(ev.toolName)) {
|
|
451
|
-
pendingFsCalls.push(ev.toolCallId);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
yield ev;
|
|
60
|
+
if (action.kind === 'yield') {
|
|
61
|
+
for (const out of action.events)
|
|
62
|
+
yield out;
|
|
455
63
|
continue;
|
|
456
64
|
}
|
|
457
|
-
if (
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
{
|
|
461
|
-
const pi = pendingFsCalls.indexOf(ev.toolCallId);
|
|
462
|
-
if (pi !== -1)
|
|
463
|
-
pendingFsCalls.splice(pi, 1);
|
|
464
|
-
}
|
|
465
|
-
// ── 分类器批3([907]):auto-mode 分类器 deny 裁决帧 —— 结构性识别(core 机器签名)优先于
|
|
466
|
-
// fs HOLD/REJECT 分支(签名比"fs 写报错"更特定;分类器 deny 从不产 tool_approval 帧,
|
|
467
|
-
// deniedCalls/denyStampNextFsEnd 与之互斥)。副作用 = CC 207 通知行 + Recent Denials 记账;
|
|
468
|
-
// render 面 stamp 去 system-reminder 包裹渲裁决原文(render-only,模型面原文不动)。
|
|
469
|
-
if (ev.isError === true && !deniedCalls.has(ev.toolCallId)) {
|
|
470
|
-
const verdict = classifierDenyFromToolEnd(ev);
|
|
471
|
-
if (verdict) {
|
|
472
|
-
endedCalls.add(ev.toolCallId);
|
|
473
|
-
heldAskEnds.delete(ev.toolCallId);
|
|
474
|
-
surface()?.surfaceClassifierDeny(typeof ev.toolName === 'string' ? ev.toolName : 'tool', verdict);
|
|
475
|
-
hostLog('debug', `liveHitlAskWire: classifier deny verdict on ${ev.toolCallId} (${String(ev.toolName)}) — labeled render + notice`);
|
|
476
|
-
yield { ...ev, output: verdict.message, structured: undefined };
|
|
477
|
-
continue;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
if (ev.isError === true &&
|
|
481
|
-
// #110:同步帧腿 deny 的 stamp 也要盖 shell —— 否则按 No 时用户看到的是引擎原文
|
|
482
|
-
// `Operation aborted` 而不是 CC 的 `User rejected …`。
|
|
483
|
-
isGatedToolName(ev.toolName) &&
|
|
484
|
-
denyStampNextFsEnd &&
|
|
485
|
-
!deniedCalls.has(ev.toolCallId)) {
|
|
486
|
-
// 同步帧腿 deny 且 callId 没关联上:下一张 fs 写报错帧即该 gate 的收口帧,stamp REJECT 文案。
|
|
487
|
-
denyStampNextFsEnd = false;
|
|
488
|
-
endedCalls.add(ev.toolCallId);
|
|
489
|
-
heldAskEnds.delete(ev.toolCallId);
|
|
490
|
-
yield { ...ev, output: rejectMessageForRender(), structured: undefined };
|
|
491
|
-
continue;
|
|
492
|
-
}
|
|
493
|
-
if (ev.isError === true && isGatedToolName(ev.toolName)) {
|
|
494
|
-
if (deniedCalls.has(ev.toolCallId)) {
|
|
495
|
-
// 三选卡 No:重放的报错帧 stamp REJECT 文案 → vendored 卡渲 `User rejected <op> to <path>`。
|
|
496
|
-
deniedCalls.delete(ev.toolCallId);
|
|
497
|
-
endedCalls.add(ev.toolCallId);
|
|
498
|
-
heldAskEnds.delete(ev.toolCallId);
|
|
499
|
-
yield { ...ev, output: rejectMessageForRender(), structured: undefined };
|
|
500
|
-
continue;
|
|
501
|
-
}
|
|
502
|
-
// [2084]①-b HOLD 谓词收窄(2026-07-30,desktop 复审建议采纳):park 毒化帧的载体是
|
|
503
|
-
// **确切的引擎 abort 标记**(core 对被 gate/连坐 abort 的 call 铸
|
|
504
|
-
// `tool_end{isError:true, output:"Operation aborted"}`;desktop session-host 2026-07-29
|
|
505
|
-
// 对真引擎实测同款)。普通工具错(old_string 不命中/read-gate 拒绝…)输出是各自的错误
|
|
506
|
-
// 文案,**根本不该进 HOLD**——收窄后它们零滞后直达转录,不再依赖「推进信号放行」。
|
|
507
|
-
if (ev.output === ENGINE_ABORT_TOOL_RESULT) {
|
|
508
|
-
heldAskEnds.set(ev.toolCallId, ev); // park 毒化帧,先 HOLD(gate 定性前不渲 Error 卡)
|
|
509
|
-
continue;
|
|
510
|
-
}
|
|
511
|
-
// 普通失败:落到下方通用收口路径,立即上屏。
|
|
512
|
-
}
|
|
513
|
-
endedCalls.add(ev.toolCallId);
|
|
514
|
-
heldAskEnds.delete(ev.toolCallId); // 正常收口(重放的 isError:false 解答帧)赢
|
|
515
|
-
const answered = resolvedAnswers.get(ev.toolCallId);
|
|
516
|
-
if (answered &&
|
|
517
|
-
ev.isError !== true &&
|
|
518
|
-
ev.structured === undefined) {
|
|
519
|
-
resolvedAnswers.delete(ev.toolCallId);
|
|
520
|
-
yield { ...ev, structured: answered }; // 真实答案卡(见 resolvedAnswers 注)
|
|
521
|
-
}
|
|
522
|
-
else {
|
|
523
|
-
yield ev;
|
|
524
|
-
}
|
|
525
|
-
continue;
|
|
526
|
-
}
|
|
527
|
-
if (ev.type === 'suspended') {
|
|
528
|
-
// durable leg 的 park arm(续流中模型再次提问/再次撞写权限)。gate 只带 kind/toolName,
|
|
529
|
-
// payload 走 approvals。
|
|
530
|
-
if (ev.gate && isAskTool(ev.gate.toolName) && realTaskId) {
|
|
531
|
-
park = { gate: 'ask' };
|
|
532
|
-
break;
|
|
533
|
-
}
|
|
534
|
-
if (ev.gate && isToolApprovalGate(ev.gate) && realTaskId) {
|
|
535
|
-
park = { gate: 'fs' };
|
|
536
|
-
break;
|
|
537
|
-
}
|
|
538
|
-
yield ev; // 其余 gate:透传(现状,eventToSdkMessage 出 null)
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
if (ev.type === 'done') {
|
|
542
|
-
if (isAskGatePark(ev.result)) {
|
|
543
|
-
realTaskId = realTaskId ?? ev.result.taskId;
|
|
544
|
-
park = { pendingDone: ev, gate: 'ask' };
|
|
545
|
-
break;
|
|
546
|
-
}
|
|
547
|
-
if (isFsApprovalPark(ev.result)) {
|
|
548
|
-
realTaskId = realTaskId ?? ev.result.taskId;
|
|
549
|
-
park = { pendingDone: ev, gate: 'fs' };
|
|
550
|
-
break;
|
|
551
|
-
}
|
|
552
|
-
yield* flushHeld(); // 真错(非 gate)的 Ask tool_end 此刻诚实渲染
|
|
553
|
-
// durable 终帧的 result.taskId === sessionId(引擎 durable quirk)会让 captureSessionId 跳过
|
|
554
|
-
// rewind 绑定 —— 用 sync leg 捕的真 handle 修正回去。
|
|
555
|
-
const r = ev.result;
|
|
556
|
-
if (realTaskId && r && r.taskId === r.sessionId && r.taskId !== realTaskId) {
|
|
557
|
-
yield { ...ev, result: { ...ev.result, taskId: realTaskId } };
|
|
558
|
-
}
|
|
559
|
-
else {
|
|
560
|
-
yield ev;
|
|
561
|
-
}
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
if (ev.type === 'failed') {
|
|
565
|
-
yield* flushHeld();
|
|
566
|
-
yield ev;
|
|
65
|
+
if (action.kind === 'end') {
|
|
66
|
+
for (const out of action.events)
|
|
67
|
+
yield out;
|
|
567
68
|
return;
|
|
568
69
|
}
|
|
569
|
-
|
|
70
|
+
park = {
|
|
71
|
+
gate: action.gate,
|
|
72
|
+
...(action.pendingDone !== undefined ? { pendingDone: action.pendingDone } : {}),
|
|
73
|
+
};
|
|
74
|
+
break;
|
|
570
75
|
}
|
|
571
76
|
if (!park) {
|
|
572
77
|
// 源流走完没终帧(disconnect/abort)——flush 后结束,与现状一致。
|
|
573
|
-
yield* flushHeld();
|
|
78
|
+
yield* led.flushHeld();
|
|
574
79
|
return;
|
|
575
80
|
}
|
|
576
81
|
hops++;
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
// `.denied` access doesn't hold for every 'decided' member even
|
|
588
|
-
// though this branch only ever assigns the FsApprovalOutcome shape.
|
|
589
|
-
// 'in' narrowing sidesteps it.
|
|
590
|
-
const outcomeDenied = 'denied' in outcome ? outcome.denied : undefined;
|
|
591
|
-
if (outcome.kind === 'decided' && outcomeDenied === true && outcome.gatedCallId) {
|
|
592
|
-
deniedCalls.add(outcome.gatedCallId); // 重放的报错帧渲 `User rejected …`(见 tool_end 手柄)
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
else {
|
|
596
|
-
outcome = await surfaceGateAndDecide(deps, realTaskId, askArgsByCall, opts?.signal);
|
|
597
|
-
}
|
|
598
|
-
// #110 缺陷② b/c —— **重放的、早已决断过的 park 不是失败**。
|
|
599
|
-
// durable re-attach 必然会把 park 帧再送一遍(`lastEventId` 再准也只能精确到帧,park 就在
|
|
600
|
-
// 重放窗口里)。这时 approval 行早已 resolved,`surfaceFsApprovalAndDecide` 取不到 pending,
|
|
601
|
-
// 返回 `failed: no pending checkpoint for this run (resolved/expired?)` —— 那句话的语义恰恰是
|
|
602
|
-
// **「这个 gate 已经被解决了」**,正确处置是**继续读流**(引擎那边 run 正常往下跑,实测
|
|
603
|
-
// `run.json status="completed"`),而不是宣布 turn 失败。旧行为的代价不是一句错文案:
|
|
604
|
-
// 终帧被投影成 `error_during_execution` ⇒ 壳走失败路径 ⇒ CC 转录**一行都不写** ⇒
|
|
605
|
-
// 同 session `--resume` 报 `No conversation found` ⇒ **整个会话废掉**。
|
|
606
|
-
// 无限循环由 `hops > MAX_GATE_HOPS` 兜底(本 continue 照常吃 hop 预算,不绕过它)。
|
|
607
|
-
if (outcome.kind === 'failed' && isAlreadyResolvedGateReason(outcome.reason)) {
|
|
608
|
-
hostLog('debug', `liveHitlAskWire: gate already resolved (${outcome.reason}) — replayed park, re-attaching runs.events(${realTaskId})${lastSeq ? ` from seq ${lastSeq}` : ''} instead of failing the turn` +
|
|
609
|
-
(decidedGates.size > 0 ? ` [decided so far: ${decidedGates.size}]` : ''));
|
|
610
|
-
stream = deps.runsEvents(realTaskId, {
|
|
611
|
-
...(lastSeq !== undefined ? { lastEventId: lastSeq } : {}),
|
|
612
|
-
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
613
|
-
});
|
|
614
|
-
continue;
|
|
615
|
-
}
|
|
616
|
-
if (outcome.kind !== 'decided') {
|
|
617
|
-
hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
|
|
618
|
-
yield* flushHeld(); // 回退:毒化帧照旧渲染(= 修复前的诚实红)
|
|
619
|
-
if (park.pendingDone) {
|
|
620
|
-
yield park.pendingDone;
|
|
621
|
-
}
|
|
622
|
-
else if (outcome.kind === 'failed') {
|
|
623
|
-
// 记案(#87 评审② minor,不修):durable re-attach 的第二问(park.pendingDone 无)走 `aborted`
|
|
624
|
-
// 时(仅用户主动 Esc/Ctrl+C 中断触发)不吐终帧——下游正在拆流,合成终帧也没人渲;flushHeld
|
|
625
|
-
// 已把毒化帧诚实吐出。真正的 failed 才合成下面的可见终帧。
|
|
626
|
-
// durable-leg park 无 done 可回吐 —— 合成 failed 让用户看得见为什么停了
|
|
627
|
-
yield {
|
|
628
|
-
type: 'failed',
|
|
629
|
-
errorCode: 'hitl_unanswered',
|
|
630
|
-
errorMessage: `${park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion'} gate could not be answered: ${outcome.reason}`,
|
|
631
|
-
};
|
|
632
|
-
}
|
|
82
|
+
const resolution = await resolvePark(park, {
|
|
83
|
+
deps,
|
|
84
|
+
led,
|
|
85
|
+
taskId: taskId.current,
|
|
86
|
+
hops,
|
|
87
|
+
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
88
|
+
});
|
|
89
|
+
if (resolution.kind === 'failsoft') {
|
|
90
|
+
for (const out of resolution.events)
|
|
91
|
+
yield out;
|
|
633
92
|
return;
|
|
634
93
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
heldAskEnds.delete(outcome.gatedCallId);
|
|
639
|
-
decidedGates.add(outcome.gatedCallId); // #110 缺陷② b:决断过的 gate 身份留痕(诊断面)
|
|
640
|
-
if ('answered' in outcome && outcome.answered)
|
|
641
|
-
resolvedAnswers.set(outcome.gatedCallId, outcome.answered);
|
|
642
|
-
}
|
|
643
|
-
hostLog('debug', `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) — attaching runs.events(${realTaskId})${lastSeq ? ` from seq ${lastSeq}` : ''}`);
|
|
644
|
-
stream = deps.runsEvents(realTaskId, {
|
|
645
|
-
...(lastSeq !== undefined ? { lastEventId: lastSeq } : {}),
|
|
94
|
+
const seq = led.lastSeq();
|
|
95
|
+
stream = deps.runsEvents(taskId.current, {
|
|
96
|
+
...(seq !== undefined ? { lastEventId: seq } : {}),
|
|
646
97
|
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
647
98
|
});
|
|
648
99
|
}
|