agent-swarm-kit 4.0.0 → 5.0.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.
package/build/index.mjs CHANGED
@@ -8,6 +8,7 @@ import * as path from 'path';
8
8
  import path__default, { join } from 'path';
9
9
  import crypto, { createHash } from 'crypto';
10
10
  import os from 'os';
11
+ import { AsyncLocalStorage } from 'async_hooks';
11
12
  import { getMomentStamp, getTimeStamp } from 'get-moment-stamp';
12
13
 
13
14
  /**
@@ -3502,8 +3503,23 @@ const createToolCall = async (idx, tool, toolCalls, targetFn, reason, mode, outp
3502
3503
  // handles this late error (e.g. changeToAgent recursion guard thrown after
3503
3504
  // commitToolOutput). Without a recovery emission the pending waitForOutput
3504
3505
  // of the enclosing execution would hang forever.
3505
- const result = await self._resurrectModel(mode, `Late tool error after commit: name=${tool.function.name} error=${getErrorMessage(error)}`);
3506
- await self._emitOutput(mode, result, outputEpoch);
3506
+ try {
3507
+ const result = await self._resurrectModel(mode, `Late tool error after commit: name=${tool.function.name} error=${getErrorMessage(error)}`);
3508
+ await self._emitOutput(mode, result, outputEpoch);
3509
+ }
3510
+ catch (recoveryError) {
3511
+ // createToolCall is fired without await: a throwing recovery
3512
+ // (_emitOutput throws by contract when validation fails twice) would
3513
+ // be an unhandled rejection. Resolve the waiter directly instead.
3514
+ console.error(`agent-swarm late tool error recovery failed functionName=${tool.function.name} error=${getErrorMessage(recoveryError)}`);
3515
+ await errorSubject.next([
3516
+ self.params.clientId,
3517
+ recoveryError,
3518
+ ]);
3519
+ if (outputEpoch === self._outputEpoch) {
3520
+ await self._outputSubject.next(createPlaceholder());
3521
+ }
3522
+ }
3507
3523
  }
3508
3524
  }
3509
3525
  finally {
@@ -3568,7 +3584,7 @@ const RUN_FN = async (incoming, self) => {
3568
3584
  agentName: self.params.agentName,
3569
3585
  content: incoming,
3570
3586
  mode: "user",
3571
- role: "assistant",
3587
+ role: "user",
3572
3588
  });
3573
3589
  const args = {
3574
3590
  clientId: self.params.clientId,
@@ -3667,19 +3683,29 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3667
3683
  if (!toolCalls.length) {
3668
3684
  // maxToolCalls=0 dropped every call: the model wanted tools but none may
3669
3685
  // run. Emit the (tool-stripped) content as the answer instead of leaving
3670
- // the pending waitForOutput hanging forever.
3671
- const result = await self.params.transform(message.content, self.params.clientId, self.params.agentName);
3686
+ // the pending waitForOutput hanging forever. _emitOutput owns the
3687
+ // transform, so pass the raw content transforming here too would
3688
+ // apply a non-idempotent transform twice.
3672
3689
  await self.params.history.push({
3673
3690
  ...message,
3674
3691
  tool_calls: [],
3675
3692
  agentName: self.params.agentName,
3676
3693
  });
3677
- await self._emitOutput(mode, result, outputEpoch);
3694
+ await self._emitOutput(mode, message.content, outputEpoch);
3678
3695
  return;
3679
3696
  }
3680
3697
  {
3681
- const priorityTool = toolCalls.find((call) => swarm$1.navigationSchemaService.hasTool(call.function.name) ||
3682
- swarm$1.actionSchemaService.hasTool(call.function.name));
3698
+ // Navigation/action tools are registered by toolName, while the model
3699
+ // calls tools by function.name — check both, since they diverge for MCP
3700
+ // tools (mcp_ prefix) and dynamic tool schemas.
3701
+ const priorityTool = toolCalls.find((call) => {
3702
+ const targetFn = toolList.find((t) => t.function.name === call.function.name);
3703
+ return (swarm$1.navigationSchemaService.hasTool(call.function.name) ||
3704
+ swarm$1.actionSchemaService.hasTool(call.function.name) ||
3705
+ (targetFn &&
3706
+ (swarm$1.navigationSchemaService.hasTool(targetFn.toolName) ||
3707
+ swarm$1.actionSchemaService.hasTool(targetFn.toolName))));
3708
+ });
3683
3709
  if (priorityTool) {
3684
3710
  toolCalls = [priorityTool];
3685
3711
  }
@@ -3704,6 +3730,11 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3704
3730
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3705
3731
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3706
3732
  await self._emitOutput(mode, result, outputEpoch);
3733
+ // The chain's .finally decrement is attached only after the loop, so
3734
+ // an early return must roll the counter back itself — otherwise a
3735
+ // late tool error would forever think a chain is still consuming
3736
+ // events and skip its own recovery.
3737
+ self._activeToolChains -= 1;
3707
3738
  run(false);
3708
3739
  return;
3709
3740
  }
@@ -3737,6 +3768,9 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3737
3768
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3738
3769
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3739
3770
  await self._emitOutput(mode, result, outputEpoch);
3771
+ // See the "tool function not found" branch above: roll back the
3772
+ // counter that the post-loop .finally would otherwise decrement.
3773
+ self._activeToolChains -= 1;
3740
3774
  run(false);
3741
3775
  return;
3742
3776
  }
@@ -3768,6 +3802,12 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3768
3802
  if (lastStatus === TOOL_ERROR_SYMBOL) {
3769
3803
  return lastStatus;
3770
3804
  }
3805
+ if (lastStatus === CANCEL_OUTPUT_SYMBOL) {
3806
+ // commitCancelOutput halts the chain exactly like stop/error/change:
3807
+ // without this guard the next tool would still run after the user
3808
+ // cancelled the output.
3809
+ return lastStatus;
3810
+ }
3771
3811
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3772
3812
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} functionName=${tool.function.name} tool call executing`);
3773
3813
  const statusAwaiter = Promise.race([
@@ -3793,39 +3833,53 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3793
3833
  if (status === MODEL_RESQUE_SYMBOL) {
3794
3834
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3795
3835
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} functionName=${tool.function.name} the next tool execution stopped due to the model resque`);
3796
- self.params.onAfterToolCalls &&
3797
- self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
3798
3836
  }
3799
3837
  if (status === AGENT_CHANGE_SYMBOL) {
3800
3838
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3801
3839
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} functionName=${tool.function.name} the next tool execution stopped due to the agent changed`);
3802
- self.params.onAfterToolCalls &&
3803
- self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
3804
3840
  }
3805
3841
  if (status === TOOL_STOP_SYMBOL) {
3806
3842
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3807
3843
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} functionName=${tool.function.name} the next tool execution stopped due to the commitStopTools call`);
3808
- self.params.onAfterToolCalls &&
3809
- self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
3810
3844
  }
3811
3845
  if (status === CANCEL_OUTPUT_SYMBOL) {
3812
3846
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3813
3847
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} functionName=${tool.function.name} the next tool execution stopped due to the commitCancelOutput call`);
3814
- self.params.onAfterToolCalls &&
3815
- self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
3816
3848
  }
3817
3849
  if (status === TOOL_ERROR_SYMBOL) {
3818
3850
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3819
3851
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} functionName=${tool.function.name} the next tool execution stopped due to the call error`);
3820
- const result = await self._resurrectModel(mode, `Function call failed with error: name=${tool.function.name} arguments=${JSON.stringify(tool.function.arguments)}`);
3821
- GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3822
- self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3823
- await self._emitOutput(mode, result, outputEpoch);
3852
+ try {
3853
+ const result = await self._resurrectModel(mode, `Function call failed with error: name=${tool.function.name} arguments=${JSON.stringify(tool.function.arguments)}`);
3854
+ GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3855
+ self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3856
+ await self._emitOutput(mode, result, outputEpoch);
3857
+ }
3858
+ catch (recoveryError) {
3859
+ // Nobody awaits the status chain: a throwing recovery
3860
+ // (_emitOutput throws by contract when validation fails twice)
3861
+ // would reject it unhandled. Resolve the waiter directly instead.
3862
+ console.error(`agent-swarm tool error recovery failed functionName=${tool.function.name} error=${getErrorMessage(recoveryError)}`);
3863
+ await errorSubject.next([
3864
+ self.params.clientId,
3865
+ recoveryError,
3866
+ ]);
3867
+ if (outputEpoch === self._outputEpoch) {
3868
+ await self._outputSubject.next(createPlaceholder());
3869
+ }
3870
+ }
3824
3871
  }
3825
3872
  return status;
3826
3873
  });
3827
3874
  }
3828
- lastToolStatusRef.finally(() => {
3875
+ lastToolStatusRef
3876
+ .catch((error) => {
3877
+ // The chain is fire-and-forget; a rejected link (e.g. a throwing
3878
+ // params.map/bus adapter) must not surface as an unhandled rejection.
3879
+ console.error(`agent-swarm tool status chain error agentName=${self.params.agentName} clientId=${self.params.clientId} error=${getErrorMessage(error)}`);
3880
+ return null;
3881
+ })
3882
+ .finally(() => {
3829
3883
  self._activeToolChains -= 1;
3830
3884
  self.params.onAfterToolCalls &&
3831
3885
  self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
@@ -3837,22 +3891,16 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3837
3891
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3838
3892
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute no tool calls detected`);
3839
3893
  }
3840
- const result = await self.params.transform(message.content, self.params.clientId, self.params.agentName);
3841
3894
  await self.params.history.push({
3842
3895
  ...message,
3843
3896
  agentName: self.params.agentName,
3844
3897
  });
3845
- let validation = null;
3846
- if ((validation = await self.params.validate(result))) {
3847
- GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3848
- self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute invalid tool call detected: ${validation}`, { result });
3849
- const result1 = await self._resurrectModel(mode, `Invalid model output: ${result}`);
3850
- await self._emitOutput(mode, result1, outputEpoch);
3851
- return;
3852
- }
3853
3898
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3854
- self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3855
- await self._emitOutput(mode, result, outputEpoch);
3899
+ self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${message.content}`);
3900
+ // _emitOutput owns transform + validate (with resurrect on failure), so the
3901
+ // raw content goes straight through — transforming/validating here as well
3902
+ // ran every transform twice per answer and broke non-idempotent transforms.
3903
+ await self._emitOutput(mode, message.content, outputEpoch);
3856
3904
  };
3857
3905
  /**
3858
3906
  * Represents a client-side agent in the swarm system, implementing the IAgent interface.
@@ -3945,9 +3993,30 @@ class ClientAgent {
3945
3993
  */
3946
3994
  this.execute = queued(async (incoming, mode) => {
3947
3995
  this._activeExecutions += 1;
3996
+ const outputEpoch = this._outputEpoch;
3948
3997
  try {
3949
3998
  return await EXECUTE_FN(incoming, mode, this);
3950
3999
  }
4000
+ catch (error) {
4001
+ // EXECUTE_FN is fired without await from ClientSession.execute and
4002
+ // queued() rejects internally: an unguarded rejection (e.g. a throwing
4003
+ // completion in getCompletion) crashes the host process and leaves the
4004
+ // pending waitForOutput hanging forever. Recover with the resque flow.
4005
+ console.error(`agent-swarm execute error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${getErrorMessage(error)}`);
4006
+ await errorSubject.next([this.params.clientId, error]);
4007
+ try {
4008
+ const result = await this._resurrectModel(mode, `Execution failed: ${getErrorMessage(error)}`);
4009
+ await this._emitOutput(mode, result, outputEpoch);
4010
+ }
4011
+ catch (recoveryError) {
4012
+ // Even the recovery failed (e.g. recomplete strategy hitting the same
4013
+ // broken completion): resolve the waiter directly so nothing hangs.
4014
+ console.error(`agent-swarm execute recovery error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${getErrorMessage(recoveryError)}`);
4015
+ if (outputEpoch === this._outputEpoch) {
4016
+ await this._outputSubject.next(createPlaceholder());
4017
+ }
4018
+ }
4019
+ }
3951
4020
  finally {
3952
4021
  this._activeExecutions -= 1;
3953
4022
  }
@@ -3956,7 +4025,19 @@ class ClientAgent {
3956
4025
  * Runs a stateless completion for the incoming message, queued to prevent overlapping executions.
3957
4026
  * Implements IAgent.run, delegating to RUN_FN with queuing via functools-kit’s queued decorator.
3958
4027
  */
3959
- this.run = queued(async (incoming) => await RUN_FN(incoming, this));
4028
+ this.run = queued(async (incoming) => {
4029
+ try {
4030
+ return await RUN_FN(incoming, this);
4031
+ }
4032
+ catch (error) {
4033
+ // queued() rejects internally, so a throwing completion would raise an
4034
+ // unhandled rejection besides failing the caller: degrade to an empty
4035
+ // stateless result and surface the error through errorSubject.
4036
+ console.error(`agent-swarm run error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${getErrorMessage(error)}`);
4037
+ await errorSubject.next([this.params.clientId, error]);
4038
+ return "";
4039
+ }
4040
+ });
3960
4041
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3961
4042
  this.params.logger.debug(`ClientAgent agentName=${this.params.agentName} clientId=${this.params.clientId} CTOR`, {
3962
4043
  params,
@@ -3973,11 +4054,15 @@ class ClientAgent {
3973
4054
  const seen = new Set();
3974
4055
  const agentToolList = [];
3975
4056
  if (this.params.tools) {
3976
- await Promise.all(this.params.tools.map(async (tool) => {
4057
+ // Resolve availability/dynamic schemas concurrently, but keep the RESULT
4058
+ // in declaration order: pushing from within the concurrent map would order
4059
+ // tools by resolution time, making the tool list, the seen-based dedup and
4060
+ // the single-commit-action filter all non-deterministic across runs.
4061
+ const resolvedTools = await Promise.all(this.params.tools.map(async (tool) => {
3977
4062
  const { toolName, function: upperFn, isAvailable = TOOL_AVAILABLE_DEFAULT, ...other } = tool;
3978
4063
  try {
3979
4064
  if (await not(isAvailable(this.params.clientId, this.params.agentName, toolName))) {
3980
- return;
4065
+ return null;
3981
4066
  }
3982
4067
  }
3983
4068
  catch (error) {
@@ -3985,7 +4070,7 @@ class ClientAgent {
3985
4070
  // hang the pending waitForOutput: treat it as "not available".
3986
4071
  console.error(`agent-swarm isAvailable error toolName=${toolName} error=${getErrorMessage(error)}`);
3987
4072
  await errorSubject.next([this.params.clientId, error]);
3988
- return;
4073
+ return null;
3989
4074
  }
3990
4075
  let fn;
3991
4076
  try {
@@ -3999,14 +4084,19 @@ class ClientAgent {
3999
4084
  // the execution — the tool is skipped for this completion.
4000
4085
  console.error(`agent-swarm dynamic tool function error toolName=${toolName} error=${getErrorMessage(error)}`);
4001
4086
  await errorSubject.next([this.params.clientId, error]);
4002
- return;
4087
+ return null;
4003
4088
  }
4004
- agentToolList.push({
4089
+ return {
4005
4090
  ...other,
4006
4091
  toolName,
4007
4092
  function: fn,
4008
- });
4093
+ };
4009
4094
  }));
4095
+ for (const tool of resolvedTools) {
4096
+ if (tool) {
4097
+ agentToolList.push(tool);
4098
+ }
4099
+ }
4010
4100
  }
4011
4101
  let mcpToolList = [];
4012
4102
  try {
@@ -4155,6 +4245,10 @@ class ClientAgent {
4155
4245
  }
4156
4246
  this.params.onOutput &&
4157
4247
  this.params.onOutput(this.params.clientId, this.params.agentName, result);
4248
+ // Keep the callback surface symmetric with the resurrect branch above:
4249
+ // the emitted output is an assistant message on both paths.
4250
+ this.params.onAssistantMessage &&
4251
+ this.params.onAssistantMessage(this.params.clientId, this.params.agentName, result);
4158
4252
  await this._outputSubject.next(result);
4159
4253
  await this.params.bus.emit(this.params.clientId, {
4160
4254
  type: "emit-output",
@@ -4209,7 +4303,21 @@ class ClientAgent {
4209
4303
  await this._resqueSubject.next(MODEL_RESQUE_SYMBOL);
4210
4304
  return placeholder;
4211
4305
  }
4212
- const rawMessage = await this.getCompletion(mode, []);
4306
+ let rawMessage;
4307
+ try {
4308
+ rawMessage = await this.getCompletion(mode, []);
4309
+ }
4310
+ catch (error) {
4311
+ // _resurrectModel IS the recovery path: if the resque completion itself
4312
+ // rejects (e.g. the provider is down — often the very reason we are
4313
+ // here), rethrowing would surface as an unhandled rejection from the
4314
+ // fire-and-forget callers (createToolCall, tool status chain). Degrade
4315
+ // to the placeholder instead.
4316
+ console.error(`agent-swarm _resurrectModel completion error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${getErrorMessage(error)}`);
4317
+ await errorSubject.next([this.params.clientId, error]);
4318
+ await this._resqueSubject.next(MODEL_RESQUE_SYMBOL);
4319
+ return placeholder;
4320
+ }
4213
4321
  if (rawMessage.tool_calls?.length) {
4214
4322
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
4215
4323
  this.params.logger.debug(`ClientAgent agentName=${this.params.agentName} clientId=${this.params.clientId} _resurrectModel should not emit tool_calls`);
@@ -4698,6 +4806,21 @@ class OperatorSignal {
4698
4806
  sendMessage(message, next) {
4699
4807
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
4700
4808
  this.params.logger.debug(`OperatorSignal agentName=${this.params.agentName} clientId=${this.params.clientId} sendMessage`, { message });
4809
+ // Dispose the previous signal before opening a new one: overwriting the
4810
+ // ref leaks the pending signal, and its late answer would resolve the
4811
+ // NEXT waitForOutput with a reply to the previous question.
4812
+ if (this._disposeRef) {
4813
+ const disposeRef = this._disposeRef;
4814
+ this._disposeRef = null;
4815
+ try {
4816
+ disposeRef();
4817
+ }
4818
+ catch (error) {
4819
+ // A throwing user dispose must not prevent the next signal from
4820
+ // opening — the previous one is torn down on a best-effort basis.
4821
+ console.error(`agent-swarm operator signal dispose error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${getErrorMessage(error)}`);
4822
+ }
4823
+ }
4701
4824
  this._disposeRef = this._signalFactory(message, next);
4702
4825
  }
4703
4826
  /**
@@ -4709,7 +4832,14 @@ class OperatorSignal {
4709
4832
  if (this._disposeRef) {
4710
4833
  const disposeRef = this._disposeRef;
4711
4834
  this._disposeRef = null;
4712
- await disposeRef();
4835
+ try {
4836
+ await disposeRef();
4837
+ }
4838
+ catch (error) {
4839
+ // dispose is awaited from waitForOutput's timeout path: a throwing
4840
+ // user dispose would reject waitForOutput and hang the swarm race.
4841
+ console.error(`agent-swarm operator signal dispose error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${getErrorMessage(error)}`);
4842
+ }
4713
4843
  }
4714
4844
  }
4715
4845
  }
@@ -4752,7 +4882,17 @@ class ClientOperator {
4752
4882
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
4753
4883
  this.params.logger.debug(`ClientOperator agentName=${this.params.agentName} clientId=${this.params.clientId} execute tool mode forwarded to operator`);
4754
4884
  }
4755
- this._operatorSignal.sendMessage(input, this._outgoingSubject.next);
4885
+ try {
4886
+ this._operatorSignal.sendMessage(input, this._outgoingSubject.next);
4887
+ }
4888
+ catch (error) {
4889
+ // execute is fired without await from ClientSession.execute: a throwing
4890
+ // operator connector would surface as an unhandled rejection and crash
4891
+ // the host process. Surface the error instead; the pending waitForOutput
4892
+ // resolves through the operator timeout.
4893
+ console.error(`agent-swarm operator connector error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${getErrorMessage(error)}`);
4894
+ await errorSubject.next([this.params.clientId, error]);
4895
+ }
4756
4896
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
4757
4897
  this.params.logger.debug(`ClientOperator agentName=${this.params.agentName} clientId=${this.params.clientId} execute end`, { input, mode });
4758
4898
  }
@@ -5830,6 +5970,8 @@ class ClientHistory {
5830
5970
  // the error through errorSubject so the caller's complete() rejects.
5831
5971
  console.error(`agent-swarm history push error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${getErrorMessage(error)}`);
5832
5972
  await errorSubject.next([this.params.clientId, error]);
5973
+ // The record was dropped: do not announce a successful push on the bus.
5974
+ return;
5833
5975
  }
5834
5976
  await this.params.bus.emit(this.params.clientId, {
5835
5977
  type: "push",
@@ -6288,6 +6430,7 @@ class NoopAgent {
6288
6430
  const message = `called commitCancelOutput on agent which not in the swarm clientId=${this.clientId} agentName=${this.agentName} swarmName=${this.swarmName}`;
6289
6431
  this.logger.log(message);
6290
6432
  console.error(message);
6433
+ return await this.defaultAgent.commitCancelOutput();
6291
6434
  }
6292
6435
  /**
6293
6436
  * Logs an attempt to run the missing agent and delegates to the default agent's run method.
@@ -6484,7 +6627,10 @@ class ClientSwarm {
6484
6627
  setBusy(isBusy) {
6485
6628
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
6486
6629
  this.params.logger.debug(`ClientSwarm swarmName=${this.params.swarmName} clientId=${this.params.clientId} setBusy`, { isBusy });
6487
- this._isBusy += isBusy ? 1 : -1;
6630
+ // Clamp at zero: an unmatched setBusy(false) (reachable through the public
6631
+ // SwarmPublicService.setBusy) would drive the counter negative, and a
6632
+ // negative counter reads as "busy" forever — deadlocking the session lock.
6633
+ this._isBusy = Math.max(0, this._isBusy + (isBusy ? 1 : -1));
6488
6634
  }
6489
6635
  /**
6490
6636
  * Getter for the busy state of the swarm.
@@ -6715,7 +6861,14 @@ class ClientSwarm {
6715
6861
  });
6716
6862
  if (!result) {
6717
6863
  console.error(`agent-swarm ClientSwarm getAgent current agent is not in the swarm agentName=${agentName} clientId=${this.params.clientId} swarmName=${this.params.swarmName}`);
6718
- return new NoopAgent(this.params.clientId, this.params.swarmName, agentName, this.params.agentMap[this.params.defaultAgent], this.params.logger);
6864
+ const defaultAgent = this.params.agentMap[this.params.defaultAgent];
6865
+ if (!defaultAgent) {
6866
+ // NoopAgent delegates every call to the default agent: with that one
6867
+ // missing too the delegation would die with a bare TypeError instead
6868
+ // of an actionable message.
6869
+ throw new Error(`agent-swarm ClientSwarm getAgent default agent is not in the swarm either agentName=${agentName} defaultAgent=${this.params.defaultAgent} clientId=${this.params.clientId} swarmName=${this.params.swarmName}`);
6870
+ }
6871
+ return new NoopAgent(this.params.clientId, this.params.swarmName, agentName, defaultAgent, this.params.logger);
6719
6872
  }
6720
6873
  return result;
6721
6874
  }
@@ -10862,15 +11015,49 @@ class ClientStorage {
10862
11015
  */
10863
11016
  this._itemMap = new Map();
10864
11017
  /**
10865
- * Dispatches a storage action (upsert, remove, or clear) in a queued manner, delegating to DISPATCH_FN.
10866
- * Ensures sequential execution of storage operations, supporting thread-safe updates from ClientAgent or tools.
11018
+ * Queued core of dispatch. The wrapped function must never reject: queued()
11019
+ * chains every call on the previous promise, so a rejection inside the queue
11020
+ * (e.g. a throwing createIndex/createEmbedding/setData) would reject every
11021
+ * already-queued dispatch with this foreign error WITHOUT running it — a
11022
+ * concurrent upsert/remove/clear would be silently dropped. Errors are boxed
11023
+ * here and rethrown outside the queue, so only the failing caller observes them.
10867
11024
  */
10868
- this.dispatch = queued(async (action, payload) => await DISPATCH_FN$1(action, this, payload));
11025
+ this._dispatchQueue = queued(async (action, payload) => {
11026
+ try {
11027
+ await DISPATCH_FN$1(action, this, payload);
11028
+ return null;
11029
+ }
11030
+ catch (error) {
11031
+ return { error };
11032
+ }
11033
+ });
11034
+ /**
11035
+ * Dispatches a storage action (upsert, remove, or clear) through the serialized
11036
+ * queue, delegating to DISPATCH_FN. Ensures sequential execution of storage
11037
+ * operations, supporting thread-safe updates from ClientAgent or tools.
11038
+ */
11039
+ this.dispatch = async (action, payload) => {
11040
+ const result = await this._dispatchQueue(action, payload);
11041
+ if (result) {
11042
+ throw result.error;
11043
+ }
11044
+ };
10869
11045
  /**
10870
11046
  * Creates embeddings for the given item, memoized by item ID to avoid redundant calculations via CREATE_EMBEDDING_FN.
10871
11047
  * Caches results for efficiency, cleared on upsert/remove to ensure freshness, supporting EmbeddingSchemaService.
10872
11048
  */
10873
- this._createEmbedding = memoize(([{ id }]) => id, async (item) => await CREATE_EMBEDDING_FN(item, this));
11049
+ this._createEmbedding = memoize(([{ id }]) => id, async (item) => {
11050
+ try {
11051
+ return await CREATE_EMBEDDING_FN(item, this);
11052
+ }
11053
+ catch (error) {
11054
+ // Never cache a rejected embedding: the memoized promise would keep
11055
+ // this item permanently broken (every take re-throwing the same
11056
+ // error) even after the embedding provider recovers.
11057
+ this._createEmbedding.clear(item.id);
11058
+ throw error;
11059
+ }
11060
+ });
10874
11061
  /**
10875
11062
  * Waits for the initialization of the storage, loading initial data and creating embeddings via WAIT_FOR_INIT_FN.
10876
11063
  * Ensures initialization happens only once using singleshot, supporting StorageConnectionService’s lifecycle.
@@ -11750,6 +11937,13 @@ const WAIT_FOR_INIT_FN = async (self) => {
11750
11937
  * SwarmConnectionService (swarm-level state), and BusService (event emission).
11751
11938
  */
11752
11939
  class ClientState {
11940
+ /**
11941
+ * True only while the caller runs inside the async context of an active
11942
+ * dispatch on this instance (i.e. a reentrant call from within a dispatchFn).
11943
+ */
11944
+ get _inDispatch() {
11945
+ return this._dispatchContext.getStore() === true;
11946
+ }
11753
11947
  /**
11754
11948
  * Constructs a ClientState instance with the provided parameters.
11755
11949
  * Invokes the onInit callback if provided and logs construction if debugging is enabled.
@@ -11758,29 +11952,50 @@ class ClientState {
11758
11952
  this.params = params;
11759
11953
  this.stateChanged = new Subject();
11760
11954
  /**
11761
- * True while a queued dispatch (read/write) is executing for this instance.
11762
- * getState checks it to serve reentrant reads (getState inside a setState
11763
- * dispatchFn) without re-entering the queue, which would deadlock.
11955
+ * Marks the async execution context of a running dispatch (read/write).
11956
+ * getState checks it to serve reentrant reads (getState called from INSIDE a
11957
+ * setState dispatchFn/middleware) without re-entering the queue, which would
11958
+ * deadlock. Scoping this per async-context — instead of a plain instance flag —
11959
+ * ensures an UNRELATED concurrent getState still queues and observes writes in
11960
+ * order, rather than reading a stale field while some other write is in flight.
11764
11961
  */
11765
- this._inDispatch = false;
11962
+ this._dispatchContext = new AsyncLocalStorage();
11766
11963
  /**
11767
11964
  * The current state data, initialized as null and set during waitForInit.
11768
11965
  * Updated by setState and clearState, persisted via params.setState if provided.
11769
11966
  */
11770
11967
  this._state = null;
11771
11968
  /**
11772
- * Queued dispatch function to read or write the state, delegating to DISPATCH_FN.
11773
- * Ensures thread-safe state operations, supporting concurrent access from ClientAgent or tools.
11969
+ * Queued core of dispatch. The wrapped function must never reject: queued()
11970
+ * chains every call on the previous promise, so a rejection inside the queue
11971
+ * would reject every already-queued dispatch with this foreign error WITHOUT
11972
+ * running it — a concurrent setState would be silently dropped. Errors (e.g.
11973
+ * a throwing user dispatchFn or middleware) are boxed here and rethrown
11974
+ * outside the queue, so only the caller whose operation failed observes them.
11774
11975
  */
11775
- this.dispatch = queued(async (action, payload) => {
11776
- this._inDispatch = true;
11976
+ this._dispatchQueue = queued(async (action, payload) => {
11777
11977
  try {
11778
- return await DISPATCH_FN(action, this, payload);
11978
+ return {
11979
+ ok: true,
11980
+ state: await this._dispatchContext.run(true, async () => await DISPATCH_FN(action, this, payload)),
11981
+ };
11779
11982
  }
11780
- finally {
11781
- this._inDispatch = false;
11983
+ catch (error) {
11984
+ return { ok: false, error };
11782
11985
  }
11783
11986
  });
11987
+ /**
11988
+ * Dispatches a read or write of the state through the serialized queue,
11989
+ * delegating to DISPATCH_FN. Ensures thread-safe state operations, supporting
11990
+ * concurrent access from ClientAgent or tools.
11991
+ */
11992
+ this.dispatch = async (action, payload) => {
11993
+ const result = await this._dispatchQueue(action, payload);
11994
+ if ("error" in result) {
11995
+ throw result.error;
11996
+ }
11997
+ return result.state;
11998
+ };
11784
11999
  /**
11785
12000
  * Waits for the state to initialize via WAIT_FOR_INIT_FN, ensuring it’s only called once using singleshot.
11786
12001
  * Loads the initial state into _state, supporting StateConnectionService’s lifecycle management.
@@ -11810,8 +12025,18 @@ class ClientState {
11810
12025
  });
11811
12026
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
11812
12027
  this.params.logger.debug(`ClientState stateName=${this.params.stateName} clientId=${this.params.clientId} shared=${this.params.shared} setState output`, { pendingState: this._state });
11813
- this.params.setState &&
11814
- this.params.setState(this._state, this.params.clientId, this.params.stateName);
12028
+ if (this.params.setState) {
12029
+ try {
12030
+ await this.params.setState(this._state, this.params.clientId, this.params.stateName);
12031
+ }
12032
+ catch (error) {
12033
+ // Persistence was fired without await before: a rejecting adapter
12034
+ // raised an unhandled rejection. Keep the in-memory state and surface
12035
+ // the error to the caller through errorSubject instead.
12036
+ console.error(`agent-swarm state persist error stateName=${this.params.stateName} clientId=${this.params.clientId} error=${getErrorMessage(error)}`);
12037
+ await errorSubject.next([this.params.clientId, error]);
12038
+ }
12039
+ }
11815
12040
  if (this.params.callbacks?.onWrite) {
11816
12041
  this.params.callbacks.onWrite(this._state, this.params.clientId, this.params.stateName);
11817
12042
  }
@@ -11843,8 +12068,17 @@ class ClientState {
11843
12068
  });
11844
12069
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
11845
12070
  this.params.logger.debug(`ClientState stateName=${this.params.stateName} clientId=${this.params.clientId} shared=${this.params.shared} clearState output`, { pendingState: this._state });
11846
- this.params.setState &&
11847
- this.params.setState(this._state, this.params.clientId, this.params.stateName);
12071
+ if (this.params.setState) {
12072
+ try {
12073
+ await this.params.setState(this._state, this.params.clientId, this.params.stateName);
12074
+ }
12075
+ catch (error) {
12076
+ // See setState above: a rejecting persistence adapter must not raise
12077
+ // an unhandled rejection.
12078
+ console.error(`agent-swarm state persist error stateName=${this.params.stateName} clientId=${this.params.clientId} error=${getErrorMessage(error)}`);
12079
+ await errorSubject.next([this.params.clientId, error]);
12080
+ }
12081
+ }
11848
12082
  if (this.params.callbacks?.onWrite) {
11849
12083
  this.params.callbacks.onWrite(this._state, this.params.clientId, this.params.stateName);
11850
12084
  }
@@ -14909,7 +15143,6 @@ class PolicyPublicService {
14909
15143
  }
14910
15144
  }
14911
15145
 
14912
- const BAN_NEED_FETCH = Symbol("ban-need-fetch");
14913
15146
  /**
14914
15147
  * Class representing a client policy in the swarm system, implementing the IPolicy interface.
14915
15148
  * Manages client bans, input/output validation, and restrictions, with lazy-loaded ban lists and event emission via BusService.
@@ -14918,6 +15151,32 @@ const BAN_NEED_FETCH = Symbol("ban-need-fetch");
14918
15151
  * Supports auto-banning on validation failure and customizable ban messages, ensuring swarm security and compliance.
14919
15152
  */
14920
15153
  class ClientPolicy {
15154
+ /**
15155
+ * Returns the ban set of the given swarm, fetching it on first access.
15156
+ * Re-checks the map after the await: a ban committed through _banQueue while
15157
+ * the fetch was in flight must not be overwritten by the stale fetch result.
15158
+ */
15159
+ async _getBanSet(swarmName) {
15160
+ const existing = this._banSetBySwarm.get(swarmName);
15161
+ if (existing) {
15162
+ return existing;
15163
+ }
15164
+ const fetched = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15165
+ if (!this._banSetBySwarm.has(swarmName)) {
15166
+ this._banSetBySwarm.set(swarmName, fetched);
15167
+ }
15168
+ return this._banSetBySwarm.get(swarmName);
15169
+ }
15170
+ /**
15171
+ * Runs a ban-set mutation through _banQueue, rethrowing its boxed error
15172
+ * outside the queue so only the failing caller observes it.
15173
+ */
15174
+ async _runBanQueue(fn) {
15175
+ const result = await this._banQueue(fn);
15176
+ if (result) {
15177
+ throw result.error;
15178
+ }
15179
+ }
14921
15180
  /**
14922
15181
  * Constructs a ClientPolicy instance with the provided parameters.
14923
15182
  * Invokes the onInit callback if defined and logs construction if debugging is enabled.
@@ -14925,11 +15184,13 @@ class ClientPolicy {
14925
15184
  constructor(params) {
14926
15185
  this.params = params;
14927
15186
  /**
14928
- * Set of banned client IDs or a symbol indicating the ban list needs to be fetched.
14929
- * Initialized as BAN_NEED_FETCH, lazily populated via params.getBannedClients on first use in hasBan, validateInput, etc.
14930
- * Updated by banClient and unbanClient, persisted if params.setBannedClients is provided.
15187
+ * Ban sets keyed by swarm name, lazily populated via params.getBannedClients.
15188
+ * A ClientPolicy instance is memoized per policyName and shared by every swarm
15189
+ * that lists the policy, while bans are persisted per (policy, swarm) — a
15190
+ * single shared set would leak bans of one swarm into another and persist
15191
+ * the mixed set into the wrong store.
14931
15192
  */
14932
- this._banSet = BAN_NEED_FETCH;
15193
+ this._banSetBySwarm = new Map();
14933
15194
  /**
14934
15195
  * Serializes the read-modify-write of _banSet shared by banClient/unbanClient.
14935
15196
  * Without it two concurrent bans of different clients both read the same ban
@@ -14937,8 +15198,23 @@ class ClientPolicy {
14937
15198
  * the earlier one — one ban is silently lost in memory and in the persisted
14938
15199
  * store. queued() runs the mutations one at a time so each observes the result
14939
15200
  * of the previous one.
14940
- */
14941
- this._banQueue = queued(async (fn) => await fn());
15201
+ *
15202
+ * The wrapped function must never reject: queued() chains every call on the
15203
+ * previous promise, so a rejection inside the queue (e.g. a throwing
15204
+ * setBannedClients adapter) would reject every already-queued ban/unban with
15205
+ * this foreign error WITHOUT running it — a concurrent ban of another client
15206
+ * would be silently lost. Errors are boxed here and rethrown by the caller
15207
+ * outside the queue.
15208
+ */
15209
+ this._banQueue = queued(async (fn) => {
15210
+ try {
15211
+ await fn();
15212
+ return null;
15213
+ }
15214
+ catch (error) {
15215
+ return { error };
15216
+ }
15217
+ });
14942
15218
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
14943
15219
  this.params.logger.debug(`ClientPolicy policyName=${this.params.policyName} CTOR`, {
14944
15220
  params,
@@ -14957,10 +15233,8 @@ class ClientPolicy {
14957
15233
  clientId,
14958
15234
  swarmName,
14959
15235
  });
14960
- if (this._banSet === BAN_NEED_FETCH) {
14961
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
14962
- }
14963
- return this._banSet.has(clientId);
15236
+ const banSet = await this._getBanSet(swarmName);
15237
+ return banSet.has(clientId);
14964
15238
  }
14965
15239
  /**
14966
15240
  * Retrieves the ban message for a client, using a custom getBanMessage function if provided or falling back to params.banMessage.
@@ -15009,10 +15283,8 @@ class ClientPolicy {
15009
15283
  },
15010
15284
  clientId,
15011
15285
  });
15012
- if (this._banSet === BAN_NEED_FETCH) {
15013
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15014
- }
15015
- if (this._banSet.has(clientId)) {
15286
+ const banSet = await this._getBanSet(swarmName);
15287
+ if (banSet.has(clientId)) {
15016
15288
  return false;
15017
15289
  }
15018
15290
  if (!this.params.validateInput) {
@@ -15054,10 +15326,8 @@ class ClientPolicy {
15054
15326
  },
15055
15327
  clientId,
15056
15328
  });
15057
- if (this._banSet === BAN_NEED_FETCH) {
15058
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15059
- }
15060
- if (this._banSet.has(clientId)) {
15329
+ const banSet = await this._getBanSet(swarmName);
15330
+ if (banSet.has(clientId)) {
15061
15331
  return false;
15062
15332
  }
15063
15333
  if (!this.params.validateOutput) {
@@ -15082,31 +15352,32 @@ class ClientPolicy {
15082
15352
  clientId,
15083
15353
  swarmName,
15084
15354
  });
15085
- if (this.params.callbacks?.onBanClient) {
15086
- this.params.callbacks.onBanClient(clientId, swarmName, this.params.policyName);
15087
- }
15088
- await this.params.bus.emit(clientId, {
15089
- type: "ban-client",
15090
- source: "policy-bus",
15091
- input: {},
15092
- output: {},
15093
- context: {
15094
- policyName: this.params.policyName,
15095
- swarmName,
15096
- },
15097
- clientId,
15098
- });
15099
- await this._banQueue(async () => {
15100
- if (this._banSet === BAN_NEED_FETCH) {
15101
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15102
- }
15103
- if (this._banSet.has(clientId)) {
15355
+ await this._runBanQueue(async () => {
15356
+ const banSet = await this._getBanSet(swarmName);
15357
+ if (banSet.has(clientId)) {
15358
+ // Already banned: skip the mutation AND the notifications, matching
15359
+ // the documented "skips if already banned" contract — otherwise every
15360
+ // redundant ban re-fires onBanClient and the bus event.
15104
15361
  return;
15105
15362
  }
15106
- this._banSet = new Set(this._banSet).add(clientId);
15363
+ this._banSetBySwarm.set(swarmName, new Set(banSet).add(clientId));
15107
15364
  if (this.params.setBannedClients) {
15108
- await this.params.setBannedClients([...this._banSet], this.params.policyName, swarmName);
15365
+ await this.params.setBannedClients([...this._banSetBySwarm.get(swarmName)], this.params.policyName, swarmName);
15366
+ }
15367
+ if (this.params.callbacks?.onBanClient) {
15368
+ this.params.callbacks.onBanClient(clientId, swarmName, this.params.policyName);
15109
15369
  }
15370
+ await this.params.bus.emit(clientId, {
15371
+ type: "ban-client",
15372
+ source: "policy-bus",
15373
+ input: {},
15374
+ output: {},
15375
+ context: {
15376
+ policyName: this.params.policyName,
15377
+ swarmName,
15378
+ },
15379
+ clientId,
15380
+ });
15110
15381
  });
15111
15382
  }
15112
15383
  /**
@@ -15120,35 +15391,35 @@ class ClientPolicy {
15120
15391
  clientId,
15121
15392
  swarmName,
15122
15393
  });
15123
- if (this.params.callbacks?.onUnbanClient) {
15124
- this.params.callbacks.onUnbanClient(clientId, swarmName, this.params.policyName);
15125
- }
15126
- await this.params.bus.emit(clientId, {
15127
- type: "unban-client",
15128
- source: "policy-bus",
15129
- input: {},
15130
- output: {},
15131
- context: {
15132
- policyName: this.params.policyName,
15133
- swarmName,
15134
- },
15135
- clientId,
15136
- });
15137
- await this._banQueue(async () => {
15138
- if (this._banSet === BAN_NEED_FETCH) {
15139
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15140
- }
15141
- if (!this._banSet.has(clientId)) {
15394
+ await this._runBanQueue(async () => {
15395
+ const banSet = await this._getBanSet(swarmName);
15396
+ if (!banSet.has(clientId)) {
15397
+ // Not banned: skip the mutation and the notifications, matching the
15398
+ // documented "skips if not banned" contract.
15142
15399
  return;
15143
15400
  }
15144
15401
  {
15145
- const banSet = new Set(this._banSet);
15146
- banSet.delete(clientId);
15147
- this._banSet = banSet;
15402
+ const nextBanSet = new Set(banSet);
15403
+ nextBanSet.delete(clientId);
15404
+ this._banSetBySwarm.set(swarmName, nextBanSet);
15148
15405
  }
15149
15406
  if (this.params.setBannedClients) {
15150
- await this.params.setBannedClients([...this._banSet], this.params.policyName, swarmName);
15407
+ await this.params.setBannedClients([...this._banSetBySwarm.get(swarmName)], this.params.policyName, swarmName);
15408
+ }
15409
+ if (this.params.callbacks?.onUnbanClient) {
15410
+ this.params.callbacks.onUnbanClient(clientId, swarmName, this.params.policyName);
15151
15411
  }
15412
+ await this.params.bus.emit(clientId, {
15413
+ type: "unban-client",
15414
+ source: "policy-bus",
15415
+ input: {},
15416
+ output: {},
15417
+ context: {
15418
+ policyName: this.params.policyName,
15419
+ swarmName,
15420
+ },
15421
+ clientId,
15422
+ });
15152
15423
  });
15153
15424
  }
15154
15425
  }