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}
85
86impl RunOptions {
87 fn folded(self) -> Self {
91 let (system_prompt, append) = (
92 self.system_prompt.clone(),
93 self.append_system_prompt.clone(),
94 );
95 Self {
96 system_prompt: system_prompt.or_else(|| append.map(SystemPrompt::Append)),
97 append_system_prompt: None,
98 ..self
99 }
100 }
101}
102
103fn system_prompt_compat<'de, D>(deserializer: D) -> Result<Option<SystemPrompt>, D::Error>
107where
108 D: serde::Deserializer<'de>,
109{
110 #[derive(Deserialize)]
111 #[serde(untagged)]
112 enum Compat {
113 Typed(SystemPrompt),
114 Legacy(String),
115 }
116
117 Ok(
118 Option::<Compat>::deserialize(deserializer)?.map(|value| match value {
119 Compat::Typed(system_prompt) => system_prompt,
120 Compat::Legacy(replace) => SystemPrompt::Replace(replace),
121 }),
122 )
123}
124
125fn bound_compat<'de, D>(deserializer: D) -> Result<Option<Bound>, D::Error>
130where
131 D: serde::Deserializer<'de>,
132{
133 let name = Option::<String>::deserialize(deserializer)?;
134 Ok(name.and_then(|name| Bound::from_type_tag(&name)))
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(tag = "state", rename_all = "snake_case")]
143#[non_exhaustive]
144pub enum Terminal {
145 Succeeded { result: String },
147 Failed { error: String },
149 Cancelled,
151}
152
153#[derive(Debug, Clone, PartialEq)]
156pub struct TerminalRecord {
157 pub raw: Value,
159 pub terminal: Option<Terminal>,
162 pub stopped_by: Option<Bound>,
164 pub usage: Option<RunUsage>,
166 pub result_truncated: bool,
168}
169
170impl TerminalRecord {
171 pub(crate) fn from_raw(raw: Value) -> Self {
172 let terminal = serde_json::from_value(raw.clone()).ok();
173 let stopped_by = raw
174 .get("stopped_by")
175 .and_then(Value::as_str)
176 .and_then(Bound::from_type_tag);
177 let usage = raw
178 .get("usage")
179 .cloned()
180 .and_then(|usage| serde_json::from_value(usage).ok());
181 let result_truncated = raw
182 .get("result_truncated")
183 .and_then(Value::as_bool)
184 .unwrap_or(false);
185 Self {
186 raw,
187 terminal,
188 stopped_by,
189 usage,
190 result_truncated,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum MessageState {
198 Pending,
200 InFlight,
202 Delivered,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct MessageReply {
208 pub result: String,
210 #[serde(default, skip_serializing_if = "is_false")]
212 pub result_truncated: bool,
213 #[serde(
215 default,
216 skip_serializing_if = "Option::is_none",
217 deserialize_with = "bound_compat"
218 )]
219 pub stopped_by: Option<Bound>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct MessageRecord {
224 pub id: String,
226 pub body: String,
228 #[serde(default, skip_serializing_if = "is_false")]
230 pub body_truncated: bool,
231 pub state: MessageState,
233 pub created_ms: u64,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub reply: Option<MessageReply>,
238}
239
240#[derive(Debug, Clone, PartialEq)]
242pub struct InboxRecord {
243 pub raw: Value,
245 pub messages: Vec<MessageRecord>,
247}
248
249fn is_zero(value: &u32) -> bool {
250 *value == 0
251}
252
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub(crate) struct TaskMeta {
255 pub id: String,
256 pub parent: Option<String>,
257 pub detached: bool,
258 pub workspace: String,
259 pub agent_id: String,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub continues: Option<String>,
268 #[serde(default)]
277 pub answered_before: usize,
278 pub prompt: String,
279 pub options: RunOptions,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub pending_terminal: Option<Terminal>,
282 #[serde(default, skip_serializing_if = "is_false")]
283 pub result_truncated: bool,
284 #[serde(
285 default,
286 skip_serializing_if = "Option::is_none",
287 deserialize_with = "bound_compat"
288 )]
289 pub stopped_by: Option<Bound>,
290 #[serde(default)]
297 pub usage: RunUsage,
298 pub deadline_at_ms: Option<u64>,
299 #[serde(default, skip_serializing_if = "is_zero")]
303 pub hop: u32,
304 pub created_ms: u64,
305 #[serde(default)]
318 pub updated_ms: u64,
319}
320
321impl TaskMeta {
322 pub(crate) fn new(
323 id: String,
324 parent: Option<String>,
325 detached: bool,
326 workspace: String,
327 prompt: String,
328 options: RunOptions,
329 deadline_at_ms: Option<u64>,
330 ) -> Self {
331 let now = now_ms();
332 Self {
333 id,
334 parent,
335 detached,
336 workspace,
337 agent_id: String::new(),
338 continues: None,
339 answered_before: 0,
340 prompt,
341 options,
342 pending_terminal: None,
343 result_truncated: false,
344 stopped_by: None,
345 usage: RunUsage::default(),
346 deadline_at_ms,
347 hop: 0,
348 created_ms: now,
349 updated_ms: now,
350 }
351 }
352
353 pub(crate) fn at_hop(self, hop: u32) -> Self {
354 Self { hop, ..self }
355 }
356
357 #[must_use]
364 pub(crate) fn continuing(self, agent_id: Option<String>) -> Self {
365 Self {
366 continues: agent_id,
367 ..self
368 }
369 }
370
371 pub(crate) fn deadline_passed(&self) -> bool {
372 self.deadline_at_ms
373 .is_some_and(|deadline| deadline <= now_ms())
374 }
375
376 pub(crate) fn terminal_payload(&self) -> Option<Value> {
379 let payload = match self.pending_terminal.as_ref()? {
380 Terminal::Succeeded { result } => {
381 let mut terminal = serde_json::json!({"state": "succeeded", "result": result});
382 if self.result_truncated {
383 terminal["result_truncated"] = Value::Bool(true);
384 }
385 with_stopped_by(terminal, self.stopped_by)
386 }
387 Terminal::Failed { error } => with_stopped_by(
388 serde_json::json!({"state": "failed", "error": error}),
389 self.stopped_by,
390 ),
391 Terminal::Cancelled => serde_json::json!({"state": "cancelled"}),
392 };
393 Some(with_usage(payload, self.usage))
394 }
395}
396
397fn with_stopped_by(mut payload: Value, stopped_by: Option<Bound>) -> Value {
398 if let Some(stopped_by) = stopped_by {
399 payload["stopped_by"] = serde_json::json!(stopped_by);
400 }
401 payload
402}
403
404fn with_usage(mut payload: Value, usage: RunUsage) -> Value {
412 if usage != RunUsage::default() {
413 payload["usage"] = serde_json::json!(usage);
414 }
415 payload
416}
417
418fn is_false(value: &bool) -> bool {
419 !*value
420}
421
422pub(crate) fn load_meta(paths: &AgentPaths) -> Result<TaskMeta, String> {
423 let bytes = std::fs::read(paths.meta())
424 .map_err(|error| format!("read task metadata for {}: {error}", paths.dir().display()))?;
425 let meta: TaskMeta = serde_json::from_slice(&bytes).map_err(|error| {
426 format!(
427 "decode task metadata for {}: {error}",
428 paths.dir().display()
429 )
430 })?;
431
432 Ok(TaskMeta {
435 options: meta.options.folded(),
436 ..meta
437 })
438}
439
440pub(crate) fn save_meta(paths: &AgentPaths, meta: &TaskMeta) -> Result<(), String> {
441 let bytes =
442 serde_json::to_vec(meta).map_err(|error| format!("encode task metadata: {error}"))?;
443 write_private_atomic(&paths.meta(), &bytes)
444 .map_err(|error| format!("persist task metadata: {error}"))
445}
446
447pub(crate) fn read_terminal(paths: &AgentPaths) -> Result<Option<Value>, String> {
449 match std::fs::read(paths.terminal()) {
450 Ok(bytes) => serde_json::from_slice(&bytes)
451 .map(Some)
452 .map_err(|error| format!("decode terminal record: {error}")),
453 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
454 Err(error) => Err(format!("read terminal record: {error}")),
455 }
456}
457
458pub(crate) fn write_terminal(paths: &AgentPaths, payload: &Value) -> Result<(), String> {
459 let bytes =
460 serde_json::to_vec(payload).map_err(|error| format!("encode terminal record: {error}"))?;
461 write_private_atomic(&paths.terminal(), &bytes)
462 .map_err(|error| format!("persist terminal record: {error}"))
463}
464
465pub(crate) fn cancel_requested(paths: &AgentPaths) -> bool {
466 paths.cancel_marker().exists()
467}
468
469pub(crate) fn request_cancel(paths: &AgentPaths, by: Option<&str>) -> Result<(), String> {
472 let content = serde_json::json!({"requested_ms": now_ms(), "by": by});
473 write_private_atomic(&paths.cancel_marker(), content.to_string().as_bytes())
474 .map_err(|error| format!("record cancel request: {error}"))
475}
476
477pub(crate) fn bounded_text(mut value: String, limit: usize) -> (String, bool) {
478 if value.len() <= limit {
479 return (value, false);
480 }
481 let mut end = limit;
482 while !value.is_char_boundary(end) {
483 end -= 1;
484 }
485 value.truncate(end);
486 (value, true)
487}
488
489pub fn now_ms() -> u64 {
496 SystemTime::now()
497 .duration_since(UNIX_EPOCH)
498 .unwrap_or_default()
499 .as_millis()
500 .min(u128::from(u64::MAX)) as u64
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::data_dir::DataDir;
507
508 fn agent(dir: &tempfile::TempDir) -> AgentPaths {
509 let data = DataDir::from_path(dir.path()).unwrap();
510 let paths = data
511 .agent_dir("0123456789abcdef/0123456789abcdef0123456789abcdef")
512 .unwrap();
513 std::fs::create_dir_all(paths.dir()).unwrap();
514 paths
515 }
516
517 fn meta(paths: &AgentPaths) -> TaskMeta {
518 TaskMeta::new(
519 "0123456789abcdef/0123456789abcdef0123456789abcdef".to_string(),
520 None,
521 true,
522 "/repo".to_string(),
523 "prompt".to_string(),
524 RunOptions::default(),
525 None,
526 )
527 .tap(paths)
528 }
529
530 trait Tap {
531 fn tap(self, paths: &AgentPaths) -> Self;
532 }
533 impl Tap for TaskMeta {
534 fn tap(self, paths: &AgentPaths) -> Self {
535 save_meta(paths, &self).unwrap();
536 self
537 }
538 }
539
540 #[test]
541 fn metadata_round_trips_through_its_file() {
542 let dir = tempfile::tempdir().unwrap();
543 let paths = agent(&dir);
544 let saved = meta(&paths);
545 assert_eq!(load_meta(&paths).unwrap(), saved);
546 }
547
548 #[test]
549 fn terminal_records_are_absent_until_written_then_repeatably_observable() {
550 let dir = tempfile::tempdir().unwrap();
551 let paths = agent(&dir);
552 assert!(read_terminal(&paths).unwrap().is_none());
553
554 let mut record = meta(&paths);
555 record.pending_terminal = Some(Terminal::Succeeded {
556 result: "done".to_string(),
557 });
558 let payload = record.terminal_payload().unwrap();
559 write_terminal(&paths, &payload).unwrap();
560
561 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
562 assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
563 assert_eq!(
564 payload,
565 serde_json::json!({"state": "succeeded", "result": "done"})
566 );
567 }
568
569 #[test]
570 fn terminal_record_keeps_raw_json_beside_the_typed_view() {
571 let raw = serde_json::json!({
572 "state": "succeeded",
573 "result": "done",
574 "result_truncated": true,
575 "stopped_by": "token_budget",
576 "usage": {
577 "input_tokens": 7,
578 "output_tokens": 3,
579 "cache_read_tokens": 0,
580 "cache_creation_tokens": 0
581 },
582 "newer_field": "kept",
583 });
584 let record = TerminalRecord::from_raw(raw.clone());
585
586 assert_eq!(record.raw, raw);
587 assert_eq!(
588 record.terminal,
589 Some(Terminal::Succeeded {
590 result: "done".to_string()
591 })
592 );
593 assert_eq!(record.stopped_by, Some(Bound::TokenBudget));
594 assert_eq!(record.usage.expect("typed usage").input_tokens, 7);
595 assert!(record.result_truncated);
596
597 let unknown = serde_json::json!({"state": "paused_by_newer_basis", "detail": 1});
598 let record = TerminalRecord::from_raw(unknown.clone());
599 assert_eq!(record.raw, unknown);
600 assert_eq!(record.terminal, None);
601 }
602
603 #[test]
604 fn terminal_payloads_carry_truncation_and_bound_metadata() {
605 let dir = tempfile::tempdir().unwrap();
606 let paths = agent(&dir);
607 let mut record = meta(&paths);
608 record.pending_terminal = Some(Terminal::Failed {
609 error: "took too long".to_string(),
610 });
611 record.stopped_by = Some(Bound::Deadline);
612 assert_eq!(
613 record.terminal_payload().unwrap(),
614 serde_json::json!({"state": "failed", "error": "took too long", "stopped_by": "deadline"})
615 );
616
617 record.pending_terminal = Some(Terminal::Cancelled);
618 assert_eq!(
619 record.terminal_payload().unwrap(),
620 serde_json::json!({"state": "cancelled"}),
621 "a cancelled terminal never reports a bound"
622 );
623 }
624
625 #[test]
630 fn a_settled_task_records_what_it_spent_only_when_it_spent_something() {
631 let dir = tempfile::tempdir().unwrap();
632 let paths = agent(&dir);
633 let mut record = meta(&paths);
634 record.pending_terminal = Some(Terminal::Succeeded {
635 result: "done".to_string(),
636 });
637
638 assert_eq!(
639 record.terminal_payload().unwrap(),
640 serde_json::json!({"state": "succeeded", "result": "done"}),
641 "a task whose turns reported nothing claims no measurement"
642 );
643
644 record.usage = RunUsage {
645 input_tokens: 900,
646 output_tokens: 100,
647 cache_read_tokens: 7,
648 cache_creation_tokens: 3,
649 reasoning_tokens: 0,
650 thoughts_tokens: 0,
651 model_responses: 1,
652 };
653 let payload = record.terminal_payload().unwrap();
654 assert_eq!(payload["usage"]["input_tokens"], 900);
655 assert_eq!(payload["usage"]["output_tokens"], 100);
656 assert_eq!(payload["usage"]["cache_read_tokens"], 7);
657 assert_eq!(payload["usage"]["cache_creation_tokens"], 3);
658 }
659
660 #[test]
661 fn cancel_markers_signal_by_existence() {
662 let dir = tempfile::tempdir().unwrap();
663 let paths = agent(&dir);
664 assert!(!cancel_requested(&paths));
665 request_cancel(&paths, Some("caller/id")).unwrap();
666 assert!(cancel_requested(&paths));
667 }
668
669 #[test]
670 fn bounded_text_never_splits_utf8() {
671 let (value, truncated) = bounded_text("a界b".to_string(), 2);
672 assert_eq!(value, "a");
673 assert!(truncated);
674 }
675}