@framers/agentos 0.11.6 → 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 (54) 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 +61 -7
  13. package/dist/cognition/emergent/EmergentCapabilityEngine.d.ts.map +1 -1
  14. package/dist/cognition/emergent/EmergentCapabilityEngine.js +389 -100
  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 +40 -10
  21. package/dist/cognition/emergent/EmergentToolRegistry.d.ts.map +1 -1
  22. package/dist/cognition/emergent/EmergentToolRegistry.js +84 -16
  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 +28 -0
  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/tools/ITool.d.ts +6 -0
  41. package/dist/core/tools/ITool.d.ts.map +1 -1
  42. package/dist/core/tools/ToolExecutor.d.ts +19 -0
  43. package/dist/core/tools/ToolExecutor.d.ts.map +1 -1
  44. package/dist/core/tools/ToolExecutor.js +9 -0
  45. package/dist/core/tools/ToolExecutor.js.map +1 -1
  46. package/dist/core/tools/ToolOrchestrator.d.ts +13 -0
  47. package/dist/core/tools/ToolOrchestrator.d.ts.map +1 -1
  48. package/dist/core/tools/ToolOrchestrator.js +129 -13
  49. package/dist/core/tools/ToolOrchestrator.js.map +1 -1
  50. package/dist/extensions/ExtensionRegistry.d.ts +7 -0
  51. package/dist/extensions/ExtensionRegistry.d.ts.map +1 -1
  52. package/dist/extensions/ExtensionRegistry.js +24 -0
  53. package/dist/extensions/ExtensionRegistry.js.map +1 -1
  54. 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;
152
203
  }
204
+ const details = result.details;
205
+ if (details?.code === 'dry_run_needs_output') {
206
+ return { success: false, error: result.error };
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 {
@@ -289,7 +361,16 @@ export class EmergentCapabilityEngine {
289
361
  error: error instanceof Error ? error.message : 'Failed to activate forged tool.',
290
362
  };
291
363
  }
292
- const settled = await this.settleRegistration(live);
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
+ }
293
374
  if (settled && settled.reason === 'removed') {
294
375
  // Removed while the host was registering it: nothing is registered.
295
376
  return { success: false, error: 'the tool was removed while it was being forged' };
@@ -334,10 +415,21 @@ export class EmergentCapabilityEngine {
334
415
  if (!tool) {
335
416
  return null;
336
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
+ }
337
424
  // Only session-tier tools are eligible for auto-promotion.
338
425
  if (tool.tier !== 'session') {
339
426
  return null;
340
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
+ }
341
433
  // Check thresholds.
342
434
  const { uses, confidence } = this.config.promotionThreshold;
343
435
  if (tool.usageStats.totalUses < uses || tool.usageStats.confidenceScore < confidence) {
@@ -440,30 +532,34 @@ export class EmergentCapabilityEngine {
440
532
  * @returns what happened, so a host can tell a registered tool from a refused one.
441
533
  */
442
534
  async syncPersistedTool(tool) {
443
- let row = await this.registry.loadRow(tool.id);
444
- if (!row && this.registry.hasStorage()) {
535
+ const existing = await this.registry.loadRow(tool.id);
536
+ if (!existing && this.registry.hasStorage()) {
445
537
  // No stored row yet: the host hydrates from its own store. The row is
446
538
  // written as it was before loading went through the stored row, so the
447
539
  // tool's uses are recorded and the next load finds it.
448
540
  await this.registry.writeToolRow(tool);
449
- row = await this.registry.loadRow(tool.id);
450
- }
451
- if (row) {
452
- // With source persistence off the row holds a redacted record; the
453
- // implementation the host supplied is what runs then.
454
- return this.admitRow(row, { sourceFallback: sourceFromImplementation(tool.implementation) });
455
541
  }
456
- const stored = await this.registry.readState(tool.id);
457
- return this.admit({
458
- toolId: tool.id,
459
- name: tool.name,
460
- source: sourceFromImplementation(tool.implementation),
461
- stored,
462
- requestStored: stored?.request != null,
463
- legacyActive: tool.isActive ?? true,
464
- tier: tool.tier,
465
- createdBy: tool.createdBy,
466
- 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 });
467
563
  });
468
564
  }
469
565
  /**
@@ -516,17 +612,47 @@ export class EmergentCapabilityEngine {
516
612
  throw new Error("selector_required: loadPersistedTools({ tiers: ['session'] }) needs the sessionId whose tools to load; " +
517
613
  "a session's tools are private to it.");
518
614
  }
519
- const rows = await this.registry.loadRows(tiers, { agentId, sessionId });
520
615
  const outcomes = [];
521
616
  const failed = [];
522
- 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
+ }
523
641
  try {
524
- 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
+ }
525
649
  }
526
650
  catch (error) {
527
651
  const message = error instanceof Error ? error.message : String(error);
528
- failed.push({ toolId: row.id, name: row.name, error: message });
529
- 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}`);
530
656
  }
531
657
  }
532
658
  for (const outcome of outcomes) {
@@ -602,26 +728,69 @@ export class EmergentCapabilityEngine {
602
728
  * @returns the outcome, or `undefined` when the tool is unknown.
603
729
  */
604
730
  async reactivateTool(toolId) {
605
- const row = await this.registry.loadRow(toolId);
606
- if (row) {
607
- 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
+ }
608
755
  }
609
- const tool = this.registry.get(toolId);
610
- if (!tool) {
611
- 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();
612
793
  }
613
- const held = this.registry.getState(toolId);
614
- return this.admit({
615
- toolId,
616
- name: tool.name,
617
- source: sourceFromImplementation(tool.implementation),
618
- stored: held,
619
- requestStored: held?.request != null,
620
- legacyActive: true,
621
- tier: tool.tier,
622
- createdBy: tool.createdBy,
623
- buildTool: () => tool,
624
- }, { force: true });
625
794
  }
626
795
  admitRow(row, options = {}) {
627
796
  const stored = row.state
@@ -676,12 +845,17 @@ export class EmergentCapabilityEngine {
676
845
  const { toolId, name } = candidate;
677
846
  const depth = options.readmitted ?? 0;
678
847
  if (depth < 2) {
679
- const fresh = await this.registry.loadRow(toolId);
680
- if (fresh) {
681
- return this.admitRow(fresh, { ...options, readmitted: depth + 1 });
682
- }
683
- await this.unregisterIfLive(toolId);
684
- 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
+ });
685
859
  }
686
860
  // Nothing is written; the tool is held off here until the next load.
687
861
  this.holdStored(toolId, {
@@ -697,10 +871,9 @@ export class EmergentCapabilityEngine {
697
871
  }
698
872
  async admit(candidate, options = {}) {
699
873
  const { toolId, name, source, stored, requestStored } = candidate;
700
- // The tool's generation as this admission starts: a removal, a
701
- // registration or a row write after this point makes what it holds stale,
702
- // and the adoption below is refused.
703
- const generation = this.registry.generation(toolId);
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);
704
877
  let legacyActive = candidate.legacyActive;
705
878
  // A row whose last state write did not finish its flag write: finish it
706
879
  // first, so the flag reads what the state row says before anything is
@@ -792,11 +965,11 @@ export class EmergentCapabilityEngine {
792
965
  // ever pass the owner check: it loads suspended rather than running
793
966
  // for everyone. Forging the tool again under the persona is the way
794
967
  // back; `reactivateTool` goes through this same path.
795
- if (!refusal && candidate.tier === 'agent' && candidate.createdBy.startsWith('gmi-instance-')) {
968
+ if (!refusal && candidate.tier === 'agent' && candidate.createdBy.startsWith(GMI_INSTANCE_ID_PREFIX)) {
796
969
  refusal = 'legacy_owner';
797
970
  }
798
971
  if (implementation && !refusal) {
799
- refusal = this.refusalFor(implementation);
972
+ refusal = this.refusalFor(implementation, name);
800
973
  }
801
974
  if (refusal || !implementation) {
802
975
  const reason = refusal ?? 'source_unreadable';
@@ -891,11 +1064,22 @@ export class EmergentCapabilityEngine {
891
1064
  setBy: 'library',
892
1065
  at: Date.now(),
893
1066
  request,
894
- }, generation);
1067
+ }, readAt);
895
1068
  if (!adopted) {
896
- // Removed, or replaced under its id, in this process while the row was
897
- // being admitted: what this admission read is stale.
898
- 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 };
899
1083
  }
900
1084
  this.indexTool(tool.id, tool.createdBy, this.extractSessionId(tool.source) ?? `persisted:${tool.id}`);
901
1085
  if (this.onToolForged) {
@@ -922,49 +1106,133 @@ export class EmergentCapabilityEngine {
922
1106
  * hold that very object. When it does not (the tool was removed, or
923
1107
  * replaced under its id, while the registration ran), the executor is
924
1108
  * brought back in line with the registry: the current tool's executable is
925
- * registered again when there is one, so whichever registration landed
926
- * last, the current tool is what runs under the name; the stale executable
927
- * is taken out when there is none. Returns the outcome to report, or
928
- * undefined when the registration stands.
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.
929
1117
  */
930
1118
  async settleRegistration(tool) {
931
- const current = this.registry.get(tool.id);
932
- if (current === tool) {
933
- return undefined;
934
- }
935
- try {
936
- if (current && this.onToolForged) {
937
- await this.onToolForged(current, this.createExecutableTool(current));
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 };
938
1132
  }
939
- else if (!current && this.onToolRemoved) {
940
- await this.onToolRemoved(tool);
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}`);
941
1149
  }
942
1150
  }
943
- catch (error) {
944
- console.warn(`[agentos:emergent] could not settle the executable of "${tool.name}" (${tool.id}):`, error instanceof Error ? error.message : error);
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;
945
1158
  }
946
- finally {
947
- if (!current) {
948
- this.removeIndexedToolEverywhere(tool.id);
949
- }
1159
+ try {
1160
+ await this.onToolRemoved(tool);
950
1161
  }
951
- if (!current) {
952
- return { toolId: tool.id, name: tool.name, state: 'demoted', reason: 'removed' };
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);
953
1164
  }
954
- const held = this.registry.getState(tool.id);
955
- return { toolId: tool.id, name: current.name, state: held?.state ?? 'active', reason: held?.reason ?? null };
956
1165
  }
957
1166
  /**
958
1167
  * The library's reason for not running an implementation under the
959
1168
  * configuration in force, or `null`. Later steps add their checks here, so
960
1169
  * forging, loading and promotion all ask the same question.
961
1170
  */
962
- refusalFor(implementation) {
963
- if (implementation.mode === 'sandbox' && implementation.code.trim() === '') {
964
- 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';
1189
+ }
1190
+ return null;
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
+ }
965
1210
  }
966
1211
  return null;
967
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
+ }
968
1236
  async unregisterIfLive(toolId) {
969
1237
  const live = this.registry.get(toolId);
970
1238
  if (live && this.onToolRemoved) {
@@ -1067,6 +1335,7 @@ export class EmergentCapabilityEngine {
1067
1335
  },
1068
1336
  executeTool: deps.executeTool,
1069
1337
  listTools: deps.listTools,
1338
+ checkStep: (name) => this.composableBuilder.check(name),
1070
1339
  }));
1071
1340
  }
1072
1341
  catch {
@@ -1116,7 +1385,12 @@ export class EmergentCapabilityEngine {
1116
1385
  inputSchema: tool.inputSchema,
1117
1386
  outputSchema: tool.outputSchema,
1118
1387
  category: 'emergent',
1119
- 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),
1120
1394
  execute: async (args, context) => {
1121
1395
  // The registry no longer holds the tool (removed, or its session
1122
1396
  // cleaned up) while the executable is still registered: refuse, do
@@ -1151,6 +1425,20 @@ export class EmergentCapabilityEngine {
1151
1425
  const startTime = performance.now();
1152
1426
  const result = await baseTool.execute(args, context);
1153
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
+ }
1154
1442
  let success = result.success;
1155
1443
  let error = result.error;
1156
1444
  // Track whether the output passed schema validation separately from
@@ -1178,6 +1466,7 @@ export class EmergentCapabilityEngine {
1178
1466
  success: false,
1179
1467
  output: result.output,
1180
1468
  error: error ?? 'Emergent tool execution failed.',
1469
+ ...(result.details ? { details: result.details } : {}),
1181
1470
  };
1182
1471
  },
1183
1472
  };