@zenera/engine 1.1.50 → 1.1.51

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/README.md CHANGED
@@ -17,6 +17,30 @@ Pass `user` to say who the run is acting for. The agent gets it from the `curren
17
17
  the sandbox gets it from `ZEN_USER_*` or the `zenera` library. See
18
18
  [docs/identity.md](../../docs/identity.md).
19
19
 
20
+ Pass `plugins` to bring tools of your own. Each `ZenPlugin` is asked for its tools once, when the
21
+ engine opens, with the run's `user`, project and session, so a tool can hold what this run needs.
22
+ Config selects plugin tools like built-in ones (`tools: [mcp:*]`). A name or group that clashes with
23
+ a built-in tool, or with another plugin, is refused. The engine calls `close` when it closes, or when
24
+ it fails to open after the plugin was asked:
25
+
26
+ ```ts
27
+ import { openEngine, tool, type ZenPlugin } from '@zenera/engine';
28
+
29
+ const lookup: ZenPlugin = {
30
+ name: 'lookup',
31
+ tools: ({ user }) => [
32
+ tool<{ q: string }>({
33
+ name: 'lookup_search',
34
+ group: 'lookup',
35
+ description: 'Searches the directory as the signed-in user.',
36
+ parameters: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] },
37
+ execute: ({ q }) => search(q, user?.id),
38
+ }),
39
+ ],
40
+ };
41
+ const engine = await openEngine({ project, session, user, plugins: [lookup] });
42
+ ```
43
+
20
44
  ## Use
21
45
 
22
46
  ```ts
package/dist/engine.d.ts CHANGED
@@ -2,6 +2,7 @@ import { AgentRunner, Workspace, type AgentEvent, type AgentProject, type AgentS
2
2
  import { type ModelIssue } from './audit.ts';
3
3
  import { type Logger } from './errors.ts';
4
4
  import { type Project } from './projects.ts';
5
+ import { type ZenPlugin } from './plugins.ts';
5
6
  import { type Preflight, type SandboxSetup } from './sandbox.ts';
6
7
  import { type Held, type RunPaths, type SessionPaths } from './session.ts';
7
8
  /** Who a run acts for — re-exported so a host needs only this package. */
@@ -67,6 +68,13 @@ export interface EngineOptions {
67
68
  * letting whatever its own environment holds speak for one.
68
69
  */
69
70
  user?: RunIdentity | null;
71
+ /**
72
+ * Tools the host brings — see `ZenPlugin`. Each plugin is asked once, here,
73
+ * so its tools can hold what this run needs (who it is for, a credential
74
+ * the host minted for it). Config selects them like any other tool, and a
75
+ * name or group that clashes with a built-in is refused.
76
+ */
77
+ plugins?: ZenPlugin[];
70
78
  }
71
79
  export interface Engine {
72
80
  project: AgentProject;
package/dist/engine.js CHANGED
@@ -9,6 +9,7 @@ import { EXIT, EngineError, invalidError, stderrLogger, usageError, } from "./er
9
9
  import { readJson, writeJson } from "./home.js";
10
10
  import { KeyStore, assertUsable } from "./keys.js";
11
11
  import { projectMounts } from "./projects.js";
12
+ import { loadPlugins } from "./plugins.js";
12
13
  import { buildSandbox, prepareHome, teardown, usesSandbox, } from "./sandbox.js";
13
14
  import { acquire, createRun, readSessionMeta, writeRunMeta, writeSessionMeta, } from "./session.js";
14
15
  /**
@@ -51,6 +52,7 @@ export async function openEngine(opts) {
51
52
  let project;
52
53
  let files;
53
54
  let memory;
55
+ let plugins;
54
56
  try {
55
57
  const { root, config } = readProjectConfig(opts.project.dir);
56
58
  // Naming a directory declares the graph, but it cannot bind anyone to
@@ -83,22 +85,25 @@ export async function openEngine(opts) {
83
85
  // The same view the file tools have, so a path the agent just wrote
84
86
  // resolves the same way when it asks memory to keep a copy.
85
87
  files = new Workspace(workspaceOptions);
88
+ const builtins = [
89
+ // Both groups are pointed at one directory, so both are told the
90
+ // one name for it: whatever `run_command` prints a path as,
91
+ // `read_file` accepts.
92
+ ...fileTools(workspaceOptions),
93
+ ...sandboxTools(sandbox.pool),
94
+ // Registered whether or not a key exists: the credential is
95
+ // read when a tool is called, so a project that names `exa:*`
96
+ // loads on a machine that cannot yet search, and says so on the
97
+ // turn that tried.
98
+ ...exaTools(),
99
+ ];
100
+ // Every agent can ask who it is acting for without the project
101
+ // having to know the tool exists; `-current_user` still takes it away.
102
+ const always = identityTools(user);
103
+ plugins = await loadPlugins(opts.plugins ?? [], { user, project: opts.project, session: opts.session, logger }, [...builtins, ...always]);
86
104
  project = await loadProject(opts.project.dir, {
87
- tools: [
88
- // Both groups are pointed at one directory, so both are told the
89
- // one name for it: whatever `run_command` prints a path as,
90
- // `read_file` accepts.
91
- ...fileTools(workspaceOptions),
92
- ...sandboxTools(sandbox.pool),
93
- // Registered whether or not a key exists: the credential is
94
- // read when a tool is called, so a project that names `exa:*`
95
- // loads on a machine that cannot yet search, and says so on the
96
- // turn that tried.
97
- ...exaTools(),
98
- ],
99
- // Every agent can ask who it is acting for without the project
100
- // having to know the tool exists; `-current_user` still takes it away.
101
- alwaysTools: identityTools(user),
105
+ tools: [...builtins, ...plugins.tools],
106
+ alwaysTools: always,
102
107
  skillsAt: SKILLS_MOUNT,
103
108
  memory: opts.memory,
104
109
  memoryDir: opts.memoryDir,
@@ -108,28 +113,39 @@ export async function openEngine(opts) {
108
113
  });
109
114
  }
110
115
  catch (err) {
116
+ await plugins?.close();
111
117
  throw invalidError(err instanceof Error ? err.message : String(err), `while loading ${opts.project.dir}`);
112
118
  }
113
- // Asked here rather than at the first `run_command`, so a host with no
114
- // container engine costs an error instead of a round trip. Nothing is
115
- // started: the container itself waits until something actually runs.
116
- if (usesSandbox(project)) {
117
- prepareHome(sandbox);
118
- await opts.preflight?.(sandbox, opts.yes);
119
+ const loaded = plugins;
120
+ let state;
121
+ let lock;
122
+ let runner;
123
+ try {
124
+ // Asked here rather than at the first `run_command`, so a host with no
125
+ // container engine costs an error instead of a round trip. Nothing is
126
+ // started: the container itself waits until something actually runs.
127
+ if (usesSandbox(project)) {
128
+ prepareHome(sandbox);
129
+ await opts.preflight?.(sandbox, opts.yes);
130
+ }
131
+ // `recordRequests` is what makes the report show the exact bytes sent
132
+ // rather than a reconstruction. It costs state size, and a CLI run is
133
+ // written to disk once and inspected by a human — exactly the case the
134
+ // library leaves it off for by default.
135
+ //
136
+ // `--model` becomes the runner's fallback rather than an alias, so it
137
+ // overrides the config's `model:` without touching agents that pinned one.
138
+ runner = project.runner({
139
+ recordRequests: true,
140
+ model: opts.model ? project.models.model(opts.model) : undefined,
141
+ });
142
+ state = await loadState(opts.session);
143
+ lock = acquire(opts.session);
144
+ }
145
+ catch (err) {
146
+ await loaded.close();
147
+ throw err;
119
148
  }
120
- // `recordRequests` is what makes the report show the exact bytes sent
121
- // rather than a reconstruction. It costs state size, and a CLI run is
122
- // written to disk once and inspected by a human — exactly the case the
123
- // library leaves it off for by default.
124
- //
125
- // `--model` becomes the runner's fallback rather than an alias, so it
126
- // overrides the config's `model:` without touching agents that pinned one.
127
- const runner = project.runner({
128
- recordRequests: true,
129
- model: opts.model ? project.models.model(opts.model) : undefined,
130
- });
131
- const state = await loadState(opts.session);
132
- const lock = acquire(opts.session);
133
149
  return {
134
150
  project,
135
151
  runner,
@@ -151,6 +167,7 @@ export async function openEngine(opts) {
151
167
  if (!opts.memory) {
152
168
  project.close();
153
169
  }
170
+ await loaded.close();
154
171
  lock.release();
155
172
  },
156
173
  };
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './home.ts';
6
6
  export * from './ids.ts';
7
7
  export * from './image.ts';
8
8
  export * from './keys.ts';
9
+ export * from './plugins.ts';
9
10
  export * from './projects.ts';
10
11
  export * from './sandbox.ts';
11
12
  export * from './session.ts';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export * from "./home.js";
6
6
  export * from "./ids.js";
7
7
  export * from "./image.js";
8
8
  export * from "./keys.js";
9
+ export * from "./plugins.js";
9
10
  export * from "./projects.js";
10
11
  export * from "./sandbox.js";
11
12
  export * from "./session.js";
@@ -0,0 +1,36 @@
1
+ import type { AnyTool, RunIdentity } from '@zenera/neo';
2
+ import type { Logger } from './errors.ts';
3
+ import type { SessionPaths } from './session.ts';
4
+ /** What a plugin needs to write a tool — re-exported so a host needs only this package. */
5
+ export { tool, type AnyTool, type ToolContext } from '@zenera/neo';
6
+ /** What a plugin is told when it is asked for its tools. */
7
+ export interface PluginContext {
8
+ /** who the run acts for, when anyone; the same value `current_user` answers with */
9
+ user?: RunIdentity;
10
+ project: {
11
+ dir: string;
12
+ name: string;
13
+ };
14
+ session: SessionPaths;
15
+ logger: Logger;
16
+ }
17
+ export interface ZenPlugin {
18
+ /** for error messages: which plugin a tool or a failure came from */
19
+ name: string;
20
+ /** Called once when the engine opens. May return nothing, which adds nothing. */
21
+ tools(ctx: PluginContext): AnyTool[] | Promise<AnyTool[]>;
22
+ /** Called when the engine closes, or when it fails to open after the plugin was asked. */
23
+ close?(): void | Promise<void>;
24
+ }
25
+ export interface LoadedPlugins {
26
+ tools: AnyTool[];
27
+ /** Closes every plugin that was asked for tools, latest first. Never throws: failures are warnings. */
28
+ close(): Promise<void>;
29
+ }
30
+ /**
31
+ * Asks each plugin for its tools and checks them against the built-ins and each
32
+ * other. When a plugin fails or a check does, the plugins already asked are
33
+ * closed before the error is thrown.
34
+ */
35
+ export declare function loadPlugins(plugins: readonly ZenPlugin[], ctx: PluginContext, builtins: readonly AnyTool[]): Promise<LoadedPlugins>;
36
+ //# sourceMappingURL=plugins.d.ts.map
@@ -0,0 +1,70 @@
1
+ /** What a plugin needs to write a tool — re-exported so a host needs only this package. */
2
+ export { tool } from '@zenera/neo';
3
+ /**
4
+ * Asks each plugin for its tools and checks them against the built-ins and each
5
+ * other. When a plugin fails or a check does, the plugins already asked are
6
+ * closed before the error is thrown.
7
+ */
8
+ export async function loadPlugins(plugins, ctx, builtins) {
9
+ const asked = [];
10
+ const close = async () => {
11
+ for (const plugin of asked.splice(0).reverse()) {
12
+ try {
13
+ await plugin.close?.();
14
+ }
15
+ catch (err) {
16
+ ctx.logger.warn(`plugin ${plugin.name}: close failed: ${message(err)}`);
17
+ }
18
+ }
19
+ };
20
+ const owner = new Map(builtins.map((t) => [t.name, 'built-in']));
21
+ const builtinGroups = new Set(builtins.map((t) => t.group).filter((g) => g !== undefined));
22
+ const names = new Set();
23
+ const tools = [];
24
+ try {
25
+ for (const plugin of plugins) {
26
+ const name = plugin.name?.trim();
27
+ if (!name) {
28
+ throw new Error('a plugin has no name');
29
+ }
30
+ if (names.has(name)) {
31
+ throw new Error(`two plugins are named ${name}`);
32
+ }
33
+ names.add(name);
34
+ asked.push(plugin);
35
+ let given;
36
+ try {
37
+ given = (await plugin.tools(ctx)) ?? [];
38
+ }
39
+ catch (err) {
40
+ throw new Error(`plugin ${name}: ${message(err)}`);
41
+ }
42
+ for (const t of given) {
43
+ if (!t?.name?.trim()) {
44
+ throw new Error(`plugin ${name}: a tool has no name`);
45
+ }
46
+ const taken = owner.get(t.name);
47
+ if (taken) {
48
+ throw new Error(`plugin ${name}: tool ${t.name} is already ` +
49
+ (taken === 'built-in'
50
+ ? 'a built-in tool'
51
+ : `provided by plugin ${taken}`));
52
+ }
53
+ if (t.group && builtinGroups.has(t.group)) {
54
+ throw new Error(`plugin ${name}: tool ${t.name} is in group ${t.group}, which belongs to the built-in tools`);
55
+ }
56
+ owner.set(t.name, name);
57
+ tools.push(t);
58
+ }
59
+ }
60
+ }
61
+ catch (err) {
62
+ await close();
63
+ throw err;
64
+ }
65
+ return { tools, close };
66
+ }
67
+ function message(err) {
68
+ return err instanceof Error ? err.message : String(err);
69
+ }
70
+ //# sourceMappingURL=plugins.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/engine",
3
- "version": "1.1.50",
3
+ "version": "1.1.51",
4
4
  "description": "Runs a @zenera/neo project on this machine: keyring, sandbox, sessions and the record each turn leaves behind.",
5
5
  "keywords": [
6
6
  "agents",
@@ -45,6 +45,6 @@
45
45
  "prepack": "tsc -b"
46
46
  },
47
47
  "dependencies": {
48
- "@zenera/neo": "^1.1.50"
48
+ "@zenera/neo": "^1.1.51"
49
49
  }
50
50
  }