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,1159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ # Chat extends RubyLLM::Chat to enable parallel agent-to-agent tool calling
6
+ # with two-level rate limiting to prevent API quota exhaustion
7
+ #
8
+ # ## Rate Limiting Strategy
9
+ #
10
+ # In hierarchical agent trees, unlimited parallelism can cause exponential growth:
11
+ # Main → 10 agents → 100 agents → 1,000 agents = API meltdown!
12
+ #
13
+ # Solution: Two-level semaphore system
14
+ # 1. **Global semaphore** - Total concurrent LLM calls across entire swarm
15
+ # 2. **Local semaphore** - Max concurrent tool calls for this specific agent
16
+ #
17
+ # ## Architecture
18
+ #
19
+ # This class is now organized with clear separation of concerns:
20
+ # - Core (this file): Initialization, provider setup, rate limiting, parallel execution
21
+ # - SystemReminderInjector: First message reminders, TodoWrite reminders
22
+ # - LoggingHelpers: Tool call formatting, result serialization
23
+ # - ContextTracker: Logging callbacks, delegation tracking
24
+ # - HookIntegration: Hook system integration (wraps tool execution with hooks)
25
+ class Chat < RubyLLM::Chat
26
+ # Include logging helpers for tool call formatting
27
+ include LoggingHelpers
28
+
29
+ # Include hook integration for user_prompt hooks and hook trigger methods
30
+ # This module overrides ask() to inject user_prompt hooks
31
+ # and provides trigger methods for pre/post tool use hooks
32
+ include HookIntegration
33
+
34
+ # Register custom provider for responses API support
35
+ # This is done once at class load time
36
+ unless RubyLLM::Provider.providers.key?(:openai_with_responses)
37
+ RubyLLM::Provider.register(:openai_with_responses, SwarmSDK::Providers::OpenAIWithResponses)
38
+ end
39
+
40
+ # Initialize AgentChat with rate limiting
41
+ #
42
+ # @param definition [Hash] Agent definition containing all configuration
43
+ # @param agent_name [Symbol, nil] Agent identifier (for plugin callbacks)
44
+ # @param global_semaphore [Async::Semaphore, nil] Shared across all agents (not part of definition)
45
+ # @param options [Hash] Additional options to pass to RubyLLM::Chat
46
+ # @raise [ArgumentError] If provider doesn't support custom base_url or provider not specified with base_url
47
+ def initialize(definition:, agent_name: nil, global_semaphore: nil, **options)
48
+ # Extract configuration from definition
49
+ model = definition[:model]
50
+ provider = definition[:provider]
51
+ context_window = definition[:context_window]
52
+ max_concurrent_tools = definition[:max_concurrent_tools]
53
+ base_url = definition[:base_url]
54
+ api_version = definition[:api_version]
55
+ timeout = definition[:timeout] || Definition::DEFAULT_TIMEOUT
56
+ assume_model_exists = definition[:assume_model_exists]
57
+ system_prompt = definition[:system_prompt]
58
+ parameters = definition[:parameters]
59
+ headers = definition[:headers]
60
+
61
+ # Create isolated context if custom base_url or timeout specified
62
+ if base_url || timeout != Definition::DEFAULT_TIMEOUT
63
+ # Provider is required when using custom base_url
64
+ raise ArgumentError, "Provider must be specified when base_url is set" if base_url && !provider
65
+
66
+ # Determine actual provider to use
67
+ actual_provider = determine_provider(provider, base_url, api_version)
68
+ RubyLLM.logger.debug("SwarmSDK Agent::Chat: Using provider '#{actual_provider}' (requested='#{provider}', api_version='#{api_version}')")
69
+
70
+ context = build_custom_context(provider: provider, base_url: base_url, timeout: timeout)
71
+
72
+ # Use assume_model_exists to bypass model validation for custom endpoints
73
+ # Default to true when base_url is set, false otherwise (unless explicitly specified)
74
+ assume_model_exists = base_url ? true : false if assume_model_exists.nil?
75
+
76
+ super(model: model, provider: actual_provider, assume_model_exists: assume_model_exists, context: context, **options)
77
+
78
+ # Configure custom provider after creation (RubyLLM doesn't support custom init params)
79
+ if actual_provider == :openai_with_responses && api_version == "v1/responses"
80
+ configure_responses_api_provider
81
+ end
82
+ elsif provider
83
+ # No custom base_url or timeout: use RubyLLM's defaults (with optional provider override)
84
+ assume_model_exists = false if assume_model_exists.nil?
85
+ super(model: model, provider: provider, assume_model_exists: assume_model_exists, **options)
86
+ else
87
+ # No custom base_url, timeout, or provider: use RubyLLM's defaults
88
+ assume_model_exists = false if assume_model_exists.nil?
89
+ super(model: model, assume_model_exists: assume_model_exists, **options)
90
+ end
91
+
92
+ # Agent identifier (for plugin callbacks)
93
+ @agent_name = agent_name
94
+
95
+ # Context manager for ephemeral messages and future context optimization
96
+ @context_manager = ContextManager.new
97
+
98
+ # Rate limiting semaphores
99
+ @global_semaphore = global_semaphore
100
+ @local_semaphore = max_concurrent_tools ? Async::Semaphore.new(max_concurrent_tools) : nil
101
+ @explicit_context_window = context_window
102
+
103
+ # Track TodoWrite usage for periodic reminders
104
+ @last_todowrite_message_index = nil
105
+
106
+ # Agent context for logging (set via setup_context)
107
+ @agent_context = nil
108
+
109
+ # Context tracker (created after agent_context is set)
110
+ @context_tracker = nil
111
+
112
+ # Track which tools are immutable (cannot be removed by dynamic tool swapping)
113
+ # Default: Think, Clock, and TodoWrite are immutable utilities
114
+ # Plugins can mark additional tools as immutable via on_agent_initialized hook
115
+ @immutable_tool_names = Set.new(["Think", "Clock", "TodoWrite"])
116
+
117
+ # Track active skill (only used if memory enabled)
118
+ @active_skill_path = nil
119
+
120
+ # Try to fetch real model info for accurate context tracking
121
+ # This searches across ALL providers, so it works even when using proxies
122
+ # (e.g., Claude model through OpenAI-compatible proxy)
123
+ fetch_real_model_info(model)
124
+
125
+ # Configure system prompt, parameters, and headers after parent initialization
126
+ with_instructions(system_prompt) if system_prompt
127
+ configure_parameters(parameters)
128
+ configure_headers(headers)
129
+ end
130
+
131
+ # Setup agent context
132
+ #
133
+ # Sets the agent context for this chat, enabling delegation tracking.
134
+ # This is always called, regardless of whether logging is enabled.
135
+ #
136
+ # @param context [Agent::Context] Agent context for this chat
137
+ # @return [void]
138
+ def setup_context(context)
139
+ @agent_context = context
140
+ @context_tracker = ContextTracker.new(self, context)
141
+ end
142
+
143
+ # Setup logging callbacks
144
+ #
145
+ # This configures the chat to emit log events via LogStream.
146
+ # Should only be called when LogStream.emitter is set.
147
+ #
148
+ # @return [void]
149
+ def setup_logging
150
+ raise StateError, "Agent context not set. Call setup_context first." unless @agent_context
151
+
152
+ @context_tracker.setup_logging
153
+ end
154
+
155
+ # Emit model lookup warning if one occurred during initialization
156
+ #
157
+ # If a model wasn't found in the registry during initialization, this will
158
+ # emit a proper JSON log event through LogStream.
159
+ #
160
+ # @param agent_name [Symbol, String] The agent name for logging context
161
+ def emit_model_lookup_warning(agent_name)
162
+ return unless @model_lookup_error
163
+
164
+ LogStream.emit(
165
+ type: "model_lookup_warning",
166
+ agent: agent_name,
167
+ model: @model_lookup_error[:model],
168
+ error_message: @model_lookup_error[:error_message],
169
+ suggestions: @model_lookup_error[:suggestions].map { |s| { id: s.id, name: s.name, context_window: s.context_window } },
170
+ )
171
+ end
172
+
173
+ # Mark tools as immutable (cannot be removed by dynamic tool swapping)
174
+ #
175
+ # Called by plugins during on_agent_initialized lifecycle hook to mark
176
+ # their tools as immutable. This allows plugins to protect their core
177
+ # tools from being removed by dynamic tool swapping operations.
178
+ #
179
+ # @param tool_names [Array<String>] Tool names to mark as immutable
180
+ # @return [void]
181
+ def mark_tools_immutable(*tool_names)
182
+ @immutable_tool_names.merge(tool_names.flatten.map(&:to_s))
183
+ end
184
+
185
+ # Remove all mutable tools (keeps immutable tools)
186
+ #
187
+ # Used by LoadSkill to swap tools. Only works if called from a tool
188
+ # that has been given access to the chat instance.
189
+ #
190
+ # @return [void]
191
+ def remove_mutable_tools
192
+ @tools.select! { |tool| @immutable_tool_names.include?(tool.name) }
193
+ end
194
+
195
+ # Add a tool instance dynamically
196
+ #
197
+ # Used by LoadSkill to add skill-required tools after removing mutable tools.
198
+ # This is just a convenience wrapper around with_tool.
199
+ #
200
+ # @param tool_instance [RubyLLM::Tool] Tool to add
201
+ # @return [void]
202
+ def add_tool(tool_instance)
203
+ with_tool(tool_instance)
204
+ end
205
+
206
+ # Mark skill as loaded (tracking for debugging/logging)
207
+ #
208
+ # Called by LoadSkill after successfully swapping tools.
209
+ # This can be used for logging or debugging purposes.
210
+ #
211
+ # @param file_path [String] Path to loaded skill
212
+ # @return [void]
213
+ def mark_skill_loaded(file_path)
214
+ @active_skill_path = file_path
215
+ end
216
+
217
+ # Check if a skill is currently loaded
218
+ #
219
+ # @return [Boolean] True if a skill has been loaded
220
+ def skill_loaded?
221
+ !@active_skill_path.nil?
222
+ end
223
+
224
+ # Override ask to inject system reminders and periodic TodoWrite reminders
225
+ #
226
+ # Note: This is called BEFORE HookIntegration#ask (due to module include order),
227
+ # so HookIntegration will wrap this and inject user_prompt hooks.
228
+ #
229
+ # @param prompt [String] User prompt
230
+ # @param options [Hash] Additional options to pass to complete
231
+ # @return [RubyLLM::Message] LLM response
232
+ def ask(prompt, **options)
233
+ # Check if this is the first user message
234
+ is_first = SystemReminderInjector.first_message?(self)
235
+
236
+ if is_first
237
+ # Collect plugin reminders first
238
+ plugin_reminders = collect_plugin_reminders(prompt, is_first_message: true)
239
+
240
+ # Build full prompt with embedded plugin reminders
241
+ full_prompt = prompt
242
+ plugin_reminders.each do |reminder|
243
+ full_prompt = "#{full_prompt}\n\n#{reminder}"
244
+ end
245
+
246
+ # Inject first message reminders (includes system reminders + toolset + after)
247
+ # SystemReminderInjector will embed all reminders in the prompt via add_message
248
+ SystemReminderInjector.inject_first_message_reminders(self, full_prompt)
249
+
250
+ # Trigger user_prompt hook manually since we're bypassing the normal ask flow
251
+ if @hook_executor
252
+ hook_result = trigger_user_prompt(prompt)
253
+
254
+ # Check if hook halted execution
255
+ if hook_result[:halted]
256
+ # Return a halted message instead of calling LLM
257
+ return RubyLLM::Message.new(
258
+ role: :assistant,
259
+ content: hook_result[:halt_message],
260
+ model_id: model.id,
261
+ )
262
+ end
263
+
264
+ # NOTE: We ignore modified_prompt for first message since reminders already injected
265
+ end
266
+
267
+ # Call complete to get LLM response
268
+ complete(**options)
269
+ else
270
+ # Build prompt with embedded reminders (if needed)
271
+ full_prompt = prompt
272
+
273
+ # Add periodic TodoWrite reminder if needed
274
+ if SystemReminderInjector.should_inject_todowrite_reminder?(self, @last_todowrite_message_index)
275
+ full_prompt = "#{full_prompt}\n\n#{SystemReminderInjector::TODOWRITE_PERIODIC_REMINDER}"
276
+ # Update tracking
277
+ @last_todowrite_message_index = SystemReminderInjector.find_last_todowrite_index(self)
278
+ end
279
+
280
+ # Collect plugin reminders and embed them
281
+ plugin_reminders = collect_plugin_reminders(full_prompt, is_first_message: false)
282
+ plugin_reminders.each do |reminder|
283
+ full_prompt = "#{full_prompt}\n\n#{reminder}"
284
+ end
285
+
286
+ # Normal ask behavior for subsequent messages
287
+ # This calls super which goes to HookIntegration's ask override
288
+ # HookIntegration will call add_message, and we'll extract reminders there
289
+ super(full_prompt, **options)
290
+ end
291
+ end
292
+
293
+ # Override add_message to automatically extract and strip system reminders
294
+ #
295
+ # System reminders are extracted and tracked as ephemeral content (embedded
296
+ # when sent to LLM but not persisted in conversation history).
297
+ #
298
+ # @param message_or_attributes [RubyLLM::Message, Hash] Message object or attributes hash
299
+ # @return [RubyLLM::Message] The added message (with clean content)
300
+ def add_message(message_or_attributes)
301
+ # Handle both forms: add_message(message) and add_message({role: :user, content: "text"})
302
+ if message_or_attributes.is_a?(RubyLLM::Message)
303
+ # Message object provided
304
+ msg = message_or_attributes
305
+ content_str = msg.content.is_a?(RubyLLM::Content) ? msg.content.text : msg.content.to_s
306
+
307
+ # Extract system reminders
308
+ if @context_manager.has_system_reminders?(content_str)
309
+ reminders = @context_manager.extract_system_reminders(content_str)
310
+ clean_content_str = @context_manager.strip_system_reminders(content_str)
311
+
312
+ clean_content = if msg.content.is_a?(RubyLLM::Content)
313
+ RubyLLM::Content.new(clean_content_str, msg.content.attachments)
314
+ else
315
+ clean_content_str
316
+ end
317
+
318
+ clean_message = RubyLLM::Message.new(
319
+ role: msg.role,
320
+ content: clean_content,
321
+ tool_call_id: msg.tool_call_id,
322
+ )
323
+
324
+ result = super(clean_message)
325
+
326
+ # Track reminders as ephemeral
327
+ reminders.each do |reminder|
328
+ @context_manager.add_ephemeral_reminder(reminder, messages_array: @messages)
329
+ end
330
+
331
+ result
332
+ else
333
+ # No reminders - call parent normally
334
+ super(msg)
335
+ end
336
+ else
337
+ # Hash attributes provided
338
+ attrs = message_or_attributes
339
+ content_value = attrs[:content] || attrs["content"]
340
+ content_str = content_value.is_a?(RubyLLM::Content) ? content_value.text : content_value.to_s
341
+
342
+ # Extract system reminders
343
+ if @context_manager.has_system_reminders?(content_str)
344
+ reminders = @context_manager.extract_system_reminders(content_str)
345
+ clean_content_str = @context_manager.strip_system_reminders(content_str)
346
+
347
+ clean_content = if content_value.is_a?(RubyLLM::Content)
348
+ RubyLLM::Content.new(clean_content_str, content_value.attachments)
349
+ else
350
+ clean_content_str
351
+ end
352
+
353
+ clean_attrs = attrs.merge(content: clean_content)
354
+ result = super(clean_attrs)
355
+
356
+ # Track reminders as ephemeral
357
+ reminders.each do |reminder|
358
+ @context_manager.add_ephemeral_reminder(reminder, messages_array: @messages)
359
+ end
360
+
361
+ result
362
+ else
363
+ # No reminders - call parent normally
364
+ super(attrs)
365
+ end
366
+ end
367
+ end
368
+
369
+ # Collect reminders from all plugins
370
+ #
371
+ # Plugins can contribute system reminders based on the user's message.
372
+ # Returns array of reminder strings to be embedded in the user prompt.
373
+ #
374
+ # @param prompt [String] User's message
375
+ # @param is_first_message [Boolean] True if first message
376
+ # @return [Array<String>] Array of reminder strings
377
+ def collect_plugin_reminders(prompt, is_first_message:)
378
+ return [] unless @agent_name # Skip if agent_name not set
379
+
380
+ # Collect reminders from all plugins
381
+ PluginRegistry.all.flat_map do |plugin|
382
+ plugin.on_user_message(
383
+ agent_name: @agent_name,
384
+ prompt: prompt,
385
+ is_first_message: is_first_message,
386
+ )
387
+ end.compact
388
+ end
389
+
390
+ # Override complete() to inject ephemeral messages
391
+ #
392
+ # Ephemeral messages are sent to the LLM for the current turn only
393
+ # and are NOT stored in the conversation history. This prevents
394
+ # system reminders from accumulating and being resent every turn.
395
+ #
396
+ # @param options [Hash] Options to pass to provider
397
+ # @return [RubyLLM::Message] LLM response
398
+ def complete(**options, &block)
399
+ # Prepare messages: persistent + ephemeral for this turn
400
+ messages_for_llm = @context_manager.prepare_for_llm(@messages)
401
+
402
+ # Call provider with retry logic for transient failures
403
+ response = call_llm_with_retry do
404
+ @provider.complete(
405
+ messages_for_llm,
406
+ tools: @tools,
407
+ temperature: @temperature,
408
+ model: @model,
409
+ params: @params,
410
+ headers: @headers,
411
+ schema: @schema,
412
+ &wrap_streaming_block(&block)
413
+ )
414
+ end
415
+
416
+ # Handle nil response from provider (malformed API response)
417
+ if response.nil?
418
+ raise RubyLLM::Error, "Provider returned nil response. This usually indicates a malformed API response " \
419
+ "that couldn't be parsed.\n\n" \
420
+ "Provider: #{@provider.class.name}\n" \
421
+ "API Base: #{@provider.api_base}\n" \
422
+ "Model: #{@model.id}\n" \
423
+ "Response: #{response.inspect}\n\n" \
424
+ "The API endpoint returned a response that couldn't be parsed into a valid Message object. " \
425
+ "Enable RubyLLM debug logging (RubyLLM.logger.level = Logger::DEBUG) to see the raw API response."
426
+ end
427
+
428
+ @on[:new_message]&.call unless block
429
+
430
+ # Handle schema parsing if needed
431
+ if @schema && response.content.is_a?(String)
432
+ begin
433
+ response.content = JSON.parse(response.content)
434
+ rescue JSON::ParserError
435
+ # Keep as string if parsing fails
436
+ end
437
+ end
438
+
439
+ # Add response to persistent history
440
+ add_message(response)
441
+ @on[:end_message]&.call(response)
442
+
443
+ # Clear ephemeral messages after use
444
+ @context_manager.clear_ephemeral
445
+
446
+ # Handle tool calls if present
447
+ if response.tool_call?
448
+ handle_tool_calls(response, &block)
449
+ else
450
+ response
451
+ end
452
+ end
453
+
454
+ # Override handle_tool_calls to execute multiple tool calls in parallel with rate limiting.
455
+ #
456
+ # RubyLLM's default implementation executes tool calls one at a time. This
457
+ # override uses Async to execute all tool calls concurrently, with semaphores
458
+ # to prevent API quota exhaustion. Hooks are integrated via HookIntegration module.
459
+ #
460
+ # @param response [RubyLLM::Message] LLM response with tool calls
461
+ # @param block [Proc] Optional block passed through to complete
462
+ # @return [RubyLLM::Message] Final response when loop completes
463
+ def handle_tool_calls(response, &block)
464
+ # Single tool call: sequential execution with hooks
465
+ if response.tool_calls.size == 1
466
+ tool_call = response.tool_calls.values.first
467
+
468
+ # Handle pre_tool_use hook (skip for delegation tools)
469
+ unless delegation_tool_call?(tool_call)
470
+ # Trigger pre_tool_use hook (can block or provide custom result)
471
+ pre_result = trigger_pre_tool_use(tool_call)
472
+
473
+ # Handle finish_agent marker
474
+ if pre_result[:finish_agent]
475
+ message = RubyLLM::Message.new(
476
+ role: :assistant,
477
+ content: pre_result[:custom_result],
478
+ model_id: model.id,
479
+ )
480
+ # Set custom finish reason before triggering on_end_message
481
+ @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
482
+ # Trigger on_end_message to ensure agent_stop event is emitted
483
+ @on[:end_message]&.call(message)
484
+ return message
485
+ end
486
+
487
+ # Handle finish_swarm marker
488
+ if pre_result[:finish_swarm]
489
+ return { __finish_swarm__: true, message: pre_result[:custom_result] }
490
+ end
491
+
492
+ # Handle blocked execution
493
+ unless pre_result[:proceed]
494
+ content = pre_result[:custom_result] || "Tool execution blocked by hook"
495
+ message = add_message(
496
+ role: :tool,
497
+ content: content,
498
+ tool_call_id: tool_call.id,
499
+ )
500
+ @on[:end_message]&.call(message)
501
+ return complete(&block)
502
+ end
503
+ end
504
+
505
+ # Execute tool
506
+ @on[:tool_call]&.call(tool_call)
507
+
508
+ result = execute_tool_with_error_handling(tool_call)
509
+
510
+ @on[:tool_result]&.call(result)
511
+
512
+ # Trigger post_tool_use hook (skip for delegation tools)
513
+ unless delegation_tool_call?(tool_call)
514
+ result = trigger_post_tool_use(result, tool_call: tool_call)
515
+ end
516
+
517
+ # Check for finish markers from hooks
518
+ if result.is_a?(Hash)
519
+ if result[:__finish_agent__]
520
+ # Finish this agent with the provided message
521
+ message = RubyLLM::Message.new(
522
+ role: :assistant,
523
+ content: result[:message],
524
+ model_id: model.id,
525
+ )
526
+ # Set custom finish reason before triggering on_end_message
527
+ @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
528
+ # Trigger on_end_message to ensure agent_stop event is emitted
529
+ @on[:end_message]&.call(message)
530
+ return message
531
+ elsif result[:__finish_swarm__]
532
+ # Propagate finish_swarm marker up (don't add to conversation)
533
+ return result
534
+ end
535
+ end
536
+
537
+ # Check for halt result
538
+ return result if result.is_a?(RubyLLM::Tool::Halt)
539
+
540
+ # Add tool result to conversation
541
+ # add_message automatically extracts reminders and stores them as ephemeral
542
+ content = result.is_a?(RubyLLM::Content) ? result : result.to_s
543
+ message = add_message(
544
+ role: :tool,
545
+ content: content,
546
+ tool_call_id: tool_call.id,
547
+ )
548
+ @on[:end_message]&.call(message)
549
+
550
+ # Continue loop
551
+ return complete(&block)
552
+ end
553
+
554
+ # Multiple tool calls: execute in parallel with rate limiting and hooks
555
+ halt_result = nil
556
+
557
+ results = Async do
558
+ tasks = response.tool_calls.map do |_id, tool_call|
559
+ Async do
560
+ # Acquire semaphores (queues if limit reached)
561
+ acquire_semaphores do
562
+ @on[:tool_call]&.call(tool_call)
563
+
564
+ # Handle pre_tool_use hook (skip for delegation tools)
565
+ unless delegation_tool_call?(tool_call)
566
+ pre_result = trigger_pre_tool_use(tool_call)
567
+
568
+ # Handle finish markers first (early exit)
569
+ # Don't call on_tool_result for finish markers - they're not tool results
570
+ if pre_result[:finish_agent]
571
+ result = { __finish_agent__: true, message: pre_result[:custom_result] }
572
+ next { tool_call: tool_call, result: result, message: nil }
573
+ end
574
+
575
+ if pre_result[:finish_swarm]
576
+ result = { __finish_swarm__: true, message: pre_result[:custom_result] }
577
+ next { tool_call: tool_call, result: result, message: nil }
578
+ end
579
+
580
+ # Handle blocked execution
581
+ unless pre_result[:proceed]
582
+ result = pre_result[:custom_result] || "Tool execution blocked by hook"
583
+ @on[:tool_result]&.call(result)
584
+
585
+ # add_message automatically extracts reminders
586
+ content = result.is_a?(RubyLLM::Content) ? result : result.to_s
587
+ message = add_message(
588
+ role: :tool,
589
+ content: content,
590
+ tool_call_id: tool_call.id,
591
+ )
592
+ @on[:end_message]&.call(message)
593
+
594
+ next { tool_call: tool_call, result: result, message: message }
595
+ end
596
+ end
597
+
598
+ # Execute tool - Faraday yields during HTTP I/O
599
+ result = execute_tool_with_error_handling(tool_call)
600
+
601
+ @on[:tool_result]&.call(result)
602
+
603
+ # Trigger post_tool_use hook (skip for delegation tools)
604
+ unless delegation_tool_call?(tool_call)
605
+ result = trigger_post_tool_use(result, tool_call: tool_call)
606
+ end
607
+
608
+ # Check if result is a finish marker (don't add to conversation)
609
+ if result.is_a?(Hash) && (result[:__finish_agent__] || result[:__finish_swarm__])
610
+ # Finish markers will be detected after parallel execution completes
611
+ { tool_call: tool_call, result: result, message: nil }
612
+ else
613
+ # Add tool result to conversation
614
+ # add_message automatically extracts reminders and stores them as ephemeral
615
+ content = result.is_a?(RubyLLM::Content) ? result : result.to_s
616
+ message = add_message(
617
+ role: :tool,
618
+ content: content,
619
+ tool_call_id: tool_call.id,
620
+ )
621
+ @on[:end_message]&.call(message)
622
+
623
+ # Return result data for collection
624
+ { tool_call: tool_call, result: result, message: message }
625
+ end
626
+ end
627
+ end
628
+ end
629
+
630
+ # Wait for all tasks to complete
631
+ tasks.map(&:wait)
632
+ end.wait
633
+
634
+ # Check for halt and finish results
635
+ results.each do |data|
636
+ result = data[:result]
637
+
638
+ # Check for halt result (from tool execution errors)
639
+ if result.is_a?(RubyLLM::Tool::Halt)
640
+ halt_result = result
641
+ # Continue checking for finish markers below
642
+ end
643
+
644
+ # Check for finish markers (from hooks)
645
+ if result.is_a?(Hash)
646
+ if result[:__finish_agent__]
647
+ message = RubyLLM::Message.new(
648
+ role: :assistant,
649
+ content: result[:message],
650
+ model_id: model.id,
651
+ )
652
+ # Set custom finish reason before triggering on_end_message
653
+ @context_tracker.finish_reason_override = "finish_agent" if @context_tracker
654
+ # Trigger on_end_message to ensure agent_stop event is emitted
655
+ @on[:end_message]&.call(message)
656
+ return message
657
+ elsif result[:__finish_swarm__]
658
+ # Propagate finish_swarm marker up
659
+ return result
660
+ end
661
+ end
662
+ end
663
+
664
+ # Return halt result if we found one (but no finish markers)
665
+ halt_result = results.find { |data| data[:result].is_a?(RubyLLM::Tool::Halt) }&.dig(:result)
666
+
667
+ # Continue automatic loop (recursive call to complete)
668
+ halt_result || complete(&block)
669
+ end
670
+
671
+ # Get the provider instance
672
+ #
673
+ # Exposes the RubyLLM provider instance for configuration.
674
+ # This is needed for setting agent_name and other provider-specific settings.
675
+ #
676
+ # @return [RubyLLM::Provider::Base] Provider instance
677
+ attr_reader :provider, :global_semaphore, :local_semaphore, :real_model_info, :context_tracker, :context_manager
678
+
679
+ # Get context window limit for the current model
680
+ #
681
+ # Priority order:
682
+ # 1. Explicit context_window parameter (user override)
683
+ # 2. Real model info from RubyLLM registry (searched across all providers)
684
+ # 3. Model info from chat (may be nil if assume_model_exists was used)
685
+ #
686
+ # @return [Integer, nil] Maximum context tokens, or nil if not available
687
+ def context_limit
688
+ # Priority 1: Explicit override
689
+ return @explicit_context_window if @explicit_context_window
690
+
691
+ # Priority 2: Real model info from registry (searched across all providers)
692
+ return @real_model_info.context_window if @real_model_info&.context_window
693
+
694
+ # Priority 3: Fall back to model from chat
695
+ model.context_window
696
+ rescue StandardError
697
+ nil
698
+ end
699
+
700
+ # Calculate cumulative input tokens for the conversation
701
+ #
702
+ # The latest assistant message's input_tokens already includes the cumulative
703
+ # total for the entire conversation (all previous messages, system instructions,
704
+ # tool definitions, etc.). We don't sum across messages as that would double-count.
705
+ #
706
+ # @return [Integer] Total input tokens used in conversation
707
+ def cumulative_input_tokens
708
+ # Find the latest assistant message with input_tokens
709
+ messages.reverse.find { |msg| msg.role == :assistant && msg.input_tokens }&.input_tokens || 0
710
+ end
711
+
712
+ # Calculate cumulative output tokens across all assistant messages
713
+ #
714
+ # Unlike input tokens, output tokens are per-response and should be summed.
715
+ #
716
+ # @return [Integer] Total output tokens used in conversation
717
+ def cumulative_output_tokens
718
+ messages.select { |msg| msg.role == :assistant }.sum { |msg| msg.output_tokens || 0 }
719
+ end
720
+
721
+ # Calculate total tokens used (input + output)
722
+ #
723
+ # @return [Integer] Total tokens used in conversation
724
+ def cumulative_total_tokens
725
+ cumulative_input_tokens + cumulative_output_tokens
726
+ end
727
+
728
+ # Calculate percentage of context window used
729
+ #
730
+ # @return [Float] Percentage (0.0 to 100.0), or 0.0 if limit unavailable
731
+ def context_usage_percentage
732
+ limit = context_limit
733
+ return 0.0 if limit.nil? || limit.zero?
734
+
735
+ (cumulative_total_tokens.to_f / limit * 100).round(2)
736
+ end
737
+
738
+ # Calculate remaining tokens in context window
739
+ #
740
+ # @return [Integer, nil] Tokens remaining, or nil if limit unavailable
741
+ def tokens_remaining
742
+ limit = context_limit
743
+ return if limit.nil?
744
+
745
+ limit - cumulative_total_tokens
746
+ end
747
+
748
+ # Compact the conversation history to reduce token usage
749
+ #
750
+ # Uses the Hybrid Production Strategy to intelligently compress the conversation:
751
+ # 1. Tool result pruning - Truncate tool outputs (they're 80%+ of tokens!)
752
+ # 2. Checkpoint creation - LLM-generated summary of conversation chunks
753
+ # 3. Sliding window - Keep recent messages in full detail
754
+ #
755
+ # This is a manual operation - call it when you need to free up context space.
756
+ # The method emits compression events via LogStream for monitoring.
757
+ #
758
+ # ## Usage
759
+ #
760
+ # # Use defaults
761
+ # metrics = agent.compact_context
762
+ # puts metrics.summary
763
+ #
764
+ # # With custom options
765
+ # metrics = agent.compact_context(
766
+ # tool_result_max_length: 300,
767
+ # checkpoint_threshold: 40,
768
+ # sliding_window_size: 15
769
+ # )
770
+ #
771
+ # @param options [Hash] Compression options (see ContextCompactor::DEFAULT_OPTIONS)
772
+ # @return [ContextCompactor::Metrics] Compression statistics
773
+ def compact_context(**options)
774
+ compactor = ContextCompactor.new(self, options)
775
+ compactor.compact
776
+ end
777
+
778
+ private
779
+
780
+ # Call LLM with retry logic for transient failures
781
+ #
782
+ # Retries up to 10 times with fixed 10-second delays for:
783
+ # - Network errors
784
+ # - Proxy failures
785
+ # - Transient API errors
786
+ #
787
+ # @yield Block that makes the LLM call
788
+ # @return [RubyLLM::Message] LLM response
789
+ # @raise [StandardError] If all retries exhausted
790
+ def call_llm_with_retry(max_retries: 10, delay: 10, &block)
791
+ attempts = 0
792
+
793
+ loop do
794
+ attempts += 1
795
+
796
+ begin
797
+ return yield
798
+ rescue StandardError => e
799
+ # Check if we should retry
800
+ if attempts >= max_retries
801
+ # Emit final failure log
802
+ LogStream.emit(
803
+ type: "llm_retry_exhausted",
804
+ agent: @agent_name,
805
+ model: @model&.id,
806
+ attempts: attempts,
807
+ error_class: e.class.name,
808
+ error_message: e.message,
809
+ )
810
+ raise
811
+ end
812
+
813
+ # Emit retry attempt log
814
+ LogStream.emit(
815
+ type: "llm_retry_attempt",
816
+ agent: @agent_name,
817
+ model: @model&.id,
818
+ attempt: attempts,
819
+ max_retries: max_retries,
820
+ error_class: e.class.name,
821
+ error_message: e.message,
822
+ retry_delay: delay,
823
+ )
824
+
825
+ # Wait before retry
826
+ sleep(delay)
827
+ end
828
+ end
829
+ end
830
+
831
+ # Build custom RubyLLM context for base_url/timeout overrides
832
+ #
833
+ # @param provider [String, Symbol] Provider name
834
+ # @param base_url [String, nil] Custom API base URL
835
+ # @param timeout [Integer] Request timeout in seconds
836
+ # @return [RubyLLM::Context] Configured context
837
+ def build_custom_context(provider:, base_url:, timeout:)
838
+ RubyLLM.context do |config|
839
+ # Set timeout for all providers
840
+ config.request_timeout = timeout
841
+
842
+ # Configure base_url if specified
843
+ next unless base_url
844
+
845
+ case provider.to_s
846
+ when "openai", "deepseek", "perplexity", "mistral", "openrouter"
847
+ config.openai_api_base = base_url
848
+ config.openai_api_key = ENV["OPENAI_API_KEY"] || "dummy-key-for-local"
849
+ # Use standard 'system' role instead of 'developer' for OpenAI-compatible proxies
850
+ # Most proxies don't support OpenAI's newer 'developer' role convention
851
+ config.openai_use_system_role = true
852
+ when "ollama"
853
+ config.ollama_api_base = base_url
854
+ when "gpustack"
855
+ config.gpustack_api_base = base_url
856
+ config.gpustack_api_key = ENV["GPUSTACK_API_KEY"] || "dummy-key"
857
+ else
858
+ raise ArgumentError,
859
+ "Provider '#{provider}' doesn't support custom base_url. " \
860
+ "Only OpenAI-compatible providers (openai, deepseek, perplexity, mistral, openrouter), " \
861
+ "ollama, and gpustack support custom endpoints."
862
+ end
863
+ end
864
+ end
865
+
866
+ # Fetch real model info for accurate context tracking
867
+ #
868
+ # This searches across ALL providers, so it works even when using proxies
869
+ # (e.g., Claude model through OpenAI-compatible proxy).
870
+ #
871
+ # @param model [String] Model ID to lookup
872
+ # @return [void]
873
+ def fetch_real_model_info(model)
874
+ @model_lookup_error = nil
875
+ @real_model_info = begin
876
+ RubyLLM.models.find(model) # Searches all providers when no provider specified
877
+ rescue StandardError => e
878
+ # Store warning info to emit later through LogStream
879
+ suggestions = suggest_similar_models(model)
880
+ @model_lookup_error = {
881
+ model: model,
882
+ error_message: e.message,
883
+ suggestions: suggestions,
884
+ }
885
+ nil
886
+ end
887
+ end
888
+
889
+ # Determine which provider to use based on configuration
890
+ #
891
+ # When using base_url with OpenAI-compatible providers and api_version is set to
892
+ # 'v1/responses', use our custom provider that supports the responses API endpoint.
893
+ #
894
+ # @param provider [Symbol, String] The requested provider
895
+ # @param base_url [String, nil] Custom base URL
896
+ # @param api_version [String, nil] API endpoint version
897
+ # @return [Symbol] The provider to use
898
+ def determine_provider(provider, base_url, api_version)
899
+ return provider unless base_url
900
+
901
+ # Use custom provider for OpenAI-compatible providers when api_version is v1/responses
902
+ # The custom provider supports both chat/completions and responses endpoints
903
+ case provider.to_s
904
+ when "openai", "deepseek", "perplexity", "mistral", "openrouter"
905
+ if api_version == "v1/responses"
906
+ :openai_with_responses
907
+ else
908
+ provider
909
+ end
910
+ else
911
+ provider
912
+ end
913
+ end
914
+
915
+ # Configure the custom provider after creation to use responses API
916
+ #
917
+ # RubyLLM doesn't support passing custom parameters to provider initialization,
918
+ # so we configure the provider after the chat is created.
919
+ def configure_responses_api_provider
920
+ return unless provider.is_a?(SwarmSDK::Providers::OpenAIWithResponses)
921
+
922
+ provider.use_responses_api = true
923
+ RubyLLM.logger.debug("SwarmSDK: Configured provider to use responses API")
924
+ end
925
+
926
+ # Configure LLM parameters with proper temperature normalization
927
+ #
928
+ # Note: RubyLLM only normalizes temperature (for models that require specific values
929
+ # like gpt-5-mini which requires temperature=1.0) when using with_temperature().
930
+ # The with_params() method is designed for sending unparsed parameters directly to
931
+ # the LLM without provider-specific normalization. Therefore, we extract temperature
932
+ # and call with_temperature() separately to ensure proper normalization.
933
+ #
934
+ # @param params [Hash] Parameter hash (may include temperature and other params)
935
+ # @return [self] Returns self for method chaining
936
+ def configure_parameters(params)
937
+ return self if params.nil? || params.empty?
938
+
939
+ # Extract temperature for separate handling
940
+ if params[:temperature]
941
+ with_temperature(params[:temperature])
942
+ params = params.except(:temperature)
943
+ end
944
+
945
+ # Apply remaining parameters
946
+ with_params(**params) if params.any?
947
+
948
+ self
949
+ end
950
+
951
+ # Configure custom HTTP headers for LLM requests
952
+ #
953
+ # @param headers [Hash, nil] Custom HTTP headers
954
+ # @return [self] Returns self for method chaining
955
+ def configure_headers(headers)
956
+ return self if headers.nil? || headers.empty?
957
+
958
+ with_headers(**headers)
959
+
960
+ self
961
+ end
962
+
963
+ # Acquire both global and local semaphores (if configured).
964
+ #
965
+ # Semaphores queue requests when limits are reached, ensuring graceful
966
+ # degradation instead of API errors.
967
+ #
968
+ # Order matters: acquire global first (broader scope), then local
969
+ def acquire_semaphores(&block)
970
+ if @global_semaphore && @local_semaphore
971
+ # Both limits: acquire global first, then local
972
+ @global_semaphore.acquire do
973
+ @local_semaphore.acquire(&block)
974
+ end
975
+ elsif @global_semaphore
976
+ # Only global limit
977
+ @global_semaphore.acquire(&block)
978
+ elsif @local_semaphore
979
+ # Only local limit
980
+ @local_semaphore.acquire(&block)
981
+ else
982
+ # No limits: execute immediately
983
+ yield
984
+ end
985
+ end
986
+
987
+ # Suggest similar models when a model is not found
988
+ #
989
+ # @param query [String] Model name to search for
990
+ # @return [Array<RubyLLM::Model::Info>] Up to 3 similar models
991
+ def suggest_similar_models(query)
992
+ normalized_query = query.to_s.downcase.gsub(/[.\-_]/, "")
993
+
994
+ RubyLLM.models.all.select do |model|
995
+ normalized_id = model.id.downcase.gsub(/[.\-_]/, "")
996
+ normalized_id.include?(normalized_query) ||
997
+ model.name&.downcase&.gsub(/[.\-_]/, "")&.include?(normalized_query)
998
+ end.first(3)
999
+ rescue StandardError
1000
+ []
1001
+ end
1002
+
1003
+ # Execute a tool with error handling for common issues
1004
+ #
1005
+ # Handles:
1006
+ # - Missing required parameters (validated before calling)
1007
+ # - Tool doesn't exist (nil.call)
1008
+ # - Other ArgumentErrors (from tool execution)
1009
+ #
1010
+ # Returns helpful messages with system reminders showing available tools
1011
+ # or required parameters.
1012
+ #
1013
+ # @param tool_call [RubyLLM::ToolCall] Tool call from LLM
1014
+ # @return [String, Object] Tool result or error message
1015
+ def execute_tool_with_error_handling(tool_call)
1016
+ tool_name = tool_call.name
1017
+ tool_instance = tools[tool_name.to_sym]
1018
+
1019
+ # Check if tool exists
1020
+ unless tool_instance
1021
+ return build_tool_not_found_error(tool_call)
1022
+ end
1023
+
1024
+ # Validate required parameters BEFORE calling the tool
1025
+ validation_error = validate_tool_parameters(tool_call, tool_instance)
1026
+ return validation_error if validation_error
1027
+
1028
+ # Execute the tool
1029
+ execute_tool(tool_call)
1030
+ rescue ArgumentError => e
1031
+ # This is an ArgumentError from INSIDE the tool execution (not missing params)
1032
+ # Still try to provide helpful error message
1033
+ build_argument_error(tool_call, e)
1034
+ end
1035
+
1036
+ # Validate that all required tool parameters are present
1037
+ #
1038
+ # @param tool_call [RubyLLM::ToolCall] Tool call from LLM
1039
+ # @param tool_instance [RubyLLM::Tool] Tool instance
1040
+ # @return [String, nil] Error message if validation fails, nil if valid
1041
+ def validate_tool_parameters(tool_call, tool_instance)
1042
+ return unless tool_instance.respond_to?(:parameters)
1043
+
1044
+ # Get required parameters from tool definition
1045
+ required_params = tool_instance.parameters.select { |_, param| param.required }
1046
+
1047
+ # Check which required parameters are missing from the tool call
1048
+ # ToolCall stores arguments in tool_call.arguments (not .parameters)
1049
+ missing_params = required_params.reject do |param_name, _param|
1050
+ tool_call.arguments.key?(param_name.to_s) || tool_call.arguments.key?(param_name.to_sym)
1051
+ end
1052
+
1053
+ return if missing_params.empty?
1054
+
1055
+ # Build missing parameter error
1056
+ build_missing_parameters_error(tool_call, tool_instance, missing_params.keys)
1057
+ end
1058
+
1059
+ # Build error message for missing required parameters
1060
+ #
1061
+ # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1062
+ # @param tool_instance [RubyLLM::Tool] Tool instance
1063
+ # @param missing_param_names [Array<Symbol>] Names of missing parameters
1064
+ # @return [String] Formatted error message
1065
+ def build_missing_parameters_error(tool_call, tool_instance, missing_param_names)
1066
+ tool_name = tool_call.name
1067
+
1068
+ # Get all parameter information
1069
+ param_info = tool_instance.parameters.map do |_param_name, param_obj|
1070
+ {
1071
+ name: param_obj.name.to_s,
1072
+ type: param_obj.type,
1073
+ description: param_obj.description,
1074
+ required: param_obj.required,
1075
+ }
1076
+ end
1077
+
1078
+ # Format missing parameter names nicely
1079
+ missing_list = missing_param_names.map(&:to_s).join(", ")
1080
+
1081
+ error_message = "Error calling #{tool_name}: missing parameters: #{missing_list}\n\n"
1082
+ error_message += build_parameter_reminder(tool_name, param_info)
1083
+ error_message
1084
+ end
1085
+
1086
+ # Build a helpful error message for ArgumentErrors from tool execution
1087
+ #
1088
+ # This handles ArgumentErrors that come from INSIDE the tool (not our validation).
1089
+ # We still try to be helpful if it looks like a parameter issue.
1090
+ #
1091
+ # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1092
+ # @param error [ArgumentError] The ArgumentError raised
1093
+ # @return [String] Formatted error message
1094
+ def build_argument_error(tool_call, error)
1095
+ tool_name = tool_call.name
1096
+
1097
+ # Just report the error - we already validated parameters, so this is an internal tool error
1098
+ "Error calling #{tool_name}: #{error.message}"
1099
+ end
1100
+
1101
+ # Build system reminder with parameter information
1102
+ #
1103
+ # @param tool_name [String] Tool name
1104
+ # @param param_info [Array<Hash>] Parameter information
1105
+ # @return [String] Formatted parameter reminder
1106
+ def build_parameter_reminder(tool_name, param_info)
1107
+ return "" if param_info.empty?
1108
+
1109
+ required_params = param_info.select { |p| p[:required] }
1110
+ optional_params = param_info.reject { |p| p[:required] }
1111
+
1112
+ reminder = "<system-reminder>\n"
1113
+ reminder += "CRITICAL: The #{tool_name} tool call failed due to missing parameters.\n\n"
1114
+ reminder += "ALL REQUIRED PARAMETERS for #{tool_name}:\n\n"
1115
+
1116
+ required_params.each do |param|
1117
+ reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
1118
+ end
1119
+
1120
+ if optional_params.any?
1121
+ reminder += "\nOptional parameters:\n"
1122
+ optional_params.each do |param|
1123
+ reminder += "- #{param[:name]} (#{param[:type]}): #{param[:description]}\n"
1124
+ end
1125
+ end
1126
+
1127
+ reminder += "\nINSTRUCTIONS FOR RECOVERY:\n"
1128
+ reminder += "1. Use the Think tool to reason about what value EACH required parameter should have\n"
1129
+ reminder += "2. After thinking, retry the #{tool_name} tool call with ALL required parameters included\n"
1130
+ reminder += "3. Do NOT skip any required parameters - the tool will fail again if you do\n"
1131
+ reminder += "</system-reminder>"
1132
+
1133
+ reminder
1134
+ end
1135
+
1136
+ # Build a helpful error message when a tool doesn't exist
1137
+ #
1138
+ # @param tool_call [RubyLLM::ToolCall] Tool call that failed
1139
+ # @return [String] Formatted error message with available tools list
1140
+ def build_tool_not_found_error(tool_call)
1141
+ tool_name = tool_call.name
1142
+ available_tools = tools.keys.map(&:to_s).sort
1143
+
1144
+ error_message = "Error: Tool '#{tool_name}' is not available.\n\n"
1145
+ error_message += "You attempted to use '#{tool_name}', but this tool is not in your current toolset.\n\n"
1146
+
1147
+ error_message += "<system-reminder>\n"
1148
+ error_message += "Your available tools are:\n"
1149
+ available_tools.each do |name|
1150
+ error_message += " - #{name}\n"
1151
+ end
1152
+ error_message += "\nDo NOT attempt to use tools that are not in this list.\n"
1153
+ error_message += "</system-reminder>"
1154
+
1155
+ error_message
1156
+ end
1157
+ end
1158
+ end
1159
+ end