@sema-agent/client-core 0.8.0 → 0.9.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 +18 -6
- package/dist/adapt.js +12 -48
- package/dist/compensations.d.ts +2 -1
- package/dist/compensations.js +71 -1
- package/dist/hitl/approvalsFeed.d.ts +102 -0
- package/dist/hitl/approvalsFeed.js +185 -0
- package/dist/hitl/askGateWire.d.ts +155 -0
- package/dist/hitl/askGateWire.js +530 -0
- package/dist/hitl/hitlBridge.d.ts +245 -0
- package/dist/hitl/hitlBridge.js +267 -0
- package/dist/hitl/planReviewWire.d.ts +17 -0
- package/dist/hitl/planReviewWire.js +142 -0
- package/dist/hitl/toolApprovalWire.d.ts +126 -0
- package/dist/hitl/toolApprovalWire.js +263 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +36 -0
- package/dist/notifications.d.ts +67 -0
- package/dist/notifications.js +94 -0
- package/dist/subagent/engineTaskHandleWire.d.ts +0 -1
- package/dist/subagent/engineTaskHandleWire.js +4 -3
- package/package.json +2 -2
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { HitlBridge } from './hitlBridge.js';
|
|
2
|
+
import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverlay, } from '../liveQuestionStore.js';
|
|
3
|
+
import { hostLog } from '../host.js';
|
|
4
|
+
import { isFsApprovalGate, toolNameIsFsWrite, surfaceFsApprovalAndDecide, isToolApprovalFrame, isFromSubagent, surfaceToolApprovalFrameAndRespond, } from './toolApprovalWire.js';
|
|
5
|
+
import { classifierDenyFromToolEnd } from '../classifierVerdictWire.js';
|
|
6
|
+
let hostSurface = null;
|
|
7
|
+
let hostSurfaceMisses = 0;
|
|
8
|
+
/** 装 HITL 宿主面(传 null 卸)。返回还原函数。 */
|
|
9
|
+
export function installHitlHostSurface(surface) {
|
|
10
|
+
const prev = hostSurface;
|
|
11
|
+
hostSurface = surface;
|
|
12
|
+
return () => {
|
|
13
|
+
hostSurface = prev;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** 🔴 宿主自检:恒应为 0。非 0 = 有 HITL 副作用发生时口不在,那一行 warn / 那条记账丢了。 */
|
|
17
|
+
export function hitlHostSurfaceMisses() {
|
|
18
|
+
return hostSurfaceMisses;
|
|
19
|
+
}
|
|
20
|
+
/** 测试钩:卸口 + 清计数。 */
|
|
21
|
+
export function _resetHitlHostSurfaceForTest() {
|
|
22
|
+
hostSurface = null;
|
|
23
|
+
hostSurfaceMisses = 0;
|
|
24
|
+
}
|
|
25
|
+
function surface() {
|
|
26
|
+
if (hostSurface === null)
|
|
27
|
+
hostSurfaceMisses++;
|
|
28
|
+
return hostSurface;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* CC `utils/messages.ts` 的 `REJECT_MESSAGE` **逐字**(搬迁差分 2)。deny 后重放 tool_end 的
|
|
32
|
+
* render 面 stamp 用 —— vendored `renderToolUseRejectedMessage` 渲 `User rejected <op> to <path>`。
|
|
33
|
+
* 🔴 与壳树那份的 byte-identity 由 pure 门 B7 段锁住(壳树缺席 ⇒ DEGRADED,不假绿)。
|
|
34
|
+
*/
|
|
35
|
+
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.";
|
|
36
|
+
function rejectMessageForRender() {
|
|
37
|
+
return HITL_REJECT_MESSAGE;
|
|
38
|
+
}
|
|
39
|
+
/** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
|
|
40
|
+
const MAX_GATE_HOPS = 24;
|
|
41
|
+
function isAskTool(name) {
|
|
42
|
+
return typeof name === 'string' && name.replace(/[\s_-]+/g, '').toLowerCase() === 'askuserquestion';
|
|
43
|
+
}
|
|
44
|
+
/** done 帧的 AskUserQuestion park 形状(结构性读;别的终态一律 false)。 */
|
|
45
|
+
function isAskGatePark(result) {
|
|
46
|
+
const r = result;
|
|
47
|
+
return (!!r &&
|
|
48
|
+
r.status === 'suspended' &&
|
|
49
|
+
isAskTool(r.checkpointGate?.toolName) &&
|
|
50
|
+
typeof r.taskId === 'string' &&
|
|
51
|
+
r.taskId.length > 0);
|
|
52
|
+
}
|
|
53
|
+
/** done 帧的 fs 写权限 park 形状([816] 放宽腿):status suspended + checkpointGate 指 fs 写工具
|
|
54
|
+
* (Write/Edit/NotebookEdit)或一等 kind==='tool_approval'。Ask gate 恒先判(问答 overlay 原路)。 */
|
|
55
|
+
function isFsApprovalPark(result) {
|
|
56
|
+
const r = result;
|
|
57
|
+
return (!!r &&
|
|
58
|
+
r.status === 'suspended' &&
|
|
59
|
+
typeof r.taskId === 'string' &&
|
|
60
|
+
r.taskId.length > 0 &&
|
|
61
|
+
!isAskTool(r.checkpointGate?.toolName) &&
|
|
62
|
+
isFsApprovalGate(r.checkpointGate));
|
|
63
|
+
}
|
|
64
|
+
/** 把 wire 答案({answers:[{header,selected,note?}]})折回 CC 卡片的 Record<question,string> 形状
|
|
65
|
+
* (multiSelect 与对话框同款 ", " lossy join;note → annotations.notes)。 */
|
|
66
|
+
export function toAnsweredOutput(questions, answer) {
|
|
67
|
+
const answers = {};
|
|
68
|
+
const annotations = {};
|
|
69
|
+
for (const entry of answer.answers ?? []) {
|
|
70
|
+
const q = questions.find(qq => qq.header === entry.header);
|
|
71
|
+
const key = typeof q?.question === 'string' ? q.question : entry.header;
|
|
72
|
+
answers[key] = (entry.selected ?? []).join(', ');
|
|
73
|
+
if (entry.note)
|
|
74
|
+
annotations[key] = { notes: entry.note };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
type: 'ask-user-question',
|
|
78
|
+
questions,
|
|
79
|
+
answers,
|
|
80
|
+
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// ── 件3(中断事故修复批 G,2026-07-15)—— 中断 deny 的有界观察 ─────────────────────────────────
|
|
84
|
+
/** cancel-by-deny 的后台 settle 预算。decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测
|
|
85
|
+
* 4-5s+),但 DENY-abort 语义上引擎收到即终结 run;2s 内连收都没收到 ⇒ 按丢失警示(晚到成功
|
|
86
|
+
* 只是多一行良性 warn,比锁死无线索诚实)。 */
|
|
87
|
+
export const CANCEL_DENY_BUDGET_MS = 2000;
|
|
88
|
+
/** warn 行文案(测试锁字面)。 */
|
|
89
|
+
export const CANCEL_DENY_WARN_TEXT = 'could not cancel the pending question — the session may stay locked; the run may need engine-side recovery';
|
|
90
|
+
const CANCEL_DENY_WARN_KEY = 'hitl-cancel-deny-warn';
|
|
91
|
+
const CANCEL_DENY_WARN_TIMEOUT_MS = 10_000;
|
|
92
|
+
/** 失败 warn 上屏:借 footer notifications 通道。壳原文经 `appStateRef` 直写 store
|
|
93
|
+
* (immediate 语义:立即顶到 current + 到点自清「仅当仍是本 warn 时」);本包把那两次
|
|
94
|
+
* setState 拆成宿主口的两个动词,**时序纪律(2s 预算 / 10s 自清)留在库内**——
|
|
95
|
+
* [paired-mechanisms-must-share-premise]:只给「显示」不给「清」,端各写各的清法必然漂。
|
|
96
|
+
* 无口(print/headless 没屏可上)⇒ 静默,与壳原文 `if (!store) return` 同语义。 */
|
|
97
|
+
function surfaceCancelDenyWarn() {
|
|
98
|
+
try {
|
|
99
|
+
const s = surface();
|
|
100
|
+
if (!s)
|
|
101
|
+
return;
|
|
102
|
+
s.showNotice({
|
|
103
|
+
key: CANCEL_DENY_WARN_KEY,
|
|
104
|
+
text: CANCEL_DENY_WARN_TEXT,
|
|
105
|
+
color: 'warning',
|
|
106
|
+
priority: 'immediate',
|
|
107
|
+
timeoutMs: CANCEL_DENY_WARN_TIMEOUT_MS,
|
|
108
|
+
});
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
try {
|
|
111
|
+
s.clearNoticeIfCurrent(CANCEL_DENY_WARN_KEY);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* fail-soft */
|
|
115
|
+
}
|
|
116
|
+
}, CANCEL_DENY_WARN_TIMEOUT_MS);
|
|
117
|
+
timer.unref?.();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* fail-soft:上屏失败绝不再伤害 abort 流 */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面)。
|
|
125
|
+
* 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
|
|
126
|
+
* - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
|
|
127
|
+
* - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
|
|
128
|
+
* 撞 409;配合件2b 的专属文案,用户知道现场 + 出路);
|
|
129
|
+
* - HitlSafetyError code==='no_pending' ⇒ 良性静默(pending 已被别处消解/过期 —— run 没锁;
|
|
130
|
+
* hitlBridge decideTool 的同款语义,那里的静默维持不动)。
|
|
131
|
+
*/
|
|
132
|
+
export function observeCancelByDeny(settle, taskId) {
|
|
133
|
+
let timer;
|
|
134
|
+
const budget = new Promise((_, reject) => {
|
|
135
|
+
timer = setTimeout(() => reject(new Error(`cancel-by-deny did not settle within ${CANCEL_DENY_BUDGET_MS}ms`)), CANCEL_DENY_BUDGET_MS);
|
|
136
|
+
timer.unref?.();
|
|
137
|
+
});
|
|
138
|
+
void Promise.race([settle, budget])
|
|
139
|
+
.then(() => {
|
|
140
|
+
hostLog('debug', `[hitl-ask] cancel-by-deny settled for run ${taskId} (session unlocked)`);
|
|
141
|
+
})
|
|
142
|
+
.catch((e) => {
|
|
143
|
+
if (e?.code === 'no_pending') {
|
|
144
|
+
hostLog('debug', `[hitl-ask] cancel-by-deny no_pending for run ${taskId} (benign — already resolved)`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
hostLog('error', `[hitl-ask] cancel-by-deny FAILED for run ${taskId}: ${String(e)}`);
|
|
148
|
+
surfaceCancelDenyWarn();
|
|
149
|
+
})
|
|
150
|
+
.finally(() => {
|
|
151
|
+
if (timer !== undefined)
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
});
|
|
154
|
+
// 超时后原 promise 迟到 reject 不得变成 unhandledRejection。
|
|
155
|
+
void settle.catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 弹既有 AskUserQuestion overlay → 等作答 → 经 HitlBridge decide(答案/拒答)。
|
|
159
|
+
* decide 成功即引擎已把 run 驱到下一状态(sync);调用方随后 attach runs.events 续流。
|
|
160
|
+
*/
|
|
161
|
+
async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
|
|
162
|
+
if (!hasQuestionOverlay()) {
|
|
163
|
+
return { kind: 'failed', reason: 'no question overlay mounted (print/non-REPL mode)' };
|
|
164
|
+
}
|
|
165
|
+
// pending 行 = 问题 payload 的权威源 + D-1 绑定的唯一 surface(PendingCheckpoint.input)。
|
|
166
|
+
let pending;
|
|
167
|
+
try {
|
|
168
|
+
const rows = await deps.client.approvals.list(signal ? { signal } : undefined);
|
|
169
|
+
pending =
|
|
170
|
+
rows.find(r => r.taskId === taskId && isAskTool(r.toolName ?? undefined)) ??
|
|
171
|
+
rows.find(r => r.taskId === taskId);
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
return { kind: 'failed', reason: `approvals.list failed: ${String(e)}` };
|
|
175
|
+
}
|
|
176
|
+
if (!pending)
|
|
177
|
+
return { kind: 'failed', reason: 'no pending checkpoint for this run (resolved/expired?)' };
|
|
178
|
+
const gatedCallId = pending.toolCallId ?? pending.boundCallId ?? undefined;
|
|
179
|
+
// 问题 payload:优先流上 tool_start.args(零额外语义),缺则 pending.input(service 已 redact + 限长)。
|
|
180
|
+
const fromArgs = gatedCallId
|
|
181
|
+
? askArgsByCall.get(gatedCallId)?.questions
|
|
182
|
+
: undefined;
|
|
183
|
+
const fromPending = pending.input?.questions;
|
|
184
|
+
const questions = Array.isArray(fromArgs) && fromArgs.length > 0 ? fromArgs : fromPending;
|
|
185
|
+
if (!Array.isArray(questions) || questions.length === 0) {
|
|
186
|
+
// [1543]②:input 超引擎入参帽(MAX_TOOL_INPUT_CHARS,preview 富文本题易中)时 server 降级为
|
|
187
|
+
// {truncated:true} ——无题可渲不是引擎代差,是 payload 被截。分形出诚实理由(fail-soft 链
|
|
188
|
+
// 会把 reason 记入 SEMA_DEBUG,别再误导成 pre-CHANNEL[56] 老引擎)。
|
|
189
|
+
const truncated = pending.input?.truncated === true;
|
|
190
|
+
return {
|
|
191
|
+
kind: 'failed',
|
|
192
|
+
gatedCallId,
|
|
193
|
+
reason: truncated
|
|
194
|
+
? 'question payload exceeded the engine input cap (input={truncated:true}) — dialog cannot be re-rendered; run stays suspended for out-of-band decide'
|
|
195
|
+
: 'gate has no question payload (pre-CHANNEL[56] engine?)',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// overlay 往返:合成 frame(id 独占命名空间 hitl-ask:)→ local responder 一次性收答。
|
|
199
|
+
const questionId = `hitl-ask:${gatedCallId ?? taskId}`;
|
|
200
|
+
const answer = await new Promise(resolve => {
|
|
201
|
+
const unregister = registerLocalQuestionResponder(questionId, async (_id, a) => {
|
|
202
|
+
cleanup();
|
|
203
|
+
resolve(a);
|
|
204
|
+
return { ok: true };
|
|
205
|
+
});
|
|
206
|
+
const onAbort = () => {
|
|
207
|
+
cleanup();
|
|
208
|
+
// 撤下还开着的对话框(question_complete = overlay 的既有 dismiss 语义)
|
|
209
|
+
publishQuestionFrame({ type: 'question_complete', questionId });
|
|
210
|
+
resolve(null);
|
|
211
|
+
};
|
|
212
|
+
const cleanup = () => {
|
|
213
|
+
unregister();
|
|
214
|
+
signal?.removeEventListener('abort', onAbort);
|
|
215
|
+
};
|
|
216
|
+
if (signal?.aborted) {
|
|
217
|
+
onAbort();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
221
|
+
publishQuestionFrame({ type: 'question', questionId, questions });
|
|
222
|
+
});
|
|
223
|
+
const bridge = new HitlBridge(deps.client, taskId);
|
|
224
|
+
if (answer === null) {
|
|
225
|
+
// turn 被中断(Esc/Ctrl+C):cancel-by-deny(contract/04 §2.4 —— suspended run 不 runs.cancel)。
|
|
226
|
+
// 件3(中断事故修复批 G,2026-07-15,症状1 壳侧配套):此前 .catch(()=>{}) 全吞 = deny 丢失时
|
|
227
|
+
// run 永卡 suspended,session 锁死,用户下一条消息撞 409「active run」还全无线索。改为有界观察
|
|
228
|
+
// (observeCancelByDeny,2s 预算):abort 仍立即返回用户控制(不 await,交互时序不变),后台
|
|
229
|
+
// settle 失败/超时上屏一行 warn + SEMA_DEBUG 记失败原因。引擎侧解锁腿([866] server:cancel
|
|
230
|
+
// suspended 改语义 + reapSuspended TTL)到货前,这是壳能做的最诚实半场。
|
|
231
|
+
observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId), taskId);
|
|
232
|
+
return { kind: 'aborted', gatedCallId };
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const entries = (answer.answers ?? []);
|
|
236
|
+
const hasContent = entries.some(a => (a.selected?.length ?? 0) > 0 || (a.note?.length ?? 0) > 0);
|
|
237
|
+
if (hasContent) {
|
|
238
|
+
await bridge.answerQuestion(entries, gatedCallId, signal ? { signal } : undefined);
|
|
239
|
+
return { kind: 'decided', gatedCallId, answered: toAnsweredOutput(questions, answer) };
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
// 用户拒答(overlay reject/dismiss 送空 answers)= 诚实 deny;模型收 denied 结果自续。
|
|
243
|
+
await bridge.decideTool({ decision: 'deny', reason: 'User declined to answer' }, gatedCallId, signal ? { signal } : undefined);
|
|
244
|
+
}
|
|
245
|
+
return { kind: 'decided', gatedCallId };
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
return { kind: 'failed', gatedCallId, reason: `decide failed: ${String(e)}` };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
|
|
253
|
+
* 其它事件原样透传;非 AskUserQuestion 的 gate 保持现状。fail-soft:任何桥内失败回退为
|
|
254
|
+
* 「flush 毒化 tool_end + 原样终帧」(= 修复前行为)。
|
|
255
|
+
*
|
|
256
|
+
* @param source 上游 AgentEvent 流(tasks.stream 或 runs.events,已过 demuxQuestionFrames)。
|
|
257
|
+
* @param opts.taskId runs.events 消费时已知的 run handle(sync leg 从 suspended done 捕获)。
|
|
258
|
+
*/
|
|
259
|
+
export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
260
|
+
const startedCalls = new Set();
|
|
261
|
+
const endedCalls = new Set();
|
|
262
|
+
/** park 毒化的 gated tool_end(isError:true "Operation aborted")——HOLD 到 gate 定性
|
|
263
|
+
* (AskUserQuestion + [816] 放宽后的 fs 写工具)。 */
|
|
264
|
+
const heldAskEnds = new Map();
|
|
265
|
+
/** gated 工具的 tool_start.args(toolCallId → args)= 对话框/三选卡 payload 的一手源。 */
|
|
266
|
+
const askArgsByCall = new Map();
|
|
267
|
+
/** 三选卡 deny 过的 call:重放 tool_end 的 render 面 stamp REJECT_MESSAGE(vendored
|
|
268
|
+
* renderToolUseRejectedMessage 渲 `User rejected <op> to <path>`,CC 逐字)。 */
|
|
269
|
+
const deniedCalls = new Set();
|
|
270
|
+
/** started-未-ended 的 fs 写 call(顺序保留)——同步 tool_approval 帧的 callId 关联面
|
|
271
|
+
* (帧契约不带 toolCallId;引擎阻塞在帧上,最后一张未收口 fs 写 call 即被 gate 的 call)。 */
|
|
272
|
+
const pendingFsCalls = [];
|
|
273
|
+
/** 同步帧腿 deny 但 callId 关联不上(引擎在 tool_start 前出帧的形状):stamp 下一张 fs 写报错帧。 */
|
|
274
|
+
let denyStampNextFsEnd = false;
|
|
275
|
+
/** 已作答的 gate(toolCallId → CC 卡片形状的真实答案)——续流重放的解答 tool_end 无 output,
|
|
276
|
+
* 不补就会渲成结果不可用;这里 stamp `structured` 让卡片渲真实选择。 */
|
|
277
|
+
const resolvedAnswers = new Map();
|
|
278
|
+
let lastSeq;
|
|
279
|
+
let realTaskId = opts?.taskId;
|
|
280
|
+
let stream = source;
|
|
281
|
+
let hops = 0;
|
|
282
|
+
function* flushHeld() {
|
|
283
|
+
for (const [callId, held] of heldAskEnds) {
|
|
284
|
+
endedCalls.add(callId);
|
|
285
|
+
yield held;
|
|
286
|
+
}
|
|
287
|
+
heldAskEnds.clear();
|
|
288
|
+
}
|
|
289
|
+
while (true) {
|
|
290
|
+
/** park 请求:遇 AskUserQuestion / fs 写权限 gate 时置位后 break 内环。 */
|
|
291
|
+
let park = null;
|
|
292
|
+
for await (const ev of stream) {
|
|
293
|
+
const seq = ev.id;
|
|
294
|
+
if (typeof seq === 'string' && seq.length > 0)
|
|
295
|
+
lastSeq = seq;
|
|
296
|
+
// ── server 1.191 同步帧腿([830]①):`tool_approval` 帧 → 三选卡 → respond ────────────────
|
|
297
|
+
// 引擎此刻同步阻塞在帧上(fail-closed:无 respond/TTL 5min/abort ⇒ deny),await 卡决断安全。
|
|
298
|
+
// 帧是 HITL 词汇非 AgentEvent arm——两种帧都不外泄到渲染管道。
|
|
299
|
+
if (isToolApprovalFrame(ev)) {
|
|
300
|
+
// #51: `ToolApprovalFrame` frames are documented as NOT part of the
|
|
301
|
+
// `AgentEvent` union (a separate SSE wire vocabulary riding the same
|
|
302
|
+
// stream) — intersecting the type-predicate's asserted type with the
|
|
303
|
+
// unrelated `AgentEvent` union collapses every arm to `never`. Local
|
|
304
|
+
// rebind restores the real (already-checked) shape.
|
|
305
|
+
const frame = ev;
|
|
306
|
+
if (frame.type === 'tool_approval_complete')
|
|
307
|
+
continue; // 收口帧:卡已由决断路径撤下,吞掉
|
|
308
|
+
if (!deps.respondToolApproval) {
|
|
309
|
+
hostLog('debug', `liveHitlAskWire: tool_approval frame ${frame.approvalId} but no respond wire — engine self-settles (TTL deny)`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
// 补验批 askq 案定谳(2026-07-23,黑板 [1534],引擎 dist 逐坐标读定):core 的 durable
|
|
313
|
+
// question policy 对 AskUserQuestion 出 action:"ask",本意让 durable gate park(suspended
|
|
314
|
+
// → 本函数下方 bridge 弹卡收答);但 core suspendAsk 的退位条件(safety 缺席 + onAsk 在场)
|
|
315
|
+
// 把问题门也抢进了同步审批帧腿(壳为 fs 写权限三选卡开的 TOOL_APPROVAL_ENABLED)——同步帧
|
|
316
|
+
// 二值 respond 装不下「答案」,链路死锁:deny=模型误信问题已展示原地等;allow=执行工具
|
|
317
|
+
// 本体炸 durable 装配的 onQuestion 占位(QUESTION_AWAITS_RESUME)。此处取两害之轻恒 allow:
|
|
318
|
+
// 模型收到明确错误后退化成正文提问,用户尚可下一条消息作答(deny 形连这条退化路都没有)。
|
|
319
|
+
// 承重域收敛史([1543]§一 cli 注意):core 1.375 修 question gate 恒 park(健康路),
|
|
320
|
+
// 1.376 又断了降级路(checkpoint pre-commit/put 失败 fallback 改 typed refusal)与继承链
|
|
321
|
+
// 两臂——1.376+ 下 ask 工具在任何路径都不再出 tool_approval 帧,本分支唯一剩余承重
|
|
322
|
+
// = 旧引擎(<1.375)兼容,askq pty fence @1.375+ 已四门全绿实证([1542])。
|
|
323
|
+
if (isAskTool(ev.toolName)) {
|
|
324
|
+
try {
|
|
325
|
+
await deps.respondToolApproval(frame.approvalId, 'allow', {
|
|
326
|
+
...(opts?.signal && !opts.signal.aborted ? { signal: opts.signal } : {}),
|
|
327
|
+
});
|
|
328
|
+
hostLog('debug', `liveHitlAskWire: ask-tool approval frame ${frame.approvalId} → allow(问题门放行,候 park 弹卡)`);
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
hostLog('debug', `liveHitlAskWire: ask-tool approval allow failed for ${frame.approvalId}: ${String(e)} — engine self-settles (TTL)`);
|
|
332
|
+
}
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// [1535]/server 1.258:子代的审批帧(broker 腿浮到宿主流,判别=fromSubagent 显式键
|
|
336
|
+
// core 1.378 优先/sourceTaskId 在场性兜底)——子代的 tool_start 不在宿主行流上,绝不把
|
|
337
|
+
// 宿主自己的 pending fs call 关联给它(args 会错配成宿主的 diff、deny 会误标宿主 call 的
|
|
338
|
+
// REJECT 收口)。args 从帧上取,deny 收口归子代 lane 自理。
|
|
339
|
+
const fromSubagent = isFromSubagent(ev);
|
|
340
|
+
hostLog('debug', `liveHitlAskWire: tool_approval frame ${frame.approvalId} tool=${String(ev.toolName)}${fromSubagent ? ` from-subagent=${String(ev.sourceTaskId ?? ev.sourceAgentName ?? 'explicit')}` : ''}`);
|
|
341
|
+
const gatedCallId = !fromSubagent && pendingFsCalls.length > 0 ? pendingFsCalls[pendingFsCalls.length - 1] : undefined;
|
|
342
|
+
const decision = await surfaceToolApprovalFrameAndRespond(ev, deps.respondToolApproval, gatedCallId !== undefined ? askArgsByCall.get(gatedCallId) : undefined, opts?.signal);
|
|
343
|
+
if (decision === 'deny' && !fromSubagent) {
|
|
344
|
+
// 该 call 的报错收口帧 stamp REJECT 文案(vendored `User rejected <op> to <path>` 卡)。
|
|
345
|
+
if (gatedCallId !== undefined)
|
|
346
|
+
deniedCalls.add(gatedCallId);
|
|
347
|
+
else
|
|
348
|
+
denyStampNextFsEnd = true;
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (ev.type === 'tool_start' && typeof ev.toolCallId === 'string') {
|
|
353
|
+
if (startedCalls.has(ev.toolCallId))
|
|
354
|
+
continue; // durable 重放的已渲 call
|
|
355
|
+
startedCalls.add(ev.toolCallId);
|
|
356
|
+
if (isAskTool(ev.toolName) || (typeof ev.toolName === 'string' && toolNameIsFsWrite(ev.toolName))) {
|
|
357
|
+
askArgsByCall.set(ev.toolCallId, ev.args);
|
|
358
|
+
if (typeof ev.toolName === 'string' && toolNameIsFsWrite(ev.toolName)) {
|
|
359
|
+
pendingFsCalls.push(ev.toolCallId);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
yield ev;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (ev.type === 'tool_end' && typeof ev.toolCallId === 'string') {
|
|
366
|
+
if (endedCalls.has(ev.toolCallId))
|
|
367
|
+
continue; // durable 重放/park 复写的已收口 call
|
|
368
|
+
{
|
|
369
|
+
const pi = pendingFsCalls.indexOf(ev.toolCallId);
|
|
370
|
+
if (pi !== -1)
|
|
371
|
+
pendingFsCalls.splice(pi, 1);
|
|
372
|
+
}
|
|
373
|
+
// ── 分类器批3([907]):auto-mode 分类器 deny 裁决帧 —— 结构性识别(core 机器签名)优先于
|
|
374
|
+
// fs HOLD/REJECT 分支(签名比"fs 写报错"更特定;分类器 deny 从不产 tool_approval 帧,
|
|
375
|
+
// deniedCalls/denyStampNextFsEnd 与之互斥)。副作用 = CC 207 通知行 + Recent Denials 记账;
|
|
376
|
+
// render 面 stamp 去 system-reminder 包裹渲裁决原文(render-only,模型面原文不动)。
|
|
377
|
+
if (ev.isError === true && !deniedCalls.has(ev.toolCallId)) {
|
|
378
|
+
const verdict = classifierDenyFromToolEnd(ev);
|
|
379
|
+
if (verdict) {
|
|
380
|
+
endedCalls.add(ev.toolCallId);
|
|
381
|
+
heldAskEnds.delete(ev.toolCallId);
|
|
382
|
+
surface()?.surfaceClassifierDeny(typeof ev.toolName === 'string' ? ev.toolName : 'tool', verdict);
|
|
383
|
+
hostLog('debug', `liveHitlAskWire: classifier deny verdict on ${ev.toolCallId} (${String(ev.toolName)}) — labeled render + notice`);
|
|
384
|
+
yield { ...ev, output: verdict.message, structured: undefined };
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (ev.isError === true &&
|
|
389
|
+
typeof ev.toolName === 'string' &&
|
|
390
|
+
toolNameIsFsWrite(ev.toolName) &&
|
|
391
|
+
denyStampNextFsEnd &&
|
|
392
|
+
!deniedCalls.has(ev.toolCallId)) {
|
|
393
|
+
// 同步帧腿 deny 且 callId 没关联上:下一张 fs 写报错帧即该 gate 的收口帧,stamp REJECT 文案。
|
|
394
|
+
denyStampNextFsEnd = false;
|
|
395
|
+
endedCalls.add(ev.toolCallId);
|
|
396
|
+
heldAskEnds.delete(ev.toolCallId);
|
|
397
|
+
yield { ...ev, output: rejectMessageForRender(), structured: undefined };
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (ev.isError === true &&
|
|
401
|
+
(isAskTool(ev.toolName) || (typeof ev.toolName === 'string' && toolNameIsFsWrite(ev.toolName)))) {
|
|
402
|
+
if (deniedCalls.has(ev.toolCallId)) {
|
|
403
|
+
// 三选卡 No:重放的报错帧 stamp REJECT 文案 → vendored 卡渲 `User rejected <op> to <path>`。
|
|
404
|
+
deniedCalls.delete(ev.toolCallId);
|
|
405
|
+
endedCalls.add(ev.toolCallId);
|
|
406
|
+
heldAskEnds.delete(ev.toolCallId);
|
|
407
|
+
yield { ...ev, output: rejectMessageForRender(), structured: undefined };
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
heldAskEnds.set(ev.toolCallId, ev); // park 毒化帧,先 HOLD(gate 定性前不渲 Error 卡)
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
endedCalls.add(ev.toolCallId);
|
|
414
|
+
heldAskEnds.delete(ev.toolCallId); // 正常收口(重放的 isError:false 解答帧)赢
|
|
415
|
+
const answered = resolvedAnswers.get(ev.toolCallId);
|
|
416
|
+
if (answered &&
|
|
417
|
+
ev.isError !== true &&
|
|
418
|
+
ev.structured === undefined) {
|
|
419
|
+
resolvedAnswers.delete(ev.toolCallId);
|
|
420
|
+
yield { ...ev, structured: answered }; // 真实答案卡(见 resolvedAnswers 注)
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
yield ev;
|
|
424
|
+
}
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (ev.type === 'suspended') {
|
|
428
|
+
// durable leg 的 park arm(续流中模型再次提问/再次撞写权限)。gate 只带 kind/toolName,
|
|
429
|
+
// payload 走 approvals。
|
|
430
|
+
if (ev.gate && isAskTool(ev.gate.toolName) && realTaskId) {
|
|
431
|
+
park = { gate: 'ask' };
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
if (ev.gate && isFsApprovalGate(ev.gate) && realTaskId) {
|
|
435
|
+
park = { gate: 'fs' };
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
yield ev; // 其余 gate:透传(现状,eventToSdkMessage 出 null)
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (ev.type === 'done') {
|
|
442
|
+
if (isAskGatePark(ev.result)) {
|
|
443
|
+
realTaskId = realTaskId ?? ev.result.taskId;
|
|
444
|
+
park = { pendingDone: ev, gate: 'ask' };
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
if (isFsApprovalPark(ev.result)) {
|
|
448
|
+
realTaskId = realTaskId ?? ev.result.taskId;
|
|
449
|
+
park = { pendingDone: ev, gate: 'fs' };
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
yield* flushHeld(); // 真错(非 gate)的 Ask tool_end 此刻诚实渲染
|
|
453
|
+
// durable 终帧的 result.taskId === sessionId(引擎 durable quirk)会让 captureSessionId 跳过
|
|
454
|
+
// rewind 绑定 —— 用 sync leg 捕的真 handle 修正回去。
|
|
455
|
+
const r = ev.result;
|
|
456
|
+
if (realTaskId && r && r.taskId === r.sessionId && r.taskId !== realTaskId) {
|
|
457
|
+
yield { ...ev, result: { ...ev.result, taskId: realTaskId } };
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
yield ev;
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (ev.type === 'failed') {
|
|
465
|
+
yield* flushHeld();
|
|
466
|
+
yield ev;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
yield ev;
|
|
470
|
+
}
|
|
471
|
+
if (!park) {
|
|
472
|
+
// 源流走完没终帧(disconnect/abort)——flush 后结束,与现状一致。
|
|
473
|
+
yield* flushHeld();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
hops++;
|
|
477
|
+
let outcome;
|
|
478
|
+
if (hops > MAX_GATE_HOPS) {
|
|
479
|
+
outcome = { kind: 'failed', reason: `gate hop limit (${MAX_GATE_HOPS}) exceeded` };
|
|
480
|
+
}
|
|
481
|
+
else if (park.gate === 'fs') {
|
|
482
|
+
// [816] 放宽腿:fs 写权限 gate → CC 三选卡(vendored PermissionRequest)→ decide。
|
|
483
|
+
outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, realTaskId, askArgsByCall, opts?.signal);
|
|
484
|
+
// #51: `outcome`'s declared type is the wider `GateOutcome |
|
|
485
|
+
// FsApprovalOutcome`; both unions share a 'decided' kind with
|
|
486
|
+
// different optional fields (`answered` vs `denied`), so a plain
|
|
487
|
+
// `.denied` access doesn't hold for every 'decided' member even
|
|
488
|
+
// though this branch only ever assigns the FsApprovalOutcome shape.
|
|
489
|
+
// 'in' narrowing sidesteps it.
|
|
490
|
+
const outcomeDenied = 'denied' in outcome ? outcome.denied : undefined;
|
|
491
|
+
if (outcome.kind === 'decided' && outcomeDenied === true && outcome.gatedCallId) {
|
|
492
|
+
deniedCalls.add(outcome.gatedCallId); // 重放的报错帧渲 `User rejected …`(见 tool_end 手柄)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
outcome = await surfaceGateAndDecide(deps, realTaskId, askArgsByCall, opts?.signal);
|
|
497
|
+
}
|
|
498
|
+
if (outcome.kind !== 'decided') {
|
|
499
|
+
hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
|
|
500
|
+
yield* flushHeld(); // 回退:毒化帧照旧渲染(= 修复前的诚实红)
|
|
501
|
+
if (park.pendingDone) {
|
|
502
|
+
yield park.pendingDone;
|
|
503
|
+
}
|
|
504
|
+
else if (outcome.kind === 'failed') {
|
|
505
|
+
// 记案(#87 评审② minor,不修):durable re-attach 的第二问(park.pendingDone 无)走 `aborted`
|
|
506
|
+
// 时(仅用户主动 Esc/Ctrl+C 中断触发)不吐终帧——下游正在拆流,合成终帧也没人渲;flushHeld
|
|
507
|
+
// 已把毒化帧诚实吐出。真正的 failed 才合成下面的可见终帧。
|
|
508
|
+
// durable-leg park 无 done 可回吐 —— 合成 failed 让用户看得见为什么停了
|
|
509
|
+
yield {
|
|
510
|
+
type: 'failed',
|
|
511
|
+
errorCode: 'hitl_unanswered',
|
|
512
|
+
errorMessage: `${park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion'} gate could not be answered: ${outcome.reason}`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
|
|
518
|
+
// 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
|
|
519
|
+
if (outcome.gatedCallId) {
|
|
520
|
+
heldAskEnds.delete(outcome.gatedCallId);
|
|
521
|
+
if ('answered' in outcome && outcome.answered)
|
|
522
|
+
resolvedAnswers.set(outcome.gatedCallId, outcome.answered);
|
|
523
|
+
}
|
|
524
|
+
hostLog('debug', `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) — attaching runs.events(${realTaskId})${lastSeq ? ` from seq ${lastSeq}` : ''}`);
|
|
525
|
+
stream = deps.runsEvents(realTaskId, {
|
|
526
|
+
...(lastSeq !== undefined ? { lastEventId: lastSeq } : {}),
|
|
527
|
+
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|