claude_swarm 1.0.6 → 1.0.7

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +336 -1037
  5. data/docs/V1_TO_V2_MIGRATION_GUIDE.md +1120 -0
  6. data/docs/v1/README.md +1195 -0
  7. data/docs/v2/CHANGELOG.swarm_cli.md +22 -0
  8. data/docs/v2/CHANGELOG.swarm_memory.md +20 -0
  9. data/docs/v2/CHANGELOG.swarm_sdk.md +287 -10
  10. data/docs/v2/README.md +32 -6
  11. data/docs/v2/guides/complete-tutorial.md +133 -37
  12. data/docs/v2/guides/composable-swarms.md +1178 -0
  13. data/docs/v2/guides/getting-started.md +42 -1
  14. data/docs/v2/guides/snapshots.md +1498 -0
  15. data/docs/v2/reference/architecture-flow.md +5 -3
  16. data/docs/v2/reference/event_payload_structures.md +249 -12
  17. data/docs/v2/reference/execution-flow.md +1 -1
  18. data/docs/v2/reference/ruby-dsl.md +368 -22
  19. data/docs/v2/reference/yaml.md +314 -63
  20. data/examples/snapshot_demo.rb +119 -0
  21. data/examples/v2/dsl/01_basic.rb +0 -2
  22. data/examples/v2/dsl/02_core_parameters.rb +0 -2
  23. data/examples/v2/dsl/03_capabilities.rb +0 -2
  24. data/examples/v2/dsl/04_llm_parameters.rb +0 -2
  25. data/examples/v2/dsl/05_advanced_flags.rb +0 -3
  26. data/examples/v2/dsl/06_permissions.rb +0 -4
  27. data/examples/v2/dsl/07_mcp_server.rb +0 -2
  28. data/examples/v2/dsl/08_swarm_hooks.rb +0 -2
  29. data/examples/v2/dsl/09_agent_hooks.rb +0 -2
  30. data/examples/v2/dsl/10_all_agents_hooks.rb +0 -3
  31. data/examples/v2/dsl/11_delegation.rb +0 -2
  32. data/examples/v2/dsl/12_complete_integration.rb +2 -6
  33. data/examples/v2/node_context_demo.rb +1 -1
  34. data/examples/v2/node_workflow.rb +2 -4
  35. data/examples/v2/plan_and_execute.rb +157 -0
  36. data/lib/claude_swarm/configuration.rb +28 -4
  37. data/lib/claude_swarm/version.rb +1 -1
  38. data/lib/swarm_cli/formatters/human_formatter.rb +103 -0
  39. data/lib/swarm_cli/interactive_repl.rb +9 -3
  40. data/lib/swarm_cli/version.rb +1 -1
  41. data/lib/swarm_memory/core/storage_read_tracker.rb +51 -14
  42. data/lib/swarm_memory/integration/cli_registration.rb +3 -2
  43. data/lib/swarm_memory/integration/sdk_plugin.rb +11 -5
  44. data/lib/swarm_memory/tools/memory_edit.rb +2 -2
  45. data/lib/swarm_memory/tools/memory_multi_edit.rb +2 -2
  46. data/lib/swarm_memory/tools/memory_read.rb +3 -3
  47. data/lib/swarm_memory/version.rb +1 -1
  48. data/lib/swarm_memory.rb +5 -0
  49. data/lib/swarm_sdk/agent/builder.rb +33 -0
  50. data/lib/swarm_sdk/agent/chat/context_tracker.rb +33 -0
  51. data/lib/swarm_sdk/agent/chat/hook_integration.rb +49 -3
  52. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +11 -27
  53. data/lib/swarm_sdk/agent/chat.rb +200 -51
  54. data/lib/swarm_sdk/agent/context.rb +6 -2
  55. data/lib/swarm_sdk/agent/context_manager.rb +6 -0
  56. data/lib/swarm_sdk/agent/definition.rb +14 -2
  57. data/lib/swarm_sdk/agent/llm_instrumentation_middleware.rb +180 -0
  58. data/lib/swarm_sdk/configuration.rb +387 -94
  59. data/lib/swarm_sdk/events_to_messages.rb +181 -0
  60. data/lib/swarm_sdk/log_collector.rb +31 -5
  61. data/lib/swarm_sdk/log_stream.rb +37 -8
  62. data/lib/swarm_sdk/model_aliases.json +4 -1
  63. data/lib/swarm_sdk/node/agent_config.rb +33 -8
  64. data/lib/swarm_sdk/node/builder.rb +39 -18
  65. data/lib/swarm_sdk/node_orchestrator.rb +293 -26
  66. data/lib/swarm_sdk/proc_helpers.rb +53 -0
  67. data/lib/swarm_sdk/providers/openai_with_responses.rb +22 -15
  68. data/lib/swarm_sdk/restore_result.rb +65 -0
  69. data/lib/swarm_sdk/snapshot.rb +156 -0
  70. data/lib/swarm_sdk/snapshot_from_events.rb +386 -0
  71. data/lib/swarm_sdk/state_restorer.rb +491 -0
  72. data/lib/swarm_sdk/state_snapshot.rb +369 -0
  73. data/lib/swarm_sdk/swarm/agent_initializer.rb +360 -55
  74. data/lib/swarm_sdk/swarm/all_agents_builder.rb +28 -1
  75. data/lib/swarm_sdk/swarm/builder.rb +208 -12
  76. data/lib/swarm_sdk/swarm/swarm_registry_builder.rb +67 -0
  77. data/lib/swarm_sdk/swarm/tool_configurator.rb +46 -11
  78. data/lib/swarm_sdk/swarm.rb +338 -42
  79. data/lib/swarm_sdk/swarm_loader.rb +145 -0
  80. data/lib/swarm_sdk/swarm_registry.rb +136 -0
  81. data/lib/swarm_sdk/tools/delegate.rb +92 -7
  82. data/lib/swarm_sdk/tools/read.rb +17 -5
  83. data/lib/swarm_sdk/tools/stores/read_tracker.rb +47 -12
  84. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +45 -0
  85. data/lib/swarm_sdk/utils.rb +18 -0
  86. data/lib/swarm_sdk/validation_result.rb +33 -0
  87. data/lib/swarm_sdk/version.rb +1 -1
  88. data/lib/swarm_sdk.rb +40 -8
  89. data/swarm_cli.gemspec +1 -1
  90. data/swarm_memory.gemspec +2 -2
  91. data/swarm_sdk.gemspec +2 -2
  92. metadata +21 -13
  93. data/examples/learning-assistant/assistant.md +0 -7
  94. data/examples/learning-assistant/example-memories/concept-example.md +0 -90
  95. data/examples/learning-assistant/example-memories/experience-example.md +0 -66
  96. data/examples/learning-assistant/example-memories/fact-example.md +0 -76
  97. data/examples/learning-assistant/example-memories/memory-index.md +0 -78
  98. data/examples/learning-assistant/example-memories/skill-example.md +0 -168
  99. data/examples/learning-assistant/learning_assistant.rb +0 -34
  100. data/examples/learning-assistant/learning_assistant.yml +0 -20
  101. data/lib/swarm_sdk/mcp.rb +0 -16
  102. data/llm.v2.txt +0 -13407
  103. /data/docs/v2/guides/{MEMORY_DEFRAG_GUIDE.md → memory-defrag-guide.md} +0 -0
  104. /data/{llms.txt → llms.claude-swarm.txt} +0 -0
@@ -8,10 +8,6 @@
8
8
  # Run: bundle exec ruby -Ilib lib/swarm_sdk/examples/dsl/06_permissions.rb
9
9
 
10
10
  require "swarm_sdk"
11
- require_relative "../../../swarm_sdk/swarm_builder"
12
- require_relative "../../../swarm_sdk/agent_builder"
13
- require_relative "../../../swarm_sdk/all_agents_builder"
14
- require_relative "../../../swarm_sdk/permissions_builder"
15
11
  require "fileutils"
16
12
 
17
13
  ENV["OPENAI_API_KEY"] = "test-key"
@@ -8,8 +8,6 @@
8
8
  # Run: bundle exec ruby -Ilib lib/swarm_sdk/examples/dsl/07_mcp_server.rb
9
9
 
10
10
  require "swarm_sdk"
11
- require_relative "../../../swarm_sdk/swarm_builder"
12
- require_relative "../../../swarm_sdk/agent_builder"
13
11
 
14
12
  ENV["OPENAI_API_KEY"] = "test-key"
15
13
 
@@ -8,8 +8,6 @@
8
8
  # Run: bundle exec ruby -Ilib lib/swarm_sdk/examples/dsl/08_swarm_hooks.rb
9
9
 
10
10
  require "swarm_sdk"
11
- require_relative "../../../swarm_sdk/swarm_builder"
12
- require_relative "../../../swarm_sdk/agent_builder"
13
11
 
14
12
  ENV["OPENAI_API_KEY"] = "test-key"
15
13
 
@@ -8,8 +8,6 @@
8
8
  # Run: bundle exec ruby -Ilib lib/swarm_sdk/examples/dsl/09_agent_hooks.rb
9
9
 
10
10
  require "swarm_sdk"
11
- require_relative "../../../swarm_sdk/swarm_builder"
12
- require_relative "../../../swarm_sdk/agent_builder"
13
11
 
14
12
  ENV["OPENAI_API_KEY"] = "test-key"
15
13
 
@@ -8,9 +8,6 @@
8
8
  # Run: bundle exec ruby -Ilib lib/swarm_sdk/examples/dsl/10_all_agents_hooks.rb
9
9
 
10
10
  require "swarm_sdk"
11
- require_relative "../../../swarm_sdk/swarm_builder"
12
- require_relative "../../../swarm_sdk/agent_builder"
13
- require_relative "../../../swarm_sdk/all_agents_builder"
14
11
 
15
12
  ENV["OPENAI_API_KEY"] = "test-key"
16
13
 
@@ -8,8 +8,6 @@
8
8
  # Run: bundle exec ruby -Ilib lib/swarm_sdk/examples/dsl/11_delegation.rb
9
9
 
10
10
  require "swarm_sdk"
11
- require_relative "../../../swarm_sdk/swarm_builder"
12
- require_relative "../../../swarm_sdk/agent_builder"
13
11
 
14
12
  ENV["OPENAI_API_KEY"] = "test-key"
15
13
 
@@ -8,10 +8,6 @@
8
8
  # Run: bundle exec ruby -Ilib lib/swarm_sdk/examples/dsl/12_complete_integration.rb
9
9
 
10
10
  require "swarm_sdk"
11
- require_relative "../../../swarm_sdk/swarm_builder"
12
- require_relative "../../../swarm_sdk/agent_builder"
13
- require_relative "../../../swarm_sdk/all_agents_builder"
14
- require_relative "../../../swarm_sdk/permissions_builder"
15
11
 
16
12
  ENV["OPENAI_API_KEY"] = "test-key"
17
13
 
@@ -76,7 +72,7 @@ swarm = SwarmSDK.build do
76
72
 
77
73
  # Advanced flags
78
74
  bypass_permissions(false)
79
- skip_base_prompt(false)
75
+ coding_agent(false)
80
76
  assume_model_exists(true)
81
77
  timeout(120)
82
78
 
@@ -113,7 +109,7 @@ puts " ✓ Agent identity (system_prompt, description)"
113
109
  puts " ✓ Capabilities (tools, delegates_to, directory)"
114
110
  puts " ✓ MCP servers (filesystem via stdio)"
115
111
  puts " ✓ LLM params (parameters, timeout)"
116
- puts " ✓ Advanced flags (disable_default_tools, bypass_permissions, skip_base_prompt, assume_model_exists)"
112
+ puts " ✓ Advanced flags (disable_default_tools, bypass_permissions, coding_agent, assume_model_exists)"
117
113
  puts " ✓ Permissions (all_agents and agent-level)"
118
114
  puts " ✓ Hooks (swarm-level, agent-level, all_agents)"
119
115
  puts " ✓ Delegation"
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # Run: ruby examples/node_context_demo.rb
12
12
 
13
- require_relative "../lib/swarm_sdk"
13
+ require "swarm_sdk"
14
14
 
15
15
  swarm = SwarmSDK.build do
16
16
  name("NodeContext Demo")
@@ -13,7 +13,7 @@
13
13
  #
14
14
  # Run: ruby examples/node_workflow.rb
15
15
 
16
- require_relative "../lib/swarm_sdk"
16
+ require "swarm_sdk"
17
17
 
18
18
  swarm = SwarmSDK.build do
19
19
  name("Haiku Workflow")
@@ -27,7 +27,6 @@ swarm = SwarmSDK.build do
27
27
  Your job is to break down tasks into smaller subtasks. Extract the intent of the user's prompt and break it down into smaller subtasks.
28
28
  Return a list of subtasks.
29
29
  PROMPT
30
- tools(include_default: false)
31
30
  end
32
31
 
33
32
  agent(:implementer) do
@@ -37,7 +36,6 @@ swarm = SwarmSDK.build do
37
36
  system_prompt(<<~PROMPT)
38
37
  Your job is to execute the subtasks given to you.
39
38
  PROMPT
40
- tools(include_default: false)
41
39
  end
42
40
 
43
41
  agent(:verifier) do
@@ -47,12 +45,12 @@ swarm = SwarmSDK.build do
47
45
  system_prompt(<<~PROMPT)
48
46
  Your job is to verify work given to you and return a summary of your findings
49
47
  PROMPT
50
- tools(include_default: false)
51
48
  end
52
49
 
53
50
  # Stage 1: Planning
54
51
  node(:planning) do
55
52
  # Input transformer - ctx.content is the initial prompt
53
+ # Demonstrates using return for early exit with skip_execution
56
54
  input do |ctx|
57
55
  <<~PROMPT
58
56
  Please break down the following prompt into smaller subtasks:
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "swarm_sdk"
5
+
6
+ # Example: Plan and Execute Pattern
7
+ # Two nodes, one agent - first node plans, second node executes the plan
8
+
9
+ swarm = SwarmSDK.build do
10
+ name("Plan and Execute")
11
+
12
+ # Define a single agent that will be used in both nodes
13
+ agent(:assistant) do
14
+ description("A versatile assistant that can plan and execute tasks")
15
+ provider(:openai)
16
+ model("gpt-5")
17
+ coding_agent(false)
18
+
19
+ system_prompt(<<~PROMPT)
20
+ You are a helpful assistant who can both plan and execute tasks.
21
+
22
+ When planning, you should:
23
+ - Break down the task into clear, actionable steps
24
+ - Identify any dependencies or prerequisites
25
+ - Consider potential challenges
26
+
27
+ When executing, you should:
28
+ - Follow the plan carefully
29
+ - Use available tools effectively
30
+ - Report on progress and completion
31
+ PROMPT
32
+
33
+ # Give the assistant tools for execution
34
+ disable_default_tools(true)
35
+ end
36
+
37
+ # Node 1: Planning stage
38
+ # The agent receives the original input and creates a plan
39
+ node(:planning) do
40
+ # Use the assistant agent in planning mode (fresh context)
41
+ agent(:assistant)
42
+
43
+ # Transform input for planning
44
+ #
45
+ # NOTE: Input/output blocks are automatically converted to lambdas,
46
+ # which means you can use `return` safely for early exits!
47
+ #
48
+ # Example of using return for conditional skip:
49
+ # return ctx.skip_execution(content: "cached result") if cached
50
+ input do |ctx|
51
+ <<~INPUT
52
+ Please create a detailed plan for the following task:
53
+
54
+ #{ctx.original_prompt}
55
+
56
+ Your plan should:
57
+ 1. Break down the task into specific steps
58
+ 2. Identify what needs to be done first
59
+ 3. List any tools or resources needed
60
+ 4. Be clear and actionable
61
+
62
+ Output your plan in a structured format.
63
+ INPUT
64
+ end
65
+
66
+ # Transform output to extract key information
67
+ output do |ctx|
68
+ # Save the plan to a file for reference
69
+ File.write("plan.txt", ctx.content)
70
+
71
+ # Pass the plan to the next node
72
+ <<~OUTPUT
73
+ PLAN CREATED:
74
+ #{ctx.content}
75
+
76
+ Now execute this plan step by step.
77
+ OUTPUT
78
+ end
79
+ end
80
+
81
+ # Node 2: Execution stage
82
+ # The agent receives the plan and executes it
83
+ node(:implementation) do
84
+ # Depends on planning node
85
+ depends_on(:planning)
86
+
87
+ # Use the assistant agent in execution mode (fresh context)
88
+ agent(:assistant)
89
+
90
+ # Transform input to provide context from planning
91
+ input do |ctx|
92
+ plan = ctx.all_results[:planning].content
93
+
94
+ <<~INPUT
95
+ You previously created the following plan:
96
+
97
+ #{plan}
98
+
99
+ Now execute this plan. Use the available tools (Write, Edit, Bash) to complete each step.
100
+ Report on what you accomplished.
101
+ INPUT
102
+ end
103
+
104
+ # Output transformer for final results
105
+ output do |ctx|
106
+ <<~OUTPUT
107
+ EXECUTION COMPLETE
108
+
109
+ #{ctx.content}
110
+
111
+ Plan reference: #{File.exist?("plan.txt") ? "Saved in plan.txt" : "Not saved"}
112
+ OUTPUT
113
+ end
114
+ end
115
+
116
+ # Set the starting node
117
+ start_node(:planning)
118
+ end
119
+
120
+ # Execute the swarm
121
+ if __FILE__ == $PROGRAM_NAME
122
+ # Example task
123
+ task = "Create a simple Ruby script that reads a CSV file and outputs a summary"
124
+
125
+ puts "Starting Plan and Execute swarm..."
126
+ puts "Task: #{task}"
127
+ puts "\n" + "=" * 80 + "\n"
128
+
129
+ result = swarm.execute(task) do |log|
130
+ # Optional: Log events as they happen
131
+ case log[:type]
132
+ when "node_start"
133
+ puts "\n🔵 Starting node: #{log[:node]}"
134
+ when "node_complete"
135
+ puts "✅ Completed node: #{log[:node]} (#{log[:duration].round(2)}s)"
136
+ when "tool_call"
137
+ puts " 🔧 Tool: #{log[:tool]}"
138
+ end
139
+ end
140
+
141
+ puts "\n" + "=" * 80 + "\n"
142
+
143
+ if result.success?
144
+ puts "✅ Swarm execution successful!\n\n"
145
+ puts result.content
146
+ puts "\n" + "-" * 80
147
+ puts "Stats:"
148
+ puts " Duration: #{result.duration.round(2)}s"
149
+ puts " Total cost: $#{result.total_cost.round(4)}"
150
+ puts " Total tokens: #{result.total_tokens}"
151
+ puts " Agents involved: #{result.agents_involved.join(", ")}"
152
+ else
153
+ puts "❌ Swarm execution failed!"
154
+ puts "Error: #{result.error.message}"
155
+ puts result.error.backtrace.first(5).join("\n")
156
+ end
157
+ end
@@ -69,19 +69,43 @@ module ClaudeSwarm
69
69
  validate_directories unless has_before_commands?
70
70
  end
71
71
 
72
- def interpolate_env_vars!(obj)
72
+ def interpolate_env_vars!(obj, path = [])
73
73
  case obj
74
74
  when String
75
- interpolate_env_string(obj)
75
+ # Skip interpolation for any values inside MCP configurations
76
+ # Check if we're inside an mcps array element (path like: [..., "instances", <name>, "mcps", <index>, ...])
77
+ if in_mcp_config?(path)
78
+ obj
79
+ else
80
+ interpolate_env_string(obj)
81
+ end
76
82
  when Hash
77
- obj.transform_values! { |v| interpolate_env_vars!(v) }
83
+ obj.each do |key, value|
84
+ obj[key] = interpolate_env_vars!(value, path + [key])
85
+ end
86
+ obj
78
87
  when Array
79
- obj.map! { |v| interpolate_env_vars!(v) }
88
+ obj.map!.with_index { |v, i| interpolate_env_vars!(v, path + [i]) }
80
89
  else
81
90
  obj
82
91
  end
83
92
  end
84
93
 
94
+ def in_mcp_config?(path)
95
+ # Check if we're inside an MCP configuration
96
+ # Pattern: [..., "instances", instance_name, "mcps", index, ...]
97
+ return false if path.size < 4
98
+
99
+ # Find the position of "mcps" in the path
100
+ mcps_index = path.rindex("mcps")
101
+ return false unless mcps_index
102
+
103
+ # Check if this is under instances and followed by an array index
104
+ return false if mcps_index < 2
105
+
106
+ path[mcps_index - 2] == "instances" && path[mcps_index + 1].is_a?(Integer)
107
+ end
108
+
85
109
  def interpolate_env_string(str)
86
110
  str.gsub(ENV_VAR_WITH_DEFAULT_PATTERN) do |_match|
87
111
  env_var = Regexp.last_match(1)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "1.0.6"
4
+ VERSION = "1.0.7"
5
5
  end
@@ -95,11 +95,20 @@ module SwarmCLI
95
95
  handle_breakpoint_enter(entry)
96
96
  when "breakpoint_exit"
97
97
  handle_breakpoint_exit(entry)
98
+ when "llm_retry_attempt"
99
+ handle_llm_retry_attempt(entry)
100
+ when "llm_retry_exhausted"
101
+ handle_llm_retry_exhausted(entry)
102
+ when "response_parse_error"
103
+ handle_response_parse_error(entry)
98
104
  end
99
105
  end
100
106
 
101
107
  # Called when swarm execution completes successfully
102
108
  def on_success(result:)
109
+ # Defensive: ensure all spinners are stopped before showing result
110
+ @spinner_manager.stop_all
111
+
103
112
  if @mode == :non_interactive
104
113
  # Full result display with summary
105
114
  @output.puts
@@ -115,6 +124,9 @@ module SwarmCLI
115
124
 
116
125
  # Called when swarm execution fails
117
126
  def on_error(error:, duration: nil)
127
+ # Defensive: ensure all spinners are stopped before showing error
128
+ @spinner_manager.stop_all
129
+
118
130
  @output.puts
119
131
  @output.puts @divider.full
120
132
  print_error(error)
@@ -575,6 +587,97 @@ module SwarmCLI
575
587
  @output.puts
576
588
  end
577
589
 
590
+ def handle_llm_retry_attempt(entry)
591
+ agent = entry[:agent]
592
+ attempt = entry[:attempt]
593
+ max_retries = entry[:max_retries]
594
+ error_class = entry[:error_class]
595
+ error_message = entry[:error_message]
596
+ retry_delay = entry[:retry_delay]
597
+
598
+ # Stop agent thinking spinner (if active)
599
+ unless @quiet
600
+ spinner_key = "agent_#{agent}".to_sym
601
+ @spinner_manager.stop(spinner_key) if @spinner_manager.active?(spinner_key)
602
+ end
603
+
604
+ lines = [
605
+ @pastel.yellow("LLM API request failed (attempt #{attempt}/#{max_retries})"),
606
+ @pastel.dim("Error: #{error_class}: #{error_message}"),
607
+ @pastel.dim("Retrying in #{retry_delay}s..."),
608
+ ]
609
+
610
+ @output.puts @panel.render(
611
+ type: :warning,
612
+ title: "RETRY #{@agent_badge.render(agent)}",
613
+ lines: lines,
614
+ indent: @depth_tracker.get(agent),
615
+ )
616
+
617
+ # Restart spinner for next attempt
618
+ unless @quiet
619
+ spinner_key = "agent_#{agent}".to_sym
620
+ @spinner_manager.start(spinner_key, "#{agent} is retrying...")
621
+ end
622
+ end
623
+
624
+ def handle_llm_retry_exhausted(entry)
625
+ agent = entry[:agent]
626
+ attempts = entry[:attempts]
627
+ error_class = entry[:error_class]
628
+ error_message = entry[:error_message]
629
+
630
+ # Stop agent thinking spinner (if active)
631
+ unless @quiet
632
+ spinner_key = "agent_#{agent}".to_sym
633
+ @spinner_manager.stop(spinner_key) if @spinner_manager.active?(spinner_key)
634
+ end
635
+
636
+ lines = [
637
+ @pastel.red("LLM API request failed after #{attempts} attempts"),
638
+ @pastel.dim("Error: #{error_class}: #{error_message}"),
639
+ @pastel.dim("No more retries available"),
640
+ ]
641
+
642
+ @output.puts @panel.render(
643
+ type: :error,
644
+ title: "RETRY EXHAUSTED #{@agent_badge.render(agent)}",
645
+ lines: lines,
646
+ indent: @depth_tracker.get(agent),
647
+ )
648
+ end
649
+
650
+ def handle_response_parse_error(entry)
651
+ agent = entry[:agent]
652
+ error_class = entry[:error_class]
653
+ error_message = entry[:error_message]
654
+
655
+ # Stop agent thinking spinner (if active)
656
+ unless @quiet
657
+ spinner_key = "agent_#{agent}".to_sym
658
+ @spinner_manager.stop(spinner_key) if @spinner_manager.active?(spinner_key)
659
+ end
660
+
661
+ lines = [
662
+ @pastel.red("Failed to parse LLM API response"),
663
+ @pastel.dim("Error: #{error_class}: #{error_message}"),
664
+ ]
665
+
666
+ # Add response body preview if available (truncated)
667
+ if entry[:response_body]
668
+ body_preview = entry[:response_body].to_s[0..200]
669
+ body_preview += "..." if entry[:response_body].to_s.length > 200
670
+ lines << @pastel.dim("Response: #{body_preview}")
671
+ end
672
+
673
+ @output.puts @panel.render(
674
+ type: :error,
675
+ title: "PARSE ERROR #{@agent_badge.render(agent)}",
676
+ lines: lines,
677
+ indent: @depth_tracker.get(agent),
678
+ )
679
+ end
680
+
578
681
  def display_todo_list(agent, timestamp)
579
682
  todos = SwarmSDK::Tools::Stores::TodoManager.get_todos(agent.to_sym)
580
683
  indent = @depth_tracker.indent(agent)
@@ -81,6 +81,9 @@ module SwarmCLI
81
81
  display_session_summary
82
82
  exit(130)
83
83
  ensure
84
+ # Defensive: ensure all spinners are stopped on exit
85
+ @formatter&.spinner_manager&.stop_all
86
+
84
87
  # Save history on exit
85
88
  save_persistent_history
86
89
  end
@@ -432,11 +435,12 @@ module SwarmCLI
432
435
  end
433
436
  end
434
437
 
438
+ # CRITICAL: Stop all spinners after execution completes
439
+ # This ensures spinner doesn't interfere with error/success display or REPL prompt
440
+ @formatter.spinner_manager.stop_all
441
+
435
442
  # Handle cancellation (result is nil when cancelled)
436
443
  if result.nil?
437
- # Stop all active spinners
438
- @formatter.spinner_manager.stop_all
439
-
440
444
  puts ""
441
445
  puts @colors[:warning].call("✗ Request cancelled by user")
442
446
  puts ""
@@ -459,6 +463,8 @@ module SwarmCLI
459
463
  # Add response to history
460
464
  @conversation_history << { role: "agent", content: result.content }
461
465
  rescue StandardError => e
466
+ # Defensive: ensure spinners are stopped on exception
467
+ @formatter.spinner_manager.stop_all
462
468
  @formatter.on_error(error: e)
463
469
  end
464
470
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwarmCLI
4
- VERSION = "2.1.2"
4
+ VERSION = "2.1.3"
5
5
  end
@@ -2,40 +2,77 @@
2
2
 
3
3
  module SwarmMemory
4
4
  module Core
5
- # StorageReadTracker manages read-entry tracking for all agents
5
+ # StorageReadTracker manages read-entry tracking for all agents with content digest verification
6
6
  #
7
7
  # This module maintains a global registry of which memory entries each agent
8
- # has read during their conversation. This enables enforcement of the
9
- # "read-before-edit" rule that ensures agents have context before modifying entries.
8
+ # has read during their conversation along with SHA256 digests of the content.
9
+ # This enables enforcement of the "read-before-edit" rule that ensures agents
10
+ # have context before modifying entries, AND prevents editing entries that have
11
+ # changed externally since being read.
10
12
  #
11
- # Each agent maintains an independent set of read entries, keyed by agent identifier.
13
+ # Each agent maintains an independent map of read entries to content digests.
12
14
  module StorageReadTracker
13
- @read_entries = {}
15
+ @read_entries = {} # { agent_id => { entry_path => sha256_digest } }
14
16
  @mutex = Mutex.new
15
17
 
16
18
  class << self
17
- # Register that an agent has read a storage entry
19
+ # Register that an agent has read a storage entry with content digest
18
20
  #
19
21
  # @param agent_id [Symbol] The agent identifier
20
22
  # @param entry_path [String] The storage entry path
21
- # @return [void]
22
- def register_read(agent_id, entry_path)
23
+ # @param content [String] Entry content (for digest calculation)
24
+ # @return [String] The calculated SHA256 digest
25
+ def register_read(agent_id, entry_path, content)
23
26
  @mutex.synchronize do
24
- @read_entries[agent_id] ||= Set.new
25
- @read_entries[agent_id] << entry_path
27
+ @read_entries[agent_id] ||= {}
28
+ digest = Digest::SHA256.hexdigest(content)
29
+ @read_entries[agent_id][entry_path] = digest
30
+ digest
26
31
  end
27
32
  end
28
33
 
29
- # Check if an agent has read a storage entry
34
+ # Check if an agent has read an entry AND content hasn't changed
30
35
  #
31
36
  # @param agent_id [Symbol] The agent identifier
32
37
  # @param entry_path [String] The storage entry path
33
- # @return [Boolean] true if the agent has read this entry
34
- def entry_read?(agent_id, entry_path)
38
+ # @param storage [Storage] Storage instance to read current content
39
+ # @return [Boolean] true if agent read entry and content matches
40
+ def entry_read?(agent_id, entry_path, storage)
35
41
  @mutex.synchronize do
36
42
  return false unless @read_entries[agent_id]
37
43
 
38
- @read_entries[agent_id].include?(entry_path)
44
+ stored_digest = @read_entries[agent_id][entry_path]
45
+ return false unless stored_digest
46
+
47
+ # Check if entry still matches stored digest
48
+ begin
49
+ current_content = storage.read(file_path: entry_path)
50
+ current_digest = Digest::SHA256.hexdigest(current_content)
51
+ current_digest == stored_digest
52
+ rescue StandardError
53
+ false # Entry deleted or inaccessible
54
+ end
55
+ end
56
+ end
57
+
58
+ # Get all read entries with digests for snapshot
59
+ #
60
+ # @param agent_id [Symbol] The agent identifier
61
+ # @return [Hash] { entry_path => digest }
62
+ def get_read_entries(agent_id)
63
+ @mutex.synchronize do
64
+ @read_entries[agent_id]&.dup || {}
65
+ end
66
+ end
67
+
68
+ # Restore read entries with digests from snapshot
69
+ #
70
+ # @param agent_id [Symbol] The agent identifier
71
+ # @param entries_with_digests [Hash] { entry_path => digest }
72
+ # @return [void]
73
+ def restore_read_entries(agent_id, entries_with_digests)
74
+ @mutex.synchronize do
75
+ @read_entries[agent_id] = entries_with_digests.dup
39
76
  end
40
77
  end
41
78
 
@@ -13,8 +13,9 @@ module SwarmMemory
13
13
  #
14
14
  # @return [void]
15
15
  def register!
16
- # Only register if SwarmCLI is present
17
- return unless defined?(SwarmCLI)
16
+ # Only register if SwarmCLI::CommandRegistry is available
17
+ # Check for the specific class, not just the module
18
+ return unless defined?(SwarmCLI::CommandRegistry)
18
19
 
19
20
  # Load CLI commands explicitly (Zeitwerk might not have loaded it yet)
20
21
  require_relative "../cli/commands"
@@ -250,9 +250,12 @@ module SwarmMemory
250
250
  :interactive # Default
251
251
  end
252
252
 
253
- # Store storage and mode for this agent
254
- @storages[agent_name] = storage
255
- @modes[agent_name] = mode
253
+ # V7.0: Extract base name for storage tracking (delegation instances share storage)
254
+ base_name = agent_name.to_s.split("@").first.to_sym
255
+
256
+ # Store storage and mode using BASE NAME
257
+ @storages[base_name] = storage # ← Changed from agent_name to base_name
258
+ @modes[base_name] = mode # ← Changed from agent_name to base_name
256
259
 
257
260
  # Get mode-specific tools
258
261
  allowed_tools = tools_for_mode(mode)
@@ -298,9 +301,12 @@ module SwarmMemory
298
301
  # @param is_first_message [Boolean] True if first message
299
302
  # @return [Array<String>] System reminders (0-2 reminders)
300
303
  def on_user_message(agent_name:, prompt:, is_first_message:)
301
- storage = @storages[agent_name]
304
+ # V7.0: Extract base name for storage lookup (delegation instances share storage)
305
+ base_name = agent_name.to_s.split("@").first.to_sym
306
+ storage = @storages[base_name] # ← Changed from agent_name to base_name
307
+
302
308
  return [] unless storage&.semantic_index
303
- return [] if prompt.empty?
309
+ return [] if prompt.nil? || prompt.empty?
304
310
 
305
311
  # Adaptive threshold based on query length
306
312
  # Short queries use lower threshold as they have less semantic richness
@@ -124,8 +124,8 @@ module SwarmMemory
124
124
  # Read current content (this will raise ArgumentError if entry doesn't exist)
125
125
  content = @storage.read(file_path: file_path)
126
126
 
127
- # Enforce read-before-edit
128
- unless Core::StorageReadTracker.entry_read?(@agent_name, file_path)
127
+ # Enforce read-before-edit with content verification
128
+ unless Core::StorageReadTracker.entry_read?(@agent_name, file_path, @storage)
129
129
  return validation_error(
130
130
  "Cannot edit memory entry without reading it first. " \
131
131
  "You must use MemoryRead on 'memory://#{file_path}' before editing it. " \