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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
branches: ["main", "feat/**", "fix/**", "chore/**"]
pull_request:
branches: ["main"]

env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1

jobs:
test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy

- name: Cache cargo registry & build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-

- name: cargo check
run: cargo check --workspace --all-targets

- name: cargo test
run: cargo test --workspace

- name: cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions sentinel-agent-llm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ reqwest = { workspace = true }
tokio = { workspace = true }
mockall = { workspace = true }
wiremock = { workspace = true }
sentinel-capabilities = { workspace = true }
sentinel-exec = { workspace = true }
sentinel-policy = { workspace = true }
tempfile = { workspace = true }
56 changes: 40 additions & 16 deletions sentinel-agent-llm/src/reasoning_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//! 4. **Act** — each plan step is policy-checked and executed in sequence.
//! Failures may trigger rollback of completed steps.

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;

Expand All @@ -20,7 +21,7 @@ use tracing::{debug, error, info, warn};
use uuid::Uuid;

use sentinel_audit::{AuditEventType, AuditLog};
use sentinel_core::{ApprovalDecision, ExecutionContext, Plan, StepStatus};
use sentinel_core::{ApprovalDecision, Capability, ExecutionContext, Plan, StepStatus};
use sentinel_policy::{PolicyEffect, PolicyEvaluator, PolicyRequest};

use crate::backend::{LlmBackend, Message};
Expand Down Expand Up @@ -81,6 +82,7 @@ pub struct ExecutionSummary {
pub struct ReasoningLoop {
backend: Box<dyn LlmBackend>,
capability_registry: Arc<CapabilityRegistry>,
capability_impls: HashMap<String, Box<dyn Capability>>,
policy_evaluator: Arc<PolicyEvaluator>,
audit_log: Arc<Mutex<AuditLog>>,
config: ReasoningConfig,
Expand All @@ -98,12 +100,27 @@ impl ReasoningLoop {
Self {
backend,
capability_registry,
capability_impls: HashMap::new(),
policy_evaluator,
audit_log,
config,
}
}

/// Register concrete capability implementations for real dispatch.
///
/// Without this, [`invoke_capability`](Self::invoke_capability) falls back
/// to stub results. Supplying real implementations wires the loop to
/// actual capability execution (via the underlying executor).
pub fn with_capabilities(mut self, capabilities: Vec<Box<dyn Capability>>) -> Self {
self.capability_impls = capabilities
.into_iter()
.map(|cap| (cap.manifest().id.clone(), cap))
.collect();
info!(count = self.capability_impls.len(), "capability implementations registered");
self
}

// ── Investigate phase ─────────────────────────────────────────────────────

/// Run the investigation phase.
Expand Down Expand Up @@ -629,8 +646,17 @@ impl ReasoningLoop {
"attempting rollback"
);

// In a real implementation we'd call the capability's inverse().
// Here we mark the step as rolled back and audit it.
// Invoke the capability's inverse to actually undo the effect.
if let Some(cap) = self.capability_impls.get(&capability_id) {
let rb_ctx = ExecutionContext::new(session_id, host);
match cap.invoke_inverse(plan.steps[step_idx].args.clone(), &rb_ctx).await {
Some(sentinel_core::CapabilityResult::Success { .. }) => info!(capability_id = %capability_id, "rollback succeeded"),
Some(sentinel_core::CapabilityResult::Failure { error, .. }) => warn!(capability_id = %capability_id, error = %error, "rollback failed"),
None => info!(capability_id = %capability_id, "capability has no inverse"),
_ => {}
}
}

plan.steps[step_idx].status = StepStatus::RolledBack;
steps_completed -= 1;
steps_rolled_back += 1;
Expand Down Expand Up @@ -692,20 +718,18 @@ impl ReasoningLoop {
&self,
_session_id: Uuid,
capability_id: &str,
_args: &serde_json::Value,
_ctx: &ExecutionContext,
args: &serde_json::Value,
ctx: &ExecutionContext,
) -> Result<sentinel_core::CapabilityResult, AgentError> {
// In a real deployment, this would look up the capability by ID and
// call capability.invoke(args, ctx). For the LLM crate we return a
// stub result so tests can exercise the loop without real capabilities.
debug!(
capability_id = %capability_id,
"stub capability invocation (no capability executor wired up)"
);
Ok(sentinel_core::CapabilityResult::success(serde_json::json!({
"stub": true,
"capability_id": capability_id
})))
if let Some(cap) = self.capability_impls.get(capability_id) {
let result = cap.invoke(args.clone(), ctx).await;
Ok(result)
} else {
debug!(capability_id = %capability_id, "stub invocation — no implementation registered");
Ok(sentinel_core::CapabilityResult::success(serde_json::json!({
"stub": true, "capability_id": capability_id
})))
}
}
}

Expand Down
Loading
Loading