@deepstrike/sdk 0.2.49 → 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".
@@ -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,12 +63,13 @@ 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
  /**
@@ -165,23 +175,19 @@ function validateRuntimePatch(runtime) {
165
175
  }
166
176
  }
167
177
  }
168
- /**
169
- * Validate an id-list surface: array of unique, non-empty strings (each ≤128 chars), ≤128 entries.
170
- * `allowEmpty` is the load-bearing asymmetry. For the tool-id arrays it is FALSE: the runner reads an
171
- * empty/absent `allowedToolIds` as "no gating — expose ALL registered tools", so an empty array would
172
- * WIDEN exposure to everything if it reached the runner (and a zero-tool run is the v0.2.46 pathology).
173
- * For `skillFilter` it is TRUE: the runner's no-gating sentinel is ONLY `undefined`, and an empty array
174
- * legitimately means "no skills available" (a proposer may find skills are a distraction) — a narrowing.
175
- */
176
- 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) {
177
180
  if (!Array.isArray(value))
178
181
  throw new TypeError(`runtime.${key} must be a string[]`);
179
182
  if (value.length > MAX_TOOL_LIST_ENTRIES) {
180
183
  throw new RangeError(`runtime.${key} exceeds ${MAX_TOOL_LIST_ENTRIES} entries`);
181
184
  }
182
- if (!allowEmpty && value.length === 0) {
185
+ if (emptyPolicy === "reject:widens" && value.length === 0) {
183
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`);
184
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
+ }
185
191
  const seen = new Set();
186
192
  for (const entry of value) {
187
193
  if (typeof entry !== "string" || entry.length === 0) {
@@ -219,10 +225,13 @@ function validateRuntimeValue(key, value) {
219
225
  return;
220
226
  case "allowedToolIds":
221
227
  case "stableCoreToolIds":
222
- validateIdList(key, value, /* allowEmpty */ false);
228
+ validateIdList(key, value, "reject:widens");
229
+ return;
230
+ case "baselineToolIds":
231
+ validateIdList(key, value, "reject:drastic");
223
232
  return;
224
233
  case "skillFilter":
225
- validateIdList(key, value, /* allowEmpty */ true);
234
+ validateIdList(key, value, "allow");
226
235
  return;
227
236
  case "knowledgeBudgetRatio":
228
237
  if (typeof value !== "number" || !(value > 0 && value <= 1)) {
@@ -335,7 +344,9 @@ export function applyManifest(manifest, base) {
335
344
  }
336
345
  /**
337
346
  * Fold one intersection surface (capability ceiling): effective = manifest ∩ host-baseline, so a
338
- * 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:
339
350
  *
340
351
  * - allowedToolIds / stableCoreToolIds — the runner reads an empty OR absent baseline as
341
352
  * "no gating = all registered tools" (the universe), so a non-array/empty baseline yields the
@@ -343,19 +354,22 @@ export function applyManifest(manifest, base) {
343
354
  * empty intersection THROWS: a zero-tool run reprises the v0.2.46 pathology AND the runner would
344
355
  * silently reinterpret the empty result as "no gating" (full exposure) — so we turn the candidate
345
356
  * into a discardable error instead.
346
- * - skillFilter — the runner's no-gating sentinel is ONLY `undefined`; an empty-array baseline is a
347
- * genuine, maximally-tight ceiling (no skills). So ANY present array (even `[]`) is intersected,
348
- * 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.
349
363
  */
350
364
  function foldIntersection(key, manifestList, baseList) {
351
- const skillLike = key === "skillFilter";
352
- // Is the host baseline a real constraining set? Tool ids: non-empty array only (empty == universe).
353
- // skillFilter: any array (empty == the empty set).
354
- 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);
355
369
  const effective = constrained
356
370
  ? manifestList.filter(id => baseList.includes(id)) // manifest order → deterministic
357
371
  : manifestList;
358
- if (!skillLike && effective.length === 0) {
372
+ if (!emptyBaselineIsTight && effective.length === 0) {
359
373
  throw new RangeError(`applyManifest: runtime.${key} intersection is empty — manifest [${manifestList.join(", ")}] ∩ ` +
360
374
  `host [${(baseList ?? []).join(", ")}] names no shared tool. A zero-tool run is rejected (it ` +
361
375
  `reprises the v0.2.46 pathology and the runner would read empty as "no gating" = full exposure).`);
@@ -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/read_result 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.49",
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.49",
75
+ "@deepstrike/core": "0.2.50",
76
76
  "@google/generative-ai": "^0.24.1",
77
77
  "openai": "^5.23.2"
78
78
  },