@sema-agent/client-core 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/hitl/askGateWire.js +81 -9
- package/dist/hitl/planReviewWire.js +42 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
|
|
|
23
23
|
|
|
24
24
|
## Scope
|
|
25
25
|
|
|
26
|
-
**Version:** 0.
|
|
26
|
+
**Version:** 0.12.2
|
|
27
27
|
|
|
28
28
|
- **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
|
|
29
29
|
B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
|
|
@@ -217,6 +217,8 @@ itself (FAILED names + skipped names + arithmetic reconciliation). All-SKIP repo
|
|
|
217
217
|
| `scripts/run-background-view-test.mjs` | `createBackgroundView` lifecycle: polling/notify pairing, per-source degrade (`501 → not-configured` vs `unavailable`), the capabilities `scheduler` probe, and dispose really aborting the in-flight fleet snapshot (pure projection lives in the pure suite's W-A segment) |
|
|
218
218
|
| `scripts/run-fleet-view-keys-test.mjs` | The fleet projection views, **both directions**: `FleetTaskView`/`FleetWorkflowView` ⇄ their key lists (compile-pinned) ⇄ what a maximal/minimal row really projects, plus a wire-key coverage ledger (every `FleetTaskRow` key is either projected or carries a written reason why not) and a drift ledger against the shell's render contract. A one-directional assignability check is blind to optional keys — which is how `startedAt` was silently dropped |
|
|
219
219
|
| `scripts/run-usage-verbatim-channel-test.mjs` | The two complementary usage disciplines (core 3.0.0 metering semantics): the CC `ModelUsage` mirror stays pure (five pinned keys, `totalInputTokens` has no seat), while the sema-owned channel forwards the engine `turn_end.usage` object **verbatim** (six keys, incl. `totalInputTokens`) via `last_turn_usage.engineUsage` / `handle.latestEngineUsage` — honest absence on pre-3.0.0 engines, no fabricated zeros |
|
|
220
|
+
| `scripts/run-plan-review-decide-verify-test.mjs` | `decidePlanReview`'s post-decide honesty ([2315]/[2316], engine RB-471 family): a 2xx from the decide endpoint is **not** a terminal — the wire re-pulls the task status and words the outcome by the real shape (still-locked / legal new gate / genuinely left park / unverified), never claiming success it hasn't earned. Driven against a real fake-engine HTTP server through the shipped dist |
|
|
221
|
+
| `scripts/run-shell-gate-durable-allow-test.mjs` | #110: the durable approval leg for **shell** gates. The tool_end HOLD/REJECT predicate must cover Bash the same way park detection already does (otherwise the park poison frame `Operation aborted` hits the transcript, `endedCalls` swallows the real replayed result, and the user who pressed Yes watches a command that really ran be reported as aborted); a replayed, already-decided park must resume reading the stream instead of being reported as a failed turn; `lastEventId` must track numeric `seq` too. Mutation-proven: each of the three fixes reverted turns the gate red |
|
|
220
222
|
| `scripts/run-public-surface-test.mjs` | The outward promises: the npm export surface baseline, the peer floor witness, and this README's claims |
|
|
221
223
|
|
|
222
224
|
Each suite carries a floor that only moves up — a refactor that stops executing a group of
|
package/dist/hitl/askGateWire.js
CHANGED
|
@@ -2,7 +2,7 @@ import { HitlBridge } from './hitlBridge.js';
|
|
|
2
2
|
import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverlay, } from '../liveQuestionStore.js';
|
|
3
3
|
import { hostLog } from '../host.js';
|
|
4
4
|
import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
|
|
5
|
-
import { isToolApprovalGate, toolNameIsFsWrite, surfaceFsApprovalAndDecide, isToolApprovalFrame, isFromSubagent, surfaceToolApprovalFrameAndRespond, } from './toolApprovalWire.js';
|
|
5
|
+
import { isToolApprovalGate, toolNameIsFsWrite, toolNameIsShellExec, surfaceFsApprovalAndDecide, isToolApprovalFrame, isFromSubagent, surfaceToolApprovalFrameAndRespond, } from './toolApprovalWire.js';
|
|
6
6
|
import { classifierDenyFromToolEnd } from '../classifierVerdictWire.js';
|
|
7
7
|
// W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层(cli 装配不动)。
|
|
8
8
|
const hostSurfaceByKey = createSessionSlot();
|
|
@@ -57,6 +57,42 @@ const MAX_GATE_HOPS = 24;
|
|
|
57
57
|
/** core 对被 gate/连坐 abort 的 call 铸的 tool_end 载体(逐字;desktop session-host 真引擎实测
|
|
58
58
|
* 同款)——HOLD 谓词锚它做**精确等值**,普通工具错的输出是各自错误文案,永不进 HOLD。 */
|
|
59
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
|
+
}
|
|
60
96
|
function isAskTool(name) {
|
|
61
97
|
return typeof name === 'string' && name.replace(/[\s_-]+/g, '').toLowerCase() === 'askuserquestion';
|
|
62
98
|
}
|
|
@@ -300,6 +336,17 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
|
300
336
|
* 不补就会渲成结果不可用;这里 stamp `structured` 让卡片渲真实选择。 */
|
|
301
337
|
const resolvedAnswers = new Map();
|
|
302
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();
|
|
303
350
|
let realTaskId = opts?.taskId;
|
|
304
351
|
let stream = source;
|
|
305
352
|
let hops = 0;
|
|
@@ -314,9 +361,14 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
|
314
361
|
/** park 请求:遇 AskUserQuestion / fs 写权限 gate 时置位后 break 内环。 */
|
|
315
362
|
let park = null;
|
|
316
363
|
for await (const ev of stream) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
364
|
+
// #110 缺陷② a:`lastSeq` 要跟得上**已消费的最大 seq**。durable `runs.events` 流上并非每帧
|
|
365
|
+
// 都带 `id`(数字 `seq` 位的帧同样在场),旧式只吃 `ev.id` ⇒ 停在最后一个带 id 的帧上 ⇒
|
|
366
|
+
// re-attach 从一个陈旧位置重放,把已经决断的 park 又送一遍(实测:消费到 16 却 attach from 8)。
|
|
367
|
+
const rawSeq = ev.id ?? ev.seq;
|
|
368
|
+
if (typeof rawSeq === 'string' && rawSeq.length > 0)
|
|
369
|
+
lastSeq = rawSeq;
|
|
370
|
+
else if (typeof rawSeq === 'number' && Number.isFinite(rawSeq))
|
|
371
|
+
lastSeq = String(rawSeq);
|
|
320
372
|
// ── [2084]① 扣留帧「模型推进即放行」(2026-07-30;同日 web 复审纠偏收窄触发集)──────────
|
|
321
373
|
// HOLD 只护 park 窗口。放行触发集 = host-lane **模型推进**帧:text_delta/reasoning_delta/
|
|
322
374
|
// tool_start —— park 路径上这三种在 done{suspended} 之前不会出现,真错路径上下一轮必来其一。
|
|
@@ -392,7 +444,8 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
|
392
444
|
if (startedCalls.has(ev.toolCallId))
|
|
393
445
|
continue; // durable 重放的已渲 call
|
|
394
446
|
startedCalls.add(ev.toolCallId);
|
|
395
|
-
if (
|
|
447
|
+
if (isGatedToolName(ev.toolName)) {
|
|
448
|
+
// #110:Bash 也要记 args —— 卡片渲染要拿到 `command`,不记则 shell gate 的卡面空着。
|
|
396
449
|
askArgsByCall.set(ev.toolCallId, ev.args);
|
|
397
450
|
if (typeof ev.toolName === 'string' && toolNameIsFsWrite(ev.toolName)) {
|
|
398
451
|
pendingFsCalls.push(ev.toolCallId);
|
|
@@ -425,8 +478,9 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
|
425
478
|
}
|
|
426
479
|
}
|
|
427
480
|
if (ev.isError === true &&
|
|
428
|
-
|
|
429
|
-
|
|
481
|
+
// #110:同步帧腿 deny 的 stamp 也要盖 shell —— 否则按 No 时用户看到的是引擎原文
|
|
482
|
+
// `Operation aborted` 而不是 CC 的 `User rejected …`。
|
|
483
|
+
isGatedToolName(ev.toolName) &&
|
|
430
484
|
denyStampNextFsEnd &&
|
|
431
485
|
!deniedCalls.has(ev.toolCallId)) {
|
|
432
486
|
// 同步帧腿 deny 且 callId 没关联上:下一张 fs 写报错帧即该 gate 的收口帧,stamp REJECT 文案。
|
|
@@ -436,8 +490,7 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
|
436
490
|
yield { ...ev, output: rejectMessageForRender(), structured: undefined };
|
|
437
491
|
continue;
|
|
438
492
|
}
|
|
439
|
-
if (ev.isError === true &&
|
|
440
|
-
(isAskTool(ev.toolName) || (typeof ev.toolName === 'string' && toolNameIsFsWrite(ev.toolName)))) {
|
|
493
|
+
if (ev.isError === true && isGatedToolName(ev.toolName)) {
|
|
441
494
|
if (deniedCalls.has(ev.toolCallId)) {
|
|
442
495
|
// 三选卡 No:重放的报错帧 stamp REJECT 文案 → vendored 卡渲 `User rejected <op> to <path>`。
|
|
443
496
|
deniedCalls.delete(ev.toolCallId);
|
|
@@ -542,6 +595,24 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
|
542
595
|
else {
|
|
543
596
|
outcome = await surfaceGateAndDecide(deps, realTaskId, askArgsByCall, opts?.signal);
|
|
544
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
|
+
}
|
|
545
616
|
if (outcome.kind !== 'decided') {
|
|
546
617
|
hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
|
|
547
618
|
yield* flushHeld(); // 回退:毒化帧照旧渲染(= 修复前的诚实红)
|
|
@@ -565,6 +636,7 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
|
565
636
|
// 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
|
|
566
637
|
if (outcome.gatedCallId) {
|
|
567
638
|
heldAskEnds.delete(outcome.gatedCallId);
|
|
639
|
+
decidedGates.add(outcome.gatedCallId); // #110 缺陷② b:决断过的 gate 身份留痕(诊断面)
|
|
568
640
|
if ('answered' in outcome && outcome.answered)
|
|
569
641
|
resolvedAnswers.set(outcome.gatedCallId, outcome.answered);
|
|
570
642
|
}
|
|
@@ -119,7 +119,48 @@ export async function decidePlanReview(taskId, decision) {
|
|
|
119
119
|
try {
|
|
120
120
|
const body = await client.assistant.planReview(taskId, { decision });
|
|
121
121
|
hostLog('debug', `planReviewWire: ${decision} → ok ${JSON.stringify(body).slice(0, 200)}`);
|
|
122
|
-
|
|
122
|
+
// [2315]/[2316](#109,2026-08-02):decide 的 **2xx 不当终态** —— test 黑盒实测 reject 9/9
|
|
123
|
+
// 返回 200 而会话仍锁在同一 gate(core RB-471:重开兜底对 reject 腿恒真误触发)。这里回拉
|
|
124
|
+
// 一次任务状态,按真形分三路措辞;根因归 core/server,本腿是「对外动作回读验证」在产品面的
|
|
125
|
+
// 同款,上游修后仍有价值(approve 合法推进新 gate 的形也靠它说真话)。
|
|
126
|
+
// 回拉用**短超时**独立 client(15s):verify 腿挂住不该占用 decide 的 6h 宽预算。
|
|
127
|
+
let postStatus;
|
|
128
|
+
try {
|
|
129
|
+
const probe = makeEngineWireClient({
|
|
130
|
+
baseUrl: cfg.baseUrl,
|
|
131
|
+
...(cfg.token ? { token: cfg.token } : {}),
|
|
132
|
+
principal: cfg.principal,
|
|
133
|
+
timeoutMs: 15_000,
|
|
134
|
+
});
|
|
135
|
+
const post = probe ? (await probe.runs.get(taskId)) : null;
|
|
136
|
+
postStatus = typeof post?.status === 'string' ? post.status : undefined;
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
hostLog('debug', `planReviewWire: post-decide status re-pull failed (fail-soft): ${String(e)}`);
|
|
140
|
+
}
|
|
141
|
+
// 回拉失败时回落 decide 200 体自带的 status(次级来源;两者都缺=unverified,措辞如实降级)。
|
|
142
|
+
const effective = postStatus ?? (typeof body?.status === 'string' ? body.status : undefined);
|
|
143
|
+
if (effective === 'needs_review') {
|
|
144
|
+
// 决定没生效,仍锁原 gate。不渲「已处理」;真出路只有 approve 或 cancel(下一次提交撞 409
|
|
145
|
+
// 时 activeRunSelfHeal 会把审批卡重开——这里不自动重弹卡,避免「刚拒绝又弹卡」的突袭感,
|
|
146
|
+
// 决定权经卡的重开路径还给用户)。
|
|
147
|
+
outcome =
|
|
148
|
+
`The plan_review ${decision} returned HTTP 200 but the session is STILL locked on the same review gate ` +
|
|
149
|
+
`(post-decide status: needs_review). The decision did NOT take effect (known engine issue, RB-471 family). ` +
|
|
150
|
+
`Tell the user plainly that the ${decision} did not go through; the reliable exits today are approving the plan or cancelling the task.`;
|
|
151
|
+
}
|
|
152
|
+
else if (effective === 'suspended') {
|
|
153
|
+
// 合法推进到新 gate(test [2315] ②形):不是「完成」,如实说下一张审批卡会跟上。
|
|
154
|
+
outcome =
|
|
155
|
+
`The plan was ${decision === 'approve' ? 'approved' : 'rejected'} and the task advanced to a NEW approval gate ` +
|
|
156
|
+
`(post-decide status: suspended) — the next approval card will surface it; this is not a completion yet.`;
|
|
157
|
+
}
|
|
158
|
+
else if (effective !== undefined) {
|
|
159
|
+
outcome = `The plan was ${decision === 'approve' ? 'approved and the parked task resumed to completion' : 'rejected (plan discarded)'} — final status: ${effective} (post-decide re-checked: task left the review gate).`;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
outcome = `The plan was ${decision === 'approve' ? 'approved and the parked task resumed to completion' : 'rejected (plan discarded)'} — final status: unknown (post-decide verification unavailable; treat as unconfirmed).`;
|
|
163
|
+
}
|
|
123
164
|
}
|
|
124
165
|
catch (e) {
|
|
125
166
|
// APIError 判型走 status duck-check(sseIdleTriage 同款纪律:双包时 instanceof 会分叉)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/client-core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
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",
|