@sema-agent/client-core 0.83.6 → 0.84.1

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +20 -13
  3. package/dist/abortableSleep.d.ts +10 -0
  4. package/dist/abortableSleep.js +37 -0
  5. package/dist/adapt/wireShapes.js +3 -2
  6. package/dist/adapter/activeRunSelfHeal.js +7 -7
  7. package/dist/adapter/downstream/eventToSdkMessage.js +6 -2
  8. package/dist/adapter/downstream/terminalToSdkResult.d.ts +1 -0
  9. package/dist/adapter/downstream/terminalToSdkResult.js +10 -5
  10. package/dist/adapter/downstream/wiringManifestView.d.ts +3 -1
  11. package/dist/adapter/downstream/wiringManifestView.js +1 -0
  12. package/dist/agentSession/engineAgentRegistryRead.d.ts +18 -0
  13. package/dist/agentSession/engineAgentRegistryRead.js +167 -0
  14. package/dist/agentsWireCaps.js +10 -1
  15. package/dist/attachmentsWireCaps.js +2 -2
  16. package/dist/controlRouter.js +1 -1
  17. package/dist/decideFailureNote.d.ts +2 -1
  18. package/dist/decideFailureNote.js +5 -3
  19. package/dist/detachWire.d.ts +1 -0
  20. package/dist/detachWire.js +13 -5
  21. package/dist/displayUntrusted.js +114 -18
  22. package/dist/engineAgentAbsence.d.ts +37 -0
  23. package/dist/engineAgentAbsence.js +142 -0
  24. package/dist/engineErrorCodes.d.ts +2 -0
  25. package/dist/engineErrorCodes.js +9 -5
  26. package/dist/engineNoticeCodes.d.ts +43 -0
  27. package/dist/engineNoticeCodes.js +118 -5
  28. package/dist/engineWireSdk.d.ts +2 -0
  29. package/dist/fleet/fleetProjection.js +3 -2
  30. package/dist/frozenSet.d.ts +1 -0
  31. package/dist/frozenSet.js +20 -0
  32. package/dist/handsSeam.d.ts +3 -0
  33. package/dist/handsSeam.js +19 -0
  34. package/dist/hitl/askGateWire.d.ts +1 -1
  35. package/dist/hitl/askGateWire.js +1 -1
  36. package/dist/hitl/hitlHostSurface.js +1 -1
  37. package/dist/hitl/parkResolver.d.ts +1 -0
  38. package/dist/hitl/parkResolver.js +9 -3
  39. package/dist/hitl/planReviewWire.d.ts +30 -3
  40. package/dist/hitl/planReviewWire.js +139 -32
  41. package/dist/hitl/sessionPolicyDeliverable.d.ts +2 -0
  42. package/dist/hitl/sessionPolicyDeliverable.js +14 -3
  43. package/dist/hitl/sessionPolicyWire.d.ts +40 -0
  44. package/dist/hitl/sessionPolicyWire.js +210 -0
  45. package/dist/hitl/toolApprovalWire.js +2 -2
  46. package/dist/index.d.ts +24 -13
  47. package/dist/index.js +13 -13
  48. package/dist/liveInitToolFace.js +28 -10
  49. package/dist/model/catalogLoader.js +3 -3
  50. package/dist/model/tierVocabulary.js +1 -1
  51. package/dist/request/taskRequest.js +15 -5
  52. package/dist/resumeRefusalCopy.d.ts +11 -0
  53. package/dist/resumeRefusalCopy.js +39 -1
  54. package/dist/rewindWireCaps.d.ts +0 -1
  55. package/dist/seam.d.ts +2 -1
  56. package/dist/seatContract.js +2 -2
  57. package/dist/systemReminderTag.d.ts +5 -0
  58. package/dist/systemReminderTag.js +20 -9
  59. package/dist/toolResult.js +2 -6
  60. package/dist/toolRoster.d.ts +16 -0
  61. package/dist/toolRoster.js +46 -5
  62. package/dist/wireErrorTriage.js +1 -11
  63. package/dist/workflowClient.js +4 -3
  64. package/dist/workflowMonitor.d.ts +1 -1
  65. package/docs/INTEGRATION-CLIENTS.md +701 -37
  66. package/package.json +3 -3
@@ -1,12 +1,12 @@
1
1
  import { publishQuestionFrame, publishQuestionFrameFor, registerLocalQuestionResponder, unregisterLocalQuestionResponder, hasLocalQuestionResponder, hasQuestionOverlayFor, } from '../liveQuestionStore.js';
2
2
  import { hostLog } from '../host.js';
3
- import { abortableSleep } from '../abortableSleep.js';
3
+ import { abortableSleep, settleWithinDeadline } from '../abortableSleep.js';
4
4
  import { makeEngineWireClient } from '../engineWireSdk.js';
5
5
  import { engineWireTarget } from '../engineWireTarget.js';
6
6
  import { enqueuePlanReviewOutcome } from '../notifications.js';
7
7
  import { corruptStoredRowContent, corruptStoredRowWhere } from '../wireErrorTriage.js';
8
8
  import { decideFailureNote } from '../decideFailureNote.js';
9
- import { isPlanReviewModeAfter } from './hitlBridge.js';
9
+ import { denyReasonForWire, isPlanReviewModeAfter } from './hitlBridge.js';
10
10
  import { engineCapString } from '../engineCapsCache.js';
11
11
  import { planReviewQuestionId, REOPEN_ID_TAIL, taskIdFromPlanReviewQuestionId } from './gateIdentity.js';
12
12
  import { notePlanReviewAnswered, notePlanReviewAnsweredFor, planReviewArmedKeyFor, registerArmedGateFor, waitForGateArmedFor, wasGateArmedFor, } from './armedGateRegistry.js';
@@ -225,8 +225,19 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
225
225
  '此处拒绝而不是回落默认槽 —— 回落会把这条会话的决断带着别的槽的 baseUrl/token/principal 发出去。');
226
226
  return false;
227
227
  }
228
- if (!engineWireTarget())
228
+ const wire = opts?.wire;
229
+ if (wire !== undefined) {
230
+ if (!usablePlanReviewWireClient(wire.client)) {
231
+ hostLog('error', 'planReviewWire: plan_review card NOT armed — the engine client supplied by the host is not usable (needs assistant.planReview and runs.get)');
232
+ return false;
233
+ }
234
+ }
235
+ else if (!engineWireTarget()) {
229
236
  return false;
237
+ }
238
+ const delivery = wire !== undefined || opts?.onOutcome !== undefined
239
+ ? { ...(wire !== undefined ? { wire } : {}), ...(opts?.onOutcome !== undefined ? { onOutcome: opts.onOutcome } : {}) }
240
+ : undefined;
230
241
  const { taskId } = result;
231
242
  retireActiveReopen(DEFAULT_SESSION_KEY, planReviewQuestionId(taskId));
232
243
  if (armedPlanReviewTaskIds.has(taskId)) {
@@ -240,8 +251,9 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
240
251
  }
241
252
  armedPlanReviewTaskIds.add(taskId);
242
253
  armedTaskId = taskId;
254
+ const capsBaseUrl = wire !== undefined ? wire.capsBaseUrl : engineWireTarget()?.baseUrl;
243
255
  const offerModeAfter = opts?.submittedInPlanMode === true &&
244
- versionSupportsPlanReviewModeAfter(engineCapString(engineWireTarget()?.baseUrl, 'version')) === 'supported';
256
+ versionSupportsPlanReviewModeAfter(engineCapString(capsBaseUrl, 'version')) === 'supported';
245
257
  if (offerModeAfter)
246
258
  offerModeAfterByTask.set(taskId, true);
247
259
  else
@@ -262,7 +274,7 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
262
274
  offerModeAfterByTask.delete(taskId);
263
275
  notePlanReviewAnswered(questionId);
264
276
  hostLog('debug', `planReviewWire: arm responder delivering ${decided} for task ${taskId} (path=arm)`);
265
- void decidePlanReview(taskId, decided, modeAfter);
277
+ void (delivery === undefined ? decidePlanReview(taskId, decided, modeAfter) : decidePlanReview(taskId, decided, modeAfter, delivery));
266
278
  return { ok: true };
267
279
  });
268
280
  publishPlanReviewCard(taskId);
@@ -296,12 +308,31 @@ export function planReviewOutcomeTail(taskId, decision, effect) {
296
308
  return `Do not state the result as fact: tell the user the ${decision} was submitted but its effect could not be confirmed; TaskOutput("${taskId}") shows where the task stands.`;
297
309
  }
298
310
  }
311
+ function planReviewDecisionTimedOutOutcome(decision) {
312
+ return (`The plan_review ${decision} was sent, but no answer came back from the engine within the time limit this host allows for it — ` +
313
+ `it may have taken effect (${decision === 'approve' ? 'an approval keeps the engine busy until the resumed work finishes' : 'the engine may still be applying it'}).`);
314
+ }
315
+ function isRequestTimeout(e) {
316
+ try {
317
+ return typeof e === 'object' && e !== null && e.name === 'TimeoutError';
318
+ }
319
+ catch {
320
+ return false;
321
+ }
322
+ }
323
+ function usablePlanReviewWireClient(client) {
324
+ if (client === null || typeof client !== 'object')
325
+ return false;
326
+ const c = client;
327
+ return typeof c.assistant?.planReview === 'function' && typeof c.runs?.get === 'function';
328
+ }
329
+ const PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS = 15_000;
299
330
  const decideInFlight = new Map();
300
331
  let decideDispatchSeq = 0;
301
332
  export function __resetPlanReviewDecisionLatchForTests() {
302
333
  decideInFlight.clear();
303
334
  }
304
- export async function decidePlanReview(taskId, decision, permissionModeAfter) {
335
+ export async function decidePlanReview(taskId, decision, permissionModeAfter, opts) {
305
336
  if (decideInFlight.has(taskId)) {
306
337
  hostLog('error', `planReviewWire: decidePlanReview(${decision}) for task ${taskId} REFUSED — a decision for this task is already in flight (duplicate delivery latched)`);
307
338
  return;
@@ -315,43 +346,84 @@ export async function decidePlanReview(taskId, decision, permissionModeAfter) {
315
346
  const dispatchNo = ++decideDispatchSeq;
316
347
  hostLog('debug', `planReviewWire: decidePlanReview dispatch #${dispatchNo} task=${taskId} decision=${decision} mode=${permissionModeAfter ?? '-'}`);
317
348
  try {
318
- await decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch);
349
+ await decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, opts);
319
350
  }
320
351
  finally {
321
352
  releaseLatch();
322
353
  }
323
354
  }
324
- async function decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch) {
355
+ function planReviewClientsFor(wire, cfg) {
356
+ if (wire !== undefined) {
357
+ let injected;
358
+ try {
359
+ injected = typeof wire === 'object' && wire !== null ? wire.client : undefined;
360
+ }
361
+ catch {
362
+ injected = undefined;
363
+ }
364
+ if (!usablePlanReviewWireClient(injected))
365
+ return null;
366
+ const client = injected;
367
+ return { decide: client, repull: () => client };
368
+ }
369
+ if (!cfg)
370
+ return null;
371
+ const decide = makeEngineWireClient({
372
+ baseUrl: cfg.baseUrl,
373
+ ...(cfg.token ? { token: cfg.token } : {}),
374
+ principal: cfg.principal,
375
+ timeoutMs: PLAN_REVIEW_RESUME_TIMEOUT_MS,
376
+ });
377
+ if (!decide)
378
+ return null;
379
+ return {
380
+ decide,
381
+ repull: () => makeEngineWireClient({
382
+ baseUrl: cfg.baseUrl,
383
+ ...(cfg.token ? { token: cfg.token } : {}),
384
+ principal: cfg.principal,
385
+ timeoutMs: 15_000,
386
+ }),
387
+ };
388
+ }
389
+ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, opts) {
325
390
  let outcome;
326
391
  let effect = 'unconfirmed';
327
- const cfg = engineWireTarget();
392
+ let statusSeen;
393
+ const wire = opts?.wire;
394
+ const cfg = wire === undefined ? engineWireTarget() : null;
328
395
  const badMode = permissionModeAfter !== undefined && (decision !== 'approve' || !isPlanReviewModeAfter(permissionModeAfter));
329
396
  if (badMode) {
330
397
  hostLog('error', `planReviewWire: ${decision} NOT sent — permissionModeAfter is only valid with approve and must be "default" or "acceptEdits"`);
331
398
  effect = 'not_sent';
332
399
  outcome = `The plan_review ${decision} could NOT be sent: the post-approval permission mode given with it is not valid for this decision (it only applies to an approval, and must be "default" or "acceptEdits"). Tell the user plainly that the decision did not go through; the plan is still waiting.`;
333
400
  }
334
- else if (!cfg) {
401
+ else if (wire === undefined && !cfg) {
335
402
  hostLog('error', `planReviewWire: ${decision} NOT sent — engineWireTarget() unavailable`);
336
403
  effect = 'not_sent';
337
404
  outcome = `The plan_review ${decision} could NOT be sent: no engine connection is configured on this host. Tell the user plainly that the decision did not go through.`;
338
405
  }
339
406
  else {
340
- const client = makeEngineWireClient({
341
- baseUrl: cfg.baseUrl,
342
- ...(cfg.token ? { token: cfg.token } : {}),
343
- principal: cfg.principal,
344
- timeoutMs: PLAN_REVIEW_RESUME_TIMEOUT_MS,
345
- });
346
- if (!client) {
347
- hostLog('error', `planReviewWire: ${decision} NOT sent — makeEngineWireClient() returned null`);
407
+ const clients = planReviewClientsFor(wire, cfg);
408
+ if (!clients) {
409
+ if (wire !== undefined) {
410
+ hostLog('error', `planReviewWire: ${decision} NOT sent — the engine client supplied by the host is not usable (needs assistant.planReview and runs.get)`);
411
+ outcome = `The plan_review ${decision} could NOT be sent: the engine client supplied by the host cannot send it (it lacks the plan-review verb). Tell the user plainly that the decision did not go through.`;
412
+ }
413
+ else {
414
+ hostLog('error', `planReviewWire: ${decision} NOT sent — makeEngineWireClient() returned null`);
415
+ outcome = `The plan_review ${decision} could NOT be sent: the engine client could not be constructed. Tell the user plainly that the decision did not go through.`;
416
+ }
348
417
  effect = 'not_sent';
349
- outcome = `The plan_review ${decision} could NOT be sent: the engine client could not be constructed. Tell the user plainly that the decision did not go through.`;
350
418
  }
351
419
  else {
420
+ const client = clients.decide;
352
421
  try {
353
422
  const mode = isPlanReviewModeAfter(permissionModeAfter) ? permissionModeAfter : undefined;
423
+ const reason = denyReasonForWire(opts?.reason, `plan-review ${taskId}`);
354
424
  const reqBody = { decision };
425
+ if (reason !== undefined)
426
+ reqBody.reason = reason;
355
427
  if (mode !== undefined)
356
428
  reqBody.permissionModeAfter = mode;
357
429
  let body;
@@ -362,7 +434,7 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
362
434
  const conflict = e1;
363
435
  if (mode === 'default' && conflict?.status === 400 && conflict?.errorCode === 'request.field_conflict') {
364
436
  hostLog('debug', 'planReviewWire: permissionModeAfter "default" refused as not applicable — re-sending the approval without the key (same meaning)');
365
- body = await client.assistant.planReview(taskId, { decision });
437
+ body = await client.assistant.planReview(taskId, reason !== undefined ? { decision, reason } : { decision });
366
438
  }
367
439
  else {
368
440
  throw e1;
@@ -374,19 +446,28 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
374
446
  hostLog('debug', `planReviewWire: ${decision} → ok (dispatch #${dispatchNo}) ${JSON.stringify(body).slice(0, 200)}`);
375
447
  let postStatus;
376
448
  try {
377
- const probe = makeEngineWireClient({
378
- baseUrl: cfg.baseUrl,
379
- ...(cfg.token ? { token: cfg.token } : {}),
380
- principal: cfg.principal,
381
- timeoutMs: 15_000,
382
- });
383
- const post = probe ? (await probe.runs.get(taskId)) : null;
384
- postStatus = typeof post?.status === 'string' ? post.status : undefined;
449
+ const probe = clients.repull();
450
+ if (probe) {
451
+ let post;
452
+ if (wire !== undefined) {
453
+ const got = await settleWithinDeadline(PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS, (signal) => probe.runs.get(taskId, { signal }));
454
+ if (got.kind === 'error')
455
+ throw new Error(got.detail);
456
+ if (got.kind === 'deadline')
457
+ throw new Error(`post-decide status re-pull passed its ${PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS} ms deadline`);
458
+ post = got.value;
459
+ }
460
+ else {
461
+ post = (await probe.runs.get(taskId));
462
+ }
463
+ postStatus = typeof post?.status === 'string' ? post.status : undefined;
464
+ }
385
465
  }
386
466
  catch (e) {
387
467
  hostLog('debug', `planReviewWire: post-decide status re-pull failed (fail-soft): ${String(e)}`);
388
468
  }
389
469
  const effective = postStatus ?? (typeof body?.status === 'string' ? body.status : undefined);
470
+ statusSeen = effective;
390
471
  if (effective === 'needs_review') {
391
472
  effect = 'still_parked';
392
473
  outcome =
@@ -437,21 +518,41 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
437
518
  effect = status >= 400 && status < 500 ? 'not_applied' : 'unconfirmed';
438
519
  outcome = `${`The plan_review decision failed: HTTP ${status} ${msg}`.trim()}${decideFailureNote(e)}`;
439
520
  }
521
+ else if (isRequestTimeout(e)) {
522
+ hostLog('debug', `planReviewWire: ${decision} sent but no answer within the request time limit (task ${taskId})`);
523
+ outcome = planReviewDecisionTimedOutOutcome(decision);
524
+ }
440
525
  else {
441
526
  outcome = `The plan_review decision could not reach the engine: ${String(e)}`;
442
527
  }
443
528
  }
444
529
  }
445
530
  }
531
+ const tail = planReviewOutcomeTail(taskId, decision, effect);
532
+ const prompt = `<plan-review-outcome>\n${outcome}\n${tail}\n</plan-review-outcome>`;
533
+ const onOutcome = opts?.onOutcome;
446
534
  try {
447
- const delivered = enqueuePlanReviewOutcome(`<plan-review-outcome>\n${outcome}\n${planReviewOutcomeTail(taskId, decision, effect)}\n</plan-review-outcome>`, { taskId, dispatchNo, decision, effect });
535
+ const delivered = enqueuePlanReviewOutcome(prompt, { taskId, dispatchNo, decision, effect });
448
536
  if (!delivered) {
449
- hostLog('error', `planReviewWire: outcome enqueue MISSED (queue port not installed or lacks enqueueMetaPrompt) for task ${taskId} — model will not learn the ${decision} result`);
537
+ if (onOutcome !== undefined) {
538
+ hostLog('debug', `planReviewWire: outcome queue port not installed for task ${taskId} — outcome handed to the host's onOutcome callback only`);
539
+ }
540
+ else {
541
+ hostLog('error', `planReviewWire: outcome enqueue MISSED (queue port not installed or lacks enqueueMetaPrompt) for task ${taskId} — model will not learn the ${decision} result`);
542
+ }
450
543
  }
451
544
  }
452
545
  catch (e) {
453
546
  hostLog('debug', `planReviewWire: outcome enqueue threw: ${String(e)}`);
454
547
  }
548
+ if (onOutcome !== undefined) {
549
+ try {
550
+ onOutcome({ taskId, dispatchNo, decision, effect, detail: outcome, tail, prompt, ...(statusSeen !== undefined ? { status: statusSeen } : {}) });
551
+ }
552
+ catch (e) {
553
+ hostLog('debug', `planReviewWire: onOutcome callback threw for task ${taskId} (ignored): ${String(e)}`);
554
+ }
555
+ }
455
556
  }
456
557
  function reopenIdSuffix() {
457
558
  return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
@@ -545,6 +646,9 @@ function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs) {
545
646
  if (opts?.deliverDecision !== undefined) {
546
647
  hostLog('debug', `planReviewWire: reopen canonical-reuse branch for task ${taskId} ignores opts.deliverDecision (answers route through the existing arm responder — documented exception)`);
547
648
  }
649
+ if (opts?.wire !== undefined || opts?.onOutcome !== undefined) {
650
+ hostLog('debug', `planReviewWire: reopen canonical-reuse branch for task ${taskId} ignores opts.wire / opts.onOutcome (answers route through the existing arm responder and the wire it was armed with — documented exception)`);
651
+ }
548
652
  retireActiveReopen(sessionKey, canonicalId);
549
653
  if (receiptMs !== undefined) {
550
654
  const receipt = waitForGateArmedFor(sessionKey, [canonicalId], receiptMs);
@@ -571,10 +675,13 @@ function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs) {
571
675
  publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: canonicalId });
572
676
  const questionId = `${canonicalId}${REOPEN_ID_TAIL}${reopenIdSuffix()}`;
573
677
  hostLog('debug', `planReviewWire: reopen for task ${taskId} minting a fresh card ${questionId} (path=reopen-fresh)`);
678
+ const delivery = opts?.wire !== undefined || opts?.onOutcome !== undefined
679
+ ? { ...(opts.wire !== undefined ? { wire: opts.wire } : {}), ...(opts.onOutcome !== undefined ? { onOutcome: opts.onOutcome } : {}) }
680
+ : undefined;
574
681
  const deliver = opts?.deliverDecision ??
575
682
  ((tid, decision) => {
576
683
  hostLog('debug', `planReviewWire: reopen responder delivering ${decision} for task ${tid} (path=reopen-default)`);
577
- void decidePlanReview(tid, decision);
684
+ void (delivery === undefined ? decidePlanReview(tid, decision) : decidePlanReview(tid, decision, undefined, delivery));
578
685
  });
579
686
  const activeKey = `${sessionKey}\u0000${canonicalId}`;
580
687
  const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
@@ -724,7 +831,7 @@ export function reopenPlanReviewCard(taskId, opts) {
724
831
  if (!hasQuestionOverlayFor(sessionKey)) {
725
832
  return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
726
833
  }
727
- if (sessionKey !== DEFAULT_SESSION_KEY && opts?.deliverDecision === undefined) {
834
+ if (sessionKey !== DEFAULT_SESSION_KEY && opts?.deliverDecision === undefined && opts?.wire === undefined) {
728
835
  hostLog('error', `planReviewWire: plan_review 卡未重开 —— sessionKey=${JSON.stringify(sessionKey)} 是非默认槽,` +
729
836
  '而缺省决断投递口(decidePlanReview)今天固定打默认槽的 baseUrl/token/principal。' +
730
837
  '整条链转 keyed 之前,非默认槽要重开请自带 deliverDecision(按本槽投递);' +
@@ -11,5 +11,7 @@ export interface SessionPolicyDeliverability {
11
11
  readonly withheld: readonly SessionPolicyWithheldRule[];
12
12
  readonly unreadable?: true;
13
13
  }
14
+ export declare function isEngineRefusedToolName(name: string): boolean;
14
15
  export declare function sessionPolicyDeliverable(behavior: 'deny' | 'ask', rules: readonly string[]): SessionPolicyDeliverability;
15
16
  export declare function sessionPolicyWithheldNotice(why: SessionPolicyWithheldWhy): string;
17
+ export declare function legacyToolNameFailureNoteOf(code: string | undefined): string | undefined;
@@ -1,3 +1,4 @@
1
+ import { CONFIG_LEGACY_TOOL_NAME } from '../engineErrorCodes.js';
1
2
  const COVERING_NAMESPACE_PREFIXES = ['mcp__', 'a2a__'];
2
3
  const ENGINE_RETIRED_TOOL_NAMES = new Map([
3
4
  ['read_file', 'Read'],
@@ -71,12 +72,17 @@ function withheldWhyOf(rule) {
71
72
  }
72
73
  if (rule.trim() !== rule)
73
74
  return 'not_a_tool_name';
74
- if (ENGINE_RETIRED_TOOL_NAMES.has(rule))
75
- return 'legacy_tool_name';
76
- if (rule.includes('__') && !COVERING_NAMESPACE_PREFIXES.some((prefix) => rule.startsWith(prefix)))
75
+ if (isEngineRefusedToolName(rule))
77
76
  return 'legacy_tool_name';
78
77
  return undefined;
79
78
  }
79
+ export function isEngineRefusedToolName(name) {
80
+ if (typeof name !== 'string')
81
+ return false;
82
+ if (ENGINE_RETIRED_TOOL_NAMES.has(name))
83
+ return true;
84
+ return name.includes('__') && !COVERING_NAMESPACE_PREFIXES.some((prefix) => name.startsWith(prefix));
85
+ }
80
86
  export function sessionPolicyDeliverable(behavior, rules) {
81
87
  const entries = [];
82
88
  try {
@@ -128,3 +134,8 @@ export function sessionPolicyWithheldNotice(why) {
128
134
  ? WITHHELD_NOTICE[why]
129
135
  : 'Not written to this session’s rule record.';
130
136
  }
137
+ const LEGACY_TOOL_NAME_FAILURE_NOTE = `This run did not start: a tool rule applied to it names a tool the engine refuses — a retired tool name, or a name that contains "__" but does not start with ${PREFIXES_IN_PROSE} (the engine's own error text names the entry). ` +
138
+ 'Every later run fails the same way until that entry is removed from wherever it is set: this session’s rule record, the settings sent with the run, a parent session’s rule record, or the deployment’s own tool rules.';
139
+ export function legacyToolNameFailureNoteOf(code) {
140
+ return code === CONFIG_LEGACY_TOOL_NAME ? LEGACY_TOOL_NAME_FAILURE_NOTE : undefined;
141
+ }
@@ -1,5 +1,6 @@
1
1
  import type { SessionPermissionRules, StoredSessionRules } from '@sema-agent/sdk';
2
2
  import type { SessionPolicyReading } from '../sessionPolicyCapability.js';
3
+ import type { ToolRosterView } from '../toolRoster.js';
3
4
  export interface SessionPolicyFacade {
4
5
  getPolicy(sessionId: string, opts?: {
5
6
  signal?: AbortSignal;
@@ -93,3 +94,42 @@ export declare function tightenSessionPolicy(facade: SessionPolicyFacade, req: S
93
94
  signal?: AbortSignal;
94
95
  }): Promise<SessionPolicyTightenOutcome>;
95
96
  export declare function sessionPolicyTightenNotice(outcome: SessionPolicyTightenOutcome): string;
97
+ export interface SessionPolicyRemovedEntry {
98
+ readonly field: 'toolDeny' | 'toolAllow';
99
+ readonly rule: string;
100
+ }
101
+ export type SessionPolicyRemovalOutcome = {
102
+ kind: 'roster_required';
103
+ why: SessionPolicyRosterRequiredWhy;
104
+ } | {
105
+ kind: 'removed';
106
+ rev: number;
107
+ removed: readonly SessionPolicyRemovedEntry[];
108
+ } | {
109
+ kind: 'nothing_to_remove';
110
+ rev: number;
111
+ } | {
112
+ kind: 'loosen_forbidden';
113
+ message: string;
114
+ refused: readonly SessionPolicyRemovedEntry[];
115
+ } | {
116
+ kind: 'conflict';
117
+ rev: number;
118
+ } | {
119
+ kind: 'read_failed';
120
+ why: Extract<SessionPolicyTightenUnknownWhy, 'face_unavailable' | 'session_not_found' | 'session_ownerless' | 'unauthorized' | 'request_rejected' | 'store_corrupt' | 'read_unreadable' | 'read_failed'>;
121
+ message?: string;
122
+ } | {
123
+ kind: 'write_refused';
124
+ why: Extract<SessionPolicyTightenUnknownWhy, 'face_unavailable' | 'session_not_found' | 'session_ownerless' | 'unauthorized' | 'request_rejected' | 'store_corrupt' | 'write_refused'>;
125
+ message?: string;
126
+ } | {
127
+ kind: 'write_unconfirmed';
128
+ why: Extract<SessionPolicyTightenUnknownWhy, 'write_indeterminate' | 'write_receipt_unreadable' | 'write_receipt_mismatch'>;
129
+ message?: string;
130
+ };
131
+ export type SessionPolicyRosterRequiredWhy = 'absent' | 'unreadable';
132
+ export declare function removeRefusedSessionRules(facade: SessionPolicyFacade, sessionId: string, roster: ToolRosterView | undefined, opts?: {
133
+ signal?: AbortSignal;
134
+ }): Promise<SessionPolicyRemovalOutcome>;
135
+ export declare function sessionPolicyRemovalNotice(outcome: SessionPolicyRemovalOutcome): string;
@@ -1,5 +1,6 @@
1
1
  import { wireFailureShapeOf } from '../wireFailureShape.js';
2
2
  import { hostLog } from '../host.js';
3
+ import { isEngineRefusedToolName } from './sessionPolicyDeliverable.js';
3
4
  const RULE_FIELD_PRESENCE = {
4
5
  toolAllow: true,
5
6
  toolDeny: true,
@@ -306,3 +307,212 @@ export function sessionPolicyTightenNotice(outcome) {
306
307
  }
307
308
  }
308
309
  }
310
+ function rosterMountedNames(roster) {
311
+ if (roster === undefined || roster === null)
312
+ return 'absent';
313
+ try {
314
+ if (typeof roster !== 'object' || Array.isArray(roster))
315
+ return 'unreadable';
316
+ const entries = roster.entries;
317
+ if (!Array.isArray(entries))
318
+ return 'unreadable';
319
+ const names = new Set();
320
+ const n = entries.length;
321
+ for (let i = 0; i < n; i++) {
322
+ if (!Object.hasOwn(entries, i))
323
+ return 'unreadable';
324
+ const row = entries[i];
325
+ if (typeof row !== 'object' || row === null)
326
+ return 'unreadable';
327
+ const name = row.name;
328
+ if (typeof name !== 'string' || name.length === 0)
329
+ return 'unreadable';
330
+ names.add(name);
331
+ const aliases = row.aliases;
332
+ if (aliases === undefined)
333
+ continue;
334
+ if (!Array.isArray(aliases))
335
+ return 'unreadable';
336
+ for (const a of aliases) {
337
+ if (typeof a !== 'string')
338
+ return 'unreadable';
339
+ names.add(a);
340
+ }
341
+ }
342
+ return names;
343
+ }
344
+ catch {
345
+ return 'unreadable';
346
+ }
347
+ }
348
+ function withoutRefusedEntries(prior, mounted) {
349
+ const REFUSABLE_NAME_FIELDS = ['toolDeny', 'toolAllow'];
350
+ const isRefusable = (f) => REFUSABLE_NAME_FIELDS.includes(f);
351
+ const next = {};
352
+ const removed = [];
353
+ for (const field of SESSION_POLICY_RULE_FIELDS) {
354
+ const list = Object.hasOwn(prior, field) ? prior[field] : undefined;
355
+ if (list === undefined)
356
+ continue;
357
+ if (!isRefusable(field)) {
358
+ next[field] = [...list];
359
+ continue;
360
+ }
361
+ const kept = [];
362
+ const seen = new Set();
363
+ for (const x of list) {
364
+ if (!isEngineRefusedToolName(x) || mounted.has(x)) {
365
+ kept.push(x);
366
+ continue;
367
+ }
368
+ if (seen.has(x))
369
+ continue;
370
+ seen.add(x);
371
+ removed.push({ field, rule: x });
372
+ }
373
+ next[field] = kept;
374
+ }
375
+ return { next, removed };
376
+ }
377
+ function removalFailed(failure, leg) {
378
+ const u = unknownFrom(failure, leg);
379
+ const message = u.message;
380
+ switch (u.why) {
381
+ case 'write_indeterminate':
382
+ return { kind: 'write_unconfirmed', why: 'write_indeterminate', ...(message !== undefined ? { message } : {}) };
383
+ case 'read_failed':
384
+ case 'read_unreadable':
385
+ return { kind: 'read_failed', why: u.why, ...(message !== undefined ? { message } : {}) };
386
+ case 'write_refused':
387
+ return { kind: 'write_refused', why: 'write_refused', ...(message !== undefined ? { message } : {}) };
388
+ case 'face_unavailable':
389
+ case 'session_not_found':
390
+ case 'session_ownerless':
391
+ case 'unauthorized':
392
+ case 'request_rejected':
393
+ case 'store_corrupt':
394
+ return leg === 'read'
395
+ ? { kind: 'read_failed', why: u.why, ...(message !== undefined ? { message } : {}) }
396
+ : { kind: 'write_refused', why: u.why, ...(message !== undefined ? { message } : {}) };
397
+ default:
398
+ return leg === 'read'
399
+ ? { kind: 'read_failed', why: 'read_failed', ...(message !== undefined ? { message } : {}) }
400
+ : { kind: 'write_unconfirmed', why: 'write_indeterminate', ...(message !== undefined ? { message } : {}) };
401
+ }
402
+ }
403
+ async function attemptRemoval(facade, sessionId, mounted, opts) {
404
+ const read = await readSessionPolicy(facade, sessionId, opts);
405
+ if (read.kind === 'failed')
406
+ return { done: removalFailed(read.failure, 'read') };
407
+ if (read.kind === 'malformed')
408
+ return { done: { kind: 'read_failed', why: 'read_unreadable', message: read.detail } };
409
+ const { next, removed } = withoutRefusedEntries(read.rules, mounted);
410
+ if (removed.length === 0)
411
+ return { done: { kind: 'nothing_to_remove', rev: read.rev } };
412
+ let receipt;
413
+ try {
414
+ receipt = await facade.putPolicy(sessionId, { rules: next, expectedRev: read.rev }, opts);
415
+ }
416
+ catch (e) {
417
+ const failure = classifySessionPolicyFailure(e);
418
+ if (failure.kind === 'loosen-forbidden')
419
+ return { done: { kind: 'loosen_forbidden', message: failure.message, refused: removed } };
420
+ if (failure.kind === 'cas-conflict')
421
+ return { retryAtRev: read.rev };
422
+ return { done: removalFailed(failure, 'write') };
423
+ }
424
+ let narrowed;
425
+ try {
426
+ narrowed = narrowStoredRecord(receipt);
427
+ }
428
+ catch {
429
+ narrowed = { ok: false, why: 'envelope', detail: 'policy write receipt could not be read' };
430
+ }
431
+ if (!narrowed.ok)
432
+ return { done: { kind: 'write_unconfirmed', why: 'write_receipt_unreadable', message: narrowed.detail } };
433
+ const mismatch = receiptMismatch(next, narrowed.rules, read.rev, narrowed.rev) ??
434
+ (removed.some((e) => {
435
+ const r = Object.hasOwn(narrowed.rules, e.field) ? narrowed.rules[e.field] : undefined;
436
+ return r !== undefined && r.includes(e.rule);
437
+ })
438
+ ? 'the write receipt still holds an entry that was sent for removal'
439
+ : null);
440
+ if (mismatch !== null)
441
+ return { done: { kind: 'write_unconfirmed', why: 'write_receipt_mismatch', message: mismatch } };
442
+ return { done: { kind: 'removed', rev: narrowed.rev, removed } };
443
+ }
444
+ export async function removeRefusedSessionRules(facade, sessionId, roster, opts) {
445
+ const mounted = rosterMountedNames(roster);
446
+ if (typeof mounted === 'string')
447
+ return { kind: 'roster_required', why: mounted };
448
+ const first = await attemptRemoval(facade, sessionId, mounted, opts);
449
+ if ('done' in first)
450
+ return first.done;
451
+ hostLog('debug', 'sessionPolicyWire: the session rule record moved between this read and this removal — re-reading once and judging the other writer’s record again');
452
+ const second = await attemptRemoval(facade, sessionId, mounted, opts);
453
+ if ('done' in second)
454
+ return second.done;
455
+ return { kind: 'conflict', rev: second.retryAtRev };
456
+ }
457
+ export function sessionPolicyRemovalNotice(outcome) {
458
+ const generic = 'The session’s rule record may not have changed — re-read it before doing anything else.';
459
+ if (outcome === null || typeof outcome !== 'object')
460
+ return generic;
461
+ switch (outcome.kind) {
462
+ case 'roster_required':
463
+ return outcome.why === 'unreadable'
464
+ ? 'The tool roster supplied for this removal could not be read, so nothing was changed: without it an entry that looks refused may name a tool this deployment mounts itself, and removing it would lift a restriction that is in effect. Supply the tool roster exactly as read from a run’s assembly manifest and try again.'
465
+ : 'No tool roster was supplied for this removal, so nothing was changed: without it an entry that looks refused may name a tool this deployment mounts itself, and removing it would lift a restriction that is in effect. Supply the tool roster from a run of this session on this deployment (a run that fails at startup reports none) and try again.';
466
+ case 'removed': {
467
+ const list = Array.isArray(outcome.removed) ? outcome.removed : [];
468
+ const n = list.length;
469
+ const what = n === 1 ? '1 entry' : `${n} entries`;
470
+ const fields = new Set(list.map((e) => (typeof e === 'object' && e !== null ? e.field : undefined)));
471
+ const deny = fields.has('toolDeny')
472
+ ? ' Removing names from the deny list lifts no block on any tool mounted for this session’s runs; to block such a tool under its current name, add a new deny rule with that name.'
473
+ : '';
474
+ const allow = fields.has('toolAllow')
475
+ ? ' Removing names from the allow list allows nothing new: the tools those names were meant to allow stay outside it, and an allow list left empty allows no tool at all; to allow such a tool under its current name, add its current name to the allow list.'
476
+ : '';
477
+ return `Removed ${what} from this session’s rule record (now at version ${outcome.rev}): ${n === 1 ? 'it names' : 'each names'} a tool the engine refuses, and no tool in the supplied roster answers to ${n === 1 ? 'it' : 'those names'}; this record no longer stops the session’s runs from starting.${deny}${allow}`;
478
+ }
479
+ case 'nothing_to_remove':
480
+ return 'This session’s rule record holds no entry naming a tool the engine refuses that the supplied roster does not mount, so nothing was changed. If runs still fail at startup over a refused tool name, that entry is set somewhere this record does not cover — the settings sent with the run, a parent session’s rule record, or the deployment’s own tool rules — or it is a name the engine refuses that this version does not yet recognise, or the failing run does not mount a tool the supplied roster has under that name.';
481
+ case 'loosen_forbidden':
482
+ return 'The engine refused to take these entries out of this session’s rule record: on this engine only an operator can remove entries from it, so the record is unchanged. Ask an operator to remove them, or continue in a new session.';
483
+ case 'conflict':
484
+ return 'This session’s rule record was changed by someone else while the refused entries were being removed, twice in a row — nothing was changed. Try again once the other change has finished.';
485
+ case 'read_failed':
486
+ switch (outcome.why) {
487
+ case 'face_unavailable':
488
+ return 'This deployment does not keep per-session tool rules, so there is no session rule record to remove entries from — nothing was changed.';
489
+ case 'session_not_found':
490
+ return 'This session is not readable from here — it may not exist, or it may belong to someone else; nothing was changed.';
491
+ case 'session_ownerless':
492
+ return 'This session has no owner, so it has no session rule record to change; nothing was changed.';
493
+ case 'unauthorized':
494
+ return 'This call could not present a verifiable identity, so nothing was changed.';
495
+ case 'store_corrupt':
496
+ return 'The engine could not establish which version this session’s rules are at, so nothing was changed. This needs an operator to look at the rule store.';
497
+ case 'read_unreadable':
498
+ return 'The session’s current rules could not be read, so nothing was changed — rewriting a record that cannot be read could replace real restrictions.';
499
+ default:
500
+ return 'The session’s current rules could not be fetched, so nothing was changed.';
501
+ }
502
+ case 'write_refused':
503
+ switch (outcome.why) {
504
+ case 'store_corrupt':
505
+ return 'The engine could not establish which version this session’s rules are at and refused to stamp a new one, so nothing was changed. This needs an operator to look at the rule store.';
506
+ case 'session_not_found':
507
+ return 'This session is not writable from here — it may not exist, or it may belong to someone else; nothing was changed.';
508
+ case 'unauthorized':
509
+ return 'This call could not present a verifiable identity, so nothing was changed.';
510
+ default:
511
+ return 'The engine refused this change, so nothing was changed. Re-read the session’s rules before trying again.';
512
+ }
513
+ case 'write_unconfirmed':
514
+ return 'The removal was sent but no clear answer came back from the engine — it may already be in effect. Run the removal again: it re-reads the record, and reports nothing left to remove if it did take effect.';
515
+ default:
516
+ return generic;
517
+ }
518
+ }
@@ -282,7 +282,7 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
282
282
  kind: 'failed',
283
283
  stage: 'decide',
284
284
  gatedCallId,
285
- reason: `decide(approve) failed: ${String(e)}${decideFailureNote(e)}`,
285
+ reason: `decide(approve) failed: ${String(e)}${decideFailureNote(e, 'approve')}`,
286
286
  ...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
287
287
  ...(currentPending !== undefined ? { currentPending } : {}),
288
288
  ...(wireCode !== undefined ? { errorCode: wireCode } : {}),
@@ -339,7 +339,7 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
339
339
  kind: 'failed',
340
340
  stage: 'decide',
341
341
  gatedCallId,
342
- reason: `decide(deny) failed: ${String(e)}${decideFailureNote(e)}`,
342
+ reason: `decide(deny) failed: ${String(e)}${decideFailureNote(e, 'deny')}`,
343
343
  ...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
344
344
  ...(currentPending !== undefined ? { currentPending } : {}),
345
345
  ...(wireCode !== undefined ? { errorCode: wireCode } : {}),