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    /// ZK Verifier (Phase 4)
88    pub verifier: Option<Arc<dyn vex_core::zk::ZkVerifier>>,
89}
90
91impl<L: LlmProvider + ?Sized> std::fmt::Debug for AgentExecutor<L> {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("AgentExecutor")
94            .field("config", &self.config)
95            .field("identity", &self.identity)
96            .finish()
97    }
98}
99
100impl<L: LlmProvider + ?Sized> Clone for AgentExecutor<L> {
101    fn clone(&self) -> Self {
102        Self {
103            config: self.config.clone(),
104            llm: self.llm.clone(),
105            gate: self.gate.clone(),
106            audit_store: self.audit_store.clone(),
107            identity: self.identity.clone(),
108            verifier: self.verifier.clone(),
109        }
110    }
111}
112
113impl<L: LlmProvider + ?Sized> AgentExecutor<L> {
114    /// Create a new executor
115    pub fn new(llm: Arc<L>, config: ExecutorConfig, gate: Arc<dyn Gate>) -> Self {
116        Self {
117            config,
118            llm,
119            gate,
120            audit_store: None,
121            identity: None,
122            verifier: None,
123        }
124    }
125
126    /// Attach a hardware-rooted identity and audit store (Phase 3)
127    pub fn with_identity(
128        mut self,
129        identity: Arc<AgentIdentity>,
130        audit_store: Arc<AuditStore<dyn StorageBackend>>,
131    ) -> Self {
132        self.identity = Some(identity);
133        self.audit_store = Some(audit_store);
134        self
135    }
136
137    /// Attach a ZK Verifier (Phase 4)
138    pub fn with_verifier(mut self, verifier: Arc<dyn vex_core::zk::ZkVerifier>) -> Self {
139        self.verifier = Some(verifier);
140        self
141    }
142
143    /// Execute an agent with a prompt and return the result
144    pub async fn execute(
145        &self,
146        tenant_id: &str, // Added tenant_id for audit logging
147        agent: &mut Agent,
148        prompt: &str,
149        intent_data: Option<vex_core::segment::IntentData>,
150        capabilities: Vec<Capability>,
151    ) -> Result<ExecutionResult, String> {
152        // Step 1: Format context and get initial response from Blue agent
153        let full_prompt = if !agent.context.content.is_empty() {
154            format!(
155                "Previous Context (Time: {}):\n\"{}\"\n\nActive Prompt:\n\"{}\"",
156                agent.context.created_at, agent.context.content, prompt
157            )
158        } else {
159            prompt.to_string()
160        };
161
162        let blue_response = self
163            .llm
164            .complete(LlmRequest::with_role(&agent.config.role, &full_prompt))
165            .await
166            .map_err(|e| e.to_string())?
167            .content;
168
169        // Step 2: If adversarial is enabled, run debate
170        let (final_response, verified, confidence, debate) = if self.config.enable_adversarial {
171            self.run_adversarial_verification(agent, prompt, &blue_response)
172                .await?
173        } else {
174            (blue_response, false, 0.5, None)
175        };
176
177        // Step 2.5: Policy Gate Verification (Mutation Risk Control)
178        let capsule = self
179            .gate
180            .execute_gate(
181                agent.id,
182                prompt,
183                &final_response,
184                intent_data,
185                confidence,
186                &capabilities,
187                Uuid::new_v4(),
188            )
189            .await;
190
191        if capsule.outcome == "HALT" {
192            return Err(format!("Gate Blocking (HALT): {}", capsule.reason_code));
193        }
194
195        if capsule.outcome == "ESCALATE" {
196            let escalation_id = capsule
197                .escalation_id
198                .clone()
199                .unwrap_or_else(|| "unknown".into());
200            tracing::warn!(agent_id = %agent.id, escalation_id = %escalation_id, "Gate requires EMS escalation before proceeding.");
201            return Err(format!("AEM_ESCALATION_REQUIRED: {}", escalation_id));
202        }
203
204        // Step 2.6: Governed Execution Verification (The AEM Trap)
205        // If capabilities are requested, we MUST have a valid, context-bound Continuation Token.
206        if !capabilities.is_empty() {
207            if let Some(token) = &capsule.continuation_token {
208                let aid = self.identity.as_ref().map(|id| id.agent_id.clone());
209
210                // Binding Surface: Intent Hash (Merkle-hardened representation of the current command)
211                use sha2::{Digest, Sha256};
212                let intent_hash = hex::encode(Sha256::digest(final_response.as_bytes()));
213
214                // Perform Stateless Edge Verification
215                self.gate
216                    .verify_token(
217                        token,
218                        aid.as_deref(),
219                        Some(&intent_hash),
220                        Some(&token.payload.execution_target.circuit_id),
221                    )
222                    .await
223                    .map_err(|e| format!("AEM_GOVERNANCE_VIOLATION: {}", e))?;
224
225                // Phase 4: ZK Re-verification (High Assurance)
226                // If we have a Shadow Intent and a local verifier, re-check the STARK proof.
227                if let (Some(verifier), Some(intent)) = (&self.verifier, &capsule.intent_data) {
228                    if let vex_core::segment::IntentData::Shadow { .. } = intent {
229                        intent
230                            .verify_shadow(verifier.as_ref())
231                            .map_err(|e| format!("AEM_GOVERNANCE_VIOLATION (ZK_FAIL): {}", e))?;
232
233                        tracing::info!(
234                            agent_id = %agent.id,
235                            "AEM: STARK proof re-verified locally for Shadow Intent."
236                        );
237                    }
238                }
239
240                tracing::info!(
241                    agent_id = %agent.id,
242                    intent_hash = %intent_hash,
243                    "AEM: Governed execution permitted via validated token."
244                );
245            } else {
246                // Fail-Closed: No token = No privileged action.
247                return Err("AEM_GOVERNANCE_VIOLATION: Privileged action requires a valid continuation token (Escalation Required).".to_string());
248            }
249        }
250
251        // Step 3: Create context packet with hash
252        let mut context = ContextPacket::new(&final_response);
253        context.source_agent = Some(agent.id);
254        context.importance = confidence;
255
256        // Step 4: Update agent's context
257        agent.context = context.clone();
258        agent.fitness = confidence;
259
260        let result = ExecutionResult {
261            agent_id: agent.id,
262            response: final_response,
263            verified,
264            confidence,
265            trace_root: context.trace_root.clone(),
266            context: context.clone(),
267            debate,
268            evidence: Some(capsule.clone()),
269        };
270
271        // Step 5: Automatic Hardware-Signed Audit Log (Phase 3)
272        if let Some(store) = &self.audit_store {
273            let _ = store
274                .log(
275                    tenant_id,
276                    vex_core::audit::AuditEventType::AgentExecuted,
277                    vex_core::audit::ActorType::Bot(agent.id),
278                    Some(agent.id),
279                    serde_json::json!({
280                        "prompt": prompt,
281                        "confidence": confidence,
282                        "verified": verified,
283                    }),
284                    self.identity.as_ref().map(|id| id.as_ref()),
285                    Some(capsule.witness_receipt.clone()),
286                    capsule.vep_blob.clone(),
287                )
288                .await;
289        }
290
291        Ok(result)
292    }
293
294    /// Run adversarial verification with Red agent
295    async fn run_adversarial_verification(
296        &self,
297        blue_agent: &Agent,
298        _original_prompt: &str,
299        blue_response: &str,
300    ) -> Result<(String, bool, f64, Option<Debate>), String> {
301        // Create shadow agent
302        let shadow = ShadowAgent::new(blue_agent, ShadowConfig::default());
303
304        // Create debate
305        let mut debate = Debate::new(blue_agent.id, shadow.agent.id, blue_response);
306
307        // Initialize weighted consensus
308        let mut consensus = Consensus::new(ConsensusProtocol::WeightedConfidence);
309
310        // Run debate rounds
311        for round_num in 1..=self.config.max_debate_rounds {
312            // Red agent challenges
313            let mut challenge_prompt = shadow.challenge_prompt(blue_response);
314            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.");
315
316            let red_output = self
317                .llm
318                .complete(LlmRequest::with_role(
319                    &shadow.agent.config.role,
320                    &challenge_prompt,
321                ))
322                .await
323                .map_err(|e| e.to_string())?
324                .content;
325
326            // Try to parse JSON response — fail closed on parse errors
327            let (is_challenge, red_confidence, red_reasoning, _suggested_revision) =
328                if let Ok(res) = serde_json::from_str::<ChallengeResponse>(&red_output) {
329                    (
330                        res.is_challenge,
331                        res.confidence,
332                        res.reasoning,
333                        res.suggested_revision,
334                    )
335                } else if let Some(start) = red_output.find('{') {
336                    if let Some(end) = red_output.rfind('}') {
337                        if let Ok(res) =
338                            serde_json::from_str::<ChallengeResponse>(&red_output[start..=end])
339                        {
340                            (
341                                res.is_challenge,
342                                res.confidence,
343                                res.reasoning,
344                                res.suggested_revision,
345                            )
346                        } else {
347                            // Fail closed: treat unparseable response as a challenge
348                            (true, 0.5, red_output.clone(), None)
349                        }
350                    } else {
351                        // Fail closed
352                        (true, 0.5, "Parsing failed".to_string(), None)
353                    }
354                } else {
355                    // Fail closed
356                    (true, 0.5, "No JSON found".to_string(), None)
357                };
358
359            let rebuttal = if is_challenge {
360                let rebuttal_prompt = format!(
361                    "Your previous response was challenged by a Red agent:\n\n\
362                     Original: \"{}\"\n\n\
363                     Challenge: \"{}\"\n\n\
364                     Please address these concerns or provide a revised response.",
365                    blue_response, red_reasoning
366                );
367                Some(
368                    self.llm
369                        .complete(LlmRequest::with_role(
370                            &blue_agent.config.role,
371                            &rebuttal_prompt,
372                        ))
373                        .await
374                        .map_err(|e| e.to_string())?
375                        .content,
376                )
377            } else {
378                None
379            };
380
381            debate.add_round(DebateRound {
382                round: round_num,
383                blue_claim: blue_response.to_string(),
384                red_challenge: red_reasoning.clone(),
385                blue_rebuttal: rebuttal,
386            });
387
388            // Vote: Red votes based on whether it found a challenge
389            consensus.add_vote(Vote {
390                agent_id: shadow.agent.id,
391                agrees: !is_challenge,
392                confidence: red_confidence,
393                reasoning: Some(red_reasoning),
394            });
395
396            if !is_challenge {
397                break;
398            }
399        }
400
401        // Blue agent reflects on the debate and decides its final vote (Fix for #3 bias)
402        let mut reflection_prompt = format!(
403            "You have just finished an adversarial debate about your original response.\n\n\
404             Original Response: \"{}\"\n\n\
405             Debate Rounds:\n",
406            blue_response
407        );
408
409        for (i, round) in debate.rounds.iter().enumerate() {
410            reflection_prompt.push_str(&format!(
411                "Round {}: Red challenged: \"{}\" -> You rebutted: \"{}\"\n",
412                i + 1,
413                round.red_challenge,
414                round.blue_rebuttal.as_deref().unwrap_or("N/A")
415            ));
416        }
417
418        reflection_prompt.push_str("\nBased on this debate, do you still stand by your original response? \
419                                    Respond in valid JSON: {\"agrees\": boolean, \"confidence\": float (0.0-1.0), \"reasoning\": \"string\"}.");
420
421        let blue_vote_res = self
422            .llm
423            .complete(LlmRequest::with_role(
424                &blue_agent.config.role,
425                &reflection_prompt,
426            ))
427            .await;
428
429        // Fail closed: on parse failure, blue does NOT agree (conservative)
430        let (blue_agrees, blue_confidence, blue_reasoning) = if let Ok(resp) = blue_vote_res {
431            if let Ok(vote) = serde_json::from_str::<VoteResponse>(&resp.content) {
432                (vote.agrees, vote.confidence, vote.reflection)
433            } else if let Some(start) = resp.content.find('{') {
434                if let Some(end) = resp.content.rfind('}') {
435                    if let Ok(vote) =
436                        serde_json::from_str::<VoteResponse>(&resp.content[start..=end])
437                    {
438                        (vote.agrees, vote.confidence, vote.reflection)
439                    } else {
440                        (
441                            false,
442                            blue_agent.fitness,
443                            "Failed to parse reflection JSON".to_string(),
444                        )
445                    }
446                } else {
447                    (
448                        false,
449                        blue_agent.fitness,
450                        "No JSON in reflection".to_string(),
451                    )
452                }
453            } else {
454                (
455                    false,
456                    blue_agent.fitness,
457                    "No reflection content".to_string(),
458                )
459            }
460        } else {
461            (
462                false,
463                blue_agent.fitness,
464                "Reflection LLM call failed".to_string(),
465            )
466        };
467
468        consensus.add_vote(Vote {
469            agent_id: blue_agent.id,
470            agrees: blue_agrees,
471            confidence: blue_confidence,
472            reasoning: Some(blue_reasoning),
473        });
474
475        consensus.evaluate();
476
477        // Determine final response
478        let final_response = if consensus.reached && consensus.decision == Some(true) {
479            blue_response.to_string()
480        } else if let Some(last_round) = debate.rounds.last() {
481            // Use rebuttal if available, otherwise original
482            last_round
483                .blue_rebuttal
484                .clone()
485                .unwrap_or_else(|| blue_response.to_string())
486        } else {
487            blue_response.to_string()
488        };
489
490        let verified = consensus.reached;
491        let confidence = consensus.confidence;
492
493        // Fail closed: if no consensus decision, reject the claim
494        debate.conclude(consensus.decision.unwrap_or(false), confidence);
495
496        Ok((final_response, verified, confidence, Some(debate)))
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use vex_core::AgentConfig;
504
505    #[tokio::test]
506    async fn test_executor() {
507        use crate::gate::GenericGateMock;
508        use vex_llm::MockProvider;
509        let llm = Arc::new(MockProvider::smart());
510        let gate = Arc::new(GenericGateMock);
511        let config = ExecutorConfig {
512            enable_adversarial: false,
513            ..Default::default()
514        };
515        let executor = AgentExecutor::new(llm, config, gate);
516        let mut agent = Agent::new(AgentConfig::default());
517
518        let result = executor
519            .execute("test-tenant", &mut agent, "Test prompt", None, vec![])
520            .await
521            .unwrap();
522        assert!(!result.response.is_empty());
523        // verified is false by design when enable_adversarial = false
524        assert!(!result.verified);
525    }
526}