feat(runtimes): acp runtime launches a named command with no model - #1677
feat(runtimes): acp runtime launches a named command with no model#1677lex00 wants to merge 3 commits into
Conversation
…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>
4a30a16 to
42de301
Compare
817fdde to
3cfb1cf
Compare
|
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
Four things had to change rather than be copied, because this branch is stale against
And two defects that only showed up once the pieces were run separately:
Full suite on the stack tip: 4805 core tests, 144 fountain_buzz, 33 fountain_support, 0 failures. Leaving this open for you to close. |
|
All eight landed on
Three things the split turned up that this branch would have carried in:
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. |
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 anacpruntime that launches a named command and speaks ACP to it over stdio like the four existing runtimes.Fountain.Runtimes.Command, aManagoat.Runtimesimplementation for a command the agent names, and aFountain.Runtimesfacade that answersacpitself and delegates everything else to the library registry. Every runtime lookup, including skills installation, goes through the facade.agents.runtime_command, required foracpand refused on any other runtime, and makeagents.modeloptional foracponly. Both refusals are 422s naming the field. The command is a free string, run asbash -lcinside the sandbox.acpturn, somodel.failedcannot come from the pin path and a turn on an account with no credentials succeeds.turn.usageis null; credits price the turn by sandbox time only.acp) and the account export. Model suggestions return nothing foracp.docs/sdk.md,docs/concepts/permissions.mdand the integrations pages, including what "the same isolation" means on a self-hosted runner.Closes #1634
Stacked on #1637; merge that first.
Validation:
mix compile --warnings-as-errors,mix format --check-formatted,mix credo --strict, dialyzer, sobelow anddeps.unlock --unusedare clean. The prod release assembles.scripts/docs-style.pyis clean.