feat(medulla_local): supervise a local medulla-serve child (Flavor A draft) (#5105)

This commit is contained in:
Steven Enamakel
2026-07-22 17:20:40 +03:00
committed by GitHub
parent a8d13fa781
commit 9bb59ca538
22 changed files with 4170 additions and 2 deletions
+21 -1
View File
@@ -385,7 +385,7 @@ tokio = { version = "1", features = ["test-util"] }
proptest = "1"
[features]
default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "desktop-automation", "channels", "tui"]
default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "desktop-automation", "channels", "tui", "medulla-local"]
# HTTP + Socket.IO server transport (#5048): the `/rpc` JSON-RPC endpoint and
# its auth middleware/CORS layer (`core::jsonrpc`, `core::auth`), the `/v1`
# OpenAI-compatible router (`inference::http`), the ad-hoc static-dir file
@@ -596,6 +596,26 @@ desktop-automation = ["dep:uiautomation"]
# build-fact "tui feature disabled at compile time" error from the untouched
# `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern).
tui = ["dep:ratatui", "dep:crossterm", "dep:unicode-width"]
# Local Medulla brain (plan Flavor A, §3.1–§3.2): the `medulla_local` domain —
# a supervised `medulla-serve` Node child speaking the serve NDJSON protocol,
# host-side inference + tools port callbacks, the `medulla_local` RPC namespace,
# and the subconscious-replacement draft (`subconscious.engine = "medulla"`,
# §5.2). Default-ON — the desktop app ships it — and therefore FORWARDED to the
# Tauri shell's `openhuman_core` features (default-features=false there). Slim /
# headless builds opt out via `--no-default-features --features "<list without
# medulla-local>"`. The module is `#[cfg(feature = "medulla-local")]` at its
# `pub mod` declaration (a registration-site gate, like `flows`); the
# `subconscious.engine` config field stays compiled in ALL builds (inert serde),
# and the subconscious tick's medulla branch is `#[cfg]`-gated so the default
# (`local`) path is byte-identical whether or not this feature is on.
# Sheds ZERO exclusive dependencies — tokio net/process, reqwest, serde are all
# load-bearing for other domains; the value is compile-time surface.
# The serve transport is unix-only (unix domain sockets): on non-unix targets
# (Windows desktop) the feature still compiles via a supervisor stub that
# reports a typed unsupported-platform error, so forwarding the default-ON
# gate to the Tauri shell never breaks Windows packaging.
medulla-local = []
# Channels domain: `openhuman::channels` (external-messaging providers — Telegram,
# Discord, Slack, Signal, WhatsApp, iMessage, IRC, … — plus the channel runtime,
# controllers, host, proactive messaging and inbound dispatch) together with the
+1
View File
@@ -178,6 +178,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal
# (#5048). Enforced by the HTTP_SERVER_COMPILED_IN compile assert in lib.rs.
"http-server",
"desktop-automation",
"medulla-local",
] }
tinyjuice = { version = "0.2.1", default-features = false }
+1
View File
@@ -516,6 +516,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
| ------ | --------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 11.3.1 | Hosted-only orchestration (client = trigger + effects + render) | RI+VU | `tests/orchestration_hosted_client.rs`, `app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx`, `app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx` | ✅ | Local wake-graph brain retired (frontend_agent/graph/master_agent/reasoning_agent deleted). Client forwards events to the hosted brain (`POST /orchestration/v1/events`), uploads world-diffs, syncs hosted sessions/messages/steering into the render cache, and executes `send_dm`/`evict` socket effects; cloud-unreachable banner on outage. |
| 11.3.2 | Direct paid Medulla orchestration with local OpenHuman tools | RU | `src/openhuman/orchestration/medulla.rs`, `src/openhuman/orchestration/schemas.rs` | ✅ | `openhuman.orchestration_run` checks the active paid plan, starts a hosted Medulla cycle, executes requested contact/session/send tools locally, and continues pending/tool-use events to a final result. Mocked HTTP tests cover direct and tool-loop success plus plan, pending, backend-error, unknown-tool, and tool-failure paths. |
| 11.3.3 | Local medulla-serve supervision (`medulla_local` RPC, Flavor A draft) | RU+RI | `src/openhuman/medulla_local/server_tests.rs`, `tests/medulla_local_e2e.rs` | ✅ | Behind the default-ON `medulla-local` feature. Unit tests drive the supervisor's handshake seam against a mock connector: restart-and-retry fires only on mid-request transport death of an idempotent op (exactly one respawn); the non-idempotent `instruct` is never replayed — a transport break surfaces the typed `MaybeApplied` error with no duplicate submission; an overall per-request deadline (`subconscious.medulla_local.request_deadline_secs`, default 300s) bounds a request even while the child keeps streaming frames or while a serve→host port callback (`inference.invoke`/`tools.invoke`) hangs on a stalled provider/tool — tripping as the typed transport error (`MaybeApplied` for `instruct`, restart-and-retry-once for `status`); a retry that also breaks mid-request resets the replacement connection, so the next request re-establishes cleanly instead of reusing a poisoned transport; child liveness is probed before the cached connection is trusted, so a child that dies between requests reports `running=false` and is respawned on the next request instead of misreporting `MaybeApplied`; `ok=false` serve rejections fail fast with the typed `RequestError` without killing the child; and the global cache is keyed on an order-canonical config fingerprint (map-field insertion order cannot invalidate it, a changed config rebuilds and bypasses the start-failure backoff). The transport is unix-only; non-unix targets compile a stub whose entry points report a typed unsupported-platform error (self-tested on those targets). The RI suite boots the real JSON-RPC router and asserts `medulla_local.status` returns a well-formed idle snapshot (running=false, actionable message) and `instruct` fails cleanly when no serve entry is configured — hermetic, no Node child or network. |
---
+11
View File
@@ -496,6 +496,16 @@ fn build_registered_controllers() -> Vec<GroupedController> {
DomainGroup::Platform,
crate::openhuman::javascript::all_javascript_registered_controllers(),
);
// Local Medulla brain (plan Flavor A, §3.1–§3.2): status/instruct against a
// supervised `medulla-serve` child. Registration-site gate, like `flows` —
// with the feature off the `medulla_local.*` methods are simply absent
// (unknown-method), not a runtime error.
#[cfg(feature = "medulla-local")]
push(
&mut controllers,
DomainGroup::Agent,
crate::openhuman::medulla_local::all_medulla_local_registered_controllers(),
);
// Discovered SKILL.md skills and their bundled resources
push(
&mut controllers,
@@ -931,6 +941,7 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"inference" => Some("Connect to configured text, vision, and embedding inference runtimes."),
"migrate" => Some("Data migration utilities."),
"javascript" => Some("First-class JavaScript runtime bridge for listing and dispatching tools."),
"medulla_local" => Some("Supervised local medulla-serve brain: status of the child and instruct enqueue (Flavor A draft)."),
"monitor" => Some("Start, inspect, read, and stop bounded background command monitors."),
"screen_intelligence" => Some("Screen capture, permissions, and accessibility automation."),
"security" => Some("Security policy and autonomy guardrail metadata."),
+2
View File
@@ -9,6 +9,8 @@ pub use cloud_providers::{
generate_provider_id, is_slug_reserved, migrate_legacy_fields, AuthStyle, CloudProviderCreds,
CloudProviderType,
};
pub mod subconscious;
pub use subconscious::{MedullaLocalConfig, SubconsciousConfig, SubconsciousEngine};
mod accessibility;
mod agent;
mod autocomplete;
+270
View File
@@ -0,0 +1,270 @@
//! Subconscious engine selection (plan §5.2 — the openhuman subconscious
//! replacement draft).
//!
//! `subconscious.engine` chooses which cognition drives the heartbeat tick's
//! observe/reflect/commit cycle:
//!
//! * `local` (default) — the existing local tinyagents graph. Unchanged.
//! * `medulla` — route each tick through a supervised local `medulla-serve`
//! child via `openhuman::medulla_local`. Draft; only wired when the crate is
//! built with the `medulla-local` feature.
//!
//! The default is `local`, so a config that omits the `[subconscious]` block —
//! every config today — behaves exactly as before.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Which engine runs the subconscious reflect/commit cognition.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum SubconsciousEngine {
/// The local tinyagents subconscious graph (unchanged default).
#[default]
Local,
/// Route ticks through a local `medulla-serve` child (draft).
Medulla,
}
impl SubconsciousEngine {
/// Whether ticks should route through the local medulla brain.
pub fn is_medulla(self) -> bool {
matches!(self, Self::Medulla)
}
}
/// Settings for the supervised local `medulla-serve` child.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct MedullaLocalConfig {
/// Path to medulla-v1's built serve entry (`dist/serve/index.js`). Empty
/// falls back to the `OPENHUMAN_MEDULLA_SERVE_ENTRY` environment override;
/// with neither set the medulla engine reports its serve entry as
/// unconfigured (see [`Self::resolved_serve_entry`]).
#[serde(default)]
pub serve_entry: String,
/// Overall deadline, in seconds, for one serve request (from writing the
/// `req` to receiving its correlated `res`), regardless of interleaved
/// frame traffic. Distinct from the per-read idle timeout: a child that
/// keeps streaming frames without ever answering is bounded by this
/// ceiling. `0` falls back to the default
/// ([`DEFAULT_REQUEST_DEADLINE_SECS`], 300).
#[serde(default = "default_request_deadline_secs")]
pub request_deadline_secs: u64,
}
impl Default for MedullaLocalConfig {
fn default() -> Self {
Self {
serve_entry: String::new(),
request_deadline_secs: DEFAULT_REQUEST_DEADLINE_SECS,
}
}
}
/// Default overall per-request deadline for serve requests, in seconds.
pub const DEFAULT_REQUEST_DEADLINE_SECS: u64 = 300;
/// Ceiling for [`MedullaLocalConfig::request_deadline_secs`]: 24 hours. Larger
/// values add no practical headroom and a near-`u64::MAX` duration would panic
/// in `Instant + Duration` arithmetic on the request path.
pub const MAX_REQUEST_DEADLINE_SECS: u64 = 24 * 60 * 60;
fn default_request_deadline_secs() -> u64 {
DEFAULT_REQUEST_DEADLINE_SECS
}
/// Environment override for the serve entry when `serve_entry` is left unset.
///
/// There is no portable compiled-in default: medulla-v1's built `dist/serve`
/// lives outside this repo, so its location is deployment-specific. A developer
/// pointing at their umbrella checkout sets this env var (or the config field)
/// rather than relying on a machine-local path baked into the binary.
const SERVE_ENTRY_ENV: &str = "OPENHUMAN_MEDULLA_SERVE_ENTRY";
impl MedullaLocalConfig {
/// The resolved serve entry, or `None` when it is unconfigured.
///
/// Precedence: the explicit `serve_entry` config value, then the
/// `OPENHUMAN_MEDULLA_SERVE_ENTRY` environment override. When neither is
/// set this returns `None` — the medulla engine then reports the serve
/// entry as unconfigured instead of pointing at a machine-local path.
pub fn resolved_serve_entry(&self) -> Option<std::path::PathBuf> {
Self::resolve_entry(&self.serve_entry, std::env::var(SERVE_ENTRY_ENV).ok())
}
/// The effective overall per-request deadline. A configured `0` (an
/// explicit zero would disable the ceiling entirely — never wanted) falls
/// back to the default.
pub fn request_deadline(&self) -> std::time::Duration {
let secs = if self.request_deadline_secs == 0 {
tracing::warn!(
configured = self.request_deadline_secs,
effective_secs = DEFAULT_REQUEST_DEADLINE_SECS,
"medulla_local request_deadline_secs=0 would disable the request ceiling; using the default"
);
DEFAULT_REQUEST_DEADLINE_SECS
} else if self.request_deadline_secs > MAX_REQUEST_DEADLINE_SECS {
tracing::warn!(
configured = self.request_deadline_secs,
effective_secs = MAX_REQUEST_DEADLINE_SECS,
"medulla_local request_deadline_secs exceeds the 24h ceiling; clamping"
);
MAX_REQUEST_DEADLINE_SECS
} else {
self.request_deadline_secs
};
std::time::Duration::from_secs(secs)
}
/// Pure resolver shared by [`Self::resolved_serve_entry`], factored out so
/// the precedence rules are testable without mutating process env.
fn resolve_entry(configured: &str, env_override: Option<String>) -> Option<std::path::PathBuf> {
let trimmed = configured.trim();
if !trimmed.is_empty() {
return Some(std::path::PathBuf::from(trimmed));
}
let env = env_override?;
let env = env.trim();
if env.is_empty() {
None
} else {
Some(std::path::PathBuf::from(env))
}
}
}
/// The `[subconscious]` config block.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct SubconsciousConfig {
/// Which engine drives the subconscious tick. Default `local`.
#[serde(default)]
pub engine: SubconsciousEngine,
/// Local `medulla-serve` child settings (only used when `engine = medulla`).
#[serde(default)]
pub medulla_local: MedullaLocalConfig,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_engine_is_local() {
assert_eq!(
SubconsciousConfig::default().engine,
SubconsciousEngine::Local
);
assert!(!SubconsciousConfig::default().engine.is_medulla());
}
#[test]
fn missing_block_deserializes_to_local() {
let config: SubconsciousConfig = serde_json::from_str("{}").unwrap();
assert_eq!(config.engine, SubconsciousEngine::Local);
}
#[test]
fn engine_serde_round_trip() {
assert_eq!(
serde_json::to_string(&SubconsciousEngine::Medulla).unwrap(),
r#""medulla""#
);
assert_eq!(
serde_json::from_str::<SubconsciousEngine>(r#""local""#).unwrap(),
SubconsciousEngine::Local
);
}
#[test]
fn explicit_serve_entry_wins_over_env() {
// An explicit config value is used verbatim, ignoring any env override.
assert_eq!(
MedullaLocalConfig::resolve_entry("/tmp/serve.js", Some("/env/serve.js".to_string())),
Some(std::path::PathBuf::from("/tmp/serve.js"))
);
// Surrounding whitespace is trimmed.
assert_eq!(
MedullaLocalConfig::resolve_entry(" /tmp/serve.js ", None),
Some(std::path::PathBuf::from("/tmp/serve.js"))
);
}
#[test]
fn serve_entry_falls_back_to_env_override() {
assert_eq!(
MedullaLocalConfig::resolve_entry("", Some("/env/serve.js".to_string())),
Some(std::path::PathBuf::from("/env/serve.js"))
);
assert_eq!(
MedullaLocalConfig::resolve_entry(" ", Some(" /env/serve.js ".to_string())),
Some(std::path::PathBuf::from("/env/serve.js"))
);
}
#[test]
fn serve_entry_unconfigured_resolves_to_none() {
// No machine-local default is baked in: unset config + unset (or blank)
// env resolves to None so the engine reports it unconfigured.
assert_eq!(MedullaLocalConfig::resolve_entry("", None), None);
assert_eq!(
MedullaLocalConfig::resolve_entry("", Some(" ".to_string())),
None
);
}
#[test]
fn request_deadline_defaults_and_zero_falls_back() {
// Both construction paths — `Default` and a config that omits the
// field — land on the same documented default.
assert_eq!(
MedullaLocalConfig::default().request_deadline_secs,
DEFAULT_REQUEST_DEADLINE_SECS
);
let omitted: MedullaLocalConfig = serde_json::from_str("{}").unwrap();
assert_eq!(omitted.request_deadline_secs, DEFAULT_REQUEST_DEADLINE_SECS);
// A configured value is honoured…
let configured: MedullaLocalConfig =
serde_json::from_str(r#"{ "request_deadline_secs": 120 }"#).unwrap();
assert_eq!(
configured.request_deadline(),
std::time::Duration::from_secs(120)
);
// …and an explicit zero (which would disable the ceiling) falls back
// to the default instead.
let zeroed: MedullaLocalConfig =
serde_json::from_str(r#"{ "request_deadline_secs": 0 }"#).unwrap();
assert_eq!(
zeroed.request_deadline(),
std::time::Duration::from_secs(DEFAULT_REQUEST_DEADLINE_SECS)
);
}
#[test]
fn request_deadline_clamps_oversized_values_to_the_ceiling() {
// A near-u64::MAX duration would panic in `Instant + Duration`
// arithmetic on the request path; the accessor clamps instead.
let oversized: MedullaLocalConfig = serde_json::from_str(&format!(
r#"{{ "request_deadline_secs": {} }}"#,
u64::MAX - 1
))
.unwrap();
assert_eq!(
oversized.request_deadline(),
std::time::Duration::from_secs(MAX_REQUEST_DEADLINE_SECS)
);
// The boundary value itself is accepted un-clamped.
let at_max: MedullaLocalConfig = serde_json::from_str(&format!(
r#"{{ "request_deadline_secs": {} }}"#,
MAX_REQUEST_DEADLINE_SECS
))
.unwrap();
assert_eq!(
at_max.request_deadline(),
std::time::Duration::from_secs(MAX_REQUEST_DEADLINE_SECS)
);
}
}
+7
View File
@@ -230,6 +230,12 @@ pub struct Config {
#[serde(default)]
pub heartbeat: HeartbeatConfig,
/// Subconscious engine selection (local tinyagents graph vs. local
/// medulla-serve child). Default `local` — omitting this block preserves
/// the historical behavior exactly.
#[serde(default)]
pub subconscious: crate::openhuman::config::schema::SubconsciousConfig,
#[serde(default)]
pub cron: CronConfig,
@@ -764,6 +770,7 @@ impl Default for Config {
model_routes: Vec::new(),
embedding_routes: Vec::new(),
heartbeat: HeartbeatConfig::default(),
subconscious: crate::openhuman::config::schema::SubconsciousConfig::default(),
cron: CronConfig::default(),
task_sources: TaskSourcesConfig::default(),
channels_config: ChannelsConfig::default(),
+221
View File
@@ -0,0 +1,221 @@
//! The concrete openhuman implementation of the serve [`HostPorts`] surface
//! (plan §3.2 — "the substance of the flavor").
//!
//! * `inference` routes onto the existing per-role provider factory: the serve
//! tier (`orchestrator`/`reasoning`/`compress`) maps to an openhuman model
//! role via [`role_for_model_tier`]/[`provider_for_role`], the same routing
//! BYOK / local / managed-backend all already flow through.
//! * `tools` exposes a SMALL curated allowlist of **read-only** tools drawn
//! from the full local surface (`runtime_node::ops::build_runtime_tools`,
//! itself `tools::ops::all_tools_with_runtime`). Every result passes through
//! unchanged; write/execute tools are refused for this draft.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{json, Value};
use tracing::{debug, warn};
use super::ports::{HostPorts, PortError};
use super::types::{InferenceCall, InferenceResult, ToolSpec, Usage};
use crate::openhuman::config::Config;
use crate::openhuman::inference::provider::{
create_chat_model_with_model_id, provider_for_role, role_for_model_tier,
};
use crate::openhuman::tools::PermissionLevel;
use tinyagents::harness::message::Message;
use tinyagents::harness::model::ModelRequest;
/// The curated read-only tool names serve may invoke this draft. The port also
/// enforces `PermissionLevel::ReadOnly` at call time, so an allowlisted name
/// that ever gained a write action is still refused.
const CURATED_READ_ONLY_TOOLS: &[&str] = &[
"file_read",
"read_diff",
"list",
"glob",
"grep",
"web_fetch",
];
/// Adapter holding the config snapshot the port routing needs.
pub struct OpenhumanHostPorts {
config: Arc<Config>,
}
impl OpenhumanHostPorts {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
/// Map a serve inference tier onto an openhuman model role.
///
/// `orchestrator` → the agentic (tool-using) role, `reasoning` → the
/// reasoning role, `compress` → the summarization role; anything else
/// falls back through `role_for_model_tier`'s own default (chat).
fn role_for_tier(tier: &str) -> &'static str {
match tier {
"orchestrator" => "agentic",
"reasoning" => "reasoning",
"compress" => "summarization",
other => role_for_model_tier(other),
}
}
/// The curated read-only tools drawn from the full local surface: the
/// intersection of [`CURATED_READ_ONLY_TOOLS`] with the built runtime tools,
/// filtered again on `PermissionLevel::ReadOnly` so an allowlisted name that
/// ever gained a write action is dropped. Both the `hello` advertisement
/// ([`Self::tool_specs`]) and the `tools.invoke` handler
/// ([`Self::invoke_tool`]) resolve tools through this one path, keeping the
/// advertised set and the invocable set identical.
fn curated_read_only_tools(
&self,
) -> Result<Vec<Box<dyn crate::openhuman::tools::Tool>>, String> {
let tools = crate::openhuman::runtime_node::ops::build_runtime_tools(&self.config)?;
Ok(tools
.into_iter()
.filter(|tool| CURATED_READ_ONLY_TOOLS.contains(&tool.name()))
.filter(|tool| tool.permission_level() == PermissionLevel::ReadOnly)
.collect())
}
}
#[async_trait]
impl HostPorts for OpenhumanHostPorts {
fn tool_specs(&self) -> Vec<ToolSpec> {
let tools = match self.curated_read_only_tools() {
Ok(tools) => tools,
Err(error) => {
warn!(
"[medulla_local] building tool surface for hello advertisement failed: {error}"
);
return Vec::new();
}
};
let specs: Vec<ToolSpec> = tools
.iter()
.map(|tool| ToolSpec {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.collect();
debug!(
count = specs.len(),
"[medulla_local] advertising curated read-only tools in hello"
);
specs
}
async fn invoke_inference(&self, call: InferenceCall) -> Result<InferenceResult, PortError> {
let role = Self::role_for_tier(&call.tier);
debug!(
tier = %call.tier,
role,
provider = %provider_for_role(role, &self.config),
messages = call.messages.len(),
"[medulla_local] inference callback routing"
);
let (model, model_id) =
create_chat_model_with_model_id(role, &self.config, 0.2).map_err(|error| {
PortError::internal(format!("model init for role `{role}`: {error}"))
})?;
let messages: Vec<Message> = call
.messages
.iter()
.map(|message| match message.role.as_str() {
"system" => Message::system(message.content.clone()),
"assistant" => Message::assistant(message.content.clone()),
_ => Message::user(message.content.clone()),
})
.collect();
let response = model
.invoke(&(), ModelRequest::new(messages).with_temperature(0.2))
.await
.map_err(|error| PortError::internal(format!("inference invoke failed: {error}")))?;
Ok(InferenceResult {
content: response.text(),
reasoning_content: None,
model: model_id,
tool_calls: Vec::new(),
usage: Usage::default(),
})
}
async fn invoke_tool(&self, name: &str, args: Value) -> Result<Value, PortError> {
if !CURATED_READ_ONLY_TOOLS.contains(&name) {
return Err(PortError::port_unavailable(format!(
"tool `{name}` is not on the curated read-only allowlist (draft)"
)));
}
// Resolve through the same curated (allowlisted + read-only) surface the
// `hello` advertisement is built from, so a tool the model was told about
// is exactly a tool this handler will run. The `curated_read_only_tools`
// filter already drops anything not read-only; the explicit re-check
// below is defence in depth against future drift.
let tools = self
.curated_read_only_tools()
.map_err(|error| PortError::internal(format!("building tool surface: {error}")))?;
let tool = tools
.into_iter()
.find(|tool| tool.name() == name)
.ok_or_else(|| {
PortError::port_unavailable(format!("tool `{name}` not present in local surface"))
})?;
// Defence in depth: refuse anything that is not read-only even if the
// allowlist and the tool registry ever drift.
if tool.permission_level() != PermissionLevel::ReadOnly {
warn!(
name,
"[medulla_local] refusing non-read-only tool over medulla port"
);
return Err(PortError::port_unavailable(format!(
"tool `{name}` is not read-only; refused for this draft"
)));
}
let result = tool
.execute(args)
.await
.map_err(|error| PortError::internal(format!("tool `{name}` failed: {error}")))?;
// Pass the ToolResult through unchanged, adapting only the error-flag
// key to the medulla wire shape (`isError`, §5.2).
Ok(json!({
"content": serde_json::to_value(&result.content).unwrap_or_else(|_| json!([])),
"isError": result.is_error,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tier_maps_onto_expected_roles() {
assert_eq!(OpenhumanHostPorts::role_for_tier("orchestrator"), "agentic");
assert_eq!(OpenhumanHostPorts::role_for_tier("reasoning"), "reasoning");
assert_eq!(
OpenhumanHostPorts::role_for_tier("compress"),
"summarization"
);
// Unknown tiers fall back through the factory's own default.
assert_eq!(OpenhumanHostPorts::role_for_tier("mystery"), "chat");
}
#[test]
fn curated_allowlist_is_read_only_by_name() {
// Sanity: the curated set never contains an obviously-mutating tool.
for forbidden in ["file_write", "edit", "apply_patch", "git_operations"] {
assert!(!CURATED_READ_ONLY_TOOLS.contains(&forbidden));
}
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Local Medulla brain — supervises a `medulla-serve` Node child and speaks the
//! medulla-serve NDJSON protocol, v1, as the host.
//!
//! This is Flavor A of the Medulla flavors plan (§3.1–§3.2): openhuman runs
//! medulla-v1's agent-harness facade in a supervised child and answers its port
//! callbacks against the local capability surface (inference routing, curated
//! read-only tools). It also backs the subconscious-replacement draft (§5.2):
//! with `subconscious.engine = "medulla"`, each heartbeat tick routes its
//! observe/reflect/commit cycle through one `instruct` instead of the local
//! tinyagents graph. The default (`local`) is untouched.
//!
//! Module shape (canonical `mod/types/…/ops/schemas`):
//! * [`protocol`] — wire frame envelopes (`ready`/`req`/`res`/`call`/`ret`/`event`).
//! * [`types`] — handshake / instruct / status / inference domain types.
//! * [`ports`] — the [`HostPorts`] seam serve's reverse-RPC drives.
//! * [`server`] — the supervisor: spawn, handshake, id-correlated NDJSON,
//! restart-and-retry-once, stderr drain (mirrors
//! `runtime_python_server/server.rs`). Unix-only — the
//! transport is a unix domain socket; non-unix targets get
//! a stub that reports the platform as unsupported.
//! * [`host_ports`] — the concrete openhuman [`HostPorts`] (inference + tools).
//! * [`ops`] — RPC handlers + the subconscious tick entrypoint.
//! * [`schemas`] — the `medulla_local` controller schemas.
pub mod host_ports;
pub mod ops;
pub mod ports;
pub mod protocol;
pub mod schemas;
#[cfg(unix)]
pub mod server;
/// The serve transport is a unix domain socket, which `tokio::net` only
/// provides on unix targets. On other targets (Windows) the feature still
/// compiles: this stub keeps the same supervisor surface but every entry
/// point reports a typed unsupported-platform error. A portable transport
/// (e.g. stdio) can lift this later without touching the callers.
#[cfg(not(unix))]
#[path = "server_unsupported.rs"]
pub mod server;
pub mod types;
pub use ports::{HostPorts, PortError};
pub use schemas::{
all_controller_schemas as all_medulla_local_controller_schemas,
all_registered_controllers as all_medulla_local_registered_controllers,
};
pub use server::{ensure_started, MedullaSupervisor};
pub use types::{InferenceCall, InferenceResult, InstructReceipt, MedullaLocalStatus};
+86
View File
@@ -0,0 +1,86 @@
//! Business logic + RPC handlers for the `medulla_local` namespace.
//!
//! Thin surface over [`super::server`]: `status` reports the supervised serve
//! child's handshake state; `instruct` enqueues one instruction and returns the
//! synchronous receipt (§4.1). The subconscious tick path (§5.2) calls
//! [`instruct_tick`] directly rather than over RPC.
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::warn;
use super::server;
use super::types::{InstructReceipt, MedullaLocalStatus};
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::config::Config;
use crate::rpc::RpcOutcome;
#[derive(Debug, Deserialize)]
pub(crate) struct InstructParams {
pub(crate) message: String,
#[serde(default)]
pub(crate) meta: Value,
}
/// Enqueue one instruction against the supervised serve child.
pub async fn instruct_tick(
config: &Config,
message: &str,
meta: Value,
) -> anyhow::Result<InstructReceipt> {
let supervisor = server::ensure_started(config).await?;
supervisor.instruct(message, meta).await
}
/// Snapshot the supervised serve child's status without forcing a spawn on the
/// failure path (a failed `ensure_started` still yields a well-formed
/// unavailable status).
pub async fn status(config: &Config) -> MedullaLocalStatus {
match server::ensure_started(config).await {
Ok(supervisor) => supervisor.snapshot().await,
Err(error) => {
warn!("[medulla_local] medulla_local.status: serve unavailable: {error:#}");
MedullaLocalStatus {
enabled: true,
running: false,
serve_version: None,
session_id: None,
ports: Vec::new(),
message: Some(error.to_string()),
}
}
}
}
pub(crate) async fn status_handler() -> Result<Value, String> {
let config = config_rpc::load_config_with_timeout().await?;
let status = status(&config).await;
let payload = json!(status);
let log = vec![format!(
"medulla_local.status: running={} ports={}",
status.running,
status.ports.len()
)];
RpcOutcome::new(payload, log).into_cli_compatible_json()
}
pub(crate) async fn instruct_handler(params: InstructParams) -> Result<Value, String> {
let config = config_rpc::load_config_with_timeout().await?;
let meta = if params.meta.is_null() {
json!({ "origin": "rpc" })
} else {
params.meta
};
let receipt = instruct_tick(&config, &params.message, meta)
.await
.map_err(|error| {
warn!("[medulla_local] medulla_local.instruct failed: {error:#}");
format!("{error:#}")
})?;
let payload = json!(receipt);
let log = vec![format!(
"medulla_local.instruct: instruction_id={} cycle_id={}",
receipt.instruction_id, receipt.cycle_id
)];
RpcOutcome::new(payload, log).into_cli_compatible_json()
}
+82
View File
@@ -0,0 +1,82 @@
//! The host-side port surface a supervised serve child drives via reverse RPC.
//!
//! serve issues `call` frames for the ports it needs (§5); the supervisor
//! demultiplexes them and dispatches to a [`HostPorts`] implementation, then
//! writes the `ret`. This trait is the clean seam between the transport
//! (`server.rs`) and the openhuman capability surface (`host_ports.rs`): the
//! supervisor and its tests are generic over `Arc<dyn HostPorts>`.
//!
//! For this DRAFT milestone only two ports are answered — `inference` and
//! `tools` — matching plan §3.2's "the substance of the flavor". Every other
//! port serve might request is refused as `port_unavailable`, handled centrally
//! in `server.rs` so an implementer cannot forget to reject one.
use async_trait::async_trait;
use serde_json::Value;
use super::protocol::ServeError;
use super::types::{error_codes, InferenceCall, InferenceResult, ToolSpec};
/// A failure answering a port callback. Serialized into a `ret` error
/// envelope (§8) with one of the reserved [`error_codes`].
#[derive(Debug, Clone)]
pub struct PortError {
pub code: &'static str,
pub message: String,
}
impl PortError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// The host does not offer this port (or the tool/method is not on the
/// curated allowlist for this draft).
pub fn port_unavailable(message: impl Into<String>) -> Self {
Self::new(error_codes::PORT_UNAVAILABLE, message)
}
/// The port is offered but the specific method is not implemented.
pub fn unsupported_method(message: impl Into<String>) -> Self {
Self::new(error_codes::UNSUPPORTED_METHOD, message)
}
/// A host-side failure while servicing an otherwise-valid callback.
pub fn internal(message: impl Into<String>) -> Self {
Self::new(error_codes::INTERNAL, message)
}
pub fn to_serve_error(&self) -> ServeError {
ServeError::new(self.code, self.message.clone())
}
}
/// The subset of medulla-v1 ports this host answers.
///
/// The wire ToolResult (`{content, isError}`, §5.2) is returned as a raw
/// [`Value`] so the concrete adapter can pass a tool's result through
/// **unchanged** (the draft's contract) without a lossy re-typing.
#[async_trait]
pub trait HostPorts: Send + Sync {
/// The host tool specs advertised in the `hello` handshake (§3), which serve
/// binds into the serve-side module registry so the model can see and emit
/// `tools.invoke` calls for them.
///
/// This MUST correspond to the set [`Self::invoke_tool`] will actually
/// answer — an advertised tool the port later refuses is a phantom the model
/// wastes a turn on, and a tool the port answers but never advertises can
/// never be invoked at all. Both derive from the same curated allowlist in
/// the concrete adapter to keep them in lock-step.
fn tool_specs(&self) -> Vec<ToolSpec>;
/// `inference.invoke` (§5.1) — route the call's tier onto the host's
/// per-role model routing and return an `InferenceResult`.
async fn invoke_inference(&self, call: InferenceCall) -> Result<InferenceResult, PortError>;
/// `tools.invoke` (§5.2) — execute a host tool by name against the curated
/// read-only allowlist and return its wire `ToolResult` unchanged.
async fn invoke_tool(&self, name: &str, args: Value) -> Result<Value, PortError>;
}
+183
View File
@@ -0,0 +1,183 @@
//! Wire-level frame envelopes for the `medulla-serve` NDJSON protocol.
//!
//! The contract follows the medulla-serve NDJSON protocol, v1 (plan §2.2).
//! `serve` is the Node child wrapping medulla-v1's agent-harness facade;
//! `host` is this Rust supervisor. Six frame kinds cross three flows:
//!
//! | `t` | direction | flow |
//! | ------- | ----------- | --------------------------------------- |
//! | `ready` | serve→host | handshake banner (unprompted, first) |
//! | `req` | host→serve | request |
//! | `res` | serve→host | response (correlated by `id`) |
//! | `call` | serve→host | port callback (reverse RPC into host) |
//! | `ret` | host→serve | port-callback return (correlated `id`) |
//! | `emit` | host→serve | streaming tap for an in-flight `call` |
//! | `event` | serve→host | unsolicited event stream (no `id`) |
//!
//! Frames are read as untyped [`serde_json::Value`] and dispatched on the `t`
//! discriminant (see [`FrameKind`]) so an unknown/extra frame is skipped and
//! logged rather than treated as fatal — mirroring
//! `runtime_python_server/server.rs`'s "unparseable response skipped".
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// Wire version the host speaks. The `ready` banner MUST carry the same value
/// or the host bails (§3, §7 handshake mismatch).
pub const PROTOCOL_VERSION: u32 = 1;
/// The `t` discriminant on an inbound (serve→host) frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameKind {
Ready,
Res,
Call,
Event,
/// Any `t` value the host does not expect inbound (`req`/`ret`/`emit` are
/// host→serve). Skipped and logged.
Unknown,
}
impl FrameKind {
/// Classify a decoded frame by its `t` field.
pub fn of(frame: &Value) -> Self {
match frame.get("t").and_then(Value::as_str) {
Some("ready") => Self::Ready,
Some("res") => Self::Res,
Some("call") => Self::Call,
Some("event") => Self::Event,
_ => Self::Unknown,
}
}
}
/// The unprompted first line serve writes on connect (§3). `error` non-null ⇒
/// startup failed and the host treats the child as unavailable.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadyLine {
#[serde(default)]
pub protocol: u32,
#[serde(default)]
pub serve: Option<String>,
#[serde(default, rename = "sessionId")]
pub session_id: Option<String>,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default)]
pub error: Option<String>,
}
/// The always-`{code,message}` error envelope shared by `res` and `ret`
/// (§8). Mirrors the `PythonServerError` shape.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServeError {
pub code: String,
pub message: String,
}
impl ServeError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
/// A decoded `res` frame (serve→host response, correlated by `id`).
#[derive(Debug, Clone, Deserialize)]
pub struct ResFrame {
pub id: Option<String>,
#[serde(default)]
pub ok: bool,
#[serde(default)]
pub result: Option<Value>,
#[serde(default)]
pub error: Option<ServeError>,
}
/// A decoded `call` frame (serve→host reverse-RPC into a host port).
#[derive(Debug, Clone, Deserialize)]
pub struct CallFrame {
pub id: String,
pub port: String,
pub method: String,
#[serde(default)]
pub params: Value,
}
/// A decoded `event` frame (serve→host unsolicited stream, §6).
#[derive(Debug, Clone, Deserialize)]
pub struct EventFrame {
#[serde(default)]
pub seq: u64,
#[serde(default)]
pub at: u64,
#[serde(default)]
pub event: Value,
}
/// Build a host→serve `req` frame line body (§4).
pub fn req_frame(id: &str, op: &str, params: Value) -> Value {
json!({ "t": "req", "id": id, "op": op, "params": params })
}
/// Build a host→serve `ret` frame answering a port `call` with success (§5).
pub fn ret_ok(id: &str, result: Value) -> Value {
json!({ "t": "ret", "id": id, "ok": true, "result": result })
}
/// Build a host→serve `ret` frame answering a port `call` with failure (§5, §8).
pub fn ret_err(id: &str, error: &ServeError) -> Value {
json!({ "t": "ret", "id": id, "ok": false, "error": { "code": error.code, "message": error.message } })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ready_line_parses_session_and_capabilities() {
let ready: ReadyLine = serde_json::from_str(
r#"{"t":"ready","protocol":1,"serve":"3.12.0","sessionId":"agent","capabilities":["inference","tools"],"error":null}"#,
)
.unwrap();
assert_eq!(ready.protocol, PROTOCOL_VERSION);
assert_eq!(ready.session_id.as_deref(), Some("agent"));
assert_eq!(ready.capabilities, vec!["inference", "tools"]);
assert!(ready.error.is_none());
}
#[test]
fn frame_kind_classifies_by_t() {
assert_eq!(
FrameKind::of(&json!({"t":"call","id":"c1"})),
FrameKind::Call
);
assert_eq!(FrameKind::of(&json!({"t":"event"})), FrameKind::Event);
assert_eq!(FrameKind::of(&json!({"t":"nope"})), FrameKind::Unknown);
assert_eq!(FrameKind::of(&json!({})), FrameKind::Unknown);
}
#[test]
fn res_error_envelope_parses() {
let res: ResFrame = serde_json::from_str(
r#"{"t":"res","id":"7","ok":false,"error":{"code":"bad_request","message":"missing message"}}"#,
)
.unwrap();
assert!(!res.ok);
assert_eq!(res.id.as_deref(), Some("7"));
assert_eq!(res.error.unwrap().code, "bad_request");
}
#[test]
fn ret_frames_carry_correct_discriminant() {
let ok = ret_ok("c1", json!({"content": []}));
assert_eq!(ok["t"], "ret");
assert_eq!(ok["ok"], true);
let err = ret_err("c2", &ServeError::new("port_unavailable", "no memory port"));
assert_eq!(err["t"], "ret");
assert_eq!(err["ok"], false);
assert_eq!(err["error"]["code"], "port_unavailable");
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Controller schemas for the `medulla_local` RPC namespace.
//!
//! Registered in `src/core/all.rs` under `DomainGroup::Agent` behind the
//! `medulla-local` feature. Two methods this draft: `status` and `instruct`.
use serde_json::{Map, Value};
use tracing::warn;
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::medulla_local::ops::{instruct_handler, status_handler, InstructParams};
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("medulla_local_status"),
schemas("medulla_local_instruct"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("medulla_local_status"),
handler: handle_status,
},
RegisteredController {
schema: schemas("medulla_local_instruct"),
handler: handle_instruct,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"medulla_local_status" => ControllerSchema {
namespace: "medulla_local",
function: "status",
description: "Status of the supervised local medulla-serve child: whether it is connected, its serve version, session id, and negotiated port set.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "status",
ty: TypeSchema::Json,
comment: "MedullaLocalStatus: {enabled, running, serve_version?, session_id?, ports, message?}.",
required: true,
}],
},
"medulla_local_instruct" => ControllerSchema {
namespace: "medulla_local",
function: "instruct",
description: "Enqueue one instruction against the local medulla-serve harness. Returns the synchronous receipt; the cycle runs async and is observed via the event stream.",
inputs: vec![
FieldSchema {
name: "message",
ty: TypeSchema::String,
comment: "The instruction text for the harness cycle.",
required: true,
},
FieldSchema {
name: "meta",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "Optional instruction metadata (e.g. {origin: 'wake'}).",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "instructionId",
ty: TypeSchema::String,
comment: "Id of the enqueued instruction.",
required: true,
},
FieldSchema {
name: "cycleId",
ty: TypeSchema::String,
comment: "Id of the cycle the instruction will run in.",
required: true,
},
],
},
_ => ControllerSchema {
namespace: "medulla_local",
function: "unknown",
description: "Unknown medulla_local controller.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { status_handler().await })
}
fn handle_instruct(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let params: InstructParams =
serde_json::from_value(Value::Object(params)).map_err(|error| {
warn!("[medulla_local] medulla_local.instruct rejected malformed params: {error}");
error.to_string()
})?;
instruct_handler(params).await
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_lists_status_and_instruct() {
let schemas = all_controller_schemas();
assert_eq!(schemas.len(), 2);
let names: Vec<&str> = schemas.iter().map(|schema| schema.function).collect();
assert!(names.contains(&"status"));
assert!(names.contains(&"instruct"));
}
#[test]
fn instruct_schema_requires_message() {
let schema = schemas("medulla_local_instruct");
assert_eq!(schema.namespace, "medulla_local");
assert!(schema
.inputs
.iter()
.any(|field| field.name == "message" && field.required));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
//! Non-unix stub for the medulla-local supervisor surface.
//!
//! The local serve transport is a unix domain socket, which `tokio::net`
//! only provides on unix targets. On other targets (Windows) the
//! `medulla-local` feature still compiles: this stub exposes the same public
//! surface `ops.rs` drives, with every entry point reporting a typed
//! unsupported-platform error instead of breaking the build. A portable
//! transport (e.g. stdio) can replace this stub later without touching the
//! callers.
use std::sync::Arc;
use anyhow::Result;
use serde_json::Value;
use super::types::{HarnessStatus, InstructReceipt, MedullaLocalStatus};
use crate::openhuman::config::Config;
/// Typed marker for "this build's target has no unix-socket transport", so
/// callers can distinguish a platform limitation from a runtime failure.
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[error("medulla-local is unavailable on this platform: the local serve transport requires unix domain sockets")]
pub struct UnsupportedPlatformError;
/// Stub supervisor: never connectable on this platform. [`ensure_started`]
/// always fails first, so these methods exist only to keep the call sites in
/// `ops.rs` compiling identically on every target.
pub struct MedullaSupervisor;
impl MedullaSupervisor {
/// Always fails with [`UnsupportedPlatformError`].
pub async fn instruct(&self, _message: &str, _meta: Value) -> Result<InstructReceipt> {
Err(UnsupportedPlatformError.into())
}
/// Always fails with [`UnsupportedPlatformError`].
pub async fn harness_status(&self) -> Result<HarnessStatus> {
Err(UnsupportedPlatformError.into())
}
/// A well-formed not-running snapshot carrying the platform message.
pub async fn snapshot(&self) -> MedullaLocalStatus {
MedullaLocalStatus {
enabled: true,
running: false,
serve_version: None,
session_id: None,
ports: Vec::new(),
message: Some(UnsupportedPlatformError.to_string()),
}
}
}
/// Always reports the platform as unsupported (typed, downcastable): `status`
/// folds it into a well-formed not-running snapshot and `instruct` fails
/// cleanly, exactly like an unconfigured serve entry does on unix.
pub async fn ensure_started(_config: &Config) -> Result<Arc<MedullaSupervisor>> {
Err(UnsupportedPlatformError.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn ensure_started_reports_unsupported_platform() {
let error = ensure_started(&Config::default())
.await
.expect_err("non-unix targets must not report a live supervisor");
assert!(
error.downcast_ref::<UnsupportedPlatformError>().is_some(),
"error must be the typed platform marker: {error:#}"
);
}
#[tokio::test]
async fn stub_supervisor_snapshot_is_well_formed_and_not_running() {
let snapshot = MedullaSupervisor.snapshot().await;
assert!(snapshot.enabled);
assert!(!snapshot.running);
assert!(snapshot
.message
.as_deref()
.unwrap_or_default()
.contains("unavailable on this platform"));
}
}
+221
View File
@@ -0,0 +1,221 @@
//! Domain types for the `medulla_local` flow: the `hello` handshake, the
//! `instruct`/`status` request results, and the `inference` port-callback
//! request/response shapes (§3, §4, §5.1 of the serve protocol spec).
//!
//! These mirror the medulla-v1 types named in the spec exactly enough to
//! decode/encode the wire; fields the draft does not consume are kept
//! `#[serde(default)]` / optional so a serve version that adds fields never
//! breaks the host.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Reserved error codes (§8). A port callback the host cannot answer returns
/// one of these in a `ret` error envelope.
pub mod error_codes {
pub const BAD_REQUEST: &str = "bad_request";
pub const NOT_READY: &str = "not_ready";
pub const UNKNOWN_OP: &str = "unknown_op";
pub const UNKNOWN_PORT: &str = "unknown_port";
pub const UNSUPPORTED_METHOD: &str = "unsupported_method";
pub const PORT_UNAVAILABLE: &str = "port_unavailable";
pub const TIMEOUT: &str = "timeout";
pub const RETRYABLE: &str = "retryable";
pub const INTERNAL: &str = "internal";
}
/// A host tool spec advertised in the `hello` request (`ToolSpec`, §3). The
/// serve side binds these into its module registry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: Value,
}
/// The `hello` request params the host sends after receiving `ready` (§3).
/// `ports` is the set of port callbacks the host will answer; the active set
/// is the intersection with serve's advertised `capabilities`.
#[derive(Debug, Clone, Serialize)]
pub struct HelloParams {
pub protocol: u32,
pub host: String,
pub ports: Vec<String>,
pub tools: Vec<ToolSpec>,
}
/// The `hello` response (§3): the negotiated active port set.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct HelloResult {
#[serde(default)]
pub protocol: u32,
#[serde(default, rename = "sessionId")]
pub session_id: Option<String>,
#[serde(default)]
pub ports: Vec<String>,
}
/// Synchronous receipt for an `instruct` (§4.1). The cycle itself runs async
/// and is observed via `event` frames.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct InstructReceipt {
#[serde(rename = "instructionId")]
pub instruction_id: String,
#[serde(rename = "cycleId")]
pub cycle_id: String,
}
/// Token accounting shared by `status` and `inference` (§4.4, §5.1).
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct Usage {
#[serde(default, rename = "inputTokens")]
pub input_tokens: u64,
#[serde(default, rename = "outputTokens")]
pub output_tokens: u64,
}
/// Snapshot of `HarnessStatus` (§4.4). Unmodelled sub-shapes ride as raw
/// `Value` so a richer serve status never fails to decode.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct HarnessStatus {
#[serde(default)]
pub state: String,
#[serde(default)]
pub queued: u64,
#[serde(default, rename = "activeInstructionId")]
pub active_instruction_id: Option<String>,
#[serde(default, rename = "activeCycleId")]
pub active_cycle_id: Option<String>,
#[serde(default)]
pub tasks: Vec<Value>,
#[serde(default, rename = "runningDelegations")]
pub running_delegations: u64,
#[serde(default)]
pub usage: Value,
#[serde(default)]
pub escalations: Vec<Value>,
}
/// A single chat message crossing the `inference` port (§5.1).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WireChatMessage {
pub role: String,
#[serde(default)]
pub content: String,
}
/// The `inference.invoke` port-callback params (§5.1). `tier` is one of
/// `orchestrator` / `reasoning` / `compress`; the host maps it onto its
/// per-role model routing.
#[derive(Debug, Clone, Deserialize)]
pub struct InferenceCall {
#[serde(default)]
pub tier: String,
#[serde(default)]
pub op: String,
#[serde(default, rename = "cycleId")]
pub cycle_id: Option<String>,
#[serde(default)]
pub messages: Vec<WireChatMessage>,
#[serde(default)]
pub tools: Vec<Value>,
#[serde(default)]
pub meta: Value,
}
/// A tool call the model requested, returned inside [`InferenceResult`] (§5.1).
#[derive(Debug, Clone, Default, Serialize)]
pub struct WireToolCall {
pub id: String,
pub name: String,
pub args: Value,
}
/// The `InferenceResult` a host returns for an `inference.invoke` (§5.1).
#[derive(Debug, Clone, Default, Serialize)]
pub struct InferenceResult {
pub content: String,
#[serde(rename = "reasoningContent")]
pub reasoning_content: Option<String>,
pub model: String,
#[serde(rename = "toolCalls")]
pub tool_calls: Vec<WireToolCall>,
pub usage: Usage,
}
/// Status of the supervised serve child, surfaced over the `medulla_local`
/// RPC namespace.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MedullaLocalStatus {
/// Whether the `medulla-local` engine is selected/available.
pub enabled: bool,
/// Whether a serve child is currently connected.
pub running: bool,
/// serve version string from the `ready` banner, if connected.
pub serve_version: Option<String>,
/// Session id negotiated in the handshake, if connected.
pub session_id: Option<String>,
/// Active port set negotiated in `hello`.
pub ports: Vec<String>,
/// Last error, if the supervisor is in a failed/unavailable state.
pub message: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn instruct_receipt_round_trips_camel_case() {
let receipt: InstructReceipt = serde_json::from_str(
r#"{"instructionId":"inst-agent-0","cycleId":"cyc:agent:agent:0"}"#,
)
.unwrap();
assert_eq!(receipt.instruction_id, "inst-agent-0");
assert_eq!(receipt.cycle_id, "cyc:agent:agent:0");
}
#[test]
fn inference_call_decodes_tier_and_messages() {
let call: InferenceCall = serde_json::from_value(json!({
"tier": "orchestrator",
"op": "orchestrate",
"cycleId": "cyc:1",
"messages": [{"role": "user", "content": "hi"}],
"meta": {"priority": "root"}
}))
.unwrap();
assert_eq!(call.tier, "orchestrator");
assert_eq!(call.messages.len(), 1);
assert_eq!(call.messages[0].role, "user");
}
#[test]
fn inference_result_serializes_camel_case() {
let result = InferenceResult {
content: "done".into(),
reasoning_content: None,
model: "orchestrator-v1".into(),
tool_calls: vec![],
usage: Usage {
input_tokens: 9,
output_tokens: 3,
},
};
let value = serde_json::to_value(&result).unwrap();
assert_eq!(value["content"], "done");
assert_eq!(value["reasoningContent"], Value::Null);
assert_eq!(value["usage"]["inputTokens"], 9);
assert_eq!(value["toolCalls"], json!([]));
}
#[test]
fn harness_status_tolerates_sparse_payload() {
let status: HarnessStatus =
serde_json::from_str(r#"{"state":"running","queued":1}"#).unwrap();
assert_eq!(status.state, "running");
assert_eq!(status.queued, 1);
assert!(status.tasks.is_empty());
}
}
+2
View File
@@ -78,6 +78,8 @@ pub mod mcp_registry;
pub mod mcp_server;
#[cfg(feature = "media")]
pub mod media_generation;
#[cfg(feature = "medulla-local")]
pub mod medulla_local;
#[cfg(feature = "meet")]
pub mod meet;
pub mod meet_agent;
+1 -1
View File
@@ -75,7 +75,7 @@ fn command_class_for_tool(
}
}
fn build_runtime_tools(config: &Config) -> Result<Vec<Box<dyn Tool>>, String> {
pub fn build_runtime_tools(config: &Config) -> Result<Vec<Box<dyn Tool>>, String> {
debug!(
workspace = %config.workspace_dir.display(),
"[runtime_node::ops] build_runtime_tools: start"
+110
View File
@@ -252,6 +252,17 @@ impl SubconsciousInstance {
/// The tick body given a loaded config. Split out so tests can drive it with
/// a crafted `Config` without hitting disk (`Config::load_or_init`).
async fn run_tick(&self, config: Config) -> Result<TickResult> {
// Subconscious-replacement draft (plan §5.2): when
// `subconscious.engine = "medulla"`, route observe→reflect→commit
// through one local `medulla-serve` instruct instead of the local
// tinyagents graph. The default (`local`) falls through to the
// unchanged body below, so default behaviour is byte-identical — the
// branch compiles out entirely when the `medulla-local` feature is off.
#[cfg(feature = "medulla-local")]
if config.subconscious.engine.is_medulla() {
return self.run_tick_medulla(config).await;
}
let prefix = self.log_prefix();
let started = std::time::Instant::now();
let tick_at = now_secs();
@@ -338,6 +349,105 @@ impl SubconsciousInstance {
})
}
/// The medulla-engine tick body (plan §5.2 draft). Observes the world the
/// same way the local graph does, then — instead of the local reflect turn
/// — enqueues ONE `instruct` on the supervised `medulla-serve` child
/// summarising the tick context, and advances the baseline via the
/// profile's own `commit`. Quiet windows short-circuit exactly like the
/// local path (no instruct, still commit). The hosted cycle runs async and
/// is observed via the serve event stream; this method only awaits the
/// synchronous receipt.
#[cfg(feature = "medulla-local")]
async fn run_tick_medulla(&self, config: Config) -> Result<TickResult> {
use crate::openhuman::medulla_local::ops::instruct_tick;
use serde_json::json;
let prefix = self.log_prefix();
let started = std::time::Instant::now();
let tick_at = now_secs();
let my_generation = self.tick_generation.fetch_add(1, Ordering::SeqCst) + 1;
let observation = self.profile.observe(&config).await;
debug!(
"{prefix} medulla tick observe has_changes={} external={}",
observation.has_changes, observation.has_external_content
);
if !observation.has_changes {
// Superseded-generation discard (mirrors the local graph path):
// a newer tick owns the baseline now, so committing this stale
// observation would clobber it.
if self.tick_generation.load(Ordering::SeqCst) != my_generation {
info!("{prefix} medulla quiet tick superseded by newer tick, discarding");
let mut state = self.state.lock().await;
state.total_ticks += 1;
return Ok(self.quiet_result(tick_at, started));
}
// Quiet window: no billed cycle, but still advance the baseline so
// the next tick observes only genuinely-new content (mirrors the
// local observe→commit quiet edge).
self.profile.commit(&config, &observation).await;
let mut state = self.state.lock().await;
state.total_ticks += 1;
state.consecutive_failures = 0;
state.last_tick_at = tick_at;
persist_last_tick_at(&self.workspace_dir, self.profile.id(), tick_at);
return Ok(self.quiet_result(tick_at, started));
}
let message = format!(
"Subconscious wake for world `{}`. Reconcile the following changes:\n\n{}",
self.profile.id(),
observation.rendered
);
let meta = json!({
"origin": "wake",
"world": self.profile.id(),
"tickId": tick_at as u64,
});
let response_chars = match instruct_tick(&config, &message, meta).await {
Ok(receipt) => {
info!(
"{prefix} medulla instruct enqueued instruction_id={} cycle_id={}",
receipt.instruction_id, receipt.cycle_id
);
// Superseded-generation discard (mirrors the local graph
// path): the instruction is already enqueued serve-side, but a
// newer tick owns the baseline now, so this stale observation
// must not commit or advance last_tick_at.
if self.tick_generation.load(Ordering::SeqCst) != my_generation {
info!("{prefix} medulla tick superseded by newer tick, discarding");
let mut state = self.state.lock().await;
state.total_ticks += 1;
return Ok(self.quiet_result(tick_at, started));
}
// Baseline advances on a successful enqueue; the cycle itself is
// observed via events (a failed cycle does not re-observe here).
self.profile.commit(&config, &observation).await;
let mut state = self.state.lock().await;
state.total_ticks += 1;
state.consecutive_failures = 0;
state.last_tick_at = tick_at;
persist_last_tick_at(&self.workspace_dir, self.profile.id(), tick_at);
observation.rendered.chars().count()
}
Err(error) => {
warn!("{prefix} medulla instruct failed: {error:#}");
let mut state = self.state.lock().await;
state.consecutive_failures += 1;
state.total_ticks += 1;
0
}
};
Ok(TickResult {
tick_at,
duration_ms: started.elapsed().as_millis() as u64,
response_chars,
})
}
fn quiet_result(&self, tick_at: f64, started: std::time::Instant) -> TickResult {
TickResult {
tick_at,
@@ -24,6 +24,10 @@ struct FakeProfile {
/// newer tick superseding the in-flight one.
supersede: Mutex<Option<Arc<AtomicU64>>>,
supersede_pending: AtomicUsize,
/// Like `supersede_pending`, but fires during `observe` — the medulla
/// engine has no reflect stage, so supersession is injected at its only
/// pre-commit await point.
supersede_in_observe: AtomicUsize,
}
impl FakeProfile {
@@ -37,6 +41,7 @@ impl FakeProfile {
commit_calls: AtomicUsize::new(0),
supersede: Mutex::new(None),
supersede_pending: AtomicUsize::new(0),
supersede_in_observe: AtomicUsize::new(0),
}
}
@@ -69,6 +74,11 @@ impl SubconsciousProfile for FakeProfile {
}
async fn observe(&self, _config: &Config) -> Observation {
self.observe_calls.fetch_add(1, Ordering::SeqCst);
if self.supersede_in_observe.swap(0, Ordering::SeqCst) > 0 {
if let Some(gen) = self.supersede.lock().unwrap().as_ref() {
gen.fetch_add(1, Ordering::SeqCst);
}
}
let mut obs = self.observations.lock().unwrap();
if obs.len() > 1 {
obs.remove(0)
@@ -341,3 +351,113 @@ async fn superseded_tick_discards_result_and_skips_commit() {
"not counted a failure"
);
}
// ── Subconscious-replacement draft (plan §5.2): default path is untouched ────
/// The subconscious engine defaults to `local` when the `[subconscious]` block
/// (or its `engine` field) is unset. This test pins that a default-config tick
/// runs the LOCAL tinyagents graph — proven by `reflect` executing — rather
/// than the medulla instruct path (which observes → instructs → commits and
/// never calls `reflect`). It is the "byte-identical when the flag is unset"
/// guarantee for the draft: whether or not the `medulla-local` feature is
/// compiled in, an unset flag routes exactly as before.
#[tokio::test]
async fn default_engine_runs_local_graph_not_medulla() {
let dir = tempfile::tempdir().unwrap();
let profile = Arc::new(FakeProfile::new(
FakeProfile::changed(),
Ok(Reflection::Acted { response_chars: 7 }),
));
let instance = build(profile.clone(), dir.path());
let config = test_config(dir.path());
// Precondition: the flag is unset, so the engine is the default `local`.
assert!(
!config.subconscious.engine.is_medulla(),
"default config must select the local engine"
);
let result = instance.run_tick_for_test(config).await.unwrap();
// The local graph's reflect stage ran — the medulla path never calls it.
assert_eq!(
profile.reflect_calls.load(Ordering::SeqCst),
1,
"default engine must drive the local reflect graph"
);
assert_eq!(profile.commit_calls.load(Ordering::SeqCst), 1);
assert_eq!(result.response_chars, 7);
}
/// A medulla-engine config for the hermetic quiet-path tests: the quiet edge
/// commits the baseline without ever touching the serve supervisor, so no
/// child process or socket is involved.
#[cfg(feature = "medulla-local")]
fn medulla_config(workspace: &std::path::Path) -> Config {
use crate::openhuman::config::schema::SubconsciousEngine;
let mut cfg = test_config(workspace);
cfg.subconscious.engine = SubconsciousEngine::Medulla;
cfg
}
/// The medulla quiet edge mirrors the local one: no reflect, no instruct,
/// baseline committed and `last_tick_at` advanced.
#[cfg(feature = "medulla-local")]
#[tokio::test]
async fn medulla_quiet_tick_commits_and_advances() {
let dir = tempfile::tempdir().unwrap();
let profile = Arc::new(FakeProfile::new(FakeProfile::quiet(), Ok(Reflection::Idle)));
let instance = build(profile.clone(), dir.path());
let result = instance
.run_tick_for_test(medulla_config(dir.path()))
.await
.unwrap();
assert_eq!(profile.observe_calls.load(Ordering::SeqCst), 1);
assert_eq!(
profile.reflect_calls.load(Ordering::SeqCst),
0,
"the medulla engine never runs the local reflect graph"
);
assert_eq!(profile.commit_calls.load(Ordering::SeqCst), 1, "committed");
let status = instance.status().await;
assert!(status.last_tick_at.is_some());
assert_eq!(status.total_ticks, 1);
assert_eq!(result.response_chars, 0);
}
/// A superseded medulla tick must not commit its stale observation — the same
/// generation guard the local graph path applies before its commit.
#[cfg(feature = "medulla-local")]
#[tokio::test]
async fn medulla_superseded_tick_skips_commit() {
let dir = tempfile::tempdir().unwrap();
let profile = Arc::new(FakeProfile::new(FakeProfile::quiet(), Ok(Reflection::Idle)));
let instance = build(profile.clone(), dir.path());
// Wire the profile to bump the instance's generation during observe — the
// medulla path's only pre-commit await point.
*profile.supersede.lock().unwrap() = Some(instance.generation_handle());
profile.supersede_in_observe.store(1, Ordering::SeqCst);
let result = instance
.run_tick_for_test(medulla_config(dir.path()))
.await
.unwrap();
assert_eq!(profile.observe_calls.load(Ordering::SeqCst), 1);
assert_eq!(
profile.commit_calls.load(Ordering::SeqCst),
0,
"a superseded medulla tick must not commit"
);
assert_eq!(result.response_chars, 0);
let status = instance.status().await;
assert!(status.last_tick_at.is_none(), "no baseline advance");
assert_eq!(status.total_ticks, 1, "the discarded tick is still counted");
assert_eq!(
instance.snapshot_failures().await,
0,
"not counted a failure"
);
}
+352
View File
@@ -0,0 +1,352 @@
//! JSON-RPC E2E coverage for the `medulla_local` namespace (Flavor A draft).
//!
//! Boots the real Axum JSON-RPC router (same pattern as
//! `domain_modules_e2e.rs`) and exercises the `medulla_local` controllers
//! hermetically: no `medulla-serve` entry is configured, so `status` must
//! report a well-formed not-running snapshot without spawning Node or touching
//! the network, and `instruct` must fail cleanly with the actionable
//! "serve entry not configured" error. Run with:
//! `cargo test --test medulla_local_e2e`.
#![cfg(feature = "medulla-local")]
use std::net::SocketAddr;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use axum::http::header::AUTHORIZATION;
use reqwest::StatusCode;
use serde_json::{json, Value};
use tempfile::{tempdir, TempDir};
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
use openhuman_core::core::jsonrpc::build_core_http_router;
const TEST_RPC_TOKEN: &str = "medulla-local-e2e-token";
/// The startup-failure needle the supervisor must surface with no serve entry
/// configured: the actionable configuration error on unix, and the typed
/// unsupported-platform error on targets without unix domain sockets (where
/// the serve transport is stubbed out).
#[cfg(unix)]
const STARTUP_UNAVAILABLE_NEEDLE: &str = "serve entry not configured";
#[cfg(not(unix))]
const STARTUP_UNAVAILABLE_NEEDLE: &str = "unavailable on this platform";
static AUTH_INIT: OnceLock<()> = OnceLock::new();
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
struct EnvVarGuard {
key: &'static str,
old: Option<String>,
}
impl EnvVarGuard {
fn set_to_path(key: &'static str, path: &Path) -> Self {
let old = std::env::var(key).ok();
std::env::set_var(key, path.as_os_str());
Self { key, old }
}
fn set(key: &'static str, value: &str) -> Self {
let old = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, old }
}
fn unset(key: &'static str) -> Self {
let old = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, old }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.old {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
/// Serializes tests in this binary: `HOME` and the serve-entry env override are
/// process-global, as is the medulla supervisor cache.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
let mutex = ENV_LOCK.get_or_init(|| Mutex::new(()));
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn ensure_rpc_auth() {
AUTH_INIT.get_or_init(|| {
// SAFETY: guarded by OnceLock and set once before the router for this
// test binary is used concurrently.
unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
let token_dir = std::env::temp_dir().join("openhuman-medulla-local-e2e-auth");
init_rpc_token(&token_dir).expect("init rpc auth token");
});
}
async fn serve_rpc() -> (
SocketAddr,
tokio::task::JoinHandle<Result<(), std::io::Error>>,
) {
ensure_rpc_auth();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind rpc listener");
let addr = listener.local_addr().expect("rpc listener addr");
let router = build_core_http_router(false);
let join = tokio::spawn(async move { axum::serve(listener, router).await });
(addr, join)
}
fn write_min_config(openhuman_dir: &Path) {
std::fs::create_dir_all(openhuman_dir).expect("create .openhuman");
let cfg = r#"api_url = "http://127.0.0.1:9"
default_model = "e2e-model"
default_temperature = 0.2
[secrets]
encrypt = false
[local_ai]
enabled = false
[memory]
provider = "none"
embedding_provider = "none"
embedding_model = "none"
embedding_dimensions = 0
[memory_tree]
embedding_strict = false
"#;
std::fs::write(openhuman_dir.join("config.toml"), cfg).expect("write config.toml");
let _: openhuman_core::openhuman::config::Config =
toml::from_str(cfg).expect("test config must match schema");
}
struct TestHarness {
_tmp: TempDir,
_guards: Vec<EnvVarGuard>,
rpc_base: String,
join: tokio::task::JoinHandle<Result<(), std::io::Error>>,
}
async fn setup() -> TestHarness {
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
write_min_config(&openhuman_home);
let guards = vec![
EnvVarGuard::set_to_path("HOME", home),
EnvVarGuard::unset("OPENHUMAN_WORKSPACE"),
EnvVarGuard::unset("BACKEND_URL"),
EnvVarGuard::unset("VITE_BACKEND_URL"),
EnvVarGuard::unset("OPENHUMAN_API_URL"),
// The whole point of this suite: no serve entry anywhere, so the
// supervisor must fail its spawn attempt with the actionable
// configuration error instead of probing Node or the network.
EnvVarGuard::unset("OPENHUMAN_MEDULLA_SERVE_ENTRY"),
EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"),
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"),
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""),
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""),
];
let (addr, join) = serve_rpc().await;
TestHarness {
_tmp: tmp,
_guards: guards,
rpc_base: format!("http://{addr}"),
join,
}
}
async fn rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("client");
let url = format!("{}/rpc", rpc_base.trim_end_matches('/'));
let response = client
.post(&url)
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
.json(&json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}))
.send()
.await
.unwrap_or_else(|err| panic!("POST {url} {method}: {err}"));
assert_eq!(
response.status(),
StatusCode::OK,
"HTTP transport should accept {method}"
);
response
.json::<Value>()
.await
.unwrap_or_else(|err| panic!("json for {method}: {err}"))
}
fn ok<'a>(value: &'a Value, context: &str) -> &'a Value {
if let Some(error) = value.get("error") {
panic!("{context}: unexpected JSON-RPC error: {error}");
}
value
.get("result")
.unwrap_or_else(|| panic!("{context}: missing result: {value}"))
}
fn err<'a>(value: &'a Value, context: &str) -> &'a Value {
value
.get("error")
.unwrap_or_else(|| panic!("{context}: expected JSON-RPC error, got: {value}"))
}
fn err_message(value: &Value, context: &str) -> String {
err(value, context)
.get("message")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("{context}: error without string message: {value}"))
.to_string()
}
/// Unwrap the CLI-compatible envelope: handlers that attach logs return
/// `{ "result": <payload>, "logs": [...] }`; otherwise the payload is bare.
fn payload<'a>(value: &'a Value, context: &str) -> &'a Value {
let result = ok(value, context);
result.get("result").unwrap_or(result)
}
#[tokio::test]
async fn medulla_local_schema_catalog_exposes_status_and_instruct() {
let _lock = env_lock();
let harness = setup().await;
let url = format!("{}/schema", harness.rpc_base.trim_end_matches('/'));
let schema = reqwest::get(&url)
.await
.unwrap_or_else(|err| panic!("GET {url}: {err}"))
.json::<Value>()
.await
.expect("schema json");
let methods: Vec<String> = schema
.get("methods")
.and_then(Value::as_array)
.expect("schema methods array")
.iter()
.map(|method| {
method
.get("method")
.and_then(Value::as_str)
.expect("method name")
.to_string()
})
.collect();
for method in [
"openhuman.medulla_local_status",
"openhuman.medulla_local_instruct",
] {
assert!(
methods.iter().any(|name| name == method),
"schema catalog must expose {method}; got {} methods",
methods.len()
);
}
harness.join.abort();
}
#[tokio::test]
async fn medulla_local_status_and_instruct_round_trip_without_serve_child() {
let _lock = env_lock();
let harness = setup().await;
// `status` with no serve entry configured: a well-formed idle snapshot,
// never a spawn attempt against Node or the network. The failed startup is
// folded into `message` rather than surfaced as an RPC error.
let status_response = rpc(
&harness.rpc_base,
40_001,
"openhuman.medulla_local_status",
json!({}),
)
.await;
let status = payload(&status_response, "medulla_local_status");
assert_eq!(
status.get("enabled").and_then(Value::as_bool),
Some(true),
"status.enabled: {status_response}"
);
assert_eq!(
status.get("running").and_then(Value::as_bool),
Some(false),
"status.running must be false without a serve child: {status_response}"
);
assert!(
status.get("serve_version").is_some_and(Value::is_null),
"status.serve_version must be null when not connected: {status_response}"
);
assert!(
status.get("session_id").is_some_and(Value::is_null),
"status.session_id must be null when not connected: {status_response}"
);
assert_eq!(
status
.get("ports")
.and_then(Value::as_array)
.map(Vec::as_slice),
Some(&[][..]),
"status.ports must be an empty array when not connected: {status_response}"
);
let message = status
.get("message")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
message.contains(STARTUP_UNAVAILABLE_NEEDLE),
"status.message must carry the actionable startup error: {status_response}"
);
// `instruct` without the required `message` param fails at deserialization,
// before any supervisor involvement.
let missing_param = rpc(
&harness.rpc_base,
40_002,
"openhuman.medulla_local_instruct",
json!({}),
)
.await;
let missing_message = err_message(&missing_param, "medulla_local_instruct missing message");
assert!(
missing_message.contains("message"),
"instruct without params should name the missing `message` field: {missing_param}"
);
// `instruct` with a message but no configured serve entry fails cleanly
// with the actionable configuration error (no hang, no Node spawn).
let instruct_response = rpc(
&harness.rpc_base,
40_003,
"openhuman.medulla_local_instruct",
json!({ "message": "hello from the e2e suite" }),
)
.await;
let instruct_error = err_message(&instruct_response, "medulla_local_instruct unconfigured");
assert!(
instruct_error.contains(STARTUP_UNAVAILABLE_NEEDLE),
"instruct must surface the actionable startup error: {instruct_response}"
);
harness.join.abort();
}