@framers/agentos 0.11.5 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/api/runtime/SelfImprovementSessionManager.d.ts.map +1 -1
  2. package/dist/api/runtime/SelfImprovementSessionManager.js +22 -8
  3. package/dist/api/runtime/SelfImprovementSessionManager.js.map +1 -1
  4. package/dist/cognition/emergent/ComposableToolBuilder.d.ts +59 -17
  5. package/dist/cognition/emergent/ComposableToolBuilder.d.ts.map +1 -1
  6. package/dist/cognition/emergent/ComposableToolBuilder.js +173 -53
  7. package/dist/cognition/emergent/ComposableToolBuilder.js.map +1 -1
  8. package/dist/cognition/emergent/CreateWorkflowTool.d.ts +19 -2
  9. package/dist/cognition/emergent/CreateWorkflowTool.d.ts.map +1 -1
  10. package/dist/cognition/emergent/CreateWorkflowTool.js +30 -2
  11. package/dist/cognition/emergent/CreateWorkflowTool.js.map +1 -1
  12. package/dist/cognition/emergent/EmergentCapabilityEngine.d.ts +68 -3
  13. package/dist/cognition/emergent/EmergentCapabilityEngine.d.ts.map +1 -1
  14. package/dist/cognition/emergent/EmergentCapabilityEngine.js +441 -78
  15. package/dist/cognition/emergent/EmergentCapabilityEngine.js.map +1 -1
  16. package/dist/cognition/emergent/EmergentJudge.d.ts +2 -0
  17. package/dist/cognition/emergent/EmergentJudge.d.ts.map +1 -1
  18. package/dist/cognition/emergent/EmergentJudge.js +3 -1
  19. package/dist/cognition/emergent/EmergentJudge.js.map +1 -1
  20. package/dist/cognition/emergent/EmergentToolRegistry.d.ts +43 -5
  21. package/dist/cognition/emergent/EmergentToolRegistry.d.ts.map +1 -1
  22. package/dist/cognition/emergent/EmergentToolRegistry.js +98 -19
  23. package/dist/cognition/emergent/EmergentToolRegistry.js.map +1 -1
  24. package/dist/cognition/emergent/ForgeToolMetaTool.d.ts +1 -0
  25. package/dist/cognition/emergent/ForgeToolMetaTool.d.ts.map +1 -1
  26. package/dist/cognition/emergent/ForgeToolMetaTool.js +7 -0
  27. package/dist/cognition/emergent/ForgeToolMetaTool.js.map +1 -1
  28. package/dist/cognition/emergent/StepGate.d.ts +73 -0
  29. package/dist/cognition/emergent/StepGate.d.ts.map +1 -0
  30. package/dist/cognition/emergent/StepGate.js +124 -0
  31. package/dist/cognition/emergent/StepGate.js.map +1 -0
  32. package/dist/cognition/emergent/index.d.ts +3 -1
  33. package/dist/cognition/emergent/index.d.ts.map +1 -1
  34. package/dist/cognition/emergent/index.js +1 -0
  35. package/dist/cognition/emergent/index.js.map +1 -1
  36. package/dist/cognition/emergent/types.d.ts +32 -2
  37. package/dist/cognition/emergent/types.d.ts.map +1 -1
  38. package/dist/cognition/emergent/types.js +10 -0
  39. package/dist/cognition/emergent/types.js.map +1 -1
  40. package/dist/core/llm/providers/implementations/OpenRouterProvider.d.ts.map +1 -1
  41. package/dist/core/llm/providers/implementations/OpenRouterProvider.js +4 -2
  42. package/dist/core/llm/providers/implementations/OpenRouterProvider.js.map +1 -1
  43. package/dist/core/tools/ITool.d.ts +6 -0
  44. package/dist/core/tools/ITool.d.ts.map +1 -1
  45. package/dist/core/tools/ToolExecutor.d.ts +19 -0
  46. package/dist/core/tools/ToolExecutor.d.ts.map +1 -1
  47. package/dist/core/tools/ToolExecutor.js +9 -0
  48. package/dist/core/tools/ToolExecutor.js.map +1 -1
  49. package/dist/core/tools/ToolOrchestrator.d.ts +13 -0
  50. package/dist/core/tools/ToolOrchestrator.d.ts.map +1 -1
  51. package/dist/core/tools/ToolOrchestrator.js +129 -13
  52. package/dist/core/tools/ToolOrchestrator.js.map +1 -1
  53. package/dist/extensions/ExtensionRegistry.d.ts +7 -0
  54. package/dist/extensions/ExtensionRegistry.d.ts.map +1 -1
  55. package/dist/extensions/ExtensionRegistry.js +24 -0
  56. package/dist/extensions/ExtensionRegistry.js.map +1 -1
  57. package/dist/io/media/images/imageToBuffer.d.ts.map +1 -1
  58. package/dist/io/media/images/imageToBuffer.js +3 -2
  59. package/dist/io/media/images/imageToBuffer.js.map +1 -1
  60. package/dist/io/speech/SpeechProviderResolver.d.ts.map +1 -1
  61. package/dist/io/speech/SpeechProviderResolver.js +13 -12
  62. package/dist/io/speech/SpeechProviderResolver.js.map +1 -1
  63. package/dist/io/speech/SpeechRuntime.d.ts.map +1 -1
  64. package/dist/io/speech/SpeechRuntime.js +16 -8
  65. package/dist/io/speech/SpeechRuntime.js.map +1 -1
  66. package/package.json +1 -1
@@ -13,6 +13,7 @@
13
13
  * After registration the engine tracks usage and auto-promotes tools that
14
14
  * meet the configured `EmergentConfig.promotionThreshold` criteria.
15
15
  */
16
+ import { GMI_INSTANCE_ID_PREFIX } from './types.js';
16
17
  import { parsePersistedSource, parseRowSchemas, parseStoredRequest, requestFromImplementation, sessionFromSource, requestFromSource, sourceFromImplementation, stateSetterFromColumn, toolFromRow, } from './persisted-source.js';
17
18
  import { normalizeAllowlist, toSandboxApis } from './capabilities.js';
18
19
  // ============================================================================
@@ -24,6 +25,35 @@ import { normalizeAllowlist, toSandboxApis } from './capabilities.js';
24
25
  /** The configuration key behind a reason, named in the start-up line. */
25
26
  const REASON_CONFIG_KEYS = {
26
27
  source_not_persisted: 'emergent.persistSandboxSource',
28
+ sandbox_tools_off: 'emergent.allowSandboxTools',
29
+ compose_needs_gate: 'a StepGate (deps.stepGate, or the ComposableToolBuilder)',
30
+ step_not_chainable: 'emergent.compose.sideEffectingTools',
31
+ side_effects_undeclared: "the step tool's hasSideEffects",
32
+ step_cycle: "the composition's steps (a step reaches the composition itself)",
33
+ };
34
+ /**
35
+ * The library's suspensions of a composition that a change in what is
36
+ * registered can lift: the composition is checked again when a tool one of
37
+ * its steps names is registered (`onHostToolRegistered`).
38
+ */
39
+ const STEP_SUSPENSION_REASONS = new Set([
40
+ 'compose_needs_gate',
41
+ 'step_missing',
42
+ 'step_not_chainable',
43
+ 'side_effects_undeclared',
44
+ 'step_replaced',
45
+ 'step_cycle',
46
+ ]);
47
+ /** Step refusals a composed call can meet at run time, and the suspension each leads to. */
48
+ const RUN_REFUSAL_REASONS = {
49
+ step_missing: 'step_missing',
50
+ step_not_chainable: 'step_replaced',
51
+ side_effects_undeclared: 'step_replaced',
52
+ compose_needs_gate: 'compose_needs_gate',
53
+ step_cycle: 'step_cycle',
54
+ // The step's tool was replaced between its check and its run: nothing ran,
55
+ // and the composition waits for a re-check.
56
+ step_replaced: 'step_replaced',
27
57
  };
28
58
  // ============================================================================
29
59
  // ENGINE
@@ -86,6 +116,12 @@ export class EmergentCapabilityEngine {
86
116
  this.onToolForged = deps.onToolForged;
87
117
  this.onToolPromoted = deps.onToolPromoted;
88
118
  this.onToolRemoved = deps.onToolRemoved;
119
+ if (deps.stepGate) {
120
+ this.composableBuilder.bind({ gate: deps.stepGate });
121
+ }
122
+ this.composableBuilder.bind({
123
+ sideEffectingTools: this.config.compose?.sideEffectingTools ?? [],
124
+ });
89
125
  }
90
126
  // --------------------------------------------------------------------------
91
127
  // PUBLIC: forge
@@ -106,7 +142,9 @@ export class EmergentCapabilityEngine {
106
142
  * 6. If rejected: return failure with the judge's reasoning.
107
143
  *
108
144
  * @param request - The forge request describing the desired tool.
109
- * @param context - Caller context containing the agent and session IDs.
145
+ * @param context - The agent and session ids. `caller` is the forging
146
+ * call's own context: a composition's test steps run as that caller, so
147
+ * they meet the checks the caller's direct calls meet.
110
148
  * @returns A {@link ForgeResult} indicating success or failure.
111
149
  */
112
150
  async forge(request, context) {
@@ -122,34 +160,68 @@ export class EmergentCapabilityEngine {
122
160
  if (request.implementation.mode === 'compose') {
123
161
  // ---- COMPOSE MODE ----
124
162
  source = JSON.stringify(request.implementation);
125
- // Build the composable tool so we can execute test cases against it.
126
- const composedTool = this.composableBuilder.build(request.name, request.description, request.inputSchema, request.implementation);
127
- // Run every declared test case.
128
- const mockContext = {
163
+ // Every step must be chainable before any test case runs.
164
+ for (const step of request.implementation.steps) {
165
+ const verdict = this.composableBuilder.check(step.tool);
166
+ if (!verdict.ok) {
167
+ return {
168
+ success: false,
169
+ error: `${verdict.code}: step "${step.name}" (tool "${step.tool}"): ${verdict.message}`,
170
+ };
171
+ }
172
+ }
173
+ // A composition must not reach itself, through any nesting.
174
+ const cycle = this.compositionCycle(request.name, request.implementation.steps);
175
+ if (cycle) {
176
+ return {
177
+ success: false,
178
+ error: `step_cycle: "${request.name}" reaches itself through ${cycle.join(' -> ')}`,
179
+ };
180
+ }
181
+ // The test steps run as the forging caller, so they meet the checks the
182
+ // caller's direct calls meet; a host that builds the engine itself and
183
+ // passes no caller gets a context of its own.
184
+ const testContext = context.caller ?? {
129
185
  gmiId: context.agentId,
130
186
  personaId: 'emergent-forge',
131
187
  userContext: { userId: 'system' },
132
188
  correlationId: context.sessionId,
133
189
  };
134
190
  for (const tc of request.testCases) {
191
+ let result;
135
192
  try {
136
- const result = await composedTool.execute(tc.input, mockContext);
137
- testResults.push({
138
- input: tc.input,
139
- output: result.output,
140
- success: result.success,
141
- error: result.error,
142
- });
193
+ result = await this.composableBuilder.runPipeline(request.implementation, tc.input, testContext, { dry: { stepOutputs: tc.stepOutputs ?? {} } });
143
194
  }
144
195
  catch (err) {
145
- const message = err instanceof Error ? err.message : String(err);
146
196
  testResults.push({
147
197
  input: tc.input,
148
198
  output: undefined,
149
199
  success: false,
150
- error: message,
200
+ error: err instanceof Error ? err.message : String(err),
151
201
  });
202
+ continue;
203
+ }
204
+ const details = result.details;
205
+ if (details?.code === 'dry_run_needs_output') {
206
+ return { success: false, error: result.error };
152
207
  }
208
+ if (details?.code === 'permission_denied' && !context.caller) {
209
+ // A step needs a capability the stand-in context does not carry:
210
+ // the test says nothing about the tool, so the forge asks for the
211
+ // caller instead of recording a failed test.
212
+ return {
213
+ success: false,
214
+ error: 'caller_context_required: a step of this composition needs a capability; ' +
215
+ "pass the forging caller's context (forge(request, { ..., caller }))",
216
+ };
217
+ }
218
+ testResults.push({
219
+ input: tc.input,
220
+ output: result.output,
221
+ success: result.success,
222
+ error: result.error,
223
+ ...(details?.effects && details.effects.length > 0 ? { effects: details.effects } : {}),
224
+ });
153
225
  }
154
226
  }
155
227
  else {
@@ -234,6 +306,7 @@ export class EmergentCapabilityEngine {
234
306
  source: `forged by agent ${context.agentId} during session ${context.sessionId}`,
235
307
  };
236
308
  this.registry.register(tool, 'session');
309
+ const registered = this.registry.generation(toolId);
237
310
  let written;
238
311
  try {
239
312
  written = await this.registry.setState(toolId, 'active', null, {
@@ -246,7 +319,7 @@ export class EmergentCapabilityEngine {
246
319
  // is re-derived from its source at the next load.
247
320
  console.warn(`[agentos:emergent] could not store the request of "${request.name}" (${toolId}):`, error instanceof Error ? error.message : error);
248
321
  }
249
- if (this.registry.wasRemoved(toolId)) {
322
+ if (this.registry.generation(toolId) !== registered) {
250
323
  // Removed while it was being forged: nothing is registered.
251
324
  return { success: false, error: 'the tool was removed while it was being forged' };
252
325
  }
@@ -273,8 +346,12 @@ export class EmergentCapabilityEngine {
273
346
  }
274
347
  this.indexTool(toolId, context.agentId, context.sessionId);
275
348
  if (this.onToolForged) {
349
+ // The object the registry holds (a registration stores a stamped copy;
350
+ // an adoption above stores the object itself), so the settlement
351
+ // below compares like with like.
352
+ const live = this.registry.get(toolId) ?? tool;
276
353
  try {
277
- await this.onToolForged(tool, this.createExecutableTool(tool));
354
+ await this.onToolForged(live, this.createExecutableTool(live));
278
355
  }
279
356
  catch (error) {
280
357
  this.registry.remove(toolId);
@@ -284,6 +361,20 @@ export class EmergentCapabilityEngine {
284
361
  error: error instanceof Error ? error.message : 'Failed to activate forged tool.',
285
362
  };
286
363
  }
364
+ let settled;
365
+ try {
366
+ settled = await this.settleRegistration(live);
367
+ }
368
+ catch (error) {
369
+ return {
370
+ success: false,
371
+ error: error instanceof Error ? error.message : 'the forged tool could not be registered',
372
+ };
373
+ }
374
+ if (settled && settled.reason === 'removed') {
375
+ // Removed while the host was registering it: nothing is registered.
376
+ return { success: false, error: 'the tool was removed while it was being forged' };
377
+ }
287
378
  }
288
379
  return {
289
380
  success: true,
@@ -324,10 +415,21 @@ export class EmergentCapabilityEngine {
324
415
  if (!tool) {
325
416
  return null;
326
417
  }
418
+ // A tool that no longer fits what is in force is suspended, not promoted.
419
+ const refusal = this.refusalFor(tool.implementation, tool.name);
420
+ if (refusal) {
421
+ await this.suspendAsLibrary(toolId, refusal);
422
+ return { success: false, error: `${refusal}: the tool no longer fits and was suspended.` };
423
+ }
327
424
  // Only session-tier tools are eligible for auto-promotion.
328
425
  if (tool.tier !== 'session') {
329
426
  return null;
330
427
  }
428
+ // An owner with the reserved instance-id prefix is never promoted: an
429
+ // agent-tier row with that owner reads as one an earlier release wrote.
430
+ if (tool.createdBy.startsWith(GMI_INSTANCE_ID_PREFIX)) {
431
+ return null;
432
+ }
331
433
  // Check thresholds.
332
434
  const { uses, confidence } = this.config.promotionThreshold;
333
435
  if (tool.usageStats.totalUses < uses || tool.usageStats.confidenceScore < confidence) {
@@ -403,6 +505,17 @@ export class EmergentCapabilityEngine {
403
505
  const removedTools = this.getSessionTools(sessionId);
404
506
  this.registry.cleanupSession(sessionId);
405
507
  this.index.bySession.delete(sessionId);
508
+ for (const tool of removedTools) {
509
+ // The agent index too, so a lookup by agent never names a tool that is gone.
510
+ this.removeIndexedToolEverywhere(tool.id);
511
+ }
512
+ if (this.onToolRemoved) {
513
+ // The executables go with the tools, as removeTool does. A host that
514
+ // cleans up through the orchestrator unregisters the same names; a
515
+ // second unregistration of a name finds nothing to do.
516
+ const unregister = this.onToolRemoved;
517
+ void Promise.allSettled(removedTools.map((tool) => unregister(tool)));
518
+ }
406
519
  return removedTools;
407
520
  }
408
521
  /**
@@ -419,28 +532,34 @@ export class EmergentCapabilityEngine {
419
532
  * @returns what happened, so a host can tell a registered tool from a refused one.
420
533
  */
421
534
  async syncPersistedTool(tool) {
422
- let row = await this.registry.loadRow(tool.id);
423
- if (!row && this.registry.hasStorage()) {
535
+ const existing = await this.registry.loadRow(tool.id);
536
+ if (!existing && this.registry.hasStorage()) {
424
537
  // No stored row yet: the host hydrates from its own store. The row is
425
538
  // written as it was before loading went through the stored row, so the
426
539
  // tool's uses are recorded and the next load finds it.
427
540
  await this.registry.writeToolRow(tool);
428
- row = await this.registry.loadRow(tool.id);
429
541
  }
430
- if (row) {
431
- // With source persistence off the row holds a redacted record; the
432
- // implementation the host supplied is what runs then.
433
- return this.admitRow(row, { sourceFallback: sourceFromImplementation(tool.implementation) });
434
- }
435
- const stored = await this.registry.readState(tool.id);
436
- return this.admit({
437
- toolId: tool.id,
438
- name: tool.name,
439
- source: sourceFromImplementation(tool.implementation),
440
- stored,
441
- requestStored: stored?.request != null,
442
- legacyActive: tool.isActive ?? true,
443
- buildTool: () => tool,
542
+ // The row is read for the admission after any write above, so the write
543
+ // is not a change the admission's read missed.
544
+ return this.withRead(async (readAt) => {
545
+ const row = await this.registry.loadRow(tool.id);
546
+ if (row) {
547
+ // With source persistence off the row holds a redacted record; the
548
+ // implementation the host supplied is what runs then.
549
+ return this.admitRow(row, { readAt, sourceFallback: sourceFromImplementation(tool.implementation) });
550
+ }
551
+ const stored = await this.registry.readState(tool.id);
552
+ return this.admit({
553
+ toolId: tool.id,
554
+ name: tool.name,
555
+ source: sourceFromImplementation(tool.implementation),
556
+ stored,
557
+ requestStored: stored?.request != null,
558
+ legacyActive: tool.isActive ?? true,
559
+ tier: tool.tier,
560
+ createdBy: tool.createdBy,
561
+ buildTool: () => tool,
562
+ }, { readAt });
444
563
  });
445
564
  }
446
565
  /**
@@ -493,17 +612,47 @@ export class EmergentCapabilityEngine {
493
612
  throw new Error("selector_required: loadPersistedTools({ tiers: ['session'] }) needs the sessionId whose tools to load; " +
494
613
  "a session's tools are private to it.");
495
614
  }
496
- const rows = await this.registry.loadRows(tiers, { agentId, sessionId });
497
615
  const outcomes = [];
498
616
  const failed = [];
499
- for (const row of rows) {
617
+ // One read for the rows and their admissions: a tool that changes in this
618
+ // process after the rows were read (a removal, a registration) is read
619
+ // again before it is adopted.
620
+ await this.withRead(async (readAt) => {
621
+ // Code tools first: a stored composition may chain a stored code tool.
622
+ const rows = (await this.registry.loadRows(tiers, { agentId, sessionId })).sort((a, b) => Number(a.implementation_mode === 'compose') - Number(b.implementation_mode === 'compose'));
623
+ for (const row of rows) {
624
+ try {
625
+ outcomes.push(await this.admitRow(row, { readAt }));
626
+ }
627
+ catch (error) {
628
+ const message = error instanceof Error ? error.message : String(error);
629
+ failed.push({ toolId: row.id, name: row.name, error: message });
630
+ console.warn(`[agentos:emergent] stored tool "${row.name}" (${row.id}) did not load: ${message}`);
631
+ }
632
+ }
633
+ });
634
+ // A composition can chain another composition loaded after it: those
635
+ // suspended because a step was missing are admitted once more.
636
+ for (let i = 0; i < outcomes.length; i += 1) {
637
+ const outcome = outcomes[i];
638
+ if (outcome.state !== 'suspended' || outcome.reason !== 'step_missing') {
639
+ continue;
640
+ }
500
641
  try {
501
- outcomes.push(await this.admitRow(row));
642
+ const again = await this.withRead(async (readAt) => {
643
+ const row = await this.registry.loadRow(outcome.toolId);
644
+ return row ? this.admitRow(row, { readAt }) : undefined;
645
+ });
646
+ if (again) {
647
+ outcomes[i] = again;
648
+ }
502
649
  }
503
650
  catch (error) {
504
651
  const message = error instanceof Error ? error.message : String(error);
505
- failed.push({ toolId: row.id, name: row.name, error: message });
506
- console.warn(`[agentos:emergent] stored tool "${row.name}" (${row.id}) did not load: ${message}`);
652
+ outcomes.splice(i, 1);
653
+ i -= 1;
654
+ failed.push({ toolId: outcome.toolId, name: outcome.name, error: message });
655
+ console.warn(`[agentos:emergent] stored tool "${outcome.name}" (${outcome.toolId}) did not load: ${message}`);
507
656
  }
508
657
  }
509
658
  for (const outcome of outcomes) {
@@ -579,24 +728,69 @@ export class EmergentCapabilityEngine {
579
728
  * @returns the outcome, or `undefined` when the tool is unknown.
580
729
  */
581
730
  async reactivateTool(toolId) {
582
- const row = await this.registry.loadRow(toolId);
583
- if (row) {
584
- return this.admitRow(row, { force: true });
731
+ return this.recheck(toolId, true);
732
+ }
733
+ /**
734
+ * Called by the host when any tool is registered. A composition the library
735
+ * suspended because a step was missing, refused, replaced or circular is
736
+ * checked again when a tool one of its steps names arrives, and registered
737
+ * when it fits. A host's own suspension is left to the host.
738
+ */
739
+ async onHostToolRegistered(toolName) {
740
+ for (const record of this.registry.listStates()) {
741
+ if (record.state !== 'suspended' ||
742
+ record.setBy !== 'library' ||
743
+ !STEP_SUSPENSION_REASONS.has(record.reason ?? '')) {
744
+ continue;
745
+ }
746
+ if (record.request?.kind !== 'compose' || !record.request.steps.some((step) => step.tool === toolName)) {
747
+ continue;
748
+ }
749
+ try {
750
+ await this.recheck(record.toolId, false);
751
+ }
752
+ catch (error) {
753
+ console.warn(`[agentos:emergent] could not re-check "${record.toolId}" after "${toolName}" was registered:`, error instanceof Error ? error.message : error);
754
+ }
585
755
  }
586
- const tool = this.registry.get(toolId);
587
- if (!tool) {
588
- return undefined;
756
+ }
757
+ async recheck(toolId, force) {
758
+ return this.withRead(async (readAt) => {
759
+ const row = await this.registry.loadRow(toolId);
760
+ if (row) {
761
+ return this.admitRow(row, { force, readAt });
762
+ }
763
+ const tool = this.registry.get(toolId);
764
+ if (!tool) {
765
+ return undefined;
766
+ }
767
+ const held = this.registry.getState(toolId);
768
+ return this.admit({
769
+ toolId,
770
+ name: tool.name,
771
+ source: sourceFromImplementation(tool.implementation),
772
+ stored: held,
773
+ requestStored: held?.request != null,
774
+ legacyActive: true,
775
+ tier: tool.tier,
776
+ createdBy: tool.createdBy,
777
+ buildTool: () => tool,
778
+ }, { force, readAt });
779
+ });
780
+ }
781
+ /**
782
+ * Run a read of stored rows and the admissions that use it. `readAt` is the
783
+ * point the read started at; an admission adopts only what has not changed
784
+ * since (see `EmergentToolRegistry.adopt`).
785
+ */
786
+ async withRead(read) {
787
+ const readAt = this.registry.beginRead();
788
+ try {
789
+ return await read(readAt);
790
+ }
791
+ finally {
792
+ this.registry.endRead();
589
793
  }
590
- const held = this.registry.getState(toolId);
591
- return this.admit({
592
- toolId,
593
- name: tool.name,
594
- source: sourceFromImplementation(tool.implementation),
595
- stored: held,
596
- requestStored: held?.request != null,
597
- legacyActive: true,
598
- buildTool: () => tool,
599
- }, { force: true });
600
794
  }
601
795
  admitRow(row, options = {}) {
602
796
  const stored = row.state
@@ -632,6 +826,8 @@ export class EmergentCapabilityEngine {
632
826
  requestStored: row.request_json != null,
633
827
  legacyActive: !(row.is_active === 0 || row.is_active === false),
634
828
  flagSynced: row.flag_synced == null ? undefined : !(row.flag_synced === 0 || row.flag_synced === false),
829
+ tier: row.tier,
830
+ createdBy: row.created_by_agent,
635
831
  buildTool: (implementation) => toolFromRow(row, implementation),
636
832
  }, options);
637
833
  }
@@ -649,12 +845,17 @@ export class EmergentCapabilityEngine {
649
845
  const { toolId, name } = candidate;
650
846
  const depth = options.readmitted ?? 0;
651
847
  if (depth < 2) {
652
- const fresh = await this.registry.loadRow(toolId);
653
- if (fresh) {
654
- return this.admitRow(fresh, { ...options, readmitted: depth + 1 });
655
- }
656
- await this.unregisterIfLive(toolId);
657
- return { toolId, name, state: 'demoted', reason: 'removed' };
848
+ // The tool's queued writes and deletes land first, so the row read next
849
+ // is the one they leave.
850
+ await this.registry.settled(toolId);
851
+ return this.withRead(async (readAt) => {
852
+ const fresh = await this.registry.loadRow(toolId);
853
+ if (fresh) {
854
+ return this.admitRow(fresh, { ...options, readmitted: depth + 1, readAt });
855
+ }
856
+ await this.unregisterIfLive(toolId);
857
+ return { toolId, name, state: 'demoted', reason: 'removed' };
858
+ });
658
859
  }
659
860
  // Nothing is written; the tool is held off here until the next load.
660
861
  this.holdStored(toolId, {
@@ -670,6 +871,9 @@ export class EmergentCapabilityEngine {
670
871
  }
671
872
  async admit(candidate, options = {}) {
672
873
  const { toolId, name, source, stored, requestStored } = candidate;
874
+ // The point after which a change makes what this admission holds stale:
875
+ // where its row was read, or, for a caller that did not say, now.
876
+ const readAt = options.readAt ?? this.registry.generation(toolId);
673
877
  let legacyActive = candidate.legacyActive;
674
878
  // A row whose last state write did not finish its flag write: finish it
675
879
  // first, so the flag reads what the state row says before anything is
@@ -756,8 +960,16 @@ export class EmergentCapabilityEngine {
756
960
  allowlist: source.format === 'code-with-list' ? granted.filter((api) => listed.allowlist.includes(api)) : granted,
757
961
  };
758
962
  }
963
+ // 3a. An agent-tier row from an earlier release is owned by the forging
964
+ // GMI instance's id, which no persona can match, so no caller could
965
+ // ever pass the owner check: it loads suspended rather than running
966
+ // for everyone. Forging the tool again under the persona is the way
967
+ // back; `reactivateTool` goes through this same path.
968
+ if (!refusal && candidate.tier === 'agent' && candidate.createdBy.startsWith(GMI_INSTANCE_ID_PREFIX)) {
969
+ refusal = 'legacy_owner';
970
+ }
759
971
  if (implementation && !refusal) {
760
- refusal = this.refusalFor(implementation);
972
+ refusal = this.refusalFor(implementation, name);
761
973
  }
762
974
  if (refusal || !implementation) {
763
975
  const reason = refusal ?? 'source_unreadable';
@@ -852,10 +1064,22 @@ export class EmergentCapabilityEngine {
852
1064
  setBy: 'library',
853
1065
  at: Date.now(),
854
1066
  request,
855
- });
1067
+ }, readAt);
856
1068
  if (!adopted) {
857
- // Removed in this process while the row was being admitted.
858
- return { toolId, name, state: 'demoted', reason: 'removed' };
1069
+ // The tool changed in this process after its row was read (removed,
1070
+ // replaced under its id, written again), or a removal of it is still
1071
+ // deleting its rows: what this admission holds may be stale. With
1072
+ // storage the row is read again once the tool's queued writes have run;
1073
+ // without, the registry's own record is the answer.
1074
+ if (this.registry.hasStorage()) {
1075
+ return this.readmit(candidate, options);
1076
+ }
1077
+ const current = this.registry.get(toolId);
1078
+ if (!current) {
1079
+ return { toolId, name, state: 'demoted', reason: 'removed' };
1080
+ }
1081
+ const held = this.registry.getState(toolId);
1082
+ return { toolId, name: current.name, state: held?.state ?? 'active', reason: held?.reason ?? null };
859
1083
  }
860
1084
  this.indexTool(tool.id, tool.createdBy, this.extractSessionId(tool.source) ?? `persisted:${tool.id}`);
861
1085
  if (this.onToolForged) {
@@ -870,28 +1094,145 @@ export class EmergentCapabilityEngine {
870
1094
  this.removeIndexedToolEverywhere(toolId);
871
1095
  throw error;
872
1096
  }
873
- if (this.registry.wasRemoved(toolId)) {
874
- // Removed while the host was registering it: the registration goes.
875
- if (this.onToolRemoved) {
876
- await this.onToolRemoved(tool);
877
- }
878
- this.removeIndexedToolEverywhere(toolId);
879
- return { toolId, name, state: 'demoted', reason: 'removed' };
1097
+ const settled = await this.settleRegistration(tool);
1098
+ if (settled) {
1099
+ return settled;
880
1100
  }
881
1101
  }
882
1102
  return { toolId, name, state: 'active', reason: null };
883
1103
  }
1104
+ /**
1105
+ * After the host registered a tool's executable, the registry must still
1106
+ * hold that very object. When it does not (the tool was removed, or
1107
+ * replaced under its id, while the registration ran), the executor is
1108
+ * brought back in line with the registry: the current tool's executable is
1109
+ * registered (the old name's taken out first when the name changed), or
1110
+ * the stale executable is taken out when the registry holds none. The
1111
+ * registry is read again after every registration, so a change during the
1112
+ * settlement is settled too, up to three rounds. A registration that fails,
1113
+ * or a tool still changing after three rounds, leaves no executable under
1114
+ * the name and throws: a tool that cannot be called is reported, never a
1115
+ * stale executable left running. Returns the outcome to report, or
1116
+ * undefined when the first registration stands.
1117
+ */
1118
+ async settleRegistration(tool) {
1119
+ let registered = tool;
1120
+ for (let round = 0; round < 3; round += 1) {
1121
+ const current = this.registry.get(tool.id);
1122
+ if (current === registered) {
1123
+ if (round === 0) {
1124
+ return undefined;
1125
+ }
1126
+ if (!current) {
1127
+ this.removeIndexedToolEverywhere(tool.id);
1128
+ return { toolId: tool.id, name: tool.name, state: 'demoted', reason: 'removed' };
1129
+ }
1130
+ const held = this.registry.getState(tool.id);
1131
+ return { toolId: tool.id, name: current.name, state: held?.state ?? 'active', reason: held?.reason ?? null };
1132
+ }
1133
+ try {
1134
+ if (registered && (!current || current.name !== registered.name) && this.onToolRemoved) {
1135
+ await this.onToolRemoved(registered);
1136
+ }
1137
+ if (current && this.onToolForged) {
1138
+ await this.onToolForged(current, this.createExecutableTool(current));
1139
+ }
1140
+ registered = current;
1141
+ }
1142
+ catch (error) {
1143
+ const message = error instanceof Error ? error.message : String(error);
1144
+ await this.dropExecutable(registered ?? current ?? tool);
1145
+ if (!this.registry.get(tool.id)) {
1146
+ this.removeIndexedToolEverywhere(tool.id);
1147
+ }
1148
+ throw new Error(`the executable of "${tool.name}" (${tool.id}) could not be brought in line with the registry: ${message}`);
1149
+ }
1150
+ }
1151
+ await this.dropExecutable(registered ?? tool);
1152
+ throw new Error(`the executable of "${tool.name}" (${tool.id}) could not be settled: the tool kept changing while it was registered`);
1153
+ }
1154
+ /** Take a tool's executable out of the host, best-effort. */
1155
+ async dropExecutable(tool) {
1156
+ if (!this.onToolRemoved) {
1157
+ return;
1158
+ }
1159
+ try {
1160
+ await this.onToolRemoved(tool);
1161
+ }
1162
+ catch (error) {
1163
+ console.warn(`[agentos:emergent] could not take out the executable of "${tool.name}" (${tool.id}):`, error instanceof Error ? error.message : error);
1164
+ }
1165
+ }
884
1166
  /**
885
1167
  * The library's reason for not running an implementation under the
886
1168
  * configuration in force, or `null`. Later steps add their checks here, so
887
1169
  * forging, loading and promotion all ask the same question.
888
1170
  */
889
- refusalFor(implementation) {
890
- if (implementation.mode === 'sandbox' && implementation.code.trim() === '') {
891
- return 'source_not_persisted';
1171
+ refusalFor(implementation, name) {
1172
+ if (implementation.mode === 'sandbox') {
1173
+ if (!this.config.allowSandboxTools) {
1174
+ return 'sandbox_tools_off';
1175
+ }
1176
+ if (implementation.code.trim() === '') {
1177
+ return 'source_not_persisted';
1178
+ }
1179
+ return null;
1180
+ }
1181
+ for (const step of implementation.steps) {
1182
+ const verdict = this.composableBuilder.check(step.tool);
1183
+ if (!verdict.ok) {
1184
+ return verdict.code;
1185
+ }
1186
+ }
1187
+ if (name !== undefined && this.compositionCycle(name, implementation.steps)) {
1188
+ return 'step_cycle';
892
1189
  }
893
1190
  return null;
894
1191
  }
1192
+ /**
1193
+ * The path of step tool names through which a composition named `name`
1194
+ * reaches itself, or a nested composition reaches one already on the path;
1195
+ * null when there is none. Nested compositions are followed through the
1196
+ * tools the gate resolves now.
1197
+ */
1198
+ compositionCycle(name, steps, path = []) {
1199
+ for (const step of steps) {
1200
+ if (step.tool === name || path.includes(step.tool)) {
1201
+ return [...path, step.tool];
1202
+ }
1203
+ const nested = this.nestedSteps(step.tool);
1204
+ if (nested) {
1205
+ const found = this.compositionCycle(name, nested, [...path, step.tool]);
1206
+ if (found) {
1207
+ return found;
1208
+ }
1209
+ }
1210
+ }
1211
+ return null;
1212
+ }
1213
+ /** The steps of the emergent composition registered as `toolName`, or undefined. */
1214
+ nestedSteps(toolName) {
1215
+ const resolved = this.composableBuilder.resolve(toolName);
1216
+ if (resolved?.emergentMode !== 'compose' || !resolved.id.startsWith('emergent-tool:')) {
1217
+ return undefined;
1218
+ }
1219
+ const implementation = this.registry.get(resolved.id.slice('emergent-tool:'.length))?.implementation;
1220
+ return implementation?.mode === 'compose' ? implementation.steps : undefined;
1221
+ }
1222
+ /**
1223
+ * A suspension the library sets from a run or a promotion check: written as
1224
+ * the library's (so a later load, or a registration of the missing step
1225
+ * tool, re-checks it), held here, and the executable taken out.
1226
+ */
1227
+ async suspendAsLibrary(toolId, reason) {
1228
+ try {
1229
+ await this.registry.setState(toolId, 'suspended', reason, { setBy: 'library' });
1230
+ }
1231
+ catch (error) {
1232
+ console.warn(`[agentos:emergent] could not store the suspension of "${toolId}" (${reason}):`, error instanceof Error ? error.message : error);
1233
+ }
1234
+ await this.unregisterIfLive(toolId);
1235
+ }
895
1236
  async unregisterIfLive(toolId) {
896
1237
  const live = this.registry.get(toolId);
897
1238
  if (live && this.onToolRemoved) {
@@ -994,6 +1335,7 @@ export class EmergentCapabilityEngine {
994
1335
  },
995
1336
  executeTool: deps.executeTool,
996
1337
  listTools: deps.listTools,
1338
+ checkStep: (name) => this.composableBuilder.check(name),
997
1339
  }));
998
1340
  }
999
1341
  catch {
@@ -1043,7 +1385,12 @@ export class EmergentCapabilityEngine {
1043
1385
  inputSchema: tool.inputSchema,
1044
1386
  outputSchema: tool.outputSchema,
1045
1387
  category: 'emergent',
1046
- hasSideEffects: tool.implementation.mode === 'sandbox',
1388
+ emergentMode: tool.implementation.mode,
1389
+ // A composition has side effects when one of its steps does (or is a
1390
+ // composition itself); each such step is then asked at the step (see
1391
+ // ToolOrchestrator), not at the composed call.
1392
+ hasSideEffects: tool.implementation.mode === 'sandbox' ||
1393
+ this.composableBuilder.hasSideEffectingStep(tool.implementation),
1047
1394
  execute: async (args, context) => {
1048
1395
  // The registry no longer holds the tool (removed, or its session
1049
1396
  // cleaned up) while the executable is still registered: refuse, do
@@ -1064,11 +1411,12 @@ export class EmergentCapabilityEngine {
1064
1411
  // The owner is the persona that forged the tool, compared as it was
1065
1412
  // stored ('unknown' for a caller without one). A row from an earlier
1066
1413
  // release holds the forging GMI instance's id instead, which no
1067
- // persona can match: such a tool runs for any caller of the host that
1068
- // loaded it by that id, as it did before this change.
1414
+ // persona can match; the loader suspends such a row (`legacy_owner`),
1415
+ // and a call to one held in memory is refused here like any other
1416
+ // owner mismatch.
1069
1417
  const owner = current.createdBy;
1070
1418
  const caller = context.personaId ?? 'unknown';
1071
- if (current.tier === 'agent' && !owner.startsWith('gmi-instance-') && caller !== owner) {
1419
+ if (current.tier === 'agent' && caller !== owner) {
1072
1420
  return {
1073
1421
  success: false,
1074
1422
  error: `Emergent tool "${tool.name}" belongs to agent ${owner}; it is not callable as ${caller}.`,
@@ -1077,6 +1425,20 @@ export class EmergentCapabilityEngine {
1077
1425
  const startTime = performance.now();
1078
1426
  const result = await baseTool.execute(args, context);
1079
1427
  const executionTimeMs = Math.round(performance.now() - startTime);
1428
+ // A step that can no longer be chained (its tool was removed or
1429
+ // replaced, or the chain reaches itself) suspends the composition
1430
+ // until a fitting tool is registered.
1431
+ const refused = result.details?.code;
1432
+ const suspendFor = !result.success && refused ? RUN_REFUSAL_REASONS[refused] : undefined;
1433
+ if (suspendFor) {
1434
+ await this.suspendAsLibrary(tool.id, suspendFor);
1435
+ return {
1436
+ success: false,
1437
+ output: result.output,
1438
+ error: result.error ?? 'A step of this composition can no longer be chained.',
1439
+ details: result.details,
1440
+ };
1441
+ }
1080
1442
  let success = result.success;
1081
1443
  let error = result.error;
1082
1444
  // Track whether the output passed schema validation separately from
@@ -1104,6 +1466,7 @@ export class EmergentCapabilityEngine {
1104
1466
  success: false,
1105
1467
  output: result.output,
1106
1468
  error: error ?? 'Emergent tool execution failed.',
1469
+ ...(result.details ? { details: result.details } : {}),
1107
1470
  };
1108
1471
  },
1109
1472
  };