riffer-rig 0.11.0 → 0.12.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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +94 -31
- data/lib/riffer/rig/acp/host.rb +83 -0
- data/lib/riffer/rig/acp.rb +353 -0
- data/lib/riffer/rig/bundled/mcp.rb +6 -1
- data/lib/riffer/rig/cli/flags.rb +50 -17
- data/lib/riffer/rig/cli/sessions.rb +124 -0
- data/lib/riffer/rig/cli.rb +16 -75
- data/lib/riffer/rig/headless/host.rb +9 -1
- data/lib/riffer/rig/headless/ndjson.rb +49 -0
- data/lib/riffer/rig/headless.rb +27 -12
- data/lib/riffer/rig/loader.rb +32 -3
- data/lib/riffer/rig/mcp/auth.rb +59 -0
- data/lib/riffer/rig/mcp/declaration.rb +29 -5
- data/lib/riffer/rig/registrar.rb +11 -3
- data/lib/riffer/rig/runtime.rb +35 -1
- data/lib/riffer/rig/terminal/picker.rb +144 -0
- data/lib/riffer/rig/terminal/session.rb +33 -0
- data/lib/riffer/rig/terminal.rb +113 -17
- data/lib/riffer/rig/version.rb +1 -1
- data/lib/riffer/rig.rb +1 -1
- data/sig/generated/riffer/rig/acp/host.rbs +53 -0
- data/sig/generated/riffer/rig/acp.rbs +169 -0
- data/sig/generated/riffer/rig/cli/flags.rbs +23 -2
- data/sig/generated/riffer/rig/cli/sessions.rbs +72 -0
- data/sig/generated/riffer/rig/cli.rbs +3 -31
- data/sig/generated/riffer/rig/headless/host.rbs +4 -1
- data/sig/generated/riffer/rig/headless/ndjson.rbs +27 -0
- data/sig/generated/riffer/rig/headless.rbs +11 -2
- data/sig/generated/riffer/rig/loader.rbs +15 -2
- data/sig/generated/riffer/rig/mcp/auth.rbs +32 -0
- data/sig/generated/riffer/rig/mcp/declaration.rbs +23 -4
- data/sig/generated/riffer/rig/registrar.rbs +6 -2
- data/sig/generated/riffer/rig/runtime.rbs +16 -3
- data/sig/generated/riffer/rig/terminal/picker.rbs +66 -0
- data/sig/generated/riffer/rig/terminal/session.rbs +32 -0
- data/sig/generated/riffer/rig/terminal.rbs +52 -8
- data/sig/manual/riffer/rig/events.rbs +1 -0
- data/sig/manual/riffer/rig/headless/ndjson.rbs +6 -0
- data/sig/manual/riffer/rig/headless.rbs +7 -0
- data/sig/manual/riffer/rig/mcp/auth.rbs +6 -0
- data/sig/manual/riffer/rig/terminal.rbs +12 -0
- metadata +33 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04c27f5bf6e9339d06df9f64501e3b3dac5fa4724b0272a29641b0d9b707f788
|
|
4
|
+
data.tar.gz: a89ddd0ef08516f50c3ea42c2374ff7ff80ea78b55ba1ed8a561851df6280745
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11115efca8ca651cf9d1211c0c17e112e4369ce72f606c0c3886e1caae0c3a5b48b281f298a88481fdfff630eb57b19b2de2aa2dffbb9ef3e01a23fe0fa32a11
|
|
7
|
+
data.tar.gz: f0548a971011eb9b8439de4237bdeaf61803867a2a692f02cf37ea8f69e6ddb1f4492e5fb5e49fe81d95ac3b379909383ca15949822e5e50f2c39c4686cf77b7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0](https://github.com/bottrall/riffer-rig/compare/v0.11.0...v0.12.0) (2026-10-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* --json streams every Runtime event as NDJSON ([#231](https://github.com/bottrall/riffer-rig/issues/231)) ([a5e4641](https://github.com/bottrall/riffer-rig/commit/a5e464172cb84fcbfc2fbce0a311c070b54a539a))
|
|
9
|
+
* /resume picker and /new in the terminal ([#232](https://github.com/bottrall/riffer-rig/issues/232)) ([140fbac](https://github.com/bottrall/riffer-rig/commit/140fbac7c7fc966987ee77c78222204bb755e37f))
|
|
10
|
+
* ACP session/load and session/list ([#237](https://github.com/bottrall/riffer-rig/issues/237)) ([db81553](https://github.com/bottrall/riffer-rig/commit/db815536fa940d3f14615fa55b27af63164665ce))
|
|
11
|
+
* resolve secret MCP headers from env and auth.json ([#234](https://github.com/bottrall/riffer-rig/issues/234)) ([6253f95](https://github.com/bottrall/riffer-rig/commit/6253f95932b81e276cec0850f7aa48abdb332b63))
|
|
12
|
+
* riffer acp serves the Runtime over ACP ([#235](https://github.com/bottrall/riffer-rig/issues/235)) ([9497122](https://github.com/bottrall/riffer-rig/commit/9497122cc0cdac3576481f6efeda6e49f595d999))
|
|
13
|
+
|
|
3
14
|
## [0.11.0](https://github.com/bottrall/riffer-rig/compare/v0.10.0...v0.11.0) (2026-10-06)
|
|
4
15
|
|
|
5
16
|
|
data/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# riffer-rig
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**riffer, in your terminal and in your process.**
|
|
4
4
|
|
|
5
|
-
`riffer-rig` is
|
|
5
|
+
`riffer-rig` is a general-purpose agent for Ruby developers who want to own their harness. It runs as a terminal agent and, because the runtime is separate from the terminal, it also runs inside any Ruby process: a Rails app, a script, a job. You extend it in Ruby: tools, commands, providers and hooks are ordinary Ruby classes on [riffer](https://github.com/janeapp/riffer)'s primitives, and the same objects work whether the host is the terminal or your app. It ships with a coding toolkit (read, write, edit, bash) as the default bundle, because a shell and file access are the most efficient way to get almost any task done — but the prompt gives it no coding identity. [Overview](docs/OVERVIEW.md) has the full thesis.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
@@ -48,10 +48,10 @@ The model is the first of these that is set: `--model`, `RIFFER_MODEL`, `model`
|
|
|
48
48
|
| `--max-steps N` | stops a turn after `N` model calls |
|
|
49
49
|
| `--no-save` | does not save this session to the [store](docs/SESSIONS.md) |
|
|
50
50
|
| `-c`, `--continue` | continues the most recent session in this directory ([Sessions](docs/SESSIONS.md#resuming)) |
|
|
51
|
-
| `-r ID`, `--resume ID` | resumes the session with id `ID` ([Sessions](docs/SESSIONS.md#resuming))
|
|
51
|
+
| `-r ID`, `--resume ID` | resumes the session with id `ID`; bare `-r` opens the [picker](docs/SESSIONS.md#the-picker) ([Sessions](docs/SESSIONS.md#resuming)) |
|
|
52
52
|
| `-h`, `--help` | prints the usage |
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
An editor registers the agent as `riffer acp`, the same Runtimes over the [Agent Client Protocol](https://agentclientprotocol.com) on stdio ([ACP](docs/ACP.md)).
|
|
55
55
|
|
|
56
56
|
### Headless
|
|
57
57
|
|
|
@@ -63,7 +63,7 @@ git diff | riffer -p "review this diff" # piped stdin appended as context
|
|
|
63
63
|
echo "explain this diff" | riffer -p # piped stdin alone is the prompt
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
Everything loads exactly as the REPL does, the shared flags work the same — `-c` and `-r` included: `riffer -p -c "now run the tests"` continues the most recent session in this directory, and a missing session exits 2 ([Headless](docs/HEADLESS.md)). The session is saved like any other unless `--no-save`. Only the assistant's text is printed, streamed to stdout; errors and warnings go to stderr, and `--verbose` adds a one-line trace of each tool call there. Headless never prompts: a missing model, credential, or SDK prints the reason on stderr and exits 2.
|
|
66
|
+
Everything loads exactly as the REPL does, the shared flags work the same — `-c` and `-r` included: `riffer -p -c "now run the tests"` continues the most recent session in this directory, and a missing session exits 2 ([Headless](docs/HEADLESS.md)). The session is saved like any other unless `--no-save`. Only the assistant's text is printed, streamed to stdout; errors and warnings go to stderr, and `--verbose` adds a one-line trace of each tool call there. With `--json`, stdout is instead one JSON object per line for every Runtime event — the NDJSON stream ([Headless](docs/HEADLESS.md#the-ndjson-stream---json)). Headless never prompts: a missing model, credential, or SDK prints the reason on stderr and exits 2.
|
|
67
67
|
|
|
68
68
|
The exit code says how the turn ended: `0` finished, `1` runtime error, `2` usage or configuration, `3` the turn ended without completing (`--max-steps`, a full context window, a provider content filter), `130` SIGINT (the turn is cancelled first).
|
|
69
69
|
|
|
@@ -73,10 +73,15 @@ Type a prompt and press Enter; the reply streams in, with each tool call and the
|
|
|
73
73
|
|
|
74
74
|
- Ctrl-C during a turn cancels it and returns to the prompt. Ctrl-C at the prompt asks for a second one, which exits.
|
|
75
75
|
- `/exit` or `/quit` ends the session, as does Ctrl-D.
|
|
76
|
+
- `/resume [--all]` opens the [session picker](docs/SESSIONS.md#the-picker): type to filter, switch with Enter on a single match or a row number, and delete with Ctrl-D and a confirm. `/new` starts a fresh session in place; the one you left stays on disk. Both replace the session the terminal drives and re-render the banner.
|
|
76
77
|
- `/model provider/name` switches the model ([below](#switching-the-model)), and `/skill:<name> [text]` runs a skill ([Skills](docs/SKILLS.md)).
|
|
77
78
|
- Any other `/name args` runs the Runtime command of that name, such as one an extension registers ([Extensions](docs/EXTENSIONS.md)); an unknown name is reported and nothing is sent to the model.
|
|
78
79
|
|
|
79
|
-
###
|
|
80
|
+
### Switching the model
|
|
81
|
+
|
|
82
|
+
`/model provider/name` switches the model for the current session only, keeping the conversation so far — `/model openai/gpt-5`, say. The provider prefix is required: a bare name such as `/model sonnet` is rejected with the list of providers. `/model` alone shows the model in use. Switching runs the same setup for the new provider as startup does: its SDK gem is offered for install (or refused with the exact Gemfile line), and missing credentials are resolved — you are prompted for them when the host can ask, and the values apply to the running session immediately. The switch is refused with one error when the host cannot supply what is missing, and the model is left unchanged. `/model --save` writes the model in effect to the home `settings.json`, so new sessions start with it; `/model provider/name --save` switches and saves in one step, and a refused switch saves nothing ([Configuration](docs/CONFIGURATION.md#model)).
|
|
83
|
+
|
|
84
|
+
## Authentication
|
|
80
85
|
|
|
81
86
|
`riffer-rig` talks to the provider named by the model's prefix — `anthropic/claude-sonnet-4-6` means Anthropic. Provide that provider's credentials in either of two ways:
|
|
82
87
|
|
|
@@ -107,33 +112,92 @@ The flat `{"anthropic": "sk-…"}` shape earlier versions wrote is no longer rea
|
|
|
107
112
|
|
|
108
113
|
Inside a session, `/auth` lists every provider with where its secret comes from — `env`, `stored`, `chain` (the SDK's own credential chain, for Bedrock) or `missing`. `/auth <provider>` re-runs that provider's setup, which is how you rotate a key, and applies the new values to the current session, so the next request uses them. `/auth remove <provider>` deletes the provider's `auth.json` entry and its `providers` block in settings.
|
|
109
114
|
|
|
110
|
-
|
|
115
|
+
## Configuration
|
|
111
116
|
|
|
112
|
-
|
|
117
|
+
Settings live in two scopes, both optional: `~/.riffer/settings.json` for every project, and `<cwd>/.riffer/settings.json` for one project, merged key by key with the project winning ([Configuration](docs/CONFIGURATION.md)). Every core key is optional:
|
|
113
118
|
|
|
114
|
-
|
|
119
|
+
| Key | What it sets |
|
|
120
|
+
| ------------ | -------------------------------------------------------------------------------- |
|
|
121
|
+
| `model` | the model new sessions start with, as `provider/name` |
|
|
122
|
+
| `reasoning` | the reasoning effort, translated to the provider's own parameter |
|
|
123
|
+
| `models` | prices each model in USD per million tokens |
|
|
124
|
+
| `providers` | non-secret provider fields such as an endpoint or region (home file only) |
|
|
125
|
+
| `extensions` | `disabled` — bundled extensions to leave out; `autoload` — gem extension autoload |
|
|
126
|
+
| `reload` | `"auto"` (default) or `"manual"` — the hot-reload trigger |
|
|
127
|
+
| `sessions` | `save: false` stops saving sessions to the store |
|
|
128
|
+
| `tools` | the provider-native tool switches (`native`), off by default |
|
|
115
129
|
|
|
116
|
-
- `AGENTS.md` — `~/.riffer/AGENTS.md` and an `AGENTS.md` in the current working directory or any directory above it, whichever exist, are re-read every turn as instructions that take precedence over the default norms ([Instructions](docs/INSTRUCTIONS.md#agentsmd)).
|
|
117
|
-
- Skills — Agent Skills in `.agents/skills/` from the current working directory up to the repository root, and in `~/.agents/skills/`, are offered to the model, and each can be run with `/skill:<name>` ([Skills](docs/SKILLS.md)).
|
|
118
130
|
- `RIFFER_MODEL` — the model for this run as `provider/name`, winning over the `model` setting. A bare name such as `sonnet` is rejected with the list of providers.
|
|
119
|
-
- `~/.riffer/
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
- `AGENTS.md` — `~/.riffer/AGENTS.md` and an `AGENTS.md` in the current working directory or any directory above it are re-read every turn as instructions that take precedence over the default norms ([Instructions](docs/INSTRUCTIONS.md#agentsmd)).
|
|
132
|
+
- Skills — Agent Skills in `.agents/skills/` from the working directory up to the repository root, and in `~/.agents/skills/`, are offered to the model, and `/skill:<name> [text]` runs one ([Skills](docs/SKILLS.md)).
|
|
133
|
+
- MCP servers — declared under the `mcp` key in either settings file and registered with the Runtime ([MCP](docs/MCP.md)).
|
|
134
|
+
|
|
135
|
+
## Extending
|
|
136
|
+
|
|
137
|
+
An extension is a named registrar block in a `rig.rb` file — `~/.riffer/rig.rb` runs in every project, `<project>/.riffer/rig.rb` in one, and the first run of a project file asks you to trust it:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Riffer::Rig.extension('git') do |rig|
|
|
141
|
+
# A tool the model can call
|
|
142
|
+
rig.tool GitLog
|
|
143
|
+
|
|
144
|
+
# A /log command in the session
|
|
145
|
+
rig.command('log', description: 'Recent commits') do |ctx|
|
|
146
|
+
ctx.say `git log --oneline -n #{ctx.args}`
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A section appended to the system message, re-read every turn
|
|
150
|
+
rig.prompt(:branch) { |ctx| "Branch: #{`git branch --show-current`}" }
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Tools, commands, prompt sections, event handlers, skills, MCP servers and providers register the same way, and the same objects work whether the host is the terminal or your app. [Extensions](docs/EXTENSIONS.md) has all eight seams, error isolation, and packaging an extension as a gem.
|
|
155
|
+
|
|
156
|
+
## Embedding
|
|
157
|
+
|
|
158
|
+
The runtime is separate from the terminal, so the same agent runs inside any Ruby process:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
runtime = Riffer::Rig::Loader.runtime(cwd: Dir.pwd, host: Riffer::Rig::Hosts::Null.new)
|
|
162
|
+
response = runtime.ask('why is this test failing?')
|
|
163
|
+
puts response.content
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`Loader.runtime` applies the same filesystem conventions as the terminal — settings, credentials, `rig.rb`, skills. [Embedding](docs/EMBEDDING.md) has the streaming `prompt`, snapshots, and rebuilding after a code reload.
|
|
167
|
+
|
|
168
|
+
## Documentation
|
|
169
|
+
|
|
170
|
+
The guides, in reading order:
|
|
171
|
+
|
|
172
|
+
Start here:
|
|
173
|
+
|
|
174
|
+
- [Overview](https://riffer.bottrall.dev/guides/overview/) — What riffer-rig is, the four tiers, the runtime and host layers
|
|
175
|
+
- [Getting started](https://riffer.bottrall.dev/guides/getting-started/) — Install, pick a model, first session
|
|
176
|
+
|
|
177
|
+
Using the terminal:
|
|
178
|
+
|
|
179
|
+
- [Configuration](https://riffer.bottrall.dev/guides/configuration/) — Settings scopes and every core key
|
|
180
|
+
- [Instructions](https://riffer.bottrall.dev/guides/instructions/) — How the system message is built; AGENTS.md
|
|
181
|
+
- [Tools](https://riffer.bottrall.dev/guides/tools/) — The bundled toolkit and provider-native tools
|
|
182
|
+
- [Skills](https://riffer.bottrall.dev/guides/skills/) — Agent Skills directories and activation
|
|
183
|
+
- [MCP](https://riffer.bottrall.dev/guides/mcp/) — Declaring MCP servers
|
|
184
|
+
- [Sessions](https://riffer.bottrall.dev/guides/sessions/) — Saving, resuming, the picker, the JSONL store
|
|
185
|
+
- [Reloading](https://riffer.bottrall.dev/guides/reloading/) — The /reload command, what reloads and what does not
|
|
186
|
+
- [Headless](https://riffer.bottrall.dev/guides/headless/) — riffer -p, NDJSON, exit codes
|
|
187
|
+
- [ACP](https://riffer.bottrall.dev/guides/acp/) — riffer acp, the ACP agent over stdio
|
|
188
|
+
|
|
189
|
+
Extending and embedding:
|
|
190
|
+
|
|
191
|
+
- [Extensions](https://riffer.bottrall.dev/guides/extensions/) — rig.rb, the registrar, the eight seams, commands, handlers, gems
|
|
192
|
+
- [Embedding](https://riffer.bottrall.dev/guides/embedding/) — Runtime and Loader from Ruby; snapshots; rebuild
|
|
193
|
+
- [Hosts](https://riffer.bottrall.dev/guides/hosts/) — The Host interface and writing a host
|
|
194
|
+
|
|
195
|
+
Providers:
|
|
196
|
+
|
|
197
|
+
- [Providers](https://riffer.bottrall.dev/guides/providers/overview/) — Model strings, precedence, credentials, /auth
|
|
198
|
+
- [Custom providers](https://riffer.bottrall.dev/guides/providers/custom/) — Registering a provider from an extension; the setup, credentials, listings
|
|
199
|
+
|
|
200
|
+
The guide sources are in `docs/`. To preview the site locally, run `bin/docs serve` and open <http://localhost:8000>.
|
|
137
201
|
|
|
138
202
|
## Development
|
|
139
203
|
|
|
@@ -150,7 +214,6 @@ Every project chore is a script in `bin/`. The Rakefile behind them is an implem
|
|
|
150
214
|
| `bin/ci` | Run everything CI runs, serially. Use before pushing |
|
|
151
215
|
| `bin/build` | Build the gem into `pkg/`; the publish workflow runs this before `gem push` |
|
|
152
216
|
| `bin/docs` | Build the docs site and API reference into `_site/`; `bin/docs serve` serves it at http://localhost:8000 |
|
|
153
|
-
| `bin/plans` | Serve the building plans in `plans/` at http://localhost:8001 |
|
|
154
217
|
|
|
155
218
|
## Releasing
|
|
156
219
|
|
|
@@ -174,4 +237,4 @@ Licensed under the MIT License. See [`LICENSE.txt`](LICENSE.txt) for details.
|
|
|
174
237
|
|
|
175
238
|
## Maintainer
|
|
176
239
|
|
|
177
|
-
- Jake Bottrall - https://github.com/bottrall
|
|
240
|
+
- Jake Bottrall - https://github.com/bottrall
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'acp/sdk'
|
|
4
|
+
|
|
5
|
+
class Riffer::Rig::ACP::Host
|
|
6
|
+
CAPABILITIES = Set[:notify].freeze #: Set[Symbol]
|
|
7
|
+
|
|
8
|
+
# @rbs @client: ::ACP::AgentConnection::_Client
|
|
9
|
+
# @rbs @session_id: String?
|
|
10
|
+
# @rbs @buffered: Array[String]
|
|
11
|
+
|
|
12
|
+
# @rbs client: ::ACP::AgentConnection::_Client
|
|
13
|
+
# @rbs return: void
|
|
14
|
+
def initialize(client:)
|
|
15
|
+
@client = client
|
|
16
|
+
@session_id = nil
|
|
17
|
+
@buffered = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @rbs return: Set[Symbol]
|
|
21
|
+
def capabilities
|
|
22
|
+
CAPABILITIES
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @rbs question: String?
|
|
26
|
+
# @rbs options: Array[String]?
|
|
27
|
+
# @rbs secret: bool
|
|
28
|
+
# @rbs return: String?
|
|
29
|
+
def ask(question = nil, options: nil, secret: false)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @rbs question: String?
|
|
34
|
+
# @rbs return: bool
|
|
35
|
+
def confirm(_question = nil)
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @rbs message: String?
|
|
40
|
+
# @rbs level: Symbol
|
|
41
|
+
# @rbs return: void
|
|
42
|
+
def notify(message = nil, level: :info)
|
|
43
|
+
id = @session_id
|
|
44
|
+
return @buffered << message.to_s unless id
|
|
45
|
+
|
|
46
|
+
send_update(id, message.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @rbs label: String?
|
|
50
|
+
# @rbs &block: ? () -> void
|
|
51
|
+
# @rbs return: void
|
|
52
|
+
def progress(label = nil, &block)
|
|
53
|
+
notify(label) if label
|
|
54
|
+
block&.call
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Session updates name their session, and acp-sdk runs session_created after
|
|
58
|
+
# the session/new reply so the client knows the id before any update for it
|
|
59
|
+
# arrives — that is why the build-time notifies wait for this, not for the
|
|
60
|
+
# Runtime's construction to end.
|
|
61
|
+
# @rbs id: String
|
|
62
|
+
# @rbs return: void
|
|
63
|
+
def session_id=(id)
|
|
64
|
+
@session_id = id
|
|
65
|
+
buffered = @buffered
|
|
66
|
+
@buffered = []
|
|
67
|
+
buffered.each { |message| send_update(id, message) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# @rbs id: String
|
|
73
|
+
# @rbs message: String
|
|
74
|
+
# @rbs return: void
|
|
75
|
+
def send_update(id, message)
|
|
76
|
+
@client.update(
|
|
77
|
+
id,
|
|
78
|
+
::ACP::Types::SessionUpdate::AgentMessageChunk.new(
|
|
79
|
+
content: ::ACP::Types::ContentBlock::Text.new(text: message)
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'acp/sdk'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
# The ACP tier: Runtime events reach the client only through this adapter's
|
|
7
|
+
# mapping, so nothing else in rig may translate an event into an ACP type.
|
|
8
|
+
class Riffer::Rig::ACP
|
|
9
|
+
AGENT_NAME = 'riffer' #: String
|
|
10
|
+
AGENT_TITLE = 'Riffer' #: String
|
|
11
|
+
|
|
12
|
+
SESSION_EXTENSION = 'acp' #: String
|
|
13
|
+
|
|
14
|
+
TOOL_KINDS = {
|
|
15
|
+
'read' => ::ACP::Types::ToolKind::READ,
|
|
16
|
+
'write' => ::ACP::Types::ToolKind::EDIT,
|
|
17
|
+
'edit' => ::ACP::Types::ToolKind::EDIT,
|
|
18
|
+
'bash' => ::ACP::Types::ToolKind::EXECUTE
|
|
19
|
+
}.freeze #: Hash[String, String]
|
|
20
|
+
|
|
21
|
+
# @rbs @input: IO
|
|
22
|
+
# @rbs @output: IO
|
|
23
|
+
# @rbs @env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
|
|
24
|
+
# @rbs @home: String
|
|
25
|
+
# @rbs @build: ^(String, Riffer::Rig::Hosts::_Host, Hash[String, Riffer::Rig::Mcp::Declaration]) -> Riffer::Rig::Runtime
|
|
26
|
+
# @rbs @client: ::ACP::AgentConnection::_Client?
|
|
27
|
+
# @rbs @sessions: Hash[String, Riffer::Rig::Runtime]
|
|
28
|
+
# @rbs @hosts: Hash[String, Riffer::Rig::ACP::Host]
|
|
29
|
+
# @rbs @lock: Thread::Mutex
|
|
30
|
+
|
|
31
|
+
# @rbs input: IO
|
|
32
|
+
# @rbs output: IO
|
|
33
|
+
# @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
|
|
34
|
+
# @rbs home: String
|
|
35
|
+
# @rbs build: (^(String, Riffer::Rig::Hosts::_Host, Hash[String, Riffer::Rig::Mcp::Declaration]) -> Riffer::Rig::Runtime)?
|
|
36
|
+
# @rbs return: Riffer::Rig::ACP
|
|
37
|
+
def self.for(input:, output:, env:, home:, build: nil)
|
|
38
|
+
new(input: input, output: output, env: env, home: home, build: build)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Builds the Runtime a session runs on: the client's MCP servers flow
|
|
42
|
+
# through the rig.mcp seam as a synthetic extension, so a reload rebuild
|
|
43
|
+
# re-declares them and a same-name declaration from an extension replaces
|
|
44
|
+
# them with the usual notify.
|
|
45
|
+
# @rbs cwd: String
|
|
46
|
+
# @rbs host: Riffer::Rig::Hosts::_Host
|
|
47
|
+
# @rbs servers: Hash[String, Riffer::Rig::Mcp::Declaration]
|
|
48
|
+
# @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
|
|
49
|
+
# @rbs home: String
|
|
50
|
+
# @rbs return: Riffer::Rig::Runtime
|
|
51
|
+
def self.build_runtime(cwd, host, servers, env:, home:)
|
|
52
|
+
extension = session_extension(servers)
|
|
53
|
+
Riffer::Rig::Loader.runtime(
|
|
54
|
+
cwd: cwd, host: host, env: env, home: home, extra_extensions: extension ? [extension] : []
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @rbs servers: Hash[String, Riffer::Rig::Mcp::Declaration]
|
|
59
|
+
# @rbs return: Riffer::Rig::Extension?
|
|
60
|
+
def self.session_extension(servers)
|
|
61
|
+
return nil if servers.empty?
|
|
62
|
+
|
|
63
|
+
Riffer::Rig::Extension.new(SESSION_EXTENSION) do |rig|
|
|
64
|
+
servers.each { |name, server| rig.mcp(name, command: server.command, args: server.args, env: server.env) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @rbs input: IO
|
|
69
|
+
# @rbs output: IO
|
|
70
|
+
# @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
|
|
71
|
+
# @rbs home: String
|
|
72
|
+
# @rbs build: (^(String, Riffer::Rig::Hosts::_Host, Hash[String, Riffer::Rig::Mcp::Declaration]) -> Riffer::Rig::Runtime)?
|
|
73
|
+
# @rbs return: void
|
|
74
|
+
def initialize(input:, output:, env:, home:, build: nil)
|
|
75
|
+
@input = input
|
|
76
|
+
@output = output
|
|
77
|
+
@env = env
|
|
78
|
+
@home = home
|
|
79
|
+
@build = build || ->(cwd, host, servers) { self.class.build_runtime(cwd, host, servers, env: @env, home: @home) }
|
|
80
|
+
@client = nil
|
|
81
|
+
@sessions = {}
|
|
82
|
+
@hosts = {}
|
|
83
|
+
@lock = Mutex.new
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Serves the connection until the client closes stdin; then the reader
|
|
87
|
+
# thread ends and run returns.
|
|
88
|
+
# @rbs return: Integer
|
|
89
|
+
def run
|
|
90
|
+
connection = ::ACP::AgentConnection.new(
|
|
91
|
+
transport: ::ACP::Transport::Stdio.new(input: @input, output: @output),
|
|
92
|
+
capabilities: ::ACP::Types::AgentCapabilities.new(
|
|
93
|
+
load_session: true,
|
|
94
|
+
session_capabilities: ::ACP::Types::SessionCapabilities.new(list: ::ACP::Types::SessionListCapabilities.new)
|
|
95
|
+
),
|
|
96
|
+
agent_info: ::ACP::Types::Implementation.new(name: AGENT_NAME, version: Riffer::Rig::VERSION, title: AGENT_TITLE)
|
|
97
|
+
) do |client|
|
|
98
|
+
@client = client
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
connection.start.join
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @rbs request: ::ACP::Types::NewSessionRequest
|
|
106
|
+
# @rbs return: (::ACP::Types::NewSessionResponse | ::ACP::RequestError)
|
|
107
|
+
def new_session(request)
|
|
108
|
+
host = Riffer::Rig::ACP::Host.new(client: client)
|
|
109
|
+
servers = declarations(request.mcp_servers, host)
|
|
110
|
+
runtime = @build.call(request.cwd, host, servers)
|
|
111
|
+
@lock.synchronize do
|
|
112
|
+
@sessions[runtime.id] = runtime
|
|
113
|
+
@hosts[runtime.id] = host
|
|
114
|
+
end
|
|
115
|
+
::ACP::Types::NewSessionResponse.new(session_id: runtime.id)
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
::ACP::RequestError.new(code: ::ACP::RequestError::INTERNAL_ERROR, message: e.message)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @rbs request: ::ACP::Types::PromptRequest
|
|
121
|
+
# @rbs return: (::ACP::Types::PromptResponse | ::ACP::RequestError)
|
|
122
|
+
def prompt(request)
|
|
123
|
+
runtime = session(request.session_id)
|
|
124
|
+
return ::ACP::RequestError.resource_not_found unless runtime
|
|
125
|
+
|
|
126
|
+
# @type var stop_reason: Symbol?
|
|
127
|
+
stop_reason = nil
|
|
128
|
+
runtime.prompt(prompt_text(request.prompt)) do |event|
|
|
129
|
+
case event
|
|
130
|
+
when Riffer::StreamEvents::TextDelta
|
|
131
|
+
client.update(request.session_id, message_chunk(event.content))
|
|
132
|
+
when Riffer::StreamEvents::ToolCallDone
|
|
133
|
+
client.update(request.session_id, tool_call(event))
|
|
134
|
+
when Riffer::Rig::Events::TurnEnd
|
|
135
|
+
stop_reason = event.stop_reason
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
::ACP::Types::PromptResponse.new(stop_reason: stop_reason_of(stop_reason))
|
|
139
|
+
rescue Riffer::Rig::Runtime::BusyError, Riffer::Rig::Runtime::ClosedError => e
|
|
140
|
+
::ACP::RequestError.new(code: ::ACP::RequestError::INTERNAL_ERROR, message: e.message)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The host takes the session id before the build, so a build-time notify
|
|
144
|
+
# still reaches the client as an update for this session.
|
|
145
|
+
# @rbs request: ::ACP::Types::LoadSessionRequest
|
|
146
|
+
# @rbs return: (::ACP::Types::LoadSessionResponse | ::ACP::RequestError)
|
|
147
|
+
def load_session(request)
|
|
148
|
+
host = Riffer::Rig::ACP::Host.new(client: client)
|
|
149
|
+
host.session_id = request.session_id
|
|
150
|
+
loader = Riffer::Rig::Loader.new(cwd: request.cwd, host: host, env: @env, home: @home)
|
|
151
|
+
runtime = loader.resume(request.session_id)
|
|
152
|
+
return ::ACP::RequestError.resource_not_found unless runtime
|
|
153
|
+
|
|
154
|
+
@lock.synchronize do
|
|
155
|
+
@sessions[request.session_id] = runtime
|
|
156
|
+
@hosts[request.session_id] = host
|
|
157
|
+
end
|
|
158
|
+
loader.messages(request.session_id)
|
|
159
|
+
.filter_map { |message| replay_of(message) }
|
|
160
|
+
.each { |update| client.update(request.session_id, update) }
|
|
161
|
+
::ACP::Types::LoadSessionResponse.new
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
::ACP::RequestError.new(code: ::ACP::RequestError::INTERNAL_ERROR, message: e.message)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @rbs request: ::ACP::Types::ListSessionsRequest
|
|
167
|
+
# @rbs return: (::ACP::Types::ListSessionsResponse | ::ACP::RequestError)
|
|
168
|
+
def list_sessions(request)
|
|
169
|
+
loader = Riffer::Rig::Loader.new(
|
|
170
|
+
cwd: request.cwd || Dir.pwd,
|
|
171
|
+
host: Riffer::Rig::Hosts::Null.new,
|
|
172
|
+
env: @env,
|
|
173
|
+
home: @home
|
|
174
|
+
)
|
|
175
|
+
headers = request.cwd ? loader.list : loader.list(all: true)
|
|
176
|
+
sessions = headers.map do |header|
|
|
177
|
+
::ACP::Types::SessionInfo.new(
|
|
178
|
+
session_id: header.id,
|
|
179
|
+
cwd: header.cwd,
|
|
180
|
+
title: header.title,
|
|
181
|
+
updated_at: header.updated&.utc&.iso8601
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
::ACP::Types::ListSessionsResponse.new(sessions: sessions)
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
::ACP::RequestError.new(code: ::ACP::RequestError::INTERNAL_ERROR, message: e.message)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Runs on the transport's reader thread, so it only sets the cancel flag.
|
|
190
|
+
# @rbs notification: ::ACP::Types::CancelNotification
|
|
191
|
+
# @rbs return: void
|
|
192
|
+
def cancel(notification)
|
|
193
|
+
session(notification.session_id)&.cancel
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Runs after the session/new reply, so the client knows the id before any
|
|
197
|
+
# update for it arrives: the build-time notifies flush here, then the
|
|
198
|
+
# commands go out.
|
|
199
|
+
# @rbs response: ::ACP::Types::NewSessionResponse
|
|
200
|
+
# @rbs return: void
|
|
201
|
+
def session_created(response)
|
|
202
|
+
id = response.session_id
|
|
203
|
+
# @type var found: [Riffer::Rig::ACP::Host?, Riffer::Rig::Runtime?]
|
|
204
|
+
found = @lock.synchronize { [@hosts[id], @sessions[id]] }
|
|
205
|
+
host, runtime = found
|
|
206
|
+
return unless host && runtime
|
|
207
|
+
|
|
208
|
+
host.session_id = id
|
|
209
|
+
client.available_commands(id, available_commands(runtime))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# @rbs return: ::ACP::AgentConnection::_Client
|
|
215
|
+
def client
|
|
216
|
+
@client or raise 'ACP agent used before ::ACP::AgentConnection.start built it'
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @rbs id: String
|
|
220
|
+
# @rbs return: Riffer::Rig::Runtime?
|
|
221
|
+
def session(id)
|
|
222
|
+
@lock.synchronize { @sessions[id] }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @rbs servers: Array[::ACP::Types::McpServer::t]
|
|
226
|
+
# @rbs host: Riffer::Rig::ACP::Host
|
|
227
|
+
# @rbs return: Hash[String, Riffer::Rig::Mcp::Declaration]
|
|
228
|
+
def declarations(servers, host)
|
|
229
|
+
servers.filter_map { |server| declaration_of(server, host) }.to_h
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @rbs server: ::ACP::Types::McpServer::t
|
|
233
|
+
# @rbs host: Riffer::Rig::ACP::Host
|
|
234
|
+
# @rbs return: [String, Riffer::Rig::Mcp::Declaration]?
|
|
235
|
+
def declaration_of(server, host)
|
|
236
|
+
unless server.is_a?(::ACP::Types::McpServerStdio)
|
|
237
|
+
host.notify("Skipped MCP server #{server_name(server)}: only stdio servers are supported", level: :warning)
|
|
238
|
+
return nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
[
|
|
242
|
+
server.name,
|
|
243
|
+
Riffer::Rig::Mcp::Declaration.new(command: server.command, args: server.args, env: stdio_env(server))
|
|
244
|
+
]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# @rbs server: ::ACP::Types::McpServer::t
|
|
248
|
+
# @rbs return: String
|
|
249
|
+
def server_name(server)
|
|
250
|
+
case server
|
|
251
|
+
when ::ACP::Types::McpServer::Http, ::ACP::Types::McpServer::Sse then server.name
|
|
252
|
+
when Hash then server['name'].to_s
|
|
253
|
+
else 'unnamed'
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# @rbs server: ::ACP::Types::McpServerStdio
|
|
258
|
+
# @rbs return: Hash[String, String]
|
|
259
|
+
def stdio_env(server)
|
|
260
|
+
server.env.to_h { |variable| [variable.name, variable.value] }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Text and resource links are ACP's must-support prompt blocks; the client
|
|
264
|
+
# cannot send the others unless the agent advertised them.
|
|
265
|
+
# @rbs blocks: Array[::ACP::Types::ContentBlock::t]
|
|
266
|
+
# @rbs return: String
|
|
267
|
+
def prompt_text(blocks)
|
|
268
|
+
blocks.filter_map { |block| prompt_text_of(block) }.join("\n\n")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# @rbs block: ::ACP::Types::ContentBlock::t
|
|
272
|
+
# @rbs return: String?
|
|
273
|
+
def prompt_text_of(block)
|
|
274
|
+
case block
|
|
275
|
+
when ::ACP::Types::ContentBlock::Text then block.text
|
|
276
|
+
when ::ACP::Types::ContentBlock::ResourceLink then "#{block.title || block.name} #{block.uri}"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @rbs content: String
|
|
281
|
+
# @rbs return: ::ACP::Types::SessionUpdate::AgentMessageChunk
|
|
282
|
+
def message_chunk(content)
|
|
283
|
+
::ACP::Types::SessionUpdate::AgentMessageChunk.new(content: text_block(content))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# @rbs text: String
|
|
287
|
+
# @rbs return: ::ACP::Types::ContentBlock::Text
|
|
288
|
+
def text_block(text)
|
|
289
|
+
::ACP::Types::ContentBlock::Text.new(text: text)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# @rbs message: Hash[Symbol, untyped]
|
|
293
|
+
# @rbs return: ::ACP::Types::SessionUpdate::t?
|
|
294
|
+
def replay_of(message)
|
|
295
|
+
case message[:role]
|
|
296
|
+
when 'user' then ::ACP::Types::SessionUpdate::UserMessageChunk.new(content: text_block(message[:content].to_s))
|
|
297
|
+
when 'assistant' then message_chunk(message[:content].to_s)
|
|
298
|
+
when 'tool' then replayed_tool_call(message)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The stored result is all a replayed call has: the arguments live in the
|
|
303
|
+
# assistant message's tool_calls, so the title is the name alone.
|
|
304
|
+
# @rbs message: Hash[Symbol, untyped]
|
|
305
|
+
# @rbs return: ::ACP::Types::SessionUpdate::ToolCall
|
|
306
|
+
def replayed_tool_call(message)
|
|
307
|
+
name = message[:name].to_s
|
|
308
|
+
::ACP::Types::SessionUpdate::ToolCall.new(
|
|
309
|
+
tool_call_id: message[:tool_call_id].to_s,
|
|
310
|
+
title: name,
|
|
311
|
+
name: name,
|
|
312
|
+
kind: TOOL_KINDS.fetch(name, ::ACP::Types::ToolKind::OTHER),
|
|
313
|
+
status: ::ACP::Types::ToolCallStatus::COMPLETED,
|
|
314
|
+
content: [::ACP::Types::ToolCallContent::Content.new(content: text_block(message[:content].to_s))]
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# riffer's stream has no completion boundary for a tool call, so a call goes
|
|
319
|
+
# out once, in progress, when its arguments are complete.
|
|
320
|
+
# @rbs event: Riffer::StreamEvents::ToolCallDone
|
|
321
|
+
# @rbs return: ::ACP::Types::SessionUpdate::ToolCall
|
|
322
|
+
def tool_call(event)
|
|
323
|
+
::ACP::Types::SessionUpdate::ToolCall.new(
|
|
324
|
+
tool_call_id: event.call_id,
|
|
325
|
+
title: "#{event.name}(#{event.arguments})",
|
|
326
|
+
name: event.name,
|
|
327
|
+
kind: TOOL_KINDS.fetch(event.name, ::ACP::Types::ToolKind::OTHER),
|
|
328
|
+
status: ::ACP::Types::ToolCallStatus::IN_PROGRESS
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# riffer's failure reasons (:error, :other, :guardrail_blocked) have no ACP
|
|
333
|
+
# counterpart; refusal is the closest signal that the turn did not complete.
|
|
334
|
+
# @rbs stop_reason: Symbol?
|
|
335
|
+
# @rbs return: String
|
|
336
|
+
def stop_reason_of(stop_reason)
|
|
337
|
+
case stop_reason
|
|
338
|
+
when :completed then ::ACP::Types::StopReason::END_TURN
|
|
339
|
+
when Riffer::Rig::Runtime::INTERRUPT_CANCELLED then ::ACP::Types::StopReason::CANCELLED
|
|
340
|
+
when :max_steps then ::ACP::Types::StopReason::MAX_TURN_REQUESTS
|
|
341
|
+
when :context_window, :length, :content_filter, :malformed_output then ::ACP::Types::StopReason::MAX_TOKENS
|
|
342
|
+
else ::ACP::Types::StopReason::REFUSAL
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# @rbs runtime: Riffer::Rig::Runtime
|
|
347
|
+
# @rbs return: Array[::ACP::Types::AvailableCommand]
|
|
348
|
+
def available_commands(runtime)
|
|
349
|
+
runtime.commands.map do |command|
|
|
350
|
+
::ACP::Types::AvailableCommand.new(name: command.name, description: command.description)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
module Riffer::Rig::Bundled
|
|
4
4
|
Mcp = Riffer::Rig::Extension.new('mcp') do |rig|
|
|
5
5
|
rig.settings[:servers].to_h.each do |name, server|
|
|
6
|
-
rig.mcp(
|
|
6
|
+
rig.mcp(
|
|
7
|
+
name.to_s,
|
|
8
|
+
url: server.fetch(:url),
|
|
9
|
+
headers: server[:headers].to_h.transform_keys(&:to_s),
|
|
10
|
+
auth: Hash(server[:auth])
|
|
11
|
+
)
|
|
7
12
|
end
|
|
8
13
|
end #: Riffer::Rig::Extension
|
|
9
14
|
end
|