use std::{
io,
time::{SystemTime, UNIX_EPOCH},
};
use basis::{Bound, Effort, RunUsage, SystemPrompt};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::approve::Approve;
use crate::data_dir::{AgentPaths, write_private_atomic};
pub(crate) const MAX_MESSAGES: usize = 16;
pub(crate) const MAX_MESSAGE: usize = 256 * 1024;
pub(crate) const MAX_PROMPT: usize = 256 * 1024;
pub(crate) const MAX_EVENT_BYTES: usize = 32 * 1024;
pub(crate) const MAX_RESULT_BYTES: usize = 1024 * 1024;
pub const MAX_TASKS: usize = 1024;
pub(crate) const MAX_EVENTS_BYTES: u64 = 32 * 1024 * 1024;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct RunOptions {
pub provider: Option<String>,
pub base_url: Option<String>,
pub model: Option<String>,
pub no_shell: bool,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "system_prompt_compat"
)]
pub system_prompt: Option<SystemPrompt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub append_system_prompt: Option<String>,
pub effort: Option<Effort>,
pub approve: Approve,
pub deadline_ms: Option<u64>,
pub tool_budget: Option<usize>,
pub token_budget: Option<u64>,
}
impl RunOptions {
fn folded(self) -> Self {
let (system_prompt, append) = (
self.system_prompt.clone(),
self.append_system_prompt.clone(),
);
Self {
system_prompt: system_prompt.or_else(|| append.map(SystemPrompt::Append)),
append_system_prompt: None,
..self
}
}
}
fn system_prompt_compat<'de, D>(deserializer: D) -> Result<Option<SystemPrompt>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Compat {
Typed(SystemPrompt),
Legacy(String),
}
Ok(
Option::<Compat>::deserialize(deserializer)?.map(|value| match value {
Compat::Typed(system_prompt) => system_prompt,
Compat::Legacy(replace) => SystemPrompt::Replace(replace),
}),
)
}
fn bound_compat<'de, D>(deserializer: D) -> Result<Option<Bound>, D::Error>
where
D: serde::Deserializer<'de>,
{
let name = Option::<String>::deserialize(deserializer)?;
Ok(name.and_then(|name| serde_json::from_value(Value::String(name)).ok()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub(crate) enum PendingTerminal {
Succeeded { result: String },
Failed { error: String },
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum MessageState {
Pending,
InFlight,
Delivered,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct MessageReply {
pub result: String,
#[serde(default, skip_serializing_if = "is_false")]
pub result_truncated: bool,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "bound_compat"
)]
pub stopped_by: Option<Bound>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct MessageRecord {
pub id: String,
pub body: String,
pub state: MessageState,
pub created_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply: Option<MessageReply>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct TaskMeta {
pub id: String,
pub parent: Option<String>,
pub detached: bool,
pub workspace: String,
pub agent_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub continues: Option<String>,
#[serde(default)]
pub answered_before: usize,
pub prompt: String,
pub options: RunOptions,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_terminal: Option<PendingTerminal>,
#[serde(default, skip_serializing_if = "is_false")]
pub result_truncated: bool,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "bound_compat"
)]
pub stopped_by: Option<Bound>,
#[serde(default)]
pub usage: RunUsage,
pub deadline_at_ms: Option<u64>,
pub created_ms: u64,
#[serde(default)]
pub updated_ms: u64,
}
impl TaskMeta {
pub(crate) fn new(
id: String,
parent: Option<String>,
detached: bool,
workspace: String,
prompt: String,
options: RunOptions,
deadline_at_ms: Option<u64>,
) -> Self {
let now = now_ms();
Self {
id,
parent,
detached,
workspace,
agent_id: String::new(),
continues: None,
answered_before: 0,
prompt,
options,
pending_terminal: None,
result_truncated: false,
stopped_by: None,
usage: RunUsage::default(),
deadline_at_ms,
created_ms: now,
updated_ms: now,
}
}
#[must_use]
pub(crate) fn continuing(self, agent_id: Option<String>) -> Self {
Self {
continues: agent_id,
..self
}
}
pub(crate) fn deadline_passed(&self) -> bool {
self.deadline_at_ms
.is_some_and(|deadline| deadline <= now_ms())
}
pub(crate) fn terminal_payload(&self) -> Option<Value> {
let payload = match self.pending_terminal.as_ref()? {
PendingTerminal::Succeeded { result } => {
let mut terminal = serde_json::json!({"state": "succeeded", "result": result});
if self.result_truncated {
terminal["result_truncated"] = Value::Bool(true);
}
with_stopped_by(terminal, self.stopped_by)
}
PendingTerminal::Failed { error } => with_stopped_by(
serde_json::json!({"state": "failed", "error": error}),
self.stopped_by,
),
PendingTerminal::Cancelled => serde_json::json!({"state": "cancelled"}),
};
Some(with_usage(payload, self.usage))
}
}
fn with_stopped_by(mut payload: Value, stopped_by: Option<Bound>) -> Value {
if let Some(stopped_by) = stopped_by {
payload["stopped_by"] = serde_json::json!(stopped_by);
}
payload
}
fn with_usage(mut payload: Value, usage: RunUsage) -> Value {
if usage != RunUsage::default() {
payload["usage"] = serde_json::json!(usage);
}
payload
}
fn is_false(value: &bool) -> bool {
!*value
}
pub(crate) fn load_meta(paths: &AgentPaths) -> Result<TaskMeta, String> {
let bytes = std::fs::read(paths.meta())
.map_err(|error| format!("read task metadata for {}: {error}", paths.dir().display()))?;
let meta: TaskMeta = serde_json::from_slice(&bytes).map_err(|error| {
format!(
"decode task metadata for {}: {error}",
paths.dir().display()
)
})?;
Ok(TaskMeta {
options: meta.options.folded(),
..meta
})
}
pub(crate) fn save_meta(paths: &AgentPaths, meta: &TaskMeta) -> Result<(), String> {
let bytes =
serde_json::to_vec(meta).map_err(|error| format!("encode task metadata: {error}"))?;
write_private_atomic(&paths.meta(), &bytes)
.map_err(|error| format!("persist task metadata: {error}"))
}
pub(crate) fn read_terminal(paths: &AgentPaths) -> Result<Option<Value>, String> {
match std::fs::read(paths.terminal()) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|error| format!("decode terminal record: {error}")),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!("read terminal record: {error}")),
}
}
pub(crate) fn write_terminal(paths: &AgentPaths, payload: &Value) -> Result<(), String> {
let bytes =
serde_json::to_vec(payload).map_err(|error| format!("encode terminal record: {error}"))?;
write_private_atomic(&paths.terminal(), &bytes)
.map_err(|error| format!("persist terminal record: {error}"))
}
pub(crate) fn cancel_requested(paths: &AgentPaths) -> bool {
paths.cancel_marker().exists()
}
pub(crate) fn request_cancel(paths: &AgentPaths, by: Option<&str>) -> Result<(), String> {
let content = serde_json::json!({"requested_ms": now_ms(), "by": by});
write_private_atomic(&paths.cancel_marker(), content.to_string().as_bytes())
.map_err(|error| format!("record cancel request: {error}"))
}
pub(crate) fn bounded_text(mut value: String, limit: usize) -> (String, bool) {
if value.len() <= limit {
return (value, false);
}
let mut end = limit;
while !value.is_char_boundary(end) {
end -= 1;
}
value.truncate(end);
(value, true)
}
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u128::from(u64::MAX)) as u64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_dir::DataDir;
fn agent(dir: &tempfile::TempDir) -> AgentPaths {
let data = DataDir::from_path(dir.path()).unwrap();
let paths = data
.agent_dir("0123456789abcdef/0123456789abcdef0123456789abcdef")
.unwrap();
std::fs::create_dir_all(paths.dir()).unwrap();
paths
}
fn meta(paths: &AgentPaths) -> TaskMeta {
TaskMeta::new(
"0123456789abcdef/0123456789abcdef0123456789abcdef".to_string(),
None,
true,
"/repo".to_string(),
"prompt".to_string(),
RunOptions::default(),
None,
)
.tap(paths)
}
trait Tap {
fn tap(self, paths: &AgentPaths) -> Self;
}
impl Tap for TaskMeta {
fn tap(self, paths: &AgentPaths) -> Self {
save_meta(paths, &self).unwrap();
self
}
}
#[test]
fn metadata_round_trips_through_its_file() {
let dir = tempfile::tempdir().unwrap();
let paths = agent(&dir);
let saved = meta(&paths);
assert_eq!(load_meta(&paths).unwrap(), saved);
}
#[test]
fn terminal_records_are_absent_until_written_then_repeatably_observable() {
let dir = tempfile::tempdir().unwrap();
let paths = agent(&dir);
assert!(read_terminal(&paths).unwrap().is_none());
let mut record = meta(&paths);
record.pending_terminal = Some(PendingTerminal::Succeeded {
result: "done".to_string(),
});
let payload = record.terminal_payload().unwrap();
write_terminal(&paths, &payload).unwrap();
assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
assert_eq!(
payload,
serde_json::json!({"state": "succeeded", "result": "done"})
);
}
#[test]
fn terminal_payloads_carry_truncation_and_bound_metadata() {
let dir = tempfile::tempdir().unwrap();
let paths = agent(&dir);
let mut record = meta(&paths);
record.pending_terminal = Some(PendingTerminal::Failed {
error: "took too long".to_string(),
});
record.stopped_by = Some(Bound::Deadline);
assert_eq!(
record.terminal_payload().unwrap(),
serde_json::json!({"state": "failed", "error": "took too long", "stopped_by": "deadline"})
);
record.pending_terminal = Some(PendingTerminal::Cancelled);
assert_eq!(
record.terminal_payload().unwrap(),
serde_json::json!({"state": "cancelled"}),
"a cancelled terminal never reports a bound"
);
}
#[test]
fn a_settled_task_records_what_it_spent_only_when_it_spent_something() {
let dir = tempfile::tempdir().unwrap();
let paths = agent(&dir);
let mut record = meta(&paths);
record.pending_terminal = Some(PendingTerminal::Succeeded {
result: "done".to_string(),
});
assert_eq!(
record.terminal_payload().unwrap(),
serde_json::json!({"state": "succeeded", "result": "done"}),
"a task whose turns reported nothing claims no measurement"
);
record.usage = RunUsage {
input_tokens: 900,
output_tokens: 100,
cache_read_tokens: 7,
cache_creation_tokens: 3,
reasoning_tokens: 0,
thoughts_tokens: 0,
};
let payload = record.terminal_payload().unwrap();
assert_eq!(payload["usage"]["input_tokens"], 900);
assert_eq!(payload["usage"]["output_tokens"], 100);
assert_eq!(payload["usage"]["cache_read_tokens"], 7);
assert_eq!(payload["usage"]["cache_creation_tokens"], 3);
}
#[test]
fn cancel_markers_signal_by_existence() {
let dir = tempfile::tempdir().unwrap();
let paths = agent(&dir);
assert!(!cancel_requested(&paths));
request_cancel(&paths, Some("caller/id")).unwrap();
assert!(cancel_requested(&paths));
}
#[test]
fn bounded_text_never_splits_utf8() {
let (value, truncated) = bounded_text("a界b".to_string(), 2);
assert_eq!(value, "a");
assert!(truncated);
}
}