soka 0.0.1.beta2
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +365 -0
- data/CHANGELOG.md +31 -0
- data/CLAUDE.md +213 -0
- data/LICENSE +21 -0
- data/README.md +650 -0
- data/Rakefile +10 -0
- data/examples/1_basic.rb +94 -0
- data/examples/2_event_handling.rb +120 -0
- data/examples/3_memory.rb +182 -0
- data/examples/4_hooks.rb +140 -0
- data/examples/5_error_handling.rb +85 -0
- data/examples/6_retry.rb +164 -0
- data/examples/7_tool_conditional.rb +180 -0
- data/examples/8_multi_provider.rb +112 -0
- data/lib/soka/agent.rb +130 -0
- data/lib/soka/agent_tool.rb +146 -0
- data/lib/soka/agent_tools/params_validator.rb +139 -0
- data/lib/soka/agents/dsl_methods.rb +140 -0
- data/lib/soka/agents/hook_manager.rb +68 -0
- data/lib/soka/agents/llm_builder.rb +32 -0
- data/lib/soka/agents/retry_handler.rb +74 -0
- data/lib/soka/agents/tool_builder.rb +78 -0
- data/lib/soka/configuration.rb +60 -0
- data/lib/soka/engines/base.rb +67 -0
- data/lib/soka/engines/concerns/prompt_template.rb +130 -0
- data/lib/soka/engines/concerns/response_processor.rb +103 -0
- data/lib/soka/engines/react.rb +136 -0
- data/lib/soka/engines/reasoning_context.rb +92 -0
- data/lib/soka/llm.rb +85 -0
- data/lib/soka/llms/anthropic.rb +124 -0
- data/lib/soka/llms/base.rb +114 -0
- data/lib/soka/llms/concerns/response_parser.rb +47 -0
- data/lib/soka/llms/concerns/streaming_handler.rb +78 -0
- data/lib/soka/llms/gemini.rb +106 -0
- data/lib/soka/llms/openai.rb +97 -0
- data/lib/soka/memory.rb +83 -0
- data/lib/soka/result.rb +136 -0
- data/lib/soka/test_helpers.rb +162 -0
- data/lib/soka/thoughts_memory.rb +112 -0
- data/lib/soka/version.rb +5 -0
- data/lib/soka.rb +49 -0
- data/sig/soka.rbs +4 -0
- metadata +158 -0
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soka
|
4
|
+
# Global configuration for Soka framework
|
5
|
+
class Configuration
|
6
|
+
# AI provider configuration
|
7
|
+
class AIConfig
|
8
|
+
attr_accessor :provider, :model, :api_key, :fallback_provider, :fallback_model, :fallback_api_key
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@provider = :gemini
|
12
|
+
@model = 'gemini-2.5-flash-lite'
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
# Performance-related configuration
|
17
|
+
class PerformanceConfig
|
18
|
+
attr_accessor :max_iterations, :timeout
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
@max_iterations = 10
|
22
|
+
@timeout = 30
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_accessor :tools
|
27
|
+
|
28
|
+
def initialize
|
29
|
+
@ai = AIConfig.new
|
30
|
+
@performance = PerformanceConfig.new
|
31
|
+
@tools = []
|
32
|
+
end
|
33
|
+
|
34
|
+
# Configure a specific section
|
35
|
+
# @param section [Symbol] The section to configure (:ai, :performance, :logging)
|
36
|
+
# @yield Configuration block for the section
|
37
|
+
# @return [Object] The configuration section
|
38
|
+
def configure(section)
|
39
|
+
config_object = instance_variable_get("@#{section}")
|
40
|
+
yield(config_object) if block_given? && config_object
|
41
|
+
config_object
|
42
|
+
end
|
43
|
+
|
44
|
+
# Configuration accessor methods with block support
|
45
|
+
|
46
|
+
# Access or configure AI settings
|
47
|
+
# @yield [AIConfig] Configuration block for AI settings
|
48
|
+
# @return [AIConfig] The AI configuration
|
49
|
+
def ai(&)
|
50
|
+
block_given? ? configure(:ai, &) : @ai
|
51
|
+
end
|
52
|
+
|
53
|
+
# Access or configure performance settings
|
54
|
+
# @yield [PerformanceConfig] Configuration block for performance settings
|
55
|
+
# @return [PerformanceConfig] The performance configuration
|
56
|
+
def performance(&)
|
57
|
+
block_given? ? configure(:performance, &) : @performance
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soka
|
4
|
+
module Engines
|
5
|
+
# Base class for reasoning engines
|
6
|
+
class Base
|
7
|
+
attr_reader :agent, :llm, :tools, :max_iterations
|
8
|
+
|
9
|
+
def initialize(agent, llm, tools, max_iterations)
|
10
|
+
@agent = agent
|
11
|
+
@llm = llm
|
12
|
+
@tools = tools
|
13
|
+
@max_iterations = max_iterations
|
14
|
+
end
|
15
|
+
|
16
|
+
def reason(task)
|
17
|
+
raise NotImplementedError, "#{self.class} must implement #reason method"
|
18
|
+
end
|
19
|
+
|
20
|
+
protected
|
21
|
+
|
22
|
+
def find_tool(name)
|
23
|
+
tools.find { |tool| tool.class.tool_name == name.to_s.downcase }
|
24
|
+
end
|
25
|
+
|
26
|
+
def execute_tool(tool_name, params = {})
|
27
|
+
tool = find_tool(tool_name)
|
28
|
+
raise ToolError, "Tool not found: #{tool_name}" unless tool
|
29
|
+
|
30
|
+
tool.execute(**params)
|
31
|
+
end
|
32
|
+
|
33
|
+
def emit_event(type, content)
|
34
|
+
return unless block_given?
|
35
|
+
|
36
|
+
event = Struct.new(:type, :content).new(type, content)
|
37
|
+
yield(event)
|
38
|
+
end
|
39
|
+
|
40
|
+
def build_messages(task)
|
41
|
+
messages = [system_message]
|
42
|
+
add_memory_messages(messages)
|
43
|
+
messages << user_message(task)
|
44
|
+
messages
|
45
|
+
end
|
46
|
+
|
47
|
+
def system_message
|
48
|
+
{ role: 'system', content: system_prompt }
|
49
|
+
end
|
50
|
+
|
51
|
+
def add_memory_messages(messages)
|
52
|
+
return unless agent.respond_to?(:memory) && agent.memory
|
53
|
+
|
54
|
+
messages.concat(agent.memory.to_messages)
|
55
|
+
end
|
56
|
+
|
57
|
+
def user_message(task)
|
58
|
+
{ role: 'user', content: task }
|
59
|
+
end
|
60
|
+
|
61
|
+
def system_prompt
|
62
|
+
# This should be overridden by specific engines
|
63
|
+
'You are a helpful AI assistant.'
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soka
|
4
|
+
module Engines
|
5
|
+
module Concerns
|
6
|
+
# Module for handling prompt templates in ReAct engine
|
7
|
+
module PromptTemplate
|
8
|
+
private
|
9
|
+
|
10
|
+
def system_prompt
|
11
|
+
tools_description = format_tools_description(tools)
|
12
|
+
|
13
|
+
<<~PROMPT
|
14
|
+
You are an AI assistant that uses the ReAct (Reasoning and Acting) framework to solve problems step by step.
|
15
|
+
|
16
|
+
You have access to the following tools:
|
17
|
+
#{tools_description}
|
18
|
+
|
19
|
+
#{format_instructions}
|
20
|
+
PROMPT
|
21
|
+
end
|
22
|
+
|
23
|
+
def format_instructions
|
24
|
+
<<~INSTRUCTIONS
|
25
|
+
You must follow this exact format for each step:
|
26
|
+
|
27
|
+
<Thought>Your reasoning about what to do next</Thought>
|
28
|
+
<Action>
|
29
|
+
Tool: tool_name
|
30
|
+
Parameters: {"param1": "value1", "param2": "value2"}
|
31
|
+
</Action>
|
32
|
+
|
33
|
+
STOP HERE after each Action. Do NOT include <Observation> in your response.
|
34
|
+
The system will execute the tool and provide the observation.
|
35
|
+
|
36
|
+
After receiving the observation, you can continue with more Thought/Action cycles or provide a final answer:
|
37
|
+
|
38
|
+
<Final_Answer>Your complete answer to the user's question</Final_Answer>
|
39
|
+
|
40
|
+
Important rules:
|
41
|
+
1. Always start with a <Thought> to analyze the problem
|
42
|
+
2. Use tools when you need information or to perform actions
|
43
|
+
3. Parameters MUST be valid JSON format (e.g., {"query": "weather"} not {query: "weather"})
|
44
|
+
4. For tools without parameters, use empty JSON object: {}
|
45
|
+
5. NEVER include <Observation> tags - wait for the system to provide them
|
46
|
+
6. Provide a clear and complete <Final_Answer> when done
|
47
|
+
7. If you cannot complete the task, explain why in the <Final_Answer>
|
48
|
+
INSTRUCTIONS
|
49
|
+
end
|
50
|
+
|
51
|
+
def format_tools_description(tools)
|
52
|
+
return 'No tools available.' if tools.empty?
|
53
|
+
|
54
|
+
tools.map do |tool|
|
55
|
+
schema = tool.class.to_h
|
56
|
+
params_desc = format_parameters(schema[:parameters])
|
57
|
+
|
58
|
+
"- #{schema[:name]}: #{schema[:description]}\n Parameters: #{params_desc}"
|
59
|
+
end.join("\n")
|
60
|
+
end
|
61
|
+
|
62
|
+
def format_parameters(params_schema)
|
63
|
+
return 'none' if params_schema[:properties].empty?
|
64
|
+
|
65
|
+
properties = params_schema[:properties].map do |name, config|
|
66
|
+
required = params_schema[:required].include?(name.to_s) ? '(required)' : '(optional)'
|
67
|
+
type = config[:type]
|
68
|
+
desc = config[:description]
|
69
|
+
|
70
|
+
"#{name} #{required} [#{type}] - #{desc}"
|
71
|
+
end
|
72
|
+
|
73
|
+
properties.join(', ')
|
74
|
+
end
|
75
|
+
|
76
|
+
def parse_response(text)
|
77
|
+
thoughts = extract_tagged_content(text, 'Thought')
|
78
|
+
actions = extract_actions(text)
|
79
|
+
final_answer = extract_tagged_content(text, 'Final_Answer').first
|
80
|
+
|
81
|
+
{
|
82
|
+
thoughts: thoughts,
|
83
|
+
actions: actions,
|
84
|
+
final_answer: final_answer
|
85
|
+
}
|
86
|
+
end
|
87
|
+
|
88
|
+
def extract_tagged_content(text, tag)
|
89
|
+
pattern = %r{<#{tag}>(.*?)</#{tag}>}m
|
90
|
+
text.scan(pattern).map { |match| match[0].strip }
|
91
|
+
end
|
92
|
+
|
93
|
+
def extract_actions(text)
|
94
|
+
action_blocks = text.scan(%r{<Action>(.*?)</Action>}m)
|
95
|
+
action_blocks.filter_map { |block| parse_action_block(block[0]) }
|
96
|
+
end
|
97
|
+
|
98
|
+
def parse_action_block(content)
|
99
|
+
content = content.strip
|
100
|
+
tool_match = content.match(/Tool:\s*(.+)/)
|
101
|
+
params_match = content.match(/Parameters:\s*(.+)/m)
|
102
|
+
|
103
|
+
return unless tool_match && params_match
|
104
|
+
|
105
|
+
tool_name = tool_match[1].strip
|
106
|
+
params_json = params_match[1].strip
|
107
|
+
params = parse_json_params(params_json)
|
108
|
+
|
109
|
+
{ tool: tool_name, params: params }
|
110
|
+
end
|
111
|
+
|
112
|
+
# Parse JSON parameters from action block
|
113
|
+
# @param params_json [String] The JSON string to parse
|
114
|
+
# @return [Hash] The parsed parameters as a hash with symbol keys
|
115
|
+
def parse_json_params(params_json)
|
116
|
+
# Clean up the JSON string - remove any trailing commas or whitespace
|
117
|
+
cleaned_json = params_json.strip.gsub(/,\s*}/, '}').gsub(/,\s*\]/, ']')
|
118
|
+
JSON.parse(cleaned_json, symbolize_names: true)
|
119
|
+
rescue JSON::ParserError
|
120
|
+
# Return empty hash to continue when JSON parsing fails
|
121
|
+
{}
|
122
|
+
end
|
123
|
+
|
124
|
+
def format_observation(observation)
|
125
|
+
"<Observation>#{observation}</Observation>"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soka
|
4
|
+
module Engines
|
5
|
+
module Concerns
|
6
|
+
# Module for processing responses in ReAct engine
|
7
|
+
module ResponseProcessor
|
8
|
+
include Concerns::PromptTemplate
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
# Process thoughts from parsed response
|
13
|
+
# @param parsed_thoughts [Array<String>] The thoughts to process
|
14
|
+
# @param context [ReasoningContext] The reasoning context
|
15
|
+
def process_thoughts(parsed_thoughts, context)
|
16
|
+
parsed_thoughts.each do |thought|
|
17
|
+
context.emit_event(:thought, thought)
|
18
|
+
context.add_thought(thought)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Process an action from the response
|
23
|
+
# @param action [Hash] The action to process
|
24
|
+
# @param context [ReasoningContext] The reasoning context
|
25
|
+
# @param content [String] The raw response content
|
26
|
+
def process_action(action, context, content)
|
27
|
+
context.emit_event(:action, action)
|
28
|
+
|
29
|
+
begin
|
30
|
+
observation = execute_tool(action[:tool], action[:params])
|
31
|
+
context.emit_event(:observation, observation)
|
32
|
+
add_observation_to_messages(context, content, observation)
|
33
|
+
context.update_last_thought(action: action, observation: observation)
|
34
|
+
rescue ToolError => e
|
35
|
+
handle_tool_error(e, context, action, content)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Add observation to messages in the context
|
40
|
+
# @param context [ReasoningContext] The reasoning context
|
41
|
+
# @param content [String] The assistant's response content
|
42
|
+
# @param observation [String] The observation to add
|
43
|
+
def add_observation_to_messages(context, content, observation)
|
44
|
+
observation_text = format_observation(observation)
|
45
|
+
context.add_message(role: 'assistant', content: content)
|
46
|
+
context.add_message(role: 'user', content: observation_text)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Handle tool execution errors
|
50
|
+
# @param error [ToolError] The error that occurred
|
51
|
+
# @param context [ReasoningContext] The reasoning context
|
52
|
+
# @param action [Hash] The action that failed
|
53
|
+
# @param content [String] The raw response content
|
54
|
+
# @raise [ToolError] Re-raises the error to trigger on_error hooks and terminate reasoning
|
55
|
+
def handle_tool_error(error, context, action, content)
|
56
|
+
error_message = "Tool error: #{error.message}"
|
57
|
+
context.emit_event(:error, error_message)
|
58
|
+
|
59
|
+
# Add error as observation so AI can see what happened
|
60
|
+
add_observation_to_messages(context, content, error_message)
|
61
|
+
context.update_last_thought(action: action, observation: error_message)
|
62
|
+
|
63
|
+
# Re-raise the error to propagate to agent level
|
64
|
+
# This will trigger on_error hook and terminate the reasoning process
|
65
|
+
# If you want reasoning to continue after tool errors, comment out this line
|
66
|
+
# raise error
|
67
|
+
end
|
68
|
+
|
69
|
+
# Execute a tool with the given name and input
|
70
|
+
# @param tool_name [String] The name of the tool to execute
|
71
|
+
# @param tool_input [Hash] The input parameters for the tool
|
72
|
+
# @return [String] The result of the tool execution
|
73
|
+
# @raise [ToolError] If the tool is not found or execution fails
|
74
|
+
def execute_tool(tool_name, tool_input)
|
75
|
+
tool = tools.find { |t| t.class.tool_name == tool_name }
|
76
|
+
raise ToolError, "Tool '#{tool_name}' not found" unless tool
|
77
|
+
|
78
|
+
tool.call(**symbolize_keys(tool_input))
|
79
|
+
rescue StandardError => e
|
80
|
+
# Re-raise as ToolError to be caught by process_action
|
81
|
+
raise ToolError, "Error executing tool: #{e.message}"
|
82
|
+
end
|
83
|
+
|
84
|
+
def symbolize_keys(hash)
|
85
|
+
return {} unless hash.is_a?(Hash)
|
86
|
+
|
87
|
+
hash.transform_keys(&:to_sym)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Handle case when no action is found in response
|
91
|
+
# @param context [ReasoningContext] The reasoning context
|
92
|
+
# @param content [String] The raw response content
|
93
|
+
def handle_no_action(context, content)
|
94
|
+
context.add_message(role: 'assistant', content: content)
|
95
|
+
context.add_message(
|
96
|
+
role: 'user',
|
97
|
+
content: 'Please follow the exact format with <Thought>, <Action>, and <Final_Answer> tags.'
|
98
|
+
)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Soka
|
6
|
+
module Engines
|
7
|
+
# ReAct (Reasoning and Acting) engine implementation
|
8
|
+
class React < Base
|
9
|
+
include Concerns::ResponseProcessor
|
10
|
+
include Concerns::PromptTemplate
|
11
|
+
|
12
|
+
ReasonResult = Struct.new(:input, :thoughts, :final_answer, :status, :error, :confidence_score,
|
13
|
+
keyword_init: true) do
|
14
|
+
def successful?
|
15
|
+
status == :success
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Main reasoning entry point
|
20
|
+
# @param task [String] The task to process
|
21
|
+
# @yield [event] Optional block to handle events during execution
|
22
|
+
# @return [ReasonResult] The result of the reasoning process
|
23
|
+
def reason(task, &block)
|
24
|
+
context = ReasoningContext.new(task: task, event_handler: block, max_iterations: max_iterations)
|
25
|
+
context.messages = build_messages(task)
|
26
|
+
|
27
|
+
result = iterate_reasoning(context)
|
28
|
+
result || max_iterations_result(context)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Iterate through reasoning cycles
|
32
|
+
# @param context [ReasoningContext] The reasoning context
|
33
|
+
# @return [ReasonResult, nil] The result if found, nil otherwise
|
34
|
+
def iterate_reasoning(context)
|
35
|
+
max_iterations.times do
|
36
|
+
result = process_iteration(context)
|
37
|
+
return result if result
|
38
|
+
|
39
|
+
context.increment_iteration!
|
40
|
+
end
|
41
|
+
nil
|
42
|
+
end
|
43
|
+
|
44
|
+
# Build result when max iterations reached
|
45
|
+
# @param context [ReasoningContext] The reasoning context
|
46
|
+
# @return [ReasonResult] The error result
|
47
|
+
def max_iterations_result(context)
|
48
|
+
context.emit_event(:error, "Maximum iterations (#{context.max_iterations}) reached")
|
49
|
+
build_result(
|
50
|
+
input: context.task,
|
51
|
+
thoughts: context.thoughts,
|
52
|
+
final_answer: "I couldn't complete the task within the maximum number of iterations.",
|
53
|
+
status: :max_iterations_reached
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
# Process a single iteration of reasoning
|
60
|
+
# @param context [ReasoningContext] The reasoning context
|
61
|
+
# @return [ReasonResult, nil] The result if final answer found, nil otherwise
|
62
|
+
def process_iteration(context)
|
63
|
+
response = llm.chat(context.messages)
|
64
|
+
content = response.content
|
65
|
+
context.parsed_response = parse_response(content)
|
66
|
+
|
67
|
+
process_parsed_response(context, content)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Process the parsed response
|
71
|
+
# @param context [ReasoningContext] The reasoning context
|
72
|
+
# @param content [String] The raw response content
|
73
|
+
# @return [ReasonResult, nil] The result if final answer found, nil otherwise
|
74
|
+
def process_parsed_response(context, content)
|
75
|
+
parsed = context.parsed_response
|
76
|
+
process_thoughts(parsed[:thoughts], context)
|
77
|
+
|
78
|
+
return process_final_answer(parsed[:final_answer], context) if parsed[:final_answer]
|
79
|
+
|
80
|
+
handle_actions_or_no_action(parsed, context, content)
|
81
|
+
nil
|
82
|
+
end
|
83
|
+
|
84
|
+
# Handle either actions or no action in response
|
85
|
+
# @param parsed [Hash] The parsed response
|
86
|
+
# @param context [ReasoningContext] The reasoning context
|
87
|
+
# @param content [String] The raw response content
|
88
|
+
def handle_actions_or_no_action(parsed, context, content)
|
89
|
+
if parsed[:actions].any?
|
90
|
+
process_action(parsed[:actions].first, context, content)
|
91
|
+
else
|
92
|
+
handle_no_action(context, content)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Process the final answer
|
97
|
+
# @param final_answer [String] The final answer from the LLM
|
98
|
+
# @param context [ReasoningContext] The reasoning context
|
99
|
+
# @return [ReasonResult] The success result
|
100
|
+
def process_final_answer(final_answer, context)
|
101
|
+
context.emit_event(:final_answer, final_answer)
|
102
|
+
build_result(
|
103
|
+
input: context.task,
|
104
|
+
thoughts: context.thoughts,
|
105
|
+
final_answer: final_answer,
|
106
|
+
status: :success
|
107
|
+
)
|
108
|
+
end
|
109
|
+
|
110
|
+
def build_result(input:, thoughts:, final_answer:, status:, error: nil)
|
111
|
+
result = {
|
112
|
+
input: input,
|
113
|
+
thoughts: thoughts,
|
114
|
+
final_answer: final_answer,
|
115
|
+
status: status
|
116
|
+
}
|
117
|
+
|
118
|
+
result[:error] = error if error
|
119
|
+
|
120
|
+
# Calculate confidence score based on iterations and status
|
121
|
+
result[:confidence_score] = calculate_confidence_score(thoughts, status)
|
122
|
+
|
123
|
+
ReasonResult.new(**result)
|
124
|
+
end
|
125
|
+
|
126
|
+
def calculate_confidence_score(thoughts, status)
|
127
|
+
return 0.0 if status != :success
|
128
|
+
|
129
|
+
base_score = 0.85
|
130
|
+
iteration_penalty = thoughts.length * 0.05
|
131
|
+
|
132
|
+
[base_score - iteration_penalty, 0.5].max
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soka
|
4
|
+
module Engines
|
5
|
+
# Context object that encapsulates all data needed during reasoning process
|
6
|
+
# This eliminates the need to pass multiple parameters and blocks through method chains
|
7
|
+
class ReasoningContext
|
8
|
+
# Event structure for emitting events
|
9
|
+
Event = Struct.new(:type, :content)
|
10
|
+
|
11
|
+
attr_accessor :messages, :thoughts, :task, :iteration, :parsed_response
|
12
|
+
attr_reader :event_handler, :max_iterations
|
13
|
+
|
14
|
+
# Initialize a new reasoning context
|
15
|
+
# @param task [String] The task to be processed
|
16
|
+
# @param event_handler [Proc, nil] Optional block to handle events
|
17
|
+
# @param max_iterations [Integer] Maximum number of reasoning iterations
|
18
|
+
def initialize(task:, event_handler: nil, max_iterations: 10)
|
19
|
+
@task = task
|
20
|
+
@event_handler = event_handler
|
21
|
+
@max_iterations = max_iterations
|
22
|
+
@messages = []
|
23
|
+
@thoughts = []
|
24
|
+
@iteration = 0
|
25
|
+
@parsed_response = nil
|
26
|
+
end
|
27
|
+
|
28
|
+
# Emit an event to the event handler if present
|
29
|
+
# @param type [Symbol] The type of event (e.g., :thought, :action, :observation)
|
30
|
+
# @param content [String, Hash] The content of the event
|
31
|
+
def emit_event(type, content)
|
32
|
+
return unless @event_handler
|
33
|
+
|
34
|
+
event = Event.new(type, content)
|
35
|
+
@event_handler.call(event)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Check if we've reached the maximum number of iterations
|
39
|
+
# @return [Boolean] true if max iterations reached
|
40
|
+
def max_iterations_reached?
|
41
|
+
@iteration >= @max_iterations
|
42
|
+
end
|
43
|
+
|
44
|
+
# Increment the iteration counter
|
45
|
+
# @return [Integer] The new iteration count
|
46
|
+
def increment_iteration!
|
47
|
+
@iteration += 1
|
48
|
+
end
|
49
|
+
|
50
|
+
# Get the current iteration number (1-based for display)
|
51
|
+
# @return [Integer] The current iteration number for display
|
52
|
+
def current_step
|
53
|
+
@iteration + 1
|
54
|
+
end
|
55
|
+
|
56
|
+
# Add a thought to the thoughts collection
|
57
|
+
# @param thought [String] The thought content
|
58
|
+
# @param action [Hash, nil] Optional action associated with the thought
|
59
|
+
# @param observation [String, nil] Optional observation from action
|
60
|
+
def add_thought(thought, action: nil, observation: nil)
|
61
|
+
thought_data = { step: current_step, thought: thought }
|
62
|
+
thought_data[:action] = action if action
|
63
|
+
thought_data[:observation] = observation if observation
|
64
|
+
@thoughts << thought_data
|
65
|
+
end
|
66
|
+
|
67
|
+
# Update the last thought with action and observation
|
68
|
+
# @param action [Hash] The action that was taken
|
69
|
+
# @param observation [String] The observation from the action
|
70
|
+
def update_last_thought(action:, observation:)
|
71
|
+
return if @thoughts.empty?
|
72
|
+
|
73
|
+
@thoughts.last[:action] = action
|
74
|
+
@thoughts.last[:observation] = observation
|
75
|
+
end
|
76
|
+
|
77
|
+
# Add a message to the conversation
|
78
|
+
# @param role [String] The role of the message sender
|
79
|
+
# @param content [String] The message content
|
80
|
+
def add_message(role:, content:)
|
81
|
+
@messages << { role: role, content: content }
|
82
|
+
end
|
83
|
+
|
84
|
+
# Get the last assistant message content
|
85
|
+
# @return [String, nil] The content of the last assistant message
|
86
|
+
def last_assistant_content
|
87
|
+
last_assistant = @messages.reverse.find { |msg| msg[:role] == 'assistant' }
|
88
|
+
last_assistant&.fetch(:content, nil)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
data/lib/soka/llm.rb
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soka
|
4
|
+
# LLM wrapper class that delegates to specific provider implementations
|
5
|
+
class LLM
|
6
|
+
attr_reader :provider
|
7
|
+
|
8
|
+
# Initialize LLM with specified provider
|
9
|
+
# @param provider_name [Symbol, nil] The provider to use (:gemini, :openai, :anthropic)
|
10
|
+
# @param options [Hash] Provider-specific options (model, api_key, etc.)
|
11
|
+
def initialize(provider_name = nil, **options)
|
12
|
+
provider_name ||= Soka.configuration.ai.provider
|
13
|
+
|
14
|
+
# Merge configuration options if no explicit options provided
|
15
|
+
if options.empty? && provider_name == Soka.configuration.ai.provider
|
16
|
+
config = Soka.configuration.ai
|
17
|
+
options = {
|
18
|
+
api_key: config.api_key,
|
19
|
+
model: config.model
|
20
|
+
}.compact
|
21
|
+
end
|
22
|
+
|
23
|
+
@provider = create_provider(provider_name, **options)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Chat with the LLM
|
27
|
+
# @param messages [Array<Hash>] Array of message hashes with role and content
|
28
|
+
# @param params [Hash] Additional parameters for the chat
|
29
|
+
# @return [LLMs::Result] The chat result
|
30
|
+
def chat(messages, **params)
|
31
|
+
@provider.chat(messages, **params)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Stream chat responses
|
35
|
+
# @param messages [Array<Hash>] Array of message hashes
|
36
|
+
# @param params [Hash] Additional parameters
|
37
|
+
# @yield [chunk] Yields each response chunk
|
38
|
+
def streaming_chat(messages, **params, &)
|
39
|
+
@provider.streaming_chat(messages, **params, &)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Check if provider supports streaming
|
43
|
+
# @return [Boolean] True if streaming is supported
|
44
|
+
def supports_streaming?
|
45
|
+
@provider.supports_streaming?
|
46
|
+
end
|
47
|
+
|
48
|
+
# Get the model being used
|
49
|
+
# @return [String] The model name
|
50
|
+
def model
|
51
|
+
@provider.model
|
52
|
+
end
|
53
|
+
|
54
|
+
# Build LLM instance from configuration object
|
55
|
+
# @param config [Object] Configuration with provider, model, and api_key
|
56
|
+
# @return [LLM] New LLM instance
|
57
|
+
def self.build(config)
|
58
|
+
options = {
|
59
|
+
model: config.model,
|
60
|
+
api_key: config.api_key
|
61
|
+
}.compact
|
62
|
+
|
63
|
+
new(config.provider, **options)
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
# Create the appropriate provider instance
|
69
|
+
# @param provider_name [Symbol] Provider type
|
70
|
+
# @param options [Hash] Provider options
|
71
|
+
# @return [LLMs::Base] Provider instance
|
72
|
+
def create_provider(provider_name, **)
|
73
|
+
case provider_name.to_sym
|
74
|
+
when :gemini
|
75
|
+
LLMs::Gemini.new(**)
|
76
|
+
when :openai
|
77
|
+
LLMs::OpenAI.new(**)
|
78
|
+
when :anthropic
|
79
|
+
LLMs::Anthropic.new(**)
|
80
|
+
else
|
81
|
+
raise LLMError, "Unknown LLM provider: #{provider_name}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|