use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use serde_json::json;
use trusty_common::inference::{ChatMessage, ChatRequest};
use super::act::{ActResponse, execute_action, propose_message};
use super::actuator::resolve_actuator;
use super::chat_store::{ChatTurn, TurnRole};
use super::inference::{MANAGER_MAX_TOKENS, MANAGER_TEMPERATURE};
use super::proposal::{extract_proposed_action, is_confirmation};
use super::status::{PortfolioStatusResponse, load_portfolio_status};
use crate::daemon::error::DaemonError;
use crate::daemon::state::DaemonState;
#[derive(Debug, Deserialize)]
pub struct ChatRequestBody {
pub conversation_key: String,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct ChatReplyBody {
pub conversation_key: String,
pub reply: String,
pub model: String,
pub turn_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub action_result: Option<ActResponse>,
}
pub fn build_chat_messages(
status: &PortfolioStatusResponse,
history: &[ChatTurn],
user_message: &str,
) -> Vec<ChatMessage> {
let snapshot = serde_json::to_string_pretty(status)
.unwrap_or_else(|_| "{\"error\":\"snapshot serialization failed\"}".to_string());
let system = format!(
"You are the portfolio manager for a software developer running many coding \
sessions across multiple projects. Answer questions about the portfolio using \
ONLY the deterministic status snapshot below and the conversation so far — never \
invent projects, sessions, or counts. You are advisory: you must NOT claim to have \
already launched, injected, killed, resumed, or changed anything — you may only \
PROPOSE an action, which a human confirms separately before it runs.\n\n\
If — and ONLY if — the user explicitly asks you to launch a session for a \
project, send/inject a message into a specific session, or summarize a specific \
session, you MAY end your reply with exactly one fenced proposal block in this \
exact form (a bare JSON object, no extra keys):\n\
```manager-action\n\
{{\"type\":\"launch\",\"project\":\"<project name>\",\"task\":\"<task text>\"}}\n\
```\n\
or\n\
```manager-action\n\
{{\"type\":\"inject\",\"session\":\"<session id or name>\",\"text\":\"<message text>\"}}\n\
```\n\
or\n\
```manager-action\n\
{{\"type\":\"summarize\",\"session\":\"<session id or name>\"}}\n\
```\n\
Never include a proposal block unless the user asked for one of these three \
actions. Never propose more than one action in a single reply. The proposal is \
NOT executed automatically — a human must reply on this same conversation with an \
explicit confirmation before anything runs.\n\n\
Current portfolio snapshot (JSON):\n{snapshot}"
);
let mut messages = Vec::with_capacity(history.len() + 2);
messages.push(ChatMessage::system(system));
for turn in history {
match turn.role {
TurnRole::User => messages.push(ChatMessage::user(turn.content.clone())),
TurnRole::Assistant => messages.push(ChatMessage::assistant(turn.content.clone())),
}
}
messages.push(ChatMessage::user(user_message.to_string()));
messages
}
fn chat_error(status: StatusCode, error: &str, message: String) -> axum::response::Response {
(status, Json(json!({ "error": error, "message": message }))).into_response()
}
fn describe_action_result(result: &ActResponse) -> String {
match result {
ActResponse::Proposed { proposal, .. } => proposal.clone(),
ActResponse::Launched {
session_id, name, ..
} => format!("Launched session '{name}' ({session_id})."),
ActResponse::Injected { name, text, .. } => {
format!("Sent to session '{name}': {text:?}")
}
ActResponse::Summarized { name, summary, .. } => {
format!("Session '{name}': {summary}")
}
ActResponse::SessionNotFound { session, error } => {
format!("Could not find session '{session}': {error}")
}
ActResponse::SessionVanished { session, error } => {
format!("Session '{session}' is gone: {error}")
}
ActResponse::ActionFailed { session, error } => {
format!("Action on session '{session}' failed: {error}")
}
}
}
pub async fn manager_chat_route(
State(state): State<Arc<DaemonState>>,
Json(body): Json<ChatRequestBody>,
) -> impl IntoResponse {
match chat_op(&state, body).await {
Ok(reply) => Json(reply).into_response(),
Err(e) => render_chat_error(e),
}
}
fn render_chat_error(e: DaemonError) -> axum::response::Response {
match &e {
DaemonError::InvalidRequest(m) => {
chat_error(StatusCode::BAD_REQUEST, "invalid_request", m.clone())
}
DaemonError::ServiceUnavailable(m) => chat_error(
StatusCode::SERVICE_UNAVAILABLE,
"inference_unavailable",
m.clone(),
),
DaemonError::UpstreamFailed(m) => {
chat_error(StatusCode::BAD_GATEWAY, "inference_failed", m.clone())
}
other => (other.status(), other.detail()).into_response(),
}
}
pub async fn chat_op(
state: &Arc<DaemonState>,
body: ChatRequestBody,
) -> Result<ChatReplyBody, DaemonError> {
let conversation_key = body.conversation_key.trim().to_string();
if conversation_key.is_empty() {
return Err(DaemonError::InvalidRequest(
"conversation_key must not be empty".to_string(),
));
}
if body.message.trim().is_empty() {
return Err(DaemonError::InvalidRequest(
"message must not be empty".to_string(),
));
}
let manager = state.manager_state();
if let Some(pending) = manager.proposals().take(&conversation_key)
&& is_confirmation(&body.message)
{
let actuator = resolve_actuator(state);
let action_result = match execute_action(&actuator, &conversation_key, pending).await {
Ok(response) => response,
Err(error) => {
tracing::warn!("manager chat: confirmed action failed: {error}");
ActResponse::ActionFailed {
session: "(launch)".to_string(),
error,
}
}
};
let reply = describe_action_result(&action_result);
let model = manager.inference().model();
let turn_count =
manager
.conversations()
.record_exchange(&conversation_key, &body.message, &reply);
manager
.palace()
.record_chat_turn(&conversation_key, &body.message, &reply)
.await;
return Ok(ChatReplyBody {
conversation_key,
reply,
model,
turn_count,
action_result: Some(action_result),
});
}
let status = load_portfolio_status(state).await?;
let (model, adapter) = manager
.inference()
.resolve()
.map_err(|unavailable| DaemonError::ServiceUnavailable(unavailable.to_string()))?;
let history = manager.conversations().history(&conversation_key);
let mut request = ChatRequest::new(
model.clone(),
build_chat_messages(&status, &history, &body.message),
);
request.max_tokens = Some(MANAGER_MAX_TOKENS);
request.temperature = Some(MANAGER_TEMPERATURE);
let raw_reply = match adapter.chat(&request).await {
Ok(response) => match response.first_text().filter(|t| !t.trim().is_empty()) {
Some(text) => text,
None => {
return Err(DaemonError::UpstreamFailed(
"provider returned an empty reply".to_string(),
));
}
},
Err(e) => {
tracing::warn!("manager chat inference call failed: {e}");
return Err(DaemonError::UpstreamFailed(
"inference call failed".to_string(),
));
}
};
let (visible_text, proposed) = extract_proposed_action(&raw_reply);
let (reply, action_result) = match proposed {
Some(action) => {
manager.proposals().set(&conversation_key, action.clone());
let proposal = propose_message(&action);
let reply = if visible_text.is_empty() {
proposal.clone()
} else {
format!("{visible_text}\n\n{proposal}")
};
(reply, Some(ActResponse::Proposed { proposal, action }))
}
None => (raw_reply, None),
};
let turn_count =
manager
.conversations()
.record_exchange(&conversation_key, &body.message, &reply);
manager
.palace()
.record_chat_turn(&conversation_key, &body.message, &reply)
.await;
Ok(ChatReplyBody {
conversation_key,
reply,
model,
turn_count,
action_result,
})
}
#[cfg(test)]
#[path = "chat_tests.rs"]
mod tests;