Skip to content

feat(runtimes): acp runtime launches a named command with no model - #1677

Closed
lex00 wants to merge 3 commits into
issue-1637-conversation-labelsfrom
issue-1634-acp-runtime
Closed

feat(runtimes): acp runtime launches a named command with no model#1677
lex00 wants to merge 3 commits into
issue-1637-conversation-labelsfrom
issue-1634-acp-runtime

Conversation

@lex00

@lex00 lex00 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Every runtime Fountain has is an LLM coding agent. A deterministic program that wants a warm machine, a vault, a thread, a schedule and a human who can read what happened could not be launched: the runtime registry is a closed map, the model parser expects a provider/model_id, and a turn resolves inference credentials it will never use. This PR adds an acp runtime that launches a named command and speaks ACP to it over stdio like the four existing runtimes.

  • Add Fountain.Runtimes.Command, a Managoat.Runtimes implementation for a command the agent names, and a Fountain.Runtimes facade that answers acp itself and delegates everything else to the library registry. Every runtime lookup, including skills installation, goes through the facade.
  • Add agents.runtime_command, required for acp and refused on any other runtime, and make agents.model optional for acp only. Both refusals are 422s naming the field. The command is a free string, run as bash -lc inside the sandbox.
  • Resolve no inference credential and pin no model on an acp turn, so model.failed cannot come from the pin path and a turn on an account with no credentials succeeds. turn.usage is null; credits price the turn by sandbox time only.
  • Expose the field in the API, the console form (which disables the model input on acp) and the account export. Model suggestions return nothing for acp.
  • Add a conformance test that runs a checked-in fixture ACP agent, a small Elixir program, as a real OS process through a whole turn, plus scripted-agent tests for tool-call blocks, the stop reason, interrupt and all three permission verdicts.
  • Document the runtime in the catalog, docs/sdk.md, docs/concepts/permissions.md and the integrations pages, including what "the same isolation" means on a self-hosted runner.

Closes #1634

Stacked on #1637; merge that first.

Validation:

  • Full suite on the four-branch stack: 4579 tests, 0 failures (core and ee), 144 (fountain_buzz), 33 (fountain_support).
  • mix compile --warnings-as-errors, mix format --check-formatted, mix credo --strict, dialyzer, sobelow and deps.unlock --unused are clean. The prod release assembles.
  • The SDK contract check, the TypeScript verifier and tests, the Python verifier and the conformance lint pass. scripts/docs-style.py is clean.
  • Not run here: vale, okf and destink (not installed on the build machine).

@lex00 lex00 added enhancement New feature or request area:acp Agent Client Protocol: runtimes, editors, permissions, gateway area:sandbox Sandboxes: providers, lifetime, capabilities, the machine itself labels Sep 6, 2026
…1634)

Every runtime Fountain had was an LLM coding agent. This one is not: the
agent names a command in a new `runtime_command` field, Fountain launches it
inside the sandbox as `bash -lc <line>`, and speaks the Agent Client Protocol
to it over stdio exactly as it does to claude or codex. A deterministic
program gets a warm machine, a vault, a thread, a schedule and a human who
can read what happened.

`runtime_command` is a **free string**, not an entry in a catalog of blessed
commands. It runs inside the sandbox under the same isolation an
environment's `setup_script` already runs under, so a catalog would restrict
a self-hoster and protect nobody. That was the one product question the issue
left open, and this is the answer.

The runtime module lives here rather than in `managoat_runtimes`, whose
registry is a closed map and which knows nothing about
`agents.runtime_command`. `Fountain.CommandRuntime` implements the
`Managoat.Runtimes` behaviour, named for what varies and flat like its one
sibling, `Fountain.DeployedACPFixture`.

`Fountain.RuntimeDispatch` is the host dispatch that already existed for that
fixture, and this runtime is expressed the same way: a clause per function,
falling through to the library. `for_agent/1` resolves the module,
`acp_enabled?/1` is true, `install/3` has nothing to install, and three
functions are new there. `acp_model/2` moves the model pin behind the same
seam so it can answer nil for this runtime; `model_required?/1` and
`command_required?/1` are the two config rules the changeset and the console
form ask about.

The one shape that did not fit is the command itself. Every other runtime's
argv is a property of the runtime name, and this one's is a property of the
agent row, so `command/1` gains the agent as a second argument.

`model` is optional for this runtime, and `acp_model/2` is always nil for it,
so the peer is never given a model to pin and no `model.failed` stage can
occur. No inference credential is resolved, so a turn succeeds on an account
that holds no API key. There is no adapter to install, no config to write and
no bootstrap to run. Skills still mount, under claude-code's layout, and the
path is exported as `FOUNTAIN_SKILLS_DIR`.

Permission requests, `session/cancel`, tool-call blocks and stop reasons work
unchanged, because they are ACP. With `CREDITS_ENABLED` a turn is priced by
sandbox time alone and `turn.usage` is null.

`agents.model` loses its NOT NULL, because whether a model is required is now
a rule about the pair of columns and belongs in the changeset. A
`runtime_command` on any other runtime is a 422 that names the field, and so
is a missing one on acp.

`TurnMachine.open/4` refuses a turn when an acp conversation has no command
left, which happens when the agent was deleted while a server still held the
conversation. Refused before a turn row exists, the way capacity is, with a
stage event that says why.

Tests: changeset and API 422s, a full turn with no inference credential and a
null usage, interrupt, permission requests, the console form, and a
conformance test that runs a checked-in fixture ACP agent as a real OS
process through a whole turn.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
…r a key (#1634)

Review findings on the acp runtime, in order of how much they cost a user.

**Skills never mounted, silently.** `SandboxSkills.mount/3` forwarded the
runtime *string* to `Managoat.Runtimes.Skills.install/3`, which resolves a
string through the library's own `Managoat.Runtimes.for_runtime/1` — a closed
map of the four LLM runtimes. It answered `{:error, "unsupported runtime:
acp"}`, the provision discarded the return value, and an acp sandbox came up
with no `fountain` skill, no `create-team` skill and a `FOUNTAIN_SKILLS_DIR`
pointing at a directory that never existed. The resolution now happens in
`SandboxSkills.mount/3` through `Fountain.RuntimeDispatch`, a module argument is
passed straight through, and a runtime nothing implements is logged rather
than dropped. Three tests, including one that pins the acp skills root.

**The console asked an acp agent's owner for an Anthropic key.** The mount
read `agent.model || "anthropic/claude-sonnet-4-6"`, so an existing acp agent
with no model rendered the missing-credential card, claiming its
conversations could not start until a key was set. The fallback now applies
only where the runtime needs a model, and `validate` keys the card on the
runtime too, so picking acp clears it in the same render rather than the next
one.

**`model` could not be cleared over the API.** `AgentRequest.model` and
`AgentUpdate.model` were non-nullable, and `CastAndValidate` runs before the
changeset, so `PUT {"runtime":"acp","model":null}` was a 400 and a converted
agent kept a stale `provider/model` forever. Both are nullable now.
`Agent["model"]` in the TypeScript SDK becomes `string | null`, which the
CHANGELOG calls out for clients.

**Docs.** The free-string paragraph said the command runs "under the same
isolation as an environment's setup script" without saying what that is on a
self-hosted runner, where the default backend is a directory and the daemon's
own user. It now points at trusted mode. The worked example writes `exec`,
because bash forks for a compound line and an interrupt would otherwise stop
the shell and leave the program running on a persistent sandbox, and there is
a line saying nothing may print on stdout before the program starts, because
a profile banner lands in front of the first protocol message.

Also: the runtime lists in the glossary, the build guide and `priv/help`
gained acp; `Exports.export_agents/1` carries `runtime_command`, so an export
is still a restorable copy; the "no `model.failed` can occur" claim is
softened to what is true, which is that the model *pin* path is unreachable
while `Managoat.ACP.Peer` still classifies a `session/prompt` error naming a
missing model; and there are tests for the interrupt door on an acp
conversation and for the disabled model input.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
…1634)

Generated types gain an agent's `runtime_command` and the `acp` runtime, and
`model` becomes nullable in the agent document and in both request bodies.
Clearing a model is how an existing agent converts to a runtime that needs
none, so the type change is the one a client has to notice.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
@jhgaylor

Copy link
Copy Markdown
Collaborator

Split into a stack of eight, per the rule against big PRs. This branch's 1,760 lines are now #1832#1839, each based on the one before it, off main rather than on the labels branch:

PR Lines What it is
1 #1832 +279 Fountain.CommandRuntime and the host dispatch for acp
2 #1833 +399 agents.runtime_command, and acp becomes a runtime you can name
3 #1834 +73 skills mount on a runtime the library does not know
4 #1835 +332 a turn on the acp runtime, end to end
5 #1836 +72 refuse a turn whose agent no longer carries a command
6 #1837 +151 the acp runtime in the agent form
7 #1838 +399 a real ACP program through a whole turn
8 #1839 +139 docs, CHANGELOG and SDK 1.25.0

Four things had to change rather than be copied, because this branch is stale against main:

  • The model pin. main now has TurnMachine.acp_model/2 and CodexChatGPT.peer_auth/2; this branch still called RuntimeDispatch.acp_model/2 from the conversation server. The stack keeps main's call sites and makes TurnMachine.acp_model/2 delegate through the dispatch, so the acp → nil rule lives in one place.
  • TurnMachine.open/4's spec dropped main's {:error, term()} clause (execution allowances). The stack keeps all four returns.
  • The migration timestamp 20260906120000 sat behind five migrations already on main. Renamed 20260910120000.
  • The console default model anthropic/claude-sonnet-4-6 is no longer a catalog entry and failed an existing test on main. Now claude-sonnet-5.

And two defects that only showed up once the pieces were run separately:

  • conversation_server.ex went over its size pin. The file is line-pinned and only shrinks (Tracker: ConversationServer by subtraction #1369); the added comment put it at 2775 against a pin of 2774. Folded into the existing comment instead of raising the pin.
  • The conformance test is flaky as written. "the turn completes on the agent's stop reason" waits on the turn row reaching completed, then asserts the conversation row is idle — which the server writes afterwards. It reproduced on the first seed once the timing shifted. test(runtimes): conformance, a real ACP program through a whole turn (#1634) #1838 waits on the row the assertion reads; 25 seeds clean.

Full suite on the stack tip: 4805 core tests, 144 fountain_buzz, 33 fountain_support, 0 failures. Leaving this open for you to close.

@lex00

lex00 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #1832 to #1839; the diff over apps/ matches apart from the deviations listed above.

@lex00 lex00 closed this Sep 10, 2026
@jhgaylor

Copy link
Copy Markdown
Collaborator

All eight landed on main, plus a replacement for one of them:

PR
1 #1832 Fountain.CommandRuntime and the host dispatch for acp
2 #1833 agents.runtime_command, acp becomes nameable, and the Swift SDK widening
3 #1834 skills mount on a runtime the library does not know
4 #1835 a turn on the acp runtime, end to end
5 #1836 refuse a turn whose agent no longer carries a command
6 #1863 the console form (replaces #1837, see below)
7 #1838 a real ACP program through a whole turn
8 #1839 docs, ADR 0049 and SDK 1.26.0

git diff between this branch's rebased tree and main over the five core modules is empty, so what shipped is the code this PR proposed.

Three things the split turned up that this branch would have carried in:

  • The Swift SDK broke on a null model. FountainKit.Agent declared model: String on a Decodable struct, so an acp agent's explicit null threw valueNotFound — and because a page decodes whole, one such agent broke agents.list() for every Swift caller. Widened in feat(agents): agents.runtime_command, and acp becomes a runtime you can name (#1634) #1833 with tests that fail on the original declaration. No gate caught it: the contract checker does not compare a hand-written model's optionality against a nullable wire field.
  • conversation_server.ex went over its size pin. The file only shrinks (Tracker: ConversationServer by subtraction #1369) and the added comment took it to 2775 against a pin of 2774.
  • The conformance test was flaky as written, waiting on the turn row and asserting on the conversation row.

Two unrelated flakes were filed along the way: #1861 (Swift conformance timeout scenario) and #1862 (TeamTest co-tenant test dying on a shutting-down Horde supervisor).

Closing this in favour of the stack.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:acp Agent Client Protocol: runtimes, editors, permissions, gateway area:sandbox Sandboxes: providers, lifetime, capabilities, the machine itself enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants