stynx-code 3.12.1

stynx-code — interactive AI coding assistant
use std::sync::Arc;
use std::sync::atomic::AtomicU8;

use stynx_code_config::HooksConfig;
use stynx_code_engine::{EngineEvent, QueryEngine, sub_agent_sink};
use stynx_code_errors::AppResult;
use stynx_code_types::{Conversation, Message, PermissionChecker, Role};
use stynx_code_tools::ToolRegistry;

#[derive(Clone)]
pub(super) struct SubEngine {
    pub provider: Arc<dyn stynx_code_types::Provider>,
    pub registry: Arc<ToolRegistry>,
    pub permission: Arc<dyn PermissionChecker>,
    pub mode: Arc<AtomicU8>,
    pub hooks: HooksConfig,
}

struct Activity {
    text: String,
    actions: Vec<String>,
    current_tool: Option<(String, String)>,
    read_paths: Vec<String>,
    edited_paths: Vec<String>,
    written_paths: Vec<String>,
    bash_commands: Vec<String>,
    error_count: usize,
}

impl SubEngine {
    pub(super) async fn run(&self, label: &str, system: &str, task: &str) -> AppResult<String> {
        let sub_registry = Arc::new(self.registry.clone_excluding(&["agent", "explore"]));
        let engine = QueryEngine::new(
            self.provider.clone(),
            sub_registry,
            self.permission.clone(),
            self.mode.clone(),
            self.hooks.clone(),
        );
        let mut conv = Conversation {
            system: Some(system.to_string()),
            ..Default::default()
        };
        conv.push(Message { role: Role::User, content: vec![stynx_code_types::ContentBlock::Text { text: task.to_string() }] });

        let activity = std::sync::Arc::new(std::sync::Mutex::new(Activity {
            text: String::new(),
            actions: Vec::new(),
            current_tool: None,
            read_paths: Vec::new(),
            edited_paths: Vec::new(),
            written_paths: Vec::new(),
            bash_commands: Vec::new(),
            error_count: 0,
        }));
        let act_ref = activity.clone();
        let label_for_cb = label.to_string();
        engine
            .run(conv, move |event| {
                let mut a = act_ref.lock().unwrap();
                match event {
                    EngineEvent::TextDelta(text) => a.text.push_str(&text),
                    EngineEvent::ToolStart { name, .. } => {
                        sub_agent_sink::send(EngineEvent::SubAgentProgress {
                            label: label_for_cb.clone(),
                            summary: format!("calling {name}"),
                        });
                        a.current_tool = Some((name, String::new()));
                    }
                    EngineEvent::ToolInput { json_chunk } => {
                        if let Some((_, buf)) = a.current_tool.as_mut() {
                            buf.push_str(&json_chunk);
                        }
                    }
                    EngineEvent::ToolResult { name, output, is_error } => {
                        let input = a
                            .current_tool
                            .take()
                            .filter(|(n, _)| n == &name)
                            .map(|(_, j)| j)
                            .unwrap_or_default();
                        let summary = summarize_action(&name, &input, &output, is_error);
                        sub_agent_sink::send(EngineEvent::SubAgentProgress {
                            label: label_for_cb.clone(),
                            summary: summary.clone(),
                        });
                        a.actions.push(summary);
                        if !is_error {
                            let parsed: Option<serde_json::Value> = serde_json::from_str(&input).ok();
                            let path = parsed.as_ref()
                                .and_then(|v| v.get("file_path"))
                                .and_then(|v| v.as_str())
                                .map(str::to_string);
                            match name.as_str() {
                                "read" => if let Some(p) = path { a.read_paths.push(p); },
                                "file_edit" => if let Some(p) = path { a.edited_paths.push(p); },
                                "file_write" => if let Some(p) = path { a.written_paths.push(p); },
                                "bash" => {
                                    if let Some(cmd) = parsed.as_ref().and_then(|v| v.get("command")).and_then(|v| v.as_str()) {
                                        a.bash_commands.push(cmd.to_string());
                                    }
                                }
                                _ => {}
                            }
                        } else {
                            a.error_count += 1;
                        }
                    }
                    _ => {}
                }
            })
            .await?;

        sub_agent_sink::send(EngineEvent::SubAgentDone { label: label.to_string() });

        let act = activity.lock().unwrap();
        let mut out = String::new();
        if !act.text.trim().is_empty() {
            out.push_str(act.text.trim());
            out.push('\n');
        }
        if !act.actions.is_empty() {
            if !out.is_empty() { out.push('\n'); }
            out.push_str("Actions taken:\n");
            for a in &act.actions {
                out.push_str("");
                out.push_str(a);
                out.push('\n');
            }
        }
        if out.trim().is_empty() {
            out.push_str(
                "[FAILED] intern produced ZERO output and took ZERO tool actions — it is broken, hung mid-thought, or refused the task. \
                 AUTO-RECOVER NOW (do not ask the user): take over yourself with bash/read/file_edit/file_write, or pick a different intern. \
                 Do not retry this intern with the same task."
            );
        }
        let missing_summary = !out.contains("Summary:");
        let missing_files = !out.contains("Files changed:");
        let no_tool_actions = !out.contains("Actions taken:");
        if missing_summary || missing_files || no_tool_actions {
            out.push_str("\n\n[MALFORMED] intern skipped required closing blocks (Summary / Files changed / Actions taken). \
            Treat any claims above as unverified. Verify by reading the files yourself before trusting the result.");
        }

        let mut truth = String::new();
        truth.push_str("\n\n--- Ground truth (recorded by stynx engine, NOT by the intern) ---\n");
        truth.push_str(&format!("  read:    {} files {}\n", act.read_paths.len(), dedupe_compact(&act.read_paths)));
        truth.push_str(&format!("  edited:  {} files {}\n", act.edited_paths.len(), dedupe_compact(&act.edited_paths)));
        truth.push_str(&format!("  wrote:   {} files {}\n", act.written_paths.len(), dedupe_compact(&act.written_paths)));
        truth.push_str(&format!("  bash:    {} commands\n", act.bash_commands.len()));
        truth.push_str(&format!("  errors:  {}\n", act.error_count));
        if act.edited_paths.is_empty() && act.written_paths.is_empty() && act.bash_commands.is_empty() {
            truth.push_str("\n[NO WRITES] intern modified ZERO files and ran ZERO commands. \
                If its reply claims any file change, that claim is FALSE. \
                Trust only the ground truth above.\n");
        }
        if out.contains("Files changed:") {
            let claimed_files = extract_claimed_files(&out);
            let real_changed: std::collections::HashSet<&str> = act.edited_paths.iter()
                .chain(act.written_paths.iter())
                .map(|s| s.as_str())
                .collect();
            let fake: Vec<&String> = claimed_files.iter()
                .filter(|p| !real_changed.contains(p.as_str()))
                .collect();
            if !fake.is_empty() {
                truth.push_str("\n[FALSE CLAIMS] intern's 'Files changed' list contains paths that were NEVER actually edited/written:\n");
                for f in fake {
                    truth.push_str(&format!("{f}\n"));
                }
                truth.push_str("Treat the entire intern output as suspect. Verify the real changes via the ground-truth list above.\n");
            }
        }
        out.push_str(&truth);

        Ok(out)
    }
}

fn summarize_action(name: &str, input_json: &str, output: &str, is_error: bool) -> String {
    let parsed: Option<serde_json::Value> = serde_json::from_str(input_json).ok();
    let get_str = |k: &str| -> String {
        parsed
            .as_ref()
            .and_then(|v| v.get(k))
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string()
    };
    let head = match name {
        "bash" => format!("bash $ {}", first_line_trunc(&get_str("command"), 100)),
        "read" => format!("read {}", get_str("file_path")),
        "file_write" => format!("write {}", get_str("file_path")),
        "file_edit" => format!("edit {}", get_str("file_path")),
        "glob" => format!("glob {}", get_str("pattern")),
        "grep" => {
            let p = get_str("pattern");
            let path = get_str("path");
            if path.is_empty() { format!("grep {p}") } else { format!("grep {p} in {path}") }
        }
        other => format!("{other}"),
    };
    let tail = if is_error {
        format!(" — ERROR: {}", first_line_trunc(output, 120))
    } else if name == "bash" || name == "file_write" || name == "file_edit" {
        let line_count = output.lines().filter(|l| !l.trim().is_empty()).count();
        if line_count > 0 { format!(" ({line_count} output lines)") } else { String::new() }
    } else {
        String::new()
    };
    format!("{head}{tail}")
}

fn dedupe_compact(paths: &[String]) -> String {
    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
    let mut uniq: Vec<&str> = Vec::new();
    for p in paths {
        if seen.insert(p.as_str()) {
            uniq.push(p.as_str());
        }
    }
    if uniq.is_empty() { return String::new(); }
    let preview: Vec<String> = uniq.iter().take(8).map(|s| s.to_string()).collect();
    let more = if uniq.len() > 8 { format!(" (+{} more)", uniq.len() - 8) } else { String::new() };
    format!("[{}{}]", preview.join(", "), more)
}

fn extract_claimed_files(out: &str) -> Vec<String> {
    let mut files = Vec::new();
    let mut in_section = false;
    for line in out.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("Files changed:") {
            in_section = true;
            continue;
        }
        if !in_section {
            continue;
        }
        if trimmed.starts_with("Output:") || trimmed.starts_with("Summary:") || trimmed.starts_with("Actions taken:") {
            break;
        }
        if let Some(p) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("")) {
            let p = p.trim();
            if p.is_empty() || p.eq_ignore_ascii_case("none") {
                continue;
            }
            let p = p.trim_matches(|c: char| c == '`' || c == '"' || c == '\'');
            if p.starts_with('/') || p.contains('/') {
                files.push(p.to_string());
            }
        }
    }
    files
}

fn first_line_trunc(s: &str, max: usize) -> String {
    let line = s.lines().next().unwrap_or("").trim();
    if line.chars().count() <= max {
        return line.to_string();
    }
    let mut out: String = line.chars().take(max.saturating_sub(1)).collect();
    out.push('');
    out
}