From 1b24edf4b48f56e047f6ddb7a2de0144399bd347 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 29 Sep 2026 21:22:41 +0200 Subject: [PATCH] nix: runtime option, acp.* and an opencode preset services.hyperhive.agent.runtime ("claude" default | "acp") and acp.{command,args,env}, rendered into HIVE_RUNTIME / HIVE_ACP_* only for acp, so a claude agent's unit is unchanged. acp implies useApiKey. acp.presets.opencode runs `opencode acp` from nixpkgs against an OpenAI-compatible provider from acp.opencode.{provider,model, contextWindow,outputLimit}: the config is rendered to the store with the API key as an {env:VAR} reference, so the key is read at runtime from backendEnvironmentFile. OPENCODE_PERMISSION denies opencode's built-in bash, task, todowrite and websearch, and makes webfetch ask. Refs #4391 --- nix/agent-modules/agent-service.nix | 215 ++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/nix/agent-modules/agent-service.nix b/nix/agent-modules/agent-service.nix index bfa2259f..a80f8e26 100644 --- a/nix/agent-modules/agent-service.nix +++ b/nix/agent-modules/agent-service.nix @@ -11,6 +11,51 @@ let userName = config.services.hyperhive.agent.user.name; homeDir = "/home/${userName}"; + acp = config.services.hyperhive.agent.acp; + isAcp = config.services.hyperhive.agent.runtime == "acp"; + preset = if acp.preset == null then null else acp.presets.${acp.preset} or null; + oc = acp.opencode; + + # Tools opencode must not offer, mirroring claude's built-in allow-list + # (hive_sh4re::permissions): no built-in shell (shell is `mcp__bash__run`), + # no nested agents, no in-session todo list, no web search. `webfetch` + # asks, and the harness answers per the `web_tools` tool group. Passed as + # OPENCODE_PERMISSION because opencode merges that over every config file, + # including ones the agent can write. + opencodePermission = { + bash = "deny"; + task = "deny"; + todowrite = "deny"; + websearch = "deny"; + webfetch = "ask"; + }; + opencodeConfig = pkgs.writeText "opencode.json" ( + builtins.toJSON { + "$schema" = "https://opencode.ai/config.json"; + autoupdate = false; + share = "disabled"; + model = "${oc.provider.id}/${oc.model}"; + provider.${oc.provider.id} = { + npm = "@ai-sdk/openai-compatible"; + inherit (oc.provider) name; + options = { + baseURL = oc.provider.baseUrl; + # Substituted by opencode from its environment at startup, so the + # key stays in backendEnvironmentFile and out of the store. + apiKey = "{env:${oc.provider.apiKeyEnv}}"; + }; + # `limit.context` is what opencode reports as the window in its + # `usage_update`, i.e. the harness's ctx %. + models.${oc.model} = { + name = oc.model; + limit = { + context = oc.contextWindow; + output = oc.outputLimit; + }; + }; + }; + } + ); in { options.services.hyperhive.agent.model = lib.mkOption { @@ -165,6 +210,136 @@ in ''; }; + options.services.hyperhive.agent.runtime = lib.mkOption { + type = lib.types.enum [ + "claude" + "acp" + ]; + default = "claude"; + example = "acp"; + description = '' + What drives this agent's turns. `"claude"` runs `claude --print`. + `"acp"` runs the Agent Client Protocol agent described by + `services.hyperhive.agent.acp`, as one long-lived child of the + harness, and turns `services.hyperhive.agent.useApiKey` on by default: + the agent authenticates to its own provider, so there is no Claude + login to wait for. + + On `"acp"`, `model`, `effortLevel` and `autoCompact` have no effect + (the model is whatever the agent is configured with), and neither the + web UI's cancel button nor `/compact` works yet. + ''; + }; + + options.services.hyperhive.agent.acp = { + preset = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "opencode"; + description = '' + Name of an entry in `services.hyperhive.agent.acp.presets` whose + `command`, `args` and `env` become the defaults of the options of + the same name here. + ''; + }; + + command = lib.mkOption { + type = lib.types.str; + default = ""; + example = lib.literalExpression ''"''${pkgs.opencode}/bin/opencode"''; + description = "Program the harness spawns as its ACP agent (`HIVE_ACP_COMMAND`)."; + }; + + args = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "acp" ]; + description = "Arguments for `command` (`HIVE_ACP_ARGS`, as JSON)."; + }; + + env = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = '' + Environment for the ACP agent only, on top of the harness's own + (`HIVE_ACP_ENV`, as JSON). Rendered into the nix store: never put a + credential here. Put it in `services.hyperhive.agent.backendEnvironmentFile`, + which the agent inherits through the harness. + ''; + }; + + presets = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + command = lib.mkOption { type = lib.types.str; }; + args = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + }; + env = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + }; + }; + } + ); + description = '' + Named ACP agent setups `services.hyperhive.agent.acp.preset` can + select. `opencode` runs `opencode acp` against the OpenAI-compatible + provider set in `services.hyperhive.agent.acp.opencode`. + ''; + }; + + opencode = { + provider = { + id = lib.mkOption { + type = lib.types.str; + default = "provider"; + example = "hetzner"; + description = "Provider id in opencode's config; the model is addressed as `/`."; + }; + name = lib.mkOption { + type = lib.types.str; + default = oc.provider.id; + defaultText = lib.literalExpression "config.services.hyperhive.agent.acp.opencode.provider.id"; + description = "Display name of the provider."; + }; + baseUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "https://inference.hetzner.com/api/v1"; + description = "Base URL of the OpenAI-compatible API."; + }; + apiKeyEnv = lib.mkOption { + type = lib.types.str; + default = "ACP_PROVIDER_API_KEY"; + description = '' + Environment variable opencode reads the provider's API key from. + Set it in `services.hyperhive.agent.backendEnvironmentFile`. + ''; + }; + }; + model = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "Qwen/Qwen3.6-35B-A3B-FP8"; + description = "Model id, as the provider's API names it."; + }; + contextWindow = lib.mkOption { + type = lib.types.ints.positive; + default = 131072; + example = 262144; + description = "The model's context window in tokens; the harness's ctx % is measured against it."; + }; + outputLimit = lib.mkOption { + type = lib.types.ints.positive; + default = 32768; + description = "Maximum output tokens per model response."; + }; + }; + }; + options.services.hyperhive.agent.extraWebProxies = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = { }; @@ -209,8 +384,41 @@ in + "services.hyperhive.agent.availableModels ([ ${lib.concatStringsSep " " config.services.hyperhive.agent.availableModels} ]) " + "— add it to the list or change the model."; } + { + assertion = acp.preset == null || preset != null; + message = + "services.hyperhive.agent.acp.preset (\"${toString acp.preset}\") names no entry in " + + "services.hyperhive.agent.acp.presets ([ ${lib.concatStringsSep " " (lib.attrNames acp.presets)} ])."; + } + { + assertion = !isAcp || acp.command != ""; + message = "services.hyperhive.agent.runtime = \"acp\" needs services.hyperhive.agent.acp.command (or acp.preset)."; + } + { + assertion = acp.preset != "opencode" || (oc.provider.baseUrl != null && oc.model != null); + message = "services.hyperhive.agent.acp.preset = \"opencode\" needs acp.opencode.provider.baseUrl and acp.opencode.model."; + } ]; + services.hyperhive.agent.useApiKey = lib.mkIf isAcp (lib.mkDefault true); + + services.hyperhive.agent.acp = { + presets.opencode = { + command = "${pkgs.opencode}/bin/opencode"; + args = [ "acp" ]; + env = { + OPENCODE_CONFIG = "${opencodeConfig}"; + OPENCODE_PERMISSION = builtins.toJSON opencodePermission; + # Keeps a config file in the agent's working directory from + # overriding the one above. + OPENCODE_DISABLE_PROJECT_CONFIG = "1"; + }; + }; + command = lib.mkIf (preset != null) (lib.mkDefault preset.command); + args = lib.mkIf (preset != null) (lib.mkDefault preset.args); + env = lib.mkIf (preset != null) (lib.mkDefault preset.env); + }; + # HIVE_DEFAULT_MODEL seeds the initial model selection when no # persisted model choice exists in the state dir. environment.variables = { @@ -295,6 +503,13 @@ in # Tells the harness not to wait for a Claude OAuth session — see # `services.hyperhive.agent.useApiKey`'s own description for the full mechanism. HIVE_USE_API_KEY = "1"; + } + // lib.optionalAttrs isAcp { + # Read by `hive_runtime::RuntimeSpec`; unset means claude. + HIVE_RUNTIME = "acp"; + HIVE_ACP_COMMAND = acp.command; + HIVE_ACP_ARGS = builtins.toJSON acp.args; + HIVE_ACP_ENV = builtins.toJSON acp.env; }; serviceConfig = { ExecStart = "${config.services.hyperhive.agent.packages.hive-agent}/bin/${binary}";