use crate::core::WindowId;
use crate::reader::AgentReaderTool;
use anyhow::Context;
use chrono::{DateTime, Utc};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use uuid::Uuid;
pub const AGENT_EVENT_SCHEMA: &str = "cephas.agent.event";
pub const AGENT_EVENT_SCHEMA_VERSION: u16 = 1;
pub const DEFAULT_MAX_AGENT_EVENTS: usize = 1_000;
pub const DEFAULT_MAX_AGENT_DELEGATIONS: usize = 64;
pub const DEFAULT_MAX_AGENT_LOG_BYTES: u64 = 5 * 1024 * 1024;
pub const DEFAULT_MAX_AGENT_LOG_ARCHIVES: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AgentId(Uuid);
impl AgentId {
pub fn new_v7() -> Self {
Self(Uuid::now_v7())
}
pub fn as_uuid(&self) -> Uuid {
self.0
}
}
impl fmt::Display for AgentId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentStatus {
Tasked,
Active,
Completed,
Failed,
Revoked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentActionState {
Running,
Completed,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentEventType {
AgentTasked,
AgentActive,
AgentLog,
AgentCommandQueued,
AgentCommandCompleted,
AgentCommandFailed,
AgentCompleted,
AgentFailed,
AgentRevoked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentCommandKind {
Navigate,
OpenTab,
Reload,
Back,
Forward,
CloseTab,
CopyCleanLink,
StartRecording,
StopRecording,
CaptureSnapshot,
CaptureWebsiteScreenshot,
EnableHumanControl,
DisableHumanControl,
HumanMovePointer,
HumanClick,
HumanTypeText,
HumanPressKey,
HumanScroll,
HumanWait,
ReaderBrief,
ReaderFullText,
ReaderOutline,
ReaderLinkMap,
ReaderDataSignals,
ReaderJsonArtifact,
}
impl AgentCommandKind {
pub const ALL: [Self; 25] = [
Self::Navigate,
Self::OpenTab,
Self::Reload,
Self::Back,
Self::Forward,
Self::CloseTab,
Self::CopyCleanLink,
Self::StartRecording,
Self::StopRecording,
Self::CaptureSnapshot,
Self::CaptureWebsiteScreenshot,
Self::EnableHumanControl,
Self::DisableHumanControl,
Self::HumanMovePointer,
Self::HumanClick,
Self::HumanTypeText,
Self::HumanPressKey,
Self::HumanScroll,
Self::HumanWait,
Self::ReaderBrief,
Self::ReaderFullText,
Self::ReaderOutline,
Self::ReaderLinkMap,
Self::ReaderDataSignals,
Self::ReaderJsonArtifact,
];
pub fn id(&self) -> &'static str {
match self {
Self::Navigate => "navigate",
Self::OpenTab => "open-tab",
Self::Reload => "reload",
Self::Back => "back",
Self::Forward => "forward",
Self::CloseTab => "close-tab",
Self::CopyCleanLink => "copy-clean-link",
Self::StartRecording => "start-recording",
Self::StopRecording => "stop-recording",
Self::CaptureSnapshot => "capture-snapshot",
Self::CaptureWebsiteScreenshot => "capture-website-screenshot",
Self::EnableHumanControl => "enable-human-control",
Self::DisableHumanControl => "disable-human-control",
Self::HumanMovePointer => "human-move-pointer",
Self::HumanClick => "human-click",
Self::HumanTypeText => "human-type-text",
Self::HumanPressKey => "human-press-key",
Self::HumanScroll => "human-scroll",
Self::HumanWait => "human-wait",
Self::ReaderBrief => "reader-brief",
Self::ReaderFullText => "reader-full-text",
Self::ReaderOutline => "reader-outline",
Self::ReaderLinkMap => "reader-link-map",
Self::ReaderDataSignals => "reader-data-signals",
Self::ReaderJsonArtifact => "reader-json-artifact",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Navigate => "Navigate active tab",
Self::OpenTab => "Open tab",
Self::Reload => "Reload",
Self::Back => "Back",
Self::Forward => "Forward",
Self::CloseTab => "Close tab",
Self::CopyCleanLink => "Copy clean link",
Self::StartRecording => "Start recording",
Self::StopRecording => "Stop recording",
Self::CaptureSnapshot => "Capture snapshot",
Self::CaptureWebsiteScreenshot => "Capture website screenshot",
Self::EnableHumanControl => "Enable human control",
Self::DisableHumanControl => "Disable human control",
Self::HumanMovePointer => "Move pointer",
Self::HumanClick => "Click page",
Self::HumanTypeText => "Type text",
Self::HumanPressKey => "Press key",
Self::HumanScroll => "Scroll page",
Self::HumanWait => "Wait",
Self::ReaderBrief => "Reader brief",
Self::ReaderFullText => "Reader full text",
Self::ReaderOutline => "Reader outline",
Self::ReaderLinkMap => "Reader link map",
Self::ReaderDataSignals => "Reader data signals",
Self::ReaderJsonArtifact => "Reader JSON artifact",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Navigate => "Navigate the active tab to a URL or search query.",
Self::OpenTab => "Open a new tab with a URL or search query.",
Self::Reload => "Reload the active tab.",
Self::Back => "Go back in the active tab if possible.",
Self::Forward => "Go forward in the active tab if possible.",
Self::CloseTab => "Close the active tab using normal tab lifecycle rules.",
Self::CopyCleanLink => "Copy a tracking-stripped link for the active page.",
Self::StartRecording => "Start visual recording for the active browser window.",
Self::StopRecording => "Stop visual recording and write the replay bundle.",
Self::CaptureSnapshot => "Capture a visible-frame snapshot into the active recording.",
Self::CaptureWebsiteScreenshot => {
"Render a URL or internal browser page and write a PNG screenshot."
}
Self::EnableHumanControl => {
"Enable opt-in browser-window human control for the active Cephas window."
}
Self::DisableHumanControl => "Disable browser-window human control.",
Self::HumanMovePointer => {
"Dispatch a mouse move at viewport coordinates in the active page."
}
Self::HumanClick => "Dispatch a click at viewport coordinates in the active page.",
Self::HumanTypeText => "Type text into the focused element in the active page.",
Self::HumanPressKey => "Press a key in the active page.",
Self::HumanScroll => "Scroll the active page by viewport deltas.",
Self::HumanWait => "Pause an agent workflow without changing the page.",
Self::ReaderBrief => "Extract compact source metadata, headings, and leading text.",
Self::ReaderFullText => "Extract the full agent reader text packet.",
Self::ReaderOutline => "Extract the page heading outline and structure counts.",
Self::ReaderLinkMap => "Extract canonicalized page links.",
Self::ReaderDataSignals => {
"Extract emails, dates, money, percentages, and compact tables."
}
Self::ReaderJsonArtifact => "Extract a machine-readable JSON reader artifact.",
}
}
pub fn requires_target(&self) -> bool {
matches!(
self,
Self::Navigate
| Self::OpenTab
| Self::CaptureWebsiteScreenshot
| Self::HumanMovePointer
| Self::HumanClick
| Self::HumanTypeText
| Self::HumanPressKey
| Self::HumanScroll
| Self::HumanWait
)
}
pub fn reader_tool(&self) -> Option<AgentReaderTool> {
match self {
Self::ReaderBrief => Some(AgentReaderTool::AgentBrief),
Self::ReaderFullText => Some(AgentReaderTool::FullText),
Self::ReaderOutline => Some(AgentReaderTool::Outline),
Self::ReaderLinkMap => Some(AgentReaderTool::LinkMap),
Self::ReaderDataSignals => Some(AgentReaderTool::DataSignals),
Self::ReaderJsonArtifact => Some(AgentReaderTool::JsonArtifact),
_ => None,
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|command| command.id() == id)
}
}
impl fmt::Display for AgentCommandKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.id())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentCommandManifestEntry {
pub id: &'static str,
pub label: &'static str,
pub description: &'static str,
pub requires_target: bool,
}
pub fn agent_command_manifest() -> Vec<AgentCommandManifestEntry> {
AgentCommandKind::ALL
.into_iter()
.map(|command| AgentCommandManifestEntry {
id: command.id(),
label: command.label(),
description: command.description(),
requires_target: command.requires_target(),
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentCommandRequest {
pub command_id: Uuid,
pub kind: AgentCommandKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
pub created_at: DateTime<Utc>,
}
impl AgentCommandRequest {
fn new(
kind: AgentCommandKind,
target: Option<String>,
created_at: DateTime<Utc>,
) -> Result<Self, AgentControlError> {
let target = target
.map(|target| target.trim().to_string())
.filter(|target| !target.is_empty());
if kind.requires_target() && target.is_none() {
return Err(AgentControlError::InvalidCommandTarget(kind));
}
Ok(Self {
command_id: Uuid::now_v7(),
kind,
target,
created_at,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTask {
pub task_id: Uuid,
pub description: String,
pub created_at: DateTime<Utc>,
}
impl AgentTask {
fn new(description: impl Into<String>, created_at: DateTime<Utc>) -> Self {
Self {
task_id: Uuid::now_v7(),
description: description.into(),
created_at,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentAction {
pub action_id: Uuid,
pub name: String,
pub state: AgentActionState,
pub started_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl AgentAction {
fn running(
name: impl Into<String>,
details: Option<String>,
started_at: DateTime<Utc>,
) -> Self {
Self {
action_id: Uuid::now_v7(),
name: name.into(),
state: AgentActionState::Running,
started_at,
finished_at: None,
details,
}
}
fn finish(&mut self, state: AgentActionState, finished_at: DateTime<Utc>) {
self.state = state;
self.finished_at = Some(finished_at);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStateSnapshot {
pub status: AgentStatus,
pub task: AgentTask,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_action: Option<AgentAction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_action: Option<AgentAction>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDelegation {
pub agent_id: AgentId,
pub agent_label: String,
pub window_id: WindowId,
pub task: AgentTask,
pub status: AgentStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_action: Option<AgentAction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_action: Option<AgentAction>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<DateTime<Utc>>,
}
impl AgentDelegation {
fn new(
agent_label: impl Into<String>,
window_id: WindowId,
task_description: impl Into<String>,
created_at: DateTime<Utc>,
) -> Self {
Self {
agent_id: AgentId::new_v7(),
agent_label: agent_label.into(),
window_id,
task: AgentTask::new(task_description, created_at),
status: AgentStatus::Tasked,
active_action: None,
last_action: None,
created_at,
updated_at: created_at,
completed_at: None,
}
}
fn snapshot(&self) -> AgentStateSnapshot {
AgentStateSnapshot {
status: self.status,
task: self.task.clone(),
active_action: self.active_action.clone(),
last_action: self.last_action.clone(),
updated_at: self.updated_at,
completed_at: self.completed_at,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentLogEvent {
pub schema: &'static str,
pub schema_version: u16,
pub event_id: Uuid,
pub agent_id: AgentId,
pub window_id: WindowId,
pub sequence: u64,
pub occurred_at: DateTime<Utc>,
pub event_type: AgentEventType,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub task: Option<AgentTask>,
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<AgentAction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<AgentCommandRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
pub state: AgentStateSnapshot,
}
#[derive(Debug, Clone, Default)]
struct AgentEventDetails {
task: Option<AgentTask>,
action: Option<AgentAction>,
command: Option<AgentCommandRequest>,
metadata: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentControlError {
AiModeDisabled,
UnknownAgent(AgentId),
InvalidCommandTarget(AgentCommandKind),
}
impl fmt::Display for AgentControlError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AiModeDisabled => formatter.write_str("AI browsing mode is disabled"),
Self::UnknownAgent(agent_id) => write!(formatter, "unknown agent {agent_id}"),
Self::InvalidCommandTarget(command) => {
write!(formatter, "command {command} requires a target")
}
}
}
}
impl std::error::Error for AgentControlError {}
#[derive(Debug, Clone)]
pub struct AgentControl {
delegations: BTreeMap<AgentId, AgentDelegation>,
events: Vec<AgentLogEvent>,
next_sequence: u64,
max_events: usize,
max_delegations: usize,
}
impl Default for AgentControl {
fn default() -> Self {
Self::new(DEFAULT_MAX_AGENT_EVENTS)
}
}
impl AgentControl {
pub fn new(max_events: usize) -> Self {
Self::with_limits(max_events, DEFAULT_MAX_AGENT_DELEGATIONS)
}
pub fn with_limits(max_events: usize, max_delegations: usize) -> Self {
Self {
delegations: BTreeMap::new(),
events: Vec::new(),
next_sequence: 1,
max_events: max_events.max(1),
max_delegations: max_delegations.max(1),
}
}
pub fn delegate_window(
&mut self,
ai_mode_enabled: bool,
window_id: WindowId,
agent_label: impl Into<String>,
task_description: impl Into<String>,
) -> Result<AgentLogEvent, AgentControlError> {
if !ai_mode_enabled {
return Err(AgentControlError::AiModeDisabled);
}
let now = Utc::now();
let delegation = AgentDelegation::new(agent_label, window_id, task_description, now);
let event = self.event_for(
&delegation,
AgentEventType::AgentTasked,
"agent tasked for browser window",
AgentEventDetails {
task: Some(delegation.task.clone()),
..AgentEventDetails::default()
},
now,
);
self.delegations.insert(delegation.agent_id, delegation);
self.enforce_delegation_limit();
self.record(event.clone());
Ok(event)
}
pub fn start_action(
&mut self,
agent_id: AgentId,
action_name: impl Into<String>,
details: Option<String>,
) -> Result<AgentLogEvent, AgentControlError> {
let now = Utc::now();
let action = AgentAction::running(action_name, details, now);
let (delegation, action) = {
let delegation = self.delegation_mut(agent_id)?;
delegation.status = AgentStatus::Active;
delegation.active_action = Some(action.clone());
delegation.updated_at = now;
(delegation.clone(), action)
};
let event = self.event_for(
&delegation,
AgentEventType::AgentActive,
"agent action active",
AgentEventDetails {
action: Some(action),
..AgentEventDetails::default()
},
now,
);
self.record(event.clone());
Ok(event)
}
pub fn append_log(
&mut self,
agent_id: AgentId,
message: impl Into<String>,
metadata: Option<Value>,
) -> Result<AgentLogEvent, AgentControlError> {
let now = Utc::now();
let (delegation, action) = {
let delegation = self.delegation_mut(agent_id)?;
delegation.updated_at = now;
(delegation.clone(), delegation.active_action.clone())
};
let event = self.event_for(
&delegation,
AgentEventType::AgentLog,
message,
AgentEventDetails {
action,
metadata,
..AgentEventDetails::default()
},
now,
);
self.record(event.clone());
Ok(event)
}
pub fn queue_command(
&mut self,
ai_mode_enabled: bool,
agent_id: AgentId,
kind: AgentCommandKind,
target: Option<String>,
) -> Result<AgentLogEvent, AgentControlError> {
if !ai_mode_enabled {
return Err(AgentControlError::AiModeDisabled);
}
let now = Utc::now();
let command = AgentCommandRequest::new(kind, target, now)?;
let action = AgentAction::running(
format!("agent_command:{}", kind.id()),
Some(format!("command_id={}", command.command_id)),
now,
);
let (delegation, action, command) = {
let delegation = self.delegation_mut(agent_id)?;
delegation.status = AgentStatus::Active;
delegation.active_action = Some(action.clone());
delegation.updated_at = now;
(delegation.clone(), action, command)
};
let event = self.event_for(
&delegation,
AgentEventType::AgentCommandQueued,
format!("agent command queued: {}", command.kind.id()),
AgentEventDetails {
action: Some(action),
command: Some(command),
..AgentEventDetails::default()
},
now,
);
self.record(event.clone());
Ok(event)
}
pub fn complete_command(
&mut self,
agent_id: AgentId,
command: AgentCommandRequest,
outcome: impl Into<String>,
metadata: Option<Value>,
) -> Result<AgentLogEvent, AgentControlError> {
let now = Utc::now();
let (delegation, action, command) = {
let delegation = self.delegation_mut(agent_id)?;
if let Some(mut action) = delegation.active_action.take() {
action.finish(AgentActionState::Completed, now);
delegation.last_action = Some(action);
}
delegation.status = AgentStatus::Active;
delegation.updated_at = now;
(delegation.clone(), delegation.last_action.clone(), command)
};
let event = self.event_for(
&delegation,
AgentEventType::AgentCommandCompleted,
outcome,
AgentEventDetails {
action,
command: Some(command),
metadata,
..AgentEventDetails::default()
},
now,
);
self.record(event.clone());
Ok(event)
}
pub fn fail_command(
&mut self,
agent_id: AgentId,
command: AgentCommandRequest,
error: impl Into<String>,
metadata: Option<Value>,
) -> Result<AgentLogEvent, AgentControlError> {
let now = Utc::now();
let (delegation, action, command) = {
let delegation = self.delegation_mut(agent_id)?;
if let Some(mut action) = delegation.active_action.take() {
action.finish(AgentActionState::Failed, now);
delegation.last_action = Some(action);
}
delegation.status = AgentStatus::Active;
delegation.updated_at = now;
(delegation.clone(), delegation.last_action.clone(), command)
};
let event = self.event_for(
&delegation,
AgentEventType::AgentCommandFailed,
error,
AgentEventDetails {
action,
command: Some(command),
metadata,
..AgentEventDetails::default()
},
now,
);
self.record(event.clone());
Ok(event)
}
pub fn complete_agent(
&mut self,
agent_id: AgentId,
outcome: impl Into<String>,
) -> Result<AgentLogEvent, AgentControlError> {
let now = Utc::now();
let (delegation, action) = {
let delegation = self.delegation_mut(agent_id)?;
if let Some(mut action) = delegation.active_action.take() {
action.finish(AgentActionState::Completed, now);
delegation.last_action = Some(action);
}
delegation.status = AgentStatus::Completed;
delegation.updated_at = now;
delegation.completed_at = Some(now);
(delegation.clone(), delegation.last_action.clone())
};
let event = self.event_for(
&delegation,
AgentEventType::AgentCompleted,
outcome,
AgentEventDetails {
action,
..AgentEventDetails::default()
},
now,
);
self.record(event.clone());
Ok(event)
}
pub fn fail_agent(
&mut self,
agent_id: AgentId,
error: impl Into<String>,
) -> Result<AgentLogEvent, AgentControlError> {
let now = Utc::now();
let (delegation, action) = {
let delegation = self.delegation_mut(agent_id)?;
if let Some(mut action) = delegation.active_action.take() {
action.finish(AgentActionState::Failed, now);
delegation.last_action = Some(action);
}
delegation.status = AgentStatus::Failed;
delegation.updated_at = now;
delegation.completed_at = Some(now);
(delegation.clone(), delegation.last_action.clone())
};
let event = self.event_for(
&delegation,
AgentEventType::AgentFailed,
error,
AgentEventDetails {
action,
..AgentEventDetails::default()
},
now,
);
self.record(event.clone());
Ok(event)
}
pub fn revoke_agent(
&mut self,
agent_id: AgentId,
reason: impl Into<String>,
) -> Result<AgentLogEvent, AgentControlError> {
let now = Utc::now();
let (delegation, action) = {
let delegation = self.delegation_mut(agent_id)?;
delegation.active_action = None;
delegation.status = AgentStatus::Revoked;
delegation.updated_at = now;
delegation.completed_at = Some(now);
(delegation.clone(), delegation.last_action.clone())
};
let event = self.event_for(
&delegation,
AgentEventType::AgentRevoked,
reason,
AgentEventDetails {
action,
..AgentEventDetails::default()
},
now,
);
self.record(event.clone());
Ok(event)
}
pub fn delegation(&self, agent_id: AgentId) -> Option<&AgentDelegation> {
self.delegations.get(&agent_id)
}
pub fn active_for_window(&self, window_id: WindowId) -> Vec<&AgentDelegation> {
self.delegations
.values()
.filter(|delegation| {
delegation.window_id == window_id && delegation.status == AgentStatus::Active
})
.collect()
}
pub fn events(&self) -> &[AgentLogEvent] {
&self.events
}
pub fn delegation_count(&self) -> usize {
self.delegations.len()
}
pub fn max_delegations(&self) -> usize {
self.max_delegations
}
pub fn max_events(&self) -> usize {
self.max_events
}
fn delegation_mut(
&mut self,
agent_id: AgentId,
) -> Result<&mut AgentDelegation, AgentControlError> {
self.delegations
.get_mut(&agent_id)
.ok_or(AgentControlError::UnknownAgent(agent_id))
}
fn event_for(
&mut self,
delegation: &AgentDelegation,
event_type: AgentEventType,
message: impl Into<String>,
details: AgentEventDetails,
occurred_at: DateTime<Utc>,
) -> AgentLogEvent {
let sequence = self.next_sequence;
self.next_sequence += 1;
AgentLogEvent {
schema: AGENT_EVENT_SCHEMA,
schema_version: AGENT_EVENT_SCHEMA_VERSION,
event_id: Uuid::now_v7(),
agent_id: delegation.agent_id,
window_id: delegation.window_id,
sequence,
occurred_at,
event_type,
message: message.into(),
task: details.task,
action: details.action,
command: details.command,
metadata: details.metadata,
state: delegation.snapshot(),
}
}
fn record(&mut self, event: AgentLogEvent) {
self.events.push(event);
let overflow = self.events.len().saturating_sub(self.max_events);
if overflow > 0 {
self.events.drain(0..overflow);
}
}
fn enforce_delegation_limit(&mut self) {
while self.delegations.len() > self.max_delegations {
let removable = self
.delegations
.iter()
.filter(|(_, delegation)| {
matches!(
delegation.status,
AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Revoked
)
})
.min_by_key(|(_, delegation)| delegation.updated_at)
.map(|(agent_id, _)| *agent_id)
.or_else(|| self.delegations.keys().next().copied());
let Some(agent_id) = removable else {
break;
};
self.delegations.remove(&agent_id);
}
}
}
#[derive(Debug, Clone)]
pub struct AgentLogStore {
path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AgentLogDiagnostics {
pub bytes: u64,
pub max_bytes: u64,
pub archives: usize,
pub max_archives: usize,
pub total_archive_bytes: u64,
pub max_total_archive_bytes: u64,
pub largest_archive_bytes: u64,
pub max_archive_bytes: u64,
}
impl AgentLogStore {
pub fn new(path: Option<PathBuf>) -> anyhow::Result<Self> {
let path = match path {
Some(path) => path,
None => default_agent_log_path()?,
};
Ok(Self { path })
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn diagnostics(&self) -> anyhow::Result<AgentLogDiagnostics> {
let archive_paths = rotated_log_paths(&self.path)?;
let mut total_archive_bytes = 0_u64;
let mut largest_archive_bytes = 0_u64;
for path in &archive_paths {
let bytes = fs::metadata(path)
.with_context(|| format!("failed to inspect agent log archive {}", path.display()))?
.len();
total_archive_bytes = total_archive_bytes.saturating_add(bytes);
largest_archive_bytes = largest_archive_bytes.max(bytes);
}
Ok(AgentLogDiagnostics {
bytes: fs::metadata(&self.path)
.map(|metadata| metadata.len())
.unwrap_or(0),
max_bytes: DEFAULT_MAX_AGENT_LOG_BYTES,
archives: archive_paths.len(),
max_archives: DEFAULT_MAX_AGENT_LOG_ARCHIVES,
total_archive_bytes,
max_total_archive_bytes: DEFAULT_MAX_AGENT_LOG_BYTES
.saturating_mul(DEFAULT_MAX_AGENT_LOG_ARCHIVES as u64),
largest_archive_bytes,
max_archive_bytes: DEFAULT_MAX_AGENT_LOG_BYTES,
})
}
pub fn append(&self, event: &AgentLogEvent) -> anyhow::Result<()> {
self.append_all(std::slice::from_ref(event))
}
pub fn append_all(&self, events: &[AgentLogEvent]) -> anyhow::Result<()> {
if events.is_empty() {
return Ok(());
}
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create agent log directory {}", parent.display())
})?;
}
for event in events {
let line = serialized_agent_event_line(event)?;
self.rotate_before_append(line.len() as u64)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.with_context(|| format!("failed to open agent log {}", self.path.display()))?;
file.write_all(&line)
.context("failed to write agent event")?;
}
self.rotate_if_needed()?;
Ok(())
}
fn rotate_before_append(&self, additional_bytes: u64) -> anyhow::Result<()> {
let current = fs::metadata(&self.path)
.map(|metadata| metadata.len())
.unwrap_or(0);
if current > 0 && current.saturating_add(additional_bytes) > DEFAULT_MAX_AGENT_LOG_BYTES {
self.rotate_active_log()?;
}
Ok(())
}
fn rotate_if_needed(&self) -> anyhow::Result<()> {
let Ok(metadata) = fs::metadata(&self.path) else {
return Ok(());
};
if metadata.len() <= DEFAULT_MAX_AGENT_LOG_BYTES {
return Ok(());
}
self.rotate_active_log()
}
fn rotate_active_log(&self) -> anyhow::Result<()> {
let rotated = rotated_log_path(&self.path);
fs::rename(&self.path, &rotated).with_context(|| {
format!(
"failed to rotate agent log {} to {}",
self.path.display(),
rotated.display()
)
})?;
prune_rotated_logs(&self.path)
}
}
fn serialized_agent_event_line(event: &AgentLogEvent) -> anyhow::Result<Vec<u8>> {
let mut line = serde_json::to_vec(event).context("failed to serialize agent event")?;
if line.len().saturating_add(1) > DEFAULT_MAX_AGENT_LOG_BYTES as usize {
line = serde_json::to_vec(&json!({
"schema": event.schema,
"schema_version": event.schema_version,
"event_id": event.event_id,
"agent_id": event.agent_id,
"window_id": event.window_id,
"sequence": event.sequence,
"occurred_at": event.occurred_at,
"event_type": event.event_type,
"message": "agent event exceeded log byte cap and was summarized",
"truncated": true,
"original_bytes": line.len()
}))
.context("failed to serialize summarized agent event")?;
}
line.push(b'\n');
Ok(line)
}
fn rotated_log_path(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("agent-events");
let extension = path.extension().and_then(|extension| extension.to_str());
let suffix = Uuid::now_v7();
let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
let file_name = match extension {
Some(extension) if !extension.is_empty() => {
format!("{stem}-{timestamp}-{suffix}.{extension}")
}
_ => format!("{stem}-{timestamp}-{suffix}"),
};
parent.join(file_name)
}
fn rotated_log_paths(path: &Path) -> anyhow::Result<Vec<PathBuf>> {
let Some(parent) = path.parent() else {
return Ok(Vec::new());
};
if !parent.exists() {
return Ok(Vec::new());
}
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("agent-events");
let extension = path.extension().and_then(|extension| extension.to_str());
let prefix = format!("{stem}-");
let suffix = extension.map(|extension| format!(".{extension}"));
let mut paths = Vec::new();
for entry in fs::read_dir(parent)
.with_context(|| format!("failed to read agent log directory {}", parent.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if !name.starts_with(&prefix) {
continue;
}
if let Some(suffix) = &suffix
&& !name.ends_with(suffix)
{
continue;
}
paths.push(path);
}
paths.sort();
Ok(paths)
}
fn prune_rotated_logs(path: &Path) -> anyhow::Result<()> {
let mut paths = rotated_log_paths(path)?;
let overflow = paths.len().saturating_sub(DEFAULT_MAX_AGENT_LOG_ARCHIVES);
for path in paths.drain(0..overflow) {
fs::remove_file(&path)
.with_context(|| format!("failed to remove old agent log {}", path.display()))?;
}
Ok(())
}
fn default_agent_log_path() -> anyhow::Result<PathBuf> {
let dirs = ProjectDirs::from("dev", "Cephas", "Cephas")
.context("could not determine platform agent log directory")?;
Ok(dirs.data_local_dir().join("agent-events.jsonl"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn delegation_requires_ai_mode() {
let mut control = AgentControl::new(10);
let err = control
.delegate_window(false, WindowId(1), "test-agent", "Handle window")
.unwrap_err();
assert_eq!(err, AgentControlError::AiModeDisabled);
assert!(control.events().is_empty());
}
#[test]
fn agent_lifecycle_logs_state_snapshots() {
let mut control = AgentControl::new(10);
let tasked = control
.delegate_window(true, WindowId(1), "test-agent", "Handle window")
.unwrap();
let agent_id = tasked.agent_id;
assert_eq!(agent_id.as_uuid().get_version_num(), 7);
assert_eq!(tasked.event_id.get_version_num(), 7);
assert_eq!(tasked.event_type, AgentEventType::AgentTasked);
assert_eq!(tasked.state.status, AgentStatus::Tasked);
let active = control
.start_action(
agent_id,
"navigate",
Some("Agent may control this window".to_string()),
)
.unwrap();
assert_eq!(active.event_type, AgentEventType::AgentActive);
assert_eq!(active.state.status, AgentStatus::Active);
assert_eq!(
active.state.active_action.as_ref().unwrap().state,
AgentActionState::Running
);
let log = control
.append_log(
agent_id,
"agent is waiting for backend instructions",
Some(json!({ "backend": "elixir" })),
)
.unwrap();
assert_eq!(log.event_type, AgentEventType::AgentLog);
assert_eq!(log.state.status, AgentStatus::Active);
let completed = control.complete_agent(agent_id, "done").unwrap();
assert_eq!(completed.event_type, AgentEventType::AgentCompleted);
assert_eq!(completed.state.status, AgentStatus::Completed);
assert!(completed.state.active_action.is_none());
assert_eq!(
completed.state.last_action.as_ref().unwrap().state,
AgentActionState::Completed
);
assert_eq!(control.events().len(), 4);
}
#[test]
fn delegations_are_capped() {
let mut control = AgentControl::with_limits(10, 2);
let first = control
.delegate_window(true, WindowId(1), "agent-1", "Handle window")
.unwrap()
.agent_id;
control
.delegate_window(true, WindowId(1), "agent-2", "Handle window")
.unwrap();
control
.delegate_window(true, WindowId(1), "agent-3", "Handle window")
.unwrap();
assert_eq!(control.delegation_count(), 2);
assert_eq!(control.max_delegations(), 2);
assert!(control.delegation(first).is_none());
}
#[test]
fn agent_command_manifest_is_unique_and_resolvable() {
let mut ids = std::collections::BTreeSet::new();
for entry in agent_command_manifest() {
assert!(ids.insert(entry.id));
let command = AgentCommandKind::from_id(entry.id).unwrap();
assert_eq!(command.label(), entry.label);
assert_eq!(command.description(), entry.description);
assert_eq!(command.requires_target(), entry.requires_target);
}
}
#[test]
fn agent_commands_validate_targets_and_log_lifecycle() {
let mut control = AgentControl::new(10);
let tasked = control
.delegate_window(true, WindowId(1), "test-agent", "Handle window")
.unwrap();
let agent_id = tasked.agent_id;
let missing = control
.queue_command(true, agent_id, AgentCommandKind::Navigate, None)
.unwrap_err();
assert_eq!(
missing,
AgentControlError::InvalidCommandTarget(AgentCommandKind::Navigate)
);
let queued = control
.queue_command(
true,
agent_id,
AgentCommandKind::Navigate,
Some("https://example.com".to_string()),
)
.unwrap();
assert_eq!(queued.event_type, AgentEventType::AgentCommandQueued);
assert_eq!(
queued.command.as_ref().unwrap().kind,
AgentCommandKind::Navigate
);
assert_eq!(
queued
.command
.as_ref()
.unwrap()
.command_id
.get_version_num(),
7
);
assert_eq!(queued.state.status, AgentStatus::Active);
assert_eq!(
queued.state.active_action.as_ref().unwrap().state,
AgentActionState::Running
);
let completed = control
.complete_command(
agent_id,
queued.command.clone().unwrap(),
"navigation executed",
Some(json!({ "result": "ok" })),
)
.unwrap();
assert_eq!(completed.event_type, AgentEventType::AgentCommandCompleted);
assert_eq!(completed.state.status, AgentStatus::Active);
assert!(completed.state.active_action.is_none());
assert_eq!(
completed.state.last_action.as_ref().unwrap().state,
AgentActionState::Completed
);
}
#[test]
fn jsonl_store_writes_parseable_events() {
let dir = tempfile::tempdir().unwrap();
let store = AgentLogStore::new(Some(dir.path().join("agents.jsonl"))).unwrap();
let mut control = AgentControl::new(10);
let event = control
.delegate_window(true, WindowId(7), "test-agent", "Handle window")
.unwrap();
store.append(&event).unwrap();
let raw = fs::read_to_string(store.path()).unwrap();
let lines = raw.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 1);
let json: Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(json["schema"], AGENT_EVENT_SCHEMA);
assert_eq!(json["schema_version"], AGENT_EVENT_SCHEMA_VERSION);
assert_eq!(json["event_type"], "agent_tasked");
assert_eq!(json["window_id"], 7);
assert_eq!(json["state"]["status"], "tasked");
}
#[test]
fn jsonl_store_rotates_oversized_logs() {
let dir = tempfile::tempdir().unwrap();
let store = AgentLogStore::new(Some(dir.path().join("agents.jsonl"))).unwrap();
fs::write(
store.path(),
vec![b'x'; DEFAULT_MAX_AGENT_LOG_BYTES as usize + 1],
)
.unwrap();
let mut control = AgentControl::new(10);
let event = control
.delegate_window(true, WindowId(7), "test-agent", "Handle window")
.unwrap();
store.append(&event).unwrap();
let diagnostics = store.diagnostics().unwrap();
assert!(diagnostics.bytes < DEFAULT_MAX_AGENT_LOG_BYTES);
assert_eq!(diagnostics.archives, 1);
assert_eq!(diagnostics.max_archives, DEFAULT_MAX_AGENT_LOG_ARCHIVES);
assert!(diagnostics.total_archive_bytes > DEFAULT_MAX_AGENT_LOG_BYTES);
assert_eq!(
diagnostics.max_total_archive_bytes,
DEFAULT_MAX_AGENT_LOG_BYTES * DEFAULT_MAX_AGENT_LOG_ARCHIVES as u64
);
assert_eq!(
diagnostics.largest_archive_bytes,
diagnostics.total_archive_bytes
);
assert_eq!(diagnostics.max_archive_bytes, DEFAULT_MAX_AGENT_LOG_BYTES);
}
#[test]
fn jsonl_store_summarizes_single_events_over_byte_cap() {
let dir = tempfile::tempdir().unwrap();
let store = AgentLogStore::new(Some(dir.path().join("agents.jsonl"))).unwrap();
let mut control = AgentControl::new(10);
let mut event = control
.delegate_window(true, WindowId(7), "test-agent", "Handle window")
.unwrap();
event.message = "x".repeat(DEFAULT_MAX_AGENT_LOG_BYTES as usize + 1);
store.append(&event).unwrap();
let diagnostics = store.diagnostics().unwrap();
assert!(diagnostics.bytes < DEFAULT_MAX_AGENT_LOG_BYTES);
let raw = fs::read_to_string(store.path()).unwrap();
let json: Value = serde_json::from_str(raw.lines().next().unwrap()).unwrap();
assert_eq!(json["truncated"], true);
assert!(json["original_bytes"].as_u64().unwrap() > DEFAULT_MAX_AGENT_LOG_BYTES);
}
}