-
Notifications
You must be signed in to change notification settings - Fork 0
Add a Ruby UTCP coding-agent example with approvals and Code Mode #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "json" | ||
| require "optparse" | ||
| require_relative "coding_agent/agent" | ||
| require_relative "coding_agent/llm" | ||
|
|
||
| module RubyUTCPAgent | ||
| class CLI | ||
| def initialize(input: $stdin, output: $stdout, error: $stderr, env: ENV) | ||
| @input, @output, @error, @env = input, output, error, env | ||
| end | ||
|
|
||
| def run(argv) | ||
| options = { workspace: Dir.pwd, model: @env["OPENROUTER_MODEL"] || @env["LLM_MODEL"], | ||
| base_url: @env.fetch("UTCP_AGENT_BASE_URL", LLM::DEFAULT_BASE_URL), max_turns: 12 } | ||
| parser = OptionParser.new do |opts| | ||
| opts.banner = "Usage: ruby -Ilib examples/coding_agent.rb [options] [task]" | ||
| opts.on("--workspace DIR", "Workspace directory (default: current directory)") { |v| options[:workspace] = v } | ||
| opts.on("--model ID", "Tool-capable model ID (or OPENROUTER_MODEL)") { |v| options[:model] = v } | ||
| opts.on("--base-url URL", "Chat-completions API base URL") { |v| options[:base_url] = v } | ||
| opts.on("--prompt TASK", "Run one task, then exit") { |v| options[:prompt] = v } | ||
| opts.on("--max-turns N", Integer, "LLM iterations per task (default: 12)") { |v| options[:max_turns] = v } | ||
| opts.on("--codemode", "Expose restricted Ruby tool-chain execution") { options[:codemode] = true } | ||
| opts.on("--read-only", "Deny all file edits and command execution") { options[:read_only] = true } | ||
| opts.on("--yes", "Auto-approve edits AND arbitrary commands; trusted workspaces only") { options[:yes] = true } | ||
| opts.on("-h", "--help", "Show this help") { options[:help] = true } | ||
| end | ||
| remaining = parser.parse(argv.dup) | ||
| if options[:help] | ||
| @output.puts(parser) | ||
| return 0 | ||
| end | ||
| raise ArgumentError, "use --prompt or a positional task, not both" if options[:prompt] && !remaining.empty? | ||
|
|
||
| options[:prompt] ||= remaining.join(" ") unless remaining.empty? | ||
| llm = LLM.new(api_key: @env["LLM_API_KEY"] || @env["OPENROUTER_API_KEY"], | ||
| model: options[:model], base_url: options[:base_url]) | ||
| unless options[:prompt] || @input.tty? | ||
| raise ArgumentError, "interactive mode requires a terminal; pass --prompt for a single task" | ||
| end | ||
| $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) | ||
| require_relative "coding_agent/utcp_workspace" | ||
| @options = options | ||
| workspace = Workspace.new(root: options[:workspace], approve: method(:approve), read_only: options[:read_only]) | ||
| client = WorkspaceClient.build(workspace) | ||
| code_mode = options[:codemode] ? UTCP::CodeMode.new(client) : nil | ||
| agent = Agent.new(client: client, llm: llm, code_mode: code_mode, max_turns: options[:max_turns], | ||
| on_event: ->(event) { @error.puts(safe_text(event)) }) | ||
| @error.puts("Workspace: #{workspace.root.to_json}") | ||
| @error.puts("Source/tool output will be sent to the configured LLM provider. Review changes before committing.") | ||
| if options[:yes] && !options[:read_only] | ||
| @error.puts("WARNING: --yes permits file edits and arbitrary commands without confirmation. This is not a sandbox.") | ||
| end | ||
| return show_result(agent.run(options[:prompt])) if options[:prompt] | ||
|
|
||
| @output.puts("Ruby UTCP coding agent. Type a task, /reset, or /exit.") | ||
| loop do | ||
| @output.print("> ") | ||
| @output.flush | ||
| line = @input.gets | ||
| break if line.nil? || %w[/exit /quit].include?(line.strip) | ||
| next if line.strip.empty? | ||
|
|
||
| if line.strip == "/reset" | ||
| agent.reset | ||
| @output.puts("Conversation cleared.") | ||
| next | ||
| end | ||
| begin | ||
| show_result(agent.run(line.strip)) | ||
| rescue StandardError => error | ||
| @error.puts("Error: #{safe_text(error.message)}") | ||
| end | ||
| end | ||
| 0 | ||
| rescue Interrupt | ||
| @error.puts("Interrupted. Completed edits are not rolled back; review the workspace.") | ||
| 130 | ||
| rescue LoadError => error | ||
| @error.puts("Unable to load ruby-utcp. Run from the repository with ruby -Ilib, or install the gem. #{safe_text(error.message)}") | ||
| 1 | ||
| rescue StandardError => error | ||
| @error.puts("Error: #{safe_text(error.message)}") | ||
| 1 | ||
| ensure | ||
| client.close if defined?(client) && client | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def show_result(result) | ||
| @output.puts(safe_text(result.answer)) | ||
| result.status == "completed" ? 0 : 2 | ||
| end | ||
|
|
||
| def approve(name, details) | ||
| return true if @options[:yes] | ||
| unless @input.tty? | ||
| @error.puts("Denied #{name}: approval requires an interactive terminal (or explicit --yes).") | ||
| return false | ||
| end | ||
| preview = details.each_with_object({}) do |(key, value), data| | ||
| data[key] = if value.is_a?(String) && value.bytesize > 4000 | ||
| value.byteslice(0, 4000).force_encoding(Encoding::UTF_8).scrub("") + "\n[PREVIEW TRUNCATED; #{value.bytesize} bytes total]" | ||
| else | ||
| value | ||
| end | ||
| end | ||
| @error.puts("\nApproval required: #{name}") | ||
| @error.puts(JSON.pretty_generate(preview)) | ||
| @error.print("Apply this operation? [y/N] ") | ||
| @error.flush | ||
| %w[y yes].include?(@input.gets.to_s.strip.downcase) | ||
| end | ||
|
|
||
| def safe_text(text) | ||
| text.to_s.gsub(/[\x00-\x08\x0B-\x1F\x7F]/) { |char| format("\\u%04x", char.ord) } | ||
| end | ||
| end | ||
| end | ||
|
|
||
| exit RubyUTCPAgent::CLI.new.run(ARGV) if $PROGRAM_NAME == __FILE__ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| # Ruby UTCP coding agent | ||
|
|
||
| A terminal coding agent using the repository's real Ruby UTCP client for tool | ||
| discovery and invocation. It can inspect a workspace, make approved edits, and | ||
| run approved test/build commands. Optional Code Mode composes the same tools in | ||
| UTCP's restricted Ruby interpreter. | ||
|
|
||
| ## Run | ||
|
|
||
| From the repository root, install the development dependencies as usual: | ||
|
|
||
| ```sh | ||
| bundle install | ||
| export OPENROUTER_API_KEY='your-key' | ||
| export OPENROUTER_MODEL='your-tool-capable-model-id' | ||
|
|
||
| # Interactive session; edits and commands require approval. | ||
| bundle exec ruby -Ilib examples/coding_agent.rb --workspace /path/to/project | ||
|
|
||
| # One task, then exit. | ||
| bundle exec ruby -Ilib examples/coding_agent.rb \ | ||
| --workspace /path/to/project \ | ||
| --prompt 'Find the bug in the parser, add a regression test, and run the relevant tests.' | ||
|
|
||
| # Enable Code Mode in addition to individual tools. | ||
| bundle exec ruby -Ilib examples/coding_agent.rb \ | ||
| --workspace /path/to/project --codemode \ | ||
| --prompt 'Inspect the README and source, fix the outdated usage example, and verify it.' | ||
|
|
||
| # Inspect without granting edits or command execution. | ||
| bundle exec ruby -Ilib examples/coding_agent.rb \ | ||
| --workspace /path/to/project --read-only \ | ||
| --prompt 'Review the error handling and report concrete problems.' | ||
| ``` | ||
|
|
||
| Choose a model that supports tool calling. There is intentionally no hardcoded | ||
| model ID. `--model` overrides `OPENROUTER_MODEL` (or `LLM_MODEL`). The example does | ||
| not load `.env` files. Source snippets and command output are sent to the selected | ||
| LLM provider, which may incur charges. | ||
|
|
||
| For an OpenRouter-compatible local endpoint, pass the API **base** URL, not the | ||
| full `/chat/completions` URL: | ||
|
|
||
| ```sh | ||
| LLM_API_KEY='' bundle exec ruby -Ilib examples/coding_agent.rb \ | ||
| --base-url http://127.0.0.1:1234/v1 --model your-local-model \ | ||
| --workspace /path/to/project --read-only --prompt 'Explain the project structure.' | ||
| ``` | ||
|
|
||
| `LLM_API_KEY` takes precedence over `OPENROUTER_API_KEY`. | ||
| `UTCP_AGENT_BASE_URL` supplies the default for `--base-url`. Remote endpoints must | ||
| use HTTPS. Plain HTTP and an empty API key are accepted only for loopback hosts. | ||
|
|
||
| In the interactive session, `/reset` clears conversation history, and `/exit` | ||
| (or `/quit`) exits. Each new task gets its own iteration and tool-call budget. | ||
|
|
||
| ## Tools and UTCP integration | ||
|
|
||
| | Canonical UTCP name | Purpose | | ||
| | --- | --- | | ||
| | `workspace.list_files` | Bounded file listing, excluding common generated folders and protected names | | ||
| | `workspace.read_file` | UTF-8 file content, line ranges, and the full-file SHA-256 | | ||
| | `workspace.search` | Literal text search with paths and line numbers | | ||
| | `workspace.write_file` | Create or atomically replace a file after approval | | ||
| | `workspace.replace_text` | Replace one unique literal block after approval | | ||
| | `workspace.run_command` | Execute an approved argv array and capture output, exit status, and timeout state | | ||
|
|
||
| `WorkspaceClient` subclasses `UTCP::Client`, registers an example-local | ||
| `coding_agent_local` protocol and a `workspace` manual, and dispatches calls with | ||
| `Client#call_tool`. This is an **in-process custom protocol**, not a new SDK | ||
| transport, HTTP server, CLI transport, or MCP wrapper. It leaves existing SDK | ||
| protocols unchanged. A protocol instance is stateless; workspace permissions and | ||
| tool budgets belong to each client. | ||
|
|
||
| The LLM-facing function names use underscores (`workspace_read_file`) for provider | ||
| compatibility. The agent maps these aliases to the canonical dotted UTCP names. | ||
| It obtains descriptions and input schemas from `client.list_tools` rather than | ||
| maintaining a separate LLM-only schema registry. | ||
|
|
||
| With `--codemode`, the model also receives `codemode_run_code`, routed through | ||
| `UTCP::CodeMode.new(client).execute`. A typical tool chain is: | ||
|
|
||
| ```ruby | ||
| before = codemode.call_tool("workspace.read_file", {"path" => "README.md"}) | ||
| codemode.call_tool("workspace.replace_text", { | ||
| "path" => "README.md", | ||
| "old_text" => "an outdated command", | ||
| "new_text" => "the corrected command", | ||
| "expected_sha256" => before["sha256"] | ||
| }) | ||
| ``` | ||
|
|
||
| The last expression is returned. The restricted interpreter does not provide | ||
| arbitrary Ruby execution; operations go through the same approved workspace | ||
| tools. Code Mode batches are **not transactions**. An earlier successful edit is | ||
| not undone when a later operation fails. Its 120-second timeout also includes | ||
| time spent at approval prompts. | ||
|
|
||
| ## Approval and limits | ||
|
|
||
| By default, file writes/replacements and **every command** require terminal | ||
| approval. The prompt shows the path and before/after content, or the exact argv, | ||
| working directory, and timeout. Long previews are explicitly marked as truncated. | ||
| Noninteractive input cannot grant approval implicitly: operations are denied | ||
| unless `--yes` was explicitly supplied. | ||
|
|
||
| `--yes` auto-approves edits **and arbitrary command execution**. Use it only in a | ||
| trusted, disposable checkout or a properly isolated container. `--read-only` | ||
| always wins over `--yes` and also blocks commands, because test/build programs can | ||
| mutate files or access the network. | ||
|
|
||
| Existing-file edits require `expected_sha256` from `read_file`. The agent checks | ||
| the revision before and after approval and again immediately before replacement. | ||
| A stale revision is an error, not a silent overwrite. New files omit the revision. | ||
| No-op writes return `changed: false` without claiming a modification. | ||
|
|
||
| The example rejects absolute paths, `..`, symlinks, hardlinked files, `.git`, `.env*`, | ||
| `.ssh`, `.aws`, `.gnupg`, `id_rsa`, `id_ed25519`, `*.pem`, and `*.key` through its | ||
| file tools. These are conservative name-based exclusions, **not comprehensive | ||
| secret detection**. Listing also skips common dependency/build directories; it | ||
| does not interpret `.gitignore`. | ||
|
|
||
| These checks are **not an OS security sandbox**. An approved command can access | ||
| anything permitted to your user account, including paths outside the workspace, | ||
| network services, and credentials stored on disk. Repository tests can execute | ||
| arbitrary code. Commands do not inherit the full agent environment, so provider | ||
| API keys are not automatically passed to subprocesses; allowed variables are | ||
| `PATH`, `HOME`, `LANG`, `LC_ALL`, and `TMPDIR`. Do not use the example on a workspace | ||
| that is concurrently being modified by an untrusted process: path/revision checks | ||
| cannot eliminate all filesystem races. Review changes with your normal tools | ||
| before committing. The agent never automatically commits or pushes changes. | ||
|
|
||
| Defaults and hard bounds: | ||
|
|
||
| - 12 LLM iterations per task (`--max-turns`, between 1 and 100), 8 function calls | ||
| per model response, and 64 underlying workspace calls per task, including Code Mode. | ||
| - 1 MiB text files; 32 KiB read/command output; 1,000 listed files; 50 search matches. | ||
| - 30-second commands by default, with a maximum of 120 seconds. The process group | ||
| is terminated on timeout, and output is drained without retaining excess bytes. | ||
| - Code Mode: 5,000 interpreter steps and 120 seconds per execution. Large tool | ||
| results and conversation histories are bounded; use `/reset` for a fresh task. | ||
|
|
||
| The CLI targets Linux/macOS (POSIX process groups) and uses Ruby standard libraries | ||
| plus this SDK. It is a synchronous example, not a production multi-user agent. | ||
| There is no streaming, persistent chat history, automatic rollback, or automatic | ||
| HTTP retry. Tool/parse errors are returned to the model for correction. Provider | ||
| errors and incomplete completions are reported rather than treated as success. | ||
| Exit codes are `0` for a normally completed conversation, `1` for an error, `2` | ||
| for the iteration limit, and `130` for interruption. A normal model answer is not | ||
| an independent guarantee that its claims are correct; inspect the tool evidence. | ||
|
|
||
| ## Tests | ||
|
|
||
| ```sh | ||
| # All coding-agent tests, including a real SDK + Code Mode integration subprocess. | ||
| bundle exec ruby -Ilib -Itest -e \ | ||
| 'Dir["test/coding_agent_*_test.rb"].sort.each { |file| require File.expand_path(file) }' | ||
|
|
||
| # Existing repository suite also discovers these tests automatically. | ||
| bundle exec rake test | ||
| ``` | ||
|
|
||
| No external LLM calls or API keys are required for tests. The HTTP tests use a real | ||
| local TCP server; the agent-loop tests use explicit provider/client test doubles; | ||
| the integration test separately uses the actual UTCP client and Code Mode. The | ||
| integration runs in a subprocess to avoid modifying other tests' protocol registry. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Interactive tasks that hit the iteration limit or raise an error still make the CLI exit with status 0 because the loop discards
show_result's status and the rescued errors are not recorded. Preserve a nonzero session status and return it when the interactive loop ends so callers can detect incomplete work.Prompt for AI agents