Skip to content

Commit 1561770

Browse files
committed
feat(ruby): persist tools and evolve artifacts across sessions
1 parent c124860 commit 1561770

18 files changed

Lines changed: 1673 additions & 6 deletions

bin/recurgent-tools

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "optparse"
5+
require "json"
6+
require_relative "../runtimes/ruby/lib/recurgent"
7+
8+
def usage!
9+
warn <<~USAGE
10+
Usage:
11+
bin/recurgent-tools list-stale [--days N] [--limit N] [--root PATH]
12+
bin/recurgent-tools prune [--days N] [--mode archive|delete] [--dry-run|--apply] [--root PATH]
13+
USAGE
14+
exit 1
15+
end
16+
17+
command = ARGV.shift
18+
usage! if command.nil?
19+
20+
options = {
21+
days: 30,
22+
limit: nil,
23+
root: Agent.default_toolstore_root,
24+
mode: "archive",
25+
dry_run: true
26+
}
27+
28+
parser = OptionParser.new do |opts|
29+
opts.on("--days N", Integer, "stale threshold in days (default: 30)") { |value| options[:days] = value }
30+
opts.on("--limit N", Integer, "max rows for list-stale") { |value| options[:limit] = value }
31+
opts.on("--root PATH", String, "toolstore root path") { |value| options[:root] = value }
32+
opts.on("--mode MODE", String, "prune mode: archive|delete (default: archive)") { |value| options[:mode] = value }
33+
opts.on("--dry-run", "preview prune changes (default)") { options[:dry_run] = true }
34+
opts.on("--apply", "apply prune changes") { options[:dry_run] = false }
35+
end
36+
parser.parse!(ARGV)
37+
38+
maintenance = Agent::ToolMaintenance.new(toolstore_root: options[:root])
39+
40+
case command
41+
when "list-stale"
42+
result = maintenance.list_stale_tools(stale_days: options[:days], limit: options[:limit])
43+
puts JSON.pretty_generate(result)
44+
when "prune"
45+
result = maintenance.prune_stale_tools(
46+
stale_days: options[:days],
47+
dry_run: options[:dry_run],
48+
mode: options[:mode]
49+
)
50+
puts JSON.pretty_generate(result)
51+
else
52+
usage!
53+
end

runtimes/ruby/lib/recurgent.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
require_relative "recurgent/observability"
1313
require_relative "recurgent/dependencies"
1414
require_relative "recurgent/runtime_config"
15+
require_relative "recurgent/known_tool_ranker"
16+
require_relative "recurgent/tool_store_paths"
17+
require_relative "recurgent/tool_store"
18+
require_relative "recurgent/artifact_metrics"
19+
require_relative "recurgent/artifact_store"
20+
require_relative "recurgent/artifact_selector"
21+
require_relative "recurgent/artifact_repair"
22+
require_relative "recurgent/persisted_execution"
23+
require_relative "recurgent/tool_maintenance"
1524
require_relative "recurgent/call_state"
1625
require_relative "recurgent/call_execution"
1726
require_relative "recurgent/worker_executor"
@@ -55,6 +64,10 @@ class Agent
5564
DEFAULT_DELEGATION_BUDGET = 8
5665
DEFAULT_GEM_SOURCES = ["https://rubygems.org"].freeze
5766
DEFAULT_SOURCE_MODE = "public_only"
67+
TOOLSTORE_SCHEMA_VERSION = 1
68+
PROMPT_VERSION = "2026-02-15.depth-aware.v3"
69+
MAX_REPAIRS_BEFORE_REGEN = 3
70+
KNOWN_TOOLS_PROMPT_LIMIT = 12
5871
CALL_STACK_KEY = :__recurgent_call_stack
5972
OUTCOME_CONTEXT_KEY = :__recurgent_outcome_context
6073
RUNTIME_NAME = "ruby"
@@ -105,6 +118,14 @@ class BudgetExceededError < Error; end
105118
include Prompting
106119
include Observability
107120
include Dependencies
121+
include KnownToolRanker
122+
include ToolStorePaths
123+
include ToolStore
124+
include ArtifactMetrics
125+
include ArtifactStore
126+
include ArtifactSelector
127+
include ArtifactRepair
128+
include PersistedExecution
108129
include CallExecution
109130
include WorkerExecution
110131

@@ -114,6 +135,11 @@ def self.default_log_path
114135
File.join(state_home, "recurgent", "recurgent.jsonl")
115136
end
116137

138+
def self.default_toolstore_root
139+
state_home = ENV.fetch("XDG_STATE_HOME", File.join(Dir.home, ".local", "state"))
140+
File.join(state_home, "recurgent", "tools")
141+
end
142+
117143
def self.for(role, purpose: nil, deliverable: nil, acceptance: nil, failure_policy: nil, delegation_contract: nil, **)
118144
contract, source = _compose_delegation_contract(
119145
delegation_contract,
@@ -183,6 +209,8 @@ def initialize(role, **options)
183209
@prep_ticket_id = nil
184210
@trace_id = _validate_trace_id(config[:trace_id] || _new_trace_id)
185211
@log_dir_exists = false
212+
213+
_hydrate_tool_registry!
186214
end
187215

188216
# -- The metaprogramming core -----------------------------------------------
@@ -584,6 +612,7 @@ def _register_delegated_tool(role:, tool:, explicit_purpose:)
584612
metadata.merge!(_delegated_tool_contract_summary(contract))
585613

586614
registry[role_name] = metadata
615+
_persist_tool_registry_entry(role_name, metadata)
587616
end
588617

589618
def _registered_tool_metadata(role_name)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# frozen_string_literal: true
2+
3+
class Agent
4+
# Agent::ArtifactMetrics — failure classification and artifact health accounting.
5+
module ArtifactMetrics
6+
EXTRINSIC_FAILURE_TYPES = %w[
7+
timeout
8+
provider
9+
network_error
10+
rate_limit
11+
rate_limited
12+
environment_preparing
13+
worker_crash
14+
dependency_resolution_failed
15+
dependency_install_failed
16+
dependency_activation_failed
17+
].freeze
18+
ADAPTIVE_FAILURE_TYPES = %w[
19+
parse_error
20+
parse_failed
21+
missing_input
22+
invalid_format
23+
schema_mismatch
24+
].freeze
25+
26+
private
27+
28+
def _artifact_update_metrics!(artifact, state)
29+
if state.outcome&.ok?
30+
_artifact_increment_success!(artifact)
31+
else
32+
_artifact_increment_failure!(artifact, state)
33+
end
34+
_artifact_update_failure_rate!(artifact)
35+
end
36+
37+
def _artifact_increment_success!(artifact)
38+
artifact["success_count"] = artifact["success_count"].to_i + 1
39+
end
40+
41+
def _artifact_increment_failure!(artifact, state)
42+
artifact["failure_count"] = artifact["failure_count"].to_i + 1
43+
failure_class = _artifact_failure_class(state)
44+
_artifact_increment_failure_counter!(artifact, failure_class)
45+
artifact["last_failure_class"] = failure_class
46+
artifact["last_failure_reason"] = state.outcome&.error_message || state.error&.message || "unknown failure"
47+
end
48+
49+
def _artifact_update_failure_rate!(artifact)
50+
successes = artifact["success_count"].to_i
51+
failures = artifact["failure_count"].to_i
52+
total = successes + failures
53+
artifact["recent_failure_rate"] = total.zero? ? 0.0 : (failures.to_f / total).round(4)
54+
end
55+
56+
def _artifact_increment_failure_counter!(artifact, failure_class)
57+
key = case failure_class
58+
when "extrinsic"
59+
"extrinsic_failure_count"
60+
when "adaptive"
61+
"adaptive_failure_count"
62+
else
63+
"intrinsic_failure_count"
64+
end
65+
artifact[key] = artifact[key].to_i + 1
66+
end
67+
68+
def _artifact_failure_class(state)
69+
_artifact_failure_class_for(outcome: state.outcome, error: state.error)
70+
end
71+
72+
def _artifact_failure_class_for(outcome:, error:)
73+
failure_type = outcome&.error_type.to_s
74+
return "extrinsic" if EXTRINSIC_FAILURE_TYPES.include?(failure_type)
75+
return "adaptive" if ADAPTIVE_FAILURE_TYPES.include?(failure_type)
76+
return "intrinsic" unless failure_type.empty?
77+
78+
return "extrinsic" if error.is_a?(TimeoutError) || error.is_a?(ProviderError)
79+
80+
"intrinsic"
81+
end
82+
end
83+
end
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# frozen_string_literal: true
2+
3+
class Agent
4+
# Agent::ArtifactRepair — persisted artifact repair flow with bounded retry budget.
5+
module ArtifactRepair
6+
private
7+
8+
def _repair_persisted_artifact(
9+
method_name:,
10+
args:,
11+
kwargs:,
12+
persisted_artifact:,
13+
failure_class:,
14+
failure_message:,
15+
state:
16+
)
17+
return nil unless _toolstore_repair_enabled?
18+
return nil unless _artifact_repair_budget_available?(persisted_artifact)
19+
20+
state.repair_attempted = true
21+
repair_user_prompt = _artifact_repair_user_prompt(
22+
method_name: method_name,
23+
args: args,
24+
kwargs: kwargs,
25+
persisted_artifact: persisted_artifact,
26+
failure_class: failure_class,
27+
failure_message: failure_message
28+
)
29+
repair_system_prompt = _build_system_prompt(call_context: _call_stack.last)
30+
31+
repaired_program, state.generation_attempt = _generate_program_with_retry(
32+
method_name,
33+
repair_system_prompt,
34+
repair_user_prompt
35+
)
36+
_capture_generated_program_state!(state, repaired_program)
37+
_mark_repaired_program_state!(state, trigger: _repair_trigger_for(failure_class))
38+
environment_info = _prepare_dependency_environment!(
39+
method_name: method_name,
40+
normalized_dependencies: state.normalized_dependencies
41+
)
42+
_capture_environment_state!(state, environment_info)
43+
44+
_execute_generated_program(
45+
method_name,
46+
state.code,
47+
args,
48+
kwargs,
49+
normalized_dependencies: state.normalized_dependencies,
50+
environment_info: environment_info,
51+
state: state
52+
)
53+
rescue ProviderError, ExecutionError, WorkerCrashError, NonSerializableResultError,
54+
InvalidDependencyManifestError, DependencyManifestIncompatibleError,
55+
DependencyPolicyViolationError, DependencyResolutionError,
56+
DependencyInstallError, DependencyActivationError => e
57+
warn "[AGENT REPAIR #{@role}.#{method_name}] repair attempt failed: #{e.class}: #{e.message}" if @debug
58+
nil
59+
end
60+
61+
def _artifact_repair_budget_available?(artifact)
62+
repair_count = artifact.fetch("repair_count_since_regen", 0).to_i
63+
repair_count < Agent::MAX_REPAIRS_BEFORE_REGEN
64+
end
65+
66+
def _repair_trigger_for(failure_class)
67+
case failure_class
68+
when "adaptive"
69+
"repair:adaptive_failure"
70+
when "intrinsic"
71+
"repair:intrinsic_failure"
72+
else
73+
"repair:unknown_failure"
74+
end
75+
end
76+
77+
def _artifact_repair_user_prompt(method_name:, args:, kwargs:, persisted_artifact:, failure_class:, failure_message:)
78+
<<~PROMPT
79+
<repair_invocation>
80+
<method>#{method_name}</method>
81+
<args>#{args.inspect}</args>
82+
<kwargs>#{kwargs.inspect}</kwargs>
83+
<failure_class>#{failure_class}</failure_class>
84+
<failure_message>#{failure_message}</failure_message>
85+
<existing_code>
86+
#{persisted_artifact.fetch("code", "")}
87+
</existing_code>
88+
<artifact_metadata>
89+
<prompt_version>#{persisted_artifact["prompt_version"]}</prompt_version>
90+
<contract_fingerprint>#{persisted_artifact["contract_fingerprint"]}</contract_fingerprint>
91+
</artifact_metadata>
92+
</repair_invocation>
93+
94+
<repair_goal>
95+
Repair this existing method implementation. Preserve intent and contract compatibility.
96+
Do not invent new capabilities. Ensure returned code executes for the provided args/kwargs.
97+
</repair_goal>
98+
99+
<response_contract>
100+
- Return a GeneratedProgram payload with `code` and optional `dependencies`.
101+
- Set `result` to the raw domain value, or use `return` in generated code.
102+
</response_contract>
103+
PROMPT
104+
end
105+
end
106+
end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
class Agent
4+
# Agent::ArtifactSelector — persisted artifact compatibility and selection policy.
5+
module ArtifactSelector
6+
private
7+
8+
def _select_persisted_artifact(method_name, state:)
9+
return nil unless _toolstore_enabled? && _toolstore_artifact_read_enabled?
10+
11+
artifact = _artifact_load(method_name)
12+
return nil unless artifact
13+
return nil unless _artifact_compatible_for_execution?(artifact)
14+
return nil if _artifact_degraded?(artifact)
15+
16+
state.artifact_prompt_version = artifact["prompt_version"]
17+
state.artifact_contract_fingerprint = artifact["contract_fingerprint"]
18+
artifact
19+
end
20+
21+
def _artifact_compatible_for_execution?(artifact)
22+
return false unless _artifact_runtime_compatible?(artifact)
23+
return false unless _artifact_contract_compatible?(artifact)
24+
25+
_artifact_checksum_valid?(artifact)
26+
end
27+
28+
def _artifact_runtime_compatible?(artifact)
29+
runtime_version = artifact["runtime_version"]
30+
return true if runtime_version.nil?
31+
32+
runtime_version.to_s == Agent::VERSION
33+
end
34+
35+
def _artifact_contract_compatible?(artifact)
36+
artifact.fetch("contract_fingerprint", "none") == _artifact_contract_fingerprint
37+
end
38+
39+
def _artifact_checksum_valid?(artifact)
40+
code = artifact.fetch("code", "").to_s
41+
return false if code.strip.empty?
42+
43+
checksum = artifact["code_checksum"].to_s
44+
checksum == _artifact_code_checksum(code)
45+
end
46+
47+
def _artifact_degraded?(artifact)
48+
failures = artifact.fetch("failure_count", 0).to_i
49+
successes = artifact.fetch("success_count", 0).to_i
50+
failure_rate = artifact.fetch("recent_failure_rate", 0.0).to_f
51+
52+
failures >= 3 && failure_rate > 0.6 && failures > successes
53+
end
54+
end
55+
end

0 commit comments

Comments
 (0)