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 #[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}
90
91fn legacy_config_lookup() -> basis::ConfigLookup {
93 basis::ConfigLookup::Discover
94}
95
96impl RunOptions {
97 fn folded(self) -> Self {
101 let (system_prompt, append) = (
102 self.system_prompt.clone(),
103 self.append_system_prompt.clone(),
104 );
105 Self {
106 system_prompt: system_prompt.or_else(|| append.map(SystemPrompt::Append)),
107 append_system_prompt: None,
108 ..self
109 }
110 }
111}
112
113fn system_prompt_compat<'de, D>(deserializer: D) -> Result<Option<SystemPrompt>, D::Error>
117where
118 D: serde::Deserializer<'de>,
119{
120 #[derive(Deserialize)]
121 #[serde(untagged)]
122 enum Compat {
123 Typed(SystemPrompt),
124 Legacy(String),
125 }
126
127 Ok(
128 Option::<Compat>::deserialize(deserializer)?.map(|value| match value {
129 Compat::Typed(system_prompt) => system_prompt,
130 Compat::Legacy(replace) => SystemPrompt::Replace(replace),
131 }),
132 )
133}
134
135fn bound_compat<'de, D>(deserializer: D) -> Result<Option<Bound>, D::Error>
140where
141 D: serde::Deserializer<'de>,
142{
143 let name = Option::<String>::deserialize(deserializer)?;
144 Ok(name.and_then(|name| Bound::from_type_tag(&name)))
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(tag = "state", rename_all = "snake_case")]
153#[non_exhaustive]
154pub enum Terminal {
155 Succeeded { result: String },
157 Failed { error: String },
159 Cancelled,
161}
162
163#[derive(Debug, Clone, PartialEq)]
166pub struct TerminalRecord {
167 pub raw: Value,
169 pub terminal: Option<Terminal>,
172 pub stopped_by: Option<Bound>,
174 pub usage: Option<RunUsage>,
176 pub result_truncated: bool,
178}
179
180impl TerminalRecord {
181 pub(crate) fn from_raw(raw: Value) -> Self {
182 let terminal = serde_json::from_value(raw.clone()).ok();
183 let stopped_by = raw
184 .get("stopped_by")
185 .and_then(Value::as_str)
186 .and_then(Bound::from_type_tag);
187 let usage = raw
188 .get("usage")
189 .cloned()
190 .and_then(|usage| serde_json::from_value(usage).ok());
191 let result_truncated = raw
192 .get("result_truncated")
193 .and_then(Value::as_bool)
194 .unwrap_or(false);
195 Self {
196 raw,
197 terminal,
198 stopped_by,
199 usage,
200 result_truncated,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum MessageState {
208 Pending,
210 InFlight,
212 Delivered,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct MessageReply {
218 pub result: String,
220 #[serde(default, skip_serializing_if = "is_false")]
222 pub result_truncated: bool,
223 #[serde(
225 default,
226 skip_serializing_if = "Option::is_none",
227 deserialize_with = "bound_compat"
228 )]
229 pub stopped_by: Option<Bound>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct MessageRecord {
234 pub id: String,
236 pub body: String,
238 #[serde(default, skip_serializing_if = "is_false")]
240 pub body_truncated: bool,
241 pub state: MessageState,
243 pub created_ms: u64,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub reply: Option<MessageReply>,
248}
249
250#[derive(Debug, Clone, PartialEq)]
252pub struct InboxRecord {
253 pub raw: Value,
255 pub messages: Vec<MessageRecord>,
257}
258
259fn is_zero(value: &u32) -> bool {
260 *value == 0
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub(crate) struct TaskMeta {
265 pub id: String,
266 pub parent: Option<String>,
267 pub detached: bool,
268 pub workspace: String,
269 pub agent_id: String,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub continues: Option<String>,
278 #[serde(default)]
287 pub answered_before: usize,
288 pub prompt: String,
289 pub options: RunOptions,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub pending_terminal: Option<Terminal>,
292 #[serde(default, skip_serializing_if = "is_false")]
293 pub result_truncated: bool,
294 #[serde(
295 default,
296 skip_serializing_if = "Option::is_none",
297 deserialize_with = "bound_compat"
298 )]
299 pub stopped_by: Option<Bound>,
300 #[serde(default)]
307 pub usage: RunUsage,
308 pub deadline_at_ms: Option<u64>,
309 #[serde(default, skip_serializing_if = "is_zero")]
313 pub hop: u32,
314 pub created_ms: u64,
315 #[serde(default)]
328 pub updated_ms: u64,
329}
330
331impl TaskMeta {
332 pub(crate) fn new(
333 id: String,
334 parent: Option<String>,
335 detached: bool,
336 workspace: String,
337 prompt: String,
338 options: RunOptions,
339 deadline_at_ms: Option<u64>,
340 ) -> Self {
341 let now = now_ms();
342 Self {
343 id,
344 parent,
345 detached,
346 workspace,
347 agent_id: String::new(),
348 continues: None,
349 answered_before: 0,
350 prompt,
351 options,
352 pending_terminal: None,
353 result_truncated: false,
354 stopped_by: None,
355 usage: RunUsage::default(),
356 deadline_at_ms,
357 hop: 0,
358 created_ms: now,
359 updated_ms: now,
360 }
361 }
362
363 pub(crate) fn at_hop(self, hop: u32) -> Self {
364 Self { hop, ..self }
365 }
366
367 #[must_use]
374 pub(crate) fn continuing(self, agent_id: Option<String>) -> Self {
375 Self {
376 continues: agent_id,
377 ..self
378 }
379 }
380
381 pub(crate) fn deadline_passed(&self) -> bool {
382 self.deadline_at_ms
383 .is_some_and(|deadline| deadline <= now_ms())
384 }
385
386 pub(crate) fn terminal_payload(&self) -> Option<Value> {
389 let payload = match self.pending_terminal.as_ref()? {
390 Terminal::Succeeded { result } => {
391 let mut terminal = serde_json::json!({"state": "succeeded", "result": result});
392 if self.result_truncated {
393 terminal["result_truncated"] = Value::Bool(true);
394 }
395 with_stopped_by(terminal, self.stopped_by)
396 }
397 Terminal::Failed { error } => with_stopped_by(
398 serde_json::json!({"state": "failed", "error": error}),
399 self.stopped_by,
400 ),
401 Terminal::Cancelled => serde_json::json!({"state": "cancelled"}),
402 };
403 Some(with_usage(payload, self.usage))
404 }
405}
406
407fn with_stopped_by(mut payload: Value, stopped_by: Option<Bound>) -> Value {
408 if let Some(stopped_by) = stopped_by {
409 payload["stopped_by"] = serde_json::json!(stopped_by);
410 }
411 payload
412}
413
414fn with_usage(mut payload: Value, usage: RunUsage) -> Value {
422 if usage != RunUsage::default() {
423 payload["usage"] = serde_json::json!(usage);
424 }
425 payload
426}
427
428fn is_false(value: &bool) -> bool {
429 !*value
430}
431
432pub(crate) fn load_meta(paths: &AgentPaths) -> Result<TaskMeta, String> {
433 let bytes = std::fs::read(paths.meta())
434 .map_err(|error| format!("read task metadata for {}: {error}", paths.dir().display()))?;
435 let meta: TaskMeta = serde_json::from_slice(&bytes).map_err(|error| {
436 format!(
437 "decode task metadata for {}: {error}",
438 paths.dir().display()
439 )
440 })?;
441
442 Ok(TaskMeta {
445 options: meta.options.folded(),
446 ..meta
447 })
448}
449
450pub(crate) fn save_meta(paths: &AgentPaths, meta: &TaskMeta) -> Result<(), String> {
451 let bytes =
452 serde_json::to_vec(meta).map_err(|error| format!("encode task metadata: {error}"))?;
453 write_private_atomic(&paths.meta(), &bytes)
454 .map_err(|error| format!("persist task metadata: {error}"))
455}
456
457pub(crate) fn read_terminal(paths: &AgentPaths) -> Result<Option<Value>, String> {
459 match std::fs::read(paths.terminal()) {
460 Ok(bytes) => serde_json::from_slice(&bytes)
461 .map(Some)
462 .map_err(|error| format!("decode terminal record: {error}")),
463 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
464 Err(error) => Err(format!("read terminal record: {error}")),
465 }
466}
467
468pub(crate) fn write_terminal(paths: &AgentPaths, payload: &Value) -> Result<(), String> {
469 let bytes =
470 serde_json::to_vec(payload).map_err(|error| format!("encode terminal record: {error}"))?;
471 write_private_atomic(&paths.terminal(), &bytes)
472 .map_err(|error| format!("persist terminal record: {error}"))
473}
474
475pub(crate) fn cancel_requested(paths: &AgentPaths) -> bool {
476 paths.cancel_marker().exists()
477}
478
479pub(crate) fn request_cancel(paths: &AgentPaths, by: Option<&str>) -> Result<(), String> {
482 let content = serde_json::json!({"requested_ms": now_ms(), "by": by});
483 write_private_atomic(&paths.cancel_marker(), content.to_string().as_bytes())
484 .map_err(|error| format!("record cancel request: {error}"))
485}
486
487pub(crate) fn bounded_text(mut value: String, limit: usize) -> (String, bool) {
488 if value.len() <= limit {
489 return (value, false);
490 }
491 let mut end = limit;
492 while !value.is_char_boundary(end) {
493 end -= 1;
494 }
495 value.truncate(end);
496 (value, true)
497}
498
499pub fn now_ms() -> u64 {
506 SystemTime::now()
507 .duration_since(UNIX_EPOCH)
508 .unwrap_or_default()
509 .as_millis()
510 .min(u128::from(u64::MAX)) as u64
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::data_dir::DataDir;
517
518 fn agent(dir: &tempfile::TempDir) -> AgentPaths {
519 let data = DataDir::from_path(dir.path()).unwrap();
520 let paths = data
521 .agent_dir("0123456789abcdef/0123456789abcdef0123456789abcdef")
522 .unwrap();
523 std::fs::create_dir_all(paths.dir()).unwrap();
524 paths
525 }
526
527 fn meta(paths: &AgentPaths) -> TaskMeta {
528 TaskMeta::new(
529 "0123456789abcdef/0123456789abcdef0123456789abcdef".to_string(),
530 None,
531 true,
532 "/repo".to_string(),
533 "prompt".to_string(),
534 RunOptions::default(),
535 None,
536 )
537 .tap(paths)
538 }
539
540 trait Tap {
541 fn tap(self, paths: &AgentPaths) -> Self;
542 }
543 impl Tap for TaskMeta {
544 fn tap(self, paths: &AgentPaths) -> Self {
545 save_meta(paths, &self).unwrap();
546 self
547 }
548 }
549
550 #[test]
551 fn a_record_from_before_the_config_lookup_keeps_discovering() {
552 let mut legacy = serde_json::to_value(RunOptions::default()).unwrap();
555 legacy.as_object_mut().unwrap().remove("config");
556
557 let options: RunOptions = serde_json::from_value(legacy).unwrap();
558
559 assert_eq!(options.config, basis::ConfigLookup::Discover);
560 }
561
562 #[test]
563 fn the_config_lookup_is_always_written() {
564 let written = serde_json::to_value(RunOptions::default()).unwrap();
565
566 assert_eq!(written["config"], serde_json::json!("none"));
567 }
568
569 #[test]
570 fn metadata_round_trips_through_its_file() {
571 let dir = tempfile::tempdir().unwrap();
572 let paths = agent(&dir);
573 let saved = meta(&paths);
574 assert_eq!(load_meta(&paths).unwrap(), saved);
575 }
576
577 #[test]
578 fn terminal_records_are_absent_until_written_then_repeatably_observable() {
579 let dir = tempfile::tempdir().unwrap();
580 let paths = agent(&dir);
581 assert!(read_terminal(&paths).unwrap().is_none());
582
583 let mut record = meta(&paths);
584 record.pending_terminal = Some(Terminal::Succeeded {
585 result: "done".to_string(),
586 });
587 let payload = record.terminal_payload().unwrap();
588 write_terminal(&paths, &payload).unwrap();
589
590 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
591 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
592 assert_eq!(
593 payload,
594 serde_json::json!({"state": "succeeded", "result": "done"})
595 );
596 }
597
598 #[test]
599 fn terminal_record_keeps_raw_json_beside_the_typed_view() {
600 let raw = serde_json::json!({
601 "state": "succeeded",
602 "result": "done",
603 "result_truncated": true,
604 "stopped_by": "token_budget",
605 "usage": {
606 "input_tokens": 7,
607 "output_tokens": 3,
608 "cache_read_tokens": 0,
609 "cache_creation_tokens": 0
610 },
611 "newer_field": "kept",
612 });
613 let record = TerminalRecord::from_raw(raw.clone());
614
615 assert_eq!(record.raw, raw);
616 assert_eq!(
617 record.terminal,
618 Some(Terminal::Succeeded {
619 result: "done".to_string()
620 })
621 );
622 assert_eq!(record.stopped_by, Some(Bound::TokenBudget));
623 assert_eq!(record.usage.expect("typed usage").input_tokens, 7);
624 assert!(record.result_truncated);
625
626 let unknown = serde_json::json!({"state": "paused_by_newer_basis", "detail": 1});
627 let record = TerminalRecord::from_raw(unknown.clone());
628 assert_eq!(record.raw, unknown);
629 assert_eq!(record.terminal, None);
630 }
631
632 #[test]
633 fn terminal_payloads_carry_truncation_and_bound_metadata() {
634 let dir = tempfile::tempdir().unwrap();
635 let paths = agent(&dir);
636 let mut record = meta(&paths);
637 record.pending_terminal = Some(Terminal::Failed {
638 error: "took too long".to_string(),
639 });
640 record.stopped_by = Some(Bound::Deadline);
641 assert_eq!(
642 record.terminal_payload().unwrap(),
643 serde_json::json!({"state": "failed", "error": "took too long", "stopped_by": "deadline"})
644 );
645
646 record.pending_terminal = Some(Terminal::Cancelled);
647 assert_eq!(
648 record.terminal_payload().unwrap(),
649 serde_json::json!({"state": "cancelled"}),
650 "a cancelled terminal never reports a bound"
651 );
652 }
653
654 #[test]
659 fn a_settled_task_records_what_it_spent_only_when_it_spent_something() {
660 let dir = tempfile::tempdir().unwrap();
661 let paths = agent(&dir);
662 let mut record = meta(&paths);
663 record.pending_terminal = Some(Terminal::Succeeded {
664 result: "done".to_string(),
665 });
666
667 assert_eq!(
668 record.terminal_payload().unwrap(),
669 serde_json::json!({"state": "succeeded", "result": "done"}),
670 "a task whose turns reported nothing claims no measurement"
671 );
672
673 record.usage = RunUsage {
674 input_tokens: 900,
675 output_tokens: 100,
676 cache_read_tokens: 7,
677 cache_creation_tokens: 3,
678 reasoning_tokens: 0,
679 thoughts_tokens: 0,
680 model_responses: 1,
681 };
682 let payload = record.terminal_payload().unwrap();
683 assert_eq!(payload["usage"]["input_tokens"], 900);
684 assert_eq!(payload["usage"]["output_tokens"], 100);
685 assert_eq!(payload["usage"]["cache_read_tokens"], 7);
686 assert_eq!(payload["usage"]["cache_creation_tokens"], 3);
687 }
688
689 #[test]
690 fn cancel_markers_signal_by_existence() {
691 let dir = tempfile::tempdir().unwrap();
692 let paths = agent(&dir);
693 assert!(!cancel_requested(&paths));
694 request_cancel(&paths, Some("caller/id")).unwrap();
695 assert!(cancel_requested(&paths));
696 }
697
698 #[test]
699 fn bounded_text_never_splits_utf8() {
700 let (value, truncated) = bounded_text("a界b".to_string(), 2);
701 assert_eq!(value, "a");
702 assert!(truncated);
703 }
704}