claude_swarm 1.0.1 → 1.0.4

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 (267) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/release.md +1 -1
  3. data/.claude/hooks/lint-code-files.rb +65 -0
  4. data/.rubocop.yml +22 -2
  5. data/CHANGELOG.md +14 -1
  6. data/CLAUDE.md +1 -1
  7. data/CONTRIBUTING.md +69 -0
  8. data/README.md +27 -2
  9. data/Rakefile +71 -3
  10. data/analyze_coverage.rb +94 -0
  11. data/docs/v2/CHANGELOG.swarm_cli.md +43 -0
  12. data/docs/v2/CHANGELOG.swarm_memory.md +379 -0
  13. data/docs/v2/CHANGELOG.swarm_sdk.md +362 -0
  14. data/docs/v2/README.md +308 -0
  15. data/docs/v2/guides/claude-code-agents.md +262 -0
  16. data/docs/v2/guides/complete-tutorial.md +3088 -0
  17. data/docs/v2/guides/getting-started.md +1456 -0
  18. data/docs/v2/guides/memory-adapters.md +998 -0
  19. data/docs/v2/guides/plugins.md +816 -0
  20. data/docs/v2/guides/quick-start-cli.md +1745 -0
  21. data/docs/v2/guides/rails-integration.md +1902 -0
  22. data/docs/v2/guides/swarm-memory.md +599 -0
  23. data/docs/v2/reference/cli.md +729 -0
  24. data/docs/v2/reference/ruby-dsl.md +2154 -0
  25. data/docs/v2/reference/yaml.md +1835 -0
  26. data/docs-team-swarm.yml +2222 -0
  27. data/examples/learning-assistant/assistant.md +7 -0
  28. data/examples/learning-assistant/example-memories/concept-example.md +90 -0
  29. data/examples/learning-assistant/example-memories/experience-example.md +66 -0
  30. data/examples/learning-assistant/example-memories/fact-example.md +76 -0
  31. data/examples/learning-assistant/example-memories/memory-index.md +78 -0
  32. data/examples/learning-assistant/example-memories/skill-example.md +168 -0
  33. data/examples/learning-assistant/learning_assistant.rb +34 -0
  34. data/examples/learning-assistant/learning_assistant.yml +20 -0
  35. data/examples/v2/dsl/01_basic.rb +44 -0
  36. data/examples/v2/dsl/02_core_parameters.rb +59 -0
  37. data/examples/v2/dsl/03_capabilities.rb +71 -0
  38. data/examples/v2/dsl/04_llm_parameters.rb +56 -0
  39. data/examples/v2/dsl/05_advanced_flags.rb +73 -0
  40. data/examples/v2/dsl/06_permissions.rb +80 -0
  41. data/examples/v2/dsl/07_mcp_server.rb +62 -0
  42. data/examples/v2/dsl/08_swarm_hooks.rb +53 -0
  43. data/examples/v2/dsl/09_agent_hooks.rb +67 -0
  44. data/examples/v2/dsl/10_all_agents_hooks.rb +67 -0
  45. data/examples/v2/dsl/11_delegation.rb +60 -0
  46. data/examples/v2/dsl/12_complete_integration.rb +137 -0
  47. data/examples/v2/file_tools_swarm.yml +102 -0
  48. data/examples/v2/hooks/01_basic_hooks.rb +133 -0
  49. data/examples/v2/hooks/02_usage_tracking.rb +201 -0
  50. data/examples/v2/hooks/03_production_monitoring.rb +429 -0
  51. data/examples/v2/hooks/agent_stop_exit_0.yml +21 -0
  52. data/examples/v2/hooks/agent_stop_exit_1.yml +21 -0
  53. data/examples/v2/hooks/agent_stop_exit_2.yml +26 -0
  54. data/examples/v2/hooks/multiple_hooks_all_pass.yml +37 -0
  55. data/examples/v2/hooks/multiple_hooks_first_fails.yml +37 -0
  56. data/examples/v2/hooks/multiple_hooks_second_fails.yml +37 -0
  57. data/examples/v2/hooks/multiple_hooks_warnings.yml +37 -0
  58. data/examples/v2/hooks/post_tool_use_exit_0.yml +24 -0
  59. data/examples/v2/hooks/post_tool_use_exit_1.yml +24 -0
  60. data/examples/v2/hooks/post_tool_use_exit_2.yml +24 -0
  61. data/examples/v2/hooks/post_tool_use_multi_matcher_exit_0.yml +26 -0
  62. data/examples/v2/hooks/post_tool_use_multi_matcher_exit_1.yml +26 -0
  63. data/examples/v2/hooks/post_tool_use_multi_matcher_exit_2.yml +26 -0
  64. data/examples/v2/hooks/pre_tool_use_exit_0.yml +24 -0
  65. data/examples/v2/hooks/pre_tool_use_exit_1.yml +24 -0
  66. data/examples/v2/hooks/pre_tool_use_exit_2.yml +24 -0
  67. data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_0.yml +26 -0
  68. data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_1.yml +26 -0
  69. data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_2.yml +27 -0
  70. data/examples/v2/hooks/swarm_summary.sh +44 -0
  71. data/examples/v2/hooks/user_prompt_exit_0.yml +21 -0
  72. data/examples/v2/hooks/user_prompt_exit_1.yml +21 -0
  73. data/examples/v2/hooks/user_prompt_exit_2.yml +21 -0
  74. data/examples/v2/hooks/validate_bash.rb +59 -0
  75. data/examples/v2/multi_directory_permissions.yml +221 -0
  76. data/examples/v2/node_context_demo.rb +127 -0
  77. data/examples/v2/node_workflow.rb +173 -0
  78. data/examples/v2/path_resolution_demo.rb +216 -0
  79. data/examples/v2/simple-swarm-v2.rb +90 -0
  80. data/examples/v2/simple-swarm-v2.yml +62 -0
  81. data/examples/v2/swarm.yml +71 -0
  82. data/examples/v2/swarm_with_hooks.yml +61 -0
  83. data/examples/v2/swarm_with_hooks_simple.yml +25 -0
  84. data/examples/v2/think_tool_demo.rb +62 -0
  85. data/exe/swarm +6 -0
  86. data/lib/claude_swarm/claude_mcp_server.rb +0 -6
  87. data/lib/claude_swarm/cli.rb +10 -3
  88. data/lib/claude_swarm/commands/ps.rb +19 -20
  89. data/lib/claude_swarm/commands/show.rb +1 -1
  90. data/lib/claude_swarm/configuration.rb +10 -12
  91. data/lib/claude_swarm/mcp_generator.rb +10 -1
  92. data/lib/claude_swarm/orchestrator.rb +73 -49
  93. data/lib/claude_swarm/system_utils.rb +37 -11
  94. data/lib/claude_swarm/version.rb +1 -1
  95. data/lib/claude_swarm/worktree_manager.rb +1 -0
  96. data/lib/claude_swarm/yaml_loader.rb +22 -0
  97. data/lib/claude_swarm.rb +7 -2
  98. data/lib/swarm_cli/cli.rb +201 -0
  99. data/lib/swarm_cli/command_registry.rb +61 -0
  100. data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
  101. data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
  102. data/lib/swarm_cli/commands/migrate.rb +55 -0
  103. data/lib/swarm_cli/commands/run.rb +173 -0
  104. data/lib/swarm_cli/config_loader.rb +97 -0
  105. data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
  106. data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
  107. data/lib/swarm_cli/interactive_repl.rb +918 -0
  108. data/lib/swarm_cli/mcp_serve_options.rb +44 -0
  109. data/lib/swarm_cli/mcp_tools_options.rb +59 -0
  110. data/lib/swarm_cli/migrate_options.rb +54 -0
  111. data/lib/swarm_cli/migrator.rb +132 -0
  112. data/lib/swarm_cli/options.rb +151 -0
  113. data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
  114. data/lib/swarm_cli/ui/components/content_block.rb +120 -0
  115. data/lib/swarm_cli/ui/components/divider.rb +57 -0
  116. data/lib/swarm_cli/ui/components/panel.rb +62 -0
  117. data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
  118. data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
  119. data/lib/swarm_cli/ui/formatters/number.rb +58 -0
  120. data/lib/swarm_cli/ui/formatters/text.rb +77 -0
  121. data/lib/swarm_cli/ui/formatters/time.rb +73 -0
  122. data/lib/swarm_cli/ui/icons.rb +59 -0
  123. data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
  124. data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
  125. data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
  126. data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
  127. data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
  128. data/lib/swarm_cli/version.rb +5 -0
  129. data/lib/swarm_cli.rb +44 -0
  130. data/lib/swarm_memory/adapters/base.rb +141 -0
  131. data/lib/swarm_memory/adapters/filesystem_adapter.rb +845 -0
  132. data/lib/swarm_memory/chat_extension.rb +34 -0
  133. data/lib/swarm_memory/cli/commands.rb +306 -0
  134. data/lib/swarm_memory/core/entry.rb +37 -0
  135. data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
  136. data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
  137. data/lib/swarm_memory/core/path_normalizer.rb +75 -0
  138. data/lib/swarm_memory/core/semantic_index.rb +244 -0
  139. data/lib/swarm_memory/core/storage.rb +288 -0
  140. data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
  141. data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
  142. data/lib/swarm_memory/dsl/memory_config.rb +113 -0
  143. data/lib/swarm_memory/embeddings/embedder.rb +36 -0
  144. data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
  145. data/lib/swarm_memory/errors.rb +21 -0
  146. data/lib/swarm_memory/integration/cli_registration.rb +30 -0
  147. data/lib/swarm_memory/integration/configuration.rb +43 -0
  148. data/lib/swarm_memory/integration/registration.rb +31 -0
  149. data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
  150. data/lib/swarm_memory/optimization/analyzer.rb +244 -0
  151. data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
  152. data/lib/swarm_memory/prompts/memory.md.erb +109 -0
  153. data/lib/swarm_memory/prompts/memory_assistant.md.erb +181 -0
  154. data/lib/swarm_memory/prompts/memory_researcher.md.erb +281 -0
  155. data/lib/swarm_memory/prompts/memory_retrieval.md.erb +78 -0
  156. data/lib/swarm_memory/search/semantic_search.rb +112 -0
  157. data/lib/swarm_memory/search/text_search.rb +42 -0
  158. data/lib/swarm_memory/search/text_similarity.rb +80 -0
  159. data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
  160. data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
  161. data/lib/swarm_memory/tools/load_skill.rb +313 -0
  162. data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
  163. data/lib/swarm_memory/tools/memory_delete.rb +99 -0
  164. data/lib/swarm_memory/tools/memory_edit.rb +185 -0
  165. data/lib/swarm_memory/tools/memory_glob.rb +160 -0
  166. data/lib/swarm_memory/tools/memory_grep.rb +247 -0
  167. data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
  168. data/lib/swarm_memory/tools/memory_read.rb +123 -0
  169. data/lib/swarm_memory/tools/memory_write.rb +231 -0
  170. data/lib/swarm_memory/utils.rb +50 -0
  171. data/lib/swarm_memory/version.rb +5 -0
  172. data/lib/swarm_memory.rb +166 -0
  173. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
  174. data/lib/swarm_sdk/agent/builder.rb +461 -0
  175. data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
  176. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  177. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
  178. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
  179. data/lib/swarm_sdk/agent/chat.rb +1159 -0
  180. data/lib/swarm_sdk/agent/context.rb +112 -0
  181. data/lib/swarm_sdk/agent/context_manager.rb +309 -0
  182. data/lib/swarm_sdk/agent/definition.rb +556 -0
  183. data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
  184. data/lib/swarm_sdk/configuration.rb +296 -0
  185. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  186. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  187. data/lib/swarm_sdk/context_compactor.rb +340 -0
  188. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  189. data/lib/swarm_sdk/hooks/context.rb +197 -0
  190. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  191. data/lib/swarm_sdk/hooks/error.rb +29 -0
  192. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  193. data/lib/swarm_sdk/hooks/registry.rb +147 -0
  194. data/lib/swarm_sdk/hooks/result.rb +150 -0
  195. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  196. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  197. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  198. data/lib/swarm_sdk/log_collector.rb +51 -0
  199. data/lib/swarm_sdk/log_stream.rb +69 -0
  200. data/lib/swarm_sdk/markdown_parser.rb +75 -0
  201. data/lib/swarm_sdk/model_aliases.json +5 -0
  202. data/lib/swarm_sdk/models.json +1 -0
  203. data/lib/swarm_sdk/models.rb +120 -0
  204. data/lib/swarm_sdk/node/agent_config.rb +49 -0
  205. data/lib/swarm_sdk/node/builder.rb +439 -0
  206. data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
  207. data/lib/swarm_sdk/node_context.rb +170 -0
  208. data/lib/swarm_sdk/node_orchestrator.rb +384 -0
  209. data/lib/swarm_sdk/permissions/config.rb +239 -0
  210. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  211. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  212. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  213. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  214. data/lib/swarm_sdk/plugin.rb +147 -0
  215. data/lib/swarm_sdk/plugin_registry.rb +101 -0
  216. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
  217. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  218. data/lib/swarm_sdk/result.rb +97 -0
  219. data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
  220. data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
  221. data/lib/swarm_sdk/swarm/builder.rb +586 -0
  222. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  223. data/lib/swarm_sdk/swarm/tool_configurator.rb +419 -0
  224. data/lib/swarm_sdk/swarm.rb +982 -0
  225. data/lib/swarm_sdk/tools/bash.rb +274 -0
  226. data/lib/swarm_sdk/tools/clock.rb +44 -0
  227. data/lib/swarm_sdk/tools/delegate.rb +164 -0
  228. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  229. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  230. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
  231. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  232. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  233. data/lib/swarm_sdk/tools/edit.rb +150 -0
  234. data/lib/swarm_sdk/tools/glob.rb +158 -0
  235. data/lib/swarm_sdk/tools/grep.rb +228 -0
  236. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  237. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  238. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  239. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  240. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  241. data/lib/swarm_sdk/tools/read.rb +251 -0
  242. data/lib/swarm_sdk/tools/registry.rb +93 -0
  243. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
  244. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
  245. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
  246. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  247. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
  248. data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
  249. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  250. data/lib/swarm_sdk/tools/think.rb +95 -0
  251. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  252. data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
  253. data/lib/swarm_sdk/tools/write.rb +117 -0
  254. data/lib/swarm_sdk/utils.rb +50 -0
  255. data/lib/swarm_sdk/version.rb +5 -0
  256. data/lib/swarm_sdk.rb +157 -0
  257. data/llm.v2.txt +13407 -0
  258. data/rubocop/cop/security/no_reflection_methods.rb +47 -0
  259. data/rubocop/cop/security/no_ruby_llm_logger.rb +32 -0
  260. data/swarm_cli.gemspec +57 -0
  261. data/swarm_memory.gemspec +28 -0
  262. data/swarm_sdk.gemspec +41 -0
  263. data/team.yml +1 -1
  264. data/team_full.yml +1875 -0
  265. data/{team_v2.yml → team_sdk.yml} +121 -52
  266. metadata +249 -6
  267. data/EXAMPLES.md +0 -164
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ # NodeOrchestrator executes a multi-node workflow
5
+ #
6
+ # Each node represents a mini-swarm execution stage. The orchestrator:
7
+ # - Builds execution order from node dependencies (topological sort)
8
+ # - Creates a separate swarm instance for each node
9
+ # - Passes output from one node as input to dependent nodes
10
+ # - Supports input/output transformers for data flow customization
11
+ #
12
+ # @example
13
+ # orchestrator = NodeOrchestrator.new(
14
+ # swarm_name: "Dev Team",
15
+ # agent_definitions: { backend: def1, tester: def2 },
16
+ # nodes: { planning: node1, implementation: node2 },
17
+ # start_node: :planning
18
+ # )
19
+ # result = orchestrator.execute("Build auth system")
20
+ class NodeOrchestrator
21
+ attr_reader :swarm_name, :nodes, :start_node
22
+
23
+ def initialize(swarm_name:, agent_definitions:, nodes:, start_node:)
24
+ @swarm_name = swarm_name
25
+ @agent_definitions = agent_definitions
26
+ @nodes = nodes
27
+ @start_node = start_node
28
+
29
+ validate!
30
+ @execution_order = build_execution_order
31
+ end
32
+
33
+ # Execute the node workflow
34
+ #
35
+ # Executes nodes in topological order, passing output from each node
36
+ # to its dependents. Supports streaming logs if block given.
37
+ #
38
+ # @param prompt [String] Initial prompt for the workflow
39
+ # @yield [Hash] Log entry if block given (for streaming)
40
+ # @return [Result] Final result from last node execution
41
+ def execute(prompt, &block)
42
+ logs = []
43
+ current_input = prompt
44
+ results = {}
45
+ @original_prompt = prompt # Store original prompt for NodeContext
46
+
47
+ # Setup logging if block given
48
+ if block_given?
49
+ # Register callback to collect logs and forward to user's block
50
+ LogCollector.on_log do |entry|
51
+ logs << entry
52
+ block.call(entry)
53
+ end
54
+
55
+ # Set LogStream to use LogCollector as emitter
56
+ LogStream.emitter = LogCollector
57
+ end
58
+
59
+ @execution_order.each do |node_name|
60
+ node = @nodes[node_name]
61
+ node_start_time = Time.now
62
+
63
+ # Emit node_start event
64
+ emit_node_start(node_name, node)
65
+
66
+ # Transform input if node has transformer (Ruby block or bash command)
67
+ skip_execution = false
68
+ skip_content = nil
69
+
70
+ if node.has_input_transformer?
71
+ # Build NodeContext based on dependencies
72
+ #
73
+ # For single dependency: previous_result has original Result metadata,
74
+ # transformed_content has output from previous transformer
75
+ # For multiple dependencies: previous_result is hash of Results
76
+ # For no dependencies: previous_result is initial prompt string
77
+ previous_result = if node.dependencies.size > 1
78
+ # Multiple dependencies: pass hash of original results
79
+ node.dependencies.to_h { |dep| [dep, results[dep]] }
80
+ elsif node.dependencies.size == 1
81
+ # Single dependency: pass the original result
82
+ results[node.dependencies.first]
83
+ else
84
+ # No dependencies: initial prompt
85
+ current_input
86
+ end
87
+
88
+ # Create NodeContext for input transformer
89
+ input_context = NodeContext.for_input(
90
+ previous_result: previous_result,
91
+ all_results: results,
92
+ original_prompt: @original_prompt,
93
+ node_name: node_name,
94
+ dependencies: node.dependencies,
95
+ transformed_content: node.dependencies.size == 1 ? current_input : nil,
96
+ )
97
+
98
+ # Apply input transformer (passes current_input for bash command fallback)
99
+ # Bash transformer exit codes:
100
+ # - Exit 0: Use STDOUT as transformed content
101
+ # - Exit 1: Skip node execution, use current_input unchanged (STDOUT ignored)
102
+ # - Exit 2: Halt workflow with error from STDERR (STDOUT ignored)
103
+ transformed = node.transform_input(input_context, current_input: current_input)
104
+
105
+ # Check if transformer requested skipping execution
106
+ # (from Ruby block returning hash OR bash command exit 1)
107
+ if transformed.is_a?(Hash) && transformed[:skip_execution]
108
+ skip_execution = true
109
+ skip_content = transformed[:content] || transformed["content"]
110
+ else
111
+ current_input = transformed
112
+ end
113
+ end
114
+
115
+ # Execute node (or skip if requested)
116
+ if skip_execution
117
+ # Skip execution: return result immediately with provided content
118
+ result = Result.new(
119
+ content: skip_content,
120
+ agent: "skipped:#{node_name}",
121
+ logs: [],
122
+ duration: 0.0,
123
+ )
124
+ elsif node.agent_less?
125
+ # Agent-less node: run pure computation without LLM
126
+ result = execute_agent_less_node(node, current_input)
127
+ else
128
+ # Normal node: build mini-swarm and execute with LLM
129
+ # NOTE: Don't pass block to mini-swarm - LogCollector already captures all logs
130
+ mini_swarm = build_swarm_for_node(node)
131
+ result = mini_swarm.execute(current_input)
132
+
133
+ # If result has error, log it with backtrace
134
+ if result.error
135
+ RubyLLM.logger.error("NodeOrchestrator: Node '#{node_name}' failed: #{result.error.message}")
136
+ RubyLLM.logger.error(" Backtrace: #{result.error.backtrace&.first(5)&.join("\n ")}")
137
+ end
138
+ end
139
+
140
+ results[node_name] = result
141
+
142
+ # Transform output for next node using NodeContext
143
+ output_context = NodeContext.for_output(
144
+ result: result,
145
+ all_results: results,
146
+ original_prompt: @original_prompt,
147
+ node_name: node_name,
148
+ )
149
+ current_input = node.transform_output(output_context)
150
+
151
+ # For agent-less nodes, update the result with transformed content
152
+ # This ensures all_results contains the actual output, not the input
153
+ if node.agent_less? && current_input != result.content
154
+ results[node_name] = Result.new(
155
+ content: current_input,
156
+ agent: result.agent,
157
+ logs: result.logs,
158
+ duration: result.duration,
159
+ error: result.error,
160
+ )
161
+ end
162
+
163
+ # Emit node_stop event
164
+ node_duration = Time.now - node_start_time
165
+ emit_node_stop(node_name, node, result, node_duration, skip_execution)
166
+ end
167
+
168
+ results.values.last
169
+ ensure
170
+ # Reset logging state for next execution
171
+ LogCollector.reset!
172
+ LogStream.reset!
173
+ end
174
+
175
+ private
176
+
177
+ # Emit node_start event
178
+ #
179
+ # @param node_name [Symbol] Name of the node
180
+ # @param node [Node::Builder] Node configuration
181
+ # @return [void]
182
+ def emit_node_start(node_name, node)
183
+ return unless LogStream.emitter
184
+
185
+ LogStream.emit(
186
+ type: "node_start",
187
+ node: node_name.to_s,
188
+ agent_less: node.agent_less?,
189
+ agents: node.agent_configs.map { |ac| ac[:agent].to_s },
190
+ dependencies: node.dependencies.map(&:to_s),
191
+ timestamp: Time.now.utc.iso8601,
192
+ )
193
+ end
194
+
195
+ # Emit node_stop event
196
+ #
197
+ # @param node_name [Symbol] Name of the node
198
+ # @param node [Node::Builder] Node configuration
199
+ # @param result [Result] Node execution result
200
+ # @param duration [Float] Node execution duration in seconds
201
+ # @param skipped [Boolean] Whether execution was skipped
202
+ # @return [void]
203
+ def emit_node_stop(node_name, node, result, duration, skipped)
204
+ return unless LogStream.emitter
205
+
206
+ LogStream.emit(
207
+ type: "node_stop",
208
+ node: node_name.to_s,
209
+ agent_less: node.agent_less?,
210
+ skipped: skipped,
211
+ agents: node.agent_configs.map { |ac| ac[:agent].to_s },
212
+ duration: duration.round(3),
213
+ timestamp: Time.now.utc.iso8601,
214
+ )
215
+ end
216
+
217
+ # Execute an agent-less (computation-only) node
218
+ #
219
+ # Agent-less nodes run pure Ruby code without LLM execution.
220
+ # Creates a minimal Result object with the transformed content.
221
+ #
222
+ # @param node [Node::Builder] Agent-less node configuration
223
+ # @param input [String] Input content
224
+ # @return [Result] Result with transformed content
225
+ def execute_agent_less_node(node, input)
226
+ # For agent-less nodes, the "content" is just the input passed through
227
+ # The output transformer will do the actual work
228
+ Result.new(
229
+ content: input,
230
+ agent: "computation:#{node.name}",
231
+ logs: [],
232
+ duration: 0.0,
233
+ )
234
+ end
235
+
236
+ # Validate orchestrator configuration
237
+ #
238
+ # @return [void]
239
+ # @raise [ConfigurationError] If configuration is invalid
240
+ def validate!
241
+ # Validate start_node exists
242
+ unless @nodes.key?(@start_node)
243
+ raise ConfigurationError,
244
+ "start_node '#{@start_node}' not found. Available nodes: #{@nodes.keys.join(", ")}"
245
+ end
246
+
247
+ # Validate all nodes
248
+ @nodes.each_value(&:validate!)
249
+
250
+ # Validate node dependencies reference existing nodes
251
+ @nodes.each do |node_name, node|
252
+ node.dependencies.each do |dep|
253
+ unless @nodes.key?(dep)
254
+ raise ConfigurationError,
255
+ "Node '#{node_name}' depends on unknown node '#{dep}'"
256
+ end
257
+ end
258
+ end
259
+
260
+ # Validate all agents referenced in nodes exist (skip agent-less nodes)
261
+ @nodes.each do |node_name, node|
262
+ next if node.agent_less? # Skip validation for agent-less nodes
263
+
264
+ node.agent_configs.each do |config|
265
+ agent_name = config[:agent]
266
+ unless @agent_definitions.key?(agent_name)
267
+ raise ConfigurationError,
268
+ "Node '#{node_name}' references undefined agent '#{agent_name}'"
269
+ end
270
+
271
+ # Validate delegation targets exist
272
+ config[:delegates_to].each do |delegate|
273
+ unless @agent_definitions.key?(delegate)
274
+ raise ConfigurationError,
275
+ "Node '#{node_name}' agent '#{agent_name}' delegates to undefined agent '#{delegate}'"
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
281
+
282
+ # Build a swarm instance for a specific node
283
+ #
284
+ # Creates a new Swarm with only the agents specified in the node,
285
+ # configured with the node's delegation topology.
286
+ #
287
+ # @param node [Node::Builder] Node configuration
288
+ # @return [Swarm] Configured swarm instance
289
+ def build_swarm_for_node(node)
290
+ swarm = Swarm.new(name: "#{@swarm_name}:#{node.name}")
291
+
292
+ # Add each agent specified in this node
293
+ node.agent_configs.each do |config|
294
+ agent_name = config[:agent]
295
+ delegates_to = config[:delegates_to]
296
+
297
+ # Get global agent definition
298
+ agent_def = @agent_definitions[agent_name]
299
+
300
+ # Clone definition with node-specific delegation
301
+ node_specific_def = clone_with_delegation(agent_def, delegates_to)
302
+
303
+ swarm.add_agent(node_specific_def)
304
+ end
305
+
306
+ # Set lead agent
307
+ swarm.lead = node.lead_agent
308
+
309
+ swarm
310
+ end
311
+
312
+ # Clone an agent definition with different delegates_to
313
+ #
314
+ # @param agent_def [Agent::Definition] Original definition
315
+ # @param delegates_to [Array<Symbol>] New delegation targets
316
+ # @return [Agent::Definition] Cloned definition
317
+ def clone_with_delegation(agent_def, delegates_to)
318
+ config = agent_def.to_h
319
+ config[:delegates_to] = delegates_to
320
+ Agent::Definition.new(agent_def.name, config)
321
+ end
322
+
323
+ # Build execution order using topological sort (Kahn's algorithm)
324
+ #
325
+ # Processes all nodes in dependency order, starting from start_node.
326
+ # Ensures all nodes are reachable from start_node.
327
+ #
328
+ # @return [Array<Symbol>] Ordered list of node names
329
+ # @raise [CircularDependencyError] If circular dependency detected
330
+ def build_execution_order
331
+ # Build in-degree map and adjacency list
332
+ in_degree = {}
333
+ adjacency = Hash.new { |h, k| h[k] = [] }
334
+
335
+ @nodes.each do |node_name, node|
336
+ in_degree[node_name] = node.dependencies.size
337
+ node.dependencies.each do |dep|
338
+ adjacency[dep] << node_name
339
+ end
340
+ end
341
+
342
+ # Start with nodes that have no dependencies
343
+ queue = in_degree.select { |_, degree| degree == 0 }.keys
344
+ order = []
345
+
346
+ while queue.any?
347
+ # Process nodes with all dependencies satisfied
348
+ node_name = queue.shift
349
+ order << node_name
350
+
351
+ # Reduce in-degree for dependent nodes
352
+ adjacency[node_name].each do |dependent|
353
+ in_degree[dependent] -= 1
354
+ queue << dependent if in_degree[dependent] == 0
355
+ end
356
+ end
357
+
358
+ # Check for circular dependencies
359
+ if order.size < @nodes.size
360
+ unprocessed = @nodes.keys - order
361
+ raise CircularDependencyError,
362
+ "Circular dependency detected. Unprocessed nodes: #{unprocessed.join(", ")}"
363
+ end
364
+
365
+ # Verify start_node is in the execution order
366
+ unless order.include?(@start_node)
367
+ raise ConfigurationError,
368
+ "start_node '#{@start_node}' is not reachable in the dependency graph"
369
+ end
370
+
371
+ # Verify start_node is actually first (or rearrange to make it first)
372
+ # This ensures we start from the declared start_node
373
+ start_index = order.index(@start_node)
374
+ if start_index && start_index > 0
375
+ # start_node has dependencies - this violates the assumption
376
+ raise ConfigurationError,
377
+ "start_node '#{@start_node}' has dependencies: #{@nodes[@start_node].dependencies.join(", ")}. " \
378
+ "start_node must have no dependencies."
379
+ end
380
+
381
+ order
382
+ end
383
+ end
384
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Permissions
5
+ # Config parses and validates permission configuration for tools
6
+ #
7
+ # Handles:
8
+ # - Allowed path patterns (allowlist)
9
+ # - Denied path patterns (explicit denylist)
10
+ # - Allowed command patterns (regex for Bash tool)
11
+ # - Denied command patterns (regex for Bash tool)
12
+ # - Relative paths converted to absolute based on agent directory
13
+ # - Glob pattern matching with absolute paths
14
+ #
15
+ # All paths and patterns are converted to absolute:
16
+ # - Patterns starting with / are kept as-is
17
+ # - Relative patterns are expanded against the agent's base directory
18
+ # - Paths starting with / are kept as-is
19
+ # - Relative paths are expanded against the agent's base directory
20
+ #
21
+ # Example:
22
+ # config = Config.new(
23
+ # {
24
+ # allowed_paths: ["tmp/**/*"],
25
+ # denied_paths: ["tmp/secrets/**"],
26
+ # allowed_commands: ["^git (status|diff|log)$"],
27
+ # denied_commands: ["^rm -rf"]
28
+ # },
29
+ # base_directories: ["/home/user/project"]
30
+ # )
31
+ # config.allowed?("tmp/file.txt") # => true (checks /home/user/project/tmp/file.txt)
32
+ # config.allowed?("tmp/secrets/key.pem") # => false (denied takes precedence)
33
+ # config.command_allowed?("git status") # => true
34
+ # config.command_allowed?("rm -rf /") # => false (denied takes precedence)
35
+ class Config
36
+ attr_reader :allowed_patterns, :denied_patterns, :allowed_commands, :denied_commands
37
+
38
+ # Initialize permission configuration
39
+ #
40
+ # @param config_hash [Hash] Permission configuration with :allowed_paths, :denied_paths, :allowed_commands, :denied_commands
41
+ # @param base_directory [String] Base directory for the agent
42
+ def initialize(config_hash, base_directory:)
43
+ # Use agent's directory as the base for path resolution
44
+ @base_directory = File.expand_path(base_directory)
45
+
46
+ # Expand all patterns to absolute paths
47
+ @allowed_patterns = expand_patterns(config_hash[:allowed_paths] || [])
48
+ @denied_patterns = expand_patterns(config_hash[:denied_paths] || [])
49
+
50
+ # Parse command patterns (regex strings)
51
+ @allowed_commands = compile_regex_patterns(config_hash[:allowed_commands] || [])
52
+ @denied_commands = compile_regex_patterns(config_hash[:denied_commands] || [])
53
+ end
54
+
55
+ # Check if a path is allowed according to this configuration
56
+ #
57
+ # Rules:
58
+ # 1. Denied patterns take precedence and always block
59
+ # 2. If allowed_paths specified: must match at least one pattern (allowlist)
60
+ # 3. If allowed_paths NOT specified: allow everything (except denied)
61
+ # 4. All paths are converted to absolute for consistent matching
62
+ # 5. For directories used as search bases (Glob/Grep), allow if any pattern would match inside
63
+ #
64
+ # @param path [String] Path to check (relative or absolute)
65
+ # @param directory_search [Boolean] True if this is a directory search base (Glob/Grep)
66
+ # @return [Boolean] True if path is allowed
67
+ def allowed?(path, directory_search: false)
68
+ # Convert path to absolute
69
+ absolute_path = to_absolute_path(path)
70
+
71
+ # Denied patterns take precedence - check first
72
+ return false if matches_any?(@denied_patterns, absolute_path)
73
+
74
+ # If no allowed patterns, allow everything (except denied)
75
+ return true if @allowed_patterns.empty?
76
+
77
+ # For directory searches, check if directory is a prefix of any allowed pattern
78
+ # Don't check if directory exists - allow non-existent directories as search bases
79
+ if directory_search
80
+ return true if allowed_as_search_base?(absolute_path)
81
+ end
82
+
83
+ # Must match at least one allowed pattern
84
+ matches_any?(@allowed_patterns, absolute_path)
85
+ end
86
+
87
+ # Find the specific pattern that denies or doesn't allow a path
88
+ #
89
+ # @param path [String] Path to check (relative or absolute)
90
+ # @param directory_search [Boolean] True if this is a directory search base (Glob/Grep)
91
+ # @return [String, nil] The pattern that blocks this path, or nil if allowed
92
+ def find_blocking_pattern(path, directory_search: false)
93
+ absolute_path = to_absolute_path(path)
94
+
95
+ # Check denied patterns first
96
+ denied_match = @denied_patterns.find { |pattern| PathMatcher.matches?(pattern, absolute_path) }
97
+ return denied_match if denied_match
98
+
99
+ # Check allowed patterns
100
+ if @allowed_patterns.any?
101
+ # For directory searches, check if allowed as search base
102
+ # Don't check if directory exists - allow non-existent directories as search bases
103
+ if directory_search
104
+ return if allowed_as_search_base?(absolute_path)
105
+ end
106
+
107
+ # Check if path matches any allowed pattern
108
+ return if @allowed_patterns.any? { |pattern| PathMatcher.matches?(pattern, absolute_path) }
109
+
110
+ # Path doesn't match any allowed pattern
111
+ return "(not in allowed list)"
112
+ end
113
+
114
+ nil
115
+ end
116
+
117
+ # Convert a path to absolute form
118
+ #
119
+ # @param path [String] Path to convert
120
+ # @return [String] Absolute path
121
+ def to_absolute(path)
122
+ to_absolute_path(path)
123
+ end
124
+
125
+ # Check if a command is allowed according to this configuration
126
+ #
127
+ # Rules:
128
+ # 1. Denied command patterns take precedence and always block
129
+ # 2. If allowed_commands specified: must match at least one pattern (allowlist)
130
+ # 3. If allowed_commands NOT specified: allow everything (except denied)
131
+ #
132
+ # @param command [String] Command to check
133
+ # @return [Boolean] True if command is allowed
134
+ def command_allowed?(command)
135
+ # Denied patterns take precedence - check first
136
+ return false if matches_any_regex?(@denied_commands, command)
137
+
138
+ # If no allowed patterns, allow everything (except denied)
139
+ return true if @allowed_commands.empty?
140
+
141
+ # Must match at least one allowed pattern
142
+ matches_any_regex?(@allowed_commands, command)
143
+ end
144
+
145
+ # Find the specific pattern that denies or doesn't allow a command
146
+ #
147
+ # @param command [String] Command to check
148
+ # @return [String, nil] The pattern that blocks this command, or nil if allowed
149
+ def find_blocking_command_pattern(command)
150
+ # Check denied patterns first
151
+ denied_match = @denied_commands.find { |pattern| pattern.match?(command) }
152
+ return denied_match.source if denied_match
153
+
154
+ # Check allowed patterns
155
+ if @allowed_commands.any?
156
+ # Check if command matches any allowed pattern
157
+ return if @allowed_commands.any? { |pattern| pattern.match?(command) }
158
+
159
+ # Command doesn't match any allowed pattern
160
+ return "(not in allowed list)"
161
+ end
162
+
163
+ nil
164
+ end
165
+
166
+ private
167
+
168
+ # Expand patterns to absolute paths
169
+ #
170
+ # Patterns starting with / are kept as-is
171
+ # Relative patterns are joined with base directory
172
+ def expand_patterns(patterns)
173
+ Array(patterns).map do |pattern|
174
+ if pattern.to_s.start_with?("/")
175
+ pattern.to_s
176
+ else
177
+ File.join(@base_directory, pattern.to_s)
178
+ end
179
+ end
180
+ end
181
+
182
+ # Convert path to absolute
183
+ #
184
+ # Paths starting with / are kept as-is
185
+ # Relative paths are expanded against base directory
186
+ def to_absolute_path(path)
187
+ if path.start_with?("/")
188
+ path
189
+ else
190
+ File.expand_path(path, @base_directory)
191
+ end
192
+ end
193
+
194
+ # Check if path matches any pattern in the list
195
+ def matches_any?(patterns, path)
196
+ patterns.any? { |pattern| PathMatcher.matches?(pattern, path) }
197
+ end
198
+
199
+ # Check if a directory is allowed as a search base
200
+ #
201
+ # A directory is allowed as a search base if any allowed pattern
202
+ # would match files or directories inside it.
203
+ #
204
+ # @param directory_path [String] Absolute path to directory
205
+ # @return [Boolean] True if directory can be used as search base
206
+ def allowed_as_search_base?(directory_path)
207
+ # Normalize directory path (ensure trailing slash for comparison)
208
+ dir_with_slash = directory_path.end_with?("/") ? directory_path : "#{directory_path}/"
209
+
210
+ @allowed_patterns.any? do |pattern|
211
+ # Check if the pattern starts with this directory
212
+ # This means files inside this directory would match the pattern
213
+ pattern.start_with?(dir_with_slash) || pattern == directory_path
214
+ end
215
+ end
216
+
217
+ # Compile regex patterns from strings
218
+ #
219
+ # @param patterns [Array<String>] Array of regex pattern strings
220
+ # @return [Array<Regexp>] Array of compiled regex objects
221
+ def compile_regex_patterns(patterns)
222
+ Array(patterns).map do |pattern|
223
+ Regexp.new(pattern)
224
+ rescue RegexpError => e
225
+ raise ConfigurationError, "Invalid regex pattern '#{pattern}': #{e.message}"
226
+ end
227
+ end
228
+
229
+ # Check if command matches any regex pattern in the list
230
+ #
231
+ # @param patterns [Array<Regexp>] Array of compiled regex patterns
232
+ # @param command [String] Command to check
233
+ # @return [Boolean] True if command matches any pattern
234
+ def matches_any_regex?(patterns, command)
235
+ patterns.any? { |pattern| pattern.match?(command) }
236
+ end
237
+ end
238
+ end
239
+ end