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 +7 -0
- data/LICENSE +21 -0
- data/README.md +14 -0
- data/lib/sandbox/agent/claude_code/mcp_server.rb +33 -0
- data/lib/sandbox/agent/claude_code/stream.rb +103 -0
- data/lib/sandbox/agent/claude_code.rb +96 -0
- data/lib/sandbox/agent/provider.rb +58 -0
- data/lib/sandbox/agent/version.rb +5 -0
- data/lib/sandbox/agent.rb +52 -0
- data/lib/sandbox-agent.rb +13 -0
- metadata +50 -0
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,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: []
|