agent-swarm-kit 4.0.0 → 5.1.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.cjs CHANGED
@@ -8,6 +8,7 @@ var fs = require('fs/promises');
8
8
  var path = require('path');
9
9
  var crypto = require('crypto');
10
10
  var os = require('os');
11
+ var async_hooks = require('async_hooks');
11
12
  var getMomentStamp = require('get-moment-stamp');
12
13
 
13
14
  function _interopNamespaceDefault(e) {
@@ -3522,8 +3523,23 @@ const createToolCall = async (idx, tool, toolCalls, targetFn, reason, mode, outp
3522
3523
  // handles this late error (e.g. changeToAgent recursion guard thrown after
3523
3524
  // commitToolOutput). Without a recovery emission the pending waitForOutput
3524
3525
  // of the enclosing execution would hang forever.
3525
- const result = await self._resurrectModel(mode, `Late tool error after commit: name=${tool.function.name} error=${functoolsKit.getErrorMessage(error)}`);
3526
- await self._emitOutput(mode, result, outputEpoch);
3526
+ try {
3527
+ const result = await self._resurrectModel(mode, `Late tool error after commit: name=${tool.function.name} error=${functoolsKit.getErrorMessage(error)}`);
3528
+ await self._emitOutput(mode, result, outputEpoch);
3529
+ }
3530
+ catch (recoveryError) {
3531
+ // createToolCall is fired without await: a throwing recovery
3532
+ // (_emitOutput throws by contract when validation fails twice) would
3533
+ // be an unhandled rejection. Resolve the waiter directly instead.
3534
+ console.error(`agent-swarm late tool error recovery failed functionName=${tool.function.name} error=${functoolsKit.getErrorMessage(recoveryError)}`);
3535
+ await errorSubject.next([
3536
+ self.params.clientId,
3537
+ recoveryError,
3538
+ ]);
3539
+ if (outputEpoch === self._outputEpoch) {
3540
+ await self._outputSubject.next(createPlaceholder());
3541
+ }
3542
+ }
3527
3543
  }
3528
3544
  }
3529
3545
  finally {
@@ -3588,7 +3604,7 @@ const RUN_FN = async (incoming, self) => {
3588
3604
  agentName: self.params.agentName,
3589
3605
  content: incoming,
3590
3606
  mode: "user",
3591
- role: "assistant",
3607
+ role: "user",
3592
3608
  });
3593
3609
  const args = {
3594
3610
  clientId: self.params.clientId,
@@ -3687,19 +3703,29 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3687
3703
  if (!toolCalls.length) {
3688
3704
  // maxToolCalls=0 dropped every call: the model wanted tools but none may
3689
3705
  // run. Emit the (tool-stripped) content as the answer instead of leaving
3690
- // the pending waitForOutput hanging forever.
3691
- const result = await self.params.transform(message.content, self.params.clientId, self.params.agentName);
3706
+ // the pending waitForOutput hanging forever. _emitOutput owns the
3707
+ // transform, so pass the raw content transforming here too would
3708
+ // apply a non-idempotent transform twice.
3692
3709
  await self.params.history.push({
3693
3710
  ...message,
3694
3711
  tool_calls: [],
3695
3712
  agentName: self.params.agentName,
3696
3713
  });
3697
- await self._emitOutput(mode, result, outputEpoch);
3714
+ await self._emitOutput(mode, message.content, outputEpoch);
3698
3715
  return;
3699
3716
  }
3700
3717
  {
3701
- const priorityTool = toolCalls.find((call) => swarm$1.navigationSchemaService.hasTool(call.function.name) ||
3702
- swarm$1.actionSchemaService.hasTool(call.function.name));
3718
+ // Navigation/action tools are registered by toolName, while the model
3719
+ // calls tools by function.name — check both, since they diverge for MCP
3720
+ // tools (mcp_ prefix) and dynamic tool schemas.
3721
+ const priorityTool = toolCalls.find((call) => {
3722
+ const targetFn = toolList.find((t) => t.function.name === call.function.name);
3723
+ return (swarm$1.navigationSchemaService.hasTool(call.function.name) ||
3724
+ swarm$1.actionSchemaService.hasTool(call.function.name) ||
3725
+ (targetFn &&
3726
+ (swarm$1.navigationSchemaService.hasTool(targetFn.toolName) ||
3727
+ swarm$1.actionSchemaService.hasTool(targetFn.toolName))));
3728
+ });
3703
3729
  if (priorityTool) {
3704
3730
  toolCalls = [priorityTool];
3705
3731
  }
@@ -3724,6 +3750,11 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3724
3750
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3725
3751
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3726
3752
  await self._emitOutput(mode, result, outputEpoch);
3753
+ // The chain's .finally decrement is attached only after the loop, so
3754
+ // an early return must roll the counter back itself — otherwise a
3755
+ // late tool error would forever think a chain is still consuming
3756
+ // events and skip its own recovery.
3757
+ self._activeToolChains -= 1;
3727
3758
  run(false);
3728
3759
  return;
3729
3760
  }
@@ -3757,6 +3788,9 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3757
3788
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3758
3789
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3759
3790
  await self._emitOutput(mode, result, outputEpoch);
3791
+ // See the "tool function not found" branch above: roll back the
3792
+ // counter that the post-loop .finally would otherwise decrement.
3793
+ self._activeToolChains -= 1;
3760
3794
  run(false);
3761
3795
  return;
3762
3796
  }
@@ -3788,6 +3822,12 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3788
3822
  if (lastStatus === TOOL_ERROR_SYMBOL) {
3789
3823
  return lastStatus;
3790
3824
  }
3825
+ if (lastStatus === CANCEL_OUTPUT_SYMBOL) {
3826
+ // commitCancelOutput halts the chain exactly like stop/error/change:
3827
+ // without this guard the next tool would still run after the user
3828
+ // cancelled the output.
3829
+ return lastStatus;
3830
+ }
3791
3831
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3792
3832
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} functionName=${tool.function.name} tool call executing`);
3793
3833
  const statusAwaiter = Promise.race([
@@ -3813,39 +3853,53 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3813
3853
  if (status === MODEL_RESQUE_SYMBOL) {
3814
3854
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3815
3855
  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`);
3816
- self.params.onAfterToolCalls &&
3817
- self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
3818
3856
  }
3819
3857
  if (status === AGENT_CHANGE_SYMBOL) {
3820
3858
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3821
3859
  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`);
3822
- self.params.onAfterToolCalls &&
3823
- self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
3824
3860
  }
3825
3861
  if (status === TOOL_STOP_SYMBOL) {
3826
3862
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3827
3863
  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`);
3828
- self.params.onAfterToolCalls &&
3829
- self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
3830
3864
  }
3831
3865
  if (status === CANCEL_OUTPUT_SYMBOL) {
3832
3866
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3833
3867
  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`);
3834
- self.params.onAfterToolCalls &&
3835
- self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
3836
3868
  }
3837
3869
  if (status === TOOL_ERROR_SYMBOL) {
3838
3870
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3839
3871
  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`);
3840
- const result = await self._resurrectModel(mode, `Function call failed with error: name=${tool.function.name} arguments=${JSON.stringify(tool.function.arguments)}`);
3841
- GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3842
- self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3843
- await self._emitOutput(mode, result, outputEpoch);
3872
+ try {
3873
+ const result = await self._resurrectModel(mode, `Function call failed with error: name=${tool.function.name} arguments=${JSON.stringify(tool.function.arguments)}`);
3874
+ GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3875
+ self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3876
+ await self._emitOutput(mode, result, outputEpoch);
3877
+ }
3878
+ catch (recoveryError) {
3879
+ // Nobody awaits the status chain: a throwing recovery
3880
+ // (_emitOutput throws by contract when validation fails twice)
3881
+ // would reject it unhandled. Resolve the waiter directly instead.
3882
+ console.error(`agent-swarm tool error recovery failed functionName=${tool.function.name} error=${functoolsKit.getErrorMessage(recoveryError)}`);
3883
+ await errorSubject.next([
3884
+ self.params.clientId,
3885
+ recoveryError,
3886
+ ]);
3887
+ if (outputEpoch === self._outputEpoch) {
3888
+ await self._outputSubject.next(createPlaceholder());
3889
+ }
3890
+ }
3844
3891
  }
3845
3892
  return status;
3846
3893
  });
3847
3894
  }
3848
- lastToolStatusRef.finally(() => {
3895
+ lastToolStatusRef
3896
+ .catch((error) => {
3897
+ // The chain is fire-and-forget; a rejected link (e.g. a throwing
3898
+ // params.map/bus adapter) must not surface as an unhandled rejection.
3899
+ console.error(`agent-swarm tool status chain error agentName=${self.params.agentName} clientId=${self.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
3900
+ return null;
3901
+ })
3902
+ .finally(() => {
3849
3903
  self._activeToolChains -= 1;
3850
3904
  self.params.onAfterToolCalls &&
3851
3905
  self.params.onAfterToolCalls(self.params.clientId, self.params.agentName, toolCalls);
@@ -3857,22 +3911,16 @@ const EXECUTE_FN = async (incoming, mode, self) => {
3857
3911
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3858
3912
  self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute no tool calls detected`);
3859
3913
  }
3860
- const result = await self.params.transform(message.content, self.params.clientId, self.params.agentName);
3861
3914
  await self.params.history.push({
3862
3915
  ...message,
3863
3916
  agentName: self.params.agentName,
3864
3917
  });
3865
- let validation = null;
3866
- if ((validation = await self.params.validate(result))) {
3867
- GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3868
- self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute invalid tool call detected: ${validation}`, { result });
3869
- const result1 = await self._resurrectModel(mode, `Invalid model output: ${result}`);
3870
- await self._emitOutput(mode, result1, outputEpoch);
3871
- return;
3872
- }
3873
3918
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3874
- self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${result}`);
3875
- await self._emitOutput(mode, result, outputEpoch);
3919
+ self.params.logger.debug(`ClientAgent agentName=${self.params.agentName} clientId=${self.params.clientId} execute end result=${message.content}`);
3920
+ // _emitOutput owns transform + validate (with resurrect on failure), so the
3921
+ // raw content goes straight through — transforming/validating here as well
3922
+ // ran every transform twice per answer and broke non-idempotent transforms.
3923
+ await self._emitOutput(mode, message.content, outputEpoch);
3876
3924
  };
3877
3925
  /**
3878
3926
  * Represents a client-side agent in the swarm system, implementing the IAgent interface.
@@ -3965,9 +4013,30 @@ class ClientAgent {
3965
4013
  */
3966
4014
  this.execute = functoolsKit.queued(async (incoming, mode) => {
3967
4015
  this._activeExecutions += 1;
4016
+ const outputEpoch = this._outputEpoch;
3968
4017
  try {
3969
4018
  return await EXECUTE_FN(incoming, mode, this);
3970
4019
  }
4020
+ catch (error) {
4021
+ // EXECUTE_FN is fired without await from ClientSession.execute and
4022
+ // queued() rejects internally: an unguarded rejection (e.g. a throwing
4023
+ // completion in getCompletion) crashes the host process and leaves the
4024
+ // pending waitForOutput hanging forever. Recover with the resque flow.
4025
+ console.error(`agent-swarm execute error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
4026
+ await errorSubject.next([this.params.clientId, error]);
4027
+ try {
4028
+ const result = await this._resurrectModel(mode, `Execution failed: ${functoolsKit.getErrorMessage(error)}`);
4029
+ await this._emitOutput(mode, result, outputEpoch);
4030
+ }
4031
+ catch (recoveryError) {
4032
+ // Even the recovery failed (e.g. recomplete strategy hitting the same
4033
+ // broken completion): resolve the waiter directly so nothing hangs.
4034
+ console.error(`agent-swarm execute recovery error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(recoveryError)}`);
4035
+ if (outputEpoch === this._outputEpoch) {
4036
+ await this._outputSubject.next(createPlaceholder());
4037
+ }
4038
+ }
4039
+ }
3971
4040
  finally {
3972
4041
  this._activeExecutions -= 1;
3973
4042
  }
@@ -3976,7 +4045,19 @@ class ClientAgent {
3976
4045
  * Runs a stateless completion for the incoming message, queued to prevent overlapping executions.
3977
4046
  * Implements IAgent.run, delegating to RUN_FN with queuing via functools-kit’s queued decorator.
3978
4047
  */
3979
- this.run = functoolsKit.queued(async (incoming) => await RUN_FN(incoming, this));
4048
+ this.run = functoolsKit.queued(async (incoming) => {
4049
+ try {
4050
+ return await RUN_FN(incoming, this);
4051
+ }
4052
+ catch (error) {
4053
+ // queued() rejects internally, so a throwing completion would raise an
4054
+ // unhandled rejection besides failing the caller: degrade to an empty
4055
+ // stateless result and surface the error through errorSubject.
4056
+ console.error(`agent-swarm run error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
4057
+ await errorSubject.next([this.params.clientId, error]);
4058
+ return "";
4059
+ }
4060
+ });
3980
4061
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
3981
4062
  this.params.logger.debug(`ClientAgent agentName=${this.params.agentName} clientId=${this.params.clientId} CTOR`, {
3982
4063
  params,
@@ -3993,11 +4074,15 @@ class ClientAgent {
3993
4074
  const seen = new Set();
3994
4075
  const agentToolList = [];
3995
4076
  if (this.params.tools) {
3996
- await Promise.all(this.params.tools.map(async (tool) => {
4077
+ // Resolve availability/dynamic schemas concurrently, but keep the RESULT
4078
+ // in declaration order: pushing from within the concurrent map would order
4079
+ // tools by resolution time, making the tool list, the seen-based dedup and
4080
+ // the single-commit-action filter all non-deterministic across runs.
4081
+ const resolvedTools = await Promise.all(this.params.tools.map(async (tool) => {
3997
4082
  const { toolName, function: upperFn, isAvailable = TOOL_AVAILABLE_DEFAULT, ...other } = tool;
3998
4083
  try {
3999
4084
  if (await functoolsKit.not(isAvailable(this.params.clientId, this.params.agentName, toolName))) {
4000
- return;
4085
+ return null;
4001
4086
  }
4002
4087
  }
4003
4088
  catch (error) {
@@ -4005,7 +4090,7 @@ class ClientAgent {
4005
4090
  // hang the pending waitForOutput: treat it as "not available".
4006
4091
  console.error(`agent-swarm isAvailable error toolName=${toolName} error=${functoolsKit.getErrorMessage(error)}`);
4007
4092
  await errorSubject.next([this.params.clientId, error]);
4008
- return;
4093
+ return null;
4009
4094
  }
4010
4095
  let fn;
4011
4096
  try {
@@ -4019,14 +4104,19 @@ class ClientAgent {
4019
4104
  // the execution — the tool is skipped for this completion.
4020
4105
  console.error(`agent-swarm dynamic tool function error toolName=${toolName} error=${functoolsKit.getErrorMessage(error)}`);
4021
4106
  await errorSubject.next([this.params.clientId, error]);
4022
- return;
4107
+ return null;
4023
4108
  }
4024
- agentToolList.push({
4109
+ return {
4025
4110
  ...other,
4026
4111
  toolName,
4027
4112
  function: fn,
4028
- });
4113
+ };
4029
4114
  }));
4115
+ for (const tool of resolvedTools) {
4116
+ if (tool) {
4117
+ agentToolList.push(tool);
4118
+ }
4119
+ }
4030
4120
  }
4031
4121
  let mcpToolList = [];
4032
4122
  try {
@@ -4175,6 +4265,10 @@ class ClientAgent {
4175
4265
  }
4176
4266
  this.params.onOutput &&
4177
4267
  this.params.onOutput(this.params.clientId, this.params.agentName, result);
4268
+ // Keep the callback surface symmetric with the resurrect branch above:
4269
+ // the emitted output is an assistant message on both paths.
4270
+ this.params.onAssistantMessage &&
4271
+ this.params.onAssistantMessage(this.params.clientId, this.params.agentName, result);
4178
4272
  await this._outputSubject.next(result);
4179
4273
  await this.params.bus.emit(this.params.clientId, {
4180
4274
  type: "emit-output",
@@ -4229,7 +4323,21 @@ class ClientAgent {
4229
4323
  await this._resqueSubject.next(MODEL_RESQUE_SYMBOL);
4230
4324
  return placeholder;
4231
4325
  }
4232
- const rawMessage = await this.getCompletion(mode, []);
4326
+ let rawMessage;
4327
+ try {
4328
+ rawMessage = await this.getCompletion(mode, []);
4329
+ }
4330
+ catch (error) {
4331
+ // _resurrectModel IS the recovery path: if the resque completion itself
4332
+ // rejects (e.g. the provider is down — often the very reason we are
4333
+ // here), rethrowing would surface as an unhandled rejection from the
4334
+ // fire-and-forget callers (createToolCall, tool status chain). Degrade
4335
+ // to the placeholder instead.
4336
+ console.error(`agent-swarm _resurrectModel completion error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
4337
+ await errorSubject.next([this.params.clientId, error]);
4338
+ await this._resqueSubject.next(MODEL_RESQUE_SYMBOL);
4339
+ return placeholder;
4340
+ }
4233
4341
  if (rawMessage.tool_calls?.length) {
4234
4342
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
4235
4343
  this.params.logger.debug(`ClientAgent agentName=${this.params.agentName} clientId=${this.params.clientId} _resurrectModel should not emit tool_calls`);
@@ -4718,6 +4826,21 @@ class OperatorSignal {
4718
4826
  sendMessage(message, next) {
4719
4827
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
4720
4828
  this.params.logger.debug(`OperatorSignal agentName=${this.params.agentName} clientId=${this.params.clientId} sendMessage`, { message });
4829
+ // Dispose the previous signal before opening a new one: overwriting the
4830
+ // ref leaks the pending signal, and its late answer would resolve the
4831
+ // NEXT waitForOutput with a reply to the previous question.
4832
+ if (this._disposeRef) {
4833
+ const disposeRef = this._disposeRef;
4834
+ this._disposeRef = null;
4835
+ try {
4836
+ disposeRef();
4837
+ }
4838
+ catch (error) {
4839
+ // A throwing user dispose must not prevent the next signal from
4840
+ // opening — the previous one is torn down on a best-effort basis.
4841
+ console.error(`agent-swarm operator signal dispose error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
4842
+ }
4843
+ }
4721
4844
  this._disposeRef = this._signalFactory(message, next);
4722
4845
  }
4723
4846
  /**
@@ -4729,7 +4852,14 @@ class OperatorSignal {
4729
4852
  if (this._disposeRef) {
4730
4853
  const disposeRef = this._disposeRef;
4731
4854
  this._disposeRef = null;
4732
- await disposeRef();
4855
+ try {
4856
+ await disposeRef();
4857
+ }
4858
+ catch (error) {
4859
+ // dispose is awaited from waitForOutput's timeout path: a throwing
4860
+ // user dispose would reject waitForOutput and hang the swarm race.
4861
+ console.error(`agent-swarm operator signal dispose error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
4862
+ }
4733
4863
  }
4734
4864
  }
4735
4865
  }
@@ -4772,7 +4902,17 @@ class ClientOperator {
4772
4902
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
4773
4903
  this.params.logger.debug(`ClientOperator agentName=${this.params.agentName} clientId=${this.params.clientId} execute tool mode forwarded to operator`);
4774
4904
  }
4775
- this._operatorSignal.sendMessage(input, this._outgoingSubject.next);
4905
+ try {
4906
+ this._operatorSignal.sendMessage(input, this._outgoingSubject.next);
4907
+ }
4908
+ catch (error) {
4909
+ // execute is fired without await from ClientSession.execute: a throwing
4910
+ // operator connector would surface as an unhandled rejection and crash
4911
+ // the host process. Surface the error instead; the pending waitForOutput
4912
+ // resolves through the operator timeout.
4913
+ console.error(`agent-swarm operator connector error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
4914
+ await errorSubject.next([this.params.clientId, error]);
4915
+ }
4776
4916
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
4777
4917
  this.params.logger.debug(`ClientOperator agentName=${this.params.agentName} clientId=${this.params.clientId} execute end`, { input, mode });
4778
4918
  }
@@ -5850,6 +5990,8 @@ class ClientHistory {
5850
5990
  // the error through errorSubject so the caller's complete() rejects.
5851
5991
  console.error(`agent-swarm history push error agentName=${this.params.agentName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
5852
5992
  await errorSubject.next([this.params.clientId, error]);
5993
+ // The record was dropped: do not announce a successful push on the bus.
5994
+ return;
5853
5995
  }
5854
5996
  await this.params.bus.emit(this.params.clientId, {
5855
5997
  type: "push",
@@ -6308,6 +6450,7 @@ class NoopAgent {
6308
6450
  const message = `called commitCancelOutput on agent which not in the swarm clientId=${this.clientId} agentName=${this.agentName} swarmName=${this.swarmName}`;
6309
6451
  this.logger.log(message);
6310
6452
  console.error(message);
6453
+ return await this.defaultAgent.commitCancelOutput();
6311
6454
  }
6312
6455
  /**
6313
6456
  * Logs an attempt to run the missing agent and delegates to the default agent's run method.
@@ -6504,7 +6647,10 @@ class ClientSwarm {
6504
6647
  setBusy(isBusy) {
6505
6648
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
6506
6649
  this.params.logger.debug(`ClientSwarm swarmName=${this.params.swarmName} clientId=${this.params.clientId} setBusy`, { isBusy });
6507
- this._isBusy += isBusy ? 1 : -1;
6650
+ // Clamp at zero: an unmatched setBusy(false) (reachable through the public
6651
+ // SwarmPublicService.setBusy) would drive the counter negative, and a
6652
+ // negative counter reads as "busy" forever — deadlocking the session lock.
6653
+ this._isBusy = Math.max(0, this._isBusy + (isBusy ? 1 : -1));
6508
6654
  }
6509
6655
  /**
6510
6656
  * Getter for the busy state of the swarm.
@@ -6735,7 +6881,14 @@ class ClientSwarm {
6735
6881
  });
6736
6882
  if (!result) {
6737
6883
  console.error(`agent-swarm ClientSwarm getAgent current agent is not in the swarm agentName=${agentName} clientId=${this.params.clientId} swarmName=${this.params.swarmName}`);
6738
- return new NoopAgent(this.params.clientId, this.params.swarmName, agentName, this.params.agentMap[this.params.defaultAgent], this.params.logger);
6884
+ const defaultAgent = this.params.agentMap[this.params.defaultAgent];
6885
+ if (!defaultAgent) {
6886
+ // NoopAgent delegates every call to the default agent: with that one
6887
+ // missing too the delegation would die with a bare TypeError instead
6888
+ // of an actionable message.
6889
+ 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}`);
6890
+ }
6891
+ return new NoopAgent(this.params.clientId, this.params.swarmName, agentName, defaultAgent, this.params.logger);
6739
6892
  }
6740
6893
  return result;
6741
6894
  }
@@ -10882,15 +11035,49 @@ class ClientStorage {
10882
11035
  */
10883
11036
  this._itemMap = new Map();
10884
11037
  /**
10885
- * Dispatches a storage action (upsert, remove, or clear) in a queued manner, delegating to DISPATCH_FN.
10886
- * Ensures sequential execution of storage operations, supporting thread-safe updates from ClientAgent or tools.
11038
+ * Queued core of dispatch. The wrapped function must never reject: queued()
11039
+ * chains every call on the previous promise, so a rejection inside the queue
11040
+ * (e.g. a throwing createIndex/createEmbedding/setData) would reject every
11041
+ * already-queued dispatch with this foreign error WITHOUT running it — a
11042
+ * concurrent upsert/remove/clear would be silently dropped. Errors are boxed
11043
+ * here and rethrown outside the queue, so only the failing caller observes them.
10887
11044
  */
10888
- this.dispatch = functoolsKit.queued(async (action, payload) => await DISPATCH_FN$1(action, this, payload));
11045
+ this._dispatchQueue = functoolsKit.queued(async (action, payload) => {
11046
+ try {
11047
+ await DISPATCH_FN$1(action, this, payload);
11048
+ return null;
11049
+ }
11050
+ catch (error) {
11051
+ return { error };
11052
+ }
11053
+ });
11054
+ /**
11055
+ * Dispatches a storage action (upsert, remove, or clear) through the serialized
11056
+ * queue, delegating to DISPATCH_FN. Ensures sequential execution of storage
11057
+ * operations, supporting thread-safe updates from ClientAgent or tools.
11058
+ */
11059
+ this.dispatch = async (action, payload) => {
11060
+ const result = await this._dispatchQueue(action, payload);
11061
+ if (result) {
11062
+ throw result.error;
11063
+ }
11064
+ };
10889
11065
  /**
10890
11066
  * Creates embeddings for the given item, memoized by item ID to avoid redundant calculations via CREATE_EMBEDDING_FN.
10891
11067
  * Caches results for efficiency, cleared on upsert/remove to ensure freshness, supporting EmbeddingSchemaService.
10892
11068
  */
10893
- this._createEmbedding = functoolsKit.memoize(([{ id }]) => id, async (item) => await CREATE_EMBEDDING_FN(item, this));
11069
+ this._createEmbedding = functoolsKit.memoize(([{ id }]) => id, async (item) => {
11070
+ try {
11071
+ return await CREATE_EMBEDDING_FN(item, this);
11072
+ }
11073
+ catch (error) {
11074
+ // Never cache a rejected embedding: the memoized promise would keep
11075
+ // this item permanently broken (every take re-throwing the same
11076
+ // error) even after the embedding provider recovers.
11077
+ this._createEmbedding.clear(item.id);
11078
+ throw error;
11079
+ }
11080
+ });
10894
11081
  /**
10895
11082
  * Waits for the initialization of the storage, loading initial data and creating embeddings via WAIT_FOR_INIT_FN.
10896
11083
  * Ensures initialization happens only once using singleshot, supporting StorageConnectionService’s lifecycle.
@@ -11770,6 +11957,13 @@ const WAIT_FOR_INIT_FN = async (self) => {
11770
11957
  * SwarmConnectionService (swarm-level state), and BusService (event emission).
11771
11958
  */
11772
11959
  class ClientState {
11960
+ /**
11961
+ * True only while the caller runs inside the async context of an active
11962
+ * dispatch on this instance (i.e. a reentrant call from within a dispatchFn).
11963
+ */
11964
+ get _inDispatch() {
11965
+ return this._dispatchContext.getStore() === true;
11966
+ }
11773
11967
  /**
11774
11968
  * Constructs a ClientState instance with the provided parameters.
11775
11969
  * Invokes the onInit callback if provided and logs construction if debugging is enabled.
@@ -11778,29 +11972,50 @@ class ClientState {
11778
11972
  this.params = params;
11779
11973
  this.stateChanged = new functoolsKit.Subject();
11780
11974
  /**
11781
- * True while a queued dispatch (read/write) is executing for this instance.
11782
- * getState checks it to serve reentrant reads (getState inside a setState
11783
- * dispatchFn) without re-entering the queue, which would deadlock.
11975
+ * Marks the async execution context of a running dispatch (read/write).
11976
+ * getState checks it to serve reentrant reads (getState called from INSIDE a
11977
+ * setState dispatchFn/middleware) without re-entering the queue, which would
11978
+ * deadlock. Scoping this per async-context — instead of a plain instance flag —
11979
+ * ensures an UNRELATED concurrent getState still queues and observes writes in
11980
+ * order, rather than reading a stale field while some other write is in flight.
11784
11981
  */
11785
- this._inDispatch = false;
11982
+ this._dispatchContext = new async_hooks.AsyncLocalStorage();
11786
11983
  /**
11787
11984
  * The current state data, initialized as null and set during waitForInit.
11788
11985
  * Updated by setState and clearState, persisted via params.setState if provided.
11789
11986
  */
11790
11987
  this._state = null;
11791
11988
  /**
11792
- * Queued dispatch function to read or write the state, delegating to DISPATCH_FN.
11793
- * Ensures thread-safe state operations, supporting concurrent access from ClientAgent or tools.
11989
+ * Queued core of dispatch. The wrapped function must never reject: queued()
11990
+ * chains every call on the previous promise, so a rejection inside the queue
11991
+ * would reject every already-queued dispatch with this foreign error WITHOUT
11992
+ * running it — a concurrent setState would be silently dropped. Errors (e.g.
11993
+ * a throwing user dispatchFn or middleware) are boxed here and rethrown
11994
+ * outside the queue, so only the caller whose operation failed observes them.
11794
11995
  */
11795
- this.dispatch = functoolsKit.queued(async (action, payload) => {
11796
- this._inDispatch = true;
11996
+ this._dispatchQueue = functoolsKit.queued(async (action, payload) => {
11797
11997
  try {
11798
- return await DISPATCH_FN(action, this, payload);
11998
+ return {
11999
+ ok: true,
12000
+ state: await this._dispatchContext.run(true, async () => await DISPATCH_FN(action, this, payload)),
12001
+ };
11799
12002
  }
11800
- finally {
11801
- this._inDispatch = false;
12003
+ catch (error) {
12004
+ return { ok: false, error };
11802
12005
  }
11803
12006
  });
12007
+ /**
12008
+ * Dispatches a read or write of the state through the serialized queue,
12009
+ * delegating to DISPATCH_FN. Ensures thread-safe state operations, supporting
12010
+ * concurrent access from ClientAgent or tools.
12011
+ */
12012
+ this.dispatch = async (action, payload) => {
12013
+ const result = await this._dispatchQueue(action, payload);
12014
+ if ("error" in result) {
12015
+ throw result.error;
12016
+ }
12017
+ return result.state;
12018
+ };
11804
12019
  /**
11805
12020
  * Waits for the state to initialize via WAIT_FOR_INIT_FN, ensuring it’s only called once using singleshot.
11806
12021
  * Loads the initial state into _state, supporting StateConnectionService’s lifecycle management.
@@ -11830,8 +12045,18 @@ class ClientState {
11830
12045
  });
11831
12046
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
11832
12047
  this.params.logger.debug(`ClientState stateName=${this.params.stateName} clientId=${this.params.clientId} shared=${this.params.shared} setState output`, { pendingState: this._state });
11833
- this.params.setState &&
11834
- this.params.setState(this._state, this.params.clientId, this.params.stateName);
12048
+ if (this.params.setState) {
12049
+ try {
12050
+ await this.params.setState(this._state, this.params.clientId, this.params.stateName);
12051
+ }
12052
+ catch (error) {
12053
+ // Persistence was fired without await before: a rejecting adapter
12054
+ // raised an unhandled rejection. Keep the in-memory state and surface
12055
+ // the error to the caller through errorSubject instead.
12056
+ console.error(`agent-swarm state persist error stateName=${this.params.stateName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
12057
+ await errorSubject.next([this.params.clientId, error]);
12058
+ }
12059
+ }
11835
12060
  if (this.params.callbacks?.onWrite) {
11836
12061
  this.params.callbacks.onWrite(this._state, this.params.clientId, this.params.stateName);
11837
12062
  }
@@ -11863,8 +12088,17 @@ class ClientState {
11863
12088
  });
11864
12089
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
11865
12090
  this.params.logger.debug(`ClientState stateName=${this.params.stateName} clientId=${this.params.clientId} shared=${this.params.shared} clearState output`, { pendingState: this._state });
11866
- this.params.setState &&
11867
- this.params.setState(this._state, this.params.clientId, this.params.stateName);
12091
+ if (this.params.setState) {
12092
+ try {
12093
+ await this.params.setState(this._state, this.params.clientId, this.params.stateName);
12094
+ }
12095
+ catch (error) {
12096
+ // See setState above: a rejecting persistence adapter must not raise
12097
+ // an unhandled rejection.
12098
+ console.error(`agent-swarm state persist error stateName=${this.params.stateName} clientId=${this.params.clientId} error=${functoolsKit.getErrorMessage(error)}`);
12099
+ await errorSubject.next([this.params.clientId, error]);
12100
+ }
12101
+ }
11868
12102
  if (this.params.callbacks?.onWrite) {
11869
12103
  this.params.callbacks.onWrite(this._state, this.params.clientId, this.params.stateName);
11870
12104
  }
@@ -14929,7 +15163,6 @@ class PolicyPublicService {
14929
15163
  }
14930
15164
  }
14931
15165
 
14932
- const BAN_NEED_FETCH = Symbol("ban-need-fetch");
14933
15166
  /**
14934
15167
  * Class representing a client policy in the swarm system, implementing the IPolicy interface.
14935
15168
  * Manages client bans, input/output validation, and restrictions, with lazy-loaded ban lists and event emission via BusService.
@@ -14938,6 +15171,32 @@ const BAN_NEED_FETCH = Symbol("ban-need-fetch");
14938
15171
  * Supports auto-banning on validation failure and customizable ban messages, ensuring swarm security and compliance.
14939
15172
  */
14940
15173
  class ClientPolicy {
15174
+ /**
15175
+ * Returns the ban set of the given swarm, fetching it on first access.
15176
+ * Re-checks the map after the await: a ban committed through _banQueue while
15177
+ * the fetch was in flight must not be overwritten by the stale fetch result.
15178
+ */
15179
+ async _getBanSet(swarmName) {
15180
+ const existing = this._banSetBySwarm.get(swarmName);
15181
+ if (existing) {
15182
+ return existing;
15183
+ }
15184
+ const fetched = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15185
+ if (!this._banSetBySwarm.has(swarmName)) {
15186
+ this._banSetBySwarm.set(swarmName, fetched);
15187
+ }
15188
+ return this._banSetBySwarm.get(swarmName);
15189
+ }
15190
+ /**
15191
+ * Runs a ban-set mutation through _banQueue, rethrowing its boxed error
15192
+ * outside the queue so only the failing caller observes it.
15193
+ */
15194
+ async _runBanQueue(fn) {
15195
+ const result = await this._banQueue(fn);
15196
+ if (result) {
15197
+ throw result.error;
15198
+ }
15199
+ }
14941
15200
  /**
14942
15201
  * Constructs a ClientPolicy instance with the provided parameters.
14943
15202
  * Invokes the onInit callback if defined and logs construction if debugging is enabled.
@@ -14945,11 +15204,13 @@ class ClientPolicy {
14945
15204
  constructor(params) {
14946
15205
  this.params = params;
14947
15206
  /**
14948
- * Set of banned client IDs or a symbol indicating the ban list needs to be fetched.
14949
- * Initialized as BAN_NEED_FETCH, lazily populated via params.getBannedClients on first use in hasBan, validateInput, etc.
14950
- * Updated by banClient and unbanClient, persisted if params.setBannedClients is provided.
15207
+ * Ban sets keyed by swarm name, lazily populated via params.getBannedClients.
15208
+ * A ClientPolicy instance is memoized per policyName and shared by every swarm
15209
+ * that lists the policy, while bans are persisted per (policy, swarm) — a
15210
+ * single shared set would leak bans of one swarm into another and persist
15211
+ * the mixed set into the wrong store.
14951
15212
  */
14952
- this._banSet = BAN_NEED_FETCH;
15213
+ this._banSetBySwarm = new Map();
14953
15214
  /**
14954
15215
  * Serializes the read-modify-write of _banSet shared by banClient/unbanClient.
14955
15216
  * Without it two concurrent bans of different clients both read the same ban
@@ -14957,8 +15218,23 @@ class ClientPolicy {
14957
15218
  * the earlier one — one ban is silently lost in memory and in the persisted
14958
15219
  * store. queued() runs the mutations one at a time so each observes the result
14959
15220
  * of the previous one.
14960
- */
14961
- this._banQueue = functoolsKit.queued(async (fn) => await fn());
15221
+ *
15222
+ * The wrapped function must never reject: queued() chains every call on the
15223
+ * previous promise, so a rejection inside the queue (e.g. a throwing
15224
+ * setBannedClients adapter) would reject every already-queued ban/unban with
15225
+ * this foreign error WITHOUT running it — a concurrent ban of another client
15226
+ * would be silently lost. Errors are boxed here and rethrown by the caller
15227
+ * outside the queue.
15228
+ */
15229
+ this._banQueue = functoolsKit.queued(async (fn) => {
15230
+ try {
15231
+ await fn();
15232
+ return null;
15233
+ }
15234
+ catch (error) {
15235
+ return { error };
15236
+ }
15237
+ });
14962
15238
  GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
14963
15239
  this.params.logger.debug(`ClientPolicy policyName=${this.params.policyName} CTOR`, {
14964
15240
  params,
@@ -14977,10 +15253,8 @@ class ClientPolicy {
14977
15253
  clientId,
14978
15254
  swarmName,
14979
15255
  });
14980
- if (this._banSet === BAN_NEED_FETCH) {
14981
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
14982
- }
14983
- return this._banSet.has(clientId);
15256
+ const banSet = await this._getBanSet(swarmName);
15257
+ return banSet.has(clientId);
14984
15258
  }
14985
15259
  /**
14986
15260
  * Retrieves the ban message for a client, using a custom getBanMessage function if provided or falling back to params.banMessage.
@@ -15029,10 +15303,8 @@ class ClientPolicy {
15029
15303
  },
15030
15304
  clientId,
15031
15305
  });
15032
- if (this._banSet === BAN_NEED_FETCH) {
15033
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15034
- }
15035
- if (this._banSet.has(clientId)) {
15306
+ const banSet = await this._getBanSet(swarmName);
15307
+ if (banSet.has(clientId)) {
15036
15308
  return false;
15037
15309
  }
15038
15310
  if (!this.params.validateInput) {
@@ -15074,10 +15346,8 @@ class ClientPolicy {
15074
15346
  },
15075
15347
  clientId,
15076
15348
  });
15077
- if (this._banSet === BAN_NEED_FETCH) {
15078
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15079
- }
15080
- if (this._banSet.has(clientId)) {
15349
+ const banSet = await this._getBanSet(swarmName);
15350
+ if (banSet.has(clientId)) {
15081
15351
  return false;
15082
15352
  }
15083
15353
  if (!this.params.validateOutput) {
@@ -15102,31 +15372,32 @@ class ClientPolicy {
15102
15372
  clientId,
15103
15373
  swarmName,
15104
15374
  });
15105
- if (this.params.callbacks?.onBanClient) {
15106
- this.params.callbacks.onBanClient(clientId, swarmName, this.params.policyName);
15107
- }
15108
- await this.params.bus.emit(clientId, {
15109
- type: "ban-client",
15110
- source: "policy-bus",
15111
- input: {},
15112
- output: {},
15113
- context: {
15114
- policyName: this.params.policyName,
15115
- swarmName,
15116
- },
15117
- clientId,
15118
- });
15119
- await this._banQueue(async () => {
15120
- if (this._banSet === BAN_NEED_FETCH) {
15121
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15122
- }
15123
- if (this._banSet.has(clientId)) {
15375
+ await this._runBanQueue(async () => {
15376
+ const banSet = await this._getBanSet(swarmName);
15377
+ if (banSet.has(clientId)) {
15378
+ // Already banned: skip the mutation AND the notifications, matching
15379
+ // the documented "skips if already banned" contract — otherwise every
15380
+ // redundant ban re-fires onBanClient and the bus event.
15124
15381
  return;
15125
15382
  }
15126
- this._banSet = new Set(this._banSet).add(clientId);
15383
+ this._banSetBySwarm.set(swarmName, new Set(banSet).add(clientId));
15127
15384
  if (this.params.setBannedClients) {
15128
- await this.params.setBannedClients([...this._banSet], this.params.policyName, swarmName);
15385
+ await this.params.setBannedClients([...this._banSetBySwarm.get(swarmName)], this.params.policyName, swarmName);
15386
+ }
15387
+ if (this.params.callbacks?.onBanClient) {
15388
+ this.params.callbacks.onBanClient(clientId, swarmName, this.params.policyName);
15129
15389
  }
15390
+ await this.params.bus.emit(clientId, {
15391
+ type: "ban-client",
15392
+ source: "policy-bus",
15393
+ input: {},
15394
+ output: {},
15395
+ context: {
15396
+ policyName: this.params.policyName,
15397
+ swarmName,
15398
+ },
15399
+ clientId,
15400
+ });
15130
15401
  });
15131
15402
  }
15132
15403
  /**
@@ -15140,35 +15411,35 @@ class ClientPolicy {
15140
15411
  clientId,
15141
15412
  swarmName,
15142
15413
  });
15143
- if (this.params.callbacks?.onUnbanClient) {
15144
- this.params.callbacks.onUnbanClient(clientId, swarmName, this.params.policyName);
15145
- }
15146
- await this.params.bus.emit(clientId, {
15147
- type: "unban-client",
15148
- source: "policy-bus",
15149
- input: {},
15150
- output: {},
15151
- context: {
15152
- policyName: this.params.policyName,
15153
- swarmName,
15154
- },
15155
- clientId,
15156
- });
15157
- await this._banQueue(async () => {
15158
- if (this._banSet === BAN_NEED_FETCH) {
15159
- this._banSet = new Set(await this.params.getBannedClients(this.params.policyName, swarmName));
15160
- }
15161
- if (!this._banSet.has(clientId)) {
15414
+ await this._runBanQueue(async () => {
15415
+ const banSet = await this._getBanSet(swarmName);
15416
+ if (!banSet.has(clientId)) {
15417
+ // Not banned: skip the mutation and the notifications, matching the
15418
+ // documented "skips if not banned" contract.
15162
15419
  return;
15163
15420
  }
15164
15421
  {
15165
- const banSet = new Set(this._banSet);
15166
- banSet.delete(clientId);
15167
- this._banSet = banSet;
15422
+ const nextBanSet = new Set(banSet);
15423
+ nextBanSet.delete(clientId);
15424
+ this._banSetBySwarm.set(swarmName, nextBanSet);
15168
15425
  }
15169
15426
  if (this.params.setBannedClients) {
15170
- await this.params.setBannedClients([...this._banSet], this.params.policyName, swarmName);
15427
+ await this.params.setBannedClients([...this._banSetBySwarm.get(swarmName)], this.params.policyName, swarmName);
15428
+ }
15429
+ if (this.params.callbacks?.onUnbanClient) {
15430
+ this.params.callbacks.onUnbanClient(clientId, swarmName, this.params.policyName);
15171
15431
  }
15432
+ await this.params.bus.emit(clientId, {
15433
+ type: "unban-client",
15434
+ source: "policy-bus",
15435
+ input: {},
15436
+ output: {},
15437
+ context: {
15438
+ policyName: this.params.policyName,
15439
+ swarmName,
15440
+ },
15441
+ clientId,
15442
+ });
15172
15443
  });
15173
15444
  }
15174
15445
  }