use serde_json::{Value, json};
use super::SmDispatcher;
use super::control::{LaunchParams, SessionControlError};
#[derive(Debug, thiserror::Error)]
pub enum MethodError {
#[error("{0}")]
InvalidParams(String),
#[error("{0}")]
NotFound(String),
#[error("{0}")]
Unavailable(String),
#[error("{0}")]
Internal(String),
}
pub const CODE_NOT_FOUND: i32 = -32001;
pub const CODE_UNAVAILABLE: i32 = -32002;
fn ensure_obj(params: &Value) -> Result<(), MethodError> {
match params {
Value::Null | Value::Object(_) => Ok(()),
other => Err(MethodError::InvalidParams(format!(
"params must be a JSON object, got {other}"
))),
}
}
fn req_str(params: &Value, field: &str) -> Result<String, MethodError> {
ensure_obj(params)?;
match params.get(field).and_then(Value::as_str) {
Some(s) if !s.trim().is_empty() => Ok(s.to_string()),
Some(_) => Err(MethodError::InvalidParams(format!(
"param `{field}` must not be blank"
))),
None => Err(MethodError::InvalidParams(format!(
"missing required string param `{field}`"
))),
}
}
fn opt_str(params: &Value, field: &str) -> Option<String> {
params
.get(field)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_string)
}
pub async fn sm_chat(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let message = req_str(params, "message")?;
let conv_id = opt_str(params, "conv_id");
match d.agent.chat(&message, conv_id.as_deref()).await {
Ok(outcome) => Ok(json!({
"reply": outcome.reply,
"conv_id": outcome.conv_id,
"cost": outcome.cost_usd,
})),
Err(crate::core::sm::SmAgentError::Degraded(notice)) => {
Err(MethodError::Unavailable(notice))
}
Err(e) => Err(MethodError::Internal(e.to_string())),
}
}
#[cfg(feature = "sm-memory")]
pub async fn sm_delegate(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let message = req_str(params, "message")?;
match d.agent.delegate_goal(&message, &d.sessions, &d.goals).await {
Ok(outcome) => Ok(json!({
"reply": outcome.reply,
"goal_id": outcome.goal_id,
"launched": outcome.launched,
"goal_done": outcome.goal_done,
"goal_status": outcome.goal_status,
})),
Err(crate::core::sm::agent::DelegationError::Degraded(notice)) => {
Err(MethodError::Unavailable(notice))
}
Err(e) => Err(MethodError::Internal(e.to_string())),
}
}
#[cfg(not(feature = "sm-memory"))]
pub async fn sm_delegate(_d: &SmDispatcher, _params: &Value) -> Result<Value, MethodError> {
Err(MethodError::Unavailable(
"sm.delegate is not available without the sm-memory feature".to_string(),
))
}
pub async fn sm_health(d: &SmDispatcher, _params: &Value) -> Result<Value, MethodError> {
let health = d.agent.health().await;
serde_json::to_value(health).map_err(|e| MethodError::Internal(e.to_string()))
}
pub async fn sm_sessions_launch(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let workdir = req_str(params, "workdir")?;
let goal_id = opt_str(params, "goal_id");
let launch = LaunchParams {
workdir,
model: opt_str(params, "model"),
prompt: opt_str(params, "prompt"),
goal_id: goal_id.clone(),
};
let result = d.sessions.launch(launch).await.map_err(map_control_err)?;
if let Some(goal_id) = goal_id {
let session_id = result
.get("session_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let linked = link_session_to_goal(d, &goal_id, &session_id).await;
if !linked {
tracing::warn!(
session_id = %session_id,
goal_id = %goal_id,
"sm.sessions.launch: goal↔session link failed (best-effort); session launched but not linked"
);
}
let mut out = result;
if let Some(obj) = out.as_object_mut() {
obj.insert("goal_id".to_string(), json!(goal_id));
obj.insert("linked".to_string(), json!(linked));
}
return Ok(out);
}
Ok(result)
}
pub async fn sm_sessions_list(d: &SmDispatcher, _params: &Value) -> Result<Value, MethodError> {
d.sessions.list().await.map_err(map_control_err)
}
pub async fn sm_sessions_get(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let session_id = req_str(params, "session_id")?;
d.sessions.get(&session_id).await.map_err(map_control_err)
}
pub async fn sm_sessions_send(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let session_id = req_str(params, "session_id")?;
let text = req_str(params, "text")?;
d.sessions
.send(&session_id, &text)
.await
.map_err(map_control_err)
}
pub async fn sm_sessions_stop(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let session_id = req_str(params, "session_id")?;
d.sessions.stop(&session_id).await.map_err(map_control_err)
}
pub async fn sm_sessions_resume(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let session_id = req_str(params, "session_id")?;
d.sessions
.resume(&session_id)
.await
.map_err(map_control_err)
}
pub async fn sm_sessions_kill(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let session_id = req_str(params, "session_id")?;
d.sessions.kill(&session_id).await.map_err(map_control_err)
}
fn map_control_err(e: SessionControlError) -> MethodError {
match e {
SessionControlError::NotFound(msg) => MethodError::NotFound(msg),
SessionControlError::Backend(msg) => MethodError::Internal(msg),
}
}
#[cfg(feature = "sm-memory")]
pub async fn sm_context_get(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
use crate::core::sm::SmContextEngine;
let conv_id = req_str(params, "conv_id")?;
let engine = SmContextEngine::open(
&conv_id,
&d.data_root,
&d.config.inference,
&d.config.rounds,
)
.map_err(|e| MethodError::Internal(e.to_string()))?;
let conv = engine.conversation();
let recent: Vec<Value> = conv
.recent_rounds
.iter()
.map(|r| json!({ "user": r.user, "assistant": r.assistant }))
.collect();
Ok(json!({
"compressed_context": conv.compressed_context,
"recent_rounds": recent,
"total_rounds": conv.total_rounds,
"token_estimate": conv.token_estimate,
}))
}
#[cfg(not(feature = "sm-memory"))]
pub async fn sm_context_get(_d: &SmDispatcher, _params: &Value) -> Result<Value, MethodError> {
Err(MethodError::Unavailable(
"sm.context.get is not available without the sm-memory feature".to_string(),
))
}
#[cfg(feature = "sm-memory")]
pub async fn sm_goals_list(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let status = opt_str(params, "status");
let store = d.goals.lock().await;
let goals: Vec<_> = store
.all()
.into_iter()
.filter(|g| match &status {
Some(s) => goal_status_str(g) == s.to_ascii_lowercase(),
None => true,
})
.collect();
serde_json::to_value(json!({ "goals": goals }))
.map_err(|e| MethodError::Internal(e.to_string()))
}
#[cfg(feature = "sm-memory")]
pub async fn sm_goals_create(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let description = req_str(params, "description")?;
let acceptance = params
.get("acceptance")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut store = d.goals.lock().await;
let goal = store
.create(description, acceptance)
.await
.map_err(|e| MethodError::Internal(e.to_string()))?;
serde_json::to_value(json!({ "goal": goal })).map_err(|e| MethodError::Internal(e.to_string()))
}
#[cfg(feature = "sm-memory")]
pub async fn sm_goals_update(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
let id = req_str(params, "id")?;
let note = opt_str(params, "note");
let status = opt_str(params, "status");
let mut store = d.goals.lock().await;
if let Some(note) = note {
store.note(&id, note).await.map_err(map_goal_err)?;
}
if let Some(status) = status {
let parsed = parse_goal_status(&status)?;
store.set_status(&id, parsed).await.map_err(map_goal_err)?;
}
let goal = store
.get(&id)
.cloned()
.ok_or_else(|| MethodError::NotFound(format!("goal not found: {id}")))?;
serde_json::to_value(json!({ "goal": goal })).map_err(|e| MethodError::Internal(e.to_string()))
}
#[cfg(feature = "sm-memory")]
fn goal_status_str(goal: &crate::core::sm::Goal) -> String {
use crate::core::sm::GoalStatus;
match goal.status {
GoalStatus::Pending => "pending",
GoalStatus::InProgress => "inprogress",
GoalStatus::Blocked => "blocked",
GoalStatus::Done => "done",
GoalStatus::Abandoned => "abandoned",
}
.to_string()
}
#[cfg(feature = "sm-memory")]
fn parse_goal_status(s: &str) -> Result<crate::core::sm::GoalStatus, MethodError> {
use crate::core::sm::GoalStatus;
match s
.trim()
.to_ascii_lowercase()
.replace(['_', '-'], "")
.as_str()
{
"pending" => Ok(GoalStatus::Pending),
"inprogress" => Ok(GoalStatus::InProgress),
"blocked" => Ok(GoalStatus::Blocked),
"done" => Ok(GoalStatus::Done),
"abandoned" => Ok(GoalStatus::Abandoned),
other => Err(MethodError::InvalidParams(format!(
"unknown goal status {other:?}; expected one of: \
pending, inProgress, blocked, done, abandoned"
))),
}
}
#[cfg(feature = "sm-memory")]
fn map_goal_err(e: crate::core::sm::SmGoalError) -> MethodError {
match e {
crate::core::sm::SmGoalError::NotFound(msg) => {
MethodError::NotFound(format!("not found: {msg}"))
}
other => MethodError::Internal(other.to_string()),
}
}
#[cfg(feature = "sm-memory")]
async fn link_session_to_goal(d: &SmDispatcher, goal_id: &str, session_id: &str) -> bool {
use crate::core::sm::SessionLink;
let mut store = d.goals.lock().await;
store
.link(
goal_id,
SessionLink::launched(session_id, "session-manager launched task"),
)
.await
.is_ok()
}
#[cfg(not(feature = "sm-memory"))]
async fn link_session_to_goal(_d: &SmDispatcher, _goal_id: &str, _session_id: &str) -> bool {
false
}
#[cfg(not(feature = "sm-memory"))]
pub async fn sm_goals_list(_d: &SmDispatcher, _params: &Value) -> Result<Value, MethodError> {
goals_unavailable()
}
#[cfg(not(feature = "sm-memory"))]
pub async fn sm_goals_create(_d: &SmDispatcher, _params: &Value) -> Result<Value, MethodError> {
goals_unavailable()
}
#[cfg(not(feature = "sm-memory"))]
pub async fn sm_goals_update(_d: &SmDispatcher, _params: &Value) -> Result<Value, MethodError> {
goals_unavailable()
}
#[cfg(not(feature = "sm-memory"))]
fn goals_unavailable() -> Result<Value, MethodError> {
Err(MethodError::Unavailable(
"sm.goals.* are not available without the sm-memory feature".to_string(),
))
}