agent-harness 0.2.1

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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.markdownlint.yml +6 -0
  3. data/.markdownlintignore +8 -0
  4. data/.release-please-manifest.json +3 -0
  5. data/.rspec +3 -0
  6. data/.simplecov +26 -0
  7. data/.tool-versions +1 -0
  8. data/CHANGELOG.md +27 -0
  9. data/CODE_OF_CONDUCT.md +10 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +274 -0
  12. data/Rakefile +103 -0
  13. data/bin/console +11 -0
  14. data/bin/setup +8 -0
  15. data/lib/agent_harness/command_executor.rb +146 -0
  16. data/lib/agent_harness/configuration.rb +299 -0
  17. data/lib/agent_harness/error_taxonomy.rb +128 -0
  18. data/lib/agent_harness/errors.rb +63 -0
  19. data/lib/agent_harness/orchestration/circuit_breaker.rb +169 -0
  20. data/lib/agent_harness/orchestration/conductor.rb +179 -0
  21. data/lib/agent_harness/orchestration/health_monitor.rb +170 -0
  22. data/lib/agent_harness/orchestration/metrics.rb +167 -0
  23. data/lib/agent_harness/orchestration/provider_manager.rb +240 -0
  24. data/lib/agent_harness/orchestration/rate_limiter.rb +113 -0
  25. data/lib/agent_harness/providers/adapter.rb +163 -0
  26. data/lib/agent_harness/providers/aider.rb +109 -0
  27. data/lib/agent_harness/providers/anthropic.rb +345 -0
  28. data/lib/agent_harness/providers/base.rb +198 -0
  29. data/lib/agent_harness/providers/codex.rb +100 -0
  30. data/lib/agent_harness/providers/cursor.rb +281 -0
  31. data/lib/agent_harness/providers/gemini.rb +136 -0
  32. data/lib/agent_harness/providers/github_copilot.rb +155 -0
  33. data/lib/agent_harness/providers/kilocode.rb +73 -0
  34. data/lib/agent_harness/providers/opencode.rb +75 -0
  35. data/lib/agent_harness/providers/registry.rb +137 -0
  36. data/lib/agent_harness/response.rb +100 -0
  37. data/lib/agent_harness/token_tracker.rb +170 -0
  38. data/lib/agent_harness/version.rb +5 -0
  39. data/lib/agent_harness.rb +115 -0
  40. data/release-please-config.json +63 -0
  41. metadata +129 -0
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module AgentHarness
6
+ module Providers
7
+ # Registry for provider classes
8
+ #
9
+ # Manages registration and lookup of provider classes. Supports dynamic
10
+ # registration of custom providers and aliasing of provider names.
11
+ #
12
+ # @example Registering a custom provider
13
+ # AgentHarness::Providers::Registry.instance.register(:my_provider, MyProviderClass)
14
+ #
15
+ # @example Looking up a provider
16
+ # klass = AgentHarness::Providers::Registry.instance.get(:claude)
17
+ class Registry
18
+ include Singleton
19
+
20
+ def initialize
21
+ @providers = {}
22
+ @aliases = {}
23
+ @builtin_registered = false
24
+ end
25
+
26
+ # Register a provider class
27
+ #
28
+ # @param name [Symbol, String] the provider name
29
+ # @param klass [Class] the provider class
30
+ # @param aliases [Array<Symbol, String>] alternative names
31
+ # @return [void]
32
+ def register(name, klass, aliases: [])
33
+ name = name.to_sym
34
+ validate_provider_class!(klass)
35
+
36
+ @providers[name] = klass
37
+
38
+ aliases.each do |alias_name|
39
+ @aliases[alias_name.to_sym] = name
40
+ end
41
+
42
+ AgentHarness.logger&.debug("[AgentHarness::Registry] Registered provider: #{name}")
43
+ end
44
+
45
+ # Get provider class by name
46
+ #
47
+ # @param name [Symbol, String] the provider name
48
+ # @return [Class] the provider class
49
+ # @raise [ConfigurationError] if provider not found
50
+ def get(name)
51
+ ensure_builtin_providers_registered
52
+ name = resolve_alias(name.to_sym)
53
+ @providers[name] || raise(ConfigurationError, "Unknown provider: #{name}")
54
+ end
55
+
56
+ # Check if provider is registered
57
+ #
58
+ # @param name [Symbol, String] the provider name
59
+ # @return [Boolean] true if registered
60
+ def registered?(name)
61
+ ensure_builtin_providers_registered
62
+ name = resolve_alias(name.to_sym)
63
+ @providers.key?(name)
64
+ end
65
+
66
+ # List all registered provider names
67
+ #
68
+ # @return [Array<Symbol>] provider names
69
+ def all
70
+ ensure_builtin_providers_registered
71
+ @providers.keys
72
+ end
73
+
74
+ # List available providers (CLI installed)
75
+ #
76
+ # @return [Array<Symbol>] available provider names
77
+ def available
78
+ ensure_builtin_providers_registered
79
+ @providers.select { |_, klass| klass.available? }.keys
80
+ end
81
+
82
+ # Reset registry (useful for testing)
83
+ #
84
+ # @return [void]
85
+ def reset!
86
+ @providers.clear
87
+ @aliases.clear
88
+ @builtin_registered = false
89
+ end
90
+
91
+ private
92
+
93
+ def resolve_alias(name)
94
+ @aliases[name] || name
95
+ end
96
+
97
+ def validate_provider_class!(klass)
98
+ includes_adapter = klass.included_modules.include?(Adapter)
99
+ has_required_methods = klass.respond_to?(:provider_name) &&
100
+ klass.respond_to?(:available?) &&
101
+ klass.respond_to?(:binary_name)
102
+
103
+ return if includes_adapter || has_required_methods
104
+
105
+ raise ConfigurationError, "Provider class must include AgentHarness::Providers::Adapter or implement required class methods"
106
+ end
107
+
108
+ def ensure_builtin_providers_registered
109
+ return if @builtin_registered
110
+
111
+ register_builtin_providers
112
+ @builtin_registered = true
113
+ end
114
+
115
+ def register_builtin_providers
116
+ # Only register providers that exist
117
+ # These will be loaded on demand
118
+ register_if_available(:claude, "agent_harness/providers/anthropic", :Anthropic, aliases: [:anthropic])
119
+ register_if_available(:cursor, "agent_harness/providers/cursor", :Cursor)
120
+ register_if_available(:gemini, "agent_harness/providers/gemini", :Gemini)
121
+ register_if_available(:github_copilot, "agent_harness/providers/github_copilot", :GithubCopilot, aliases: [:copilot])
122
+ register_if_available(:codex, "agent_harness/providers/codex", :Codex)
123
+ register_if_available(:opencode, "agent_harness/providers/opencode", :Opencode)
124
+ register_if_available(:kilocode, "agent_harness/providers/kilocode", :Kilocode)
125
+ register_if_available(:aider, "agent_harness/providers/aider", :Aider)
126
+ end
127
+
128
+ def register_if_available(name, require_path, class_name, aliases: [])
129
+ require_relative require_path.sub("agent_harness/providers/", "")
130
+ klass = AgentHarness::Providers.const_get(class_name)
131
+ register(name, klass, aliases: aliases)
132
+ rescue LoadError, NameError => e
133
+ AgentHarness.logger&.debug("[AgentHarness::Registry] Provider #{name} not available: #{e.message}")
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ # Response object returned from provider send_message calls
5
+ #
6
+ # Contains the output, status, and metadata from a provider interaction.
7
+ #
8
+ # @example
9
+ # response = provider.send_message(prompt: "Hello")
10
+ # if response.success?
11
+ # puts response.output
12
+ # else
13
+ # puts "Error: #{response.error}"
14
+ # end
15
+ class Response
16
+ attr_reader :output, :exit_code, :duration, :provider, :model
17
+ attr_reader :tokens, :metadata, :error
18
+
19
+ # Create a new Response
20
+ #
21
+ # @param output [String] the output from the provider
22
+ # @param exit_code [Integer] the exit code (0 for success)
23
+ # @param duration [Float] execution duration in seconds
24
+ # @param provider [Symbol, String] the provider name
25
+ # @param model [String, nil] the model used
26
+ # @param tokens [Hash, nil] token usage information
27
+ # @param metadata [Hash] additional metadata
28
+ # @param error [String, nil] error message if failed
29
+ def initialize(output:, exit_code:, duration:, provider:, model: nil,
30
+ tokens: nil, metadata: {}, error: nil)
31
+ @output = output
32
+ @exit_code = exit_code
33
+ @duration = duration
34
+ @provider = provider.to_sym
35
+ @model = model
36
+ @tokens = tokens
37
+ @metadata = metadata
38
+ @error = error
39
+ end
40
+
41
+ # Check if the response indicates success
42
+ #
43
+ # @return [Boolean] true if exit_code is 0 and no error
44
+ def success?
45
+ @exit_code == 0 && @error.nil?
46
+ end
47
+
48
+ # Check if the response indicates failure
49
+ #
50
+ # @return [Boolean] true if not successful
51
+ def failed?
52
+ !success?
53
+ end
54
+
55
+ # Get total tokens used
56
+ #
57
+ # @return [Integer, nil] total tokens or nil if not tracked
58
+ def total_tokens
59
+ @tokens&.[](:total)
60
+ end
61
+
62
+ # Get input tokens used
63
+ #
64
+ # @return [Integer, nil] input tokens or nil if not tracked
65
+ def input_tokens
66
+ @tokens&.[](:input)
67
+ end
68
+
69
+ # Get output tokens used
70
+ #
71
+ # @return [Integer, nil] output tokens or nil if not tracked
72
+ def output_tokens
73
+ @tokens&.[](:output)
74
+ end
75
+
76
+ # Convert to hash representation
77
+ #
78
+ # @return [Hash] hash representation of the response
79
+ def to_h
80
+ {
81
+ output: @output,
82
+ exit_code: @exit_code,
83
+ duration: @duration,
84
+ provider: @provider,
85
+ model: @model,
86
+ tokens: @tokens,
87
+ metadata: @metadata,
88
+ error: @error,
89
+ success: success?
90
+ }
91
+ end
92
+
93
+ # String representation for debugging
94
+ #
95
+ # @return [String] debug string
96
+ def inspect
97
+ "#<AgentHarness::Response provider=#{@provider} success=#{success?} duration=#{@duration.round(2)}s>"
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module AgentHarness
6
+ # Tracks token usage across provider interactions
7
+ #
8
+ # Provides in-memory tracking of token usage with support for callbacks
9
+ # when tokens are used. Consumers can register callbacks to persist
10
+ # usage data externally.
11
+ #
12
+ # @example Basic usage
13
+ # tracker = AgentHarness::TokenTracker.new
14
+ # tracker.record(provider: :claude, input_tokens: 100, output_tokens: 50)
15
+ # puts tracker.summary
16
+ #
17
+ # @example With callback
18
+ # tracker.on_tokens_used do |event|
19
+ # MyDatabase.save_usage(event)
20
+ # end
21
+ class TokenTracker
22
+ # Token usage event structure
23
+ TokenEvent = Struct.new(
24
+ :provider, :model, :input_tokens, :output_tokens, :total_tokens,
25
+ :timestamp, :request_id,
26
+ keyword_init: true
27
+ )
28
+
29
+ def initialize
30
+ @events = []
31
+ @callbacks = []
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ # Record token usage
36
+ #
37
+ # @param provider [Symbol, String] the provider name
38
+ # @param model [String, nil] the model used
39
+ # @param input_tokens [Integer] input tokens used
40
+ # @param output_tokens [Integer] output tokens used
41
+ # @param total_tokens [Integer, nil] total tokens (calculated if nil)
42
+ # @param request_id [String, nil] unique request ID (generated if nil)
43
+ # @return [TokenEvent] the recorded event
44
+ def record(provider:, model: nil, input_tokens: 0, output_tokens: 0, total_tokens: nil, request_id: nil)
45
+ total = total_tokens || (input_tokens + output_tokens)
46
+
47
+ event = TokenEvent.new(
48
+ provider: provider.to_sym,
49
+ model: model,
50
+ input_tokens: input_tokens,
51
+ output_tokens: output_tokens,
52
+ total_tokens: total,
53
+ timestamp: Time.now,
54
+ request_id: request_id || SecureRandom.uuid
55
+ )
56
+
57
+ @mutex.synchronize do
58
+ @events << event
59
+ end
60
+
61
+ # Notify callbacks
62
+ notify_callbacks(event)
63
+
64
+ event
65
+ end
66
+
67
+ # Get usage summary
68
+ #
69
+ # @param since [Time, nil] only include events after this time
70
+ # @param provider [Symbol, String, nil] filter by provider
71
+ # @return [Hash] usage summary
72
+ def summary(since: nil, provider: nil)
73
+ events = filtered_events(since: since, provider: provider)
74
+
75
+ {
76
+ total_requests: events.size,
77
+ total_input_tokens: events.sum(&:input_tokens),
78
+ total_output_tokens: events.sum(&:output_tokens),
79
+ total_tokens: events.sum(&:total_tokens),
80
+ by_provider: group_by_provider(events),
81
+ by_model: group_by_model(events)
82
+ }
83
+ end
84
+
85
+ # Get recent events
86
+ #
87
+ # @param limit [Integer] maximum number of events to return
88
+ # @return [Array<TokenEvent>] recent events
89
+ def recent_events(limit: 100)
90
+ @mutex.synchronize do
91
+ @events.last(limit)
92
+ end
93
+ end
94
+
95
+ # Register callback for token events
96
+ #
97
+ # @yield [TokenEvent] called when tokens are recorded
98
+ # @return [void]
99
+ def on_tokens_used(&block)
100
+ @callbacks << block
101
+ end
102
+
103
+ # Clear all recorded events
104
+ #
105
+ # @return [void]
106
+ def clear!
107
+ @mutex.synchronize do
108
+ @events.clear
109
+ end
110
+ end
111
+
112
+ # Get total token count
113
+ #
114
+ # @param since [Time, nil] only include events after this time
115
+ # @return [Integer] total tokens used
116
+ def total_tokens(since: nil)
117
+ filtered_events(since: since).sum(&:total_tokens)
118
+ end
119
+
120
+ # Get event count
121
+ #
122
+ # @return [Integer] number of recorded events
123
+ def event_count
124
+ @mutex.synchronize do
125
+ @events.size
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def filtered_events(since: nil, provider: nil)
132
+ @mutex.synchronize do
133
+ events = @events.dup
134
+ events = events.select { |e| e.timestamp >= since } if since
135
+ events = events.select { |e| e.provider.to_s == provider.to_s } if provider
136
+ events
137
+ end
138
+ end
139
+
140
+ def group_by_provider(events)
141
+ events.group_by(&:provider).transform_values do |provider_events|
142
+ {
143
+ requests: provider_events.size,
144
+ input_tokens: provider_events.sum(&:input_tokens),
145
+ output_tokens: provider_events.sum(&:output_tokens),
146
+ total_tokens: provider_events.sum(&:total_tokens)
147
+ }
148
+ end
149
+ end
150
+
151
+ def group_by_model(events)
152
+ events.group_by { |e| "#{e.provider}:#{e.model}" }.transform_values do |model_events|
153
+ {
154
+ requests: model_events.size,
155
+ input_tokens: model_events.sum(&:input_tokens),
156
+ output_tokens: model_events.sum(&:output_tokens),
157
+ total_tokens: model_events.sum(&:total_tokens)
158
+ }
159
+ end
160
+ end
161
+
162
+ def notify_callbacks(event)
163
+ @callbacks.each do |callback|
164
+ callback.call(event)
165
+ rescue => e
166
+ AgentHarness.logger&.error("[AgentHarness::TokenTracker] Callback error: #{e.message}")
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ VERSION = "0.2.1"
5
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agent_harness/version"
4
+
5
+ # AgentHarness provides a unified interface for CLI-based AI coding agents.
6
+ #
7
+ # It offers:
8
+ # - Unified interface for multiple AI coding agents (Claude Code, Cursor, Gemini CLI, etc.)
9
+ # - Full orchestration layer with provider switching, circuit breakers, and health monitoring
10
+ # - Flexible configuration via YAML, Ruby DSL, or environment variables
11
+ # - Dynamic provider registration for custom provider support
12
+ # - Token usage tracking for cost and limit calculations
13
+ #
14
+ # @example Basic usage
15
+ # AgentHarness.send_message("Write a hello world function", provider: :claude)
16
+ #
17
+ # @example With configuration
18
+ # AgentHarness.configure do |config|
19
+ # config.logger = Logger.new(STDOUT)
20
+ # config.default_provider = :cursor
21
+ # end
22
+ #
23
+ # @example Direct provider access
24
+ # provider = AgentHarness.provider(:claude)
25
+ # provider.send_message(prompt: "Hello")
26
+ #
27
+ module AgentHarness
28
+ class Error < StandardError; end
29
+
30
+ class << self
31
+ # Returns the global configuration instance
32
+ # @return [Configuration] the configuration object
33
+ def configuration
34
+ @configuration ||= Configuration.new
35
+ end
36
+
37
+ # Configure AgentHarness with a block
38
+ # @yield [Configuration] the configuration object
39
+ # @return [void]
40
+ def configure
41
+ yield(configuration) if block_given?
42
+ end
43
+
44
+ # Reset configuration to defaults (useful for testing)
45
+ # @return [void]
46
+ def reset!
47
+ @configuration = nil
48
+ @conductor = nil
49
+ @token_tracker = nil
50
+ end
51
+
52
+ # Returns the global logger
53
+ # @return [Logger, nil] the configured logger
54
+ def logger
55
+ configuration.logger
56
+ end
57
+
58
+ # Returns the global token tracker
59
+ # @return [TokenTracker] the token tracker instance
60
+ def token_tracker
61
+ @token_tracker ||= TokenTracker.new
62
+ end
63
+
64
+ # Returns the global conductor for orchestrated requests
65
+ # @return [Orchestration::Conductor] the conductor instance
66
+ def conductor
67
+ @conductor ||= Orchestration::Conductor.new(config: configuration)
68
+ end
69
+
70
+ # Send a message using the orchestration layer
71
+ # @param prompt [String] the prompt to send
72
+ # @param provider [Symbol, nil] optional provider override
73
+ # @param options [Hash] additional options
74
+ # @return [Response] the response from the provider
75
+ def send_message(prompt, provider: nil, **options)
76
+ conductor.send_message(prompt, provider: provider, **options)
77
+ end
78
+
79
+ # Get a provider instance
80
+ # @param name [Symbol] the provider name
81
+ # @return [Providers::Base] the provider instance
82
+ def provider(name)
83
+ conductor.provider_manager.get_provider(name)
84
+ end
85
+ end
86
+ end
87
+
88
+ # Core components
89
+ require_relative "agent_harness/errors"
90
+ require_relative "agent_harness/configuration"
91
+ require_relative "agent_harness/command_executor"
92
+ require_relative "agent_harness/response"
93
+ require_relative "agent_harness/token_tracker"
94
+ require_relative "agent_harness/error_taxonomy"
95
+
96
+ # Provider layer
97
+ require_relative "agent_harness/providers/registry"
98
+ require_relative "agent_harness/providers/adapter"
99
+ require_relative "agent_harness/providers/base"
100
+ require_relative "agent_harness/providers/anthropic"
101
+ require_relative "agent_harness/providers/aider"
102
+ require_relative "agent_harness/providers/codex"
103
+ require_relative "agent_harness/providers/cursor"
104
+ require_relative "agent_harness/providers/gemini"
105
+ require_relative "agent_harness/providers/github_copilot"
106
+ require_relative "agent_harness/providers/kilocode"
107
+ require_relative "agent_harness/providers/opencode"
108
+
109
+ # Orchestration layer
110
+ require_relative "agent_harness/orchestration/circuit_breaker"
111
+ require_relative "agent_harness/orchestration/rate_limiter"
112
+ require_relative "agent_harness/orchestration/health_monitor"
113
+ require_relative "agent_harness/orchestration/metrics"
114
+ require_relative "agent_harness/orchestration/provider_manager"
115
+ require_relative "agent_harness/orchestration/conductor"
@@ -0,0 +1,63 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "ruby",
6
+ "package-name": "agent-harness",
7
+ "version-file": "lib/agent_harness/version.rb",
8
+ "extra-files": ["Gemfile.lock"],
9
+ "changelog-sections": [
10
+ {
11
+ "type": "feat",
12
+ "section": "Features",
13
+ "hidden": false
14
+ },
15
+ {
16
+ "type": "fix",
17
+ "section": "Bug Fixes",
18
+ "hidden": false
19
+ },
20
+ {
21
+ "type": "refactor",
22
+ "section": "Improvements",
23
+ "hidden": false
24
+ },
25
+ {
26
+ "type": "chore",
27
+ "section": "Maintenance",
28
+ "hidden": true
29
+ },
30
+ {
31
+ "type": "docs",
32
+ "section": "Documentation",
33
+ "hidden": false
34
+ },
35
+ {
36
+ "type": "test",
37
+ "section": "Testing",
38
+ "hidden": true
39
+ },
40
+ {
41
+ "type": "spec",
42
+ "section": "Test Specifications",
43
+ "hidden": true
44
+ },
45
+ {
46
+ "type": "ci",
47
+ "section": "CI/CD",
48
+ "hidden": true
49
+ },
50
+ {
51
+ "type": "deps",
52
+ "section": "Dependencies",
53
+ "hidden": false
54
+ },
55
+ {
56
+ "type": "BREAKING CHANGE",
57
+ "section": "Breaking Changes",
58
+ "hidden": false
59
+ }
60
+ ]
61
+ }
62
+ }
63
+ }