@sema-agent/core 2.7.0 → 2.9.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.
Files changed (45) hide show
  1. package/dist/agents/send-message-tool.d.ts +2 -0
  2. package/dist/agents/send-message-tool.js +38 -30
  3. package/dist/agents/subagent.js +15 -3
  4. package/dist/brain/circuit-breaker.js +18 -8
  5. package/dist/brain/retry.d.ts +1 -0
  6. package/dist/brain/retry.js +29 -7
  7. package/dist/brain/stream-engine.d.ts +1 -0
  8. package/dist/brain/stream-engine.js +74 -12
  9. package/dist/core/auto-compaction.js +9 -1
  10. package/dist/core/background-agent-store.d.ts +2 -0
  11. package/dist/core/background-agent-store.js +20 -0
  12. package/dist/core/mcp.js +8 -5
  13. package/dist/core/runner/prepare-task.js +25 -8
  14. package/dist/core/runner/runtask.js +20 -7
  15. package/dist/core/runner/tool-disclosure.d.ts +8 -3
  16. package/dist/core/runner/tool-disclosure.js +22 -8
  17. package/dist/core/skills-directory.d.ts +1 -1
  18. package/dist/core/skills-directory.js +257 -28
  19. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +3 -1
  20. package/dist/core/store-contracts/file-snapshot-store-contract.js +11 -3
  21. package/dist/core/task-registry-agent.d.ts +2 -1
  22. package/dist/core/task-registry-agent.js +47 -54
  23. package/dist/core/task-registry.d.ts +1 -0
  24. package/dist/core/task-registry.js +1 -1
  25. package/dist/core/tools.d.ts +6 -2
  26. package/dist/core/tools.js +3 -2
  27. package/dist/core/types.d.ts +5 -1
  28. package/dist/engine/compaction/compaction.js +71 -20
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/internal/harness-types.d.ts +1 -1
  32. package/dist/internal/harness.d.ts +1 -1
  33. package/dist/internal/harness.js +1 -1
  34. package/dist/orchestration/run-workflow-tool.d.ts +1 -0
  35. package/dist/orchestration/run-workflow-tool.js +4 -1
  36. package/dist/orchestration/workflow.d.ts +1 -1
  37. package/dist/orchestration/workflow.js +2 -2
  38. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -0
  39. package/dist/tools/fs/bash-readonly-classifier.js +113 -33
  40. package/dist/tools/fs/fs-bash.d.ts +2 -1
  41. package/dist/tools/fs/fs-bash.js +26 -6
  42. package/dist/tools/fs/fs-shared.d.ts +1 -0
  43. package/dist/tools/fs/fs-shared.js +44 -2
  44. package/dist/tools/fs/index.js +1 -0
  45. package/package.json +1 -1
@@ -758,6 +758,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
758
758
  clientContext: spec.clientContext,
759
759
  excludeTools: toolFaceSnapshot.exclude,
760
760
  deferTools: toolFaceSnapshot.defer,
761
+ alwaysLoadTools: toolFaceSnapshot.alwaysLoad,
761
762
  promptProfile,
762
763
  ...(spec.additionalDirectories !== undefined ? { additionalDirectories: Object.freeze([...spec.additionalDirectories]) } : {}),
763
764
  ...(spec.envFacts !== undefined ? { envFacts: { ...spec.envFacts } } : {}),
@@ -885,6 +886,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
885
886
  governanceBaseline: deps.workflowGovernanceBaseline,
886
887
  parentExcludeTools: toolFaceSnapshot.exclude,
887
888
  parentDeferTools: toolFaceSnapshot.defer,
889
+ parentAlwaysLoadTools: toolFaceSnapshot.alwaysLoad,
888
890
  parentPromptProfile: promptProfile,
889
891
  models: deps.models,
890
892
  agents: deps.agents,
@@ -1278,6 +1280,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1278
1280
  ...(internals?.parentRetainLedger !== undefined ? { siblingRetain: internals.parentRetainLedger } : {}),
1279
1281
  ...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
1280
1282
  ...(internals?.parentSessionId !== undefined ? { parentSessionId: internals.parentSessionId } : {}),
1283
+ enrichCtx: enrichSpecToolCtx,
1281
1284
  ...(deps.rosterStore !== undefined ? { roster: deps.rosterStore } : {}),
1282
1285
  ...(deps.onBackgroundChildEvent ? { onBackgroundChildEvent: deps.onBackgroundChildEvent } : {}),
1283
1286
  ...(deps.backgroundAgentStore !== undefined ? { agentStore: deps.backgroundAgentStore } : {}),
@@ -1795,7 +1798,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1795
1798
  deferNames: (toolFaceSnapshot.defer ?? []).filter((n) => tools.some((t) => t.name === n)),
1796
1799
  alwaysLoadNames: [
1797
1800
  ...(toolFaceSnapshot.alwaysLoad ?? []),
1798
- ...mcp.tools.filter((t) => t.mcpAlwaysLoad === true).map((t) => t.name),
1801
+ ...mcp.tools
1802
+ .filter((t) => t.mcpAlwaysLoad === true && !(toolFaceSnapshot.defer ?? []).includes(t.name))
1803
+ .map((t) => t.name),
1799
1804
  ],
1800
1805
  });
1801
1806
  for (const n of [...deferred]) {
@@ -1830,21 +1835,32 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1830
1835
  throw e;
1831
1836
  }
1832
1837
  const registry = buildDeferredRegistry(deferred, tools);
1833
- const realByName = new Map(tools.map((t) => [t.name, t]));
1834
1838
  const directCallFor = (name) => {
1835
1839
  if (spec.deferSelfResolve === false)
1836
1840
  return undefined;
1837
- const real = realByName.get(name);
1838
- if (real === undefined)
1839
- return undefined;
1841
+ const executionMode = tools.find((t) => t.name === name)?.executionMode;
1840
1842
  return {
1841
- parameters: real.parameters,
1842
- invoke: (toolCallId, params, signal) => real.execute(toolCallId, params, signal),
1843
+ resolveReal: () => {
1844
+ const real = tools.find((t) => t.name === name);
1845
+ if (real === undefined)
1846
+ return undefined;
1847
+ return {
1848
+ parameters: real.parameters,
1849
+ invoke: (toolCallId, params, signal, onUpdate) => real.execute(toolCallId, params, signal, onUpdate),
1850
+ };
1851
+ },
1852
+ ...(executionMode !== undefined ? { executionMode } : {}),
1843
1853
  activate: async () => {
1844
1854
  if (activeTools.has(name))
1845
1855
  return;
1846
1856
  activeTools.add(name);
1847
- await rematerialize(activeTools);
1857
+ try {
1858
+ await rematerialize(activeTools);
1859
+ }
1860
+ catch (e) {
1861
+ activeTools.delete(name);
1862
+ throw e;
1863
+ }
1848
1864
  },
1849
1865
  };
1850
1866
  };
@@ -1917,6 +1933,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1917
1933
  rematerialize,
1918
1934
  listingRide: (newly) => listingRideRef.current?.(newly),
1919
1935
  mountedNames: () => new Set(buildToolList(activeTools).map((t) => t.name).filter((n) => !registry.has(n) || activeTools.has(n))),
1936
+ directCallEnabled: spec.deferSelfResolve !== false,
1920
1937
  });
1921
1938
  harnessTools = buildToolList(activeTools);
1922
1939
  }
@@ -688,13 +688,6 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
688
688
  queue.push({ type: "compaction_outcome", outcome, trigger: passTrigger, ...ident() });
689
689
  }
690
690
  }
691
- if (comp.compacted && !forceManual) {
692
- if (recordCompactionAndCheckRapidRefill(rapidRefill, stats.turns)) {
693
- compactionBreaker.failures = MAX_CONSECUTIVE_COMPACTION_FAILURES;
694
- runnerHooks.onError?.(new Error("compaction.rapid_refill: the context refilled within <3 turns of compaction 3 times in a row — compaction disabled for the rest of this task (thrash spiral; the transcript is dominated by incompressible content)"), { phase: "compaction", sessionId: prepared.sessionId });
695
- queue.push({ type: "compaction_outcome", outcome: "disabled", trigger: passTrigger, reason: "rapid_refill: compaction disabled for the rest of this task", ...ident() });
696
- }
697
- }
698
691
  runnerHooks.recordCompactionReuse(prepared, comp);
699
692
  if (comp.compacted) {
700
693
  queue.push({
@@ -713,6 +706,11 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
713
706
  ...(comp.clampReason !== undefined ? { clampReason: comp.clampReason } : {}),
714
707
  ...ident(),
715
708
  });
709
+ if (!forceManual && recordCompactionAndCheckRapidRefill(rapidRefill, stats.turns)) {
710
+ compactionBreaker.failures = MAX_CONSECUTIVE_COMPACTION_FAILURES;
711
+ queue.push({ type: "compaction_outcome", outcome: "disabled", trigger: passTrigger, reason: "rapid_refill: compaction disabled for the rest of this task", ...ident() });
712
+ runnerHooks.onError?.(new Error("compaction.rapid_refill: the context refilled within <3 turns of compaction 3 times in a row — compaction disabled for the rest of this task (thrash spiral; the transcript is dominated by incompressible content)"), { phase: "compaction", sessionId: prepared.sessionId });
713
+ }
716
714
  if (comp.phaseDurations !== undefined && comp.durationMs !== undefined) {
717
715
  const pd = comp.phaseDurations;
718
716
  const pdDur = comp.durationMs;
@@ -2074,6 +2072,9 @@ export class Runner {
2074
2072
  phase: s.phase,
2075
2073
  ...(s.detail !== undefined ? { detail: s.detail } : {}),
2076
2074
  ...(s.retryInSec !== undefined ? { retryInSec: s.retryInSec } : {}),
2075
+ ...(s.retryInMs !== undefined ? { retryInMs: s.retryInMs } : {}),
2076
+ ...(s.attempt !== undefined ? { attempt: s.attempt } : {}),
2077
+ ...(s.maxRetries !== undefined ? { maxRetries: s.maxRetries } : {}),
2077
2078
  ...ident(),
2078
2079
  });
2079
2080
  };
@@ -2675,6 +2676,7 @@ export class Runner {
2675
2676
  minTokens: rs.counters.compactionFloor,
2676
2677
  brain: compactionBrain,
2677
2678
  windowSafety: windowSafetyOptions(prepared.model),
2679
+ onCompactionFailed: (reason) => queue.push({ type: "compaction_outcome", outcome: "failed", trigger: "auto", reason, ...ident() }),
2678
2680
  });
2679
2681
  try {
2680
2682
  if (comp?.compacted) {
@@ -3516,6 +3518,17 @@ export class Runner {
3516
3518
  comp = finishComp;
3517
3519
  }
3518
3520
  catch (err) {
3521
+ const failMsg = String(err instanceof Error ? err.message : err);
3522
+ const failReason = failMsg.length > 512 ? `${failMsg.slice(0, 512)}…` : failMsg;
3523
+ emitTrace(spec.tracer ?? this.deps.tracer, () => ({
3524
+ kind: "compaction.failed",
3525
+ version: 1,
3526
+ taskId: spec.taskId ?? prepared.sessionId,
3527
+ trigger: "auto",
3528
+ reason: failReason,
3529
+ ts: Date.now(),
3530
+ }));
3531
+ opts?.onCompactionFailed?.(failReason);
3519
3532
  this.deps.onError?.(err, { phase: "compaction", sessionId: prepared.sessionId });
3520
3533
  }
3521
3534
  }
@@ -1,5 +1,5 @@
1
1
  import { type TSchema } from "typebox";
2
- import type { AgentMessage, AgentTool, AgentToolResult } from "../../internal/harness-types.js";
2
+ import type { AgentMessage, AgentTool, AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode } from "../../internal/harness-types.js";
3
3
  import type { Model } from "../../internal/llm.js";
4
4
  import type { ToolSpec } from "../types.js";
5
5
  export declare const TOOL_SEARCH_NAME = "ToolSearch";
@@ -29,10 +29,14 @@ export declare function buildDeferredRegistry(deferred: ReadonlySet<string>, too
29
29
  description: string;
30
30
  }>): Map<string, DeferredToolInfo>;
31
31
  export interface PlaceholderDirectCall {
32
- parameters: TSchema;
33
- invoke: (toolCallId: string, params: unknown, signal?: AbortSignal) => Promise<AgentToolResult<unknown>>;
32
+ resolveReal: () => PlaceholderDirectTarget | undefined;
33
+ executionMode?: ToolExecutionMode;
34
34
  activate: () => Promise<void>;
35
35
  }
36
+ export interface PlaceholderDirectTarget {
37
+ parameters: TSchema;
38
+ invoke: (toolCallId: string, params: unknown, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<unknown>) => Promise<AgentToolResult<unknown>>;
39
+ }
36
40
  export declare function createPlaceholderTool(info: DeferredToolInfo, direct?: PlaceholderDirectCall): AgentTool;
37
41
  export declare function scoreToolMatch(query: string, info: DeferredToolInfo): number;
38
42
  export interface ToolSearchArgs {
@@ -52,4 +56,5 @@ export declare function createToolSearchTool(opts: {
52
56
  rematerialize: (active: ReadonlySet<string>) => Promise<void>;
53
57
  listingRide?: (newlyActivated: readonly string[]) => string | undefined;
54
58
  mountedNames?: () => ReadonlySet<string>;
59
+ directCallEnabled?: boolean;
55
60
  }): AgentTool;
@@ -43,7 +43,8 @@ export function classifyDeferred(opts) {
43
43
  deferred.add(name);
44
44
  if (opts.deferMode === "auto") {
45
45
  const candidates = opts.fullTools.filter((t) => !deferred.has(t.name) && !pinned.has(t.name));
46
- const total = candidates.reduce((n, t) => n + inlinedChars(t), 0);
46
+ const inlineFace = opts.fullTools.filter((t) => !deferred.has(t.name));
47
+ const total = inlineFace.reduce((n, t) => n + inlinedChars(t), 0);
47
48
  const window = (opts.model.contextTokens ?? opts.model.contextWindow ?? 0) * CHARS_PER_TOKEN;
48
49
  if (window > 0 && total > DEFER_AUTO_FRACTION * window) {
49
50
  for (const t of candidates)
@@ -70,12 +71,16 @@ export function createPlaceholderTool(info, direct) {
70
71
  return {
71
72
  name: info.name,
72
73
  label: info.name,
73
- description: `${info.hint} — deferred: call ${TOOL_SEARCH_NAME}({"query":"select:${sn}"}) to load its parameters before use.`,
74
+ description: `${info.hint} — deferred: its parameters are not listed here. Call ` +
75
+ `${TOOL_SEARCH_NAME}({"query":"select:${sn}"}) to load them; a call that already matches this ` +
76
+ `tool's real parameters runs directly and activates it.`,
74
77
  parameters: EMPTY_PARAMS,
75
- execute: async (toolCallId, params, signal) => {
76
- if (Value.Check(direct.parameters, params)) {
78
+ ...(direct.executionMode !== undefined ? { executionMode: direct.executionMode } : {}),
79
+ execute: async (toolCallId, params, signal, onUpdate) => {
80
+ const real = direct.resolveReal();
81
+ if (real !== undefined && Value.Check(real.parameters, params)) {
77
82
  await direct.activate();
78
- return direct.invoke(toolCallId, params, signal);
83
+ return real.invoke(toolCallId, params, signal, onUpdate);
79
84
  }
80
85
  return teachingRejection();
81
86
  },
@@ -178,13 +183,22 @@ export function extractDiscoveredToolNames(messages, registry) {
178
183
  }
179
184
  export function createToolSearchTool(opts) {
180
185
  const { registry, active, rematerialize, listingRide, mountedNames } = opts;
186
+ const directCallEnabled = opts.directCallEnabled !== false;
187
+ const activationPosture = directCallEnabled
188
+ ? "Most tools start as name-only placeholders to keep requests small; activating one here loads its full " +
189
+ "parameter schema. Until you have that schema you cannot reliably form a call, so activate a tool rather " +
190
+ "than guessing its arguments — a call that does match the real schema executes and activates the tool. " +
191
+ "When any instruction, reminder, or another tool's description names a deferred tool, activate it here " +
192
+ 'with query "select:<name>". '
193
+ : "Most tools start as name-only placeholders to keep requests small; to USE one you must activate it here " +
194
+ "first. When any instruction, reminder, or another tool's description names a deferred tool, activate it " +
195
+ 'with query "select:<name>" before calling it. ';
181
196
  let activationChain = Promise.resolve();
182
197
  return defineTool({
183
198
  name: TOOL_SEARCH_NAME,
184
199
  contract: { contractId: "core.tool_search@1", implementationRevision: "1" },
185
- description: "Discover and activate deferred tools. Most tools start as name-only placeholders to keep requests " +
186
- "small; to USE one you must activate it here first. When any instruction, reminder, or another " +
187
- 'tool\'s description names a deferred tool, activate it with query "select:<name>" before calling it. ' +
200
+ description: "Discover and activate deferred tools. " +
201
+ activationPosture +
188
202
  "Query forms: " +
189
203
  '"select:ToolA,ToolB" — activate these exact tools by name; ' +
190
204
  '"notebook jupyter" — keyword search, up to max_results best matches; ' +
@@ -1,5 +1,5 @@
1
1
  import type { SkillSpec } from "./types.js";
2
- export type SkillsDirectoryWarningCode = "no_skill_file" | "no_frontmatter" | "missing_name" | "invalid_name" | "name_mismatch" | "missing_description" | "description_too_long" | "allowed_tool_not_mounted" | "attachment_skipped" | "read_failed";
2
+ export type SkillsDirectoryWarningCode = "no_skill_file" | "no_frontmatter" | "missing_name" | "invalid_name" | "name_mismatch" | "missing_description" | "description_too_long" | "allowed_tool_not_mounted" | "disallowed_tools_unenforced" | "attachment_skipped" | "read_failed";
3
3
  export interface SkillsDirectoryWarning {
4
4
  code: SkillsDirectoryWarningCode;
5
5
  skill: string;
@@ -1,5 +1,6 @@
1
- import { readFileSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+ import { canonicalToolName } from "./tool-name-aliases.js";
3
4
  const SKILL_FILE = "SKILL.md";
4
5
  const RESOURCE_DIRS = ["assets", "references", "scripts"];
5
6
  const NAME_MAX_CHARS = 64;
@@ -22,26 +23,139 @@ function parseSkillFile(text) {
22
23
  }
23
24
  if (end === -1)
24
25
  return { fields, body: normalized, hadFrontmatter: false };
25
- for (let i = 1; i < end; i++) {
26
+ let i = 1;
27
+ while (i < end) {
26
28
  const line = lines[i];
27
29
  const trimmed = line.trim();
28
- if (trimmed === "" || trimmed.startsWith("#"))
30
+ if (trimmed === "" || trimmed.startsWith("#")) {
31
+ i++;
29
32
  continue;
30
- if (/^\s/.test(line))
33
+ }
34
+ if (/^\s/.test(line)) {
35
+ i++;
31
36
  continue;
37
+ }
32
38
  const kv = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(trimmed);
33
- if (!kv)
39
+ if (!kv) {
40
+ i++;
34
41
  continue;
42
+ }
35
43
  const [, key, raw] = kv;
44
+ const rawValue = raw.trim();
45
+ const block = parseBlockScalarHeader(rawValue);
46
+ if (block !== undefined) {
47
+ const read = readBlockScalar(lines, i + 1, end, block);
48
+ i = read.next;
49
+ if (!fields.has(key))
50
+ fields.set(key, read.value);
51
+ continue;
52
+ }
53
+ if (rawValue.startsWith("[") && !isFlowSequenceClosed(rawValue)) {
54
+ const joined = readFlowSequenceContinuation(lines, i + 1, end, rawValue);
55
+ i = joined.next;
56
+ if (!fields.has(key))
57
+ fields.set(key, joined.value);
58
+ continue;
59
+ }
60
+ i++;
36
61
  if (fields.has(key))
37
62
  continue;
38
- fields.set(key, unquote(raw.trim()));
63
+ fields.set(key, unquote(rawValue));
39
64
  }
40
65
  let body = lines.slice(end + 1).join("\n");
41
66
  if (body.startsWith("\n"))
42
67
  body = body.slice(1);
43
68
  return { fields, body, hadFrontmatter: true };
44
69
  }
70
+ function parseBlockScalarHeader(value) {
71
+ const m = /^([|>])([0-9+-]*)$/.exec(value);
72
+ if (!m)
73
+ return undefined;
74
+ const rest = m[2];
75
+ if (!/^(?:[1-9]?[+-]?|[+-][1-9]?)$/.test(rest))
76
+ return undefined;
77
+ const digit = /[1-9]/.exec(rest)?.[0];
78
+ return { style: m[1] === "|" ? "literal" : "folded", ...(digit !== undefined ? { indent: Number(digit) } : {}) };
79
+ }
80
+ function readBlockScalar(lines, start, end, header) {
81
+ const indentOf = (s) => /^[ \t]*/.exec(s)[0].length;
82
+ let indent = header.indent;
83
+ if (indent === undefined) {
84
+ for (let j = start; j < end; j++) {
85
+ if (lines[j].trim() === "")
86
+ continue;
87
+ indent = indentOf(lines[j]);
88
+ break;
89
+ }
90
+ }
91
+ if (indent === undefined || indent === 0)
92
+ return { value: "", next: start };
93
+ const taken = [];
94
+ let j = start;
95
+ for (; j < end; j++) {
96
+ const line = lines[j];
97
+ if (line.trim() === "") {
98
+ taken.push("");
99
+ continue;
100
+ }
101
+ if (indentOf(line) < indent)
102
+ break;
103
+ taken.push(line.slice(indent));
104
+ }
105
+ while (taken.length > 0 && taken[taken.length - 1] === "")
106
+ taken.pop();
107
+ if (header.style === "literal")
108
+ return { value: taken.join("\n"), next: j };
109
+ let folded = "";
110
+ let blanks = 0;
111
+ let started = false;
112
+ let prevMoreIndented = false;
113
+ for (const cur of taken) {
114
+ if (cur === "") {
115
+ blanks++;
116
+ continue;
117
+ }
118
+ const moreIndented = indentOf(cur) > 0;
119
+ if (!started) {
120
+ folded = cur;
121
+ started = true;
122
+ }
123
+ else if (blanks > 0) {
124
+ folded += "\n".repeat(blanks) + cur;
125
+ }
126
+ else if (moreIndented || prevMoreIndented) {
127
+ folded += `\n${cur}`;
128
+ }
129
+ else {
130
+ folded += ` ${cur}`;
131
+ }
132
+ blanks = 0;
133
+ prevMoreIndented = moreIndented;
134
+ }
135
+ return { value: folded, next: j };
136
+ }
137
+ function isFlowSequenceClosed(text) {
138
+ let depth = 0;
139
+ for (const ch of text) {
140
+ if (ch === "[")
141
+ depth++;
142
+ else if (ch === "]")
143
+ depth--;
144
+ }
145
+ return depth <= 0;
146
+ }
147
+ function readFlowSequenceContinuation(lines, start, end, head) {
148
+ let acc = head;
149
+ let j = start;
150
+ for (; j < end; j++) {
151
+ acc += ` ${lines[j].trim()}`;
152
+ if (isFlowSequenceClosed(acc)) {
153
+ j++;
154
+ break;
155
+ }
156
+ }
157
+ return { value: acc, next: j };
158
+ }
45
159
  function unquote(value) {
46
160
  if (value.length < 2)
47
161
  return value;
@@ -51,7 +165,13 @@ function unquote(value) {
51
165
  const inner = value.slice(1, -1);
52
166
  return q === '"' ? inner.replace(/\\t/g, "\t").replace(/\\n/g, "\n") : inner;
53
167
  }
54
- function listRelativeFiles(base, dir, prefix, out) {
168
+ function isInsideRoot(root, p) {
169
+ if (p === root)
170
+ return true;
171
+ const rel = relative(root, p);
172
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
173
+ }
174
+ function listRelativeFiles(base, dir, prefix, out, walk) {
55
175
  let entries;
56
176
  try {
57
177
  entries = readdirSync(join(base, dir), { withFileTypes: true });
@@ -61,24 +181,68 @@ function listRelativeFiles(base, dir, prefix, out) {
61
181
  }
62
182
  for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
63
183
  const rel = `${prefix}${e.name}`;
64
- if (e.isDirectory())
65
- listRelativeFiles(base, join(dir, e.name), `${rel}/`, out);
66
- else if (e.isFile())
184
+ const abs = join(base, dir, e.name);
185
+ const resolved = resolveInsideSkill(abs, rel, walk);
186
+ if (resolved === undefined)
187
+ continue;
188
+ if (resolved.stats.isDirectory()) {
189
+ if (walk.visited.has(resolved.real))
190
+ continue;
191
+ walk.visited.add(resolved.real);
192
+ listRelativeFiles(base, join(dir, e.name), `${rel}/`, out, walk);
193
+ }
194
+ else if (resolved.stats.isFile()) {
67
195
  out.push(rel);
196
+ }
197
+ }
198
+ }
199
+ function resolveInsideSkill(abs, rel, walk) {
200
+ let real;
201
+ let stats;
202
+ try {
203
+ real = realpathSync(abs);
204
+ stats = statSync(abs);
205
+ }
206
+ catch (err) {
207
+ walk.warn({ code: "attachment_skipped", skill: walk.skill, detail: `${rel}: could not be resolved (${errText(err)})` });
208
+ return undefined;
209
+ }
210
+ if (!isInsideRoot(walk.realRoot, real)) {
211
+ walk.warn({
212
+ code: "attachment_skipped",
213
+ skill: walk.skill,
214
+ detail: `${rel}: resolves outside the skill directory — skipped (attachments are collected from the declared skill directory only)`,
215
+ });
216
+ return undefined;
68
217
  }
218
+ return { real, stats };
69
219
  }
70
220
  function readAttachments(skillDir, skillName, budgetBytes, warn) {
221
+ let realRoot;
222
+ try {
223
+ realRoot = realpathSync(skillDir);
224
+ }
225
+ catch (err) {
226
+ warn({ code: "read_failed", skill: skillName, detail: `the skill directory could not be resolved (${errText(err)}) — attachments skipped` });
227
+ return [];
228
+ }
229
+ const walk = { realRoot, skill: skillName, warn, visited: new Set([realRoot]) };
71
230
  const relPaths = [];
72
231
  for (const d of RESOURCE_DIRS) {
73
- let st;
232
+ let exists = true;
74
233
  try {
75
- st = statSync(join(skillDir, d));
234
+ statSync(join(skillDir, d));
76
235
  }
77
236
  catch {
78
- continue;
237
+ exists = false;
79
238
  }
80
- if (st.isDirectory())
81
- listRelativeFiles(skillDir, d, `${d}/`, relPaths);
239
+ if (!exists)
240
+ continue;
241
+ const resolved = resolveInsideSkill(join(skillDir, d), d, walk);
242
+ if (resolved === undefined || !resolved.stats.isDirectory())
243
+ continue;
244
+ walk.visited.add(resolved.real);
245
+ listRelativeFiles(skillDir, d, `${d}/`, relPaths, walk);
82
246
  }
83
247
  relPaths.sort();
84
248
  const files = [];
@@ -114,20 +278,60 @@ function isDecodableText(bytes) {
114
278
  function errText(err) {
115
279
  return err instanceof Error ? err.message : String(err);
116
280
  }
117
- function manifestFromAllowedTools(declared, skillName, deployedTools, warn) {
281
+ const ALL_TOOLS_WILDCARD = "*";
282
+ function splitToolNames(declared) {
283
+ let text = declared.trim();
284
+ if (text.startsWith("[") && text.endsWith("]"))
285
+ text = text.slice(1, -1);
118
286
  const names = [];
119
- for (const n of declared.split(/\s+/)) {
120
- if (n !== "" && !names.includes(n))
121
- names.push(n);
287
+ let current = "";
288
+ let depth = 0;
289
+ const flush = () => {
290
+ const name = unquote(current.trim());
291
+ if (name !== "")
292
+ names.push(name);
293
+ current = "";
294
+ };
295
+ for (const ch of text) {
296
+ if (ch === "(") {
297
+ depth++;
298
+ current += ch;
299
+ }
300
+ else if (ch === ")") {
301
+ if (depth > 0)
302
+ depth--;
303
+ current += ch;
304
+ }
305
+ else if (depth === 0 && (ch === "," || /\s/.test(ch))) {
306
+ flush();
307
+ }
308
+ else {
309
+ current += ch;
310
+ }
311
+ }
312
+ flush();
313
+ return names;
314
+ }
315
+ function manifestFromAllowedTools(declared, disallowed, skillName, deployedTools, warn) {
316
+ const names = [];
317
+ const seen = new Set();
318
+ for (const n of splitToolNames(declared)) {
319
+ const key = canonicalToolName(n);
320
+ if (seen.has(key))
321
+ continue;
322
+ seen.add(key);
323
+ names.push(n);
122
324
  }
123
325
  if (names.length === 0)
124
326
  return undefined;
327
+ if (names.some((n) => n === ALL_TOOLS_WILDCARD))
328
+ return undefined;
125
329
  let allowTools = names;
126
330
  if (deployedTools !== undefined) {
127
- const mounted = new Set(deployedTools);
128
- allowTools = names.filter((n) => mounted.has(n));
331
+ const mounted = new Set(deployedTools.map(canonicalToolName));
332
+ allowTools = names.filter((n) => mounted.has(canonicalToolName(n)));
129
333
  for (const n of names) {
130
- if (!mounted.has(n)) {
334
+ if (!mounted.has(canonicalToolName(n))) {
131
335
  warn({
132
336
  code: "allowed_tool_not_mounted",
133
337
  skill: skillName,
@@ -136,6 +340,10 @@ function manifestFromAllowedTools(declared, skillName, deployedTools, warn) {
136
340
  }
137
341
  }
138
342
  }
343
+ if (disallowed.length > 0) {
344
+ const denied = new Set(disallowed.map(canonicalToolName));
345
+ allowTools = allowTools.filter((n) => !denied.has(canonicalToolName(n)));
346
+ }
139
347
  return { allowTools, lineageId: `skill:${skillName}` };
140
348
  }
141
349
  export function createSkillsFromDirectory(dir, options = {}) {
@@ -153,10 +361,23 @@ export function createSkillsFromDirectory(dir, options = {}) {
153
361
  catch (err) {
154
362
  throw new Error(`skills directory could not be read: ${dir} (${errText(err)})`);
155
363
  }
156
- const dirNames = entries
157
- .filter((e) => e.isDirectory())
158
- .map((e) => e.name)
159
- .sort();
364
+ const dirNames = [];
365
+ for (const e of entries) {
366
+ if (e.isDirectory()) {
367
+ dirNames.push(e.name);
368
+ continue;
369
+ }
370
+ if (!e.isSymbolicLink())
371
+ continue;
372
+ try {
373
+ if (statSync(join(dir, e.name)).isDirectory())
374
+ dirNames.push(e.name);
375
+ }
376
+ catch (err) {
377
+ warn({ code: "read_failed", skill: e.name, detail: `entry is a symbolic link that could not be resolved (${errText(err)}) — skipped` });
378
+ }
379
+ }
380
+ dirNames.sort();
160
381
  const skills = [];
161
382
  for (const name of dirNames) {
162
383
  const skillDir = join(dir, name);
@@ -200,7 +421,15 @@ export function createSkillsFromDirectory(dir, options = {}) {
200
421
  continue;
201
422
  }
202
423
  const allowed = parsed.fields.get("allowed-tools");
203
- const manifest = allowed === undefined ? undefined : manifestFromAllowedTools(allowed, name, options.deployedTools, warn);
424
+ const disallowed = splitToolNames(parsed.fields.get("disallowed-tools") ?? "");
425
+ const manifest = allowed === undefined ? undefined : manifestFromAllowedTools(allowed, disallowed, name, options.deployedTools, warn);
426
+ if (disallowed.length > 0 && manifest === undefined) {
427
+ warn({
428
+ code: "disallowed_tools_unenforced",
429
+ skill: name,
430
+ detail: `disallowed-tools names ${disallowed.map((n) => `"${n}"`).join(", ")}, but this skill produced no allowlist to subtract from (allowed-tools is absent or claims every tool) — the deny declaration is NOT enforced (express it in the task's own tool policy)`,
431
+ });
432
+ }
204
433
  const files = readAttachments(skillDir, name, budget, warn);
205
434
  skills.push({
206
435
  name: declaredName,
@@ -1,3 +1,5 @@
1
1
  import type { FileSnapshotStore } from "../file-snapshot-store.js";
2
2
  import { type ContractAssertionRunner } from "./contract-harness.js";
3
- export declare function fileSnapshotStoreContract(make: () => FileSnapshotStore, runAssertion?: ContractAssertionRunner): Promise<void>;
3
+ export declare function fileSnapshotStoreContract(make: () => FileSnapshotStore, runAssertion?: ContractAssertionRunner, options?: {
4
+ blobGc?: "immediate" | "eventual";
5
+ }): Promise<void>;
@@ -4,8 +4,9 @@ import { beginContract } from "./contract-harness.js";
4
4
  const bytes = (s) => new TextEncoder().encode(s);
5
5
  const sha256 = (s) => createHash("sha256").update(bytes(s)).digest("hex");
6
6
  const srcBlob = async (hash) => hash === sha256("alpha") ? bytes("alpha") : hash === sha256("beta") ? bytes("beta") : undefined;
7
- export async function fileSnapshotStoreContract(make, runAssertion) {
7
+ export async function fileSnapshotStoreContract(make, runAssertion, options) {
8
8
  const { run, settle } = beginContract(runAssertion);
9
+ const blobGc = options?.blobGc ?? "immediate";
9
10
  run("kit prerequisites: exportManifest/getBlob/putBlob/importManifest are implemented (REQUIRED by this kit)", async () => {
10
11
  const probe = make();
11
12
  const missing = ["exportManifest", "getBlob", "putBlob", "importManifest"].filter((m) => typeof probe[m] !== "function");
@@ -115,12 +116,19 @@ export async function fileSnapshotStoreContract(make, runAssertion) {
115
116
  assert.deepEqual(await store.listKeys("sc"), ["k1"]);
116
117
  assert.deepEqual([...(await store.getBlob(sha256("alpha")))], [...bytes("alpha")]);
117
118
  });
118
- run("reap keep-nothing drops the key and GCs the now-unreferenced blobs", async () => {
119
+ run("reap keep-nothing drops the key IMMEDIATELY (every backend)", async () => {
119
120
  const store = make();
120
121
  await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
121
122
  assert.equal(await store.reap("sc", []), 1);
122
123
  assert.equal(await store.has("sc", "k1"), false);
123
- assert.equal(await store.getBlob(sha256("alpha")), undefined);
124
124
  });
125
+ if (blobGc === "immediate") {
126
+ run("reap keep-nothing GCs the now-unreferenced blob bytes immediately (blobGc: immediate)", async () => {
127
+ const store = make();
128
+ await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
129
+ assert.equal(await store.reap("sc", []), 1);
130
+ assert.equal(await store.getBlob(sha256("alpha")), undefined);
131
+ });
132
+ }
125
133
  await settle();
126
134
  }