Watch
0
0
Fork
You've already forked hyperhive
0

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
This commit is contained in:
atlas 2026-09-29 21:22:41 +02:00 • committed by mara
commit 1b24edf4b4

View file

@ -11,6 +11,51 @@
let let
userName = config.services.hyperhive.agent.user.name; userName = config.services.hyperhive.agent.user.name;
homeDir = "/home/${userName}"; 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 in
{ {
options.services.hyperhive.agent.model = lib.mkOption { 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 `<id>/<model>`.";
};
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 { options.services.hyperhive.agent.extraWebProxies = lib.mkOption {
type = lib.types.attrsOf lib.types.str; type = lib.types.attrsOf lib.types.str;
default = { }; default = { };
@ -209,8 +384,41 @@ in
+ "services.hyperhive.agent.availableModels ([ ${lib.concatStringsSep " " config.services.hyperhive.agent.availableModels} ]) " + "services.hyperhive.agent.availableModels ([ ${lib.concatStringsSep " " config.services.hyperhive.agent.availableModels} ]) "
+ "— add it to the list or change the model."; + "— 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 # HIVE_DEFAULT_MODEL seeds the initial model selection when no
# persisted model choice exists in the state dir. # persisted model choice exists in the state dir.
environment.variables = { environment.variables = {
@ -295,6 +503,13 @@ in
# Tells the harness not to wait for a Claude OAuth session — see # Tells the harness not to wait for a Claude OAuth session — see
# `services.hyperhive.agent.useApiKey`'s own description for the full mechanism. # `services.hyperhive.agent.useApiKey`'s own description for the full mechanism.
HIVE_USE_API_KEY = "1"; 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 = { serviceConfig = {
ExecStart = "${config.services.hyperhive.agent.packages.hive-agent}/bin/${binary}"; ExecStart = "${config.services.hyperhive.agent.packages.hive-agent}/bin/${binary}";