@deepstrike/sdk 0.2.48 → 0.2.50

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.
@@ -22,7 +22,7 @@ export declare function composeSystemPrompt(base: string | undefined, instructio
22
22
  * The exact `RuntimeOptions` fields a manifest may drive. Derived via `Pick` so field names and types
23
23
  * track `RuntimeOptions` verbatim; anything outside this set is rejected by `applyManifest`/`applyPatch`.
24
24
  */
25
- export type HarnessRuntimePatch = Pick<RuntimeOptions, "maxTurns" | "maxTotalTokens" | "criteriaGate" | "repeatFuse" | "entropyWatch" | "knowledgeBudgetRatio" | "skillLeaseTurns" | "allowedToolIds" | "stableCoreToolIds" | "enablePlanTool" | "skillFilter"> & Pick<MemoryPolicy, "retrievalTopK" | "promotionRecallThreshold">;
25
+ export type HarnessRuntimePatch = Pick<RuntimeOptions, "maxTurns" | "maxTotalTokens" | "criteriaGate" | "repeatFuse" | "entropyWatch" | "knowledgeBudgetRatio" | "skillLeaseTurns" | "allowedToolIds" | "baselineToolIds" | "stableCoreToolIds" | "enablePlanTool" | "skillFilter"> & Pick<MemoryPolicy, "retrievalTopK" | "promotionRecallThreshold">;
26
26
  /**
27
27
  * The promotion tier of an editable surface — the SECOND axis of the safety boundary (the whitelist is
28
28
  * the first). Even a whitelisted surface may need a heavier gate than "typed validation passed".
@@ -30,17 +30,18 @@ export type HarnessRuntimePatch = Pick<RuntimeOptions, "maxTurns" | "maxTotalTok
30
30
  export type SurfaceTier = "auto" | "screened" | "human";
31
31
  /**
32
32
  * Map an editable surface to its promotion tier. Maintained HERE beside the whitelist so a surface can
33
- * never be whitelisted without also being assigned a tier (spec V2-S3 same-place maintenance):
33
+ * never be whitelisted without also being assigned a tier (the spec's same-place maintenance rule):
34
34
  * - "auto" (Tier A): every `runtime.*` whitelist surface. Typed validation + the capability
35
- * ceiling invariant + the v1 acceptance rule already guard them, so promotion is fully
35
+ * ceiling invariant + the acceptance rule already guard them, so promotion is fully
36
36
  * automatic — there is no free text and no injection surface.
37
37
  * - "screened" (Tier B): `instructions.*` and `nudges`. Free text can smuggle instructions
38
38
  * (persistent prompt-injection laundered through the evidence loop), so a screen runs
39
39
  * before promotion.
40
- * - "human" (Tier C): reserved for capability-WIDENING surfaces. None exist in v2 — intersection
41
- * semantics make widening structurally inexpressible — but the enum value exists so a v3
42
- * surface cannot be added without consciously assigning it a tier (and building the
43
- * human gate). `surfaceTier` therefore never returns "human" in v2.
40
+ * - "human" (Tier C): reserved for capability-WIDENING surfaces. None currently exist — intersection
41
+ * semantics make widening structurally inexpressible — but the enum value exists so a new
42
+ * capability-widening surface cannot be added without consciously assigning it a tier (and
43
+ * building the human gate). `surfaceTier` therefore never returns "human" today — no
44
+ * capability-widening surface is expressible.
44
45
  * An unknown surface / slot / runtime key THROWS (same discipline as applySurfaceEdit): a surface with
45
46
  * no tier must never fall through to auto-promotion.
46
47
  */
@@ -55,7 +56,7 @@ export interface HarnessManifest {
55
56
  * Opaque isolation key — host decides its semantics (user / tenant / agent-group). Orthogonal to
56
57
  * `modelProfile` (never concatenate the two — that reprises the identity-scoping bug class); absent
57
58
  * ⇒ the host treats it as `"default"`. It rides canonical JSON, so digests domain-separate by scope,
58
- * but an absent scope leaves a v1-shaped manifest's digest byte-identical (canonicalJson skips
59
+ * but an absent scope leaves a pre-scope manifest's digest byte-identical (canonicalJson skips
59
60
  * undefined). Becomes a lineage directory name downstream, hence the path-safe character bound.
60
61
  */
61
62
  scope?: string;
@@ -71,9 +72,9 @@ export interface HarnessManifest {
71
72
  rationale?: string;
72
73
  deltaHeldIn?: number;
73
74
  deltaHeldOut?: number;
74
- /** Promotion tier of the driving edit (V2-S3). */
75
+ /** Promotion tier of the driving edit. */
75
76
  tier?: SurfaceTier;
76
- /** Injection-screen verdict — present only for a screened (Tier B) promotion (V2-S3). */
77
+ /** Injection-screen verdict — present only for a screened (Tier B) promotion. */
77
78
  screenVerdict?: "pass" | "screened_out";
78
79
  };
79
80
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Self-Harness H1.1 + H1.3 — the harness face as DATA.
2
+ * Self-Harness editable surfaces — the harness face as DATA.
3
3
  *
4
4
  * A `HarnessManifest` is a versioned, hashable lineage node: the editable surfaces a fixed model may
5
5
  * rewrite about its OWN harness — instruction slots, nudge rules, and a whitelisted `RuntimeOptions`
@@ -11,12 +11,16 @@
11
11
  * absent, so a proposer can never rewrite them (spec design principle: conservative promotion).
12
12
  *
13
13
  * Tool/skill surfaces add the SECOND safety invariant (spec design principle A — the capability
14
- * ceiling): `allowedToolIds`, `stableCoreToolIds`, and `skillFilter` fold onto the host baseline by
15
- * INTERSECTION, never assignment. A manifest can only NARROW the tools/skills the host already
16
- * exposes — never widen. Capability expansion (naming a tool the host does not expose) is therefore
17
- * structurally inexpressible, and the whole security audit stays O(1): read the whitelist, check the
18
- * one invariant. (`enablePlanTool` is exempt — it toggles a kernel-owned meta-tool, attention-shaping
19
- * not capability-granting, so it folds by plain assignment.)
14
+ * ceiling): `allowedToolIds`, `baselineToolIds`, `stableCoreToolIds`, and `skillFilter` fold onto the
15
+ * host baseline by INTERSECTION, never assignment. A manifest can only NARROW the tools/skills the
16
+ * host already exposes — never widen. Capability expansion (naming a tool the host does not expose) is
17
+ * therefore structurally inexpressible, and the whole security audit stays O(1): read the whitelist,
18
+ * check the one invariant. (`enablePlanTool` is exempt — it toggles a kernel-owned meta-tool,
19
+ * attention-shaping not capability-granting, so it folds by plain assignment.)
20
+ *
21
+ * `toolDispatchGate` is deliberately ABSENT from the whitelist and must stay so: it selects whether
22
+ * the kernel enforces the exposure surface at dispatch. A proposer that could set it to `"registered"`
23
+ * would disable the enforcement half of the ceiling it is otherwise structurally unable to widen.
20
24
  */
21
25
  import { createHash } from "node:crypto";
22
26
  import { validateNudgeRules } from "./nudge.js";
@@ -44,7 +48,12 @@ export function composeSystemPrompt(base, instructions) {
44
48
  }
45
49
  const MEMORY_POLICY_PATCH_KEYS = ["retrievalTopK", "promotionRecallThreshold"];
46
50
  /** Tool/skill surfaces whose fold is intersection-with-baseline (capability ceiling), not assignment. */
47
- const INTERSECTION_PATCH_KEYS = ["allowedToolIds", "stableCoreToolIds", "skillFilter"];
51
+ const INTERSECTION_PATCH_KEYS = [
52
+ "allowedToolIds",
53
+ "baselineToolIds",
54
+ "stableCoreToolIds",
55
+ "skillFilter",
56
+ ];
48
57
  const RUNTIME_PATCH_KEYS = [
49
58
  "maxTurns",
50
59
  "maxTotalTokens",
@@ -54,27 +63,29 @@ const RUNTIME_PATCH_KEYS = [
54
63
  "knowledgeBudgetRatio",
55
64
  "skillLeaseTurns",
56
65
  "allowedToolIds",
66
+ "baselineToolIds",
57
67
  "stableCoreToolIds",
58
68
  "enablePlanTool",
59
69
  "skillFilter",
60
70
  ...MEMORY_POLICY_PATCH_KEYS,
61
71
  ];
62
- /** Bounds for the id-list surfaces (allowedToolIds / stableCoreToolIds / skillFilter). */
72
+ /** Bounds for the id-list surfaces (allowedToolIds / baselineToolIds / stableCoreToolIds / skillFilter). */
63
73
  const MAX_TOOL_ID_CHARS = 128;
64
74
  const MAX_TOOL_LIST_ENTRIES = 128;
65
75
  /**
66
76
  * Map an editable surface to its promotion tier. Maintained HERE beside the whitelist so a surface can
67
- * never be whitelisted without also being assigned a tier (spec V2-S3 same-place maintenance):
77
+ * never be whitelisted without also being assigned a tier (the spec's same-place maintenance rule):
68
78
  * - "auto" (Tier A): every `runtime.*` whitelist surface. Typed validation + the capability
69
- * ceiling invariant + the v1 acceptance rule already guard them, so promotion is fully
79
+ * ceiling invariant + the acceptance rule already guard them, so promotion is fully
70
80
  * automatic — there is no free text and no injection surface.
71
81
  * - "screened" (Tier B): `instructions.*` and `nudges`. Free text can smuggle instructions
72
82
  * (persistent prompt-injection laundered through the evidence loop), so a screen runs
73
83
  * before promotion.
74
- * - "human" (Tier C): reserved for capability-WIDENING surfaces. None exist in v2 — intersection
75
- * semantics make widening structurally inexpressible — but the enum value exists so a v3
76
- * surface cannot be added without consciously assigning it a tier (and building the
77
- * human gate). `surfaceTier` therefore never returns "human" in v2.
84
+ * - "human" (Tier C): reserved for capability-WIDENING surfaces. None currently exist — intersection
85
+ * semantics make widening structurally inexpressible — but the enum value exists so a new
86
+ * capability-widening surface cannot be added without consciously assigning it a tier (and
87
+ * building the human gate). `surfaceTier` therefore never returns "human" today — no
88
+ * capability-widening surface is expressible.
78
89
  * An unknown surface / slot / runtime key THROWS (same discipline as applySurfaceEdit): a surface with
79
90
  * no tier must never fall through to auto-promotion.
80
91
  */
@@ -164,23 +175,19 @@ function validateRuntimePatch(runtime) {
164
175
  }
165
176
  }
166
177
  }
167
- /**
168
- * Validate an id-list surface: array of unique, non-empty strings (each ≤128 chars), ≤128 entries.
169
- * `allowEmpty` is the load-bearing asymmetry. For the tool-id arrays it is FALSE: the runner reads an
170
- * empty/absent `allowedToolIds` as "no gating — expose ALL registered tools", so an empty array would
171
- * WIDEN exposure to everything if it reached the runner (and a zero-tool run is the v0.2.46 pathology).
172
- * For `skillFilter` it is TRUE: the runner's no-gating sentinel is ONLY `undefined`, and an empty array
173
- * legitimately means "no skills available" (a proposer may find skills are a distraction) — a narrowing.
174
- */
175
- function validateIdList(key, value, allowEmpty) {
178
+ /** Validate an id-list surface: array of unique, non-empty strings (each ≤128 chars), ≤128 entries. */
179
+ function validateIdList(key, value, emptyPolicy) {
176
180
  if (!Array.isArray(value))
177
181
  throw new TypeError(`runtime.${key} must be a string[]`);
178
182
  if (value.length > MAX_TOOL_LIST_ENTRIES) {
179
183
  throw new RangeError(`runtime.${key} exceeds ${MAX_TOOL_LIST_ENTRIES} entries`);
180
184
  }
181
- if (!allowEmpty && value.length === 0) {
185
+ if (emptyPolicy === "reject:widens" && value.length === 0) {
182
186
  throw new RangeError(`runtime.${key} must be a non-empty list — an empty array is read by the runner as "no gating" (expose all registered tools), which WIDENS exposure`);
183
187
  }
188
+ if (emptyPolicy === "reject:drastic" && value.length === 0) {
189
+ throw new RangeError(`runtime.${key} must be a non-empty list — an empty baseline collapses the pre-activation surface to meta-tools only, which stays a human/host decision`);
190
+ }
184
191
  const seen = new Set();
185
192
  for (const entry of value) {
186
193
  if (typeof entry !== "string" || entry.length === 0) {
@@ -218,10 +225,13 @@ function validateRuntimeValue(key, value) {
218
225
  return;
219
226
  case "allowedToolIds":
220
227
  case "stableCoreToolIds":
221
- validateIdList(key, value, /* allowEmpty */ false);
228
+ validateIdList(key, value, "reject:widens");
229
+ return;
230
+ case "baselineToolIds":
231
+ validateIdList(key, value, "reject:drastic");
222
232
  return;
223
233
  case "skillFilter":
224
- validateIdList(key, value, /* allowEmpty */ true);
234
+ validateIdList(key, value, "allow");
225
235
  return;
226
236
  case "knowledgeBudgetRatio":
227
237
  if (typeof value !== "number" || !(value > 0 && value <= 1)) {
@@ -334,7 +344,9 @@ export function applyManifest(manifest, base) {
334
344
  }
335
345
  /**
336
346
  * Fold one intersection surface (capability ceiling): effective = manifest ∩ host-baseline, so a
337
- * manifest can only NARROW. The empty-baseline meaning is surface-specific and load-bearing:
347
+ * manifest can only NARROW. The empty-baseline meaning is surface-specific and load-bearing — it
348
+ * tracks whatever the RUNNER's own no-gating sentinel is for that option, not whether the option
349
+ * happens to hold tool ids:
338
350
  *
339
351
  * - allowedToolIds / stableCoreToolIds — the runner reads an empty OR absent baseline as
340
352
  * "no gating = all registered tools" (the universe), so a non-array/empty baseline yields the
@@ -342,19 +354,22 @@ export function applyManifest(manifest, base) {
342
354
  * empty intersection THROWS: a zero-tool run reprises the v0.2.46 pathology AND the runner would
343
355
  * silently reinterpret the empty result as "no gating" (full exposure) — so we turn the candidate
344
356
  * into a discardable error instead.
345
- * - skillFilter — the runner's no-gating sentinel is ONLY `undefined`; an empty-array baseline is a
346
- * genuine, maximally-tight ceiling (no skills). So ANY present array (even `[]`) is intersected,
347
- * and an empty result is FINE (= no skills). This mirrors the validation asymmetry exactly.
357
+ * - skillFilter / baselineToolIds — the runner's no-gating sentinel is ONLY `undefined`; an
358
+ * empty-array baseline is a genuine, maximally-tight ceiling (no skills / the minimal meta-only
359
+ * tool surface). So ANY present array (even `[]`) is intersected, and an empty result is FINE —
360
+ * it reaches the runner as exactly that maximally-tight value, never as "no gating". This mirrors
361
+ * the validation asymmetry exactly. `baselineToolIds` sits on THIS side despite being a tool-id
362
+ * list: `[]` is its documented minimal surface (kernel `Some([])`), distinct from absent.
348
363
  */
349
364
  function foldIntersection(key, manifestList, baseList) {
350
- const skillLike = key === "skillFilter";
351
- // Is the host baseline a real constraining set? Tool ids: non-empty array only (empty == universe).
352
- // skillFilter: any array (empty == the empty set).
353
- const constrained = Array.isArray(baseList) && (skillLike || baseList.length > 0);
365
+ // Is an EMPTY host baseline a maximally-tight ceiling (intersect it, empty result fine) or the
366
+ // universe (ignore it, empty result is a bug)?
367
+ const emptyBaselineIsTight = key === "skillFilter" || key === "baselineToolIds";
368
+ const constrained = Array.isArray(baseList) && (emptyBaselineIsTight || baseList.length > 0);
354
369
  const effective = constrained
355
370
  ? manifestList.filter(id => baseList.includes(id)) // manifest order → deterministic
356
371
  : manifestList;
357
- if (!skillLike && effective.length === 0) {
372
+ if (!emptyBaselineIsTight && effective.length === 0) {
358
373
  throw new RangeError(`applyManifest: runtime.${key} intersection is empty — manifest [${manifestList.join(", ")}] ∩ ` +
359
374
  `host [${(baseList ?? []).join(", ")}] names no shared tool. A zero-tool run is rejected (it ` +
360
375
  `reprises the v0.2.46 pathology and the runner would read empty as "no gating" = full exposure).`);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Self-Harness H1.2 — declarative event→note rules (the runtime control-policy surface).
2
+ * Self-Harness nudge rules — declarative event→note rules (the runtime control-policy surface).
3
3
  *
4
4
  * A `NudgeRule` says "when this session event fires, push this note to the model". It generalizes the
5
5
  * two hard-coded precedents (EntropyWatch.notify_model, the RepeatFuse STOP text) into data the
@@ -2,7 +2,7 @@
2
2
  export { AttemptLoop, RuntimeAttemptBody, continueSession, freshWithFeedback, freshWithDigest, } from "./harness.js";
3
3
  export { VerdictFnJudge, LlmEvalJudge, HybridJudge } from "./judge.js";
4
4
  export { judge } from "../runtime/eval.js";
5
- // Self-Harness H1: the harness face as data (manifest lineage + declarative event→note rules). The
5
+ // Self-Harness editable surfaces: the harness face as data (manifest lineage + declarative event→note rules). The
6
6
  // lab layer loads these through the compiled dist, so they live on this public barrel.
7
7
  export { composeSystemPrompt, manifestDigest, applyManifest, applyPatch, validateManifest, surfaceTier, } from "./manifest.js";
8
8
  export { NudgeEngine, validateNudgeRules } from "./nudge.js";
@@ -1,4 +1,7 @@
1
- const DEFAULT_META_TOOLS = new Set(["skill", "memory", "knowledge", "update_plan"]);
1
+ // Mirrors the kernel's EXPOSURE_EXEMPT_META_TOOLS invariant: kernel-owned meta surfaces are
2
+ // never narrowed away by a tool allow-list. `read_result` is runner-resolved before reaching any
3
+ // plane today, but the lists must not drift.
4
+ const DEFAULT_META_TOOLS = new Set(["skill", "memory", "knowledge", "update_plan", "read_result"]);
2
5
  /** Wraps an execution plane, allowing only manifest-permitted tool IDs (+ meta-tools). */
3
6
  export class FilteredExecutionPlane {
4
7
  inner;
@@ -144,12 +144,12 @@ export interface RuntimeOptions {
144
144
  phase?: "initial" | "renewal";
145
145
  }) => Promise<MemoryQuery[] | undefined> | MemoryQuery[] | undefined;
146
146
  systemPrompt?: string;
147
- /** Self-Harness H1.1: the four instruction slots (bootstrap/execution/verification/failureRecovery)
147
+ /** Self-Harness instruction surface: the four instruction slots (bootstrap/execution/verification/failureRecovery)
148
148
  * composed onto `systemPrompt` in fixed order at option normalization. The kernel still sees ONE
149
149
  * system prompt; this is the editable instruction surface the self-harness loop rewrites. Absent ⇒
150
150
  * `systemPrompt` is used verbatim (zero behavior difference). */
151
151
  instructions?: InstructionProfile;
152
- /** Self-Harness H1.2: declarative event→note rules. On each matching session event a rendered note
152
+ /** Self-Harness nudge surface: declarative event→note rules. On each matching session event a rendered note
153
153
  * is pushed through the `injectNote` signal channel (same path as `onToolResult`'s `{note}`).
154
154
  * ≤16 rules, validated at construction. Absent/empty ⇒ no engine, no append wrapping, zero
155
155
  * behavior difference. */
@@ -306,13 +306,57 @@ export interface RuntimeOptions {
306
306
  }) => Promise<MilestoneCheckResult> | MilestoneCheckResult;
307
307
  /** Passed to kernel start_run for role/isolation metadata. */
308
308
  runSpec?: AgentRunSpec;
309
- /** P0-A tool gating: a static per-run tool profile — only these tool ids (plus the
310
- * skill/memory/knowledge/update_plan meta-tools) are exposed to the model each turn.
311
- * Sugar that lowers to the same `capability_filter` sub-agents use; byte-stable across
312
- * the run, so it never busts the prompt-cache prefix. Augments `runSpec`'s filter when
313
- * both are set; synthesizes a minimal run spec when `runSpec` is absent. Omitted/empty
314
- * all registered tools exposed (no gating). */
309
+ /**
310
+ * The run's **exposure ceiling** the outer bound on what this run may EVER advertise to the
311
+ * model. Not a static profile: it is an INTERSECTION applied on every turn (`exposed ⊆ ceiling`),
312
+ * so every narrowing mechanism operates *within* it and none can widen past it. Skills narrow
313
+ * inside the ceiling (`allowed_tools`), `baselineToolIds` selects which of the ceiling's tools are
314
+ * exposed before any skill activates, `stableCoreToolIds` pins tools against skill narrowing, and
315
+ * the self-harness manifest surface folds by intersection for exactly this reason.
316
+ *
317
+ * Exempt on the id axis: the kernel-owned meta-tools (`skill`, `memory`, `knowledge`,
318
+ * `update_plan`, `read_result`) stay exposed regardless of this list — a ceiling that hid `skill`
319
+ * would make progressive disclosure unreachable. The KIND axis
320
+ * (`runSpec.capabilityFilter.allowedKinds`) still applies to them.
321
+ *
322
+ * Byte-stable across the run, so it never busts the prompt-cache prefix. Lowers to the same
323
+ * `capability_filter` sub-agents use: augments `runSpec`'s filter when both are set, else
324
+ * synthesizes a minimal run spec. Omitted **or empty** ⇒ no ceiling (all registered tools) — the
325
+ * empty array is NOT a minimal surface here; use `baselineToolIds: []` for that.
326
+ *
327
+ * Enforcement: `toolDispatchGate` (default `"exposed"`) makes this a real boundary — a call to a
328
+ * tool outside the advertised set never executes.
329
+ */
315
330
  allowedToolIds?: string[];
331
+ /**
332
+ * The **pre-activation** exposure surface, selected from under the `allowedToolIds` ceiling.
333
+ * Makes the narrow→wide progressive-disclosure shape expressible: start the run advertising only
334
+ * these tools, and let a skill activation widen the surface by exactly its declared
335
+ * `allowed_tools` (still ∩ the ceiling). Per turn:
336
+ *
337
+ * `exposed = meta ∪ ((baseline ∪ stableCore ∪ ⋃ activeSkills.allowed_tools) ∩ ceiling)`
338
+ *
339
+ * An active skill that declares no `allowed_tools` contributes nothing — with a baseline set the
340
+ * surface stays narrow (strict; the legacy errs-open widening is deliberately not inherited).
341
+ *
342
+ * `undefined` ⇒ legacy behavior, byte-identical (ceiling + errs-open skill narrowing). `[]` is a
343
+ * legitimate, distinct value: the minimal surface (meta-tools + `stableCoreToolIds` only) — the
344
+ * `allowedToolIds` "empty means no gating" trap does NOT recur here. Entries outside the ceiling
345
+ * silently intersect away (no start_run error), the same fold every id-list surface uses.
346
+ */
347
+ baselineToolIds?: string[];
348
+ /**
349
+ * Dispatch enforcement for the exposure surface. `"exposed"` (default) is fail-closed: a tool call
350
+ * the model was never advertised this turn never reaches the host — the kernel commits a
351
+ * model-visible `governance_denied` result instead ("Tool 'X' is not part of this run's toolset"),
352
+ * which feeds the repeat fuse like any other denial. Allowed siblings in the same batch still
353
+ * execute; `pace` and the meta-tool family always pass through.
354
+ *
355
+ * `"registered"` is the escape hatch restoring the pre-gate permissive behavior (any registered
356
+ * tool the model names executes, even if it was gated out of the tools schema). Set it only when a
357
+ * host deliberately relies on blind calls to unadvertised tools.
358
+ */
359
+ toolDispatchGate?: "exposed" | "registered";
316
360
  /** P0-C: optional per-turn metrics sink for tool-gating telemetry (see `TurnMetrics`). Pure
317
361
  * observation; invoked once per LLM turn. Never throws into the run loop (errors are swallowed). */
318
362
  onTurnMetrics?: (metrics: TurnMetrics) => void;
@@ -438,6 +438,11 @@ export class RuntimeRunner {
438
438
  if (this.opts.criteriaGate !== undefined) {
439
439
  config.criteria_gate = this.opts.criteriaGate;
440
440
  }
441
+ // P1: fail-closed dispatch selector (absent ⇒ kernel default "exposed"). "registered" is the
442
+ // escape hatch back to permissive dispatch; the kernel rejects any other value.
443
+ if (this.opts.toolDispatchGate !== undefined) {
444
+ config.tool_dispatch_gate = this.opts.toolDispatchGate;
445
+ }
441
446
  // K2: knowledge budget ratio (absent ⇒ kernel default 0.25; 0 disables).
442
447
  if (this.opts.knowledgeBudgetRatio !== undefined) {
443
448
  config.knowledge_budget_ratio = this.opts.knowledgeBudgetRatio;
@@ -1579,21 +1584,28 @@ export class RuntimeRunner {
1579
1584
  kind: "start_run",
1580
1585
  task: { goal, criteria },
1581
1586
  };
1582
- // P0-A: lower an explicit `runSpec` and/or the `allowedToolIds` profile to the kernel's
1583
- // `capability_filter`. `allowedToolIds` augments an explicit spec's filter, else synthesizes
1584
- // a minimal top-level spec carrying just the filter (reuses the existing run_spec wire — no
1585
- // new ABI). Unset on both ⇒ no run_spec ⇒ no gating (铁律: no config = old behavior).
1587
+ // P0-A: lower an explicit `runSpec`, the `allowedToolIds` ceiling, and/or the `baselineToolIds`
1588
+ // pre-activation surface to the kernel run spec. Each augments an explicit spec, else
1589
+ // synthesizes a minimal top-level spec carrying just the exposure config (reuses the existing
1590
+ // run_spec wire — no new ABI). Unset on all ⇒ no run_spec ⇒ no gating (铁律: no config = old
1591
+ // behavior).
1586
1592
  const allowedToolIds = this.opts.allowedToolIds;
1587
1593
  const hasProfile = allowedToolIds !== undefined && allowedToolIds.length > 0;
1588
- if (this.opts.runSpec || hasProfile) {
1594
+ // NOT the `length > 0` idiom above: `baselineToolIds: []` is the legitimate minimal surface
1595
+ // (meta + stable-core only), so mere presence triggers the lowering.
1596
+ const baselineToolIds = this.opts.baselineToolIds;
1597
+ const hasBaseline = baselineToolIds !== undefined;
1598
+ if (this.opts.runSpec || hasProfile || hasBaseline) {
1589
1599
  const baseSpec = this.opts.runSpec ?? {
1590
1600
  identity: { agentId: this.opts.agentId ?? "root", sessionId, isSubAgent: false },
1591
1601
  role: "custom",
1592
1602
  goal,
1593
1603
  };
1594
- const spec = hasProfile
1604
+ let spec = hasProfile
1595
1605
  ? { ...baseSpec, capabilityFilter: { ...baseSpec.capabilityFilter, allowedIds: allowedToolIds } }
1596
1606
  : baseSpec;
1607
+ if (hasBaseline)
1608
+ spec = { ...spec, exposureBaseline: baselineToolIds };
1597
1609
  startPayload.run_spec = agentRunSpecToKernel(spec);
1598
1610
  }
1599
1611
  // Reserve capacity before start_run. The kernel enforces only this vehicle's grant and reports
@@ -41,6 +41,15 @@ export interface AgentRunSpec {
41
41
  /** ③ loop-agent rounds: presence makes this run ONE round of a paced loop (gates the
42
42
  * kernel `pace` meta-tool and arms the pacing trap). */
43
43
  loopRound?: LoopRoundSpec;
44
+ /** Exposure baseline — the PRE-ACTIVATION tool surface *under* the `capabilityFilter` ceiling.
45
+ * The ceiling bounds what this run may EVER expose; the baseline selects which of those are
46
+ * advertised before any skill activates, so `exposed = meta ∪ ((baseline ∪ stableCore ∪
47
+ * ⋃ activeSkills.allowed_tools) ∩ ceiling)`. That makes narrow→wide progressive disclosure
48
+ * expressible: a tool can be reachable after `skill(x)` without being advertised beforehand.
49
+ * Absent ⇒ legacy behavior (ceiling + errs-open skill narrowing). `[]` is meaningful and
50
+ * distinct from absent: the minimal surface (meta-tools + stable-core only). Entries outside
51
+ * the ceiling silently intersect away. Lowered from `RuntimeOptions.baselineToolIds`. */
52
+ exposureBaseline?: string[];
44
53
  /** M1/G3: per-agent model preference (e.g. "opus"/"sonnet"/"haiku"); the host resolves it to a
45
54
  * provider via `RuntimeOptions.providerFor`. Host-side routing only — not sent to the kernel. */
46
55
  modelHint?: string;
@@ -54,6 +54,11 @@ export function agentRunSpecToKernel(spec) {
54
54
  ...(spec.loopRound.defaultAction !== undefined ? { default_action: spec.loopRound.defaultAction } : {}),
55
55
  };
56
56
  }
57
+ // Exposure baseline: `undefined` ⇒ omit the field entirely (kernel `None` = legacy behavior);
58
+ // `[]` ⇒ send `[]` (kernel `Some([])` = the minimal surface). The unset/minimal distinction is
59
+ // load-bearing, so this is deliberately NOT the `length > 0` idiom `allowedToolIds` uses.
60
+ if (spec.exposureBaseline !== undefined)
61
+ out.exposure_baseline = [...spec.exposureBaseline];
57
62
  return out;
58
63
  }
59
64
  export function milestoneContractToKernel(contract) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepstrike/sdk",
3
- "version": "0.2.48",
3
+ "version": "0.2.50",
4
4
  "description": "DeepStrike Node.js SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@anthropic-ai/sdk": "^0.99.0",
75
- "@deepstrike/core": "0.2.48",
75
+ "@deepstrike/core": "0.2.50",
76
76
  "@google/generative-ai": "^0.24.1",
77
77
  "openai": "^5.23.2"
78
78
  },