@nylorun/agents 0.3.0-beta → 0.4.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @nylorun/agents
2
2
 
3
+ ## 0.4.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - b8d822a: Add `mcp(...)` to declare MCP servers as one capability for `Agent.use`, matching agent-plugins / manifest v3 `mcpServers` shape.
8
+ - b8d822a: Add `skills(path)` to load an agentskills.io catalog folder into `Agent.use` without duplicating `skills` and `skillRecords`. Parse `SKILL.md` frontmatter with `gray-matter`.
9
+ - b8d822a: Add `sandbox()`: one `.use(sandbox())` gives an agent Runtime-executed `bash`, `read`, `write`, `edit`, `grep` and `glob` tools on an isolated machine with a persistent `/workspace`. The Runtime selects a microsandbox microVM where available, otherwise an in-process virtual shell, enforces deny-by-default egress presets, owns sandbox lifecycle, and reports its choice through `GET /v1/host/sandbox`, the `nylorun dev` banner and `nylorun doctor sandbox`.
10
+ - b8d822a: Breaking beta: replace `beforeModelCall` / `afterModelCall` with scoped hooks. Register `before("turn" | "step", fn)` and `after("step" | "turn", fn)` on the agent, or `before: { turn, step }` / `after: { step, turn }` on a capability. `before("turn")` runs once per turn and its `Patch` applies to every model call in the turn; the new `after("turn")` returns a `TurnDecision` for the final answer. `after` hooks take one argument and receive `attempt`, and `retry` now retries instead of failing the run. The manifest moves to `manifestSchemaVersion: 4` with `capabilities[].hooks`, and `BeforeModelCallFn`, `AfterModelCallFn` and the `beforeModelCall` / `afterModelCall` action kinds are removed. Every capability registered at a hook point now runs in one `hook` executor action, an expired hook claim is offered again instead of becoming uncertain, and the durable engine version is `hosted-2`. Hook toggles now hide a capability's tools, or one tool of a multi-tool capability, instead of having no effect or failing. Studio lists each capability's hooks with how often they run and labels hook actions. See MIGRATION.md.
11
+
12
+ ### Patch Changes
13
+
14
+ - Pin core to the tested release.
15
+ - Updated dependencies [b8d822a]
16
+ - Updated dependencies [b8d822a]
17
+ - Updated dependencies [b8d822a]
18
+ - Updated dependencies [b8d822a]
19
+ - @nylorun/core@0.3.0-beta
20
+
3
21
  ## 0.3.0-beta
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -49,12 +49,73 @@ await connection.ready;
49
49
  await connection.close();
50
50
  ```
51
51
 
52
+ Load Agent Skills from a local catalog folder with `skills(path)`:
53
+
54
+ ```ts
55
+ import { Agent, skills } from "@nylorun/agents";
56
+
57
+ const assistant = Agent({
58
+ id: "assistant",
59
+ name: "Order assistant",
60
+ instructions: "Use lookup_order for orders.",
61
+ tools: [lookupOrder],
62
+ }).use(skills("./assistant-skills"));
63
+ ```
64
+
65
+ Each subdirectory under the catalog must contain a `SKILL.md` with YAML frontmatter (`name`, `description`) per [Agent Skills](https://agentskills.io/home). Supporting files (for example `references/`) are available through `read_skill_resource` after `load_skill`. The helper sets both the manifest skill catalog and the on-disk skill records so you do not duplicate content.
66
+
67
+ Declare MCP servers with `mcp(...)` (same map shape as agent-plugins `mcpServers` / manifest v3):
68
+
69
+ ```ts
70
+ import { Agent, mcp } from "@nylorun/agents";
71
+
72
+ const assistant = Agent({
73
+ id: "assistant",
74
+ name: "Assistant",
75
+ instructions: "Use the available tools.",
76
+ }).use(
77
+ mcp({
78
+ github: {
79
+ name: "github",
80
+ type: "streamable-http",
81
+ url: "https://mcp.example.com/github",
82
+ },
83
+ })
84
+ );
85
+ ```
86
+
87
+ Each key must equal that server's `name`. Transports follow [Agent Plugins MCP servers](https://agent-plugins.org/plugin-authors/mcp-servers): `stdio`, `streamable-http`, and `sse`. Pass `{ id: "…" }` as the second argument to override the default capability id `"mcp"`.
88
+
89
+ Give an agent an isolated computer with `sandbox()`:
90
+
91
+ ```ts
92
+ import { Agent, sandbox } from "@nylorun/agents";
93
+
94
+ const analyst = Agent({
95
+ id: "analyst",
96
+ instructions: "Analyse the data the user gives you. Use Python.",
97
+ }).use(sandbox());
98
+ ```
99
+
100
+ The model gets `bash`, `read`, `write`, `edit`, `grep` and `glob` on a Linux machine with a persistent `/workspace`. These tools run in the Runtime, not in your process, so sandbox-only agents need no connected executor. The agent declares what it needs; the Runtime decides where it runs (a microsandbox VM where the machine supports it, otherwise an emulated shell). Every option is optional plain data:
101
+
102
+ ```ts
103
+ .use(sandbox({
104
+ image: "python:3.13", // any OCI image; default python:3.13-slim
105
+ network: { preset: "dev", allow: ["api.example.com"] }, // "none" | "dev" (default) | "open"
106
+ resources: { cpus: 2, memory: "2GiB" },
107
+ idle: "15m", // stop compute when idle; files persist
108
+ }))
109
+ ```
110
+
111
+ The `dev` preset allows package registries and code hosts. Private networks, loopback, the host and cloud metadata endpoints are always blocked. Options from the full design that are not in this version (`setup`, `files`, `secrets`, `mount`, `onStart`, `scope`, ...) throw a `SandboxError` that says so. See [the sandbox design](../docs/design/sandboxes.md).
112
+
52
113
  Use `session.observe({ cursor, signal })` for resumable canonical events, `session.inspect()` for waiting/uncertain state, and `approve`, `respond`, or `cancel` with an explicit stable idempotency key. Retry the same semantic command with the same key. `ownerUserId` must come from trusted server authentication. Application credentials are not browser credentials; browser applications need an authorized backend. Definition authoring is browser-bundleable.
53
114
 
54
115
  The Runtime destination is explicit, or defaults from `NYLORUN_RUNTIME_URL`. Application and executor keys default from `NYLORUN_SERVER_KEY` and `NYLORUN_EXECUTOR_KEY`. Implementation version defaults from `NYLORUN_IMPLEMENTATION_VERSION`, then `dev`. A host must provision executor scope for the agent id. Agents do not hash the manifest, and an in-flight action stays claimable after the registered digest changes. Model selection and model credentials belong to the Runtime.
55
116
 
56
117
  `connectAgents({ agents })` opens authenticated fetch SSE before discovering actions, rediscovers after reconnection, and claims pending actions for a connected agent id. It renews leases while executing, then retries HTTP result delivery with the same recorded outcome and idempotency key. Reconnect delay is bounded at 30 seconds. Notifications confer no execution authority. There is no periodic action-discovery polling. Close aborts the stream, HTTP requests and lease timers and signals running functions; JavaScript cannot forcibly terminate a function that ignores its signal. The host makes expired in-flight actions uncertain instead of automatically repeating external effects.
57
118
 
58
- Tools receive state, info, identity, resume, signal, approval/response helpers and memoized `step`. Step outcomes survive a persisted wait result; they do not establish exactly-once external effects after an unacknowledged crash. `sleep` and `waitFor` currently return inspectable deferred outcomes; automatic timer/event wakeups remain runtime implementation work. Remote `onModelCall` convenience and progress-event transport are not supplied in this pass. Arbitrary middleware closures are rejected for durable definitions; use before/after hooks. Agent definitions have no `.run()`; explicit local execution is available through `@nylorun/harness/run`.
119
+ Tools receive state, info, identity, resume, signal, approval/response helpers and memoized `step`. Step outcomes survive a persisted wait result; they do not establish exactly-once external effects after an unacknowledged crash. `sleep` and `waitFor` currently return inspectable deferred outcomes; automatic timer/event wakeups remain runtime implementation work. Remote `onModelCall` convenience and progress-event transport are not supplied in this pass. Arbitrary middleware closures are rejected for durable definitions; use `before`/`after` hooks. The executor runs every capability registered at a hook point in one action. Agent definitions have no `.run()`; explicit local execution is available through `@nylorun/harness/run`.
59
120
 
60
- The SDK depends only on core within the Nylorun packages; installing it does not install harness. Use `/define`, `/client`, or `/executor` for focused imports, or the root for convenience. Studio uses `/client`. See [the adopted host contract](../harness/HOST_CONTRACT.md) and the repository handoff for the verification boundary.
121
+ The SDK depends only on core within the Nylorun packages; installing it does not install harness. Use `/define`, `/client`, or `/executor` for focused imports, or the root for convenience. Studio uses `/client`. See [the adopted host contract](../harness/HOST_CONTRACT.md).
@@ -1,4 +1,4 @@
1
- import { implementationsFor, isToolError, normalizeToolDefinition, normalizedSchemasFor, } from "@nylorun/core/define";
1
+ import { implementationsFor, isToolError, normalizeToolDefinition, normalizedSchemasFor, runHookPoint, } from "@nylorun/core/define";
2
2
  class Suspend {
3
3
  outcome;
4
4
  constructor(outcome) {
@@ -10,32 +10,17 @@ const object = (value) => value !== null && typeof value === "object" && !Array.
10
10
  : {};
11
11
  /** Executes code only after a host-issued claim. No engine dependency. */
12
12
  export async function executeAction(action, agent, signal) {
13
+ if (action.kind === "hook")
14
+ // All capabilities at this hook point run concurrently in one action. Never throws.
15
+ return {
16
+ value: {
17
+ results: await runHookPoint(implementationsFor(agent), action.hook, action.input),
18
+ },
19
+ };
13
20
  const impl = implementationsFor(agent)[action.capabilityId];
14
21
  if (!impl)
15
22
  throw new Error(`No capability ${action.capabilityId}`);
16
- if (action.kind === "beforeModelCall") {
17
- if (!impl.beforeModelCall)
18
- throw new Error("Missing beforeModelCall implementation");
19
- try {
20
- return { value: (await impl.beforeModelCall(action.input)) ?? {} };
21
- }
22
- catch (error) {
23
- return { value: { block: message(error) } };
24
- }
25
- }
26
- if (action.kind === "afterModelCall") {
27
- if (!impl.afterModelCall)
28
- throw new Error("Missing afterModelCall implementation");
29
- try {
30
- return {
31
- value: (await impl.afterModelCall(action.input, action.context)) ?? {},
32
- };
33
- }
34
- catch (error) {
35
- return { value: { block: message(error) } };
36
- }
37
- }
38
- const raw = impl.tools?.[action.toolName ?? ""];
23
+ const raw = impl.tools?.[action.toolName];
39
24
  if (!raw)
40
25
  throw new Error("Missing tool implementation");
41
26
  const tool = normalizeToolDefinition(raw);
package/dist/executor.js CHANGED
@@ -47,7 +47,13 @@ export function connectAgents(options) {
47
47
  dirty = false;
48
48
  const response = await transport.json("/v1/actions", "GET", undefined, signal);
49
49
  for (const item of response.actions) {
50
- const action = ActionSchema.parse(item);
50
+ // Skip actions this SDK cannot run (e.g. from an older Runtime) instead of stalling discovery.
51
+ const parsed = ActionSchema.safeParse(item);
52
+ if (!parsed.success) {
53
+ report(parsed.error);
54
+ continue;
55
+ }
56
+ const action = parsed.data;
51
57
  const agent = agents.get(action.agentId);
52
58
  if (!agent || action.status !== "pending" || active.has(action.actionId))
53
59
  continue;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Agent, AgentBuilder, AgentBuildError, AgentLifecycleError, capability, tool, defineSchema, ToolError, } from "@nylorun/core/define";
2
- export type { AgentOptions, AgentManifest, BuiltAgent, ToolDefinition, ToolExecutionContext, ToolOutcome, Patch, Decision, JsonValue, JsonObject, } from "@nylorun/core/define";
2
+ export type { AgentOptions, AgentManifest, BuiltAgent, ToolDefinition, ToolExecutionContext, ToolOutcome, Patch, Decision, TurnDecision, HookScope, BeforeHook, AfterHook, JsonValue, JsonObject, } from "@nylorun/core/define";
3
3
  export type { LiveEvent, SessionCommand, Action, ActionOutcome, CredentialInfo, CredentialSelection, VaultInfo, } from "@nylorun/core/contracts";
4
4
  export { AgentsClient, SessionClient, createClient } from "./client.js";
5
5
  export type { AgentSource, SessionView, CommandOptions } from "./client.js";
@@ -13,3 +13,9 @@ export { loadPlugin, PluginError } from "./plugins/load.js";
13
13
  export type { LoadedPlugin, PluginDiagnostic } from "./plugins/load.js";
14
14
  export { prepareStdioLaunch, expandPluginPlaceholders } from "./plugins/launch.js";
15
15
  export type { StdioLaunch } from "./plugins/launch.js";
16
+ export { skills, loadSkillsFromDirectory, resolveSkillsRoot, parseSkill, SkillsError, formatSkillCatalog, SKILLS_USAGE, } from "./skills/index.js";
17
+ export type { SkillsCapability, SkillsOptions, SkillDiagnostic, } from "./skills/index.js";
18
+ export { mcp, McpError } from "./mcp/index.js";
19
+ export type { McpCapability, McpOptions } from "./mcp/index.js";
20
+ export { sandbox, SandboxError } from "./sandbox/index.js";
21
+ export type { SandboxCapability, SandboxCapabilityOptions, SandboxOptions, } from "./sandbox/index.js";
package/dist/index.js CHANGED
@@ -6,3 +6,6 @@ export { RuntimeError } from "./http.js";
6
6
  export { plugin } from "./plugins/plugin.js";
7
7
  export { loadPlugin, PluginError } from "./plugins/load.js";
8
8
  export { prepareStdioLaunch, expandPluginPlaceholders } from "./plugins/launch.js";
9
+ export { skills, loadSkillsFromDirectory, resolveSkillsRoot, parseSkill, SkillsError, formatSkillCatalog, SKILLS_USAGE, } from "./skills/index.js";
10
+ export { mcp, McpError } from "./mcp/index.js";
11
+ export { sandbox, SandboxError } from "./sandbox/index.js";
@@ -0,0 +1,2 @@
1
+ export { mcp, McpError } from "./mcp.js";
2
+ export type { McpCapability, McpOptions } from "./mcp.js";
@@ -0,0 +1 @@
1
+ export { mcp, McpError } from "./mcp.js";
@@ -0,0 +1,30 @@
1
+ import type { CapabilityDeclaration, McpServerManifest } from "@nylorun/core/define";
2
+ export interface McpOptions {
3
+ /** Capability id. Defaults to `"mcp"`. */
4
+ readonly id?: string;
5
+ }
6
+ export interface McpCapability extends CapabilityDeclaration {
7
+ readonly mcpServers: Readonly<Record<string, McpServerManifest>>;
8
+ }
9
+ export declare class McpError extends Error {
10
+ readonly code: string;
11
+ constructor(code: string, message: string);
12
+ }
13
+ /**
14
+ * Declare MCP servers as one capability for `Agent.use`.
15
+ *
16
+ * The map shape matches agent-plugins `mcpServers` and manifest v3: each key
17
+ * must equal that server's `name`. Supported transports are `stdio`,
18
+ * `streamable-http`, and `sse` (see https://agent-plugins.org/plugin-authors/mcp-servers).
19
+ *
20
+ * ```ts
21
+ * Agent({...}).use(mcp({
22
+ * github: {
23
+ * name: "github",
24
+ * type: "streamable-http",
25
+ * url: "https://mcp.example.com/github",
26
+ * },
27
+ * }))
28
+ * ```
29
+ */
30
+ export declare function mcp(servers: Readonly<Record<string, McpServerManifest>>, options?: McpOptions): McpCapability;
@@ -0,0 +1,80 @@
1
+ export class McpError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.name = "McpError";
6
+ this.code = code;
7
+ }
8
+ }
9
+ /**
10
+ * Declare MCP servers as one capability for `Agent.use`.
11
+ *
12
+ * The map shape matches agent-plugins `mcpServers` and manifest v3: each key
13
+ * must equal that server's `name`. Supported transports are `stdio`,
14
+ * `streamable-http`, and `sse` (see https://agent-plugins.org/plugin-authors/mcp-servers).
15
+ *
16
+ * ```ts
17
+ * Agent({...}).use(mcp({
18
+ * github: {
19
+ * name: "github",
20
+ * type: "streamable-http",
21
+ * url: "https://mcp.example.com/github",
22
+ * },
23
+ * }))
24
+ * ```
25
+ */
26
+ export function mcp(servers, options = {}) {
27
+ const id = options.id ?? "mcp";
28
+ if (!isRecord(servers)) {
29
+ throw new McpError("mcp.invalid", "mcp() expects a non-empty map of MCP servers");
30
+ }
31
+ const entries = Object.entries(servers);
32
+ if (entries.length === 0) {
33
+ throw new McpError("mcp.empty", "mcp() requires at least one MCP server");
34
+ }
35
+ const mcpServers = {};
36
+ for (const [key, server] of entries) {
37
+ if (!isMcpServer(server)) {
38
+ throw new McpError("mcp.invalid-server", `MCP server '${key}' is not a valid stdio, streamable-http, or sse declaration`);
39
+ }
40
+ if (server.name !== key) {
41
+ throw new McpError("mcp.name-mismatch", `MCP server key '${key}' must equal the server name '${server.name}'`);
42
+ }
43
+ mcpServers[key] = freezeServer(server);
44
+ }
45
+ return { id, mcpServers: Object.freeze(mcpServers) };
46
+ }
47
+ function freezeServer(server) {
48
+ if (server.type === "stdio") {
49
+ return Object.freeze({
50
+ name: server.name,
51
+ type: "stdio",
52
+ command: server.command,
53
+ ...(server.args === undefined ? {} : { args: Object.freeze([...server.args]) }),
54
+ ...(server.env === undefined
55
+ ? {}
56
+ : { env: Object.freeze({ ...server.env }) }),
57
+ ...(server.cwd === undefined ? {} : { cwd: server.cwd }),
58
+ });
59
+ }
60
+ return Object.freeze({
61
+ name: server.name,
62
+ type: server.type,
63
+ url: server.url,
64
+ ...(server.headers === undefined
65
+ ? {}
66
+ : { headers: Object.freeze({ ...server.headers }) }),
67
+ });
68
+ }
69
+ function isMcpServer(value) {
70
+ if (!isRecord(value) || typeof value.name !== "string" || !value.name)
71
+ return false;
72
+ if (value.type === "stdio")
73
+ return typeof value.command === "string" && value.command.length > 0;
74
+ if (value.type === "streamable-http" || value.type === "sse")
75
+ return typeof value.url === "string" && value.url.length > 0;
76
+ return false;
77
+ }
78
+ function isRecord(value) {
79
+ return !!value && typeof value === "object" && !Array.isArray(value);
80
+ }
@@ -1,5 +1,6 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { loadSkillsFromDirectory } from "../skills/load.js";
3
4
  import { expandPluginPlaceholders } from "./launch.js";
4
5
  export const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
5
6
  export const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
@@ -17,7 +18,6 @@ const PLUGIN_FIELDS = new Set([
17
18
  ]);
18
19
  const AUTHOR_FIELDS = new Set(["name", "email", "url"]);
19
20
  const RESERVED_ENV = new Set(["PLUGIN_ROOT", "PLUGIN_DATA"]);
20
- const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
21
  export class PluginError extends Error {
22
22
  code;
23
23
  diagnostics;
@@ -127,125 +127,10 @@ function loadSkills(root, diagnostics) {
127
127
  });
128
128
  return {};
129
129
  }
130
- const skills = {};
131
- for (const entry of readdirSync(real, { withFileTypes: true })) {
132
- if (!entry.isDirectory() || entry.name.startsWith("."))
133
- continue;
134
- const directory = join(real, entry.name);
135
- const directoryReal = realInside(root, directory);
136
- if (!directoryReal) {
137
- diagnostics.push({
138
- severity: "warning",
139
- code: "plugin.skill-skipped",
140
- message: `Skipped skill '${entry.name}' because its directory escapes the package`,
141
- path: directory,
142
- });
143
- continue;
144
- }
145
- const skillFile = join(directoryReal, "SKILL.md");
146
- if (!existsSync(skillFile))
147
- continue;
148
- const skillReal = realInside(root, skillFile);
149
- if (!skillReal || !statSync(skillReal).isFile()) {
150
- diagnostics.push({
151
- severity: "warning",
152
- code: "plugin.skill-skipped",
153
- message: `Skipped skill '${entry.name}' because SKILL.md escapes the package`,
154
- path: skillFile,
155
- });
156
- continue;
157
- }
158
- const parsed = parseSkill(readFileSync(skillReal, "utf8"), entry.name);
159
- if (!parsed) {
160
- diagnostics.push({
161
- severity: "warning",
162
- code: "plugin.skill-skipped",
163
- message: `Skipped skill '${entry.name}' because SKILL.md is not a valid Agent Skill`,
164
- path: skillFile,
165
- });
166
- continue;
167
- }
168
- if (skills[parsed.name]) {
169
- diagnostics.push({
170
- severity: "warning",
171
- code: "plugin.skill-skipped",
172
- message: `Skipped duplicate skill '${parsed.name}'`,
173
- path: skillFile,
174
- });
175
- continue;
176
- }
177
- const resources = readResources(root, directoryReal, diagnostics);
178
- skills[parsed.name] = {
179
- name: parsed.name,
180
- description: parsed.description,
181
- instructions: parsed.instructions,
182
- ...(Object.keys(resources).length === 0 ? {} : { resources }),
183
- };
184
- }
185
- return skills;
186
- }
187
- function parseSkill(text, directoryName) {
188
- const match = /^-{3}\r?\n([\s\S]*?)\r?\n-{3}(?:\r?\n|$)/.exec(text);
189
- if (!match)
190
- return undefined;
191
- const fields = {};
192
- for (const raw of (match[1] ?? "").split(/\r?\n/)) {
193
- if (!raw.trim() || raw.trimStart().startsWith("#"))
194
- continue;
195
- const line = /^([\w-]+)\s*:\s*(.*)$/.exec(raw);
196
- if (!line || line[1] === "metadata")
197
- continue;
198
- fields[line[1]] = unquote(line[2] ?? "");
199
- }
200
- const name = fields.name?.trim() ?? "";
201
- const description = fields.description?.trim() ?? "";
202
- if (!name || !description || description.length > 1024)
203
- return undefined;
204
- if (name.length > 64 || !SKILL_NAME.test(name) || name !== directoryName)
205
- return undefined;
206
- return { name, description, instructions: text.slice(match[0].length).replace(/^\r?\n/, "") };
207
- }
208
- function readResources(root, directory, diagnostics) {
209
- const resources = {};
210
- const walk = (current, depth) => {
211
- if (depth > 6)
212
- return;
213
- for (const entry of readdirSync(current, { withFileTypes: true })) {
214
- if (entry.name.startsWith(".") || entry.name === "node_modules")
215
- continue;
216
- const full = join(current, entry.name);
217
- if (entry.isDirectory()) {
218
- const real = realInside(root, full);
219
- if (!real)
220
- continue;
221
- walk(real, depth + 1);
222
- continue;
223
- }
224
- if (!entry.isFile() && !entry.isSymbolicLink())
225
- continue;
226
- if (depth === 0 && entry.name === "SKILL.md")
227
- continue;
228
- const real = realInside(root, full);
229
- if (!real || !statSync(real).isFile()) {
230
- diagnostics.push({
231
- severity: "warning",
232
- code: "plugin.resource-denied",
233
- message: `Denied resource outside the package: ${entry.name}`,
234
- path: full,
235
- });
236
- continue;
237
- }
238
- const bytes = readFileSync(real);
239
- if (bytes.includes(0))
240
- continue;
241
- const rel = relative(directory, real).split(sep).join("/");
242
- if (!rel || rel.startsWith(".."))
243
- continue;
244
- resources[rel] = bytes.toString("utf8");
245
- }
246
- };
247
- walk(directory, 0);
248
- return resources;
130
+ return loadSkillsFromDirectory(real, diagnostics, {
131
+ boundary: root,
132
+ codePrefix: "plugin",
133
+ });
249
134
  }
250
135
  function loadMcp(root, pluginName, diagnostics) {
251
136
  const location = join(root, "mcp.json");
@@ -482,10 +367,3 @@ function isInside(root, target) {
482
367
  function isRecord(value) {
483
368
  return !!value && typeof value === "object" && !Array.isArray(value);
484
369
  }
485
- function unquote(value) {
486
- const trimmed = value.trim();
487
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
488
- (trimmed.startsWith("'") && trimmed.endsWith("'")))
489
- return trimmed.slice(1, -1);
490
- return trimmed;
491
- }
@@ -1,5 +1,5 @@
1
+ import { formatSkillCatalog, SKILLS_USAGE } from "../skills/catalog.js";
1
2
  import { loadPlugin, } from "./load.js";
2
- const SKILL_USAGE = "The following skills provide specialized instructions. When a task matches a skill description, call load_skill with that name before proceeding.";
3
3
  /** Read an Agent Plugin package and return one capability for `Agent.use`. */
4
4
  export function plugin(directory) {
5
5
  const loaded = loadPlugin(directory);
@@ -9,10 +9,13 @@ export function capabilityFromPlugin(loaded) {
9
9
  const names = Object.values(loaded.skills);
10
10
  const instructions = names.length === 0
11
11
  ? undefined
12
- : [SKILL_USAGE, formatCatalog(names.map((skill) => ({
12
+ : [
13
+ SKILLS_USAGE,
14
+ formatSkillCatalog(names.map((skill) => ({
13
15
  name: skill.name,
14
16
  description: skill.description,
15
- })))];
17
+ }))),
18
+ ];
16
19
  const skills = Object.fromEntries(names.map((skill) => [
17
20
  skill.name,
18
21
  { name: skill.name, description: skill.description },
@@ -30,16 +33,3 @@ export function capabilityFromPlugin(loaded) {
30
33
  diagnostics: loaded.diagnostics,
31
34
  };
32
35
  }
33
- function formatCatalog(skills) {
34
- const entries = skills
35
- .map((skill) => ` <skill>\n <name>${escapeXml(skill.name)}</name>\n <description>${escapeXml(skill.description)}</description>\n </skill>`)
36
- .join("\n");
37
- return `<available_skills>\n${entries}\n</available_skills>`;
38
- }
39
- function escapeXml(value) {
40
- return value
41
- .replaceAll("&", "&amp;")
42
- .replaceAll("<", "&lt;")
43
- .replaceAll(">", "&gt;")
44
- .replaceAll('"', "&quot;");
45
- }
@@ -0,0 +1,2 @@
1
+ export { sandbox, SandboxError } from "./sandbox.js";
2
+ export type { SandboxCapability, SandboxCapabilityOptions, SandboxOptions, } from "./sandbox.js";
@@ -0,0 +1 @@
1
+ export { sandbox, SandboxError } from "./sandbox.js";
@@ -0,0 +1,28 @@
1
+ import type { CapabilityDeclaration, SandboxManifest, ToolDefinition } from "@nylorun/core/define";
2
+ /** What computer the agent needs. The Runtime decides where it runs. */
3
+ export type SandboxOptions = SandboxManifest;
4
+ export interface SandboxCapabilityOptions {
5
+ /** Capability id. Defaults to `"sandbox"`. */
6
+ readonly id?: string;
7
+ }
8
+ export interface SandboxCapability extends CapabilityDeclaration {
9
+ readonly sandbox: SandboxManifest;
10
+ readonly tools: readonly ToolDefinition[];
11
+ }
12
+ export declare class SandboxError extends Error {
13
+ readonly code: string;
14
+ constructor(code: string, message: string);
15
+ }
16
+ /**
17
+ * Give the agent an isolated Linux machine with a persistent `/workspace`.
18
+ *
19
+ * Adds the built-in `bash`, `read`, `write`, `edit`, `grep` and `glob` tools. They run in the
20
+ * Nylorun Runtime, which picks the backend (a microsandbox VM where available). Agent code
21
+ * declares requirements only.
22
+ *
23
+ * ```ts
24
+ * Agent({ id: "analyst", instructions: "..." }).use(sandbox())
25
+ * Agent({ ... }).use(sandbox({ image: "node:24", network: { allow: ["api.github.com"] } }))
26
+ * ```
27
+ */
28
+ export declare function sandbox(options?: SandboxOptions, capability?: SandboxCapabilityOptions): SandboxCapability;
@@ -0,0 +1,93 @@
1
+ import { SANDBOX_DEFERRED_FIELDS, SANDBOX_FIELDS, SANDBOX_INSTRUCTIONS, SANDBOX_NETWORK_PRESETS, createSandboxTools, isSandboxHostPattern, parseSandboxDuration, parseSandboxSize, } from "@nylorun/core/define";
2
+ export class SandboxError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = "SandboxError";
7
+ this.code = code;
8
+ }
9
+ }
10
+ /**
11
+ * Give the agent an isolated Linux machine with a persistent `/workspace`.
12
+ *
13
+ * Adds the built-in `bash`, `read`, `write`, `edit`, `grep` and `glob` tools. They run in the
14
+ * Nylorun Runtime, which picks the backend (a microsandbox VM where available). Agent code
15
+ * declares requirements only.
16
+ *
17
+ * ```ts
18
+ * Agent({ id: "analyst", instructions: "..." }).use(sandbox())
19
+ * Agent({ ... }).use(sandbox({ image: "node:24", network: { allow: ["api.github.com"] } }))
20
+ * ```
21
+ */
22
+ export function sandbox(options = {}, capability = {}) {
23
+ const id = capability.id ?? "sandbox";
24
+ if (!isRecord(options))
25
+ throw new SandboxError("sandbox.invalid", "sandbox() expects an options object");
26
+ for (const key of Object.keys(options)) {
27
+ if (SANDBOX_DEFERRED_FIELDS.includes(key))
28
+ throw new SandboxError("sandbox.unsupported", `sandbox({ ${key} }) is not supported in this version. ` +
29
+ `Supported options: ${SANDBOX_FIELDS.join(", ")}. See docs/design/sandboxes.md for what is planned.`);
30
+ if (!SANDBOX_FIELDS.includes(key))
31
+ throw new SandboxError("sandbox.unknown-option", `Unknown sandbox option '${key}'. Supported options: ${SANDBOX_FIELDS.join(", ")}`);
32
+ }
33
+ return Object.freeze({
34
+ id,
35
+ instructions: Object.freeze([SANDBOX_INSTRUCTIONS]),
36
+ tools: createSandboxTools(),
37
+ sandbox: freezeOptions(options),
38
+ });
39
+ }
40
+ function freezeOptions(options) {
41
+ const result = {};
42
+ if (options.image !== undefined) {
43
+ if (typeof options.image !== "string" || options.image.trim().length === 0)
44
+ throw new SandboxError("sandbox.invalid-image", "sandbox({ image }) must be a non-empty OCI image reference");
45
+ result.image = options.image;
46
+ }
47
+ if (options.network !== undefined) {
48
+ if (!isRecord(options.network))
49
+ throw new SandboxError("sandbox.invalid-network", "sandbox({ network }) must be an object");
50
+ for (const key of Object.keys(options.network))
51
+ if (key !== "preset" && key !== "allow")
52
+ throw new SandboxError("sandbox.invalid-network", `Unknown network option '${key}'. Use preset and allow.`);
53
+ const { preset, allow } = options.network;
54
+ if (preset !== undefined && !SANDBOX_NETWORK_PRESETS.includes(preset))
55
+ throw new SandboxError("sandbox.invalid-network", `network.preset must be one of ${SANDBOX_NETWORK_PRESETS.join(", ")}`);
56
+ if (allow !== undefined) {
57
+ if (!Array.isArray(allow))
58
+ throw new SandboxError("sandbox.invalid-network", "network.allow must be an array of host names");
59
+ for (const host of allow)
60
+ if (typeof host !== "string" || !isSandboxHostPattern(host))
61
+ throw new SandboxError("sandbox.invalid-network", `network.allow entry '${String(host)}' must be a host name such as api.github.com or *.example.com (no scheme, port or path)`);
62
+ }
63
+ result.network = Object.freeze({
64
+ ...(preset === undefined ? {} : { preset: preset }),
65
+ ...(allow === undefined ? {} : { allow: Object.freeze([...allow]) }),
66
+ });
67
+ }
68
+ if (options.resources !== undefined) {
69
+ if (!isRecord(options.resources))
70
+ throw new SandboxError("sandbox.invalid-resources", "sandbox({ resources }) must be an object");
71
+ for (const key of Object.keys(options.resources))
72
+ if (key !== "cpus" && key !== "memory")
73
+ throw new SandboxError("sandbox.invalid-resources", `resources.${key} is not supported in this version. Use cpus and memory.`);
74
+ const { cpus, memory } = options.resources;
75
+ if (cpus !== undefined && (!Number.isInteger(cpus) || cpus < 1 || cpus > 64))
76
+ throw new SandboxError("sandbox.invalid-resources", "resources.cpus must be an integer from 1 to 64");
77
+ if (memory !== undefined && (typeof memory !== "string" || parseSandboxSize(memory) === undefined))
78
+ throw new SandboxError("sandbox.invalid-resources", "resources.memory must be a size such as 512MiB or 2GiB");
79
+ result.resources = Object.freeze({
80
+ ...(cpus === undefined ? {} : { cpus }),
81
+ ...(memory === undefined ? {} : { memory }),
82
+ });
83
+ }
84
+ if (options.idle !== undefined) {
85
+ if (typeof options.idle !== "string" || parseSandboxDuration(options.idle) === undefined)
86
+ throw new SandboxError("sandbox.invalid-idle", "sandbox({ idle }) must be a duration such as 30s, 15m or 1h");
87
+ result.idle = options.idle;
88
+ }
89
+ return Object.freeze(result);
90
+ }
91
+ function isRecord(value) {
92
+ return !!value && typeof value === "object" && !Array.isArray(value);
93
+ }
@@ -0,0 +1,5 @@
1
+ export declare const SKILLS_USAGE = "The following skills provide specialized instructions. When a task matches a skill description, call load_skill with that name before proceeding.";
2
+ export declare function formatSkillCatalog(skills: readonly {
3
+ name: string;
4
+ description: string;
5
+ }[]): string;
@@ -0,0 +1,14 @@
1
+ export const SKILLS_USAGE = "The following skills provide specialized instructions. When a task matches a skill description, call load_skill with that name before proceeding.";
2
+ export function formatSkillCatalog(skills) {
3
+ const entries = skills
4
+ .map((skill) => ` <skill>\n <name>${escapeXml(skill.name)}</name>\n <description>${escapeXml(skill.description)}</description>\n </skill>`)
5
+ .join("\n");
6
+ return `<available_skills>\n${entries}\n</available_skills>`;
7
+ }
8
+ function escapeXml(value) {
9
+ return value
10
+ .replaceAll("&", "&amp;")
11
+ .replaceAll("<", "&lt;")
12
+ .replaceAll(">", "&gt;")
13
+ .replaceAll('"', "&quot;");
14
+ }
@@ -0,0 +1,5 @@
1
+ export { skills } from "./skills.js";
2
+ export type { SkillsCapability, SkillsOptions } from "./skills.js";
3
+ export { loadSkillsFromDirectory, resolveSkillsRoot, parseSkill, SkillsError, } from "./load.js";
4
+ export type { SkillDiagnostic } from "./load.js";
5
+ export { formatSkillCatalog, SKILLS_USAGE } from "./catalog.js";
@@ -0,0 +1,3 @@
1
+ export { skills } from "./skills.js";
2
+ export { loadSkillsFromDirectory, resolveSkillsRoot, parseSkill, SkillsError, } from "./load.js";
3
+ export { formatSkillCatalog, SKILLS_USAGE } from "./catalog.js";
@@ -0,0 +1,27 @@
1
+ import type { SkillRecord } from "@nylorun/core/define";
2
+ export interface SkillDiagnostic {
3
+ readonly severity: "info" | "warning";
4
+ readonly code: string;
5
+ readonly message: string;
6
+ readonly path?: string;
7
+ }
8
+ export declare class SkillsError extends Error {
9
+ readonly code: string;
10
+ readonly diagnostics: readonly SkillDiagnostic[];
11
+ constructor(code: string, message: string, diagnostics?: readonly SkillDiagnostic[]);
12
+ }
13
+ /** Resolve a skills catalog directory; throws when missing or not a directory. */
14
+ export declare function resolveSkillsRoot(directory: string): string;
15
+ /**
16
+ * Load Agent Skills (agentskills.io) from a catalog directory.
17
+ * Each immediate subdirectory with a valid SKILL.md becomes one skill.
18
+ */
19
+ export declare function loadSkillsFromDirectory(directory: string, diagnostics: SkillDiagnostic[], options?: {
20
+ readonly boundary?: string;
21
+ readonly codePrefix?: string;
22
+ }): Readonly<Record<string, SkillRecord>>;
23
+ export declare function parseSkill(text: string, directoryName: string): {
24
+ name: string;
25
+ description: string;
26
+ instructions: string;
27
+ } | undefined;
@@ -0,0 +1,171 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import matter from "gray-matter";
4
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+ export class SkillsError extends Error {
6
+ code;
7
+ diagnostics;
8
+ constructor(code, message, diagnostics = []) {
9
+ super(message);
10
+ this.name = "SkillsError";
11
+ this.code = code;
12
+ this.diagnostics = diagnostics;
13
+ }
14
+ }
15
+ /** Resolve a skills catalog directory; throws when missing or not a directory. */
16
+ export function resolveSkillsRoot(directory) {
17
+ const resolved = resolve(directory);
18
+ if (!existsSync(resolved))
19
+ throw new SkillsError("skills.missing", `Skills directory does not exist: ${directory}`);
20
+ const real = realpathSync(resolved);
21
+ if (!statSync(real).isDirectory())
22
+ throw new SkillsError("skills.missing", `Skills path is not a directory: ${directory}`);
23
+ return real;
24
+ }
25
+ /**
26
+ * Load Agent Skills (agentskills.io) from a catalog directory.
27
+ * Each immediate subdirectory with a valid SKILL.md becomes one skill.
28
+ */
29
+ export function loadSkillsFromDirectory(directory, diagnostics, options = {}) {
30
+ const root = resolveSkillsRoot(directory);
31
+ const boundary = options.boundary ?? root;
32
+ const prefix = options.codePrefix ?? "skills";
33
+ const skills = {};
34
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
35
+ if (!entry.isDirectory() || entry.name.startsWith("."))
36
+ continue;
37
+ if (entry.name === "node_modules" || entry.name === ".git")
38
+ continue;
39
+ const skillDir = join(root, entry.name);
40
+ const directoryReal = realInside(boundary, skillDir);
41
+ if (!directoryReal) {
42
+ diagnostics.push({
43
+ severity: "warning",
44
+ code: `${prefix}.skill-skipped`,
45
+ message: `Skipped skill '${entry.name}' because its directory escapes the package`,
46
+ path: skillDir,
47
+ });
48
+ continue;
49
+ }
50
+ const skillFile = join(directoryReal, "SKILL.md");
51
+ if (!existsSync(skillFile))
52
+ continue;
53
+ const skillReal = realInside(boundary, skillFile);
54
+ if (!skillReal || !statSync(skillReal).isFile()) {
55
+ diagnostics.push({
56
+ severity: "warning",
57
+ code: `${prefix}.skill-skipped`,
58
+ message: `Skipped skill '${entry.name}' because SKILL.md escapes the package`,
59
+ path: skillFile,
60
+ });
61
+ continue;
62
+ }
63
+ const parsed = parseSkill(readFileSync(skillReal, "utf8"), entry.name);
64
+ if (!parsed) {
65
+ diagnostics.push({
66
+ severity: "warning",
67
+ code: `${prefix}.skill-skipped`,
68
+ message: `Skipped skill '${entry.name}' because SKILL.md is not a valid Agent Skill`,
69
+ path: skillFile,
70
+ });
71
+ continue;
72
+ }
73
+ if (skills[parsed.name]) {
74
+ diagnostics.push({
75
+ severity: "warning",
76
+ code: `${prefix}.skill-skipped`,
77
+ message: `Skipped duplicate skill '${parsed.name}'`,
78
+ path: skillFile,
79
+ });
80
+ continue;
81
+ }
82
+ const resources = readResources(boundary, directoryReal, diagnostics, prefix);
83
+ skills[parsed.name] = {
84
+ name: parsed.name,
85
+ description: parsed.description,
86
+ instructions: parsed.instructions,
87
+ ...(Object.keys(resources).length === 0 ? {} : { resources }),
88
+ };
89
+ }
90
+ return skills;
91
+ }
92
+ export function parseSkill(text, directoryName) {
93
+ let parsed;
94
+ try {
95
+ parsed = matter(text);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ const name = asTrimmedString(parsed.data.name);
101
+ const description = asTrimmedString(parsed.data.description);
102
+ if (!name || !description || description.length > 1024)
103
+ return undefined;
104
+ if (name.length > 64 || !SKILL_NAME.test(name) || name !== directoryName)
105
+ return undefined;
106
+ return {
107
+ name,
108
+ description,
109
+ instructions: parsed.content.replace(/^\r?\n/, ""),
110
+ };
111
+ }
112
+ function asTrimmedString(value) {
113
+ return typeof value === "string" ? value.trim() : "";
114
+ }
115
+ function readResources(boundary, directory, diagnostics, prefix) {
116
+ const resources = {};
117
+ const walk = (current, depth) => {
118
+ if (depth > 6)
119
+ return;
120
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
121
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
122
+ continue;
123
+ const full = join(current, entry.name);
124
+ if (entry.isDirectory()) {
125
+ const real = realInside(boundary, full);
126
+ if (!real)
127
+ continue;
128
+ walk(real, depth + 1);
129
+ continue;
130
+ }
131
+ if (!entry.isFile() && !entry.isSymbolicLink())
132
+ continue;
133
+ if (depth === 0 && entry.name === "SKILL.md")
134
+ continue;
135
+ const real = realInside(boundary, full);
136
+ if (!real || !statSync(real).isFile()) {
137
+ diagnostics.push({
138
+ severity: "warning",
139
+ code: `${prefix}.resource-denied`,
140
+ message: `Denied resource outside the package: ${entry.name}`,
141
+ path: full,
142
+ });
143
+ continue;
144
+ }
145
+ const bytes = readFileSync(real);
146
+ if (bytes.includes(0))
147
+ continue;
148
+ const rel = relative(directory, real).split(sep).join("/");
149
+ if (!rel || rel.startsWith(".."))
150
+ continue;
151
+ resources[rel] = bytes.toString("utf8");
152
+ }
153
+ };
154
+ walk(directory, 0);
155
+ return resources;
156
+ }
157
+ function realInside(root, candidate) {
158
+ if (!existsSync(candidate))
159
+ return undefined;
160
+ try {
161
+ const real = realpathSync(candidate);
162
+ return isInside(root, real) ? real : undefined;
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
168
+ function isInside(root, target) {
169
+ const rel = relative(root, target);
170
+ return (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)));
171
+ }
@@ -0,0 +1,28 @@
1
+ import type { CapabilityDeclaration } from "@nylorun/core/define";
2
+ import { type SkillDiagnostic } from "./load.js";
3
+ export interface SkillsOptions {
4
+ /** Capability id. Defaults to the catalog directory basename. */
5
+ readonly id?: string;
6
+ }
7
+ export interface SkillsCapability extends CapabilityDeclaration {
8
+ readonly diagnostics: readonly SkillDiagnostic[];
9
+ /** Absolute catalog directory that was loaded. */
10
+ readonly root: string;
11
+ }
12
+ /**
13
+ * Load an agentskills.io catalog folder and return one capability for `Agent.use`.
14
+ *
15
+ * Expected layout:
16
+ * ```
17
+ * assistant-skills/
18
+ * lookup-order/SKILL.md
19
+ * refund/
20
+ * SKILL.md
21
+ * references/policy.md
22
+ * ```
23
+ *
24
+ * Sets both `skills` (manifest names/descriptions) and `skillRecords` (bodies +
25
+ * resources) so callers do not duplicate content. The harness injects
26
+ * `load_skill` / `read_skill_resource` from the records.
27
+ */
28
+ export declare function skills(directory: string, options?: SkillsOptions): SkillsCapability;
@@ -0,0 +1,47 @@
1
+ import { basename } from "node:path";
2
+ import { formatSkillCatalog, SKILLS_USAGE } from "./catalog.js";
3
+ import { loadSkillsFromDirectory, resolveSkillsRoot, } from "./load.js";
4
+ /**
5
+ * Load an agentskills.io catalog folder and return one capability for `Agent.use`.
6
+ *
7
+ * Expected layout:
8
+ * ```
9
+ * assistant-skills/
10
+ * lookup-order/SKILL.md
11
+ * refund/
12
+ * SKILL.md
13
+ * references/policy.md
14
+ * ```
15
+ *
16
+ * Sets both `skills` (manifest names/descriptions) and `skillRecords` (bodies +
17
+ * resources) so callers do not duplicate content. The harness injects
18
+ * `load_skill` / `read_skill_resource` from the records.
19
+ */
20
+ export function skills(directory, options = {}) {
21
+ const root = resolveSkillsRoot(directory);
22
+ const diagnostics = [];
23
+ const records = loadSkillsFromDirectory(root, diagnostics);
24
+ const listed = Object.values(records);
25
+ const id = options.id ?? basename(root);
26
+ if (listed.length === 0) {
27
+ return { id, root, diagnostics };
28
+ }
29
+ const skillManifest = Object.fromEntries(listed.map((skill) => [
30
+ skill.name,
31
+ { name: skill.name, description: skill.description },
32
+ ]));
33
+ return {
34
+ id,
35
+ root,
36
+ instructions: [
37
+ SKILLS_USAGE,
38
+ formatSkillCatalog(listed.map((skill) => ({
39
+ name: skill.name,
40
+ description: skill.description,
41
+ }))),
42
+ ],
43
+ skills: skillManifest,
44
+ skillRecords: records,
45
+ diagnostics,
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/agents",
3
- "version": "0.3.0-beta",
3
+ "version": "0.4.0-beta",
4
4
  "description": "Nylorun agent definitions, session client and connected executor",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -35,7 +35,8 @@
35
35
  "test": "vitest run --passWithNoTests"
36
36
  },
37
37
  "dependencies": {
38
- "@nylorun/core": "0.2.0-beta"
38
+ "@nylorun/core": "0.3.0-beta",
39
+ "gray-matter": "^4.0.3"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "zod": "^4.6.5"