summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.serena/project.yml114
-rw-r--r--README.md56
-rw-r--r--docs/PROJECT_FLOW.md133
-rw-r--r--lib/dsl.rb2
-rw-r--r--lib/dslkeywords/agent.rb37
-rw-r--r--lib/dslkeywords/file.rb255
-rw-r--r--lib/dslkeywords/file_backup.rb12
-rw-r--r--lib/dslkeywords/prompt.rb39
-rw-r--r--test/lib/dslkeywords/agent_test.rb733
-rw-r--r--test/lib/dslkeywords/file_test.rb136
-rw-r--r--test/support/mock_agent.rb46
11 files changed, 1482 insertions, 81 deletions
diff --git a/.serena/project.yml b/.serena/project.yml
index 5b72e88..392a540 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -3,21 +3,26 @@ project_name: "rcm"
# list of languages for which language servers are started; choose from:
-# al bash clojure cpp csharp
-# csharp_omnisharp dart elixir elm erlang
-# fortran fsharp go groovy haskell
-# java julia kotlin lua markdown
-# matlab nix pascal perl php
-# php_phpactor powershell python python_jedi r
-# rego ruby ruby_solargraph rust scala
-# swift terraform toml typescript typescript_vts
-# vue yaml zig
+# al angular ansible bash clojure
+# cpp cpp_ccls crystal csharp csharp_omnisharp
+# dart elixir elm erlang fortran
+# fsharp go groovy haskell haxe
+# hlsl html java json julia
+# kotlin lean4 lua luau markdown
+# matlab msl nix ocaml pascal
+# perl php php_phpactor powershell python
+# python_jedi python_ty r rego ruby
+# ruby_solargraph rust scala scss solidity
+# swift systemverilog terraform toml typescript
+# typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
+# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
+# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
@@ -52,52 +57,19 @@ ignored_paths: []
# Added on 2025-04-18
read_only: false
-# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
-# Below is the complete list of tools for convenience.
-# To make sure you have the latest list of tools, and to view their descriptions,
-# execute `uv run scripts/print_tool_overview.py`.
-#
-# * `activate_project`: Activates a project by name.
-# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
-# * `create_text_file`: Creates/overwrites a file in the project directory.
-# * `delete_lines`: Deletes a range of lines within a file.
-# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
-# * `execute_shell_command`: Executes a shell command.
-# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
-# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
-# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
-# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
-# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
-# * `initial_instructions`: Gets the initial instructions for the current project.
-# Should only be used in settings where the system prompt cannot be set,
-# e.g. in clients you have no control over, like Claude Desktop.
-# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
-# * `insert_at_line`: Inserts content at a given line in a file.
-# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
-# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
-# * `list_memories`: Lists memories in Serena's project-specific memory store.
-# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
-# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
-# * `read_file`: Reads a file within the project directory.
-# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
-# * `remove_project`: Removes a project from the Serena configuration.
-# * `replace_lines`: Replaces a range of lines within a file with new content.
-# * `replace_symbol_body`: Replaces the full definition of a symbol.
-# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
-# * `search_for_pattern`: Performs a search for a pattern in the project.
-# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
-# * `switch_modes`: Activates modes by providing a list of their names
-# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
-# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
-# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
-# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+# list of tool names to exclude.
+# This extends the existing exclusions (e.g. from the global configuration)
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
-# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
+# This extends the existing inclusions (e.g. from the global configuration).
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
@@ -108,11 +80,14 @@ fixed_tools: []
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
-# list of mode names that are to be activated by default.
-# The full set of modes to be activated is base_modes + default_modes.
-# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# list of mode names that are to be activated by default, overriding the setting in the global configuration.
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
+# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -128,3 +103,38 @@ symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
+
+# line ending convention to use when writing source files.
+# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
+# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
+line_ending:
+
+# list of regex patterns for memories to completely ignore.
+# Matching memories will not appear in list_memories or activate_project output
+# and cannot be accessed via read_memory or write_memory.
+# To access ignored memory files, use the read_file tool on the raw file path.
+# Extends the list from the global configuration, merging the two lists.
+# Example: ["_archive/.*", "_episodes/.*"]
+ignored_memory_patterns: []
+
+# advanced configuration option allowing to configure language server-specific options.
+# Maps the language key to the options.
+# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
+# No documentation on options means no options are available.
+ls_specific_settings: {}
+
+# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
+added_modes:
+
+# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
+# Paths can be absolute or relative to the project root.
+# Each folder is registered as an LSP workspace folder, enabling language servers to discover
+# symbols and references across package boundaries.
+# Currently supported for: TypeScript.
+# Example:
+# additional_workspace_folders:
+# - ../sibling-package
+# - ../shared-lib
+additional_workspace_folders: []
diff --git a/README.md b/README.md
index 05562a0..f2cb84c 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ This software has been written by a human by 90%, and only the last 10% were AI
- [DSL Reference](#dsl-reference)
- [configure / configure_from_scratch](#configure--configure_from_scratch)
- [file](#file)
+ - [agent / prompt / command](#agent--prompt--command)
- [touch](#touch)
- [symlink](#symlink)
- [directory](#directory)
@@ -201,6 +202,16 @@ file '/tmp/deep/nested/dir/config.txt' do
'content'
end
+# File content extended from a command
+command spell do
+ 'aspell list < FILE_PATH'
+end
+
+file '/tmp/post.txt' do
+ append from command spell
+ 'Draft text'
+end
+
# Named file with explicit path
file create config do
path '/etc/myapp.conf'
@@ -215,6 +226,51 @@ file '/tmp/obsolete.txt' do
end
```
+### agent / prompt / command
+
+Use `agent` definitions for the final file-processing command, `command`
+definitions for reusable prompt-time shell commands, and `prompt`
+definitions to compose the text passed to the agent.
+
+```ruby
+agent hexai do
+ retries 3
+ retry_delay 1
+ retry_backoff 2
+ 'hexai PROMPT'
+end
+
+command spell do
+ 'aspell list < FILE_PATH'
+end
+
+prompt fix english do
+ append from command spell
+ 'Correct spelling and grammar. Use the spell-check output as hints.'
+end
+
+file '/tmp/post.txt' do
+ agent hexai fix english
+end
+```
+
+Inside a prompt block, `append from command ...` and `prepend from command ...`
+insert the raw stdout of the named command after or before the static prompt
+text. `FILE_PATH` expands to the current file being processed.
+
+Agent-backed files are cached under `$XDG_CACHE_HOME/rcm/agents` (or
+`~/.cache/rcm/agents` when `XDG_CACHE_HOME` is unset). RCM reruns the
+agent only when the file content checksum changes or the selected
+agent, prompt, or prompt-command definition changes.
+
+Agent definitions retry failed invocations by default twice after the
+first run, for 3 total attempts. If the agent still fails, RCM skips
+that file and continues with the next resource. `retries` sets the
+number of extra attempts after the first run, `retry_delay` sets the
+initial wait in seconds, and `retry_backoff` multiplies that delay after
+each failure. This is useful for transient failures from remote-backed
+agents such as network timeouts.
+
### touch
Create empty files, like the Unix `touch` command.
diff --git a/docs/PROJECT_FLOW.md b/docs/PROJECT_FLOW.md
new file mode 100644
index 0000000..f8f0146
--- /dev/null
+++ b/docs/PROJECT_FLOW.md
@@ -0,0 +1,133 @@
+# RCM Project Flow
+
+This diagram shows how a configuration run moves from the entry point through parsing, registration, dependency resolution, and finally execution.
+
+## High-Level Flow
+
+```mermaid
+flowchart TD
+ subgraph Entry["Entry Point"]
+ A[configure / configure_from_scratch] --> B{reset?}
+ B -->|yes| C[DSL.reset!]
+ B -->|no| D[DSL.new]
+ end
+
+ subgraph Init["Bootstrap"]
+ D --> E[Options.parse!]
+ E --> F[Config.load!]
+ F --> G[DSL instance_eval &block]
+ end
+
+ subgraph Register["Registration"]
+ G --> H[file / package / touch / ...]
+ H --> I{conds_met?}
+ I -->|yes| J[Keyword.new]
+ I -->|no| Z1[skip]
+ J --> K[register obj in @@objs]
+ K --> L{is Resource?}
+ L -->|yes| M[add to @scheduled]
+ L -->|no| Z2[done]
+ end
+
+ subgraph Eval["Evaluation"]
+ N[evaluate!] --> O[foreach scheduled]
+ O --> P[Resource.evaluate!]
+ P --> Q{already evaluated?}
+ Q -->|yes| Z3[skip]
+ Q -->|no| R[loop detection check]
+ R --> S[resolve dependencies]
+ S --> T[run action / dry-run?]
+ T --> U[mark evaluated]
+ end
+
+ G -.-> N
+```
+
+## Class & Mixin Relationships
+
+```mermaid
+classDiagram
+ class Keyword {
+ +id
+ +dsl
+ +id_for(name)
+ }
+
+ class Resource {
+ +evaluated
+ +subclass_names
+ +find(id)
+ +evaluate!()
+ }
+
+ class DSL {
+ +conds_met
+ +register(obj)
+ +object!(klass, name)
+ +evaluate!()
+ +register_keyword(...)
+ }
+
+ class Config {
+ +load!()
+ +config(key)
+ }
+
+ class Options {
+ +parse!()
+ +option(key)
+ }
+
+ class Log {
+ +info(msg)
+ +debug(msg)
+ +warn(msg)
+ }
+
+ class Chained {
+ +method_missing()
+ }
+
+ class DryRun {
+ +do?(message)
+ }
+
+ class ResourceDependencies {
+ +requires(*others)
+ +requires?(*others)
+ }
+
+ class DependencyEvaluator {
+ +evaluate!()
+ }
+
+ Keyword <|-- Resource
+ DSL --> Keyword : registers
+ DSL --> Resource : schedules
+
+ Keyword ..> Log : includes
+ Keyword ..> Options : includes
+ DSL ..> Config : includes
+ DSL ..> Options : includes
+ DSL ..> Log : includes
+ DSL ..> Chained : includes
+
+ Resource ..> DryRun : includes
+ Resource ..> ResourceDependencies : includes
+ Resource ..> DependencyEvaluator : includes
+```
+
+## What Happens During a Run
+
+1. **Entry** — `configure(reset: true)` or `configure(reset: false)` is called.
+2. **Bootstrap** — `Options.parse!` reads CLI flags (`--debug`, `--dry`, `--hosts`) and `Config.load!` reads `config.toml`.
+3. **DSL block** — The user’s configuration block is executed inside the DSL instance via `instance_eval`.
+4. **Keyword creation** — Each keyword (`file`, `given`, `notify`, etc.) instantiates a `Keyword` or `Resource` subclass. Names are normalised via `Keyword.id_for`.
+5. **Registration** — The generic `register(obj)` stores the object in the class-level `@@objs` hash keyed by `id`. If the object is a `Resource`, it is also appended to `@scheduled`.
+6. **Conditionals** — `given { ... }` sets `@conds_met`. When false, subsequent keyword calls are skipped (no object created).
+7. **Evaluation** — After the block finishes, `DSL#evaluate!` iterates `@scheduled`. Each `Resource#evaluate!`:
+ - Checks for dependency loops.
+ - Recursively evaluates its `requires` dependencies.
+ - Executes the concrete action (file write, package install, etc.) unless `--dry` is active.
+ - Marks itself as evaluated.
+8. **Dry-run** — The `DryRun#do?` mixin wraps every side-effecting action. In dry mode it logs and returns without touching the system.
diff --git a/lib/dsl.rb b/lib/dsl.rb
index 0414050..ff147f3 100644
--- a/lib/dsl.rb
+++ b/lib/dsl.rb
@@ -7,6 +7,7 @@ require_relative 'log'
require_relative 'chained'
require_relative 'dslkeywords/agent'
+require_relative 'dslkeywords/command'
require_relative 'dslkeywords/prompt'
require_relative 'dslkeywords/file'
require_relative 'dslkeywords/symlink'
@@ -38,6 +39,7 @@ module RCM
class DuplicateResource < StandardError; end
class DuplicateDefinition < StandardError; end
class NoSuchAgentDefinition < StandardError; end
+ class NoSuchCommandDefinition < StandardError; end
class NoSuchPromptDefinition < StandardError; end
def initialize(reset)
diff --git a/lib/dslkeywords/agent.rb b/lib/dslkeywords/agent.rb
index 6632835..3d6874c 100644
--- a/lib/dslkeywords/agent.rb
+++ b/lib/dslkeywords/agent.rb
@@ -8,6 +8,7 @@ module RCM
attr_reader :name
class InvalidName < StandardError; end
+ class InvalidRetrySetting < StandardError; end
def self.id_for(name) = super(normalize_name(name))
@@ -20,6 +21,9 @@ module RCM
def initialize(name)
@name = self.class.normalize_name(name)
+ @retries = 2
+ @retry_delay = 1.0
+ @retry_backoff = 2.0
super(@name)
end
@@ -28,6 +32,39 @@ module RCM
@command = text.to_s
end
+
+ def retries(value = nil)
+ return @retries if value.nil?
+
+ @retries = Integer(value)
+ raise InvalidRetrySetting, 'Retry count must be non-negative' if @retries.negative?
+
+ @retries
+ rescue ArgumentError, TypeError
+ raise InvalidRetrySetting, "Invalid retry count: #{value.inspect}"
+ end
+
+ def retry_delay(value = nil)
+ return @retry_delay if value.nil?
+
+ @retry_delay = Float(value)
+ raise InvalidRetrySetting, 'Retry delay must be non-negative' if @retry_delay.negative?
+
+ @retry_delay
+ rescue ArgumentError, TypeError
+ raise InvalidRetrySetting, "Invalid retry delay: #{value.inspect}"
+ end
+
+ def retry_backoff(value = nil)
+ return @retry_backoff if value.nil?
+
+ @retry_backoff = Float(value)
+ raise InvalidRetrySetting, 'Retry backoff must be at least 1.0' if @retry_backoff < 1.0
+
+ @retry_backoff
+ rescue ArgumentError, TypeError
+ raise InvalidRetrySetting, "Invalid retry backoff: #{value.inspect}"
+ end
end
# Adds the `agent` definition keyword to the top-level DSL.
diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb
index 1e40691..06bb5ce 100644
--- a/lib/dslkeywords/file.rb
+++ b/lib/dslkeywords/file.rb
@@ -3,12 +3,14 @@
require 'digest'
require 'erb'
require 'fileutils'
+require 'json'
require 'open3'
require 'shellwords'
require 'tempfile'
require_relative 'resource'
require_relative '../chained'
+require_relative 'command'
require_relative 'file_backup'
module RCM
@@ -131,7 +133,11 @@ module RCM
# sourcefile reading. Touch and Directory extend BasicFile directly so
# they are not burdened with content/from (ISP).
class BaseFile < BasicFile
- def from(what) = @from = validate(__method__, what.to_sym, :sourcefile, :template)
+ def from(what)
+ return what if what.is_a?(CommandDefinition::Reference)
+
+ @from = validate(__method__, what.to_sym, :sourcefile, :template)
+ end
# Return or set the resource's content.
# Getter: resolves ERB templates or reads sourcefile on demand.
@@ -149,12 +155,22 @@ module RCM
# delete. Writes via a temp file so the final rename is atomic.
# rubocop:disable Metrics/ClassLength
class File < BaseFile
+ include CommandComposable
+
class AgentCommandFailed < StandardError; end
+ class CommandFailed < StandardError; end
+ class InvalidComposition < StandardError; end
class InvalidAgentSpec < StandardError; end
+ class InvalidAgentCacheRecord < StandardError; end
class MissingAgentInput < StandardError; end
attr_reader :agent_name, :prompt_name
+ def initialize(file_path)
+ super(file_path)
+ initialize_command_composition!
+ end
+
def agent(spec = nil, prompt_name = nil)
agent_name, prompt_name = resolved_agent_spec(spec, prompt_name)
@@ -170,6 +186,12 @@ module RCM
def line(line) = @ensure_line = line
+ def content(text = nil)
+ return render_composed_content(super(), @file_path) if text.nil?
+
+ super
+ end
+
def evaluate!
return unless super
@@ -254,16 +276,23 @@ module RCM
raise MissingAgentInput, "File #{@file_path} does not exist for agent processing" unless ::File.file?(@file_path)
agent_definition, prompt_definition = agent_configuration!
+ cache_state = agent_cache_state(agent_definition, prompt_definition)
+ return skip_fresh_agent_processing! unless cache_state[:stale]
- if option :dry
- info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name} - dry run!"
- return
- end
+ info "Agent cache is stale for #{@file_path}: #{cache_state[:reason]}"
+ process_stale_agent!(agent_definition, prompt_definition, cache_state)
+ rescue AgentCommandFailed => e
+ warn "#{e.message}. Skipping #{@file_path} and continuing"
+ end
+ def process_stale_agent!(agent_definition, prompt_definition, cache_state)
input = ::File.read(@file_path)
output = run_agent!(input, agent_definition, prompt_definition)
+ return if option :dry
+
create_parent_directory! unless ::File.directory?(::File.dirname(@file_path))
write!(output)
+ refresh_agent_cache_record!(cache_state[:record_path], cache_state[:definition_fingerprint])
end
# rubocop:disable Metrics/MethodLength
@@ -294,25 +323,121 @@ module RCM
end
# rubocop:enable Metrics/MethodLength
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def run_agent!(input, agent_definition, prompt_definition)
+ with_agent_input_file(input) do |input_path|
+ prompt_text = prompt_definition.render(@file_path)
+ return dry_run_agent_input(input) if option :dry
+
+ command = render_agent_command(agent_definition.command.to_s, prompt_text, input_path)
+ execute_agent_command_with_retries(command, input, agent_definition)
+ end
+ end
+
+ def execute_agent_command_with_retries(command, input, agent_definition)
+ attempt = 1
+ retry_state = agent_retry_state(agent_definition)
+
+ loop do
+ stdout, stderr, status = capture_agent_command(command, input, attempt, retry_state[:max_attempts])
+ return stdout if status.success?
+
+ retry_state[:delay] = handle_failed_agent_command(status, stderr, attempt, retry_state)
+ attempt += 1
+ end
+ end
+
+ def agent_retry_state(agent_definition)
+ {
+ max_attempts: agent_definition.retries + 1,
+ delay: agent_definition.retry_delay,
+ backoff: agent_definition.retry_backoff
+ }
+ end
+
+ def handle_failed_agent_command(status, stderr, attempt, retry_state)
+ failure_message = agent_command_failure_message(status, stderr, attempt, retry_state[:max_attempts])
+ retry_agent_command!(
+ failure_message,
+ attempt,
+ retry_state[:max_attempts],
+ retry_state[:delay],
+ retry_state[:backoff]
+ )
+ end
+
+ def with_agent_input_file(input)
Tempfile.create(['rcm-agent-input', '.txt']) do |tmp|
tmp.write(input)
tmp.flush
tmp.close
+ yield tmp.path
+ end
+ end
- command = render_agent_command(agent_definition.command.to_s, prompt_definition.text.to_s, tmp.path)
- info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}"
- stdout, stderr, status = Open3.capture3(command, stdin_data: input)
- return stdout if status.success?
+ def dry_run_agent_input(input)
+ info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name} - dry run!"
+ input
+ end
+
+ def capture_agent_command(command, input, attempt, max_attempts)
+ info agent_processing_message(attempt, max_attempts)
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thread|
+ stdin.write(input)
+ stdin.close
+
+ stdout_buffer, stderr_buffer, stream_threads = start_agent_streams(stdout, stderr)
+ stream_threads.each(&:join)
+
+ [stdout_buffer, stderr_buffer, wait_thread.value]
+ end
+ end
+
+ def retry_agent_command!(failure_message, attempt, max_attempts, retry_delay, retry_backoff)
+ raise AgentCommandFailed, failure_message if attempt >= max_attempts
- message = stderr.to_s.strip
- message = 'no stderr output' if message.empty?
- raise AgentCommandFailed,
- "Agent #{@agent_name} failed for #{@file_path} (exit #{status.exitstatus}): #{message}"
+ warn "#{failure_message}. Retrying in #{formatted_retry_delay(retry_delay)}s"
+ sleep(retry_delay) if retry_delay.positive?
+ retry_delay * retry_backoff
+ end
+
+ def stream_agent_io(source, buffer, destination)
+ loop do
+ chunk = source.readpartial(4096)
+ buffer << chunk
+ destination.write(chunk)
+ destination.flush
+ rescue EOFError
+ break
end
+
+ destination.puts unless buffer.empty? || buffer.end_with?("\n")
+ end
+
+ def start_agent_streams(stdout, stderr)
+ stdout_buffer = +''
+ stderr_buffer = +''
+ stream_threads = [
+ Thread.new { stream_agent_io(stdout, stdout_buffer, $stdout) },
+ Thread.new { stream_agent_io(stderr, stderr_buffer, $stderr) }
+ ]
+ [stdout_buffer, stderr_buffer, stream_threads]
+ end
+
+ def agent_processing_message(attempt, max_attempts)
+ suffix = max_attempts > 1 ? " (attempt #{attempt}/#{max_attempts})" : ''
+ "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}#{suffix}"
+ end
+
+ def agent_command_failure_message(status, stderr, attempt, max_attempts)
+ message = stderr.to_s.strip
+ message = 'no stderr output' if message.empty?
+ attempt_suffix = max_attempts > 1 ? ", attempt #{attempt}/#{max_attempts}" : ''
+ "Agent #{@agent_name} failed for #{@file_path} (exit #{status.exitstatus}#{attempt_suffix}): #{message}"
+ end
+
+ def formatted_retry_delay(retry_delay)
+ format('%.3g', retry_delay)
end
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
def agent_configuration!
[
@@ -328,6 +453,106 @@ module RCM
command.gsub!(/\bFILE_PATH\b/, Shellwords.escape(@file_path))
command
end
+
+ def agent_cache_state(agent_definition, prompt_definition)
+ file_checksum = checksum_for(@file_path)
+ definition_fingerprint = agent_definition_fingerprint(agent_definition, prompt_definition)
+ record_path = agent_cache_record_path
+ stale, reason = stale_agent_cache_record?(
+ load_agent_cache_record(record_path),
+ file_checksum,
+ definition_fingerprint
+ )
+
+ { stale:, reason:, record_path:, definition_fingerprint: }
+ end
+
+ def stale_agent_cache_record?(record, file_checksum, definition_fingerprint)
+ return [true, 'no track record'] if record.nil?
+ return [true, 'cache format changed'] if record['version'] != 2 || record['file_checksum'].nil?
+ return [true, 'file checksum changed'] if record['file_checksum'] != file_checksum
+ return [true, 'agent or prompt changed'] if record['definition_fingerprint'] != definition_fingerprint
+
+ [false, 'fresh']
+ end
+
+ def agent_definition_fingerprint(agent_definition, prompt_definition)
+ Digest::SHA256.hexdigest(
+ JSON.generate(
+ agent_command: agent_definition.command.to_s,
+ prompt: prompt_definition.fingerprint_source
+ )
+ )
+ end
+
+ def refresh_agent_cache_record!(record_path, definition_fingerprint)
+ persist_agent_cache_record!(
+ record_path,
+ agent_cache_record_payload(checksum_for(@file_path), definition_fingerprint)
+ )
+ end
+
+ def agent_cache_record_payload(file_checksum, definition_fingerprint)
+ {
+ version: 2,
+ file_path: expanded_file_path,
+ agent_name: @agent_name,
+ prompt_name: @prompt_name,
+ file_checksum:,
+ definition_fingerprint:,
+ recorded_at_ns: current_time_ns
+ }
+ end
+
+ def persist_agent_cache_record!(record_path, payload)
+ cache_dir = ::File.dirname(record_path)
+ ::FileUtils.mkdir_p(cache_dir)
+ tmp_path = "#{record_path}.tmp"
+ ::File.write(tmp_path, JSON.generate(payload))
+ ::File.rename(tmp_path, record_path)
+ rescue SystemCallError => e
+ warn "Unable to persist agent cache #{record_path}: #{e.message}"
+ ::File.delete(tmp_path) if defined?(tmp_path) && ::File.file?(tmp_path)
+ end
+
+ def load_agent_cache_record(record_path)
+ return unless ::File.file?(record_path)
+
+ JSON.parse(::File.read(record_path))
+ rescue JSON::ParserError, SystemCallError => e
+ warn "Ignoring invalid agent cache #{record_path}: #{e.message}"
+ nil
+ end
+
+ def agent_cache_record_path
+ key = Digest::SHA256.hexdigest(
+ JSON.generate(file_path: expanded_file_path, agent_name: @agent_name, prompt_name: @prompt_name)
+ )
+ ::File.join(agent_cache_dir, "#{key}.json")
+ end
+
+ def agent_cache_dir
+ cache_root = ENV['XDG_CACHE_HOME'] || ::File.expand_path('~/.cache')
+ ::File.join(cache_root, 'rcm', 'agents')
+ end
+
+ def expanded_file_path = ::File.expand_path(@file_path)
+
+ def current_time_ns
+ now = ::Time.now
+ (now.to_i * 1_000_000_000) + now.nsec
+ end
+
+ def skip_fresh_agent_processing!
+ info "Skipping #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}; agent cache is fresh"
+ end
+
+ def invalid_command_composition!(position)
+ raise InvalidComposition, "#{position} expects `from command ...`"
+ end
+
+ def command_failure_class = CommandFailed
+ def command_composition_subject = "file #{@file_path}"
end
# rubocop:enable Metrics/ClassLength
diff --git a/lib/dslkeywords/file_backup.rb b/lib/dslkeywords/file_backup.rb
index 210804c..1716aa5 100644
--- a/lib/dslkeywords/file_backup.rb
+++ b/lib/dslkeywords/file_backup.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'digest'
module RCM
@@ -5,12 +7,16 @@ module RCM
# Included by BasicFile so all file/directory/symlink resources share
# the same backup logic.
module FileBackup
+ def checksum_for(file_path)
+ Digest::SHA256.file(file_path).hexdigest
+ end
+
# TODO: Make protected?
def backup!(file_path, checksum = nil)
return if @without_backup
suffix = if ::File.file?(file_path)
- checksum.nil? ? Digest::SHA256.file(file_path).hexdigest : checksum
+ checksum.nil? ? checksum_for(file_path) : checksum
else
Time.now.strftime('%s-%L')
end
@@ -18,8 +24,8 @@ module RCM
end
def different?(file_a, file_b)
- checksum_a = Digest::SHA256.file(file_a).hexdigest
- checksum_b = Digest::SHA256.file(file_b).hexdigest
+ checksum_a = checksum_for(file_a)
+ checksum_b = checksum_for(file_b)
[checksum_a != checksum_b, checksum_a, checksum_b]
end
diff --git a/lib/dslkeywords/prompt.rb b/lib/dslkeywords/prompt.rb
index 9e599ef..1b383b9 100644
--- a/lib/dslkeywords/prompt.rb
+++ b/lib/dslkeywords/prompt.rb
@@ -1,13 +1,20 @@
# frozen_string_literal: true
+require_relative '../chained'
+require_relative 'command'
require_relative 'keyword'
module RCM
# Stores a named prompt body for agent-backed file processing.
class PromptDefinition < Keyword
+ include Chained
+ include CommandComposable
+
attr_reader :name
class InvalidName < StandardError; end
+ class InvalidComposition < StandardError; end
+ class CommandFailed < StandardError; end
def self.id_for(name) = super(normalize_name(name))
@@ -20,6 +27,7 @@ module RCM
def initialize(name)
@name = self.class.normalize_name(name)
+ initialize_command_composition!
super(@name)
end
@@ -28,6 +36,37 @@ module RCM
@text = value.to_s
end
+
+ def from(value = nil) = value
+
+ def render(file_path) = render_composed_content(text.to_s, file_path)
+
+ def fingerprint_source
+ {
+ text: text.to_s,
+ composition: @command_composition.map { |entry| fingerprinted_command(entry) }
+ }
+ end
+
+ private
+
+ def fingerprinted_command(entry)
+ command_definition = dsl.object!(
+ CommandDefinition,
+ entry[:name],
+ error_class: DSL::NoSuchCommandDefinition,
+ kind: 'command'
+ )
+
+ entry.merge(template: command_definition.template.to_s)
+ end
+
+ def invalid_command_composition!(position)
+ raise InvalidComposition, "#{position} expects `from command ...`"
+ end
+
+ def command_failure_class = CommandFailed
+ def command_composition_subject = "prompt #{@name}"
end
# Adds the `prompt` definition keyword to the top-level DSL.
diff --git a/test/lib/dslkeywords/agent_test.rb b/test/lib/dslkeywords/agent_test.rb
index b09a2ec..39d997e 100644
--- a/test/lib/dslkeywords/agent_test.rb
+++ b/test/lib/dslkeywords/agent_test.rb
@@ -2,6 +2,7 @@
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
require 'minitest/autorun'
+require 'json'
require 'fileutils'
require 'rbconfig'
require 'shellwords'
@@ -15,10 +16,13 @@ class RCMAgentTest < Minitest::Test
def setup