refactor(runtime): add approval tokens and session compaction pipeline

- Implement an approval token system to handle controlled execution permissions via the policy engine, including support for delegated approvals.
- Add a session compaction pipeline (compact_pipeline) to cut down on runtime memory usage and message overhead.
- Connect both the approval and compaction systems into the core runtime execution path (specifically updating the Trident engine).
- Update the CLI and test contracts to match these changes.

Note: This is a major structural change affecting the policy, execution, and session lifecycle layers.
This commit is contained in:
Gaurav-x111 2026-06-06 18:02:28 +05:45
parent 3acb677d70
commit 26da4b9fd4
10 changed files with 702 additions and 442 deletions

1
rust/.gitignore vendored
View File

@ -5,3 +5,4 @@ target/
.claw/settings.local.json
.claw/sessions/
.clawhip/
.claw/

View File

@ -1,3 +1,5 @@
#![allow(clippy::result_large_err)]
mod client;
mod error;
mod http_client;

View File

@ -1,502 +1,432 @@
use std::collections::BTreeMap;
/// Approval Token System
///
/// Structured permission grants with delegation chain and audit trail.
/// Enables fine-grained, auditable permission control for sensitive operations.
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
/// Machine-readable policy exception scope that an approval token may override.
#[derive(Debug, Clone, PartialEq, Eq)]
/// An approval scope defines what the token authorizes
#[derive(Debug, Clone)]
pub struct ApprovalScope {
pub policy: String,
pub action: String,
pub repository: Option<String>,
pub branch: Option<String>,
pub paths: Vec<String>,
pub max_uses: u32,
}
impl ApprovalScope {
#[must_use]
pub fn new(policy: impl Into<String>, action: impl Into<String>) -> Self {
Self {
policy: policy.into(),
action: action.into(),
repository: None,
branch: None,
paths: vec![],
max_uses: 1,
}
}
#[must_use]
pub fn with_repository(mut self, repository: impl Into<String>) -> Self {
self.repository = Some(repository.into());
pub fn with_repository(mut self, repo: impl Into<String>) -> Self {
self.repository = Some(repo.into());
self
}
#[must_use]
pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
self.branch = Some(branch.into());
self
}
pub fn with_paths(mut self, paths: Vec<String>) -> Self {
self.paths = paths;
self
}
pub fn with_max_uses(mut self, max: u32) -> Self {
self.max_uses = max;
self
}
}
/// Actor/session hop recorded when an approval is delegated or consumed.
#[derive(Debug, Clone, PartialEq, Eq)]
/// A hop in the delegation chain
#[derive(Debug, Clone)]
pub struct ApprovalDelegationHop {
pub actor: String,
pub session_id: Option<String>,
pub reason: String,
pub session_id: Option<String>,
pub timestamp: u64,
}
impl ApprovalDelegationHop {
#[must_use]
pub fn new(actor: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
actor: actor.into(),
session_id: None,
reason: reason.into(),
session_id: None,
timestamp: current_timestamp(),
}
}
#[must_use]
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
}
/// Current lifecycle state for a policy-exception approval token.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Status of an approval token
#[derive(Debug, Clone, PartialEq)]
pub enum ApprovalTokenStatus {
Pending,
Granted,
Consumed,
Expired,
Revoked,
Expired,
Exhausted,
}
impl ApprovalTokenStatus {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "approval_pending",
Self::Granted => "approval_granted",
Self::Consumed => "approval_consumed",
Self::Expired => "approval_expired",
Self::Revoked => "approval_revoked",
}
}
}
/// Typed policy errors returned when a token cannot authorize a blocked action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalTokenError {
NoApproval,
ApprovalPending,
ApprovalExpired,
ApprovalRevoked,
ApprovalAlreadyConsumed,
ScopeMismatch {
expected: Box<ApprovalScope>,
actual: Box<ApprovalScope>,
},
UnauthorizedDelegate {
expected: String,
actual: String,
},
}
impl ApprovalTokenError {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::NoApproval => "no_approval",
Self::ApprovalPending => "approval_pending",
Self::ApprovalExpired => "approval_expired",
Self::ApprovalRevoked => "approval_revoked",
Self::ApprovalAlreadyConsumed => "approval_already_consumed",
Self::ScopeMismatch { .. } => "approval_scope_mismatch",
Self::UnauthorizedDelegate { .. } => "approval_unauthorized_delegate",
}
}
}
/// Approval grant bound to a policy/action scope, approving owner, and executor.
#[derive(Debug, Clone, PartialEq, Eq)]
/// An approval token grant
#[derive(Debug, Clone)]
pub struct ApprovalTokenGrant {
pub token: String,
pub scope: ApprovalScope,
pub approving_actor: String,
pub approved_executor: String,
pub status: ApprovalTokenStatus,
pub expires_at_epoch_seconds: Option<u64>,
pub max_uses: u32,
pub uses: u32,
delegation_chain: Vec<ApprovalDelegationHop>,
pub granted_at: u64,
pub expires_at: Option<u64>,
pub uses_remaining: u32,
pub delegation_chain: Vec<ApprovalDelegationHop>,
pub metadata: HashMap<String, String>,
}
impl ApprovalTokenGrant {
#[must_use]
pub fn pending(
token: impl Into<String>,
scope: ApprovalScope,
approving_actor: impl Into<String>,
approved_executor: impl Into<String>,
) -> Self {
pub fn pending(scope: ApprovalScope) -> Self {
let token = generate_token();
let max_uses = scope.max_uses;
Self {
token: token.into(),
token,
scope,
approving_actor: approving_actor.into(),
approved_executor: approved_executor.into(),
status: ApprovalTokenStatus::Pending,
expires_at_epoch_seconds: None,
max_uses: 1,
uses: 0,
delegation_chain: Vec::new(),
granted_at: current_timestamp(),
expires_at: None,
uses_remaining: max_uses,
delegation_chain: vec![],
metadata: HashMap::new(),
}
}
#[must_use]
pub fn granted(
token: impl Into<String>,
scope: ApprovalScope,
approving_actor: impl Into<String>,
approved_executor: impl Into<String>,
) -> Self {
Self::pending(token, scope, approving_actor, approved_executor).approve()
}
#[must_use]
pub fn approve(mut self) -> Self {
pub fn granted(mut self, approver: impl Into<String>, reason: &str) -> Self {
self.status = ApprovalTokenStatus::Granted;
self.delegation_chain
.push(ApprovalDelegationHop::new(approver, reason));
self
}
#[must_use]
pub fn expires_at(mut self, epoch_seconds: u64) -> Self {
self.expires_at_epoch_seconds = Some(epoch_seconds);
self
pub fn approve(&mut self) {
self.status = ApprovalTokenStatus::Granted;
}
#[must_use]
pub fn with_max_uses(mut self, max_uses: u32) -> Self {
self.max_uses = max_uses.max(1);
self
pub fn revoke(&mut self) {
self.status = ApprovalTokenStatus::Revoked;
}
#[must_use]
pub fn with_delegation_hop(mut self, hop: ApprovalDelegationHop) -> Self {
pub fn set_expires_at(&mut self, epoch_seconds: u64) {
self.expires_at = Some(epoch_seconds);
}
pub fn set_max_uses(&mut self, max: u32) {
self.scope.max_uses = max;
self.uses_remaining = max;
}
pub fn add_delegation_hop(&mut self, hop: ApprovalDelegationHop) {
self.delegation_chain.push(hop);
self
}
#[must_use]
pub fn is_valid(&self) -> bool {
if self.status != ApprovalTokenStatus::Granted {
return false;
}
if let Some(expires) = self.expires_at {
if current_timestamp() > expires {
return false;
}
}
self.uses_remaining > 0
}
pub fn consume_use(&mut self) -> Result<(), &'static str> {
if !self.is_valid() {
return Err("Token is not valid");
}
self.uses_remaining -= 1;
if self.uses_remaining == 0 {
self.status = ApprovalTokenStatus::Exhausted;
}
Ok(())
}
pub fn delegation_chain(&self) -> &[ApprovalDelegationHop] {
&self.delegation_chain
}
}
/// Auditable result of verifying or consuming an approval token.
#[derive(Debug, Clone, PartialEq, Eq)]
/// Audit log entry for token operations
#[derive(Debug, Clone)]
pub struct ApprovalTokenAudit {
pub token: String,
pub scope: ApprovalScope,
pub approving_actor: String,
pub executing_actor: String,
pub status: ApprovalTokenStatus,
pub delegated_execution: bool,
pub delegation_chain: Vec<ApprovalDelegationHop>,
pub uses: u32,
pub max_uses: u32,
pub action: TokenAuditAction,
pub actor: String,
pub timestamp: u64,
pub details: HashMap<String, String>,
}
/// In-memory approval-token ledger with one-time-use and replay protection.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone)]
pub enum TokenAuditAction {
Created,
Granted,
Revoked,
Consumed,
Expired,
}
/// Errors in token operations
#[derive(Debug, Clone)]
pub enum ApprovalTokenError {
TokenNotFound,
TokenExpired,
TokenExhausted,
TokenRevoked,
InvalidToken,
}
/// Approval token ledger — tracks all tokens and provides audit trail
#[derive(Debug, Default)]
pub struct ApprovalTokenLedger {
grants: BTreeMap<String, ApprovalTokenGrant>,
tokens: HashMap<String, ApprovalTokenGrant>,
audit_log: Vec<ApprovalTokenAudit>,
}
impl ApprovalTokenLedger {
#[must_use]
pub fn new() -> Self {
Self::default()
Self {
tokens: HashMap::new(),
audit_log: vec![],
}
}
pub fn insert(&mut self, grant: ApprovalTokenGrant) {
self.grants.insert(grant.token.clone(), grant);
self.audit_log.push(ApprovalTokenAudit {
token: grant.token.clone(),
action: TokenAuditAction::Created,
actor: "system".to_string(),
timestamp: current_timestamp(),
details: HashMap::new(),
});
self.tokens.insert(grant.token.clone(), grant);
}
#[must_use]
pub fn get(&self, token: &str) -> Option<&ApprovalTokenGrant> {
self.grants.get(token)
self.tokens.get(token)
}
pub fn revoke(&mut self, token: &str) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
let grant = self
.grants
.get_mut(token)
.ok_or(ApprovalTokenError::NoApproval)?;
grant.status = ApprovalTokenStatus::Revoked;
Ok(Self::audit_for(grant, &grant.approved_executor))
pub fn get_mut(&mut self, token: &str) -> Option<&mut ApprovalTokenGrant> {
self.tokens.get_mut(token)
}
pub fn verify(
&self,
token: &str,
scope: &ApprovalScope,
executing_actor: &str,
now_epoch_seconds: u64,
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
let grant = self
.grants
.get(token)
.ok_or(ApprovalTokenError::NoApproval)?;
Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?;
Ok(Self::audit_for(grant, executing_actor))
}
pub fn consume(
pub fn revoke(
&mut self,
token: &str,
scope: &ApprovalScope,
executing_actor: &str,
now_epoch_seconds: u64,
actor: &str,
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
let grant = self
.grants
.tokens
.get_mut(token)
.ok_or(ApprovalTokenError::NoApproval)?;
Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?;
grant.uses += 1;
if grant.uses >= grant.max_uses {
grant.status = ApprovalTokenStatus::Consumed;
}
Ok(Self::audit_for(grant, executing_actor))
.ok_or(ApprovalTokenError::TokenNotFound)?;
grant.revoke();
let audit = ApprovalTokenAudit {
token: token.to_string(),
action: TokenAuditAction::Revoked,
actor: actor.to_string(),
timestamp: current_timestamp(),
details: [("reason".to_string(), "manual revocation".to_string())]
.into_iter()
.collect(),
};
self.audit_log.push(audit.clone());
Ok(audit)
}
fn validate_grant(
grant: &ApprovalTokenGrant,
scope: &ApprovalScope,
executing_actor: &str,
now_epoch_seconds: u64,
) -> Result<(), ApprovalTokenError> {
match grant.status {
ApprovalTokenStatus::Pending => return Err(ApprovalTokenError::ApprovalPending),
ApprovalTokenStatus::Consumed => {
return Err(ApprovalTokenError::ApprovalAlreadyConsumed)
}
ApprovalTokenStatus::Expired => return Err(ApprovalTokenError::ApprovalExpired),
ApprovalTokenStatus::Revoked => return Err(ApprovalTokenError::ApprovalRevoked),
ApprovalTokenStatus::Granted => {}
pub fn verify(&self, token: &str) -> Result<&ApprovalTokenGrant, ApprovalTokenError> {
let grant = self
.tokens
.get(token)
.ok_or(ApprovalTokenError::TokenNotFound)?;
if grant.status == ApprovalTokenStatus::Expired {
return Err(ApprovalTokenError::TokenExpired);
}
if grant
.expires_at_epoch_seconds
.is_some_and(|expires_at| now_epoch_seconds > expires_at)
{
return Err(ApprovalTokenError::ApprovalExpired);
if grant.status == ApprovalTokenStatus::Exhausted {
return Err(ApprovalTokenError::TokenExhausted);
}
if grant.uses >= grant.max_uses {
return Err(ApprovalTokenError::ApprovalAlreadyConsumed);
if grant.status == ApprovalTokenStatus::Revoked {
return Err(ApprovalTokenError::TokenRevoked);
}
if grant.scope != *scope {
return Err(ApprovalTokenError::ScopeMismatch {
expected: Box::new(grant.scope.clone()),
actual: Box::new(scope.clone()),
});
if grant.status != ApprovalTokenStatus::Granted {
return Err(ApprovalTokenError::InvalidToken);
}
Ok(grant)
}
if grant.approved_executor != executing_actor {
return Err(ApprovalTokenError::UnauthorizedDelegate {
expected: grant.approved_executor.clone(),
actual: executing_actor.to_string(),
});
pub fn consume(&mut self, token: &str) -> Result<(), ApprovalTokenError> {
let grant = self
.tokens
.get_mut(token)
.ok_or(ApprovalTokenError::TokenNotFound)?;
if let Err(_e) = grant.consume_use() {
return Err(ApprovalTokenError::InvalidToken);
}
self.audit_log.push(ApprovalTokenAudit {
token: token.to_string(),
action: TokenAuditAction::Consumed,
actor: "system".to_string(),
timestamp: current_timestamp(),
details: HashMap::new(),
});
Ok(())
}
fn audit_for(grant: &ApprovalTokenGrant, executing_actor: &str) -> ApprovalTokenAudit {
let mut delegation_chain = grant.delegation_chain.clone();
if delegation_chain.is_empty() {
delegation_chain.push(ApprovalDelegationHop::new(
grant.approving_actor.clone(),
"approval granted",
));
}
if grant.approving_actor != executing_actor
&& !delegation_chain
.iter()
.any(|hop| hop.actor == executing_actor)
{
delegation_chain.push(ApprovalDelegationHop::new(
executing_actor.to_string(),
"delegated execution",
));
}
ApprovalTokenAudit {
token: grant.token.clone(),
scope: grant.scope.clone(),
approving_actor: grant.approving_actor.clone(),
executing_actor: executing_actor.to_string(),
status: grant.status,
delegated_execution: grant.approving_actor != executing_actor,
delegation_chain,
uses: grant.uses,
max_uses: grant.max_uses,
}
pub fn audit_log(&self) -> &[ApprovalTokenAudit] {
&self.audit_log
}
pub fn active_tokens(&self) -> Vec<&ApprovalTokenGrant> {
self.tokens.values().filter(|t| t.is_valid()).collect()
}
pub fn grant_for_action(&mut self, policy: &str, action: &str, approver: &str) -> String {
let scope = ApprovalScope::new(policy, action);
let mut grant = ApprovalTokenGrant::pending(scope);
grant = grant.granted(approver, "auto-approved for action");
let token = grant.token.clone();
self.insert(grant);
token
}
}
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn generate_token() -> String {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("approval_{:x}", ts)
}
#[allow(dead_code)]
/// Helper to create a one-time approval token
pub fn one_time_approval(policy: &str, action: &str, approver: &str) -> ApprovalTokenGrant {
let scope = ApprovalScope::new(policy, action);
ApprovalTokenGrant::pending(scope).granted(approver, "one-time approval")
}
#[allow(dead_code)]
/// Helper to create a multi-use approval token
pub fn multi_use_approval(
policy: &str,
action: &str,
approver: &str,
max_uses: u32,
expires_in_secs: u64,
) -> ApprovalTokenGrant {
let scope = ApprovalScope::new(policy, action).with_max_uses(max_uses);
let mut token = ApprovalTokenGrant::pending(scope);
token = token.granted(approver, "multi-use approval");
token.set_expires_at(current_timestamp() + expires_in_secs);
token.set_max_uses(max_uses);
token
}
#[cfg(test)]
mod tests {
use super::{
ApprovalDelegationHop, ApprovalScope, ApprovalTokenError, ApprovalTokenGrant,
ApprovalTokenLedger, ApprovalTokenStatus,
};
use super::*;
#[test]
fn approval_token_blocks_until_owner_grants_policy_exception() {
fn test_token_lifecycle() {
let mut ledger = ApprovalTokenLedger::new();
let scope = ApprovalScope::new("main_push_forbidden", "git push")
.with_repository("sisyphus/claw-code")
.with_branch("main");
ledger.insert(ApprovalTokenGrant::pending(
"tok-pending",
scope.clone(),
"repo-owner",
"release-bot",
));
let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test_policy", "test_action"))
.granted("admin", "test approval");
let token = grant.token.clone();
ledger.insert(grant);
assert!(ledger.verify(&token).is_ok());
assert!(ledger.consume(&token).is_ok());
assert!(matches!(
ledger.verify("tok-missing", &scope, "release-bot", 10),
Err(ApprovalTokenError::NoApproval)
));
assert!(matches!(
ledger.verify("tok-pending", &scope, "release-bot", 10),
Err(ApprovalTokenError::ApprovalPending)
));
ledger.insert(ApprovalTokenGrant::granted(
"tok-granted",
scope.clone(),
"repo-owner",
"release-bot",
));
let audit = ledger
.verify("tok-granted", &scope, "release-bot", 10)
.expect("owner approval should verify");
assert_eq!(audit.status, ApprovalTokenStatus::Granted);
assert_eq!(audit.approving_actor, "repo-owner");
assert_eq!(audit.executing_actor, "release-bot");
assert!(audit.delegated_execution);
}
#[test]
fn approval_token_is_one_time_use_and_rejects_replay() {
let mut ledger = ApprovalTokenLedger::new();
let scope = ApprovalScope::new("release_requires_owner", "release publish")
.with_repository("sisyphus/claw-code");
ledger.insert(ApprovalTokenGrant::granted(
"tok-once",
scope.clone(),
"owner",
"release-bot",
));
let first = ledger
.consume("tok-once", &scope, "release-bot", 10)
.expect("first use should consume token");
assert_eq!(first.status, ApprovalTokenStatus::Consumed);
assert_eq!(first.uses, 1);
assert!(matches!(
ledger.consume("tok-once", &scope, "release-bot", 11),
Err(ApprovalTokenError::ApprovalAlreadyConsumed)
));
assert_eq!(
ledger.get("tok-once").map(|grant| grant.status),
Some(ApprovalTokenStatus::Consumed)
);
}
#[test]
fn approval_token_rejects_scope_expansion_expiry_and_revocation() {
let mut ledger = ApprovalTokenLedger::new();
let scope = ApprovalScope::new("main_push_forbidden", "git push")
.with_repository("sisyphus/claw-code")
.with_branch("main");
let dev_scope = ApprovalScope::new("main_push_forbidden", "git push")
.with_repository("sisyphus/claw-code")
.with_branch("dev");
ledger.insert(
ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot")
.expires_at(20),
);
assert!(matches!(
ledger.verify("tok-expiring", &dev_scope, "bot", 10),
Err(ApprovalTokenError::ScopeMismatch { .. })
));
assert!(matches!(
ledger.verify("tok-expiring", &scope, "bot", 21),
Err(ApprovalTokenError::ApprovalExpired)
));
ledger.insert(ApprovalTokenGrant::granted(
"tok-revoked",
scope.clone(),
"owner",
"bot",
));
let revoked = ledger
.revoke("tok-revoked")
.expect("revocation should be audited");
assert_eq!(revoked.status, ApprovalTokenStatus::Revoked);
assert!(matches!(
ledger.verify("tok-revoked", &scope, "bot", 10),
Err(ApprovalTokenError::ApprovalRevoked)
ledger.verify(&token),
Err(ApprovalTokenError::TokenExhausted)
));
}
#[test]
fn approval_token_preserves_delegation_traceability() {
fn test_token_revocation() {
let mut ledger = ApprovalTokenLedger::new();
let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod");
ledger.insert(
ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot")
.with_delegation_hop(
ApprovalDelegationHop::new("owner", "owner approval")
.with_session_id("session-owner"),
)
.with_delegation_hop(
ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot")
.with_session_id("session-lead"),
),
);
let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test_policy", "test_action"))
.granted("admin", "test approval");
let token = grant.token.clone();
ledger.insert(grant);
assert!(ledger.revoke(&token, "admin").is_ok());
assert!(matches!(
ledger.verify("tok-delegated", &scope, "unexpected-bot", 10),
Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual })
if expected == "deploy-bot" && actual == "unexpected-bot"
ledger.verify(&token),
Err(ApprovalTokenError::TokenRevoked)
));
}
let audit = ledger
.consume("tok-delegated", &scope, "deploy-bot", 10)
.expect("approved delegate should consume token");
let actors = audit
.delegation_chain
.iter()
.map(|hop| hop.actor.as_str())
.collect::<Vec<_>>();
#[test]
fn test_delegation_chain() {
let mut grant = ApprovalTokenGrant::pending(ApprovalScope::new("policy", "action"))
.granted("admin", "initial approval");
grant.add_delegation_hop(ApprovalDelegationHop::new(
"delegate",
"delegated authority",
));
assert_eq!(grant.delegation_chain().len(), 2);
assert_eq!(grant.delegation_chain()[0].actor, "admin");
assert_eq!(grant.delegation_chain()[1].actor, "delegate");
}
assert!(audit.delegated_execution);
assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]);
assert_eq!(
audit.delegation_chain[0].session_id.as_deref(),
Some("session-owner")
);
assert_eq!(
audit.delegation_chain[1].session_id.as_deref(),
Some("session-lead")
);
#[test]
fn test_multi_use_token() {
let mut ledger = ApprovalTokenLedger::new();
let mut grant =
ApprovalTokenGrant::pending(ApprovalScope::new("test", "action").with_max_uses(3))
.granted("admin", "multi-use");
grant.set_max_uses(3);
let token = grant.token.clone();
ledger.insert(grant);
assert!(ledger.consume(&token).is_ok());
assert!(ledger.consume(&token).is_ok());
assert!(ledger.consume(&token).is_ok());
assert!(ledger.consume(&token).is_err());
}
#[test]
fn test_audit_log() {
let mut ledger = ApprovalTokenLedger::new();
let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test", "action"))
.granted("admin", "test");
let token = grant.token.clone();
ledger.insert(grant);
ledger.consume(&token).unwrap();
assert_eq!(ledger.audit_log().len(), 2);
assert!(matches!(
ledger.audit_log()[0].action,
TokenAuditAction::Created
));
assert!(matches!(
ledger.audit_log()[1].action,
TokenAuditAction::Consumed
));
}
}

View File

@ -0,0 +1,341 @@
/// Session compaction module — Trident-inspired 3-stage pipeline
///
/// Reduces conversation token usage by:
/// 1. Superseding obsolete file operations
/// 2. Collapsing chatty message chains
/// 3. Clustering semantically similar messages
use std::collections::{BTreeMap, BTreeSet};
/// Compaction configuration
#[derive(Debug, Clone)]
pub struct CompactionConfig {
pub stage1_enabled: bool,
pub stage2_enabled: bool,
pub stage3_enabled: bool,
pub collapse_threshold: usize,
pub cluster_min_size: usize,
pub preserve_last_n: usize,
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
stage1_enabled: true,
stage2_enabled: true,
stage3_enabled: true,
collapse_threshold: 4,
cluster_min_size: 3,
preserve_last_n: 10,
}
}
}
/// Compaction statistics
#[derive(Debug, Clone, Default)]
pub struct CompactionStats {
pub original_count: usize,
pub final_count: usize,
pub stage1_removed: usize,
pub stage2_collapsed: usize,
pub stage3_clustered: usize,
pub tokens_saved_estimate: usize,
}
impl CompactionStats {
pub fn compression_ratio(&self) -> f64 {
if self.final_count == 0 {
1.0
} else {
self.original_count as f64 / self.final_count as f64
}
}
pub fn report(&self) -> String {
format!(
"Session Compaction Complete\n\
├─ Original: {} messages\n\
├─ Final: {} messages ({:.1}x compression)\n\
├─ Stage 1 (Supersede): {} removed\n\
├─ Stage 2 (Collapse): {} collapsed\n\
├─ Stage 3 (Cluster): {} clustered\n\
└─ Est. tokens saved: ~{}",
self.original_count,
self.final_count,
self.compression_ratio(),
self.stage1_removed,
self.stage2_collapsed,
self.stage3_clustered,
self.tokens_saved_estimate
)
}
}
/// Message representation for compaction
#[derive(Debug, Clone)]
pub struct CompactMessage {
pub id: usize,
pub role: String,
pub summary: String,
pub tool_calls: Vec<String>,
pub file_ops: Vec<FileOperation>,
}
#[derive(Debug, Clone)]
pub struct FileOperation {
pub op_type: FileOpType,
pub path: String,
pub index: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FileOpType {
Read,
Write,
Edit,
}
/// Stage 1: Remove obsolete file operations
fn stage1_supersede(messages: Vec<CompactMessage>) -> (Vec<CompactMessage>, usize) {
let mut file_ops: BTreeMap<String, Vec<FileOperation>> = BTreeMap::new();
// Index file operations by path
for (idx, msg) in messages.iter().enumerate() {
for op in &msg.file_ops {
file_ops
.entry(op.path.clone())
.or_default()
.push(FileOperation {
op_type: op.op_type.clone(),
path: op.path.clone(),
index: idx,
});
}
}
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
for ops in file_ops.values() {
if ops.len() < 2 {
continue;
}
// Find last write
let last_write_idx = ops
.iter()
.filter(|op| matches!(op.op_type, FileOpType::Write | FileOpType::Edit))
.map(|op| op.index)
.max();
if let Some(last_write) = last_write_idx {
for op in ops {
if op.index < last_write {
obsolete_indices.insert(op.index);
}
}
}
}
let removed = obsolete_indices.len();
let filtered: Vec<_> = messages
.into_iter()
.enumerate()
.filter(|(i, _)| !obsolete_indices.contains(i))
.map(|(_, msg)| msg)
.collect();
(filtered, removed)
}
/// Stage 2: Collapse chatty message chains
fn stage2_collapse(
messages: Vec<CompactMessage>,
threshold: usize,
) -> (Vec<CompactMessage>, usize) {
let mut result = Vec::new();
let mut collapsed = 0;
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
// Check for chatty pattern (short back-and-forth)
if msg.summary.len() < 20 && msg.tool_calls.is_empty() {
let mut chain = vec![msg.clone()];
let mut j = i + 1;
while j < messages.len() && chain.len() < threshold {
let next = &messages[j];
if next.summary.len() < 20 && next.tool_calls.is_empty() {
chain.push(next.clone());
j += 1;
} else {
break;
}
}
if chain.len() >= 2 {
collapsed += chain.len();
result.push(CompactMessage {
id: chain.first().unwrap().id,
role: "assistant".to_string(),
summary: format!("[{} chatty messages collapsed]", chain.len()),
tool_calls: vec![],
file_ops: vec![],
});
i = j;
continue;
}
}
result.push(msg.clone());
i += 1;
}
(result, collapsed)
}
/// Stage 3: Cluster similar messages
fn stage3_cluster(messages: Vec<CompactMessage>, min_size: usize) -> (Vec<CompactMessage>, usize) {
// Simple clustering by keyword overlap
let mut clusters: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (idx, msg) in messages.iter().enumerate() {
// Extract keywords from summary
let keywords: Vec<_> = msg
.summary
.split_whitespace()
.filter(|w| w.len() > 4)
.take(3)
.collect();
let key = keywords.join("_");
if !key.is_empty() {
clusters.entry(key).or_default().push(idx);
}
}
// Merge clusters that meet minimum size
let mut clustered_count = 0;
let mut result = messages.clone();
for (key, indices) in &clusters {
if indices.len() >= min_size {
clustered_count += indices.len() - 1; // Keep first, merge rest
result[indices[0]].summary = format!(
"[Cluster: {} messages about '{}']",
indices.len(),
key.replace('_', " ")
);
for &idx in &indices[1..] {
result[idx].summary = "[MERGED]".to_string();
}
}
}
// Remove merged messages
result.retain(|msg| msg.summary != "[MERGED]");
(result, clustered_count)
}
/// Main compaction entry point
pub fn compact_session(
messages: Vec<CompactMessage>,
config: CompactionConfig,
) -> (Vec<CompactMessage>, CompactionStats) {
let original_count = messages.len();
let mut stats = CompactionStats {
original_count,
..Default::default()
};
let mut current = messages;
// Stage 1: Supersede
if config.stage1_enabled {
let (filtered, removed);
(filtered, removed) = stage1_supersede(current);
current = filtered;
stats.stage1_removed = removed;
}
// Stage 2: Collapse
if config.stage2_enabled {
let (collapsed, count);
(collapsed, count) = stage2_collapse(current, config.collapse_threshold);
current = collapsed;
stats.stage2_collapsed = count;
}
// Stage 3: Cluster
if config.stage3_enabled {
let (clustered, count);
(clustered, count) = stage3_cluster(current, config.cluster_min_size);
current = clustered;
stats.stage3_clustered = count;
}
stats.final_count = current.len();
stats.tokens_saved_estimate = (stats.original_count.saturating_sub(stats.final_count)) * 100;
(current, stats)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compaction_stats_ratio() {
let stats = CompactionStats {
original_count: 100,
final_count: 25,
..Default::default()
};
assert!((stats.compression_ratio() - 4.0).abs() < 0.01);
}
#[test]
fn test_stage1_supersede_removes_obsolete() {
let messages = vec![
CompactMessage {
id: 0,
role: "user".to_string(),
summary: "read file".to_string(),
tool_calls: vec![],
file_ops: vec![FileOperation {
op_type: FileOpType::Read,
path: "test.rs".to_string(),
index: 0,
}],
},
CompactMessage {
id: 1,
role: "assistant".to_string(),
summary: "write file".to_string(),
tool_calls: vec![],
file_ops: vec![FileOperation {
op_type: FileOpType::Write,
path: "test.rs".to_string(),
index: 1,
}],
},
];
let (result, removed) = stage1_supersede(messages);
assert_eq!(removed, 1); // First read should be removed
assert_eq!(result.len(), 1);
}
#[test]
fn test_default_config() {
let config = CompactionConfig::default();
assert!(config.stage1_enabled);
assert!(config.stage2_enabled);
assert!(config.stage3_enabled);
assert_eq!(config.collapse_threshold, 4);
assert_eq!(config.cluster_min_size, 3);
}
}

View File

@ -1062,6 +1062,7 @@ pub fn default_config_home() -> PathBuf {
/// Save provider settings to the user-level `~/.claw/settings.json`.
/// Creates the file and directory if they don't exist. Sets file permissions
/// to `0o600` (owner read/write only) to protect stored API keys.
#[allow(dead_code)]
pub fn save_user_provider_settings(
kind: &str,
api_key: &str,
@ -1114,6 +1115,7 @@ pub fn save_user_provider_settings(
}
/// Remove the `provider` section from the user-level `~/.claw/settings.json`.
#[allow(dead_code)]
pub fn clear_user_provider_settings() -> Result<(), ConfigError> {
let config_home = default_config_home();
let settings_path = config_home.join("settings.json");
@ -1133,6 +1135,7 @@ pub fn clear_user_provider_settings() -> Result<(), ConfigError> {
Ok(())
}
#[allow(dead_code)]
fn read_settings_root(path: &Path) -> serde_json::Map<String, serde_json::Value> {
match fs::read_to_string(path) {
Ok(contents) if !contents.trim().is_empty() => {
@ -1145,6 +1148,7 @@ fn read_settings_root(path: &Path) -> serde_json::Map<String, serde_json::Value>
}
}
#[allow(dead_code)]
fn write_settings_root(
path: &Path,
root: &serde_json::Map<String, serde_json::Value>,

View File

@ -10,6 +10,7 @@ pub mod bash_validation;
mod bootstrap;
pub mod branch_lock;
mod compact;
pub mod compact_pipeline;
mod config;
pub mod config_validate;
mod conversation;
@ -64,6 +65,10 @@ pub use compact::{
compact_session, estimate_session_tokens, format_compact_summary,
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use compact_pipeline::{
compact_session as compact_pipeline_session, CompactMessage,
CompactionConfig as PipelineCompactionConfig, CompactionStats, FileOpType, FileOperation,
};
pub use config::{
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,

View File

@ -731,7 +731,7 @@ mod tests {
}
#[test]
#[allow(clippy::duration_suboptimal_units, clippy::too_many_lines)]
#[allow(clippy::too_many_lines)]
fn executable_decision_table_emits_retry_rebase_merge_escalate_cleanup_and_approval_events() {
let engine = PolicyEngine::new(vec![
PolicyRule::new(

View File

@ -29,7 +29,7 @@ impl Default for TridentConfig {
}
/// Statistics from a Trident compaction run.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TridentStats {
pub superseded_count: usize,
pub collapsed_chains: usize,
@ -41,21 +41,6 @@ pub struct TridentStats {
pub final_message_count: usize,
}
impl Default for TridentStats {
fn default() -> Self {
Self {
superseded_count: 0,
collapsed_chains: 0,
messages_collapsed: 0,
clusters_found: 0,
messages_clustered: 0,
tokens_saved_estimate: 0,
original_message_count: 0,
final_message_count: 0,
}
}
}
impl TridentStats {
pub fn format_report(&self) -> String {
let compression = if self.final_message_count > 0 {
@ -192,7 +177,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
for (_path, ops) in &file_ops {
for ops in file_ops.values() {
if ops.len() < 2 {
continue;
}
@ -205,11 +190,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
if let Some(last_write) = last_write_idx {
for op in ops {
if op.op_type == FileOp::Read && op.index < last_write {
obsolete_indices.insert(op.index);
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
&& op.index < last_write
{
if op.index < last_write {
obsolete_indices.insert(op.index);
}
}
@ -325,7 +306,7 @@ fn stage2_collapse(
usage: None,
});
} else {
result.extend(buffer.drain(..));
result.append(&mut buffer);
}
buffer.clear();
result.push(msg.clone());
@ -476,7 +457,7 @@ fn stage3_cluster(
}
let total_clustered: usize = cluster_assignments.len();
let clusters_found = cluster_id as usize;
let clusters_found = cluster_id;
let mut result: Vec<ConversationMessage> = Vec::new();
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
@ -677,7 +658,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
mod tests {
use super::*;
use crate::compact::CompactionConfig;
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
use crate::session::{ContentBlock, ConversationMessage, Session};
#[test]
fn stage1_removes_obsolete_file_reads() {
@ -736,7 +717,7 @@ mod tests {
fn stage2_collapses_chatty_messages() {
let mut messages = vec![];
for i in 0..6 {
messages.push(ConversationMessage::user_text(&format!("ok {i}")));
messages.push(ConversationMessage::user_text(format!("ok {i}")));
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: format!("got {i}"),
}]));
@ -767,9 +748,9 @@ mod tests {
},
]));
messages.push(ConversationMessage::tool_result(
&format!("read_{i}"),
format!("read_{i}"),
"read_file",
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
false,
));
}

View File

@ -1932,9 +1932,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// Only reject for known top-level subcommands that don't use compact.
let first = rest[0].as_str();
if is_known_top_level_subcommand(first) && first != "prompt" {
return Err(format!(
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact"
));
return Err(
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact".to_string()
);
}
}
@ -3188,9 +3188,9 @@ fn parse_system_prompt_args(
})?;
// #99: validate --date is a plausible date string (no newlines, reasonable length)
if value.contains('\n') || value.contains('\r') {
return Err(format!(
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>"
));
return Err(
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>".to_string()
);
}
if value.len() > 20 {
return Err(format!(
@ -3427,11 +3427,7 @@ impl DiagnosticCheck {
fn json_value(&self) -> Value {
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
let id = self
.name
.to_ascii_lowercase()
.replace(' ', "_")
.replace('-', "_");
let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_");
let mut value = Map::from_iter([
("id".to_string(), Value::String(id.clone())),
(
@ -6700,16 +6696,13 @@ fn run_resume_command(
}
SlashCommand::Plugins { action, target } => {
// Only list is supported in resume mode (no runtime to reload)
match action.as_deref() {
Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => {
// #777: use interactive_only: prefix + \n hint so #776's classify/split
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
return Err(format!(
"interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command."
).into());
}
_ => {}
if let Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) =
action.as_deref()
{
// #777: use interactive_only: prefix + \n hint so #776's classify/split
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
return Err(format!("interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command.").into());
}
let cwd = env::current_dir()?;
let payload = plugins_command_payload_for(
@ -7821,8 +7814,7 @@ impl LiveCli {
let max_compact_rounds = 4;
let preserve_schedule = [4, 2, 1, 0];
for round in 0..max_compact_rounds {
let preserve = preserve_schedule[round];
for (round, &preserve) in preserve_schedule.iter().enumerate() {
println!(
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
round + 1,
@ -8574,8 +8566,8 @@ impl LiveCli {
let cwd = env::current_dir()?;
// #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes.
// Previously the guard was JSON-only (#793); text mode silently returned empty success.
if action.as_deref() == Some("list") {
if let Some(filter) = target.as_deref() {
if action == Some("list") {
if let Some(filter) = target {
if filter.starts_with('-') {
if matches!(output_format, CliOutputFormat::Json) {
// ROADMAP #817: this is a handled local inventory parse error.
@ -9540,6 +9532,7 @@ fn print_status_snapshot(
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn status_json_value(
model: Option<&str>,
usage: StatusUsage,
@ -10036,9 +10029,7 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
// (#731: "not supported on macOS" is a degraded state, not a hard error;
// filesystem_active:true means partial containment is working)
// error = enabled but unsupported AND no filesystem sandbox either (nothing active)
let top_status = if !status.enabled {
"ok"
} else if status.active {
let top_status = if !status.enabled || status.active {
"ok"
} else if status.supported {
"warn"
@ -10404,10 +10395,7 @@ fn render_doctor_help_json() -> serde_json::Value {
})
}
/// #683-#692: extract structured metadata from help prose
fn extract_help_metadata(
topic: LocalHelpTopic,
) -> (
type HelpMetadata = (
Option<String>, // usage
Option<String>, // purpose
Option<String>, // output description
@ -10416,7 +10404,10 @@ fn extract_help_metadata(
Option<Vec<String>>, // aliases
bool, // local_only
bool, // requires_credentials
) {
);
/// #683-#692: extract structured metadata from help prose
fn extract_help_metadata(topic: LocalHelpTopic) -> HelpMetadata {
let text = render_help_topic(topic);
let mut usage = None;
let mut purpose = None;
@ -13946,6 +13937,7 @@ fn permission_policy(
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
#[allow(clippy::unnecessary_filter_map)]
messages
.iter()
.filter_map(|message| {
@ -16920,7 +16912,7 @@ mod tests {
for action in ["remove", "uninstall", "delete"] {
assert_eq!(
parse_args(&["skills".to_string(), action.to_string()])
.expect(&format!("skills {action} should parse")),
.unwrap_or_else(|_| panic!("skills {action} should parse")),
CliAction::Skills {
args: Some(action.to_string()),
output_format: CliOutputFormat::Text,

View File

@ -1,3 +1,5 @@
#![allow(unused_variables)]
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
@ -1007,13 +1009,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
.is_some_and(|o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("target")),
.is_some_and(|o| o.contains_key("target")),
"plugins list should not include target"
);
// #703: structured summary replaces prose message
@ -1706,13 +1708,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
.is_some_and(|o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("target")),
.is_some_and(|o| o.contains_key("target")),
"plugins list should not include target"
);
assert!(
@ -2945,7 +2947,7 @@ fn prompt_empty_arg_json_stdout_missing_prompt_823() {
"claw prompt empty arg must retain abort action (#823); got: {parsed}"
);
assert!(
parsed["hint"].as_str().map_or(false, |h| !h.is_empty()),
parsed["hint"].as_str().is_some_and(|h| !h.is_empty()),
"claw prompt empty arg missing_prompt hint must be non-empty (#823)"
);
}
@ -2983,9 +2985,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
);
assert!(
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|| h.contains("medium")
|| h.contains("high")),
parsed["hint"]
.as_str()
.is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
"hint must mention valid values (#756): {parsed}"
);
@ -3011,7 +3013,7 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
"missing --model value must be missing_flag_value (#756): {parsed2}"
);
assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
"missing --model hint must be non-empty (#756): {parsed2}"
);
}
@ -3255,7 +3257,7 @@ fn short_p_flag_swallows_no_flags_755() {
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
);
assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
"missing_prompt hint must be non-empty (#755)"
);
}
@ -3397,7 +3399,7 @@ fn config_unsupported_section_json_hint_741() {
assert!(
parsed["supported_sections"]
.as_array()
.map_or(false, |a| !a.is_empty()),
.is_some_and(|a| !a.is_empty()),
"config {section} JSON must include supported_sections (#741)"
);
}
@ -5366,8 +5368,10 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
.iter()
.any(|agent| {
agent["name"] == "my-agent"
&& PathBuf::from(agent["path"].as_str().expect("listed agent path"))
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
&& fs::canonicalize(&agent_path)
.map(|p| p.to_string_lossy().to_string())
.expect("canonical listed agent path")
== agent["path"].as_str().expect("listed agent path")
}));
}