@sema-agent/core 5.27.0 → 5.28.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 +64 -0
- package/dist/core/hooks.d.ts +22 -0
- package/dist/core/hooks.js +22 -3
- package/dist/core/memory-engine/engine.js +2 -4
- package/dist/core/memory-engine/file-backend.d.ts +66 -7
- package/dist/core/memory-engine/file-backend.js +69 -27
- package/dist/core/memory-engine/layout.d.ts +31 -2
- package/dist/core/memory-engine/layout.js +132 -8
- package/dist/core/memory-engine/types.d.ts +6 -5
- package/dist/core/permission-rule-consent.d.ts +82 -8
- package/dist/core/permission-rule-consent.js +92 -1
- package/dist/core/permission-rule-model.d.ts +17 -1
- package/dist/core/permission-rule-model.js +21 -0
- package/dist/core/permission-rule-org.d.ts +22 -3
- package/dist/core/permission-rule-org.js +67 -20
- package/dist/core/permission-rule-store.js +2 -2
- package/dist/core/permission-rule-sync.d.ts +15 -1
- package/dist/core/permission-rule-sync.js +89 -47
- package/dist/core/runner/prepare-task.js +8 -3
- package/dist/core/runner/runtask.js +8 -1
- package/dist/core/task-registry-agent.d.ts +9 -0
- package/dist/core/task-registry-agent.js +51 -21
- package/dist/core/task-registry-monitor.js +1 -1
- package/dist/core/task-registry-shared.d.ts +9 -0
- package/dist/core/tool-policy.d.ts +35 -2
- package/dist/core/tool-policy.js +37 -3
- package/dist/core/tool-result-store.d.ts +108 -7
- package/dist/core/tool-result-store.js +95 -15
- package/dist/core/types.d.ts +80 -10
- package/dist/core/types.js +30 -1
- package/dist/index.d.ts +2 -2
- package/dist/stores/file/tool-result-store.d.ts +41 -1
- package/dist/stores/file/tool-result-store.js +107 -19
- package/dist/tools/fs/fs-bash.d.ts +7 -0
- package/dist/tools/fs/fs-shared.d.ts +5 -0
- package/dist/tools/fs/fs-shared.js +11 -7
- package/dist/tools/fs/index.d.ts +6 -0
- package/dist/tools/fs/index.js +2 -0
- package/package.json +1 -1
package/dist/core/types.d.ts
CHANGED
|
@@ -1734,6 +1734,11 @@ export interface TaskSpec {
|
|
|
1734
1734
|
* surfaced to the operator as a config-phase warning. Protocol tools (MCP/A2A) can never collide
|
|
1735
1735
|
* here: their wire names are namespaced (`mcp__<server>__…`) and a caller name containing `__`
|
|
1736
1736
|
* is rejected at prepare.
|
|
1737
|
+
*
|
|
1738
|
+
* Mount point: the roster (with `defer`/`excludeTools`/`deferTools`) lives on this PER-TASK spec —
|
|
1739
|
+
* the Runner constructor's deps carry no tool roster. Plain-JS callers beware: an unrecognized key
|
|
1740
|
+
* passed to the constructor is dropped by ordinary object semantics (TypeScript callers get an
|
|
1741
|
+
* excess-property error), so a roster placed there never mounts and its `defer` flags never apply.
|
|
1737
1742
|
*/
|
|
1738
1743
|
tools?: ToolSpec[];
|
|
1739
1744
|
/**
|
|
@@ -2750,9 +2755,11 @@ export interface TaskResult {
|
|
|
2750
2755
|
* How long (milliseconds) until the deployment usage window that stopped this task frees up — the wait
|
|
2751
2756
|
* hint a scheduler needs to decide WHEN to re-submit, rather than polling.
|
|
2752
2757
|
*
|
|
2753
|
-
* **In-presence condition:** set on exactly
|
|
2754
|
-
* (either moment: the entry refusal that ran nothing, or the running terminal that could not suspend)
|
|
2755
|
-
*
|
|
2758
|
+
* **In-presence condition:** set on exactly two terminals — `errorCode === "usage.window_exhausted"`
|
|
2759
|
+
* (either moment: the entry refusal that ran nothing, or the running terminal that could not suspend)
|
|
2760
|
+
* and `errorCode === "memory.admission_required"` (the prepare-throw path sets it there too — see
|
|
2761
|
+
* assemble-result's accepted-code pair). Absent everywhere else, INCLUDING when a usage window did
|
|
2762
|
+
* stop the run but a higher-ranked terminal
|
|
2756
2763
|
* named the result (a budget ceiling crossed on the same turn): the reported cause is then that other
|
|
2757
2764
|
* code, and a wait filed under it would describe something the code does not name.
|
|
2758
2765
|
*
|
|
@@ -3265,13 +3272,16 @@ export type TaskEvent = ({
|
|
|
3265
3272
|
*/
|
|
3266
3273
|
structured?: unknown;
|
|
3267
3274
|
/**
|
|
3268
|
-
* Present iff {@link isError} is true AND the harness result's `details` carried a string
|
|
3269
|
-
*
|
|
3270
|
-
*
|
|
3271
|
-
*
|
|
3272
|
-
*
|
|
3273
|
-
*
|
|
3274
|
-
*
|
|
3275
|
+
* Present iff {@link isError} is true AND the harness result's `details` carried a string
|
|
3276
|
+
* discriminator — read as `details.code` first, falling back to `details.errorKind` (each must be
|
|
3277
|
+
* a string; `code` wins when both are present, as the deliberate tool-chosen spelling). The
|
|
3278
|
+
* `errorKind` leg is what makes a LOOP-THROWN error's frame classifiable: the loop's thrown-error
|
|
3279
|
+
* fold and the resume legs write the discriminator under that name. Lifted so a consumer never
|
|
3280
|
+
* has to parse the (contract-stable) result text. Engine-minted vocabulary today includes
|
|
3281
|
+
* `"tool.not_found"` (unknown tool name) and `"gate.parked"` (an abort short-circuit poisoned
|
|
3282
|
+
* this call because a durable gate parked the batch — the "Operation aborted" family). Additive:
|
|
3283
|
+
* absent on error frames minted before this field existed, and on error results whose details
|
|
3284
|
+
* carry no string discriminator under either name.
|
|
3275
3285
|
*/
|
|
3276
3286
|
errorCode?: string;
|
|
3277
3287
|
/**
|
|
@@ -4161,6 +4171,51 @@ export interface ProjectMemoryLoad {
|
|
|
4161
4171
|
contentHash: string | null;
|
|
4162
4172
|
}>;
|
|
4163
4173
|
}
|
|
4174
|
+
/**
|
|
4175
|
+
* A structured operator-facing notice ({@link RunnerDeps.onNotice}) — a fact the engine announces that
|
|
4176
|
+
* is neither an error nor part of the task result: today, a configured value that was discarded in
|
|
4177
|
+
* favor of another (the loud-bad-value discipline's announcement dialect). Structured so a host can
|
|
4178
|
+
* FORWARD it to its own user surface instead of losing it in process stderr.
|
|
4179
|
+
*/
|
|
4180
|
+
export interface EngineNotice {
|
|
4181
|
+
/** Stable machine-readable family, dot-namespaced. Current families:
|
|
4182
|
+
* - `"config.env_timeout_discarded"` — a Bash timeout knob (option or env) held a value that is not
|
|
4183
|
+
* the value in force; `detail: { knob, raw, usedMs }`.
|
|
4184
|
+
* - `"config.materialize_env_discarded"` — `SEMA_TOOL_MATERIALIZE_STRATEGY` held a value outside the
|
|
4185
|
+
* closed set in a seat where it is not in force; `detail: { raw, specStrategy? }`.
|
|
4186
|
+
* - `"tool_result.offload_put_failed"` (#167) — a clear-with-offload persist's fire-and-forget put
|
|
4187
|
+
* failed; THIS attempt stored nothing (the failure arm reports, it never re-inserts under the
|
|
4188
|
+
* ref) — an earlier attempt of the same idempotent re-put may already have stored the row, so
|
|
4189
|
+
* the notice claims a failed write, not an empty ref; `detail: { ref, sessionId, cause }`.
|
|
4190
|
+
* Per-occurrence, not per-process-deduplicated: each failed write is a distinct fact. */
|
|
4191
|
+
code: string;
|
|
4192
|
+
/** The exact human-readable line the unwired build prints via `console.warn` — same words, one text. */
|
|
4193
|
+
message: string;
|
|
4194
|
+
/** Machine-readable facts of the notice (knob names, arriving values, values in force). */
|
|
4195
|
+
detail?: Record<string, unknown>;
|
|
4196
|
+
}
|
|
4197
|
+
/** Test seam (mirrors `__resetBashTimeoutAnnouncements`): never called by production code. */
|
|
4198
|
+
export declare function __resetMalformedNoticeSeatAnnouncement(): void;
|
|
4199
|
+
/**
|
|
4200
|
+
* The ONE delivery form behind every {@link RunnerDeps.onNotice} emission point (#170: the form was
|
|
4201
|
+
* triplicated across the emission stations, and every copy judged the seat with `!== undefined` — so a
|
|
4202
|
+
* PRESENT non-function seat (null, a config typo, an untyped host's JSON wiring) entered the wired arm,
|
|
4203
|
+
* threw `TypeError` on the call, and the swallow guard silenced BOTH channels at once). Contract:
|
|
4204
|
+
* · a FUNCTION seat REPLACES the console line (a host forwarding notices to its own surface must not
|
|
4205
|
+
* show every fact twice), swallow-guarded against both failure shapes the void-typed seat admits —
|
|
4206
|
+
* a synchronous throw and an async sink's rejected promise (unhandled, that rejection is a
|
|
4207
|
+
* process-level fault): a notice sink must never turn an announcement into a failure. Deliberately
|
|
4208
|
+
* NO console fallback on sink failure: loudness ownership transfers with the wiring, and a console
|
|
4209
|
+
* echo of a transient sink failure would double-send the fact. Corollary kept as-is: stations that
|
|
4210
|
+
* de-duplicate ledger the line BEFORE this call, so a true-function sink that throws can in
|
|
4211
|
+
* principle lose a ledgered line for the process lifetime — un-ledgering after the fact would race
|
|
4212
|
+
* an async sink's late rejection and re-announce (double-send) on transient failures.
|
|
4213
|
+
* · a PRESENT NON-function seat is a bad deployment value, and #123 (loud-bad-value law) forbids
|
|
4214
|
+
* folding it to silence: the notice itself falls back to `console.warn` (no line is lost), and the
|
|
4215
|
+
* seat defect — the one fact every fallback would otherwise repeat — is announced once per process.
|
|
4216
|
+
* · an ABSENT seat prints the historic `console.warn` line verbatim (byte-compat loudness).
|
|
4217
|
+
*/
|
|
4218
|
+
export declare function deliverEngineNotice(onNotice: ((notice: EngineNotice) => void) | undefined, notice: EngineNotice): void;
|
|
4164
4219
|
/** Runtime dependencies shared across tasks. */
|
|
4165
4220
|
export interface RunnerDeps {
|
|
4166
4221
|
brain: Brain;
|
|
@@ -4797,6 +4852,21 @@ export interface RunnerDeps {
|
|
|
4797
4852
|
*/
|
|
4798
4853
|
classification?: string;
|
|
4799
4854
|
}) => void;
|
|
4855
|
+
/**
|
|
4856
|
+
* Structured sink for operator-facing NOTICES ({@link EngineNotice}) — announcements that are not
|
|
4857
|
+
* errors and do not affect the run, which the engine otherwise prints via `console.warn` (e.g. a
|
|
4858
|
+
* configured timeout/env value discarded in favor of another). When wired, a notice goes HERE
|
|
4859
|
+
* INSTEAD of `console.warn` (structured replaces the console line — a host forwarding notices to
|
|
4860
|
+
* its own surface must not show every fact twice); when absent, the historic `console.warn` line
|
|
4861
|
+
* is printed verbatim, so an unwired build keeps its exact loudness. Swallow-guarded at every
|
|
4862
|
+
* emission point (`onError`/`tracer` posture): neither a throwing sink nor an async sink's
|
|
4863
|
+
* rejected promise ever affects the run. A PRESENT NON-function value (an untyped host wiring
|
|
4864
|
+
* null/junk) is a bad deployment value, not a wired sink: every notice then falls back to the
|
|
4865
|
+
* `console.warn` line and the seat defect itself is announced once per process (#170 — a bad seat
|
|
4866
|
+
* must not silence both channels). Per-process announcement de-duplication is unchanged and
|
|
4867
|
+
* sits BEFORE the sink branch — a deduplicated repeat reaches neither channel.
|
|
4868
|
+
*/
|
|
4869
|
+
onNotice?: (notice: EngineNotice) => void;
|
|
4800
4870
|
/** Best-effort fire-and-forget trace sink (task/turn/brain/tool); `TaskSpec.tracer` overrides per task. */
|
|
4801
4871
|
tracer?: import("./trace.js").TracerHook;
|
|
4802
4872
|
/**
|
package/dist/core/types.js
CHANGED
|
@@ -1 +1,30 @@
|
|
|
1
|
-
|
|
1
|
+
let malformedNoticeSeatAnnounced = false;
|
|
2
|
+
export function __resetMalformedNoticeSeatAnnouncement() {
|
|
3
|
+
malformedNoticeSeatAnnounced = false;
|
|
4
|
+
}
|
|
5
|
+
export function deliverEngineNotice(onNotice, notice) {
|
|
6
|
+
if (typeof onNotice === "function") {
|
|
7
|
+
try {
|
|
8
|
+
const r = onNotice(notice);
|
|
9
|
+
if (typeof r?.then === "function") {
|
|
10
|
+
r.then(undefined, () => {
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (onNotice !== undefined) {
|
|
19
|
+
if (!malformedNoticeSeatAnnounced) {
|
|
20
|
+
malformedNoticeSeatAnnounced = true;
|
|
21
|
+
try {
|
|
22
|
+
console.warn(`The structured notice sink (RunnerDeps.onNotice) holds ${onNotice === null ? "null" : typeof onNotice} — not a ` +
|
|
23
|
+
`function. Notices fall back to console.warn until the wiring is fixed (omit the key, or wire a function).`);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
console.warn(notice.message);
|
|
30
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -87,7 +87,7 @@ export type { InvariantKind, FunctionContract, Invariant, InvariantViolation, Ch
|
|
|
87
87
|
export { HAND_TOOL_EFFECTS, bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
|
|
88
88
|
export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, type BashReadonlyRootBoundary, type CompoundReadonlyVerdict, } from "./tools/fs/index.js";
|
|
89
89
|
export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
|
|
90
|
-
export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, type ToolResultProvenance, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, type ToolResultStore, type ToolResultSlice, } from "./core/tool-result-store.js";
|
|
90
|
+
export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, type ToolResultProvenance, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, type ToolResultStore, type ToolResultSlice, type ToolResultDeletionReport, } from "./core/tool-result-store.js";
|
|
91
91
|
export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, type ActorAssertion, type PendingSteerEntry, type PendingSteerInput, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type SafetyAxis, type RealApprovalGateBit, type ResourceLedger, type ResourceLimitReason, type PlatformLimitReason, } from "./core/checkpoint-store.js";
|
|
92
92
|
export { InMemoryUsageWindowStore, GLOBAL_USAGE_KEY, EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord, usageRetryAfterMs, resolveUsageWindows, type UsageWindow, type UsageWindowStore, type UsageWindowReading, type UsageWindowRecord, type UsageSlot, type UsageBucketRow, } from "./core/usage-window-store.js";
|
|
93
93
|
export { FileUsageWindowStore } from "./stores/file/usage-window-store.js";
|
|
@@ -250,7 +250,7 @@ export { retryBackoffMs, parseRetryAfter } from "./brain/retry.js";
|
|
|
250
250
|
export { type BrainTimeoutConfig } from "./brain/timeout.js";
|
|
251
251
|
export { createAssistantMessageEventStream } from "./internal/llm.js";
|
|
252
252
|
export type { AssistantMessage, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, StopReason, StreamFn, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "./internal/llm.js";
|
|
253
|
-
export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, BrainRetryErrClass, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, RuntimeCaps, BackgroundChildEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, StaleToolResultOffloadOptions, TaskResult, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolEffect, ToolContentOrigin, WorkflowGovernanceBaseline, DelegationTaskType, } from "./core/types.js";
|
|
253
|
+
export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, BrainRetryErrClass, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, EngineNotice, RuntimeCaps, BackgroundChildEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, StaleToolResultOffloadOptions, TaskResult, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolEffect, ToolContentOrigin, WorkflowGovernanceBaseline, DelegationTaskType, } from "./core/types.js";
|
|
254
254
|
export { Type } from "typebox";
|
|
255
255
|
export type { TSchema, Static } from "typebox";
|
|
256
256
|
export { explainPromptAssembly, describeDefaultPack, type DefaultPackDescription, type ExplainInput } from "./prompt-assembly/explain.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ToolResultProvenance, ToolResultSlice, ToolResultStore } from "../../core/tool-result-store.js";
|
|
1
|
+
import type { ToolResultDeletionReport, ToolResultProvenance, ToolResultSlice, ToolResultStore } from "../../core/tool-result-store.js";
|
|
2
2
|
/**
|
|
3
3
|
* design/80 §2.5 — file {@link ToolResultStore}: write-once-idempotent, ONE file per ref.
|
|
4
4
|
*
|
|
@@ -55,4 +55,44 @@ export declare class FileToolResultStore implements ToolResultStore {
|
|
|
55
55
|
offset?: number;
|
|
56
56
|
limit?: number;
|
|
57
57
|
}): ToolResultSlice | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Erase every entry owned by `sessionId` (interface: {@link ToolResultStore.deleteBySession}). Without
|
|
60
|
+
* it a session deletion leaves this store's files on disk for the lifetime of the data root.
|
|
61
|
+
*
|
|
62
|
+
* Selection matches on the SIDECAR's `sessionId`, never on the filename. That is not a preference, it
|
|
63
|
+
* is the only thing available: the mint separates segments with `~`, which is outside this backend's
|
|
64
|
+
* filename charset, so every engine-minted ref folds to `<base>-<sha256 of the raw ref>` and the
|
|
65
|
+
* session segment is gone from the name (and the readable base is a lossy, many-to-one prefix, so
|
|
66
|
+
* matching on it would reach a neighbouring session's files). `taskId`, where an older write site
|
|
67
|
+
* stamped one, is ignored — it narrows the same session.
|
|
68
|
+
*
|
|
69
|
+
* Both namespaces are swept: identity-named files in the store dir (refs a caller minted inside the
|
|
70
|
+
* filename charset, and everything written before the injective mint) and {@link FOLDED_SUBDIR}.
|
|
71
|
+
*
|
|
72
|
+
* Removal order is CONTENT then SIDECAR, and it is load-bearing. Interrupted after the content unlink,
|
|
73
|
+
* the next sweep still finds the sidecar, matches the same session and converges; interrupted after a
|
|
74
|
+
* sidecar-first unlink, the content file would be left owner-less — readable, attributable to nobody,
|
|
75
|
+
* and beyond the reach of every future session sweep. Same reasoning as `put`'s publication order, in
|
|
76
|
+
* the opposite direction. The pair is then re-examined ONCE after both unlinks, which is what keeps
|
|
77
|
+
* a `put` racing the two from leaving bytes whose owner this sweep removed — and what keeps that
|
|
78
|
+
* re-examination from deleting a row a DIFFERENT session claimed in the same window (see the body).
|
|
79
|
+
*
|
|
80
|
+
* What the re-examination is NOT is atomic: it reads the owner and then acts, so a writer landing
|
|
81
|
+
* inside THAT gap is narrowed, not excluded. Stated rather than locked away, because the locking that
|
|
82
|
+
* would close it is ruled out one layer down — `fs-atomic.ts` §2.4 admits exactly one file lock, the
|
|
83
|
+
* boot lock, on the premise that a data root has a single writing process (`FileStorageBackend`
|
|
84
|
+
* acquires it before constructing this store; cross-process correctness is a database backend's job,
|
|
85
|
+
* as everywhere else in this family). Under that premise there is no gap at all: both `put` and this
|
|
86
|
+
* sweep are wholly synchronous, so nothing can interleave with either. The narrowing above is what
|
|
87
|
+
* remains worth doing for a root someone shares anyway, and it costs one `readFileSync`.
|
|
88
|
+
*
|
|
89
|
+
* Failure posture: an entry that vanished between `readdir` and the unlink is honest absence (ENOENT
|
|
90
|
+
* ok, the concurrent sweeper's row). Any OTHER unlink error throws — a deletion face that cannot
|
|
91
|
+
* delete has to be loud — and because the sweep is idempotent, the retry after the operator clears
|
|
92
|
+
* the cause resumes where it stopped. A sidecar that cannot be READ (EACCES, torn JSON, a shape that
|
|
93
|
+
* is not the whole record) is counted `unattributable` and its content is left alone: a file we could
|
|
94
|
+
* not read carries no evidence about whose it is, and this store's rows stay readable through `get`
|
|
95
|
+
* even with a damaged owner record, so removing one on a guess would destroy another session's output.
|
|
96
|
+
*/
|
|
97
|
+
deleteBySession(sessionId: string): ToolResultDeletionReport;
|
|
58
98
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { assertSafeToolResultRef, assertToolResultProvenanceMatch, normalizeToolResultProvenance, } from "../../core/tool-result-store.js";
|
|
4
|
+
import { assertSafeToolResultRef, assertToolResultProvenanceMatch, normalizeToolResultProvenance, ToolResultRefConflictError, } from "../../core/tool-result-store.js";
|
|
5
5
|
import { ensureDir, sanitizePathComponent, writeThenLink } from "./fs-atomic.js";
|
|
6
6
|
import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
7
7
|
export class FileToolResultStore {
|
|
@@ -21,11 +21,11 @@ export class FileToolResultStore {
|
|
|
21
21
|
}
|
|
22
22
|
pathFor(ref) {
|
|
23
23
|
const { dir, stem } = this.locate(ref);
|
|
24
|
-
return join(dir, `${stem}
|
|
24
|
+
return join(dir, `${stem}${CONTENT_SUFFIX}`);
|
|
25
25
|
}
|
|
26
26
|
ownerPathFor(ref) {
|
|
27
27
|
const { dir, stem } = this.locate(ref);
|
|
28
|
-
return join(dir, `${stem}
|
|
28
|
+
return join(dir, `${stem}${OWNER_SUFFIX}`);
|
|
29
29
|
}
|
|
30
30
|
put(ref, content, provenance) {
|
|
31
31
|
assertSafeToolResultRef(ref);
|
|
@@ -46,7 +46,14 @@ export class FileToolResultStore {
|
|
|
46
46
|
}
|
|
47
47
|
if (provenance === undefined)
|
|
48
48
|
return;
|
|
49
|
-
|
|
49
|
+
try {
|
|
50
|
+
this.writeOwnerFile(ref, provenance);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof ToolResultRefConflictError)
|
|
54
|
+
unlinkIfAbsentOk(this.pathFor(ref));
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
50
57
|
}
|
|
51
58
|
ownerOf(ref) {
|
|
52
59
|
return this.readOwnerFile(ref, { loud: false });
|
|
@@ -63,20 +70,9 @@ export class FileToolResultStore {
|
|
|
63
70
|
throw err;
|
|
64
71
|
return undefined;
|
|
65
72
|
}
|
|
66
|
-
const parsed = (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
return undefined;
|
|
72
|
-
}
|
|
73
|
-
})();
|
|
74
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
75
|
-
const { sessionId, taskId } = parsed;
|
|
76
|
-
if (typeof sessionId === "string" && (taskId === undefined || typeof taskId === "string")) {
|
|
77
|
-
return taskId === undefined ? { sessionId } : { sessionId, taskId };
|
|
78
|
-
}
|
|
79
|
-
}
|
|
73
|
+
const parsed = parseOwnerRecord(raw);
|
|
74
|
+
if (parsed !== undefined)
|
|
75
|
+
return parsed;
|
|
80
76
|
if (opts.loud) {
|
|
81
77
|
throw new Error(`tool-result store: owner record for ref ${JSON.stringify(ref)} is present but unreadable — refusing to write over it`);
|
|
82
78
|
}
|
|
@@ -111,6 +107,98 @@ export class FileToolResultStore {
|
|
|
111
107
|
const content = limit != null ? full.slice(offset, offset + limit) : full.slice(offset);
|
|
112
108
|
return { content, offset, totalChars: full.length };
|
|
113
109
|
}
|
|
110
|
+
deleteBySession(sessionId) {
|
|
111
|
+
let deleted = 0;
|
|
112
|
+
let unattributable = 0;
|
|
113
|
+
for (const dir of [this.dir, join(this.dir, FOLDED_SUBDIR)]) {
|
|
114
|
+
let names;
|
|
115
|
+
try {
|
|
116
|
+
names = readdirSync(dir);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (err.code === "ENOENT")
|
|
120
|
+
continue;
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
const listed = new Set(names);
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
if (!name.endsWith(OWNER_SUFFIX))
|
|
126
|
+
continue;
|
|
127
|
+
const owner = readOwnerAt(join(dir, name));
|
|
128
|
+
if (owner === "gone")
|
|
129
|
+
continue;
|
|
130
|
+
if (owner === "damaged") {
|
|
131
|
+
unattributable++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (owner.sessionId !== sessionId)
|
|
135
|
+
continue;
|
|
136
|
+
const stem = name.slice(0, -OWNER_SUFFIX.length);
|
|
137
|
+
const sidecarPath = join(dir, name);
|
|
138
|
+
const contentPath = join(dir, `${stem}${CONTENT_SUFFIX}`);
|
|
139
|
+
unlinkIfAbsentOk(contentPath);
|
|
140
|
+
unlinkIfAbsentOk(sidecarPath);
|
|
141
|
+
const republished = readOwnerAt(sidecarPath);
|
|
142
|
+
if (republished === "gone") {
|
|
143
|
+
unlinkIfAbsentOk(contentPath);
|
|
144
|
+
}
|
|
145
|
+
else if (republished === "damaged") {
|
|
146
|
+
unattributable++;
|
|
147
|
+
}
|
|
148
|
+
else if (republished.sessionId === sessionId) {
|
|
149
|
+
unlinkIfAbsentOk(contentPath);
|
|
150
|
+
unlinkIfAbsentOk(sidecarPath);
|
|
151
|
+
}
|
|
152
|
+
deleted++;
|
|
153
|
+
}
|
|
154
|
+
for (const name of names) {
|
|
155
|
+
if (!name.endsWith(CONTENT_SUFFIX))
|
|
156
|
+
continue;
|
|
157
|
+
if (listed.has(`${name.slice(0, -CONTENT_SUFFIX.length)}${OWNER_SUFFIX}`))
|
|
158
|
+
continue;
|
|
159
|
+
unattributable++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { deleted, unattributable };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const CONTENT_SUFFIX = ".txt";
|
|
166
|
+
const OWNER_SUFFIX = ".owner.json";
|
|
167
|
+
function parseOwnerRecord(raw) {
|
|
168
|
+
const parsed = (() => {
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(raw);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
177
|
+
const { sessionId, taskId } = parsed;
|
|
178
|
+
if (typeof sessionId === "string" && (taskId === undefined || typeof taskId === "string")) {
|
|
179
|
+
return taskId === undefined ? { sessionId } : { sessionId, taskId };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
function readOwnerAt(path) {
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = readFileSync(path, "utf8");
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
return err.code === "ENOENT" ? "gone" : "damaged";
|
|
191
|
+
}
|
|
192
|
+
return parseOwnerRecord(raw) ?? "damaged";
|
|
193
|
+
}
|
|
194
|
+
function unlinkIfAbsentOk(path) {
|
|
195
|
+
try {
|
|
196
|
+
unlinkSync(path);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
if (err.code !== "ENOENT")
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
114
202
|
}
|
|
115
203
|
const NATIVE_FILENAME_CHARSET = /^[A-Za-z0-9_.-]+$/;
|
|
116
204
|
const FOLDED_SUBDIR = "folded-v2";
|
|
@@ -110,6 +110,10 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
|
|
|
110
110
|
/** RB-370 ② (cli [2088]): override of the `timeout` parameter's engine CEILING, in ms — only-widen
|
|
111
111
|
* vs the resolved default; see {@link import("./index.js").HandsToolkitOptions.bashMaxTimeoutMs}. */
|
|
112
112
|
bashMaxTimeoutMs?: number;
|
|
113
|
+
/** Structured sink for this leg's timeout-cap discard announcements — consumed by
|
|
114
|
+
* {@link import("./fs-shared.js").resolveBashTimeoutCaps} at mount (see
|
|
115
|
+
* {@link import("./index.js").HandsToolkitOptions.onNotice} for the replace-vs-warn contract). */
|
|
116
|
+
onNotice?: (n: import("../../core/types.js").EngineNotice) => void;
|
|
113
117
|
/**
|
|
114
118
|
* #181-F6 — whether the Monitor tool is on THIS run's roster (the Runner's own mount predicate is
|
|
115
119
|
* `backgroundTaskToolsActive`; it, not this tool, mounts Monitor). Read by the SR-2 gh rate-limit
|
|
@@ -142,6 +146,9 @@ export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical:
|
|
|
142
146
|
* legs share runShell's clamp, so both must share the configurable resolution). */
|
|
143
147
|
bashDefaultTimeoutMs?: number;
|
|
144
148
|
bashMaxTimeoutMs?: number;
|
|
149
|
+
/** Structured sink for the timeout-cap discard announcements — same seat the full `bash` leg
|
|
150
|
+
* takes (both foreground legs share the resolver, so both must share the sink). */
|
|
151
|
+
onNotice?: (n: import("../../core/types.js").EngineNotice) => void;
|
|
145
152
|
/** RB-413: the extra containment roots the structured file tools got (design/119 `--add-dir`,
|
|
146
153
|
* canonical). A read this deployment already sanctions for Read/Grep/Glob is equally sanctioned
|
|
147
154
|
* here — the two faces must not disagree about which directories exist for this session. */
|
|
@@ -3,6 +3,7 @@ import type { ExecutionEnv } from "../../internal/harness-types.js";
|
|
|
3
3
|
import { type FsViolation, type ReadFileState } from "./safety.js";
|
|
4
4
|
import { type DecodedTextFile } from "./encoding.js";
|
|
5
5
|
import { type ImageDownsampler } from "../../core/mcp.js";
|
|
6
|
+
import { type EngineNotice } from "../../core/types.js";
|
|
6
7
|
/**
|
|
7
8
|
* The "hand" tool band (design/44 §3) — built per task over an injected {@link ExecutionEnv} and a
|
|
8
9
|
* per-task {@link ReadFileState}, closure-captured (design/44 §11 ruling A: the codebase tool idiom; no
|
|
@@ -223,6 +224,10 @@ export declare function bashTimeoutArgRefusal(timeoutMs: number | undefined, cap
|
|
|
223
224
|
export declare function resolveBashTimeoutCaps(opts?: {
|
|
224
225
|
bashDefaultTimeoutMs?: number;
|
|
225
226
|
bashMaxTimeoutMs?: number;
|
|
227
|
+
/** Structured seat for the discard announcements below — wired, it REPLACES their `console.warn`
|
|
228
|
+
* (same ledger, same lines as `message`); absent, the console line prints verbatim. Threaded from
|
|
229
|
+
* the toolkit assembly (ultimately `RunnerDeps.onNotice`); never a global. */
|
|
230
|
+
onNotice?: (n: EngineNotice) => void;
|
|
226
231
|
}): {
|
|
227
232
|
defaultMs: number;
|
|
228
233
|
maxMs: number;
|
|
@@ -5,6 +5,7 @@ import { decodeTextBytes, normalizeFileText } from "./encoding.js";
|
|
|
5
5
|
import { shellQuote } from "./search.js";
|
|
6
6
|
import { isNotebookPath } from "./notebook.js";
|
|
7
7
|
import { sharpImageDownsampler } from "../../core/mcp.js";
|
|
8
|
+
import { deliverEngineNotice } from "../../core/types.js";
|
|
8
9
|
export const MAX_READ_BYTES = 256 * 1024;
|
|
9
10
|
export const SLICED_READ_MAX_BYTES = 64 * 1024 * 1024;
|
|
10
11
|
export const MAX_EDIT_BYTES = 1024 * 1024 * 1024;
|
|
@@ -88,7 +89,10 @@ export function envErrorDetail(message) {
|
|
|
88
89
|
return trimmed === "" ? "the execution environment reported no reason" : trimmed;
|
|
89
90
|
}
|
|
90
91
|
const announcedTimeoutConfig = new Set();
|
|
91
|
-
function
|
|
92
|
+
function emitTimeoutDiscardNotice(message, detail, onNotice) {
|
|
93
|
+
deliverEngineNotice(onNotice, { code: "config.env_timeout_discarded", message, detail });
|
|
94
|
+
}
|
|
95
|
+
function announceDiscardedTimeout(knob, raw, usedMs, onNotice) {
|
|
92
96
|
if (typeof raw === "string" && raw === "")
|
|
93
97
|
return;
|
|
94
98
|
const n = typeof raw === "number" ? raw : Number(raw);
|
|
@@ -105,7 +109,7 @@ function announceDiscardedTimeout(knob, raw, usedMs) {
|
|
|
105
109
|
if (announcedTimeoutConfig.has(line))
|
|
106
110
|
return;
|
|
107
111
|
announcedTimeoutConfig.add(line);
|
|
108
|
-
|
|
112
|
+
emitTimeoutDiscardNotice(line, { knob, raw, usedMs }, onNotice);
|
|
109
113
|
}
|
|
110
114
|
export function bashTimeoutParamDescription(caps, withDefault) {
|
|
111
115
|
const bounds = `${withDefault ? `default ${caps.defaultMs}, ` : ""}min ${MIN_BASH_TIMEOUT_MS}, max ${caps.maxMs}`;
|
|
@@ -131,13 +135,13 @@ export function resolveBashTimeoutCaps(opts) {
|
|
|
131
135
|
BASH_DEFAULT_TIMEOUT_MS;
|
|
132
136
|
const maxMs = Math.max(validTimeoutMs(opts?.bashMaxTimeoutMs) ?? validTimeoutMs(Number(process.env.BASH_MAX_TIMEOUT_MS)) ?? BASH_MAX_TIMEOUT_MS, defaultMs);
|
|
133
137
|
if (opts?.bashDefaultTimeoutMs !== undefined)
|
|
134
|
-
announceDiscardedTimeout("bashDefaultTimeoutMs", opts.bashDefaultTimeoutMs, defaultMs);
|
|
138
|
+
announceDiscardedTimeout("bashDefaultTimeoutMs", opts.bashDefaultTimeoutMs, defaultMs, opts?.onNotice);
|
|
135
139
|
if (process.env.BASH_DEFAULT_TIMEOUT_MS !== undefined)
|
|
136
|
-
announceDiscardedTimeout("BASH_DEFAULT_TIMEOUT_MS", process.env.BASH_DEFAULT_TIMEOUT_MS, defaultMs);
|
|
140
|
+
announceDiscardedTimeout("BASH_DEFAULT_TIMEOUT_MS", process.env.BASH_DEFAULT_TIMEOUT_MS, defaultMs, opts?.onNotice);
|
|
137
141
|
if (opts?.bashMaxTimeoutMs !== undefined)
|
|
138
|
-
announceDiscardedTimeout("bashMaxTimeoutMs", opts.bashMaxTimeoutMs, maxMs);
|
|
142
|
+
announceDiscardedTimeout("bashMaxTimeoutMs", opts.bashMaxTimeoutMs, maxMs, opts?.onNotice);
|
|
139
143
|
if (process.env.BASH_MAX_TIMEOUT_MS !== undefined)
|
|
140
|
-
announceDiscardedTimeout("BASH_MAX_TIMEOUT_MS", process.env.BASH_MAX_TIMEOUT_MS, maxMs);
|
|
144
|
+
announceDiscardedTimeout("BASH_MAX_TIMEOUT_MS", process.env.BASH_MAX_TIMEOUT_MS, maxMs, opts?.onNotice);
|
|
141
145
|
const optsCap = validTimeoutMs(opts?.bashMaxTimeoutMs);
|
|
142
146
|
const requestedCap = optsCap ?? validTimeoutMs(Number(process.env.BASH_MAX_TIMEOUT_MS));
|
|
143
147
|
if (requestedCap !== undefined && requestedCap < defaultMs) {
|
|
@@ -145,7 +149,7 @@ export function resolveBashTimeoutCaps(opts) {
|
|
|
145
149
|
const line = `${knob}=${requestedCap} is below the resolved default budget (${defaultMs}ms) — the ceiling was raised to ${maxMs}ms (the default always fits under the cap).`;
|
|
146
150
|
if (!announcedTimeoutConfig.has(line)) {
|
|
147
151
|
announcedTimeoutConfig.add(line);
|
|
148
|
-
|
|
152
|
+
emitTimeoutDiscardNotice(line, { knob, raw: requestedCap, usedMs: maxMs }, opts?.onNotice);
|
|
149
153
|
}
|
|
150
154
|
}
|
|
151
155
|
return { defaultMs, maxMs };
|
package/dist/tools/fs/index.d.ts
CHANGED
|
@@ -74,6 +74,12 @@ export interface HandsToolkitOptions {
|
|
|
74
74
|
* only-widen semantic: a ceiling below the default is a narrowing intent = invalid, ignored in
|
|
75
75
|
* favor of the default. Additive: absent ⇒ byte-compat 600s. */
|
|
76
76
|
bashMaxTimeoutMs?: number;
|
|
77
|
+
/** Structured sink for the timeout-cap discard announcements both shell legs emit at mount (see
|
|
78
|
+
* {@link import("../../core/types.js").EngineNotice} / `RunnerDeps.onNotice`). Wired, it REPLACES
|
|
79
|
+
* their `console.warn` line (a forwarding host must not show every fact twice); absent, the
|
|
80
|
+
* console line prints verbatim (byte-compat loudness). Threaded per toolkit build — never a
|
|
81
|
+
* global. */
|
|
82
|
+
onNotice?: (n: import("../../core/types.js").EngineNotice) => void;
|
|
77
83
|
/** RB-220 — see createBashTool's taskOpts.oneShot / createTaskOutputTool's TaskToolOptions.oneShot for
|
|
78
84
|
* the full contract: this run has no later turn for an async background notification to land in.
|
|
79
85
|
* Threaded to both the Bash background-launch receipt and (when this band mounts TaskOutput itself,
|
package/dist/tools/fs/index.js
CHANGED
|
@@ -41,6 +41,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
|
|
|
41
41
|
? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), {
|
|
42
42
|
...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
|
|
43
43
|
...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
|
|
44
|
+
...(opts.onNotice !== undefined ? { onNotice: opts.onNotice } : {}),
|
|
44
45
|
...(readFaceRoots !== undefined ? { additionalRoots: readFaceRoots } : {}),
|
|
45
46
|
})
|
|
46
47
|
: createBashTool(env, rootCanonical, commitCoAuthor, cwdRef, {
|
|
@@ -55,6 +56,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
|
|
|
55
56
|
...(additionalRoots !== undefined ? { additionalRoots } : {}),
|
|
56
57
|
...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
|
|
57
58
|
...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
|
|
59
|
+
...(opts.onNotice !== undefined ? { onNotice: opts.onNotice } : {}),
|
|
58
60
|
...(opts.monitorToolActive !== undefined ? { monitorToolActive: opts.monitorToolActive } : {}),
|
|
59
61
|
}));
|
|
60
62
|
if (!readOnly && mountBackgroundTaskTools && hasBackgroundShell(env)) {
|