Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/fountain/lib/fountain/agents.ex
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ defmodule Fountain.Agents do
:system,
:model,
:runtime,
:runtime_command,
:sandbox_provider,
:sandbox_mode,
:skills,
Expand Down
55 changes: 53 additions & 2 deletions apps/fountain/lib/fountain/agents/agent.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ defmodule Fountain.Agents.Agent do

alias Fountain.Accounts.User
alias Fountain.Environments.Environment
alias Fountain.RuntimeDispatch
alias Managoat.Runtimes.Model

@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id

@runtimes ~w(claude codex gemini opencode)
# `acp` is the odd one (#1634): not a coding-agent CLI but a command the
# agent names, launched inside the sandbox and spoken to over the same
# protocol. It takes a `runtime_command` and needs no model, and the other
# four are the reverse of both. See `Fountain.RuntimeDispatch`.
@runtimes ~w(claude codex gemini opencode acp)

@typedoc "A persisted agent."
@type t :: %__MODULE__{}
Expand All @@ -20,6 +25,10 @@ defmodule Fountain.Agents.Agent do
field :system, :string, default: ""
field :model, :string
field :runtime, :string
# The command the `acp` runtime launches, as a shell line resolved inside
# the sandbox (#1634). Required for that runtime and refused for every
# other one, which resolves its own executable from a pinned table.
field :runtime_command, :string
# Optional sandbox-backend override; nil inherits the instance default
# (SANDBOX_PROVIDER) at conversation start.
field :sandbox_provider, :string
Expand Down Expand Up @@ -78,6 +87,7 @@ defmodule Fountain.Agents.Agent do
:system,
:model,
:runtime,
:runtime_command,
:sandbox_provider,
:sandbox_mode,
:skills,
Expand All @@ -93,10 +103,12 @@ defmodule Fountain.Agents.Agent do
def changeset(agent, attrs) do
agent
|> cast(attrs, cast_fields())
|> validate_required([:name, :model, :runtime])
|> validate_required([:name, :runtime])
|> validate_inclusion(:runtime, runtimes())
|> validate_fixture_account()
|> validate_inclusion(:sandbox_mode, @sandbox_modes)
|> validate_model_presence()
|> validate_runtime_command()
|> validate_format(:model, ~r{^[a-z0-9_-]+/[a-z0-9._-]+$},
message: "must be in canonical provider/model_id form"
)
Expand Down Expand Up @@ -133,6 +145,45 @@ defmodule Fountain.Agents.Agent do
end
end

# `model` is required for every runtime but `acp`, where it is optional and
# inert: that runtime resolves no inference credential, so a model would be
# a field nothing reads. It is still accepted, and still has to parse and
# name a known provider if it is given, because a value that is stored and
# ignored is worse than one that is refused.
defp validate_model_presence(changeset) do
if RuntimeDispatch.model_required?(get_field(changeset, :runtime)) do
validate_required(changeset, [:model])
else
changeset
end
end

# The command is the whole configuration of the `acp` runtime and means
# nothing to any other, so it is required for one and refused for the rest.
# Refused rather than ignored: a `runtime_command` sitting on a claude agent
# reads as something that runs, and nothing would ever run it.
defp validate_runtime_command(changeset) do
runtime = get_field(changeset, :runtime)
command = get_field(changeset, :runtime_command)

cond do
RuntimeDispatch.command_required?(runtime) ->
# `validate_required/2` trims, so a blank line is a missing command
# rather than one that spawns an empty shell.
validate_required(changeset, [:runtime_command])

is_nil(command) or String.trim(command) == "" ->
changeset

true ->
add_error(
changeset,
:runtime_command,
"only the acp runtime launches a command; #{runtime || "this runtime"} resolves its own"
)
end
end

# claude / codex / gemini each drive a single provider's CLI and take a
# bare model id, so the runtime strips the canonical prefix at spawn.
# Reject a mismatched prefix here rather than shipping `gpt-5` to
Expand Down
4 changes: 4 additions & 0 deletions apps/fountain/lib/fountain/agents/model_catalog.ex
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ defmodule Fountain.Agents.ModelCatalog do
@spec suggestions(String.t() | nil) :: [String.t()]
def suggestions("fountain-fixture"), do: ["fixture/deterministic-v1"]

# The acp runtime resolves no inference credential and reads no model, so
# suggesting one would be advice to fill in a field that does nothing.
def suggestions("acp"), do: []

def suggestions(runtime) do
case Model.provider_for_runtime(runtime) do
nil -> Enum.flat_map(Model.providers(), &suggestions_for_provider/1)
Expand Down
110 changes: 110 additions & 0 deletions apps/fountain/lib/fountain/command_runtime.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
defmodule Fountain.CommandRuntime do
@moduledoc """
The `acp` runtime: launch the command the agent names and speak ACP to it.

Every other runtime Fountain has is a coding agent driven by a model.
`Managoat.Runtimes` knows four of them by name, installs a pinned adapter
for each and hands each one an inference credential. This one knows
nothing. The agent carries a `runtime_command`, Fountain runs it inside the
sandbox, and whatever comes back over stdio is the Agent Client Protocol
(ADR 0014) exactly as it is for claude or codex. That is the whole runtime.

It exists for a deterministic program that wants what Fountain gives an
agent and not what a model gives one. A convergent operation on a schedule,
inside a persistent sandbox that holds the repo and the toolchain, with the
run readable as a turn in a teammate's thread (#1634).

## The name

Named for what varies, which is the command, and flat like its one sibling:
`Fountain.DeployedACPFixture` is the other runtime that is Fountain's
rather than the library's. `Managoat.Runtimes.ACP` was not available and
would have been wrong anyway, since that is the provisioning table saying
which adapter each of the four LLM runtimes reaches the protocol through.
`Command` alone would have read against `Managoat.Sandbox.Command`, which is
a struct the same call sites hold.

It lives in Fountain rather than in the library because the registry there
is a closed map and the field it reads (`agents.runtime_command`) is
Fountain's own column. `Fountain.RuntimeDispatch` is the host dispatch that
resolves this module for `"acp"` and delegates everything else.

## What it does not do

There is no adapter to install, no config file to write, no bootstrap to
run and no credential to export. The command owns its own configuration,
which is the point of naming a command rather than a runtime.

* `write_config/2` and `prepare_sandbox/3` are not implemented at all, so
`Fountain.Conversations.Provisioning`'s `function_exported?` guards skip
them.
* `build_command/5` is not implemented either. The legacy spawn path is
dead for every runtime that speaks ACP, and this one always does.
* `default_env/2` ignores the credentials it is handed. It exports one
variable, `FOUNTAIN_SKILLS_DIR`, so the command can read the agent's
skills without knowing the layout convention below.

## Skills still mount

A deterministic agent may read a skill the same way a model does, so the
ordinary skills pipeline runs. There is no CLI here with a directory of its
own, so the layout borrows claude-code's: inline skills are written under
`/home/sprite/.claude/skills`, and a github-source skill is installed by
skills.sh with `--agent claude-code`, which puts it in the same tree. One
location rather than two, and the path is exported so the command need not
know which one was chosen.

`Fountain.DeployedACPFixture` answers these two differently, with a private
root and an empty skills.sh id, and that is right for it: its changeset
refuses an agent that carries any skills at all, so the pair never has to
agree. This runtime accepts them, so the pair does.
"""

@behaviour Managoat.Runtimes

# claude-code's tree, borrowed. skills.sh has no agent id for a command it
# has never heard of, and this is the layout the others copy. `skills_root/0`
# and `skills_sh_agent/0` have to agree or a github skill and an inline one
# land in different directories.
@skills_root "/home/sprite/.claude/skills"
@skills_sh_agent "claude-code"

@doc """
Where the command's argv comes from.

A shell line rather than a parsed argv, and always run through
`bash -lc`. Three reasons, all of them about the sandbox rather than about
us: the command is resolved against the sandbox's own PATH (a login shell
is what puts `~/.local/bin` and the language shims on it), an operator can
write `cd /srv/app && bin/agent acp` without Fountain inventing a
chdir field, and quoting is the shell's rule, which is the rule whoever
wrote the string already knows.

Returns `:error` when there is no command to run. The changeset requires
one for this runtime, so that means an agent deleted out from under a live
conversation. `Fountain.Conversations.TurnMachine.open/4` refuses the turn
there rather than opening one with nothing to spawn, which is what makes
`Fountain.RuntimeDispatch.command/2`'s match total.
"""
@spec argv(map() | nil) :: {:ok, {String.t(), [String.t()]}} | :error
def argv(%{runtime_command: cmd}) when is_binary(cmd) do
case String.trim(cmd) do
"" -> :error
line -> {:ok, {"bash", ["-lc", line]}}
end
end

def argv(_agent), do: :error

@impl true
def skills_root, do: @skills_root

@impl true
def skills_sh_agent, do: @skills_sh_agent

# No inference credential, on purpose: a turn on this runtime resolves
# none, so an account that holds no key at all runs one. The skills path is
# here because the command has no convention to fall back on.
@impl true
def default_env(_agent, _inference_credentials), do: [{"FOUNTAIN_SKILLS_DIR", @skills_root}]
end
4 changes: 3 additions & 1 deletion apps/fountain/lib/fountain/conversations/provisioning.ex
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,9 @@ defmodule Fountain.Conversations.Provisioning do
# Install during provisioning and check the pin before opening a fresh
# connection on a persistent sandbox. Use the runtime's env (including its
# broker proxy) for registry access; never relax the sandbox network policy.
# Keyed on the conversation's runtime, matching the spawn decision.
# Keyed on the conversation's runtime, matching the spawn decision. The acp
# runtime installs nothing, since the command is whatever the environment's
# packages and setup script already put on the machine (#1634).
def prepare_acp_adapter(handle, runtime, sprite_env) do
if Fountain.RuntimeDispatch.acp_enabled?(runtime) do
Fountain.RuntimeDispatch.install(handle, runtime, sprite_env)
Expand Down
7 changes: 5 additions & 2 deletions apps/fountain/lib/fountain/conversations/turn_machine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,9 @@ defmodule Fountain.Conversations.TurnMachine do
a PTY so `isatty(0)` is true), `dir` (a workspace with a local .git) and
`prompt_suffix` (image references for a runtime that cannot take images
as flags).

On the acp runtime the argv is the agent's own `runtime_command` (#1634),
which is why the agent is in hand here as well as the conversation.
"""
@spec command(
boolean(),
Expand All @@ -992,7 +995,7 @@ defmodule Fountain.Conversations.TurnMachine do
) :: {String.t(), [String.t()], keyword()}
def command(acp?, conv, agent, prompt, mode, runtime_session_id, opts) do
if acp? do
{c, a} = Fountain.RuntimeDispatch.command(conv.runtime)
{c, a} = Fountain.RuntimeDispatch.command(conv.runtime, agent)
# The ACP `cwd` is validated in band by the agent CLI against the real
# filesystem, so it must be the path a process inside the sandbox sees
# — identity on hosted providers, the mapped directory on a runner
Expand Down Expand Up @@ -1053,7 +1056,7 @@ defmodule Fountain.Conversations.TurnMachine do
def acp_model(_conv, nil), do: nil

def acp_model(conv, agent),
do: Managoat.Runtimes.Model.acp_model(conv.runtime || agent.runtime, agent.model)
do: Fountain.RuntimeDispatch.acp_model(conv.runtime || agent.runtime, agent.model)

# The permission policy in force for this turn (#939): the agent's own,
# clamped by whatever narrowing the launch asked for. Resolved per turn from
Expand Down
1 change: 1 addition & 0 deletions apps/fountain/lib/fountain/exports.ex
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ defmodule Fountain.Exports do
"system" => agent.system,
"model" => agent.model,
"runtime" => agent.runtime,
"runtime_command" => agent.runtime_command,
"skills" => agent.skills,
"mcp_servers" => agent.mcp_servers,
"metadata" => agent.metadata,
Expand Down
67 changes: 62 additions & 5 deletions apps/fountain/lib/fountain/runtime_dispatch.ex
Original file line number Diff line number Diff line change
@@ -1,29 +1,55 @@
defmodule Fountain.RuntimeDispatch do
@moduledoc """
Host dispatch for the four packaged runtimes and the opt-in deployed ACP fixture.
Host dispatch for the four packaged runtimes and the two that are Fountain's.

The fixture is one fixed, account-restricted testing seam for #1611/#1007.
It does not register tenant-provided code or replace a packaged runtime.
`Fountain.DeployedACPFixture` is one fixed, account-restricted testing seam
for #1611/#1007. It does not register tenant-provided code or replace a
packaged runtime.

`Fountain.CommandRuntime` is the `acp` runtime (#1634): the agent names a
command, Fountain launches it inside the sandbox and speaks the protocol to
it. It is generally available rather than account-restricted, and it is the
one runtime whose command is not a property of the runtime name, which is
why `command/2` takes the agent.
"""

alias Fountain.CommandRuntime
alias Fountain.DeployedACPFixture
alias Managoat.Runtimes
alias Managoat.Runtimes.ACP
alias Managoat.Runtimes.Model

def for_agent(%{runtime: "fountain-fixture", user_id: user_id}) do
if DeployedACPFixture.allowed?(user_id),
do: {:ok, DeployedACPFixture},
else: {:error, "deployed ACP fixture is not enabled for this account"}
end

def for_agent(%{runtime: "acp"}), do: {:ok, CommandRuntime}
def for_agent(%{runtime: runtime}), do: Runtimes.for_runtime(runtime)

def acp_enabled?(%{runtime: runtime}), do: acp_enabled?(runtime)
def acp_enabled?("fountain-fixture"), do: DeployedACPFixture.enabled?()
def acp_enabled?("acp"), do: true
def acp_enabled?(runtime), do: ACP.enabled?(runtime)

def command("fountain-fixture"), do: {"node", [".fountain-acp-fixture.mjs"]}
def command(runtime), do: ACP.command(runtime)
@doc """
Argv for the process a turn spawns.

Takes the agent as well as the runtime because the `acp` runtime's argv is
the agent's own `runtime_command`. The match is total by the time a spawn
asks: `Fountain.Conversations.TurnMachine.open/4` refuses a turn whose
agent has no command, before a turn row exists.
"""
def command(runtime, agent \\ nil)

def command("acp", agent) do
{:ok, argv} = CommandRuntime.argv(agent)
argv
end

def command("fountain-fixture", _agent), do: {"node", [".fountain-acp-fixture.mjs"]}
def command(runtime, _agent), do: ACP.command(runtime)

def cwd("fountain-fixture"), do: "/home/sprite"
def cwd(runtime), do: ACP.cwd(runtime)
Expand All @@ -35,5 +61,36 @@ defmodule Fountain.RuntimeDispatch do
def asks_permission?(runtime), do: ACP.asks_permission?(runtime)

def install(_handle, "fountain-fixture", _env), do: :ok
def install(_handle, "acp", _env), do: :ok
def install(handle, runtime, env), do: ACP.install(handle, runtime, env)

@doc """
The model id to pin on the ACP session, or nil to leave the runtime's own.

Always nil for `acp`. A model is optional there and nothing reads it, so
pinning one could only fail. That closes the pin path, which is where the
`model`/`failed` stage comes from for the runtimes that do drive a model.
"""
def acp_model("acp", _model), do: nil
def acp_model(runtime, model), do: Model.acp_model(runtime, model)

@doc """
Whether this runtime needs a `model` on the agent.

Only `acp` does not. Every other runtime is a model driving something, and
an agent with no model there runs whatever that thing defaults to, which is
the defect `Agent.changeset/2`'s provider check exists to prevent. The
fixture needs one too: its changeset pins it to `fixture/deterministic-v1`.
"""
def model_required?("acp"), do: false
def model_required?(_runtime), do: true

@doc """
Whether this runtime takes a `runtime_command`.

Only `acp`. On any other runtime the field would be stored, shown in the
console and never run, which reads as configuration and is not.
"""
def command_required?("acp"), do: true
def command_required?(_runtime), do: false
end
3 changes: 3 additions & 0 deletions apps/fountain/lib/fountain_web/controllers/agent_json.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ defmodule FountainWeb.AgentJSON do
system: a.system,
model: a.model,
runtime: a.runtime,
# The command the acp runtime launches, and null on every other one
# (#1634).
runtime_command: a.runtime_command,
# Derived, never stored: whether this agent's runtime speaks ACP, and so
# whether a client outside the server can render its output as protocol
# rather than as one of the four proprietary dialects. `fountain acp`
Expand Down
Loading
Loading