subagents: fold hive-extra-mcp into hive-agent-sock per mara's rescoping
This commit is contained in:
parent
670e0ccad3
commit
349f7096ba
12 changed files with 75 additions and 81 deletions
|
|
@ -9,4 +9,6 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
hive-sh4re.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
# hive-agent-sock
|
||||
|
||||
Wire types for the **in-agent socket** — the one the `hive-agent` harness serves
|
||||
_inside_ the container to its local producers. The matrix / bash MCP daemons
|
||||
and `forge_notify` are the _built-in_ producers that ship today, but any
|
||||
user-configured MCP server declared in an agent's `agent.nix` can dial this
|
||||
socket and push its own todos with an arbitrary `subsystem` marker — the
|
||||
wire format doesn't restrict the set. Unlike the host-served sockets, this
|
||||
one **never leaves the container**.
|
||||
What's common across the in-container "agent plugins" — the matrix / bash /
|
||||
subagent MCP daemons and `forge_notify` today, plus any user-configured MCP
|
||||
server declared in an agent's `agent.nix`. Two things live here:
|
||||
|
||||
## What it carries
|
||||
## Wire types for the in-agent socket
|
||||
|
||||
The socket the `hive-agent` harness serves _inside_ the container to those
|
||||
local producers. Any producer can dial it and push its own todos with an
|
||||
arbitrary `subsystem` marker — the wire format doesn't restrict the set.
|
||||
Unlike the host-served sockets, this one **never leaves the container**.
|
||||
|
||||
- the loose-ends-v2 **todo** op family
|
||||
- the harness-local **reminder** op family
|
||||
|
|
@ -16,14 +17,23 @@ one **never leaves the container**.
|
|||
More in-agent request families may be added over time — the socket is
|
||||
deliberately named for the agent, not for the todos.
|
||||
|
||||
## Why in-container
|
||||
**Why in-container**: the harness owns the todo + reminder stores locally and
|
||||
signals its own turn loop directly, so `hive-c0re` is not in either path: no
|
||||
broker round-trip, no long-poll, no marker files. That locality is the
|
||||
point — it's also what lets these stores travel with the agent for hive
|
||||
portability.
|
||||
|
||||
The harness owns the todo + reminder stores locally and signals its own turn
|
||||
loop directly, so `hive-c0re` is not in either path: no broker round-trip, no
|
||||
long-poll, no marker files. That locality is the point — it's also what lets
|
||||
these stores travel with the agent for hive portability.
|
||||
**Not to be confused with `hive-core-agent-sock`**: that crate is the
|
||||
_host_-served core↔agent protocol on `/run/hive/mcp.sock` (the harness
|
||||
talking out to `hive-c0re`). This socket is purely intra-container.
|
||||
|
||||
## Not to be confused with `hive-core-agent-sock`
|
||||
## `extra_mcp`: the `hyperhive.extraMcpServers` spec
|
||||
|
||||
That crate is the _host_-served core↔agent protocol on `/run/hive/mcp.sock`
|
||||
(the harness talking out to `hive-c0re`). This socket is purely intra-container.
|
||||
Not a socket protocol — a shared config type. Parses
|
||||
`/etc/hyperhive/extra-mcp.json` (the nix-rendered `hyperhive.extraMcpServers`
|
||||
option) and renders each entry into the shape claude expects in a
|
||||
`--mcp-config` blob. Shared by `hive-agent` (the main session's servers) and
|
||||
`hive-subagent-mcp` (the subagent-eligible subset, gated on
|
||||
`availableToSubagents`) — both already depend on this crate for the socket
|
||||
types above, so a new plugin-facing shared type belongs here rather than in
|
||||
a crate of its own.
|
||||
|
|
|
|||
186
hive-agent-sock/src/extra_mcp.rs
Normal file
186
hive-agent-sock/src/extra_mcp.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
//! Shared spec for `hyperhive.extraMcpServers` entries: the on-disk JSON
|
||||
//! shape the nix module (`nix/agent-modules/mcp.nix`) renders to
|
||||
//! `/etc/hyperhive/extra-mcp.json`, plus the parsing and per-entry
|
||||
//! JSON-rendering logic every consumer needs. Lives in `hive-agent-sock`
|
||||
//! rather than a crate of its own: this crate is already what's common
|
||||
//! across the in-container "agent plugins" (matrix/bash/subagent MCP
|
||||
//! daemons, `forge_notify`) — the todo-socket wire types above, and now
|
||||
//! this — so a new plugin-facing shared type belongs here rather than in
|
||||
//! a fresh single-purpose crate.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the NixOS module writes the per-agent extra-MCP spec. Each entry
|
||||
/// becomes an additional `mcpServers.<key>` block in a rendered claude
|
||||
/// `--mcp-config`.
|
||||
pub const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
|
||||
|
||||
/// An extra MCP server declared via `hyperhive.extraMcpServers`. Two
|
||||
/// transports: `Stdio` (the consumer spawns `command` fresh each turn, talks
|
||||
/// JSON-RPC over its stdin/stdout) and `Http` (point claude at a long-lived
|
||||
/// streamable-http `url` instead — no per-turn spawn, no re-registration
|
||||
/// race). Internally tagged on the nix-rendered `type` field; unrecognised
|
||||
/// fields for the inactive variant (e.g. `command` on an `Http` entry) are
|
||||
/// ignored by serde's default struct deserialization.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ExtraMcpServer {
|
||||
Stdio {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
#[serde(default = "default_allowed_tools", rename = "allowedTools")]
|
||||
allowed_tools: Vec<String>,
|
||||
#[serde(default, rename = "availableToSubagents")]
|
||||
available_to_subagents: bool,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
#[serde(default = "default_allowed_tools", rename = "allowedTools")]
|
||||
allowed_tools: Vec<String>,
|
||||
#[serde(default, rename = "availableToSubagents")]
|
||||
available_to_subagents: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExtraMcpServer {
|
||||
#[must_use]
|
||||
pub fn allowed_tools(&self) -> &[String] {
|
||||
match self {
|
||||
Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this entry is opted in to subagent sessions
|
||||
/// (`hyperhive.extraMcpServers.<name>.availableToSubagents`, default
|
||||
/// `false`). Subagents run `--strict-mcp-config` with no discovery, so
|
||||
/// this flag is the only path an entry reaches a subagent's own
|
||||
/// `--mcp-config` at all — see `hive-subagent-mcp`'s `mcp_config`
|
||||
/// module.
|
||||
#[must_use]
|
||||
pub fn available_to_subagents(&self) -> bool {
|
||||
match self {
|
||||
Self::Stdio {
|
||||
available_to_subagents,
|
||||
..
|
||||
}
|
||||
| Self::Http {
|
||||
available_to_subagents,
|
||||
..
|
||||
} => *available_to_subagents,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render this entry into the shape claude expects under
|
||||
/// `mcpServers.<name>` in a `--mcp-config` blob. `state_dir` is injected
|
||||
/// as `HYPERHIVE_STATE_DIR` into a stdio entry's env when the entry
|
||||
/// doesn't already set it, so an extra stdio MCP server can resolve the
|
||||
/// agent's durable state dir without its author hard-coding it; an http
|
||||
/// entry has no child-process env to inject into (the daemon behind the
|
||||
/// URL resolves its own state dir independently).
|
||||
#[must_use]
|
||||
pub fn to_json_entry(&self, state_dir: &Path) -> serde_json::Value {
|
||||
match self {
|
||||
Self::Stdio {
|
||||
command, args, env, ..
|
||||
} => {
|
||||
let mut env = env.clone();
|
||||
env.entry("HYPERHIVE_STATE_DIR".to_owned())
|
||||
.or_insert_with(|| state_dir.display().to_string());
|
||||
serde_json::json!({ "command": command, "args": args, "env": env })
|
||||
}
|
||||
Self::Http { url, .. } => serde_json::json!({ "type": "http", "url": url }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_allowed_tools() -> Vec<String> {
|
||||
vec!["*".to_owned()]
|
||||
}
|
||||
|
||||
/// Read + parse the extra-MCP spec from [`EXTRA_MCP_PATH`]. Returns an empty
|
||||
/// map when the file is missing or unparsable (the agent has none
|
||||
/// configured, or the file is malformed — both cases degrade to "no extra
|
||||
/// servers").
|
||||
#[must_use]
|
||||
pub fn load_extra_mcp() -> BTreeMap<String, ExtraMcpServer> {
|
||||
let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
path = EXTRA_MCP_PATH,
|
||||
error = ?e,
|
||||
"extra-mcp spec parse failed; ignoring",
|
||||
);
|
||||
BTreeMap::new()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn available_to_subagents_defaults_false() {
|
||||
let stdio: ExtraMcpServer =
|
||||
serde_json::from_value(serde_json::json!({"type": "stdio", "command": "/bin/foo"}))
|
||||
.unwrap();
|
||||
assert!(!stdio.available_to_subagents());
|
||||
|
||||
let http: ExtraMcpServer = serde_json::from_value(
|
||||
serde_json::json!({"type": "http", "url": "http://127.0.0.1:1/mcp"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!http.available_to_subagents());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_to_subagents_true_is_read() {
|
||||
let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": "/bin/foo",
|
||||
"availableToSubagents": true,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(stdio.available_to_subagents());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_json_entry_injects_state_dir_when_absent() {
|
||||
let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": "/bin/foo",
|
||||
}))
|
||||
.unwrap();
|
||||
let entry = stdio.to_json_entry(Path::new("/agents/x/state"));
|
||||
assert_eq!(entry["env"]["HYPERHIVE_STATE_DIR"], "/agents/x/state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_json_entry_respects_explicit_state_dir() {
|
||||
let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": "/bin/foo",
|
||||
"env": {"HYPERHIVE_STATE_DIR": "/custom"},
|
||||
}))
|
||||
.unwrap();
|
||||
let entry = stdio.to_json_entry(Path::new("/agents/x/state"));
|
||||
assert_eq!(entry["env"]["HYPERHIVE_STATE_DIR"], "/custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_entry_has_no_env() {
|
||||
let http: ExtraMcpServer = serde_json::from_value(serde_json::json!({
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:1/mcp",
|
||||
}))
|
||||
.unwrap();
|
||||
let entry = http.to_json_entry(Path::new("/agents/x/state"));
|
||||
assert_eq!(entry["url"], "http://127.0.0.1:1/mcp");
|
||||
assert!(entry.get("env").is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,31 @@
|
|||
//! Wire types for the *in-agent* socket, served by the hive-agent harness
|
||||
//! to the in-container producers. Matrix / bash MCP daemons and
|
||||
//! `forge_notify` are the built-in ones today, but `subsystem` on the todo
|
||||
//! ops below is a plain string, not a closed set: any user-configured MCP
|
||||
//! server declared in an agent's `agent.nix` can dial this socket and push
|
||||
//! its own todos the same way. Carries the loose-ends-v2 *todo* op family
|
||||
//! plus the harness-local *reminder* op family; more in-agent request
|
||||
//! families may be added over time (the socket is deliberately named for
|
||||
//! the agent, not the todos).
|
||||
//! Rescoped beyond its original name: what's common across the in-container
|
||||
//! "agent plugins" (matrix/bash/subagent MCP daemons, `forge_notify`, and any
|
||||
//! user-configured MCP server) — not just the in-agent socket wire types.
|
||||
//!
|
||||
//! **Wire types for the *in-agent* socket**, served by the hive-agent harness
|
||||
//! to those producers. `subsystem` on the todo ops below is a plain string,
|
||||
//! not a closed set: any user-configured MCP server declared in an agent's
|
||||
//! `agent.nix` can dial this socket and push its own todos the same way.
|
||||
//! Carries the loose-ends-v2 *todo* op family plus the harness-local
|
||||
//! *reminder* op family; more in-agent request families may be added over
|
||||
//! time (the socket is deliberately named for the agent, not the todos).
|
||||
//!
|
||||
//! Distinct from `hive-core-agent-sock`, the *host*-served core↔agent
|
||||
//! protocol on `/run/hive/mcp.sock`: this socket never leaves the
|
||||
//! container. The harness owns the todo + reminder stores locally and
|
||||
//! signals its own turn loop directly, so hive-c0re is not in either path —
|
||||
//! no broker round-trip, no long-poll, no marker files.
|
||||
//!
|
||||
//! **`extra_mcp`**: the `hyperhive.extraMcpServers` spec + parsing, shared by
|
||||
//! `hive-agent` (the main session's `--mcp-config`) and `hive-subagent-mcp`
|
||||
//! (the subagent-eligible subset) — not a socket protocol at all, but this
|
||||
//! crate is the natural home since both consumers already depend on it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use hive_sh4re::inbox::LooseEnd;
|
||||
|
||||
pub mod extra_mcp;
|
||||
pub mod paths;
|
||||
|
||||
/// In-container path of the harness-served in-agent socket. The harness
|
||||
|
|
|
|||
Loading…
Reference in a new issue