use std::collections::HashMap;
use super::super::tcb::{TaskLifecycle, WaitReason};
use super::{
ApprovalRequest, GateToolOutcome, KernelObservation, LoopAction, LoopEvent, LoopPhase,
LoopStateMachine, SuspendState,
};
use crate::runtime::session::RollbackReason;
use crate::syscall::{Disposition, Syscall};
use crate::types::agent::AgentIdentity;
use crate::types::message::{Content, Message, ToolCall, ToolErrorKind, ToolResult};
impl LoopStateMachine {
pub(super) fn evaluate_syscall(&mut self, sys: &Syscall) -> Disposition {
match sys {
Syscall::Invoke(call) => {
let caller = self
.run_spec
.as_ref()
.map(|s| s.identity.clone())
.unwrap_or_else(|| AgentIdentity::new("agent", "session"));
match self.governance.as_mut() {
Some(pipeline) => pipeline.evaluate(call, &caller).into(),
None => Disposition::Allow,
}
}
Syscall::Spawn(_) => self.evaluate_spawn_quota(),
Syscall::WriteMemory(_) => self.evaluate_memory_write_quota(),
Syscall::SubmitNodes { count } => self.evaluate_submit_nodes_quota(*count),
Syscall::LoadWorkflow { node_count } => self.evaluate_submit_nodes_quota(*node_count),
}
}
pub(super) fn evaluate_submit_nodes_quota(&self, count: usize) -> Disposition {
let Some(max) = self
.resource_quota
.as_ref()
.and_then(|q| q.max_workflow_nodes)
else {
return Disposition::Allow;
};
let current = self.workflow.as_ref().map(|w| w.len()).unwrap_or(0);
let projected = current.saturating_add(count);
if projected > max {
Disposition::Deny {
stage: "workflow_growth",
reason: format!(
"submit_nodes would grow workflow to {projected} nodes (max {max})"
),
}
} else {
Disposition::Allow
}
}
pub fn gate_syscall(&mut self, sys: &Syscall) -> Disposition {
self.evaluate_syscall(sys)
}
pub(super) fn workflow_budget(&self) -> Option<crate::orchestration::workflow::WorkflowBudget> {
let quota = self.resource_quota.as_ref();
if quota.is_none() && self.budget_grant.is_none() {
return None;
}
let nodes_used = self.workflow.as_ref().map(|w| w.len()).unwrap_or(0);
let running_subagents = self
.tasks
.all()
.iter()
.filter(|t| t.proc.is_some() && matches!(t.state, TaskLifecycle::Running))
.count();
let nodes_max = quota.and_then(|quota| quota.max_workflow_nodes);
let max_concurrent_subagents = quota
.and_then(|quota| quota.max_concurrent_subagents)
.map(|m| m as usize);
let tokens_max = self
.budget_grant
.as_ref()
.and_then(|grant| grant.tokens)
.unwrap_or(self.policy.max_total_tokens)
.min(self.policy.max_total_tokens);
let tokens_used = self.total_tokens;
Some(crate::orchestration::workflow::WorkflowBudget {
nodes_used,
nodes_max,
nodes_remaining: nodes_max.map(|m| m.saturating_sub(nodes_used)),
running_subagents,
max_concurrent_subagents,
concurrency_remaining: max_concurrent_subagents
.map(|m| m.saturating_sub(running_subagents)),
tokens_used,
tokens_max: Some(tokens_max),
tokens_remaining: Some(tokens_max.saturating_sub(tokens_used)),
})
}
fn evaluate_spawn_quota_inner(&mut self, concurrency_transient: bool) -> Disposition {
let quota = self.resource_quota.as_ref();
if let Some(max) = quota.and_then(|quota| quota.max_concurrent_subagents) {
if max == 0 {
return Disposition::Deny {
stage: "quota",
reason: "max_concurrent_subagents=0 permits no spawn (misconfigured quota)"
.to_string(),
};
}
let running = self
.tasks
.all()
.iter()
.filter(|t| t.proc.is_some() && matches!(t.state, TaskLifecycle::Running))
.count() as u32;
if running >= max {
return if concurrency_transient {
Disposition::Defer { slot: running }
} else {
Disposition::Deny {
stage: "quota",
reason: format!(
"max_concurrent_subagents={max} reached ({running} running)"
),
}
};
}
}
let quota_max = quota.and_then(|quota| quota.max_total_subagents);
let grant_max = self.budget_grant.as_ref().and_then(|grant| grant.subagents);
let max_total = match (quota_max, grant_max) {
(Some(quota), Some(grant)) => Some(quota.min(grant)),
(Some(quota), None) => Some(quota),
(None, Some(grant)) => Some(grant),
(None, None) => None,
};
if let Some(max) = max_total {
let total = self.local_subagents_spawned();
if total >= max {
if grant_max.is_some() {
self.observations.push(KernelObservation::BudgetExceeded {
turn: self.turn,
budget: "subagents".into(),
operation_id: String::new(),
reservation_id: self
.budget_grant
.as_ref()
.map(|grant| grant.reservation_id.clone()),
});
}
return Disposition::Deny {
stage: "budget_grant",
reason: format!("subagent grant {max} reached ({total} spawned locally)"),
};
}
}
if let Some(max) = quota.and_then(|quota| quota.max_spawn_depth) {
let depth = 1u32;
if depth > max {
return Disposition::Deny {
stage: "quota",
reason: format!("max_spawn_depth={max} exceeded (depth {depth})"),
};
}
}
Disposition::Allow
}
pub(super) fn evaluate_spawn_quota(&mut self) -> Disposition {
self.evaluate_spawn_quota_inner(false)
}
pub(super) fn evaluate_spawn_quota_deferrable(&mut self) -> Disposition {
self.evaluate_spawn_quota_inner(true)
}
pub(super) fn evaluate_memory_write_quota(&mut self) -> Disposition {
let Some((max, window)) = self
.resource_quota
.as_ref()
.and_then(|q| q.memory_writes_per_window)
else {
return Disposition::Allow;
};
let now = self.last_now_ms.unwrap_or(0);
self.memory_write_times
.retain(|&t| now.saturating_sub(t) < window);
if self.memory_write_times.len() as u32 >= max {
let oldest = self.memory_write_times.first().copied().unwrap_or(now);
let retry_after_ms = window.saturating_sub(now.saturating_sub(oldest));
return Disposition::RateLimited { retry_after_ms };
}
self.memory_write_times.push(now);
Disposition::Allow
}
pub(super) fn check_repeat_fuse(&mut self, calls: &[ToolCall]) -> Option<LoopAction> {
if !self.repeat_fuse.enabled {
return None;
}
let sig = calls
.iter()
.filter(|c| !crate::context::manager::is_meta_tool(c.name.as_str()))
.map(|c| {
let args = super::compact_tool_args(&c.arguments);
if args.is_empty() {
c.name.to_string()
} else {
format!("{}({})", c.name, args)
}
})
.collect::<Vec<_>>()
.join(", ");
if sig.is_empty() {
return None;
}
if self.repeat_sig.as_deref() == Some(sig.as_str()) {
self.repeat_count += 1;
} else {
self.repeat_sig = Some(sig.clone());
self.repeat_count = 1;
return None;
}
let fuse = self.repeat_fuse;
let count = self.repeat_count;
let tool_name = calls
.first()
.map(|c| c.name.to_string())
.unwrap_or_default();
if fuse.terminate_after > 0 && count >= fuse.terminate_after {
self.observations
.push(KernelObservation::RepeatFuseTripped {
turn: self.turn,
signature: sig.clone(),
count,
action: "terminate".to_string(),
});
let rb = RollbackReason::GovernanceDenied {
tool_name,
reason: format!("repeat fuse: `{sig}` re-issued {count}x consecutively"),
};
self.rollback(rb);
self.ctx.push_signal(format!(
"[NO-PROGRESS] `{sig}` was re-issued {count}x consecutively with no new outcome. \
The run is terminating. Report what was accomplished and what remains, in plain text."
));
self.pending_termination = Some(crate::types::result::TerminationReason::NoProgress);
self.phase = LoopPhase::Reason;
return Some(self.emit_call_llm());
}
if fuse.deny_after > 0 && count >= fuse.deny_after {
self.observations
.push(KernelObservation::RepeatFuseTripped {
turn: self.turn,
signature: sig.clone(),
count,
action: "deny".to_string(),
});
let rb = RollbackReason::GovernanceDenied {
tool_name,
reason: format!(
"repeat fuse: this exact call (same tool, same arguments) has been issued \
{count}x consecutively with no new outcome — do something DIFFERENT: change \
the arguments, use another tool, or report the task state as it stands"
),
};
let note = Message::user(super::super::rollback::build_rollback_note(
&rb,
self.ctx.config.verbose_control_notes,
));
self.rollback(rb);
self.ctx
.push_signal(note.content.as_text().unwrap_or_default().to_string());
self.phase = LoopPhase::Reason;
return Some(self.emit_call_llm());
}
None
}
pub(super) fn gate_tool_calls(&mut self, calls: &[ToolCall]) -> GateToolOutcome {
if self.governance.is_none() {
return GateToolOutcome::Proceed;
}
let mut gated: Vec<(String, String, String)> = Vec::new();
let mut hard_block: Option<(String, String)> = None;
for call in calls {
match self.evaluate_syscall(&Syscall::Invoke(call.clone())) {
Disposition::Allow => {}
Disposition::Gate { reason, .. } => {
gated.push((call.id.to_string(), call.name.to_string(), reason));
}
Disposition::Deny { reason, .. } => {
if hard_block.is_none() {
hard_block = Some((call.name.to_string(), reason));
}
}
Disposition::RateLimited { retry_after_ms } => {
if hard_block.is_none() {
hard_block = Some((
call.name.to_string(),
format!("rate limited, retry after {retry_after_ms}ms"),
));
}
}
Disposition::Defer { .. } => {}
}
}
if let Some((tool_name, reason)) = hard_block {
let rb = RollbackReason::GovernanceDenied { tool_name, reason };
let note = Message::user(super::super::rollback::build_rollback_note(
&rb,
self.ctx.config.verbose_control_notes,
));
self.rollback(rb);
self.ctx
.push_signal(note.content.as_text().unwrap_or_default().to_string());
self.phase = LoopPhase::Reason;
return GateToolOutcome::Blocked(self.emit_call_llm());
}
if gated.is_empty() {
return GateToolOutcome::Proceed;
}
let pending_calls: Vec<String> = gated.iter().map(|(id, _, _)| id.clone()).collect();
let gated_reasons: HashMap<String, String> = gated
.iter()
.map(|(id, _, reason)| (id.clone(), reason.clone()))
.collect();
let requests = calls
.iter()
.filter_map(|call| {
gated_reasons
.get(call.id.as_str())
.map(|reason| ApprovalRequest {
call_id: call.id.to_string(),
tool: call.name.to_string(),
arguments: call.arguments.clone(),
reason: reason.clone(),
})
})
.collect();
self.suspend_state = Some(SuspendState::AskUser {
calls: calls.to_vec(),
gated_reasons,
});
self.set_lifecycle(TaskLifecycle::Suspended, Some(WaitReason::Approval));
self.observations.push(KernelObservation::Suspended {
turn: self.turn,
reason: "ask_user".to_string(),
pending_calls,
});
GateToolOutcome::ApprovalRequired(requests)
}
pub fn resolve_approval(
&mut self,
approved_calls: Vec<String>,
denied_calls: Vec<String>,
) -> LoopAction {
self.observations.clear();
let Some(state) = self.suspend_state.take() else {
return LoopAction::AwaitingResume;
};
if !self.is_suspended() {
return LoopAction::AwaitingResume;
}
let approved_set: std::collections::HashSet<String> =
approved_calls.iter().cloned().collect();
let denied_set: std::collections::HashSet<String> = denied_calls.iter().cloned().collect();
let SuspendState::AskUser {
calls,
gated_reasons,
} = state
else {
return LoopAction::AwaitingResume;
};
for call in &calls {
if let Some(reason) = gated_reasons.get(call.id.as_str()) {
self.observations.push(KernelObservation::ToolGated {
turn: self.turn,
call_id: call.id.to_string(),
tool: call.name.to_string(),
reason: reason.clone(),
});
}
}
self.observations.push(KernelObservation::Resumed {
turn: self.turn,
approved: approved_calls,
denied: denied_calls,
});
let mut to_execute = Vec::new();
let mut synthetic_results = Vec::new();
for call in calls {
let id = call.id.to_string();
if let Some(reason) = gated_reasons.get(&id) {
if approved_set.contains(&id) {
to_execute.push(call.clone());
} else if denied_set.contains(&id) || !approved_set.contains(&id) {
synthetic_results.push(ToolResult {
call_id: call.id.clone(),
output: Content::Text(format!("permission denied: {reason}")),
is_error: true,
is_fatal: false,
error_kind: Some(ToolErrorKind::GovernanceDenied),
token_count: None,
});
}
} else {
to_execute.push(call.clone());
}
}
self.pending_denied_results = synthetic_results;
if to_execute.is_empty() {
let results = std::mem::take(&mut self.pending_denied_results);
self.phase = LoopPhase::Reason;
self.set_lifecycle(TaskLifecycle::Running, None);
return self.feed(LoopEvent::ToolResults { results });
}
self.phase = LoopPhase::Act {
tool_calls: to_execute.clone(),
};
self.set_lifecycle(TaskLifecycle::Running, None);
LoopAction::ExecuteTools { calls: to_execute }
}
pub fn retry_approval(&mut self, error: String) -> LoopAction {
self.observations.clear();
self.observations
.push(KernelObservation::ApprovalResolutionFailed {
turn: self.turn,
error,
});
let Some(SuspendState::AskUser {
calls,
gated_reasons,
}) = &self.suspend_state
else {
return LoopAction::AwaitingResume;
};
let requests = calls
.iter()
.filter_map(|call| {
gated_reasons
.get(call.id.as_str())
.map(|reason| ApprovalRequest {
call_id: call.id.to_string(),
tool: call.name.to_string(),
arguments: call.arguments.clone(),
reason: reason.clone(),
})
})
.collect();
LoopAction::RequestApproval { requests }
}
}