agentic 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/.agentic.yml +2 -0
  3. data/.architecture/decisions/ArchitecturalFeatureBuilder.md +136 -0
  4. data/.architecture/decisions/ArchitectureConsiderations.md +200 -0
  5. data/.architecture/decisions/adr_001_observer_pattern_implementation.md +196 -0
  6. data/.architecture/decisions/adr_002_plan_orchestrator.md +320 -0
  7. data/.architecture/decisions/adr_003_plan_orchestrator_interface.md +179 -0
  8. data/.architecture/decisions/adrs/ADR-001-dependency-management.md +147 -0
  9. data/.architecture/decisions/adrs/ADR-002-system-boundaries.md +162 -0
  10. data/.architecture/decisions/adrs/ADR-003-content-safety.md +158 -0
  11. data/.architecture/decisions/adrs/ADR-004-agent-permissions.md +161 -0
  12. data/.architecture/decisions/adrs/ADR-005-adaptation-engine.md +127 -0
  13. data/.architecture/decisions/adrs/ADR-006-extension-system.md +273 -0
  14. data/.architecture/decisions/adrs/ADR-007-learning-system.md +156 -0
  15. data/.architecture/decisions/adrs/ADR-008-prompt-generation.md +325 -0
  16. data/.architecture/decisions/adrs/ADR-009-task-failure-handling.md +353 -0
  17. data/.architecture/decisions/adrs/ADR-010-task-input-handling.md +251 -0
  18. data/.architecture/decisions/adrs/ADR-011-task-observable-pattern.md +391 -0
  19. data/.architecture/decisions/adrs/ADR-012-task-output-handling.md +205 -0
  20. data/.architecture/decisions/adrs/ADR-013-architecture-alignment.md +211 -0
  21. data/.architecture/decisions/adrs/ADR-014-agent-capability-registry.md +80 -0
  22. data/.architecture/decisions/adrs/ADR-015-persistent-agent-store.md +100 -0
  23. data/.architecture/decisions/adrs/ADR-016-agent-assembly-engine.md +117 -0
  24. data/.architecture/decisions/adrs/ADR-017-streaming-observability.md +171 -0
  25. data/.architecture/decisions/capability_tools_distinction.md +150 -0
  26. data/.architecture/decisions/cli_command_structure.md +61 -0
  27. data/.architecture/implementation/agent_self_assembly_implementation.md +267 -0
  28. data/.architecture/implementation/agent_self_assembly_summary.md +138 -0
  29. data/.architecture/members.yml +187 -0
  30. data/.architecture/planning/self_implementation_exercise.md +295 -0
  31. data/.architecture/planning/session_compaction_rule.md +43 -0
  32. data/.architecture/planning/streaming_observability_feature.md +223 -0
  33. data/.architecture/principles.md +151 -0
  34. data/.architecture/recalibration/0-2-0.md +92 -0
  35. data/.architecture/recalibration/agent_self_assembly.md +238 -0
  36. data/.architecture/recalibration/cli_command_structure.md +91 -0
  37. data/.architecture/recalibration/implementation_roadmap_0-2-0.md +301 -0
  38. data/.architecture/recalibration/progress_tracking_0-2-0.md +114 -0
  39. data/.architecture/recalibration_process.md +127 -0
  40. data/.architecture/reviews/0-2-0.md +181 -0
  41. data/.architecture/reviews/cli_command_duplication.md +98 -0
  42. data/.architecture/templates/adr.md +105 -0
  43. data/.architecture/templates/implementation_roadmap.md +125 -0
  44. data/.architecture/templates/progress_tracking.md +89 -0
  45. data/.architecture/templates/recalibration_plan.md +70 -0
  46. data/.architecture/templates/version_comparison.md +124 -0
  47. data/.claude/settings.local.json +13 -0
  48. data/.claude-sessions/001-task-class-architecture-implementation.md +129 -0
  49. data/.claude-sessions/002-plan-orchestrator-interface-review.md +105 -0
  50. data/.claude-sessions/architecture-governance-implementation.md +37 -0
  51. data/.claude-sessions/architecture-review-session.md +27 -0
  52. data/ArchitecturalFeatureBuilder.md +136 -0
  53. data/ArchitectureConsiderations.md +229 -0
  54. data/CHANGELOG.md +57 -2
  55. data/CLAUDE.md +111 -0
  56. data/CONTRIBUTING.md +286 -0
  57. data/MAINTAINING.md +301 -0
  58. data/README.md +582 -28
  59. data/docs/agent_capabilities_api.md +259 -0
  60. data/docs/artifact_extension_points.md +757 -0
  61. data/docs/artifact_generation_architecture.md +323 -0
  62. data/docs/artifact_implementation_plan.md +596 -0
  63. data/docs/artifact_integration_points.md +345 -0
  64. data/docs/artifact_verification_strategies.md +581 -0
  65. data/docs/streaming_observability_architecture.md +510 -0
  66. data/exe/agentic +6 -1
  67. data/lefthook.yml +5 -0
  68. data/lib/agentic/adaptation_engine.rb +124 -0
  69. data/lib/agentic/agent.rb +181 -4
  70. data/lib/agentic/agent_assembly_engine.rb +442 -0
  71. data/lib/agentic/agent_capability_registry.rb +260 -0
  72. data/lib/agentic/agent_config.rb +63 -0
  73. data/lib/agentic/agent_specification.rb +46 -0
  74. data/lib/agentic/capabilities/examples.rb +530 -0
  75. data/lib/agentic/capabilities.rb +14 -0
  76. data/lib/agentic/capability_provider.rb +146 -0
  77. data/lib/agentic/capability_specification.rb +118 -0
  78. data/lib/agentic/cli/agent.rb +31 -0
  79. data/lib/agentic/cli/capabilities.rb +191 -0
  80. data/lib/agentic/cli/config.rb +134 -0
  81. data/lib/agentic/cli/execution_observer.rb +796 -0
  82. data/lib/agentic/cli.rb +1068 -0
  83. data/lib/agentic/default_agent_provider.rb +35 -0
  84. data/lib/agentic/errors/llm_error.rb +184 -0
  85. data/lib/agentic/execution_plan.rb +53 -0
  86. data/lib/agentic/execution_result.rb +91 -0
  87. data/lib/agentic/expected_answer_format.rb +46 -0
  88. data/lib/agentic/extension/domain_adapter.rb +109 -0
  89. data/lib/agentic/extension/plugin_manager.rb +163 -0
  90. data/lib/agentic/extension/protocol_handler.rb +116 -0
  91. data/lib/agentic/extension.rb +45 -0
  92. data/lib/agentic/factory_methods.rb +9 -1
  93. data/lib/agentic/generation_stats.rb +61 -0
  94. data/lib/agentic/learning/README.md +84 -0
  95. data/lib/agentic/learning/capability_optimizer.rb +613 -0
  96. data/lib/agentic/learning/execution_history_store.rb +251 -0
  97. data/lib/agentic/learning/pattern_recognizer.rb +500 -0
  98. data/lib/agentic/learning/strategy_optimizer.rb +706 -0
  99. data/lib/agentic/learning.rb +131 -0
  100. data/lib/agentic/llm_assisted_composition_strategy.rb +188 -0
  101. data/lib/agentic/llm_client.rb +215 -15
  102. data/lib/agentic/llm_config.rb +65 -1
  103. data/lib/agentic/llm_response.rb +163 -0
  104. data/lib/agentic/logger.rb +1 -1
  105. data/lib/agentic/observable.rb +51 -0
  106. data/lib/agentic/persistent_agent_store.rb +385 -0
  107. data/lib/agentic/plan_execution_result.rb +129 -0
  108. data/lib/agentic/plan_orchestrator.rb +464 -0
  109. data/lib/agentic/plan_orchestrator_config.rb +57 -0
  110. data/lib/agentic/retry_config.rb +63 -0
  111. data/lib/agentic/retry_handler.rb +125 -0
  112. data/lib/agentic/structured_outputs.rb +1 -1
  113. data/lib/agentic/task.rb +193 -0
  114. data/lib/agentic/task_definition.rb +39 -0
  115. data/lib/agentic/task_execution_result.rb +92 -0
  116. data/lib/agentic/task_failure.rb +66 -0
  117. data/lib/agentic/task_output_schemas.rb +112 -0
  118. data/lib/agentic/task_planner.rb +54 -19
  119. data/lib/agentic/task_result.rb +48 -0
  120. data/lib/agentic/ui.rb +244 -0
  121. data/lib/agentic/verification/critic_framework.rb +116 -0
  122. data/lib/agentic/verification/llm_verification_strategy.rb +60 -0
  123. data/lib/agentic/verification/schema_verification_strategy.rb +47 -0
  124. data/lib/agentic/verification/verification_hub.rb +62 -0
  125. data/lib/agentic/verification/verification_result.rb +50 -0
  126. data/lib/agentic/verification/verification_strategy.rb +26 -0
  127. data/lib/agentic/version.rb +1 -1
  128. data/lib/agentic.rb +74 -2
  129. data/plugins/README.md +41 -0
  130. metadata +245 -6
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "learning/execution_history_store"
4
+ require_relative "learning/pattern_recognizer"
5
+ require_relative "learning/strategy_optimizer"
6
+
7
+ module Agentic
8
+ # The Learning module provides components for capturing execution history,
9
+ # recognizing patterns, and optimizing strategies based on feedback and metrics.
10
+ #
11
+ # @example Using the Learning System components
12
+ # # Initialize components
13
+ # history_store = Agentic::Learning::ExecutionHistoryStore.new
14
+ # recognizer = Agentic::Learning::PatternRecognizer.new(history_store: history_store)
15
+ # optimizer = Agentic::Learning::StrategyOptimizer.new(
16
+ # pattern_recognizer: recognizer,
17
+ # history_store: history_store
18
+ # )
19
+ #
20
+ # # Record execution data
21
+ # history_store.record_execution(
22
+ # task_id: "task-123",
23
+ # agent_type: "research_agent",
24
+ # duration_ms: 1500,
25
+ # success: true,
26
+ # metrics: { tokens_used: 2000 }
27
+ # )
28
+ #
29
+ # # Analyze patterns
30
+ # patterns = recognizer.analyze_agent_performance("research_agent")
31
+ #
32
+ # # Optimize strategies
33
+ # improved_prompt = optimizer.optimize_prompt_template(
34
+ # original_template: "Please research the topic: {topic}",
35
+ # agent_type: "research_agent"
36
+ # )
37
+ module Learning
38
+ # Factory method to create a complete learning system
39
+ #
40
+ # @param options [Hash] Configuration options for all components
41
+ # @option options [Logger] :logger Custom logger (defaults to Agentic.logger)
42
+ # @option options [String] :storage_path Path for storing execution history
43
+ # @option options [Boolean] :auto_optimize Whether to auto-apply optimizations
44
+ # @option options [Symbol] :optimization_strategy Strategy (:conservative, :balanced, :aggressive)
45
+ # @option options [LlmClient] :llm_client Optional LLM client for optimizations
46
+ # @return [Hash] Hash containing all initialized learning system components
47
+ def self.create(options = {})
48
+ history_store = ExecutionHistoryStore.new(
49
+ logger: options[:logger],
50
+ storage_path: options[:storage_path],
51
+ anonymize: options.fetch(:anonymize, true)
52
+ )
53
+
54
+ pattern_recognizer = PatternRecognizer.new(
55
+ logger: options[:logger],
56
+ history_store: history_store,
57
+ min_sample_size: options[:min_sample_size] || 10
58
+ )
59
+
60
+ strategy_optimizer = StrategyOptimizer.new(
61
+ logger: options[:logger],
62
+ pattern_recognizer: pattern_recognizer,
63
+ history_store: history_store,
64
+ llm_client: options[:llm_client],
65
+ auto_apply_optimizations: options.fetch(:auto_optimize, false)
66
+ )
67
+
68
+ {
69
+ history_store: history_store,
70
+ pattern_recognizer: pattern_recognizer,
71
+ strategy_optimizer: strategy_optimizer
72
+ }
73
+ end
74
+
75
+ # Register a learning system with a plan orchestrator
76
+ #
77
+ # @param plan_orchestrator [PlanOrchestrator] The plan orchestrator to integrate with
78
+ # @param learning_system [Hash] The learning system components from Learning.create
79
+ # @return [Boolean] true if successfully registered
80
+ def self.register_with_orchestrator(plan_orchestrator, learning_system)
81
+ # Register execution history tracking
82
+ plan_orchestrator.on(:task_completed) do |task, result|
83
+ learning_system[:history_store].record_execution(
84
+ task_id: task.id,
85
+ plan_id: task.context[:plan_id],
86
+ agent_type: task.agent_spec&.type,
87
+ duration_ms: result.metrics[:duration_ms],
88
+ success: result.success?,
89
+ metrics: result.metrics,
90
+ context: {
91
+ task_description: task.description,
92
+ task_type: task.type,
93
+ input_size: task.input ? task.input.to_s.length : 0
94
+ }
95
+ )
96
+ end
97
+
98
+ plan_orchestrator.on(:plan_completed) do |plan, results|
99
+ # Record overall plan execution
100
+ task_durations = {}
101
+ task_dependencies = {}
102
+
103
+ results.each do |task_id, result|
104
+ task_durations[task_id] = result.metrics[:duration_ms] if result.metrics[:duration_ms]
105
+ end
106
+
107
+ # Extract dependencies from plan
108
+ plan.tasks.each do |task|
109
+ task_dependencies[task.id] = task.dependencies if task.dependencies&.any?
110
+ end
111
+
112
+ learning_system[:history_store].record_execution(
113
+ plan_id: plan.id,
114
+ success: results.values.all?(&:success?),
115
+ duration_ms: results.values.sum { |r| r.metrics[:duration_ms] || 0 },
116
+ metrics: {
117
+ total_tasks: results.size,
118
+ successful_tasks: results.values.count(&:success?),
119
+ failed_tasks: results.values.count { |r| !r.success? }
120
+ },
121
+ context: {
122
+ task_durations: task_durations,
123
+ task_dependencies: task_dependencies
124
+ }
125
+ )
126
+ end
127
+
128
+ true
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentic
4
+ # LLM-assisted strategy for agent composition
5
+ # Uses an LLM to analyze requirements and suggest capabilities
6
+ class LlmAssistedCompositionStrategy < AgentCompositionStrategy
7
+ # Initialize a new LLM-assisted composition strategy
8
+ # @param llm_config_or_client [LlmConfig, LlmClient, nil] The LLM configuration or client to use
9
+ def initialize(llm_config_or_client = nil)
10
+ if llm_config_or_client.is_a?(LlmClient)
11
+ @llm_client = llm_config_or_client
12
+ else
13
+ @llm_config = llm_config_or_client || LlmConfig.new
14
+ end
15
+ end
16
+
17
+ # Select capabilities based on requirements
18
+ # @param requirements [Hash] The capability requirements
19
+ # @param registry [AgentCapabilityRegistry] The capability registry
20
+ # @return [Array<Hash>] The selected capabilities
21
+ def select_capabilities(requirements, registry)
22
+ # Get all available capabilities from the registry
23
+ available_capabilities = registry.list
24
+
25
+ # Use existing client or create a new one
26
+ client = @llm_client || Agentic.client(@llm_config)
27
+
28
+ # Create a prompt for the LLM
29
+ prompt = build_llm_prompt(requirements, available_capabilities)
30
+
31
+ # Get LLM response
32
+ response = client.complete(
33
+ prompt: prompt,
34
+ response_format: {type: "json"}
35
+ )
36
+
37
+ # Parse the response
38
+ suggested_capabilities = parse_llm_response(response.to_s, registry)
39
+
40
+ # Fall back to default strategy if LLM suggestion fails
41
+ if suggested_capabilities.empty?
42
+ Agentic.logger.warn("LLM capability suggestion failed, falling back to default strategy")
43
+ return DefaultCompositionStrategy.new.select_capabilities(requirements, registry)
44
+ end
45
+
46
+ # Add dependencies
47
+ add_dependencies(suggested_capabilities, registry)
48
+ end
49
+
50
+ private
51
+
52
+ # Build a prompt for the LLM to suggest capabilities
53
+ # @param requirements [Hash] The capability requirements
54
+ # @param available_capabilities [Hash] The available capabilities in the registry
55
+ # @return [String] The prompt for the LLM
56
+ def build_llm_prompt(requirements, available_capabilities)
57
+ # Format the requirements
58
+ req_text = requirements.map do |name, info|
59
+ "- #{name} (importance: #{info[:importance]}, version: #{info[:version_constraint] || "any"})"
60
+ end.join("\n")
61
+
62
+ # Format the available capabilities
63
+ avail_text = available_capabilities.map do |name, info|
64
+ versions_text = info[:versions].join(", ")
65
+ "- #{name} (versions: #{versions_text}, latest: #{info[:latest]})"
66
+ end.join("\n")
67
+
68
+ <<~PROMPT
69
+ You are an AI assistant helping to select the most appropriate capabilities for an agent.
70
+
71
+ Given the following requirements and available capabilities, select the most appropriate
72
+ capabilities for the agent. Consider the importance of each requirement and ensure all
73
+ high-importance requirements are satisfied.
74
+
75
+ # Requirements:
76
+ #{req_text.empty? ? "No specific requirements provided." : req_text}
77
+
78
+ # Available Capabilities:
79
+ #{avail_text}
80
+
81
+ For each requirement, select the most appropriate capability and version.
82
+ Also consider dependencies between capabilities and ensure all necessary capabilities are included.
83
+
84
+ Provide your response in JSON format with this structure:
85
+ {
86
+ "selected_capabilities": [
87
+ {"name": "capability_name", "version": "version_string", "reason": "Reason for selection"},
88
+ ...
89
+ ],
90
+ "rationale": "Overall explanation of your selection logic"
91
+ }
92
+ PROMPT
93
+ end
94
+
95
+ # Parse the LLM response to get suggested capabilities
96
+ # @param response [String] The LLM response
97
+ # @param registry [AgentCapabilityRegistry] The capability registry
98
+ # @return [Array<Hash>] The selected capabilities
99
+ def parse_llm_response(response, registry)
100
+ # Extract JSON from the response
101
+ json_match = response.match(/\{.*"selected_capabilities".*\}/m)
102
+ return [] unless json_match
103
+
104
+ json_str = json_match[0]
105
+
106
+ # Parse the JSON response
107
+ json_response = JSON.parse(json_str, symbolize_names: true)
108
+
109
+ # Extract the selected capabilities
110
+ selected = json_response[:selected_capabilities] || []
111
+
112
+ # Log the rationale if provided
113
+ if json_response[:rationale]
114
+ Agentic.logger.info("LLM capability selection rationale: #{json_response[:rationale]}")
115
+ end
116
+
117
+ # Validate the selected capabilities
118
+ validated = []
119
+
120
+ selected.each do |cap|
121
+ name = cap[:name]
122
+ version = cap[:version]
123
+
124
+ # Log the reason if provided
125
+ if cap[:reason]
126
+ Agentic.logger.info("Selected #{name} v#{version}: #{cap[:reason]}")
127
+ end
128
+
129
+ # Check if the capability exists in the registry
130
+ capability = registry.get(name, version)
131
+
132
+ if capability
133
+ validated << {
134
+ name: capability.name,
135
+ version: capability.version
136
+ }
137
+ else
138
+ Agentic.logger.warn("LLM suggested non-existent capability: #{name} v#{version}")
139
+ end
140
+ end
141
+
142
+ validated
143
+ rescue => e
144
+ Agentic.logger.error("Failed to parse LLM response: #{e.message}")
145
+ []
146
+ end
147
+
148
+ # Add dependencies to the selected capabilities
149
+ # @param selected [Array<Hash>] The selected capabilities
150
+ # @param registry [AgentCapabilityRegistry] The capability registry
151
+ # @return [Array<Hash>] The selected capabilities with dependencies
152
+ def add_dependencies(selected, registry)
153
+ # Track dependencies to add
154
+ to_add = []
155
+
156
+ # Check each selected capability for dependencies
157
+ selected.each do |cap_info|
158
+ capability = registry.get(cap_info[:name], cap_info[:version])
159
+ next unless capability
160
+
161
+ # Check each dependency
162
+ capability.dependencies.each do |dep|
163
+ # Skip if we already selected this dependency
164
+ next if selected.any? { |sel| sel[:name] == dep[:name] } ||
165
+ to_add.any? { |sel| sel[:name] == dep[:name] }
166
+
167
+ # Find the dependency in the registry
168
+ dep_capability = registry.get(dep[:name], dep[:version])
169
+
170
+ # Skip if not found
171
+ next unless dep_capability
172
+
173
+ # Add to the list of dependencies to add
174
+ to_add << {
175
+ name: dep_capability.name,
176
+ version: dep_capability.version
177
+ }
178
+
179
+ Agentic.logger.info("Added dependency: #{dep_capability.name} v#{dep_capability.version}")
180
+ end
181
+ end
182
+
183
+ # Add the dependencies to the selected capabilities
184
+ selected.concat(to_add)
185
+ selected
186
+ end
187
+ end
188
+ end
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openai"
4
+ require "net/http"
5
+ require_relative "llm_response"
6
+ require_relative "errors/llm_error"
7
+ require_relative "retry_handler"
8
+ require_relative "retry_config"
4
9
 
5
10
  module Agentic
6
11
  # Generic wrapper for LLM API clients
@@ -8,19 +13,44 @@ module Agentic
8
13
  # @return [OpenAI::Client] The underlying LLM client instance
9
14
  attr_reader :client, :last_response
10
15
 
16
+ # @return [RetryHandler] The retry handler for transient errors
17
+ attr_reader :retry_handler
18
+
11
19
  # Initializes a new LlmClient
12
20
  # @param config [LlmConfig] The configuration for the LLM
13
- def initialize(config)
14
- @client = OpenAI::Client.new(access_token: Agentic.configuration.access_token)
21
+ # @param retry_config [RetryConfig, Hash] Configuration for the retry handler
22
+ def initialize(config, retry_config = {})
23
+ client_options = {access_token: Agentic.configuration.access_token}
24
+
25
+ # Add custom base URL if configured (for Ollama, etc.)
26
+ if Agentic.configuration.api_base_url
27
+ client_options[:uri_base] = Agentic.configuration.api_base_url
28
+ end
29
+
30
+ @client = OpenAI::Client.new(client_options)
15
31
  @config = config
16
32
  @last_response = nil
33
+
34
+ # Convert retry_config to RetryConfig if it's a hash
35
+ @retry_handler = if retry_config.is_a?(RetryConfig)
36
+ retry_config.to_handler
37
+ else
38
+ RetryHandler.new(**retry_config)
39
+ end
17
40
  end
18
41
 
19
42
  # Sends a completion request to the LLM
20
43
  # @param messages [Array<Hash>] The messages to send
21
- # @return [Hash] The response from the LLM
22
- def complete(messages, output_schema: nil)
23
- parameters = {model: @config.model, messages: messages}
44
+ # @param output_schema [Agentic::StructuredOutputs::Schema, nil] Optional schema for structured output
45
+ # @param fail_on_error [Boolean] Whether to raise errors or return them as part of the response
46
+ # @param use_retries [Boolean] Whether to retry on transient errors
47
+ # @param options [Hash] Additional options to override the config
48
+ # @return [LlmResponse] The structured response from the LLM
49
+ def complete(messages, output_schema: nil, fail_on_error: false, use_retries: true, options: {})
50
+ # Start with base parameters from the config
51
+ parameters = @config.to_api_parameters({messages: messages})
52
+
53
+ # Add response format if schema is provided
24
54
  if output_schema
25
55
  parameters[:response_format] = {
26
56
  type: "json_schema",
@@ -28,32 +58,202 @@ module Agentic
28
58
  }
29
59
  end
30
60
 
61
+ # Override with any additional options
62
+ parameters.merge!(options)
63
+
64
+ execution_method = use_retries ? method(:with_retry) : method(:without_retry)
65
+ execution_method.call(messages, parameters, output_schema, fail_on_error)
66
+ end
67
+
68
+ # Executes the API call with retries for transient errors
69
+ # @param messages [Array<Hash>] The messages being sent
70
+ # @param parameters [Hash] The request parameters
71
+ # @param output_schema [Agentic::StructuredOutputs::Schema, nil] Optional schema for structured output
72
+ # @param fail_on_error [Boolean] Whether to raise errors or return them as part of the response
73
+ # @return [LlmResponse] The structured response from the LLM
74
+ def with_retry(messages, parameters, output_schema, fail_on_error)
75
+ retry_handler.with_retry do
76
+ without_retry(messages, parameters, output_schema, fail_on_error)
77
+ end
78
+ rescue Errors::LlmError => e
79
+ # If we get here, we've exhausted retries or hit a non-retryable error
80
+ Agentic.logger.error("Failed after retries: #{e.message}")
81
+ handle_error(e, fail_on_error)
82
+ end
83
+
84
+ # Executes the API call without retries
85
+ # @param messages [Array<Hash>] The messages being sent
86
+ # @param parameters [Hash] The request parameters
87
+ # @param output_schema [Agentic::StructuredOutputs::Schema, nil] Optional schema for structured output
88
+ # @param fail_on_error [Boolean] Whether to raise errors or return them as part of the response
89
+ # @return [LlmResponse] The structured response from the LLM
90
+ def without_retry(messages, parameters, output_schema, fail_on_error)
31
91
  @last_response = client.chat(parameters: parameters)
32
92
 
33
- if output_schema
34
- content = JSON.parse(@last_response.dig("choices", 0, "message", "content"))
93
+ # Check for API-level refusal
94
+ if (refusal = @last_response.dig("choices", 0, "message", "refusal"))
95
+ refusal_error = Errors::LlmRefusalError.new(
96
+ refusal,
97
+ response: @last_response,
98
+ context: {input_messages: extract_message_content(messages)}
99
+ )
100
+
101
+ Agentic.logger.warn("LLM refused the request: #{refusal} (Category: #{refusal_error.refusal_category})")
35
102
 
36
- if (refusal = @last_response.dig("choices", 0, "message", "refusal"))
37
- {refusal: refusal, content: nil}
103
+ if fail_on_error
104
+ raise refusal_error
38
105
  else
39
- {content: content}
106
+ return LlmResponse.refusal(@last_response, refusal, refusal_error)
107
+ end
108
+ end
109
+
110
+ # Process the response based on whether we expect structured output
111
+ if output_schema
112
+ begin
113
+ content_text = @last_response.dig("choices", 0, "message", "content")
114
+ if content_text.nil? || content_text.empty?
115
+ error = Errors::LlmParseError.new("Empty content returned from LLM", response: @last_response)
116
+ Agentic.logger.error(error.message)
117
+ return handle_error(error, fail_on_error)
118
+ end
119
+
120
+ content = JSON.parse(content_text)
121
+ LlmResponse.success(@last_response, content)
122
+ rescue JSON::ParserError => e
123
+ error = Errors::LlmParseError.new(
124
+ "Failed to parse JSON response: #{e.message}",
125
+ parse_exception: e,
126
+ response: @last_response
127
+ )
128
+ Agentic.logger.error(error.message)
129
+ handle_error(error, fail_on_error)
40
130
  end
41
131
  else
42
- @last_response.dig("choices", 0, "message", "content")
132
+ content = @last_response.dig("choices", 0, "message", "content")
133
+ LlmResponse.success(@last_response, content)
43
134
  end
135
+ rescue OpenAI::Error => e
136
+ error = map_openai_error(e)
137
+ Agentic.logger.error("OpenAI API error: #{error.message}")
138
+ handle_error(error, fail_on_error)
139
+ rescue Net::ReadTimeout, Net::OpenTimeout => e
140
+ error = Errors::LlmTimeoutError.new("Request to LLM timed out: #{e.message}", context: {timeout_type: e.class.name})
141
+ Agentic.logger.error(error.message)
142
+ handle_error(error, fail_on_error)
143
+ rescue JSON::ParserError => e
144
+ error = Errors::LlmParseError.new("Failed to parse LLM response: #{e.message}", parse_exception: e)
145
+ Agentic.logger.error(error.message)
146
+ handle_error(error, fail_on_error)
147
+ rescue => e
148
+ error = Errors::LlmError.new("Unexpected error in LLM request: #{e.message}", context: {error_class: e.class.name})
149
+ Agentic.logger.error("#{error.message}\n#{e.backtrace.join("\n")}")
150
+ handle_error(error, fail_on_error)
44
151
  end
45
152
 
46
153
  # Fetches available models from the LLM provider
47
- # @return [Array<Hash>] The list of available models
48
- def models
154
+ # @param fail_on_error [Boolean] Whether to raise errors or return nil on error
155
+ # @return [Array<Hash>, nil] The list of available models, or nil if an error occurred and fail_on_error is false
156
+ # @raise [Agentic::Errors::LlmError] If an error occurred and fail_on_error is true
157
+ def models(fail_on_error: false)
49
158
  client.models.list&.dig("data")
159
+ rescue OpenAI::Error => e
160
+ error = map_openai_error(e)
161
+ Agentic.logger.error("OpenAI API error when listing models: #{error.message}")
162
+ handle_error(error, fail_on_error)
163
+ nil
164
+ rescue => e
165
+ error = Errors::LlmError.new("Unexpected error listing models: #{e.message}")
166
+ Agentic.logger.error("#{error.message}\n#{e.backtrace.join("\n")}")
167
+ handle_error(error, fail_on_error)
168
+ nil
50
169
  end
51
170
 
52
171
  # Queries generation stats for a given generation ID
53
172
  # @param generation_id [String] The ID of the generation
54
- # @return [Hash] The generation stats
55
- def query_generation_stats(generation_id)
173
+ # @param fail_on_error [Boolean] Whether to raise errors or return nil on error
174
+ # @return [Hash, nil] The generation stats, or nil if an error occurred and fail_on_error is false
175
+ # @raise [Agentic::Errors::LlmError] If an error occurred and fail_on_error is true
176
+ def query_generation_stats(generation_id, fail_on_error: false)
56
177
  client.query_generation_stats(generation_id)
178
+ rescue OpenAI::Error => e
179
+ error = map_openai_error(e)
180
+ Agentic.logger.error("OpenAI API error when querying generation stats: #{error.message}")
181
+ handle_error(error, fail_on_error)
182
+ nil
183
+ rescue => e
184
+ error = Errors::LlmError.new("Unexpected error querying generation stats: #{e.message}")
185
+ Agentic.logger.error("#{error.message}\n#{e.backtrace.join("\n")}")
186
+ handle_error(error, fail_on_error)
187
+ nil
188
+ end
189
+
190
+ private
191
+
192
+ # Extracts content from messages for logging purposes
193
+ # @param messages [Array<Hash>] The messages
194
+ # @return [Array<String>] The extracted content
195
+ def extract_message_content(messages)
196
+ messages.map do |msg|
197
+ content = msg[:content] || msg["content"]
198
+ role = msg[:role] || msg["role"]
199
+ "#{role}: #{if content
200
+ content[0..100] + ((content.length > 100) ? "..." : "")
201
+ else
202
+ "[no content]"
203
+ end}"
204
+ end
205
+ end
206
+
207
+ # Maps OpenAI error types to our custom error classes
208
+ # @param error [OpenAI::Error] The original error from the OpenAI gem
209
+ # @return [Agentic::Errors::LlmError] A mapped error
210
+ def map_openai_error(error)
211
+ case error
212
+ when OpenAI::Timeout
213
+ Errors::LlmTimeoutError.new("OpenAI API request timed out: #{error.message}")
214
+ when OpenAI::RateLimitError
215
+ retry_after = error.response&.headers&.[]("retry-after")&.to_i
216
+ Errors::LlmRateLimitError.new(
217
+ "OpenAI API rate limit exceeded: #{error.message}",
218
+ retry_after: retry_after,
219
+ response: error.response&.to_h
220
+ )
221
+ when OpenAI::AuthenticationError
222
+ Errors::LlmAuthenticationError.new(
223
+ "OpenAI API authentication error: #{error.message}",
224
+ response: error.response&.to_h
225
+ )
226
+ when OpenAI::APIConnectionError
227
+ Errors::LlmNetworkError.new(
228
+ "OpenAI API connection error: #{error.message}",
229
+ network_exception: error
230
+ )
231
+ when OpenAI::InvalidRequestError
232
+ Errors::LlmInvalidRequestError.new(
233
+ "Invalid request to OpenAI API: #{error.message}",
234
+ response: error.response&.to_h
235
+ )
236
+ when OpenAI::APIError
237
+ Errors::LlmServerError.new(
238
+ "OpenAI API server error: #{error.message}",
239
+ response: error.response&.to_h
240
+ )
241
+ else
242
+ Errors::LlmError.new(
243
+ "Unexpected OpenAI API error: #{error.message}",
244
+ response: error.response&.to_h
245
+ )
246
+ end
247
+ end
248
+
249
+ # Handles an error based on whether to fail or return it in the response
250
+ # @param error [Agentic::Errors::LlmError] The error to handle
251
+ # @param fail_on_error [Boolean] Whether to raise the error
252
+ # @return [LlmResponse] An error response if fail_on_error is false
253
+ # @raise [Agentic::Errors::LlmError] If fail_on_error is true
254
+ def handle_error(error, fail_on_error)
255
+ raise error if fail_on_error
256
+ LlmResponse.error(error, @last_response)
57
257
  end
58
258
  end
59
259
  end
@@ -1,11 +1,75 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agentic
4
+ # Configuration object for LLM API calls
4
5
  class LlmConfig
6
+ # @return [String] The model to use for LLM requests
5
7
  attr_accessor :model
6
8
 
7
- def initialize(model: "gpt-4o-2024-08-06")
9
+ # @return [Integer] The maximum number of tokens to generate
10
+ attr_accessor :max_tokens
11
+
12
+ # @return [Float] The temperature parameter (controls randomness)
13
+ attr_accessor :temperature
14
+
15
+ # @return [Float] The top_p parameter (nucleus sampling)
16
+ attr_accessor :top_p
17
+
18
+ # @return [Integer] The frequency penalty
19
+ attr_accessor :frequency_penalty
20
+
21
+ # @return [Integer] The presence penalty
22
+ attr_accessor :presence_penalty
23
+
24
+ # @return [Hash] Additional options to pass to the API
25
+ attr_accessor :additional_options
26
+
27
+ # Initializes a new LLM configuration
28
+ # @param model [String] The model to use
29
+ # @param max_tokens [Integer, nil] The maximum number of tokens to generate
30
+ # @param temperature [Float] The temperature parameter (0.0-2.0)
31
+ # @param top_p [Float] The top_p parameter (0.0-1.0)
32
+ # @param frequency_penalty [Float] The frequency penalty (-2.0-2.0)
33
+ # @param presence_penalty [Float] The presence penalty (-2.0-2.0)
34
+ # @param additional_options [Hash] Additional options to pass to the API
35
+ def initialize(
36
+ model: "gpt-4o-2024-08-06",
37
+ max_tokens: nil,
38
+ temperature: 0.7,
39
+ top_p: 1.0,
40
+ frequency_penalty: 0.0,
41
+ presence_penalty: 0.0,
42
+ additional_options: {}
43
+ )
8
44
  @model = model
45
+ @max_tokens = max_tokens
46
+ @temperature = temperature
47
+ @top_p = top_p
48
+ @frequency_penalty = frequency_penalty
49
+ @presence_penalty = presence_penalty
50
+ @additional_options = additional_options
51
+ end
52
+
53
+ # Returns a hash of parameters for the API call
54
+ # @param base_params [Hash] Base parameters to include
55
+ # @return [Hash] Parameters for the API call
56
+ def to_api_parameters(base_params = {})
57
+ params = {
58
+ model: @model,
59
+ temperature: @temperature,
60
+ top_p: @top_p,
61
+ frequency_penalty: @frequency_penalty,
62
+ presence_penalty: @presence_penalty
63
+ }
64
+
65
+ # Add max_tokens if specified
66
+ params[:max_tokens] = @max_tokens if @max_tokens
67
+
68
+ # Merge any additional options
69
+ params.merge!(@additional_options)
70
+
71
+ # Merge with base parameters
72
+ base_params.merge(params)
9
73
  end
10
74
  end
11
75
  end