278 lines
8.9 KiB
Rust
278 lines
8.9 KiB
Rust
//! Observe phase: LLM-driven multi-channel environment perception.
|
|
//!
|
|
//! The Observe phase gives the LLM a set of read-only observation tools and
|
|
//! instructs it to explore the environment. All file/git/system access goes
|
|
//! through function calls (tools), never direct filesystem operations.
|
|
//!
|
|
//! After exploration, the LLM produces a structured [`PerceptionSnapshot`]
|
|
//! summarizing the current state of the project.
|
|
|
|
use rig::agent::AgentBuilder;
|
|
use rig::client::CompletionClient;
|
|
use rig::completion::Prompt;
|
|
|
|
use crate::client::AiClientConfig;
|
|
use crate::error::AgentError;
|
|
|
|
use super::types::{ActionResult, PerceptionSnapshot};
|
|
|
|
/// Prompt for the ORAO Observe phase.
|
|
const OBSERVE_SYSTEM_PROMPT: &str = r#"You are an expert software engineering agent using the ORAO (Observe-Reason-Act-Observe) framework.
|
|
|
|
## Your Role: OBSERVE Phase
|
|
|
|
You are currently in the OBSERVE phase. Your task is to explore the project environment
|
|
and gather all relevant information using the available tools.
|
|
|
|
## What to Observe
|
|
|
|
Use the tools provided to you to check:
|
|
|
|
1. **Git status**: What branch are we on? What files have changed? Any uncommitted work?
|
|
2. **Project structure**: What directories and key files exist?
|
|
3. **Code content**: Read relevant source files to understand the codebase state.
|
|
4. **Errors/warnings**: Check build output, test results, linter output for issues.
|
|
5. **Configuration**: Check project config files (Cargo.toml, package.json, etc.) if relevant.
|
|
|
|
## Rules
|
|
|
|
- Use tools to explore — do NOT guess or assume file contents.
|
|
- Focus on information relevant to the task at hand.
|
|
- Be thorough but efficient: 3-8 tool calls is typical.
|
|
- After gathering information, summarize your findings clearly.
|
|
|
|
## Output Format
|
|
|
|
After you have finished observing, provide a summary with these sections:
|
|
|
|
### Git Status
|
|
[Current branch, changed files, commit status]
|
|
|
|
### Project Structure
|
|
[Key directories and files relevant to the task]
|
|
|
|
### Key Files
|
|
[Important files you read, with brief notes on their content]
|
|
|
|
### Errors / Issues
|
|
[Any errors, warnings, or problems detected]
|
|
|
|
### Previous Action Result
|
|
[If a previous action was executed, describe its outcome]"#;
|
|
|
|
/// Run the Observe phase: let the LLM explore the environment via tools.
|
|
///
|
|
/// Returns a structured [`PerceptionSnapshot`] built from the LLM's observations.
|
|
/// All environment access goes through the provided `tools` — no direct
|
|
/// filesystem operations.
|
|
///
|
|
/// Takes ownership of `tools` (caller must clone if they need to reuse them).
|
|
pub async fn observe(
|
|
config: &AiClientConfig,
|
|
model_name: &str,
|
|
task_goal: &str,
|
|
previous_result: Option<ActionResult>,
|
|
tools: Vec<Box<dyn rig::tool::ToolDyn + 'static>>,
|
|
max_turns: usize,
|
|
) -> Result<PerceptionSnapshot, AgentError> {
|
|
let user_prompt = build_observe_prompt(task_goal, previous_result.as_ref());
|
|
|
|
let client = config.build_rig_client();
|
|
let model = client.completion_model(model_name);
|
|
|
|
let agent = AgentBuilder::new(model)
|
|
.preamble(OBSERVE_SYSTEM_PROMPT)
|
|
.tools(tools)
|
|
.default_max_turns(max_turns)
|
|
.build();
|
|
|
|
let response = agent
|
|
.prompt(&user_prompt)
|
|
.max_turns(max_turns)
|
|
.extended_details()
|
|
.await
|
|
.map_err(|e: rig::completion::PromptError| AgentError::OpenAi(e.to_string()))?;
|
|
|
|
// Build snapshot from the LLM's final summary
|
|
let summary = response.output;
|
|
let snapshot = parse_observation_summary(&summary, previous_result);
|
|
|
|
Ok(snapshot)
|
|
}
|
|
|
|
/// Build the user prompt for the Observe phase.
|
|
fn build_observe_prompt(task_goal: &str, previous_result: Option<&ActionResult>) -> String {
|
|
let mut prompt = format!(
|
|
"## Task Goal\n\n{}\n\n## Instructions\n\n\
|
|
Explore the project environment using the available tools. \
|
|
Gather all information relevant to the task above. \
|
|
After you have gathered sufficient information, provide a structured summary.",
|
|
task_goal
|
|
);
|
|
|
|
if let Some(prev) = previous_result {
|
|
prompt.push_str(&format!(
|
|
"\n\n## Previous Action Result\n\n\
|
|
- Action: {}\n\
|
|
- Verdict: {:?}\n\
|
|
- Exit code: {:?}\n\
|
|
- stdout: {}\n\
|
|
- stderr: {}",
|
|
prev.action.description,
|
|
prev.verdict,
|
|
prev.exit_code,
|
|
truncate_str(&prev.stdout, 2000),
|
|
truncate_str(&prev.stderr, 2000),
|
|
));
|
|
}
|
|
|
|
prompt
|
|
}
|
|
|
|
/// Parse the LLM's observation summary into a structured snapshot.
|
|
fn parse_observation_summary(
|
|
summary: &str,
|
|
previous_result: Option<ActionResult>,
|
|
) -> PerceptionSnapshot {
|
|
let mut snapshot = PerceptionSnapshot::default();
|
|
|
|
// Extract sections from the markdown summary
|
|
let mut current_section = "";
|
|
let mut section_content: Vec<&str> = Vec::new();
|
|
|
|
for line in summary.lines() {
|
|
if line.starts_with("### ") {
|
|
// Save previous section
|
|
store_section(&mut snapshot, current_section, §ion_content);
|
|
current_section = line.trim_start_matches("### ").trim();
|
|
section_content.clear();
|
|
} else {
|
|
section_content.push(line);
|
|
}
|
|
}
|
|
// Save last section
|
|
store_section(&mut snapshot, current_section, §ion_content);
|
|
|
|
snapshot.previous_action_result = previous_result;
|
|
|
|
// If no structured data was parsed, store the raw summary
|
|
if snapshot.git_status.is_none()
|
|
&& snapshot.project_structure.is_none()
|
|
&& snapshot.files.is_empty()
|
|
&& snapshot.errors.is_empty()
|
|
{
|
|
snapshot
|
|
.notes
|
|
.insert("raw_observation".to_string(), summary.to_string());
|
|
}
|
|
|
|
snapshot
|
|
}
|
|
|
|
fn store_section(snapshot: &mut PerceptionSnapshot, section: &str, content: &[&str]) {
|
|
let text = content.join("\n").trim().to_string();
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
|
|
match section.to_lowercase().as_str() {
|
|
s if s.contains("git") => {
|
|
snapshot.git_status = Some(text);
|
|
}
|
|
s if s.contains("project") && s.contains("structure") => {
|
|
snapshot.project_structure = Some(text);
|
|
}
|
|
s if s.contains("file") => {
|
|
// Parse file references from the text
|
|
for line in content {
|
|
let line = line.trim();
|
|
if let Some(path) = extract_file_path(line) {
|
|
snapshot.files.push(super::types::PerceivedFile {
|
|
path,
|
|
size_bytes: 0,
|
|
content_preview: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
s if s.contains("error") || s.contains("issue") || s.contains("warning") => {
|
|
for line in content {
|
|
let line = line.trim();
|
|
if !line.is_empty() && !line.starts_with('#') {
|
|
snapshot.errors.push(line.to_string());
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
// Store unknown sections as notes
|
|
snapshot.notes.insert(section.to_string(), text);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract a file path from a markdown list item or code reference.
|
|
fn extract_file_path(line: &str) -> Option<String> {
|
|
// Match patterns like: - `src/main.rs` or - src/main.rs or `src/main.rs`
|
|
let line = line.trim();
|
|
|
|
// Backtick-wrapped path
|
|
if let Some(start) = line.find('`') {
|
|
let rest = &line[start + 1..];
|
|
if let Some(end) = rest.find('`') {
|
|
let path = rest[..end].to_string();
|
|
if path.contains('.') || path.contains('/') || path.contains('\\') {
|
|
return Some(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bare path pattern (word chars, slashes, dots)
|
|
if line.starts_with('-') || line.starts_with('*') {
|
|
let rest = line.trim_start_matches(&['-', '*', ' ']);
|
|
if rest.contains('/') || (rest.contains('.') && !rest.starts_with("http")) {
|
|
return Some(rest.to_string());
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn truncate_str(s: &str, max_len: usize) -> String {
|
|
if s.len() <= max_len {
|
|
s.to_string()
|
|
} else {
|
|
format!("{}...", &s[..max_len])
|
|
}
|
|
}
|
|
|
|
/// Determine whether the environment has changed since the last snapshot.
|
|
///
|
|
/// Used for deadlock detection: if 3 consecutive rounds show no change,
|
|
/// the loop is terminated.
|
|
pub fn has_environment_changed(
|
|
previous: &PerceptionSnapshot,
|
|
current: &PerceptionSnapshot,
|
|
) -> bool {
|
|
if previous.git_status != current.git_status {
|
|
return true;
|
|
}
|
|
|
|
let prev_files: Vec<&str> = previous.files.iter().map(|f| f.path.as_str()).collect();
|
|
let curr_files: Vec<&str> = current.files.iter().map(|f| f.path.as_str()).collect();
|
|
if prev_files != curr_files {
|
|
return true;
|
|
}
|
|
|
|
if previous.errors != current.errors {
|
|
return true;
|
|
}
|
|
|
|
let prev_has_result = previous.previous_action_result.is_some();
|
|
let curr_has_result = current.previous_action_result.is_some();
|
|
if prev_has_result != curr_has_result {
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|