Skip to main content

vex_runtime/
executor.rs

1//! Agent executor - runs individual agents with LLM backend
2
3use std::sync::Arc;
4use uuid::Uuid;
5
6use crate::gate::Gate;
7use serde::Deserialize;
8use vex_adversarial::{
9    Consensus, ConsensusProtocol, Debate, DebateRound, ShadowAgent, ShadowConfig, Vote,
10};
11use vex_core::{Agent, ContextPacket, Hash};
12use vex_hardware::api::AgentIdentity;
13use vex_llm::Capability;
14use vex_persist::{AuditStore, StorageBackend};
15
16#[derive(Debug, Deserialize)]
17struct ChallengeResponse {
18    is_challenge: bool,
19    confidence: f64,
20    reasoning: String,
21    suggested_revision: Option<String>,
22}
23
24#[derive(Debug, Deserialize)]
25struct VoteResponse {
26    agrees: bool,
27    reflection: String,
28    confidence: f64,
29}
30
31/// Configuration for agent execution
32#[derive(Debug, Clone)]
33pub struct ExecutorConfig {
34    /// Maximum debate rounds
35    pub max_debate_rounds: u32,
36    /// Consensus protocol to use
37    pub consensus_protocol: ConsensusProtocol,
38    /// Whether to spawn shadow agents
39    pub enable_adversarial: bool,
40}
41
42impl Default for ExecutorConfig {
43    fn default() -> Self {
44        Self {
45            max_debate_rounds: 3,
46            consensus_protocol: ConsensusProtocol::Majority,
47            enable_adversarial: true,
48        }
49    }
50}
51
52/// Result of agent execution
53#[derive(Debug, Clone)]
54pub struct ExecutionResult {
55    /// The agent that produced this result
56    pub agent_id: Uuid,
57    /// The final response
58    pub response: String,
59    /// Whether it was verified by adversarial debate
60    pub verified: bool,
61    /// Confidence score (0.0 - 1.0)
62    pub confidence: f64,
63    /// Context packet with merkle hash
64    pub context: ContextPacket,
65    /// Logit-Merkle trace root (for provenance)
66    pub trace_root: Option<Hash>,
67    /// Debate details (if adversarial was enabled)
68    pub debate: Option<Debate>,
69    /// CHORA Evidence Capsule
70    pub evidence: Option<vex_core::audit::EvidenceCapsule>,
71}
72
73use vex_llm::{LlmProvider, LlmRequest};
74
75/// Agent executor - runs agents with LLM backends
76pub struct AgentExecutor<L: LlmProvider + ?Sized> {
77    /// Configuration
78    pub config: ExecutorConfig,
79    /// LLM backend
80    llm: Arc<L>,
81    /// Policy Gate
82    gate: Arc<dyn Gate>,
83    /// Audit Store (Phase 3)
84    pub audit_store: Option<Arc<AuditStore<dyn StorageBackend>>>,
85    /// Hardware Identity (Phase 3)
86    pub identity: Option<Arc<AgentIdentity>>,
87}
88
89impl<L: LlmProvider + ?Sized> std::fmt::Debug for AgentExecutor<L> {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("AgentExecutor")
92            .field("config", &self.config)
93            .field("identity", &self.identity)
94            .finish()
95    }
96}
97
98impl<L: LlmProvider + ?Sized> Clone for AgentExecutor<L> {
99    fn clone(&self) -> Self {
100        Self {
101            config: self.config.clone(),
102            llm: self.llm.clone(),
103            gate: self.gate.clone(),
104            audit_store: self.audit_store.clone(),
105            identity: self.identity.clone(),
106        }
107    }
108}
109
110impl<L: LlmProvider + ?Sized> AgentExecutor<L> {
111    /// Create a new executor
112    pub fn new(llm: Arc<L>, config: ExecutorConfig, gate: Arc<dyn Gate>) -> Self {
113        Self {
114            config,
115            llm,
116            gate,
117            audit_store: None,
118            identity: None,
119        }
120    }
121
122    /// Attach a hardware-rooted identity and audit store (Phase 3)
123    pub fn with_identity(
124        mut self,
125        identity: Arc<AgentIdentity>,
126        audit_store: Arc<AuditStore<dyn StorageBackend>>,
127    ) -> Self {
128        self.identity = Some(identity);
129        self.audit_store = Some(audit_store);
130        self
131    }
132
133    /// Execute an agent with a prompt and return the result
134    pub async fn execute(
135        &self,
136        tenant_id: &str, // Added tenant_id for audit logging
137        agent: &mut Agent,
138        prompt: &str,
139        capabilities: Vec<Capability>,
140    ) -> Result<ExecutionResult, String> {
141        // Step 1: Format context and get initial response from Blue agent
142        let full_prompt = if !agent.context.content.is_empty() {
143            format!(
144                "Previous Context (Time: {}):\n\"{}\"\n\nActive Prompt:\n\"{}\"",
145                agent.context.created_at, agent.context.content, prompt
146            )
147        } else {
148            prompt.to_string()
149        };
150
151        let blue_response = self
152            .llm
153            .complete(LlmRequest::with_role(&agent.config.role, &full_prompt))
154            .await
155            .map_err(|e| e.to_string())?
156            .content;
157
158        // Step 2: If adversarial is enabled, run debate
159        let (final_response, verified, confidence, debate) = if self.config.enable_adversarial {
160            self.run_adversarial_verification(agent, prompt, &blue_response)
161                .await?
162        } else {
163            (blue_response, false, 0.5, None)
164        };
165
166        // Step 2.5: Policy Gate Verification (Mutation Risk Control)
167        let capsule = self
168            .gate
169            .execute_gate(agent.id, prompt, &final_response, confidence, capabilities)
170            .await;
171
172        if capsule.outcome == "HALT" {
173            return Err(format!("Gate Blocking: {}", capsule.reason_code));
174        }
175
176        // Step 3: Create context packet with hash
177        let mut context = ContextPacket::new(&final_response);
178        context.source_agent = Some(agent.id);
179        context.importance = confidence;
180
181        // Step 4: Update agent's context
182        agent.context = context.clone();
183        agent.fitness = confidence;
184
185        let result = ExecutionResult {
186            agent_id: agent.id,
187            response: final_response,
188            verified,
189            confidence,
190            trace_root: context.trace_root.clone(),
191            context: context.clone(),
192            debate,
193            evidence: Some(capsule.clone()),
194        };
195
196        // Step 5: Automatic Hardware-Signed Audit Log (Phase 3)
197        if let Some(store) = &self.audit_store {
198            let _ = store
199                .log(
200                    tenant_id,
201                    vex_core::audit::AuditEventType::AgentExecuted,
202                    vex_core::audit::ActorType::Bot(agent.id),
203                    Some(agent.id),
204                    serde_json::json!({
205                        "prompt": prompt,
206                        "confidence": confidence,
207                        "verified": verified,
208                    }),
209                    self.identity.as_ref().map(|id| id.as_ref()),
210                    Some(capsule.witness_receipt.clone()),
211                    capsule.vep_blob.clone(),
212                )
213                .await;
214        }
215
216        Ok(result)
217    }
218
219    /// Run adversarial verification with Red agent
220    async fn run_adversarial_verification(
221        &self,
222        blue_agent: &Agent,
223        _original_prompt: &str,
224        blue_response: &str,
225    ) -> Result<(String, bool, f64, Option<Debate>), String> {
226        // Create shadow agent
227        let shadow = ShadowAgent::new(blue_agent, ShadowConfig::default());
228
229        // Create debate
230        let mut debate = Debate::new(blue_agent.id, shadow.agent.id, blue_response);
231
232        // Initialize weighted consensus
233        let mut consensus = Consensus::new(ConsensusProtocol::WeightedConfidence);
234
235        // Run debate rounds
236        for round_num in 1..=self.config.max_debate_rounds {
237            // Red agent challenges
238            let mut challenge_prompt = shadow.challenge_prompt(blue_response);
239            challenge_prompt.push_str("\n\nIMPORTANT: Respond in valid JSON format: {\"is_challenge\": boolean, \"confidence\": float (0.0-1.0), \"reasoning\": \"string\", \"suggested_revision\": \"string\" | null}. If you agree with the statement, set is_challenge to false.");
240
241            let red_output = self
242                .llm
243                .complete(LlmRequest::with_role(
244                    &shadow.agent.config.role,
245                    &challenge_prompt,
246                ))
247                .await
248                .map_err(|e| e.to_string())?
249                .content;
250
251            // Try to parse JSON response — fail closed on parse errors
252            let (is_challenge, red_confidence, red_reasoning, _suggested_revision) =
253                if let Ok(res) = serde_json::from_str::<ChallengeResponse>(&red_output) {
254                    (
255                        res.is_challenge,
256                        res.confidence,
257                        res.reasoning,
258                        res.suggested_revision,
259                    )
260                } else if let Some(start) = red_output.find('{') {
261                    if let Some(end) = red_output.rfind('}') {
262                        if let Ok(res) =
263                            serde_json::from_str::<ChallengeResponse>(&red_output[start..=end])
264                        {
265                            (
266                                res.is_challenge,
267                                res.confidence,
268                                res.reasoning,
269                                res.suggested_revision,
270                            )
271                        } else {
272                            // Fail closed: treat unparseable response as a challenge
273                            (true, 0.5, red_output.clone(), None)
274                        }
275                    } else {
276                        // Fail closed
277                        (true, 0.5, "Parsing failed".to_string(), None)
278                    }
279                } else {
280                    // Fail closed
281                    (true, 0.5, "No JSON found".to_string(), None)
282                };
283
284            let rebuttal = if is_challenge {
285                let rebuttal_prompt = format!(
286                    "Your previous response was challenged by a Red agent:\n\n\
287                     Original: \"{}\"\n\n\
288                     Challenge: \"{}\"\n\n\
289                     Please address these concerns or provide a revised response.",
290                    blue_response, red_reasoning
291                );
292                Some(
293                    self.llm
294                        .complete(LlmRequest::with_role(
295                            &blue_agent.config.role,
296                            &rebuttal_prompt,
297                        ))
298                        .await
299                        .map_err(|e| e.to_string())?
300                        .content,
301                )
302            } else {
303                None
304            };
305
306            debate.add_round(DebateRound {
307                round: round_num,
308                blue_claim: blue_response.to_string(),
309                red_challenge: red_reasoning.clone(),
310                blue_rebuttal: rebuttal,
311            });
312
313            // Vote: Red votes based on whether it found a challenge
314            consensus.add_vote(Vote {
315                agent_id: shadow.agent.id,
316                agrees: !is_challenge,
317                confidence: red_confidence,
318                reasoning: Some(red_reasoning),
319            });
320
321            if !is_challenge {
322                break;
323            }
324        }
325
326        // Blue agent reflects on the debate and decides its final vote (Fix for #3 bias)
327        let mut reflection_prompt = format!(
328            "You have just finished an adversarial debate about your original response.\n\n\
329             Original Response: \"{}\"\n\n\
330             Debate Rounds:\n",
331            blue_response
332        );
333
334        for (i, round) in debate.rounds.iter().enumerate() {
335            reflection_prompt.push_str(&format!(
336                "Round {}: Red challenged: \"{}\" -> You rebutted: \"{}\"\n",
337                i + 1,
338                round.red_challenge,
339                round.blue_rebuttal.as_deref().unwrap_or("N/A")
340            ));
341        }
342
343        reflection_prompt.push_str("\nBased on this debate, do you still stand by your original response? \
344                                    Respond in valid JSON: {\"agrees\": boolean, \"confidence\": float (0.0-1.0), \"reasoning\": \"string\"}.");
345
346        let blue_vote_res = self
347            .llm
348            .complete(LlmRequest::with_role(
349                &blue_agent.config.role,
350                &reflection_prompt,
351            ))
352            .await;
353
354        // Fail closed: on parse failure, blue does NOT agree (conservative)
355        let (blue_agrees, blue_confidence, blue_reasoning) = if let Ok(resp) = blue_vote_res {
356            if let Ok(vote) = serde_json::from_str::<VoteResponse>(&resp.content) {
357                (vote.agrees, vote.confidence, vote.reflection)
358            } else if let Some(start) = resp.content.find('{') {
359                if let Some(end) = resp.content.rfind('}') {
360                    if let Ok(vote) =
361                        serde_json::from_str::<VoteResponse>(&resp.content[start..=end])
362                    {
363                        (vote.agrees, vote.confidence, vote.reflection)
364                    } else {
365                        (
366                            false,
367                            blue_agent.fitness,
368                            "Failed to parse reflection JSON".to_string(),
369                        )
370                    }
371                } else {
372                    (
373                        false,
374                        blue_agent.fitness,
375                        "No JSON in reflection".to_string(),
376                    )
377                }
378            } else {
379                (
380                    false,
381                    blue_agent.fitness,
382                    "No reflection content".to_string(),
383                )
384            }
385        } else {
386            (
387                false,
388                blue_agent.fitness,
389                "Reflection LLM call failed".to_string(),
390            )
391        };
392
393        consensus.add_vote(Vote {
394            agent_id: blue_agent.id,
395            agrees: blue_agrees,
396            confidence: blue_confidence,
397            reasoning: Some(blue_reasoning),
398        });
399
400        consensus.evaluate();
401
402        // Determine final response
403        let final_response = if consensus.reached && consensus.decision == Some(true) {
404            blue_response.to_string()
405        } else if let Some(last_round) = debate.rounds.last() {
406            // Use rebuttal if available, otherwise original
407            last_round
408                .blue_rebuttal
409                .clone()
410                .unwrap_or_else(|| blue_response.to_string())
411        } else {
412            blue_response.to_string()
413        };
414
415        let verified = consensus.reached;
416        let confidence = consensus.confidence;
417
418        // Fail closed: if no consensus decision, reject the claim
419        debate.conclude(consensus.decision.unwrap_or(false), confidence);
420
421        Ok((final_response, verified, confidence, Some(debate)))
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use vex_core::AgentConfig;
429
430    #[tokio::test]
431    async fn test_executor() {
432        use crate::gate::GenericGateMock;
433        use vex_llm::MockProvider;
434        let llm = Arc::new(MockProvider::smart());
435        let gate = Arc::new(GenericGateMock);
436        let config = ExecutorConfig {
437            enable_adversarial: false,
438            ..Default::default()
439        };
440        let executor = AgentExecutor::new(llm, config, gate);
441        let mut agent = Agent::new(AgentConfig::default());
442
443        let result = executor
444            .execute("test-tenant", &mut agent, "Test prompt", vec![])
445            .await
446            .unwrap();
447        assert!(!result.response.is_empty());
448        // verified is false by design when enable_adversarial = false
449        assert!(!result.verified);
450    }
451}