use serde::{Deserialize, Serialize};
use crate::types::{ContainerConfig, ContextMode, ScheduleType};
pub const OUTPUT_START_MARKER: &str = "---TALON_OUTPUT_START---";
pub const OUTPUT_END_MARKER: &str = "---TALON_OUTPUT_END---";
pub const IPC_CLOSE_SENTINEL: &str = "_close";
pub mod container_paths {
pub const GROUP: &str = "/workspace/group";
pub const GLOBAL: &str = "/workspace/global";
pub const PROJECT: &str = "/workspace/project";
pub const EXTRA: &str = "/workspace/extra";
pub const IPC: &str = "/workspace/ipc";
pub const IPC_INPUT: &str = "/workspace/ipc/input";
pub const IPC_MESSAGES: &str = "/workspace/ipc/messages";
pub const IPC_TASKS: &str = "/workspace/ipc/tasks";
pub const CURRENT_TASKS: &str = "/workspace/ipc/current_tasks.json";
pub const AVAILABLE_GROUPS: &str = "/workspace/ipc/available_groups.json";
pub const AGENT_STATE: &str = "/home/agent/.talon";
pub const MCP_JSON: &str = "/workspace/group/.mcp.json";
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LlmEndpoint {
pub base_url: String,
pub token: String,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fast_model: Option<String>,
pub max_context_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enable_thinking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContainerInput {
pub prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub group_folder: String,
pub chat_jid: String,
pub is_main: bool,
#[serde(default)]
pub is_scheduled_task: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assistant_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmEndpoint>,
#[serde(default)]
pub ephemeral: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputStatus {
Success,
Error,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionUsage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_tokens: Option<u64>,
}
impl SessionUsage {
pub fn context_size(&self) -> u64 {
self.input_tokens.unwrap_or(0)
+ self.cache_creation_input_tokens.unwrap_or(0)
+ self.cache_read_input_tokens.unwrap_or(0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContainerOutput {
pub status: OutputStatus,
pub result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<SessionUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub consumed_inputs: Option<Vec<String>>,
}
impl ContainerOutput {
pub fn success(result: Option<String>, new_session_id: Option<String>) -> Self {
Self {
status: OutputStatus::Success,
result,
new_session_id,
error: None,
usage: None,
consumed_inputs: None,
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
status: OutputStatus::Error,
result: None,
new_session_id: None,
error: Some(message.into()),
usage: None,
consumed_inputs: None,
}
}
pub fn to_framed(&self) -> String {
let json = serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string());
format!("{OUTPUT_START_MARKER}\n{json}\n{OUTPUT_END_MARKER}\n")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum IpcInput {
Message { text: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum IpcOutbound {
Message {
#[serde(rename = "chatJid")]
chat_jid: String,
text: String,
#[serde(
rename = "groupFolder",
default,
skip_serializing_if = "Option::is_none"
)]
group_folder: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[allow(clippy::large_enum_variant)] pub enum IpcTask {
ScheduleTask {
#[serde(rename = "taskId", default, skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
script: Option<String>,
schedule_type: ScheduleType,
schedule_value: String,
#[serde(default)]
context_mode: Option<ContextMode>,
#[serde(rename = "targetJid")]
target_jid: String,
#[serde(rename = "createdBy", default, skip_serializing_if = "Option::is_none")]
created_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
PauseTask {
#[serde(rename = "taskId")]
task_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
ResumeTask {
#[serde(rename = "taskId")]
task_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
CancelTask {
#[serde(rename = "taskId")]
task_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
UpdateTask {
#[serde(rename = "taskId")]
task_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
script: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
schedule_type: Option<ScheduleType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
schedule_value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
RefreshGroups {
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
RegisterGroup {
jid: String,
name: String,
folder: String,
trigger: String,
#[serde(
rename = "requiresTrigger",
default,
skip_serializing_if = "Option::is_none"
)]
requires_trigger: Option<bool>,
#[serde(
rename = "containerConfig",
default,
skip_serializing_if = "Option::is_none"
)]
container_config: Option<ContainerConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScriptResult {
pub wake_agent: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
pub fn ipc_file_name() -> String {
let millis = chrono::Utc::now().timestamp_millis();
let rand = uuid::Uuid::new_v4().simple().to_string();
format!("{millis}-{}.json", &rand[..6])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipc_task_roundtrip_matches_ts_shape() {
let raw = r#"{"type":"schedule_task","taskId":"task-1","prompt":"hi","schedule_type":"cron","schedule_value":"*/5 * * * *","context_mode":"group","targetJid":"tg:1","createdBy":"main","timestamp":"2026-01-01T00:00:00.000Z"}"#;
let t: IpcTask = serde_json::from_str(raw).unwrap();
match &t {
IpcTask::ScheduleTask {
task_id,
schedule_type,
target_jid,
..
} => {
assert_eq!(task_id.as_deref(), Some("task-1"));
assert_eq!(*schedule_type, ScheduleType::Cron);
assert_eq!(target_jid, "tg:1");
}
other => panic!("unexpected {other:?}"),
}
let v = serde_json::to_value(&t).unwrap();
assert_eq!(v["type"], "schedule_task");
assert_eq!(v["targetJid"], "tg:1");
}
#[test]
fn outbound_message_shape() {
let raw = r#"{"type":"message","chatJid":"x","text":"t","groupFolder":"g"}"#;
let m: IpcOutbound = serde_json::from_str(raw).unwrap();
let IpcOutbound::Message { chat_jid, .. } = m;
assert_eq!(chat_jid, "x");
}
#[test]
fn framed_output_contains_markers() {
let s = ContainerOutput::success(Some("ok".into()), Some("s1".into())).to_framed();
assert!(s.starts_with(OUTPUT_START_MARKER));
assert!(s.trim_end().ends_with(OUTPUT_END_MARKER));
assert!(s.contains("\"newSessionId\":\"s1\""));
}
#[test]
fn usage_context_size() {
let u = SessionUsage {
input_tokens: Some(10),
output_tokens: Some(99),
cache_creation_input_tokens: None,
cache_read_input_tokens: Some(5),
};
assert_eq!(u.context_size(), 15);
}
#[test]
fn ipc_file_names_sort_and_differ() {
let a = ipc_file_name();
let b = ipc_file_name();
assert!(a.ends_with(".json"));
assert_ne!(a, b);
}
}