Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/agent-service.nix
atlas 1b24edf4b4 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
2026-09-29 22:29:36 +02:00

546 lines
24 KiB
Nix

# The hive-agent harness service itself, plus the per-agent knobs it
# reads from its environment: model selection, effort level,
# compaction watermark, and the extra reverse-proxies of the per-agent
# web UI.
{
pkgs,
lib,
config,
...
}:
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 {
type = lib.types.str;
default = "haiku";
example = "sonnet";
description = ''
Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL`
environment variable; the harness applies it at boot and it takes
priority over any persisted runtime override. The operator can still
switch the model at runtime via the per-agent web UI — that choice
is tracked in the state dir for the current session but is reset by
any rebuild that changes this option.
Valid values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context
window sizes are looked up at runtime from the
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars injected by the
meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens`
on the host.
'';
};
options.services.hyperhive.agent.availableModels = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"haiku"
"sonnet"
"opus"
];
example = [
"sonnet"
"opus"
];
description = ''
Models offered in the per-agent web UI's model quick-picker. Rendered
into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated)
which the harness surfaces to the agent UI, so the picker lists exactly
these models instead of a hardcoded set.
Configure hive-wide by setting a shared default (e.g. in your
`agent.nix` shared defaults) or per-agent to narrow the menu — for example a
haiku-only agent can hide `opus` and `sonnet`. The *current* model is
still set by `services.hyperhive.agent.model` and remains switchable at runtime via the
UI; this option only controls which choices the picker presents.
Values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
'';
};
options.services.hyperhive.agent.effortLevel = lib.mkOption {
type = lib.types.enum [
"low"
"medium"
"high"
"xhigh"
"max"
];
default = "medium";
example = "high";
description = ''
Baseline claude effort level for this agent. Rendered into the
`HIVE_DEFAULT_EFFORT` environment variable; the harness resolves
effort as operator-override-file → this env → built-in `"medium"`,
and passes the result to `claude --effort` at turn launch.
Ascending scale: `"low"` (minimal thinking budget), `"medium"`
(default — balanced), `"high"` (platform default), `"xhigh"`
(recommended for autonomous coding on capable models), `"max"`
(maximum thinking budget, highest cost). The operator can override
at runtime per-agent via the web UI (applied on the next session);
any rebuild that changes this option resets that override.
'';
};
options.services.hyperhive.agent.autoCompact = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable proactive watermark-based compaction. When `true` (the
default) the harness automatically runs a notes-checkpoint turn
followed by `/compact` once the context window crosses 75% of
the model's limit, keeping later turns from hitting the hard
overflow path. Set to `false` to disable proactive compaction
entirely (`HIVE_COMPACT_WATERMARK_TOKENS=0`); the reactive path
(compact-on-overflow when the session is already past the limit)
still applies.
Disable for agents that run large-context models (sonnet/opus)
where the heuristic fires too early and discards useful history
before the session is actually close to the limit.
'';
};
options.services.hyperhive.agent.useApiKey = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Authenticate this agent's `claude` invocations with an API key
(`ANTHROPIC_API_KEY`/`ANTHROPIC_BASE_URL`, e.g. OpenRouter) rather
than a Claude OAuth session. Sets `HIVE_USE_API_KEY=1`, which the
harness reads (`hive_agent::login::using_api_key`) to report
`LoginState::Online` at boot without checking `~/.claude/` — an
api-key agent has no OAuth session to wait for, so it must never
park the turn loop expecting one. Also stamped into the consolidated
harness state file so hive-c0re's dashboard stops reading an empty
`~/.claude/` as "needs login" for this agent (see
`hive_c0re::container_view`'s `needs_login` computation).
Set this AND `services.hyperhive.agent.backendEnvironmentFile` together — this
option changes what the harness believes about its own login state,
the other actually supplies the credentials `claude` reads. Neither
is useful alone.
'';
};
options.services.hyperhive.agent.backendEnvironmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/agents/myagent/harness/openrouter.env";
description = ''
Path (outside the nix store) to a systemd `EnvironmentFile` loaded
by the harness service — the mechanism for supplying
`ANTHROPIC_API_KEY`/`ANTHROPIC_BASE_URL` (or any other backend
credential `claude`/the harness reads from the environment) without
baking a secret into the nix store.
The file must use systemd `EnvironmentFile` syntax: one `KEY=value`
pair per line, no `export`, no shell quoting needed for simple
values. Example contents:
```
ANTHROPIC_API_KEY=sk-or-v1-...
ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
```
Place the file inside the agent's bind-mounted **harness** dir (e.g.
`/agents/<name>/harness/openrouter.env`, `$HYPERHIVE_HARNESS_DIR`),
not `state/` — `harness/` survives container rebuilds exactly like
`state/` does, but is never bind-mounted into another agent's
container (unlike `state/`, which a `ManageRootAgent` holder gets
read-write for recovery — see `docs/agent-lifecycle/persistence.md`'s "Cross-agent access to
state"), so this credential is reachable by nothing but this agent
and the host. Permissions should be `0600`, owned by the agent's
unix user. Loaded with a leading `-` (optional `EnvironmentFile`),
so a path that doesn't exist yet — an operator setting this option
before creating the file, or a fresh host rebuild before state is
restored — makes systemd skip it rather than refuse to start the
harness. See `services.hyperhive.agent.useApiKey`'s doc for the option this one is
paired with.
'';
};
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 {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''{ "stats" = "http://127.0.0.1:3737"; }'';
description = ''
Transparent reverse-proxies mounted under `/extra/` in the per-agent web UI.
Each attribute name becomes the sub-path and the value is the upstream.
E.g. `{ "stats" = "http://127.0.0.1:3737"; }` mounts a proxy at
`/agent/<name>/extra/stats/` that forwards to port 3737 with the prefix
stripped. All user-declared proxies live under `/extra/` so they can
never conflict with native agent endpoints (`/api/*`, `/events/*`, etc.).
The upstream value is either an `http(s)://` URL or a Unix domain
socket, spelled `unix:<path>` (e.g. `unix:/run/myapp/http.sock`) — for
agents whose secondary web server only listens on a UDS.
Intended for agents that run secondary web servers in the same container.
Static assets served by the secondary app must use relative paths to
resolve correctly under the sub-path prefix.
Sets the `HIVE_EXTRA_WEB_PROXIES` environment variable (JSON object)
on the harness service unit.
'';
};
config = {
assertions = [
# services.hyperhive.agent.model must be a non-empty string — an empty value causes
# the harness to pass an invalid model flag to claude.
{
assertion = config.services.hyperhive.agent.model != "";
message = "services.hyperhive.agent.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")";
}
# The current model must appear in the quick-picker menu, otherwise the
# UI would offer no way back to the model the agent is actually running.
{
assertion =
config.services.hyperhive.agent.availableModels == [ ]
|| builtins.elem config.services.hyperhive.agent.model config.services.hyperhive.agent.availableModels;
message =
"services.hyperhive.agent.model (\"${config.services.hyperhive.agent.model}\") must be one of "
+ "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 = {
HIVE_DEFAULT_MODEL = config.services.hyperhive.agent.model;
# Comma-separated menu for the per-agent UI model quick-picker
# (see services.hyperhive.agent.availableModels). The harness surfaces it to the
# frontend; an empty value falls back to the built-in default list.
HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.services.hyperhive.agent.availableModels;
# Per-agent baseline effort (see services.hyperhive.agent.effortLevel). The
# harness resolves operator-override-file → this env → "medium"
# and passes it to claude --effort at turn launch.
HIVE_DEFAULT_EFFORT = config.services.hyperhive.agent.effortLevel;
}
// lib.optionalAttrs (!config.services.hyperhive.agent.autoCompact) {
# Zero watermark disables proactive compaction; the reactive path
# (compact-on-overflow) still fires when the session is truly full.
HIVE_COMPACT_WATERMARK_TOKENS = "0";
};
# Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars,
# RuntimeDirectory, User=, standalone-eval fallbacks):
# docs/agent-lifecycle/agent-hierarchy.md::Harness systemd unit shape. PATH /bin
# auto-append behaviour: docs/process/gotchas.md::systemd.services.*.path
# appends /bin to every entry.
systemd.services.hive-agent =
let
binary = "hive-agent";
in
{
description = "${binary} harness";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
# `/run/wrappers` before `/run/current-system/sw` so setuid
# `sudo` resolves first. Passing the bare prefixes (no trailing
# `/bin`) is intentional — see docs pointer above.
path = [
"/run/wrappers"
"/run/current-system/sw"
];
environment = {
SHELL = "${pkgs.bashInteractive}/bin/bash";
HOME = homeDir;
HIVE_STATIC_DIR = "${config.services.hyperhive.agent.frontend.mergedDist}";
HIVE_ASSETS_DIR = "${config.services.hyperhive.agent.packages.assets}/share/hyperhive";
# Unix-socket path for the harness web UI. All agents always bind
# here; there is no TCP fallback. Path matches
# `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
# bind-mounts and gateway upstream config stay in sync.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
# In-agent socket (loose-ends v2): the harness binds this and
# serves the `hive-agent-sock` todo protocol to the in-container
# producers (matrix daemon) + the MCP bridge (`get_loose_ends`).
# Same per-agent runtime dir as the web socket so all agent-user
# services in this container can reach it; purely in-container
# (never bind-mounted to the host — unlike hive-c0re's mcp.sock).
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
# Loopback URL of the persistent `hive-mcp-http` daemon that
# `render_claude_config` points claude at for the built-in
# surface (HTTP is the sole transport — no per-turn stdio child).
# Kept in sync with the `hive-mcp-http` unit's `--http` port
# (see ./mcp.nix) via the same option. Always set — network
# isolation is unconditional, so a fixed per-container port is
# collision-free.
HYPERHIVE_MCP_HTTP_PORT = toString config.services.hyperhive.agent.mcp.httpPort;
}
// lib.optionalAttrs config.services.hyperhive.agent.gui.enable {
# Tells the harness which fixed VNC port weston bound, and (by
# its presence) that gui is enabled — the harness `/screen/ws`
# relay reads this instead of a runtime marker file. The port is
# container-local + fixed (network isolation is unconditional),
# so the same value for every gui agent is fine. See
# ./weston-vnc.nix::services.hyperhive.agent.gui.vncPort.
HIVE_GUI_VNC_PORT = toString config.services.hyperhive.agent.gui.vncPort;
}
// lib.optionalAttrs (config.services.hyperhive.agent.extraWebProxies != { }) {
# JSON object {"<path>": "<upstream>"} for the transparent
# reverse-proxies. See `services.hyperhive.agent.extraWebProxies` option
# and `web_ui/proxy.rs::extra_proxy_service`.
HIVE_EXTRA_WEB_PROXIES = builtins.toJSON config.services.hyperhive.agent.extraWebProxies;
}
// lib.optionalAttrs config.services.hyperhive.agent.useApiKey {
# 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}";
# Pin the journal identity to the binary name (otherwise systemd
# derives SyslogIdentifier from the ExecStart basename).
SyslogIdentifier = binary;
Restart = "on-failure";
RestartSec = 2;
# A LOWER OOMScoreAdjust means LESS likely to be killed, so this
# negative value puts the harness — and the agent's own `claude`,
# which it spawns as a child and which therefore inherits the
# value — last in line under memory pressure, behind
# `hive-subagent-daemon`'s `+500` (see ./mcp.nix). Losing a
# subagent costs one restartable task; losing this costs the
# session that was supervising it. Not `-1000`, which would
# exempt the harness entirely and leave the kernel with nothing
# to kill in a container whose only large process is this one.
OOMScoreAdjust = -500;
# Per-service runtime dir owned by `User=` below; the harness
# writes its regenerated claude-{mcp-config,settings,system-prompt}
# files here (`paths::config_dir`). Separate from /run/hive,
# which holds hive-c0re's mcp.sock.
RuntimeDirectory = "hive-config";
User = userName;
Group = userName;
}
// lib.optionalAttrs (config.services.hyperhive.agent.backendEnvironmentFile != null) {
# See `services.hyperhive.agent.backendEnvironmentFile`'s own description for
# the file shape and the leading-`-` rationale.
EnvironmentFile = "-${config.services.hyperhive.agent.backendEnvironmentFile}";
};
};
};
}