mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(medulla_local): supervise a local medulla-serve child (Flavor A draft) (#5105)
This commit is contained in:
+21
-1
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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, ¶ms.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()
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user