1use serde::{Deserialize, Serialize};
24
25use crate::types::{ContainerConfig, ContextMode, ScheduleType};
26
27pub const OUTPUT_START_MARKER: &str = "---TALON_OUTPUT_START---";
29pub const OUTPUT_END_MARKER: &str = "---TALON_OUTPUT_END---";
31
32pub const IPC_CLOSE_SENTINEL: &str = "_close";
34
35pub mod container_paths {
37 pub const GROUP: &str = "/workspace/group";
39 pub const GLOBAL: &str = "/workspace/global";
41 pub const PROJECT: &str = "/workspace/project";
43 pub const EXTRA: &str = "/workspace/extra";
45 pub const IPC: &str = "/workspace/ipc";
47 pub const IPC_INPUT: &str = "/workspace/ipc/input";
48 pub const IPC_MESSAGES: &str = "/workspace/ipc/messages";
49 pub const IPC_TASKS: &str = "/workspace/ipc/tasks";
50 pub const CURRENT_TASKS: &str = "/workspace/ipc/current_tasks.json";
51 pub const AVAILABLE_GROUPS: &str = "/workspace/ipc/available_groups.json";
52 pub const AGENT_STATE: &str = "/home/agent/.talon";
55 pub const MCP_JSON: &str = "/workspace/group/.mcp.json";
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct LlmEndpoint {
67 pub base_url: String,
70 pub token: String,
72 pub model: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub fast_model: Option<String>,
77 pub max_context_tokens: u32,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub max_output_tokens: Option<u32>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub temperature: Option<f32>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub top_p: Option<f32>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub enable_thinking: Option<bool>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub request_timeout_secs: Option<u64>,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct ContainerInput {
95 pub prompt: String,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub session_id: Option<String>,
99 pub group_folder: String,
100 pub chat_jid: String,
101 pub is_main: bool,
102 #[serde(default)]
103 pub is_scheduled_task: bool,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub assistant_name: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub script: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub llm: Option<LlmEndpoint>,
113 #[serde(default)]
115 pub ephemeral: bool,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub timezone: Option<String>,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "lowercase")]
123pub enum OutputStatus {
124 Success,
125 Error,
126}
127
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
134pub struct SessionUsage {
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub input_tokens: Option<u64>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub output_tokens: Option<u64>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub cache_creation_input_tokens: Option<u64>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub cache_read_input_tokens: Option<u64>,
143}
144
145impl SessionUsage {
146 pub fn context_size(&self) -> u64 {
148 self.input_tokens.unwrap_or(0)
149 + self.cache_creation_input_tokens.unwrap_or(0)
150 + self.cache_read_input_tokens.unwrap_or(0)
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ContainerOutput {
158 pub status: OutputStatus,
159 pub result: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub new_session_id: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub error: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub usage: Option<SessionUsage>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub consumed_inputs: Option<Vec<String>>,
172}
173
174impl ContainerOutput {
175 pub fn success(result: Option<String>, new_session_id: Option<String>) -> Self {
176 Self {
177 status: OutputStatus::Success,
178 result,
179 new_session_id,
180 error: None,
181 usage: None,
182 consumed_inputs: None,
183 }
184 }
185
186 pub fn error(message: impl Into<String>) -> Self {
187 Self {
188 status: OutputStatus::Error,
189 result: None,
190 new_session_id: None,
191 error: Some(message.into()),
192 usage: None,
193 consumed_inputs: None,
194 }
195 }
196
197 pub fn to_framed(&self) -> String {
199 let json = serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string());
201 format!("{OUTPUT_START_MARKER}\n{json}\n{OUTPUT_END_MARKER}\n")
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(tag = "type", rename_all = "snake_case")]
208pub enum IpcInput {
209 Message { text: String },
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(tag = "type", rename_all = "snake_case")]
215pub enum IpcOutbound {
216 Message {
217 #[serde(rename = "chatJid")]
218 chat_jid: String,
219 text: String,
220 #[serde(
221 rename = "groupFolder",
222 default,
223 skip_serializing_if = "Option::is_none"
224 )]
225 group_folder: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 timestamp: Option<String>,
228 },
229}
230
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237#[serde(tag = "type", rename_all = "snake_case")]
238#[allow(clippy::large_enum_variant)] pub enum IpcTask {
240 ScheduleTask {
241 #[serde(rename = "taskId", default, skip_serializing_if = "Option::is_none")]
242 task_id: Option<String>,
243 prompt: String,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 script: Option<String>,
246 schedule_type: ScheduleType,
247 schedule_value: String,
248 #[serde(default)]
249 context_mode: Option<ContextMode>,
250 #[serde(rename = "targetJid")]
251 target_jid: String,
252 #[serde(rename = "createdBy", default, skip_serializing_if = "Option::is_none")]
253 created_by: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 timestamp: Option<String>,
256 },
257 PauseTask {
258 #[serde(rename = "taskId")]
259 task_id: String,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 timestamp: Option<String>,
262 },
263 ResumeTask {
264 #[serde(rename = "taskId")]
265 task_id: String,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 timestamp: Option<String>,
268 },
269 CancelTask {
270 #[serde(rename = "taskId")]
271 task_id: String,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 timestamp: Option<String>,
274 },
275 UpdateTask {
276 #[serde(rename = "taskId")]
277 task_id: String,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 prompt: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 script: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 schedule_type: Option<ScheduleType>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 schedule_value: Option<String>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 timestamp: Option<String>,
289 },
290 RefreshGroups {
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 timestamp: Option<String>,
293 },
294 RegisterGroup {
295 jid: String,
296 name: String,
297 folder: String,
298 trigger: String,
299 #[serde(
300 rename = "requiresTrigger",
301 default,
302 skip_serializing_if = "Option::is_none"
303 )]
304 requires_trigger: Option<bool>,
305 #[serde(
306 rename = "containerConfig",
307 default,
308 skip_serializing_if = "Option::is_none"
309 )]
310 container_config: Option<ContainerConfig>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 timestamp: Option<String>,
313 },
314}
315
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct ScriptResult {
320 pub wake_agent: bool,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub data: Option<serde_json::Value>,
323}
324
325pub fn ipc_file_name() -> String {
328 let millis = chrono::Utc::now().timestamp_millis();
329 let rand = uuid::Uuid::new_v4().simple().to_string();
330 format!("{millis}-{}.json", &rand[..6])
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn ipc_task_roundtrip_matches_ts_shape() {
339 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"}"#;
340 let t: IpcTask = serde_json::from_str(raw).unwrap();
341 match &t {
342 IpcTask::ScheduleTask {
343 task_id,
344 schedule_type,
345 target_jid,
346 ..
347 } => {
348 assert_eq!(task_id.as_deref(), Some("task-1"));
349 assert_eq!(*schedule_type, ScheduleType::Cron);
350 assert_eq!(target_jid, "tg:1");
351 }
352 other => panic!("unexpected {other:?}"),
353 }
354 let v = serde_json::to_value(&t).unwrap();
355 assert_eq!(v["type"], "schedule_task");
356 assert_eq!(v["targetJid"], "tg:1");
357 }
358
359 #[test]
360 fn outbound_message_shape() {
361 let raw = r#"{"type":"message","chatJid":"x","text":"t","groupFolder":"g"}"#;
362 let m: IpcOutbound = serde_json::from_str(raw).unwrap();
363 let IpcOutbound::Message { chat_jid, .. } = m;
364 assert_eq!(chat_jid, "x");
365 }
366
367 #[test]
368 fn framed_output_contains_markers() {
369 let s = ContainerOutput::success(Some("ok".into()), Some("s1".into())).to_framed();
370 assert!(s.starts_with(OUTPUT_START_MARKER));
371 assert!(s.trim_end().ends_with(OUTPUT_END_MARKER));
372 assert!(s.contains("\"newSessionId\":\"s1\""));
373 }
374
375 #[test]
376 fn usage_context_size() {
377 let u = SessionUsage {
378 input_tokens: Some(10),
379 output_tokens: Some(99),
380 cache_creation_input_tokens: None,
381 cache_read_input_tokens: Some(5),
382 };
383 assert_eq!(u.context_size(), 15);
384 }
385
386 #[test]
387 fn ipc_file_names_sort_and_differ() {
388 let a = ipc_file_name();
389 let b = ipc_file_name();
390 assert!(a.ends_with(".json"));
391 assert_ne!(a, b);
392 }
393}