diff --git a/apps/fountain/lib/fountain/agents.ex b/apps/fountain/lib/fountain/agents.ex index 34f954228..8541e2228 100644 --- a/apps/fountain/lib/fountain/agents.ex +++ b/apps/fountain/lib/fountain/agents.ex @@ -373,6 +373,7 @@ defmodule Fountain.Agents do :system, :model, :runtime, + :runtime_command, :sandbox_provider, :sandbox_mode, :skills, diff --git a/apps/fountain/lib/fountain/agents/agent.ex b/apps/fountain/lib/fountain/agents/agent.ex index ba09a4667..9fda174e4 100644 --- a/apps/fountain/lib/fountain/agents/agent.ex +++ b/apps/fountain/lib/fountain/agents/agent.ex @@ -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__{} @@ -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 @@ -78,6 +87,7 @@ defmodule Fountain.Agents.Agent do :system, :model, :runtime, + :runtime_command, :sandbox_provider, :sandbox_mode, :skills, @@ -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" ) @@ -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 diff --git a/apps/fountain/lib/fountain/agents/model_catalog.ex b/apps/fountain/lib/fountain/agents/model_catalog.ex index b31a8baf4..6348b78a9 100644 --- a/apps/fountain/lib/fountain/agents/model_catalog.ex +++ b/apps/fountain/lib/fountain/agents/model_catalog.ex @@ -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) diff --git a/apps/fountain/lib/fountain/command_runtime.ex b/apps/fountain/lib/fountain/command_runtime.ex new file mode 100644 index 000000000..6f9e2b237 --- /dev/null +++ b/apps/fountain/lib/fountain/command_runtime.ex @@ -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 diff --git a/apps/fountain/lib/fountain/conversations/provisioning.ex b/apps/fountain/lib/fountain/conversations/provisioning.ex index 561d29154..185e53de3 100644 --- a/apps/fountain/lib/fountain/conversations/provisioning.ex +++ b/apps/fountain/lib/fountain/conversations/provisioning.ex @@ -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) diff --git a/apps/fountain/lib/fountain/conversations/turn_machine.ex b/apps/fountain/lib/fountain/conversations/turn_machine.ex index 6e99c3014..5b47ac3b7 100644 --- a/apps/fountain/lib/fountain/conversations/turn_machine.ex +++ b/apps/fountain/lib/fountain/conversations/turn_machine.ex @@ -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(), @@ -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 @@ -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 diff --git a/apps/fountain/lib/fountain/exports.ex b/apps/fountain/lib/fountain/exports.ex index d95eeff73..82d0f854c 100644 --- a/apps/fountain/lib/fountain/exports.ex +++ b/apps/fountain/lib/fountain/exports.ex @@ -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, diff --git a/apps/fountain/lib/fountain/runtime_dispatch.ex b/apps/fountain/lib/fountain/runtime_dispatch.ex index d6e4ad221..b6f61f7a3 100644 --- a/apps/fountain/lib/fountain/runtime_dispatch.ex +++ b/apps/fountain/lib/fountain/runtime_dispatch.ex @@ -1,14 +1,23 @@ 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), @@ -16,14 +25,31 @@ defmodule Fountain.RuntimeDispatch do 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) @@ -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 diff --git a/apps/fountain/lib/fountain_web/controllers/agent_json.ex b/apps/fountain/lib/fountain_web/controllers/agent_json.ex index c6efc99fc..ee663dba1 100644 --- a/apps/fountain/lib/fountain_web/controllers/agent_json.ex +++ b/apps/fountain/lib/fountain_web/controllers/agent_json.ex @@ -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` diff --git a/apps/fountain/lib/fountain_web/schemas.ex b/apps/fountain/lib/fountain_web/schemas.ex index 8baa166c9..a0fd21bfa 100644 --- a/apps/fountain/lib/fountain_web/schemas.ex +++ b/apps/fountain/lib/fountain_web/schemas.ex @@ -734,10 +734,23 @@ defmodule FountainWeb.Schemas do "providers are rejected: Fountain has no credentials to export for " <> "them. The model id is not checked against a list, so a newly " <> "released model works without a Fountain release. The isolated fountain-fixture " <> - "runtime is the exception: it accepts only fixture/deterministic-v1.", + "runtime is the exception: it accepts only fixture/deterministic-v1. Null " <> + "on the acp runtime, which resolves no inference credential and reads no " <> + "model.", + nullable: true, pattern: "^[a-z0-9_-]+/[a-z0-9._-]+$" }, runtime: %Schema{type: :string, enum: Fountain.Agents.Agent.known_runtimes()}, + runtime_command: %Schema{ + type: :string, + nullable: true, + description: + "The command the acp runtime launches inside the sandbox, as a shell line " <> + "resolved there (for example `chant acp`). Required when runtime is " <> + "acp, and rejected on every other runtime, which resolves its own " <> + "executable. A free string by design: it runs under the same isolation " <> + "as an environment's setup script." + }, acp: %Schema{ type: :boolean, readOnly: true, @@ -857,6 +870,8 @@ defmodule FountainWeb.Schemas do inserted_at: %Schema{type: :string, format: :"date-time"}, updated_at: %Schema{type: :string, format: :"date-time"} }, + # `model` stays required: the response always carries the key, and the + # acp runtime's value for it is null rather than absent. required: [:id, :name, :model, :runtime] }) end @@ -905,9 +920,24 @@ defmodule FountainWeb.Schemas do system: %Schema{type: :string}, model: %Schema{ type: :string, + # Nullable so an agent converted to the acp runtime can clear the + # model it no longer uses. CastAndValidate runs before the + # changeset, so a non-nullable string here would reject the null + # with a 400 and leave the stale value on the row forever. + nullable: true, pattern: "^[a-z0-9_-]+/[a-z0-9._-]+$" }, runtime: %Schema{type: :string, enum: Fountain.Agents.Agent.known_runtimes()}, + runtime_command: %Schema{ + type: :string, + nullable: true, + description: + "The command the acp runtime launches inside the sandbox, as a shell line " <> + "resolved there (for example `chant acp`). Required when runtime is " <> + "acp, and rejected on every other runtime, which resolves its own " <> + "executable. A free string by design: it runs under the same isolation " <> + "as an environment's setup script." + }, sandbox_provider: %Schema{ type: :string, enum: ~w(sprites e2b daytona runner), @@ -1006,7 +1036,10 @@ defmodule FountainWeb.Schemas do "non-empty list is an allowlist. The agent's own environment always passes." } }, - required: [:name, :model, :runtime] + # `model` is required for every runtime but acp, which needs none. A + # conditional requirement is not expressible here, so the changeset is + # where it is enforced and a missing model is a 422 rather than a 400. + required: [:name, :runtime] }) end @@ -1024,8 +1057,20 @@ defmodule FountainWeb.Schemas do name: %Schema{type: :string, minLength: 1, maxLength: 200}, description: %Schema{type: :string}, system: %Schema{type: :string}, - model: %Schema{type: :string, pattern: "^[a-z0-9_-]+/[a-z0-9._-]+$"}, + # Nullable for the same reason AgentRequest's is: converting an agent + # to the acp runtime has to be able to clear the model. + model: %Schema{type: :string, nullable: true, pattern: "^[a-z0-9_-]+/[a-z0-9._-]+$"}, runtime: %Schema{type: :string, enum: Fountain.Agents.Agent.known_runtimes()}, + runtime_command: %Schema{ + type: :string, + nullable: true, + description: + "The command the acp runtime launches inside the sandbox, as a shell line " <> + "resolved there (for example `chant acp`). Required when runtime is " <> + "acp, and rejected on every other runtime, which resolves its own " <> + "executable. A free string by design: it runs under the same isolation " <> + "as an environment's setup script." + }, sandbox_provider: %Schema{ type: :string, enum: ~w(sprites e2b daytona runner), diff --git a/apps/fountain/priv/repo/migrations/20260910120000_add_runtime_command_to_agents.exs b/apps/fountain/priv/repo/migrations/20260910120000_add_runtime_command_to_agents.exs new file mode 100644 index 000000000..bd7fcae17 --- /dev/null +++ b/apps/fountain/priv/repo/migrations/20260910120000_add_runtime_command_to_agents.exs @@ -0,0 +1,31 @@ +defmodule Fountain.Repo.Migrations.AddRuntimeCommandToAgents do + use Ecto.Migration + + @moduledoc """ + The command the `acp` runtime launches (#1634). + + Every other runtime resolves its own executable from a pinned table, so the + column is null for all of them and required for `acp` alone. The value is a + free string, deliberately: it is resolved inside the sandbox, under the same + isolation an agent's `setup_script` already runs under, so a catalogue of + blessed commands would buy nothing and would stop a self-hoster running + their own program. + + Text rather than a bounded varchar. A command may be a whole shell line + ("cd /srv/app && bin/agent acp"), and the changeset is where a length rule + belongs if one is ever wanted. + + `model` loses its NOT NULL in the same migration. It is required for every + runtime that drives one and meaningless for this one, and that difference + is a rule about the pair of columns rather than about either alone, so it + belongs in `Fountain.Agents.Agent.changeset/2` where both are in hand. The + column stays for every existing row. + """ + + def change do + alter table(:agents) do + add :runtime_command, :text + modify :model, :string, null: true, from: {:string, null: false} + end + end +end diff --git a/apps/fountain/test/fountain/agents/agent_test.exs b/apps/fountain/test/fountain/agents/agent_test.exs index b01dd93b3..d23be4ad7 100644 --- a/apps/fountain/test/fountain/agents/agent_test.exs +++ b/apps/fountain/test/fountain/agents/agent_test.exs @@ -20,7 +20,7 @@ defmodule Fountain.Agents.AgentTest do describe "runtimes/0" do test "returns the expected list of runtimes" do - assert Agent.runtimes() == ~w(claude codex gemini opencode) + assert Agent.runtimes() == ~w(claude codex gemini opencode acp) end end @@ -49,6 +49,78 @@ defmodule Fountain.Agents.AgentTest do end end + describe "changeset/2 — the acp runtime (#1634)" do + @acp_attrs %{name: "converger", runtime: "acp", runtime_command: "chant acp"} + + test "a command and no model is valid" do + changeset = Agent.changeset(%Agent{}, @acp_attrs) + assert changeset.valid? + assert get_change(changeset, :runtime_command) == "chant acp" + assert is_nil(get_change(changeset, :model)) + end + + test "a whole shell line is a legal command" do + attrs = %{@acp_attrs | runtime_command: "cd /srv/app && ./bin/agent acp --env prod"} + assert Agent.changeset(%Agent{}, attrs).valid? + end + + test "no runtime_command names the field" do + changeset = Agent.changeset(%Agent{}, Map.delete(@acp_attrs, :runtime_command)) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).runtime_command + end + + test "a blank runtime_command names the field" do + changeset = Agent.changeset(%Agent{}, %{@acp_attrs | runtime_command: " "}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).runtime_command + end + + test "a model is optional but still has to parse and name a known provider" do + assert Agent.changeset(%Agent{}, Map.put(@acp_attrs, :model, "anthropic/x")).valid? + refute Agent.changeset(%Agent{}, Map.put(@acp_attrs, :model, "nope")).valid? + + changeset = Agent.changeset(%Agent{}, Map.put(@acp_attrs, :model, "anthopic/x")) + refute changeset.valid? + assert Enum.any?(errors_on(changeset).model, &(&1 =~ "unknown provider")) + end + + for runtime <- ~w(claude codex gemini opencode) do + test "runtime_command on the #{runtime} runtime names the field" do + attrs = + Map.merge(@valid_attrs, %{ + runtime: unquote(runtime), + model: @model_for[unquote(runtime)], + runtime_command: "chant acp" + }) + + changeset = Agent.changeset(%Agent{}, attrs) + refute changeset.valid? + assert Enum.any?(errors_on(changeset).runtime_command, &(&1 =~ "only the acp runtime")) + end + end + + test "a model is still required on every other runtime" do + changeset = Agent.changeset(%Agent{}, Map.delete(@valid_attrs, :model)) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).model + end + + test "switching an agent off acp clears the command it no longer takes" do + {:ok, agent} = + Fountain.Agents.create_agent(Map.put(@acp_attrs, :user_id, insert_verified_user().id)) + + # Left in place, the stored command is refused for the new runtime. + refute Agent.changeset(agent, %{runtime: "claude", model: "anthropic/x"}).valid? + + assert Agent.changeset(agent, %{ + runtime: "claude", + model: "anthropic/x", + runtime_command: nil + }).valid? + end + end + describe "changeset/2 — runtime inclusion" do for runtime <- ~w(claude codex gemini opencode) do test "runtime #{runtime} is valid" do diff --git a/apps/fountain/test/fountain/command_runtime_test.exs b/apps/fountain/test/fountain/command_runtime_test.exs new file mode 100644 index 000000000..ad940a8ae --- /dev/null +++ b/apps/fountain/test/fountain/command_runtime_test.exs @@ -0,0 +1,99 @@ +defmodule Fountain.CommandRuntimeTest do + @moduledoc """ + The `acp` runtime and the host dispatch that resolves it (#1634). + + Nothing reaches this module yet: `"acp"` is not a value `Agent.changeset/2` + accepts, and the `runtime_command` column it reads does not exist. What is + pinned here is the runtime in isolation, so the PR that opens the door has + only the schema to argue about. + """ + + use ExUnit.Case, async: true + + alias Fountain.CommandRuntime + alias Fountain.RuntimeDispatch + + describe "dispatch" do + test "acp resolves to the command runtime, and speaks the protocol" do + assert RuntimeDispatch.acp_enabled?("acp") + assert {:ok, CommandRuntime} = RuntimeDispatch.for_agent(%{runtime: "acp"}) + end + + test "the packaged runtimes still resolve to their own modules" do + assert {:ok, Managoat.Runtimes.Claude} = RuntimeDispatch.for_agent(%{runtime: "claude"}) + assert {:ok, Managoat.Runtimes.OpenCode} = RuntimeDispatch.for_agent(%{runtime: "opencode"}) + assert {:error, _} = RuntimeDispatch.for_agent(%{runtime: "nonesuch"}) + end + + test "there is no adapter to install and no model to pin" do + assert :ok = RuntimeDispatch.install(nil, "acp", []) + assert is_nil(RuntimeDispatch.acp_model("acp", nil)) + # Even when a model was set anyway: it is inert, so it is never pinned + # and the pin path that reports `model`/`failed` is unreachable. + assert is_nil(RuntimeDispatch.acp_model("acp", "anthropic/claude-sonnet-4-6")) + end + + test "a model-driven runtime still pins the model it was given" do + assert RuntimeDispatch.acp_model("claude", "anthropic/claude-sonnet-4-6") + end + + test "only acp takes a command, and only acp goes without a model" do + assert RuntimeDispatch.command_required?("acp") + refute RuntimeDispatch.model_required?("acp") + + for runtime <- ~w(claude codex gemini opencode fountain-fixture) do + refute RuntimeDispatch.command_required?(runtime) + assert RuntimeDispatch.model_required?(runtime) + end + end + end + + describe "argv" do + test "the command comes from the agent, and a missing one is answerable" do + assert :error = CommandRuntime.argv(nil) + assert :error = CommandRuntime.argv(%{}) + assert :error = CommandRuntime.argv(%{runtime_command: nil}) + assert :error = CommandRuntime.argv(%{runtime_command: " "}) + + assert {:ok, {"bash", ["-lc", "run me"]}} = + CommandRuntime.argv(%{runtime_command: "run me"}) + end + + test "a whole shell line is handed to the login shell unparsed" do + line = "cd /srv/app && exec ./bin/agent acp --env prod" + + assert {"bash", ["-lc", ^line]} = + RuntimeDispatch.command("acp", %{runtime_command: line}) + end + + test "the packaged runtimes ignore the agent and resolve their own argv" do + assert {_cmd, _args} = RuntimeDispatch.command("claude", %{runtime_command: "ignored"}) + end + end + + describe "the sandbox environment" do + test "no inference credential reaches the command" do + env = + CommandRuntime.default_env( + %{model: "anthropic/claude-sonnet-4-6"}, + %{anthropic_api_key: "sk-live", openai_api_key: "sk-openai"} + ) + + refute Enum.any?(env, fn {_k, v} -> v =~ "sk-" end) + assert {"FOUNTAIN_SKILLS_DIR", "/home/sprite/.claude/skills"} in env + end + + test "the skills root and the skills.sh agent id agree" do + # They have to: an inline skill is written to the root and a github one + # is installed by id, and a disagreement puts them in different trees. + assert CommandRuntime.skills_root() == "/home/sprite/.claude/skills" + assert CommandRuntime.skills_sh_agent() == "claude-code" + end + + test "provisioning finds nothing to write and nothing to prepare" do + refute function_exported?(CommandRuntime, :write_config, 2) + refute function_exported?(CommandRuntime, :prepare_sandbox, 3) + refute function_exported?(CommandRuntime, :build_command, 5) + end + end +end diff --git a/apps/fountain/test/fountain/exports_test.exs b/apps/fountain/test/fountain/exports_test.exs index cb0f03d6b..2ae6400a5 100644 --- a/apps/fountain/test/fountain/exports_test.exs +++ b/apps/fountain/test/fountain/exports_test.exs @@ -139,7 +139,9 @@ defmodule Fountain.ExportsTest do doc = export.payload |> :zlib.gunzip() |> Jason.decode!() assert doc["account"]["email"] == user.email - assert [%{"name" => "export-agent"}] = doc["agents"] + assert [%{"name" => "export-agent"} = agent_doc] = doc["agents"] + # Every config field an agent carries, so an export is a restorable copy. + assert Map.has_key?(agent_doc, "runtime_command") assert [env_doc] = doc["environments"] assert env_doc["name"] == "export-env" diff --git a/apps/fountain/test/fountain/manifest_test.exs b/apps/fountain/test/fountain/manifest_test.exs index d605775ac..ac8f69f87 100644 --- a/apps/fountain/test/fountain/manifest_test.exs +++ b/apps/fountain/test/fountain/manifest_test.exs @@ -110,6 +110,32 @@ defmodule Fountain.ManifestTest do end end + describe "the acp runtime in a manifest (#1634)" do + test "an Agent declares its runtime_command, and needs no model", %{user: user} do + resource = %{ + "kind" => "Agent", + "name" => "converger", + "spec" => %{"runtime" => "acp", "runtime_command" => "exec chant acp --env prod"} + } + + assert {:ok, [%{action: :created}]} = Manifest.apply_manifest(user.id, [resource]) + + agent = Agents.get_agent_by_name("converger", user.id) + assert agent.runtime == "acp" + assert agent.runtime_command == "exec chant acp --env prod" + assert is_nil(agent.model) + end + + test "runtime_command on a model-driven runtime fails its own row", %{user: user} do + resource = agent_resource("claudey", %{"runtime_command" => "chant acp"}) + + assert {:ok, [%{action: :error, errors: errors}]} = + Manifest.apply_manifest(user.id, [resource]) + + assert Map.has_key?(errors, "runtime_command") + end + end + describe "apply_manifest/2 creation" do test "creates environments, vaults, and agents with secrets", %{user: user} do resources = [ diff --git a/apps/fountain/test/fountain_web/controllers/agent_controller_test.exs b/apps/fountain/test/fountain_web/controllers/agent_controller_test.exs index f632ebbc0..efb378b82 100644 --- a/apps/fountain/test/fountain_web/controllers/agent_controller_test.exs +++ b/apps/fountain/test/fountain_web/controllers/agent_controller_test.exs @@ -70,6 +70,47 @@ defmodule FountainWeb.AgentControllerTest do end end + # A non-nullable model in the request schema made this a 400 from + # CastAndValidate, before the changeset ever saw it, so a converted agent + # kept a provider/model it no longer uses forever (#1634). + test "converts an agent to acp and clears the model it no longer uses", %{ + conn: conn, + user: user, + raw_key: raw_key + } do + agent = insert_agent(user_id: user.id, runtime: "claude") + + conn = + conn + |> authed_with_key(raw_key) + |> put_json("/api/agents/#{agent.id}", %{ + runtime: "acp", + model: nil, + runtime_command: "chant acp" + }) + + body = json_response(conn, 200) + assert body["data"]["runtime"] == "acp" + assert body["data"]["runtime_command"] == "chant acp" + assert is_nil(body["data"]["model"]) + assert is_nil(Fountain.Agents.get_agent(agent.id, user.id).model) + end + + test "a null model on a model-driven runtime is a 422 naming the field", %{ + conn: conn, + user: user, + raw_key: raw_key + } do + agent = insert_agent(user_id: user.id, runtime: "claude") + + conn = + conn + |> authed_with_key(raw_key) + |> put_json("/api/agents/#{agent.id}", %{model: nil}) + + assert json_response(conn, 422)["errors"]["model"] == ["can't be blank"] + end + test "returns 404 when the agent belongs to a different user", %{conn: conn, raw_key: raw_key} do other_user = insert_verified_user() other_agent = insert_agent(user_id: other_user.id) @@ -108,6 +149,71 @@ defmodule FountainWeb.AgentControllerTest do assert json_response(conn, 401) end + test "creates an acp agent from a command, with no model (#1634)", %{ + conn: conn, + raw_key: raw_key + } do + payload = %{name: "converger", runtime: "acp", runtime_command: "chant acp --env prod"} + + conn = + conn + |> authed_with_key(raw_key) + |> post_json("/api/agents", payload) + + body = json_response(conn, 201) + assert body["data"]["runtime"] == "acp" + assert body["data"]["runtime_command"] == "chant acp --env prod" + assert is_nil(body["data"]["model"]) + assert body["data"]["acp"] == true + end + + test "returns 422 naming runtime_command when an acp agent has none", %{ + conn: conn, + raw_key: raw_key + } do + conn = + conn + |> authed_with_key(raw_key) + |> post_json("/api/agents", %{name: "converger", runtime: "acp"}) + + body = json_response(conn, 422) + assert body["errors"]["runtime_command"] == ["can't be blank"] + end + + test "returns 422 naming runtime_command when another runtime carries one", %{ + conn: conn, + raw_key: raw_key + } do + payload = %{ + name: "confused", + model: "anthropic/claude-sonnet-4-6", + runtime: "claude", + runtime_command: "chant acp" + } + + conn = + conn + |> authed_with_key(raw_key) + |> post_json("/api/agents", payload) + + body = json_response(conn, 422) + assert [message] = body["errors"]["runtime_command"] + assert message =~ "only the acp runtime" + end + + test "returns 422 naming model when a model-driven runtime has none", %{ + conn: conn, + raw_key: raw_key + } do + conn = + conn + |> authed_with_key(raw_key) + |> post_json("/api/agents", %{name: "modelless", runtime: "claude"}) + + body = json_response(conn, 422) + assert body["errors"]["model"] == ["can't be blank"] + end + test "returns 422 when a skill entry has neither content nor source", %{ conn: conn, raw_key: raw_key diff --git a/apps/fountain/test/fountain_web/controllers/conversation_controller_test.exs b/apps/fountain/test/fountain_web/controllers/conversation_controller_test.exs index 5912c8e2b..f505d06e5 100644 --- a/apps/fountain/test/fountain_web/controllers/conversation_controller_test.exs +++ b/apps/fountain/test/fountain_web/controllers/conversation_controller_test.exs @@ -774,6 +774,35 @@ defmodule FountainWeb.ConversationControllerTest do assert conn.status == 204 end + # The acp runtime carries no special case at this door, and that is the + # claim (#1634). The other half of the path, `:interrupt` becoming a + # `session/cancel` on the command and an `interrupted` turn, is pinned in + # conversation_server_acp_runtime_test.exs against a live server. + test "an acp conversation interrupts through the same door", %{ + conn: conn, + user: user, + raw_key: raw_key + } do + agent = insert_agent(user_id: user.id, runtime: "acp", runtime_command: "chant acp") + conv = insert_conversation(user_id: user.id, agent: agent) + assert conv.runtime == "acp" + + test = self() + + stub(ConversationServer, :interrupt, fn id, _opts -> + send(test, {:interrupted, id}) && :ok + end) + + conn = + conn + |> authed_with_key(raw_key) + |> post("/api/conversations/#{conv.id}/interrupt") + + assert conn.status == 204 + assert_receive {:interrupted, id} + assert id == conv.id + end + # Ownership is established before the server is asked, so "not running" is # a state conflict on a conversation the caller can see, not a missing one # (#1179 — the owner of a stuck conversation got a 404 saying it was the diff --git a/apps/fountain/test/support/factory.ex b/apps/fountain/test/support/factory.ex index 0de7c7575..248e59134 100644 --- a/apps/fountain/test/support/factory.ex +++ b/apps/fountain/test/support/factory.ex @@ -251,21 +251,27 @@ defmodule Fountain.Factory do "name" => "agent-#{uniq()}", # The changeset requires the provider prefix to match the runtime # (#553), so a call site that overrides only :runtime still gets a - # model its runtime can actually reach. + # model its runtime can actually reach. The acp runtime is the + # inverse: no model at all, and a command instead (#1634). "model" => default_model_for(runtime), "runtime" => runtime, "skills" => [], "mcp_servers" => %{}, "metadata" => %{} - }, + } + |> Map.merge(default_command_for(runtime)), overrides ) end defp default_model_for("codex"), do: "openai/gpt-5.3-codex" defp default_model_for("gemini"), do: "google/gemini-3.1-pro-preview" + defp default_model_for("acp"), do: nil defp default_model_for(_runtime), do: "anthropic/claude-sonnet-4-6" + defp default_command_for("acp"), do: %{"runtime_command" => "fixture-agent"} + defp default_command_for(_runtime), do: %{} + def insert_agent(overrides \\ %{}) do overrides = to_string_map(overrides) overrides = Map.put_new_lazy(overrides, "user_id", fn -> insert_verified_user().id end) diff --git a/sdk/contract/contract.json b/sdk/contract/contract.json index 380adbd1b..38d0be446 100644 --- a/sdk/contract/contract.json +++ b/sdk/contract/contract.json @@ -7850,6 +7850,7 @@ "type": "object" }, "model": { + "nullable": true, "required": true, "type": "string" }, @@ -7872,6 +7873,7 @@ }, "runtime": { "enum": [ + "acp", "claude", "codex", "fountain-fixture", @@ -7881,6 +7883,11 @@ "required": true, "type": "string" }, + "runtime_command": { + "nullable": true, + "required": false, + "type": "string" + }, "sandbox_mode": { "enum": [ "ephemeral", @@ -7990,7 +7997,8 @@ "type": "object" }, "model": { - "required": true, + "nullable": true, + "required": false, "type": "string" }, "name": { @@ -8012,6 +8020,7 @@ }, "runtime": { "enum": [ + "acp", "claude", "codex", "fountain-fixture", @@ -8021,6 +8030,11 @@ "required": true, "type": "string" }, + "runtime_command": { + "nullable": true, + "required": false, + "type": "string" + }, "sandbox_mode": { "enum": [ "ephemeral", @@ -8122,6 +8136,7 @@ "type": "object" }, "model": { + "nullable": true, "required": false, "type": "string" }, @@ -8144,6 +8159,7 @@ }, "runtime": { "enum": [ + "acp", "claude", "codex", "fountain-fixture", @@ -8153,6 +8169,11 @@ "required": false, "type": "string" }, + "runtime_command": { + "nullable": true, + "required": false, + "type": "string" + }, "sandbox_mode": { "enum": [ "ephemeral", @@ -9983,6 +10004,7 @@ }, "runtime": { "enum": [ + "acp", "claude", "codex", "fountain-fixture", @@ -11486,6 +11508,7 @@ }, "runtime": { "enum": [ + "acp", "claude", "codex", "fountain-fixture", diff --git a/sdk/swift/CHANGELOG.md b/sdk/swift/CHANGELOG.md index 01aa185c8..6b1c147e1 100644 --- a/sdk/swift/CHANGELOG.md +++ b/sdk/swift/CHANGELOG.md @@ -5,8 +5,19 @@ Notable changes to the Fountain Swift SDK follow ## Unreleased +### Changed + +- `Agent.model` is `String?`. The `acp` runtime resolves no inference + credential and reads no model, so the wire sends an explicit null there. A + non-optional `model` threw `valueNotFound` on decode, and because a page is + decoded whole, one such agent in the account broke `agents.list()` for every + caller (#1634). + ### Added +- `Agent.runtimeCommand` and `AgentInput.runtimeCommand`, the shell line the + `acp` runtime launches inside the sandbox, and `Runtime.acp` (#1634). + - `FountainKit`, a second product in this package: the same API with `Codable` models, a namespace per resource, a `FountainError` enum, typed SSE (`LogEvent`/`Block`), a `TurnFollower` and a `Run` whose event stream diff --git a/sdk/swift/Sources/FountainKit/Models/Agents.swift b/sdk/swift/Sources/FountainKit/Models/Agents.swift index 33530a9ec..4a8c78de0 100644 --- a/sdk/swift/Sources/FountainKit/Models/Agents.swift +++ b/sdk/swift/Sources/FountainKit/Models/Agents.swift @@ -7,8 +7,13 @@ public struct Agent: Sendable, Decodable, Identifiable, Hashable { public var name: String public var description: String? public var system: String? - public var model: String + /// `nil` on the `acp` runtime, which resolves no inference credential and + /// reads no model. Every other runtime always carries one. + public var model: String? public var runtime: Runtime + /// The shell line the `acp` runtime launches inside the sandbox. `nil` on + /// every other runtime, which resolves its own executable. + public var runtimeCommand: String? public var acp: Bool? public var sandboxProvider: SandboxProvider? public var sandboxMode: SandboxMode? @@ -28,6 +33,7 @@ public struct Agent: Sendable, Decodable, Identifiable, Hashable { enum CodingKeys: String, CodingKey { case id, name, description, system, model, runtime, acp, skills, metadata + case runtimeCommand = "runtime_command" case sandboxProvider = "sandbox_provider" case sandboxMode = "sandbox_mode" case environmentID = "environment_id" @@ -83,6 +89,8 @@ public struct AgentInput: Sendable, Encodable { public var system: String? public var model: String? public var runtime: Runtime? + /// Required when `runtime` is `.acp`, and refused on every other runtime. + public var runtimeCommand: String? public var sandboxProvider: SandboxProvider? public var sandboxMode: SandboxMode? public var environmentID: String? @@ -99,6 +107,7 @@ public struct AgentInput: Sendable, Encodable { system: String? = nil, model: String? = nil, runtime: Runtime? = nil, + runtimeCommand: String? = nil, sandboxProvider: SandboxProvider? = nil, sandboxMode: SandboxMode? = nil, environmentID: String? = nil, @@ -114,6 +123,7 @@ public struct AgentInput: Sendable, Encodable { self.system = system self.model = model self.runtime = runtime + self.runtimeCommand = runtimeCommand self.sandboxProvider = sandboxProvider self.sandboxMode = sandboxMode self.environmentID = environmentID @@ -127,6 +137,7 @@ public struct AgentInput: Sendable, Encodable { enum CodingKeys: String, CodingKey { case name, description, system, model, runtime, skills, metadata + case runtimeCommand = "runtime_command" case sandboxProvider = "sandbox_provider" case sandboxMode = "sandbox_mode" case environmentID = "environment_id" diff --git a/sdk/swift/Sources/FountainKit/Models/Enums.swift b/sdk/swift/Sources/FountainKit/Models/Enums.swift index de1fe1151..fe15ad910 100644 --- a/sdk/swift/Sources/FountainKit/Models/Enums.swift +++ b/sdk/swift/Sources/FountainKit/Models/Enums.swift @@ -97,6 +97,9 @@ public struct Runtime: WireValue { public static let codex: Self = "codex" public static let gemini: Self = "gemini" public static let opencode: Self = "opencode" + /// Not a CLI: the agent names a command in `runtimeCommand` and Fountain + /// launches it inside the sandbox. + public static let acp: Self = "acp" } public struct BlockKind: WireValue { diff --git a/sdk/swift/Tests/FountainKitTests/ClientTests.swift b/sdk/swift/Tests/FountainKitTests/ClientTests.swift index 5bc1bbbec..cf9bf636f 100644 --- a/sdk/swift/Tests/FountainKitTests/ClientTests.swift +++ b/sdk/swift/Tests/FountainKitTests/ClientTests.swift @@ -76,6 +76,61 @@ import Testing #expect(agent.insertedAt != nil) } + /// An `acp` agent carries no model, and the wire sends an explicit null + /// rather than dropping the key (#1634). A non-optional `model` made this + /// throw `valueNotFound`, and because a page is decoded whole, one such + /// agent in the account broke `agents.list()` for every caller. + @Test func decodesAnAgentWithNoModel() async throws { + let json = """ + {"id":"a1","name":"converger","description":null,"system":null, + "model":null,"runtime":"acp","runtime_command":"chant acp --env prod", + "acp":true,"sandbox_provider":null,"sandbox_mode":"persistent", + "environment_id":null,"permission_policy":null,"skills":[], + "mcp_servers":{},"metadata":{},"allowed_vault_ids":null, + "allowed_environment_ids":null,"conversation_count":0, + "avatar_media_type":null,"inserted_at":"2026-09-10T09:00:00Z", + "updated_at":"2026-09-10T09:00:00Z"} + """ + let transport = FakeTransport(json: #"{"data": \#(json)}"#) + let client = FountainClient.fake(transport) + let agent = try await client.agents.get("a1") + + #expect(agent.model == nil) + #expect(agent.runtime == .acp) + #expect(agent.runtimeCommand == "chant acp --env prod") + } + + /// The other half: a page with one acp agent beside a model-driven one. + @Test func decodesAPageMixingAcpAndModelAgents() async throws { + let acp = """ + {"id":"a1","name":"converger","model":null,"runtime":"acp", + "runtime_command":"chant acp"} + """ + let transport = FakeTransport(json: #"{"data": [\#(acp), \#(Self.agentJSON)]}"#) + let client = FountainClient.fake(transport) + let agents = try await client.agents.list() + + #expect(agents.count == 2) + #expect(agents.first?.model == nil) + #expect(agents.last?.model == "anthropic/claude-sonnet-5") + } + + /// A command is sent on create, and omitted entirely when there is none. + @Test func encodesRuntimeCommandOnInput() throws { + let input = AgentInput(name: "converger", runtime: .acp, runtimeCommand: "chant acp") + let body = try JSONEncoder().encode(input) + let wire = try #require( + JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(wire["runtime_command"] as? String == "chant acp") + #expect(wire["model"] == nil) + + let plain = AgentInput(name: "c", model: "anthropic/claude-sonnet-5", runtime: .claude) + let plainBody = try JSONEncoder().encode(plain) + let plainWire = try #require( + JSONSerialization.jsonObject(with: plainBody) as? [String: Any]) + #expect(plainWire["runtime_command"] == nil) + } + @Test func unknownEnumValuesSurvive() throws { let json = Data(#"{"id":"x","name":"n","model":"m","runtime":"zed","status":"paused"}"#.utf8) struct Row: Decodable { diff --git a/sdk/typescript/src/generated/openapi.ts b/sdk/typescript/src/generated/openapi.ts index 00e6a7c7e..a412ce1ab 100644 --- a/sdk/typescript/src/generated/openapi.ts +++ b/sdk/typescript/src/generated/openapi.ts @@ -2586,15 +2586,17 @@ export interface components { metadata?: { [key: string]: unknown; }; - /** @description Canonical provider/model_id (e.g. anthropic/claude-sonnet-4-6). The provider must match the runtime — anthropic for claude, openai for codex, google for gemini; opencode accepts any of the three. Other providers are rejected: Fountain has no credentials to export for them. The model id is not checked against a list, so a newly released model works without a Fountain release. The isolated fountain-fixture runtime is the exception: it accepts only fixture/deterministic-v1. */ - model: string; + /** @description Canonical provider/model_id (e.g. anthropic/claude-sonnet-4-6). The provider must match the runtime — anthropic for claude, openai for codex, google for gemini; opencode accepts any of the three. Other providers are rejected: Fountain has no credentials to export for them. The model id is not checked against a list, so a newly released model works without a Fountain release. The isolated fountain-fixture runtime is the exception: it accepts only fixture/deterministic-v1. Null on the acp runtime, which resolves no inference credential and reads no model. */ + model: string | null; name: string; /** @description Per-tool permission policy: a map of key to verdict, plus an optional "default" key. A key is matched against the tool card's title first and then ACP's kind (execute, edit, read, fetch, …); prefer a kind, because claude titles a tool call with the command it is about to run. Unset keys fall back to the default, and an unset default is auto_allow — today's behaviour. "ask" holds the tool until a human answers it on the conversation stream, and denies if nobody does before the timeout. A runtime that never asks (opencode) refuses anything stricter than auto_allow with 422 permission_policy_unenforceable. */ permission_policy?: { [key: string]: "auto_allow" | "ask" | "auto_deny"; } | null; /** @enum {string} */ - runtime: "claude" | "codex" | "gemini" | "opencode" | "fountain-fixture"; + runtime: "claude" | "codex" | "gemini" | "opencode" | "acp" | "fountain-fixture"; + /** @description The command the acp runtime launches inside the sandbox, as a shell line resolved there (for example `chant acp`). Required when runtime is acp, and rejected on every other runtime, which resolves its own executable. A free string by design: it runs under the same isolation as an environment's setup script. */ + runtime_command?: string | null; /** * @description Where a conversation of this agent runs by default (ADR 0023). ephemeral: a sandbox per conversation, reclaimed with it. persistent: one sandbox per agent identity (agent, environment, vault) — the agent's computer — that every conversation of that identity lands on and shares; it survives a conversation ending and is parked, not destroyed, at the ceiling. A launch may name the other with sandbox_mode on POST /api/conversations. * @enum {string} @@ -2640,14 +2642,16 @@ export interface components { metadata?: { [key: string]: unknown; }; - model: string; + model?: string | null; name: string; /** @description Per-tool permission policy: a map of key to verdict, plus an optional "default" key. A key is matched against the tool card's title first and then ACP's kind (execute, edit, read, fetch, …); prefer a kind, because claude titles a tool call with the command it is about to run. Unset keys fall back to the default, and an unset default is auto_allow. "ask" holds the tool until a human answers it on the conversation stream, and denies if nobody does before the timeout. A conversation may narrow this at launch, never widen it. A runtime that never asks (opencode) refuses anything stricter than auto_allow with 422 permission_policy_unenforceable. */ permission_policy?: { [key: string]: "auto_allow" | "ask" | "auto_deny"; } | null; /** @enum {string} */ - runtime: "claude" | "codex" | "gemini" | "opencode" | "fountain-fixture"; + runtime: "claude" | "codex" | "gemini" | "opencode" | "acp" | "fountain-fixture"; + /** @description The command the acp runtime launches inside the sandbox, as a shell line resolved there (for example `chant acp`). Required when runtime is acp, and rejected on every other runtime, which resolves its own executable. A free string by design: it runs under the same isolation as an environment's setup script. */ + runtime_command?: string | null; /** * @description Where a conversation of this agent runs by default (ADR 0023). ephemeral: a sandbox per conversation, reclaimed with it. persistent: one sandbox per agent identity (agent, environment, vault) — the agent's computer — that every conversation of that identity lands on and shares; it survives a conversation ending and is parked, not destroyed, at the ceiling. A launch may name the other with sandbox_mode on POST /api/conversations. * @enum {string} @@ -2690,14 +2694,16 @@ export interface components { metadata?: { [key: string]: unknown; }; - model?: string; + model?: string | null; name?: string; /** @description Per-tool permission policy: a map of key to verdict, plus an optional "default" key. A key is matched against the tool card's title first and then ACP's kind (execute, edit, read, fetch, …); prefer a kind, because claude titles a tool call with the command it is about to run. Unset keys fall back to the default, and an unset default is auto_allow. "ask" holds the tool until a human answers it on the conversation stream, and denies if nobody does before the timeout. A conversation may narrow this at launch, never widen it. A runtime that never asks (opencode) refuses anything stricter than auto_allow with 422 permission_policy_unenforceable. */ permission_policy?: { [key: string]: "auto_allow" | "ask" | "auto_deny"; } | null; /** @enum {string} */ - runtime?: "claude" | "codex" | "gemini" | "opencode" | "fountain-fixture"; + runtime?: "claude" | "codex" | "gemini" | "opencode" | "acp" | "fountain-fixture"; + /** @description The command the acp runtime launches inside the sandbox, as a shell line resolved there (for example `chant acp`). Required when runtime is acp, and rejected on every other runtime, which resolves its own executable. A free string by design: it runs under the same isolation as an environment's setup script. */ + runtime_command?: string | null; /** * @description Where a conversation of this agent runs by default (ADR 0023). ephemeral: a sandbox per conversation, reclaimed with it. persistent: one sandbox per agent identity (agent, environment, vault) — the agent's computer — that every conversation of that identity lands on and shares; it survives a conversation ending and is parked, not destroyed, at the ceiling. A launch may name the other with sandbox_mode on POST /api/conversations. * @enum {string} @@ -3510,7 +3516,7 @@ export interface components { [key: string]: "auto_allow" | "ask" | "auto_deny"; } | null; /** @enum {string} */ - runtime: "claude" | "codex" | "gemini" | "opencode" | "fountain-fixture"; + runtime: "claude" | "codex" | "gemini" | "opencode" | "acp" | "fountain-fixture"; runtime_session_id?: string | null; sandbox?: components["schemas"]["Sandbox"] | null; /** @@ -4174,7 +4180,7 @@ export interface components { /** @description True while this conversation is running a turn on the machine. */ mid_turn: boolean; /** @enum {string} */ - runtime?: "claude" | "codex" | "gemini" | "opencode" | "fountain-fixture"; + runtime?: "claude" | "codex" | "gemini" | "opencode" | "acp" | "fountain-fixture"; /** @enum {string} */ status: "pending" | "running" | "idle" | "failed" | "terminated"; title?: string | null;