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