rhema-core 0.2.0

A small, embeddable, generic step engine for one exchange with a model
Documentation
//! The capability seam: one primitive, not a registry.
//!
//! A [`ToolResolver`] decides which tools the model sees this step
//! ([`ToolResolver::specs`]) and turns a model's call into an in-band
//! [`ToolResult`]. Errors never abort a step — they become `is_error` results
//! the model can react to; an unknown tool name is the resolver's error
//! result, not a panic. Each tool also declares its [`ReplaySafety`]:
//! whether a committed call that lost its result to a crash may be
//! re-executed on resume or must be reported interrupted.

use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

/// One tool invocation requested by the model.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ToolCall {
    /// Model-chosen correlation id; the result must carry it back.
    pub id: String,
    /// Name of the tool to invoke. Must be one of the offered specs.
    pub name: String,
    /// Arguments as JSON, validated against the spec's `parameters` schema.
    pub args: serde_json::Value,
    /// Opaque provider metadata that must round-trip with this call through
    /// the session history. Stateful providers mark their function calls
    /// (e.g. Gemini's thought signatures) and reject replays that lose the
    /// marker; the provider stores it here on the way in and reads it back
    /// on the way out. Crate 1 never interprets it — the provider owns the
    /// shape.
    #[serde(default)]
    pub provider_data: serde_json::Value,
}

/// The outcome of resolving one [`ToolCall`], appended to the session
/// history as a `Tool`-role message.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolResult {
    /// The [`ToolCall::id`] this result answers.
    pub id: String,
    /// Result payload as text (JSON-serialized data, or an error message).
    pub content: String,
    /// Whether the call failed; the model reacts to this in-band.
    pub is_error: bool,
    /// The process exit code of command-like tools; `None` for everything
    /// else. UI-facing metadata — the model reads the status from
    /// `content`.
    #[serde(default)]
    pub code: Option<i32>,
    /// Images the result carries beside its text, base64-encoded file
    /// bytes. Empty for most tools; the shell tool fills it from the
    /// images a command staged for viewing (rhema-agent-tools' `vision`
    /// module). Every provider maps the field onto its wire — APIs whose
    /// tool results carry text only state the withholding in the text.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub images: Vec<crate::Image>,
}

impl ToolResult {
    /// A successful result for `id`.
    pub fn success(id: impl Into<String>, content: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            content: content.into(),
            is_error: false,
            code: None,
            images: Vec::new(),
        }
    }

    /// A failed result for `id`; `content` should say what went wrong.
    pub fn failure(id: impl Into<String>, content: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            content: content.into(),
            is_error: true,
            code: None,
            images: Vec::new(),
        }
    }

    /// Attaches the process exit code, for command results.
    pub fn with_code(mut self, code: i32) -> Self {
        self.code = Some(code);
        self
    }

    /// Attaches images, replacing any already present — the staging
    /// side's builder. An empty `images` changes nothing.
    pub fn with_images(mut self, images: Vec<crate::Image>) -> Self {
        self.images = images;
        self
    }
}

/// Per-run tool execution environment, created by the driver so that
/// permissions, cwd, and cancellation are fixed before the step starts.
#[derive(Debug, Clone)]
pub struct ToolContext {
    /// Working directory tool executions run in.
    pub cwd: PathBuf,
    /// Driver-chosen identifier of the current run, for tracing.
    pub run_id: String,
    /// Set to `true` by the driver to ask in-flight tools to stop; polling
    /// the flag is the resolver's responsibility.
    pub cancelled: Arc<AtomicBool>,
}

impl Default for ToolContext {
    fn default() -> Self {
        Self {
            cwd: PathBuf::from("."),
            run_id: String::new(),
            cancelled: Arc::new(AtomicBool::new(false)),
        }
    }
}

/// Whether re-running a tool after an interruption can repeat an external
/// side effect. Declared per tool via [`ToolResolver::replay_safety`] and
/// consulted when a session resumes with a committed call whose result
/// never landed: a `Safe` call may be re-executed to finish the round, an
/// `Unsafe` one is reported interrupted so the model can verify state
/// before retrying.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ReplaySafety {
    /// Re-running may repeat an external effect — a post, a merge, a
    /// delete. The default: an unclassified tool is treated as
    /// side-effecting.
    #[default]
    Unsafe,
    /// Re-running is safe — read-only queries, pure computation. A resume
    /// may re-execute the call without asking the model again.
    Safe,
}

/// Turns a model's tool calls into results.
#[async_trait]
pub trait ToolResolver: Send + Sync {
    /// The tools offered to the model this step.
    fn specs(&self) -> Vec<crate::request::ToolSpec>;

    /// The replay safety of one offered tool (see [`ReplaySafety`]),
    /// consulted when a crash left a committed call without its result.
    /// Defaults to [`ReplaySafety::Unsafe`]: a resolver that does not
    /// classify its tools is treated as entirely side-effecting.
    fn replay_safety(&self, _name: &str) -> ReplaySafety {
        ReplaySafety::Unsafe
    }

    /// Turn a model's call into a result. Errors become in-band results:
    /// implementations must not panic and must not abort on unknown names
    /// or bad arguments — return [`ToolResult::failure`] instead.
    async fn resolve(&self, call: ToolCall, ctx: &ToolContext) -> ToolResult;

    /// Resolve with progress: an implementation running something long —
    /// a shell command — reports human-facing output chunks (complete
    /// lines) as they land, so a sink can render them live; the full
    /// text still arrives in the returned result. The default ignores
    /// progress and resolves as usual.
    async fn resolve_streaming(
        &self,
        call: ToolCall,
        ctx: &ToolContext,
        progress: &mut dyn ToolProgress,
    ) -> ToolResult {
        let _ = progress;
        self.resolve(call, ctx).await
    }
}

/// The live status of a streaming tool call's inner work: what that work
/// is doing now, the context its current model call carries, and what
/// its model calls have spent, when the tool knows. Status, not
/// output — a sink shows it beside the call, and it never lands in the
/// call's output or the session history.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolStatus {
    /// The phase the work is in, in the few words a status line reads
    /// ("thinking", "running").
    pub phase: String,
    /// The work's current model call's usage — prompt plus reply in
    /// flight — when the tool runs model calls of its own. The figure a
    /// context-window gauge sizes against [`ToolStatus::model`]'s
    /// window: each step re-sends the whole prompt, so this is the only
    /// input count that reads as "how full is the context now".
    pub context: Option<crate::output::Usage>,
    /// The work's cumulative token spend across every model call so
    /// far, when the tool runs model calls of its own. The figure cost
    /// prices — every re-sent prompt is billed again, so the spend
    /// legitimately grows past the context window.
    pub spent: Option<crate::output::Usage>,
    /// The model the work runs on — the catalog id a UI prices and sizes
    /// it by — when the tool knows it. It may differ from the caller's
    /// model.
    pub model: Option<String>,
}

/// The live channel a streaming resolver reports through. Output is
/// human-facing text owned by the call; status is the resolver's own
/// state, for a sink that has a place to show it.
pub trait ToolProgress: Send {
    /// A human-facing output chunk (a complete line), rendered live; the
    /// full text still arrives in the returned result.
    fn output(&mut self, chunk: &str);
    /// The work's current status, replacing any earlier one. The default
    /// drops it: a sink with nowhere to show status loses nothing.
    fn status(&mut self, status: ToolStatus) {
        let _ = status;
    }
}

/// A [`ToolProgress`] that keeps only the output chunks: the adapter for
/// callers — drivers, tests — that have no status surface.
pub struct OutputOnly<F: FnMut(&str) + Send>(
    /// The receiver of the output chunks.
    pub F,
);

impl<F: FnMut(&str) + Send> std::fmt::Debug for OutputOnly<F> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // The receiver is a closure: it prints as a placeholder.
        f.debug_tuple("OutputOnly").field(&"<closure>").finish()
    }
}

impl<F: FnMut(&str) + Send> ToolProgress for OutputOnly<F> {
    fn output(&mut self, chunk: &str) {
        (self.0)(chunk);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_result_without_a_code_field_loads_from_an_old_history() {
        let raw = r#"{"id":"c1","content":"ok","is_error":false}"#;
        let result: ToolResult = serde_json::from_str(raw).unwrap();
        assert_eq!(result.code, None);
        assert_eq!(result.images, Vec::new());
        assert_eq!(result.with_code(3).code, Some(3));
    }

    #[test]
    fn images_round_trip_and_absent_images_stay_out_of_the_json() {
        let result = ToolResult::success("c1", "here").with_images(vec![crate::Image::new(
            crate::ImageMediaType::Png,
            "aGVsbG8=",
        )]);
        let text = serde_json::to_string(&result).unwrap();
        assert!(text.contains("aGVsbG8="), "{text}");
        let back: ToolResult = serde_json::from_str(&text).unwrap();
        assert_eq!(back, result);

        // No images: the field vanishes, so old histories stay byte-stable.
        let bare = serde_json::to_value(ToolResult::success("c1", "here")).unwrap();
        assert!(bare.get("images").is_none(), "{bare}");
    }
}