agent-harness 0.37.5 → 0.38.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 +7 -0
- data/README.md +18 -0
- data/lib/agent_harness/providers/codex.rb +29 -5
- data/lib/agent_harness/providers/codex_model_discovery.rb +88 -0
- data/lib/agent_harness/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07b7223207e544b8728f23b9800ddc774b0c70d16cdd899ccac23550e4a10270
|
|
4
|
+
data.tar.gz: d004705a37d7d83fa6e27f16f2b3593ffb81a06f30980e4aa699a92bb9f06237
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d59ea2adf8708b5eb6177fa2314d01e6eb83cb5b8d7748ccc19b7bc07394b1edea040015071df52d53ffb39b1684920092d393a94eeb690058356e05a4eafe5
|
|
7
|
+
data.tar.gz: d9851948407b188ed95b18413b1eb647245cfe76ea0770e1952972c2a3f83c2a8bd979c90161cc04d4af78241d7dcc83f197451523af233314c28b8d853f9f12
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* add runner model compatibility contract (`AgentHarness.model_compatibility`) with structured `ModelCompatibility::Result` outcomes. Codex exposes static facts for CLI-gated models (e.g. `gpt-5.5` requires Codex CLI `>= 0.116.0`), a baseline supported-model list, supported auth modes, and a `DEFAULT_COMPATIBLE_MODEL_ID` fallback so downstream orchestrators can validate tier/model assignments before scheduling agent runs ([#259](https://github.com/viamin/agent-harness/issues/259)).
|
|
6
6
|
* **auth:** add provider-owned PKCE code-exchange API for Claude OAuth (`AgentHarness::Authentication.exchange_code`). Takes an authorization code plus PKCE verifier (and `redirect_uri`/`client_id`), posts an `authorization_code` grant to the Claude token endpoint, and persists the resulting access/refresh tokens in the native `claudeAiOauth` shape. Adds `exchange_code_supported?` and a `code_exchange` key to `auth_capabilities` ([#266](https://github.com/viamin/agent-harness/issues/266)).
|
|
7
7
|
|
|
8
|
+
## [0.38.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.37.5...agent-harness/v0.38.0) (2026-09-24)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **codex:** expose account-local discovery for container recovery ([#428](https://github.com/viamin/agent-harness/issues/428)) ([a67c11f](https://github.com/viamin/agent-harness/commit/a67c11f46aaf99cd440cbb69edbd3272f3ed9312))
|
|
14
|
+
|
|
8
15
|
## [0.37.5](https://github.com/viamin/agent-harness/compare/agent-harness/v0.37.4...agent-harness/v0.37.5) (2026-09-21)
|
|
9
16
|
|
|
10
17
|
|
data/README.md
CHANGED
|
@@ -419,6 +419,24 @@ result.fallback_model_id # => "gpt-5.2-codex"
|
|
|
419
419
|
result.source # => :static_contract
|
|
420
420
|
```
|
|
421
421
|
|
|
422
|
+
For account-local Codex discovery, use the same executor and credential
|
|
423
|
+
environment as execution:
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
provider = AgentHarness::Providers::Codex.new(executor: container_executor)
|
|
427
|
+
discovery = provider.discover_available_models(env: subscription_env, timeout: 15)
|
|
428
|
+
discovery.models # normalized visible entries from model/list
|
|
429
|
+
discovery.recommended_model_id # provider default, not proof of execution
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
This method performs a fresh, paginated app-server exchange within the supplied
|
|
433
|
+
execution context. It supports execute-only container transports using Node
|
|
434
|
+
(already required by the Codex npm installation). It never changes auth mode or
|
|
435
|
+
selects a model on the caller's behalf. Apply project policy, verify the chosen
|
|
436
|
+
model with `smoke_test`, and persist successful evidence in the calling system.
|
|
437
|
+
`classify_model_rejection_from_result(stdout:, stderr:)` excludes ordinary
|
|
438
|
+
assistant/tool stdout from rejection classification.
|
|
439
|
+
|
|
422
440
|
Outcomes follow three explicit shapes:
|
|
423
441
|
|
|
424
442
|
- **Supported** — `result.supported?` is `true`. The runner contract
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "net/http"
|
|
5
5
|
require "uri"
|
|
6
|
+
require_relative "codex_model_discovery"
|
|
6
7
|
|
|
7
8
|
module AgentHarness
|
|
8
9
|
module Providers
|
|
@@ -12,6 +13,7 @@ module AgentHarness
|
|
|
12
13
|
class Codex < Base
|
|
13
14
|
include RateLimitResetParsing
|
|
14
15
|
include McpConfigFileSupport
|
|
16
|
+
include CodexModelDiscovery
|
|
15
17
|
|
|
16
18
|
StreamingEvent = Struct.new(
|
|
17
19
|
:type, :turn, :tokens, :error_message, :tool_name, :raw_event
|
|
@@ -546,6 +548,24 @@ module AgentHarness
|
|
|
546
548
|
parser_instance.send(:classify_model_rejection, output, configured_model: configured_model)
|
|
547
549
|
end
|
|
548
550
|
|
|
551
|
+
# Successful assistant/tool output can quote the exact rejection text.
|
|
552
|
+
# Only CLI stderr and explicit JSONL error envelopes are evidence.
|
|
553
|
+
def classify_model_rejection_from_result(stdout:, stderr:, configured_model: nil)
|
|
554
|
+
texts = stdout.to_s.each_line.filter_map do |line|
|
|
555
|
+
event = parse_stdout_jsonl_event(line.strip)
|
|
556
|
+
next unless event.is_a?(Hash)
|
|
557
|
+
|
|
558
|
+
event = unwrap_classification_event(event)
|
|
559
|
+
extract_jsonl_error_text(event) if event.is_a?(Hash)
|
|
560
|
+
end
|
|
561
|
+
texts << stderr.to_s
|
|
562
|
+
texts.each do |text|
|
|
563
|
+
rejection = classify_model_rejection(text, configured_model: configured_model)
|
|
564
|
+
return rejection if rejection
|
|
565
|
+
end
|
|
566
|
+
nil
|
|
567
|
+
end
|
|
568
|
+
|
|
549
569
|
private
|
|
550
570
|
|
|
551
571
|
def classify_stdout_chunk(text, buffer)
|
|
@@ -1095,11 +1115,15 @@ module AgentHarness
|
|
|
1095
1115
|
end
|
|
1096
1116
|
|
|
1097
1117
|
def fetch_model_list(rejected_model_id:, allowed_model_ids:, env:, timeout:)
|
|
1098
|
-
result = @executor.execute_interactive
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1118
|
+
result = if @executor.respond_to?(:execute_interactive)
|
|
1119
|
+
@executor.execute_interactive(
|
|
1120
|
+
[self.class.binary_name, "app-server", "--listen", "stdio://"],
|
|
1121
|
+
timeout: timeout,
|
|
1122
|
+
env: env
|
|
1123
|
+
) { |stdin, stdout| exchange_model_list_requests(stdin, stdout) }
|
|
1124
|
+
else
|
|
1125
|
+
execute_model_discovery(env: env, timeout: timeout)
|
|
1126
|
+
end
|
|
1103
1127
|
return unavailable_model_discovery(:app_server_failed, stderr: result.stderr) unless result.success?
|
|
1104
1128
|
|
|
1105
1129
|
response = parse_app_server_response(result.stdout, 2)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Runs the interactive protocol beside the CLI, including for executors that
|
|
6
|
+
# only expose execute (Docker/remote transports). Codex's npm installation
|
|
7
|
+
# already requires Node. No shell, host credentials, or host CLI is used.
|
|
8
|
+
module CodexModelDiscovery
|
|
9
|
+
SCRIPT = <<~'JS'
|
|
10
|
+
const { spawn } = require('node:child_process');
|
|
11
|
+
const child = spawn(process.argv[1], ['app-server', '--listen', 'stdio://'], {stdio: ['pipe', 'pipe', 'pipe']});
|
|
12
|
+
let buffer = '', bytes = 0, page = 0, done = false;
|
|
13
|
+
const models = [], cursors = new Set();
|
|
14
|
+
const send = value => child.stdin.write(JSON.stringify(value) + '\n');
|
|
15
|
+
const finish = (code, value) => {
|
|
16
|
+
if (done) return;
|
|
17
|
+
done = true;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
if (value) process.stdout.write(JSON.stringify(value) + '\n');
|
|
20
|
+
process.exitCode = code;
|
|
21
|
+
child.stdin.end();
|
|
22
|
+
child.kill('SIGTERM');
|
|
23
|
+
setTimeout(() => child.kill('SIGKILL'), 250).unref();
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => finish(1), Number(process.argv[2]));
|
|
26
|
+
child.on('error', () => finish(1));
|
|
27
|
+
child.stdin.on('error', () => finish(1));
|
|
28
|
+
child.stderr.on('data', () => {});
|
|
29
|
+
child.on('close', () => { if (!done) finish(1); });
|
|
30
|
+
child.stdout.on('data', chunk => {
|
|
31
|
+
bytes += chunk.length;
|
|
32
|
+
if (bytes > 1048576) return finish(1);
|
|
33
|
+
buffer += chunk.toString();
|
|
34
|
+
let end;
|
|
35
|
+
while (!done && (end = buffer.indexOf('\n')) >= 0) {
|
|
36
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
37
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
38
|
+
if (msg.id !== 1 && msg.id !== 2) continue;
|
|
39
|
+
if (msg.error) return finish(0, {id: 2, error: msg.error});
|
|
40
|
+
if (msg.id === 1) {
|
|
41
|
+
send({method: 'initialized', params: {}});
|
|
42
|
+
send({id: 2, method: 'model/list', params: {limit: 100}});
|
|
43
|
+
} else {
|
|
44
|
+
if (!Array.isArray(msg.result?.data)) return finish(1);
|
|
45
|
+
models.push(...msg.result.data);
|
|
46
|
+
const cursor = msg.result.nextCursor;
|
|
47
|
+
if (!cursor) return finish(0, {id: 2, result: {data: models}});
|
|
48
|
+
if (++page >= 10 || cursors.has(cursor)) return finish(1);
|
|
49
|
+
cursors.add(cursor);
|
|
50
|
+
send({id: 2, method: 'model/list', params: {limit: 100, cursor}});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
send({id: 1, method: 'initialize', params: {clientInfo: {name: 'agent-harness', version: '1.0.0'}}});
|
|
55
|
+
JS
|
|
56
|
+
|
|
57
|
+
# Fresh account-local discovery. The caller owns selection policy and must
|
|
58
|
+
# still smoke-test its selected model; model/list is not execution proof.
|
|
59
|
+
def discover_available_models(env:, timeout: 15)
|
|
60
|
+
result = execute_model_discovery(env: env, timeout: timeout)
|
|
61
|
+
return unavailable_model_discovery(:app_server_failed) unless result.success?
|
|
62
|
+
|
|
63
|
+
response = parse_app_server_response(result.stdout, 2)
|
|
64
|
+
return unavailable_model_discovery(:model_list_missing_response) unless response
|
|
65
|
+
return unavailable_model_discovery(:model_list_error) if response["error"]
|
|
66
|
+
|
|
67
|
+
entries = Array(response.dig("result", "data")).filter_map { |entry| normalize_model_entry(entry) }
|
|
68
|
+
.reject { |entry| entry[:hidden] }.uniq { |entry| entry[:id] }
|
|
69
|
+
return unavailable_model_discovery(:no_compatible_model) if entries.empty?
|
|
70
|
+
|
|
71
|
+
preferred = entries.find { |entry| entry[:is_default] } || entries.first
|
|
72
|
+
self.class::ModelDiscovery.new(status: :available, models: entries,
|
|
73
|
+
recommended_model_id: preferred[:id], source: :codex_app_server_model_list)
|
|
74
|
+
rescue TimeoutError
|
|
75
|
+
unavailable_model_discovery(:app_server_timeout)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def execute_model_discovery(env:, timeout:)
|
|
81
|
+
@executor.execute(
|
|
82
|
+
["node", "-e", SCRIPT, self.class.binary_name, [(timeout * 1000).to_i - 500, 100].max.to_s],
|
|
83
|
+
env: env, timeout: timeout
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agent-harness
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.38.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -128,6 +128,7 @@ files:
|
|
|
128
128
|
- lib/agent_harness/providers/anthropic.rb
|
|
129
129
|
- lib/agent_harness/providers/base.rb
|
|
130
130
|
- lib/agent_harness/providers/codex.rb
|
|
131
|
+
- lib/agent_harness/providers/codex_model_discovery.rb
|
|
131
132
|
- lib/agent_harness/providers/cursor.rb
|
|
132
133
|
- lib/agent_harness/providers/gemini.rb
|
|
133
134
|
- lib/agent_harness/providers/github_copilot.rb
|