mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: expose paid Medulla orchestration (#4807)
This commit is contained in:
@@ -501,6 +501,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
|
|||||||
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
||||||
| ------ | ---------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------ | ---------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 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.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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ chat message. The Brain → Orchestration tab (`TinyPlaceOrchestrationTab.tsx` +
|
|||||||
`useOrchestrationChats.ts`) reads real store classification, live-updates, and lets
|
`useOrchestrationChats.ts`) reads real store classification, live-updates, and lets
|
||||||
the owner steer the front-end agent from the Master composer.
|
the owner steer the front-end agent from the Master composer.
|
||||||
|
|
||||||
|
The `openhuman.orchestration_run` renderer RPC is the direct request/response
|
||||||
|
entry point for the backend's paid Medulla engine. It performs a paid-plan
|
||||||
|
preflight, advertises OpenHuman's local contact, session-history, and
|
||||||
|
send-to-agent tools, and executes requested calls on the device. Tool results
|
||||||
|
are returned through `/orchestration/v1/run/continue` until Medulla produces a
|
||||||
|
final reply. The backend repeats authentication, entitlement, and resource
|
||||||
|
limit enforcement on every cycle.
|
||||||
|
|
||||||
## Running unattended (stage 8)
|
## Running unattended (stage 8)
|
||||||
|
|
||||||
- **No message loss**: ingest dedupes by relay `message_id` *before* decrypt (the
|
- **No message loss**: ingest dedupes by relay `message_id` *before* decrypt (the
|
||||||
@@ -103,6 +111,9 @@ the owner steer the front-end agent from the Master composer.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The `[orchestration]` config block (`src/openhuman/config/schema/orchestration.rs`):
|
The `[orchestration]` config block (`src/openhuman/config/schema/orchestration.rs`)
|
||||||
`enabled`, `debounce_ms`, `max_supersteps`, `message_window`,
|
uses `enabled` as its master switch. Optional direct-cycle overrides are grouped
|
||||||
`context_evict_threshold` (clamped 0.8 to 0.9), `subagent_concurrency`.
|
under `[orchestration.medulla.prompt_overrides]`,
|
||||||
|
`[orchestration.medulla.config]`, and `[orchestration.medulla.limits]`. Omitted
|
||||||
|
values retain backend defaults, and the backend clamps all supplied graph and
|
||||||
|
resource limits.
|
||||||
|
|||||||
@@ -1815,10 +1815,11 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
|||||||
forwards session DMs to the hosted orchestration brain, which reasons, \
|
forwards session DMs to the hosted orchestration brain, which reasons, \
|
||||||
replies, and steers on its own cadence server-side. The device executes the \
|
replies, and steers on its own cadence server-side. The device executes the \
|
||||||
effects the brain pushes back (send the reply, mirror an eviction into local \
|
effects the brain pushes back (send the reply, mirror an eviction into local \
|
||||||
memory), renders the hosted read surface, and shows a notice when the cloud \
|
memory), renders the hosted read surface, and can run the paid Medulla API \
|
||||||
brain is unreachable.",
|
directly with its local contact, session-history, and send-to-agent tools.",
|
||||||
how_to: "Intelligence > Orchestration (pair a wrapped session, then chat via the Master \
|
how_to: "Intelligence > Orchestration (pair a wrapped session, then chat via the Master \
|
||||||
window).",
|
window), or call openhuman.orchestration_run. Prompt, graph, and resource \
|
||||||
|
overrides live under [orchestration.medulla] in config.toml.",
|
||||||
status: CapabilityStatus::Beta,
|
status: CapabilityStatus::Beta,
|
||||||
privacy: DERIVED_TO_BACKEND,
|
privacy: DERIVED_TO_BACKEND,
|
||||||
},
|
},
|
||||||
|
|||||||
+16
-15
@@ -38,21 +38,22 @@ pub use schema::{
|
|||||||
DockerRuntimeConfig, EmailConfig, EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig,
|
DockerRuntimeConfig, EmailConfig, EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig,
|
||||||
HttpHeader, HttpRequestConfig, IMessageConfig, IntegrationToggle, IntegrationsConfig,
|
HttpHeader, HttpRequestConfig, IMessageConfig, IntegrationToggle, IntegrationsConfig,
|
||||||
LarkConfig, LearningConfig, LinqConfig, LlmBackend, LocalAiConfig, MatrixConfig, McpAuthConfig,
|
LarkConfig, LearningConfig, LinqConfig, LlmBackend, LocalAiConfig, MatrixConfig, McpAuthConfig,
|
||||||
McpClientConfig, McpClientIdentityConfig, McpServerConfig, MeetConfig, MemoryConfig,
|
McpClientConfig, McpClientIdentityConfig, McpServerConfig, MedullaClientConfig,
|
||||||
MemoryTreeConfig, ModelRouteConfig, MultimodalConfig, MultimodalFileConfig,
|
MedullaCycleConfig, MedullaCycleLimits, MedullaPromptOverrides, MedullaVerification,
|
||||||
ObservabilityConfig, OrchestratorModelConfig, PolymarketClobCredentials, PolymarketConfig,
|
MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig, MultimodalConfig,
|
||||||
PrivacyConfig, PrivacyMode, ProxyConfig, ProxyScope, ReflectionSource, ReliabilityConfig,
|
MultimodalFileConfig, ObservabilityConfig, OrchestratorModelConfig, PolymarketClobCredentials,
|
||||||
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
|
PolymarketConfig, PrivacyConfig, PrivacyMode, ProxyConfig, ProxyScope, ReflectionSource,
|
||||||
SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, SearchConfig, SearchEngine,
|
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
|
||||||
SearchEngineCredentials, SearxngConfig, SecretsConfig, SecurityConfig, ShellConfig,
|
SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig,
|
||||||
SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
|
SearchConfig, SearchEngine, SearchEngineCredentials, SearxngConfig, SecretsConfig,
|
||||||
TeamModelConfig, TelegramConfig, TokenjuiceConfig, UpdateConfig, UpdateRestartStrategy,
|
SecurityConfig, ShellConfig, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||||
VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig, YuanbaoConfig,
|
StorageProviderSection, StreamMode, TeamModelConfig, TelegramConfig, TokenjuiceConfig,
|
||||||
DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MEMORY_SYNC_INTERVAL_SECS, DEFAULT_MODEL,
|
UpdateConfig, UpdateRestartStrategy, VoiceActivationMode, VoiceServerConfig, WebSearchConfig,
|
||||||
MEMORY_SYNC_INTERVAL_PRESETS_SECS, MODEL_AGENTIC_V1, MODEL_BURST_V1, MODEL_CHAT_V1,
|
WebhookConfig, YuanbaoConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MEMORY_SYNC_INTERVAL_SECS,
|
||||||
MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1,
|
DEFAULT_MODEL, MEMORY_SYNC_INTERVAL_PRESETS_SECS, MODEL_AGENTIC_V1, MODEL_BURST_V1,
|
||||||
MODEL_VISION_V1, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_DISABLED, SEARCH_ENGINE_MANAGED,
|
MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1,
|
||||||
SEARCH_ENGINE_PARALLEL, SEARCH_ENGINE_QUERIT,
|
MODEL_SUMMARIZATION_V1, MODEL_VISION_V1, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_DISABLED,
|
||||||
|
SEARCH_ENGINE_MANAGED, SEARCH_ENGINE_PARALLEL, SEARCH_ENGINE_QUERIT,
|
||||||
};
|
};
|
||||||
pub use schemas::{
|
pub use schemas::{
|
||||||
all_controller_schemas as all_config_controller_schemas,
|
all_controller_schemas as all_config_controller_schemas,
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ pub use local_ai::{LocalAiConfig, LocalAiUsage};
|
|||||||
pub use meet::{AutoJoinPolicy, AutoSummarizePolicy, CalendarProvider, MeetConfig};
|
pub use meet::{AutoJoinPolicy, AutoSummarizePolicy, CalendarProvider, MeetConfig};
|
||||||
pub use node::NodeConfig;
|
pub use node::NodeConfig;
|
||||||
pub use observability::{AgentTracingBackend, AgentTracingConfig, ObservabilityConfig};
|
pub use observability::{AgentTracingBackend, AgentTracingConfig, ObservabilityConfig};
|
||||||
pub use orchestration::OrchestrationConfig;
|
pub use orchestration::{
|
||||||
|
MedullaClientConfig, MedullaCycleConfig, MedullaCycleLimits, MedullaPromptOverrides,
|
||||||
|
MedullaVerification, OrchestrationConfig,
|
||||||
|
};
|
||||||
pub use privacy::{PrivacyConfig, PrivacyMode};
|
pub use privacy::{PrivacyConfig, PrivacyMode};
|
||||||
pub use proxy::{
|
pub use proxy::{
|
||||||
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
|
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
//! Orchestration configuration.
|
//! Hosted orchestration configuration.
|
||||||
//!
|
|
||||||
//! The reasoning/wake graph runs server-side now, so the device-side config is a
|
|
||||||
//! single opt-out: whether to ingest tiny.place harness session DMs and forward
|
|
||||||
//! them to the hosted orchestration brain.
|
|
||||||
|
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -14,18 +10,124 @@ fn default_enabled() -> bool {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct OrchestrationConfig {
|
pub struct OrchestrationConfig {
|
||||||
/// Ingest inbound tiny.place harness session DMs, forward them to the hosted
|
/// Master switch for hosted orchestration ingest, rendering, and direct
|
||||||
/// brain (`POST /orchestration/v1/events`), run the device tail (effect
|
/// Medulla runs. Default: `true`.
|
||||||
/// executor, world-diff uploader, health probe), and render the hosted read
|
|
||||||
/// surface. When `false` the device does none of this. Default: `true`.
|
|
||||||
#[serde(default = "default_enabled")]
|
#[serde(default = "default_enabled")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Options forwarded to the paid `/orchestration/v1/run` surface.
|
||||||
|
#[serde(default)]
|
||||||
|
pub medulla: MedullaClientConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OrchestrationConfig {
|
impl Default for OrchestrationConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: default_enabled(),
|
enabled: default_enabled(),
|
||||||
|
medulla: MedullaClientConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct MedullaClientConfig {
|
||||||
|
/// Optional prompt replacements. Empty fields retain backend defaults.
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompt_overrides: MedullaPromptOverrides,
|
||||||
|
|
||||||
|
/// Optional graph tuning. The backend clamps every supplied value.
|
||||||
|
#[serde(default)]
|
||||||
|
pub config: MedullaCycleConfig,
|
||||||
|
|
||||||
|
/// Optional per-cycle resource limits. The backend clamps every value.
|
||||||
|
#[serde(default)]
|
||||||
|
pub limits: MedullaCycleLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct MedullaPromptOverrides {
|
||||||
|
pub orchestrate_system: Option<String>,
|
||||||
|
pub reasoning_execute_system: Option<String>,
|
||||||
|
pub orchestrate_rlm_system: Option<String>,
|
||||||
|
pub compress_system: Option<String>,
|
||||||
|
pub frontend_gate_system: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct MedullaCycleConfig {
|
||||||
|
pub max_passes: Option<u32>,
|
||||||
|
pub max_steps: Option<u32>,
|
||||||
|
pub max_depth: Option<u32>,
|
||||||
|
pub context_window_tokens: Option<u64>,
|
||||||
|
pub verification: Option<MedullaVerification>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MedullaVerification {
|
||||||
|
Remind,
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct MedullaCycleLimits {
|
||||||
|
pub max_concurrency: Option<u32>,
|
||||||
|
pub max_tokens: Option<u64>,
|
||||||
|
pub deadline_ms: Option<u64>,
|
||||||
|
pub max_tasks_per_delegate: Option<u32>,
|
||||||
|
pub max_depth: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_keep_orchestration_enabled_without_overrides() {
|
||||||
|
let config = OrchestrationConfig::default();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert!(config.medulla.config.max_passes.is_none());
|
||||||
|
assert!(config.medulla.limits.deadline_ms.is_none());
|
||||||
|
assert!(config.medulla.prompt_overrides.orchestrate_system.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn medulla_nested_toml_deserializes() {
|
||||||
|
let config: OrchestrationConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[medulla.prompt_overrides]
|
||||||
|
orchestrate_system = "Plan carefully"
|
||||||
|
|
||||||
|
[medulla.config]
|
||||||
|
max_passes = 3
|
||||||
|
verification = "remind"
|
||||||
|
|
||||||
|
[medulla.limits]
|
||||||
|
max_concurrency = 8
|
||||||
|
deadline_ms = 90000
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.medulla.config.max_passes, Some(3));
|
||||||
|
assert!(matches!(
|
||||||
|
config.medulla.config.verification,
|
||||||
|
Some(MedullaVerification::Remind)
|
||||||
|
));
|
||||||
|
assert_eq!(config.medulla.limits.max_concurrency, Some(8));
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.medulla
|
||||||
|
.prompt_overrides
|
||||||
|
.orchestrate_system
|
||||||
|
.as_deref(),
|
||||||
|
Some("Plan carefully")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,699 @@
|
|||||||
|
//! Client for the backend's paid Medulla request/response surface.
|
||||||
|
//!
|
||||||
|
//! OpenHuman supplies its existing local orchestration tools to
|
||||||
|
//! `/orchestration/v1/run`, executes requested calls on-device, and returns the
|
||||||
|
//! results through `/run/continue` until the cycle ends.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use reqwest::Method;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
|
use crate::api::config::effective_backend_api_url;
|
||||||
|
use crate::api::BackendOAuthClient;
|
||||||
|
use crate::openhuman::config::{Config, MedullaClientConfig};
|
||||||
|
use crate::openhuman::tools::Tool;
|
||||||
|
|
||||||
|
const LOG: &str = "orchestration";
|
||||||
|
const RUN_PATH: &str = "/orchestration/v1/run";
|
||||||
|
const CONTINUE_PATH: &str = "/orchestration/v1/run/continue";
|
||||||
|
const PLAN_PATH: &str = "/payments/stripe/currentPlan";
|
||||||
|
const MAX_LOOP_EVENTS: usize = 128;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MedullaRunResult {
|
||||||
|
pub reply: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pass_count: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub compressed_history: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub escalations: Vec<String>,
|
||||||
|
pub session_id: String,
|
||||||
|
pub cycle_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ToolCall {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
args: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "stop", rename_all = "snake_case")]
|
||||||
|
enum LoopEvent {
|
||||||
|
ToolUse {
|
||||||
|
#[serde(rename = "cycleId")]
|
||||||
|
cycle_id: String,
|
||||||
|
#[serde(rename = "sessionId")]
|
||||||
|
session_id: String,
|
||||||
|
#[serde(rename = "toolCalls")]
|
||||||
|
tool_calls: Vec<ToolCall>,
|
||||||
|
},
|
||||||
|
End {
|
||||||
|
#[serde(flatten)]
|
||||||
|
result: MedullaRunResult,
|
||||||
|
},
|
||||||
|
Pending {
|
||||||
|
#[serde(rename = "cycleId")]
|
||||||
|
cycle_id: String,
|
||||||
|
#[serde(rename = "sessionId")]
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
#[serde(rename = "cycleId")]
|
||||||
|
cycle_id: String,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a Medulla cycle using the signed-in user's paid backend plan. The local
|
||||||
|
/// plan lookup is a fail-fast UX guard; the backend remains authoritative and
|
||||||
|
/// repeats the paid-plan check on both run endpoints.
|
||||||
|
pub async fn run(
|
||||||
|
config: &Config,
|
||||||
|
input: &str,
|
||||||
|
session_id: Option<&str>,
|
||||||
|
) -> Result<MedullaRunResult, String> {
|
||||||
|
if !config.orchestration.enabled {
|
||||||
|
return Err("hosted orchestration is disabled in config".to_string());
|
||||||
|
}
|
||||||
|
let input = input.trim();
|
||||||
|
if input.is_empty() {
|
||||||
|
return Err("input is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = crate::openhuman::credentials::session_support::require_live_session_token(config)?;
|
||||||
|
let api_url = effective_backend_api_url(&config.api_url);
|
||||||
|
let client = BackendOAuthClient::new(&api_url).map_err(|err| err.to_string())?;
|
||||||
|
ensure_paid_plan(&client, &token).await?;
|
||||||
|
|
||||||
|
let tuning = config.orchestration.medulla.clone();
|
||||||
|
let config = Arc::new(config.clone());
|
||||||
|
let tools: Vec<Box<dyn Tool>> = vec![
|
||||||
|
Box::new(super::tools::ListContactsTool),
|
||||||
|
Box::new(super::tools::ListSessionsTool::new(Arc::clone(&config))),
|
||||||
|
Box::new(super::tools::ReadSessionTool::new(Arc::clone(&config))),
|
||||||
|
Box::new(super::tools::SendToAgentTool::new(config)),
|
||||||
|
];
|
||||||
|
|
||||||
|
run_with_client(&client, &token, input, session_id, &tools, &tuning).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_paid_plan(client: &BackendOAuthClient, token: &str) -> Result<(), String> {
|
||||||
|
let data = client
|
||||||
|
.authed_json(token, Method::GET, PLAN_PATH, None)
|
||||||
|
.await
|
||||||
|
.map_err(crate::api::flatten_authed_error)?;
|
||||||
|
if paid_plan_active(&data) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err("Medulla orchestration requires an active Basic or Pro plan".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paid_plan_active(data: &Value) -> bool {
|
||||||
|
let plan = data.get("plan").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
let active = data
|
||||||
|
.get("hasActiveSubscription")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
active && matches!(plan.to_ascii_uppercase().as_str(), "BASIC" | "PRO")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_with_client(
|
||||||
|
client: &BackendOAuthClient,
|
||||||
|
token: &str,
|
||||||
|
input: &str,
|
||||||
|
session_id: Option<&str>,
|
||||||
|
tools: &[Box<dyn Tool>],
|
||||||
|
tuning: &MedullaClientConfig,
|
||||||
|
) -> Result<MedullaRunResult, String> {
|
||||||
|
let mut body = Map::new();
|
||||||
|
body.insert("input".to_string(), Value::String(input.to_string()));
|
||||||
|
if let Some(session_id) = session_id.map(str::trim).filter(|id| !id.is_empty()) {
|
||||||
|
body.insert(
|
||||||
|
"sessionId".to_string(),
|
||||||
|
Value::String(session_id.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !tools.is_empty() {
|
||||||
|
body.insert(
|
||||||
|
"tools".to_string(),
|
||||||
|
Value::Array(tools.iter().map(|tool| tool_spec(tool.as_ref())).collect()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(options) = tuning_value(tuning) {
|
||||||
|
body.insert("options".to_string(), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
input_bytes = input.len(),
|
||||||
|
tool_count = tools.len(),
|
||||||
|
has_session = session_id.is_some(),
|
||||||
|
"[orchestration] medulla.run.start"
|
||||||
|
);
|
||||||
|
let first = post(client, token, RUN_PATH, Value::Object(body)).await?;
|
||||||
|
if tools.is_empty() {
|
||||||
|
let result = serde_json::from_value(first)
|
||||||
|
.map_err(|err| format!("parse Medulla run response: {err}"))?;
|
||||||
|
tracing::debug!("[orchestration] medulla.run.end direct=true");
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
drive_tool_loop(client, token, tools, first).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drive_tool_loop(
|
||||||
|
client: &BackendOAuthClient,
|
||||||
|
token: &str,
|
||||||
|
tools: &[Box<dyn Tool>],
|
||||||
|
mut value: Value,
|
||||||
|
) -> Result<MedullaRunResult, String> {
|
||||||
|
for event_index in 0..MAX_LOOP_EVENTS {
|
||||||
|
let event: LoopEvent = serde_json::from_value(value)
|
||||||
|
.map_err(|err| format!("parse Medulla loop event: {err}"))?;
|
||||||
|
match event {
|
||||||
|
LoopEvent::End { result } => {
|
||||||
|
tracing::debug!(
|
||||||
|
cycle_id = %result.cycle_id,
|
||||||
|
pass_count = result.pass_count,
|
||||||
|
events = event_index + 1,
|
||||||
|
"[orchestration] medulla.run.end"
|
||||||
|
);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
LoopEvent::Error { cycle_id, error } => {
|
||||||
|
tracing::warn!(cycle_id = %cycle_id, "[orchestration] medulla.run.error");
|
||||||
|
return Err(format!("Medulla cycle {cycle_id} failed: {error}"));
|
||||||
|
}
|
||||||
|
LoopEvent::Pending {
|
||||||
|
cycle_id,
|
||||||
|
session_id,
|
||||||
|
} => {
|
||||||
|
tracing::debug!(
|
||||||
|
cycle_id = %cycle_id,
|
||||||
|
session_id = %session_id,
|
||||||
|
"[orchestration] medulla.run.pending"
|
||||||
|
);
|
||||||
|
value = continue_run(client, token, &cycle_id, Vec::new()).await?;
|
||||||
|
}
|
||||||
|
LoopEvent::ToolUse {
|
||||||
|
cycle_id,
|
||||||
|
session_id,
|
||||||
|
tool_calls,
|
||||||
|
} => {
|
||||||
|
tracing::debug!(
|
||||||
|
cycle_id = %cycle_id,
|
||||||
|
session_id = %session_id,
|
||||||
|
call_count = tool_calls.len(),
|
||||||
|
"[orchestration] medulla.run.tool_use"
|
||||||
|
);
|
||||||
|
let mut results = Vec::with_capacity(tool_calls.len());
|
||||||
|
for call in tool_calls {
|
||||||
|
results.push(execute_tool_call(tools, call).await);
|
||||||
|
}
|
||||||
|
value = continue_run(client, token, &cycle_id, results).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"Medulla tool loop exceeded {MAX_LOOP_EVENTS} events"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_tool_call(tools: &[Box<dyn Tool>], call: ToolCall) -> Value {
|
||||||
|
let Some(tool) = tools.iter().find(|tool| tool.name() == call.name) else {
|
||||||
|
tracing::warn!(tool = %call.name, "[orchestration] medulla.tool.unknown");
|
||||||
|
return json!({
|
||||||
|
"id": call.id,
|
||||||
|
"ok": false,
|
||||||
|
"error": format!("unknown OpenHuman tool: {}", call.name),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!(tool = %call.name, call_id = %call.id, "[orchestration] medulla.tool.start");
|
||||||
|
match tool.execute(call.args).await {
|
||||||
|
Ok(result) if !result.is_error => {
|
||||||
|
tracing::debug!(tool = %call.name, call_id = %call.id, "[orchestration] medulla.tool.end");
|
||||||
|
json!({ "id": call.id, "ok": true, "result": result.output_for_llm(true) })
|
||||||
|
}
|
||||||
|
Ok(result) => {
|
||||||
|
tracing::warn!(tool = %call.name, call_id = %call.id, "[orchestration] medulla.tool.failed");
|
||||||
|
json!({ "id": call.id, "ok": false, "error": result.output() })
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(tool = %call.name, call_id = %call.id, error = %err, "[orchestration] medulla.tool.failed");
|
||||||
|
json!({ "id": call.id, "ok": false, "error": err.to_string() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn continue_run(
|
||||||
|
client: &BackendOAuthClient,
|
||||||
|
token: &str,
|
||||||
|
cycle_id: &str,
|
||||||
|
tool_results: Vec<Value>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
post(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
CONTINUE_PATH,
|
||||||
|
json!({ "cycleId": cycle_id, "toolResults": tool_results }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(
|
||||||
|
client: &BackendOAuthClient,
|
||||||
|
token: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Value,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
client
|
||||||
|
.authed_json(token, Method::POST, path, Some(body))
|
||||||
|
.await
|
||||||
|
.map_err(crate::api::flatten_authed_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_spec(tool: &dyn Tool) -> Value {
|
||||||
|
json!({
|
||||||
|
"name": tool.name(),
|
||||||
|
"description": tool.description(),
|
||||||
|
"parameters": tool.parameters_schema(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tuning_value(tuning: &MedullaClientConfig) -> Option<Value> {
|
||||||
|
let prompt_overrides = &tuning.prompt_overrides;
|
||||||
|
let prompt_overrides = json_object([
|
||||||
|
(
|
||||||
|
"ORCHESTRATE_SYSTEM",
|
||||||
|
prompt_overrides
|
||||||
|
.orchestrate_system
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| json!(v)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"REASONING_EXECUTE_SYSTEM",
|
||||||
|
prompt_overrides
|
||||||
|
.reasoning_execute_system
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| json!(v)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ORCHESTRATE_RLM_SYSTEM",
|
||||||
|
prompt_overrides
|
||||||
|
.orchestrate_rlm_system
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| json!(v)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"COMPRESS_SYSTEM",
|
||||||
|
prompt_overrides.compress_system.as_ref().map(|v| json!(v)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"FRONTEND_GATE_SYSTEM",
|
||||||
|
prompt_overrides
|
||||||
|
.frontend_gate_system
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| json!(v)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let config = &tuning.config;
|
||||||
|
let config = json_object([
|
||||||
|
("maxPasses", config.max_passes.map(|v| json!(v))),
|
||||||
|
("maxSteps", config.max_steps.map(|v| json!(v))),
|
||||||
|
("maxDepth", config.max_depth.map(|v| json!(v))),
|
||||||
|
(
|
||||||
|
"contextWindowTokens",
|
||||||
|
config.context_window_tokens.map(|v| json!(v)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"verification",
|
||||||
|
config.verification.map(|v| {
|
||||||
|
json!(match v {
|
||||||
|
crate::openhuman::config::MedullaVerification::Remind => "remind",
|
||||||
|
crate::openhuman::config::MedullaVerification::Off => "off",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let limits = &tuning.limits;
|
||||||
|
let limits = json_object([
|
||||||
|
("maxConcurrency", limits.max_concurrency.map(|v| json!(v))),
|
||||||
|
("maxTokens", limits.max_tokens.map(|v| json!(v))),
|
||||||
|
("deadlineMs", limits.deadline_ms.map(|v| json!(v))),
|
||||||
|
(
|
||||||
|
"maxTasksPerDelegate",
|
||||||
|
limits.max_tasks_per_delegate.map(|v| json!(v)),
|
||||||
|
),
|
||||||
|
("maxDepth", limits.max_depth.map(|v| json!(v))),
|
||||||
|
]);
|
||||||
|
let options = json_object([
|
||||||
|
("promptOverrides", prompt_overrides.map(Value::Object)),
|
||||||
|
("config", config.map(Value::Object)),
|
||||||
|
("limits", limits.map(Value::Object)),
|
||||||
|
]);
|
||||||
|
options.map(Value::Object)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_object<const N: usize>(entries: [(&str, Option<Value>); N]) -> Option<Map<String, Value>> {
|
||||||
|
let object: Map<String, Value> = entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(key, value)| value.map(|value| (key.to_string(), value)))
|
||||||
|
.collect();
|
||||||
|
(!object.is_empty()).then_some(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use wiremock::matchers::{header, method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
use crate::openhuman::config::{MedullaCycleConfig, MedullaPromptOverrides};
|
||||||
|
use crate::openhuman::tools::ToolResult;
|
||||||
|
|
||||||
|
struct EchoTool;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for EchoTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"echo"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Echo text"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> Value {
|
||||||
|
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||||
|
Ok(ToolResult::success(
|
||||||
|
args.get("text").and_then(Value::as_str).unwrap_or_default(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailingTool;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for FailingTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"fail"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Return a tool-level failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> Value {
|
||||||
|
json!({"type":"object"})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
|
||||||
|
Ok(ToolResult::error("expected failure"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExplodingTool;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ExplodingTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"explode"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Return an execution error"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> Value {
|
||||||
|
json!({"type":"object"})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
|
||||||
|
anyhow::bail!("execution exploded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end_event() -> Value {
|
||||||
|
json!({
|
||||||
|
"stop":"end",
|
||||||
|
"reply":"done",
|
||||||
|
"passCount":2,
|
||||||
|
"compressedHistory":[],
|
||||||
|
"escalations":[],
|
||||||
|
"cycleId":"cycle-1",
|
||||||
|
"sessionId":"session-1"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn envelope(data: Value) -> Value {
|
||||||
|
json!({"success": true, "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paid_plan_requires_active_basic_or_pro() {
|
||||||
|
assert!(paid_plan_active(
|
||||||
|
&json!({"plan":"PRO","hasActiveSubscription":true})
|
||||||
|
));
|
||||||
|
assert!(paid_plan_active(
|
||||||
|
&json!({"plan":"basic","hasActiveSubscription":true})
|
||||||
|
));
|
||||||
|
assert!(!paid_plan_active(
|
||||||
|
&json!({"plan":"FREE","hasActiveSubscription":true})
|
||||||
|
));
|
||||||
|
assert!(!paid_plan_active(
|
||||||
|
&json!({"plan":"PRO","hasActiveSubscription":false})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tuning_uses_backend_field_names_and_omits_empty_sections() {
|
||||||
|
assert!(tuning_value(&MedullaClientConfig::default()).is_none());
|
||||||
|
let tuning = MedullaClientConfig {
|
||||||
|
prompt_overrides: MedullaPromptOverrides {
|
||||||
|
orchestrate_system: Some("custom".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
config: MedullaCycleConfig {
|
||||||
|
max_passes: Some(3),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
tuning_value(&tuning),
|
||||||
|
Some(json!({
|
||||||
|
"promptOverrides": {"ORCHESTRATE_SYSTEM":"custom"},
|
||||||
|
"config": {"maxPasses":3}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn paid_plan_check_accepts_paid_and_rejects_free() {
|
||||||
|
for (plan, active, should_pass) in [("PRO", true, true), ("FREE", true, false)] {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path(PLAN_PATH))
|
||||||
|
.and(header("authorization", "Bearer test-token"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(envelope(json!({
|
||||||
|
"plan": plan,
|
||||||
|
"hasActiveSubscription": active
|
||||||
|
}))))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = BackendOAuthClient::new(&server.uri()).unwrap();
|
||||||
|
let result = ensure_paid_plan(&client, "test-token").await;
|
||||||
|
assert_eq!(result.is_ok(), should_pass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn public_run_validates_enabled_and_input_before_credentials() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.orchestration.enabled = false;
|
||||||
|
assert_eq!(
|
||||||
|
run(&config, "task", None).await.unwrap_err(),
|
||||||
|
"hosted orchestration is disabled in config"
|
||||||
|
);
|
||||||
|
|
||||||
|
config.orchestration.enabled = true;
|
||||||
|
assert_eq!(
|
||||||
|
run(&config, " ", None).await.unwrap_err(),
|
||||||
|
"input is required"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_without_tools_returns_direct_result_and_forwards_options() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path(RUN_PATH))
|
||||||
|
.and(header("authorization", "Bearer test-token"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(envelope(end_event())))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = BackendOAuthClient::new(&server.uri()).unwrap();
|
||||||
|
let tuning = MedullaClientConfig {
|
||||||
|
config: MedullaCycleConfig {
|
||||||
|
max_passes: Some(4),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = run_with_client(
|
||||||
|
&client,
|
||||||
|
"test-token",
|
||||||
|
"direct",
|
||||||
|
Some(" session-1 "),
|
||||||
|
&[],
|
||||||
|
&tuning,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.reply, "done");
|
||||||
|
let requests = server.received_requests().await.unwrap();
|
||||||
|
let body: Value = serde_json::from_slice(&requests[0].body).unwrap();
|
||||||
|
assert_eq!(body["sessionId"], "session-1");
|
||||||
|
assert_eq!(body["options"]["config"]["maxPasses"], 4);
|
||||||
|
assert!(body.get("tools").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_event_polls_until_end() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path(CONTINUE_PATH))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(envelope(end_event())))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let client = BackendOAuthClient::new(&server.uri()).unwrap();
|
||||||
|
|
||||||
|
let result = drive_tool_loop(
|
||||||
|
&client,
|
||||||
|
"test-token",
|
||||||
|
&[Box::new(EchoTool)],
|
||||||
|
json!({"stop":"pending","cycleId":"cycle-1","sessionId":"session-1"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.reply, "done");
|
||||||
|
let requests = server.received_requests().await.unwrap();
|
||||||
|
let body: Value = serde_json::from_slice(&requests[0].body).unwrap();
|
||||||
|
assert_eq!(body, json!({"cycleId":"cycle-1","toolResults":[]}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn error_event_is_returned_with_cycle_context() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
let client = BackendOAuthClient::new(&server.uri()).unwrap();
|
||||||
|
let error = drive_tool_loop(
|
||||||
|
&client,
|
||||||
|
"test-token",
|
||||||
|
&[],
|
||||||
|
json!({"stop":"error","cycleId":"cycle-bad","error":"budget exceeded"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error, "Medulla cycle cycle-bad failed: budget exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_and_failed_tools_are_reported_to_backend() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path(CONTINUE_PATH))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(envelope(end_event())))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let client = BackendOAuthClient::new(&server.uri()).unwrap();
|
||||||
|
let tools: Vec<Box<dyn Tool>> = vec![Box::new(FailingTool), Box::new(ExplodingTool)];
|
||||||
|
|
||||||
|
drive_tool_loop(
|
||||||
|
&client,
|
||||||
|
"test-token",
|
||||||
|
&tools,
|
||||||
|
json!({
|
||||||
|
"stop":"tool_use",
|
||||||
|
"cycleId":"cycle-1",
|
||||||
|
"sessionId":"session-1",
|
||||||
|
"toolCalls":[
|
||||||
|
{"id":"call-unknown","name":"missing","args":{}},
|
||||||
|
{"id":"call-failed","name":"fail","args":{}},
|
||||||
|
{"id":"call-exploded","name":"explode","args":{}}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let requests = server.received_requests().await.unwrap();
|
||||||
|
let body: Value = serde_json::from_slice(&requests[0].body).unwrap();
|
||||||
|
assert_eq!(body["toolResults"][0]["ok"], false);
|
||||||
|
assert_eq!(
|
||||||
|
body["toolResults"][0]["error"],
|
||||||
|
"unknown OpenHuman tool: missing"
|
||||||
|
);
|
||||||
|
assert_eq!(body["toolResults"][1]["error"], "expected failure");
|
||||||
|
assert_eq!(body["toolResults"][2]["error"], "execution exploded");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_loop_executes_locally_and_continues_to_end() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path(RUN_PATH))
|
||||||
|
.and(header("authorization", "Bearer test-token"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"stop":"tool_use",
|
||||||
|
"cycleId":"cycle-1",
|
||||||
|
"sessionId":"session-1",
|
||||||
|
"toolCalls":[{"id":"call-1","name":"echo","args":{"text":"hello"}}]
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path(CONTINUE_PATH))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(envelope(end_event())))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = BackendOAuthClient::new(&server.uri()).unwrap();
|
||||||
|
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
|
||||||
|
let result = run_with_client(
|
||||||
|
&client,
|
||||||
|
"test-token",
|
||||||
|
"use echo",
|
||||||
|
None,
|
||||||
|
&tools,
|
||||||
|
&MedullaClientConfig::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.reply, "done");
|
||||||
|
assert_eq!(result.pass_count, 2);
|
||||||
|
let requests = server.received_requests().await.unwrap();
|
||||||
|
let continued: Value = serde_json::from_slice(&requests[1].body).unwrap();
|
||||||
|
assert_eq!(continued["toolResults"][0]["result"], "hello");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ pub mod cloud;
|
|||||||
pub mod effect_executor;
|
pub mod effect_executor;
|
||||||
pub mod exec_gate;
|
pub mod exec_gate;
|
||||||
pub mod ingest;
|
pub mod ingest;
|
||||||
|
pub mod medulla;
|
||||||
pub mod migrate_history;
|
pub mod migrate_history;
|
||||||
pub mod ops;
|
pub mod ops;
|
||||||
pub mod presence;
|
pub mod presence;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
|||||||
schema_for("orchestration_self_identity"),
|
schema_for("orchestration_self_identity"),
|
||||||
schema_for("orchestration_publish_identity"),
|
schema_for("orchestration_publish_identity"),
|
||||||
schema_for("orchestration_relay_info"),
|
schema_for("orchestration_relay_info"),
|
||||||
|
schema_for("orchestration_run"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +82,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
|||||||
schema: schema_for("orchestration_relay_info"),
|
schema: schema_for("orchestration_relay_info"),
|
||||||
handler: handle_relay_info,
|
handler: handle_relay_info,
|
||||||
},
|
},
|
||||||
|
RegisteredController {
|
||||||
|
schema: schema_for("orchestration_run"),
|
||||||
|
handler: handle_medulla_run,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +183,19 @@ fn schema_for(function: &str) -> ControllerSchema {
|
|||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
outputs: vec![json_output("result", "{ baseUrl, network }.")],
|
outputs: vec![json_output("result", "{ baseUrl, network }.")],
|
||||||
},
|
},
|
||||||
|
"orchestration_run" => ControllerSchema {
|
||||||
|
namespace: "orchestration",
|
||||||
|
function: "run",
|
||||||
|
description: "Run the paid hosted Medulla engine with OpenHuman's local contact/session/send tools. Tool calls execute on this device and are returned through the backend continuation loop until a final reply is available.",
|
||||||
|
inputs: vec![
|
||||||
|
required_str("input", "The task or prompt for Medulla to orchestrate."),
|
||||||
|
optional_str("sessionId", "Optional Medulla session id to continue."),
|
||||||
|
],
|
||||||
|
outputs: vec![json_output(
|
||||||
|
"result",
|
||||||
|
"{ reply, passCount, compressedHistory, escalations, sessionId, cycleId }.",
|
||||||
|
)],
|
||||||
|
},
|
||||||
other => unreachable!("unknown orchestration schema: {other}"),
|
other => unreachable!("unknown orchestration schema: {other}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1030,6 +1048,30 @@ fn handle_relay_info(_params: Map<String, Value>) -> ControllerFuture {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Direct paid Medulla entry point. The backend remains the authority for both
|
||||||
|
/// authentication and plan enforcement; `medulla::run` also performs a local
|
||||||
|
/// plan preflight so free users receive an immediate actionable error.
|
||||||
|
fn handle_medulla_run(params: Map<String, Value>) -> ControllerFuture {
|
||||||
|
Box::pin(async move {
|
||||||
|
let input = required_param(¶ms, "input")?.trim().to_string();
|
||||||
|
let session_id = params
|
||||||
|
.get("sessionId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
let config = load_config("medulla_run").await?;
|
||||||
|
log::debug!(
|
||||||
|
target: LOG,
|
||||||
|
"[orchestration_rpc] medulla_run.entry input_bytes={} has_session={}",
|
||||||
|
input.len(),
|
||||||
|
session_id.is_some(),
|
||||||
|
);
|
||||||
|
let result = super::medulla::run(&config, &input, session_id.as_deref()).await?;
|
||||||
|
to_json(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn load_config(action: &str) -> Result<Config, String> {
|
async fn load_config(action: &str) -> Result<Config, String> {
|
||||||
@@ -1087,7 +1129,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn schemas_use_orchestration_namespace() {
|
fn schemas_use_orchestration_namespace() {
|
||||||
let schemas = all_controller_schemas();
|
let schemas = all_controller_schemas();
|
||||||
assert_eq!(schemas.len(), 10);
|
assert_eq!(schemas.len(), 11);
|
||||||
assert!(schemas.iter().all(|s| s.namespace == "orchestration"));
|
assert!(schemas.iter().all(|s| s.namespace == "orchestration"));
|
||||||
assert_eq!(schema_for("orchestration_attention").function, "attention");
|
assert_eq!(schema_for("orchestration_attention").function, "attention");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1115,6 +1157,10 @@ mod tests {
|
|||||||
schema_for("orchestration_sessions_create").function,
|
schema_for("orchestration_sessions_create").function,
|
||||||
"sessions_create"
|
"sessions_create"
|
||||||
);
|
);
|
||||||
|
assert_eq!(schema_for("orchestration_run").function, "run");
|
||||||
|
assert!(all_registered_controllers()
|
||||||
|
.iter()
|
||||||
|
.any(|controller| controller.schema.function == "run"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user