@sema-agent/core 5.37.0 → 5.39.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/CHANGELOG.md +151 -0
- package/dist/agents/send-message-tool.d.ts +8 -0
- package/dist/agents/send-message-tool.js +8 -0
- package/dist/agents/subagent.js +6 -0
- package/dist/agents/teacher.js +12 -3
- package/dist/agents/team.d.ts +7 -1
- package/dist/agents/team.js +11 -9
- package/dist/agents/verify.js +12 -3
- package/dist/core/auto-mode-prompt-assets.d.ts +5 -3
- package/dist/core/auto-mode-prompt-assets.js +1 -1
- package/dist/core/checkpoint-store.d.ts +26 -1
- package/dist/core/hooks.d.ts +152 -2
- package/dist/core/hooks.js +65 -7
- package/dist/core/mailbox-store.d.ts +39 -0
- package/dist/core/mailbox-store.js +9 -0
- package/dist/core/permission-rule-consent.d.ts +27 -4
- package/dist/core/permission-rule-consent.js +29 -4
- package/dist/core/permission-rule-model.d.ts +7 -1
- package/dist/core/runner/prepare-config-doors.d.ts +17 -0
- package/dist/core/runner/prepare-config-doors.js +33 -2
- package/dist/core/runner/prepare-task.d.ts +17 -2
- package/dist/core/runner/prepare-task.js +135 -44
- package/dist/core/runner/runtask.js +46 -11
- package/dist/core/sensitive-path-policy.js +3 -3
- package/dist/core/store-contracts/mailbox-store-contract.d.ts +29 -1
- package/dist/core/store-contracts/mailbox-store-contract.js +78 -0
- package/dist/core/tool-model-gate.d.ts +125 -0
- package/dist/core/tool-model-gate.js +303 -0
- package/dist/core/tool-policy.d.ts +1 -1
- package/dist/core/types.d.ts +210 -1
- package/dist/core/types.js +21 -0
- package/dist/core/untrusted-text.d.ts +1 -1
- package/dist/core/write-protect.d.ts +93 -0
- package/dist/core/write-protect.js +194 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.js +5 -3
- package/dist/orchestration/builtin-workflows.d.ts +68 -6
- package/dist/orchestration/builtin-workflows.js +26 -9
- package/dist/orchestration/governance-baseline-validity.d.ts +44 -0
- package/dist/orchestration/governance-baseline-validity.js +55 -0
- package/dist/orchestration/run-workflow-tool.d.ts +10 -1
- package/dist/orchestration/run-workflow-tool.js +99 -31
- package/dist/orchestration/workflow-script-runner.js +9 -4
- package/dist/orchestration/workflow-script-store.d.ts +8 -3
- package/dist/prompts/coordinator.d.ts +4 -1
- package/dist/prompts/coordinator.js +8 -0
- package/dist/prompts/default.d.ts +14 -4
- package/dist/prompts/default.js +2 -1
- package/dist/scenarios/full-body.d.ts +5 -0
- package/dist/scenarios/full-body.js +8 -4
- package/dist/tools/fs/fs-shared.d.ts +3 -2
- package/dist/tools/fs/fs-shared.js +19 -9
- package/dist/tools/fs/read-deny.d.ts +15 -5
- package/dist/tools/fs/read-deny.js +33 -12
- package/dist/tools/fs/safety.d.ts +4 -1
- package/dist/tools/fs/safety.js +4 -2
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +24 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { persistedReadDenyEntryProblem } from "../../tools/fs/read-deny.js";
|
|
2
2
|
import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js";
|
|
3
|
+
import { deliverDelegationLifecycle } from "../types.js";
|
|
3
4
|
import { AgentHarness, DEFAULT_COMPACTION_SETTINGS, uuidv7 } from "../../internal/harness.js";
|
|
4
5
|
import { snapshotActorAssertion } from "../../internal/llm.js";
|
|
5
6
|
import { CheckpointError, BINDING_CHECKPOINT_VERSION, checkpointVersionOf, F012_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, FACE_CHECKPOINT_VERSION, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, LEGACY_PENDING_STEER_INPUT_ID, MAX_STEER_INPUT_ID_CHARS, validatePendingSteer, winnerFromOutcome, } from "../checkpoint-store.js";
|
|
@@ -757,7 +758,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
|
|
|
757
758
|
const injectedThisTurn = finalVerifyInjectedThisTurn ? "final_verification" : undefined;
|
|
758
759
|
if (!boundarySteered && !prepared.abortController.signal.aborted) {
|
|
759
760
|
try {
|
|
760
|
-
const r = await postToolBatchHook(batch, injectedThisTurn !== undefined ? { injectedThisTurn } : undefined);
|
|
761
|
+
const r = await postToolBatchHook(batch, injectedThisTurn !== undefined ? { injectedThisTurn } : undefined, { identity: prepared.hookIdentity });
|
|
761
762
|
if (r?.additionalContext && injectedThisTurn === undefined) {
|
|
762
763
|
const body = sanitizeUntrustedText(r.additionalContext);
|
|
763
764
|
const budget = ATTACHMENT_BYTE_CAP - boundaryAttachmentBytes;
|
|
@@ -1484,12 +1485,12 @@ export class Runner {
|
|
|
1484
1485
|
...(typeof h.preToolUse === "function" ? { preToolUse: (t, i, c) => h.preToolUse(t, i, c) } : {}),
|
|
1485
1486
|
...(typeof h.preToolUse === "function" && h.preToolUseObservational === true ? { preToolUseObservational: true } : {}),
|
|
1486
1487
|
...(typeof h.postToolUse === "function" ? { postToolUse: (t, i, o, c) => h.postToolUse(t, i, o, c) } : {}),
|
|
1487
|
-
...(typeof h.userPromptSubmit === "function" ? { userPromptSubmit: (p) => h.userPromptSubmit(p) } : {}),
|
|
1488
|
+
...(typeof h.userPromptSubmit === "function" ? { userPromptSubmit: (p, c) => h.userPromptSubmit(p, c) } : {}),
|
|
1488
1489
|
...(typeof h.stop === "function" ? { stop: (c) => h.stop(c) } : {}),
|
|
1489
1490
|
...(typeof h.postToolUseFailure === "function"
|
|
1490
1491
|
? { postToolUseFailure: (t, i, f, c) => h.postToolUseFailure(t, i, f, c) }
|
|
1491
1492
|
: {}),
|
|
1492
|
-
...(typeof h.postToolBatch === "function" ? { postToolBatch: (b) => h.postToolBatch(b) } : {}),
|
|
1493
|
+
...(typeof h.postToolBatch === "function" ? { postToolBatch: (b, m, c) => h.postToolBatch(b, m, c) } : {}),
|
|
1493
1494
|
...(typeof h.preCompact === "function" ? { preCompact: (c) => h.preCompact(c) } : {}),
|
|
1494
1495
|
...(typeof h.postCompact === "function" ? { postCompact: (c) => h.postCompact(c) } : {}),
|
|
1495
1496
|
...(typeof h.stopFailure === "function" ? { stopFailure: (c) => h.stopFailure(c) } : {}),
|
|
@@ -1679,6 +1680,13 @@ export class Runner {
|
|
|
1679
1680
|
durationMs: Date.now() - runStartedAt,
|
|
1680
1681
|
ts: Date.now(),
|
|
1681
1682
|
}));
|
|
1683
|
+
const owedTerminal = taskIdRef.delegationTerminalOwed;
|
|
1684
|
+
if (owedTerminal !== undefined) {
|
|
1685
|
+
taskIdRef.delegationTerminalOwed = undefined;
|
|
1686
|
+
deliverDelegationLifecycle(this.deps.onDelegationLifecycle, { phase: "terminal", identity: owedTerminal, status: "failed", turns: 0, ...(code !== undefined ? { errorCode: code } : {}) }, createSafeNotifier({
|
|
1687
|
+
onError: (f) => console.warn(`[sema-core] ${f.site}: delegation-lifecycle observer threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
|
|
1688
|
+
}), "runtask.onDelegationLifecycle");
|
|
1689
|
+
}
|
|
1682
1690
|
queue.push({ type: "done", result: resultValue });
|
|
1683
1691
|
queue.close();
|
|
1684
1692
|
});
|
|
@@ -2037,6 +2045,20 @@ export class Runner {
|
|
|
2037
2045
|
const ident = () => parentToolCallId !== undefined ? { eventId: uuidv7(), parentToolCallId, sourceTaskId: runSourceTaskId } : { eventId: uuidv7() };
|
|
2038
2046
|
notificationIdent = ident;
|
|
2039
2047
|
queue.push({ type: "wiring_manifest", manifest: prepared.wiringManifest, ...ident() });
|
|
2048
|
+
const delegationLifecycleNotifier = createSafeNotifier({
|
|
2049
|
+
onError: (f) => console.warn(`[sema-core] ${f.site}: delegation-lifecycle observer threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
|
|
2050
|
+
});
|
|
2051
|
+
const emitDelegationLifecycle = (event) => {
|
|
2052
|
+
if (!prepared.hookIdentity.isDelegatedChild)
|
|
2053
|
+
return;
|
|
2054
|
+
if (this.deps.onDelegationLifecycle === undefined)
|
|
2055
|
+
return;
|
|
2056
|
+
deliverDelegationLifecycle(this.deps.onDelegationLifecycle, event, delegationLifecycleNotifier, "runtask.onDelegationLifecycle");
|
|
2057
|
+
};
|
|
2058
|
+
emitDelegationLifecycle({ phase: "spawn", identity: prepared.hookIdentity });
|
|
2059
|
+
if (taskIdRef !== undefined && prepared.hookIdentity.isDelegatedChild && this.deps.onDelegationLifecycle !== undefined) {
|
|
2060
|
+
taskIdRef.delegationTerminalOwed = prepared.hookIdentity;
|
|
2061
|
+
}
|
|
2040
2062
|
manualCompactRef.emitMooted = (reason) => {
|
|
2041
2063
|
queue.push({ type: "compaction_outcome", outcome: "mooted", trigger: "manual", reason, ...ident() });
|
|
2042
2064
|
};
|
|
@@ -2729,6 +2751,7 @@ export class Runner {
|
|
|
2729
2751
|
stopHookActive: consecutiveBlocks > 0,
|
|
2730
2752
|
consecutiveBlocks,
|
|
2731
2753
|
getBranch: () => prepared.session.getBranch(),
|
|
2754
|
+
identity: prepared.hookIdentity,
|
|
2732
2755
|
});
|
|
2733
2756
|
}
|
|
2734
2757
|
catch (err) {
|
|
@@ -2804,7 +2827,7 @@ export class Runner {
|
|
|
2804
2827
|
...this.seamCCompactionOptions(prepared),
|
|
2805
2828
|
...gitRestateOption(prepared),
|
|
2806
2829
|
...windowSafetyOptions(prepared.harness.getModel()),
|
|
2807
|
-
...this.compactionHookOptions(spec, prepared.sessionId, "forced"),
|
|
2830
|
+
...this.compactionHookOptions(spec, prepared.sessionId, "forced", prepared.hookIdentity),
|
|
2808
2831
|
});
|
|
2809
2832
|
if (comp.compacted) {
|
|
2810
2833
|
compactionBreaker.failures = 0;
|
|
@@ -2869,7 +2892,7 @@ export class Runner {
|
|
|
2869
2892
|
runnerHooks: {
|
|
2870
2893
|
onError: this.deps.onError,
|
|
2871
2894
|
seamCCompactionOptions: (p) => this.seamCCompactionOptions(p),
|
|
2872
|
-
compactionHookOptions: (s, sid, trig) => this.compactionHookOptions(s, sid, trig),
|
|
2895
|
+
compactionHookOptions: (s, sid, trig) => this.compactionHookOptions(s, sid, trig, prepared.hookIdentity),
|
|
2873
2896
|
recordCompactionReuse: (p, c) => this.recordCompactionReuse(p, c),
|
|
2874
2897
|
},
|
|
2875
2898
|
});
|
|
@@ -3099,7 +3122,7 @@ export class Runner {
|
|
|
3099
3122
|
const userPromptSubmit = (spec.hooks ?? this.deps.hooks)?.userPromptSubmit;
|
|
3100
3123
|
if (userPromptSubmit) {
|
|
3101
3124
|
try {
|
|
3102
|
-
const decision = await userPromptSubmit(spec.objective);
|
|
3125
|
+
const decision = await userPromptSubmit(spec.objective, { identity: prepared.hookIdentity });
|
|
3103
3126
|
if (decision?.block) {
|
|
3104
3127
|
prepared.blockedRef.reason = formatHookFeedback(decision.block);
|
|
3105
3128
|
promptBlocked = true;
|
|
@@ -3503,6 +3526,7 @@ export class Runner {
|
|
|
3503
3526
|
result.errorCode !== "conflict") {
|
|
3504
3527
|
try {
|
|
3505
3528
|
await stopFailureHook({
|
|
3529
|
+
identity: prepared.hookIdentity,
|
|
3506
3530
|
error: result.errorMessage ?? "model error",
|
|
3507
3531
|
...(result.errorCode !== undefined ? { errorKind: result.errorCode } : {}),
|
|
3508
3532
|
turns: stats.turns,
|
|
@@ -3516,6 +3540,15 @@ export class Runner {
|
|
|
3516
3540
|
}
|
|
3517
3541
|
}
|
|
3518
3542
|
}
|
|
3543
|
+
emitDelegationLifecycle({
|
|
3544
|
+
phase: "terminal",
|
|
3545
|
+
identity: prepared.hookIdentity,
|
|
3546
|
+
status: result.status,
|
|
3547
|
+
turns: stats.turns,
|
|
3548
|
+
...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
|
|
3549
|
+
});
|
|
3550
|
+
if (taskIdRef !== undefined)
|
|
3551
|
+
taskIdRef.delegationTerminalOwed = undefined;
|
|
3519
3552
|
if (rs.degrade.degraded)
|
|
3520
3553
|
result.degraded = rs.degrade.degraded;
|
|
3521
3554
|
if (prepared.outputRef.set)
|
|
@@ -4503,15 +4536,17 @@ export class Runner {
|
|
|
4503
4536
|
consecutiveProviderReuse: prepared.compactionReuseRef.consecutive,
|
|
4504
4537
|
};
|
|
4505
4538
|
}
|
|
4506
|
-
compactionHookOptions(spec, sessionId, trigger) {
|
|
4539
|
+
compactionHookOptions(spec, sessionId, trigger, identity) {
|
|
4507
4540
|
const hooks = spec.hooks ?? this.deps.hooks;
|
|
4508
4541
|
const pre = hooks?.preCompact;
|
|
4509
4542
|
const post = hooks?.postCompact;
|
|
4543
|
+
const withIdentity = (ctx) => identity !== undefined ? { ...ctx, identity } : ctx;
|
|
4510
4544
|
return {
|
|
4511
4545
|
trigger,
|
|
4512
4546
|
...(pre
|
|
4513
4547
|
? {
|
|
4514
|
-
preCompact: async (
|
|
4548
|
+
preCompact: async (rawCtx) => {
|
|
4549
|
+
const ctx = withIdentity(rawCtx);
|
|
4515
4550
|
const report = (err) => {
|
|
4516
4551
|
try {
|
|
4517
4552
|
this.deps.onError?.(err, { phase: "hook", sessionId });
|
|
@@ -4535,9 +4570,9 @@ export class Runner {
|
|
|
4535
4570
|
: {}),
|
|
4536
4571
|
...(post
|
|
4537
4572
|
? {
|
|
4538
|
-
postCompact: async (
|
|
4573
|
+
postCompact: async (rawCtx) => {
|
|
4539
4574
|
try {
|
|
4540
|
-
await post.call(hooks,
|
|
4575
|
+
await post.call(hooks, withIdentity(rawCtx));
|
|
4541
4576
|
}
|
|
4542
4577
|
catch (err) {
|
|
4543
4578
|
try {
|
|
@@ -4581,7 +4616,7 @@ export class Runner {
|
|
|
4581
4616
|
...(prepared.onCompactionApplied ? { onApplied: prepared.onCompactionApplied } : {}),
|
|
4582
4617
|
...this.seamCCompactionOptions(prepared),
|
|
4583
4618
|
...gitRestateOption(prepared),
|
|
4584
|
-
...this.compactionHookOptions(spec, prepared.sessionId, "auto"),
|
|
4619
|
+
...this.compactionHookOptions(spec, prepared.sessionId, "auto", prepared.hookIdentity),
|
|
4585
4620
|
});
|
|
4586
4621
|
this.recordCompactionReuse(prepared, finishComp);
|
|
4587
4622
|
if (finishComp.unevaluableWindow) {
|
|
@@ -39,8 +39,8 @@ function compilePatterns(patterns) {
|
|
|
39
39
|
}
|
|
40
40
|
return out;
|
|
41
41
|
}
|
|
42
|
-
function matchSensitive(canonicalKey, compiled) {
|
|
43
|
-
return matchSegmentPatterns(canonicalKey, compiled);
|
|
42
|
+
function matchSensitive(canonicalKey, compiled, aliasResolved) {
|
|
43
|
+
return matchSegmentPatterns(canonicalKey, compiled, { aliasResolved });
|
|
44
44
|
}
|
|
45
45
|
export function createSensitivePathPolicy(opts) {
|
|
46
46
|
const compiled = compilePatterns(opts.patterns);
|
|
@@ -64,7 +64,7 @@ export function createSensitivePathPolicy(opts) {
|
|
|
64
64
|
}
|
|
65
65
|
return { action: "allow" };
|
|
66
66
|
}
|
|
67
|
-
const hit = matchSensitive(canon.key, compiled);
|
|
67
|
+
const hit = matchSensitive(canon.key, compiled, canon.aliasResolved === true);
|
|
68
68
|
if (hit) {
|
|
69
69
|
return {
|
|
70
70
|
action: "deny",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type MailboxStore } from "../mailbox-store.js";
|
|
2
2
|
import { type ContractAssertionRunner } from "./contract-harness.js";
|
|
3
3
|
/**
|
|
4
4
|
* design/159 S1 — the cross-backend {@link MailboxStore} contract, extracted VERBATIM from
|
|
@@ -46,3 +46,31 @@ export declare function mailboxAckOwnershipContract(mk: () => MailboxStore, runA
|
|
|
46
46
|
* 放宽」:核心层没有一条为了任何后端而弱化。第三方后端若有租户轴,应当也挂这一层。
|
|
47
47
|
*/
|
|
48
48
|
export declare function mailboxBundledOnlyContract(mk: () => MailboxStore, runAssertion?: ContractAssertionRunner): Promise<void>;
|
|
49
|
+
export interface MailboxTombstonedRecipientContractHooks {
|
|
50
|
+
/** Put `(scope, handle)` into the deployment's PRE-DELETE state — whatever that is for this
|
|
51
|
+
* backend (a cascade marking the session row, a `deleting` column, a tombstone table). Core does
|
|
52
|
+
* not name the mechanism, only what `append` must then do. */
|
|
53
|
+
tombstone: (store: MailboxStore, scope: string, handle: string) => Promise<void>;
|
|
54
|
+
runAssertion?: ContractAssertionRunner;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* T2 可选能力层(pre-delete 分册)—— 只挂**看得见收件人生命周期**的后端:它的 mailbox 行与某个
|
|
58
|
+
* 删除级联要清掉的 session 行同生共死(server 的留存删除级联是首个此形消费方)。三个捆绑后端都
|
|
59
|
+
* 没有这个视角,因此都不挂——**这不是「做不到所以放宽」**:没有预删除态的后端根本没有可拒的对象,
|
|
60
|
+
* 而有这个态却静默接受的后端,拿到的 seq 是一张级联马上就要撕掉的回执(`append` 是 durable-first,
|
|
61
|
+
* 回执即承诺),且没有任何其它面会说出这件事。
|
|
62
|
+
*
|
|
63
|
+
* 契约不规定态怎么置(那是部署自己的删除协议),只钉「拒」这件事的三条轴 —— **三条一起列在这里,
|
|
64
|
+
* 是因为逐条补的过程本身证明了单看一条会假绿**(每加一条,都有一个能过掉前面全部用例的错误实现被
|
|
65
|
+
* 抓出来):
|
|
66
|
+
* 1. **拒因轴**:必须拒,且每条路径上都带同一个码(空箱与非空箱都算路径);
|
|
67
|
+
* 2. **副作用轴**:被拒的 append 对箱零改动 —— 不入箱(幽灵投递)、不清箱(删除窗口内静默丢)、
|
|
68
|
+
* 也不动活租约(动了就撬开 X-1 的单消费者围栏,同一批消息会被二次投递);
|
|
69
|
+
* 3. **收件人边界轴**:态属于一个 `(scope, handle)` 整体,且这个整体是**单射**的 —— 兄弟 handle
|
|
70
|
+
* 不受牵连、跨租户同名不受牵连、拼键歧义不得把两个收件人折成一个。
|
|
71
|
+
* 新增用例请按这三条轴归位;要加第四条轴,先说清它是哪一类假绿。
|
|
72
|
+
*
|
|
73
|
+
* 受众前提与 {@link mailboxBundledOnlyContract} 同线:后端有自己的租户轴(生命周期可见的后端就是
|
|
74
|
+
* 服务端形态的 store),边界轴的两条用例要用两个 scope 构造。
|
|
75
|
+
*/
|
|
76
|
+
export declare function mailboxTombstonedRecipientContract(mk: () => MailboxStore, hooks: MailboxTombstonedRecipientContractHooks): Promise<void>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { strict as assert } from "node:assert";
|
|
2
|
+
import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../mailbox-store.js";
|
|
2
3
|
import { beginContract } from "./contract-harness.js";
|
|
3
4
|
export const MAILBOX_CONTRACT_SCOPE = "default";
|
|
4
5
|
const msg = (content, sentAt = 1000) => ({ content, sentAt });
|
|
@@ -191,3 +192,80 @@ export async function mailboxBundledOnlyContract(mk, runAssertion) {
|
|
|
191
192
|
});
|
|
192
193
|
await settle();
|
|
193
194
|
}
|
|
195
|
+
export async function mailboxTombstonedRecipientContract(mk, hooks) {
|
|
196
|
+
const { run: runRaw, settle } = beginContract(hooks.runAssertion);
|
|
197
|
+
const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
|
|
198
|
+
const S = MAILBOX_CONTRACT_SCOPE;
|
|
199
|
+
const codeOf = (e) => e?.code;
|
|
200
|
+
run("预删除态的收件人:append 响亮拒且带码(既不静默收下级联即将删掉的消息,也不裸抛)", async (make) => {
|
|
201
|
+
const s = make();
|
|
202
|
+
await hooks.tombstone(s, S, "a1");
|
|
203
|
+
let thrown;
|
|
204
|
+
try {
|
|
205
|
+
await s.append(S, "a1", msg("m1"));
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
thrown = e;
|
|
209
|
+
}
|
|
210
|
+
assert.notEqual(thrown, undefined, "静默收下 = 给发件人一张级联马上撕掉的回执");
|
|
211
|
+
assert.equal(codeOf(thrown), MAILBOX_TOMBSTONED_RECIPIENT_CODE, "裸抛与拒同形:消费方要能按码分诊");
|
|
212
|
+
let again;
|
|
213
|
+
try {
|
|
214
|
+
await s.append(S, "a1", msg("m2"));
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
again = e;
|
|
218
|
+
}
|
|
219
|
+
assert.equal(codeOf(again), MAILBOX_TOMBSTONED_RECIPIENT_CODE);
|
|
220
|
+
});
|
|
221
|
+
run("拒的粒度是收件人,不是开关:同 scope 下另一个活收件人照常收信", async (make) => {
|
|
222
|
+
const s = make();
|
|
223
|
+
await hooks.tombstone(s, S, "a1");
|
|
224
|
+
assert.equal(await s.append(S, "a2", msg("still-live")), 1, "活收件人不受邻居的删除级联牵连");
|
|
225
|
+
assert.equal(await s.peekCount(S, "a2"), 1);
|
|
226
|
+
});
|
|
227
|
+
run("收件人身份是 (scope, handle) 整体:A 租户的删除级联不得拒掉 B 租户的同名收件人", async (make) => {
|
|
228
|
+
const s = make();
|
|
229
|
+
await hooks.tombstone(s, "tenant-a", "a1");
|
|
230
|
+
assert.equal(await s.append("tenant-b", "a1", msg("other-tenant")), 1, "同名 handle 在另一个租户下是另一个收件人");
|
|
231
|
+
assert.equal(await s.peekCount("tenant-b", "a1"), 1);
|
|
232
|
+
});
|
|
233
|
+
run("两件套还得是单射的:把 (scope, handle) 拼成一个字符串记态,不得让两个不同收件人撞成一个", async (make) => {
|
|
234
|
+
const s = make();
|
|
235
|
+
await hooks.tombstone(s, "tenant a", "b");
|
|
236
|
+
assert.equal(await s.append("tenant", "a b", msg("distinct-recipient")), 1, "拼键歧义不得把另一个收件人一起拒掉");
|
|
237
|
+
assert.equal(await s.peekCount("tenant", "a b"), 1);
|
|
238
|
+
});
|
|
239
|
+
run("副作用轴 · 活租约:被拒的 append 不得动别人手上的租约(单消费者围栏不因删除窗口松开)", async (make) => {
|
|
240
|
+
const s = make();
|
|
241
|
+
await s.append(S, "a1", msg("in-flight"));
|
|
242
|
+
const held = await s.claimLease(S, "a1", "consumer-A", 60_000, 10_000);
|
|
243
|
+
assert.equal(held?.messages.length, 1);
|
|
244
|
+
await hooks.tombstone(s, S, "a1");
|
|
245
|
+
await assert.rejects(s.append(S, "a1", msg("after-tombstone", 2_000)), "预删除态下的 append 必须拒");
|
|
246
|
+
assert.equal(await s.claimLease(S, "a1", "rival", 60_000, 11_000), null, "A 的活租约必须仍然 fence 住别人");
|
|
247
|
+
await s.ack(S, "a1", "consumer-A", held.maxSeq);
|
|
248
|
+
assert.equal(await s.peekCount(S, "a1"), 0);
|
|
249
|
+
});
|
|
250
|
+
run("拒的是入口,不是清仓:被拒的 append 零副作用,已入箱的消息按原 seq/内容原封不动", async (make) => {
|
|
251
|
+
const s = make();
|
|
252
|
+
const seq = await s.append(S, "a1", msg("parked-before"));
|
|
253
|
+
await hooks.tombstone(s, S, "a1");
|
|
254
|
+
let thrown;
|
|
255
|
+
let refused = false;
|
|
256
|
+
try {
|
|
257
|
+
await s.append(S, "a1", msg("after-tombstone", 2_000));
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
refused = true;
|
|
261
|
+
thrown = e;
|
|
262
|
+
}
|
|
263
|
+
assert.equal(refused, true, "预删除态下的 append 必须拒");
|
|
264
|
+
assert.equal(codeOf(thrown), MAILBOX_TOMBSTONED_RECIPIENT_CODE, "箱非空时的拒同样要带码");
|
|
265
|
+
assert.equal(await s.peekCount(S, "a1"), 1, "被拒的 append 不得入箱(幽灵投递),也不得清箱");
|
|
266
|
+
const lease = await s.claimLease(S, "a1", "w1", 60_000, 10_000);
|
|
267
|
+
assert.deepEqual(lease?.messages.map((m) => m.seq), [seq]);
|
|
268
|
+
assert.deepEqual(lease?.messages.map((m) => m.content), ["parked-before"], "取到的仍是拒之前那一条");
|
|
269
|
+
});
|
|
270
|
+
await settle();
|
|
271
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/277 — the tool-registration MODEL GATE (CC 233 counterpart, first formalized there as a
|
|
3
|
+
* family+version-floor table over the todo/task-board tool family).
|
|
4
|
+
*
|
|
5
|
+
* WHAT THE GATE IS: a DEFAULT-MOUNT trim, never a capability ban. A {@link ToolSpec.modelGate}
|
|
6
|
+
* tag declares "this entry is a default-mounted scaffold of the named class"; when the task's
|
|
7
|
+
* RESOLVED model id matches the class's rule below, that entry is dropped from the roster at
|
|
8
|
+
* prepare (true unmount — the schema never reaches `tools[]`). An EXPLICITLY composed tool is
|
|
9
|
+
* never tagged (the bundle only stamps its default arms), so "the user asked for it" is the
|
|
10
|
+
* first restore channel by construction. Restore channels beyond that:
|
|
11
|
+
* `TaskSpec.restoreGatedTools` (per task-tree), env `SEMA_TOOL_MODEL_GATE=off` (process),
|
|
12
|
+
* `RunnerDeps.toolModelGate: false` (deployment).
|
|
13
|
+
*
|
|
14
|
+
* FAIL-OPEN, BY CONTRACT (not by accident): sema is BYOM — model ids are an OPEN set, and this
|
|
15
|
+
* table encodes POSITIVE knowledge ("this family at this version and above manages multi-step
|
|
16
|
+
* work without the scaffold"). An id the table knows nothing about (deepseek-* / gpt-* / qwen-* / any
|
|
17
|
+
* custom name) is NOT gated: no knowledge ⇒ no trim ⇒ tool stays. The matcher's three pass-through
|
|
18
|
+
* edges (family absent, id shape unmatched, version below floor) all point the same way, and it is
|
|
19
|
+
* the only polarity compatible with BYOM — the reverse would trim every non-claude deployment's
|
|
20
|
+
* default face. The engine does NOT guarantee a canonical id either: a deployment-authored
|
|
21
|
+
* `Model.id` (a raw Model object, or a catalog key shadowing the tier expansion) that is not in
|
|
22
|
+
* canonical `claude-<family>-<version>` shape simply falls off the regex and stays open — the
|
|
23
|
+
* explicit-id discipline's deployment-side duty. To gate a non-claude strong model, write the
|
|
24
|
+
* exact id into a {@link ToolModelGateRule.modelIds} row (the only non-claude channel; no prefix
|
|
25
|
+
* or wildcard syntax exists).
|
|
26
|
+
*
|
|
27
|
+
* DIVERGENCE (registered, deliberate): CC judges the MAIN-LOOP canonical model once per process;
|
|
28
|
+
* sema judges the PER-TASK resolved model on every prepare — BYOM per-task models are first-class,
|
|
29
|
+
* so a delegated child on a different model re-judges under its own (strong parent / weak child ⇒
|
|
30
|
+
* the child gets the scaffold back). And the env valve's polarity is reversed (CC: enable the
|
|
31
|
+
* tools; sema: disable the GATE) because sema's class vocabulary is open — a per-family enable
|
|
32
|
+
* env would have to grow with every class.
|
|
33
|
+
*/
|
|
34
|
+
import type { ToolSpec } from "./types.js";
|
|
35
|
+
/** The shared tail extractor (BYOM ids may carry provider prefixes — "openrouter/anthropic/claude-fable-5"):
|
|
36
|
+
* boundary-aware LAST path segment, case-folded. Single source for every model-family/model-id
|
|
37
|
+
* comparison site (`isFableFamilyModelId` consumes it too — the prompt-shape axis, semantically
|
|
38
|
+
* independent but sharing the one tail-extraction posture). */
|
|
39
|
+
export declare function modelIdTail(id: string): string;
|
|
40
|
+
/** One gate class's matching rule (both axes optional; a rule with BOTH axes empty gates nothing —
|
|
41
|
+
* the per-class OFF shape a deployment reaches by explicitly clearing the axes). */
|
|
42
|
+
export interface ToolModelGateRule {
|
|
43
|
+
/** claude-syntax family+floor rows — meaningful only for ids whose TAIL SEGMENT matches
|
|
44
|
+
* `claude-<letters>-<digits(-digits)*>`. Version tuples compare positionally, missing positions
|
|
45
|
+
* read 0, tuple ≥ floor ⇒ gated (CC 233 comparator, verbatim semantics). Any other id shape
|
|
46
|
+
* falls through this axis entirely (fail-open). */
|
|
47
|
+
floors?: ReadonlyArray<readonly [family: string, floor: ReadonlyArray<number>]>;
|
|
48
|
+
/** Exact-id rows (explicit-id discipline): the resolved `Model.id`'s tail segment must equal the
|
|
49
|
+
* row case-insensitively. The ONLY channel by which a BYOM deployment gates its own non-claude
|
|
50
|
+
* strong model — deliberately no prefix/wildcard syntax (a substring guess against an open id
|
|
51
|
+
* set is how the `deepseek-chat` alias downgrade class of accident happens). */
|
|
52
|
+
modelIds?: ReadonlyArray<string>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The built-in gate table: gate-class → rule. CC 233 z_S floors, verbatim (evidence D2; the
|
|
56
|
+
* `mythos` row rides per the anchor). v1 vocabulary = ONE class, `"task-scaffold"` — the
|
|
57
|
+
* TodoWrite/TaskCreate/TaskGet/TaskUpdate/TaskList default bundle (`assembleCodeTools`). One class
|
|
58
|
+
* is evidence discipline, not mechanism limit: a family enters this table only with positive
|
|
59
|
+
* "strong models don't need it" evidence (a CC anchor or deployment measurement); classes are an
|
|
60
|
+
* open vocabulary and a deployment adds its own via `RunnerDeps.toolModelGate.classes`.
|
|
61
|
+
*/
|
|
62
|
+
export declare const TOOL_MODEL_GATE_CLASSES: Readonly<Record<string, ToolModelGateRule>>;
|
|
63
|
+
/**
|
|
64
|
+
* Pure matcher: is `modelId` gated under `rule`? Every unmatched shape returns `false` (the
|
|
65
|
+
* fail-open contract in the module header). Exported for deployment pre-flight ("would my model
|
|
66
|
+
* lose the scaffold?") — the engine's own decision point calls this same function.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isModelGatedForClass(modelId: string, rule: ToolModelGateRule): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* #123 value screen for `TaskSpec.restoreGatedTools` (same posture as `assertReadFaceValue`: the
|
|
71
|
+
* VALUE is screened at the door, unconditionally — a garbage restore list must refuse loudly on
|
|
72
|
+
* every leg, never be silently read as "no restore" in the narrowing direction). Legal: absent,
|
|
73
|
+
* literal `true` (restore every class), or an array of strings (wire names; unknown names are
|
|
74
|
+
* inert by contract, like `excludeTools`).
|
|
75
|
+
*/
|
|
76
|
+
export declare function assertRestoreGatedToolsValue(value: unknown): asserts value is true | readonly string[] | undefined;
|
|
77
|
+
/** Doors-facing decision (built + applied inside `prepareConfigDoors`' synchronous stretch —
|
|
78
|
+
* decision and application share one atomic window, no drift gap). */
|
|
79
|
+
export interface ToolModelGateDecision {
|
|
80
|
+
/** Present ⇔ at least one entry was removed: the fresh, PRIVATE survivor array `spec.tools` is
|
|
81
|
+
* rebound to (a caller mutating its original live array afterwards can neither re-add a removed
|
|
82
|
+
* entry nor displace anything — there are no indices left to misalign). `undefined` = no
|
|
83
|
+
* removal, the spec is left untouched. */
|
|
84
|
+
survivors: ToolSpec[] | undefined;
|
|
85
|
+
/** gate-class → removed wire names (sorted, unique) — the removal-notice payload. */
|
|
86
|
+
removedByClass: ReadonlyMap<string, readonly string[]>;
|
|
87
|
+
/** Stamped classes with no row in the merged table — announce-once material (the loud half of
|
|
88
|
+
* fail-open: a tag typo must not silently become "never gated" with nobody told). */
|
|
89
|
+
unknownClasses: readonly string[];
|
|
90
|
+
/** An env value outside the closed set that was NOT in force (nothing this prepare would gate) —
|
|
91
|
+
* discarded-value announce material. In-force garbage never lands here: it throws. */
|
|
92
|
+
discardedEnvRaw: string | undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The single decision point, called from `prepareConfigDoors` with the model id read ONCE into the
|
|
96
|
+
* decision (the Model object is mutable; decision and later reads must not diverge) and the frozen
|
|
97
|
+
* restore snapshot (never the live spec).
|
|
98
|
+
*
|
|
99
|
+
* Channel semantics (a UNION of loosening channels — any hit ⇒ don't trim; the order below is
|
|
100
|
+
* evaluation order, not priority):
|
|
101
|
+
* - `depsSeat === false` — deployment kill switch; nothing is scanned.
|
|
102
|
+
* - env `SEMA_TOOL_MODEL_GATE` `off|0|false` (case-folded) — process kill switch; `on|1|true` is
|
|
103
|
+
* the explicit default (no-op). A value outside the closed set follows the loud-bad-value
|
|
104
|
+
* dialect split (#123, the SEMA_TOOL_MATERIALIZE_STRATEGY precedent): where the value is IN
|
|
105
|
+
* FORCE — its reading would change this prepare's outcome, i.e. after folding the other
|
|
106
|
+
* channels there remains a stamped entry the gate would remove — the prepare REFUSES
|
|
107
|
+
* (`config.tool_model_gate_env_invalid`; a mistyped `off` must not silently select the
|
|
108
|
+
* narrowing arm). Everywhere else it is a DISCARDED value: announced once per process, never a
|
|
109
|
+
* veto of the configuration that outranks it.
|
|
110
|
+
* - `restoreGated === true` — every class exempt for this task; an array exempts the WHOLE class
|
|
111
|
+
* of any stamped tool it names (CC "opting into any tool of the family restores the family",
|
|
112
|
+
* judged against the FULL stamp set — a name `excludeTools` will later remove still selects its
|
|
113
|
+
* class here, while the exclusion itself stands downstream; unknown names inert).
|
|
114
|
+
* - a stamped class with no merged row ⇒ kept + reported in `unknownClasses` (fail-open, loud).
|
|
115
|
+
* - a merged rule with both axes empty ⇒ kept, silent (the documented per-class OFF).
|
|
116
|
+
* - the exclusion valve (`excludeTools`, applied downstream at the roster splice) ALWAYS wins in
|
|
117
|
+
* the result: no restore channel resurrects an excluded name.
|
|
118
|
+
*/
|
|
119
|
+
export declare function applyToolModelGate(input: {
|
|
120
|
+
tools: ReadonlyArray<ToolSpec> | undefined;
|
|
121
|
+
modelId: string;
|
|
122
|
+
depsSeat: unknown;
|
|
123
|
+
restoreGated: true | readonly string[] | undefined;
|
|
124
|
+
envRaw: string | undefined;
|
|
125
|
+
}): ToolModelGateDecision;
|