@sema-agent/core 2.5.0 → 2.6.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.
@@ -68,6 +68,7 @@ export declare function mcpToolTotalTimeoutMs(perCallMs: number): number;
68
68
  export declare const MCP_IDLE_TIMEOUT_STDIO_DEFAULT_MS: number;
69
69
  export declare const MCP_IDLE_TIMEOUT_HTTP_DEFAULT_MS: number;
70
70
  export declare function mcpIdleTimeoutMs(kind: "stdio" | "http"): number;
71
+ export declare function describeMcpSpecErrorCode(code: unknown): string | undefined;
71
72
  export declare function normalizeMcpName(name: string): string;
72
73
  export declare function clampNameSegment(seg: string, max?: number): string;
73
74
  export * from "./image-downsample.js";
@@ -106,3 +107,11 @@ export type McpSchemaNormalizeResult = {
106
107
  export declare function normalizeMcpToolSchema(schema: unknown): McpSchemaNormalizeResult;
107
108
  export declare function mcpToolSchemaProblem(schema: unknown): string | undefined;
108
109
  export declare function materializeMcpTools(specs: McpServerSpec[], principal?: string, onElicit?: OnElicit, imageResizer?: McpImageResizer): Promise<MaterializedMcp>;
110
+ export declare function classifyDirReadInvalidParams(message: string): "not_found" | "not_directory";
111
+ export declare function parseCallToolResultLenient(data: unknown): {
112
+ success: true;
113
+ data: unknown;
114
+ } | {
115
+ success: false;
116
+ error: unknown;
117
+ };
package/dist/core/mcp.js CHANGED
@@ -5,7 +5,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
5
5
  import { lstat, mkdir, writeFile } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
- import { ElicitRequestSchema, ErrorCode, ListResourcesResultSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
8
+ import { CallToolResultSchema, ElicitRequestSchema, ErrorCode, ListResourcesResultSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
9
9
  import { MCP_IMAGE_MAX_BASE64, sharpImageResizer } from "./image-downsample.js";
10
10
  import { truncateError } from "./tool-errors.js";
11
11
  import { delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
@@ -137,6 +137,14 @@ function armMcpIdleWatchdog(health, idleMs, outerSignal) {
137
137
  function mcpStartupTimeoutMs() {
138
138
  return parseEnvMs("MCP_TIMEOUT");
139
139
  }
140
+ const MCP_SPEC_ERROR_CODE_NAMES = new Map([
141
+ [-32020, "header mismatch (the server saw an HTTP header that disagreed with the tool parameter mapped onto it)"],
142
+ [-32021, "missing required client capability (the server requires a capability this client does not declare)"],
143
+ [-32022, "unsupported protocol version (the server implements no protocol revision this client offers)"],
144
+ ]);
145
+ export function describeMcpSpecErrorCode(code) {
146
+ return typeof code === "number" ? MCP_SPEC_ERROR_CODE_NAMES.get(code) : undefined;
147
+ }
140
148
  function isTransportLost(err) {
141
149
  if (err instanceof McpError && err.code === ErrorCode.ConnectionClosed)
142
150
  return true;
@@ -180,6 +188,15 @@ function rethrowHonestMcpError(err, ctx) {
180
188
  e.details = { transportLost: true, server: ctx.server };
181
189
  throw e;
182
190
  }
191
+ if (err instanceof McpError) {
192
+ const condition = describeMcpSpecErrorCode(err.code);
193
+ if (condition !== undefined) {
194
+ const e = new Error(`${ctx.what} on MCP server "${serverLabel}" was rejected with MCP protocol error ${err.code} — ${condition}. The server's error text follows as external/untrusted data:\n${delimitUntrusted(`${ctx.server} error`, truncateMcpErrorText(err.message))}`, { cause: err });
195
+ e.errorKind = "protocol_error";
196
+ e.details = { server: ctx.server, specErrorCode: err.code };
197
+ throw e;
198
+ }
199
+ }
183
200
  if (ctx.attributeServer) {
184
201
  const msg = err instanceof Error ? err.message : String(err);
185
202
  throw new Error(`${ctx.what} on MCP server "${serverLabel}" failed. The server's error text follows as external/untrusted data:\n${delimitUntrusted(`${ctx.server} error`, truncateMcpErrorText(msg))}`, { cause: err });
@@ -653,6 +670,15 @@ const LEGACY_READ_MCP_RESOURCE = "ReadMcpResource";
653
670
  const LEGACY_READ_MCP_RESOURCE_DIR = "ReadMcpResourceDir";
654
671
  const MCP_SKILLS_EXTENSION = "io.modelcontextprotocol/skills";
655
672
  const MAX_DIR_READ_PAGES = 20;
673
+ const DIR_READ_NOT_A_DIRECTORY_RE = /not a directory|isn'?t a directory|not a folder/i;
674
+ const DIR_READ_NOT_FOUND_RE = /not found|no such|does not exist|doesn'?t exist|unknown (?:resource|uri)/i;
675
+ export function classifyDirReadInvalidParams(message) {
676
+ if (DIR_READ_NOT_A_DIRECTORY_RE.test(message))
677
+ return "not_directory";
678
+ if (DIR_READ_NOT_FOUND_RE.test(message))
679
+ return "not_found";
680
+ return "not_directory";
681
+ }
656
682
  function resourceLine(r) {
657
683
  return `${r.uri}${r.name ? ` — ${r.name}` : ""}${r.mimeType ? ` (${r.mimeType})` : ""}${r.description ? `: ${r.description}` : ""}`;
658
684
  }
@@ -672,7 +698,7 @@ async function readDirViaExtension(rs, uri, signal, timeoutMs, watchdog) {
672
698
  if (err instanceof McpError && err.code === ErrorCode.InvalidParams) {
673
699
  if (pages > 0)
674
700
  return { kind: "ok", resources, cursorInvalid: true };
675
- return { kind: "not_directory" };
701
+ return { kind: classifyDirReadInvalidParams(err.message), detail: err.message };
676
702
  }
677
703
  if (err instanceof McpError && err.code === ErrorCode.MethodNotFound)
678
704
  return { kind: "unsupported" };
@@ -866,9 +892,22 @@ function buildResourceTools(resourceServers) {
866
892
  const ext = rs.client.getServerCapabilities()?.extensions?.[MCP_SKILLS_EXTENSION];
867
893
  if (ext?.directoryRead === true) {
868
894
  const r = await readDirViaExtension(rs, uri, signal, timeoutMs, watchdog).catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs } }));
895
+ const serverSaid = (detail) => `\nThe server's error text follows as external/untrusted data:\n${delimitUntrusted(`${server} error`, truncateMcpErrorText(detail))}`;
896
+ if (r.kind === "not_found") {
897
+ return {
898
+ content: [
899
+ {
900
+ type: "text",
901
+ text: `Resource not found: ${inlineUntrusted(uri)} — the server reports no such resource, so re-reading it will not help either. Use ${LIST_MCP_RESOURCES} to see what this server exposes.${serverSaid(r.detail)}`,
902
+ },
903
+ ],
904
+ details: { resources: [], notFound: true },
905
+ terminate: false,
906
+ };
907
+ }
869
908
  if (r.kind === "not_directory") {
870
909
  return {
871
- content: [{ type: "text", text: `Not a directory resource: ${inlineUntrusted(uri)}. If it is a file resource, use ${READ_MCP_RESOURCE} instead.` }],
910
+ content: [{ type: "text", text: `Not a directory resource: ${inlineUntrusted(uri)}. If it is a file resource, use ${READ_MCP_RESOURCE} instead.${serverSaid(r.detail)}` }],
872
911
  details: { resources: [] },
873
912
  terminate: false,
874
913
  };
@@ -929,6 +968,17 @@ const LenientListToolsResultSchema = {
929
968
  return { success: true, data: { tools, ...(typeof nextCursor === "string" ? { nextCursor } : {}) } };
930
969
  },
931
970
  };
971
+ export function parseCallToolResultLenient(data) {
972
+ if (typeof data !== "object" || data === null || Array.isArray(data) || !("structuredContent" in data)) {
973
+ return CallToolResultSchema.safeParse(data);
974
+ }
975
+ const { structuredContent, ...rest } = data;
976
+ const parsed = CallToolResultSchema.safeParse(rest);
977
+ if (!parsed.success)
978
+ return parsed;
979
+ return { success: true, data: { ...parsed.data, structuredContent } };
980
+ }
981
+ const LENIENT_CALL_TOOL_RESULT_SCHEMA = { safeParse: parseCallToolResultLenient };
932
982
  async function listToolsLenient(client, options) {
933
983
  return client.request({ method: "tools/list", params: {} }, LenientListToolsResultSchema, options);
934
984
  }
@@ -1059,7 +1109,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer) {
1059
1109
  let res;
1060
1110
  try {
1061
1111
  res = await client
1062
- .callTool({ name: remoteName, arguments: (params ?? {}) }, undefined, {
1112
+ .callTool({ name: remoteName, arguments: (params ?? {}) }, LENIENT_CALL_TOOL_RESULT_SCHEMA, {
1063
1113
  signal: watchdog.combinedSignal,
1064
1114
  timeout: timeoutMs,
1065
1115
  resetTimeoutOnProgress: true,
@@ -1106,7 +1156,8 @@ function intakeListedTools(listed, spec, client, health, imageResizer) {
1106
1156
  }
1107
1157
  function asServerWarning(spec, err) {
1108
1158
  const detail = err instanceof Error ? err.message : String(err);
1109
- const warning = new Error(`mcp: server "${spec.name}" failed to connect — skipped (${detail})`, { cause: err });
1159
+ const condition = err instanceof McpError ? describeMcpSpecErrorCode(err.code) : undefined;
1160
+ const warning = new Error(`mcp: server "${spec.name}" failed to connect — skipped (${condition !== undefined ? `${condition}: ` : ""}${detail})`, { cause: err });
1110
1161
  warning.code = "mcp.server_unavailable";
1111
1162
  return warning;
1112
1163
  }
@@ -1826,7 +1826,25 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1826
1826
  throw e;
1827
1827
  }
1828
1828
  const registry = buildDeferredRegistry(deferred, tools);
1829
- const placeholders = new Map([...registry.values()].map((i) => [i.name, createPlaceholderTool(i)]));
1829
+ const realByName = new Map(tools.map((t) => [t.name, t]));
1830
+ const directCallFor = (name) => {
1831
+ if (spec.deferSelfResolve === false)
1832
+ return undefined;
1833
+ const real = realByName.get(name);
1834
+ if (real === undefined)
1835
+ return undefined;
1836
+ return {
1837
+ parameters: real.parameters,
1838
+ invoke: (toolCallId, params, signal) => real.execute(toolCallId, params, signal),
1839
+ activate: async () => {
1840
+ if (activeTools.has(name))
1841
+ return;
1842
+ activeTools.add(name);
1843
+ await rematerialize(activeTools);
1844
+ },
1845
+ };
1846
+ };
1847
+ const placeholders = new Map([...registry.values()].map((i) => [i.name, createPlaceholderTool(i, directCallFor(i.name))]));
1830
1848
  const { messages } = await session.buildContext();
1831
1849
  for (const n of extractDiscoveredToolNames(messages, registry)) {
1832
1850
  if (deferred.has(n))
@@ -844,6 +844,7 @@ function makeHarnessHandlers(prepared, stats, rs, deps) {
844
844
  taskId: rs.telemetry.taskId,
845
845
  model: m.model,
846
846
  provider: m.provider,
847
+ turn: stats.turns + 1,
847
848
  promptTokens: turnInput,
848
849
  completionTokens: u.output || 0,
849
850
  cacheRead: u.cacheRead || 0,
@@ -1011,6 +1012,8 @@ function makeHarnessHandlers(prepared, stats, rs, deps) {
1011
1012
  version: 1,
1012
1013
  taskId: rs.telemetry.taskId,
1013
1014
  name: event.toolName,
1015
+ toolCallId: event.toolCallId,
1016
+ turn: stats.turns + 1,
1014
1017
  durationMs: toolStarted !== undefined ? toolNow - toolStarted : 0,
1015
1018
  ok: !event.isError,
1016
1019
  ts: toolNow,
@@ -1,4 +1,5 @@
1
- import type { AgentMessage, AgentTool } from "../../internal/harness-types.js";
1
+ import { type TSchema } from "typebox";
2
+ import type { AgentMessage, AgentTool, AgentToolResult } from "../../internal/harness-types.js";
2
3
  import type { Model } from "../../internal/llm.js";
3
4
  import type { ToolSpec } from "../types.js";
4
5
  export declare const TOOL_SEARCH_NAME = "ToolSearch";
@@ -27,7 +28,12 @@ export declare function buildDeferredRegistry(deferred: ReadonlySet<string>, too
27
28
  name: string;
28
29
  description: string;
29
30
  }>): Map<string, DeferredToolInfo>;
30
- export declare function createPlaceholderTool(info: DeferredToolInfo): AgentTool;
31
+ export interface PlaceholderDirectCall {
32
+ parameters: TSchema;
33
+ invoke: (toolCallId: string, params: unknown, signal?: AbortSignal) => Promise<AgentToolResult<unknown>>;
34
+ activate: () => Promise<void>;
35
+ }
36
+ export declare function createPlaceholderTool(info: DeferredToolInfo, direct?: PlaceholderDirectCall): AgentTool;
31
37
  export declare function scoreToolMatch(query: string, info: DeferredToolInfo): number;
32
38
  export interface ToolSearchArgs {
33
39
  query?: string;
@@ -1,4 +1,5 @@
1
1
  import { Type } from "typebox";
2
+ import { Value } from "typebox/value";
2
3
  import { defineTool } from "../tools.js";
3
4
  export const TOOL_SEARCH_NAME = "ToolSearch";
4
5
  const DEFER_AUTO_FRACTION = 0.1;
@@ -59,17 +60,33 @@ export function buildDeferredRegistry(deferred, tools) {
59
60
  }
60
61
  return reg;
61
62
  }
62
- export function createPlaceholderTool(info) {
63
+ export function createPlaceholderTool(info, direct) {
63
64
  const sn = safeName(info.name);
65
+ const teachingRejection = () => {
66
+ throw new Error(`Tool "${sn}" is not active yet. Call ${TOOL_SEARCH_NAME} with {"query":"select:${sn}"} ` +
67
+ `(or a keyword \`query\`) to load its full schema, then call ${sn} with the proper arguments.`);
68
+ };
69
+ if (direct !== undefined) {
70
+ return {
71
+ name: info.name,
72
+ label: info.name,
73
+ description: `${info.hint} — deferred: call ${TOOL_SEARCH_NAME}({"query":"select:${sn}"}) to load its parameters before use.`,
74
+ parameters: EMPTY_PARAMS,
75
+ execute: async (toolCallId, params, signal) => {
76
+ if (Value.Check(direct.parameters, params)) {
77
+ await direct.activate();
78
+ return direct.invoke(toolCallId, params, signal);
79
+ }
80
+ return teachingRejection();
81
+ },
82
+ };
83
+ }
64
84
  return defineTool({
65
85
  name: info.name,
66
86
  description: `${info.hint} — deferred: call ${TOOL_SEARCH_NAME}({"query":"select:${sn}"}) to load its parameters before use.`,
67
87
  parameters: EMPTY_PARAMS,
68
88
  effect: "read",
69
- execute: () => {
70
- throw new Error(`Tool "${sn}" is not active yet. Call ${TOOL_SEARCH_NAME} with {"query":"select:${sn}"} ` +
71
- `(or a keyword \`query\`) to load its full schema, then call ${sn} with the proper arguments.`);
72
- },
89
+ execute: () => teachingRejection(),
73
90
  });
74
91
  }
75
92
  export function scoreToolMatch(query, info) {
@@ -193,7 +210,7 @@ export function createToolSearchTool(opts) {
193
210
  : "";
194
211
  const missingNote = callableNote +
195
212
  (missUnknown.length > 0
196
- ? `\nNot found: ${missUnknown.map((n) => safeName(n)).join(", ")} — no deferred tool has this exact name. ` +
213
+ ? `\nNot found: ${missUnknown.map((n) => safeName(n)).join(", ")} — not in the deferred registry under this exact name (lookup is case-sensitive). ` +
197
214
  "(Already-active and non-deferred tools are callable directly and don't appear here.)"
198
215
  : "");
199
216
  if (matched.length === 0) {
@@ -0,0 +1,13 @@
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";
3
+ export interface SkillsDirectoryWarning {
4
+ code: SkillsDirectoryWarningCode;
5
+ skill: string;
6
+ detail: string;
7
+ }
8
+ export interface SkillsDirectoryOptions {
9
+ deployedTools?: readonly string[];
10
+ onWarning?: (warning: SkillsDirectoryWarning) => void;
11
+ maxAttachmentBytes?: number;
12
+ }
13
+ export declare function createSkillsFromDirectory(dir: string, options?: SkillsDirectoryOptions): SkillSpec[];
@@ -0,0 +1,214 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const SKILL_FILE = "SKILL.md";
4
+ const RESOURCE_DIRS = ["assets", "references", "scripts"];
5
+ const NAME_MAX_CHARS = 64;
6
+ const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ const DESCRIPTION_MAX_CHARS = 1024;
8
+ const DEFAULT_MAX_ATTACHMENT_BYTES = 256 * 1024;
9
+ const FENCE = "---";
10
+ function parseSkillFile(text) {
11
+ const fields = new Map();
12
+ const normalized = text.replace(/\r\n/g, "\n");
13
+ const lines = normalized.split("\n");
14
+ if (lines[0]?.trim() !== FENCE)
15
+ return { fields, body: normalized, hadFrontmatter: false };
16
+ let end = -1;
17
+ for (let i = 1; i < lines.length; i++) {
18
+ if (lines[i].trim() === FENCE) {
19
+ end = i;
20
+ break;
21
+ }
22
+ }
23
+ if (end === -1)
24
+ return { fields, body: normalized, hadFrontmatter: false };
25
+ for (let i = 1; i < end; i++) {
26
+ const line = lines[i];
27
+ const trimmed = line.trim();
28
+ if (trimmed === "" || trimmed.startsWith("#"))
29
+ continue;
30
+ if (/^\s/.test(line))
31
+ continue;
32
+ const kv = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(trimmed);
33
+ if (!kv)
34
+ continue;
35
+ const [, key, raw] = kv;
36
+ if (fields.has(key))
37
+ continue;
38
+ fields.set(key, unquote(raw.trim()));
39
+ }
40
+ let body = lines.slice(end + 1).join("\n");
41
+ if (body.startsWith("\n"))
42
+ body = body.slice(1);
43
+ return { fields, body, hadFrontmatter: true };
44
+ }
45
+ function unquote(value) {
46
+ if (value.length < 2)
47
+ return value;
48
+ const q = value[0];
49
+ if ((q !== '"' && q !== "'") || value[value.length - 1] !== q)
50
+ return value;
51
+ const inner = value.slice(1, -1);
52
+ return q === '"' ? inner.replace(/\\t/g, "\t").replace(/\\n/g, "\n") : inner;
53
+ }
54
+ function listRelativeFiles(base, dir, prefix, out) {
55
+ let entries;
56
+ try {
57
+ entries = readdirSync(join(base, dir), { withFileTypes: true });
58
+ }
59
+ catch {
60
+ return;
61
+ }
62
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
63
+ const rel = `${prefix}${e.name}`;
64
+ if (e.isDirectory())
65
+ listRelativeFiles(base, join(dir, e.name), `${rel}/`, out);
66
+ else if (e.isFile())
67
+ out.push(rel);
68
+ }
69
+ }
70
+ function readAttachments(skillDir, skillName, budgetBytes, warn) {
71
+ const relPaths = [];
72
+ for (const d of RESOURCE_DIRS) {
73
+ let st;
74
+ try {
75
+ st = statSync(join(skillDir, d));
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ if (st.isDirectory())
81
+ listRelativeFiles(skillDir, d, `${d}/`, relPaths);
82
+ }
83
+ relPaths.sort();
84
+ const files = [];
85
+ let spent = 0;
86
+ for (const rel of relPaths) {
87
+ let bytes;
88
+ try {
89
+ bytes = readFileSync(join(skillDir, rel));
90
+ }
91
+ catch (err) {
92
+ warn({ code: "attachment_skipped", skill: skillName, detail: `${rel}: unreadable (${errText(err)})` });
93
+ continue;
94
+ }
95
+ if (!isDecodableText(bytes)) {
96
+ warn({ code: "attachment_skipped", skill: skillName, detail: `${rel}: not text (attachment content is a string; binary is not carried)` });
97
+ continue;
98
+ }
99
+ if (spent + bytes.byteLength > budgetBytes) {
100
+ warn({ code: "attachment_skipped", skill: skillName, detail: `${rel}: over the ${budgetBytes}-byte attachment budget for this skill` });
101
+ continue;
102
+ }
103
+ spent += bytes.byteLength;
104
+ files.push({ path: rel, content: bytes.toString("utf8") });
105
+ }
106
+ return files;
107
+ }
108
+ function isDecodableText(bytes) {
109
+ if (bytes.includes(0))
110
+ return false;
111
+ const decoded = bytes.toString("utf8");
112
+ return Buffer.byteLength(decoded, "utf8") === bytes.byteLength;
113
+ }
114
+ function errText(err) {
115
+ return err instanceof Error ? err.message : String(err);
116
+ }
117
+ function manifestFromAllowedTools(declared, skillName, deployedTools, warn) {
118
+ const names = [];
119
+ for (const n of declared.split(/\s+/)) {
120
+ if (n !== "" && !names.includes(n))
121
+ names.push(n);
122
+ }
123
+ if (names.length === 0)
124
+ return undefined;
125
+ let allowTools = names;
126
+ if (deployedTools !== undefined) {
127
+ const mounted = new Set(deployedTools);
128
+ allowTools = names.filter((n) => mounted.has(n));
129
+ for (const n of names) {
130
+ if (!mounted.has(n)) {
131
+ warn({
132
+ code: "allowed_tool_not_mounted",
133
+ skill: skillName,
134
+ detail: `allowed-tools names "${n}", which this deployment does not mount — dropped (a skill declaration may only narrow capability, never add a tool)`,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ return { allowTools, lineageId: `skill:${skillName}` };
140
+ }
141
+ export function createSkillsFromDirectory(dir, options = {}) {
142
+ const warn = (w) => {
143
+ options.onWarning?.(w);
144
+ };
145
+ const budget = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
146
+ let entries;
147
+ try {
148
+ const st = statSync(dir);
149
+ if (!st.isDirectory())
150
+ throw new Error("not a directory");
151
+ entries = readdirSync(dir, { withFileTypes: true });
152
+ }
153
+ catch (err) {
154
+ throw new Error(`skills directory could not be read: ${dir} (${errText(err)})`);
155
+ }
156
+ const dirNames = entries
157
+ .filter((e) => e.isDirectory())
158
+ .map((e) => e.name)
159
+ .sort();
160
+ const skills = [];
161
+ for (const name of dirNames) {
162
+ const skillDir = join(dir, name);
163
+ let text;
164
+ try {
165
+ text = readFileSync(join(skillDir, SKILL_FILE), "utf8");
166
+ }
167
+ catch {
168
+ warn({ code: "no_skill_file", skill: name, detail: `no ${SKILL_FILE} in this directory — skipped` });
169
+ continue;
170
+ }
171
+ const parsed = parseSkillFile(text);
172
+ if (!parsed.hadFrontmatter) {
173
+ warn({ code: "no_frontmatter", skill: name, detail: `${SKILL_FILE} has no fenced --- frontmatter block — skipped` });
174
+ continue;
175
+ }
176
+ const declaredName = parsed.fields.get("name");
177
+ if (declaredName === undefined || declaredName === "") {
178
+ warn({ code: "missing_name", skill: name, detail: "frontmatter has no `name` (required) — skipped" });
179
+ continue;
180
+ }
181
+ if (declaredName.length > NAME_MAX_CHARS || !NAME_RE.test(declaredName)) {
182
+ warn({
183
+ code: "invalid_name",
184
+ skill: name,
185
+ detail: `\`name\` must be ≤${NAME_MAX_CHARS} chars of lowercase alphanumerics in hyphen-separated runs — skipped`,
186
+ });
187
+ continue;
188
+ }
189
+ if (declaredName !== name) {
190
+ warn({ code: "name_mismatch", skill: name, detail: `frontmatter \`name\` is "${declaredName}" but the directory is "${name}" — skipped` });
191
+ continue;
192
+ }
193
+ const description = parsed.fields.get("description");
194
+ if (description === undefined || description === "") {
195
+ warn({ code: "missing_description", skill: name, detail: "frontmatter has no `description` (required) — skipped" });
196
+ continue;
197
+ }
198
+ if (description.length > DESCRIPTION_MAX_CHARS) {
199
+ warn({ code: "description_too_long", skill: name, detail: `\`description\` is ${description.length} chars, over the ${DESCRIPTION_MAX_CHARS} cap — skipped` });
200
+ continue;
201
+ }
202
+ const allowed = parsed.fields.get("allowed-tools");
203
+ const manifest = allowed === undefined ? undefined : manifestFromAllowedTools(allowed, name, options.deployedTools, warn);
204
+ const files = readAttachments(skillDir, name, budget, warn);
205
+ skills.push({
206
+ name: declaredName,
207
+ description,
208
+ content: parsed.body,
209
+ ...(manifest !== undefined ? { manifest } : {}),
210
+ ...(files.length > 0 ? { files } : {}),
211
+ });
212
+ }
213
+ return skills;
214
+ }
@@ -138,6 +138,7 @@ export type TraceEvent = {
138
138
  taskId: string;
139
139
  model: string;
140
140
  provider?: string;
141
+ turn?: number;
141
142
  promptTokens: number;
142
143
  completionTokens: number;
143
144
  cacheRead: number;
@@ -155,6 +156,8 @@ export type TraceEvent = {
155
156
  version: 1;
156
157
  taskId: string;
157
158
  name: string;
159
+ toolCallId?: string;
160
+ turn?: number;
158
161
  durationMs: number;
159
162
  ok: boolean;
160
163
  effect?: ToolEffect;
@@ -258,6 +258,7 @@ export interface TaskSpec {
258
258
  excludeTools?: string[];
259
259
  deferTools?: string[];
260
260
  alwaysLoadTools?: string[];
261
+ deferSelfResolve?: boolean;
261
262
  promptProfile?: "simple" | "classic";
262
263
  agents?: AgentDefinition[];
263
264
  toolPolicy?: import("./tool-policy.js").ToolPolicy;
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { SESSION_LOG_DIGEST_SCHEME, sessionEntryDigest, sessionLogDigest, sessio
3
3
  export type { ResumeTaskConfig } from "./core/runner/runtask.js";
4
4
  export { defineTool } from "./core/tools.js";
5
5
  export { SKILL_TOOL_NAME } from "./core/runner/synthetic-tools.js";
6
+ export { createSkillsFromDirectory, type SkillsDirectoryOptions, type SkillsDirectoryWarning, type SkillsDirectoryWarningCode, } from "./core/skills-directory.js";
6
7
  export { REPORT_FINDINGS_TOOL_NAME, type ReportedFinding } from "./core/runner/synthetic-tools.js";
7
8
  export { formatToolError, formatZodValidationError, formatValidationPath, truncateError, errorClassOf } from "./core/tool-errors.js";
8
9
  export type { WorkerErrorClass } from "./core/tool-errors.js";
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { Runner, runTask, DEFAULT_MAX_TURNS } from "./core/runner/runtask.js";
2
2
  export { SESSION_LOG_DIGEST_SCHEME, sessionEntryDigest, sessionLogDigest, sessionLogDigestsComparable, } from "./engine/session/log-digest.js";
3
3
  export { defineTool } from "./core/tools.js";
4
4
  export { SKILL_TOOL_NAME } from "./core/runner/synthetic-tools.js";
5
+ export { createSkillsFromDirectory, } from "./core/skills-directory.js";
5
6
  export { REPORT_FINDINGS_TOOL_NAME } from "./core/runner/synthetic-tools.js";
6
7
  export { formatToolError, formatZodValidationError, formatValidationPath, truncateError, errorClassOf } from "./core/tool-errors.js";
7
8
  export { createWebFetchTool, webFetchToolSpec, htmlToText, createWebSearchTool, createWebFetchSummarizer, WEBFETCH_SUMMARY_MAX_CONTENT, WEBFETCH_SUMMARY_GUIDELINES, } from "./tools/web.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",