1use std::{
12 io,
13 time::{SystemTime, UNIX_EPOCH},
14};
15
16use basis::{Bound, Effort, RunUsage, SkillRoster, SystemPrompt, ToolRoster};
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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
83 pub answering: bool,
84 #[serde(default = "legacy_config_lookup")]
88 pub config: basis::ConfigLookup,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub tool_roster: Option<ToolRoster>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub skill_roster: Option<SkillRoster>,
99}
100
101fn legacy_config_lookup() -> basis::ConfigLookup {
103 basis::ConfigLookup::Discover
104}
105
106impl RunOptions {
107 fn folded(self) -> Self {
111 let (system_prompt, append) = (
112 self.system_prompt.clone(),
113 self.append_system_prompt.clone(),
114 );
115 Self {
116 system_prompt: system_prompt.or_else(|| append.map(SystemPrompt::Append)),
117 append_system_prompt: None,
118 ..self
119 }
120 }
121}
122
123fn system_prompt_compat<'de, D>(deserializer: D) -> Result<Option<SystemPrompt>, D::Error>
127where
128 D: serde::Deserializer<'de>,
129{
130 #[derive(Deserialize)]
131 #[serde(untagged)]
132 enum Compat {
133 Typed(SystemPrompt),
134 Legacy(String),
135 }
136
137 Ok(
138 Option::<Compat>::deserialize(deserializer)?.map(|value| match value {
139 Compat::Typed(system_prompt) => system_prompt,
140 Compat::Legacy(replace) => SystemPrompt::Replace(replace),
141 }),
142 )
143}
144
145fn bound_compat<'de, D>(deserializer: D) -> Result<Option<Bound>, D::Error>
150where
151 D: serde::Deserializer<'de>,
152{
153 let name = Option::<String>::deserialize(deserializer)?;
154 Ok(name.and_then(|name| Bound::from_type_tag(&name)))
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(tag = "state", rename_all = "snake_case")]
163#[non_exhaustive]
164pub enum Terminal {
165 Succeeded { result: String },
167 Failed { error: String },
169 Cancelled,
171}
172
173#[derive(Debug, Clone, PartialEq)]
176pub struct TerminalRecord {
177 pub raw: Value,
179 pub terminal: Option<Terminal>,
182 pub stopped_by: Option<Bound>,
184 pub usage: Option<RunUsage>,
186 pub result_truncated: bool,
188}
189
190impl TerminalRecord {
191 pub(crate) fn from_raw(raw: Value) -> Self {
192 let terminal = serde_json::from_value(raw.clone()).ok();
193 let stopped_by = raw
194 .get("stopped_by")
195 .and_then(Value::as_str)
196 .and_then(Bound::from_type_tag);
197 let usage = raw
198 .get("usage")
199 .cloned()
200 .and_then(|usage| serde_json::from_value(usage).ok());
201 let result_truncated = raw
202 .get("result_truncated")
203 .and_then(Value::as_bool)
204 .unwrap_or(false);
205 Self {
206 raw,
207 terminal,
208 stopped_by,
209 usage,
210 result_truncated,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum MessageState {
218 Pending,
220 InFlight,
222 Delivered,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct MessageReply {
228 pub result: String,
230 #[serde(default, skip_serializing_if = "is_false")]
232 pub result_truncated: bool,
233 #[serde(
235 default,
236 skip_serializing_if = "Option::is_none",
237 deserialize_with = "bound_compat"
238 )]
239 pub stopped_by: Option<Bound>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct MessageRecord {
244 pub id: String,
246 pub body: String,
248 #[serde(default, skip_serializing_if = "is_false")]
250 pub body_truncated: bool,
251 pub state: MessageState,
253 pub created_ms: u64,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub reply: Option<MessageReply>,
258}
259
260#[derive(Debug, Clone, PartialEq)]
262pub struct InboxRecord {
263 pub raw: Value,
265 pub messages: Vec<MessageRecord>,
267}
268
269fn is_zero(value: &u32) -> bool {
270 *value == 0
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub(crate) struct TaskMeta {
275 pub id: String,
276 pub parent: Option<String>,
277 pub detached: bool,
278 pub workspace: String,
279 pub agent_id: String,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub continues: Option<String>,
288 #[serde(default)]
297 pub answered_before: usize,
298 pub prompt: String,
299 pub options: RunOptions,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub pending_terminal: Option<Terminal>,
302 #[serde(default, skip_serializing_if = "is_false")]
303 pub result_truncated: bool,
304 #[serde(
305 default,
306 skip_serializing_if = "Option::is_none",
307 deserialize_with = "bound_compat"
308 )]
309 pub stopped_by: Option<Bound>,
310 #[serde(default)]
317 pub usage: RunUsage,
318 pub deadline_at_ms: Option<u64>,
319 #[serde(default, skip_serializing_if = "is_zero")]
323 pub hop: u32,
324 pub created_ms: u64,
325 #[serde(default)]
338 pub updated_ms: u64,
339}
340
341impl TaskMeta {
342 pub(crate) fn new(
343 id: String,
344 parent: Option<String>,
345 detached: bool,
346 workspace: String,
347 prompt: String,
348 options: RunOptions,
349 deadline_at_ms: Option<u64>,
350 ) -> Self {
351 let now = now_ms();
352 Self {
353 id,
354 parent,
355 detached,
356 workspace,
357 agent_id: String::new(),
358 continues: None,
359 answered_before: 0,
360 prompt,
361 options,
362 pending_terminal: None,
363 result_truncated: false,
364 stopped_by: None,
365 usage: RunUsage::default(),
366 deadline_at_ms,
367 hop: 0,
368 created_ms: now,
369 updated_ms: now,
370 }
371 }
372
373 pub(crate) fn at_hop(self, hop: u32) -> Self {
374 Self { hop, ..self }
375 }
376
377 #[must_use]
384 pub(crate) fn continuing(self, agent_id: Option<String>) -> Self {
385 Self {
386 continues: agent_id,
387 ..self
388 }
389 }
390
391 pub(crate) fn deadline_passed(&self) -> bool {
392 self.deadline_at_ms
393 .is_some_and(|deadline| deadline <= now_ms())
394 }
395
396 pub(crate) fn terminal_payload(&self) -> Option<Value> {
399 let payload = match self.pending_terminal.as_ref()? {
400 Terminal::Succeeded { result } => {
401 let mut terminal = serde_json::json!({"state": "succeeded", "result": result});
402 if self.result_truncated {
403 terminal["result_truncated"] = Value::Bool(true);
404 }
405 with_stopped_by(terminal, self.stopped_by)
406 }
407 Terminal::Failed { error } => with_stopped_by(
408 serde_json::json!({"state": "failed", "error": error}),
409 self.stopped_by,
410 ),
411 Terminal::Cancelled => serde_json::json!({"state": "cancelled"}),
412 };
413 Some(with_usage(payload, self.usage))
414 }
415}
416
417fn with_stopped_by(mut payload: Value, stopped_by: Option<Bound>) -> Value {
418 if let Some(stopped_by) = stopped_by {
419 payload["stopped_by"] = serde_json::json!(stopped_by);
420 }
421 payload
422}
423
424fn with_usage(mut payload: Value, usage: RunUsage) -> Value {
432 if usage != RunUsage::default() {
433 payload["usage"] = serde_json::json!(usage);
434 }
435 payload
436}
437
438fn is_false(value: &bool) -> bool {
439 !*value
440}
441
442pub(crate) fn load_meta(paths: &AgentPaths) -> Result<TaskMeta, String> {
443 let bytes = std::fs::read(paths.meta())
444 .map_err(|error| format!("read task metadata for {}: {error}", paths.dir().display()))?;
445 let meta: TaskMeta = serde_json::from_slice(&bytes).map_err(|error| {
446 format!(
447 "decode task metadata for {}: {error}",
448 paths.dir().display()
449 )
450 })?;
451
452 Ok(TaskMeta {
455 options: meta.options.folded(),
456 ..meta
457 })
458}
459
460pub(crate) fn save_meta(paths: &AgentPaths, meta: &TaskMeta) -> Result<(), String> {
461 let bytes =
462 serde_json::to_vec(meta).map_err(|error| format!("encode task metadata: {error}"))?;
463 write_private_atomic(&paths.meta(), &bytes)
464 .map_err(|error| format!("persist task metadata: {error}"))
465}
466
467pub(crate) fn read_terminal(paths: &AgentPaths) -> Result<Option<Value>, String> {
469 match std::fs::read(paths.terminal()) {
470 Ok(bytes) => serde_json::from_slice(&bytes)
471 .map(Some)
472 .map_err(|error| format!("decode terminal record: {error}")),
473 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
474 Err(error) => Err(format!("read terminal record: {error}")),
475 }
476}
477
478pub(crate) fn write_terminal(paths: &AgentPaths, payload: &Value) -> Result<(), String> {
479 let bytes =
480 serde_json::to_vec(payload).map_err(|error| format!("encode terminal record: {error}"))?;
481 write_private_atomic(&paths.terminal(), &bytes)
482 .map_err(|error| format!("persist terminal record: {error}"))
483}
484
485pub(crate) fn cancel_requested(paths: &AgentPaths) -> bool {
486 paths.cancel_marker().exists()
487}
488
489pub(crate) fn request_cancel(paths: &AgentPaths, by: Option<&str>) -> Result<(), String> {
492 let content = serde_json::json!({"requested_ms": now_ms(), "by": by});
493 write_private_atomic(&paths.cancel_marker(), content.to_string().as_bytes())
494 .map_err(|error| format!("record cancel request: {error}"))
495}
496
497pub(crate) fn bounded_text(mut value: String, limit: usize) -> (String, bool) {
498 if value.len() <= limit {
499 return (value, false);
500 }
501 let mut end = limit;
502 while !value.is_char_boundary(end) {
503 end -= 1;
504 }
505 value.truncate(end);
506 (value, true)
507}
508
509pub fn now_ms() -> u64 {
516 SystemTime::now()
517 .duration_since(UNIX_EPOCH)
518 .unwrap_or_default()
519 .as_millis()
520 .min(u128::from(u64::MAX)) as u64
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use crate::data_dir::DataDir;
527
528 fn agent(dir: &tempfile::TempDir) -> AgentPaths {
529 let data = DataDir::from_path(dir.path()).unwrap();
530 let paths = data
531 .agent_dir("0123456789abcdef/0123456789abcdef0123456789abcdef")
532 .unwrap();
533 std::fs::create_dir_all(paths.dir()).unwrap();
534 paths
535 }
536
537 fn meta(paths: &AgentPaths) -> TaskMeta {
538 TaskMeta::new(
539 "0123456789abcdef/0123456789abcdef0123456789abcdef".to_string(),
540 None,
541 true,
542 "/repo".to_string(),
543 "prompt".to_string(),
544 RunOptions::default(),
545 None,
546 )
547 .tap(paths)
548 }
549
550 trait Tap {
551 fn tap(self, paths: &AgentPaths) -> Self;
552 }
553 impl Tap for TaskMeta {
554 fn tap(self, paths: &AgentPaths) -> Self {
555 save_meta(paths, &self).unwrap();
556 self
557 }
558 }
559
560 #[test]
561 fn a_record_from_before_the_config_lookup_keeps_discovering() {
562 let mut legacy = serde_json::to_value(RunOptions::default()).unwrap();
565 legacy.as_object_mut().unwrap().remove("config");
566
567 let options: RunOptions = serde_json::from_value(legacy).unwrap();
568
569 assert_eq!(options.config, basis::ConfigLookup::Discover);
570 }
571
572 #[test]
573 fn rosters_are_written_only_when_stated_and_read_back_as_stated() {
574 let unstated = serde_json::to_value(RunOptions::default()).unwrap();
575 assert!(unstated.get("tool_roster").is_none());
576 assert!(unstated.get("skill_roster").is_none());
577 let read: RunOptions = serde_json::from_value(unstated).unwrap();
578 assert_eq!(read.tool_roster, None);
579 assert_eq!(read.skill_roster, None);
580
581 let stated = RunOptions {
582 tool_roster: Some(ToolRoster::only(["read"])),
583 skill_roster: Some(SkillRoster::only(["review"])),
584 ..RunOptions::default()
585 };
586 let written = serde_json::to_value(&stated).unwrap();
587 assert_eq!(
588 written["tool_roster"],
589 serde_json::json!({"only": ["read"]})
590 );
591 assert_eq!(
592 written["skill_roster"],
593 serde_json::json!({"only": ["review"]})
594 );
595 assert_eq!(
596 serde_json::from_value::<RunOptions>(written).unwrap(),
597 stated
598 );
599 }
600
601 #[test]
602 fn the_config_lookup_is_always_written() {
603 let written = serde_json::to_value(RunOptions::default()).unwrap();
604
605 assert_eq!(written["config"], serde_json::json!("none"));
606 }
607
608 #[test]
609 fn metadata_round_trips_through_its_file() {
610 let dir = tempfile::tempdir().unwrap();
611 let paths = agent(&dir);
612 let saved = meta(&paths);
613 assert_eq!(load_meta(&paths).unwrap(), saved);
614 }
615
616 #[test]
617 fn terminal_records_are_absent_until_written_then_repeatably_observable() {
618 let dir = tempfile::tempdir().unwrap();
619 let paths = agent(&dir);
620 assert!(read_terminal(&paths).unwrap().is_none());
621
622 let mut record = meta(&paths);
623 record.pending_terminal = Some(Terminal::Succeeded {
624 result: "done".to_string(),
625 });
626 let payload = record.terminal_payload().unwrap();
627 write_terminal(&paths, &payload).unwrap();
628
629 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
630 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
631 assert_eq!(
632 payload,
633 serde_json::json!({"state": "succeeded", "result": "done"})
634 );
635 }
636
637 #[test]
638 fn terminal_record_keeps_raw_json_beside_the_typed_view() {
639 let raw = serde_json::json!({
640 "state": "succeeded",
641 "result": "done",
642 "result_truncated": true,
643 "stopped_by": "token_budget",
644 "usage": {
645 "input_tokens": 7,
646 "output_tokens": 3,
647 "cache_read_tokens": 0,
648 "cache_creation_tokens": 0
649 },
650 "newer_field": "kept",
651 });
652 let record = TerminalRecord::from_raw(raw.clone());
653
654 assert_eq!(record.raw, raw);
655 assert_eq!(
656 record.terminal,
657 Some(Terminal::Succeeded {
658 result: "done".to_string()
659 })
660 );
661 assert_eq!(record.stopped_by, Some(Bound::TokenBudget));
662 assert_eq!(record.usage.expect("typed usage").input_tokens, 7);
663 assert!(record.result_truncated);
664
665 let unknown = serde_json::json!({"state": "paused_by_newer_basis", "detail": 1});
666 let record = TerminalRecord::from_raw(unknown.clone());
667 assert_eq!(record.raw, unknown);
668 assert_eq!(record.terminal, None);
669 }
670
671 #[test]
672 fn terminal_payloads_carry_truncation_and_bound_metadata() {
673 let dir = tempfile::tempdir().unwrap();
674 let paths = agent(&dir);
675 let mut record = meta(&paths);
676 record.pending_terminal = Some(Terminal::Failed {
677 error: "took too long".to_string(),
678 });
679 record.stopped_by = Some(Bound::Deadline);
680 assert_eq!(
681 record.terminal_payload().unwrap(),
682 serde_json::json!({"state": "failed", "error": "took too long", "stopped_by": "deadline"})
683 );
684
685 record.pending_terminal = Some(Terminal::Cancelled);
686 assert_eq!(
687 record.terminal_payload().unwrap(),
688 serde_json::json!({"state": "cancelled"}),
689 "a cancelled terminal never reports a bound"
690 );
691 }
692
693 #[test]
698 fn a_settled_task_records_what_it_spent_only_when_it_spent_something() {
699 let dir = tempfile::tempdir().unwrap();
700 let paths = agent(&dir);
701 let mut record = meta(&paths);
702 record.pending_terminal = Some(Terminal::Succeeded {
703 result: "done".to_string(),
704 });
705
706 assert_eq!(
707 record.terminal_payload().unwrap(),
708 serde_json::json!({"state": "succeeded", "result": "done"}),
709 "a task whose turns reported nothing claims no measurement"
710 );
711
712 record.usage = RunUsage {
713 input_tokens: 900,
714 output_tokens: 100,
715 cache_read_tokens: 7,
716 cache_creation_tokens: 3,
717 reasoning_tokens: 0,
718 thoughts_tokens: 0,
719 model_responses: 1,
720 };
721 let payload = record.terminal_payload().unwrap();
722 assert_eq!(payload["usage"]["input_tokens"], 900);
723 assert_eq!(payload["usage"]["output_tokens"], 100);
724 assert_eq!(payload["usage"]["cache_read_tokens"], 7);
725 assert_eq!(payload["usage"]["cache_creation_tokens"], 3);
726 }
727
728 #[test]
729 fn cancel_markers_signal_by_existence() {
730 let dir = tempfile::tempdir().unwrap();
731 let paths = agent(&dir);
732 assert!(!cancel_requested(&paths));
733 request_cancel(&paths, Some("caller/id")).unwrap();
734 assert!(cancel_requested(&paths));
735 }
736
737 #[test]
738 fn bounded_text_never_splits_utf8() {
739 let (value, truncated) = bounded_text("a界b".to_string(), 2);
740 assert_eq!(value, "a");
741 assert!(truncated);
742 }
743}