agent-harness 0.10.0 → 0.11.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/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +11 -0
- data/lib/agent_harness/conversation.rb +326 -0
- data/lib/agent_harness/mcp_server.rb +32 -0
- data/lib/agent_harness/openai_compatible_transport.rb +391 -0
- data/lib/agent_harness/provider_runtime.rb +40 -4
- data/lib/agent_harness/providers/adapter.rb +62 -3
- data/lib/agent_harness/providers/anthropic.rb +30 -0
- data/lib/agent_harness/providers/base.rb +142 -0
- data/lib/agent_harness/providers/github_copilot.rb +61 -0
- data/lib/agent_harness/text_transport.rb +320 -13
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 635aae919a5bbbf99af4a24199b45507370c01ccbf637bfd8d9d0fa18bdb3c22
|
|
4
|
+
data.tar.gz: 30548d834ae0195030e98565007ced6ebf140f12f9a489ae10a6d423c40e087f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac200425094b482ad90fd6492ba0bf4d612ed08560bacde517c988375d1d452b12aca7c34f8ed9519cf907daa87a07a96a4c044952126ce3cd9e37f2d6b9a788
|
|
7
|
+
data.tar.gz: a871de9fcc11224506f4220025016b3b7201ef97bc1e1aca918562c1f983b9dd175a93dbd6315a71a3a270234d3b9cd019f7deaa82030b984e11434ca86328f8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.11.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.10.0...agent-harness/v0.11.0) (2026-04-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add conversation manager for multi-turn chat ([#159](https://github.com/viamin/agent-harness/issues/159)) ([14f1d55](https://github.com/viamin/agent-harness/commit/14f1d551008c2d52a0aee7c2a7e2e0273f254578))
|
|
9
|
+
* add MCP HTTP transport support for servers ([#153](https://github.com/viamin/agent-harness/issues/153)) ([#155](https://github.com/viamin/agent-harness/issues/155)) ([8ea631a](https://github.com/viamin/agent-harness/commit/8ea631a3274ca4331ce42e8d63fc972cd48fbb12))
|
|
10
|
+
* add OpenAI-compatible chat transport ([#154](https://github.com/viamin/agent-harness/issues/154)) ([6005702](https://github.com/viamin/agent-harness/commit/60057029ba6eaaf81f65d42e487e6f0ca8cd159f))
|
|
11
|
+
* add provider chat capability with GitHub Models and Anthropic support ([#158](https://github.com/viamin/agent-harness/issues/158)) ([4188fa5](https://github.com/viamin/agent-harness/commit/4188fa542e6c4d330e5b230e54b1c1a5a55f4e8a))
|
|
12
|
+
* add structured streaming response observer for chat ([#157](https://github.com/viamin/agent-harness/issues/157)) ([225f4d9](https://github.com/viamin/agent-harness/commit/225f4d99b2b89d8eb030018236050672d3e47ba2))
|
|
13
|
+
|
|
3
14
|
## [0.10.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.9.0...agent-harness/v0.10.0) (2026-04-21)
|
|
4
15
|
|
|
5
16
|
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AgentHarness
|
|
6
|
+
# Manages multi-turn conversation history with token tracking and
|
|
7
|
+
# transport-specific message formatting.
|
|
8
|
+
#
|
|
9
|
+
# Encapsulates message storage, token budget awareness, context window
|
|
10
|
+
# truncation, and serialisation to OpenAI and Anthropic API formats.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# convo = AgentHarness::Conversation.new(system_prompt: "You are helpful.")
|
|
14
|
+
# convo.add_message(:user, "Hello")
|
|
15
|
+
# convo.add_message(:assistant, "Hi there!", tokens: { input: 10, output: 5 })
|
|
16
|
+
# convo.to_openai_messages
|
|
17
|
+
#
|
|
18
|
+
# @example Token-aware truncation
|
|
19
|
+
# convo = AgentHarness::Conversation.new(system_prompt: "...", token_limit: 8000)
|
|
20
|
+
# # ... add many messages ...
|
|
21
|
+
# convo.truncate(keep_recent: 4) if convo.approaching_limit?
|
|
22
|
+
class Conversation
|
|
23
|
+
VALID_ROLES = %i[system user assistant tool].freeze
|
|
24
|
+
|
|
25
|
+
# @return [Integer, nil] the token budget for this conversation
|
|
26
|
+
attr_reader :token_limit
|
|
27
|
+
|
|
28
|
+
# @param system_prompt [String, nil] optional system prompt prepended to messages
|
|
29
|
+
# @param token_limit [Integer, nil] optional context-window token budget
|
|
30
|
+
def initialize(system_prompt: nil, token_limit: nil)
|
|
31
|
+
@messages = []
|
|
32
|
+
@token_limit = token_limit
|
|
33
|
+
|
|
34
|
+
if system_prompt
|
|
35
|
+
add_message(:system, system_prompt)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Append a message to the conversation.
|
|
40
|
+
#
|
|
41
|
+
# @param role [Symbol] one of :system, :user, :assistant, :tool
|
|
42
|
+
# @param content [String, nil] message text
|
|
43
|
+
# @param metadata [Hash] optional fields — :tool_calls, :tool_call_id,
|
|
44
|
+
# :tool_name, :tool_arguments, :tool_result, :model, :tokens
|
|
45
|
+
# @return [Hash] the message that was added
|
|
46
|
+
# @raise [ArgumentError] if role is invalid
|
|
47
|
+
def add_message(role, content = nil, **metadata)
|
|
48
|
+
role = role.to_sym
|
|
49
|
+
unless VALID_ROLES.include?(role)
|
|
50
|
+
raise ArgumentError, "Invalid role: #{role}. Must be one of #{VALID_ROLES.join(", ")}"
|
|
51
|
+
end
|
|
52
|
+
if role == :system && !@messages.empty?
|
|
53
|
+
raise ArgumentError, "System messages are only allowed as the first message"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
message = {
|
|
57
|
+
role: role,
|
|
58
|
+
content: content,
|
|
59
|
+
created_at: Time.now
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
message[:tool_calls] = metadata[:tool_calls] if metadata[:tool_calls]
|
|
63
|
+
message[:tool_call_id] = metadata[:tool_call_id] if metadata[:tool_call_id]
|
|
64
|
+
message[:tool_name] = metadata[:tool_name] if metadata[:tool_name]
|
|
65
|
+
message[:tool_arguments] = metadata[:tool_arguments] if metadata[:tool_arguments]
|
|
66
|
+
message[:tool_result] = metadata[:tool_result] if metadata[:tool_result]
|
|
67
|
+
message[:model] = metadata[:model] if metadata[:model]
|
|
68
|
+
message[:tokens] = metadata[:tokens] if metadata[:tokens]
|
|
69
|
+
|
|
70
|
+
@messages << message
|
|
71
|
+
deep_copy(message)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns the full message history.
|
|
75
|
+
#
|
|
76
|
+
# @return [Array<Hash>] all messages in chronological order
|
|
77
|
+
def messages
|
|
78
|
+
deep_copy(@messages)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Integer] the number of messages in the conversation
|
|
82
|
+
def message_count
|
|
83
|
+
@messages.size
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Sum of all tracked tokens (input + output) across messages.
|
|
87
|
+
#
|
|
88
|
+
# @return [Integer] total tokens consumed
|
|
89
|
+
def token_count
|
|
90
|
+
@messages.sum do |msg|
|
|
91
|
+
tokens = msg[:tokens]
|
|
92
|
+
next 0 unless tokens
|
|
93
|
+
|
|
94
|
+
(tokens[:input] || 0) + (tokens[:output] || 0)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Tokens remaining before hitting the limit.
|
|
99
|
+
#
|
|
100
|
+
# @return [Integer, nil] remaining tokens, or nil when no limit is set
|
|
101
|
+
def token_remaining
|
|
102
|
+
return nil unless @token_limit
|
|
103
|
+
|
|
104
|
+
@token_limit - token_count
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether token usage has reached or exceeded the given threshold of the limit.
|
|
108
|
+
#
|
|
109
|
+
# @param threshold [Float] fraction of token_limit (0.0–1.0) at which to warn
|
|
110
|
+
# @return [Boolean] true when usage >= threshold * limit; false when no limit set
|
|
111
|
+
def approaching_limit?(threshold: 0.8)
|
|
112
|
+
return false unless @token_limit
|
|
113
|
+
|
|
114
|
+
token_count >= (threshold * @token_limit)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Remove oldest non-system messages to free context window.
|
|
118
|
+
#
|
|
119
|
+
# keep_recent counts conversational turns, not individual messages. A turn is
|
|
120
|
+
# anchored by a user message and includes any following assistant/tool
|
|
121
|
+
# messages up to the next user message.
|
|
122
|
+
#
|
|
123
|
+
# @param keep_recent [Integer, nil] minimum number of recent turns to preserve
|
|
124
|
+
# @param keep_system_prompt [Boolean] whether to preserve the system prompt
|
|
125
|
+
# @return [Integer] number of messages removed
|
|
126
|
+
def truncate(keep_recent: nil, keep_system_prompt: true)
|
|
127
|
+
original_size = @messages.size
|
|
128
|
+
system_message = initial_system_message
|
|
129
|
+
system_messages = (keep_system_prompt && system_message) ? [system_message] : []
|
|
130
|
+
non_system = system_message ? @messages.drop(1) : @messages
|
|
131
|
+
|
|
132
|
+
kept = if keep_recent
|
|
133
|
+
recent_turns(non_system, keep_recent).flatten
|
|
134
|
+
else
|
|
135
|
+
non_system
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@messages = system_messages + kept
|
|
139
|
+
original_size - @messages.size
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Format messages for OpenAI-compatible chat completions APIs.
|
|
143
|
+
#
|
|
144
|
+
# @return [Array<Hash>] messages with string roles and content
|
|
145
|
+
def to_openai_messages
|
|
146
|
+
@messages.map { |msg| openai_format(msg) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Format messages for the Anthropic Messages API.
|
|
150
|
+
#
|
|
151
|
+
# The system prompt is returned separately; tool results are wrapped as
|
|
152
|
+
# content blocks inside user messages per Anthropic's schema.
|
|
153
|
+
#
|
|
154
|
+
# @return [Hash] :system [String, nil] and :messages [Array<Hash>]
|
|
155
|
+
def to_anthropic_messages
|
|
156
|
+
system_prompt = initial_system_message&.dig(:content)
|
|
157
|
+
result_messages = []
|
|
158
|
+
|
|
159
|
+
start_index = system_prompt ? 1 : 0
|
|
160
|
+
@messages.drop(start_index).each do |msg|
|
|
161
|
+
case msg[:role]
|
|
162
|
+
when :user
|
|
163
|
+
result_messages << {
|
|
164
|
+
role: "user",
|
|
165
|
+
content: [{type: "text", text: msg[:content]}]
|
|
166
|
+
}
|
|
167
|
+
when :assistant
|
|
168
|
+
content_blocks = []
|
|
169
|
+
content_blocks << {type: "text", text: msg[:content]} if msg[:content]
|
|
170
|
+
|
|
171
|
+
msg[:tool_calls]&.each do |tc|
|
|
172
|
+
arguments = tool_call_arguments(tc)
|
|
173
|
+
parsed_arguments = if arguments.is_a?(String)
|
|
174
|
+
begin
|
|
175
|
+
JSON.parse(arguments)
|
|
176
|
+
rescue JSON::ParserError
|
|
177
|
+
arguments
|
|
178
|
+
end
|
|
179
|
+
else
|
|
180
|
+
arguments
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
content_blocks << {
|
|
184
|
+
type: "tool_use",
|
|
185
|
+
id: tool_call_value(tc, :id),
|
|
186
|
+
name: tool_call_name(tc),
|
|
187
|
+
input: parsed_arguments
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
result_messages << {role: "assistant", content: content_blocks}
|
|
192
|
+
when :tool
|
|
193
|
+
tool_result_block = {
|
|
194
|
+
type: "tool_result",
|
|
195
|
+
tool_use_id: msg[:tool_call_id],
|
|
196
|
+
content: msg[:content]
|
|
197
|
+
}
|
|
198
|
+
prev = result_messages.last
|
|
199
|
+
if prev && prev[:role] == "user" && prev[:content]&.first&.dig(:type) == "tool_result"
|
|
200
|
+
prev[:content] << tool_result_block
|
|
201
|
+
else
|
|
202
|
+
result_messages << {
|
|
203
|
+
role: "user",
|
|
204
|
+
content: [tool_result_block]
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
{system: system_prompt, messages: result_messages}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Returns the most recent assistant message, or nil.
|
|
214
|
+
#
|
|
215
|
+
# @return [Hash, nil]
|
|
216
|
+
def last_assistant_message
|
|
217
|
+
@messages.reverse_each do |msg|
|
|
218
|
+
return deep_copy(msg) if msg[:role] == :assistant
|
|
219
|
+
end
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Remove all messages except the system prompt.
|
|
224
|
+
#
|
|
225
|
+
# @return [void]
|
|
226
|
+
def clear!
|
|
227
|
+
system_message = initial_system_message
|
|
228
|
+
@messages = system_message ? [system_message] : []
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def initial_system_message
|
|
234
|
+
@messages.first if @messages.first&.dig(:role) == :system
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def recent_turns(non_system_messages, keep_recent)
|
|
238
|
+
turns = non_system_messages.each_with_object([]) do |msg, grouped_turns|
|
|
239
|
+
if msg[:role] == :user || grouped_turns.empty?
|
|
240
|
+
grouped_turns << [msg]
|
|
241
|
+
else
|
|
242
|
+
grouped_turns.last << msg
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
(keep_recent < turns.size) ? turns.last(keep_recent) : turns
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def openai_format(msg)
|
|
250
|
+
case msg[:role]
|
|
251
|
+
when :tool
|
|
252
|
+
{
|
|
253
|
+
role: "tool",
|
|
254
|
+
content: msg[:content],
|
|
255
|
+
tool_call_id: msg[:tool_call_id]
|
|
256
|
+
}
|
|
257
|
+
when :assistant
|
|
258
|
+
formatted = {role: "assistant", content: msg[:content]}
|
|
259
|
+
if msg[:tool_calls]
|
|
260
|
+
formatted[:tool_calls] = msg[:tool_calls].map do |tc|
|
|
261
|
+
{
|
|
262
|
+
id: tool_call_value(tc, :id),
|
|
263
|
+
type: "function",
|
|
264
|
+
function: {
|
|
265
|
+
name: tool_call_name(tc),
|
|
266
|
+
arguments: serialize_tool_call_arguments(tc)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
formatted
|
|
272
|
+
else
|
|
273
|
+
{role: msg[:role].to_s, content: msg[:content]}
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def deep_copy(value)
|
|
278
|
+
case value
|
|
279
|
+
when Array
|
|
280
|
+
value.map { |item| deep_copy(item) }
|
|
281
|
+
when Hash
|
|
282
|
+
value.each_with_object({}) do |(key, nested_value), copy|
|
|
283
|
+
copy[key] = deep_copy(nested_value)
|
|
284
|
+
end
|
|
285
|
+
else
|
|
286
|
+
begin
|
|
287
|
+
value.dup
|
|
288
|
+
rescue TypeError
|
|
289
|
+
value
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def serialize_tool_call_arguments(tool_call)
|
|
295
|
+
arguments = tool_call_arguments(tool_call)
|
|
296
|
+
arguments.is_a?(Hash) ? JSON.generate(arguments) : arguments
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def tool_call_name(tool_call)
|
|
300
|
+
tool_call_value(tool_call, :name) || nested_tool_call_value(tool_call, :function, :name)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def tool_call_arguments(tool_call)
|
|
304
|
+
tool_call_value(tool_call, :arguments) || nested_tool_call_value(tool_call, :function, :arguments)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def nested_tool_call_value(tool_call, *keys)
|
|
308
|
+
value = tool_call
|
|
309
|
+
keys.each do |key|
|
|
310
|
+
value = hash_value(value, key)
|
|
311
|
+
return nil if value.nil?
|
|
312
|
+
end
|
|
313
|
+
value
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def tool_call_value(tool_call, key)
|
|
317
|
+
hash_value(tool_call, key)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def hash_value(hash, key)
|
|
321
|
+
return nil unless hash.is_a?(Hash)
|
|
322
|
+
|
|
323
|
+
hash[key] || hash[key.to_s]
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -75,6 +75,25 @@ module AgentHarness
|
|
|
75
75
|
%w[http sse].include?(@transport)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Check if the MCP server is reachable based on its transport type.
|
|
79
|
+
#
|
|
80
|
+
# For stdio servers, checks that a command is present.
|
|
81
|
+
# For HTTP/SSE servers, checks that a URL is present and the server
|
|
82
|
+
# responds to an HTTP HEAD request.
|
|
83
|
+
#
|
|
84
|
+
# @param timeout [Integer] HTTP request timeout in seconds (default: 5)
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def reachable?(timeout: 5)
|
|
87
|
+
case transport
|
|
88
|
+
when "stdio"
|
|
89
|
+
!command.nil? && !command.empty?
|
|
90
|
+
when "http", "sse"
|
|
91
|
+
!url.nil? && !url.to_s.strip.empty? && http_ping_ok?(timeout: timeout)
|
|
92
|
+
else
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
78
97
|
def to_h
|
|
79
98
|
h = {name: @name, transport: @transport}
|
|
80
99
|
if stdio?
|
|
@@ -153,5 +172,18 @@ module AgentHarness
|
|
|
153
172
|
raise McpConfigurationError,
|
|
154
173
|
"MCP server '#{@name}' with #{@transport} transport should not have args (args are only valid for stdio)"
|
|
155
174
|
end
|
|
175
|
+
|
|
176
|
+
def http_ping_ok?(timeout: 5)
|
|
177
|
+
require "net/http"
|
|
178
|
+
uri = URI.parse(@url)
|
|
179
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
180
|
+
http.use_ssl = (uri.scheme == "https")
|
|
181
|
+
http.open_timeout = timeout
|
|
182
|
+
http.read_timeout = timeout
|
|
183
|
+
response = http.head(uri.request_uri)
|
|
184
|
+
response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
|
|
185
|
+
rescue
|
|
186
|
+
false
|
|
187
|
+
end
|
|
156
188
|
end
|
|
157
189
|
end
|