Skip to main content

perspt_agent/
agent.rs

1//! Agent Trait and Implementations
2//!
3//! Defines the interface for all agent implementations and provides
4//! LLM-integrated implementations for Architect, Actuator, and Verifier roles.
5
6use 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/// The Agent trait defines the interface for SRBN agents.
15///
16/// Each agent role (Architect, Actuator, Verifier, Speculator) implements
17/// this trait to provide specialized behavior.
18#[async_trait]
19pub trait Agent: Send + Sync {
20    /// Process a task and return a message
21    async fn process(&self, node: &SRBNNode, ctx: &AgentContext) -> Result<AgentMessage>;
22
23    /// Get the agent's display name
24    fn name(&self) -> &str;
25
26    /// Check if this agent can handle the given node
27    fn can_handle(&self, node: &SRBNNode) -> bool;
28
29    /// Get the model name used by this agent (for logging)
30    fn model(&self) -> &str;
31
32    /// Build the prompt for this agent (for logging)
33    fn build_prompt(&self, node: &SRBNNode, ctx: &AgentContext) -> String;
34}
35
36/// Architect agent - handles planning and DAG construction
37pub 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        // Delegate to the canonical task decomposition prompt with node-level context
52        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    /// PSP-5 Fix F: Canonical task decomposition prompt with the full JSON schema contract.
64    /// Used by both the ArchitectAgent (node-level) and the Orchestrator (initial planning).
65    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
238/// Actuator agent - handles code generation
239pub 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        // Determine target file from output_targets or generate default
262        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        // PSP-5: Determine output format based on execution mode and plugin
269        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
495/// Verifier agent - handles stability verification and contract checking
496pub 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        // In a real implementation, we would get the actual implementation from the context
558        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        // Verifier needs implementation context, use a placeholder
588        self.build_verification_prompt(node, "<implementation>")
589    }
590}
591
592/// Speculator agent - handles fast lookahead for exploration
593pub 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}