Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,4 @@ Tools declare a `PermissionLevel` (ReadOnly / Supervised / Dangerous). The runti
## Principles

- Architecture must conform to SOLID, GRASP, and YAGNI; files should stay under 200 lines; balance cohesion and SRP — split by reason to change, not by line count.
- Names must be specific and descriptive — files, modules, functions, and variables should say exactly what they do. Avoid vague names like `common`, `helpers`, `utils`, `misc`, `edge_test`, `manager`, `handler`, `data`, `info`, `process`.
3 changes: 2 additions & 1 deletion crates/loopal-acp/src/translate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ pub fn translate_event(payload: &AgentEventPayload, session_id: &str) -> Option<
| AgentEventPayload::ServerToolResult { .. }
| AgentEventPayload::RetryCleared
| AgentEventPayload::SubAgentSpawned { .. }
| AgentEventPayload::AutoModeDecision { .. } => None,
| AgentEventPayload::AutoModeDecision { .. }
| AgentEventPayload::TurnCompleted { .. } => None,
}
}

Expand Down
6 changes: 6 additions & 0 deletions crates/loopal-agent-client/src/bridge_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ pub(crate) async fn handle_permission(
let tool_id = params["tool_call_id"].as_str().unwrap_or("").to_string();
let event = AgentEvent {
agent_name: None,
event_id: loopal_protocol::event_id::next_event_id(),
turn_id: loopal_protocol::event_id::current_turn_id(),
correlation_id: loopal_protocol::event_id::current_correlation_id(),
payload: loopal_protocol::AgentEventPayload::ToolPermissionRequest {
id: tool_id,
name: tool_name.clone(),
Expand Down Expand Up @@ -60,6 +63,9 @@ pub(crate) async fn handle_question(
if let Ok(questions) = parsed {
let event = AgentEvent {
agent_name: None,
event_id: loopal_protocol::event_id::next_event_id(),
turn_id: 0,
correlation_id: 0,
payload: loopal_protocol::AgentEventPayload::UserQuestionRequest {
id: "ipc".into(),
questions,
Expand Down
14 changes: 10 additions & 4 deletions crates/loopal-agent-server/src/agent_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,14 @@ pub fn build_with_frontend(
);

let auto_classifier = if permission_mode == loopal_tool_api::PermissionMode::Auto {
Some(Arc::new(loopal_auto_mode::AutoClassifier::new(
config.instructions.clone(),
cwd.to_string_lossy().into_owned(),
)))
Some(Arc::new(
loopal_auto_mode::AutoClassifier::new_with_thresholds(
config.instructions.clone(),
cwd.to_string_lossy().into_owned(),
config.settings.harness.cb_max_consecutive_denials,
config.settings.harness.cb_max_total_denials,
),
))
} else {
None
};
Expand Down Expand Up @@ -197,6 +201,8 @@ pub fn build_with_frontend(
memory_channel,
scheduled_rx: Some(scheduled_rx),
auto_classifier,
harness: config.settings.harness.clone(),
rewake_rx: None, // TODO: wire from AsyncHookStore when async hooks are configured
};
Ok(params)
}
3 changes: 3 additions & 0 deletions crates/loopal-agent-server/src/hub_emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ impl EventEmitter for HubEventEmitter {
};
let event = AgentEvent {
agent_name: self.agent_name.clone(),
event_id: loopal_protocol::event_id::next_event_id(),
turn_id: loopal_protocol::event_id::current_turn_id(),
correlation_id: loopal_protocol::event_id::current_correlation_id(),
payload,
};
let params = serde_json::to_value(&event)
Expand Down
6 changes: 6 additions & 0 deletions crates/loopal-agent-server/src/hub_frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ impl AgentFrontend for HubFrontend {
async fn emit(&self, payload: AgentEventPayload) -> Result<()> {
let event = AgentEvent {
agent_name: self.agent_name.clone(),
event_id: loopal_protocol::event_id::next_event_id(),
turn_id: loopal_protocol::event_id::current_turn_id(),
correlation_id: loopal_protocol::event_id::current_correlation_id(),
payload,
};
let params = serde_json::to_value(&event)
Expand Down Expand Up @@ -174,6 +177,9 @@ impl AgentFrontend for HubFrontend {
fn try_emit(&self, payload: AgentEventPayload) -> bool {
let event = AgentEvent {
agent_name: self.agent_name.clone(),
event_id: loopal_protocol::event_id::next_event_id(),
turn_id: loopal_protocol::event_id::current_turn_id(),
correlation_id: loopal_protocol::event_id::current_correlation_id(),
payload,
};
let Ok(params) = serde_json::to_value(&event) else {
Expand Down
3 changes: 3 additions & 0 deletions crates/loopal-agent-server/src/ipc_emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ impl EventEmitter for IpcEventEmitter {
async fn emit(&self, payload: AgentEventPayload) -> Result<()> {
let event = AgentEvent {
agent_name: self.agent_name.clone(),
event_id: loopal_protocol::event_id::next_event_id(),
turn_id: loopal_protocol::event_id::current_turn_id(),
correlation_id: loopal_protocol::event_id::current_correlation_id(),
payload,
};
let params = serde_json::to_value(&event)
Expand Down
6 changes: 6 additions & 0 deletions crates/loopal-agent-server/src/ipc_frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ impl AgentFrontend for IpcFrontend {
async fn emit(&self, payload: AgentEventPayload) -> Result<()> {
let event = AgentEvent {
agent_name: self.agent_name.clone(),
event_id: loopal_protocol::event_id::next_event_id(),
turn_id: loopal_protocol::event_id::current_turn_id(),
correlation_id: loopal_protocol::event_id::current_correlation_id(),
payload,
};
let params = serde_json::to_value(&event)
Expand Down Expand Up @@ -180,6 +183,9 @@ impl AgentFrontend for IpcFrontend {
fn try_emit(&self, payload: AgentEventPayload) -> bool {
let event = AgentEvent {
agent_name: self.agent_name.clone(),
event_id: loopal_protocol::event_id::next_event_id(),
turn_id: loopal_protocol::event_id::current_turn_id(),
correlation_id: loopal_protocol::event_id::current_correlation_id(),
payload,
};
let Ok(params) = serde_json::to_value(&event) else {
Expand Down
3 changes: 3 additions & 0 deletions crates/loopal-agent-server/tests/suite/params_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ async fn event_forwarder_delivers_sub_agent_events() {
// Send a sub-agent event through the channel
let event = loopal_protocol::AgentEvent {
agent_name: Some("sub-1".into()),
event_id: 0,
turn_id: 0,
correlation_id: 0,
payload: loopal_protocol::AgentEventPayload::Stream {
text: "from sub-agent".into(),
},
Expand Down
13 changes: 11 additions & 2 deletions crates/loopal-auto-mode/src/circuit_breaker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const MAX_TOTAL_DENIALS: u32 = 20;
/// are exceeded, preventing runaway classification loops.
pub struct CircuitBreaker {
inner: Mutex<Inner>,
max_consecutive: u32,
max_total: u32,
}

struct Inner {
Expand All @@ -25,12 +27,19 @@ struct Inner {

impl CircuitBreaker {
pub fn new() -> Self {
Self::with_thresholds(MAX_CONSECUTIVE_DENIALS, MAX_TOTAL_DENIALS)
}

/// Create with custom thresholds (from HarnessConfig).
pub fn with_thresholds(max_consecutive: u32, max_total: u32) -> Self {
Self {
inner: Mutex::new(Inner {
consecutive: HashMap::new(),
total_denials: 0,
degraded: false,
}),
max_consecutive,
max_total,
}
}

Expand All @@ -39,9 +48,9 @@ impl CircuitBreaker {
let mut inner = self.inner.lock().unwrap();
let count = inner.consecutive.entry(tool_name.to_string()).or_insert(0);
*count += 1;
let consecutive_exceeded = *count >= MAX_CONSECUTIVE_DENIALS;
let consecutive_exceeded = *count >= self.max_consecutive;
inner.total_denials += 1;
if consecutive_exceeded || inner.total_denials >= MAX_TOTAL_DENIALS {
if consecutive_exceeded || inner.total_denials >= self.max_total {
inner.degraded = true;
}
}
Expand Down
15 changes: 15 additions & 0 deletions crates/loopal-auto-mode/src/classifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ impl AutoClassifier {
}
}

/// Create with custom circuit breaker thresholds (from HarnessConfig).
pub fn new_with_thresholds(
instructions: String,
cwd: String,
max_consecutive: u32,
max_total: u32,
) -> Self {
Self {
circuit_breaker: CircuitBreaker::with_thresholds(max_consecutive, max_total),
cache: ClassifierCache::new(),
instructions,
cwd,
}
}

/// Whether the circuit breaker has tripped (too many denials/errors).
pub fn is_degraded(&self) -> bool {
self.circuit_breaker.is_degraded()
Expand Down
35 changes: 35 additions & 0 deletions crates/loopal-config/src/harness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use serde::{Deserialize, Serialize};

/// Control-loop parameters for the agent harness.
///
/// All fields have sensible defaults matching the previous hardcoded values.
/// Override via `settings.json` under the `"harness"` key.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HarnessConfig {
/// Loop detection: warn after this many repeated tool calls (default: 3).
pub loop_warn_threshold: u32,
/// Loop detection: abort turn after this many repeats (default: 5).
pub loop_abort_threshold: u32,
/// Auto-mode circuit breaker: max consecutive denials per tool (default: 3).
pub cb_max_consecutive_denials: u32,
/// Auto-mode circuit breaker: max total denials per session (default: 20).
pub cb_max_total_denials: u32,
/// Max automatic continuations when LLM hits max_tokens (default: 3).
pub max_auto_continuations: u32,
/// Max Stop hook feedback rounds before forcing exit (default: 2).
pub max_stop_feedback: u32,
}

impl Default for HarnessConfig {
fn default() -> Self {
Self {
loop_warn_threshold: 3,
loop_abort_threshold: 5,
cb_max_consecutive_denials: 3,
cb_max_total_denials: 20,
max_auto_continuations: 3,
max_stop_feedback: 2,
}
}
}
66 changes: 55 additions & 11 deletions crates/loopal-config/src/hook.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// Hook event types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
/// Hook event types — lifecycle points where hooks can intercept.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
/// Before tool execution
Expand All @@ -12,35 +14,77 @@ pub enum HookEvent {
PreRequest,
/// After user submits input
PostInput,
/// When a session starts
SessionStart,
/// When a session ends
SessionEnd,
/// Right before the agent concludes its response (exit-gate)
Stop,
/// Before conversation compaction
PreCompact,
}

/// Hook configuration
/// Hook executor type — determines how the hook runs.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookType {
/// Shell command (default, backward compatible).
#[default]
Command,
/// HTTP POST to a webhook URL.
Http,
/// LLM prompt hook (lightweight classifier call).
Prompt,
}

/// Hook configuration.
///
/// Backward compatible: `{"event": "pre_tool_use", "command": "echo hi"}`
/// still works (type defaults to Command, url/prompt/headers ignored).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
/// Event that triggers this hook
pub event: HookEvent,
/// Shell command to execute
/// Executor type (default: command).
#[serde(default, rename = "type")]
pub hook_type: HookType,
/// Shell command (Command type). Ignored for Http/Prompt types (leave empty).
#[serde(default)]
pub command: String,
/// Optional: only trigger for specific tool names
/// Webhook URL (required for Http type).
#[serde(default)]
pub url: Option<String>,
/// HTTP headers (Http type only).
#[serde(default)]
pub headers: HashMap<String, String>,
/// LLM prompt (required for Prompt type).
#[serde(default)]
pub prompt: Option<String>,
/// LLM model override (Prompt type only).
#[serde(default)]
pub model: Option<String>,
/// Legacy tool_filter (use `condition` instead).
#[serde(default)]
pub tool_filter: Option<Vec<String>>,
/// Timeout in milliseconds (default: 10000)
/// Condition expression: "Bash(git push*)", "Write(*.rs)", "*"
#[serde(default, rename = "if")]
pub condition: Option<String>,
/// Timeout in milliseconds (default: 10000).
#[serde(default = "default_hook_timeout")]
pub timeout_ms: u64,
/// Deduplication ID across config layers.
#[serde(default)]
pub id: Option<String>,
}

fn default_hook_timeout() -> u64 {
10_000
}

/// Result from hook execution
/// Result from hook execution (legacy, used by runner.rs).
#[derive(Debug, Clone)]
pub struct HookResult {
/// Exit code (0 = success)
pub exit_code: i32,
/// Stdout output
pub stdout: String,
/// Stderr output
pub stderr: String,
}

Expand Down
Loading
Loading