M1: tool trait, permission service, config, Provider trait, engine loop

Adds the Tool trait, config loading/schema, the Provider trait plus an Llm event surface, a permission service, and the core engine loop with a doom-loop guard and retry scaffolding. The processor drives a text/tool-call/final-text turn against a Provider, laying the groundwork for the MockProvider integration test and the real Anthropic path.
This commit is contained in:
2026-07-10 16:19:38 +02:00
parent 420f00494f
commit 5ac646b3c6
19 changed files with 2933 additions and 10 deletions
@@ -0,0 +1,359 @@
use std::collections::HashMap;
use std::sync::Mutex;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use ulid::Ulid;
use crate::event::{AppEvent, EventBus, PermissionRequest};
use crate::types::SessionId;
use super::rule::{evaluate, Action, Rule, Ruleset};
pub struct AskInput {
pub permission: String,
/// Pattern evaluated against the ruleset stack, and granted on a `Once` reply.
pub pattern: String,
/// Coarser pattern granted (as an `Allow` rule on `session.extra_rules`) on an `Always` reply.
pub always_pattern: String,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionReply {
Once,
Always,
Reject,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AskError {
#[error("permission denied by ruleset")]
Denied,
#[error("permission rejected by user")]
Rejected,
#[error("cancelled")]
Cancelled,
}
#[derive(Debug)]
pub enum AskDecision {
/// Ruleset allowed it outright, or the user replied `Once`.
Allowed,
/// User replied `Always` — caller persists this rule onto `session.extra_rules`.
AllowedAlways(Rule),
}
/// Port of opencode's permission/index.ts ask flow: evaluate the ruleset stack first;
/// only fall through to a blocking, event-published ask when the verdict is `Ask`.
pub struct PermissionService {
bus: EventBus,
pending: Mutex<HashMap<String, oneshot::Sender<PermissionReply>>>,
}
impl PermissionService {
pub fn new(bus: EventBus) -> Self {
Self {
bus,
pending: Mutex::new(HashMap::new()),
}
}
pub async fn ask(
&self,
session_id: &SessionId,
stack: &[&Ruleset],
input: AskInput,
cancel: &CancellationToken,
) -> Result<AskDecision, AskError> {
match evaluate(stack, &input.permission, &input.pattern) {
Action::Allow => Ok(AskDecision::Allowed),
Action::Deny => Err(AskError::Denied),
Action::Ask => self.ask_user(session_id, input, cancel).await,
}
}
/// Bypasses ruleset evaluation entirely — used by the doom-loop guard, which must ask
/// regardless of any `Allow` rule.
pub async fn force_ask(
&self,
session_id: &SessionId,
input: AskInput,
cancel: &CancellationToken,
) -> Result<AskDecision, AskError> {
self.ask_user(session_id, input, cancel).await
}
async fn ask_user(
&self,
session_id: &SessionId,
input: AskInput,
cancel: &CancellationToken,
) -> Result<AskDecision, AskError> {
let id = Ulid::new().to_string();
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(id.clone(), tx);
self.bus.publish(AppEvent::PermissionAsked {
request: PermissionRequest {
id: id.clone(),
session_id: session_id.clone(),
permission: input.permission.clone(),
pattern: input.pattern.clone(),
always_pattern: input.always_pattern.clone(),
metadata: input.metadata.clone(),
},
});
let reply = tokio::select! {
reply = rx => reply.map_err(|_| AskError::Cancelled)?,
_ = cancel.cancelled() => {
self.pending.lock().unwrap().remove(&id);
return Err(AskError::Cancelled);
}
};
self.bus.publish(AppEvent::PermissionResolved { id });
match reply {
PermissionReply::Once => Ok(AskDecision::Allowed),
PermissionReply::Always => Ok(AskDecision::AllowedAlways(Rule {
permission: input.permission,
pattern: input.always_pattern,
action: Action::Allow,
})),
PermissionReply::Reject => Err(AskError::Rejected),
}
}
/// Resolve a pending ask (called by a frontend: TUI keypress, auto-approve stub, ...).
/// Returns `false` if `id` had already been resolved or never existed.
pub fn reply(&self, id: &str, reply: PermissionReply) -> bool {
match self.pending.lock().unwrap().remove(id) {
Some(tx) => tx.send(reply).is_ok(),
None => false,
}
}
}
/// Auto-approves every ask as `Once` — the M1..M2 stand-in for a real TUI permission modal.
pub fn spawn_auto_approve(bus: EventBus, service: std::sync::Arc<PermissionService>) {
let mut rx = bus.subscribe();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
if let AppEvent::PermissionAsked { request } = event {
service.reply(&request.id, PermissionReply::Once);
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(permission: &str, pattern: &str, action: Action) -> Rule {
Rule {
permission: permission.to_string(),
pattern: pattern.to_string(),
action,
}
}
#[tokio::test]
async fn allow_rule_short_circuits_without_asking() {
let service = PermissionService::new(EventBus::new());
let rules: Ruleset = vec![rule("bash", "ls*", Action::Allow)];
let outcome = service
.ask(
&SessionId::new(),
&[&rules],
AskInput {
permission: "bash".into(),
pattern: "ls -la".into(),
always_pattern: "ls *".into(),
metadata: serde_json::Value::Null,
},
&CancellationToken::new(),
)
.await
.unwrap();
assert!(matches!(outcome, AskDecision::Allowed));
}
#[tokio::test]
async fn deny_rule_short_circuits_to_denied_error() {
let service = PermissionService::new(EventBus::new());
let rules: Ruleset = vec![rule("edit", "*.lock", Action::Deny)];
let err = service
.ask(
&SessionId::new(),
&[&rules],
AskInput {
permission: "edit".into(),
pattern: "Cargo.lock".into(),
always_pattern: "Cargo.lock".into(),
metadata: serde_json::Value::Null,
},
&CancellationToken::new(),
)
.await
.unwrap_err();
assert_eq!(err, AskError::Denied);
}
#[tokio::test]
async fn ask_publishes_event_and_waits_for_reply() {
let bus = EventBus::new();
let mut sub = bus.subscribe();
let service = std::sync::Arc::new(PermissionService::new(bus));
let svc = service.clone();
let handle = tokio::spawn(async move {
svc.ask(
&SessionId::new(),
&[],
AskInput {
permission: "bash".into(),
pattern: "rm -rf /".into(),
always_pattern: "rm *".into(),
metadata: serde_json::Value::Null,
},
&CancellationToken::new(),
)
.await
});
let event = sub.recv().await.unwrap();
let id = match event {
AppEvent::PermissionAsked { request } => request.id,
_ => panic!("expected PermissionAsked"),
};
assert!(service.reply(&id, PermissionReply::Once));
let outcome = handle.await.unwrap().unwrap();
assert!(matches!(outcome, AskDecision::Allowed));
}
#[tokio::test]
async fn always_reply_yields_rule_to_persist() {
let bus = EventBus::new();
let mut sub = bus.subscribe();
let service = std::sync::Arc::new(PermissionService::new(bus));
let svc = service.clone();
let handle = tokio::spawn(async move {
svc.ask(
&SessionId::new(),
&[],
AskInput {
permission: "bash".into(),
pattern: "git push origin main".into(),
always_pattern: "git push*".into(),
metadata: serde_json::Value::Null,
},
&CancellationToken::new(),
)
.await
});
let request = match sub.recv().await.unwrap() {
AppEvent::PermissionAsked { request } => request,
_ => panic!("expected PermissionAsked"),
};
service.reply(&request.id, PermissionReply::Always);
let outcome = handle.await.unwrap().unwrap();
match outcome {
AskDecision::AllowedAlways(rule) => {
assert_eq!(rule.permission, "bash");
assert_eq!(rule.pattern, "git push*");
assert_eq!(rule.action, Action::Allow);
}
_ => panic!("expected AllowedAlways"),
}
}
#[tokio::test]
async fn reject_reply_yields_rejected_error() {
let bus = EventBus::new();
let mut sub = bus.subscribe();
let service = std::sync::Arc::new(PermissionService::new(bus));
let svc = service.clone();
let handle = tokio::spawn(async move {
svc.ask(
&SessionId::new(),
&[],
AskInput {
permission: "bash".into(),
pattern: "rm -rf /".into(),
always_pattern: "rm *".into(),
metadata: serde_json::Value::Null,
},
&CancellationToken::new(),
)
.await
});
let request = match sub.recv().await.unwrap() {
AppEvent::PermissionAsked { request } => request,
_ => panic!("expected PermissionAsked"),
};
service.reply(&request.id, PermissionReply::Reject);
let err = handle.await.unwrap().unwrap_err();
assert_eq!(err, AskError::Rejected);
}
#[tokio::test]
async fn cancellation_aborts_a_pending_ask() {
let bus = EventBus::new();
let service = std::sync::Arc::new(PermissionService::new(bus));
let cancel = CancellationToken::new();
let cancel2 = cancel.clone();
let svc = service.clone();
let handle = tokio::spawn(async move {
svc.ask(
&SessionId::new(),
&[],
AskInput {
permission: "bash".into(),
pattern: "rm -rf /".into(),
always_pattern: "rm *".into(),
metadata: serde_json::Value::Null,
},
&cancel2,
)
.await
});
cancel.cancel();
let err = handle.await.unwrap().unwrap_err();
assert_eq!(err, AskError::Cancelled);
}
#[tokio::test]
async fn auto_approve_resolves_asks_as_once() {
let bus = EventBus::new();
let service = std::sync::Arc::new(PermissionService::new(bus.clone()));
spawn_auto_approve(bus, service.clone());
let outcome = service
.ask(
&SessionId::new(),
&[],
AskInput {
permission: "bash".into(),
pattern: "ls".into(),
always_pattern: "ls *".into(),
metadata: serde_json::Value::Null,
},
&CancellationToken::new(),
)
.await
.unwrap();
assert!(matches!(outcome, AskDecision::Allowed));
}
}