opencrabs 0.3.81

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/adolfousier/opencrabs/releases
//! Fire-and-forget feedback-ledger writes.
//!
//! Extracted from `tool_loop.rs` (was lines 137-181) as part of the
//! 2026-05-04 Linor-flagged refactor: `tool_loop.rs` was 4,047 lines.
//! Both methods spawn a detached task that writes to the
//! `FeedbackLedgerRepository`; neither blocks the caller and write
//! failures only log at `debug!`. This is the RSI feedback signal
//! source — the data the hourly RSI loop later analyses for tool /
//! provider success patterns.
//!
//! Behaviour is unchanged from the pre-extraction version. Lives in
//! the same `impl AgentService { ... }` block as the rest of the
//! service so call sites inside `tool_loop.rs` keep using
//! `self.record_tool_feedback(...)` / `self.record_provider_feedback(...)`
//! exactly as before.

use super::builder::AgentService;
use uuid::Uuid;

/// Enrich the metadata snippet for tool failures so RSI / SQL
/// analyses can categorize them by subsystem. Without this every
/// `bash` failure lands in the same dimension and the question
/// "which kind of bash fails most?" has no answer (issue #132).
///
/// Today this only enriches `bash`: we append `| cmd=<text>` so
/// queries like `WHERE meta LIKE '%cmd=git%'` work. The command
/// is truncated independently of the error snippet so a giant
/// command line can't crowd out the error itself; the full meta
/// then gets the 500-char outer cap below.
///
/// A bash FAILURE additionally leads with `class=` and `exit=` and
/// ends with the head of stderr rather than the raw snippet (#1068),
/// so `tool_failure|bash` can be split by hand or by the RSI pass
/// without a new event type. Ordering is load-bearing: the raw
/// snippet carries captured stdout and routinely runs past the
/// 500-char cap, which silently truncated away the `cmd=` this
/// function exists to add. Short discriminators go first so the cap
/// can only ever eat the least useful tail.
pub(crate) fn enrich_metadata(
    tool_name: &str,
    error_snippet: Option<&str>,
    tool_input: Option<&serde_json::Value>,
) -> Option<String> {
    let snippet = error_snippet.unwrap_or("");
    if tool_name == "bash" && !snippet.is_empty() {
        return Some(bash_failure_metadata(snippet, tool_input));
    }
    let cmd_suffix = if tool_name == "bash"
        && let Some(input) = tool_input
        && let Some(cmd) = input.get("command").and_then(|v| v.as_str())
        && !cmd.is_empty()
    {
        // Cap the command alone at 300 chars so the error snippet
        // still gets meaningful room within the outer 500-char cap.
        //
        // Enriched on BOTH success and failure so RSI's
        // success-pattern detection pass can group bash invocations
        // by subsystem (gh, git, docker, ...) — without this, only
        // failures would carry the command and RSI couldn't see
        // patterns in the (much more common) successful calls.
        let cmd_short: String = cmd.chars().take(300).collect();
        Some(format!(" | cmd={cmd_short}"))
    } else {
        None
    };
    match (snippet.is_empty(), cmd_suffix) {
        (true, None) => None,
        // Strip the " | " prefix the suffix carries (used when
        // joining with a snippet). With no snippet to join against
        // we emit a bare `cmd=...` so downstream LIKE queries can
        // still match without the leading separator.
        (true, Some(suffix)) => Some(suffix.strip_prefix(" | ").unwrap_or(&suffix).to_string()),
        (false, None) => Some(snippet.to_string()),
        (false, Some(suffix)) => Some(format!("{snippet}{suffix}")),
    }
}

/// How much stderr to carry. 80 chars, as first sketched, cuts a typical git
/// or ssh diagnostic mid-sentence; 160 holds one whole line in nearly every
/// case and still leaves room under the 500-char cap for a long `cmd=`.
const STDERR_HEAD_CAP: usize = 160;

/// The metadata row for a failed bash call.
///
/// Captured stdout is deliberately dropped. `exit=` already carries the tool's
/// error line and `stderr_head=` carries the diagnostic, so what stdout adds to
/// a FAILURE row is bulk that pushes the fields above out past the cap.
fn bash_failure_metadata(snippet: &str, tool_input: Option<&serde_json::Value>) -> String {
    use crate::brain::bash_failure::{BashFailureKind, classify, exit_code, stderr_head};

    let class = match classify(snippet) {
        BashFailureKind::ModelError => "model_error",
        BashFailureKind::Environmental => "environmental",
        BashFailureKind::Unknown => "unknown",
    };
    let mut meta = format!("class={class}");
    if let Some(code) = exit_code(snippet) {
        meta.push_str(&format!(" | exit={code}"));
    }
    if let Some(cmd) = tool_input
        .and_then(|i| i.get("command"))
        .and_then(|v| v.as_str())
        .filter(|c| !c.is_empty())
    {
        let cmd_short: String = cmd.chars().take(300).collect();
        meta.push_str(&format!(" | cmd={cmd_short}"));
    }
    // No stderr section means the command wrote its diagnostic to stdout (or
    // nowhere). Fall back to the snippet head so the row is not left with only
    // a class and an exit code to explain itself.
    match stderr_head(snippet, STDERR_HEAD_CAP) {
        Some(head) => meta.push_str(&format!(" | stderr_head={head}")),
        None => {
            let head: String = snippet.trim().chars().take(STDERR_HEAD_CAP).collect();
            if !head.is_empty() {
                meta.push_str(&format!(" | {head}"));
            }
        }
    }
    meta
}

impl AgentService {
    /// Fire-and-forget recording of a tool execution to the feedback ledger.
    /// Never blocks, never fails visibly — if the DB is unavailable or the
    /// write fails we just log and move on.
    ///
    /// `tool_input` is the JSON the agent passed to the tool. We use it
    /// to enrich failure metadata for subsystem-specific analysis (e.g.
    /// appending `cmd=...` for bash failures so RSI can group by
    /// `git` / `python` / `docker` prefixes). Pass `None` when the
    /// call site doesn't have a meaningful input (e.g. user-denial
    /// before execution).
    pub(super) fn record_tool_feedback(
        &self,
        session_id: Uuid,
        tool_name: &str,
        tool_input: Option<&serde_json::Value>,
        success: bool,
        error_snippet: Option<&str>,
    ) {
        let pool = self.context.pool();
        let sid = session_id.to_string();
        let tname = tool_name.to_string();
        let enriched = enrich_metadata(tool_name, error_snippet, tool_input);
        let meta = enriched.map(|s| s.chars().take(500).collect::<String>());
        // Recoverable / environmental failures (stale-hash retry, channel not
        // connected, user-cancelled prompt) are NOT tool defects. Record them
        // under a non-`tool_` event so they stay queryable but OUT of the
        // success-rate denominator — otherwise they drove the RSI to ban
        // working built-in tools (#236).
        let recoverable = !success
            && crate::brain::feedback_policy::is_recoverable_tool_failure(tool_name, error_snippet);
        tokio::spawn(async move {
            let repo = crate::db::repository::FeedbackLedgerRepository::new(pool);
            let event = if success {
                "tool_success"
            } else if recoverable {
                "recoverable_failure"
            } else {
                "tool_failure"
            };
            let val = if success { 1.0 } else { 0.0 };
            if let Err(e) = repo.record(&sid, event, &tname, val, meta.as_deref()).await {
                tracing::debug!("feedback ledger write failed: {e}");
            }
        });
    }

    /// Fire-and-forget recording of a pre-execution tool miss (#214): the model
    /// called a tool that didn't resolve, or with arguments that failed
    /// validation, so the tool never ran. Recorded as `discovery_miss` (NOT a
    /// `tool_`-prefixed event) so it stays OUT of the success-rate denominator
    /// in `stats_by_dimension("tool_")`, while staying visible in the
    /// event-type breakdown and queryable for "what is the model guessing
    /// blind" analysis. `dimension` is the tool name.
    pub(super) fn record_tool_discovery_miss(
        &self,
        session_id: Uuid,
        tool_name: &str,
        tool_input: Option<&serde_json::Value>,
        error_snippet: Option<&str>,
    ) {
        let pool = self.context.pool();
        let sid = session_id.to_string();
        let tname = tool_name.to_string();
        let enriched = enrich_metadata(tool_name, error_snippet, tool_input);
        let meta = enriched.map(|s| s.chars().take(500).collect::<String>());
        tokio::spawn(async move {
            let repo = crate::db::repository::FeedbackLedgerRepository::new(pool);
            if let Err(e) = repo
                .record(&sid, "discovery_miss", &tname, 0.0, meta.as_deref())
                .await
            {
                tracing::debug!("feedback ledger write failed: {e}");
            }
        });
    }

    /// Fire-and-forget recording of a provider error to the feedback ledger.
    pub(super) fn record_provider_feedback(
        &self,
        session_id: Uuid,
        event_type: &str,
        dimension: &str,
        metadata: Option<&str>,
    ) {
        let pool = self.context.pool();
        let sid = session_id.to_string();
        let et = event_type.to_string();
        let dim = dimension.to_string();
        let meta = metadata.map(|s| s.chars().take(500).collect::<String>());
        tokio::spawn(async move {
            let repo = crate::db::repository::FeedbackLedgerRepository::new(pool);
            if let Err(e) = repo.record(&sid, &et, &dim, 0.0, meta.as_deref()).await {
                tracing::debug!("feedback ledger write failed: {e}");
            }
        });
    }
}