@particle-academy/prism-harness 0.8.0 → 0.9.1

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/dist/tools.js CHANGED
@@ -1,33 +1,107 @@
1
1
  import { HarnessError } from './errors.js';
2
- /**
3
- * Which tools exist, and which a given run may be offered.
4
- *
5
- * Three ways in, because the three answer different questions: a tool that is
6
- * always the same (`register`), one that needs the session to build
7
- * (`registerFactory`), and a set discovered at resolve time — an MCP server's
8
- * catalogue, say (`registerProvider`).
9
- */
2
+ import { validateAgent } from './address.js';
3
+ import { RegistrationCollision, registrationOwner } from './registration.js';
10
4
  export class ToolRegistry {
11
5
  #tools = new Map();
12
6
  #factories = new Map();
13
- #providers = [];
14
- register(tool) {
7
+ #metadata = new Map();
8
+ #providers = new Map();
9
+ #agents = new Map();
10
+ #staticOwners = new Map();
11
+ #factoryOwners = new Map();
12
+ #refuseDuplicates;
13
+ namespace;
14
+ root;
15
+ constructor(options = null, root) {
16
+ if (options !== null && typeof options !== 'string' && (typeof options !== 'object' || Array.isArray(options)))
17
+ throw new TypeError('Registry options must be an options object, agent string or null');
18
+ this.namespace = typeof options === 'string' ? options : null;
19
+ this.root = root;
20
+ const setting = options !== null && typeof options === 'object' ? options.refuseDuplicates : (root === undefined ? undefined : root.#refuseDuplicates);
21
+ if (setting !== undefined && typeof setting !== 'boolean')
22
+ throw new TypeError('refuseDuplicates must be a boolean');
23
+ this.#refuseDuplicates = setting ?? false;
24
+ validateAgent(this.namespace);
25
+ for (const key of ['namespace', 'root'])
26
+ Object.defineProperty(this, key, { writable: false, configurable: false });
27
+ }
28
+ /** Immutable namespace views share registrations, never a current-agent flag. */
29
+ forAgent(agent) {
30
+ validateAgent(agent);
31
+ const root = this.root ?? this;
32
+ if (agent === null)
33
+ return root;
34
+ let view = root.#agents.get(agent);
35
+ if (!view) {
36
+ view = new ToolRegistry(agent, root);
37
+ root.#agents.set(agent, view);
38
+ }
39
+ return view;
40
+ }
41
+ register(tool, owner) {
42
+ this.#claim(tool.name, owner, 'static');
15
43
  this.#tools.set(tool.name, tool);
44
+ this.#staticOwners.set(tool.name, registrationOwner(owner, this.namespace));
16
45
  return this;
17
46
  }
18
- registerMany(tools) {
47
+ registerMany(tools, owner) {
19
48
  for (const tool of tools)
20
- this.register(tool);
49
+ this.register(tool, owner);
50
+ return this;
51
+ }
52
+ registerFactory(name, factory, owner) {
53
+ this.#claim(name, owner, 'factory');
54
+ this.#factories.set(name, factory);
55
+ this.#factoryOwners.set(name, registrationOwner(owner, this.namespace));
56
+ return this;
57
+ }
58
+ registerProvider(provider, owner, name) {
59
+ const label = registrationOwner(owner, this.namespace);
60
+ const id = name ?? (this.namespace === null ? `provider:${this.#providers.size}` : undefined);
61
+ if (!id)
62
+ throw new TypeError('A named provider requires a registration name.');
63
+ const previous = this.#providers.get(id);
64
+ if (this.#strict() && previous)
65
+ throw new RegistrationCollision(this.namespace, id, previous.owner, label);
66
+ this.#providers.set(id, { provider, owner: label });
67
+ return this;
68
+ }
69
+ replace(tool, predecessorOwner, owner) {
70
+ this.#claim(tool.name, owner, 'static', predecessorOwner);
71
+ this.#tools.set(tool.name, tool);
72
+ this.#staticOwners.set(tool.name, registrationOwner(owner, this.namespace));
21
73
  return this;
22
74
  }
23
- registerFactory(name, factory) {
75
+ replaceFactory(name, factory, predecessorOwner, owner) {
76
+ this.#claim(name, owner, 'factory', predecessorOwner);
24
77
  this.#factories.set(name, factory);
78
+ this.#factoryOwners.set(name, registrationOwner(owner, this.namespace));
25
79
  return this;
26
80
  }
27
- registerProvider(provider) {
28
- this.#providers.push(provider);
81
+ replaceProvider(name, provider, predecessorOwner, owner) {
82
+ const label = registrationOwner(owner, this.namespace), previous = this.#providers.get(name);
83
+ if (!previous || previous.owner !== predecessorOwner)
84
+ throw new Error('Replacement must name the registered predecessor owner.');
85
+ this.#providers.set(name, { provider, owner: label });
29
86
  return this;
30
87
  }
88
+ #claim(name, owner, source, predecessor) {
89
+ const label = registrationOwner(owner, this.namespace), previous = this.#metadata.get(name);
90
+ if (predecessor !== undefined) {
91
+ if (!previous || previous.owner !== predecessor)
92
+ throw new Error('Replacement must name the registered predecessor owner.');
93
+ }
94
+ else if (this.#strict() && previous)
95
+ throw new RegistrationCollision(this.namespace, name, previous.owner, label);
96
+ if (this.#strict() || predecessor !== undefined) {
97
+ this.#tools.delete(name);
98
+ this.#factories.delete(name);
99
+ this.#staticOwners.delete(name);
100
+ this.#factoryOwners.delete(name);
101
+ }
102
+ this.#metadata.set(name, { owner: label, source });
103
+ }
104
+ #strict() { return this.namespace !== null || this.#refuseDuplicates; }
31
105
  /** Every name this registry can produce without a session. */
32
106
  names() {
33
107
  return [...new Set([...this.#tools.keys(), ...this.#factories.keys()])].sort();
@@ -41,11 +115,26 @@ export class ToolRegistry {
41
115
  * run wondering why the model never called it.
42
116
  */
43
117
  async resolve(names, session) {
44
- const provided = new Map();
118
+ const detailed = await this.resolveDetailed(names, session);
119
+ return new Map([...detailed].map(([name, entry]) => [name, entry.tool]));
120
+ }
121
+ /** The offer path and inspection path consume the same ownership result. */
122
+ async resolveDetailed(names, session) {
123
+ if (session && session.agent !== this.namespace) {
124
+ if (this.namespace !== null)
125
+ throw new Error('Harness registry/session agent mismatch.');
126
+ return this.forAgent(session.agent).resolveDetailed(names, session);
127
+ }
128
+ const provided = new Map(), metadata = new Map(this.#metadata);
45
129
  if (session !== undefined) {
46
- for (const provider of this.#providers) {
47
- for (const tool of await provider(session))
130
+ for (const { provider, owner } of this.#providers.values()) {
131
+ for (const tool of await provider(session)) {
132
+ const previous = metadata.get(tool.name);
133
+ if (this.#strict() && previous)
134
+ throw new RegistrationCollision(this.namespace, tool.name, previous.owner, owner);
48
135
  provided.set(tool.name, tool);
136
+ metadata.set(tool.name, { owner, source: 'provider' });
137
+ }
49
138
  }
50
139
  }
51
140
  const available = [
@@ -56,7 +145,7 @@ export class ToolRegistry {
56
145
  for (const name of selected) {
57
146
  const tool = this.#tools.get(name) ?? provided.get(name);
58
147
  if (tool !== undefined) {
59
- resolved.set(name, tool);
148
+ resolved.set(name, { tool, ...(this.#tools.has(name) ? { owner: this.#staticOwners.get(name), source: 'static' } : metadata.get(name)) });
60
149
  continue;
61
150
  }
62
151
  const factory = this.#factories.get(name);
@@ -66,7 +155,16 @@ export class ToolRegistry {
66
155
  if (session === undefined) {
67
156
  throw HarnessError.toolNotAvailable(`${name} (needs a session to build)`, available);
68
157
  }
69
- resolved.set(name, factory(session));
158
+ const built = factory(session);
159
+ if (this.#strict() && built.name !== name)
160
+ throw new Error(`Session-bound tool factory [${name}] returned [${built.name}].`);
161
+ if (built.name !== name) {
162
+ const previous = metadata.get(built.name);
163
+ if (this.#strict() && previous)
164
+ throw new RegistrationCollision(this.namespace, built.name, previous.owner, metadata.get(name).owner);
165
+ metadata.set(built.name, metadata.get(name));
166
+ }
167
+ resolved.set(name, { tool: built, owner: this.#factoryOwners.get(name), source: 'factory' });
70
168
  }
71
169
  return resolved;
72
170
  }
@@ -85,19 +183,54 @@ export class ToolRegistry {
85
183
  * not to leave in place — both at once.
86
184
  */
87
185
  export class ToolAuthorizer {
186
+ namespace;
187
+ root;
88
188
  enabled;
89
189
  #offer;
90
190
  #call;
91
- constructor(options = {}) {
191
+ #agents;
192
+ #scoped = new Map();
193
+ constructor(options = {}, namespace = null, root) {
194
+ this.namespace = namespace;
195
+ this.root = root;
196
+ validateAgent(namespace);
92
197
  this.enabled = options.enabled ?? false;
93
198
  this.#offer = options.offer;
94
199
  this.#call = options.call;
200
+ this.#agents = Object.fromEntries(Object.entries(options.agents ?? {}).map(([agent, config]) => {
201
+ validateAgent(agent);
202
+ return [agent, { ...config }];
203
+ }));
95
204
  if (!this.enabled && (options.offer !== undefined || options.call !== undefined)) {
96
205
  throw HarnessError.policyDefinedButDisabled();
97
206
  }
207
+ for (const key of ['enabled', 'namespace', 'root'])
208
+ Object.defineProperty(this, key, { writable: false, configurable: false });
209
+ }
210
+ forAgent(agent) {
211
+ validateAgent(agent);
212
+ const root = this.root ?? this;
213
+ if (agent === null)
214
+ return root;
215
+ let scoped = root.#scoped.get(agent);
216
+ if (!scoped) {
217
+ const overrides = root.#agents[agent] ?? {};
218
+ const options = { enabled: root.enabled, offer: root.#offer, call: root.#call, ...overrides, agents: {} };
219
+ if (typeof options.enabled !== 'boolean' || (options.offer !== undefined && typeof options.offer !== 'function') || (options.call !== undefined && typeof options.call !== 'function'))
220
+ throw new TypeError('Malformed agent tool authorization options.');
221
+ scoped = new ToolAuthorizer(options, agent, root);
222
+ root.#scoped.set(agent, scoped);
223
+ }
224
+ return scoped;
98
225
  }
226
+ hasCallPolicy() { return this.#call !== undefined; }
99
227
  /** The tools a run may be offered, each wrapped so the call policy is asked again. */
100
228
  async allowed(session, tools) {
229
+ if (session.agent !== this.namespace) {
230
+ if (this.namespace !== null)
231
+ throw new Error('Harness authorizer/session agent mismatch.');
232
+ return this.forAgent(session.agent).allowed(session, tools);
233
+ }
101
234
  if (!this.enabled) {
102
235
  return [...tools.values()];
103
236
  }
@@ -120,6 +253,11 @@ export class ToolAuthorizer {
120
253
  * be mistaken for enforcement.
121
254
  */
122
255
  async allowsCall(session, tool, args) {
256
+ if (session.agent !== this.namespace) {
257
+ if (this.namespace !== null)
258
+ throw new Error('Harness authorizer/session agent mismatch.');
259
+ return this.forAgent(session.agent).allowsCall(session, tool, args);
260
+ }
123
261
  if (!this.enabled || this.#call === undefined) {
124
262
  return true;
125
263
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/prism-harness",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Durable agent sessions for TypeScript — threads, session state and store drivers.",
5
5
  "type": "module",
6
6
  "sideEffects": false,