@sema-agent/core 5.25.0 → 5.27.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 +115 -0
- package/dist/agents/agent-definition.js +5 -0
- package/dist/agents/agent-transcript-tool.d.ts +5 -2
- package/dist/agents/agent-transcript-tool.js +2 -1
- package/dist/agents/send-message-tool.d.ts +4 -1
- package/dist/agents/send-message-tool.js +1 -0
- package/dist/agents/subagent.d.ts +6 -2
- package/dist/agents/subagent.js +5 -0
- package/dist/core/checkpoint-store.d.ts +7 -2
- package/dist/core/hooks.d.ts +39 -4
- package/dist/core/hooks.js +18 -14
- package/dist/core/memory-engine/dual-root.js +3 -1
- package/dist/core/memory-engine/engine.d.ts +53 -6
- package/dist/core/memory-engine/engine.js +43 -11
- package/dist/core/memory-engine/file-backend.d.ts +81 -0
- package/dist/core/memory-engine/file-backend.js +250 -24
- package/dist/core/memory-engine/index.d.ts +1 -1
- package/dist/core/memory-engine/index.js +1 -1
- package/dist/core/memory-engine/types.d.ts +8 -1
- package/dist/core/memory-vector.d.ts +6 -1
- package/dist/core/memory-vector.js +14 -4
- package/dist/core/memory.js +1 -6
- package/dist/core/permission-rule-consent.js +8 -1
- package/dist/core/permission-rule-model.d.ts +70 -5
- package/dist/core/permission-rule-model.js +58 -0
- package/dist/core/runner/compaction-call-options.d.ts +4 -4
- package/dist/core/runner/compaction-call-options.js +3 -4
- package/dist/core/runner/prepare-memory.d.ts +34 -15
- package/dist/core/runner/prepare-memory.js +99 -26
- package/dist/core/runner/prepare-task.d.ts +9 -3
- package/dist/core/runner/prepare-task.js +60 -15
- package/dist/core/runner/runtask.d.ts +8 -1
- package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
- package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
- package/dist/core/task-registry-agent.d.ts +4 -3
- package/dist/core/task-registry-agent.js +3 -3
- package/dist/core/task-registry-monitor.js +6 -5
- package/dist/core/task-registry.d.ts +6 -3
- package/dist/core/tool-policy.d.ts +9 -2
- package/dist/core/tool-result-budget.d.ts +1 -1
- package/dist/core/tool-result-budget.js +3 -3
- package/dist/core/tool-result-store.d.ts +164 -9
- package/dist/core/tool-result-store.js +82 -23
- package/dist/core/types.d.ts +103 -7
- package/dist/core/untrusted-text.d.ts +6 -2
- package/dist/core/untrusted-text.js +1 -1
- package/dist/engine/loop/types.d.ts +10 -3
- package/dist/engine/session/import-validate.js +2 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/orchestration/run-workflow-tool.d.ts +5 -3
- package/dist/orchestration/workflow.d.ts +9 -6
- package/dist/orchestration/workflow.js +2 -0
- package/dist/prompts/default.d.ts +11 -0
- package/dist/prompts/default.js +3 -0
- package/dist/stores/file/checkpoint-store.d.ts +2 -1
- package/dist/stores/file/fs-atomic.d.ts +1 -1
- package/dist/stores/file/index.d.ts +1 -1
- package/dist/stores/file/tool-result-store.d.ts +45 -9
- package/dist/stores/file/tool-result-store.js +76 -9
- package/dist/tools/fs/fs-shared.js +5 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,120 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.27.0 — 2026-08-11
|
|
4
|
+
|
|
5
|
+
No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
|
|
6
|
+
|
|
7
|
+
### Narrowed (behavior, ruled)
|
|
8
|
+
|
|
9
|
+
- **A declared-unavailable session reads the committed memory view.** Under
|
|
10
|
+
`memoryPersistenceCapable: false`, materialize/search/harvest serve the committed account
|
|
11
|
+
(ledger + shadow): disk divergence with no transaction backing is neither adopted nor served —
|
|
12
|
+
it stays on disk, is loudly reported (`restricted_divergence`, a new `HarvestRejectionCode`
|
|
13
|
+
member — consumers pinning that enum as a closed set must add the row), and a later unrestricted
|
|
14
|
+
session adopts it through the normal gates. Legitimate flows are untouched: user hand-edits and
|
|
15
|
+
git-pull drops keep working (deferred, not destroyed), a concurrent writable session's committed
|
|
16
|
+
changes pass as transaction-backed, and **a writeScope-null plane (org layers default read-only;
|
|
17
|
+
the dual-root non-write plane) keeps its ordinary adopt-on-read semantics** — the restriction is
|
|
18
|
+
the SESSION's declaration, never the plane's structure.
|
|
19
|
+
- **Rule suggestions appear only where a persisted rule could actually work.** Mandated
|
|
20
|
+
(`shellGate:"always"` / tool marks / org / requiresRealApproval), shadowed, hook-originated,
|
|
21
|
+
inherited-unresolved, ancestor-resolved (a live parent approver), and anonymous-task (no
|
|
22
|
+
principal, no local-owner declaration) asks no longer carry `ruleSuggestions` — a card whose rule
|
|
23
|
+
could never silence it stops offering to mint one. Classifier-tier and ordinary policy asks keep
|
|
24
|
+
their suggestions.
|
|
25
|
+
- **Known residual (registered)**: bytes refused by a restricted session remain on disk and are
|
|
26
|
+
adoptable by a later unrestricted session — the alternative (destroying bytes indistinguishable
|
|
27
|
+
from user edits) is ruled out; the boundary is "the restricted session is never polluted and
|
|
28
|
+
tampering is always visible", not "the bytes can never enter".
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **Prefix rule candidates**: the suggestion engine mints a reviewed PREFIX option beside the exact
|
|
33
|
+
one for lexicon-listed command bodies (`git push origin main` → optional `Bash(git push:*)`),
|
|
34
|
+
longest word-boundary hit, no fallback; candidate arrays grow from ≤1 to ≤2 with `exact` fixed at
|
|
35
|
+
index 0 (a documented contract across `AskRequest.ruleSuggestions`, checkpoint park rows, and the
|
|
36
|
+
approval card record). Consumers reading `[0]` unconditionally only miss the new option.
|
|
37
|
+
- **CJK lexical retrieval**: `termSet` now mints CJK character bigrams and unigrams beside
|
|
38
|
+
alphanumeric runs — a pure-Chinese memory entry was previously INVISIBLE to the File backend's
|
|
39
|
+
lexical rung (empty term set); the class is written in explicit escapes (a literal compat
|
|
40
|
+
ideograph NFC-folds to its unified twin, silently widening the range).
|
|
41
|
+
- **`toolResultContentSegment` exported** from the package root (the digest segment the engine's
|
|
42
|
+
own mints carry — consumers derive it instead of inventing a placeholder).
|
|
43
|
+
- `MemoryEngine.materialize` accepts an optional `adoptionRestricted` verdict;
|
|
44
|
+
`MemorySessionHandle.adoptionRestricted` reports it (both additive).
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Three loaded-suite timing margins widened without losing discrimination; the defectscan
|
|
49
|
+
guardrail audit skips a concurrently-vanished temp dir instead of throwing; 24 stale doc/JSDoc
|
|
50
|
+
claims restated to actual behavior (bidirectional sweep + rescan trio).
|
|
51
|
+
|
|
52
|
+
## 5.26.0 — 2026-08-11
|
|
53
|
+
|
|
54
|
+
No API-BREAKING changes (exports grow only; new members optional/additive). Several
|
|
55
|
+
behavior-surface narrowings — every one tighten-direction — called out below.
|
|
56
|
+
|
|
57
|
+
### Narrowed (behavior, ruled)
|
|
58
|
+
|
|
59
|
+
- **A remote execution env no longer teaches the memory write path.** With `executionEnv` remote
|
|
60
|
+
and a memory store mounted, the `# Memory` write instruction is withheld (the sandboxed hands
|
|
61
|
+
cannot reach the host store; teaching the path walked the model into receipting saves that never
|
|
62
|
+
land). A deployment persisting through its own closure restores it by declaring
|
|
63
|
+
`TaskSpec.memoryPersistenceCapable: true`. **Consumer note**: probes pinning "remote run carries
|
|
64
|
+
the write instruction" go red; re-pin on the declaration.
|
|
65
|
+
- **`memoryPersistenceCapable: false` is enforced, not just disclosed.** Over a writable scope the
|
|
66
|
+
session mounts the read-only notice AND the write gate refuses file-tool writes into the memory
|
|
67
|
+
root (`read_only_layering`, loud), and the terminal/checkpoint harvest admits nothing — through
|
|
68
|
+
the engine's zero-admission arm, so the report half still runs: materialize-time inbound findings
|
|
69
|
+
and quarantine escalations are drained into a real `HarvestReport` (with a warning naming the
|
|
70
|
+
declaration) instead of a fabricated empty one.
|
|
71
|
+
- **The read-only notice retraction is DECLARED-only.** Only an explicit
|
|
72
|
+
`memoryPersistenceCapable: true` retracts the engine's writeScope-null notice; a write-capable
|
|
73
|
+
roster alone (inferred capability) no longer strips it — the gate refuses those very writes, so
|
|
74
|
+
inference proved nothing about the store.
|
|
75
|
+
- **Dual-root overlap is a prepare-time configuration error.** Two memory planes whose data roots
|
|
76
|
+
or control dirs overlap (either direction, either tier) are refused loudly at prepare instead of
|
|
77
|
+
running two engines over shared physical state.
|
|
78
|
+
- **A shell-doctrine mark is positive-only and monotonic.** A negative `irreversibility` tier is
|
|
79
|
+
not a provenance mark; `shellGate:"classify"` never downgrades an explicitly marked `"always"`.
|
|
80
|
+
Deployments with marked tools will see asks that classify used to swallow.
|
|
81
|
+
- **Tool-result refs are injective and conflict-typed (#119).** The mint hashes an injective
|
|
82
|
+
representation (four `~`-separated segments incl. a content digest); writing a different payload
|
|
83
|
+
under an existing ref is a typed `ref_conflict` error, never a silent overwrite; a damaged owner
|
|
84
|
+
record refuses whole.
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
|
|
88
|
+
- **`TaskSpec.memoryPersistenceCapable`** (tri-state, additive): `true` = the deployment vouches
|
|
89
|
+
for a persistence channel the engine cannot see; `false` = mandatory floor (disclosure + write
|
|
90
|
+
gate + zero-admission harvest); absent = inferred from the final roster. Crosses the delegation
|
|
91
|
+
boundary tighten-only (a parent's `false` binds the subtree; a child definition cannot loosen
|
|
92
|
+
it). Non-boolean values are refused loudly at both doors (`config.memory_persistence_invalid` /
|
|
93
|
+
`config.agent.invalid`).
|
|
94
|
+
- **`MEMORY_READONLY_NOTICE` / `NO_PERSISTENT_MEMORY_NOTICE` exported**, and
|
|
95
|
+
**`MemoryInjection.readOnlyNotice`** (additive member): a session with no write channel is told
|
|
96
|
+
so instead of silently receipting saves; the standalone notice serves rosters the engine never
|
|
97
|
+
sees.
|
|
98
|
+
- **`MemoryEngine.harvest` accepts `admitNothing`** (additive option): a real harvest that commits
|
|
99
|
+
nothing while still draining and announcing inbound findings.
|
|
100
|
+
|
|
101
|
+
### Fixed
|
|
102
|
+
|
|
103
|
+
- Delegation seats forward the capability floor (subagent arbitration, retained-resume folding,
|
|
104
|
+
workflow agent-type folding fill absence only).
|
|
105
|
+
- The offloaded-detail notice allowance derives from the ref-mint ceiling (a literal sized against
|
|
106
|
+
the retired two-segment shape under-bounded the four-segment notice).
|
|
107
|
+
- Ten stale-or-loose doc/comment spots from the merged-code rescan (classify doctrine conditionals
|
|
108
|
+
stated in public docs; JSDoc reattachments; TiDB sizing note states the four-segment mint).
|
|
109
|
+
|
|
110
|
+
### Known residual (registered, not a regression)
|
|
111
|
+
|
|
112
|
+
- The zero-copy File backend's read-side inbound sync adopts disk divergence independent of session
|
|
113
|
+
intent: bytes a shell lands under the memory root during a read-only or declared-false session
|
|
114
|
+
can be adopted at a later materialize (checkpoint-resume included). Registered with three
|
|
115
|
+
candidate fixes pending a direction ruling; the disclosure, gate, and harvest boundaries above
|
|
116
|
+
all hold — this is the remaining channel, stated here so the boundary's edges are explicit.
|
|
117
|
+
|
|
3
118
|
## 5.25.0 — 2026-08-10
|
|
4
119
|
|
|
5
120
|
No API-BREAKING changes (exports grow only; every new member is optional/additive). One
|
|
@@ -4,5 +4,10 @@ export function defineAgent(def) {
|
|
|
4
4
|
e.code = "config.agent.invalid";
|
|
5
5
|
throw e;
|
|
6
6
|
}
|
|
7
|
+
if (def.memoryPersistenceCapable !== undefined && typeof def.memoryPersistenceCapable !== "boolean") {
|
|
8
|
+
const e = new Error(`defineAgent("${def.name}"): memoryPersistenceCapable must be a boolean when present — got ${JSON.stringify(def.memoryPersistenceCapable)} (a string "false" would silently read as capable).`);
|
|
9
|
+
e.code = "config.agent.invalid";
|
|
10
|
+
throw e;
|
|
11
|
+
}
|
|
7
12
|
return Object.freeze({ ...def });
|
|
8
13
|
}
|
|
@@ -37,8 +37,11 @@ export interface AgentTranscriptToolOptions {
|
|
|
37
37
|
* into a child's tail when the residual isn't enough.
|
|
38
38
|
*
|
|
39
39
|
* Safety: reads are bounded to the parent's OWN lineage — the same non-leaking registry access scope as
|
|
40
|
-
* SendMessage/TaskOutput (an unknown or out-of-scope id reads identically to "not found").
|
|
41
|
-
*
|
|
40
|
+
* SendMessage/TaskOutput (an unknown or out-of-scope id reads identically to "not found"). Two read
|
|
41
|
+
* paths keep a child's session reachable: the retain ledger (a RETAINED child), and the durable
|
|
42
|
+
* agent-store fallback (`agentStore` wired + accessible row + preserved `sessionId` — readable even
|
|
43
|
+
* with retain off/expired/evicted). Only when BOTH miss does the call return the honest
|
|
44
|
+
* "not retained" / "not found".
|
|
42
45
|
*/
|
|
43
46
|
export declare function createAgentTranscriptTool(opts: AgentTranscriptToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{
|
|
44
47
|
id: Type.TString;
|
|
@@ -16,7 +16,8 @@ export function createAgentTranscriptTool(opts) {
|
|
|
16
16
|
description: `Read the recent tool steps of a background agent you launched (task_id 'a…' from the Agent tool with ` +
|
|
17
17
|
`run_in_background), so you can see precisely where it got to — useful before SendMessage-ing a follow-up, ` +
|
|
18
18
|
`or to inspect a stopped agent's tail. Returns the last N steps (tool, target, outcome); it does not re-run ` +
|
|
19
|
-
`the agent.
|
|
19
|
+
`the agent. Works for agents this run retained (retainSubagentSessions) and for agents whose durable ` +
|
|
20
|
+
`record preserved their transcript session; when neither holds you get an honest error.`,
|
|
20
21
|
parameters: Type.Object({
|
|
21
22
|
id: Type.String({ description: "The background agent's task_id (a…) returned by the Agent tool." }),
|
|
22
23
|
lastN: Type.Optional(Type.Integer({ minimum: 1, maximum: AGENT_TRANSCRIPT_MAX_N, description: `How many recent steps to return (default ${AGENT_TRANSCRIPT_DEFAULT_N}).` })),
|
|
@@ -19,7 +19,10 @@ export interface SendMessageToolOptions {
|
|
|
19
19
|
owner?: string;
|
|
20
20
|
scope?: string;
|
|
21
21
|
sessionId?: string;
|
|
22
|
-
/**
|
|
22
|
+
/** Completion-notify sink for the resumed run. The Runner mount wires the SENDING run's own
|
|
23
|
+
* notification injector here (its runtask-wrapped `injectTaskNotification`), so the completion
|
|
24
|
+
* notice lands in the sender's live injection queue at a turn boundary; a direct mount may wire a
|
|
25
|
+
* deployment-level background-notify sink instead. Absent ⇒ completion is silent. */
|
|
23
26
|
notify?: (n: TaskNotificationPayload, opts?: {
|
|
24
27
|
priority?: "now" | "next" | "later";
|
|
25
28
|
}) => void;
|
|
@@ -682,6 +682,7 @@ export function createSendMessageTool(opts) {
|
|
|
682
682
|
...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
|
|
683
683
|
...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
|
|
684
684
|
...(ctx.oneShot === true ? { oneShot: true } : {}),
|
|
685
|
+
...(ctx.memoryPersistenceCapable === false ? { memoryPersistenceCapable: false } : {}),
|
|
685
686
|
},
|
|
686
687
|
...(ctx.autoModeReview !== undefined ? { currentAutoModeReview: ctx.autoModeReview } : {}),
|
|
687
688
|
});
|
|
@@ -361,8 +361,11 @@ export interface SubagentSteerHandle {
|
|
|
361
361
|
* before any await; rolled back on pre-launch failure) → session exists AND non-empty (r1-m1).
|
|
362
362
|
* Then: fenced revive prompt (unpredictable marker) →
|
|
363
363
|
* `runTaskStream` on the FROZEN spawn snapshot + `requireExistingSession` (spec is tighten-only: the caller
|
|
364
|
-
* contributes ONLY `content`) → ledger `activeRun` bookkeeping → completion
|
|
365
|
-
*
|
|
364
|
+
* contributes ONLY `content`) → ledger `activeRun` bookkeeping → completion announced through
|
|
365
|
+
* `deps.notify` (no sink ⇒ silent). Which lane
|
|
366
|
+
* that is depends on the mount: the background Agent lane wires the deployment-level background-notify
|
|
367
|
+
* sink, while the SendMessage mount wires the SENDING run's own notification injector — there the
|
|
368
|
+
* completion notice lands in the sender's live injection queue at its next turn boundary.
|
|
366
369
|
*/
|
|
367
370
|
export declare function createSubagentResume(deps: {
|
|
368
371
|
ledger: SubagentRetainLedger | undefined;
|
|
@@ -400,6 +403,7 @@ export declare function createSubagentResume(deps: {
|
|
|
400
403
|
handsReadOnly?: true;
|
|
401
404
|
interactiveTools?: false;
|
|
402
405
|
oneShot?: true;
|
|
406
|
+
memoryPersistenceCapable?: false;
|
|
403
407
|
};
|
|
404
408
|
/** The RESUMING caller's own handback-review seat (its trusted `ToolExecuteContext.autoModeReview`),
|
|
405
409
|
* same turn-bound reasoning as `currentOnQuestion` above: a resumed cycle is a completion like any
|
package/dist/agents/subagent.js
CHANGED
|
@@ -522,6 +522,9 @@ export function createSubagentResume(deps) {
|
|
|
522
522
|
...(entry.specSnapshot.handsReadOnly === true || deps.currentClamps?.handsReadOnly === true ? { handsReadOnly: true } : {}),
|
|
523
523
|
...(entry.specSnapshot.interactiveTools === false || deps.currentClamps?.interactiveTools === false ? { interactiveTools: false } : {}),
|
|
524
524
|
...(entry.specSnapshot.oneShot === true || deps.currentClamps?.oneShot === true ? { oneShot: true } : {}),
|
|
525
|
+
...(entry.specSnapshot.memoryPersistenceCapable === false || deps.currentClamps?.memoryPersistenceCapable === false
|
|
526
|
+
? { memoryPersistenceCapable: false }
|
|
527
|
+
: {}),
|
|
525
528
|
signal: abort.signal,
|
|
526
529
|
};
|
|
527
530
|
if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
|
|
@@ -1668,6 +1671,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
1668
1671
|
};
|
|
1669
1672
|
const childThinking = def?.thinking ?? ctx.thinkingLevel;
|
|
1670
1673
|
const provenanceAgentName = agentName ?? def?.name;
|
|
1674
|
+
const childMemoryPersistenceCapable = ctx.memoryPersistenceCapable === false ? false : (def?.memoryPersistenceCapable ?? ctx.memoryPersistenceCapable);
|
|
1671
1675
|
const childOnAsk = ctx.onAsk !== undefined
|
|
1672
1676
|
? withDelegationProvenance(ctx.onAsk, {
|
|
1673
1677
|
parentToolCallId: ctx.toolCallId,
|
|
@@ -1689,6 +1693,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
1689
1693
|
...(ctx.onQuestion !== undefined ? { onQuestion: ctx.onQuestion } : {}),
|
|
1690
1694
|
...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
|
|
1691
1695
|
...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
|
|
1696
|
+
...(childMemoryPersistenceCapable !== undefined ? { memoryPersistenceCapable: childMemoryPersistenceCapable } : {}),
|
|
1692
1697
|
...(ctx.oneShot === true ? { oneShot: true } : {}),
|
|
1693
1698
|
...(ctx.clientContext !== undefined ? { clientContext: ctx.clientContext } : {}),
|
|
1694
1699
|
...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
|
|
@@ -574,8 +574,13 @@ export type PendingAction = {
|
|
|
574
574
|
/** design/179 §4 (additive) — the persistable allow-rule forms this parked call could be covered by,
|
|
575
575
|
* minted from the SAME post-hook args the row executes. Advisory display metadata for the approval
|
|
576
576
|
* inbox's "stop asking me this" option; never read by the resume path, and never a rule by itself
|
|
577
|
-
* (minting one goes through the approval-record protocol). Absent when no rule lane is armed
|
|
578
|
-
* command is one the lane cannot speak for
|
|
577
|
+
* (minting one goes through the approval-record protocol). Absent when no rule lane is armed, the
|
|
578
|
+
* command is one the lane cannot speak for, or the parked ask is one a persisted rule could not
|
|
579
|
+
* clear (mandated marks / a real-approval demand / a shadowed matching rule).
|
|
580
|
+
* CONTRACT (same as the synchronous `AskRequest.ruleSuggestions`) — array order is display order,
|
|
581
|
+
* narrowest first: the EXACT form is always index 0, a broader reviewed PREFIX form (at most one)
|
|
582
|
+
* follows; basis ≤ 2, and selection/ticket indices are keyed against this order. A row persisted
|
|
583
|
+
* before the prefix arm existed carries ≤ 1 entry and stays valid under the same reading. */
|
|
579
584
|
ruleSuggestions?: readonly import("./permission-rule-model.js").RuleSuggestion[];
|
|
580
585
|
/**
|
|
581
586
|
* design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
|
|
2
2
|
import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
|
|
3
|
-
import type { PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
|
|
3
|
+
import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
|
|
4
4
|
import { type AskClass } from "./ask-class.js";
|
|
5
5
|
/**
|
|
6
|
-
* In-process hook seam (design/37) — a
|
|
7
|
-
*
|
|
6
|
+
* In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
|
|
7
|
+
* kept process-internal (no shell/HTTP executors, no settings files). The original tool-call trio:
|
|
8
8
|
*
|
|
9
9
|
* - {@link Hooks.preToolUse} runs before a tool executes. It may **rewrite** the args (an `allow` with
|
|
10
10
|
* `updatedInput`), **restrict** the call (`deny`/`ask`), and/or inject `additionalContext`. It is a
|
|
@@ -15,6 +15,13 @@ import { type AskClass } from "./ask-class.js";
|
|
|
15
15
|
* - {@link Hooks.userPromptSubmit} runs before the objective becomes a user message. It may `block`
|
|
16
16
|
* submission (the task fails with a model-readable reason) or inject `additionalContext` ahead of it.
|
|
17
17
|
*
|
|
18
|
+
* The seam has since grown well past the trio — each later member carries its own contract below:
|
|
19
|
+
* {@link Hooks.stop} / {@link Hooks.stopFailure} (run-end pushback / API-error terminal observer),
|
|
20
|
+
* {@link Hooks.postToolUseFailure} and {@link Hooks.postToolBatch} (failure + batch-boundary taps),
|
|
21
|
+
* {@link Hooks.preCompact} / {@link Hooks.postCompact} (compaction gate + observer),
|
|
22
|
+
* {@link Hooks.permissionDenied} (deny observer), and the {@link Hooks.preToolUseObservational}
|
|
23
|
+
* declaration flag.
|
|
24
|
+
*
|
|
18
25
|
* All feedback the model should see (a block reason, injected context) is wrapped as a
|
|
19
26
|
* `<system-reminder>` via {@link formatHookFeedback} so the model can read and self-correct.
|
|
20
27
|
*/
|
|
@@ -611,7 +618,12 @@ export interface ToolGateInput {
|
|
|
611
618
|
realApproval?: import("./checkpoint-store.js").RealApprovalGateBit,
|
|
612
619
|
/** #144: the matched-but-outranked persisted rule (the surviving ask's `persistedRuleShadowed`)
|
|
613
620
|
* — threaded so the park mint's risk descriptor carries the disclosure on the durable route. */
|
|
614
|
-
shadowedRule?: string
|
|
621
|
+
shadowedRule?: string,
|
|
622
|
+
/** The surviving ask's `decisionReason` — threaded so the park mint's rule-suggestion door can
|
|
623
|
+
* read the ask's origin on the durable route exactly as the synchronous mints read it off the
|
|
624
|
+
* decision (a hook-raised ask is one no persisted rule may clear, so its parked card must not
|
|
625
|
+
* offer to mint one). */
|
|
626
|
+
askDecisionReason?: DecisionReason) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
|
|
615
627
|
/**
|
|
616
628
|
* design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
|
|
617
629
|
* before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
|
|
@@ -871,6 +883,29 @@ export interface ToolGateInput {
|
|
|
871
883
|
* reach the same lane from inside a descendant's fold.
|
|
872
884
|
*/
|
|
873
885
|
export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
|
|
886
|
+
/**
|
|
887
|
+
* The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
|
|
888
|
+
* the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
|
|
889
|
+
* classifier's questions, never a mandated one, and this predicate is the mandated-family half of
|
|
890
|
+
* that boundary (the real-approval/governance half rides the decision's own `requiresRealApproval`
|
|
891
|
+
* bit, which the org layer stamps):
|
|
892
|
+
* · `egress` — the tool's own external-write mark, judged FIRST: it is the tool's declaration even
|
|
893
|
+
* when the coarse doctrine also installed a shell tier on the same seat;
|
|
894
|
+
* · `shellGated` + tier `"always"` — the operator's per-call confirmation doctrine
|
|
895
|
+
* (`"operator_always"`); the classify doctrine installs `"maybe"`, and THOSE asks stay the rule
|
|
896
|
+
* lane's home turf (`undefined`);
|
|
897
|
+
* · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
|
|
898
|
+
*
|
|
899
|
+
* Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as shadowed
|
|
900
|
+
* instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
|
|
901
|
+
* "stop asking me this" option — a rule minted from it would never clear it). A drift between the
|
|
902
|
+
* two would let a card offer a rule the lane then refuses to honor.
|
|
903
|
+
*/
|
|
904
|
+
export declare function persistedRuleMandateOf(marks: {
|
|
905
|
+
egress?: boolean;
|
|
906
|
+
shellGated?: boolean;
|
|
907
|
+
irreversibility?: "never" | "maybe" | "always";
|
|
908
|
+
}): "operator_always" | "tool_marks" | undefined;
|
|
874
909
|
/**
|
|
875
910
|
* The design/37 **two-phase tool gate** — the single chokepoint that makes the load-bearing invariant
|
|
876
911
|
* structural ("a hook's `allow` cannot bypass the policy's `deny`/`ask`"):
|
package/dist/core/hooks.js
CHANGED
|
@@ -161,6 +161,17 @@ function withProbeTimeout(p, ms, signal) {
|
|
|
161
161
|
p.then((v) => done(resolve, v), (err) => done(reject, err));
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
|
+
export function persistedRuleMandateOf(marks) {
|
|
165
|
+
return marks.egress === true
|
|
166
|
+
? "tool_marks"
|
|
167
|
+
: marks.shellGated === true
|
|
168
|
+
? marks.irreversibility === "always"
|
|
169
|
+
? "operator_always"
|
|
170
|
+
: undefined
|
|
171
|
+
: marks.irreversibility === "always" || marks.irreversibility === "maybe"
|
|
172
|
+
? "tool_marks"
|
|
173
|
+
: undefined;
|
|
174
|
+
}
|
|
164
175
|
export async function runToolGate(input) {
|
|
165
176
|
const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
|
|
166
177
|
const { toolCallId, toolName } = event;
|
|
@@ -315,15 +326,7 @@ export async function runToolGate(input) {
|
|
|
315
326
|
currentInput = policyRewrite;
|
|
316
327
|
req.args = policyRewrite;
|
|
317
328
|
}
|
|
318
|
-
const persistedRuleMandate = input
|
|
319
|
-
? "tool_marks"
|
|
320
|
-
: input.shellGated === true
|
|
321
|
-
? input.irreversibility === "always"
|
|
322
|
-
? "operator_always"
|
|
323
|
-
: undefined
|
|
324
|
-
: input.irreversibility === "always" || input.irreversibility === "maybe"
|
|
325
|
-
? "tool_marks"
|
|
326
|
-
: undefined;
|
|
329
|
+
const persistedRuleMandate = persistedRuleMandateOf(input);
|
|
327
330
|
if (input.persistedRules &&
|
|
328
331
|
!orgRealApprovalRequired &&
|
|
329
332
|
decision.action === "ask" &&
|
|
@@ -342,11 +345,12 @@ export async function runToolGate(input) {
|
|
|
342
345
|
await notifier.notifyAsync(() => input.persistedRules?.onResolved?.({ toolName: req.toolName, toolCallId, rule: hit }), "toolGate.persistedRuleResolved");
|
|
343
346
|
}
|
|
344
347
|
else if (hit !== undefined) {
|
|
348
|
+
const shownRule = inlineUntrusted(hit, 200);
|
|
345
349
|
const mandateNoun = persistedRuleMandate === "operator_always" ? "this deployment mandates per-call confirmation for shell commands (shellGate: always)" : "this tool carries egress/irreversibility marks (a mandated confirmation a rule cannot clear)";
|
|
346
350
|
decision = {
|
|
347
351
|
...decision,
|
|
348
|
-
persistedRuleShadowed:
|
|
349
|
-
message: `${decision.message !== undefined ? `${decision.message} ` : ""}(a persisted allow rule (${
|
|
352
|
+
persistedRuleShadowed: shownRule,
|
|
353
|
+
message: `${decision.message !== undefined ? `${decision.message} ` : ""}(a persisted allow rule (${shownRule}) matches this call but does not clear the ask — ${mandateNoun})`,
|
|
350
354
|
};
|
|
351
355
|
}
|
|
352
356
|
}
|
|
@@ -407,7 +411,7 @@ export async function runToolGate(input) {
|
|
|
407
411
|
? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
|
|
408
412
|
: undefined;
|
|
409
413
|
if (suspendAsk && decision.action === "ask") {
|
|
410
|
-
const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
|
|
414
|
+
const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
|
|
411
415
|
if (suspended) {
|
|
412
416
|
if ("parkFailed" in suspended)
|
|
413
417
|
parkFailed = suspended.parkFailed;
|
|
@@ -426,7 +430,7 @@ export async function runToolGate(input) {
|
|
|
426
430
|
req.args = outcome.presentedInput;
|
|
427
431
|
}
|
|
428
432
|
if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
|
|
429
|
-
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined);
|
|
433
|
+
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined);
|
|
430
434
|
if (suspended) {
|
|
431
435
|
if ("parkFailed" in suspended)
|
|
432
436
|
parkFailed = suspended.parkFailed;
|
|
@@ -454,7 +458,7 @@ export async function runToolGate(input) {
|
|
|
454
458
|
const resolved = await resolveAsk(decision, req);
|
|
455
459
|
decision = resolved;
|
|
456
460
|
if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
|
|
457
|
-
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined);
|
|
461
|
+
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined);
|
|
458
462
|
if (suspended) {
|
|
459
463
|
if ("parkFailed" in suspended)
|
|
460
464
|
parkFailed = suspended.parkFailed;
|
|
@@ -31,13 +31,15 @@ export function mergeInjections(project, personal) {
|
|
|
31
31
|
if (!personal)
|
|
32
32
|
return project;
|
|
33
33
|
const instruction = project.instruction || personal.instruction;
|
|
34
|
+
const readOnlyNotice = instruction === "" && project.readOnlyNotice !== undefined && personal.readOnlyNotice !== undefined ? project.readOnlyNotice : undefined;
|
|
34
35
|
const indexParts = [project.index, personal.index].filter((s) => Boolean(s && s.trim()));
|
|
35
36
|
const announcements = [...(project.announcements ?? []), ...(personal.announcements ?? [])];
|
|
36
37
|
const announceParts = [project.announceBlock, personal.announceBlock].filter((s) => Boolean(s && s.trim()));
|
|
37
|
-
const blockParts = [instruction, ...indexParts, ...announceParts];
|
|
38
|
+
const blockParts = [instruction || readOnlyNotice, ...indexParts, ...announceParts];
|
|
38
39
|
const indexSeed = project.indexSeed ?? personal.indexSeed;
|
|
39
40
|
return {
|
|
40
41
|
instruction,
|
|
42
|
+
...(readOnlyNotice !== undefined ? { readOnlyNotice } : {}),
|
|
41
43
|
...(indexParts.length > 0 ? { index: indexParts.join("\n\n") } : {}),
|
|
42
44
|
...(announcements.length > 0 ? { announcements } : {}),
|
|
43
45
|
...(announceParts.length > 0 ? { announceBlock: announceParts.join("\n\n") } : {}),
|
|
@@ -40,6 +40,37 @@ export declare const MEMORY_RECALL_DISCIPLINE = "Before answering questions abou
|
|
|
40
40
|
* would forge the very account the three-tier discipline forbids forging).
|
|
41
41
|
*/
|
|
42
42
|
export declare const MEMORY_PREFERENCE_DISCIPLINE = "When the user confirms a stored preference or fact still holds, refresh that entry's `last-confirmed: <YYYY-MM-DD>` frontmatter line (add it when absent). When you save a preference, add an `applies-when: <context>` frontmatter line naming when it applies. Both are plain frontmatter lines \u2014 write them yourself; nothing fills them in for you.";
|
|
43
|
+
/**
|
|
44
|
+
* Corrections for announcement segments that carry store-mutation guidance ("record a fresh entry …
|
|
45
|
+
* tombstone the old one", minted in an earlier writable session) a session cannot act on. The queue
|
|
46
|
+
* items are opaque strings (rewriting them would be text surgery over minted-at-enqueue wording),
|
|
47
|
+
* so the correction is a trailing coda, not a rewrite — in two scopes, because the two mounting
|
|
48
|
+
* seats speak about different things (an unqualified "the memory store is not writable" beside
|
|
49
|
+
* another, WRITABLE plane's instruction would negate that plane's guidance):
|
|
50
|
+
* - PLANE scope, attached by `inject()` right after a read-only layering's own announcement block:
|
|
51
|
+
* explicitly local to the immediately preceding notices, so a mixed dual-root merge keeps the
|
|
52
|
+
* writable plane's instruction and announcements fully actionable.
|
|
53
|
+
* - SESSION scope, attached by the runner at the block tail when the whole session cannot persist:
|
|
54
|
+
* there is no writable instruction left standing there (it is replaced or absent), so the global
|
|
55
|
+
* wording is accurate. Name-free by construction (#181 class).
|
|
56
|
+
*/
|
|
57
|
+
export declare const MEMORY_ANNOUNCEMENT_READONLY_PLANE_CODA = "The notices immediately above concern a READ-ONLY memory store: any guidance in them to record, update, or tombstone an entry cannot be applied to that store this session \u2014 surface it to the user instead of claiming it done.";
|
|
58
|
+
export declare const MEMORY_ANNOUNCEMENT_READONLY_CODA = "The memory store itself is not writable this session, so any guidance above to record, update, or tombstone a memory entry cannot be applied here \u2014 surface it to the user instead of claiming it done.";
|
|
59
|
+
/**
|
|
60
|
+
* The read-only counterpart of the `# Memory` write instruction. A run with memory mounted but no
|
|
61
|
+
* way to write it used to get an EMPTY instruction — correct in what it doesn't teach, but silent
|
|
62
|
+
* about the state itself, and a model asked "remember X" under that silence answers with a success
|
|
63
|
+
* receipt for a save that never happens (the confabulated-receipt shape). This section states the
|
|
64
|
+
* state instead. Mounted only where "you cannot save" is provably TRUE: by `inject()` for a
|
|
65
|
+
* read-only layering (writeScope null ⇒ chmod'd tree), and by the runner for handsReadOnly (the
|
|
66
|
+
* shell rides the read-only band), for a declared-unavailable session (`memoryPersistenceCapable:
|
|
67
|
+
* false` — where the runner's write gate also refuses the file channel, keeping the engine-refusal
|
|
68
|
+
* sentence true), and for a write-less roster the persistence inference cannot vouch for. A merely
|
|
69
|
+
* Write-less roster does NOT qualify — other tools can still write the root. Name-free by
|
|
70
|
+
* construction (#181 class — it names no tool), and NOT part of the CC-verbatim capture: CC has no
|
|
71
|
+
* read-only memory layering, so there is nothing to capture; the section is sema-authored.
|
|
72
|
+
*/
|
|
73
|
+
export declare const MEMORY_READONLY_NOTICE = "# Memory\n\nYou have READ-ONLY access to persistent memory in this session: stored notes are available below, but this session has no memory write channel \u2014 the engine will not accept writes into the memory store. If the user asks you to remember something for later, say plainly that you cannot save it in this session \u2014 never claim to have noted or remembered it.";
|
|
43
74
|
/** CC index-injection parameters: MEMORY.md's first 200 lines / 25KB enter the prompt. */
|
|
44
75
|
export declare const MEMORY_INDEX_MAX_LINES = 200;
|
|
45
76
|
export declare const MEMORY_INDEX_MAX_BYTES: number;
|
|
@@ -105,6 +136,16 @@ export interface MemoryInjection {
|
|
|
105
136
|
/** CC `# Memory` section (system-authority instruction — §0.3 逐字 surface). Empty for a read-only
|
|
106
137
|
* layering (no write channel to instruct). */
|
|
107
138
|
instruction: string;
|
|
139
|
+
/** Present exactly when this is a read-only LAYERING (writeScope null — the tree is chmod'd
|
|
140
|
+
* read-only, so no tool writes into it whatever the roster): the {@link MEMORY_READONLY_NOTICE}
|
|
141
|
+
* section stating that memory cannot be saved to, so the model declines "remember X" instead of
|
|
142
|
+
* issuing a confabulated success receipt. NOT set for `writeToolMounted:false` over a writable
|
|
143
|
+
* scope — there only the instruction-named tool is absent, other roster tools can still write the
|
|
144
|
+
* root, and the claim would be false (the runner mounts the notice for its own provably
|
|
145
|
+
* write-less shape, handsReadOnly). Kept as its OWN member (not folded into `instruction`) so the
|
|
146
|
+
* dual-root merge's "the write plane's instruction wins" falsy-OR keeps working: a read-only
|
|
147
|
+
* plane must never outrank a write plane's instruction. */
|
|
148
|
+
readOnlyNotice?: string;
|
|
108
149
|
/** The derived MEMORY.md index, truncated (200 lines / 25KB) and FENCED untrusted. Undefined when empty. */
|
|
109
150
|
index?: string;
|
|
110
151
|
/** design/138 S2-B (时机①) — the announcements DRAINED by this inject (queued by the previous
|
|
@@ -280,7 +321,9 @@ export declare class MemoryEngine {
|
|
|
280
321
|
* eventId anchors on the pollution record (sessionId + marked-at), so however many times this
|
|
281
322
|
* settlement replays the same pollution event, the generation is allocated once. */
|
|
282
323
|
private settlePromotions;
|
|
283
|
-
materialize(scopes: readonly string[], writeScope: string | null
|
|
324
|
+
materialize(scopes: readonly string[], writeScope: string | null, opts?: {
|
|
325
|
+
adoptionRestricted?: boolean;
|
|
326
|
+
}): Promise<MemorySessionHandle>;
|
|
284
327
|
inject(handle: MemorySessionHandle, opts?: {
|
|
285
328
|
writeToolMounted?: boolean;
|
|
286
329
|
}): MemoryInjection;
|
|
@@ -301,7 +344,7 @@ export declare class MemoryEngine {
|
|
|
301
344
|
ok: true;
|
|
302
345
|
} | {
|
|
303
346
|
ok: false;
|
|
304
|
-
code: ScanFinding["code"];
|
|
347
|
+
code: ScanFinding["code"] | "read_only_layering";
|
|
305
348
|
reason: string;
|
|
306
349
|
muted: boolean;
|
|
307
350
|
};
|
|
@@ -315,16 +358,20 @@ export declare class MemoryEngine {
|
|
|
315
358
|
reason: string;
|
|
316
359
|
};
|
|
317
360
|
sessionId?: string;
|
|
361
|
+
admitNothing?: {
|
|
362
|
+
reason: string;
|
|
363
|
+
};
|
|
318
364
|
}): Promise<HarvestReport>;
|
|
319
365
|
private harvestCore;
|
|
320
366
|
/**
|
|
321
367
|
* @param keepBaseline RB-440 (2026-07-31, hardening probe P3) — relative paths whose baseline must NOT
|
|
322
368
|
* advance: the files a DEGRADED harvest deferred (`report.degraded.pending`).
|
|
323
369
|
*
|
|
324
|
-
* The deferral contract
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
370
|
+
* The deferral contract in the harvest scan (the budget/deadline arm — see `harvestDeadlineMs`)
|
|
371
|
+
* promises "the REST is deferred to the next harvest (still on disk), never dropped", but execution
|
|
372
|
+
* then continued into this scan, which re-baselined EVERY file carrying an id — the pending ones
|
|
373
|
+
* included — writing the CURRENT disk rev into `handle.baseRevs`. The next harvest hit the harvest
|
|
374
|
+
* scan's unchanged-baseline fast path and skipped the file FOREVER: the deferred edit never
|
|
328
375
|
* became a patch. Session-NEW files escaped it (no id ⇒ `continue` below), which is exactly why the
|
|
329
376
|
* existing budget test passed. Measured with a copy-out backend: harvest#1 {update:1} + pending ["b.md"],
|
|
330
377
|
* harvest#2 {update:0}, and the backend still held v1 — so the next `materialize` wrote the stale text
|