diff --git a/flake.lock b/flake.lock index a61a702..355816e 100644 --- a/flake.lock +++ b/flake.lock @@ -114,6 +114,27 @@ "type": "github" } }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": [ + "hermes-agent", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782949081, + "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": [ @@ -135,6 +156,29 @@ "type": "github" } }, + "hermes-agent": { + "inputs": { + "flake-parts": "flake-parts_2", + "nixpkgs": "nixpkgs_2", + "npm-lockfile-fix": "npm-lockfile-fix", + "pyproject-build-systems": "pyproject-build-systems_2", + "pyproject-nix": "pyproject-nix_2", + "uv2nix": "uv2nix_2" + }, + "locked": { + "lastModified": 1787007062, + "narHash": "sha256-C1SJX0VqlxrLknGhFKj7+DKpfJ1eI4udPE67kW+dT+M=", + "owner": "NousResearch", + "repo": "hermes-agent", + "rev": "55e34fb7d0cd8fd1e16be82a1ceafee05fca63d3", + "type": "github" + }, + "original": { + "owner": "NousResearch", + "repo": "hermes-agent", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -198,9 +242,9 @@ "nixpkgs": [ "nixpkgs" ], - "pyproject-build-systems": "pyproject-build-systems_2", - "pyproject-nix": "pyproject-nix_2", - "uv2nix": "uv2nix_2" + "pyproject-build-systems": "pyproject-build-systems_3", + "pyproject-nix": "pyproject-nix_3", + "uv2nix": "uv2nix_3" }, "locked": { "lastModified": 1768002052, @@ -369,6 +413,22 @@ } }, "nixpkgs_2": { + "locked": { + "lastModified": 1785318670, + "narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { "locked": { "lastModified": 1786943417, "narHash": "sha256-b4qgjdFtlz5TAZ1Hi7DFJeqX3sjaDUnrwr9OO+O1rM0=", @@ -384,6 +444,27 @@ "type": "github" } }, + "npm-lockfile-fix": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775903712, + "narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=", + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8", + "type": "github" + }, + "original": { + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "type": "github" + } + }, "pyproject-build-systems": { "inputs": { "nixpkgs": [ @@ -414,6 +495,35 @@ } }, "pyproject-build-systems_2": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ], + "pyproject-nix": [ + "hermes-agent", + "pyproject-nix" + ], + "uv2nix": [ + "hermes-agent", + "uv2nix" + ] + }, + "locked": { + "lastModified": 1785115949, + "narHash": "sha256-8AM37BfyGaL2v/SZyg4PupRxJ01Y4htvM+WrTjWrPpo=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "62c0d86027edb1c4f39a5facc09876348144f7c9", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-build-systems_3": { "inputs": { "nixpkgs": [ "mediamanager-nix", @@ -464,6 +574,27 @@ } }, "pyproject-nix_2": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784591072, + "narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "e3b599ca2e7fcf93d4edf65d7f19bbf6491724f3", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "pyproject-nix_3": { "inputs": { "nixpkgs": [ "mediamanager-nix", @@ -488,13 +619,14 @@ "inputs": { "authentik-nix": "authentik-nix", "disko": "disko", + "hermes-agent": "hermes-agent", "home-manager": "home-manager", "hypr-chrome": "hypr-chrome", "mediamanager-nix": "mediamanager-nix", "nix-flatpak": "nix-flatpak", "nixos-anywhere": "nixos-anywhere", "nixos-images": "nixos-images", - "nixpkgs": "nixpkgs_2", + "nixpkgs": "nixpkgs_3", "nixpkgs-unstable": "nixpkgs-unstable", "sops-nix": "sops-nix", "tome": "tome" @@ -598,6 +730,31 @@ } }, "uv2nix_2": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ], + "pyproject-nix": [ + "hermes-agent", + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1785277507, + "narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + }, + "uv2nix_3": { "inputs": { "nixpkgs": [ "mediamanager-nix", diff --git a/flake.nix b/flake.nix index 12e71fb..4eea9b2 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,11 @@ }; authentik-nix.url = "github:nix-community/authentik-nix"; nix-flatpak.url = "github:gmodena/nix-flatpak"; + # Hermes Agent (see services/desktop/hermes-agent.nix) — Tier 2 platform + # per its own docs (best-effort Nix support, can break on any upstream + # commit), so pinned like everything else via flake.lock rather than + # followed loosely. + hermes-agent.url = "github:NousResearch/hermes-agent"; # Own Hyprland plugin (border + title bar), public repo, fetched over # https (no credentials needed, unlike tome below). `nixpkgs.follows` is # what makes its packaged build ABI-correct — Hyprland plugins are @@ -105,6 +110,7 @@ sops-nix.nixosModules.sops home-manager.nixosModules.home-manager inputs.nix-flatpak.nixosModules.nix-flatpak + inputs.hermes-agent.nixosModules.default ./hosts/terra/configuration.nix ]; }; diff --git a/hosts/terra/configuration.nix b/hosts/terra/configuration.nix index 6fa019c..92fd75c 100644 --- a/hosts/terra/configuration.nix +++ b/hosts/terra/configuration.nix @@ -18,6 +18,7 @@ in ../../services/desktop/desktop-hyprland.nix ../../services/desktop/desktop-apps.nix ../../services/desktop/librechat.nix + ../../services/desktop/hermes-agent.nix ]; networking.hostName = "terra"; @@ -95,6 +96,24 @@ in # Sparse activation makes that far less painful than it'd be for a dense # model this size, but still expect it to run slower than the two above. loadModels = [ "gemma4:12b" "qwen3.6:35b-a3b" ]; + # Ollama truncates context far below the model's real window unless + # told otherwise (the OpenAI-compat /v1 route it's reached through has + # no way to set this per-request — see services/desktop/hermes-agent.nix, + # which points at this server and mirrors the same number in + # model.context_length). 131072 chosen as the practical ceiling after + # load-testing with real prompts, not just idle `ollama ps` checks: + # 32768 (31.6k-token prompt) and 65536 (40.8k-token prompt) both stayed + # 100% GPU with VRAM barely moving (~10.1G / ~10.67G of 16G) — KV cache + # cost barely grows with context, likely sliding-window/local attention + # on most of gemma4:12b's layers. At 131072 that stopped being true: a + # ~108k-token prompt pushed VRAM to ~11.4G/16G (still 100% GPU, no CPU + # spillover, negligible GTT) but with visibly shrinking headroom, and + # prefill throughput measurably dropped (~490 -> ~460 tok/s) over just + # the last 13k tokens — filling the full window would take minutes of + # pure prompt processing. Stopped here rather than push further: next + # doubling would risk CPU spillover under any concurrent GPU load + # (desktop compositor, jellyfin transcode) for diminishing benefit. + environmentVariables.OLLAMA_CONTEXT_LENGTH = "131072"; }; # ---- Dev-data disks — NOT in disko, mounted read-write, never wiped ---- diff --git a/services/desktop/hermes-agent.nix b/services/desktop/hermes-agent.nix new file mode 100644 index 0000000..efec42a --- /dev/null +++ b/services/desktop/hermes-agent.nix @@ -0,0 +1,36 @@ +{ ... }: + +# Hermes Agent (https://github.com/NousResearch/hermes-agent) — Nous +# Research's tool-calling CLI/gateway agent, pointed at the local ollama +# server (see hosts/terra/configuration.nix) over its OpenAI-compatible /v1 +# route instead of a cloud provider. The nixosModules.default this pulls in +# comes from the hermes-agent flake input itself (flake.nix), not from +# nixpkgs — it builds the agent from source via uv2nix, so there's no +# curl-|-bash installer or runtime pip/npm involved. +# +# Native mode (container.enable stays false): a hardened systemd service is +# enough here since the agent only needs to reach loopback ollama, and it +# avoids the podman-rootful-needs-passwordless-sudo dance the module's docs +# call out for container mode. addToSystemPackages puts `hermes` on darman's +# PATH sharing state (sessions, memories, cron) with the service instead of +# starting a second, disconnected ~/.hermes. +{ + services.hermes-agent = { + enable = true; + addToSystemPackages = true; + + settings.model = { + provider = "custom"; + base_url = "http://127.0.0.1:11434/v1"; + # gemma4:12b, not qwen3.6:35b-a3b: same daily-driver model already + # loaded for librechat (services/desktop/librechat.nix) — reusing it + # means no second model has to swap into the 6800 XT's 16G VRAM + # alongside whatever's already resident. + default = "gemma4:12b"; + # No api_key — ollama's OpenAI-compat endpoint doesn't check one. + # Mirrors OLLAMA_CONTEXT_LENGTH set on the ollama service itself; + # this is a client-side hint only, ollama enforces the real limit. + context_length = 131072; + }; + }; +}