@sema-agent/client-core 0.28.0 → 0.29.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 +3 -2
- package/dist/adapter/activeRunSelfHeal.d.ts +191 -0
- package/dist/adapter/activeRunSelfHeal.js +287 -0
- package/dist/adapter/runStream.d.ts +23 -6
- package/dist/adapter/runStream.js +6 -2
- package/dist/hitl/armedGateRegistry.d.ts +19 -0
- package/dist/hitl/armedGateRegistry.js +92 -0
- package/dist/hitl/frameRouter.d.ts +5 -1
- package/dist/hitl/frameRouter.js +1 -1
- package/dist/hitl/gateIdentity.d.ts +42 -0
- package/dist/hitl/gateIdentity.js +56 -0
- package/dist/hitl/parkOwnership.d.ts +65 -0
- package/dist/hitl/parkOwnership.js +49 -0
- package/dist/hitl/parkResolver.js +3 -1
- package/dist/hitl/planReviewWire.d.ts +51 -0
- package/dist/hitl/planReviewWire.js +172 -17
- package/dist/hitl/toolApprovalWire.d.ts +23 -14
- package/dist/hitl/toolApprovalWire.js +51 -24
- package/dist/index.d.ts +4 -0
- package/dist/index.js +20 -0
- package/dist/liveQuestionStore.d.ts +11 -0
- package/dist/liveQuestionStore.js +13 -0
- package/package.json +3 -3
|
@@ -36,11 +36,14 @@
|
|
|
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, hasLocalQuestionResponder, } from '../liveQuestionStore.js';
|
|
39
|
+
import { publishQuestionFrame, publishQuestionFrameFor, registerLocalQuestionResponder, unregisterLocalQuestionResponder, hasLocalQuestionResponder, hasQuestionOverlayFor, } from '../liveQuestionStore.js';
|
|
40
40
|
import { hostLog } from '../host.js';
|
|
41
41
|
import { makeEngineWireClient } from '../engineWireSdk.js';
|
|
42
42
|
import { engineWireTarget } from '../engineWireTarget.js';
|
|
43
43
|
import { enqueuePlanReviewOutcome } from '../notifications.js';
|
|
44
|
+
import { planReviewQuestionId, REOPEN_ID_TAIL } from './gateIdentity.js';
|
|
45
|
+
import { clearArmedGate, clearArmedGateFor, registerArmedGateFor, wasGateArmedFor } from './armedGateRegistry.js';
|
|
46
|
+
import { DEFAULT_SESSION_KEY } from '../sessionSlot.js';
|
|
44
47
|
/** resume 是 SYNC 驱动到终态的(可能分钟级)——原 raw fetch 无超时;SDK Transport 恒有每请求
|
|
45
48
|
* 超时,取 6h 宽上界(诚实预算:超过它基本是引擎僵死,超时文案与网络失败同走 outcome 通知)。 */
|
|
46
49
|
const PLAN_REVIEW_RESUME_TIMEOUT_MS = 6 * 60 * 60_000;
|
|
@@ -92,26 +95,32 @@ const armedPlanReviewTaskIds = new Set();
|
|
|
92
95
|
export function _resetArmedPlanReviewsForTest() {
|
|
93
96
|
armedPlanReviewTaskIds.clear();
|
|
94
97
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
/* 合成 questionId 的唯一铸口:A-028.3 起收编进 `gateIdentity.planReviewQuestionId`(公面导出,
|
|
99
|
+
* 壳侧重开腿从此 import 同一铸口,不再手抄字面)。本文件只 import,不再自铸。 */
|
|
100
|
+
/** 首次 arm 的题面正句。 */
|
|
101
|
+
const ARM_QUESTION = 'The plan is ready for review. Approve it and start the implementation?';
|
|
102
|
+
/** 重开腿的题面正句(点明「这张卡就是占住会话的那件事」;标签/选项与首呈同源)。 */
|
|
103
|
+
const REOPEN_QUESTION = 'This plan is still waiting for your review — it is what is holding this session. Approve it and start the implementation?';
|
|
104
|
+
/** 题面唯一构造点 —— 首次 arm、重放重呈、重开三处共用,标签/选项描述不许各写一份。 */
|
|
105
|
+
function planReviewQuestions(question) {
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
header: 'Plan review',
|
|
109
|
+
question,
|
|
110
|
+
options: [
|
|
111
|
+
{ label: APPROVE_LABEL, description: 'Resume the task now and execute the plan (runs to completion engine-side)' },
|
|
112
|
+
{ label: REJECT_LABEL, description: 'Discard this plan and stay in plan mode to refine it' },
|
|
113
|
+
],
|
|
114
|
+
multiSelect: false,
|
|
115
|
+
},
|
|
116
|
+
];
|
|
98
117
|
}
|
|
99
|
-
/**
|
|
118
|
+
/** 审批卡的发布口 —— 首次 arm 与「重放 arm 重新弹卡」共用同一份(canonical 身份)。 */
|
|
100
119
|
function publishPlanReviewCard(taskId) {
|
|
101
120
|
publishQuestionFrame({
|
|
102
121
|
type: 'question',
|
|
103
122
|
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
|
-
],
|
|
123
|
+
questions: planReviewQuestions(ARM_QUESTION),
|
|
115
124
|
});
|
|
116
125
|
}
|
|
117
126
|
/**
|
|
@@ -128,6 +137,11 @@ export function armPlanReviewApproval(result) {
|
|
|
128
137
|
if (!engineWireTarget())
|
|
129
138
|
return false;
|
|
130
139
|
const { taskId } = result;
|
|
140
|
+
// 单活纪律(复审二轮,与 reopen 侧对偶):arm/重呈要发的 canonical 卡从此是这个 gate 唯一的
|
|
141
|
+
// 作答入口 —— 名下还在场的重开卡先退役(注销 responder + 撤帧)。不退役的话,durable 重放的
|
|
142
|
+
// arm 会在 fresh 重开卡还开着时再立一张 canonical 卡,双活双投递面从 arm 侧复发。
|
|
143
|
+
// (arm 臂是默认会话装配,退役也按默认键;多会话宿主的 arm 路径归后续工单。)
|
|
144
|
+
retireActiveReopen(DEFAULT_SESSION_KEY, planReviewQuestionId(taskId));
|
|
131
145
|
if (armedPlanReviewTaskIds.has(taskId)) {
|
|
132
146
|
// REF-CC-027:同一张卡还没消解就被重放的 arm 撞上——幂等,**不二次注册** responder。
|
|
133
147
|
//
|
|
@@ -165,9 +179,15 @@ export function armPlanReviewApproval(result) {
|
|
|
165
179
|
const decided = planReviewDecisionFromAnswer(answer);
|
|
166
180
|
if (decided === 'dismissed') {
|
|
167
181
|
// dismiss/无法判读:诚实缺席优先于编造默认值(REF-CC-026)——不静默驱动 decidePlanReview,
|
|
168
|
-
//
|
|
182
|
+
// 卡的重开路径把决定权还给用户。呈现台账的键**不清**:同一张卡再被重开就是真「reopened」。
|
|
169
183
|
return { ok: true };
|
|
170
184
|
}
|
|
185
|
+
// A-024.4(键粒度收口):plan 键 = `plan-review:<taskId>`,粒度是 taskId 不是 gate ——
|
|
186
|
+
// 决断一经递交,这个门的呈现史就消费掉;同 run 的**下一个** plan gate(approve 推进后
|
|
187
|
+
// 引擎可再 park 一个新 plan)必须读回首见,否则文案对一张从未呈现过的新卡说「reopened」。
|
|
188
|
+
// 递交后决定未生效的形(RB-471 族)读回首见只损失「reopened」一词 —— 首见文案零历史断言,
|
|
189
|
+
// 诚实方向安全。
|
|
190
|
+
clearArmedGate(planReviewQuestionId(taskId));
|
|
171
191
|
// fire-and-forget:resume 同步驱动到终态可能分钟级,不能挂住 overlay;结果经 queue 通知回来
|
|
172
192
|
void decidePlanReview(taskId, decided);
|
|
173
193
|
return { ok: true };
|
|
@@ -287,3 +307,138 @@ export async function decidePlanReview(taskId, decision) {
|
|
|
287
307
|
hostLog('debug', `planReviewWire: outcome enqueue threw: ${String(e)}`);
|
|
288
308
|
}
|
|
289
309
|
}
|
|
310
|
+
/** 重开身份的进程内唯一尾缀(时基 + 随机段:两份包实例同时铸也不撞键 —— 撞键会被宿主 overlay
|
|
311
|
+
* 的去重集吃掉帧,重开静默变 no-op;所以刻意不用模块级计数器)。 */
|
|
312
|
+
function reopenIdSuffix() {
|
|
313
|
+
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* 每个 `(sessionKey, canonicalId)` **至多一个在场的重开 responder**(复审 [high]:此前每次重开
|
|
317
|
+
* 都新注册一个 responder 而不退役上一个 —— 重试/并发 409 会留下两张都能投递决断的活卡,
|
|
318
|
+
* 一张 approve 一张 reject 就是两次相互冲突的状态变更请求)。铸新卡前:退役上一张的 responder +
|
|
319
|
+
* 对它的帧 id 发 `question_complete` 撤卡;作答/退役后清账。
|
|
320
|
+
* 🔴 模块级单例(singleton-manifest 登记):两份实例 ⇒ 各退各的,跨份的旧卡退役不掉 ——
|
|
321
|
+
* 退化回修复前的重复活卡形。
|
|
322
|
+
*/
|
|
323
|
+
const activeReopenResponders = new Map();
|
|
324
|
+
/** 退役 `(sessionKey, canonicalId)` 名下还在场的重开 responder(撤帧 + 注销 + 清账)。 */
|
|
325
|
+
function retireActiveReopen(sessionKey, canonicalId) {
|
|
326
|
+
const key = `${sessionKey}\u0000${canonicalId}`;
|
|
327
|
+
const prev = activeReopenResponders.get(key);
|
|
328
|
+
if (!prev)
|
|
329
|
+
return;
|
|
330
|
+
activeReopenResponders.delete(key);
|
|
331
|
+
try {
|
|
332
|
+
prev.unregister();
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// 注销钩自身的意外 throw 不许挡住新卡铸造(退役面 fail-soft,新卡才是用户的把手)
|
|
336
|
+
}
|
|
337
|
+
publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: prev.questionId });
|
|
338
|
+
}
|
|
339
|
+
/** 测试钩:清空重开 responder 台账(跨用例状态)。 */
|
|
340
|
+
export function _resetActiveReopenRespondersForTest() {
|
|
341
|
+
activeReopenResponders.clear();
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* 重开某个 parked plan_review 的审批卡(409 自愈分诊树 `attemptActiveRunSelfHeal` 的
|
|
345
|
+
* `reopenPlanReview` 注入口的包内生产实现)。判决形/成文语义见 {@link ReopenCardVerdict}
|
|
346
|
+
* (`adapter/activeRunSelfHeal.ts`);本函数不产 `presented` 位 —— 包看不到像素,回执机制归
|
|
347
|
+
* 宿主端包装。绝不抛(turn 收尾路径)。
|
|
348
|
+
*
|
|
349
|
+
* 与 arm 臂的合成关系(A-028.4):首呈(done 帧)= `armPlanReviewApproval`;重开(409 撞锁)=
|
|
350
|
+
* 本函数。两臂共用同一份题面构造点/标签单源/决断三态判决与同一条 `decidePlanReview` 投递管道;
|
|
351
|
+
* 「铸新 questionId 还是复用同 id」由宿主去重语义作参数(见 {@link ReopenPlanReviewOpts})。
|
|
352
|
+
* firstSight 判据 = 呈现台账(`armedGateRegistry`,键 = canonical `plan-review:<taskId>`);
|
|
353
|
+
* 先查后记 —— 顺序决定判决正确性。
|
|
354
|
+
*/
|
|
355
|
+
export function reopenPlanReviewCard(taskId, opts) {
|
|
356
|
+
try {
|
|
357
|
+
if (typeof taskId !== 'string' || taskId.length === 0)
|
|
358
|
+
return { reopened: false };
|
|
359
|
+
const sessionKey = opts?.sessionKey ?? DEFAULT_SESSION_KEY;
|
|
360
|
+
// 诚实边界:请求方会话没有 overlay 订阅者(headless / 尚未挂载)⇒ 返回 false,调用方如实说
|
|
361
|
+
// 「没能重开」。overlay 在场答的是「有没有一个 UI 会收到这一帧」;检查按 sessionKey 走 ——
|
|
362
|
+
// 别的会话挂着 overlay 不等于本会话的卡有地方渲(复审 [high]:默认键硬走会把帧投进别人的面)。
|
|
363
|
+
if (!hasQuestionOverlayFor(sessionKey))
|
|
364
|
+
return { reopened: false };
|
|
365
|
+
const canonicalId = planReviewQuestionId(taskId);
|
|
366
|
+
// 先查(firstSight)后记(铸卡即 arm)—— 顺序决定判决正确性;台账按 sessionKey 分账。
|
|
367
|
+
const firstSight = !wasGateArmedFor(sessionKey, canonicalId);
|
|
368
|
+
const mintFresh = opts?.mintFreshQuestionId !== false;
|
|
369
|
+
if (!mintFresh && hasLocalQuestionResponder(canonicalId)) {
|
|
370
|
+
// 重呈短路(与 arm 臂的重放短路同形):同一个 canonical 身份、同一份题面、同一个 responder。
|
|
371
|
+
// 卡还开着的宿主只是收到一帧同形重绘,卡已被 dismiss 的宿主拿回重开路径。
|
|
372
|
+
// 0.29.0 发包扫描门:本臂沿用 arm responder ⇒ opts.deliverDecision 不生效(JSDoc 成文例外),
|
|
373
|
+
// 传了就留痕 —— 静默吞注入口是契约不一致的形。
|
|
374
|
+
if (opts?.deliverDecision !== undefined) {
|
|
375
|
+
hostLog('debug', `planReviewWire: reopen canonical-reuse branch for task ${taskId} ignores opts.deliverDecision (answers route through the existing arm responder — documented exception)`);
|
|
376
|
+
}
|
|
377
|
+
// 先退役本会话名下还在场的**重开**卡(如此前走过 mintFresh 臂)—— 同一 gate 不留两张活卡。
|
|
378
|
+
retireActiveReopen(sessionKey, canonicalId);
|
|
379
|
+
publishQuestionFrameFor(sessionKey, {
|
|
380
|
+
type: 'question',
|
|
381
|
+
questionId: canonicalId,
|
|
382
|
+
questions: planReviewQuestions(ARM_QUESTION),
|
|
383
|
+
});
|
|
384
|
+
registerArmedGateFor(sessionKey, canonicalId);
|
|
385
|
+
return { reopened: true, firstSight };
|
|
386
|
+
}
|
|
387
|
+
// 复审 [high] 单活纪律(两轮收紧):铸新卡前退役**全部**旧作答入口 —— 同一个 gate 至多一张
|
|
388
|
+
// 能投递决断的卡,两张都活着时一张 approve 一张 reject = 两次相互冲突的状态变更请求。
|
|
389
|
+
// · 上一张重开卡:注销 responder + question_complete 撤帧(单活台账);
|
|
390
|
+
// · canonical(arm)卡:question_complete 撤帧**且按 id 注销 responder**(二轮 [high]:
|
|
391
|
+
// 撤帧只是 UI 动作,表里的 responder 不死,在途的迟到作答仍能经它投递)。canonical
|
|
392
|
+
// responder 死后,arm 重放走它自己的「陈旧武装态 ⇒ 整条重新 arm」自愈臂([2393] hitl-F3),
|
|
393
|
+
// 不会留下弹不出卡的死短路。
|
|
394
|
+
retireActiveReopen(sessionKey, canonicalId);
|
|
395
|
+
if (unregisterLocalQuestionResponder(canonicalId)) {
|
|
396
|
+
hostLog('debug', `planReviewWire: reopen retired the canonical responder for task ${taskId} (single-active discipline)`);
|
|
397
|
+
}
|
|
398
|
+
publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: canonicalId });
|
|
399
|
+
const questionId = `${canonicalId}${REOPEN_ID_TAIL}${reopenIdSuffix()}`;
|
|
400
|
+
const deliver = opts?.deliverDecision ??
|
|
401
|
+
((tid, decision) => {
|
|
402
|
+
// fire-and-forget:resume 同步驱动到终态可能分钟级,不能挂住 overlay(arm 臂同姿势);
|
|
403
|
+
// 失败处置/结果回植由 decidePlanReview 自带的 outcome 管道承担。
|
|
404
|
+
void decidePlanReview(tid, decision);
|
|
405
|
+
});
|
|
406
|
+
const activeKey = `${sessionKey}\u0000${canonicalId}`;
|
|
407
|
+
const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
|
|
408
|
+
unregister(); // 一次性(arm 臂同款):respond 侧 delete-before-invoke 之外的第二道钉
|
|
409
|
+
// 单活台账清账(只清**自己**的条目 —— 自己被更新的重开取代后,旧 responder 的迟到作答
|
|
410
|
+
// 不许把新卡的条目清掉)。
|
|
411
|
+
if (activeReopenResponders.get(activeKey)?.questionId === questionId) {
|
|
412
|
+
activeReopenResponders.delete(activeKey);
|
|
413
|
+
}
|
|
414
|
+
const decided = planReviewDecisionFromAnswer(answer);
|
|
415
|
+
if (decided === 'dismissed') {
|
|
416
|
+
// 三态(REF-CC-026):dismiss/读不出不驱动 decidePlanReview;呈现台账不清 ——
|
|
417
|
+
// 这张卡再被重开就是真「reopened」。
|
|
418
|
+
return { ok: true };
|
|
419
|
+
}
|
|
420
|
+
// A-024.4:决断递交即消费呈现史(与 arm 臂 responder 同点,理由见那里)。
|
|
421
|
+
clearArmedGateFor(sessionKey, canonicalId);
|
|
422
|
+
try {
|
|
423
|
+
await deliver(taskId, decided);
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
// 投递编排是注入方的职责面(缺省臂自带 outcome 管道不会走到这);这里只留痕,
|
|
427
|
+
// 绝不把宿主投递臂的 throw 冒进 overlay 的 respond 链。
|
|
428
|
+
hostLog('error', `planReviewWire: reopen decision delivery threw for task ${taskId}: ${String(e)}`);
|
|
429
|
+
}
|
|
430
|
+
return { ok: true };
|
|
431
|
+
});
|
|
432
|
+
activeReopenResponders.set(activeKey, { questionId, unregister });
|
|
433
|
+
registerArmedGateFor(sessionKey, canonicalId);
|
|
434
|
+
publishQuestionFrameFor(sessionKey, {
|
|
435
|
+
type: 'question',
|
|
436
|
+
questionId,
|
|
437
|
+
questions: planReviewQuestions(REOPEN_QUESTION),
|
|
438
|
+
});
|
|
439
|
+
return { reopened: true, firstSight };
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return { reopened: false }; // 重开失败绝不炸 turn —— 调用方会如实说「没能重开」
|
|
443
|
+
}
|
|
444
|
+
}
|
|
@@ -69,10 +69,13 @@
|
|
|
69
69
|
* 规则逐字节相同、更不是它的 id;下游「删除/撤销规则」面拿它当身份用 = 删错或删不掉。
|
|
70
70
|
* ② **deny 归因通道**({@link ApprovalCardDenyDecision.reason}):两腿不同形 ——
|
|
71
71
|
* durable `/decide` **有** reason 位 ⇒ 原文上 wire(包内按 server 4096 上限截断);
|
|
72
|
-
* live 帧腿 `/v1/tool-approvals/:id/respond
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
72
|
+
* live 帧腿 `/v1/tool-approvals/:id/respond`:✅ **#229 已兑现**(0.29.0 respond-note 批,
|
|
73
|
+
* server ≥7.15.0)—— 端点收 **`note`**(不是 reason;与 durable `AskDecisionBody.note`
|
|
74
|
+
* 同词同源同一列 `decision_note`),deny reason 经 {@link RespondToolApprovalOpts.note} 上 wire。
|
|
75
|
+
* 发送前置两道门:能力位 `approvalDecisionNote`(SDK 6.16 成文「位缺席就别发」)+ 2048 上限
|
|
76
|
+
* (server 超限响亮 400 且**连决断一起拒**,所以超限 ⇒ 整条不发 + 留痕,诚实缺席优先于
|
|
77
|
+
* 静默截断);真落行与否看 ack 的 `noteRecorded`(缺席 ≠ false)。
|
|
78
|
+
* 🔴 方向纪律:reason/note 只做归因,绝不参与裁决;缺席 ⇒ 现状字节不变。
|
|
76
79
|
*/
|
|
77
80
|
import { type HitlClientLike } from './hitlBridge.js';
|
|
78
81
|
import type { RuleSuggestion, ToolApprovalRespondAck } from '@sema-agent/sdk';
|
|
@@ -350,6 +353,11 @@ export interface RespondToolApprovalOpts {
|
|
|
350
353
|
updatedInput?: unknown;
|
|
351
354
|
/** #225 件1:兑付键 —— 帧候选之一的**原文**(编排层已做表内核对与 deny 剥除)。 */
|
|
352
355
|
persistRule?: string;
|
|
356
|
+
/** #229(server ≥7.15.0):回决备注 —— 与 durable 腿 `AskDecisionBody.note` **同词同源同一列**
|
|
357
|
+
* (`decision_note`,≤2048)。任何 decision 都可带(deny 的「为什么拒」正是审计面上最值钱的
|
|
358
|
+
* 一条);真落行与否看 ack 的 {@link surfaceToolApprovalFrameAndRespond} 消费的 `noteRecorded`。
|
|
359
|
+
* 🔴 编排层(本文件)只在能力位确认 + 上限内才填本位 —— 注入面原样透传,别自己再判。 */
|
|
360
|
+
note?: string;
|
|
353
361
|
}
|
|
354
362
|
export type RespondToolApprovalFn = (approvalId: string, decision: ToolApprovalRespondDecision, opts?: RespondToolApprovalOpts) => Promise<ToolApprovalRespondAck | void>;
|
|
355
363
|
/**
|
|
@@ -380,13 +388,14 @@ export interface ToolApprovalFrameOutcome {
|
|
|
380
388
|
* `AgentEvent` 的臂了(durable 腿也回放),所以结构识别与 union 收窄两条路都成立;本函数仍按
|
|
381
389
|
* 结构读(不依赖类型收窄),因为它同时服务 raw SSE 与 durable 回放两条入口。 */
|
|
382
390
|
export declare function isToolApprovalFrame(ev: unknown): ev is ToolApprovalFrame;
|
|
383
|
-
/**
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
391
|
+
/** 帧腿的宿主车道参数(#229 respond-note 批,0.29.0)。 */
|
|
392
|
+
export interface ToolApprovalFrameLaneOpts {
|
|
393
|
+
/**
|
|
394
|
+
* server 能力位 `capabilities.approvalDecisionNote` 的读数(宿主从自己的 caps 缓存供给)。
|
|
395
|
+
* 🔴 SDK 6.16 成文:**位缺席就别发 `note`** —— 老 server 对未知请求键静默忽略且照回 200,
|
|
396
|
+
* 「这台不认识 note」与「记上了」在响应上不可分,发了只造「已留档」的错觉。
|
|
397
|
+
* 缺席/false ⇒ 不发(fail-closed 到「不发」侧;决断本身照常送达,现状字节不变)。
|
|
398
|
+
*/
|
|
399
|
+
approvalDecisionNoteCapable?: boolean;
|
|
400
|
+
}
|
|
401
|
+
export declare function surfaceToolApprovalFrameAndRespond(frame: ToolApprovalFrame, respond: RespondToolApprovalFn, streamArgs: unknown | undefined, signal?: AbortSignal, lane?: ToolApprovalFrameLaneOpts): Promise<ToolApprovalFrameOutcome>;
|
|
@@ -69,16 +69,20 @@
|
|
|
69
69
|
* 规则逐字节相同、更不是它的 id;下游「删除/撤销规则」面拿它当身份用 = 删错或删不掉。
|
|
70
70
|
* ② **deny 归因通道**({@link ApprovalCardDenyDecision.reason}):两腿不同形 ——
|
|
71
71
|
* durable `/decide` **有** reason 位 ⇒ 原文上 wire(包内按 server 4096 上限截断);
|
|
72
|
-
* live 帧腿 `/v1/tool-approvals/:id/respond
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
72
|
+
* live 帧腿 `/v1/tool-approvals/:id/respond`:✅ **#229 已兑现**(0.29.0 respond-note 批,
|
|
73
|
+
* server ≥7.15.0)—— 端点收 **`note`**(不是 reason;与 durable `AskDecisionBody.note`
|
|
74
|
+
* 同词同源同一列 `decision_note`),deny reason 经 {@link RespondToolApprovalOpts.note} 上 wire。
|
|
75
|
+
* 发送前置两道门:能力位 `approvalDecisionNote`(SDK 6.16 成文「位缺席就别发」)+ 2048 上限
|
|
76
|
+
* (server 超限响亮 400 且**连决断一起拒**,所以超限 ⇒ 整条不发 + 留痕,诚实缺席优先于
|
|
77
|
+
* 静默截断);真落行与否看 ack 的 `noteRecorded`(缺席 ≠ false)。
|
|
78
|
+
* 🔴 方向纪律:reason/note 只做归因,绝不参与裁决;缺席 ⇒ 现状字节不变。
|
|
76
79
|
*/
|
|
77
80
|
import { DEFAULT_DENY_REASON, HitlBridge, HitlSafetyError, denyReasonForWire, findPendingForTask, } from './hitlBridge.js';
|
|
78
81
|
import { hostLog } from '../host.js';
|
|
79
82
|
import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
|
|
80
83
|
import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
|
|
81
84
|
import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded } from './hitlHostSurface.js';
|
|
85
|
+
import { approvalCallKey, liveFrameCallKey } from './gateIdentity.js';
|
|
82
86
|
/** fs 写权限 gate 判定:未来的一等 kind(tool_approval)或按 toolName(server 桥首批=fs 写三件,
|
|
83
87
|
* [820] 表)。AskUserQuestion 永不进这里(ask 桥先判)。 */
|
|
84
88
|
export function isFsApprovalGate(gate) {
|
|
@@ -242,7 +246,8 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
|
|
|
242
246
|
return { kind: 'failed', gatedCallId, reason: 'gate has no tool input payload' };
|
|
243
247
|
}
|
|
244
248
|
const bridge = new HitlBridge(deps.client, taskId);
|
|
245
|
-
|
|
249
|
+
// A-028.3:卡身份键经 gateIdentity 唯一铸口(呈现台账/壳侧重开腿同源,字面不再各铸各的)。
|
|
250
|
+
const callKey = approvalCallKey(gatedCallId, taskId);
|
|
246
251
|
const card = await surfaceApprovalCard({
|
|
247
252
|
toolName,
|
|
248
253
|
args,
|
|
@@ -408,6 +413,9 @@ export function readToolApprovalRespondAck(v) {
|
|
|
408
413
|
...(typeof o.ruleRefusal === 'string' && o.ruleRefusal !== ''
|
|
409
414
|
? { ruleRefusal: o.ruleRefusal }
|
|
410
415
|
: {}),
|
|
416
|
+
// #229(sdk 6.16.0):这次回决的 note 有没有**真落进持久行**。缺席 ≠ false(未带 note 的回决/
|
|
417
|
+
// 老 server 字段省略);非布尔降缺席 —— 它决定「审计理由记没记上」的诚实告知。
|
|
418
|
+
...(typeof o.noteRecorded === 'boolean' ? { noteRecorded: o.noteRecorded } : {}),
|
|
411
419
|
};
|
|
412
420
|
}
|
|
413
421
|
/** 结构性识别流上的 tool_approval 帧(named SSE frame,payload.type === 帧名)。
|
|
@@ -497,7 +505,10 @@ function subagentBadgeFor(frame) {
|
|
|
497
505
|
*
|
|
498
506
|
* @returns {@link ToolApprovalFrameOutcome} —— 决断 + server ack(ack 缺席 = 未知,不是 false)。
|
|
499
507
|
*/
|
|
500
|
-
|
|
508
|
+
/** respond `note` 位的 server 上限(server `MAX_DECISION_NOTE_CHARS` = 2048,与 durable
|
|
509
|
+
* `AskDecisionBody.note` 同一列;超限 server **响亮 400 且连决断一起拒**,所以本包发送前有界)。 */
|
|
510
|
+
const MAX_RESPOND_NOTE_CHARS = 2048;
|
|
511
|
+
export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamArgs, signal, lane) {
|
|
501
512
|
const toolName = typeof frame.toolName === 'string' ? frame.toolName : 'Write';
|
|
502
513
|
// 工具入参:流上 tool_start.args(完整一手源)> 帧 args(redacted+16KiB 帽)> message 兜 path。
|
|
503
514
|
let args = streamArgs !== undefined && streamArgs !== null
|
|
@@ -527,7 +538,8 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
|
|
|
527
538
|
const card = await surfaceApprovalCard({
|
|
528
539
|
toolName,
|
|
529
540
|
args: args,
|
|
530
|
-
|
|
541
|
+
// A-028.3:live 帧腿的卡身份键经 gateIdentity 唯一铸口(帧上无 gatedCallId,以 approvalId 铸)。
|
|
542
|
+
callKey: liveFrameCallKey(frame.approvalId),
|
|
531
543
|
...(signal ? { signal } : {}),
|
|
532
544
|
...(isFromSubagent(frame) ? { workerBadge: subagentBadgeFor(frame) } : {}),
|
|
533
545
|
...(wireNote !== undefined ? { wireNote } : {}),
|
|
@@ -554,24 +566,31 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
|
|
|
554
566
|
if (card.kind === 'failed') {
|
|
555
567
|
hostLog('debug', `liveToolApprovalWire: approval card unavailable (${card.reason}) — fail-closed deny for ${frame.approvalId}`);
|
|
556
568
|
}
|
|
557
|
-
// #
|
|
558
|
-
// (
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
// 0.28.0 发版扫描 F4:本腿留痕**不借** denyReasonForWire —— 那个窄化器的超限日志讲的是
|
|
569
|
-
// 「413 会丢掉决断」,对 respond 端点是假话(它根本不收 reason,无 413 可言);且截断后 len
|
|
570
|
-
// 恒 4096,掩盖真实丢弃量。本地判空白+报**原始**长度,只说这条腿真实发生的事:归因整条没发。
|
|
569
|
+
// #229 兑现(0.29.0 respond-note 批;此前 0.28.0 记账「候 server」,server 7.15.0 已到货):
|
|
570
|
+
// respond 端点收 **`note`**(不是 reason —— 与 durable `AskDecisionBody.note` 同词同源同一列
|
|
571
|
+
// `decision_note`),任何 decision 都可带。deny reason 经它上 wire,发送前置两道门:
|
|
572
|
+
// · 能力位(`lane.approvalDecisionNoteCapable === true`)—— SDK 6.16 成文「位缺席就别发」:
|
|
573
|
+
// 老 server 静默忽略未知键照回 200,「没听懂」与「记上了」不可分,发了只造「已留档」错觉;
|
|
574
|
+
// · 上限(≤{@link MAX_RESPOND_NOTE_CHARS}=2048)—— server 超限**响亮 400 且连决断一起拒**,
|
|
575
|
+
// 而备注是决断的补充面,绝不许它把决断本身打失败。超限 ⇒ **整条不发** + 留痕
|
|
576
|
+
// (诚实缺席优先于静默截断 —— 「一条被悄悄砍半的审计理由比没有理由更坏」,server 同款立场)。
|
|
577
|
+
// 🔴 留痕纪律不变:只写**元数据**(在场 + 长度 + 没发的原因),绝不把正文拷进日志 ——
|
|
578
|
+
// 正文是宿主自己卡口返回的自由文本,回喂宿主 sink 零诊断增量,还是外溢/注入面。
|
|
579
|
+
let note;
|
|
571
580
|
if (card.kind === 'deny' && typeof card.reason === 'string' && card.reason.trim() !== '') {
|
|
572
|
-
|
|
573
|
-
'
|
|
574
|
-
|
|
581
|
+
if (lane?.approvalDecisionNoteCapable !== true) {
|
|
582
|
+
hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} card supplied a deny reason (len=${card.reason.length}) but the ` +
|
|
583
|
+
'approvalDecisionNote capability is not confirmed for this engine — note not sent (an unknown key would be ' +
|
|
584
|
+
'silently swallowed by an older server while still acking 200)');
|
|
585
|
+
}
|
|
586
|
+
else if (card.reason.length > MAX_RESPOND_NOTE_CHARS) {
|
|
587
|
+
hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} deny reason exceeds the decision_note cap ` +
|
|
588
|
+
`(len=${card.reason.length} > ${MAX_RESPOND_NOTE_CHARS}) — note not sent at all (the server rejects the WHOLE ` +
|
|
589
|
+
'respond with 400 over an oversize note, and a silently halved audit reason is worse than none)');
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
note = card.reason;
|
|
593
|
+
}
|
|
575
594
|
}
|
|
576
595
|
try {
|
|
577
596
|
// abort 后的 deny 仍要送达(引擎侧同款 cancel-by-deny)——不带已 aborted 的 signal。
|
|
@@ -592,6 +611,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
|
|
|
592
611
|
...(signal && !signal.aborted ? { signal } : {}),
|
|
593
612
|
...(card.kind === 'allow' && card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}),
|
|
594
613
|
...(persistRule !== undefined ? { persistRule } : {}),
|
|
614
|
+
...(note !== undefined ? { note } : {}),
|
|
595
615
|
});
|
|
596
616
|
// 🔴 相关性门(对抗复审二轮):ack 必须是**这一次**审批的回执 —— id 与决断词都要对上。
|
|
597
617
|
// 对不上的 ack(注入面串了别人的响应 / 坏 mock / raw-fetch 包装器复用了连接)如果照样被
|
|
@@ -619,6 +639,13 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
|
|
|
619
639
|
hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} edited args were NOT forwarded (ack.updatedInputForwarded=false) — the tool runs on the ORIGINAL input`);
|
|
620
640
|
surfaceEditNotForwarded();
|
|
621
641
|
}
|
|
642
|
+
// #229:note 发了而 ack 说没落行(noteRecorded=false:纯活卡无行可落 / 店抖但裁决照常生效 /
|
|
643
|
+
// 并发同决议先落行)或位缺席(异常形:能力位为真却没回执)—— 审计面事实,decision 本身不受
|
|
644
|
+
// 影响,debug 留痕即可(不惊动用户:决断没丢,丢的只是理由的持久档)。
|
|
645
|
+
if (note !== undefined && ack !== undefined && ack.noteRecorded !== true) {
|
|
646
|
+
hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} decision note was sent but not persisted ` +
|
|
647
|
+
`(ack.noteRecorded=${String(ack.noteRecorded)}) — decision stood; the audit note did not land on the ask row`);
|
|
648
|
+
}
|
|
622
649
|
return ack !== undefined ? { decision, ack } : { decision };
|
|
623
650
|
}
|
|
624
651
|
catch (e) {
|
package/dist/index.d.ts
CHANGED
|
@@ -182,6 +182,7 @@ export * from './adapter/downstream/turnUsageToModelUsage.js';
|
|
|
182
182
|
export * from './adapter/downstream/eventToSdkMessage.js';
|
|
183
183
|
export * from './adapter/downstream/terminalToSdkResult.js';
|
|
184
184
|
export * from './adapter/runStream.js';
|
|
185
|
+
export * from './adapter/activeRunSelfHeal.js';
|
|
185
186
|
export * from './request/taskRequest.js';
|
|
186
187
|
export * from './clientSlice.js';
|
|
187
188
|
export * from './host.js';
|
|
@@ -220,6 +221,9 @@ export * from './hitl/hitlHostSurface.js';
|
|
|
220
221
|
export * from './hitl/toolApprovalWire.js';
|
|
221
222
|
export * from './hitl/askGateWire.js';
|
|
222
223
|
export * from './hitl/planReviewWire.js';
|
|
224
|
+
export * from './hitl/gateIdentity.js';
|
|
225
|
+
export * from './hitl/armedGateRegistry.js';
|
|
226
|
+
export * from './hitl/parkOwnership.js';
|
|
223
227
|
export * from './hitl/approvalsFeed.js';
|
|
224
228
|
export * from './compensations.js';
|
|
225
229
|
export * from './request/printNotification.js';
|
package/dist/index.js
CHANGED
|
@@ -208,6 +208,14 @@ export * from './adapter/downstream/turnUsageToModelUsage.js';
|
|
|
208
208
|
export * from './adapter/downstream/eventToSdkMessage.js';
|
|
209
209
|
export * from './adapter/downstream/terminalToSdkResult.js';
|
|
210
210
|
export * from './adapter/runStream.js';
|
|
211
|
+
// ── A-028.1(#244 族A,2026-08-12):409 active-run 自愈的分诊树 + 结局文案层 ————————————
|
|
212
|
+
// 判别半场(activeRunBusySignal)在 runStream.js 早已出面;本件是它的消费侧:四路分诊
|
|
213
|
+
// (pendingGate.kind 优先 / status 表回退)+ 八种结局的可覆盖默认文案。归通用半场的理由:
|
|
214
|
+
// 三端面对同一把会话锁 —— cli 交互/headless 两车道换包导入删本地抄件(提货批);desktop
|
|
215
|
+
// session-host 已自写同族「parked run 不能 cancel」分诊,收敛到此;web 读 activeTaskId 的
|
|
216
|
+
// busy 面同源。文案默认串 = cli 产品面逐字,端经 ActiveRunSelfHealCopy 覆盖出路句。
|
|
217
|
+
// 🔴 零值级 import 纯函数叶(类型 type-only 借 runStream),不进内核/A 层闭包。
|
|
218
|
+
export * from './adapter/activeRunSelfHeal.js';
|
|
211
219
|
// ── B4 批:A 层帧分派收官 + 请求面合一 + 构造点收编(2026-07-27)────────────────────────────────
|
|
212
220
|
// 请求面:三个构造器(seamQuery.toTaskRequest / seamQueryEngine / liveClient.toLiveRequest)
|
|
213
221
|
// 的**字段集与合并语义**收成一份;车道差异做成 REQUEST_FIELD_MATRIX 数据表(gap:true = 判为漏)。
|
|
@@ -306,6 +314,18 @@ export * from './hitl/hitlHostSurface.js';
|
|
|
306
314
|
export * from './hitl/toolApprovalWire.js';
|
|
307
315
|
export * from './hitl/askGateWire.js';
|
|
308
316
|
export * from './hitl/planReviewWire.js';
|
|
317
|
+
// ── A-028.2/.3(#244 族A,2026-08-12):决断卡链的键词汇/呈现台账/归属判据三件 ————————————
|
|
318
|
+
// · gateIdentity:三条身份键字面(hitl-ask:/callKey/plan-review:,外加 hitl-frame: 同族)的唯一
|
|
319
|
+
// 铸口 —— 包内三处铸口与呈现台账的键推导同源;壳侧 armedGateRegistry/askParkReopen 的手抄字面
|
|
320
|
+
// 换 import 删本地(提货批);desktop session-host 的 plan:/durable: 前缀族收敛候后续对齐。
|
|
321
|
+
// · armedGateRegistry:per-session 呈现台账(firstSight 文案分形的唯一判据);sessionSlot 键支持
|
|
322
|
+
// 多会话宿主(desktop session-host 多引擎会话),cli 走 DEFAULT_SESSION_KEY 零参兼容层。
|
|
323
|
+
// · parkOwnership:ask park 重开腿的纯判据三件(fail-closed 归属证明/questions 结构判定/
|
|
324
|
+
// HitlSafetyError duck 判型)—— cli 换包导入删抄件;desktop refreshPendingApprovals 的自写
|
|
325
|
+
// 归属判定收敛到同一判据。approvals.list 编排与 stderr 留各端。
|
|
326
|
+
export * from './hitl/gateIdentity.js';
|
|
327
|
+
export * from './hitl/armedGateRegistry.js';
|
|
328
|
+
export * from './hitl/parkOwnership.js';
|
|
309
329
|
// B7 ③(census G20,**行为改动**不是搬迁):pending-approvals 推送 feed(stream 优先 / 断流回落
|
|
310
330
|
// 轮询 / 定期再试)。🔴 它**不替换** D-1 的取件 —— 那三处必须继续走权威 `list()`(见文件头)。
|
|
311
331
|
export * from './hitl/approvalsFeed.js';
|
|
@@ -68,6 +68,17 @@ 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
|
+
* Unregister a local responder **by id**(A-028.4 复审二轮收紧,2026-08-12)。
|
|
73
|
+
*
|
|
74
|
+
* 为什么需要按 id 注销:`registerLocalQuestionResponder` 的注销钩只回到注册方手里,而重开腿
|
|
75
|
+
* (`planReviewWire.reopenPlanReviewCard`)要退役的是**别的注册方**(arm 臂)登记的 canonical
|
|
76
|
+
* responder —— 只发 `question_complete` 撤帧撤不掉表里的 responder,一个在途的迟到作答仍能经
|
|
77
|
+
* 它投递决断,与新卡的投递构成同一 gate 的双决断面。返回是否真删了一条(调用方留痕用)。
|
|
78
|
+
* 🔴 只删表项,不通知原注册方 —— 原注册方的自愈臂(如 arm 的陈旧武装态分支)按
|
|
79
|
+
* `hasLocalQuestionResponder` 探询真相,不依赖回调。
|
|
80
|
+
*/
|
|
81
|
+
export declare function unregisterLocalQuestionResponder(id: string): boolean;
|
|
71
82
|
/**
|
|
72
83
|
* Is a local responder for this synthetic questionId still bound?([2393] hitl-F3,2026-08-02)
|
|
73
84
|
*
|
|
@@ -20,6 +20,19 @@ export function registerLocalQuestionResponder(id, fn) {
|
|
|
20
20
|
localResponders.delete(id);
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Unregister a local responder **by id**(A-028.4 复审二轮收紧,2026-08-12)。
|
|
25
|
+
*
|
|
26
|
+
* 为什么需要按 id 注销:`registerLocalQuestionResponder` 的注销钩只回到注册方手里,而重开腿
|
|
27
|
+
* (`planReviewWire.reopenPlanReviewCard`)要退役的是**别的注册方**(arm 臂)登记的 canonical
|
|
28
|
+
* responder —— 只发 `question_complete` 撤帧撤不掉表里的 responder,一个在途的迟到作答仍能经
|
|
29
|
+
* 它投递决断,与新卡的投递构成同一 gate 的双决断面。返回是否真删了一条(调用方留痕用)。
|
|
30
|
+
* 🔴 只删表项,不通知原注册方 —— 原注册方的自愈臂(如 arm 的陈旧武装态分支)按
|
|
31
|
+
* `hasLocalQuestionResponder` 探询真相,不依赖回调。
|
|
32
|
+
*/
|
|
33
|
+
export function unregisterLocalQuestionResponder(id) {
|
|
34
|
+
return localResponders.delete(id);
|
|
35
|
+
}
|
|
23
36
|
/**
|
|
24
37
|
* Is a local responder for this synthetic questionId still bound?([2393] hitl-F3,2026-08-02)
|
|
25
38
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/client-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,12 +28,12 @@
|
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@sema-agent/agent-types": ">=0.2.0",
|
|
31
|
-
"@sema-agent/sdk": ">=6.
|
|
31
|
+
"@sema-agent/sdk": ">=6.16.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@sema-agent/agent-types": "^0.2.0",
|
|
35
35
|
"@sema-agent/core": "^5.20.0",
|
|
36
|
-
"@sema-agent/sdk": "^6.
|
|
36
|
+
"@sema-agent/sdk": "^6.16.0",
|
|
37
37
|
"esbuild": "^0.27.4",
|
|
38
38
|
"typescript": "^6.0.2"
|
|
39
39
|
}
|