use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::api::types::ToolCall;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AgentDecision {
ExecutedTools { tools: Vec<String> },
NoToolCall,
NudgeInjected { reason: String },
Aborted { reason: String },
Completed { text: String },
Refused { reason: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnArtifact {
pub step: usize,
pub timestamp: DateTime<Utc>,
pub request_body: serde_json::Value,
pub response_body: serde_json::Value,
pub finish_reason: Option<String>,
pub completion_tokens: Option<u32>,
pub prompt_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
pub parsed_tool_calls: Vec<ToolCall>,
pub agent_decision: AgentDecision,
pub elapsed_ms: u64,
}
const REDACTED: &str = "<redacted>";
fn key_is_secret(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
if matches!(
lower.as_str(),
"authorization" | "bearer" | "secret" | "password"
) {
return true;
}
if lower.contains("api_key") || lower.contains("apikey") || lower.contains("api-key") {
return true;
}
let norm = lower.replace('-', "_");
if norm == "token" {
return true;
}
if let Some(stripped) = norm.strip_suffix("_token") {
if !stripped.is_empty() {
return true;
}
}
false
}
pub fn sanitize_request_body(body: &mut serde_json::Value) {
sanitize_value(body);
}
fn sanitize_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for (k, v) in map.iter_mut() {
if key_is_secret(k) {
*v = serde_json::Value::String(REDACTED.to_string());
} else {
sanitize_value(v);
}
}
}
serde_json::Value::Array(items) => {
for v in items.iter_mut() {
sanitize_value(v);
}
}
_ => {}
}
}
pub fn artifact_dir(workdir: &Path) -> PathBuf {
workdir.join(".selfware").join("turns")
}
pub(crate) fn ensure_selfware_gitignore(selfware_dir: &Path) {
if !selfware_dir.is_dir() {
return;
}
let gitignore = selfware_dir.join(".gitignore");
if !gitignore.exists() {
let _ = std::fs::write(&gitignore, "# Selfware scratch — do not commit.\n*\n");
}
}
const MAX_TURN_ARTIFACTS: usize = 500;
async fn prune_old_artifacts(dir: &Path) {
let mut entries: Vec<(std::time::SystemTime, std::path::PathBuf)> = Vec::new();
let mut rd = match tokio::fs::read_dir(dir).await {
Ok(rd) => rd,
Err(_) => return,
};
while let Ok(Some(entry)) = rd.next_entry().await {
let path = entry.path();
let is_turn = path
.file_name()
.and_then(|s| s.to_str())
.map(|n| n.starts_with("turn_") && n.ends_with(".json"))
.unwrap_or(false);
if !is_turn {
continue;
}
let mtime = match entry.metadata().await.and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => continue,
};
entries.push((mtime, path));
}
if entries.len() <= MAX_TURN_ARTIFACTS {
return;
}
entries.sort_by_key(|(t, _)| *t);
let remove_count = entries.len() - MAX_TURN_ARTIFACTS;
for (_, path) in entries.into_iter().take(remove_count) {
if let Err(e) = tokio::fs::remove_file(&path).await {
tracing::warn!("Failed to prune turn artifact {:?}: {}", path, e);
}
}
}
pub async fn write_artifact(workdir: &Path, artifact: &TurnArtifact) {
let dir = artifact_dir(workdir);
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
tracing::warn!("Failed to create turn artifact dir {:?}: {}", dir, e);
return;
}
ensure_selfware_gitignore(&workdir.join(".selfware"));
let path = dir.join(format!("turn_{:04}.json", artifact.step));
let json = match serde_json::to_string_pretty(artifact) {
Ok(s) => s,
Err(e) => {
tracing::warn!("Failed to serialize turn artifact {}: {}", artifact.step, e);
return;
}
};
if let Err(e) = tokio::fs::write(&path, json).await {
tracing::warn!("Failed to write turn artifact {:?}: {}", path, e);
return;
}
prune_old_artifacts(&dir).await;
}
#[cfg(test)]
#[path = "../../tests/unit/agent/turn_artifacts/turn_artifacts_test.rs"]
mod tests;