Skip to content

Latest commit

 

History

History
368 lines (265 loc) · 21.6 KB

File metadata and controls

368 lines (265 loc) · 21.6 KB

AGENTS.md

Technical briefing for AI coding agents working on this repository.

RubyCoded is a Ruby gem (MIT, Ruby >= 3.3) that runs an AI coding assistant inside the terminal. It is not a web app and not a Rails project. The product is a Ratatui TUI: chat with an LLM, optionally let it edit the current working directory (agent mode), or produce a structured plan first (product plan mode).

This file has two jobs:

  1. Describe the current architecture (stack, runtime, tools, extensions).
  2. Tell agents how to work: agree behavior with the developer (Planning rules, BDD), then implement (Development rules, TDD).

User-facing docs: README.md. Release notes: CHANGELOG.md.

“Plan” means three different things in this project. Do not mix them:

Term What it is
Agent planning This file’s Planning rules: BDD with the developer before writing code
RuntimeMode.plan Product capability: the in-app assistant plans with read-only tools
State#mode TUI modal (:chat, :login, :tool_confirmation, …)

How to run

bundle install
bundle exec rake test
bundle exec rake rubocop
bundle exec rake            # test + rubocop (same as CI default)
bundle exec exe/ruby_coded  # run from a project directory

CI (.github/workflows/ci.yml): tests on Ruby 3.3 and 3.4, RuboCop on 3.3, then gem build. It deletes Gemfile.lock before bundling. A rust toolchain step exists because ratatui_ruby needs it.

User-visible changes: update CHANGELOG.md. Version bumps: lib/ruby_coded/version.rb.

Stack

Layer Library Why it is here
TUI ratatui_ruby ~> 1.4 Event loop, layout, widgets
LLM (API keys) ruby_llm ~> 1.13 Chat, streaming, tool-calling for OpenAI/Anthropic keys
HTTP (ChatGPT OAuth) faraday Codex Responses API + SSE
CLI prompts (pre-TUI) tty-prompt Directory trust + first-run login
OAuth callback webrick Localhost redirect http://localhost:1455/auth/callback
Width/display unicode-display_width Input/cursor math in the TUI
Tests minitest test/test_*.rb
Lint rubocop Double quotes, metrics, TargetRubyVersion 3.3

Entry point: exe/ruby_codedRubyCoded.startInitializer.new. Packaged via ruby_coded.gemspec; version is RubyCoded::VERSION in lib/ruby_coded/version.rb (currently 0.4.0).

Project root at runtime is Dir.pwd, not the gem install path. Tools, skills, and markdown commands all resolve against the directory the user launched from.

Architecture

The TUI is a classic orchestrator + shared state design. Chat::App wires components and runs the event loop. It should stay thin.

Initializer
  UserConfig + AuthManager.configure_ruby_llm!
       │
       ▼
  Chat::App
  ├── Commands::Catalog + Skills::Catalog
  ├── State          (mutex, dirty flag, messages, UI mode)
  ├── BridgeFactory  → LLMBridge | CodexBridge
  ├── InputHandler   (key/mouse → action Symbol)
  ├── CommandHandler (slash commands)
  └── Renderer       (draw from State snapshot)
flowchart LR
  TUI[Ratatui poll] --> IH[InputHandler]
  IH -->|action| ED[EventDispatch]
  ED -->|slash| CH[CommandHandler]
  ED -->|message| BR[Bridge send_async]
  ED -->|y/n/a| BR
  BR -->|background thread| API[RubyLLM or Codex SSE]
  BR --> ST[State]
  ED --> ST
  ST --> R[Renderer]
Loading

Component responsibilities

Piece Lives in Job
Initializer lib/ruby_coded/initializer.rb Trust cwd, auth, pick model, construct App
Chat::App chat/app.rb Construct, event loop, recreate bridge after login
EventDispatch chat/app/event_dispatch.rb Map action symbols to methods
State chat/state.rb + chat/state/*.rb Single mutable store for UI + session
InputHandler chat/input_handler/ Modal-aware keyboard routing
Renderer chat/renderer/ Clear + draw chat / status / input / overlays
CommandHandler chat/command_handler/ /help, /agent, /plan, /login, markdown cmds
BridgeFactory chat/bridge_factory.rb Choose backend from OpenAI auth_method
LLMBridge chat/llm_bridge.rb RubyLLM path
CodexBridge chat/codex_bridge.rb ChatGPT Codex path
RuntimeMode chat/runtime_mode.rb chat / agent / plan (assistant capability)
PromptBuilder chat/prompt_builder.rb System instructions + skills
Tools::* lib/ruby_coded/tools/ Agent capabilities
Auth::* lib/ruby_coded/auth/ Providers, credentials, OAuth, JWT
Commands::* lib/ruby_coded/commands/ Unified slash catalog
Skills::* lib/ruby_coded/skills/ Prompt overlays
Plugins::* lib/ruby_coded/plugins/ Mixins into State/Input/Renderer/CommandHandler

Keep App as orchestration. New behavior goes in a mixin or a new class under the matching namespace.

Plugin modules are included on the classes (State, InputHandler, Renderer, CommandHandler) in App#apply_plugin_extensions! before build_components! instantiates them. State#initialize then calls init_#{plugin_name} if the state extension defines it.

RuntimeMode vs TUI mode

Do not confuse these (and do not confuse either with agent planning in Planning rules):

  1. RuntimeMode on the bridge (@mode) — what the in-app model is allowed to do: :chat, :agent, :plan. Source of truth for tools, mutation, confirmation policy, and skill selection.
  2. State#mode — what the TUI is showing: :chat, :tool_confirmation, :login, model-select, plan clarification, etc.

State#agentic_mode is a UI/status flag kept in sync by BridgeCommon::ModeTransitions. Bridges must use RuntimeMode, not a pair of booleans.

RuntimeMode Tools Writes Confirmation
chat none no n/a
plan read-only registry no yes for non-safe
agent full registry yes yes for non-safe

Default at boot: agent (App#enable_default_agent_mode!).

BridgeCommon::AutoSwitch flips product plan → agent when there is a stored current_plan and the user message matches implementation phrasing (English and Spanish: implement, go ahead, hazlo, ejecut, …).

Request lifecycle

  1. Initializer loads ~/.ruby_coded/config.yaml, optionally asks “Do you trust this directory?”, ensures at least one provider is authenticated, configures RubyLLM, resolves the model (stored model if its provider is authenticated, else that provider’s default).
  2. App#run enters RatatuiRuby.run. Loop: redraw if dirty → poll OAuth if waiting → poll_eventdispatch_event.
  3. InputHandler#process returns nil or an action (:submit, :quit, :tool_approved, …). Login, tool confirmation, plan clarifier, model selector, and streaming each have their own keymap.
  4. :submit on /…CommandHandler. Anything else → State#add_message(:user, …) + bridge.send_async.
  5. send_async may auto-switch product plan→agent, resets tool-call counters, sets streaming = true, and starts a background thread. The UI thread must not call the provider.
  6. Chunks append to the last assistant message. Esc sets @cancel_requested and signals tool_cv.
  7. Tool calls: ExecutionPolicy then optional confirmation, then the tool body (ExecutionPipeline for filesystem tools). Results go back to the model (:tool_result, truncated at 10_000 chars).
  8. Product plan-mode post-process: PlanClarificationParser may open an overlay; otherwise the assistant text becomes current_plan.

Poll timeout: 0.016s idle, 0.05s while streaming. Redraws during streaming are throttled by State::MIN_RENDER_INTERVAL (0.05s).

Threading and State

  • UI thread: poll, dispatch, render.
  • Bridge thread: HTTP / SSE / RubyLLM chat.ask.
  • OAuth callback thread: Webrick server; the UI loop polls login_oauth_result.

State is the coordination board. Message mutations go through @mutex. Tool confirmation uses a ConditionVariable (@tool_cv): the bridge thread waits in poll_tool_decision; State#tool_confirmation_response= signals it.

messages_snapshot copies messages for the renderer (keyed by @message_generation) so the renderer never iterates the live array without a lock.

Message hash shape:

{ role:, content:, timestamp:, input_tokens:, output_tokens:, thinking_tokens:, cached_tokens:, cache_creation_tokens: }

content is always a mutable string (String.new(...)) because # frozen_string_literal: true would freeze "". Roles used today: :user, :assistant, :system, :tool_call, :tool_result, :tool_pending.

When you add state, put it in chat/state/<concern>.rb as a mixin nested under class State, initialize it from State#initialize, and mark_dirty! on user-visible changes.

Dual backends

BridgeFactory#build: if stored OpenAI credentials exist and auth_method == "oauth"CodexBridge; else LLMBridge. Both must expose the same public surface so App and CommandHandler stay backend-agnostic:

send_async, cancel!, approve_tool!, approve_all_tools!, reject_tool!, reset_chat!, reset_agent_session!, toggle_agentic_mode!, toggle_plan_mode!, agentic_mode?, plan_mode?.

Shared (do not fork): RuntimeMode, PromptBuilder, Tools::ExecutionPolicy, Tools::Registry, BridgeCommon (ModeTransitions, ToolFlow, AutoSwitch).

LLMBridge CodexBridge
Transport RubyLLM Faraday SSE to https://chatgpt.com/backend-api/codex/responses
History RubyLLM Chat object @conversation_history resent every request (store: false)
Tools chat.with_tools + on_tool_call / on_tool_result Bridge executes tools, then continue_after_tools
PromptBuilder chat_base :agentic (chat still gets the full agent prompt) :simple (short default in chat mode)
Tokens RubyLLM response object response.completed SSE event
Models RubyLLM catalog Chat::CodexModels (some pro_only, gated by JWT plan)

PromptBuilder composes Tools::SystemPrompt / PlanSystemPrompt / a short chat string, then Skills::PromptFormatter.append.

If you change confirmation, mode transitions, or prompt assembly, change the shared layer and verify both bridges. After /login, App#recreate_bridge! rebuilds the bridge and restores the previous RuntimeMode.

Auth and config

Config file: ~/.ruby_coded/config.yaml.

user_config:
  trusted_directories: [...]
  model: gpt-5.4
providers:
  openai: { auth_method: oauth|api_key, ... }
  anthropic: { ... }

One UserConfig instance is threaded Initializer → AuthManagerCredentialsStoreChat::App. A second in-memory copy will overwrite OAuth tokens on the next set_config (this already shipped as a bug). See Invariants.

Auth is strategy + provider:

  • Providers: Auth::Providers::OpenAI, Auth::Providers::Anthropic (display name, URLs, key pattern, RubyLLM config key).
  • Strategies: Strategies::ApiKeyStrategy, Strategies::OAuthStrategy (PKCE, browser open, refresh).
  • OpenAI OAuth uses ChatGPT (originator: codex_cli_rs). Auth::JWTDecoder pulls chatgpt_account_id (and plan type) from the access token with no extra JWT gem.
  • In-TUI /login is a wizard on State (:provider_select:auth_method_select:api_key_input | :oauth_waiting), rendered by renderer/login_flow.rb. It does not suspend the TUI for a tty-prompt form.

Tools

Tools::BaseTool < RubyLLM::Tool. Declare description, risk :safe|:confirm|:dangerous, and params. execute keyword args must match params.

Tools::Registry instantiates tools with project_root:. TOOL_CLASSES is the full agent set; READONLY_TOOL_CLASSES is product plan mode. RubyLLM may send read_file_tool or ruby_coded--tools--read_file_tool; lookup is on the last -- segment.

Current tools: read_file, list_directory (safe); write_file, edit_file, create_directory, git_add, git_commit (confirm); delete_path, run_command (dangerous); git_status, git_diff (safe). Git tools subclass GitBaseTool (Open3, chdir: project_root, GIT_EDITOR=true, 5000-char truncate). RunCommandTool is 30s timeout, same env, chdir: project_root.

Two layers — do not collapse them

ExecutionPolicy (bridges, before execute):

  • Risk via registry
  • Budgets: 50 write rounds (counter resets with a system message), 200 total (raises AgentIterationLimitError)
  • Warn at 80% of total
  • requires_confirmation? is false for :safe, false if auto-approve, else true when RuntimeMode#requires_confirmation?

ExecutionPipeline (inside the tool):

  1. File.expand_path relative to project root
  2. realpath (or expanded path if ENOENT — needed for writes that create files)
  3. Reject if not start_with?(project_root)
  4. Optional forbid_root
  5. yield(full_path)
  6. Rescue SystemCallError{ error: "Filesystem error: …" }

Use BaseTool#run_pipeline for side-effecting filesystem tools. validate_path! is the lighter helper for custom read flows. Never File.write a model-supplied path without one of these.

Expected tool failures return { error: "…" } (string or that hash). Control-flow exceptions the bridges already rescue: ToolRejectedError, AgentCancelledError, AgentIterationLimitError.

Commands, skills, plugins

Three extension mechanisms. Pick one. Do not invent a fourth. Planning rules require mapping each BDD scenario to one of these (or to a change inside an existing module).

Commands Skills Plugins
What Explicit /foo Prompt overlay Ruby mixins
Trigger User types it Mode + optional tag/trigger Startup
Code command_handler/ or markdown .rubycoded/skills/*.md Plugins::Base subclass
On-disk {cwd}/.ruby_coded/commands/*.md {cwd}/.rubycoded/skills/*.md gem lib/

Paths are intentionally different (.ruby_coded vs .rubycoded).

Commands::Catalog merges markdown → plugin → core. Priority numbers: markdown 1, plugin 2, core 3 (higher wins). Core and plugin names reserve the slash; conflicting markdown is reported on /commands reload and ignored.

Markdown commands have no Ruby handler: CommandHandler#handle_markdown_command sends the file body (+ extra args) as a user prompt via send_async.

Skills: YAML frontmatter (name, description, modes, optional tags, trigger, priority). Skills::Catalog#relevant_skills_for prefers tag/trigger matches; if none match, all mode-compatible skills stay active. First duplicate name wins.

Plugins: subclass Plugins::Base, implement .plugin_name, optionally state_extension, input_extension, renderer_extension, command_handler_extension, commands, command_descriptions. Register with RubyCoded.register_plugin in lib/ruby_coded/plugins.rb. Reference: Plugins::CommandCompletion.

Adding a core command

Agree behavior first (Planning rules), then implement with TDD (Development rules):

  1. Commands::CoreProvider::DEFINITIONS (name, description, handler:, source: :core, usage:).
  2. Mixin under chat/command_handler/ with cmd_*.
  3. include it from CommandHandler.
  4. Tests in test/test_*_commands.rb.

Adding a tool

Same workflow (BDD, then TDD):

  1. lib/ruby_coded/tools/<name>_tool.rb, subclass BaseTool (or GitBaseTool).
  2. risk, params, execute. Use run_pipeline for filesystem paths.
  3. Register in Registry (TOOL_CLASSES and READONLY_TOOL_CLASSES if read-only).
  4. Tests: execute, risk, path escape, expected error hashes.

Code organization and style

The codebase prefers one class per file in lib/, then mixins for metrics. When RuboCop Metrics/* fires, extract a module next to the class (llm_bridge/tool_call_handling.rb, state/messages.rb, renderer/chat_panel_*.rb). Mixins are nested in the class they extend:

module RubyCoded
  module Chat
    class State
      module Messages
        def add_message(role, content)
          # ...
        end
      end
    end
  end
end

Do not disable Metrics cops on production files to avoid extracting. Tests are already excluded from those cops in .rubocop.yml. Development rules require the suite and RuboCop to stay green.

Style rules (enforced)

  • # frozen_string_literal: true on every Ruby file.
  • Double-quoted strings (Style/StringLiterals).
  • Target Ruby 3.3; endless methods are used in plugin hooks (def state_extension = nil).
  • Namespaces: RubyCoded::Chat, Tools, Auth, Commands, Skills, Plugins, Strategies, Errors.
  • Predicate methods: agentic_mode?, plan_mode?, streaming?, login_active?.
  • Bang methods mutate session/UI: mark_dirty!, toggle_agentic_mode!, request_tool_confirmation!.
  • Constants are frozen (MAX_TOOL_RESULT_CHARS = 10_000).
  • Frozen literals: mutable buffers use String.new, not "".
  • Frozen hashes duplicated before mutation (ZERO_TOKEN_USAGE.dup).
  • Prefer keyword arguments on execute and public APIs.
  • Comments explain why or the module’s contract; class/module doc comments are the norm at the top of a file.

Error handling

  • Tools: return { error: } for business/filesystem failure; do not raise past the tool unless it is a bridge control-flow error.
  • Bridges: rescue rate limits with 2 retries and exponential backoff (2s, 4s); surface a system or failed-assistant message.
  • Auth: RubyCoded::Errors::AuthError.
  • Codex HTTP: Chat::CodexAPIError with status.

Public APIs to preserve

When renaming, keep the bridge predicates (agentic_mode? / plan_mode?) and RuntimeMode API. Plugins and command handlers call those.

UserConfig#get_config / #set_config read/write user_config.* and persist YAML immediately.

Tests

Where tests live and how they are shaped. When to write them, and the red → green → refactor loop, are in Development rules. Agreed Given/When/Then scenarios from Planning rules should become test_* methods, not a second informal checklist.

  • Minitest. require "test_helper" then require the specific lib files (tests do not autoload the whole gem unless needed).
  • File: test/test_<topic>.rb. Class: Test<Topic> < Minitest::Test. test_tools.rb hosts several classes in one file (allowed).
  • Filesystem tests: Dir.mktmpdir as project_root, FileUtils.remove_entry in teardown.
  • Cover path-escape (../), missing files, risk levels, catalog merge/priority, and mode transitions.
  • Prefer exercising the class under test over spinning the full TUI. Renderer tests exist where layout/status behavior is asserted.

Planning rules

These rules apply when the agent is in planning / plan mode (Cursor Plan mode, or any “design first, then code” pass). This is not RubyCoded’s RuntimeMode.plan (that is the in-app assistant). Product plan mode is under Architecture.

Do not implement while planning. Bounce the idea with the developer using BDD until the behavior is small, testable, and agreed. Skip a full planning pass only for an obvious one-line fix the developer already specified.

  1. Restate the request as behavior, not as a file list. Who is the user, what do they do, what should they observe?
  2. Propose concrete Given / When / Then scenarios (happy path, at least one failure or edge case, and what is explicitly out of scope).
  3. Map each scenario to an extension mechanism: command, skill, plugin, tool, or a change inside an existing module. Do not invent a fourth extension type.
  4. Call out open questions, Invariants that might be affected, and whether both bridges need the same change.
  5. Stop and wait for the developer. Refine or drop scenarios until they confirm. Do not treat silence as approval.
  6. Only after that confirmation, write a short implementation outline (files, tests to add first, RuboCop/extraction risks). Then hand off to Development rules.

Scenarios should be specific enough to become Minitest examples. Vague goals (“improve the TUI”, “make tools safer”) are not a plan.

Development rules

  • Agree the plan with the developer first when the work is a new feature or a behavior change. See Planning rules.
  • Use TDD for new behavior: turn an agreed scenario into a failing Minitest, then the minimum production code to pass, then refactor. Follow Code organization and style.
  • Do not consider a change done while bundle exec rake test or bundle exec rake rubocop is red. Fix failures before handing work back.
  • Follow RuboCop. Prefer extracting a mixin over disabling Metrics cops in lib/. Tests are already excluded from those cops.
  • Add or update tests for every behavior change. Do not weaken assertions to make a change pass.
  • If the change is user-visible, update CHANGELOG.md. Bump RubyCoded::VERSION only when the developer asks for a release.

Invariants

Hard constraints on the design. They are not a Ruby module. Planning and implementation must preserve them.

  • Do not mix commands, skills, and plugins for one feature.
  • Do not add write tools to product plan mode (RuntimeMode.plan) or skip confirmation for :confirm / :dangerous without going through ExecutionPolicy.
  • Do not let tools operate outside project_root.
  • Do not block the TUI thread on HTTP.
  • Do not construct a second UserConfig that can clobber ~/.ruby_coded/config.yaml.
  • Do not duplicate policy/mode/prompt logic in only one bridge.
  • Do not commit secrets, credentials, or a real config.yaml.
  • Do not treat State#mode as RuntimeMode, and do not treat agent planning as product plan mode.
  • Paths: commands .ruby_coded/commands, skills .rubycoded/skills.
  • Keep agent-only notes here; README/CHANGELOG only for user-visible behavior.