agent: make claudePlugins additive instead of replacing

The base set (skill-creator + base@hyperhive) was declared via the
option's `default`, so a per-agent definition of claudePlugins
replaced it wholesale. Move the base set to a plain `config`
definition instead: a listOf option merges multiple plain definitions
by concatenation, so an agent's own list now adds to the base set
rather than replacing it, while lib.mkForce / lib.mkOverride on the
agent side still replace the whole merged list deliberately (mkDefault
was ruled out explicitly).

Also de-dup at the JSON-render site with lib.unique, so an agent that
names a base-set entry itself doesn't get it installed twice, and
reword the option doc, which still claimed the old REPLACES semantics.

Four module-eval cases cover the unset / agent-adds / mkForce-replaces
/ duplicate-entry shapes.

Refs #4467
This commit is contained in:
atlas 2026-09-18 16:36:44 +02:00 committed by mara
commit b6e180dfbf
2 changed files with 88 additions and 11 deletions

View file

@ -190,10 +190,13 @@ in
options.services.hyperhive.agent.claudePlugins = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"skill-creator@claude-plugins-official"
"base@hyperhive"
];
# No `default` here — the base set below is set as a plain `config`
# DEFINITION (near the bottom of this file) instead. A
# `default` is exactly what a per-agent definition REPLACES; a second
# definition at the same option instead MERGES, which for a
# `listOf` is a concatenation — the standard NixOS shape for "the base
# set plus whatever the agent adds". `lib.mkForce` / `lib.mkOverride`
# still let an operator replace the whole merged list deliberately.
example = [
"formatter@my-marketplace"
"thinking-tools@anthropics"
@ -208,7 +211,7 @@ in
`/etc/hyperhive/claude-plugins.json`; the harness reads it via
`plugins::install_configured`.
Defaults to Anthropic's `skill-creator` (teaches an agent to
Always includes Anthropic's `skill-creator` (teaches an agent to
write, refine, and evaluate its own skills) plus hyperhive's own
`base` plugin one plugin bundling every skill that applies to
*all* agents regardless of role (currently just `state-hygiene`,
@ -219,11 +222,15 @@ in
Agents get both out of the box, matching the default
marketplaces above.
Note that a per-agent definition REPLACES this default rather
than extending it (ordinary NixOS list-option semantics, same as
`claudeMarketplaces`). An agent that wants extra plugins AND the
defaults should list both `skill-creator@claude-plugins-official`
and `base@hyperhive` alongside them.
A per-agent definition ADDS to that base set rather than
replacing it unlike `claudeMarketplaces`, this list is merged
across every module that defines it (ordinary NixOS list-option
semantics: two plain definitions concatenate). An agent that sets
`services.hyperhive.agent.claudePlugins = [ "foo@bar" ]` gets the
base set plus `foo@bar`, with no need to repeat the base entries.
An operator who wants to replace the base set entirely, rather
than add to it, can still do that with `lib.mkForce` or
`lib.mkOverride`.
'';
};
@ -241,8 +248,20 @@ in
};
config = {
# The base set, as a DEFINITION rather than the option's `default` —
# see the comment on the option above. A plain definition (no
# `lib.mkOrder` wrapper) merges at the same priority as a per-agent
# definition, so `lib.mkForce` on the agent side still wins outright.
services.hyperhive.agent.claudePlugins = [
"skill-creator@claude-plugins-official"
"base@hyperhive"
];
environment.etc."hyperhive/claude-plugins.json".text =
builtins.toJSON config.services.hyperhive.agent.claudePlugins;
# `lib.unique` so an agent that lists `base@hyperhive` (or
# `skill-creator@…`) explicitly, on top of the base set this module
# already contributes, doesn't install it twice.
builtins.toJSON (lib.unique config.services.hyperhive.agent.claudePlugins);
environment.etc."hyperhive/claude-marketplaces.json".text =
builtins.toJSON config.services.hyperhive.agent.claudeMarketplaces;

View file

@ -697,6 +697,19 @@ let
homeserver = "https://matrix.example.invalid";
};
};
# `claudePlugins`'s additive-merge shape: a plain per-agent
# definition must ADD to the module's own base-set definition rather than
# replacing it, while `lib.mkForce` must still replace the whole list
# outright — the two arms below plus the unset default (read directly off
# `bare`-shaped `agent { }`, no fixture of its own needed) are the three
# cases that shape has to hold.
agentPluginsDefault = agent { };
agentPluginsAdded = agent { claudePlugins = [ "foo@bar" ]; };
agentPluginsForced = agent { claudePlugins = lib.mkForce [ "foo@bar" ]; };
# The de-dup arm: an agent that names a base-set entry explicitly must not
# get it installed twice.
agentPluginsDuplicate = agent { claudePlugins = [ "base@hyperhive" ]; };
agentPlugins = machine: machine.services.hyperhive.agent.claudePlugins;
agentHarness = machine: machine.systemd.services.hive-agent;
agentSubagentDaemon = machine: machine.systemd.services.hive-subagent-daemon;
agentBaoIdentity = machine: machine.systemd.services.hive-agent-bao-identity;
@ -2080,6 +2093,51 @@ let
# No hive homeserver, so nothing may claim one.
&& !(env ? HIVE_MATRIX_URL);
}
{
# Case 1 of the additive-merge shape: an agent that declares
# nothing gets exactly the module's base set, no more and no less.
name = "an agent with no claudePlugins definition gets exactly the base set";
ok =
agentPlugins agentPluginsDefault == [
"skill-creator@claude-plugins-official"
"base@hyperhive"
];
}
{
# Case 2: a plain per-agent definition ADDS to the base set (list-typed
# options at equal priority concatenate) rather than replacing it —
# the property the operator ruling asked for instead of `mkDefault`.
# Sorted before comparing: concatenation order between two same-
# priority definitions is a module-system implementation detail this
# case isn't about — membership and count are.
name = "an agent's own claudePlugins definition adds to the base set";
ok =
builtins.sort builtins.lessThan (agentPlugins agentPluginsAdded) == [
"base@hyperhive"
"foo@bar"
"skill-creator@claude-plugins-official"
];
}
{
# Case 3: `lib.mkForce` is still the escape hatch — an operator who
# wants the base set gone, not extended, can still say so outright.
name = "an agent's mkForce claudePlugins replaces the base set outright";
ok = agentPlugins agentPluginsForced == [ "foo@bar" ];
}
{
# De-dup arm: an agent that names a base-set entry itself must not get
# it installed twice — `lib.unique` at the JSON-render site, not the
# option's merge (the merge is a plain concatenation on purpose, so
# the un-deduped list stays readable for `agentPlugins` above).
name = "an agent repeating a base-set plugin does not get it installed twice";
ok =
builtins.sort builtins.lessThan (
builtins.fromJSON agentPluginsDuplicate.environment.etc."hyperhive/claude-plugins.json".text
) == [
"base@hyperhive"
"skill-creator@claude-plugins-official"
];
}
{
# The doctrine three glue files state, as a property a rewrite has to
# keep: a client is defined by holding a certificate the store accepts,