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:
parent
f0110be76c
commit
1b24edf4b4
1 changed files with 215 additions and 0 deletions
|
|
@ -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}";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue