sandbox-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 61c56399f2e6900b8ecc89ccda3074f37d604ecfd424aef6e0f7b9743b48dfb4
4
+ data.tar.gz: 1a9881246c543c4dcb783eda829d450aaad5cb65f6987debd56568089e7ef744
5
+ SHA512:
6
+ metadata.gz: 8df59d351e95189187ddc1756502b69af83669f2d31f0c617f782bfb03f1717063bdfbe26b68f4b6721740ef772aedf84347d2c7ec7170eca0e7ee0e3a60fa38
7
+ data.tar.gz: 414451d5cd11fc1cce04db53a23c6ea2c2e6159e549aa938b4ba0b92f6c5d60b3475ffbabb6bf008d198e85f94c580608560c31dc7922c1910f7fbc14f79cec4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nvoi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # sandbox-agent
2
+
3
+ Registry of coding agent CLIs. Each agent answers the same contract, so callers never branch on the vendor.
4
+
5
+ - `argv(prompt:, id:, resume:, model:, instructions:)`: the command line for one turn.
6
+ - `env` / `box_env`: the variables the CLI reads.
7
+ - `events(line)`: one printed line to `Sandbox::Agent::Event` values (`status`, `message`, `thinking`, `tool_use`, `tool_result`, `task`, `result`, `error`, `notice`).
8
+
9
+ ```ruby
10
+ agent = Sandbox::Agent.provider("claude_code")
11
+ agent.argv(prompt: "Fix the failing test", id: session_id, resume: false, model: "sonnet")
12
+ ```
13
+
14
+ Agents: Claude Code (`Sandbox::Agent::ClaudeCode`).
@@ -0,0 +1,33 @@
1
+ # Stdio MCP server installed on the box. Lists the tools Rails defines and answers every call with
2
+ # "recorded": the call is parked as an event and Rails performs it after the run.
3
+ module Sandbox::Agent::ClaudeCode::McpServer
4
+ NAME = "nvoi"
5
+ PARKED = "Queued. This action is performed for you once your turn ends and you will be told " \
6
+ "the outcome. This is the normal path; end your turn now with a one-line summary of what " \
7
+ "you asked for.".freeze
8
+
9
+ SCRIPT = <<~PY
10
+ #!/usr/bin/env python3
11
+ import json, os, sys
12
+
13
+ tools = json.loads(os.environ.get("NVOI_TOOLS", "[]"))
14
+
15
+ def reply(msg, result):
16
+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": result}) + "\\n")
17
+ sys.stdout.flush()
18
+
19
+ for line in sys.stdin:
20
+ msg = json.loads(line)
21
+ method = msg.get("method")
22
+ if method == "initialize":
23
+ reply(msg, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "#{NAME}", "version": "1"}})
24
+ elif method == "tools/list":
25
+ reply(msg, {"tools": tools})
26
+ elif method == "tools/call":
27
+ reply(msg, {"content": [{"type": "text", "text": #{PARKED.to_json}}]})
28
+ elif "id" in msg:
29
+ reply(msg, {})
30
+ PY
31
+
32
+ def self.config = { mcpServers: { NAME => { command: "nvoi-mcp" } } }.to_json
33
+ end
@@ -0,0 +1,103 @@
1
+ # Maps one stream-json object (symbol-keyed) to zero or more Sandbox::Agent::Event.
2
+ module Sandbox::Agent::ClaudeCode::Stream
3
+ # System lines that repeat what another line says, or say nothing shown.
4
+ QUIET = %w[thinking_tokens task_updated background_tasks_changed hook_started
5
+ hook_response].freeze
6
+ TASK = %w[task_started task_progress task_notification].freeze
7
+
8
+ def self.decode(data)
9
+ parent = data[:parent_tool_use_id]
10
+ case data[:type]
11
+ when "system" then system(data)
12
+ when "assistant" then nested(blocks(data).filter_map { assistant(_1) }, parent)
13
+ when "user" then nested(blocks(data).filter_map { user(_1) }, parent)
14
+ when "result"
15
+ if data[:is_error]
16
+ [ error(reason(data)) ]
17
+ else
18
+ [ Sandbox::Agent::Event.new(kind: "result", content: data[:result]) ]
19
+ end
20
+ when "error" then [ error(data[:message].to_s) ]
21
+ else []
22
+ end
23
+ end
24
+
25
+ def self.nested(events, parent) = events.map { _1.with(parent_tool_use_id: parent) }
26
+
27
+ def self.system(data)
28
+ subtype = data[:subtype].to_s
29
+ return [] if QUIET.include?(subtype)
30
+ return [ Sandbox::Agent::Event.new(kind: "status", content: subtype) ] unless TASK.include?(subtype)
31
+ return [] unless data[:tool_use_id]
32
+
33
+ content = JSON.dump(task(data))
34
+ [ Sandbox::Agent::Event.new(kind: "task", tool_use_id: data[:tool_use_id], content:) ]
35
+ end
36
+
37
+ # A sub-agent's state as of this line: what it was asked, what it is doing, how it ended. A progress
38
+ # line's description is the current activity, not the task's.
39
+ def self.task(data)
40
+ usage = data[:usage] || {}
41
+ progress = data[:subtype] == "task_progress"
42
+ {
43
+ state: data[:subtype].delete_prefix("task_"),
44
+ status: data[:status],
45
+ type: data[:subagent_type],
46
+ description: (data[:description] unless progress),
47
+ activity: (data[:description] if progress),
48
+ summary: data[:summary],
49
+ tokens: usage[:total_tokens],
50
+ tools: usage[:tool_uses],
51
+ duration_ms: usage[:duration_ms],
52
+ last_tool: data[:last_tool_name]
53
+ }.compact
54
+ end
55
+
56
+ LOST = /No conversation found with session ID/
57
+
58
+ # A transcript the box no longer has is not a failure of the turn: the caller starts a new one.
59
+ def self.error(message)
60
+ Sandbox::Agent::Event.new(kind: "error", content: message, lost: message.match?(LOST))
61
+ end
62
+
63
+ def self.blocks(data) = Array(data.dig(:message, :content))
64
+
65
+ def self.assistant(block)
66
+ case block[:type]
67
+ when "text" then Sandbox::Agent::Event.new(kind: "message", content: block[:text])
68
+ when "thinking" then Sandbox::Agent::Event.new(kind: "thinking", content: block[:thinking])
69
+ when "tool_use"
70
+ Sandbox::Agent::Event.new(
71
+ kind: "tool_use",
72
+ tool: block[:name],
73
+ content: JSON.dump(block[:input] || {}),
74
+ tool_use_id: block[:id]
75
+ )
76
+ end
77
+ end
78
+
79
+ def self.user(block)
80
+ return unless block[:type] == "tool_result"
81
+
82
+ Sandbox::Agent::Event.new(
83
+ kind: "tool_result",
84
+ tool_use_id: block[:tool_use_id],
85
+ content: text(block[:content])
86
+ )
87
+ end
88
+
89
+ def self.text(content)
90
+ return content.to_s unless content.is_a?(Array)
91
+
92
+ content.filter_map { |part| part[:text] if part[:type] == "text" }.join("\n")
93
+ end
94
+
95
+ def self.reason(data)
96
+ errors = Array(data[:errors]).join("; ")
97
+ return errors unless errors.empty?
98
+ return data[:result] if data[:result].is_a?(String) && !data[:result].empty?
99
+ return "the model API answered #{data[:api_error_status]}" if data[:api_error_status]
100
+
101
+ "the turn failed without a reason"
102
+ end
103
+ end
@@ -0,0 +1,96 @@
1
+ # Anthropic's `claude` CLI. Everything this app knows about this vendor is here: what a credential for
2
+ # it is, which variable each kind goes in, which models it offers, the command line that runs a turn and
3
+ # how its output is read.
4
+ #
5
+ # Its whole configuration is a credential and WHICH VARIABLE it goes in: the CLI reads its own name and
6
+ # only its own, so an API key exported as CLAUDE_CODE_OAUTH_TOKEN is "Not logged in" and the turn ends
7
+ # having written nothing. So the kind is asked, never inferred from the token's shape: a gateway's key
8
+ # carries no `sk-ant-` prefix.
9
+ class Sandbox::Agent::ClaudeCode < Sandbox::Agent::Provider
10
+ # The kinds and the variable each one names. This map IS the enum: a kind in the form and missing here
11
+ # would validate, then raise as a box started.
12
+ CREDENTIAL_ENV = {
13
+ "oauth" => "CLAUDE_CODE_OAUTH_TOKEN",
14
+ "api_key" => "ANTHROPIC_API_KEY",
15
+ "bearer" => "ANTHROPIC_AUTH_TOKEN"
16
+ }.freeze
17
+ # Where a third-party endpoint is named; not a credential, but ours to set for the same reason.
18
+ BASE_URL_ENV = "ANTHROPIC_BASE_URL".freeze
19
+
20
+ FIELDS = [
21
+ { key: "kind", type: :select, label: "Credential kind", required: true,
22
+ help: "oauth for a Claude subscription, api_key for an Anthropic key, " \
23
+ "bearer for a third-party endpoint such as z.ai or Kimi",
24
+ options: [
25
+ { value: "oauth", label: "Claude subscription (OAuth)" },
26
+ { value: "api_key", label: "Anthropic API key" },
27
+ { value: "bearer", label: "Third-party endpoint (bearer)" }
28
+ ] },
29
+ { key: "token", type: :password, label: "Token", required: true, secret: true,
30
+ placeholder: "sk-ant-..." },
31
+ { key: "base_url", type: :text, label: "Base URL", placeholder: "https://api.anthropic.com",
32
+ help: "Only for a third-party endpoint. Leave empty for Anthropic." }
33
+ ].freeze
34
+
35
+ # Aliases rather than pinned ids: an alias follows the vendor's current model.
36
+ MODELS = [
37
+ { id: "opus", label: "Opus" },
38
+ { id: "sonnet", label: "Sonnet" },
39
+ { id: "haiku", label: "Haiku" }
40
+ ].freeze
41
+
42
+ def self.key = "claude_code"
43
+ def self.label = "Claude Code"
44
+ def self.fields = FIELDS
45
+ def self.models = MODELS
46
+ # The middle one: opus costs several times more per turn and haiku cannot hold a long tool-using
47
+ # conversation. A default is what most people never change.
48
+ def self.default_model = "sonnet"
49
+
50
+ # `fetch` throughout. A kind outside the set, or a config saved with no token, is our broken
51
+ # invariant; a default here hands the CLI an empty credential, which it reports as a refusal.
52
+ def self.env(settings, secrets)
53
+ settings = settings.to_h.transform_keys(&:to_s)
54
+ secrets = secrets.to_h.transform_keys(&:to_s)
55
+ env = { CREDENTIAL_ENV.fetch(settings.fetch("kind")) => secrets.fetch("token") }
56
+ url = settings["base_url"].to_s
57
+ env[BASE_URL_ENV] = url unless url.empty?
58
+ env
59
+ end
60
+
61
+ def self.env_keys = CREDENTIAL_ENV.values + [ BASE_URL_ENV ]
62
+
63
+ # Where the CLI keeps transcripts and settings: on the worktree's volume, so a paused box keeps its
64
+ # memory. Tool search off: the CLI would otherwise defer nvoi's MCP tools behind it, and an agent that
65
+ # never loads ask_user asks its questions in prose.
66
+ def self.box_env(layout) = { CLAUDE_CONFIG_DIR: layout.claude, ENABLE_TOOL_SEARCH: "false" }
67
+
68
+ # The CLI's own question tool has nobody to answer it in print mode; nvoi's ask_user takes its place.
69
+ DISALLOWED = %w[AskUserQuestion].freeze
70
+
71
+ def self.argv(prompt:, id:, resume:, model: nil, instructions: nil)
72
+ argv = [
73
+ "claude", "-p", prompt,
74
+ "--verbose",
75
+ "--output-format", "stream-json",
76
+ "--setting-sources", "user",
77
+ "--settings", JSON.dump(hooks: {}, permissions: { defaultMode: "bypassPermissions" }),
78
+ "--mcp-config", Sandbox::Agent::ClaudeCode::McpServer.config,
79
+ "--disallowedTools", DISALLOWED.join(",")
80
+ ]
81
+ argv += [ "--model", model ] if model
82
+ argv += [ "--append-system-prompt", instructions ] unless instructions.to_s.empty?
83
+ argv + [ resume ? "--resume" : "--session-id", id ]
84
+ end
85
+
86
+ def self.events(line)
87
+ data = JSON.parse(line, symbolize_names: true)
88
+ return [] unless data.is_a?(Hash)
89
+
90
+ Sandbox::Agent::ClaudeCode::Stream.decode(data).map { |event| event.with(session_id: data[:session_id]) }
91
+ rescue JSON::ParserError => e
92
+ # A line the CLI printed that is not an event: dropped, and said so.
93
+ Sandbox::Agent.reporter.report(e, handled: true, context: { line: line.to_s[0, 200] })
94
+ []
95
+ end
96
+ end
@@ -0,0 +1,58 @@
1
+ # What one vendor is. Vendors share this contract and nothing else: each states its own credential,
2
+ # its own variables, its own models and its own command line, and a caller reads the same shape from
3
+ # every one of them.
4
+ #
5
+ # All of it is class methods. A vendor holds no state: it describes itself and builds a command.
6
+ class Sandbox::Agent::Provider
7
+ # ── WHAT A CREDENTIAL FOR THIS VENDOR IS ────────────────────────────────────
8
+
9
+ # The stored key. `llm_configs.provider` holds it.
10
+ def self.key = raise NotImplementedError
11
+ # What a person calls this vendor.
12
+ def self.label = raise NotImplementedError
13
+
14
+ # The form, in the order it is asked. A field is
15
+ # { key:, type: text|password|select, label:, secret:, required:, placeholder:, help:, options: }.
16
+ # `secret: true` decides which half of the submitted values is encrypted, so there is no second list
17
+ # of sensitive keys anywhere. `options` ([{ value:, label: }]) is for a select only.
18
+ def self.fields = raise NotImplementedError
19
+
20
+ def self.secret_keys = fields.select { _1[:secret] }.map { _1[:key] }
21
+
22
+ # A submitted hash split into what is stored plainly and what is sealed, read off `fields` so there is
23
+ # no second list to disagree with the form.
24
+ def self.split(submitted)
25
+ submitted = submitted.to_h.transform_keys(&:to_s)
26
+ [ submitted.except(*secret_keys), submitted.slice(*secret_keys) ]
27
+ end
28
+
29
+ # ── WHAT IT OFFERS ──────────────────────────────────────────────────────────
30
+
31
+ # Every model this vendor can run, as { id:, label: }. Empty means we have not integrated its
32
+ # catalogue, and is an answer rather than permission to guess.
33
+ def self.models = []
34
+ # The model a new credential starts on, so the form does not open on a blank select. An alias where
35
+ # the vendor has them: an alias follows the vendor's current model, a pinned id freezes it.
36
+ def self.default_model = nil
37
+
38
+ # ── WHAT IT READS ───────────────────────────────────────────────────────────
39
+
40
+ # The process environment a turn runs under. Both halves of the credential are passed because the
41
+ # vendor declared the split and is the only thing that knows it.
42
+ def self.env(settings, secrets) = raise NotImplementedError
43
+
44
+ # Every variable this vendor reads, credential or otherwise. Nothing else may name one.
45
+ def self.env_keys = raise NotImplementedError
46
+
47
+ # What the runner needs of the box it runs on, such as where it keeps its transcripts.
48
+ def self.box_env(_layout) = {}
49
+
50
+ # ── HOW A TURN RUNS ─────────────────────────────────────────────────────────
51
+
52
+ # The command line for one turn. `id` is the transcript to create or, with resume, to continue.
53
+ def self.argv(prompt:, id:, resume:, model: nil, instructions: nil) = raise NotImplementedError
54
+
55
+ # The events in one line of what the runner printed, as Sandbox::Agent::Event. A line that carries none is an
56
+ # empty list, never an error: a runner prints what it likes.
57
+ def self.events(line) = raise NotImplementedError
58
+ end
@@ -0,0 +1,5 @@
1
+ module Sandbox
2
+ module Agent
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,52 @@
1
+ # The agent runners this library knows, and everything each one knows about itself: what a credential
2
+ # for it looks like, which variables it reads, which models it offers, the command line that runs a turn
3
+ # and how to read what that prints.
4
+ #
5
+ # A caller asks the registry and gets the same shape back whichever vendor answers. It never learns that
6
+ # one wants an API key and another an OAuth token, that one prints stream-json and another something
7
+ # else, or which variable either reads: that is the vendor's own business and it is stated once, in the
8
+ # vendor's own class.
9
+ module Sandbox::Agent
10
+ # One thing a runner said while a turn ran. kind is status, message, thinking, tool_use, tool_result,
11
+ # task, result, error or notice; lost marks a transcript the box no longer has. parent_tool_use_id is the
12
+ # sub-agent call that emitted it. A task event is a sub-agent's state, keyed by its call's tool_use_id.
13
+ Event = Data.define(:kind, :content, :tool, :tool_use_id, :parent_tool_use_id, :session_id,
14
+ :lost) do
15
+ def initialize(kind:, content: nil, tool: nil, tool_use_id: nil, parent_tool_use_id: nil,
16
+ session_id: nil, lost: false)
17
+ super
18
+ end
19
+ end
20
+
21
+ # A vendor is code, not a row, so this is a class list. Adding one is adding a file here.
22
+ def self.providers = [ Sandbox::Agent::ClaudeCode ]
23
+ def self.keys = providers.map(&:key)
24
+
25
+ # The vendor a stored key names. Raises: a row naming one we do not have is a broken invariant.
26
+ def self.provider(key)
27
+ providers.find { _1.key == key.to_s } or
28
+ raise ArgumentError, "no llm provider #{key.inspect}; have #{keys.inspect}"
29
+ end
30
+
31
+ # The one a caller gets when nothing chose.
32
+ def self.default = providers.first
33
+
34
+ # Every variable any vendor reads. A turn's credential arrives under one of these, so nothing else may
35
+ # name them: a variable that did would decide which credential the runner reads.
36
+ def self.env_keys = providers.flat_map(&:env_keys).uniq
37
+
38
+ # Every model any vendor offers. Not one vendor's set: what an agent may be pinned to is asked before
39
+ # a credential is chosen, so the vendor is not known yet.
40
+ def self.models = providers.flat_map(&:models)
41
+ def self.model_ids = models.map { _1[:id] }
42
+
43
+ # Where a line this library could not read is reported. Rails answers it; outside an app it goes
44
+ # nowhere rather than crashing on a missing constant.
45
+ NOWHERE = Object.new.tap { |o| o.define_singleton_method(:report) { |*, **| nil } }.freeze
46
+
47
+ def self.reporter = @reporter ||= (defined?(Rails) ? Rails.error : NOWHERE)
48
+
49
+ class << self
50
+ attr_writer :reporter
51
+ end
52
+ end
@@ -0,0 +1,13 @@
1
+ # The agent runners a box can run: what a credential for each is, which variables it reads, which models
2
+ # it offers, the command line for one turn and how to read what it prints. It knows nothing about a box,
3
+ # a record or a framework: a caller asks the registry and gets the same shape from every vendor.
4
+ require "json"
5
+
6
+ module Sandbox; end
7
+ require_relative "sandbox/agent/version"
8
+
9
+ require_relative "sandbox/agent"
10
+ require_relative "sandbox/agent/provider"
11
+ require_relative "sandbox/agent/claude_code"
12
+ require_relative "sandbox/agent/claude_code/mcp_server"
13
+ require_relative "sandbox/agent/claude_code/stream"
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sandbox-agent
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - nvoi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ email:
13
+ - admin@nvoi.to
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - LICENSE
19
+ - README.md
20
+ - lib/sandbox-agent.rb
21
+ - lib/sandbox/agent.rb
22
+ - lib/sandbox/agent/claude_code.rb
23
+ - lib/sandbox/agent/claude_code/mcp_server.rb
24
+ - lib/sandbox/agent/claude_code/stream.rb
25
+ - lib/sandbox/agent/provider.rb
26
+ - lib/sandbox/agent/version.rb
27
+ homepage: https://rubygems.org/gems/sandbox-agent
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ rubygems_mfa_required: 'true'
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.4'
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 3.6.7
47
+ specification_version: 4
48
+ summary: 'Coding agent CLI registry: the command line for one turn and the events
49
+ it prints.'
50
+ test_files: []