Skip to main content

icebox/ai/
agent.rs

1use std::collections::HashMap;
2use tracing::info;
3
4use crate::core::framework::SharedFramework;
5use crate::core::job::Job;
6use crate::core::safety::{
7    make_config_policy, now_secs, MemoryKind, PolicyContext, ReasoningTrace, RiskLevel,
8};
9use crate::core::session::{Session, SessionId, SessionKind};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Phase {
13    Idle,
14    Scan,
15    Analyze,
16    Plan,
17    Execute,
18    Report,
19    Done,
20}
21
22impl Phase {
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            Phase::Idle => "idle",
26            Phase::Scan => "scan",
27            Phase::Analyze => "analyze",
28            Phase::Plan => "plan",
29            Phase::Execute => "execute",
30            Phase::Report => "report",
31            Phase::Done => "done",
32        }
33    }
34}
35
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct Action {
38    pub module: String,
39    #[serde(default)]
40    pub options: HashMap<String, String>,
41    pub target: String,
42    pub priority: u32,
43    pub reason: String,
44}
45
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47struct PlanOutput {
48    actions: Vec<Action>,
49}
50
51#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct ScanFinding {
53    pub module: String,
54    pub finding: Option<String>,
55    pub evidence: Vec<String>,
56    pub data: serde_json::Value,
57}
58
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct AnalysisOutput {
61    pub summary: String,
62    pub vulnerabilities: Vec<String>,
63    pub recommended_modules: Vec<String>,
64}
65
66#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67pub struct ReportOutput {
68    pub title: String,
69    pub summary: String,
70    pub findings: Vec<String>,
71    pub actions_taken: Vec<String>,
72    pub recommendations: Vec<String>,
73}
74
75#[async_trait::async_trait]
76pub trait Planner: Send + Sync {
77    async fn analyze(&self, context: &str) -> anyhow::Result<AnalysisOutput>;
78    async fn plan(&self, context: &str) -> anyhow::Result<Vec<Action>>;
79    async fn summarize(&self, context: &str) -> anyhow::Result<ReportOutput>;
80}
81
82pub trait PlanApprover: Send + Sync {
83    fn approve(&self, actions: &[Action]) -> bool;
84}
85
86pub struct AlwaysApprove;
87impl PlanApprover for AlwaysApprove {
88    fn approve(&self, _actions: &[Action]) -> bool {
89        true
90    }
91}
92
93pub struct DenyPlan;
94impl PlanApprover for DenyPlan {
95    fn approve(&self, _actions: &[Action]) -> bool {
96        false
97    }
98}
99
100pub struct InteractiveApprover;
101impl PlanApprover for InteractiveApprover {
102    fn approve(&self, actions: &[Action]) -> bool {
103        if actions.is_empty() {
104            println!("agent: plan is empty  -  nothing to execute.");
105            return false;
106        }
107        println!("\n=== PROPOSED PLAN ({} action(s)) ===", actions.len());
108        let mut sorted = actions.to_vec();
109        sorted.sort_by_key(|a| std::cmp::Reverse(a.priority));
110        for (i, a) in sorted.iter().enumerate() {
111            let opts: Vec<String> = a.options.iter().map(|(k, v)| format!("{k}={v}")).collect();
112            println!(
113                "  {}. [prio {}] {} -> {} | {} | opts: [{}]",
114                i + 1,
115                a.priority,
116                a.module,
117                a.target,
118                a.reason,
119                opts.join(", ")
120            );
121        }
122        print!("Execute this plan? [y/N]: ");
123        use std::io::Write;
124        let _ = std::io::stdout().flush();
125        let mut line = String::new();
126        if std::io::stdin().read_line(&mut line).is_ok() {
127            let trimmed = line.trim().to_lowercase();
128            return matches!(trimmed.as_str(), "y" | "yes");
129        }
130        false
131    }
132}
133
134pub fn build_module_catalog() -> String {
135    let entries = crate::modules::discover();
136    let mut lines = Vec::new();
137    for e in entries {
138        let info = (e.info)();
139        let loaded = match crate::modules::load(&info.name) {
140            Some(l) => l,
141            None => continue,
142        };
143        let opt_desc = match loaded.module.options_json() {
144            serde_json::Value::Object(map) if !map.is_empty() => map
145                .iter()
146                .map(|(k, v)| {
147                    let t = if v.is_u64() || v.is_i64() {
148                        "int"
149                    } else if v.is_boolean() {
150                        "bool"
151                    } else {
152                        "str"
153                    };
154                    format!("{k}:{t}")
155                })
156                .collect::<Vec<_>>()
157                .join(", "),
158            _ => String::new(),
159        };
160        lines.push(format!(
161            "- {} [{}]: {}. options: [{}]",
162            info.name,
163            info.kind.as_str(),
164            info.description,
165            opt_desc
166        ));
167    }
168    lines.join("\n")
169}
170
171pub enum AiClient {
172    Ollama(crate::ai::ollama::OllamaClient),
173    OpenAi(crate::ai::openai::OpenAiClient),
174}
175
176impl AiClient {
177    pub async fn prompt(
178        &self,
179        system: &str,
180        user: &str,
181        format: Option<serde_json::Value>,
182    ) -> anyhow::Result<String> {
183        match self {
184            Self::Ollama(c) => c.prompt(system, user, format).await,
185            Self::OpenAi(c) => c.prompt(system, user, format).await,
186        }
187    }
188}
189
190pub struct LlmPlanner {
191    llm: AiClient,
192    catalog: String,
193}
194
195impl LlmPlanner {
196    pub fn new_with_catalog(model: impl Into<String>, catalog: String) -> Self {
197        let model = model.into();
198        let llm = if let Ok(key) = std::env::var("OPENAI_API_KEY") {
199            AiClient::OpenAi(crate::ai::openai::OpenAiClient::new(model, key))
200        } else {
201            AiClient::Ollama(crate::ai::ollama::OllamaClient::new(model))
202        };
203        LlmPlanner { llm, catalog }
204    }
205
206    pub fn new(model: impl Into<String>) -> Self {
207        Self::new_with_catalog(model, build_module_catalog())
208    }
209
210    fn system_prompt(&self) -> String {
211        format!(
212            "{}\n\nAVAILABLE MODULES (use exact names, set options by the key names below):\n{}\n",
213            SYSTEM_PROMPT, self.catalog
214        )
215    }
216    fn analysis_schema() -> serde_json::Value {
217        serde_json::json!({
218            "type": "object",
219            "properties": {
220                "summary": {"type": "string"},
221                "vulnerabilities": {"type": "array", "items": {"type": "string"}},
222                "recommended_modules": {"type": "array", "items": {"type": "string"}}
223            },
224            "required": ["summary", "vulnerabilities", "recommended_modules"]
225        })
226    }
227
228    fn plan_schema() -> serde_json::Value {
229        serde_json::json!({
230            "type": "object",
231            "properties": {
232                "actions": {
233                    "type": "array",
234                    "items": {
235                        "type": "object",
236                        "properties": {
237                            "module": {"type": "string"},
238                            "options": {"type": "object"},
239                            "target": {"type": "string"},
240                            "priority": {"type": "integer"},
241                            "reason": {"type": "string"}
242                        },
243                        "required": ["module", "target", "priority", "reason"]
244                    }
245                }
246            },
247            "required": ["actions"]
248        })
249    }
250
251    fn report_schema() -> serde_json::Value {
252        serde_json::json!({
253            "type": "object",
254            "properties": {
255                "title": {"type": "string"},
256                "summary": {"type": "string"},
257                "findings": {"type": "array", "items": {"type": "string"}},
258                "actions_taken": {"type": "array", "items": {"type": "string"}},
259                "recommendations": {"type": "array", "items": {"type": "string"}}
260            },
261            "required": ["title", "summary", "findings", "actions_taken", "recommendations"]
262        })
263    }
264}
265
266const SYSTEM_PROMPT: &str = r#"You are ICEBOX, an autonomous network security assessment agent.
267You MUST analyze the real scan data provided in SCAN RESULTS. Do NOT fabricate data.
268
269Rules:
2701. Only target IPs/hosts the operator explicitly provides as in-scope.
2712. Never recommend destructive actions (wipe, format, destroy, delete, shutdown).
2723. Base ALL analysis and plans on the SCAN RESULTS section. If it says "NONE", report no data.
2734. Always output valid JSON matching the requested schema.
2745. Be concise and technical. Use exact module names from the scan list.
275"#;
276
277const ANALYSIS_PROMPT: &str = r#"Given the scan results above, analyze the target for potential vulnerabilities.
278Output your analysis as JSON with:
279- summary: brief finding
280- vulnerabilities: list of potential vulnerabilities
281- recommended_modules: which modules to run next
282"#;
283
284const PLAN_PROMPT: &str = r#"Based on the analysis, produce a prioritized plan of actions.
285Each action runs a module against the target.
286Output a JSON object with an "actions" array.
287Each action has: module (name from registry), options (key-value), target (IP/host), priority (1-100), reason.
288"#;
289
290const REPORT_PROMPT: &str = r#"Summarize the complete assessment. Output JSON with:
291- title: campaign title
292- summary: key outcomes
293- findings: list of findings
294- actions_taken: what was run
295- recommendations: next steps
296"#;
297
298#[async_trait::async_trait]
299impl Planner for LlmPlanner {
300    async fn analyze(&self, context: &str) -> anyhow::Result<AnalysisOutput> {
301        let prompt = format!("{}\n\n{}", ANALYSIS_PROMPT, context);
302        let reply = self
303            .llm
304            .prompt(
305                &self.system_prompt(),
306                &prompt,
307                Some(Self::analysis_schema()),
308            )
309            .await?;
310        let cleaned = clean_json(&reply);
311        match serde_json::from_str::<AnalysisOutput>(&cleaned) {
312            Ok(a) => Ok(a),
313            Err(_) => {
314                let raw = self
315                    .llm
316                    .prompt(&self.system_prompt(), &prompt, None)
317                    .await?;
318                let cleaned = clean_json(&raw);
319                Ok(serde_json::from_str::<AnalysisOutput>(&cleaned)?)
320            }
321        }
322    }
323
324    async fn plan(&self, context: &str) -> anyhow::Result<Vec<Action>> {
325        let prompt = format!("{}\n\n{}", PLAN_PROMPT, context);
326        let reply = self
327            .llm
328            .prompt(&self.system_prompt(), &prompt, Some(Self::plan_schema()))
329            .await?;
330        let cleaned = clean_json(&reply);
331        match serde_json::from_str::<PlanOutput>(&cleaned) {
332            Ok(plan) => Ok(plan.actions),
333            Err(_) => {
334                let raw = self
335                    .llm
336                    .prompt(&self.system_prompt(), &prompt, None)
337                    .await?;
338                let cleaned = clean_json(&raw);
339                let plan: PlanOutput = serde_json::from_str(&cleaned)?;
340                Ok(plan.actions)
341            }
342        }
343    }
344
345    async fn summarize(&self, context: &str) -> anyhow::Result<ReportOutput> {
346        let prompt = format!("{}\n\n{}", REPORT_PROMPT, context);
347        let reply = self
348            .llm
349            .prompt(&self.system_prompt(), &prompt, Some(Self::report_schema()))
350            .await?;
351        let cleaned = clean_json(&reply);
352        match serde_json::from_str::<ReportOutput>(&cleaned) {
353            Ok(r) => Ok(r),
354            Err(_) => {
355                let raw = self
356                    .llm
357                    .prompt(&self.system_prompt(), &prompt, None)
358                    .await?;
359                let cleaned = clean_json(&raw);
360                Ok(serde_json::from_str::<ReportOutput>(&cleaned)?)
361            }
362        }
363    }
364}
365
366pub struct Agent {
367    planner: Box<dyn Planner>,
368    fw: SharedFramework,
369    phase: Phase,
370    target: String,
371    max_risk: RiskLevel,
372    approved: bool,
373    plan_approver: Option<Box<dyn PlanApprover>>,
374    max_iterations: usize,
375    max_context_chars: usize,
376    job_ids: Vec<crate::core::job::JobId>,
377    session_ids: Vec<SessionId>,
378    logs: Vec<String>,
379    scan_results: Vec<ScanFinding>,
380    executed_results: Vec<ScanFinding>,
381}
382
383impl Agent {
384    pub fn new(
385        planner: Box<dyn Planner>,
386        fw: SharedFramework,
387        target: impl Into<String>,
388        max_risk: RiskLevel,
389    ) -> Self {
390        Agent {
391            planner,
392            fw,
393            phase: Phase::Idle,
394            target: target.into(),
395            max_risk,
396            approved: false,
397            plan_approver: None,
398            max_iterations: 3,
399            max_context_chars: 8000,
400            job_ids: Vec::new(),
401            session_ids: Vec::new(),
402            logs: Vec::new(),
403            scan_results: Vec::new(),
404            executed_results: Vec::new(),
405        }
406    }
407
408    pub fn set_approved(&mut self, v: bool) {
409        self.approved = v;
410    }
411
412    pub fn set_plan_approver(&mut self, approver: Box<dyn PlanApprover>) {
413        self.plan_approver = Some(approver);
414    }
415
416    pub async fn run(&mut self) -> anyhow::Result<CampaignResult> {
417        info!("agent: starting campaign against {}", self.target);
418        self.phase = Phase::Scan;
419        self.run_scan().await?;
420
421        let mut iteration = 0usize;
422        loop {
423            self.phase = Phase::Analyze;
424            self.run_analyze().await?;
425
426            self.phase = Phase::Plan;
427            let actions = self.run_plan().await?;
428
429            let proceed = match &self.plan_approver {
430                Some(approver) => approver.approve(&actions),
431                None => true,
432            };
433            if !proceed {
434                self.logs
435                    .push("plan rejected by operator; stopping campaign".into());
436                break;
437            }
438            if actions.is_empty() {
439                self.logs
440                    .push("plan: no actions generated; stopping".into());
441                break;
442            }
443
444            self.phase = Phase::Execute;
445            let before = self.scan_results.len() + self.executed_results.len();
446            self.run_execute(actions).await?;
447            let produced = (self.scan_results.len() + self.executed_results.len()) - before;
448
449            iteration += 1;
450            if iteration >= self.max_iterations {
451                self.logs
452                    .push(format!("reached max iterations ({})", self.max_iterations));
453                break;
454            }
455            if produced == 0 {
456                self.logs
457                    .push("no new results this iteration; stopping".into());
458                break;
459            }
460            self.logs.push(format!(
461                "iteration {iteration} produced {produced} new result(s); re-analyzing"
462            ));
463        }
464
465        self.phase = Phase::Report;
466        let report = self.run_report().await?;
467
468        self.phase = Phase::Done;
469        Ok(CampaignResult {
470            summary: report.summary.clone(),
471            actions_taken: report.actions_taken.clone(),
472            sessions_opened: self.session_ids.clone(),
473            job_ids: self.job_ids.clone(),
474            report: serde_json::to_string_pretty(&report).unwrap_or_default(),
475        })
476    }
477
478    async fn run_scan(&mut self) -> anyhow::Result<()> {
479        let entries = crate::modules::discover();
480        let names: Vec<String> = entries.iter().map(|e| (e.info)().name.clone()).collect();
481
482        let scanners: Vec<_> = entries
483            .into_iter()
484            .filter(|e| {
485                let kind = (e.info)().kind;
486                matches!(
487                    kind,
488                    crate::core::module::ModuleKind::Scanner
489                        | crate::core::module::ModuleKind::Auxiliary
490                )
491            })
492            .collect();
493
494        if scanners.is_empty() {
495            self.logs.push(format!(
496                "scan: no scanner modules, available: {}",
497                names.join(", ")
498            ));
499            return Ok(());
500        }
501
502        for entry in scanners {
503            let info = (entry.info)();
504            let mut loaded = crate::modules::load(&info.name).unwrap();
505            if info.name == "tcp_port_scanner" {
506                let _ = loaded.module.set_option("host", &self.target);
507                let _ = loaded.module.set_option("ports", "1-1024");
508            }
509            if info.name == "http_probe" {
510                let _ = loaded.module.set_option("host", &self.target);
511                let _ = loaded.module.set_option("ports", "80,443,8080,8443,3000");
512            }
513            if info.name == "dns_resolver" {
514                let _ = loaded.module.set_option("hostname", &self.target);
515            }
516            if info.name == "service_fingerprinter" {
517                let _ = loaded.module.set_option("host", &self.target);
518                let _ = loaded
519                    .module
520                    .set_option("ports", "22,80,443,3306,5432,6379,8080,8443");
521            }
522            let mut fw = self.fw.lock().await;
523            let pf = fw
524                .executor
525                .preflight(
526                    &loaded,
527                    &self.target,
528                    None,
529                    self.approved,
530                    PolicyContext::Autonomous,
531                )
532                .await;
533            let policy = make_config_policy(
534                self.max_risk,
535                PolicyContext::Autonomous,
536                &fw.executor.policy_set,
537            );
538            if pf.check(&policy).is_err() {
539                self.logs
540                    .push(format!("scan: {} preflight blocked, skipping", info.name));
541                continue;
542            }
543            let job = Job::new(&info.name, &self.target);
544            let jid = job.id;
545            fw.jobs.register(job);
546            self.job_ids.push(jid);
547            match fw
548                .executor
549                .execute(
550                    &mut loaded,
551                    &self.target,
552                    None,
553                    self.approved,
554                    PolicyContext::Autonomous,
555                    Some(jid.as_u64()),
556                    false,
557                    None,
558                )
559                .await
560            {
561                Ok(r) => {
562                    fw.jobs.complete(jid, r.clone());
563                    self.logs.push(format!("scan: {} completed", info.name));
564                    self.scan_results.push(ScanFinding {
565                        module: info.name.clone(),
566                        finding: r.finding.clone(),
567                        evidence: r.evidence.clone(),
568                        data: r.data.clone(),
569                    });
570                }
571                Err(e) => {
572                    fw.jobs.cancel(jid);
573                    self.logs.push(format!("scan: {} error: {e}", info.name));
574                }
575            }
576        }
577        Ok(())
578    }
579
580    async fn run_analyze(&mut self) -> anyhow::Result<()> {
581        let context = self.build_context().await;
582        let analysis = self.planner.analyze(&context).await?;
583        self.logs.push(format!(
584            "analysis: {}  -  vulns: {}",
585            analysis.summary,
586            analysis.vulnerabilities.join(", ")
587        ));
588        let mut fw = self.fw.lock().await;
589        fw.executor.remember(
590            MemoryKind::Decision,
591            format!("analysis: {}", analysis.summary),
592        );
593        fw.executor.record_trace(ReasoningTrace {
594            at: now_secs(),
595            phase: "analyze".into(),
596            context_len: context.len(),
597            summary: analysis.summary.clone(),
598            actions: analysis.vulnerabilities.clone(),
599        });
600        Ok(())
601    }
602
603    async fn run_plan(&mut self) -> anyhow::Result<Vec<Action>> {
604        let context = self.build_context().await;
605        let actions = self.planner.plan(&context).await?;
606        self.logs
607            .push(format!("plan: {} actions generated", actions.len()));
608        let mut fw = self.fw.lock().await;
609        fw.executor.record_trace(ReasoningTrace {
610            at: now_secs(),
611            phase: "plan".into(),
612            context_len: context.len(),
613            summary: format!("{} actions generated", actions.len()),
614            actions: actions.iter().map(|a| a.module.clone()).collect(),
615        });
616        Ok(actions)
617    }
618
619    async fn run_execute(&mut self, actions: Vec<Action>) -> anyhow::Result<()> {
620        for action in actions {
621            let Some(loaded) = crate::modules::load(&action.module) else {
622                self.logs
623                    .push(format!("execute: module {} not found", action.module));
624                continue;
625            };
626            self.logs.push(format!(
627                "execute: {} -> {} ({})",
628                action.module, action.target, action.reason
629            ));
630
631            let mut loaded_mut = loaded;
632            for (k, v) in &action.options {
633                let _ = loaded_mut.module.set_option(k, v);
634            }
635
636            let mut fw = self.fw.lock().await;
637            let pf = fw
638                .executor
639                .preflight(
640                    &loaded_mut,
641                    &self.target,
642                    None,
643                    self.approved,
644                    PolicyContext::Autonomous,
645                )
646                .await;
647            let policy = make_config_policy(
648                self.max_risk,
649                PolicyContext::Autonomous,
650                &fw.executor.policy_set,
651            );
652            let cvss = fw
653                .executor
654                .recent_evidence(2000)
655                .iter()
656                .filter_map(|e| e.cvss())
657                .max_by(|a, b| {
658                    a.weighted_risk()
659                        .partial_cmp(&b.weighted_risk())
660                        .unwrap_or(std::cmp::Ordering::Equal)
661                });
662            let mut pf = pf;
663            pf.cvss = cvss;
664            if let Err(e) = pf.check(&policy) {
665                self.logs.push(format!("execute: preflight blocked: {e}"));
666                continue;
667            }
668
669            let job = Job::new(&action.module, &self.target);
670            let jid = job.id;
671            fw.jobs.register(job);
672            self.job_ids.push(jid);
673
674            match fw
675                .executor
676                .execute(
677                    &mut loaded_mut,
678                    &self.target,
679                    None,
680                    self.approved,
681                    PolicyContext::Autonomous,
682                    Some(jid.as_u64()),
683                    false,
684                    None,
685                )
686                .await
687            {
688                Ok(r) => {
689                    fw.jobs.complete(jid, r.clone());
690                    self.executed_results.push(ScanFinding {
691                        module: action.module.clone(),
692                        finding: r.finding.clone(),
693                        evidence: r.evidence.clone(),
694                        data: r.data.clone(),
695                    });
696                    let outcome = r
697                        .finding
698                        .clone()
699                        .unwrap_or_else(|| "no finding".to_string());
700                    fw.executor.remember(
701                        MemoryKind::Decision,
702                        format!("executed {}: {}", action.module, outcome),
703                    );
704                    if let Some(ref sid) = r.session_id {
705                        let kind = if sid.starts_with("session:") {
706                            SessionKind::Shell
707                        } else {
708                            SessionKind::Unknown
709                        };
710                        let sid2 =
711                            fw.sessions
712                                .register(Session::new(kind, &self.target, &action.module));
713                        self.session_ids.push(sid2);
714                    }
715                }
716                Err(e) => {
717                    fw.jobs.cancel(jid);
718                    fw.executor.remember(
719                        MemoryKind::Failure,
720                        format!("{} failed: {e}", action.module),
721                    );
722                    self.logs
723                        .push(format!("execute: {} error: {e}", action.module));
724                }
725            }
726        }
727        let modules: Vec<String> = self
728            .executed_results
729            .iter()
730            .map(|r| r.module.clone())
731            .collect();
732        let mut fw = self.fw.lock().await;
733        fw.executor.record_trace(ReasoningTrace {
734            at: now_secs(),
735            phase: "execute".into(),
736            context_len: 0,
737            summary: format!("executed {} action(s)", modules.len()),
738            actions: modules,
739        });
740        Ok(())
741    }
742
743    async fn run_report(&mut self) -> anyhow::Result<ReportOutput> {
744        let context = self.build_context().await;
745        let report = self.planner.summarize(&context).await?;
746        let mut fw = self.fw.lock().await;
747        fw.executor.record_trace(ReasoningTrace {
748            at: now_secs(),
749            phase: "report".into(),
750            context_len: context.len(),
751            summary: report.summary.clone(),
752            actions: report.recommendations.clone(),
753        });
754        Ok(report)
755    }
756
757    async fn build_context(&self) -> String {
758        let mut ctx = format!(
759            "Target: {}\nPhase: {}\n\n",
760            self.target,
761            self.phase.as_str()
762        );
763
764        if self.scan_results.is_empty() {
765            ctx.push_str("Initial Scan Results: NONE\n");
766        } else {
767            ctx.push_str("=== INITIAL SCAN RESULTS (real data about the target) ===\n");
768            for sr in &self.scan_results {
769                ctx.push_str(&format!(
770                    "  module: {}\n  finding: {}\n  evidence: {:?}\n  data: {}\n\n",
771                    sr.module,
772                    sr.finding.as_deref().unwrap_or("(none)"),
773                    sr.evidence,
774                    sr.data
775                ));
776            }
777        }
778
779        if !self.executed_results.is_empty() {
780            ctx.push_str("=== EXECUTED ACTIONS (results from prior plan steps) ===\n");
781            for sr in &self.executed_results {
782                ctx.push_str(&format!(
783                    "  module: {}\n  finding: {}\n  evidence: {:?}\n  data: {}\n\n",
784                    sr.module,
785                    sr.finding.as_deref().unwrap_or("(none)"),
786                    sr.evidence,
787                    sr.data
788                ));
789            }
790        }
791
792        let fw = self.fw.lock().await;
793        let jobs: Vec<String> = fw
794            .jobs
795            .list_recent(20)
796            .iter()
797            .map(|j| {
798                format!(
799                    "job {}: {} {} -> {}",
800                    j.id,
801                    j.module_name,
802                    j.target,
803                    j.status.as_str()
804                )
805            })
806            .collect();
807        ctx.push_str(&format!("Jobs:\n{}\n", jobs.join("\n")));
808
809        let sessions: Vec<String> = fw
810            .sessions
811            .list()
812            .iter()
813            .map(|s| format!("session {}: {} {}", s.id, s.kind.as_str(), s.target))
814            .collect();
815        ctx.push_str(&format!("Sessions:\n{}\n", sessions.join("\n")));
816
817        let memories: Vec<String> = fw
818            .executor
819            .recent_memories(20)
820            .iter()
821            .map(|m| format!("[{}] {}", m.kind.as_str(), m.text))
822            .collect();
823        if !memories.is_empty() {
824            ctx.push_str(&format!(
825                "Memory (planner learnings):\n{}\n",
826                memories.join("\n")
827            ));
828        }
829
830        if !self.logs.is_empty() {
831            let recent: Vec<String> = self.logs.iter().rev().take(40).rev().cloned().collect();
832            ctx.push_str(&format!("Logs (last 40):\n{}\n", recent.join("\n")));
833        }
834
835        if ctx.len() > self.max_context_chars {
836            if let Some(pos) = ctx.find("\n\n=== EXECUTED") {
837                let prefix = &ctx[..pos];
838                let suffix = &ctx[pos..];
839                let kept = if suffix.len() > self.max_context_chars {
840                    let start = suffix.len() - self.max_context_chars + 500;
841                    format!("\n\n[... context truncated ...]\n{}", &suffix[start..])
842                } else {
843                    suffix.to_string()
844                };
845                return format!("{prefix}{kept}");
846            }
847            let start = ctx.len().saturating_sub(self.max_context_chars);
848            format!("[... context truncated ...]\n{}", &ctx[start..])
849        } else {
850            ctx
851        }
852    }
853}
854
855#[derive(Debug, Clone)]
856pub struct CampaignResult {
857    pub summary: String,
858    pub actions_taken: Vec<String>,
859    pub sessions_opened: Vec<SessionId>,
860    pub job_ids: Vec<crate::core::job::JobId>,
861    pub report: String,
862}
863
864fn clean_json(raw: &str) -> String {
865    let raw = raw.trim();
866    if let Some(s) = raw.strip_prefix("```json") {
867        if let Some(end) = s.rfind("```") {
868            return s[..end].trim().to_string();
869        }
870    }
871    if let Some(s) = raw.strip_prefix("```") {
872        if let Some(end) = s.rfind("```") {
873            return s[..end].trim().to_string();
874        }
875    }
876    raw.to_string()
877}