llm_gateway 0.6.0 → 0.7.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/CHANGELOG.md +12 -0
- data/README.md +255 -1
- data/docs/migration_guide_0.7.0.md +193 -0
- data/lib/llm_gateway/adapters/adapter.rb +1 -1
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -8
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +48 -16
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +131 -3
- data/lib/llm_gateway/adapters/structs.rb +45 -10
- data/lib/llm_gateway/agents/event.rb +105 -0
- data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
- data/lib/llm_gateway/agents/harness.rb +176 -0
- data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
- data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
- data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
- data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
- data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
- data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
- data/lib/llm_gateway/base_client.rb +3 -3
- data/lib/llm_gateway/clients/anthropic.rb +5 -5
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
- data/lib/llm_gateway/clients/openai.rb +2 -2
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
- data/lib/llm_gateway/prompt.rb +105 -68
- data/lib/llm_gateway/utils.rb +116 -13
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +4 -0
- metadata +12 -2
|
@@ -52,17 +52,43 @@ class ToolCall < BaseStruct
|
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
class ServerToolCall < ToolCall
|
|
56
|
+
attribute :type, Types::String.enum("server_tool_use")
|
|
57
|
+
end
|
|
58
|
+
|
|
55
59
|
class ToolResult < BaseStruct
|
|
56
|
-
attribute :type, Types::String
|
|
60
|
+
attribute :type, Types::String
|
|
57
61
|
attribute :tool_use_id, Types::String
|
|
58
|
-
attribute :content, Types::
|
|
62
|
+
attribute :content, Types::Any
|
|
63
|
+
|
|
64
|
+
def to_h
|
|
65
|
+
{
|
|
66
|
+
type: type,
|
|
67
|
+
tool_use_id: tool_use_id,
|
|
68
|
+
content: content
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class ServerToolResult < ToolResult
|
|
74
|
+
attribute :type, Types::String.enum("server_tool_result")
|
|
75
|
+
attribute? :name, Types::String.optional
|
|
76
|
+
|
|
77
|
+
def to_h
|
|
78
|
+
result = super
|
|
79
|
+
result[:name] = name unless name.nil?
|
|
80
|
+
result
|
|
81
|
+
end
|
|
59
82
|
end
|
|
60
83
|
|
|
61
84
|
class PartialAssistantMessage < BaseStruct
|
|
62
85
|
ContentBlock =
|
|
63
86
|
Types.Instance(TextContent) |
|
|
64
87
|
Types.Instance(ReasoningContent) |
|
|
65
|
-
Types.Instance(ToolCall)
|
|
88
|
+
Types.Instance(ToolCall) |
|
|
89
|
+
Types.Instance(ServerToolCall) |
|
|
90
|
+
Types.Instance(ToolResult) |
|
|
91
|
+
Types.Instance(ServerToolResult)
|
|
66
92
|
|
|
67
93
|
attribute? :id, Types::String.optional
|
|
68
94
|
attribute? :model, Types::String.optional
|
|
@@ -78,7 +104,7 @@ class PartialAssistantMessage < BaseStruct
|
|
|
78
104
|
end
|
|
79
105
|
|
|
80
106
|
def self.build_content_block(block)
|
|
81
|
-
return block if block.is_a?(TextContent) || block.is_a?(ReasoningContent) || block.is_a?(ToolCall)
|
|
107
|
+
return block if block.is_a?(TextContent) || block.is_a?(ReasoningContent) || block.is_a?(ToolCall) || block.is_a?(ServerToolCall) || block.is_a?(ToolResult) || block.is_a?(ServerToolResult)
|
|
82
108
|
|
|
83
109
|
case block[:type] || block["type"]
|
|
84
110
|
when "text"
|
|
@@ -86,14 +112,16 @@ class PartialAssistantMessage < BaseStruct
|
|
|
86
112
|
when "reasoning"
|
|
87
113
|
ReasoningContent.new(block)
|
|
88
114
|
when "thinking"
|
|
89
|
-
ReasoningContent.new(
|
|
90
|
-
type: "reasoning",
|
|
91
|
-
reasoning: block[:thinking] || block["thinking"] || block[:reasoning] || block["reasoning"],
|
|
92
|
-
signature: block[:signature] || block["signature"]
|
|
93
|
-
)
|
|
115
|
+
ReasoningContent.new(type: "reasoning", reasoning: block[:thinking] || block["thinking"] || block[:reasoning] || block["reasoning"], signature: block[:signature] || block["signature"])
|
|
94
116
|
when "tool_use"
|
|
95
117
|
ToolCall.new(block)
|
|
118
|
+
when "server_tool_use"
|
|
119
|
+
ServerToolCall.new(block)
|
|
96
120
|
else
|
|
121
|
+
type = block[:type] || block["type"]
|
|
122
|
+
return ServerToolResult.new(block) if type == "server_tool_result"
|
|
123
|
+
return ToolResult.new(block) if type&.end_with?("_tool_result")
|
|
124
|
+
|
|
97
125
|
raise ArgumentError, "Unsupported content block type: #{block[:type] || block['type']}"
|
|
98
126
|
end
|
|
99
127
|
end
|
|
@@ -102,7 +130,7 @@ class PartialAssistantMessage < BaseStruct
|
|
|
102
130
|
end
|
|
103
131
|
|
|
104
132
|
class AssistantStreamEvent < BaseStruct
|
|
105
|
-
EventType = Types::Coercible::Symbol.enum(:text_start, :text_delta, :text_end, :tool_start, :tool_delta, :tool_end, :reasoning_start, :reasoning_delta, :reasoning_end)
|
|
133
|
+
EventType = Types::Coercible::Symbol.enum(:text_start, :text_delta, :text_end, :tool_start, :tool_delta, :tool_end, :tool_result_start, :tool_result_delta, :tool_result_end, :reasoning_start, :reasoning_delta, :reasoning_end)
|
|
106
134
|
|
|
107
135
|
attribute :type, EventType
|
|
108
136
|
attribute :delta, Types::Coercible::String.default { "" }
|
|
@@ -144,6 +172,13 @@ end
|
|
|
144
172
|
class AssistantToolStartEvent < AssistantStreamEvent
|
|
145
173
|
attribute :id, Types::String
|
|
146
174
|
attribute :name, Types::String
|
|
175
|
+
attribute :tool_type, Types::String.default("tool_use".freeze).enum("tool_use", "server_tool_use")
|
|
176
|
+
attribute :content_index, Types::Integer
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
class AssistantToolResultStartEvent < AssistantStreamEvent
|
|
180
|
+
attribute :tool_use_id, Types::String
|
|
181
|
+
attribute :name, Types::String
|
|
147
182
|
attribute :content_index, Types::Integer
|
|
148
183
|
end
|
|
149
184
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../adapters/structs"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Agents
|
|
7
|
+
module Event
|
|
8
|
+
AgentEventType = Types::Coercible::Symbol.enum(
|
|
9
|
+
:agent_start,
|
|
10
|
+
:turn_start,
|
|
11
|
+
:message_start,
|
|
12
|
+
:message_update,
|
|
13
|
+
:message_end,
|
|
14
|
+
:tool_execution_start,
|
|
15
|
+
:tool_execution_end,
|
|
16
|
+
:turn_end,
|
|
17
|
+
:agent_end
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
StreamEvent =
|
|
21
|
+
Types.Instance(AssistantStreamEvent) |
|
|
22
|
+
Types.Instance(AssistantStreamMessageEvent) |
|
|
23
|
+
Types.Instance(AssistantStreamMessageEndEvent)
|
|
24
|
+
|
|
25
|
+
ToolParameters = Types::Hash.schema(
|
|
26
|
+
id: Types::String,
|
|
27
|
+
type: Types::String.enum("tool_use"),
|
|
28
|
+
name: Types::String,
|
|
29
|
+
input: Types::Hash
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
class ToolCallResult < ::BaseStruct
|
|
33
|
+
attribute :type, Types::Coercible::Symbol.enum(:tool_result)
|
|
34
|
+
attribute :tool_use_id, Types::String
|
|
35
|
+
attribute :content, Types::Any
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
{
|
|
39
|
+
type: type.to_s,
|
|
40
|
+
tool_use_id: tool_use_id,
|
|
41
|
+
content: content
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def dig(*keys)
|
|
46
|
+
to_h.dig(*keys)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Base < ::BaseStruct
|
|
51
|
+
attribute :type, AgentEventType
|
|
52
|
+
|
|
53
|
+
def to_h
|
|
54
|
+
{
|
|
55
|
+
type: type
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class AgentStart < Base
|
|
61
|
+
attribute :type, Types::Coercible::Symbol.default(:agent_start).enum(:agent_start)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class TurnStart < Base
|
|
65
|
+
attribute :type, Types::Coercible::Symbol.default(:turn_start).enum(:turn_start)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class MessageStart < Base
|
|
69
|
+
attribute :type, Types::Coercible::Symbol.default(:message_start).enum(:message_start)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class MessageUpdate < Base
|
|
73
|
+
attribute :type, Types::Coercible::Symbol.default(:message_update).enum(:message_update)
|
|
74
|
+
attribute :stream_event, StreamEvent
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class MessageEnd < Base
|
|
78
|
+
attribute :type, Types::Coercible::Symbol.default(:message_end).enum(:message_end)
|
|
79
|
+
attribute :message, Types.Instance(AssistantMessage)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class ToolExecutionStart < Base
|
|
83
|
+
attribute :type, Types::Coercible::Symbol.default(:tool_execution_start).enum(:tool_execution_start)
|
|
84
|
+
attribute :parameters, ToolParameters
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class ToolExecutionEnd < Base
|
|
88
|
+
attribute :type, Types::Coercible::Symbol.default(:tool_execution_end).enum(:tool_execution_end)
|
|
89
|
+
attribute :parameters, ToolParameters
|
|
90
|
+
attribute :result, ToolCallResult
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class TurnEnd < Base
|
|
94
|
+
attribute :type, Types::Coercible::Symbol.default(:turn_end).enum(:turn_end)
|
|
95
|
+
attribute :message, Types.Instance(AssistantMessage)
|
|
96
|
+
attribute :tool_results, Types::Array.of(Types.Instance(::ToolResult))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class AgentEnd < Base
|
|
100
|
+
attribute :type, Types::Coercible::Symbol.default(:agent_end).enum(:agent_end)
|
|
101
|
+
attribute :messages, Types::Array.of(Types::Hash)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
require_relative "in_memory_session_manager"
|
|
8
|
+
|
|
9
|
+
module LlmGateway
|
|
10
|
+
module Agents
|
|
11
|
+
class FileSessionManager < InMemorySessionManager
|
|
12
|
+
attr_reader :file_name, :session_path
|
|
13
|
+
|
|
14
|
+
def initialize(file_name = nil, session_id: nil, session_start: nil, session_dir: nil)
|
|
15
|
+
super(session_id)
|
|
16
|
+
@file_name = file_name
|
|
17
|
+
@preset_session_start = session_start
|
|
18
|
+
@session_dir = session_dir
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def session_id
|
|
22
|
+
events
|
|
23
|
+
@session_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def session_start
|
|
27
|
+
events
|
|
28
|
+
@session_start
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def normalize_path(file_name)
|
|
32
|
+
File.expand_path(file_name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def events
|
|
36
|
+
@events ||= begin
|
|
37
|
+
@session_path = normalize_path(file_name) if file_name
|
|
38
|
+
if @session_path && File.exist?(@session_path)
|
|
39
|
+
load_session(@session_path)
|
|
40
|
+
else
|
|
41
|
+
create_new_session
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def create_new_session
|
|
49
|
+
@session_id ||= SecureRandom.uuid
|
|
50
|
+
@session_start = @preset_session_start || Time.now.strftime("%Y%m%d_%H%M%S")
|
|
51
|
+
|
|
52
|
+
session_event = {
|
|
53
|
+
type: "session",
|
|
54
|
+
id: @session_id,
|
|
55
|
+
timestamp: @session_start
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@session_path ||= File.join(session_dir, "#{@session_start}_#{@session_id}.jsonl")
|
|
59
|
+
FileUtils.mkdir_p(File.dirname(@session_path))
|
|
60
|
+
File.open(@session_path, "a") do |file|
|
|
61
|
+
file.puts(JSON.generate(session_event))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
[ session_event ]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def load_session(path)
|
|
68
|
+
loaded_events = []
|
|
69
|
+
File.foreach(path).with_index(1) do |line, line_number|
|
|
70
|
+
next if line.strip.empty?
|
|
71
|
+
|
|
72
|
+
loaded_events << JSON.parse(line, symbolize_names: true)
|
|
73
|
+
rescue JSON::ParserError => e
|
|
74
|
+
raise ArgumentError, "Invalid JSONL in #{path} at line #{line_number}: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
session_event = loaded_events.find { |event| event[:type] == "session" }
|
|
78
|
+
@session_id = session_event[:id] if session_event&.dig(:id)
|
|
79
|
+
@session_start = session_event[:timestamp] if session_event&.dig(:timestamp)
|
|
80
|
+
|
|
81
|
+
loaded_events
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def persist_entry(entry)
|
|
85
|
+
attributes = super
|
|
86
|
+
|
|
87
|
+
FileUtils.mkdir_p(File.dirname(session_path))
|
|
88
|
+
File.open(session_path, "a") do |file|
|
|
89
|
+
file.puts(JSON.generate(entry))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
attributes
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def session_dir
|
|
96
|
+
File.expand_path(@session_dir || ENV.fetch("LLM_GATEWAY_SESSION_DIR", "~/.llm_gateway/sessions"))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "event"
|
|
4
|
+
require_relative "../utils"
|
|
5
|
+
|
|
6
|
+
module LlmGateway
|
|
7
|
+
module Agents
|
|
8
|
+
class Harness < LlmGateway::Prompt
|
|
9
|
+
COMPACTION_TOKEN_THRESHOLD = 180_000
|
|
10
|
+
COMPACTION_IDLE_THRESHOLD_SECONDS = 60 * 60
|
|
11
|
+
attr_accessor :provider
|
|
12
|
+
attr_reader :session_manager, :default_queue_mode, :queue_drain_mode,
|
|
13
|
+
:model, :reasoning
|
|
14
|
+
|
|
15
|
+
def initialize(session_manager, provider:, model: nil, reasoning: "high")
|
|
16
|
+
@provider = provider
|
|
17
|
+
super(provider: provider, model: model, reasoning: reasoning)
|
|
18
|
+
@session_manager = session_manager
|
|
19
|
+
sync_initial_configuration_events
|
|
20
|
+
self.default_queue_mode = :next_turn
|
|
21
|
+
self.queue_drain_mode = :all
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def transcript
|
|
25
|
+
session_manager.build_model_input_messages
|
|
26
|
+
end
|
|
27
|
+
alias :prompt :transcript
|
|
28
|
+
|
|
29
|
+
def prompt_message(message, &block)
|
|
30
|
+
enqueue_or_run_message(message, default_queue_mode, &block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def steer_message(message, &block)
|
|
34
|
+
enqueue_or_run_message(message, :steer, &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def follow_up_message(message, &block)
|
|
38
|
+
enqueue_or_run_message(message, :follow_up, &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def next_turn_message(message, &block)
|
|
42
|
+
enqueue_or_run_message(message, :next_turn, &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def default_queue_mode=(mode)
|
|
46
|
+
@default_queue_mode = session_manager.validate_queue!(mode)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def queue_drain_mode=(mode)
|
|
50
|
+
@queue_drain_mode = session_manager.validate_drain_mode!(mode)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def model=(model_id)
|
|
54
|
+
return @model if @model == model_id
|
|
55
|
+
|
|
56
|
+
@model = model_id
|
|
57
|
+
publish_session_event(type: "model_change", model_id: model_id)
|
|
58
|
+
@model
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reasoning=(level)
|
|
62
|
+
return @reasoning if @reasoning == level
|
|
63
|
+
|
|
64
|
+
@reasoning = level
|
|
65
|
+
publish_session_event(type: "reasoning_change", reasoning: level)
|
|
66
|
+
@reasoning
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compact
|
|
70
|
+
session_manager.compaction(provider)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def run(&block)
|
|
74
|
+
emit(Event::AgentStart.new, &block)
|
|
75
|
+
drain_queue(:steer)
|
|
76
|
+
emit(Event::TurnStart.new, &block)
|
|
77
|
+
emit(Event::MessageStart.new, &block)
|
|
78
|
+
|
|
79
|
+
assistant_message = stream do |event|
|
|
80
|
+
emit(Event::MessageUpdate.new(stream_event: event), &block)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
session_manager.push_message(assistant_message.to_h)
|
|
84
|
+
emit(Event::MessageEnd.new(message: assistant_message), &block)
|
|
85
|
+
|
|
86
|
+
tool_results = tool_requests(assistant_message).map do |message|
|
|
87
|
+
parameters = message.to_h
|
|
88
|
+
emit(Event::ToolExecutionStart.new(parameters: parameters), &block)
|
|
89
|
+
tool_result = find_and_execute_tool(message)
|
|
90
|
+
emit(Event::ToolExecutionEnd.new(parameters: parameters, result: tool_result), &block)
|
|
91
|
+
tool_result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
tool_result_content = tool_results.map(&:to_h)
|
|
95
|
+
session_manager.push_message(
|
|
96
|
+
role: "user",
|
|
97
|
+
content: tool_result_content,
|
|
98
|
+
) unless tool_result_content.empty?
|
|
99
|
+
|
|
100
|
+
turn_end_event = Event::TurnEnd.new(message: assistant_message, tool_results: tool_results)
|
|
101
|
+
emit(turn_end_event, &block)
|
|
102
|
+
|
|
103
|
+
if tool_results.length.positive?
|
|
104
|
+
return run(&block)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if session_manager.queued_messages?(:follow_up)
|
|
108
|
+
compact_if_needed
|
|
109
|
+
return run(&block) if drain_queue(:follow_up).any?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
emit(Event::AgentEnd.new(messages: []), &block)
|
|
113
|
+
assistant_message
|
|
114
|
+
end
|
|
115
|
+
alias :continue :run
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def publish_session_event(type:, **attributes)
|
|
120
|
+
session_manager.push_entry(type: type, **attributes)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def sync_initial_configuration_events
|
|
124
|
+
publish_session_event(type: "model_change", model_id: model) if model && !session_manager.last_model_used
|
|
125
|
+
if reasoning && !session_manager.last_reasoning_level_used
|
|
126
|
+
publish_session_event(type: "reasoning_change", reasoning: reasoning)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def enqueue_or_run_message(message, queue, &block)
|
|
131
|
+
prepared_input = LlmGateway::Utils.deep_symbolize_keys(message)
|
|
132
|
+
result = session_manager.start_or_enqueue_user_message(prepared_input, queue: queue) do
|
|
133
|
+
compact_if_needed
|
|
134
|
+
end
|
|
135
|
+
return if result == session_manager.class::MESSAGE_QUEUED
|
|
136
|
+
|
|
137
|
+
begin
|
|
138
|
+
continue(&block)
|
|
139
|
+
|
|
140
|
+
loop do
|
|
141
|
+
break unless session_manager.queued_messages?(:next_turn)
|
|
142
|
+
|
|
143
|
+
compact_if_needed
|
|
144
|
+
drain_queue(:next_turn)
|
|
145
|
+
continue(&block)
|
|
146
|
+
end
|
|
147
|
+
ensure
|
|
148
|
+
session_manager.idle!
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def compact_if_needed
|
|
153
|
+
compact if compaction_needed?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def compaction_needed?
|
|
157
|
+
session_manager.total_tokens > COMPACTION_TOKEN_THRESHOLD || last_assistant_message_stale?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def last_assistant_message_stale?
|
|
161
|
+
last_assistant_message_at = session_manager.last_assistant_message_at
|
|
162
|
+
last_assistant_message_at && Time.now - last_assistant_message_at > COMPACTION_IDLE_THRESHOLD_SECONDS
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def drain_queue(queue)
|
|
166
|
+
session_manager.drain_message_queue(queue, mode: queue_drain_mode)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def emit(event, &block)
|
|
170
|
+
return unless block
|
|
171
|
+
|
|
172
|
+
block.call(event)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module LlmGateway
|
|
7
|
+
module Agents
|
|
8
|
+
class InMemorySessionManager
|
|
9
|
+
MESSAGE_QUEUED = :queued
|
|
10
|
+
MESSAGE_STARTED = :started
|
|
11
|
+
QUEUES = [ :steer, :follow_up, :next_turn ].freeze
|
|
12
|
+
DRAIN_MODES = [ :one_at_a_time, :all ].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :session_id, :session_start
|
|
15
|
+
|
|
16
|
+
def initialize(session_id = nil)
|
|
17
|
+
@state = :idle
|
|
18
|
+
@session_id = session_id
|
|
19
|
+
@message_queues = Hash.new { |hash, key| hash[key] = [] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def busy!
|
|
23
|
+
@state = :busy
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def idle!
|
|
27
|
+
@state = :idle
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def drain_message_queue(queue = :next_turn, mode: :all)
|
|
31
|
+
messages = queued_messages(queue, mode)
|
|
32
|
+
messages.each { |message| push_message(message) }
|
|
33
|
+
messages
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def queued_messages?(queue = :next_turn)
|
|
37
|
+
@message_queues[validate_queue!(queue)].any?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def push_message_to_queue(message, queue = :next_turn)
|
|
41
|
+
@message_queues[validate_queue!(queue)] << message
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def busy?
|
|
45
|
+
@state == :busy
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_queue!(queue)
|
|
49
|
+
queue = queue.to_sym
|
|
50
|
+
raise ArgumentError, "Invalid queue mode: #{queue}" unless QUEUES.include?(queue)
|
|
51
|
+
|
|
52
|
+
queue
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_drain_mode!(mode)
|
|
56
|
+
mode = mode.to_sym
|
|
57
|
+
raise ArgumentError, "Invalid queue drain mode: #{mode}" unless DRAIN_MODES.include?(mode)
|
|
58
|
+
|
|
59
|
+
mode
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def start_or_enqueue_user_message(payload, queue: :next_turn)
|
|
63
|
+
if busy?
|
|
64
|
+
push_message_to_queue(payload, queue)
|
|
65
|
+
MESSAGE_QUEUED
|
|
66
|
+
else
|
|
67
|
+
yield if block_given?
|
|
68
|
+
push_message(payload)
|
|
69
|
+
busy!
|
|
70
|
+
MESSAGE_STARTED
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def push_message(payload)
|
|
75
|
+
payload = payload.deep_symbolize_keys
|
|
76
|
+
|
|
77
|
+
push_entry(
|
|
78
|
+
type: "message",
|
|
79
|
+
usage: message_usage(payload),
|
|
80
|
+
data: payload,
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def push_entry(entry)
|
|
85
|
+
id = SecureRandom.uuid
|
|
86
|
+
new_entry = {
|
|
87
|
+
id: id,
|
|
88
|
+
parent_id: parent_id_for_new_entry,
|
|
89
|
+
timestamp: Time.now.iso8601,
|
|
90
|
+
**entry
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
persist_entry(new_entry)
|
|
94
|
+
new_entry
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def active_messages
|
|
98
|
+
active_message_events.map { |event| event[:data] }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def last_message_id
|
|
102
|
+
message_events.last&.dig(:id)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def last_model_used
|
|
106
|
+
events.reverse.find { |event| event[:type] == "model_change" }&.dig(:model_id)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def last_reasoning_level_used
|
|
110
|
+
events.reverse.find { |event| event[:type] == "reasoning_change" }&.dig(:reasoning)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def events_until(event_id)
|
|
114
|
+
index = events.index { |event| event[:id] == event_id }
|
|
115
|
+
raise ArgumentError, "Event not found in session: #{event_id}" unless index
|
|
116
|
+
|
|
117
|
+
events[0..index]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def events
|
|
121
|
+
@events ||= [ new_session_event ]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_model_input_messages
|
|
125
|
+
return active_messages unless last_compaction_entry
|
|
126
|
+
|
|
127
|
+
[ last_compaction_entry[:data], *active_messages ]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def total_tokens
|
|
131
|
+
entry = active_message_events.reverse.find { |event| event.dig(:usage, :total_tokens) }
|
|
132
|
+
entry&.dig(:usage, :total_tokens) || 0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def last_assistant_message_at
|
|
136
|
+
entry = active_message_events.reverse.find { |event| event.dig(:data, :role) == "assistant" }
|
|
137
|
+
Time.parse(entry[:timestamp]) if entry
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def compaction(adapter)
|
|
141
|
+
response = adapter.stream(
|
|
142
|
+
active_messages,
|
|
143
|
+
system: "Summarize the conversation so far for future context.",
|
|
144
|
+
tools: []
|
|
145
|
+
)
|
|
146
|
+
message = response.to_h
|
|
147
|
+
|
|
148
|
+
push_entry(
|
|
149
|
+
type: "compaction",
|
|
150
|
+
usage: message_usage(message),
|
|
151
|
+
data: message
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def queued_messages(queue, mode)
|
|
158
|
+
queue = validate_queue!(queue)
|
|
159
|
+
case validate_drain_mode!(mode)
|
|
160
|
+
when :one_at_a_time
|
|
161
|
+
message = @message_queues[queue].shift
|
|
162
|
+
message ? [ message ] : []
|
|
163
|
+
when :all
|
|
164
|
+
@message_queues[queue].shift(@message_queues[queue].length)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parent_id_for_new_entry
|
|
169
|
+
events.length.positive? ? events.last[:id] : nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def message_events
|
|
173
|
+
events.select { |event| event[:type] == "message" }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def active_message_events
|
|
177
|
+
compaction_event = last_compaction_entry
|
|
178
|
+
return message_events unless compaction_event
|
|
179
|
+
|
|
180
|
+
compaction_index = events.index(compaction_event)
|
|
181
|
+
events[(compaction_index + 1)..].select { |event| event[:type] == "message" }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def last_compaction_entry
|
|
185
|
+
events.reverse.find { |event| event[:type] == "compaction" }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def message_usage(message)
|
|
189
|
+
usage = message[:usage] || message["usage"]
|
|
190
|
+
return {} unless usage
|
|
191
|
+
|
|
192
|
+
usage.transform_keys(&:to_sym)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def persist_entry(entry)
|
|
196
|
+
attributes = {
|
|
197
|
+
session_id: @session_id,
|
|
198
|
+
position: next_position,
|
|
199
|
+
id: entry[:id],
|
|
200
|
+
parent_id: entry[:parent_id],
|
|
201
|
+
timestamp: entry[:timestamp],
|
|
202
|
+
type: entry[:type],
|
|
203
|
+
usage: entry[:usage],
|
|
204
|
+
data: entry[:data]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
events << entry
|
|
208
|
+
attributes
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def next_position
|
|
212
|
+
events.length
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def new_session_event
|
|
216
|
+
@session_id ||= SecureRandom.uuid
|
|
217
|
+
@session_start = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
218
|
+
{ type: "session", id: session_id, timestamp: session_start }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|