- Status: accepted
- Date: 2026-02-14
Recurgent currently constrains generated code to Ruby stdlib. This keeps runtime behavior simple but blocks common tool tasks (HTML -> PDF, advanced parsing, rich API clients) and causes avoidable delegation churn when tools repeatedly hit capability limits.
We want tools to decide implementation dependencies while keeping Tool Builder language intent-first (purpose, deliverable, acceptance, failure_policy). The runtime must then materialize a deterministic execution environment from tool-declared dependencies.
Two hard constraints shape the design:
- Ruby cannot unload activated gems in-process.
- Recurgent
contextandresultcurrently support arbitrary Ruby objects in-process.
Provider adapters MUST return a GeneratedProgram payload:
{
"code": "Ruby code string",
"dependencies": [
{ "name": "prawn", "version": "~> 2.5" }
]
}Rules:
codeis required and MUST be non-empty.dependenciesis optional; default is[].- Backward compatibility: if provider returns only a String, runtime interprets it as
{ code: <string>, dependencies: [] }.
Runtime tool schema MUST be updated accordingly (provider-facing only):
{
"type": "object",
"properties": {
"code": { "type": "string" },
"dependencies": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"version": { "type": "string" }
},
"required": ["name"],
"additionalProperties": false
}
}
},
"required": ["code"],
"additionalProperties": false
}Runtime MUST normalize dependencies[] before any environment action.
Normalization algorithm:
- Validate each element is a Hash-like object.
name:- MUST match
/\A[a-zA-Z0-9_-]+\z/. - Normalize to lowercase.
- MUST match
version:- Optional.
- Default to
">= 0".
- Reject duplicate gem names with different version constraints in the same payload (
invalid_dependency_manifest). - Sort by
[name, version]. - Freeze normalized manifest for deterministic hashing and logging.
Environment contract v1 is tool-scoped and monotonic.
Definitions:
tool_instance_id: unique id for eachAgentinstance.env_manifest: normalized dependency manifest attached to tool instance.env_id: deterministic hash identity for one concrete Ruby environment.
Rules:
- On first successful call for a tool:
- runtime sets
env_manifest = normalized(dependencies).
- runtime sets
- On subsequent calls:
- if
dependenciesis empty, runtime reuses existingenv_manifest. - else runtime computes
incoming_manifest = normalized(dependencies)and applies compatibility:- each existing gem constraint in
env_manifestMUST remain identical inincoming_manifest. - gems MAY be added.
- existing gems MUST NOT be removed.
- each existing gem constraint in
- if
- If compatible, runtime sets
env_manifest = incoming_manifest(monotonic growth), recomputesenv_id, and migrates execution runtime to the new environment. - If incompatible, runtime returns
dependency_manifest_incompatible(retriable: false) with conflict metadata.
This preserves deterministic replay while allowing tools to discover dependencies incrementally.
env_id MUST include platform and engine characteristics:
sha256("engine:#{RUBY_ENGINE}|ruby:#{RUBY_VERSION}|patchlevel:#{RUBY_PATCHLEVEL}|platform:#{RUBY_PLATFORM}|deps:#{normalized_manifest_json}")
Rationale:
- Native extension compatibility differs by platform.
- Same manifest on different Ruby engines/patchlevels may not be equivalent.
For each new env_id, runtime MUST create:
$XDG_CACHE_HOME/recurgent/ruby-envs/<env_id>/
Contents:
Gemfile(generated)Gemfile.lock(resolved)vendor/bundle/(installed gems)
Generated Gemfile template:
source "https://rubygems.org"
# generated by Recurgent
gem "prawn", "~> 2.5"Materialization steps:
- Write Gemfile atomically.
- Run
bundle lockin env directory. - Run
bundle install --path vendor/bundle --jobs 4 --retry 2. - Mark environment ready by writing
.readymetadata file containing manifest and lock checksum. - Cache hit rule: if
.readyexists and lock checksum matches, skip lock/install.
If any step fails, runtime returns typed dependency error outcome.
- Parse and validate
GeneratedProgram. - Normalize dependency manifest.
- Log declarations and normalized manifest.
- Do not materialize environments yet.
Purpose: validate LLM declaration reliability with minimal runtime risk.
- Materialize env by
env_id. - Activate with Bundler in current process.
- Execute generated code in-process.
- Explicit limitation: gem activation pollution across calls/process lifetime is accepted temporarily.
Purpose: validate bundler pipeline and failure mapping before process-isolation complexity.
- Execute tool calls in dedicated worker process.
- Parent process performs supervision, timeout, and lifecycle management.
- Cross-process payloads use JSON boundary only.
Agent.for(...) remains synchronous.
To avoid constructor-level async complexity while supporting background environment prep, add:
Agent.prepare(role, **opts) -> PreparationTicketPreparationTicket#status(pending|ready|error)PreparationTicket#await(timeout: nil) -> Agent|OutcomePreparationTicket#agent -> Agent|nil- Optional callbacks:
PreparationTicket#on_ready { |agent| ... }PreparationTicket#on_error { |outcome| ... }
When a call arrives before environment readiness in async flow, runtime MAY return:
error_type: "environment_preparing", retriable: true
Runtime MUST execute generated code in a dedicated Ruby worker process bound to tool env_id.
Why:
- Gem activation isolation.
- No global gem pollution across tools.
- Stable per-tool context lifecycle.
Worker lifecycle:
- Spawn worker once tool
env_idis known. - Boot with:
BUNDLE_GEMFILE=<env_dir>/GemfileBUNDLE_PATH=<env_dir>/vendor/bundlerequire "bundler/setup"
- Worker owns tool
contextstate. - On monotonic env growth (
env_idchange), parent restarts worker with expanded environment and restores context snapshot when serializable.
Cross-process protocol:
- newline-delimited JSON frames (
ipc_version: 1). - Request fields:
ipc_version,call_id,method_name,code,args,kwargs,context_snapshot(optional).
- Response fields:
ipc_version,call_id,status,valueORerror_class/error_message,context_snapshot.
- JSON boundary rule:
- parent->worker args/kwargs MUST be JSON-serializable.
- worker->parent result/context MUST be JSON-serializable.
- non-serializable values return
non_serializable_result.
Parent runtime MUST provide:
- per-call timeout (kill and classify timeout if exceeded).
- idle timeout (terminate inactive workers).
- max concurrent workers.
- max restart count per tool within one trace.
- crash handling:
- map to
worker_crash,retriable: true(subject to restart budget).
- map to
- shutdown handling:
- SIGTERM then SIGKILL escalation.
- reap child processes on exit.
Add error types:
invalid_dependency_manifestdependency_manifest_incompatibledependency_policy_violationdependency_resolution_faileddependency_install_faileddependency_activation_failedenvironment_preparingworker_crashnon_serializable_result
retriable rules:
invalid_dependency_manifest->falsedependency_manifest_incompatible->falsedependency_policy_violation->falsedependency_resolution_failed->falsedependency_install_failed->truedependency_activation_failed->trueenvironment_preparing->trueworker_crash->truenon_serializable_result->false
Outcome error metadata MUST include:
tool_rolemethod_nameenv_id(if known)dependency_name(if applicable)conflict(for incompatible manifest cases)policy(for policy violations)
Each call log entry MUST include:
program_dependencies(raw from provider payload)normalized_dependenciesenv_idenvironment_cache_hit(boolean)env_prepare_msenv_resolve_msenv_install_msworker_pid(phase 3)worker_restart_count(phase 3)prep_ticket_id(ifAgent.prepareused)
Because tools choose dependencies, runtime MUST provide policy controls to constrain installs.
Configuration surface (runtime-level):
allowed_gems: [String] | nilblocked_gems: [String] | nil
Evaluation rules:
- Normalize policy gem names to lowercase.
- If
allowed_gemsis set, every dependency name MUST be inallowed_gems. - If
blocked_gemsis set, no dependency name may appear inblocked_gems. - If both are set, both checks apply.
- Policy check runs before resolve/install.
Failure mapping:
- Return
dependency_policy_violation(retriable: false). - Include metadata:
dependency_name,policy(allowed_gems|blocked_gems),tool_role,method_name.
Phase placement:
- Phase 1: policy fields MAY exist but no install path yet.
- Phase 2: policy enforcement becomes REQUIRED before materialization.
- Default policy MAY be permissive (
allowed_gems=nil,blocked_gems=nil) during interface-ergonomics iteration.
Dependency source configuration MUST be runtime-scoped (outside Agent/delegate contracts).
Rationale:
- Source trust and repository routing are platform governance concerns.
- Tool Builder/Tool language should remain intent-focused.
- Per-tool source selection introduces policy bypass ambiguity.
Runtime configuration fields:
gem_sources: [String]source_mode: internal_only | internal_then_public | public_onlyallowed_gems: [String] | nilblocked_gems: [String] | nil
Precedence:
- Process/runtime defaults.
- Optional environment profile overrides.
- Optional runtime constructor overrides.
- Session-level overrides MAY narrow policy but MUST NOT broaden trust.
Default (ergonomics-first) runtime policy:
gem_sources = ["https://rubygems.org"]source_mode = public_onlyallowed_gems = nil(allow any gem)blocked_gems = nil
Example (enterprise policy):
gem_sources = ["https://artifactory.example.org/api/gems/ruby"]source_mode = internal_onlyallowed_gems = [...](whitelist)
runtimes/ruby/lib/recurgent/generated_program.rbruntimes/ruby/lib/recurgent/dependency_manifest.rbruntimes/ruby/lib/recurgent/environment_manager.rbruntimes/ruby/lib/recurgent/worker_executor.rbruntimes/ruby/lib/recurgent/preparation_ticket.rbruntimes/ruby/lib/recurgent/worker_supervisor.rb
runtimes/ruby/lib/recurgent/providers.rb- Introduce
generate_program. - Keep compatibility shim so older adapters returning String still function.
- Introduce
runtimes/ruby/lib/recurgent/prompting.rb- Update provider tool schema to include
dependencies. - Add prompt instructions requiring non-stdlib dependencies be declared in payload.
- Update provider tool schema to include
runtimes/ruby/lib/recurgent.rb- Parse
GeneratedProgram. - Implement monotonic manifest growth checks.
- Implement
Agent.prepareAPI and ticket lifecycle. - Add dependency policy validation before environment materialization.
- Resolve runtime-level source/policy configuration before dependency checks.
- Phase 2: route execution through
EnvironmentManager+ in-process activation. - Phase 3: route execution through
WorkerSupervisor/WorkerExecutor. - Extend
_error_outcome_forand logging fields.
- Parse
runtimes/ruby/lib/recurgent/outcome.rb- No structural change required.
runtimes/ruby/lib/recurgent/environment_manager.rb- Generate Gemfile with configured
gem_sourcesand source mode behavior.
- Generate Gemfile with configured
spec/dependency_manifest_spec.rb- validation/normalization/conflict cases.
spec/environment_manager_spec.rb- deterministic
env_idincluding platform fields, cache hit, install failure mapping.
- deterministic
spec/worker_executor_spec.rb- JSON protocol behavior, non-serializable result mapping.
spec/worker_supervisor_spec.rb- restart policy, timeout, cleanup, max-workers.
spec/preparation_ticket_spec.rb- status transitions, await, callbacks.
- Extend
spec/recurgent_spec.rbGeneratedProgrambackward compatibility.- monotonic growth behavior.
environment_preparingbehavior.- dependency policy violation mapping (
dependency_policy_violation).
- Acceptance scenario:
- Phase 1: declaration and normalization logged.
- Phase 2: first install then cache hit with same env.
- Phase 3: worker restart on manifest growth with preserved JSON context.
- Runtime source policy scenario:
- default uses
rubygems.orgwith permissive allowlist. - internal-only profile routes install attempts exclusively to configured internal source.
- default uses
- Phase 1 exit:
-
=95% GeneratedProgram responses in acceptance runs produce valid manifest structures.
- no runtime behavior change for stdlib-only tools.
-
- Phase 2 exit:
- dependency install/activation failures are consistently typed.
- warm env cache calls avoid install path.
- Phase 3 exit:
- no zombie worker processes after full acceptance suite.
- timeout/restart behavior passes deterministic supervision tests.
- Tools can use the Ruby gem ecosystem without polluting Tool Builder-level language.
- Environment behavior becomes deterministic and observable.
- Tools can discover dependencies incrementally without forced redelegation.
- Capability failures become explicit and machine-actionable.
- Runtime can constrain dependency selection policy without polluting Tool Builder-facing API.
- Runtime complexity increases significantly (materialization + supervision + IPC).
- First-call latency for new envs remains high without prewarm/prepare flows.
- JSON boundary constrains arbitrary Ruby object exchange in worker-isolated mode.
-
Tool Builder-provided gem lists in
delegate(...).- Rejected: leaks implementation details into Tool Builder intent language.
-
Agent.for(...)becoming async.- Rejected: pollutes constructor semantics and introduces readiness race complexity in the primary API.
-
Marshal IPC as default parent/worker transport.
- Rejected: unsafe deserialization boundary for an LLM-generated execution system.
-
Direct jump to worker isolation without staged rollout.
- Rejected: too large a change surface to debug if schema/materialization assumptions are wrong.