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}