llm_gateway 0.5.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 +38 -0
- data/README.md +350 -43
- data/docs/migration_guide_0.6.0.md +386 -0
- data/docs/migration_guide_0.7.0.md +193 -0
- data/lib/llm_gateway/adapters/adapter.rb +8 -11
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +61 -11
- 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 +132 -39
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +40 -16
- 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 +173 -24
- data/lib/llm_gateway/adapters/stream_mapper.rb +9 -2
- data/lib/llm_gateway/adapters/structs.rb +140 -55
- 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 +5 -7
- data/lib/llm_gateway/clients/anthropic.rb +10 -9
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
- data/lib/llm_gateway/clients/groq.rb +8 -6
- data/lib/llm_gateway/clients/openai.rb +22 -20
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
- data/lib/llm_gateway/prompt.rb +107 -52
- data/lib/llm_gateway/utils.rb +116 -13
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +7 -21
- metadata +13 -2
|
@@ -9,35 +9,6 @@ class BaseStruct < Dry::Struct
|
|
|
9
9
|
transform_keys(&:to_sym)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
class AssistantStreamEvent < BaseStruct
|
|
13
|
-
EventType = Types::Coercible::Symbol.enum(:text_start, :text_delta, :text_end, :tool_start, :tool_delta, :tool_end, :reasoning_start, :reasoning_delta, :reasoning_end)
|
|
14
|
-
|
|
15
|
-
attribute :type, EventType
|
|
16
|
-
attribute :delta, Types::Coercible::String.default { "" }
|
|
17
|
-
attribute :content_index, Types::Integer
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class AssistantToolStartEvent < AssistantStreamEvent
|
|
22
|
-
attribute :id, Types::String
|
|
23
|
-
attribute :name, Types::String
|
|
24
|
-
attribute :content_index, Types::Integer
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class AssistantStreamReasoningEvent < AssistantStreamEvent
|
|
29
|
-
attribute :signature, Types::Coercible::String.default { "" }
|
|
30
|
-
attribute :content_index, Types::Integer
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
class AssistantStreamMessageEvent < BaseStruct
|
|
34
|
-
EventType = Types::Coercible::Symbol.enum(:message_start, :message_delta, :message_end)
|
|
35
|
-
|
|
36
|
-
attribute :type, EventType
|
|
37
|
-
attribute :delta, Types::Coercible::Hash.default { {} }
|
|
38
|
-
attribute :usage_increment, Types::Coercible::Hash.default { {} }
|
|
39
|
-
end
|
|
40
|
-
|
|
41
12
|
class TextContent < BaseStruct
|
|
42
13
|
attribute :type, Types::String.enum("text")
|
|
43
14
|
attribute :text, Types::String
|
|
@@ -81,18 +52,142 @@ class ToolCall < BaseStruct
|
|
|
81
52
|
end
|
|
82
53
|
end
|
|
83
54
|
|
|
55
|
+
class ServerToolCall < ToolCall
|
|
56
|
+
attribute :type, Types::String.enum("server_tool_use")
|
|
57
|
+
end
|
|
58
|
+
|
|
84
59
|
class ToolResult < BaseStruct
|
|
85
|
-
attribute :type, Types::String
|
|
60
|
+
attribute :type, Types::String
|
|
86
61
|
attribute :tool_use_id, Types::String
|
|
87
|
-
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
|
|
88
71
|
end
|
|
89
72
|
|
|
90
|
-
class
|
|
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
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class PartialAssistantMessage < BaseStruct
|
|
91
85
|
ContentBlock =
|
|
92
86
|
Types.Instance(TextContent) |
|
|
93
87
|
Types.Instance(ReasoningContent) |
|
|
94
|
-
Types.Instance(ToolCall)
|
|
88
|
+
Types.Instance(ToolCall) |
|
|
89
|
+
Types.Instance(ServerToolCall) |
|
|
90
|
+
Types.Instance(ToolResult) |
|
|
91
|
+
Types.Instance(ServerToolResult)
|
|
92
|
+
|
|
93
|
+
attribute? :id, Types::String.optional
|
|
94
|
+
attribute? :model, Types::String.optional
|
|
95
|
+
attribute? :role, Types::String.enum("assistant").optional
|
|
96
|
+
attribute :timestamp, Types::Integer
|
|
97
|
+
attribute? :stop_reason, Types::String.enum("stop", "length", "tool_use", "toolUse", "error", "aborted").optional
|
|
98
|
+
attribute? :content, Types::Array.of(ContentBlock).optional
|
|
99
|
+
|
|
100
|
+
def self.new(attributes = {})
|
|
101
|
+
attrs = attributes.to_h.transform_keys(&:to_sym)
|
|
102
|
+
attrs[:content] = Array(attrs[:content]).map { |block| build_content_block(block) } if attrs.key?(:content)
|
|
103
|
+
super(attrs)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.build_content_block(block)
|
|
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)
|
|
108
|
+
|
|
109
|
+
case block[:type] || block["type"]
|
|
110
|
+
when "text"
|
|
111
|
+
TextContent.new(block)
|
|
112
|
+
when "reasoning"
|
|
113
|
+
ReasoningContent.new(block)
|
|
114
|
+
when "thinking"
|
|
115
|
+
ReasoningContent.new(type: "reasoning", reasoning: block[:thinking] || block["thinking"] || block[:reasoning] || block["reasoning"], signature: block[:signature] || block["signature"])
|
|
116
|
+
when "tool_use"
|
|
117
|
+
ToolCall.new(block)
|
|
118
|
+
when "server_tool_use"
|
|
119
|
+
ServerToolCall.new(block)
|
|
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
|
+
|
|
125
|
+
raise ArgumentError, "Unsupported content block type: #{block[:type] || block['type']}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private_class_method :build_content_block
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class AssistantStreamEvent < BaseStruct
|
|
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)
|
|
134
|
+
|
|
135
|
+
attribute :type, EventType
|
|
136
|
+
attribute :delta, Types::Coercible::String.default { "" }
|
|
137
|
+
attribute :content_index, Types::Integer
|
|
138
|
+
attribute :partial, Types.Instance(PartialAssistantMessage)
|
|
139
|
+
|
|
140
|
+
def content
|
|
141
|
+
case type
|
|
142
|
+
when :text_end
|
|
143
|
+
finalized_content_block&.text
|
|
144
|
+
when :reasoning_end
|
|
145
|
+
finalized_content_block&.reasoning
|
|
146
|
+
when :tool_end
|
|
147
|
+
finalized_content_block
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def text
|
|
152
|
+
content if type == :text_end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def reasoning
|
|
156
|
+
content if type == :reasoning_end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def tool_call
|
|
160
|
+
finalized_content_block if type == :tool_end
|
|
161
|
+
end
|
|
95
162
|
|
|
163
|
+
alias tool tool_call
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def finalized_content_block
|
|
168
|
+
partial.content&.[](content_index)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class AssistantToolStartEvent < AssistantStreamEvent
|
|
173
|
+
attribute :id, Types::String
|
|
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
|
|
182
|
+
attribute :content_index, Types::Integer
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
class AssistantStreamReasoningEvent < AssistantStreamEvent
|
|
186
|
+
attribute :signature, Types::Coercible::String.default { "" }
|
|
187
|
+
attribute :content_index, Types::Integer
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
class AssistantMessage < PartialAssistantMessage
|
|
96
191
|
attribute :id, Types::String
|
|
97
192
|
attribute :model, Types::String
|
|
98
193
|
attribute :usage, Types::Hash
|
|
@@ -103,12 +198,6 @@ class AssistantMessage < BaseStruct
|
|
|
103
198
|
attribute? :error_message, Types::String.optional
|
|
104
199
|
attribute :content, Types::Array.of(ContentBlock)
|
|
105
200
|
|
|
106
|
-
def self.new(attributes)
|
|
107
|
-
attrs = attributes.to_h.transform_keys(&:to_sym)
|
|
108
|
-
attrs[:content] = Array(attrs[:content]).map { |block| build_content_block(block) }
|
|
109
|
-
super(attrs)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
201
|
def to_h
|
|
113
202
|
result = {
|
|
114
203
|
id: id,
|
|
@@ -120,26 +209,22 @@ class AssistantMessage < BaseStruct
|
|
|
120
209
|
api: api,
|
|
121
210
|
content: content.map(&:to_h)
|
|
122
211
|
}
|
|
212
|
+
result[:timestamp] = timestamp unless timestamp.nil?
|
|
123
213
|
result[:error_message] = error_message unless error_message.nil?
|
|
124
214
|
result
|
|
125
215
|
end
|
|
216
|
+
end
|
|
126
217
|
|
|
127
|
-
|
|
128
|
-
|
|
218
|
+
class AssistantStreamMessageEvent < BaseStruct
|
|
219
|
+
EventType = Types::Coercible::Symbol.enum(:message_start, :message_delta)
|
|
129
220
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
when "thinking"
|
|
136
|
-
ReasoningContent.new(type: "reasoning", reasoning: block[:thinking] || block["thinking"] || block[:reasoning] || block["reasoning"], signature: block[:signature] || block["signature"])
|
|
137
|
-
when "tool_use"
|
|
138
|
-
ToolCall.new(block)
|
|
139
|
-
else
|
|
140
|
-
raise ArgumentError, "Unsupported content block type: #{block[:type] || block['type']}"
|
|
141
|
-
end
|
|
142
|
-
end
|
|
221
|
+
attribute :type, EventType
|
|
222
|
+
attribute :delta, Types::Coercible::Hash.default { {} }
|
|
223
|
+
attribute :usage, Types::Coercible::Hash.default { {} }
|
|
224
|
+
attribute :partial, Types.Instance(PartialAssistantMessage)
|
|
225
|
+
end
|
|
143
226
|
|
|
144
|
-
|
|
227
|
+
class AssistantStreamMessageEndEvent < BaseStruct
|
|
228
|
+
attribute :type, Types::Coercible::Symbol.enum(:message_end)
|
|
229
|
+
attribute :message, Types.Instance(AssistantMessage)
|
|
145
230
|
end
|
|
@@ -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
|