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.
- checksums.yaml +4 -4
- data/.agentic.yml +2 -0
- data/.architecture/decisions/ArchitecturalFeatureBuilder.md +136 -0
- data/.architecture/decisions/ArchitectureConsiderations.md +200 -0
- data/.architecture/decisions/adr_001_observer_pattern_implementation.md +196 -0
- data/.architecture/decisions/adr_002_plan_orchestrator.md +320 -0
- data/.architecture/decisions/adr_003_plan_orchestrator_interface.md +179 -0
- data/.architecture/decisions/adrs/ADR-001-dependency-management.md +147 -0
- data/.architecture/decisions/adrs/ADR-002-system-boundaries.md +162 -0
- data/.architecture/decisions/adrs/ADR-003-content-safety.md +158 -0
- data/.architecture/decisions/adrs/ADR-004-agent-permissions.md +161 -0
- data/.architecture/decisions/adrs/ADR-005-adaptation-engine.md +127 -0
- data/.architecture/decisions/adrs/ADR-006-extension-system.md +273 -0
- data/.architecture/decisions/adrs/ADR-007-learning-system.md +156 -0
- data/.architecture/decisions/adrs/ADR-008-prompt-generation.md +325 -0
- data/.architecture/decisions/adrs/ADR-009-task-failure-handling.md +353 -0
- data/.architecture/decisions/adrs/ADR-010-task-input-handling.md +251 -0
- data/.architecture/decisions/adrs/ADR-011-task-observable-pattern.md +391 -0
- data/.architecture/decisions/adrs/ADR-012-task-output-handling.md +205 -0
- data/.architecture/decisions/adrs/ADR-013-architecture-alignment.md +211 -0
- data/.architecture/decisions/adrs/ADR-014-agent-capability-registry.md +80 -0
- data/.architecture/decisions/adrs/ADR-015-persistent-agent-store.md +100 -0
- data/.architecture/decisions/adrs/ADR-016-agent-assembly-engine.md +117 -0
- data/.architecture/decisions/adrs/ADR-017-streaming-observability.md +171 -0
- data/.architecture/decisions/capability_tools_distinction.md +150 -0
- data/.architecture/decisions/cli_command_structure.md +61 -0
- data/.architecture/implementation/agent_self_assembly_implementation.md +267 -0
- data/.architecture/implementation/agent_self_assembly_summary.md +138 -0
- data/.architecture/members.yml +187 -0
- data/.architecture/planning/self_implementation_exercise.md +295 -0
- data/.architecture/planning/session_compaction_rule.md +43 -0
- data/.architecture/planning/streaming_observability_feature.md +223 -0
- data/.architecture/principles.md +151 -0
- data/.architecture/recalibration/0-2-0.md +92 -0
- data/.architecture/recalibration/agent_self_assembly.md +238 -0
- data/.architecture/recalibration/cli_command_structure.md +91 -0
- data/.architecture/recalibration/implementation_roadmap_0-2-0.md +301 -0
- data/.architecture/recalibration/progress_tracking_0-2-0.md +114 -0
- data/.architecture/recalibration_process.md +127 -0
- data/.architecture/reviews/0-2-0.md +181 -0
- data/.architecture/reviews/cli_command_duplication.md +98 -0
- data/.architecture/templates/adr.md +105 -0
- data/.architecture/templates/implementation_roadmap.md +125 -0
- data/.architecture/templates/progress_tracking.md +89 -0
- data/.architecture/templates/recalibration_plan.md +70 -0
- data/.architecture/templates/version_comparison.md +124 -0
- data/.claude/settings.local.json +13 -0
- data/.claude-sessions/001-task-class-architecture-implementation.md +129 -0
- data/.claude-sessions/002-plan-orchestrator-interface-review.md +105 -0
- data/.claude-sessions/architecture-governance-implementation.md +37 -0
- data/.claude-sessions/architecture-review-session.md +27 -0
- data/ArchitecturalFeatureBuilder.md +136 -0
- data/ArchitectureConsiderations.md +229 -0
- data/CHANGELOG.md +57 -2
- data/CLAUDE.md +111 -0
- data/CONTRIBUTING.md +286 -0
- data/MAINTAINING.md +301 -0
- data/README.md +582 -28
- data/docs/agent_capabilities_api.md +259 -0
- data/docs/artifact_extension_points.md +757 -0
- data/docs/artifact_generation_architecture.md +323 -0
- data/docs/artifact_implementation_plan.md +596 -0
- data/docs/artifact_integration_points.md +345 -0
- data/docs/artifact_verification_strategies.md +581 -0
- data/docs/streaming_observability_architecture.md +510 -0
- data/exe/agentic +6 -1
- data/lefthook.yml +5 -0
- data/lib/agentic/adaptation_engine.rb +124 -0
- data/lib/agentic/agent.rb +181 -4
- data/lib/agentic/agent_assembly_engine.rb +442 -0
- data/lib/agentic/agent_capability_registry.rb +260 -0
- data/lib/agentic/agent_config.rb +63 -0
- data/lib/agentic/agent_specification.rb +46 -0
- data/lib/agentic/capabilities/examples.rb +530 -0
- data/lib/agentic/capabilities.rb +14 -0
- data/lib/agentic/capability_provider.rb +146 -0
- data/lib/agentic/capability_specification.rb +118 -0
- data/lib/agentic/cli/agent.rb +31 -0
- data/lib/agentic/cli/capabilities.rb +191 -0
- data/lib/agentic/cli/config.rb +134 -0
- data/lib/agentic/cli/execution_observer.rb +796 -0
- data/lib/agentic/cli.rb +1068 -0
- data/lib/agentic/default_agent_provider.rb +35 -0
- data/lib/agentic/errors/llm_error.rb +184 -0
- data/lib/agentic/execution_plan.rb +53 -0
- data/lib/agentic/execution_result.rb +91 -0
- data/lib/agentic/expected_answer_format.rb +46 -0
- data/lib/agentic/extension/domain_adapter.rb +109 -0
- data/lib/agentic/extension/plugin_manager.rb +163 -0
- data/lib/agentic/extension/protocol_handler.rb +116 -0
- data/lib/agentic/extension.rb +45 -0
- data/lib/agentic/factory_methods.rb +9 -1
- data/lib/agentic/generation_stats.rb +61 -0
- data/lib/agentic/learning/README.md +84 -0
- data/lib/agentic/learning/capability_optimizer.rb +613 -0
- data/lib/agentic/learning/execution_history_store.rb +251 -0
- data/lib/agentic/learning/pattern_recognizer.rb +500 -0
- data/lib/agentic/learning/strategy_optimizer.rb +706 -0
- data/lib/agentic/learning.rb +131 -0
- data/lib/agentic/llm_assisted_composition_strategy.rb +188 -0
- data/lib/agentic/llm_client.rb +215 -15
- data/lib/agentic/llm_config.rb +65 -1
- data/lib/agentic/llm_response.rb +163 -0
- data/lib/agentic/logger.rb +1 -1
- data/lib/agentic/observable.rb +51 -0
- data/lib/agentic/persistent_agent_store.rb +385 -0
- data/lib/agentic/plan_execution_result.rb +129 -0
- data/lib/agentic/plan_orchestrator.rb +464 -0
- data/lib/agentic/plan_orchestrator_config.rb +57 -0
- data/lib/agentic/retry_config.rb +63 -0
- data/lib/agentic/retry_handler.rb +125 -0
- data/lib/agentic/structured_outputs.rb +1 -1
- data/lib/agentic/task.rb +193 -0
- data/lib/agentic/task_definition.rb +39 -0
- data/lib/agentic/task_execution_result.rb +92 -0
- data/lib/agentic/task_failure.rb +66 -0
- data/lib/agentic/task_output_schemas.rb +112 -0
- data/lib/agentic/task_planner.rb +54 -19
- data/lib/agentic/task_result.rb +48 -0
- data/lib/agentic/ui.rb +244 -0
- data/lib/agentic/verification/critic_framework.rb +116 -0
- data/lib/agentic/verification/llm_verification_strategy.rb +60 -0
- data/lib/agentic/verification/schema_verification_strategy.rb +47 -0
- data/lib/agentic/verification/verification_hub.rb +62 -0
- data/lib/agentic/verification/verification_result.rb +50 -0
- data/lib/agentic/verification/verification_strategy.rb +26 -0
- data/lib/agentic/version.rb +1 -1
- data/lib/agentic.rb +74 -2
- data/plugins/README.md +41 -0
- 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
|
data/lib/agentic/llm_client.rb
CHANGED
@@ -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
|
-
|
14
|
-
|
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
|
-
# @
|
22
|
-
|
23
|
-
|
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
|
-
|
34
|
-
|
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
|
37
|
-
|
103
|
+
if fail_on_error
|
104
|
+
raise refusal_error
|
38
105
|
else
|
39
|
-
|
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
|
-
# @
|
48
|
-
|
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
|
-
# @
|
55
|
-
|
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
|
data/lib/agentic/llm_config.rb
CHANGED
@@ -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
|
-
|
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
|