agent-harness 0.9.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 +23 -0
- data/README.md +36 -5
- data/lib/agent_harness/authentication.rb +47 -7
- data/lib/agent_harness/conversation.rb +326 -0
- data/lib/agent_harness/errors.rb +3 -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/codex.rb +26 -3
- data/lib/agent_harness/providers/github_copilot.rb +130 -74
- data/lib/agent_harness/text_transport.rb +320 -13
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +28 -2
- 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,28 @@
|
|
|
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
|
+
|
|
14
|
+
## [0.10.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.9.0...agent-harness/v0.10.0) (2026-04-21)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **codex:** expose JSONL transcript parser ([#148](https://github.com/viamin/agent-harness/issues/148)) ([05312ea](https://github.com/viamin/agent-harness/commit/05312eaf9c11fff50931e511ee6e534838eb8746))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **copilot:** github-copilot-cli does not support the -p flag used by build_command ([#141](https://github.com/viamin/agent-harness/issues/141)) ([d06fbc4](https://github.com/viamin/agent-harness/commit/d06fbc414489d6c3bc93a122d0eb2a5771ddbb26))
|
|
25
|
+
|
|
3
26
|
## [0.9.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.8.0...agent-harness/v0.9.0) (2026-04-19)
|
|
4
27
|
|
|
5
28
|
|
data/README.md
CHANGED
|
@@ -501,6 +501,29 @@ AgentHarness.auth_status(:claude)
|
|
|
501
501
|
|
|
502
502
|
For providers without a built-in auth check (including `:api_key` providers), `auth_valid?` returns `false` and `auth_status` returns an error indicating the check is not implemented. Custom providers can implement an `auth_status` instance method to provide their own check.
|
|
503
503
|
|
|
504
|
+
### Auth Flow Capabilities
|
|
505
|
+
|
|
506
|
+
Before rendering provider-specific auth controls, check whether the flow is supported:
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
AgentHarness.auth_url_supported?(:claude)
|
|
510
|
+
# => true
|
|
511
|
+
|
|
512
|
+
AgentHarness.auth_url_supported?(:codex)
|
|
513
|
+
# => false
|
|
514
|
+
|
|
515
|
+
AgentHarness.refresh_auth_supported?(:claude)
|
|
516
|
+
# => true
|
|
517
|
+
|
|
518
|
+
AgentHarness.refresh_auth_supported?(:codex)
|
|
519
|
+
# => false
|
|
520
|
+
|
|
521
|
+
AgentHarness.auth_capabilities(:codex)
|
|
522
|
+
# => { auth_type: :api_key, auth_url: false, refresh: false }
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Provider aliases are resolved the same way as other auth APIs, so `:anthropic` reports the same capabilities as `:claude`. Unknown providers raise `AgentHarness::ProviderNotFoundError`, matching `auth_url` and `refresh_auth` provider lookup behavior.
|
|
526
|
+
|
|
504
527
|
### Auth Error Detection
|
|
505
528
|
|
|
506
529
|
When a CLI agent fails due to expired or invalid authentication, `send_message` raises `AuthenticationError` with the provider name. Authentication errors are always surfaced directly to the caller (never auto-switched to another provider) so your application can trigger the appropriate re-auth flow:
|
|
@@ -509,9 +532,15 @@ When a CLI agent fails due to expired or invalid authentication, `send_message`
|
|
|
509
532
|
begin
|
|
510
533
|
AgentHarness.send_message("Hello", provider: :claude)
|
|
511
534
|
rescue AgentHarness::AuthenticationError => e
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
535
|
+
provider = e.provider
|
|
536
|
+
|
|
537
|
+
if AgentHarness.auth_url_supported?(provider)
|
|
538
|
+
redirect_to AgentHarness.auth_url(provider)
|
|
539
|
+
elsif AgentHarness.refresh_auth_supported?(provider)
|
|
540
|
+
render :reauth_token_form, locals: { provider: provider }
|
|
541
|
+
else
|
|
542
|
+
render :auth_expired_without_refresh, locals: { provider: provider, message: e.message }
|
|
543
|
+
end
|
|
515
544
|
end
|
|
516
545
|
```
|
|
517
546
|
|
|
@@ -524,7 +553,7 @@ AgentHarness.auth_url(:claude)
|
|
|
524
553
|
# => "https://claude.ai/oauth/authorize"
|
|
525
554
|
```
|
|
526
555
|
|
|
527
|
-
This raises `
|
|
556
|
+
This raises `AgentHarness::UnsupportedAuthFlowError` for `:api_key` providers or providers whose OAuth URL flow is not implemented. The exception inherits from `AgentHarness::Error` and `StandardError`, so host applications can rescue it with their normal app-level error handling.
|
|
528
557
|
|
|
529
558
|
### Credential Refresh
|
|
530
559
|
|
|
@@ -537,7 +566,9 @@ AgentHarness.refresh_auth(:claude, token: "new-oauth-token")
|
|
|
537
566
|
|
|
538
567
|
Any existing expiry metadata in the credentials file is cleared on refresh so that `auth_valid?` returns `true` immediately after a successful refresh.
|
|
539
568
|
|
|
540
|
-
This raises `
|
|
569
|
+
This raises `AgentHarness::UnsupportedAuthFlowError` for `:api_key` providers or providers whose credential refresh flow is not implemented. Credential file paths respect the `CLAUDE_CONFIG_DIR` environment variable.
|
|
570
|
+
|
|
571
|
+
If you currently rescue `NotImplementedError` for unsupported auth URL generation or credential refresh, update that code to rescue `AgentHarness::UnsupportedAuthFlowError` or the broader `AgentHarness::Error` instead.
|
|
541
572
|
|
|
542
573
|
## Provider Health Checks
|
|
543
574
|
|
|
@@ -35,19 +35,46 @@ module AgentHarness
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Get authentication flow capabilities for a provider.
|
|
39
|
+
#
|
|
40
|
+
# @param provider_name [Symbol] the provider name
|
|
41
|
+
# @return [Hash] capabilities with :auth_type, :auth_url, :refresh keys
|
|
42
|
+
# @raise [ProviderNotFoundError] if provider is unknown
|
|
43
|
+
def auth_capabilities(provider_name)
|
|
44
|
+
provider_name = provider_name.to_sym
|
|
45
|
+
provider = resolve_provider(provider_name)
|
|
46
|
+
canonical_name = Providers::Registry.instance.canonical_name(provider_name)
|
|
47
|
+
flow_supported = claude_oauth_flow_provider?(provider_name, canonical_name)
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
auth_type: provider.auth_type,
|
|
51
|
+
auth_url: flow_supported,
|
|
52
|
+
refresh: flow_supported
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check whether OAuth URL generation is supported for a provider.
|
|
57
|
+
#
|
|
58
|
+
# @param provider_name [Symbol] the provider name
|
|
59
|
+
# @return [Boolean] true if auth_url can be called for the provider
|
|
60
|
+
# @raise [ProviderNotFoundError] if provider is unknown
|
|
61
|
+
def auth_url_supported?(provider_name)
|
|
62
|
+
auth_capabilities(provider_name)[:auth_url]
|
|
63
|
+
end
|
|
64
|
+
|
|
38
65
|
# Generate an OAuth URL for a provider
|
|
39
66
|
#
|
|
40
67
|
# Only supported for :oauth auth type providers.
|
|
41
68
|
#
|
|
42
69
|
# @param provider_name [Symbol] the provider name
|
|
43
70
|
# @return [String] the OAuth authorization URL
|
|
44
|
-
# @raise [
|
|
71
|
+
# @raise [UnsupportedAuthFlowError] if provider doesn't support OAuth
|
|
45
72
|
def auth_url(provider_name)
|
|
46
73
|
provider_name = provider_name.to_sym
|
|
47
74
|
provider = resolve_provider(provider_name)
|
|
48
75
|
|
|
49
76
|
unless provider.auth_type == :oauth
|
|
50
|
-
raise
|
|
77
|
+
raise UnsupportedAuthFlowError,
|
|
51
78
|
"Provider #{provider_name} uses #{provider.auth_type} auth and does not support OAuth URL generation"
|
|
52
79
|
end
|
|
53
80
|
|
|
@@ -55,29 +82,38 @@ module AgentHarness
|
|
|
55
82
|
when :claude, :anthropic
|
|
56
83
|
claude_auth_url
|
|
57
84
|
else
|
|
58
|
-
raise
|
|
85
|
+
raise UnsupportedAuthFlowError,
|
|
59
86
|
"OAuth URL generation is not yet implemented for provider #{provider_name}"
|
|
60
87
|
end
|
|
61
88
|
end
|
|
62
89
|
|
|
90
|
+
# Check whether credential refresh is supported for a provider.
|
|
91
|
+
#
|
|
92
|
+
# @param provider_name [Symbol] the provider name
|
|
93
|
+
# @return [Boolean] true if refresh_auth can be called for the provider
|
|
94
|
+
# @raise [ProviderNotFoundError] if provider is unknown
|
|
95
|
+
def refresh_auth_supported?(provider_name)
|
|
96
|
+
auth_capabilities(provider_name)[:refresh]
|
|
97
|
+
end
|
|
98
|
+
|
|
63
99
|
# Refresh authentication credentials for a provider
|
|
64
100
|
#
|
|
65
101
|
# For OAuth providers, stores a pre-exchanged token directly.
|
|
66
102
|
# This method accepts a token (not an authorization code) because
|
|
67
103
|
# the OAuth code-exchange flow is provider-specific and should be
|
|
68
104
|
# handled by the caller or a CLI login command before calling this.
|
|
69
|
-
# For API key providers, raises
|
|
105
|
+
# For API key providers, raises UnsupportedAuthFlowError.
|
|
70
106
|
#
|
|
71
107
|
# @param provider_name [Symbol] the provider name
|
|
72
108
|
# @param token [String] OAuth token to store (must be non-blank)
|
|
73
109
|
# @return [Hash] result with :success key
|
|
74
|
-
# @raise [
|
|
110
|
+
# @raise [UnsupportedAuthFlowError] if provider doesn't support credential refresh
|
|
75
111
|
def refresh_auth(provider_name, token: nil)
|
|
76
112
|
provider_name = provider_name.to_sym
|
|
77
113
|
provider = resolve_provider(provider_name)
|
|
78
114
|
|
|
79
115
|
unless provider.auth_type == :oauth
|
|
80
|
-
raise
|
|
116
|
+
raise UnsupportedAuthFlowError,
|
|
81
117
|
"Provider #{provider_name} uses #{provider.auth_type} auth and does not support credential refresh"
|
|
82
118
|
end
|
|
83
119
|
|
|
@@ -85,13 +121,17 @@ module AgentHarness
|
|
|
85
121
|
when :claude, :anthropic
|
|
86
122
|
refresh_claude_auth(token: token)
|
|
87
123
|
else
|
|
88
|
-
raise
|
|
124
|
+
raise UnsupportedAuthFlowError,
|
|
89
125
|
"Credential refresh is not yet implemented for provider #{provider_name}"
|
|
90
126
|
end
|
|
91
127
|
end
|
|
92
128
|
|
|
93
129
|
private
|
|
94
130
|
|
|
131
|
+
def claude_oauth_flow_provider?(requested_name, canonical_name)
|
|
132
|
+
[:claude, :anthropic].include?(requested_name) || canonical_name == :claude
|
|
133
|
+
end
|
|
134
|
+
|
|
95
135
|
def resolve_provider(provider_name)
|
|
96
136
|
klass = Providers::Registry.instance.get(provider_name)
|
|
97
137
|
canonical_name = Providers::Registry.instance.canonical_name(provider_name)
|
|
@@ -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
|
data/lib/agent_harness/errors.rb
CHANGED
|
@@ -66,6 +66,9 @@ module AgentHarness
|
|
|
66
66
|
# subscription to API-metered usage.
|
|
67
67
|
class AuthMismatchError < AuthenticationError; end
|
|
68
68
|
|
|
69
|
+
# Raised when a provider does not support the requested authentication flow.
|
|
70
|
+
class UnsupportedAuthFlowError < Error; end
|
|
71
|
+
|
|
69
72
|
# Configuration errors
|
|
70
73
|
class ConfigurationError < Error; end
|
|
71
74
|
|
|
@@ -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
|