agent-harness 0.16.1 → 0.17.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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/lib/agent_harness/provider_health_check.rb +12 -1
- data/lib/agent_harness/providers/adapter.rb +33 -0
- data/lib/agent_harness/providers/kilocode.rb +60 -0
- data/lib/agent_harness/providers/opencode.rb +60 -0
- data/lib/agent_harness/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68dcb8a37c35c6ded8f4d01e0f0d6d14e1588ac41a88f2b613fa2fbab9decc79
|
|
4
|
+
data.tar.gz: 6317ce0f0ef2aaadf13dfa39b6897d67a4b453bbb7b7b09db348366d33b8d993
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 59989e7197cf596e87ae4ffb87f252419d72f3c9c119de4f210fc4a41c7b3777187a69f1745e65f7a1cefb0010b87f316cd375c673de56e906e4b223364dac44
|
|
7
|
+
data.tar.gz: a691c948ee1fb9f3f6777b3624863b42024ecf85c84b5b320a0e258cf4f7467db7832e703048cee42272cd3e946c020321873788d48de225acaf00eaa8bee757
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.17.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.17.0...agent-harness/v0.17.1) (2026-05-05)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* 173: Smoke test contract timeout (30s) overrides caller timeout, breaking slow models ([#205](https://github.com/viamin/agent-harness/issues/205)) ([3a1e301](https://github.com/viamin/agent-harness/commit/3a1e301e36ef8957fd440f2782b3b8e4687b473c))
|
|
9
|
+
|
|
10
|
+
## [0.17.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.16.1...agent-harness/v0.17.0) (2026-05-03)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **extensions:** add activity heartbeat support for OpenCode/KiloCode-compatible providers ([#201](https://github.com/viamin/agent-harness/issues/201)) ([4914f6d](https://github.com/viamin/agent-harness/commit/4914f6d7971c55600f268b6886b967b753b25c96))
|
|
16
|
+
|
|
3
17
|
## [0.16.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.16.0...agent-harness/v0.16.1) (2026-05-03)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -324,7 +324,18 @@ module AgentHarness
|
|
|
324
324
|
# contract[:timeout]. When the provider overrides #smoke_test without
|
|
325
325
|
# publishing a contract, forward the validated health-check timeout so
|
|
326
326
|
# the override can honour it instead of running without any limit.
|
|
327
|
-
|
|
327
|
+
# However, when the caller explicitly provides a timeout that exceeds
|
|
328
|
+
# the contract timeout, honour the caller's intent so slow models
|
|
329
|
+
# are not prematurely killed by the contract default.
|
|
330
|
+
contract_timeout = smoke_contract&.dig(:timeout)
|
|
331
|
+
valid_contract_timeout = contract_timeout.is_a?(Numeric) && contract_timeout.positive?
|
|
332
|
+
smoke_timeout = if valid_contract_timeout && timeout && timeout > contract_timeout
|
|
333
|
+
timeout
|
|
334
|
+
elsif smoke_contract
|
|
335
|
+
nil
|
|
336
|
+
else
|
|
337
|
+
timeout
|
|
338
|
+
end
|
|
328
339
|
smoke = provider_instance.smoke_test(timeout: smoke_timeout, provider_runtime: provider_runtime)
|
|
329
340
|
unless smoke[:ok]
|
|
330
341
|
return build_result(
|
|
@@ -1187,6 +1187,39 @@ module AgentHarness
|
|
|
1187
1187
|
nil
|
|
1188
1188
|
end
|
|
1189
1189
|
|
|
1190
|
+
# Whether this provider supports activity heartbeat signaling.
|
|
1191
|
+
#
|
|
1192
|
+
# Providers that can emit a container-visible liveness signal during
|
|
1193
|
+
# long-running CLI execution return +true+. Downstream callers use
|
|
1194
|
+
# this to decide whether heartbeat-backed idle timeout is viable
|
|
1195
|
+
# without maintaining provider-name allowlists.
|
|
1196
|
+
#
|
|
1197
|
+
# @return [Boolean] true if the provider can emit heartbeat signals
|
|
1198
|
+
def supports_activity_heartbeat?
|
|
1199
|
+
false
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
# Return structured heartbeat integration wiring for this provider.
|
|
1203
|
+
#
|
|
1204
|
+
# Providers that support activity heartbeat return a Hash describing
|
|
1205
|
+
# how to wire heartbeat file touches into the CLI execution. The
|
|
1206
|
+
# returned contract includes environment variables, execution
|
|
1207
|
+
# preparation (config/plugin file writes), and the heartbeat
|
|
1208
|
+
# granularity so downstream callers can enable heartbeat-backed idle
|
|
1209
|
+
# timeout without hardcoding provider-specific config logic.
|
|
1210
|
+
#
|
|
1211
|
+
# @param heartbeat_file_path [String] absolute path to the heartbeat
|
|
1212
|
+
# file that should be touched on activity
|
|
1213
|
+
# @return [Hash] heartbeat integration contract:
|
|
1214
|
+
# - +:supported+ [Boolean] whether heartbeat is available
|
|
1215
|
+
# - +:env+ [Hash] environment variables to set (empty when unsupported)
|
|
1216
|
+
# - +:preparation+ [ExecutionPreparation, nil] file writes for config/plugin wiring
|
|
1217
|
+
# - +:granularity+ [Symbol, nil] heartbeat event granularity
|
|
1218
|
+
# (+:tool_call+, +:turn+, or +:progress+)
|
|
1219
|
+
def heartbeat_integration(heartbeat_file_path:)
|
|
1220
|
+
{supported: false, env: {}, preparation: nil, granularity: nil}
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1190
1223
|
private
|
|
1191
1224
|
|
|
1192
1225
|
def classify_smoke_test_message(message)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "shellwords"
|
|
4
5
|
|
|
5
6
|
module AgentHarness
|
|
6
7
|
module Providers
|
|
@@ -129,6 +130,39 @@ module AgentHarness
|
|
|
129
130
|
}
|
|
130
131
|
end
|
|
131
132
|
|
|
133
|
+
def supports_activity_heartbeat?
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def heartbeat_integration(heartbeat_file_path:)
|
|
138
|
+
unless heartbeat_file_path.is_a?(String) && !heartbeat_file_path.strip.empty?
|
|
139
|
+
raise ArgumentError, "heartbeat_file_path must be a non-empty String"
|
|
140
|
+
end
|
|
141
|
+
unless heartbeat_file_path.start_with?("/")
|
|
142
|
+
raise ArgumentError, "heartbeat_file_path must be an absolute path (got #{heartbeat_file_path.inspect})"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
hook_script = heartbeat_hook_script(heartbeat_file_path)
|
|
146
|
+
config_payload = merge_heartbeat_hooks(hook_script)
|
|
147
|
+
|
|
148
|
+
preparation = ExecutionPreparation.new(
|
|
149
|
+
file_writes: [
|
|
150
|
+
{
|
|
151
|
+
path: heartbeat_hook_config_path,
|
|
152
|
+
content: JSON.pretty_generate(config_payload),
|
|
153
|
+
mode: 0o600
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
supported: true,
|
|
160
|
+
env: {"KILO_HEARTBEAT_FILE" => heartbeat_file_path},
|
|
161
|
+
preparation: preparation,
|
|
162
|
+
granularity: :tool_call
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
132
166
|
def config_file_content(options = {})
|
|
133
167
|
# Only use explicit provider_name or default to "openai".
|
|
134
168
|
# api_provider is a generic backend label (e.g. "openrouter") that is not
|
|
@@ -298,6 +332,32 @@ module AgentHarness
|
|
|
298
332
|
|
|
299
333
|
private
|
|
300
334
|
|
|
335
|
+
def heartbeat_hook_script(heartbeat_file_path)
|
|
336
|
+
"touch #{Shellwords.escape(heartbeat_file_path)}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def heartbeat_hook_config_path
|
|
340
|
+
"~/.config/kilocode/hooks.json"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def merge_heartbeat_hooks(hook_script)
|
|
344
|
+
existing = load_existing_hooks_config(heartbeat_hook_config_path)
|
|
345
|
+
hooks = existing.fetch("hooks", {})
|
|
346
|
+
on_activity = hooks.fetch("on_activity", [])
|
|
347
|
+
on_activity = on_activity.dup
|
|
348
|
+
on_activity << {"command" => hook_script}
|
|
349
|
+
existing.merge("hooks" => hooks.merge("on_activity" => on_activity))
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def load_existing_hooks_config(path)
|
|
353
|
+
expanded = File.expand_path(path)
|
|
354
|
+
return {} unless File.exist?(expanded)
|
|
355
|
+
|
|
356
|
+
JSON.parse(File.read(expanded))
|
|
357
|
+
rescue JSON::ParserError
|
|
358
|
+
{}
|
|
359
|
+
end
|
|
360
|
+
|
|
301
361
|
def each_json_event(output)
|
|
302
362
|
return if output.nil? || output.empty?
|
|
303
363
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "shellwords"
|
|
4
5
|
|
|
5
6
|
module AgentHarness
|
|
6
7
|
module Providers
|
|
@@ -147,6 +148,39 @@ module AgentHarness
|
|
|
147
148
|
}
|
|
148
149
|
end
|
|
149
150
|
|
|
151
|
+
def supports_activity_heartbeat?
|
|
152
|
+
true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def heartbeat_integration(heartbeat_file_path:)
|
|
156
|
+
unless heartbeat_file_path.is_a?(String) && !heartbeat_file_path.strip.empty?
|
|
157
|
+
raise ArgumentError, "heartbeat_file_path must be a non-empty String"
|
|
158
|
+
end
|
|
159
|
+
unless heartbeat_file_path.start_with?("/")
|
|
160
|
+
raise ArgumentError, "heartbeat_file_path must be an absolute path (got #{heartbeat_file_path.inspect})"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
hook_script = heartbeat_hook_script(heartbeat_file_path)
|
|
164
|
+
config_payload = merge_heartbeat_hooks(hook_script)
|
|
165
|
+
|
|
166
|
+
preparation = ExecutionPreparation.new(
|
|
167
|
+
file_writes: [
|
|
168
|
+
{
|
|
169
|
+
path: heartbeat_hook_config_path,
|
|
170
|
+
content: serialize_opencode_config(config_payload),
|
|
171
|
+
mode: 0o600
|
|
172
|
+
}
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
supported: true,
|
|
178
|
+
env: {"OPENCODE_HEARTBEAT_FILE" => heartbeat_file_path},
|
|
179
|
+
preparation: preparation,
|
|
180
|
+
granularity: :tool_call
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
150
184
|
def error_patterns
|
|
151
185
|
COMMON_ERROR_PATTERNS
|
|
152
186
|
end
|
|
@@ -233,6 +267,32 @@ module AgentHarness
|
|
|
233
267
|
JSON.pretty_generate(payload)
|
|
234
268
|
end
|
|
235
269
|
|
|
270
|
+
def heartbeat_hook_script(heartbeat_file_path)
|
|
271
|
+
"touch #{Shellwords.escape(heartbeat_file_path)}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def heartbeat_hook_config_path
|
|
275
|
+
"~/.config/opencode/hooks.json"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def merge_heartbeat_hooks(hook_script)
|
|
279
|
+
existing = load_existing_hooks_config(heartbeat_hook_config_path)
|
|
280
|
+
hooks = existing.fetch("hooks", {})
|
|
281
|
+
on_activity = hooks.fetch("on_activity", [])
|
|
282
|
+
on_activity = on_activity.dup
|
|
283
|
+
on_activity << {"command" => hook_script}
|
|
284
|
+
existing.merge("hooks" => hooks.merge("on_activity" => on_activity))
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def load_existing_hooks_config(path)
|
|
288
|
+
expanded = File.expand_path(path)
|
|
289
|
+
return {} unless File.exist?(expanded)
|
|
290
|
+
|
|
291
|
+
JSON.parse(File.read(expanded))
|
|
292
|
+
rescue JSON::ParserError
|
|
293
|
+
{}
|
|
294
|
+
end
|
|
295
|
+
|
|
236
296
|
def stringify_keys(hash)
|
|
237
297
|
hash.each_with_object({}) do |(key, value), result|
|
|
238
298
|
result[key.to_s] = value
|