1use std::{
12 io,
13 time::{SystemTime, UNIX_EPOCH},
14};
15
16use basis::{Bound, Effort, RunUsage, SystemPrompt};
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::approve::Approve;
22use crate::data_dir::{AgentPaths, write_private_atomic};
23
24pub(crate) const MAX_MESSAGES: usize = 16;
25pub(crate) const MAX_MESSAGE: usize = 256 * 1024;
26pub(crate) const MAX_PROMPT: usize = 256 * 1024;
27pub(crate) const MAX_EVENT_BYTES: usize = 32 * 1024;
28pub(crate) const MAX_RESULT_BYTES: usize = 1024 * 1024;
29pub const MAX_TASKS: usize = 1024;
34pub(crate) const MAX_EVENTS_BYTES: u64 = 32 * 1024 * 1024;
38
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
43pub(crate) struct RunOptions {
44 pub provider: Option<String>,
45 pub base_url: Option<String>,
46 pub model: Option<String>,
47 pub no_shell: bool,
48 #[serde(
57 default,
58 skip_serializing_if = "Option::is_none",
59 deserialize_with = "system_prompt_compat"
60 )]
61 pub system_prompt: Option<SystemPrompt>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub append_system_prompt: Option<String>,
66 pub effort: Option<Effort>,
69 pub approve: Approve,
73 pub deadline_ms: Option<u64>,
74 pub tool_budget: Option<usize>,
75 pub token_budget: Option<u64>,
76}
77
78impl RunOptions {
79 fn folded(self) -> Self {
83 let (system_prompt, append) = (
84 self.system_prompt.clone(),
85 self.append_system_prompt.clone(),
86 );
87 Self {
88 system_prompt: system_prompt.or_else(|| append.map(SystemPrompt::Append)),
89 append_system_prompt: None,
90 ..self
91 }
92 }
93}
94
95fn system_prompt_compat<'de, D>(deserializer: D) -> Result<Option<SystemPrompt>, D::Error>
99where
100 D: serde::Deserializer<'de>,
101{
102 #[derive(Deserialize)]
103 #[serde(untagged)]
104 enum Compat {
105 Typed(SystemPrompt),
106 Legacy(String),
107 }
108
109 Ok(
110 Option::<Compat>::deserialize(deserializer)?.map(|value| match value {
111 Compat::Typed(system_prompt) => system_prompt,
112 Compat::Legacy(replace) => SystemPrompt::Replace(replace),
113 }),
114 )
115}
116
117fn bound_compat<'de, D>(deserializer: D) -> Result<Option<Bound>, D::Error>
122where
123 D: serde::Deserializer<'de>,
124{
125 let name = Option::<String>::deserialize(deserializer)?;
126 Ok(name.and_then(|name| Bound::from_type_tag(&name)))
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(tag = "state", rename_all = "snake_case")]
135#[non_exhaustive]
136pub enum Terminal {
137 Succeeded { result: String },
139 Failed { error: String },
141 Cancelled,
143}
144
145#[derive(Debug, Clone, PartialEq)]
148pub struct TerminalRecord {
149 pub raw: Value,
151 pub terminal: Option<Terminal>,
154 pub stopped_by: Option<Bound>,
156 pub usage: Option<RunUsage>,
158 pub result_truncated: bool,
160}
161
162impl TerminalRecord {
163 pub(crate) fn from_raw(raw: Value) -> Self {
164 let terminal = serde_json::from_value(raw.clone()).ok();
165 let stopped_by = raw
166 .get("stopped_by")
167 .and_then(Value::as_str)
168 .and_then(Bound::from_type_tag);
169 let usage = raw
170 .get("usage")
171 .cloned()
172 .and_then(|usage| serde_json::from_value(usage).ok());
173 let result_truncated = raw
174 .get("result_truncated")
175 .and_then(Value::as_bool)
176 .unwrap_or(false);
177 Self {
178 raw,
179 terminal,
180 stopped_by,
181 usage,
182 result_truncated,
183 }
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum MessageState {
190 Pending,
192 InFlight,
194 Delivered,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct MessageReply {
200 pub result: String,
202 #[serde(default, skip_serializing_if = "is_false")]
204 pub result_truncated: bool,
205 #[serde(
207 default,
208 skip_serializing_if = "Option::is_none",
209 deserialize_with = "bound_compat"
210 )]
211 pub stopped_by: Option<Bound>,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct MessageRecord {
216 pub id: String,
218 pub body: String,
220 #[serde(default, skip_serializing_if = "is_false")]
222 pub body_truncated: bool,
223 pub state: MessageState,
225 pub created_ms: u64,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub reply: Option<MessageReply>,
230}
231
232#[derive(Debug, Clone, PartialEq)]
234pub struct InboxRecord {
235 pub raw: Value,
237 pub messages: Vec<MessageRecord>,
239}
240
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
242pub(crate) struct TaskMeta {
243 pub id: String,
244 pub parent: Option<String>,
245 pub detached: bool,
246 pub workspace: String,
247 pub agent_id: String,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub continues: Option<String>,
256 #[serde(default)]
265 pub answered_before: usize,
266 pub prompt: String,
267 pub options: RunOptions,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub pending_terminal: Option<Terminal>,
270 #[serde(default, skip_serializing_if = "is_false")]
271 pub result_truncated: bool,
272 #[serde(
273 default,
274 skip_serializing_if = "Option::is_none",
275 deserialize_with = "bound_compat"
276 )]
277 pub stopped_by: Option<Bound>,
278 #[serde(default)]
285 pub usage: RunUsage,
286 pub deadline_at_ms: Option<u64>,
287 pub created_ms: u64,
288 #[serde(default)]
301 pub updated_ms: u64,
302}
303
304impl TaskMeta {
305 pub(crate) fn new(
306 id: String,
307 parent: Option<String>,
308 detached: bool,
309 workspace: String,
310 prompt: String,
311 options: RunOptions,
312 deadline_at_ms: Option<u64>,
313 ) -> Self {
314 let now = now_ms();
315 Self {
316 id,
317 parent,
318 detached,
319 workspace,
320 agent_id: String::new(),
321 continues: None,
322 answered_before: 0,
323 prompt,
324 options,
325 pending_terminal: None,
326 result_truncated: false,
327 stopped_by: None,
328 usage: RunUsage::default(),
329 deadline_at_ms,
330 created_ms: now,
331 updated_ms: now,
332 }
333 }
334
335 #[must_use]
342 pub(crate) fn continuing(self, agent_id: Option<String>) -> Self {
343 Self {
344 continues: agent_id,
345 ..self
346 }
347 }
348
349 pub(crate) fn deadline_passed(&self) -> bool {
350 self.deadline_at_ms
351 .is_some_and(|deadline| deadline <= now_ms())
352 }
353
354 pub(crate) fn terminal_payload(&self) -> Option<Value> {
357 let payload = match self.pending_terminal.as_ref()? {
358 Terminal::Succeeded { result } => {
359 let mut terminal = serde_json::json!({"state": "succeeded", "result": result});
360 if self.result_truncated {
361 terminal["result_truncated"] = Value::Bool(true);
362 }
363 with_stopped_by(terminal, self.stopped_by)
364 }
365 Terminal::Failed { error } => with_stopped_by(
366 serde_json::json!({"state": "failed", "error": error}),
367 self.stopped_by,
368 ),
369 Terminal::Cancelled => serde_json::json!({"state": "cancelled"}),
370 };
371 Some(with_usage(payload, self.usage))
372 }
373}
374
375fn with_stopped_by(mut payload: Value, stopped_by: Option<Bound>) -> Value {
376 if let Some(stopped_by) = stopped_by {
377 payload["stopped_by"] = serde_json::json!(stopped_by);
378 }
379 payload
380}
381
382fn with_usage(mut payload: Value, usage: RunUsage) -> Value {
390 if usage != RunUsage::default() {
391 payload["usage"] = serde_json::json!(usage);
392 }
393 payload
394}
395
396fn is_false(value: &bool) -> bool {
397 !*value
398}
399
400pub(crate) fn load_meta(paths: &AgentPaths) -> Result<TaskMeta, String> {
401 let bytes = std::fs::read(paths.meta())
402 .map_err(|error| format!("read task metadata for {}: {error}", paths.dir().display()))?;
403 let meta: TaskMeta = serde_json::from_slice(&bytes).map_err(|error| {
404 format!(
405 "decode task metadata for {}: {error}",
406 paths.dir().display()
407 )
408 })?;
409
410 Ok(TaskMeta {
413 options: meta.options.folded(),
414 ..meta
415 })
416}
417
418pub(crate) fn save_meta(paths: &AgentPaths, meta: &TaskMeta) -> Result<(), String> {
419 let bytes =
420 serde_json::to_vec(meta).map_err(|error| format!("encode task metadata: {error}"))?;
421 write_private_atomic(&paths.meta(), &bytes)
422 .map_err(|error| format!("persist task metadata: {error}"))
423}
424
425pub(crate) fn read_terminal(paths: &AgentPaths) -> Result<Option<Value>, String> {
427 match std::fs::read(paths.terminal()) {
428 Ok(bytes) => serde_json::from_slice(&bytes)
429 .map(Some)
430 .map_err(|error| format!("decode terminal record: {error}")),
431 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
432 Err(error) => Err(format!("read terminal record: {error}")),
433 }
434}
435
436pub(crate) fn write_terminal(paths: &AgentPaths, payload: &Value) -> Result<(), String> {
437 let bytes =
438 serde_json::to_vec(payload).map_err(|error| format!("encode terminal record: {error}"))?;
439 write_private_atomic(&paths.terminal(), &bytes)
440 .map_err(|error| format!("persist terminal record: {error}"))
441}
442
443pub(crate) fn cancel_requested(paths: &AgentPaths) -> bool {
444 paths.cancel_marker().exists()
445}
446
447pub(crate) fn request_cancel(paths: &AgentPaths, by: Option<&str>) -> Result<(), String> {
450 let content = serde_json::json!({"requested_ms": now_ms(), "by": by});
451 write_private_atomic(&paths.cancel_marker(), content.to_string().as_bytes())
452 .map_err(|error| format!("record cancel request: {error}"))
453}
454
455pub(crate) fn bounded_text(mut value: String, limit: usize) -> (String, bool) {
456 if value.len() <= limit {
457 return (value, false);
458 }
459 let mut end = limit;
460 while !value.is_char_boundary(end) {
461 end -= 1;
462 }
463 value.truncate(end);
464 (value, true)
465}
466
467pub fn now_ms() -> u64 {
474 SystemTime::now()
475 .duration_since(UNIX_EPOCH)
476 .unwrap_or_default()
477 .as_millis()
478 .min(u128::from(u64::MAX)) as u64
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::data_dir::DataDir;
485
486 fn agent(dir: &tempfile::TempDir) -> AgentPaths {
487 let data = DataDir::from_path(dir.path()).unwrap();
488 let paths = data
489 .agent_dir("0123456789abcdef/0123456789abcdef0123456789abcdef")
490 .unwrap();
491 std::fs::create_dir_all(paths.dir()).unwrap();
492 paths
493 }
494
495 fn meta(paths: &AgentPaths) -> TaskMeta {
496 TaskMeta::new(
497 "0123456789abcdef/0123456789abcdef0123456789abcdef".to_string(),
498 None,
499 true,
500 "/repo".to_string(),
501 "prompt".to_string(),
502 RunOptions::default(),
503 None,
504 )
505 .tap(paths)
506 }
507
508 trait Tap {
509 fn tap(self, paths: &AgentPaths) -> Self;
510 }
511 impl Tap for TaskMeta {
512 fn tap(self, paths: &AgentPaths) -> Self {
513 save_meta(paths, &self).unwrap();
514 self
515 }
516 }
517
518 #[test]
519 fn metadata_round_trips_through_its_file() {
520 let dir = tempfile::tempdir().unwrap();
521 let paths = agent(&dir);
522 let saved = meta(&paths);
523 assert_eq!(load_meta(&paths).unwrap(), saved);
524 }
525
526 #[test]
527 fn terminal_records_are_absent_until_written_then_repeatably_observable() {
528 let dir = tempfile::tempdir().unwrap();
529 let paths = agent(&dir);
530 assert!(read_terminal(&paths).unwrap().is_none());
531
532 let mut record = meta(&paths);
533 record.pending_terminal = Some(Terminal::Succeeded {
534 result: "done".to_string(),
535 });
536 let payload = record.terminal_payload().unwrap();
537 write_terminal(&paths, &payload).unwrap();
538
539 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
540 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
541 assert_eq!(
542 payload,
543 serde_json::json!({"state": "succeeded", "result": "done"})
544 );
545 }
546
547 #[test]
548 fn terminal_record_keeps_raw_json_beside_the_typed_view() {
549 let raw = serde_json::json!({
550 "state": "succeeded",
551 "result": "done",
552 "result_truncated": true,
553 "stopped_by": "token_budget",
554 "usage": {
555 "input_tokens": 7,
556 "output_tokens": 3,
557 "cache_read_tokens": 0,
558 "cache_creation_tokens": 0
559 },
560 "newer_field": "kept",
561 });
562 let record = TerminalRecord::from_raw(raw.clone());
563
564 assert_eq!(record.raw, raw);
565 assert_eq!(
566 record.terminal,
567 Some(Terminal::Succeeded {
568 result: "done".to_string()
569 })
570 );
571 assert_eq!(record.stopped_by, Some(Bound::TokenBudget));
572 assert_eq!(record.usage.expect("typed usage").input_tokens, 7);
573 assert!(record.result_truncated);
574
575 let unknown = serde_json::json!({"state": "paused_by_newer_basis", "detail": 1});
576 let record = TerminalRecord::from_raw(unknown.clone());
577 assert_eq!(record.raw, unknown);
578 assert_eq!(record.terminal, None);
579 }
580
581 #[test]
582 fn terminal_payloads_carry_truncation_and_bound_metadata() {
583 let dir = tempfile::tempdir().unwrap();
584 let paths = agent(&dir);
585 let mut record = meta(&paths);
586 record.pending_terminal = Some(Terminal::Failed {
587 error: "took too long".to_string(),
588 });
589 record.stopped_by = Some(Bound::Deadline);
590 assert_eq!(
591 record.terminal_payload().unwrap(),
592 serde_json::json!({"state": "failed", "error": "took too long", "stopped_by": "deadline"})
593 );
594
595 record.pending_terminal = Some(Terminal::Cancelled);
596 assert_eq!(
597 record.terminal_payload().unwrap(),
598 serde_json::json!({"state": "cancelled"}),
599 "a cancelled terminal never reports a bound"
600 );
601 }
602
603 #[test]
608 fn a_settled_task_records_what_it_spent_only_when_it_spent_something() {
609 let dir = tempfile::tempdir().unwrap();
610 let paths = agent(&dir);
611 let mut record = meta(&paths);
612 record.pending_terminal = Some(Terminal::Succeeded {
613 result: "done".to_string(),
614 });
615
616 assert_eq!(
617 record.terminal_payload().unwrap(),
618 serde_json::json!({"state": "succeeded", "result": "done"}),
619 "a task whose turns reported nothing claims no measurement"
620 );
621
622 record.usage = RunUsage {
623 input_tokens: 900,
624 output_tokens: 100,
625 cache_read_tokens: 7,
626 cache_creation_tokens: 3,
627 reasoning_tokens: 0,
628 thoughts_tokens: 0,
629 model_responses: 1,
630 };
631 let payload = record.terminal_payload().unwrap();
632 assert_eq!(payload["usage"]["input_tokens"], 900);
633 assert_eq!(payload["usage"]["output_tokens"], 100);
634 assert_eq!(payload["usage"]["cache_read_tokens"], 7);
635 assert_eq!(payload["usage"]["cache_creation_tokens"], 3);
636 }
637
638 #[test]
639 fn cancel_markers_signal_by_existence() {
640 let dir = tempfile::tempdir().unwrap();
641 let paths = agent(&dir);
642 assert!(!cancel_requested(&paths));
643 request_cancel(&paths, Some("caller/id")).unwrap();
644 assert!(cancel_requested(&paths));
645 }
646
647 #[test]
648 fn bounded_text_never_splits_utf8() {
649 let (value, truncated) = bounded_text("a界b".to_string(), 2);
650 assert_eq!(value, "a");
651 assert!(truncated);
652 }
653}