diff options
| -rw-r--r-- | .serena/project.yml | 114 | ||||
| -rw-r--r-- | README.md | 56 | ||||
| -rw-r--r-- | docs/PROJECT_FLOW.md | 133 | ||||
| -rw-r--r-- | lib/dsl.rb | 2 | ||||
| -rw-r--r-- | lib/dslkeywords/agent.rb | 37 | ||||
| -rw-r--r-- | lib/dslkeywords/file.rb | 255 | ||||
| -rw-r--r-- | lib/dslkeywords/file_backup.rb | 12 | ||||
| -rw-r--r-- | lib/dslkeywords/prompt.rb | 39 | ||||
| -rw-r--r-- | test/lib/dslkeywords/agent_test.rb | 733 | ||||
| -rw-r--r-- | test/lib/dslkeywords/file_test.rb | 136 | ||||
| -rw-r--r-- | test/support/mock_agent.rb | 46 |
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: [] @@ -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. @@ -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 |
