feat: stabilize page AI ACP runtimes
实现并稳定页面 AI 的 ACP Hermes / ACP Reasonix 运行路径。 主要内容: - 分离 profile 与 acpRuntime,ACP Hermes 按所选 Hermes profile 启动并注入 provider key。 - 修复 Reasonix ACP wrapper 的 API key 读取、ToolRegistry 注册、LoopEvent role 映射和 reasoning/final 分流。 - 修复 ACP agent_thought_chunk 被 untagged enum 误解析为 message.delta 的问题,补充 thought 相关单测。 - 补充页面 AI 浏览器验证 skill 证据到 7-15 设计稿,并记录严格验收标准。 - 同步提交当前仓库中已存在的 rust-web / Hermes tools / SSE / bug 文档相关改动。 验证: - node --check scripts/reasonix-acp-wrapper.mjs - cargo test -p mnote-web acp -- --nocapture - 页面 AI ACP 浏览器验证:tmp/page-ai-acp-browser-UAYwyM/
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user