Skip to content

Commit 5dbfb9c

Browse files
authored
Merge pull request #73 from efecnc/feat/harness-lifecycle-logging
feat: harden agent lifecycle and diagnostic logs
2 parents da2217e + d30a0f0 commit 5dbfb9c

8 files changed

Lines changed: 1363 additions & 77 deletions

File tree

src/agent/mod.rs

Lines changed: 367 additions & 9 deletions
Large diffs are not rendered by default.

src/agent/subagent.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@ impl SubagentHarness {
573573
outbound_tx: self.inner.deps.outbound_tx.clone(),
574574
logger_tx: self.inner.deps.logger_tx.clone(),
575575
inbound,
576+
run_id: format!("subagent-{task_id}"),
576577
cancel_token: task_cancel.clone(),
577578
clarification_hub: self.inner.deps.clarification_hub.clone(),
578579
tool_exec_ctx,

src/bus.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ pub const METADATA_SYNTHETIC_BACKGROUND_RESUME: &str = "isanagent_synthetic_back
1818
pub const METADATA_BACKGROUND_JOB_ID: &str = "isanagent_background_job_id";
1919
/// Inbound metadata: the ID of the clarification ticket being replied to.
2020
pub const METADATA_CLARIFICATION_TICKET_ID: &str = "clarification_ticket_id";
21+
/// Trusted caller-provided identifier for one foreground reasoning run.
22+
pub const METADATA_RUN_ID: &str = "isanagent_run_id";
2123

2224
/// An inbound message received from a Channel (e.g. Slack, Email).
2325
//
@@ -577,6 +579,63 @@ mod tests {
577579
}
578580
}
579581

582+
/// A wrapper used to distinguish routing intents inside the Agent network.
583+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
584+
#[serde(rename_all = "snake_case")]
585+
pub enum RunFailureKind {
586+
ProviderRetriesExhausted,
587+
Provider,
588+
Tool,
589+
Protocol,
590+
Persistence,
591+
Internal,
592+
}
593+
594+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
595+
#[serde(rename_all = "snake_case")]
596+
pub enum RunStuckReason {
597+
DoomLoop,
598+
RepeatedRootCause,
599+
NoProgress,
600+
}
601+
602+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
603+
pub struct RunBudgetSnapshot {
604+
pub iterations_used: usize,
605+
pub iterations_limit: usize,
606+
}
607+
608+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
609+
#[serde(tag = "kind", rename_all = "snake_case")]
610+
pub enum RunOutcome {
611+
Completed,
612+
Failed {
613+
failure: RunFailureKind,
614+
retryable: bool,
615+
},
616+
Cancelled,
617+
Stuck {
618+
reason: RunStuckReason,
619+
},
620+
BudgetExhausted {
621+
budget: RunBudgetSnapshot,
622+
},
623+
}
624+
625+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
626+
#[serde(tag = "type", rename_all = "snake_case")]
627+
pub enum RunLifecycleEvent {
628+
Started {
629+
run_id: String,
630+
chat_id: String,
631+
},
632+
Terminated {
633+
run_id: String,
634+
chat_id: String,
635+
outcome: RunOutcome,
636+
},
637+
}
638+
580639
/// A wrapper used to distinguish routing intents inside the Agent network.
581640
#[derive(Debug, Clone, Serialize, Deserialize)]
582641
#[non_exhaustive]
@@ -586,6 +645,8 @@ pub enum BusMessage {
586645
Telemetry(TelemetryEvent),
587646
/// Verbose structured log event for file-based diagnostics.
588647
Log(LogEvent),
648+
/// Typed foreground reasoning-run lifecycle signal.
649+
RunLifecycle(RunLifecycleEvent),
589650
/// Internal control flow for deterministic logger flush/shutdown.
590651
LoggerControl(LoggerControlMessage),
591652
/// Signal to interrupt an active reasoning loop for a specific chat.
@@ -633,3 +694,54 @@ pub enum BusMessage {
633694
skill_name: Option<String>,
634695
},
635696
}
697+
698+
#[cfg(test)]
699+
mod run_lifecycle_tests {
700+
use super::{RunBudgetSnapshot, RunFailureKind, RunLifecycleEvent, RunOutcome, RunStuckReason};
701+
702+
#[test]
703+
fn lifecycle_events_round_trip_for_every_terminal_outcome() {
704+
let outcomes = vec![
705+
RunOutcome::Completed,
706+
RunOutcome::Failed {
707+
failure: RunFailureKind::ProviderRetriesExhausted,
708+
retryable: true,
709+
},
710+
RunOutcome::Cancelled,
711+
RunOutcome::Stuck {
712+
reason: RunStuckReason::RepeatedRootCause,
713+
},
714+
RunOutcome::BudgetExhausted {
715+
budget: RunBudgetSnapshot {
716+
iterations_used: 24,
717+
iterations_limit: 24,
718+
},
719+
},
720+
];
721+
722+
for outcome in outcomes {
723+
let event = RunLifecycleEvent::Terminated {
724+
run_id: "run-123".to_string(),
725+
chat_id: "chat-456".to_string(),
726+
outcome,
727+
};
728+
let encoded = serde_json::to_string(&event).expect("serialize lifecycle event");
729+
let decoded: RunLifecycleEvent =
730+
serde_json::from_str(&encoded).expect("deserialize lifecycle event");
731+
assert_eq!(decoded, event);
732+
}
733+
}
734+
735+
#[test]
736+
fn started_lifecycle_event_round_trips() {
737+
let event = RunLifecycleEvent::Started {
738+
run_id: "run-123".to_string(),
739+
chat_id: "chat-456".to_string(),
740+
};
741+
742+
let encoded = serde_json::to_string(&event).expect("serialize lifecycle event");
743+
let decoded: RunLifecycleEvent =
744+
serde_json::from_str(&encoded).expect("deserialize lifecycle event");
745+
assert_eq!(decoded, event);
746+
}
747+
}

src/config.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,33 @@ pub struct GitWorktreeConfig {
323323
pub allow_path_outside_sandbox: Option<bool>,
324324
}
325325

326+
/// Best-effort workspace diagnostic logging. These limits apply only to the
327+
/// inspectable `.system_generated/logs/` files; SQLite remains the durable
328+
/// source of truth for conversations and run state.
329+
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
330+
pub struct LoggingConfig {
331+
/// Disable file-backed diagnostic logging entirely. Defaults to enabled.
332+
pub enabled: Option<bool>,
333+
/// Active-file byte limit for `conversation.jsonl`.
334+
pub conversation_max_bytes: Option<u64>,
335+
/// Active-file byte limit for `runtime.log`.
336+
pub runtime_max_bytes: Option<u64>,
337+
/// Number of rotated files retained for each diagnostic log.
338+
pub retained_generations: Option<usize>,
339+
/// Aggregate byte cap for recognized diagnostic log files in one workspace.
340+
pub max_total_bytes: Option<u64>,
341+
}
342+
343+
/// Fully bounded diagnostic-log settings consumed by the logging actor.
344+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345+
pub struct EffectiveLoggingConfig {
346+
pub enabled: bool,
347+
pub conversation_max_bytes: u64,
348+
pub runtime_max_bytes: u64,
349+
pub retained_generations: usize,
350+
pub max_total_bytes: u64,
351+
}
352+
326353
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
327354
pub struct AppConfig {
328355
pub restrict_to_workspace: Option<bool>,
@@ -351,6 +378,8 @@ pub struct AppConfig {
351378
pub multi_tenant_edge: Option<MultiTenantEdgeConfig>,
352379
/// When `enabled`, `web_search` / `web_fetch` use [Jina Reader](https://r.jina.ai/) and search (`s.jina.ai`).
353380
pub jina: Option<JinaConfig>,
381+
/// Bounded, file-backed diagnostic logs under `.system_generated/logs/`.
382+
pub logging: Option<LoggingConfig>,
354383
pub harness: Option<HarnessConfig>,
355384
/// Named agent definitions (`[agents.<name>]` in config.toml). Top-level alias for
356385
/// `harness.agents` when the user keeps agents in the root of config.toml.
@@ -518,6 +547,56 @@ impl AppConfig {
518547
.or_else(|| self.terminal.as_ref().and_then(|t| t.max_tool_output_chars))
519548
}
520549

550+
/// Resolved bounds for file-backed diagnostic logs. Invalid or missing
551+
/// values always resolve to bounded settings; they can never enable
552+
/// unbounded workspace log growth.
553+
pub fn effective_logging_config(&self) -> EffectiveLoggingConfig {
554+
const DEFAULT_CONVERSATION_MAX_BYTES: u64 = 20 * 1024 * 1024;
555+
const DEFAULT_RUNTIME_MAX_BYTES: u64 = 10 * 1024 * 1024;
556+
const DEFAULT_RETAINED_GENERATIONS: usize = 2;
557+
const DEFAULT_TOTAL_MAX_BYTES: u64 = 90 * 1024 * 1024;
558+
const MIN_ACTIVE_FILE_BYTES: u64 = 256;
559+
const MAX_ACTIVE_FILE_BYTES: u64 = 512 * 1024 * 1024;
560+
const MAX_RETAINED_GENERATIONS: usize = 32;
561+
const MAX_TOTAL_BYTES: u64 = 1024 * 1024 * 1024;
562+
563+
let logging = self.logging.as_ref();
564+
let bounded_bytes = |configured: Option<u64>, default: u64| {
565+
configured
566+
.filter(|bytes| *bytes >= MIN_ACTIVE_FILE_BYTES)
567+
.map(|bytes| bytes.min(MAX_ACTIVE_FILE_BYTES))
568+
.unwrap_or(default)
569+
};
570+
let conversation_max_bytes = bounded_bytes(
571+
logging.and_then(|config| config.conversation_max_bytes),
572+
DEFAULT_CONVERSATION_MAX_BYTES,
573+
);
574+
let runtime_max_bytes = bounded_bytes(
575+
logging.and_then(|config| config.runtime_max_bytes),
576+
DEFAULT_RUNTIME_MAX_BYTES,
577+
);
578+
let retained_generations = logging
579+
.and_then(|config| config.retained_generations)
580+
.unwrap_or(DEFAULT_RETAINED_GENERATIONS)
581+
.min(MAX_RETAINED_GENERATIONS);
582+
let minimum_total = conversation_max_bytes.saturating_add(runtime_max_bytes);
583+
let default_total = DEFAULT_TOTAL_MAX_BYTES.max(minimum_total);
584+
let max_total_bytes = logging
585+
.and_then(|config| config.max_total_bytes)
586+
.filter(|bytes| *bytes >= minimum_total)
587+
.map(|bytes| bytes.min(MAX_TOTAL_BYTES))
588+
.filter(|bytes| *bytes >= minimum_total)
589+
.unwrap_or(default_total);
590+
591+
EffectiveLoggingConfig {
592+
enabled: logging.and_then(|config| config.enabled).unwrap_or(true),
593+
conversation_max_bytes,
594+
runtime_max_bytes,
595+
retained_generations,
596+
max_total_bytes,
597+
}
598+
}
599+
521600
/// At least one inbound channel other than terminal (API, Slack, or Email).
522601
pub fn has_non_terminal_inbound_channel(&self) -> bool {
523602
let api_on = self
@@ -1584,6 +1663,80 @@ pub struct EmailConfig {
15841663
mod tests {
15851664
use super::*;
15861665

1666+
#[test]
1667+
fn logging_config_uses_bounded_defaults() {
1668+
let config: AppConfig = toml::from_str("").expect("parse empty config");
1669+
1670+
assert_eq!(
1671+
config.effective_logging_config(),
1672+
EffectiveLoggingConfig {
1673+
enabled: true,
1674+
conversation_max_bytes: 20 * 1024 * 1024,
1675+
runtime_max_bytes: 10 * 1024 * 1024,
1676+
retained_generations: 2,
1677+
max_total_bytes: 90 * 1024 * 1024,
1678+
}
1679+
);
1680+
}
1681+
1682+
#[test]
1683+
fn logging_config_parses_explicit_bounded_values() {
1684+
let config: AppConfig = toml::from_str(
1685+
r#"
1686+
[logging]
1687+
enabled = false
1688+
conversation_max_bytes = 1024
1689+
runtime_max_bytes = 2048
1690+
retained_generations = 3
1691+
max_total_bytes = 4096
1692+
"#,
1693+
)
1694+
.expect("parse logging config");
1695+
1696+
assert_eq!(
1697+
config.effective_logging_config(),
1698+
EffectiveLoggingConfig {
1699+
enabled: false,
1700+
conversation_max_bytes: 1024,
1701+
runtime_max_bytes: 2048,
1702+
retained_generations: 3,
1703+
max_total_bytes: 4096,
1704+
}
1705+
);
1706+
}
1707+
1708+
#[test]
1709+
fn logging_config_invalid_values_stay_bounded() {
1710+
let config: AppConfig = toml::from_str(
1711+
r#"
1712+
[logging]
1713+
conversation_max_bytes = 0
1714+
runtime_max_bytes = 999999999999
1715+
retained_generations = 999
1716+
max_total_bytes = 1
1717+
"#,
1718+
)
1719+
.expect("parse logging config");
1720+
let effective = config.effective_logging_config();
1721+
1722+
assert_eq!(effective.conversation_max_bytes, 20 * 1024 * 1024);
1723+
assert_eq!(effective.runtime_max_bytes, 512 * 1024 * 1024);
1724+
assert_eq!(effective.retained_generations, 32);
1725+
assert_eq!(effective.max_total_bytes, 532 * 1024 * 1024);
1726+
}
1727+
1728+
#[test]
1729+
fn logging_config_rejects_integer_overflow() {
1730+
let parsed = toml::from_str::<AppConfig>(
1731+
r#"
1732+
[logging]
1733+
conversation_max_bytes = 18446744073709551616
1734+
"#,
1735+
);
1736+
1737+
assert!(parsed.is_err());
1738+
}
1739+
15871740
#[test]
15881741
fn harness_execution_toml_roundtrip() {
15891742
let s = r#"

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub mod clarification;
1414
pub mod config;
1515
pub mod execution;
1616
pub mod hooks;
17+
pub mod log_rotation;
1718
pub mod logging;
1819
pub mod memory;
1920
pub mod ml_engineer;

0 commit comments

Comments
 (0)