Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ Each official transport has a client example. Network transports include a match
| MCP | `mcp.rb` | `servers/mcp_stdio_server.rb`, launched by the client |
| Text | `text.rb` | self-contained |

## Coding agent

[`coding_agent.rb`](coding_agent.rb) is a terminal coding agent with OpenRouter-compatible
LLM calls, UTCP workspace tools, approval-gated edits and commands, and optional Code Mode.
See the [coding-agent guide](coding_agent/README.md) for setup, security limits, and tests.
It requires a tool-capable model and is intentionally not part of unattended `make demo` runs.

```sh
export OPENROUTER_API_KEY='your-key'
export OPENROUTER_MODEL='your-tool-capable-model-id'
ruby -Ilib examples/coding_agent.rb --workspace /path/to/project --codemode
```

## Code Mode

`code_mode.rb` uses `CodeModeUtcpClient` to discover a tool, call it twice through `codemode.call_tool`, transform both responses inside the constrained Ruby runtime, and print the result with captured logs. It reuses the HTTP example server:
Expand Down
123 changes: 123 additions & 0 deletions examples/coding_agent.rb
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))

Copy link
Copy Markdown

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
Check if this issue is valid — if so, understand the root cause and fix it. At examples/coding_agent.rb, line 71:

<comment>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.</comment>

<file context>
@@ -0,0 +1,123 @@
+          next
+        end
+        begin
+          show_result(agent.run(line.strip))
+        rescue StandardError => error
+          @error.puts("Error: #{safe_text(error.message)}")
</file context>

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__
166 changes: 166 additions & 0 deletions examples/coding_agent/README.md
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.
Loading
Loading