Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions crates/aionui-ai-agent/src/agent_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use tokio::sync::broadcast;
use crate::error::AgentError;
use crate::manager::acp::AcpAgentManager;
use crate::manager::aionrs::AionrsAgentManager;
use crate::manager::openclaw::OpenClawAgentManager;
use crate::protocol::events::AgentStreamEvent;
use crate::protocol::send_error::AgentSendError;
use crate::types::SendMessageData;
Expand Down Expand Up @@ -150,6 +151,7 @@ pub trait IMockAgent: IAgentTask {
pub enum AgentInstance {
Acp(Arc<AcpAgentManager>),
Aionrs(Arc<AionrsAgentManager>),
OpenClaw(Arc<OpenClawAgentManager>),
/// Test-only trait-object escape hatch used by downstream crates
/// (conversation/cron/team/app tests) to inject fake agents without
/// spinning up a real CLI or WebSocket connection. Gated behind
Expand All @@ -169,6 +171,7 @@ impl AgentInstance {
match self {
Self::Acp(m) => m.as_ref(),
Self::Aionrs(m) => m.as_ref(),
Self::OpenClaw(m) => m.as_ref(),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.as_ref(),
}
Expand Down Expand Up @@ -234,6 +237,7 @@ impl AgentInstance {
match self {
Self::Acp(m) => m.kill_and_wait(reason),
Self::Aionrs(m) => m.kill_and_wait(reason),
Self::OpenClaw(m) => m.kill_and_wait(reason),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(_) => Box::pin(std::future::ready(())),
}
Expand All @@ -254,6 +258,7 @@ impl AgentInstance {
match self {
Self::Acp(m) => m.get_confirmations(),
Self::Aionrs(m) => m.get_confirmations(),
Self::OpenClaw(_) => Vec::new(),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.get_confirmations(),
}
Expand All @@ -270,6 +275,7 @@ impl AgentInstance {
match self {
Self::Acp(m) => m.confirm(msg_id, call_id, data, always_allow),
Self::Aionrs(m) => m.confirm(msg_id, call_id, data, always_allow),
Self::OpenClaw(_) => Ok(()),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.confirm(msg_id, call_id, data, always_allow),
}
Expand All @@ -280,6 +286,7 @@ impl AgentInstance {
match self {
Self::Acp(_) => false,
Self::Aionrs(m) => m.check_approval(action, command_type),
Self::OpenClaw(_) => false,
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.check_approval(action, command_type),
}
Expand All @@ -288,7 +295,7 @@ impl AgentInstance {
/// Session key for test doubles that expose one.
pub fn get_session_key(&self) -> Option<String> {
match self {
Self::Acp(_) | Self::Aionrs(_) => None,
Self::Acp(_) | Self::Aionrs(_) | Self::OpenClaw(_) => None,
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.get_session_key(),
}
Expand All @@ -299,11 +306,29 @@ impl AgentInstance {
match self {
Self::Acp(m) => m.mode().await,
Self::Aionrs(m) => m.mode().await,
Self::OpenClaw(_) => Ok(aionui_api_types::AgentModeResponse {
mode: String::new(),
initialized: false,
}),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.mode().await,
}
}

/// Set the session mode. Unsupported for variants other than ACP /
/// Aionrs — returns a `BadRequest` so the caller can surface an
/// actionable error rather than silently no-op.
pub async fn set_mode(&self, _mode: &str) -> Result<(), AgentError> {
match self {
Self::Acp(m) => m.set_mode(_mode).await,
Self::Aionrs(m) => m.set_mode(_mode).await,
Self::OpenClaw(_) => Err(AgentError::bad_request(
"Mode switching is not supported for OpenClaw agents",
)),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.set_mode(_mode).await,
}
}
/// Get the current session model info. Only ACP exposes a model
/// catalog; other variants report `model_info = None` so the UI can
/// hide the model picker without an error.
Expand All @@ -320,7 +345,7 @@ impl AgentInstance {
let model_info = merge_model_info(sdk_info, cc_switch_info);
Ok(GetModelInfoResponse { model_info })
}
Self::Aionrs(_) => Ok(GetModelInfoResponse { model_info: None }),
Self::Aionrs(_) | Self::OpenClaw(_) => Ok(GetModelInfoResponse { model_info: None }),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.get_model().await,
}
Expand All @@ -330,6 +355,9 @@ impl AgentInstance {
match self {
Self::Acp(m) => m.config_options().await,
Self::Aionrs(m) => m.config_options().await,
Self::OpenClaw(_) => Ok(GetConfigOptionsResponse {
config_options: Vec::new(),
}),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.get_config_options().await,
}
Expand All @@ -345,6 +373,9 @@ impl AgentInstance {
match self {
Self::Acp(m) => m.set_config_option_confirmed(option_id, value).await,
Self::Aionrs(m) => m.set_config_option(option_id, value).await,
Self::OpenClaw(_) => Err(AgentError::bad_request(
"Config option switching is not supported for OpenClaw agents",
)),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.set_config_option(option_id, value).await,
}
Expand All @@ -367,7 +398,7 @@ impl AgentInstance {
aionui_common::normalize_keys_to_snake_case(&mut value);
Ok(Some(value))
}
Self::Aionrs(_) => Ok(None),
Self::Aionrs(_) | Self::OpenClaw(_) => Ok(None),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.get_usage().await,
}
Expand All @@ -380,6 +411,7 @@ impl AgentInstance {
match self {
Self::Acp(m) => m.load_slash_commands().await,
Self::Aionrs(m) => m.get_slash_commands().await,
Self::OpenClaw(_) => Ok(Vec::new()),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(m) => m.get_slash_commands().await,
}
Expand All @@ -406,7 +438,7 @@ impl AgentInstance {
answer: Some("Side question support will be fully wired in app integration phase.".into()),
})
}
Self::Aionrs(_) => Ok(SideQuestionResponse {
Self::Aionrs(_) | Self::OpenClaw(_) => Ok(SideQuestionResponse {
status: "unsupported".into(),
answer: None,
}),
Expand Down
9 changes: 9 additions & 0 deletions crates/aionui-ai-agent/src/factory/aionrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,15 @@ pub(crate) fn resolve_aionrs_url_and_compat(
return (Some(base), compat);
}

// Zhipu (bigmodel.cn) uses /v4/chat/completions instead of /v1/chat/completions.
// The base_url already includes the version (e.g. .../paas/v4), so we only
// append /chat/completions.
if raw_base_url.to_lowercase().contains("bigmodel.cn") {
let trimmed = raw_base_url.trim_end_matches('/');
compat.api_path = Some("/chat/completions".to_owned());
return (Some(trimmed.to_owned()), compat);
}

let normalized = normalize_aionrs_base_url(raw_base_url);
let base_url = Some(normalized).filter(|u| !u.is_empty());

Expand Down
7 changes: 6 additions & 1 deletion crates/aionui-ai-agent/src/factory/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ pub mod acp_assembler;
mod acp;
pub(crate) mod aionrs;
mod context;
mod openclaw;

use std::path::PathBuf;
use std::sync::Arc;

use aionui_db::{IMcpServerRepository, IProviderRepository};
use aionui_db::{IMcpServerRepository, IProviderRepository, IRemoteAgentRepository};
use aionui_realtime::EventBroadcaster;
use futures_util::FutureExt;

Expand Down Expand Up @@ -39,6 +40,9 @@ pub struct AgentFactoryDeps {
/// inject enabled servers into `session/new` (ELECTRON-1JG fix).
/// `None` for tests/composition paths that do not need MCP injection.
pub mcp_server_repo: Option<Arc<dyn IMcpServerRepository>>,
/// Remote agent configuration repository. Required by the remote agent
/// factory to resolve `remote_agent_id` into gateway connection details.
pub remote_agent_repo: Arc<dyn IRemoteAgentRepository>,
}

/// Build a production agent factory that dispatches to concrete agent types.
Expand All @@ -64,6 +68,7 @@ async fn build_agent(deps: Arc<AgentFactoryDeps>, options: BuildTaskOptions) ->
match context.kind {
AgentSessionKind::Acp(acp_context) => acp::build(deps, *acp_context, ctx).await,
AgentSessionKind::Aionrs(aionrs_context) => aionrs::build(deps, *aionrs_context, model, ctx).await,
AgentSessionKind::OpenclawGateway(openclaw_context) => openclaw::build(deps, *openclaw_context, ctx).await,
}
}

Expand Down
99 changes: 99 additions & 0 deletions crates/aionui-ai-agent/src/factory/openclaw.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use std::sync::Arc;

use crate::AgentError;
use aionui_api_types::OpenClawBuildExtra;
use aionui_common::AgentType;
use tracing::warn;

use crate::agent_task::AgentInstance;
use crate::factory::AgentFactoryDeps;
use crate::factory::context::FactoryContext;
use crate::manager::openclaw::OpenClawAgentManager;

pub(super) async fn build(
deps: Arc<AgentFactoryDeps>,
mut config: OpenClawBuildExtra,
ctx: FactoryContext,
) -> Result<AgentInstance, AgentError> {
// If this is a remote-agent row, resolve the gateway details from the DB.
if let Some(remote_agent_id) = config.remote_agent_id.as_deref().filter(|id| !id.is_empty()) {
let row = deps
.remote_agent_repo
.find_by_id(remote_agent_id)
.await
.map_err(|e| AgentError::Internal(format!("Failed to load remote agent config: {e}")))?
.ok_or_else(|| AgentError::NotFound(format!("Remote agent '{remote_agent_id}' not found")))?;
let auth_token = row
.auth_token
.as_deref()
.filter(|t| !t.is_empty())
.and_then(|encrypted| {
aionui_common::decrypt_string(encrypted, &deps.encryption_key)
.map_err(|e| {
warn!(error = %e, "Failed to decrypt remote agent auth_token");
})
.ok()
});

let (host, port) = parse_ws_url(&row.url).unwrap_or_else(|| {
warn!(url = %row.url, "Failed to parse remote agent URL, using as-is");
(row.url.clone(), None)
});

config.backend = Some("remote".to_owned());
config.agent_name = Some(row.name.clone());
config.gateway.host = Some(host);
config.gateway.port = port;
config.gateway.token = auth_token;
config.gateway.password = None;
config.gateway.use_external_gateway = true;
config.gateway.cli_path = Some(row.url);
}

// OpenClaw lives in the catalog as an internal row; reuse
// the registry-resolved path instead of re-running `which()`.
if config.gateway.cli_path.is_none()
&& !config.gateway.use_external_gateway
&& let Some(cli) = deps
.agent_registry
.list_by_agent_type(AgentType::OpenclawGateway)
.await
.into_iter()
.find_map(|m| m.resolved_command)
.map(|p| p.to_string_lossy().into_owned())
{
config.gateway.cli_path = Some(cli);
}

let resume_session_key = config.session_key.clone();
let agent = OpenClawAgentManager::new(
ctx.conversation_id,
ctx.workspace,
config,
resume_session_key,
deps.data_dir.clone(),
)
.await?;
let mut arc = Arc::new(agent);
let weak = Arc::downgrade(&arc);
if let Some(manager) = Arc::get_mut(&mut arc) {
manager.set_weak_self(weak).await;
}
arc.start_event_relay();
Ok(AgentInstance::OpenClaw(arc))
}

/// Parse a WebSocket URL like `ws://127.0.0.1:18790` into (host, port).
fn parse_ws_url(url: &str) -> Option<(String, Option<u16>)> {
// Strip ws:// or wss:// prefix
let without_scheme = url.strip_prefix("ws://").or_else(|| url.strip_prefix("wss://"))?;

// Split host:port
if let Some(colon_pos) = without_scheme.rfind(':') {
let host = without_scheme[..colon_pos].to_owned();
let port = without_scheme[colon_pos + 1..].parse::<u16>().ok();
Some((host, port))
} else {
Some((without_scheme.to_owned(), None))
}
}
86 changes: 86 additions & 0 deletions crates/aionui-ai-agent/src/factory/remote.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use std::sync::Arc;

use aionui_api_types::{OpenClawBuildExtra, OpenClawGatewayConfig, RemoteBuildExtra};
use crate::AgentError;
use tracing::warn;

use crate::agent_task::AgentInstance;
use crate::factory::AgentFactoryDeps;
use crate::factory::context::FactoryContext;
use crate::types::BuildTaskOptions;

pub(super) async fn build(
deps: Arc<AgentFactoryDeps>,
options: BuildTaskOptions,
ctx: FactoryContext,
) -> Result<AgentInstance, AgentError> {
let extra: RemoteBuildExtra = serde_json::from_value(options.extra.clone())
.map_err(|e| AgentError::BadRequest(format!("Invalid Remote build options: {e}")))?;
let row = deps
.remote_agent_repo
.find_by_id(&extra.remote_agent_id)
.await
.map_err(|e| AgentError::Internal(format!("Failed to load remote agent config: {e}")))?
.ok_or_else(|| AgentError::NotFound(format!("Remote agent '{}' not found", extra.remote_agent_id)))?;
let auth_token = row
.auth_token
.as_deref()
.filter(|t| !t.is_empty())
.and_then(|encrypted| {
aionui_common::decrypt_string(encrypted, &deps.encryption_key)
.map_err(|e| {
warn!(error = %e, "Failed to decrypt remote agent auth_token");
})
.ok()
});

// Delegate to OpenClaw gateway — RemoteAgentManager's WebSocket is broken.
// Build gateway config from the remote agent's URL and auth token.
let (host, port) = parse_ws_url(&row.url).unwrap_or_else(|| {
warn!(url = %row.url, "Failed to parse remote agent URL, using as-is");
(row.url.clone(), None)
});

// Start from the original extra so fields like `skills`, `cron_job_id`,
// `session_key`, etc. set by the cron executor or frontend are preserved.
let mut openclaw_extra: OpenClawBuildExtra =
serde_json::from_value(options.extra.clone()).unwrap_or_else(|_| OpenClawBuildExtra {
backend: None,
agent_name: None,
gateway: OpenClawGatewayConfig::default(),
skills: Vec::new(),
preset_assistant_id: None,
cron_job_id: None,
session_key: None,
});

openclaw_extra.backend = Some("remote".to_owned());
openclaw_extra.agent_name = Some(row.name.clone());
openclaw_extra.gateway.host = Some(host);
openclaw_extra.gateway.port = port;
openclaw_extra.gateway.token = auth_token.clone();
openclaw_extra.gateway.password = None;
openclaw_extra.gateway.use_external_gateway = true;
openclaw_extra.gateway.cli_path = Some(row.url.clone());

let mut openclaw_options = options;
openclaw_options.extra = serde_json::to_value(&openclaw_extra)
.map_err(|e| AgentError::Internal(format!("Failed to serialize OpenClaw build extra: {e}")))?;

crate::factory::openclaw::build(deps, openclaw_options, ctx).await
}

/// Parse a WebSocket URL like `ws://127.0.0.1:18790` into (host, port).
fn parse_ws_url(url: &str) -> Option<(String, Option<u16>)> {
// Strip ws:// or wss:// prefix
let without_scheme = url.strip_prefix("ws://").or_else(|| url.strip_prefix("wss://"))?;

// Split host:port
if let Some(colon_pos) = without_scheme.rfind(':') {
let host = without_scheme[..colon_pos].to_owned();
let port = without_scheme[colon_pos + 1..].parse::<u16>().ok();
Some((host, port))
} else {
Some((without_scheme.to_owned(), None))
}
}
Loading