This repository has no description
7.8 kB
169 lines
1{
2 self,
3 config,
4 pkgs,
5 ...
6}:
7{
8 imports = [
9 self.inputs.hermes-agent.nixosModules.default
10 ];
11
12 # Hermes Agent (Nous Research) — autonomous agent run as a native systemd
13 # gateway, reachable over Matrix (@hermes:lassul.us). Model backend is our
14 # shared llama cluster: one vLLM instance behind an OpenAI-compatible
15 # endpoint, so the model is whatever the cluster currently serves and a swap
16 # is a one-line settings.model.default change.
17 services.hermes-agent = {
18 enable = true;
19
20 # Build the Matrix platform dependency group into the sealed venv (mautrix
21 # SDK + liboqs for optional E2EE; Linux-only).
22 extraDependencyGroups = [ "matrix" ];
23
24 # Put the Claude Code CLI on the agent's PATH so its bundled "claude-code"
25 # skill can delegate coding via `terminal(command="claude -p '...'")`.
26 # extraPackages lands in both the systemd service PATH and the hermes
27 # user's profile. Auth is separate (run `claude` once as the hermes user).
28 extraPackages = [
29 self.legacyPackages.${pkgs.system}.llm.claude-code
30 ];
31
32 settings.model = {
33 # The cluster serves Qwen3.6-27B-FP8 (also aliased as "default" in
34 # /v1/models — both resolve to the same weights); pin the explicit id so
35 # logs and 400s name the actual model. Verified against the live endpoint
36 # that it emits native tool_calls, which Hermes Agent requires.
37 default = "Qwen3.6-27B-FP8";
38 # hermes's built-in "nous" provider is OAuth-only and hardcodes the dead
39 # host inference.nousresearch.com (NXDOMAIN) — true on both 0.17.0 and
40 # main, so we drive the cluster's OpenAI-compatible endpoint instead.
41 #
42 # It MUST be a *named* custom provider ("custom:<name>" + a
43 # custom_providers entry), not bare "custom". Bare custom resolves its
44 # credential from a host-gated candidate list
45 # (hermes_cli/runtime_provider.py:1244) that only forwards
46 # OPENAI_API_KEY when base_url's host is openai.com — a deliberate
47 # anti-credential-leak measure (upstream #28660 / GHSA-76xc-57q6-vm5m).
48 # For any other host it silently substitutes the literal placeholder
49 # "no-key-required" and the endpoint 401s. A named entry instead reads
50 # the key from its declared key_env (runtime_provider.py:692), which is
51 # not host-gated. Named entries also ignore config.yaml's model.api_key,
52 # so a stray `hermes model` run can no longer shadow this with a stale
53 # key (that is exactly how the Nous→cluster switch broke).
54 provider = "custom:llama";
55 base_url = "https://inference.p0.contact/v1";
56 # The custom provider can't auto-detect limits, so unset it leaves
57 # context uncompressed and defaults max_tokens to 65536 — once history
58 # outgrows the window, input + max_tokens exceeds it and the endpoint
59 # 400s ("check the model name and other parameters"). vLLM reports
60 # max_model_len 262144 for this model: pin that as the window so hermes
61 # compresses in time, and keep a sane output cap (covers thinking +
62 # reply) so input + max_tokens always stays under it.
63 context_length = 262144;
64 max_tokens = 16384;
65 };
66
67 # The endpoint hermes actually authenticates against. key_env names the
68 # env var carrying the token; it comes from the hermes-env generator below
69 # via environmentFiles, so the secret never enters the nix store.
70 # api_mode is pinned because auto-detection only runs as a fallback.
71 settings.custom_providers = [
72 {
73 name = "llama";
74 base_url = "https://inference.p0.contact/v1";
75 key_env = "LLAMA_API_TOKEN";
76 api_mode = "chat_completions";
77 }
78 ];
79
80 # In rooms Hermes requires an @mention by default; DMs always respond.
81 settings.matrix.session_scope = "room";
82
83 # Matrix is a Tier-2 platform whose default tool-progress preview caps
84 # commands at 40 chars (so a `terminal` call shows only `curl -X POST -H
85 # "Content-Type: applic...`). "verbose" makes the gateway render the FULL
86 # command — multi-line — as a fenced code block instead of the truncated
87 # one-liner. Trade-off: verbose also prints full JSON args for every other
88 # tool call, so the channel is chattier.
89 settings.display.platforms.matrix.tool_progress = "verbose";
90
91 # Non-secret connection + SECURITY gate. Invites are auto-accepted and that
92 # cannot be disabled, so the agent may join any room it's invited to. Joining
93 # is harmless on its own — what matters is who can *trigger* the agent, which
94 # on this host has terminal / filesystem / web tools (chat-driven RCE).
95 # MATRIX_ALLOWED_USERS is the universal trigger gate (applies even in DMs);
96 # only these MXIDs can make the agent act, in any room it's joined.
97 environment = {
98 MATRIX_HOMESERVER = "https://matrix.lassul.us";
99 MATRIX_USER_ID = "@hermes:lassul.us";
100 MATRIX_ALLOWED_USERS = "@lassulus:lassul.us";
101 # Element makes DMs end-to-end encrypted by default, so the bot must do
102 # E2EE or it can't read messages. The `matrix` dep group already bundles
103 # mautrix[encryption] (python-olm); "optional" initializes E2EE when those
104 # deps are present (they are) and keeps the crypto store under
105 # /var/lib/hermes/.hermes/platforms/matrix/store/.
106 MATRIX_E2EE_MODE = "optional";
107 # Optional defense-in-depth (confine to specific rooms; DMs are exempt):
108 # MATRIX_ALLOWED_ROOMS = "!yourRoomId:lassul.us";
109 };
110
111 # Secrets (MATRIX_ACCESS_TOKEN + LLAMA_API_TOKEN) come from clan vars below,
112 # merged into $HERMES_HOME/.env at activation.
113 environmentFiles = [
114 config.clan.core.vars.generators.hermes-env.files."hermes.env".path
115 ];
116 };
117
118 # Boot-race guard. matrix.lassul.us is IPv6-only; on this desktop
119 # network-online.target can fire before v6 DNS is ready, so the gateway's
120 # first Matrix connect fails ("Name or service not known") and it does not
121 # retry — leaving the bot silent until a manual restart. Block startup until
122 # the homeserver resolves (best-effort, ~60s cap) so reboots come up
123 # connected.
124 systemd.services.hermes-agent.serviceConfig.ExecStartPre = [
125 "${pkgs.writeShellScript "hermes-wait-matrix-dns" ''
126 i=0
127 while [ "$i" -lt 30 ]; do
128 ${pkgs.getent}/bin/getent ahosts matrix.lassul.us >/dev/null 2>&1 && exit 0
129 ${pkgs.coreutils}/bin/sleep 2
130 i=$((i + 1))
131 done
132 echo "matrix.lassul.us did not resolve within 60s; starting anyway" >&2
133 exit 0
134 ''}"
135 ];
136
137 # Matrix bot access token for @hermes:lassul.us (minted against the neoprism
138 # Synapse). persist=true so the prompt value is stored once.
139 clan.core.vars.generators.hermes-matrix.prompts.matrix-access-token = {
140 description = "Matrix access token for @hermes:lassul.us";
141 type = "hidden";
142 persist = true;
143 };
144
145 # API token for the shared llama cluster, surfaced to hermes as
146 # LLAMA_API_TOKEN (the custom_providers key_env above).
147 # persist=true so the prompt value is stored once.
148 clan.core.vars.generators.hermes-llama.prompts.llama-api-token = {
149 description = "API token for the shared llama cluster (inference.p0.contact)";
150 type = "hidden";
151 persist = true;
152 };
153
154 # Assemble the .env Hermes reads (mirrors the opencrow-env pattern).
155 clan.core.vars.generators.hermes-env = {
156 dependencies = [
157 "hermes-matrix"
158 "hermes-llama"
159 ];
160 files."hermes.env" = { };
161 runtimeInputs = [ pkgs.coreutils ];
162 script = ''
163 cat > "$out/hermes.env" <<EOF
164 MATRIX_ACCESS_TOKEN=$(cat "$in"/hermes-matrix/matrix-access-token)
165 LLAMA_API_TOKEN=$(cat "$in"/hermes-llama/llama-api-token)
166 EOF
167 '';
168 };
169}