1use crate::types::{AgentContext, AgentMessage, ModelTier, SRBNNode};
7use anyhow::Result;
8use async_trait::async_trait;
9use perspt_core::llm_provider::GenAIProvider;
10use std::fs;
11use std::path::Path;
12use std::sync::Arc;
13
14#[async_trait]
19pub trait Agent: Send + Sync {
20 async fn process(&self, node: &SRBNNode, ctx: &AgentContext) -> Result<AgentMessage>;
22
23 fn name(&self) -> &str;
25
26 fn can_handle(&self, node: &SRBNNode) -> bool;
28
29 fn model(&self) -> &str;
31
32 fn build_prompt(&self, node: &SRBNNode, ctx: &AgentContext) -> String;
34}
35
36pub struct ArchitectAgent {
38 model: String,
39 provider: Arc<GenAIProvider>,
40}
41
42impl ArchitectAgent {
43 pub fn new(provider: Arc<GenAIProvider>, model: Option<String>) -> Self {
44 Self {
45 model: model.unwrap_or_else(|| ModelTier::Architect.default_model().to_string()),
46 provider,
47 }
48 }
49
50 pub fn build_planning_prompt(&self, node: &SRBNNode, ctx: &AgentContext) -> String {
51 Self::build_task_decomposition_prompt(
53 &node.goal,
54 &ctx.working_dir,
55 &format!(
56 "Context Files: {:?}\nOutput Targets: {:?}",
57 node.context_files, node.output_targets
58 ),
59 None,
60 )
61 }
62
63 pub fn build_task_decomposition_prompt(
66 task: &str,
67 working_dir: &Path,
68 project_context: &str,
69 last_error: Option<&str>,
70 ) -> String {
71 let error_feedback = if let Some(e) = last_error {
72 format!(
73 "\n## Previous Attempt Failed\nError: {}\nPlease fix the JSON format and try again.\n",
74 e
75 )
76 } else {
77 String::new()
78 };
79
80 format!(
81 r#"You are an Architect agent in a multi-agent coding system.
82
83## Task
84{task}
85
86## Working Directory
87{working_dir}
88
89## Existing Project Structure
90{project_context}
91{error_feedback}
92## Instructions
93Analyze this task and produce a structured execution plan as JSON.
94
95### OWNERSHIP CLOSURE (CRITICAL — violating this will fail the build)
96Each file path MUST appear in the `output_files` of EXACTLY ONE task.
97- NO two tasks may list the same file in their `output_files`.
98- A task that creates `src/math.py` MUST NOT also appear in another task's `output_files`.
99- Test files (e.g., `tests/test_math.py`) are owned by whichever single task creates them.
100- If a task needs to READ a file owned by another task, list it in `context_files`, NOT `output_files`.
101
102### MODULAR PROJECT STRUCTURE
103Your plan MUST create a COMPLETE, RUNNABLE project with proper modularity:
104
1051. **Entry Point First**: Create a main entry point file (e.g., `main.py`, `src/main.rs`, `index.js`)
1062. **Logical Modules**: Split functionality into separate files/modules with clear responsibilities
1073. **Proper Imports**: Ensure all cross-file imports will resolve correctly
1084. **Package Structure**: For Python, include `__init__.py` files in subdirectories
1095. **One Test Task Per Module**: Each module's tests go in their OWN task with a UNIQUE test file.
110 - Task for `src/math.py` → its test task owns `tests/test_math.py`
111 - Task for `src/strings.py` → its test task owns `tests/test_strings.py`
112 - NEVER put tests for multiple modules in the same test file
113
114### TASK ORDERING
1151. Create foundational modules before dependent ones
1162. Specify dependencies accurately between tasks
1173. Entry point task should depend on all modules it imports
1184. Test tasks depend on the module they test
119
120### COMPLETENESS CHECKLIST
121- [ ] Every file path appears in exactly one task's `output_files` (no duplicates across tasks)
122- [ ] Every import in generated code must reference an existing or planned file
123- [ ] The project must be immediately runnable after all tasks complete
124- [ ] Include at least one test file per core module
125- [ ] All functions must have type hints (Python) or type annotations (Rust/TS)
126
127## CRITICAL CONSTRAINTS
128- DO NOT create `pyproject.toml`, `requirements.txt`, `package.json`, `Cargo.toml`, or any project configuration files
129- The system handles project initialization separately via CLI tools (uv, npm, cargo)
130- Focus ONLY on source code files (.py, .js, .rs, etc.) and test files
131- If you need to add dependencies, include them in the task goal description (e.g., "Add requests library for HTTP calls")
132
133## Output Format
134Respond with ONLY a JSON object in this exact format:
135```json
136{{
137 "tasks": [
138 {{
139 "id": "task_1",
140 "goal": "Create module_a with core functionality",
141 "context_files": [],
142 "output_files": ["module_a.py"],
143 "dependencies": [],
144 "task_type": "code",
145 "contract": {{
146 "interface_signature": "def function_name(arg: Type) -> ReturnType",
147 "invariants": ["Must handle edge cases"],
148 "forbidden_patterns": ["no bare except"],
149 "tests": [
150 {{"name": "test_function_name", "criticality": "Critical"}}
151 ]
152 }}
153 }},
154 {{
155 "id": "test_task_1",
156 "goal": "Unit tests for module_a (ONLY this module)",
157 "context_files": ["module_a.py"],
158 "output_files": ["tests/test_module_a.py"],
159 "dependencies": ["task_1"],
160 "task_type": "unit_test"
161 }},
162 {{
163 "id": "task_2",
164 "goal": "Create module_b with helper utilities",
165 "context_files": [],
166 "output_files": ["module_b.py"],
167 "dependencies": [],
168 "task_type": "code"
169 }},
170 {{
171 "id": "test_task_2",
172 "goal": "Unit tests for module_b (ONLY this module)",
173 "context_files": ["module_b.py"],
174 "output_files": ["tests/test_module_b.py"],
175 "dependencies": ["task_2"],
176 "task_type": "unit_test"
177 }},
178 {{
179 "id": "main_entry",
180 "goal": "Create main.py entry point that imports and uses other modules",
181 "context_files": ["module_a.py", "module_b.py"],
182 "output_files": ["main.py"],
183 "dependencies": ["task_1", "task_2"],
184 "task_type": "code"
185 }}
186 ]
187}}
188```
189
190Valid task_type values: "code", "unit_test", "integration_test", "refactor", "documentation"
191Valid criticality values: "Critical", "High", "Low"
192
193IMPORTANT: Output ONLY the JSON, no other text."#,
194 task = task,
195 working_dir = working_dir.display(),
196 project_context = project_context,
197 error_feedback = error_feedback
198 )
199 }
200}
201
202#[async_trait]
203impl Agent for ArchitectAgent {
204 async fn process(&self, node: &SRBNNode, ctx: &AgentContext) -> Result<AgentMessage> {
205 log::info!(
206 "[Architect] Processing node: {} with model {}",
207 node.node_id,
208 self.model
209 );
210
211 let prompt = self.build_planning_prompt(node, ctx);
212
213 let response = self
214 .provider
215 .generate_response_simple(&self.model, &prompt)
216 .await?;
217
218 Ok(AgentMessage::new(ModelTier::Architect, response))
219 }
220
221 fn name(&self) -> &str {
222 "Architect"
223 }
224
225 fn can_handle(&self, node: &SRBNNode) -> bool {
226 matches!(node.tier, ModelTier::Architect)
227 }
228
229 fn model(&self) -> &str {
230 &self.model
231 }
232
233 fn build_prompt(&self, node: &SRBNNode, ctx: &AgentContext) -> String {
234 self.build_planning_prompt(node, ctx)
235 }
236}
237
238pub struct ActuatorAgent {
240 model: String,
241 provider: Arc<GenAIProvider>,
242}
243
244impl ActuatorAgent {
245 pub fn new(provider: Arc<GenAIProvider>, model: Option<String>) -> Self {
246 Self {
247 model: model.unwrap_or_else(|| ModelTier::Actuator.default_model().to_string()),
248 provider,
249 }
250 }
251
252 pub fn build_coding_prompt(&self, node: &SRBNNode, ctx: &AgentContext) -> String {
253 let contract = &node.contract;
254 let allowed_output_paths: Vec<String> = node
255 .output_targets
256 .iter()
257 .map(|path| path.to_string_lossy().to_string())
258 .collect();
259 let workspace_import_hints = Self::workspace_import_hints(&ctx.working_dir);
260
261 let target_file = node
263 .output_targets
264 .first()
265 .map(|p| p.to_string_lossy().to_string())
266 .unwrap_or_else(|| "main.py".to_string());
267
268 let is_project_mode = ctx.execution_mode == perspt_core::types::ExecutionMode::Project;
270 let has_multiple_outputs = node.output_targets.len() > 1;
271
272 let output_format_section = if is_project_mode || has_multiple_outputs {
273 format!(
274 r#"## Output Format (Multi-Artifact Bundle)
275When producing multi-file output, use this JSON format wrapped in a ```json code block:
276
277```json
278{{
279 "artifacts": [
280 {{ "path": "{target_file}", "operation": "write", "content": "..." }},
281 {{ "path": "tests/test_main.py", "operation": "write", "content": "..." }}
282 ],
283 "commands": ["cargo add serde --features derive", "cargo add thiserror"]
284}}
285```
286
287The `commands` array should contain dependency install commands (e.g. `cargo add <crate>`, `pip install <pkg>`) that must run BEFORE the code can compile. Leave it empty `[]` only if no new dependencies are needed.
288
289Each artifact entry must have:
290- `path`: Relative path within the workspace
291- `operation`: Either `"write"` (full file) or `"diff"` (unified diff patch)
292- `content` (for write) or `patch` (for diff): The file content or patch
293
294RULES:
295- Paths MUST be relative (no leading `/`)
296- Use `"write"` for new files or full rewrites
297- Use `"diff"` with proper unified diff format for small changes to existing files
298- Include ALL files needed for the task in a single bundle
299- ONLY emit artifacts for the declared allowed output paths listed below
300- DO NOT create, modify, or patch any file not listed in `Allowed Output Paths`"#,
301 target_file = target_file
302 )
303 } else {
304 format!(
305 r#"## Output Format
306Use one of these formats:
307
308### Creating a New File
309File: {target_file}
310```python
311# your code here
312```
313
314### Modifying an Existing File
315Diff: {target_file}
316```diff
317--- {target_file}
318+++ {target_file}
319@@ -10,2 +10,3 @@
320 def calculate(x):
321- return x * 2
322+ return x * 3
323```
324
325IMPORTANT:
326- Use 'Diff:' for existing files to save tokens
327- Use 'File:' ONLY for new files or full rewrites"#,
328 target_file = target_file
329 )
330 };
331
332 format!(
333 r#"You are an Actuator agent responsible for implementing code.
334
335## Task
336Goal: {goal}
337
338## Behavioral Contract
339Interface Signature: {interface}
340Invariants: {invariants:?}
341Forbidden Patterns: {forbidden:?}
342
343## Context
344Working Directory: {working_dir:?}
345Files to Read: {context_files:?}
346Target Output File: {target_file}
347Allowed Output Paths: {allowed_output_paths:?}
348Workspace Import Hints: {workspace_import_hints:?}
349
350## Instructions
3511. Implement the required functionality
3522. Follow the interface signature exactly
3533. Maintain all specified invariants
3544. Avoid all forbidden patterns
3555. Write clean, well-documented, production-quality code
3566. Include proper imports at the top of the file
3577. Add type annotations if missing
3588. Import any missing modules
3599. Restrict all file edits to `Allowed Output Paths` only
36010. If another file needs changes, do not modify it in this node; keep that need implicit for its owning node
36111. Use `Workspace Import Hints` exactly for crate/package imports in tests, entry points, and cross-file references
36212. For library source modules (e.g. `src/*.rs` in Rust), use `crate::` for intra-crate imports, never the package name. Only use the package name in `tests/`, `examples/`, or `main.rs`.
36313. When your code uses external crates/packages not already listed in the project manifest (e.g. `Cargo.toml`, `pyproject.toml`, `package.json`), you MUST include the install commands in the `commands` array. For Rust: `cargo add <crate>` (with `--features <f>` if needed). For Python: `uv add <pkg>`. For Node.js: `npm install <pkg>`. Without these commands, the build will fail due to missing dependencies.
36414. For Python projects:
365 - Prefer src-layout: put all library code under `src/<package_name>/` with an `__init__.py`.
366 - Keep ALL modules inside the declared package directory — never mix top-level .py files with `src/<pkg>/` modules.
367 - Use relative imports (`from . import utils`, `from .core import Pipeline`) inside the package.
368 - Use the package name for imports from tests and entry points (`from mypackage.core import Foo`), never `src.mypackage`.
369 - Put tests in a top-level `tests/` directory (not inside `src/`), using `test_*.py` naming.
370 - Use `uv add <pkg>` (not `pip install`) for dependency commands. Use `uv add --dev <pkg>` for test/dev-only tools like `pytest` or `ruff`.
371 - Ensure test files import real symbols that actually exist in the generated code — do not invent class or function names that are not defined.
372
373{output_format}"#,
374 goal = node.goal,
375 interface = contract.interface_signature,
376 invariants = contract.invariants,
377 forbidden = contract.forbidden_patterns,
378 working_dir = ctx.working_dir,
379 context_files = node.context_files,
380 target_file = target_file,
381 allowed_output_paths = allowed_output_paths,
382 workspace_import_hints = workspace_import_hints,
383 output_format = output_format_section,
384 )
385 }
386
387 fn workspace_import_hints(working_dir: &Path) -> Vec<String> {
388 let mut hints = Vec::new();
389
390 if let Some(crate_name) = Self::detect_rust_crate_name(working_dir) {
391 hints.push(format!(
392 "Rust crate name: {}. Integration tests and external modules must import via `{}`.",
393 crate_name, crate_name
394 ));
395 }
396
397 if let Some(package_name) = Self::detect_python_package_name(working_dir) {
398 hints.push(format!(
399 "Python package import root: {}. Tests and entry points must import `{}` and never `src.{}`.",
400 package_name, package_name, package_name
401 ));
402 }
403
404 hints
405 }
406
407 fn detect_rust_crate_name(working_dir: &Path) -> Option<String> {
408 let cargo_toml = fs::read_to_string(working_dir.join("Cargo.toml")).ok()?;
409 let mut in_package = false;
410
411 for raw_line in cargo_toml.lines() {
412 let line = raw_line.trim();
413 if line.starts_with('[') {
414 in_package = line == "[package]";
415 continue;
416 }
417
418 if in_package && line.starts_with("name") {
419 let (_, value) = line.split_once('=')?;
420 return Some(value.trim().trim_matches('"').to_string());
421 }
422 }
423
424 None
425 }
426
427 fn detect_python_package_name(working_dir: &Path) -> Option<String> {
428 let src_dir = working_dir.join("src");
429 if let Ok(entries) = fs::read_dir(&src_dir) {
430 for entry in entries.flatten() {
431 if entry.file_type().ok()?.is_dir() {
432 let name = entry.file_name().to_string_lossy().to_string();
433 if !name.starts_with('.') {
434 return Some(name);
435 }
436 }
437 }
438 }
439
440 let pyproject = fs::read_to_string(working_dir.join("pyproject.toml")).ok()?;
441 let mut in_project = false;
442 for raw_line in pyproject.lines() {
443 let line = raw_line.trim();
444 if line.starts_with('[') {
445 in_project = line == "[project]";
446 continue;
447 }
448
449 if in_project && line.starts_with("name") {
450 let (_, value) = line.split_once('=')?;
451 return Some(value.trim().trim_matches('"').replace('-', "_"));
452 }
453 }
454
455 None
456 }
457}
458
459#[async_trait]
460impl Agent for ActuatorAgent {
461 async fn process(&self, node: &SRBNNode, ctx: &AgentContext) -> Result<AgentMessage> {
462 log::info!(
463 "[Actuator] Processing node: {} with model {}",
464 node.node_id,
465 self.model
466 );
467
468 let prompt = self.build_coding_prompt(node, ctx);
469
470 let response = self
471 .provider
472 .generate_response_simple(&self.model, &prompt)
473 .await?;
474
475 Ok(AgentMessage::new(ModelTier::Actuator, response))
476 }
477
478 fn name(&self) -> &str {
479 "Actuator"
480 }
481
482 fn can_handle(&self, node: &SRBNNode) -> bool {
483 matches!(node.tier, ModelTier::Actuator)
484 }
485
486 fn model(&self) -> &str {
487 &self.model
488 }
489
490 fn build_prompt(&self, node: &SRBNNode, ctx: &AgentContext) -> String {
491 self.build_coding_prompt(node, ctx)
492 }
493}
494
495pub struct VerifierAgent {
497 model: String,
498 provider: Arc<GenAIProvider>,
499}
500
501impl VerifierAgent {
502 pub fn new(provider: Arc<GenAIProvider>, model: Option<String>) -> Self {
503 Self {
504 model: model.unwrap_or_else(|| ModelTier::Verifier.default_model().to_string()),
505 provider,
506 }
507 }
508
509 pub fn build_verification_prompt(&self, node: &SRBNNode, implementation: &str) -> String {
510 let contract = &node.contract;
511
512 format!(
513 r#"You are a Verifier agent responsible for checking code correctness.
514
515## Task
516Verify the implementation satisfies the behavioral contract.
517
518## Behavioral Contract
519Interface Signature: {}
520Invariants: {:?}
521Forbidden Patterns: {:?}
522Weighted Tests: {:?}
523
524## Implementation
525{}
526
527## Verification Criteria
5281. Does the interface match the signature?
5292. Are all invariants satisfied?
5303. Are any forbidden patterns present?
5314. Would the weighted tests pass?
532
533## Output Format
534Provide:
535- PASS or FAIL status
536- Energy score (0.0 = perfect, 1.0 = total failure)
537- List of violations if any
538- Suggested fixes for each violation"#,
539 contract.interface_signature,
540 contract.invariants,
541 contract.forbidden_patterns,
542 contract.weighted_tests,
543 implementation,
544 )
545 }
546}
547
548#[async_trait]
549impl Agent for VerifierAgent {
550 async fn process(&self, node: &SRBNNode, ctx: &AgentContext) -> Result<AgentMessage> {
551 log::info!(
552 "[Verifier] Processing node: {} with model {}",
553 node.node_id,
554 self.model
555 );
556
557 let implementation = ctx
559 .history
560 .last()
561 .map(|m| m.content.as_str())
562 .unwrap_or("No implementation provided");
563
564 let prompt = self.build_verification_prompt(node, implementation);
565
566 let response = self
567 .provider
568 .generate_response_simple(&self.model, &prompt)
569 .await?;
570
571 Ok(AgentMessage::new(ModelTier::Verifier, response))
572 }
573
574 fn name(&self) -> &str {
575 "Verifier"
576 }
577
578 fn can_handle(&self, node: &SRBNNode) -> bool {
579 matches!(node.tier, ModelTier::Verifier)
580 }
581
582 fn model(&self) -> &str {
583 &self.model
584 }
585
586 fn build_prompt(&self, node: &SRBNNode, _ctx: &AgentContext) -> String {
587 self.build_verification_prompt(node, "<implementation>")
589 }
590}
591
592pub struct SpeculatorAgent {
594 model: String,
595 provider: Arc<GenAIProvider>,
596}
597
598impl SpeculatorAgent {
599 pub fn new(provider: Arc<GenAIProvider>, model: Option<String>) -> Self {
600 Self {
601 model: model.unwrap_or_else(|| ModelTier::Speculator.default_model().to_string()),
602 provider,
603 }
604 }
605}
606
607#[async_trait]
608impl Agent for SpeculatorAgent {
609 async fn process(&self, node: &SRBNNode, ctx: &AgentContext) -> Result<AgentMessage> {
610 log::info!(
611 "[Speculator] Processing node: {} with model {}",
612 node.node_id,
613 self.model
614 );
615
616 let prompt = self.build_prompt(node, ctx);
617
618 let response = self
619 .provider
620 .generate_response_simple(&self.model, &prompt)
621 .await?;
622
623 Ok(AgentMessage::new(ModelTier::Speculator, response))
624 }
625
626 fn name(&self) -> &str {
627 "Speculator"
628 }
629
630 fn can_handle(&self, node: &SRBNNode) -> bool {
631 matches!(node.tier, ModelTier::Speculator)
632 }
633
634 fn model(&self) -> &str {
635 &self.model
636 }
637
638 fn build_prompt(&self, node: &SRBNNode, _ctx: &AgentContext) -> String {
639 format!("Briefly analyze potential issues for: {}", node.goal)
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646 use tempfile::tempdir;
647
648 #[test]
649 fn build_coding_prompt_includes_rust_crate_hint() {
650 let dir = tempdir().unwrap();
651 fs::write(
652 dir.path().join("Cargo.toml"),
653 "[package]\nname = \"validator_lib\"\nversion = \"0.1.0\"\n",
654 )
655 .unwrap();
656
657 let provider = Arc::new(GenAIProvider::new().unwrap());
658 let agent = ActuatorAgent::new(provider, Some("test-model".into()));
659 let mut node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
660 node.output_targets.push("tests/integration.rs".into());
661 let ctx = AgentContext {
662 working_dir: dir.path().to_path_buf(),
663 ..Default::default()
664 };
665
666 let prompt = agent.build_coding_prompt(&node, &ctx);
667 assert!(
668 prompt.contains("Rust crate name: validator_lib"),
669 "{prompt}"
670 );
671 }
672
673 #[test]
674 fn build_coding_prompt_includes_python_package_hint() {
675 let dir = tempdir().unwrap();
676 fs::create_dir_all(dir.path().join("src/psp5_python_verify")).unwrap();
677 fs::write(
678 dir.path().join("pyproject.toml"),
679 "[project]\nname = \"psp5-python-verify\"\nversion = \"0.1.0\"\n",
680 )
681 .unwrap();
682
683 let provider = Arc::new(GenAIProvider::new().unwrap());
684 let agent = ActuatorAgent::new(provider, Some("test-model".into()));
685 let mut node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
686 node.output_targets.push("tests/test_main.py".into());
687 let ctx = AgentContext {
688 working_dir: dir.path().to_path_buf(),
689 ..Default::default()
690 };
691
692 let prompt = agent.build_coding_prompt(&node, &ctx);
693 assert!(
694 prompt.contains("Python package import root: psp5_python_verify"),
695 "{prompt}"
696 );
697 }
698}