hyperhive/nix/agent-modules/agent-service.nix
atlas 3662eda440 nix: move the agent option namespace under services.hyperhive.agent
Every per-agent harness option lived at the top-level `hyperhive.*` while
the host tier has always been `services.hyperhive.*`. Move all 52 agent-tier
option leaves (33 top-level names across 16 modules) to
`services.hyperhive.agent.*`, repoint every read, and keep existing agent
configs evaluating through one `mkRenamedOptionModule` per old leaf path in
the new nix/agent-modules/renamed-options.nix.

The shims are per leaf rather than per namespace: `user`, `mcp`, `otel`,
`queue`, `docs`, `forge`, `frontend`, `github`, `gui`, `logs`, `matrix` and
`cargo` are plain attrsets of declarations, not submodule-typed options, so
a parent-path rename would not reach their children. Three read-only
options (`frontend.mergedDist`, `queue.clientIdFile`,
`queue.clientSecretFile`) deliberately get no shim — a rename contributes a
definition, which a read-only option refuses; the exclusions are commented
in place.

Refs #4473
2026-09-17 20:19:30 +02:00

331 lines
16 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}";
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 a parent agent's
container (unlike `state/`, which a parent gets read-write for child
recovery — see `docs/agent-lifecycle/persistence.md`'s "Parent access to child
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.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.";
}
];
# 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";
};
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}";
};
};
};
}