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.
- checksums.yaml +4 -4
- data/.claude/commands/release.md +1 -1
- data/.claude/hooks/lint-code-files.rb +65 -0
- data/.rubocop.yml +22 -2
- data/CHANGELOG.md +14 -1
- data/CLAUDE.md +1 -1
- data/CONTRIBUTING.md +69 -0
- data/README.md +27 -2
- data/Rakefile +71 -3
- data/analyze_coverage.rb +94 -0
- data/docs/v2/CHANGELOG.swarm_cli.md +43 -0
- data/docs/v2/CHANGELOG.swarm_memory.md +379 -0
- data/docs/v2/CHANGELOG.swarm_sdk.md +362 -0
- data/docs/v2/README.md +308 -0
- data/docs/v2/guides/claude-code-agents.md +262 -0
- data/docs/v2/guides/complete-tutorial.md +3088 -0
- data/docs/v2/guides/getting-started.md +1456 -0
- data/docs/v2/guides/memory-adapters.md +998 -0
- data/docs/v2/guides/plugins.md +816 -0
- data/docs/v2/guides/quick-start-cli.md +1745 -0
- data/docs/v2/guides/rails-integration.md +1902 -0
- data/docs/v2/guides/swarm-memory.md +599 -0
- data/docs/v2/reference/cli.md +729 -0
- data/docs/v2/reference/ruby-dsl.md +2154 -0
- data/docs/v2/reference/yaml.md +1835 -0
- data/docs-team-swarm.yml +2222 -0
- data/examples/learning-assistant/assistant.md +7 -0
- data/examples/learning-assistant/example-memories/concept-example.md +90 -0
- data/examples/learning-assistant/example-memories/experience-example.md +66 -0
- data/examples/learning-assistant/example-memories/fact-example.md +76 -0
- data/examples/learning-assistant/example-memories/memory-index.md +78 -0
- data/examples/learning-assistant/example-memories/skill-example.md +168 -0
- data/examples/learning-assistant/learning_assistant.rb +34 -0
- data/examples/learning-assistant/learning_assistant.yml +20 -0
- data/examples/v2/dsl/01_basic.rb +44 -0
- data/examples/v2/dsl/02_core_parameters.rb +59 -0
- data/examples/v2/dsl/03_capabilities.rb +71 -0
- data/examples/v2/dsl/04_llm_parameters.rb +56 -0
- data/examples/v2/dsl/05_advanced_flags.rb +73 -0
- data/examples/v2/dsl/06_permissions.rb +80 -0
- data/examples/v2/dsl/07_mcp_server.rb +62 -0
- data/examples/v2/dsl/08_swarm_hooks.rb +53 -0
- data/examples/v2/dsl/09_agent_hooks.rb +67 -0
- data/examples/v2/dsl/10_all_agents_hooks.rb +67 -0
- data/examples/v2/dsl/11_delegation.rb +60 -0
- data/examples/v2/dsl/12_complete_integration.rb +137 -0
- data/examples/v2/file_tools_swarm.yml +102 -0
- data/examples/v2/hooks/01_basic_hooks.rb +133 -0
- data/examples/v2/hooks/02_usage_tracking.rb +201 -0
- data/examples/v2/hooks/03_production_monitoring.rb +429 -0
- data/examples/v2/hooks/agent_stop_exit_0.yml +21 -0
- data/examples/v2/hooks/agent_stop_exit_1.yml +21 -0
- data/examples/v2/hooks/agent_stop_exit_2.yml +26 -0
- data/examples/v2/hooks/multiple_hooks_all_pass.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_first_fails.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_second_fails.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_warnings.yml +37 -0
- data/examples/v2/hooks/post_tool_use_exit_0.yml +24 -0
- data/examples/v2/hooks/post_tool_use_exit_1.yml +24 -0
- data/examples/v2/hooks/post_tool_use_exit_2.yml +24 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_0.yml +26 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_1.yml +26 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_2.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_exit_0.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_exit_1.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_exit_2.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_0.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_1.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_2.yml +27 -0
- data/examples/v2/hooks/swarm_summary.sh +44 -0
- data/examples/v2/hooks/user_prompt_exit_0.yml +21 -0
- data/examples/v2/hooks/user_prompt_exit_1.yml +21 -0
- data/examples/v2/hooks/user_prompt_exit_2.yml +21 -0
- data/examples/v2/hooks/validate_bash.rb +59 -0
- data/examples/v2/multi_directory_permissions.yml +221 -0
- data/examples/v2/node_context_demo.rb +127 -0
- data/examples/v2/node_workflow.rb +173 -0
- data/examples/v2/path_resolution_demo.rb +216 -0
- data/examples/v2/simple-swarm-v2.rb +90 -0
- data/examples/v2/simple-swarm-v2.yml +62 -0
- data/examples/v2/swarm.yml +71 -0
- data/examples/v2/swarm_with_hooks.yml +61 -0
- data/examples/v2/swarm_with_hooks_simple.yml +25 -0
- data/examples/v2/think_tool_demo.rb +62 -0
- data/exe/swarm +6 -0
- data/lib/claude_swarm/claude_mcp_server.rb +0 -6
- data/lib/claude_swarm/cli.rb +10 -3
- data/lib/claude_swarm/commands/ps.rb +19 -20
- data/lib/claude_swarm/commands/show.rb +1 -1
- data/lib/claude_swarm/configuration.rb +10 -12
- data/lib/claude_swarm/mcp_generator.rb +10 -1
- data/lib/claude_swarm/orchestrator.rb +73 -49
- data/lib/claude_swarm/system_utils.rb +37 -11
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +1 -0
- data/lib/claude_swarm/yaml_loader.rb +22 -0
- data/lib/claude_swarm.rb +7 -2
- data/lib/swarm_cli/cli.rb +201 -0
- data/lib/swarm_cli/command_registry.rb +61 -0
- data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
- data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
- data/lib/swarm_cli/commands/migrate.rb +55 -0
- data/lib/swarm_cli/commands/run.rb +173 -0
- data/lib/swarm_cli/config_loader.rb +97 -0
- data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
- data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
- data/lib/swarm_cli/interactive_repl.rb +918 -0
- data/lib/swarm_cli/mcp_serve_options.rb +44 -0
- data/lib/swarm_cli/mcp_tools_options.rb +59 -0
- data/lib/swarm_cli/migrate_options.rb +54 -0
- data/lib/swarm_cli/migrator.rb +132 -0
- data/lib/swarm_cli/options.rb +151 -0
- data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
- data/lib/swarm_cli/ui/components/content_block.rb +120 -0
- data/lib/swarm_cli/ui/components/divider.rb +57 -0
- data/lib/swarm_cli/ui/components/panel.rb +62 -0
- data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
- data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
- data/lib/swarm_cli/ui/formatters/number.rb +58 -0
- data/lib/swarm_cli/ui/formatters/text.rb +77 -0
- data/lib/swarm_cli/ui/formatters/time.rb +73 -0
- data/lib/swarm_cli/ui/icons.rb +59 -0
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
- data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
- data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
- data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
- data/lib/swarm_cli/version.rb +5 -0
- data/lib/swarm_cli.rb +44 -0
- data/lib/swarm_memory/adapters/base.rb +141 -0
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +845 -0
- data/lib/swarm_memory/chat_extension.rb +34 -0
- data/lib/swarm_memory/cli/commands.rb +306 -0
- data/lib/swarm_memory/core/entry.rb +37 -0
- data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
- data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
- data/lib/swarm_memory/core/path_normalizer.rb +75 -0
- data/lib/swarm_memory/core/semantic_index.rb +244 -0
- data/lib/swarm_memory/core/storage.rb +288 -0
- data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
- data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
- data/lib/swarm_memory/dsl/memory_config.rb +113 -0
- data/lib/swarm_memory/embeddings/embedder.rb +36 -0
- data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
- data/lib/swarm_memory/errors.rb +21 -0
- data/lib/swarm_memory/integration/cli_registration.rb +30 -0
- data/lib/swarm_memory/integration/configuration.rb +43 -0
- data/lib/swarm_memory/integration/registration.rb +31 -0
- data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
- data/lib/swarm_memory/optimization/analyzer.rb +244 -0
- data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
- data/lib/swarm_memory/prompts/memory.md.erb +109 -0
- data/lib/swarm_memory/prompts/memory_assistant.md.erb +181 -0
- data/lib/swarm_memory/prompts/memory_researcher.md.erb +281 -0
- data/lib/swarm_memory/prompts/memory_retrieval.md.erb +78 -0
- data/lib/swarm_memory/search/semantic_search.rb +112 -0
- data/lib/swarm_memory/search/text_search.rb +42 -0
- data/lib/swarm_memory/search/text_similarity.rb +80 -0
- data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
- data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
- data/lib/swarm_memory/tools/load_skill.rb +313 -0
- data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
- data/lib/swarm_memory/tools/memory_delete.rb +99 -0
- data/lib/swarm_memory/tools/memory_edit.rb +185 -0
- data/lib/swarm_memory/tools/memory_glob.rb +160 -0
- data/lib/swarm_memory/tools/memory_grep.rb +247 -0
- data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
- data/lib/swarm_memory/tools/memory_read.rb +123 -0
- data/lib/swarm_memory/tools/memory_write.rb +231 -0
- data/lib/swarm_memory/utils.rb +50 -0
- data/lib/swarm_memory/version.rb +5 -0
- data/lib/swarm_memory.rb +166 -0
- data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
- data/lib/swarm_sdk/agent/builder.rb +461 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
- data/lib/swarm_sdk/agent/chat.rb +1159 -0
- data/lib/swarm_sdk/agent/context.rb +112 -0
- data/lib/swarm_sdk/agent/context_manager.rb +309 -0
- data/lib/swarm_sdk/agent/definition.rb +556 -0
- data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
- data/lib/swarm_sdk/configuration.rb +296 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +197 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +147 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +51 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +75 -0
- data/lib/swarm_sdk/model_aliases.json +5 -0
- data/lib/swarm_sdk/models.json +1 -0
- data/lib/swarm_sdk/models.rb +120 -0
- data/lib/swarm_sdk/node/agent_config.rb +49 -0
- data/lib/swarm_sdk/node/builder.rb +439 -0
- data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
- data/lib/swarm_sdk/node_context.rb +170 -0
- data/lib/swarm_sdk/node_orchestrator.rb +384 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/plugin.rb +147 -0
- data/lib/swarm_sdk/plugin_registry.rb +101 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
- data/lib/swarm_sdk/swarm/builder.rb +586 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +419 -0
- data/lib/swarm_sdk/swarm.rb +982 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/clock.rb +44 -0
- data/lib/swarm_sdk/tools/delegate.rb +164 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +228 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +93 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
- data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/think.rb +95 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +157 -0
- data/llm.v2.txt +13407 -0
- data/rubocop/cop/security/no_reflection_methods.rb +47 -0
- data/rubocop/cop/security/no_ruby_llm_logger.rb +32 -0
- data/swarm_cli.gemspec +57 -0
- data/swarm_memory.gemspec +28 -0
- data/swarm_sdk.gemspec +41 -0
- data/team.yml +1 -1
- data/team_full.yml +1875 -0
- data/{team_v2.yml → team_sdk.yml} +121 -52
- metadata +249 -6
- 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
|