mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
v2 architecture phase 1
This commit is contained in:
Generated
+15
@@ -3531,6 +3531,21 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw_engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw_safety"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [".", "crates/ironclaw_safety"]
|
||||
members = [".", "crates/ironclaw_safety", "crates/ironclaw_engine"]
|
||||
exclude = [
|
||||
"channels-src/discord",
|
||||
"channels-src/telegram",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "ironclaw_engine"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Unified thread-capability-CodeAct execution engine for IronClaw"
|
||||
authors = ["NEAR AI <[email protected]>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "https://github.com/nearai/ironclaw"
|
||||
repository = "https://github.com/nearai/ironclaw"
|
||||
publish = false
|
||||
|
||||
[package.metadata.dist]
|
||||
dist = false
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["sync", "time", "macros", "rt"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Capability management.
|
||||
//!
|
||||
//! Registry, lease management, and deterministic policy engine.
|
||||
//! Implemented in Phase 2.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Step execution.
|
||||
//!
|
||||
//! The core execution loop that replaces `run_agentic_loop()`, plus
|
||||
//! tier-specific executors (structured tool calls, embedded scripting, etc.).
|
||||
//! Implemented in Phase 2.
|
||||
@@ -0,0 +1,46 @@
|
||||
//! IronClaw Engine — unified thread-capability-CodeAct execution model.
|
||||
//!
|
||||
//! This crate provides the core execution engine for IronClaw, unifying
|
||||
//! ~10 separate abstractions (Session, Job, Routine, Channel, Tool, Skill,
|
||||
//! Hook, Observer, Extension, LoopDelegate) around 5 primitives:
|
||||
//!
|
||||
//! - **Thread** — unit of work (replaces Session + Job + Routine + Sub-agent)
|
||||
//! - **Step** — unit of execution (replaces agentic loop iteration + tool calls)
|
||||
//! - **Capability** — unit of effect (replaces Tool + Skill + Hook + Extension)
|
||||
//! - **MemoryDoc** — unit of durable knowledge (replaces workspace memory blobs)
|
||||
//! - **Project** — unit of context (replaces flat workspace namespace)
|
||||
//!
|
||||
//! The engine defines traits for external dependencies ([`LlmBackend`],
|
||||
//! [`Store`], [`EffectExecutor`]) that the host crate implements via bridge
|
||||
//! adapters over existing infrastructure.
|
||||
|
||||
pub mod capability;
|
||||
pub mod executor;
|
||||
pub mod memory;
|
||||
pub mod reflection;
|
||||
pub mod runtime;
|
||||
pub mod traits;
|
||||
pub mod types;
|
||||
|
||||
// ── Re-exports: types ───────────────────────────────────────
|
||||
|
||||
pub use types::capability::{
|
||||
ActionDef, Capability, CapabilityLease, EffectType, LeaseId, PolicyCondition, PolicyEffect,
|
||||
PolicyRule,
|
||||
};
|
||||
pub use types::error::{CapabilityError, EngineError, StepError, ThreadError};
|
||||
pub use types::event::{EventId, EventKind, ThreadEvent};
|
||||
pub use types::memory::{DocId, DocType, MemoryDoc};
|
||||
pub use types::message::{MessageRole, ThreadMessage};
|
||||
pub use types::project::{Project, ProjectId};
|
||||
pub use types::provenance::Provenance;
|
||||
pub use types::step::{
|
||||
ActionCall, ActionResult, ExecutionTier, LlmResponse, Step, StepId, StepStatus, TokenUsage,
|
||||
};
|
||||
pub use types::thread::{Thread, ThreadConfig, ThreadId, ThreadState, ThreadType};
|
||||
|
||||
// ── Re-exports: traits ──────────────────────────────────────
|
||||
|
||||
pub use traits::effect::{EffectExecutor, ThreadExecutionContext};
|
||||
pub use traits::llm::{LlmBackend, LlmCallConfig, LlmOutput};
|
||||
pub use traits::store::Store;
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Memory document system.
|
||||
//!
|
||||
//! Project-scoped document storage and retrieval engine for building
|
||||
//! thread context from durable knowledge.
|
||||
//! Implemented in Phase 2 (store) and Phase 4 (retrieval).
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Post-thread reflection pipeline.
|
||||
//!
|
||||
//! After a thread completes, the reflection pipeline produces structured
|
||||
//! knowledge (summaries, lessons, playbooks, issue docs) from the thread's
|
||||
//! execution trace. Implemented in Phase 4.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Thread lifecycle management.
|
||||
//!
|
||||
//! ThreadManager, thread tree, and inter-thread messaging.
|
||||
//! Implemented in Phase 2.
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Effect executor trait.
|
||||
//!
|
||||
//! The engine delegates actual action execution to the host through this
|
||||
//! trait. The main crate implements it by wrapping `ToolRegistry` and
|
||||
//! `SafetyLayer` — the engine itself has no knowledge of specific tools.
|
||||
|
||||
use crate::types::capability::{ActionDef, CapabilityLease};
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::project::ProjectId;
|
||||
use crate::types::step::{ActionResult, StepId};
|
||||
use crate::types::thread::{ThreadId, ThreadType};
|
||||
|
||||
/// Contextual information about the thread requesting an effect.
|
||||
///
|
||||
/// Passed to the executor so it can make context-dependent decisions
|
||||
/// (e.g. different tool behavior in background vs foreground threads).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreadExecutionContext {
|
||||
pub thread_id: ThreadId,
|
||||
pub thread_type: ThreadType,
|
||||
pub project_id: ProjectId,
|
||||
pub user_id: String,
|
||||
pub step_id: StepId,
|
||||
}
|
||||
|
||||
/// Abstraction over capability action execution.
|
||||
///
|
||||
/// The main crate implements this by wrapping its `ToolRegistry`, `SafetyLayer`,
|
||||
/// and tool execution pipeline. The engine calls `execute_action` and gets back
|
||||
/// a result — all safety, sanitization, and actual tool invocation happens in
|
||||
/// the host.
|
||||
#[async_trait::async_trait]
|
||||
pub trait EffectExecutor: Send + Sync {
|
||||
/// Execute a capability action.
|
||||
///
|
||||
/// The executor is responsible for:
|
||||
/// 1. Looking up the actual tool implementation
|
||||
/// 2. Validating parameters
|
||||
/// 3. Applying safety checks (sanitization, leak detection)
|
||||
/// 4. Executing the tool
|
||||
/// 5. Returning the result
|
||||
async fn execute_action(
|
||||
&self,
|
||||
action_name: &str,
|
||||
parameters: serde_json::Value,
|
||||
lease: &CapabilityLease,
|
||||
context: &ThreadExecutionContext,
|
||||
) -> Result<ActionResult, EngineError>;
|
||||
|
||||
/// List available actions given the current set of active leases.
|
||||
///
|
||||
/// Used to build the action definitions sent to the LLM.
|
||||
async fn available_actions(
|
||||
&self,
|
||||
leases: &[CapabilityLease],
|
||||
) -> Result<Vec<ActionDef>, EngineError>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! LLM backend trait.
|
||||
//!
|
||||
//! The engine's abstraction over language model providers. Deliberately
|
||||
//! simpler than the main crate's `LlmProvider` — the engine only needs
|
||||
//! to make completion calls. Cost tracking, caching, retry, and circuit
|
||||
//! breaking are host concerns handled by the bridge adapter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::types::capability::ActionDef;
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::message::ThreadMessage;
|
||||
use crate::types::step::{LlmResponse, TokenUsage};
|
||||
|
||||
/// Configuration for a single LLM call.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LlmCallConfig {
|
||||
/// Maximum tokens to generate.
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Sampling temperature.
|
||||
pub temperature: Option<f32>,
|
||||
/// When true, the LLM should not return action calls.
|
||||
pub force_text: bool,
|
||||
/// Opaque metadata forwarded to the LLM provider.
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Output from a single LLM call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LlmOutput {
|
||||
pub response: LlmResponse,
|
||||
pub usage: TokenUsage,
|
||||
}
|
||||
|
||||
/// Abstraction over language model providers.
|
||||
///
|
||||
/// The main crate implements this by wrapping its `LlmProvider` trait,
|
||||
/// converting between `ThreadMessage` and `ChatMessage`.
|
||||
#[async_trait::async_trait]
|
||||
pub trait LlmBackend: Send + Sync {
|
||||
/// Call the LLM with conversation messages and available action definitions.
|
||||
///
|
||||
/// Returns either a text response or a set of action calls.
|
||||
async fn complete(
|
||||
&self,
|
||||
messages: &[ThreadMessage],
|
||||
actions: &[ActionDef],
|
||||
config: &LlmCallConfig,
|
||||
) -> Result<LlmOutput, EngineError>;
|
||||
|
||||
/// The model identifier (e.g. "gpt-4", "claude-opus-4-20250514").
|
||||
fn model_name(&self) -> &str;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! External dependency traits.
|
||||
//!
|
||||
//! The engine defines these traits; the host (main ironclaw crate)
|
||||
//! implements them via bridge adapters over existing infrastructure.
|
||||
|
||||
pub mod effect;
|
||||
pub mod llm;
|
||||
pub mod store;
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Storage trait for engine persistence.
|
||||
//!
|
||||
//! Defines CRUD operations for all engine types. The main crate implements
|
||||
//! this by wrapping its dual-backend `Database` trait (PostgreSQL + libSQL).
|
||||
|
||||
use crate::types::capability::{CapabilityLease, LeaseId};
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::event::ThreadEvent;
|
||||
use crate::types::memory::{DocId, MemoryDoc};
|
||||
use crate::types::project::{Project, ProjectId};
|
||||
use crate::types::step::Step;
|
||||
use crate::types::thread::{Thread, ThreadId, ThreadState};
|
||||
|
||||
/// Persistence abstraction for the engine.
|
||||
#[async_trait::async_trait]
|
||||
pub trait Store: Send + Sync {
|
||||
// ── Thread operations ───────────────────────────────────
|
||||
|
||||
async fn save_thread(&self, thread: &Thread) -> Result<(), EngineError>;
|
||||
async fn load_thread(&self, id: ThreadId) -> Result<Option<Thread>, EngineError>;
|
||||
async fn list_threads(&self, project_id: ProjectId) -> Result<Vec<Thread>, EngineError>;
|
||||
async fn update_thread_state(
|
||||
&self,
|
||||
id: ThreadId,
|
||||
state: ThreadState,
|
||||
) -> Result<(), EngineError>;
|
||||
|
||||
// ── Step operations ─────────────────────────────────────
|
||||
|
||||
async fn save_step(&self, step: &Step) -> Result<(), EngineError>;
|
||||
async fn load_steps(&self, thread_id: ThreadId) -> Result<Vec<Step>, EngineError>;
|
||||
|
||||
// ── Event operations ────────────────────────────────────
|
||||
|
||||
async fn append_events(&self, events: &[ThreadEvent]) -> Result<(), EngineError>;
|
||||
async fn load_events(&self, thread_id: ThreadId) -> Result<Vec<ThreadEvent>, EngineError>;
|
||||
|
||||
// ── Project operations ──────────────────────────────────
|
||||
|
||||
async fn save_project(&self, project: &Project) -> Result<(), EngineError>;
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Option<Project>, EngineError>;
|
||||
|
||||
// ── Memory doc operations ───────────────────────────────
|
||||
|
||||
async fn save_memory_doc(&self, doc: &MemoryDoc) -> Result<(), EngineError>;
|
||||
async fn load_memory_doc(&self, id: DocId) -> Result<Option<MemoryDoc>, EngineError>;
|
||||
async fn list_memory_docs(&self, project_id: ProjectId) -> Result<Vec<MemoryDoc>, EngineError>;
|
||||
|
||||
// ── Capability lease operations ─────────────────────────
|
||||
|
||||
async fn save_lease(&self, lease: &CapabilityLease) -> Result<(), EngineError>;
|
||||
async fn load_active_leases(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<Vec<CapabilityLease>, EngineError>;
|
||||
async fn revoke_lease(&self, lease_id: LeaseId, reason: &str) -> Result<(), EngineError>;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Capability — the unit of effect.
|
||||
//!
|
||||
//! A capability bundles actions (tools), knowledge (skills), and policies
|
||||
//! (hooks) into a single installable/activatable unit. Capabilities are
|
||||
//! granted to threads via leases.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::thread::ThreadId;
|
||||
|
||||
/// Strongly-typed lease identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct LeaseId(pub Uuid);
|
||||
|
||||
impl LeaseId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LeaseId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Effect types ────────────────────────────────────────────
|
||||
|
||||
/// Classification of side effects that an action may produce.
|
||||
/// Used by the policy engine for allow/deny decisions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum EffectType {
|
||||
/// Read from local filesystem or workspace.
|
||||
ReadLocal,
|
||||
/// Read from external APIs (no mutation).
|
||||
ReadExternal,
|
||||
/// Write to local filesystem or workspace.
|
||||
WriteLocal,
|
||||
/// Write to external services (create PR, send email).
|
||||
WriteExternal,
|
||||
/// Authenticated API call requiring credentials.
|
||||
CredentialedNetwork,
|
||||
/// Code execution or shell access.
|
||||
Compute,
|
||||
/// Financial operations (payments, transfers).
|
||||
Financial,
|
||||
}
|
||||
|
||||
// ── Action definition ───────────────────────────────────────
|
||||
|
||||
/// Definition of a single action within a capability.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionDef {
|
||||
/// Action name (e.g. "create_issue", "web_fetch").
|
||||
pub name: String,
|
||||
/// Human-readable description.
|
||||
pub description: String,
|
||||
/// JSON Schema for parameters.
|
||||
pub parameters_schema: serde_json::Value,
|
||||
/// Effect types this action may produce.
|
||||
pub effects: Vec<EffectType>,
|
||||
/// Whether this action requires user approval before execution.
|
||||
pub requires_approval: bool,
|
||||
}
|
||||
|
||||
// ── Capability ──────────────────────────────────────────────
|
||||
|
||||
/// A capability — bundles actions, knowledge, and policies.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Capability {
|
||||
/// Capability name (e.g. "github", "deployment").
|
||||
pub name: String,
|
||||
/// Human-readable description.
|
||||
pub description: String,
|
||||
/// Executable actions (replaces tools).
|
||||
pub actions: Vec<ActionDef>,
|
||||
/// Domain knowledge blocks (replaces skills).
|
||||
pub knowledge: Vec<String>,
|
||||
/// Policy rules (replaces hooks).
|
||||
pub policies: Vec<PolicyRule>,
|
||||
}
|
||||
|
||||
// ── Policy ──────────────────────────────────────────────────
|
||||
|
||||
/// A named policy rule within a capability.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyRule {
|
||||
pub name: String,
|
||||
pub condition: PolicyCondition,
|
||||
pub effect: PolicyEffect,
|
||||
}
|
||||
|
||||
/// When a policy rule applies.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PolicyCondition {
|
||||
/// Always applies.
|
||||
Always,
|
||||
/// Applies when the action name matches the pattern.
|
||||
ActionMatches { pattern: String },
|
||||
/// Applies when the action has a specific effect type.
|
||||
EffectTypeIs(EffectType),
|
||||
}
|
||||
|
||||
/// What the policy engine decides.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PolicyEffect {
|
||||
Allow,
|
||||
Deny,
|
||||
RequireApproval,
|
||||
}
|
||||
|
||||
// ── Capability lease ────────────────────────────────────────
|
||||
|
||||
/// A time/use-limited grant of capability access to a thread.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapabilityLease {
|
||||
pub id: LeaseId,
|
||||
/// The thread this lease is granted to.
|
||||
pub thread_id: ThreadId,
|
||||
/// Which capability this lease covers.
|
||||
pub capability_name: String,
|
||||
/// Which actions from the capability are granted (empty = all).
|
||||
pub granted_actions: Vec<String>,
|
||||
/// When the lease was granted.
|
||||
pub granted_at: DateTime<Utc>,
|
||||
/// When the lease expires (None = no expiry).
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
/// Maximum number of action invocations (None = unlimited).
|
||||
pub max_uses: Option<u32>,
|
||||
/// Remaining invocations (None = unlimited).
|
||||
pub uses_remaining: Option<u32>,
|
||||
/// Whether the lease has been explicitly revoked.
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
impl CapabilityLease {
|
||||
/// Check whether this lease is currently valid.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
if self.revoked {
|
||||
return false;
|
||||
}
|
||||
if let Some(expires_at) = self.expires_at
|
||||
&& Utc::now() >= expires_at
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(remaining) = self.uses_remaining
|
||||
&& remaining == 0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Check whether a specific action is covered by this lease.
|
||||
pub fn covers_action(&self, action_name: &str) -> bool {
|
||||
self.granted_actions.is_empty() || self.granted_actions.iter().any(|a| a == action_name)
|
||||
}
|
||||
|
||||
/// Consume one use of this lease. Returns false if no uses remain.
|
||||
pub fn consume_use(&mut self) -> bool {
|
||||
if let Some(ref mut remaining) = self.uses_remaining {
|
||||
if *remaining == 0 {
|
||||
return false;
|
||||
}
|
||||
*remaining -= 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_lease() -> CapabilityLease {
|
||||
CapabilityLease {
|
||||
id: LeaseId::new(),
|
||||
thread_id: ThreadId::new(),
|
||||
capability_name: "test".into(),
|
||||
granted_actions: vec![],
|
||||
granted_at: Utc::now(),
|
||||
expires_at: None,
|
||||
max_uses: None,
|
||||
uses_remaining: None,
|
||||
revoked: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_lease() {
|
||||
let lease = make_lease();
|
||||
assert!(lease.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_lease_is_invalid() {
|
||||
let mut lease = make_lease();
|
||||
lease.revoked = true;
|
||||
assert!(!lease.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_lease_is_invalid() {
|
||||
let mut lease = make_lease();
|
||||
lease.expires_at = Some(Utc::now() - chrono::Duration::seconds(10));
|
||||
assert!(!lease.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_lease_is_invalid() {
|
||||
let mut lease = make_lease();
|
||||
lease.max_uses = Some(1);
|
||||
lease.uses_remaining = Some(0);
|
||||
assert!(!lease.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consume_use_decrements() {
|
||||
let mut lease = make_lease();
|
||||
lease.max_uses = Some(2);
|
||||
lease.uses_remaining = Some(2);
|
||||
assert!(lease.consume_use());
|
||||
assert_eq!(lease.uses_remaining, Some(1));
|
||||
assert!(lease.consume_use());
|
||||
assert_eq!(lease.uses_remaining, Some(0));
|
||||
assert!(!lease.consume_use());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlimited_consume_always_succeeds() {
|
||||
let mut lease = make_lease();
|
||||
for _ in 0..100 {
|
||||
assert!(lease.consume_use());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_action_empty_grants_all() {
|
||||
let lease = make_lease();
|
||||
assert!(lease.covers_action("anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_action_with_specific_grants() {
|
||||
let mut lease = make_lease();
|
||||
lease.granted_actions = vec!["create_issue".into(), "list_prs".into()];
|
||||
assert!(lease.covers_action("create_issue"));
|
||||
assert!(lease.covers_action("list_prs"));
|
||||
assert!(!lease.covers_action("delete_repo"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Engine error types.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::types::capability::EffectType;
|
||||
use crate::types::thread::{ThreadId, ThreadState};
|
||||
|
||||
/// Top-level engine error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("thread error: {0}")]
|
||||
Thread(#[from] ThreadError),
|
||||
|
||||
#[error("step error: {0}")]
|
||||
Step(#[from] StepError),
|
||||
|
||||
#[error("capability error: {0}")]
|
||||
Capability(#[from] CapabilityError),
|
||||
|
||||
#[error("store error: {reason}")]
|
||||
Store { reason: String },
|
||||
|
||||
#[error("LLM error: {reason}")]
|
||||
Llm { reason: String },
|
||||
|
||||
#[error("effect execution error: {reason}")]
|
||||
Effect { reason: String },
|
||||
|
||||
#[error("invalid state transition: {from} -> {to}")]
|
||||
InvalidTransition { from: ThreadState, to: ThreadState },
|
||||
|
||||
#[error("thread not found: {0}")]
|
||||
ThreadNotFound(ThreadId),
|
||||
|
||||
#[error("project not found: {0}")]
|
||||
ProjectNotFound(ProjectId),
|
||||
|
||||
#[error("lease expired for capability: {capability_name}")]
|
||||
LeaseExpired { capability_name: String },
|
||||
|
||||
#[error("lease denied: {reason}")]
|
||||
LeaseDenied { reason: String },
|
||||
|
||||
#[error("max iterations reached: {limit}")]
|
||||
MaxIterations { limit: usize },
|
||||
}
|
||||
|
||||
use crate::types::project::ProjectId;
|
||||
|
||||
/// Thread-specific errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ThreadError {
|
||||
#[error("thread already running: {0}")]
|
||||
AlreadyRunning(ThreadId),
|
||||
|
||||
#[error("thread is in terminal state: {0}")]
|
||||
Terminal(ThreadState),
|
||||
|
||||
#[error("cannot spawn child: parent thread {0} is not running")]
|
||||
ParentNotRunning(ThreadId),
|
||||
}
|
||||
|
||||
/// Step-specific errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StepError {
|
||||
#[error("step timed out after {0:?}")]
|
||||
Timeout(std::time::Duration),
|
||||
|
||||
#[error("action not permitted by capability lease: {action}")]
|
||||
ActionDenied { action: String },
|
||||
}
|
||||
|
||||
/// Capability-specific errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CapabilityError {
|
||||
#[error("capability not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("effect type {effect:?} not permitted by policy")]
|
||||
EffectDenied { effect: EffectType },
|
||||
}
|
||||
|
||||
// Display impls for types used in error messages that don't already impl Display.
|
||||
|
||||
impl fmt::Display for ThreadId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ThreadState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProjectId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Event sourcing types.
|
||||
//!
|
||||
//! Every significant action within a thread is recorded as an event.
|
||||
//! This enables replay, debugging, reflection, and trace-based testing.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::capability::LeaseId;
|
||||
use crate::types::step::{StepId, TokenUsage};
|
||||
use crate::types::thread::{ThreadId, ThreadState};
|
||||
|
||||
/// Strongly-typed event identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct EventId(pub Uuid);
|
||||
|
||||
impl EventId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A recorded event in a thread's execution history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadEvent {
|
||||
pub id: EventId,
|
||||
pub thread_id: ThreadId,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
impl ThreadEvent {
|
||||
pub fn new(thread_id: ThreadId, kind: EventKind) -> Self {
|
||||
Self {
|
||||
id: EventId::new(),
|
||||
thread_id,
|
||||
timestamp: Utc::now(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The specific kind of event that occurred.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EventKind {
|
||||
// ── Thread lifecycle ────────────────────────────────────
|
||||
StateChanged {
|
||||
from: ThreadState,
|
||||
to: ThreadState,
|
||||
reason: Option<String>,
|
||||
},
|
||||
|
||||
// ── Step lifecycle ──────────────────────────────────────
|
||||
StepStarted {
|
||||
step_id: StepId,
|
||||
},
|
||||
StepCompleted {
|
||||
step_id: StepId,
|
||||
tokens: TokenUsage,
|
||||
},
|
||||
StepFailed {
|
||||
step_id: StepId,
|
||||
error: String,
|
||||
},
|
||||
|
||||
// ── Action execution ────────────────────────────────────
|
||||
ActionExecuted {
|
||||
step_id: StepId,
|
||||
action_name: String,
|
||||
call_id: String,
|
||||
duration_ms: u64,
|
||||
},
|
||||
ActionFailed {
|
||||
step_id: StepId,
|
||||
action_name: String,
|
||||
call_id: String,
|
||||
error: String,
|
||||
},
|
||||
|
||||
// ── Capability leases ───────────────────────────────────
|
||||
LeaseGranted {
|
||||
lease_id: LeaseId,
|
||||
capability_name: String,
|
||||
},
|
||||
LeaseRevoked {
|
||||
lease_id: LeaseId,
|
||||
reason: String,
|
||||
},
|
||||
LeaseExpired {
|
||||
lease_id: LeaseId,
|
||||
},
|
||||
|
||||
// ── Messages ────────────────────────────────────────────
|
||||
MessageAdded {
|
||||
role: String,
|
||||
content_preview: String,
|
||||
},
|
||||
|
||||
// ── Thread tree ─────────────────────────────────────────
|
||||
ChildSpawned {
|
||||
child_id: ThreadId,
|
||||
goal: String,
|
||||
},
|
||||
ChildCompleted {
|
||||
child_id: ThreadId,
|
||||
},
|
||||
|
||||
// ── Approval flow ───────────────────────────────────────
|
||||
ApprovalRequested {
|
||||
action_name: String,
|
||||
call_id: String,
|
||||
},
|
||||
ApprovalReceived {
|
||||
call_id: String,
|
||||
approved: bool,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Memory documents — the unit of durable knowledge.
|
||||
//!
|
||||
//! Memory docs are structured knowledge produced by reflection on completed
|
||||
//! threads. They are project-scoped and used for context building (retrieval,
|
||||
//! not replay of raw history).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::project::ProjectId;
|
||||
use crate::types::thread::ThreadId;
|
||||
|
||||
/// Strongly-typed document identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct DocId(pub Uuid);
|
||||
|
||||
impl DocId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DocId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of knowledge a memory document captures.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DocType {
|
||||
/// What a thread accomplished.
|
||||
Summary,
|
||||
/// Durable learning from experience.
|
||||
Lesson,
|
||||
/// Reusable multi-step procedure.
|
||||
Playbook,
|
||||
/// Detected problem for follow-up.
|
||||
Issue,
|
||||
/// Missing capability request.
|
||||
Spec,
|
||||
/// Working memory / scratch notes.
|
||||
Note,
|
||||
}
|
||||
|
||||
/// A memory document — structured durable knowledge.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryDoc {
|
||||
pub id: DocId,
|
||||
pub project_id: ProjectId,
|
||||
pub doc_type: DocType,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub source_thread_id: Option<ThreadId>,
|
||||
pub tags: Vec<String>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl MemoryDoc {
|
||||
pub fn new(
|
||||
project_id: ProjectId,
|
||||
doc_type: DocType,
|
||||
title: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: DocId::new(),
|
||||
project_id,
|
||||
doc_type,
|
||||
title: title.into(),
|
||||
content: content.into(),
|
||||
source_thread_id: None,
|
||||
tags: Vec::new(),
|
||||
metadata: serde_json::Value::Object(serde_json::Map::new()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_source_thread(mut self, thread_id: ThreadId) -> Self {
|
||||
self.source_thread_id = Some(thread_id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
|
||||
self.tags = tags;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Thread messages — the engine's own message type.
|
||||
//!
|
||||
//! Simpler than the main crate's `ChatMessage`. Bridge adapters handle
|
||||
//! conversion between `ThreadMessage` and `ChatMessage`.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::provenance::Provenance;
|
||||
use crate::types::step::ActionCall;
|
||||
|
||||
/// Role of a message participant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
/// Result from a capability action (replaces "Tool" role).
|
||||
ActionResult,
|
||||
}
|
||||
|
||||
/// A message in a thread's conversation history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadMessage {
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
pub provenance: Provenance,
|
||||
/// For ActionResult messages: the call ID this is responding to.
|
||||
pub action_call_id: Option<String>,
|
||||
/// For ActionResult messages: the action name.
|
||||
pub action_name: Option<String>,
|
||||
/// For Assistant messages: actions the LLM wants to execute.
|
||||
pub action_calls: Option<Vec<ActionCall>>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ThreadMessage {
|
||||
/// Create a system message.
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::System,
|
||||
content: content.into(),
|
||||
provenance: Provenance::System,
|
||||
action_call_id: None,
|
||||
action_name: None,
|
||||
action_calls: None,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message.
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::User,
|
||||
content: content.into(),
|
||||
provenance: Provenance::User,
|
||||
action_call_id: None,
|
||||
action_name: None,
|
||||
action_calls: None,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant text message.
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::Assistant,
|
||||
content: content.into(),
|
||||
provenance: Provenance::LlmGenerated,
|
||||
action_call_id: None,
|
||||
action_name: None,
|
||||
action_calls: None,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message with action calls.
|
||||
pub fn assistant_with_actions(
|
||||
content: Option<String>,
|
||||
calls: Vec<ActionCall>,
|
||||
) -> Self {
|
||||
Self {
|
||||
role: MessageRole::Assistant,
|
||||
content: content.unwrap_or_default(),
|
||||
provenance: Provenance::LlmGenerated,
|
||||
action_call_id: None,
|
||||
action_name: None,
|
||||
action_calls: Some(calls),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an action result message.
|
||||
pub fn action_result(
|
||||
call_id: impl Into<String>,
|
||||
action_name: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
let name: String = action_name.into();
|
||||
Self {
|
||||
role: MessageRole::ActionResult,
|
||||
content: content.into(),
|
||||
provenance: Provenance::ToolOutput {
|
||||
action_name: name.clone(),
|
||||
},
|
||||
action_call_id: Some(call_id.into()),
|
||||
action_name: Some(name),
|
||||
action_calls: None,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Core type definitions for the engine.
|
||||
//!
|
||||
//! All data structures live here. No async, no I/O — just types and
|
||||
//! validation logic.
|
||||
|
||||
pub mod capability;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod memory;
|
||||
pub mod message;
|
||||
pub mod project;
|
||||
pub mod provenance;
|
||||
pub mod step;
|
||||
pub mod thread;
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Project — the unit of context.
|
||||
//!
|
||||
//! A project is a persistent domain of work that scopes memory documents,
|
||||
//! threads, and missions. Examples: "IronClaw architecture", "deployment system".
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Strongly-typed project identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ProjectId(pub Uuid);
|
||||
|
||||
impl ProjectId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProjectId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A project — the unit of context scoping.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: ProjectId,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: ProjectId::new(),
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
metadata: serde_json::Value::Object(serde_json::Map::new()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Provenance tracking for data flow analysis.
|
||||
//!
|
||||
//! Every data value can be tagged with its origin. The policy engine uses
|
||||
//! provenance at effect boundaries to enforce taint-based security rules.
|
||||
//! Phase 1: types only; enforcement comes in Phase 4.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::memory::DocId;
|
||||
use crate::types::thread::ThreadId;
|
||||
|
||||
/// The origin of a piece of data.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub enum Provenance {
|
||||
/// Direct user input.
|
||||
User,
|
||||
/// System prompt, configuration.
|
||||
#[default]
|
||||
System,
|
||||
/// Result from a capability action.
|
||||
ToolOutput { action_name: String },
|
||||
/// Generated by the LLM.
|
||||
LlmGenerated,
|
||||
/// Produced by the reflection pipeline.
|
||||
Reflection { source_thread_id: ThreadId },
|
||||
/// Retrieved from project memory.
|
||||
MemoryRetrieval { doc_id: DocId },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Step — the unit of execution within a thread.
|
||||
//!
|
||||
//! Each step corresponds to one LLM call plus its subsequent action
|
||||
//! executions. This replaces the implicit "iteration" counter in the
|
||||
//! existing `run_agentic_loop`.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::thread::ThreadId;
|
||||
|
||||
/// Strongly-typed step identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct StepId(pub Uuid);
|
||||
|
||||
impl StepId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StepId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a step within its lifecycle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum StepStatus {
|
||||
Pending,
|
||||
LlmCalling,
|
||||
Executing,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Which execution tier handles the step's code/actions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExecutionTier {
|
||||
/// Tier 0: structured tool calls (MVP).
|
||||
Structured,
|
||||
// Future tiers:
|
||||
// Scripting, // Tier 1: embedded Starlark/Rhai
|
||||
// Wasm, // Tier 2: WASM sandbox
|
||||
// Container, // Tier 3: Docker container
|
||||
}
|
||||
|
||||
/// A single execution step within a thread.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Step {
|
||||
pub id: StepId,
|
||||
pub thread_id: ThreadId,
|
||||
/// 1-indexed sequence within the thread.
|
||||
pub sequence: usize,
|
||||
pub status: StepStatus,
|
||||
pub tier: ExecutionTier,
|
||||
pub llm_response: Option<LlmResponse>,
|
||||
pub action_results: Vec<ActionResult>,
|
||||
pub tokens_used: TokenUsage,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Step {
|
||||
pub fn new(thread_id: ThreadId, sequence: usize) -> Self {
|
||||
Self {
|
||||
id: StepId::new(),
|
||||
thread_id,
|
||||
sequence,
|
||||
status: StepStatus::Pending,
|
||||
tier: ExecutionTier::Structured,
|
||||
llm_response: None,
|
||||
action_results: Vec::new(),
|
||||
tokens_used: TokenUsage::default(),
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLM response types ─────────────────────────────────────
|
||||
|
||||
/// Response from the LLM: either text or action calls.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum LlmResponse {
|
||||
/// Final text response.
|
||||
Text(String),
|
||||
/// One or more action calls (with optional reasoning text).
|
||||
ActionCalls {
|
||||
calls: Vec<ActionCall>,
|
||||
content: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A request from the LLM to execute a capability action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionCall {
|
||||
/// Unique call identifier (echoed in the result).
|
||||
pub id: String,
|
||||
/// Action name (e.g. "web_fetch", "create_issue").
|
||||
pub action_name: String,
|
||||
/// Action parameters as JSON.
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Result of executing a capability action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionResult {
|
||||
/// The call ID this result corresponds to.
|
||||
pub call_id: String,
|
||||
/// The action that was executed.
|
||||
pub action_name: String,
|
||||
/// Output value.
|
||||
pub output: serde_json::Value,
|
||||
/// Whether this result represents an error.
|
||||
pub is_error: bool,
|
||||
/// How long the action took.
|
||||
#[serde(with = "duration_millis")]
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
/// Token usage for a single LLM call.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub cache_write_tokens: u64,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
pub fn total(&self) -> u64 {
|
||||
self.input_tokens + self.output_tokens
|
||||
}
|
||||
}
|
||||
|
||||
/// Serde helper for Duration as milliseconds.
|
||||
mod duration_millis {
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_u64(d.as_millis() as u64)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
|
||||
let millis = u64::deserialize(d)?;
|
||||
Ok(Duration::from_millis(millis))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Thread — the unit of work.
|
||||
//!
|
||||
//! A thread is a bounded task or investigation. It unifies the concepts of
|
||||
//! Session (interactive conversation), Job (background work), Routine
|
||||
//! (scheduled execution), and Sub-agent (delegated reasoning) into a single
|
||||
//! abstraction with a shared state machine.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::capability::LeaseId;
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::event::{EventKind, ThreadEvent};
|
||||
use crate::types::message::ThreadMessage;
|
||||
use crate::types::project::ProjectId;
|
||||
|
||||
/// Strongly-typed thread identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ThreadId(pub Uuid);
|
||||
|
||||
impl ThreadId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThreadId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── State machine ───────────────────────────────────────────
|
||||
|
||||
/// Thread lifecycle state.
|
||||
///
|
||||
/// ```text
|
||||
/// Created → Running → Waiting → Running (resume)
|
||||
/// → Suspended → Running (resume)
|
||||
/// → Completed → Reflecting → Done
|
||||
/// → Failed
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ThreadState {
|
||||
/// Thread has been created but not yet started.
|
||||
Created,
|
||||
/// Thread is actively executing steps.
|
||||
Running,
|
||||
/// Waiting for external input (user approval, child completion).
|
||||
Waiting,
|
||||
/// Paused by system (resource pressure, priority preemption).
|
||||
Suspended,
|
||||
/// Execution finished successfully, may undergo reflection.
|
||||
Completed,
|
||||
/// Post-completion reflection is running.
|
||||
Reflecting,
|
||||
/// Fully finished (terminal).
|
||||
Done,
|
||||
/// Terminal failure.
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ThreadState {
|
||||
/// Check whether a transition to `target` is valid.
|
||||
pub fn can_transition_to(self, target: Self) -> bool {
|
||||
matches!(
|
||||
(self, target),
|
||||
// From Created
|
||||
(Self::Created, Self::Running)
|
||||
| (Self::Created, Self::Failed)
|
||||
// From Running
|
||||
| (Self::Running, Self::Waiting)
|
||||
| (Self::Running, Self::Suspended)
|
||||
| (Self::Running, Self::Completed)
|
||||
| (Self::Running, Self::Failed)
|
||||
// From Waiting
|
||||
| (Self::Waiting, Self::Running)
|
||||
| (Self::Waiting, Self::Failed)
|
||||
// From Suspended
|
||||
| (Self::Suspended, Self::Running)
|
||||
| (Self::Suspended, Self::Failed)
|
||||
// From Completed
|
||||
| (Self::Completed, Self::Reflecting)
|
||||
| (Self::Completed, Self::Done)
|
||||
// From Reflecting
|
||||
| (Self::Reflecting, Self::Done)
|
||||
| (Self::Reflecting, Self::Failed)
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this state is terminal (no further transitions possible).
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::Done | Self::Failed)
|
||||
}
|
||||
|
||||
/// Whether this state represents active work.
|
||||
pub fn is_active(self) -> bool {
|
||||
matches!(self, Self::Running | Self::Waiting)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thread type ─────────────────────────────────────────────
|
||||
|
||||
/// The nature of the work a thread performs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ThreadType {
|
||||
/// Interactive conversation with a user.
|
||||
Foreground,
|
||||
/// Background research or sub-task.
|
||||
Research,
|
||||
/// Long-running goal that spawns threads over time.
|
||||
Mission,
|
||||
/// Post-completion analysis of another thread.
|
||||
Reflection,
|
||||
}
|
||||
|
||||
// ── Thread configuration ────────────────────────────────────
|
||||
|
||||
/// Execution parameters for a thread.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadConfig {
|
||||
/// Maximum number of LLM call iterations.
|
||||
pub max_iterations: usize,
|
||||
/// Maximum wall-clock duration for the thread.
|
||||
pub max_duration: Option<std::time::Duration>,
|
||||
/// Whether to run reflection after completion.
|
||||
pub enable_reflection: bool,
|
||||
/// Whether to detect and nudge on tool intent without action calls.
|
||||
pub enable_tool_intent_nudge: bool,
|
||||
/// Maximum number of tool intent nudges per thread.
|
||||
pub max_tool_intent_nudges: u32,
|
||||
}
|
||||
|
||||
impl Default for ThreadConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iterations: 50,
|
||||
max_duration: None,
|
||||
enable_reflection: false,
|
||||
enable_tool_intent_nudge: true,
|
||||
max_tool_intent_nudges: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thread ──────────────────────────────────────────────────
|
||||
|
||||
/// A thread — the unit of work.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: ThreadId,
|
||||
pub goal: String,
|
||||
pub thread_type: ThreadType,
|
||||
pub state: ThreadState,
|
||||
pub project_id: ProjectId,
|
||||
pub parent_id: Option<ThreadId>,
|
||||
pub config: ThreadConfig,
|
||||
pub messages: Vec<ThreadMessage>,
|
||||
pub events: Vec<ThreadEvent>,
|
||||
pub capability_leases: Vec<LeaseId>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub step_count: usize,
|
||||
pub total_tokens_used: u64,
|
||||
}
|
||||
|
||||
impl Thread {
|
||||
/// Create a new thread in the `Created` state.
|
||||
pub fn new(
|
||||
goal: impl Into<String>,
|
||||
thread_type: ThreadType,
|
||||
project_id: ProjectId,
|
||||
config: ThreadConfig,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: ThreadId::new(),
|
||||
goal: goal.into(),
|
||||
thread_type,
|
||||
state: ThreadState::Created,
|
||||
project_id,
|
||||
parent_id: None,
|
||||
config,
|
||||
messages: Vec::new(),
|
||||
events: Vec::new(),
|
||||
capability_leases: Vec::new(),
|
||||
metadata: serde_json::Value::Object(serde_json::Map::new()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
completed_at: None,
|
||||
step_count: 0,
|
||||
total_tokens_used: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a child thread with a parent reference.
|
||||
pub fn with_parent(mut self, parent_id: ThreadId) -> Self {
|
||||
self.parent_id = Some(parent_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Transition to a new state, recording an event.
|
||||
pub fn transition_to(
|
||||
&mut self,
|
||||
new_state: ThreadState,
|
||||
reason: Option<String>,
|
||||
) -> Result<(), EngineError> {
|
||||
if !self.state.can_transition_to(new_state) {
|
||||
return Err(EngineError::InvalidTransition {
|
||||
from: self.state,
|
||||
to: new_state,
|
||||
});
|
||||
}
|
||||
|
||||
let event = ThreadEvent::new(
|
||||
self.id,
|
||||
EventKind::StateChanged {
|
||||
from: self.state,
|
||||
to: new_state,
|
||||
reason,
|
||||
},
|
||||
);
|
||||
self.events.push(event);
|
||||
self.state = new_state;
|
||||
self.updated_at = Utc::now();
|
||||
|
||||
if new_state == ThreadState::Completed || new_state == ThreadState::Done {
|
||||
self.completed_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an event to this thread's log.
|
||||
pub fn add_event(&mut self, kind: EventKind) {
|
||||
self.events.push(ThreadEvent::new(self.id, kind));
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Add a message to this thread's conversation.
|
||||
pub fn add_message(&mut self, message: ThreadMessage) {
|
||||
let preview = if message.content.len() > 80 {
|
||||
format!("{}...", &message.content[..80])
|
||||
} else {
|
||||
message.content.clone()
|
||||
};
|
||||
self.add_event(EventKind::MessageAdded {
|
||||
role: format!("{:?}", message.role),
|
||||
content_preview: preview,
|
||||
});
|
||||
self.messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_thread() -> Thread {
|
||||
Thread::new(
|
||||
"test goal",
|
||||
ThreadType::Foreground,
|
||||
ProjectId::new(),
|
||||
ThreadConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
// ── State machine tests ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn created_can_transition_to_running() {
|
||||
assert!(ThreadState::Created.can_transition_to(ThreadState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_can_transition_to_failed() {
|
||||
assert!(ThreadState::Created.can_transition_to(ThreadState::Failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_cannot_transition_to_completed() {
|
||||
assert!(!ThreadState::Created.can_transition_to(ThreadState::Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_can_transition_to_waiting() {
|
||||
assert!(ThreadState::Running.can_transition_to(ThreadState::Waiting));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_can_transition_to_suspended() {
|
||||
assert!(ThreadState::Running.can_transition_to(ThreadState::Suspended));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_can_transition_to_completed() {
|
||||
assert!(ThreadState::Running.can_transition_to(ThreadState::Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_can_transition_to_failed() {
|
||||
assert!(ThreadState::Running.can_transition_to(ThreadState::Failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_can_resume_to_running() {
|
||||
assert!(ThreadState::Waiting.can_transition_to(ThreadState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspended_can_resume_to_running() {
|
||||
assert!(ThreadState::Suspended.can_transition_to(ThreadState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_can_transition_to_reflecting() {
|
||||
assert!(ThreadState::Completed.can_transition_to(ThreadState::Reflecting));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_can_transition_to_done() {
|
||||
assert!(ThreadState::Completed.can_transition_to(ThreadState::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflecting_can_transition_to_done() {
|
||||
assert!(ThreadState::Reflecting.can_transition_to(ThreadState::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_is_terminal() {
|
||||
assert!(ThreadState::Done.is_terminal());
|
||||
assert!(!ThreadState::Done.can_transition_to(ThreadState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_is_terminal() {
|
||||
assert!(ThreadState::Failed.is_terminal());
|
||||
assert!(!ThreadState::Failed.can_transition_to(ThreadState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_is_active() {
|
||||
assert!(ThreadState::Running.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_is_active() {
|
||||
assert!(ThreadState::Waiting.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_is_not_active() {
|
||||
assert!(!ThreadState::Created.is_active());
|
||||
}
|
||||
|
||||
// ── Thread lifecycle tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn new_thread_is_created() {
|
||||
let t = make_thread();
|
||||
assert_eq!(t.state, ThreadState::Created);
|
||||
assert!(t.events.is_empty());
|
||||
assert!(t.messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_transition_succeeds() {
|
||||
let mut t = make_thread();
|
||||
assert!(t.transition_to(ThreadState::Running, None).is_ok());
|
||||
assert_eq!(t.state, ThreadState::Running);
|
||||
assert_eq!(t.events.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_transition_fails() {
|
||||
let mut t = make_thread();
|
||||
let result = t.transition_to(ThreadState::Completed, None);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(t.state, ThreadState::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_lifecycle_created_to_done() {
|
||||
let mut t = make_thread();
|
||||
t.transition_to(ThreadState::Running, None).unwrap();
|
||||
t.transition_to(ThreadState::Completed, Some("finished".into()))
|
||||
.unwrap();
|
||||
t.transition_to(ThreadState::Done, None).unwrap();
|
||||
assert!(t.state.is_terminal());
|
||||
assert_eq!(t.events.len(), 3);
|
||||
assert!(t.completed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_lifecycle_with_reflection() {
|
||||
let mut t = make_thread();
|
||||
t.transition_to(ThreadState::Running, None).unwrap();
|
||||
t.transition_to(ThreadState::Completed, None).unwrap();
|
||||
t.transition_to(ThreadState::Reflecting, None).unwrap();
|
||||
t.transition_to(ThreadState::Done, None).unwrap();
|
||||
assert_eq!(t.events.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_message_records_event() {
|
||||
let mut t = make_thread();
|
||||
t.add_message(ThreadMessage::user("hello"));
|
||||
assert_eq!(t.messages.len(), 1);
|
||||
assert_eq!(t.events.len(), 1);
|
||||
match &t.events[0].kind {
|
||||
EventKind::MessageAdded { role, .. } => assert_eq!(role, "User"),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_thread_has_parent() {
|
||||
let parent = make_thread();
|
||||
let child = Thread::new(
|
||||
"child goal",
|
||||
ThreadType::Research,
|
||||
parent.project_id,
|
||||
ThreadConfig::default(),
|
||||
)
|
||||
.with_parent(parent.id);
|
||||
assert_eq!(child.parent_id, Some(parent.id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
# IronClaw Engine v2: Unified Thread-Capability-CodeAct Architecture
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Status:** Draft
|
||||
**Goal:** Replace IronClaw's ~10 fragmented abstractions with a unified execution model built on 5 primitives: Thread, Step, Capability, MemoryDoc, Project. Developed as a standalone crate (`ironclaw_engine`) that can be swapped in when it passes all acceptance tests.
|
||||
|
||||
---
|
||||
|
||||
## Motivation
|
||||
|
||||
IronClaw currently has Session, Job, Routine, Channel, Tool, Skill, Hook, Observer, Extension, and LoopDelegate as separate abstractions. All share common patterns (lifecycle, messaging, state, capabilities) but are implemented independently. This causes:
|
||||
|
||||
- Duplicated logic across ChatDelegate, JobDelegate, ContainerDelegate
|
||||
- Inconsistent state machines (SessionState vs JobState vs RoutineState)
|
||||
- Three separate permission systems (ApprovalRequirement, ApprovalContext, SkillTrust)
|
||||
- No structured learning from completed work
|
||||
- No project-level context scoping (all memory in one flat namespace)
|
||||
- The agentic loop can only do one tool call per LLM turn (no control flow)
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Conversation is not execution** — UI surfaces (chat) are separate from work units (threads)
|
||||
2. **Everything is a thread** — conversations, jobs, sub-agents, routines are all threads with different types
|
||||
3. **Capabilities unify tools + skills + hooks** — one install gives you actions, knowledge, and policies
|
||||
4. **Effects, not commands** — capabilities declare their effect types; a deterministic policy engine enforces boundaries
|
||||
5. **Memory is docs, not logs** — durable knowledge is structured (summaries, lessons, playbooks), not raw history
|
||||
6. **CodeAct for capable models** — LLMs write code that composes tools, queries history, and spawns threads
|
||||
7. **Event sourcing from day one** — every thread records a complete execution trace for replay/debugging/reflection
|
||||
|
||||
## The Five Primitives
|
||||
|
||||
| Primitive | Purpose | Replaces |
|
||||
|-----------|---------|----------|
|
||||
| **Thread** | Unit of work with lifecycle, parent-child tree, capability leases | Session + Job + Routine + Sub-agent |
|
||||
| **Step** | Unit of execution (one LLM call + its tool/code executions) | Agentic loop iteration + tool calls |
|
||||
| **Capability** | Unit of effect (actions + knowledge + policies) | Tool + Skill + Hook + Extension |
|
||||
| **MemoryDoc** | Unit of durable knowledge (summaries, lessons, playbooks) | Workspace memory blobs |
|
||||
| **Project** | Unit of context (scopes memory, threads, missions) | Flat workspace namespace |
|
||||
|
||||
## Crate Structure
|
||||
|
||||
Single crate: `crates/ironclaw_engine/`
|
||||
|
||||
```
|
||||
crates/ironclaw_engine/
|
||||
Cargo.toml
|
||||
src/
|
||||
lib.rs # Public API, re-exports
|
||||
|
||||
types/ # Core data structures (no async, no I/O)
|
||||
mod.rs
|
||||
error.rs # EngineError, ThreadError, StepError, CapabilityError
|
||||
thread.rs # Thread, ThreadId, ThreadState, ThreadType, ThreadConfig
|
||||
step.rs # Step, StepId, StepStatus, ExecutionTier, ActionCall, ActionResult
|
||||
capability.rs # Capability, ActionDef, EffectType, CapabilityLease, PolicyRule
|
||||
memory.rs # MemoryDoc, DocId, DocType
|
||||
project.rs # Project, ProjectId
|
||||
event.rs # ThreadEvent, EventKind (event sourcing)
|
||||
provenance.rs # Provenance enum (User, System, ToolOutput, LlmGenerated, etc.)
|
||||
message.rs # ThreadMessage, MessageRole
|
||||
conversation.rs # ConversationSurface, ConversationEntry (Phase 5)
|
||||
mission.rs # Mission, MissionId (Phase 4)
|
||||
|
||||
traits/ # External dependency abstractions
|
||||
mod.rs
|
||||
llm.rs # LlmBackend trait
|
||||
store.rs # Store trait (thread/step/event/project/doc/lease CRUD)
|
||||
effect.rs # EffectExecutor trait
|
||||
code_runner.rs # CodeRunner trait (Phase 3)
|
||||
|
||||
capability/ # Capability management
|
||||
mod.rs
|
||||
registry.rs # CapabilityRegistry
|
||||
lease.rs # LeaseManager (grant, check, consume, revoke, expire)
|
||||
policy.rs # PolicyEngine (deterministic effect-level allow/deny)
|
||||
provenance.rs # ProvenanceTracker (taint analysis at effect boundaries, Phase 4)
|
||||
|
||||
runtime/ # Thread lifecycle management
|
||||
mod.rs
|
||||
manager.rs # ThreadManager (spawn, supervise, stop, inject messages)
|
||||
tree.rs # ThreadTree (parent-child relationships)
|
||||
messaging.rs # ThreadMailbox, ThreadSignal (inter-thread communication)
|
||||
conversation.rs # ConversationManager (UI surface → thread routing, Phase 5)
|
||||
|
||||
executor/ # Step execution
|
||||
mod.rs
|
||||
loop_engine.rs # ExecutionLoop (core loop replacing run_agentic_loop)
|
||||
structured.rs # Tier 0: structured tool calls
|
||||
scripting.rs # Tier 1: embedded Starlark/Rhai (Phase 3)
|
||||
context.rs # Context builder (thread state + project docs + capabilities)
|
||||
intent.rs # Tool intent nudge detection
|
||||
|
||||
memory/ # Memory document system
|
||||
mod.rs
|
||||
store.rs # MemoryStore (project-scoped doc operations)
|
||||
retrieval.rs # RetrievalEngine (context building from project docs)
|
||||
|
||||
reflection/ # Post-thread reflection pipeline
|
||||
mod.rs
|
||||
pipeline.rs # ReflectionPipeline (summarize, extract lessons, detect issues)
|
||||
learning.rs # Tool reliability learning, playbook promotion
|
||||
|
||||
testing/ # Test utilities (cfg(test))
|
||||
mod.rs
|
||||
mock_llm.rs # MockLlmBackend (queued responses)
|
||||
mock_store.rs # MockStore (in-memory HashMap storage)
|
||||
mock_effect.rs # MockEffectExecutor (configurable results)
|
||||
```
|
||||
|
||||
Dependencies (minimal — no main crate dependency):
|
||||
- `tokio` (sync, time, macros, rt), `serde` + `serde_json`, `thiserror`, `tracing`, `uuid`, `chrono`, `async-trait`
|
||||
- Phase 3 adds: `starlark` or `rhai` (embedded scripting)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation (Types + Traits + Thread Lifecycle)
|
||||
|
||||
**Goal:** Get the crate compiling with all core types, trait definitions, and thread state machine. No execution yet.
|
||||
|
||||
### 1.1 Crate scaffolding
|
||||
- Create `crates/ironclaw_engine/Cargo.toml` (follow `ironclaw_safety` pattern)
|
||||
- Add to workspace `members` in root `Cargo.toml`
|
||||
|
||||
### 1.2 Core types
|
||||
All files in `src/types/`. Pure data structures with `Serialize`/`Deserialize`.
|
||||
|
||||
**`error.rs`** — Error hierarchy:
|
||||
- `EngineError` (top-level: Thread, Step, Capability, Store, Llm, Effect, InvalidTransition, NotFound, LeaseExpired, LeaseDenied, MaxIterations)
|
||||
- `ThreadError` (AlreadyRunning, Terminal, ParentNotRunning)
|
||||
- `StepError` (Timeout, ActionDenied)
|
||||
- `CapabilityError` (NotFound, EffectDenied)
|
||||
|
||||
**`thread.rs`** — Thread state machine:
|
||||
```
|
||||
Created → Running → Waiting → Running (resume)
|
||||
→ Suspended → Running (resume)
|
||||
→ Completed → Reflecting → Done
|
||||
→ Failed
|
||||
```
|
||||
- `ThreadState::can_transition_to(target) -> bool`
|
||||
- `ThreadState::is_terminal() -> bool` (Done, Failed)
|
||||
- `ThreadState::is_active() -> bool` (Running, Waiting)
|
||||
- `Thread::transition_to(state) -> Result<(), EngineError>` (validates + records event)
|
||||
|
||||
**`step.rs`** — Step + LLM response types:
|
||||
- `LlmResponse::Text(String)` or `LlmResponse::ActionCalls { calls, content }`
|
||||
- `ActionCall { id, action_name, parameters }`
|
||||
- `ActionResult { call_id, action_name, output, is_error, duration }`
|
||||
- `TokenUsage { input_tokens, output_tokens, cache_read_tokens, cache_write_tokens }`
|
||||
- `ExecutionTier` enum — Phase 1: only `Structured`
|
||||
|
||||
**`capability.rs`** — Effect typing + leases:
|
||||
- `EffectType` enum: ReadLocal, ReadExternal, WriteLocal, WriteExternal, CredentialedNetwork, Compute, Financial
|
||||
- `ActionDef { name, description, parameters_schema, effects: Vec<EffectType>, requires_approval }`
|
||||
- `Capability { name, description, actions, knowledge: Vec<String>, policies: Vec<PolicyRule> }`
|
||||
- `CapabilityLease { id, thread_id, capability_name, granted_actions, granted_at, expires_at, max_uses, uses_remaining, revoked }`
|
||||
- `PolicyRule { name, condition: PolicyCondition, effect: PolicyEffect }`
|
||||
- `PolicyCondition` enum: Always, ActionMatches { pattern }, EffectTypeIs(EffectType)
|
||||
- `PolicyEffect` enum: Allow, Deny, RequireApproval
|
||||
|
||||
**`message.rs`** — Engine's own message type (simpler than `ChatMessage`):
|
||||
- `MessageRole` enum: System, User, Assistant, ActionResult
|
||||
- `ThreadMessage { role, content, provenance, action_call_id, action_name, action_calls, timestamp }`
|
||||
- Constructors: `system()`, `user()`, `assistant()`, `assistant_with_actions()`, `action_result()`
|
||||
|
||||
**`event.rs`** — Event sourcing:
|
||||
- `EventKind` enum: StateChanged, StepStarted, StepCompleted, StepFailed, ActionExecuted, ActionFailed, LeaseGranted, LeaseRevoked, LeaseExpired, MessageAdded, ChildSpawned, ChildCompleted, ApprovalRequested, ApprovalReceived
|
||||
|
||||
**`project.rs`**, **`memory.rs`**, **`provenance.rs`** — Straightforward structs.
|
||||
|
||||
### 1.3 Trait definitions
|
||||
- `LlmBackend` — `complete(messages, actions, config) -> LlmOutput`, `model_name() -> &str`
|
||||
- `Store` — Thread/Step/Event/Project/MemoryDoc/Lease CRUD (~20 methods)
|
||||
- `EffectExecutor` — `execute_action(name, params, lease, ctx) -> ActionResult`, `available_actions(leases) -> Vec<ActionDef>`
|
||||
|
||||
### 1.4 Tests
|
||||
- Thread state machine: all valid/invalid transitions
|
||||
- ThreadMessage constructors
|
||||
- CapabilityLease expiry checks (time-based, use-based)
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
cargo check -p ironclaw_engine
|
||||
cargo clippy -p ironclaw_engine --all-targets -- -D warnings
|
||||
cargo test -p ironclaw_engine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Execution Engine (Tier 0 — Structured Tool Calls)
|
||||
|
||||
**Goal:** A working execution loop that is functionally equivalent to the current `run_agentic_loop()`. Thread spawning, capability leasing, policy enforcement, event logging.
|
||||
|
||||
### 2.1 Capability management
|
||||
- `CapabilityRegistry` — register/get/list capabilities and their actions
|
||||
- `LeaseManager` — grant leases (scoped, time-limited, use-limited), check validity, consume uses, revoke, expire stale. State: `RwLock<HashMap<LeaseId, CapabilityLease>>`
|
||||
- `PolicyEngine` — deterministic evaluation: `evaluate(action_def, lease, thread_context) -> PolicyDecision`. Check order: global policies → capability policies → action-level `requires_approval` → effect type against lease. Deny > RequireApproval > Allow
|
||||
|
||||
### 2.2 Thread runtime
|
||||
- `ThreadTree` — in-memory parent-child tracking. `add_child()`, `parent_of()`, `children_of()`, `remove()`, `ancestors()`
|
||||
- `ThreadMailbox` + `ThreadSignal` — `mpsc`-based inter-thread messaging. Signals: Stop, Suspend, Resume, InjectMessage, ChildCompleted
|
||||
- `ThreadManager` — orchestrator. `spawn_thread()` creates thread + leases + `ExecutionLoop`, wraps in tokio task. `stop_thread()`, `inject_message()`, `get_thread_state()`. Holds `Arc<dyn Store/LlmBackend/EffectExecutor>` + capability/lease/policy
|
||||
|
||||
### 2.3 Execution loop
|
||||
- `build_step_context()` — assemble messages + action definitions from thread state + active leases
|
||||
- `execute_action_calls()` — Tier 0: for each ActionCall, find lease → check policy → consume use → call `EffectExecutor` → record result + event. Returns NeedApproval if policy requires it
|
||||
- `ExecutionLoop::run()` — core loop mirroring `run_agentic_loop()`:
|
||||
1. `signal_rx.try_recv()` → handle Stop, Suspend, InjectMessage
|
||||
2. `build_step_context()` → messages + actions
|
||||
3. `llm.complete()` → LlmOutput
|
||||
4. If `LlmResponse::Text` → check tool intent nudge, return if final
|
||||
5. If `LlmResponse::ActionCalls` → `execute_action_calls()`, add results to messages
|
||||
6. Record Step, emit events
|
||||
7. Check max_iterations, force_text on final iterations
|
||||
8. Repeat
|
||||
|
||||
### 2.4 Memory stubs
|
||||
- `MemoryStore` — thin wrapper: `create_doc()`, `get_doc()`, `update_doc()`, `list_by_type()`
|
||||
- `RetrievalEngine` — stub returning empty vec
|
||||
|
||||
### 2.5 Tests (comprehensive)
|
||||
- Simple text response: MockLlm returns text → thread Created→Running→Completed→Done
|
||||
- Tool call then text: MockLlm returns ActionCalls then text → effect executor called, result in messages
|
||||
- Multi-tool parallel: Multiple ActionCalls in one response → all executed, all results recorded
|
||||
- Max iterations: MockLlm always returns actions → loop stops at limit
|
||||
- Stop signal: Send Stop → clean termination
|
||||
- Inject message: Send InjectMessage during loop → appears in context
|
||||
- Lease expiry (uses): max_uses=1 → first OK, second fails
|
||||
- Lease expiry (time): expires_at in past → immediate failure
|
||||
- Policy deny: Financial effect blocked → ActionDenied
|
||||
- Policy require approval: → returns NeedApproval outcome
|
||||
- Event sourcing: run loop → verify all events recorded in order
|
||||
- Tool intent nudge: "Let me search..." text → nudge injected, capped at max
|
||||
- Child thread spawning: spawn from parent → tree relationships correct, child completion event on parent
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
cargo test -p ironclaw_engine
|
||||
# All existing tests still pass:
|
||||
cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: CodeAct Executor (Tier 1 — Embedded Scripting)
|
||||
|
||||
**Goal:** LLMs can write code (Starlark or Rhai) that composes tools, uses control flow, and queries thread context. This is the key differentiator from the current tool-call model.
|
||||
|
||||
### 3.1 Code runner trait
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait CodeRunner: Send + Sync {
|
||||
async fn execute(
|
||||
&self,
|
||||
code: &str,
|
||||
runtime_api: &dyn RuntimeApi,
|
||||
config: &CodeRunnerConfig,
|
||||
) -> Result<CodeResult, EngineError>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Runtime API
|
||||
The API surface that code executes against:
|
||||
```python
|
||||
# Thread operations (read-only access to thread state)
|
||||
thread.messages # list of messages
|
||||
thread.messages[-1] # last message
|
||||
thread.messages.filter(role="tool") # filter by role
|
||||
thread.goal # thread's goal
|
||||
thread.state # current state
|
||||
|
||||
# Capability actions (tool calls)
|
||||
tools.web_fetch(url="...") # invoke capability action
|
||||
tools.memory_search(query="...") # invoke capability action
|
||||
result = tools.shell(cmd="...") # invoke capability action
|
||||
|
||||
# Output
|
||||
thread.reply("response") # send final response
|
||||
thread.think("note") # add to context, not visible to user
|
||||
|
||||
# Thread spawning
|
||||
child = thread.spawn(goal="...", capabilities=["web_fetch"])
|
||||
results = thread.join([child1, child2, child3]) # fan-out/fan-in
|
||||
```
|
||||
|
||||
### 3.3 Tier selection
|
||||
The executor analyzes the LLM response to route:
|
||||
- JSON tool calls → Tier 0 (structured, existing path)
|
||||
- Code block with only `tools.*` calls → Tier 1 (embedded scripting)
|
||||
- Code with `import os`, `tools.shell` → Tier 3 (Docker, Phase 6)
|
||||
|
||||
### 3.4 Starlark/Rhai integration
|
||||
Add `starlark` or `rhai` as dependency. Implement `CodeRunner`:
|
||||
- Parse code
|
||||
- Bind `thread.*` and `tools.*` namespaces to Rust callbacks
|
||||
- Fuel metering (prevent infinite loops)
|
||||
- Execute with timeout
|
||||
- Capture output + side effects
|
||||
|
||||
### 3.5 Prompt transformation
|
||||
When CodeAct is enabled, the system prompt changes from prose tool descriptions to API documentation:
|
||||
```
|
||||
Available API:
|
||||
tools.web_fetch(url: str, headers: dict = None) -> Response
|
||||
tools.memory_search(query: str, limit: int = 10) -> List[Memory]
|
||||
thread.reply(content: str)
|
||||
thread.spawn(goal: str, capabilities: list = None) -> Thread
|
||||
|
||||
Write code to accomplish the user's request.
|
||||
```
|
||||
|
||||
### 3.6 Tests
|
||||
- Simple code: `tools.web_fetch(url="...")` → action executed, result captured
|
||||
- Control flow: `for item in tools.search(...): tools.memory_write(...)` → multiple actions
|
||||
- Thread data access: `thread.messages[-1].content` → correct value
|
||||
- Fuel exhaustion: infinite loop → timeout error
|
||||
- Tier selection: structured JSON → Tier 0, code block → Tier 1
|
||||
- Error handling: code raises exception → step fails gracefully
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Memory, Reflection, and Learning
|
||||
|
||||
**Goal:** The agent learns from its work. Completed threads produce structured knowledge (summaries, lessons, playbooks) that improve future threads.
|
||||
|
||||
### 4.1 Project-scoped retrieval
|
||||
- `RetrievalEngine::retrieve_context(project_id, query, max_docs)` — keyword + semantic search over project's memory docs
|
||||
- Context builder uses retrieval: thread state + project docs (summaries, lessons, playbooks) + capability descriptions
|
||||
- The LLM gets relevant project knowledge, not raw history
|
||||
|
||||
### 4.2 Reflection pipeline
|
||||
After thread completes (state → Completed), optionally spawns a Reflection-type thread:
|
||||
1. **Summarize** → produce `DocType::Summary` doc
|
||||
2. **Extract lessons** → scan for failures, workarounds, discoveries → produce `DocType::Lesson` docs
|
||||
3. **Detect issues** → find problems that weren't resolved → produce `DocType::Issue` docs
|
||||
4. **Detect missing capabilities** → "no tool available" patterns → produce `DocType::Spec` docs
|
||||
5. **Promote playbooks** → successful multi-step procedures → produce `DocType::Playbook` docs
|
||||
|
||||
Reflection is itself a thread running CodeAct — it's recursive.
|
||||
|
||||
### 4.3 Provenance tracking
|
||||
Every data value tagged with origin:
|
||||
- `Provenance::User` — direct user input
|
||||
- `Provenance::System` — system prompt, config
|
||||
- `Provenance::ToolOutput { action_name }` — result from a capability action
|
||||
- `Provenance::LlmGenerated` — LLM output
|
||||
- `Provenance::Reflection { source_thread_id }` — from reflection pipeline
|
||||
- `Provenance::MemoryRetrieval { doc_id }` — from project memory
|
||||
|
||||
The policy engine uses provenance at effect boundaries:
|
||||
- LlmGenerated data cannot flow into Financial effects without approval
|
||||
- ToolOutput from untrusted sources triggers extra validation
|
||||
- User-provenance data is trusted (no taint)
|
||||
|
||||
### 4.4 Missions (long-running goals)
|
||||
```rust
|
||||
pub struct Mission {
|
||||
pub id: MissionId,
|
||||
pub project_id: ProjectId,
|
||||
pub goal: String,
|
||||
pub status: MissionStatus, // Active, Paused, Completed, Failed
|
||||
pub cadence: MissionCadence, // Cron, OnEvent, OnPush, Manual
|
||||
pub thread_history: Vec<ThreadId>, // past threads spawned by this mission
|
||||
pub success_criteria: Option<String>,
|
||||
}
|
||||
```
|
||||
Missions spawn threads on cadence, track progress across runs, and adapt based on reflection docs.
|
||||
|
||||
### 4.5 Tool reliability learning
|
||||
Track per-action metrics:
|
||||
- Success rate (EMA)
|
||||
- Avg latency
|
||||
- Common failure patterns
|
||||
- Last N results
|
||||
|
||||
Feed into context builder so the LLM knows "this tool has been flaky recently."
|
||||
|
||||
### 4.6 Tests
|
||||
- Reflection produces correct doc types for a completed thread with failures
|
||||
- Retrieval returns project-scoped docs, not cross-project
|
||||
- Provenance taint blocks financial effects from LLM-generated data
|
||||
- Mission spawns thread on cadence, tracks history
|
||||
- Tool reliability metrics update correctly after successes/failures
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Conversation Surface + Multi-Channel Integration
|
||||
|
||||
**Goal:** Conversations (UI) are cleanly separated from threads (execution). Multiple channels route to the same thread model.
|
||||
|
||||
### 5.1 ConversationSurface
|
||||
```rust
|
||||
pub struct ConversationSurface {
|
||||
pub id: ConversationId,
|
||||
pub channel: String, // "telegram", "slack", "web", "cli"
|
||||
pub user_id: String,
|
||||
pub entries: Vec<ConversationEntry>,
|
||||
pub active_threads: Vec<ThreadId>,
|
||||
}
|
||||
|
||||
pub struct ConversationEntry {
|
||||
pub id: EntryId,
|
||||
pub sender: EntrySender, // User or Agent
|
||||
pub content: String,
|
||||
pub origin_thread_id: Option<ThreadId>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 ConversationManager
|
||||
- Routes incoming channel messages to conversation surfaces
|
||||
- User message → may spawn new foreground thread or inject into existing
|
||||
- Multiple threads can be active simultaneously per conversation
|
||||
- Thread outputs (replies, status updates) appear as conversation entries
|
||||
|
||||
### 5.3 Channel adaptation
|
||||
The existing `Channel` trait stays. A bridge adapter translates:
|
||||
- `IncomingMessage` → `ConversationEntry` → spawn/inject `Thread`
|
||||
- `ThreadOutcome` → `ConversationEntry` → `OutgoingResponse`
|
||||
- `StatusUpdate` events → `ConversationEntry` with metadata
|
||||
|
||||
### 5.4 Tests
|
||||
- Two concurrent threads in one conversation → entries interleaved correctly
|
||||
- Thread outlives conversation (background) → results appear when user returns
|
||||
- Channel-agnostic: same thread model works for Telegram, Web, CLI
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Advanced Execution (Tier 2-3 + Two-Phase Commit)
|
||||
|
||||
**Goal:** Full CodeAct with WASM sandbox (Tier 2) and Docker container (Tier 3). Two-phase commit for high-stakes effects.
|
||||
|
||||
### 6.1 Tier 2: WASM sandbox
|
||||
- Embed Python interpreter (RustPython) or use Starlark compiled to WASM
|
||||
- Leverage existing `wasmtime` infrastructure from `src/tools/wasm/`
|
||||
- Fuel metering, memory limits, network allowlisting (all existing)
|
||||
- Runtime API exposed via WIT interface (extend existing `wit/tool.wit`)
|
||||
|
||||
### 6.2 Tier 3: Docker container
|
||||
- Leverage existing `src/sandbox/` + `src/orchestrator/` infrastructure
|
||||
- Full Python runtime with `thread.*` and `tools.*` available via HTTP proxy
|
||||
- Network access through existing sandbox proxy (domain allowlist, credential injection)
|
||||
|
||||
### 6.3 Automatic tier selection
|
||||
Analyze LLM-generated code:
|
||||
- Pure `tools.*` calls, no I/O → Tier 1 (embedded)
|
||||
- Uses `tools.web_fetch` or HTTP → Tier 2 (WASM, allowlisted network)
|
||||
- Uses `tools.shell`, `import os`, filesystem → Tier 3 (Docker)
|
||||
- Falls back gracefully: if Tier 1 fails with capability error, promote to Tier 2/3
|
||||
|
||||
### 6.4 Two-phase commit
|
||||
For `WriteExternal` + `Financial` effects:
|
||||
1. **Simulate** — dry-run the effect, return preview
|
||||
2. **Approve** — user or policy approves
|
||||
3. **Execute** — actual effect
|
||||
|
||||
Replaces current binary approve/deny with richer commit policies:
|
||||
- `CommitPolicy::Direct` — execute immediately (ReadLocal, ReadExternal)
|
||||
- `CommitPolicy::Approved` — needs approval before execution (WriteExternal)
|
||||
- `CommitPolicy::TwoPhase` — simulate → approve → execute (Financial, production deploys)
|
||||
|
||||
### 6.5 Tests
|
||||
- Tier selection routes correctly based on code analysis
|
||||
- WASM sandbox enforces fuel limits
|
||||
- Docker container executes with proper isolation
|
||||
- Two-phase commit: simulate returns preview, approve triggers execution
|
||||
- Tier escalation: Tier 1 failure promotes to Tier 3
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Main Crate Integration
|
||||
|
||||
**Goal:** Bridge adapters connect the engine to existing IronClaw infrastructure. Feature-flagged swap.
|
||||
|
||||
### 7.1 Bridge adapters (`src/bridge/`)
|
||||
- `LlmBridgeAdapter` — wraps `Arc<dyn LlmProvider>`, converts `ThreadMessage` ↔ `ChatMessage`, `ActionDef` ↔ `ToolDefinition`
|
||||
- `StoreBridgeAdapter` — wraps `Arc<dyn Database>`, maps engine CRUD to existing sub-traits. New tables for threads/projects/docs/leases/events (migration V14+)
|
||||
- `EffectBridgeAdapter` — wraps `ToolRegistry` + `SafetyLayer`. On `execute_action()`: lookup tool → validate params via safety → execute → sanitize output → return. This is where safety logic lives (not in the engine)
|
||||
|
||||
### 7.2 Database migrations
|
||||
New tables:
|
||||
- `engine_threads` (id, goal, type, state, project_id, parent_id, config_json, metadata, timestamps)
|
||||
- `engine_steps` (id, thread_id, sequence, status, tier, request_json, response_json, results_json, tokens_json, timestamps)
|
||||
- `engine_events` (id, thread_id, timestamp, kind_json)
|
||||
- `engine_projects` (id, name, description, metadata, timestamps)
|
||||
- `engine_memory_docs` (id, project_id, doc_type, title, content, source_thread_id, tags_json, metadata, timestamps)
|
||||
- `engine_capability_leases` (id, thread_id, capability_name, granted_actions_json, granted_at, expires_at, max_uses, uses_remaining, revoked)
|
||||
|
||||
Both PostgreSQL and libSQL backends (per existing dual-backend requirement).
|
||||
|
||||
### 7.3 Feature-flagged swap
|
||||
```rust
|
||||
// In app.rs or agent_loop.rs:
|
||||
#[cfg(feature = "engine_v2")]
|
||||
{
|
||||
let engine = ironclaw_engine::ThreadManager::new(
|
||||
Arc::new(LlmBridgeAdapter::new(llm_provider)),
|
||||
Arc::new(StoreBridgeAdapter::new(database)),
|
||||
Arc::new(EffectBridgeAdapter::new(tool_registry, safety)),
|
||||
);
|
||||
// Use engine for thread management
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Alternative LoopDelegate
|
||||
Implement a `EngineV2Delegate` that wraps the engine's `ExecutionLoop` but presents the `LoopDelegate` interface. This enables gradual migration — the existing dispatcher calls `run_agentic_loop()` with either the old ChatDelegate or the new EngineV2Delegate.
|
||||
|
||||
### 7.5 Acceptance testing
|
||||
Use existing `TestRig` + `TraceLlm` infrastructure:
|
||||
- Load pre-recorded LLM trace fixtures
|
||||
- Drive the engine via bridge adapters
|
||||
- Compare output with `verify_trace_expects()`
|
||||
- All existing fixture tests must pass with identical results
|
||||
|
||||
When all tests pass: remove feature flag, make engine the default, deprecate old path.
|
||||
|
||||
### 7.6 Tests
|
||||
- Bridge adapter conversion: ThreadMessage ↔ ChatMessage round-trips correctly
|
||||
- End-to-end: TestRig drives engine, same output as old loop
|
||||
- Migration: new tables created for both PostgreSQL and libSQL
|
||||
- Feature flag: both paths compile and pass tests
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Cleanup and Migration
|
||||
|
||||
**Goal:** Remove old abstractions, migrate all code to engine model.
|
||||
|
||||
### 8.1 Deprecate old types
|
||||
- `Session` / `Thread` / `Turn` → engine `Thread` + `Step`
|
||||
- `JobState` / `JobContext` → engine `ThreadState` + `Thread`
|
||||
- `RoutineEngine` / `Routine` → engine `Mission` + `Thread`
|
||||
- `SkillSelector` / `LoadedSkill` → engine `Capability` (knowledge)
|
||||
- `HookPipeline` → engine `Capability` (policies)
|
||||
- `ApprovalRequirement` / `ApprovalContext` → engine `CapabilityLease` + `PolicyEngine`
|
||||
|
||||
### 8.2 Slim down main crate
|
||||
- Agent module becomes thin adapter over engine
|
||||
- `app.rs` orchestrates engine startup instead of manually wiring channels/tools/sessions
|
||||
- Remove `LoopDelegate` and its three implementations
|
||||
- Remove `SessionManager`, `Scheduler` (replaced by `ThreadManager`)
|
||||
|
||||
### 8.3 Sub-crate extraction
|
||||
Once engine boundaries are stable, split internal modules into sub-crates if beneficial:
|
||||
- `ironclaw_types` — shared types usable by WASM extensions
|
||||
- `ironclaw_capability` — if used by tooling/CLI independently
|
||||
- `ironclaw_codeact` — if the code runner grows complex
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
### Security Model
|
||||
- **Capability leases** replace static permissions. Scoped per-thread, time-limited, use-limited. Blast radius bounded by lease
|
||||
- **Effect typing** on every action. Policy engine uses effect types (not tool names) for allow/deny
|
||||
- **Provenance tracking** (Phase 4). Data tagged with origin; taint analysis at effect boundaries
|
||||
- **Two-phase commit** (Phase 6) for WriteExternal + Financial effects
|
||||
- **Safety at adapter boundary**. The engine is pure orchestration; `SafetyLayer` (sanitization, leak detection, injection checking) is applied in `EffectBridgeAdapter`
|
||||
|
||||
### Observability
|
||||
- **Event sourcing** replaces ad-hoc `ObserverEvent`. Every thread has a complete event log
|
||||
- **Trace-based testing** (Phase 4+). Use event logs as golden traces for regression testing
|
||||
- **Thread-structural events** (thread.started, step.completed, action.executed) vs current per-subsystem events
|
||||
|
||||
### Backward Compatibility
|
||||
- Engine runs alongside existing code (feature flag)
|
||||
- Bridge adapters translate between engine and existing types
|
||||
- WASM tools/channels unchanged — they implement `Tool`/`Channel` traits, which the bridge wraps
|
||||
- MCP tools unchanged — same adapter principle
|
||||
- Existing tests unmodified — they test the old path; new tests validate the engine
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order Summary
|
||||
|
||||
| Phase | Scope | Depends on | Key deliverable |
|
||||
|-------|-------|------------|-----------------|
|
||||
| **1** | Types + traits + state machine | Nothing | Compiling crate with all type definitions |
|
||||
| **2** | Tier 0 executor + capability + runtime | Phase 1 | Working execution loop equivalent to `run_agentic_loop()` |
|
||||
| **3** | CodeAct (Tier 1 embedded scripting) | Phase 2 | LLMs write code that composes tools |
|
||||
| **4** | Reflection + retrieval + provenance + missions | Phase 2 | Agent learns from work, project-scoped memory |
|
||||
| **5** | Conversation surface + channel integration | Phase 2 | UI separated from execution |
|
||||
| **6** | Tier 2-3 + two-phase commit | Phase 3 | Full sandboxed code execution |
|
||||
| **7** | Main crate bridge + acceptance tests | Phase 2+ | Engine passes all existing tests via adapters |
|
||||
| **8** | Cleanup + migration | Phase 7 | Old abstractions removed |
|
||||
|
||||
Phases 3, 4, 5 can proceed in parallel after Phase 2 is complete.
|
||||
|
||||
---
|
||||
|
||||
## Verification (per phase)
|
||||
|
||||
```bash
|
||||
# Engine crate only:
|
||||
cargo check -p ironclaw_engine
|
||||
cargo clippy -p ironclaw_engine --all-targets -- -D warnings
|
||||
cargo test -p ironclaw_engine
|
||||
|
||||
# Full workspace (no regressions):
|
||||
cargo check
|
||||
cargo clippy --all --benches --tests --examples --all-features
|
||||
cargo test
|
||||
|
||||
# Phase 7+ acceptance:
|
||||
cargo test --features engine_v2 # engine-driven tests match existing fixtures
|
||||
```
|
||||
Reference in New Issue
Block a user