|
|
|
@@ -5,7 +5,6 @@ use crate::error::WebError;
|
|
|
|
|
use crate::hermes_tools::manifest;
|
|
|
|
|
use crate::transport::convex::execute_convex_query_by_name;
|
|
|
|
|
use axum::body::Body;
|
|
|
|
|
use tokio::sync::broadcast;
|
|
|
|
|
use axum::extract::{Extension, Path, Query, State};
|
|
|
|
|
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
|
|
|
|
use axum::response::Response;
|
|
|
|
@@ -20,6 +19,7 @@ use std::process::Command;
|
|
|
|
|
use std::sync::{Arc, LazyLock, Mutex};
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
use tokio::sync::broadcast;
|
|
|
|
|
use tracing::{info, warn};
|
|
|
|
|
|
|
|
|
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
|
|
|
@@ -32,6 +32,8 @@ static HERMES_RUN_QUEUE: LazyLock<Mutex<HashMap<String, VecDeque<HermesQueuedRun
|
|
|
|
|
/// Store ACP run payloads keyed by run_id, so stream_events can read them.
|
|
|
|
|
static ACP_RUN_PAYLOADS: LazyLock<Mutex<HashMap<String, Value>>> =
|
|
|
|
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
|
|
|
static ACP_ACTIVE_RUNS: LazyLock<Mutex<HashMap<String, AcpActiveRun>>> =
|
|
|
|
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct HermesRuntimeState {
|
|
|
|
@@ -71,6 +73,13 @@ struct HermesQueuedRun {
|
|
|
|
|
queued_at: u128,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
struct AcpActiveRun {
|
|
|
|
|
manager: Arc<crate::acp_session_manager::AcpSessionManager>,
|
|
|
|
|
mnote_session_id: String,
|
|
|
|
|
acp_session_id: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct CreateSessionRequest {
|
|
|
|
@@ -353,10 +362,15 @@ pub async fn list_skills(
|
|
|
|
|
.get("profile")
|
|
|
|
|
.map(String::as_str)
|
|
|
|
|
.unwrap_or(fallback_profile.as_str());
|
|
|
|
|
let runtime = query.get("runtime").map(String::as_str).unwrap_or(profile);
|
|
|
|
|
Ok((
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
stamp_client_headers(),
|
|
|
|
|
Json(skills_payload(profile)),
|
|
|
|
|
Json(if runtime == "reasonix" {
|
|
|
|
|
reasonix_skills_payload()
|
|
|
|
|
} else {
|
|
|
|
|
skills_payload(profile)
|
|
|
|
|
}),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -529,9 +543,15 @@ pub async fn create_run(
|
|
|
|
|
let registration = run_registration_from_payload(&context, &payload);
|
|
|
|
|
|
|
|
|
|
// ACP path: skip the HTTP proxy, just register and return run info
|
|
|
|
|
if is_acp_profile(®istration.profile) {
|
|
|
|
|
let run_id = registration.session_id.clone(); // session_id serves as run_id
|
|
|
|
|
let runtime_state = register_acp_runtime(®istration);
|
|
|
|
|
if acp_runtime_for_payload(&payload, ®istration.profile).is_some() {
|
|
|
|
|
let run_id = payload
|
|
|
|
|
.get("runId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.unwrap_or_else(|| new_acp_run_id(®istration));
|
|
|
|
|
payload["runId"] = Value::String(run_id.clone());
|
|
|
|
|
let runtime_state = register_acp_runtime(®istration, &run_id);
|
|
|
|
|
// Store payload for stream_events to use
|
|
|
|
|
ACP_RUN_PAYLOADS
|
|
|
|
|
.lock()
|
|
|
|
@@ -600,6 +620,7 @@ async fn acp_stream_events(
|
|
|
|
|
context: RequestContext,
|
|
|
|
|
run_id: &str,
|
|
|
|
|
profile: &str,
|
|
|
|
|
acp_runtime_name: &str,
|
|
|
|
|
) -> Result<Response, WebError> {
|
|
|
|
|
// Get the stored payload from create_run
|
|
|
|
|
let payload = ACP_RUN_PAYLOADS
|
|
|
|
@@ -621,14 +642,36 @@ async fn acp_stream_events(
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or("请读取当前文档内容");
|
|
|
|
|
|
|
|
|
|
let prompt_blocks = vec![ContentBlock::Text {
|
|
|
|
|
let mut prompt_blocks = Vec::new();
|
|
|
|
|
if let Ok(upstream_body) = build_run_upstream_body(&context, payload.clone()) {
|
|
|
|
|
if let Some(instructions) = upstream_body.get("instructions").and_then(Value::as_str) {
|
|
|
|
|
prompt_blocks.push(ContentBlock::Text {
|
|
|
|
|
text: format!(
|
|
|
|
|
"以下是 mnote 页面 AI 的冻结上下文与工具约束,请在本轮回答中遵守:\n{instructions}"
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
prompt_blocks.push(ContentBlock::Text {
|
|
|
|
|
text: input.to_string(),
|
|
|
|
|
}];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let runtime_name = crate::acp_bridge::runtime_name_for_profile(profile);
|
|
|
|
|
let runtime_name = acp_runtime_name;
|
|
|
|
|
|
|
|
|
|
// Ensure runtime is active; switch_to either activates it or returns existing
|
|
|
|
|
let client = state.acp_runtime.switch_to(runtime_name).await.map_err(|e| {
|
|
|
|
|
let client = if runtime_name == "hermes" {
|
|
|
|
|
let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN")
|
|
|
|
|
.ok()
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.unwrap_or_else(|| "hermes".into());
|
|
|
|
|
let mut config =
|
|
|
|
|
crate::acp_runtime::AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(profile));
|
|
|
|
|
config.env = acp_hermes_env_for_profile(profile);
|
|
|
|
|
state.acp_runtime.switch_to_config(config).await
|
|
|
|
|
} else {
|
|
|
|
|
state.acp_runtime.switch_to(runtime_name).await
|
|
|
|
|
}
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
WebError::bad_gateway_code(
|
|
|
|
|
"acp_runtime_switch_failed",
|
|
|
|
|
format!("Failed to activate ACP runtime '{runtime_name}': {e}"),
|
|
|
|
@@ -647,15 +690,50 @@ async fn acp_stream_events(
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
mgr.create_session(None, None)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
WebError::bad_gateway_code(
|
|
|
|
|
"acp_session_create_failed",
|
|
|
|
|
format!("ACP session creation failed: {e}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
})?;
|
|
|
|
|
let acp_session_id = mgr.create_session(None, None).await.map_err(|e| {
|
|
|
|
|
WebError::bad_gateway_code(
|
|
|
|
|
"acp_session_create_failed",
|
|
|
|
|
format!("ACP session creation failed: {e}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
})?;
|
|
|
|
|
let mnote_session_id = session_id_for_run(run_id).unwrap_or_else(|| run_id.to_string());
|
|
|
|
|
ACP_ACTIVE_RUNS.lock().expect("acp active runs").insert(
|
|
|
|
|
run_id.to_string(),
|
|
|
|
|
AcpActiveRun {
|
|
|
|
|
manager: Arc::clone(&mgr),
|
|
|
|
|
mnote_session_id,
|
|
|
|
|
acp_session_id,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Build SSE response from event channel FIRST (before running prompt),
|
|
|
|
|
// so that if the prompt fails quickly, events are not lost.
|
|
|
|
|
use tokio::sync::mpsc;
|
|
|
|
|
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
|
|
|
|
|
let mut event_rx = event_tx.subscribe();
|
|
|
|
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
loop {
|
|
|
|
|
match event_rx.recv().await {
|
|
|
|
|
Ok(event) => {
|
|
|
|
|
let json_str =
|
|
|
|
|
serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
|
|
|
|
|
let bytes = axum::body::Bytes::from(format!(
|
|
|
|
|
"event: {}\ndata: {}\n\n",
|
|
|
|
|
event.event, json_str
|
|
|
|
|
));
|
|
|
|
|
if tx.send(Ok(bytes)).await.is_err() {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
|
|
|
warn!("ACP SSE lagged: {n} events dropped");
|
|
|
|
|
}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Run prompt in background
|
|
|
|
|
let run_id_owned = run_id.to_string();
|
|
|
|
@@ -681,29 +759,16 @@ async fn acp_stream_events(
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Build SSE response from event channel
|
|
|
|
|
use tokio::sync::mpsc;
|
|
|
|
|
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
|
|
|
|
|
let mut event_rx = event_tx.subscribe();
|
|
|
|
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
loop {
|
|
|
|
|
match event_rx.recv().await {
|
|
|
|
|
Ok(event) => {
|
|
|
|
|
let json_str = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
|
|
|
|
|
let bytes = axum::body::Bytes::from(
|
|
|
|
|
format!("event: {}\ndata: {}\n\n", event.event, json_str)
|
|
|
|
|
);
|
|
|
|
|
if tx.send(Ok(bytes)).await.is_err() { break; }
|
|
|
|
|
}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
|
|
|
warn!("ACP SSE lagged: {n} events dropped");
|
|
|
|
|
}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
|
|
|
}
|
|
|
|
|
mgr_clone.close().await;
|
|
|
|
|
ACP_ACTIVE_RUNS
|
|
|
|
|
.lock()
|
|
|
|
|
.expect("acp active runs")
|
|
|
|
|
.remove(&run_id_owned);
|
|
|
|
|
if !matches!(
|
|
|
|
|
runtime_status_for_run(&run_id_owned).as_deref(),
|
|
|
|
|
Some("aborting" | "aborted")
|
|
|
|
|
) {
|
|
|
|
|
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -715,8 +780,7 @@ async fn acp_stream_events(
|
|
|
|
|
.header("x-accel-buffering", "no")
|
|
|
|
|
.body(Body::from_stream(stream))
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
WebError::internal(format!("SSE response build failed: {e}"))
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
WebError::internal(format!("SSE response build failed: {e}")).with_context(&context)
|
|
|
|
|
})?;
|
|
|
|
|
stamp_client_headers_into(response.headers_mut());
|
|
|
|
|
Ok(response)
|
|
|
|
@@ -731,8 +795,8 @@ pub async fn stream_events(
|
|
|
|
|
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
|
|
|
|
|
|
|
|
|
|
// ACP path: start AcpRunBridge and return SSE stream
|
|
|
|
|
if is_acp_profile(&profile) {
|
|
|
|
|
return acp_stream_events(state, context, &run_id, &profile).await;
|
|
|
|
|
if let Some(acp_runtime_name) = acp_runtime_for_run(&run_id, &profile) {
|
|
|
|
|
return acp_stream_events(state, context, &run_id, &profile, &acp_runtime_name).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let Some(upstream) = configured_upstream_for_profile(&profile) else {
|
|
|
|
@@ -808,6 +872,51 @@ pub async fn abort_run(
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
|
|
|
|
ensure_authenticated(&context)?;
|
|
|
|
|
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
|
|
|
|
|
if acp_runtime_for_run(&run_id, &profile).is_some() {
|
|
|
|
|
let active = ACP_ACTIVE_RUNS
|
|
|
|
|
.lock()
|
|
|
|
|
.expect("acp active runs")
|
|
|
|
|
.remove(&run_id);
|
|
|
|
|
update_runtime_by_run_id(&run_id, "aborting", Some("abort.started"), None);
|
|
|
|
|
let Some(active) = active else {
|
|
|
|
|
update_runtime_by_run_id(&run_id, "aborted", Some("abort.missing_active_run"), None);
|
|
|
|
|
return Ok((
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
stamp_client_headers(),
|
|
|
|
|
Json(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"runId": run_id,
|
|
|
|
|
"status": "aborted",
|
|
|
|
|
"runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
|
|
|
|
|
"events": [
|
|
|
|
|
{"event": "abort.started", "runId": run_id},
|
|
|
|
|
{"event": "abort.completed", "runId": run_id, "note": "active ACP run was already finished or missing"}
|
|
|
|
|
]
|
|
|
|
|
})),
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
active.manager.cancel().await.map_err(|error| {
|
|
|
|
|
WebError::bad_gateway_code("acp_abort_failed", format!("ACP abort failed: {error}"))
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
})?;
|
|
|
|
|
update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None);
|
|
|
|
|
return Ok((
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
stamp_client_headers(),
|
|
|
|
|
Json(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"runId": run_id,
|
|
|
|
|
"sessionId": active.mnote_session_id,
|
|
|
|
|
"acpSessionId": active.acp_session_id,
|
|
|
|
|
"status": "aborted",
|
|
|
|
|
"runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
|
|
|
|
|
"events": [
|
|
|
|
|
{"event": "abort.started", "runId": run_id},
|
|
|
|
|
{"event": "abort.completed", "runId": run_id}
|
|
|
|
|
]
|
|
|
|
|
})),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let Some(upstream) = configured_upstream_for_profile(&profile) else {
|
|
|
|
|
return hermes_unconfigured(&context);
|
|
|
|
|
};
|
|
|
|
@@ -1467,6 +1576,141 @@ fn skills_payload(profile: &str) -> Value {
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reasonix_skill_roots() -> Vec<(PathBuf, &'static str)> {
|
|
|
|
|
let mut roots = Vec::new();
|
|
|
|
|
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
|
|
|
.ancestors()
|
|
|
|
|
.nth(3)
|
|
|
|
|
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
|
|
|
|
|
.to_path_buf();
|
|
|
|
|
roots.push((project_root.join(".reasonix").join("skills"), "project"));
|
|
|
|
|
roots.push((project_root.join(".agents").join("skills"), "project"));
|
|
|
|
|
if let Ok(home) = std::env::var("HOME") {
|
|
|
|
|
let home = PathBuf::from(home);
|
|
|
|
|
roots.push((home.join(".reasonix").join("skills"), "global"));
|
|
|
|
|
roots.push((home.join(".agents").join("skills"), "global"));
|
|
|
|
|
}
|
|
|
|
|
roots
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reasonix_skill_entry(path: PathBuf, stem: String, scope: &str) -> Option<Value> {
|
|
|
|
|
let content = fs::read_to_string(&path).ok()?;
|
|
|
|
|
let metadata = parse_reasonix_skill_frontmatter(&content);
|
|
|
|
|
let name = metadata
|
|
|
|
|
.get("name")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.unwrap_or(&stem)
|
|
|
|
|
.to_string();
|
|
|
|
|
Some(json!({
|
|
|
|
|
"name": name,
|
|
|
|
|
"description": metadata
|
|
|
|
|
.get("description")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.unwrap_or_else(|| extract_skill_description(&content)),
|
|
|
|
|
"enabled": true,
|
|
|
|
|
"toggleable": false,
|
|
|
|
|
"source": "reasonix",
|
|
|
|
|
"origin": scope,
|
|
|
|
|
"category": scope,
|
|
|
|
|
"path": path.to_string_lossy(),
|
|
|
|
|
"scope": scope,
|
|
|
|
|
"runAs": metadata.get("runAs").cloned().unwrap_or(Value::Null),
|
|
|
|
|
"model": metadata.get("model").cloned().unwrap_or(Value::Null)
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_reasonix_skill_frontmatter(markdown: &str) -> serde_json::Map<String, Value> {
|
|
|
|
|
let mut metadata = serde_json::Map::new();
|
|
|
|
|
let mut lines = markdown.lines();
|
|
|
|
|
if lines.next().map(str::trim) != Some("---") {
|
|
|
|
|
return metadata;
|
|
|
|
|
}
|
|
|
|
|
for line in lines {
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
if trimmed == "---" {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
let Some((key, value)) = trimmed.split_once(':') else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let normalized = value.trim().trim_matches('"').trim_matches('\'');
|
|
|
|
|
if !key.trim().is_empty() && !normalized.is_empty() {
|
|
|
|
|
metadata.insert(
|
|
|
|
|
key.trim().to_string(),
|
|
|
|
|
Value::String(normalized.to_string()),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
metadata
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reasonix_skills_payload() -> Value {
|
|
|
|
|
let mut seen = HashSet::new();
|
|
|
|
|
let mut by_scope: HashMap<String, Vec<Value>> = HashMap::new();
|
|
|
|
|
for (root, scope) in reasonix_skill_roots() {
|
|
|
|
|
let Ok(entries) = fs::read_dir(&root) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let mut entries = entries.filter_map(Result::ok).collect::<Vec<_>>();
|
|
|
|
|
entries.sort_by_key(|entry| entry.file_name());
|
|
|
|
|
for entry in entries {
|
|
|
|
|
let file_name = entry.file_name().to_string_lossy().to_string();
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
let skill = if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
|
|
|
|
|
reasonix_skill_entry(path.join("SKILL.md"), file_name, scope)
|
|
|
|
|
} else if entry
|
|
|
|
|
.file_type()
|
|
|
|
|
.map(|kind| kind.is_file())
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
&& file_name.ends_with(".md")
|
|
|
|
|
{
|
|
|
|
|
let stem = file_name.trim_end_matches(".md").to_string();
|
|
|
|
|
reasonix_skill_entry(path, stem, scope)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
let Some(skill) = skill else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let Some(name) = skill.get("name").and_then(Value::as_str) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if !seen.insert(name.to_string()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
by_scope.entry(scope.to_string()).or_default().push(skill);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let mut categories = by_scope
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(scope, mut skills)| {
|
|
|
|
|
skills.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string());
|
|
|
|
|
json!({
|
|
|
|
|
"name": scope,
|
|
|
|
|
"description": format!("Reasonix {scope} skills"),
|
|
|
|
|
"skills": skills
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
categories.sort_by_key(
|
|
|
|
|
|category| match category["name"].as_str().unwrap_or_default() {
|
|
|
|
|
"project" => 0,
|
|
|
|
|
"custom" => 1,
|
|
|
|
|
"global" => 2,
|
|
|
|
|
_ => 3,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"runtime": "reasonix",
|
|
|
|
|
"categories": categories,
|
|
|
|
|
"archived": []
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn merge_misc_categories(categories: Vec<Value>) -> Vec<Value> {
|
|
|
|
|
let mut merged = Vec::new();
|
|
|
|
|
let mut misc = Vec::new();
|
|
|
|
@@ -1670,6 +1914,82 @@ fn is_acp_profile(profile: &str) -> bool {
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn acp_runtime_for_payload(payload: &Value, profile: &str) -> Option<String> {
|
|
|
|
|
if let Some(runtime) = payload
|
|
|
|
|
.get("acpRuntime")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
{
|
|
|
|
|
return Some(runtime.to_lowercase());
|
|
|
|
|
}
|
|
|
|
|
if is_acp_profile(profile) {
|
|
|
|
|
return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn acp_runtime_for_run(run_id: &str, profile: &str) -> Option<String> {
|
|
|
|
|
if let Some(payload) = ACP_RUN_PAYLOADS
|
|
|
|
|
.lock()
|
|
|
|
|
.expect("acp run payloads")
|
|
|
|
|
.get(run_id)
|
|
|
|
|
.cloned()
|
|
|
|
|
{
|
|
|
|
|
return acp_runtime_for_payload(&payload, profile);
|
|
|
|
|
}
|
|
|
|
|
if ACP_ACTIVE_RUNS
|
|
|
|
|
.lock()
|
|
|
|
|
.expect("acp active runs")
|
|
|
|
|
.contains_key(run_id)
|
|
|
|
|
{
|
|
|
|
|
return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
|
|
|
|
|
}
|
|
|
|
|
if is_acp_profile(profile) {
|
|
|
|
|
return Some(crate::acp_bridge::runtime_name_for_profile(profile).to_string());
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn provider_default_key_env(provider: &str) -> Option<&'static str> {
|
|
|
|
|
match provider.trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
"deepseek" => Some("DEEPSEEK_API_KEY"),
|
|
|
|
|
"openrouter" => Some("OPENROUTER_API_KEY"),
|
|
|
|
|
"omniroute" => Some("OMNIROUTE_API_KEY"),
|
|
|
|
|
"openai" | "custom" => Some("OPENAI_API_KEY"),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn acp_hermes_env_for_profile(profile: &str) -> Option<HashMap<String, String>> {
|
|
|
|
|
let config = fs::read_to_string(profile_config_path(profile)).ok()?;
|
|
|
|
|
let provider = yaml_path_value(&config, &["model", "provider"]).unwrap_or_default();
|
|
|
|
|
let key_env = yaml_path_value(&config, &["model", "key_env"])
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
if provider.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
yaml_path_value(&config, &["providers", &provider, "key_env"])
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.or_else(|| provider_default_key_env(&provider).map(str::to_string))?;
|
|
|
|
|
let key = env_or_dotenv(&key_env)
|
|
|
|
|
.or_else(|| yaml_path_value(&config, &["model", "api_key"]))
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
if provider.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
yaml_path_value(&config, &["providers", &provider, "api_key"])
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.map(|value| value.trim().to_string())
|
|
|
|
|
.filter(|value| !value.is_empty())?;
|
|
|
|
|
|
|
|
|
|
let mut env = HashMap::new();
|
|
|
|
|
env.insert(key_env, key);
|
|
|
|
|
Some(env)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the ACP runtime name for a profile.
|
|
|
|
|
/// For ACP profiles, returns the runtime backend name ("hermes" or "reasonix").
|
|
|
|
|
/// The profile name is used as the runtime name unless overridden by env var.
|
|
|
|
@@ -2229,6 +2549,16 @@ fn runtime_state_for_run(run_id: &str) -> Option<Value> {
|
|
|
|
|
.map(runtime_state_to_json)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn runtime_status_for_run(run_id: &str) -> Option<String> {
|
|
|
|
|
let registry = HERMES_RUNTIME_REGISTRY
|
|
|
|
|
.lock()
|
|
|
|
|
.expect("hermes runtime registry");
|
|
|
|
|
registry
|
|
|
|
|
.values()
|
|
|
|
|
.find(|state| state.run_id == run_id)
|
|
|
|
|
.map(|state| state.status.clone())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_registration_from_payload(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
payload: &Value,
|
|
|
|
@@ -2268,12 +2598,15 @@ fn run_registration_from_payload(
|
|
|
|
|
|
|
|
|
|
/// Register a runtime state from a registration (without upstream response).
|
|
|
|
|
/// Used by the ACP path where no Hermes HTTP upstream exists.
|
|
|
|
|
fn register_acp_runtime(registration: &HermesRunRegistration) -> Value {
|
|
|
|
|
fn new_acp_run_id(registration: &HermesRunRegistration) -> String {
|
|
|
|
|
format!("run_{}_{}", registration.trace_id, now_ms())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn register_acp_runtime(registration: &HermesRunRegistration, run_id: &str) -> Value {
|
|
|
|
|
let now = now_ms();
|
|
|
|
|
let run_id = registration.session_id.clone(); // use session_id as run_id for ACP
|
|
|
|
|
let state = HermesRuntimeState {
|
|
|
|
|
session_id: registration.session_id.clone(),
|
|
|
|
|
run_id,
|
|
|
|
|
run_id: run_id.to_string(),
|
|
|
|
|
profile: registration.profile.clone(),
|
|
|
|
|
document_id: registration.document_id.clone(),
|
|
|
|
|
trace_id: registration.trace_id.clone(),
|
|
|
|
|