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 const MAX_PROMPT: usize = 256 * 1024;
30pub(crate) const MAX_EVENT_BYTES: usize = 32 * 1024;
31pub(crate) const MAX_RESULT_BYTES: usize = 1024 * 1024;
32pub const MAX_TASKS: usize = 1024;
37pub(crate) const MAX_EVENTS_BYTES: u64 = 32 * 1024 * 1024;
41
42#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46pub(crate) struct RunOptions {
47 pub provider: Option<String>,
48 pub base_url: Option<String>,
49 pub model: Option<String>,
50 pub no_shell: bool,
51 #[serde(
60 default,
61 skip_serializing_if = "Option::is_none",
62 deserialize_with = "system_prompt_compat"
63 )]
64 pub system_prompt: Option<SystemPrompt>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub append_system_prompt: Option<String>,
69 pub effort: Option<Effort>,
72 pub approve: Approve,
76 pub deadline_ms: Option<u64>,
77 pub tool_budget: Option<usize>,
78 pub token_budget: Option<u64>,
79}
80
81impl RunOptions {
82 fn folded(self) -> Self {
86 let (system_prompt, append) = (
87 self.system_prompt.clone(),
88 self.append_system_prompt.clone(),
89 );
90 Self {
91 system_prompt: system_prompt.or_else(|| append.map(SystemPrompt::Append)),
92 append_system_prompt: None,
93 ..self
94 }
95 }
96}
97
98fn system_prompt_compat<'de, D>(deserializer: D) -> Result<Option<SystemPrompt>, D::Error>
102where
103 D: serde::Deserializer<'de>,
104{
105 #[derive(Deserialize)]
106 #[serde(untagged)]
107 enum Compat {
108 Typed(SystemPrompt),
109 Legacy(String),
110 }
111
112 Ok(
113 Option::<Compat>::deserialize(deserializer)?.map(|value| match value {
114 Compat::Typed(system_prompt) => system_prompt,
115 Compat::Legacy(replace) => SystemPrompt::Replace(replace),
116 }),
117 )
118}
119
120fn bound_compat<'de, D>(deserializer: D) -> Result<Option<Bound>, D::Error>
125where
126 D: serde::Deserializer<'de>,
127{
128 let name = Option::<String>::deserialize(deserializer)?;
129 Ok(name.and_then(|name| Bound::from_type_tag(&name)))
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(tag = "state", rename_all = "snake_case")]
138#[non_exhaustive]
139pub enum Terminal {
140 Succeeded { result: String },
142 Failed { error: String },
144 Cancelled,
146}
147
148#[derive(Debug, Clone, PartialEq)]
151pub struct TerminalRecord {
152 pub raw: Value,
154 pub terminal: Option<Terminal>,
157 pub stopped_by: Option<Bound>,
159 pub usage: Option<RunUsage>,
161 pub result_truncated: bool,
163}
164
165impl TerminalRecord {
166 pub(crate) fn from_raw(raw: Value) -> Self {
167 let terminal = serde_json::from_value(raw.clone()).ok();
168 let stopped_by = raw
169 .get("stopped_by")
170 .and_then(Value::as_str)
171 .and_then(Bound::from_type_tag);
172 let usage = raw
173 .get("usage")
174 .cloned()
175 .and_then(|usage| serde_json::from_value(usage).ok());
176 let result_truncated = raw
177 .get("result_truncated")
178 .and_then(Value::as_bool)
179 .unwrap_or(false);
180 Self {
181 raw,
182 terminal,
183 stopped_by,
184 usage,
185 result_truncated,
186 }
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum MessageState {
193 Pending,
195 InFlight,
197 Delivered,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct MessageReply {
203 pub result: String,
205 #[serde(default, skip_serializing_if = "is_false")]
207 pub result_truncated: bool,
208 #[serde(
210 default,
211 skip_serializing_if = "Option::is_none",
212 deserialize_with = "bound_compat"
213 )]
214 pub stopped_by: Option<Bound>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct MessageRecord {
219 pub id: String,
221 pub body: String,
223 #[serde(default, skip_serializing_if = "is_false")]
225 pub body_truncated: bool,
226 pub state: MessageState,
228 pub created_ms: u64,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub reply: Option<MessageReply>,
233}
234
235#[derive(Debug, Clone, PartialEq)]
237pub struct InboxRecord {
238 pub raw: Value,
240 pub messages: Vec<MessageRecord>,
242}
243
244fn is_zero(value: &u32) -> bool {
245 *value == 0
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub(crate) struct TaskMeta {
250 pub id: String,
251 pub parent: Option<String>,
252 pub detached: bool,
253 pub workspace: String,
254 pub agent_id: String,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub continues: Option<String>,
263 #[serde(default)]
272 pub answered_before: usize,
273 pub prompt: String,
274 pub options: RunOptions,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub pending_terminal: Option<Terminal>,
277 #[serde(default, skip_serializing_if = "is_false")]
278 pub result_truncated: bool,
279 #[serde(
280 default,
281 skip_serializing_if = "Option::is_none",
282 deserialize_with = "bound_compat"
283 )]
284 pub stopped_by: Option<Bound>,
285 #[serde(default)]
292 pub usage: RunUsage,
293 pub deadline_at_ms: Option<u64>,
294 #[serde(default, skip_serializing_if = "is_zero")]
298 pub hop: u32,
299 pub created_ms: u64,
300 #[serde(default)]
313 pub updated_ms: u64,
314}
315
316impl TaskMeta {
317 pub(crate) fn new(
318 id: String,
319 parent: Option<String>,
320 detached: bool,
321 workspace: String,
322 prompt: String,
323 options: RunOptions,
324 deadline_at_ms: Option<u64>,
325 ) -> Self {
326 let now = now_ms();
327 Self {
328 id,
329 parent,
330 detached,
331 workspace,
332 agent_id: String::new(),
333 continues: None,
334 answered_before: 0,
335 prompt,
336 options,
337 pending_terminal: None,
338 result_truncated: false,
339 stopped_by: None,
340 usage: RunUsage::default(),
341 deadline_at_ms,
342 hop: 0,
343 created_ms: now,
344 updated_ms: now,
345 }
346 }
347
348 pub(crate) fn at_hop(self, hop: u32) -> Self {
349 Self { hop, ..self }
350 }
351
352 #[must_use]
359 pub(crate) fn continuing(self, agent_id: Option<String>) -> Self {
360 Self {
361 continues: agent_id,
362 ..self
363 }
364 }
365
366 pub(crate) fn deadline_passed(&self) -> bool {
367 self.deadline_at_ms
368 .is_some_and(|deadline| deadline <= now_ms())
369 }
370
371 pub(crate) fn terminal_payload(&self) -> Option<Value> {
374 let payload = match self.pending_terminal.as_ref()? {
375 Terminal::Succeeded { result } => {
376 let mut terminal = serde_json::json!({"state": "succeeded", "result": result});
377 if self.result_truncated {
378 terminal["result_truncated"] = Value::Bool(true);
379 }
380 with_stopped_by(terminal, self.stopped_by)
381 }
382 Terminal::Failed { error } => with_stopped_by(
383 serde_json::json!({"state": "failed", "error": error}),
384 self.stopped_by,
385 ),
386 Terminal::Cancelled => serde_json::json!({"state": "cancelled"}),
387 };
388 Some(with_usage(payload, self.usage))
389 }
390}
391
392fn with_stopped_by(mut payload: Value, stopped_by: Option<Bound>) -> Value {
393 if let Some(stopped_by) = stopped_by {
394 payload["stopped_by"] = serde_json::json!(stopped_by);
395 }
396 payload
397}
398
399fn with_usage(mut payload: Value, usage: RunUsage) -> Value {
407 if usage != RunUsage::default() {
408 payload["usage"] = serde_json::json!(usage);
409 }
410 payload
411}
412
413fn is_false(value: &bool) -> bool {
414 !*value
415}
416
417pub(crate) fn load_meta(paths: &AgentPaths) -> Result<TaskMeta, String> {
418 let bytes = std::fs::read(paths.meta())
419 .map_err(|error| format!("read task metadata for {}: {error}", paths.dir().display()))?;
420 let meta: TaskMeta = serde_json::from_slice(&bytes).map_err(|error| {
421 format!(
422 "decode task metadata for {}: {error}",
423 paths.dir().display()
424 )
425 })?;
426
427 Ok(TaskMeta {
430 options: meta.options.folded(),
431 ..meta
432 })
433}
434
435pub(crate) fn save_meta(paths: &AgentPaths, meta: &TaskMeta) -> Result<(), String> {
436 let bytes =
437 serde_json::to_vec(meta).map_err(|error| format!("encode task metadata: {error}"))?;
438 write_private_atomic(&paths.meta(), &bytes)
439 .map_err(|error| format!("persist task metadata: {error}"))
440}
441
442pub(crate) fn read_terminal(paths: &AgentPaths) -> Result<Option<Value>, String> {
444 match std::fs::read(paths.terminal()) {
445 Ok(bytes) => serde_json::from_slice(&bytes)
446 .map(Some)
447 .map_err(|error| format!("decode terminal record: {error}")),
448 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
449 Err(error) => Err(format!("read terminal record: {error}")),
450 }
451}
452
453pub(crate) fn write_terminal(paths: &AgentPaths, payload: &Value) -> Result<(), String> {
454 let bytes =
455 serde_json::to_vec(payload).map_err(|error| format!("encode terminal record: {error}"))?;
456 write_private_atomic(&paths.terminal(), &bytes)
457 .map_err(|error| format!("persist terminal record: {error}"))
458}
459
460pub(crate) fn cancel_requested(paths: &AgentPaths) -> bool {
461 paths.cancel_marker().exists()
462}
463
464pub(crate) fn request_cancel(paths: &AgentPaths, by: Option<&str>) -> Result<(), String> {
467 let content = serde_json::json!({"requested_ms": now_ms(), "by": by});
468 write_private_atomic(&paths.cancel_marker(), content.to_string().as_bytes())
469 .map_err(|error| format!("record cancel request: {error}"))
470}
471
472pub(crate) fn bounded_text(mut value: String, limit: usize) -> (String, bool) {
473 if value.len() <= limit {
474 return (value, false);
475 }
476 let mut end = limit;
477 while !value.is_char_boundary(end) {
478 end -= 1;
479 }
480 value.truncate(end);
481 (value, true)
482}
483
484pub fn now_ms() -> u64 {
491 SystemTime::now()
492 .duration_since(UNIX_EPOCH)
493 .unwrap_or_default()
494 .as_millis()
495 .min(u128::from(u64::MAX)) as u64
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::data_dir::DataDir;
502
503 fn agent(dir: &tempfile::TempDir) -> AgentPaths {
504 let data = DataDir::from_path(dir.path()).unwrap();
505 let paths = data
506 .agent_dir("0123456789abcdef/0123456789abcdef0123456789abcdef")
507 .unwrap();
508 std::fs::create_dir_all(paths.dir()).unwrap();
509 paths
510 }
511
512 fn meta(paths: &AgentPaths) -> TaskMeta {
513 TaskMeta::new(
514 "0123456789abcdef/0123456789abcdef0123456789abcdef".to_string(),
515 None,
516 true,
517 "/repo".to_string(),
518 "prompt".to_string(),
519 RunOptions::default(),
520 None,
521 )
522 .tap(paths)
523 }
524
525 trait Tap {
526 fn tap(self, paths: &AgentPaths) -> Self;
527 }
528 impl Tap for TaskMeta {
529 fn tap(self, paths: &AgentPaths) -> Self {
530 save_meta(paths, &self).unwrap();
531 self
532 }
533 }
534
535 #[test]
536 fn metadata_round_trips_through_its_file() {
537 let dir = tempfile::tempdir().unwrap();
538 let paths = agent(&dir);
539 let saved = meta(&paths);
540 assert_eq!(load_meta(&paths).unwrap(), saved);
541 }
542
543 #[test]
544 fn terminal_records_are_absent_until_written_then_repeatably_observable() {
545 let dir = tempfile::tempdir().unwrap();
546 let paths = agent(&dir);
547 assert!(read_terminal(&paths).unwrap().is_none());
548
549 let mut record = meta(&paths);
550 record.pending_terminal = Some(Terminal::Succeeded {
551 result: "done".to_string(),
552 });
553 let payload = record.terminal_payload().unwrap();
554 write_terminal(&paths, &payload).unwrap();
555
556 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
557 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
558 assert_eq!(
559 payload,
560 serde_json::json!({"state": "succeeded", "result": "done"})
561 );
562 }
563
564 #[test]
565 fn terminal_record_keeps_raw_json_beside_the_typed_view() {
566 let raw = serde_json::json!({
567 "state": "succeeded",
568 "result": "done",
569 "result_truncated": true,
570 "stopped_by": "token_budget",
571 "usage": {
572 "input_tokens": 7,
573 "output_tokens": 3,
574 "cache_read_tokens": 0,
575 "cache_creation_tokens": 0
576 },
577 "newer_field": "kept",
578 });
579 let record = TerminalRecord::from_raw(raw.clone());
580
581 assert_eq!(record.raw, raw);
582 assert_eq!(
583 record.terminal,
584 Some(Terminal::Succeeded {
585 result: "done".to_string()
586 })
587 );
588 assert_eq!(record.stopped_by, Some(Bound::TokenBudget));
589 assert_eq!(record.usage.expect("typed usage").input_tokens, 7);
590 assert!(record.result_truncated);
591
592 let unknown = serde_json::json!({"state": "paused_by_newer_basis", "detail": 1});
593 let record = TerminalRecord::from_raw(unknown.clone());
594 assert_eq!(record.raw, unknown);
595 assert_eq!(record.terminal, None);
596 }
597
598 #[test]
599 fn terminal_payloads_carry_truncation_and_bound_metadata() {
600 let dir = tempfile::tempdir().unwrap();
601 let paths = agent(&dir);
602 let mut record = meta(&paths);
603 record.pending_terminal = Some(Terminal::Failed {
604 error: "took too long".to_string(),
605 });
606 record.stopped_by = Some(Bound::Deadline);
607 assert_eq!(
608 record.terminal_payload().unwrap(),
609 serde_json::json!({"state": "failed", "error": "took too long", "stopped_by": "deadline"})
610 );
611
612 record.pending_terminal = Some(Terminal::Cancelled);
613 assert_eq!(
614 record.terminal_payload().unwrap(),
615 serde_json::json!({"state": "cancelled"}),
616 "a cancelled terminal never reports a bound"
617 );
618 }
619
620 #[test]
625 fn a_settled_task_records_what_it_spent_only_when_it_spent_something() {
626 let dir = tempfile::tempdir().unwrap();
627 let paths = agent(&dir);
628 let mut record = meta(&paths);
629 record.pending_terminal = Some(Terminal::Succeeded {
630 result: "done".to_string(),
631 });
632
633 assert_eq!(
634 record.terminal_payload().unwrap(),
635 serde_json::json!({"state": "succeeded", "result": "done"}),
636 "a task whose turns reported nothing claims no measurement"
637 );
638
639 record.usage = RunUsage {
640 input_tokens: 900,
641 output_tokens: 100,
642 cache_read_tokens: 7,
643 cache_creation_tokens: 3,
644 reasoning_tokens: 0,
645 thoughts_tokens: 0,
646 model_responses: 1,
647 };
648 let payload = record.terminal_payload().unwrap();
649 assert_eq!(payload["usage"]["input_tokens"], 900);
650 assert_eq!(payload["usage"]["output_tokens"], 100);
651 assert_eq!(payload["usage"]["cache_read_tokens"], 7);
652 assert_eq!(payload["usage"]["cache_creation_tokens"], 3);
653 }
654
655 #[test]
656 fn cancel_markers_signal_by_existence() {
657 let dir = tempfile::tempdir().unwrap();
658 let paths = agent(&dir);
659 assert!(!cancel_requested(&paths));
660 request_cancel(&paths, Some("caller/id")).unwrap();
661 assert!(cancel_requested(&paths));
662 }
663
664 #[test]
665 fn bounded_text_never_splits_utf8() {
666 let (value, truncated) = bounded_text("a界b".to_string(), 2);
667 assert_eq!(value, "a");
668 assert!(truncated);
669 }
670}