rhema-coding-agent 0.2.5

The coding-agent driver on top of rhema-core: turn loop, session persistence, the SYSTEM.md template
Documentation
//! The agent's configuration: model, prompt, workspace, turn budget.

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

use rhema_agent_tools::{ShellTools, Skill};
use rhema_core::ToolResolver;
use rhema_core::request::ThinkingEffort;

/// Finished-work notifications a turn loop injects into the history.
///
/// The contract is deliberately text-shaped: the driver knows nothing
/// about what the lines announce — a registry of background jobs, an
/// inbox, anything — and the source knows nothing about turns or
/// history. `drain` is a query, never a mutation of record: the same
/// lines may come back again (a crash between the append and the
/// checkpoint only re-delivers), and the driver deduplicates within a
/// turn.
pub trait Steering: Send + Sync {
    /// The lines to append to the history now, one notification per
    /// line, in announcement order. Empty means nothing to announce.
    fn drain(&self) -> Vec<String>;
}

/// How a frontend resolves any provider kind to a turn provider: the
/// credential policy behind the subagent tool's cross-provider
/// `model` argument. A `None` answer means that kind has no usable
/// credentials here — the same shape the model picker's catalog
/// reports. The registry's `build` does the wiring; what this closure
/// adds is the resolved credentials themselves.
pub type SubagentProviders =
    Arc<dyn Fn(&str) -> Option<Arc<dyn rhema_core::ModelProvider>> + Send + Sync>;

/// Everything a [`CodingAgent`](crate::CodingAgent) needs besides the
/// provider.
#[derive(Clone)]
pub struct CodingAgentConfig {
    /// Model identifier, passed through to
    /// [`ChatParams`](rhema_core::ChatParams).
    pub model: String,
    /// System prompt stamped into every request. `None` uses the base
    /// prompt — [`base_system`](Self::base_system) when set, the
    /// built-in default otherwise; setting it *replaces* everything, it
    /// does not append.
    pub system: Option<String>,
    /// The base prompt from the user's `SYSTEM.md`, a Jinja template
    /// rendered by [`render_system`](crate::render_system) — a template
    /// error fails the agent's construction. `None` uses the built-in
    /// [`default_system`](crate::default_system).
    pub base_system: Option<String>,
    /// Root all tools are scoped to; paths that escape it are rejected
    /// in-band to the model.
    pub workspace: PathBuf,
    /// Skills already collected elsewhere, for the prompt's skills list.
    /// `None` discovers from the filesystem — the global config
    /// directory and the workspace's `.rhema/skills`. A frontend with no
    /// filesystem to discover from (the cloud sandbox) seeds the skill
    /// files itself and passes the list; the model reads bodies through
    /// the shell, so each path must resolve inside the workspace.
    pub skills: Option<Vec<Skill>>,
    /// Skill folder names to drop from whichever list the prompt carries
    /// — the persisted off switch a frontend reads out of its config.
    /// The filter runs after collection and after discovery alike, so
    /// both supply paths honor it; an empty set (the default) changes
    /// nothing.
    pub disabled_skills: std::collections::BTreeSet<String>,
    /// Cap on steps per turn; a turn still requesting tools past it fails
    /// with `AgentError::MaxIterations`.
    pub max_turn_steps: u32,
    /// How much reasoning the model should do; providers without a knob
    /// ignore it, and [`ThinkingEffort::Default`] sends nothing.
    pub thinking: ThinkingEffort,
    /// The shell resolver the agent's tools run on. `None` builds one
    /// over `workspace` — the default. A frontend that shares its shell
    /// with an extension (the subagents extension reads the turn's
    /// approval hook off it) brings its own.
    pub tools: Option<Arc<ShellTools>>,
    /// An extension resolver merged over the shell's tools through
    /// [`Overlay`](rhema_agent_tools::Overlay): its specs join the
    /// prompt's tool list and its tools resolve by name, shadowing a
    /// built-in of the same name. `None` runs the built-ins alone.
    pub extra_resolver: Option<Arc<dyn ToolResolver>>,
    /// The transcript: a bounded request view over the unbounded
    /// session, plus the `zoom` tool to read back through it. On by
    /// default and below the budget a pass-through, so small sessions
    /// are byte-identical to a transcript-less run. `None` is the off
    /// switch: requests carry the whole history. See
    /// `specs/rhema-transcript.md`.
    pub transcript: Option<rhema_transcript::TranscriptSettings>,
    /// The compaction's model fill: a cheap worker the transcript's
    /// stepped compaction runs at flushes, rewriting each new block
    /// section's extractive stand-in into a dense markdown block. The
    /// worker never blocks or fails a request — a failed call leaves
    /// the stand-in in place. `None` keeps the compaction extractive:
    /// still stepped, still cache-stable, only less dense. See
    /// `specs/rhema-transcript.md`.
    pub summary_worker: Option<Arc<dyn rhema_transcript::Summarizer>>,
    /// Extra environment the shell tool's commands run with, layered
    /// over the inherited one — how a frontend provisions its agent's
    /// client tools (a daemon URL, a scoped token, a PATH addition)
    /// without touching the process environment.
    pub shell_env: std::collections::BTreeMap<String, String>,
    /// A steering source the turn loop drains: one User-role message per
    /// drain lands in the history at a step boundary, so work that
    /// finished outside the turn (a background subagent) is announced to
    /// the model. `None` — the default — runs the turn exactly as
    /// before: the loop is the provided `run_turn` with no injection.
    pub steering: Option<Arc<dyn Steering>>,
    /// The wired MCP servers, as the prompt's `mcp` section carries
    /// them — the lines a frontend's wire step produced (spec:
    /// `specs/rhema-mcp.md` §10). Empty renders no section: a
    /// frontend that wires nothing advertises nothing.
    pub mcp_servers: Vec<rhema_agent_tools::prompt::McpServerLine>,
    /// MCP server names to drop from the prompt's section — the
    /// persisted off switch a frontend reads out of its stored
    /// settings, the [`disabled_skills`](Self::disabled_skills)
    /// mechanism. Applied at stamp time over
    /// [`mcp_servers`](Self::mcp_servers) by config name; the wire
    /// step filters the same set before anything spawns.
    pub disabled_mcp_servers: std::collections::BTreeSet<String>,
    /// Subagent definition folder names the subagent wire drops from
    /// discovery — the persisted off switch a frontend reads out of
    /// its stored settings, the [`disabled_skills`](Self::disabled_skills)
    /// mechanism. The filter runs before the catalog builds, so a
    /// disabled name is in neither the prompt's tool list nor the
    /// tools array; the built-in `general-purpose` answers to its name
    /// like any other, and a set holding every discovered name leaves
    /// no catalog, which drops the tool whole.
    pub disabled_subagents: std::collections::BTreeSet<String>,
    /// Resolves a provider kind to a turn provider, for the subagent
    /// tool's `model` argument (`<provider>/<model>`) and the same
    /// spelling in a definition's `model` frontmatter: a subagent can
    /// run on another logged-in provider while the session stays put.
    /// The wire hands it to the tool; `None` means the session's own
    /// provider is the only one, and cross-provider requests fail with
    /// that named. Credential resolution stays with the frontend —
    /// this carries the answers, not the policy.
    pub subagent_providers: Option<SubagentProviders>,
    /// The environment section rendered into the system prompt: machine
    /// facts probed where commands run (see the [`environment`
    /// module](crate::environment)). `None` renders the section empty.
    /// A verbatim `system` prompt ignores it, like every template
    /// section.
    pub environment: Option<String>,
    /// The user-preferences section rendered into the system prompt —
    /// the contents of the user's global `USER.md`, verbatim, a
    /// frontend that has one reads it and passes it here. `None`
    /// renders nothing.
    pub user_preferences: Option<String>,
    /// The prompt section documenting the wired
    /// [`vision`](rhema_agent_tools::vision) macro — the `see` bash
    /// function's documentation, produced by the frontend's
    /// provisioning and passed verbatim. `None` renders no section: a
    /// frontend that wired nothing advertises nothing, the same rule
    /// the `mcp_servers` section follows.
    pub vision: Option<String>,
    /// The prompt section documenting the wired
    /// [`jobs`](rhema_agent_tools::jobs) macro — the `job` bash
    /// function's documentation, produced by the frontend's provisioning
    /// and passed verbatim. `None` renders no section: a frontend that
    /// wired nothing (a sandbox session, a machine without the backend)
    /// advertises nothing, the same rule [`vision`](Self::vision)
    /// follows.
    pub jobs: Option<String>,
    /// The directory conventions skill discovery reads when
    /// [`skills`](Self::skills) is `None`: the user-wide skills directory
    /// and the cross-agent `.agents/skills` convention. `None` — the
    /// default — discovers the reference layout
    /// ([`Home::discover`](rhema_config::Home::discover)); a frontend
    /// that owns its own directory hands a relocated `Home` instead.
    pub home: Option<rhema_config::Home>,
}

impl std::fmt::Debug for CodingAgentConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // The resolvers are trait objects and stay opaque: their
        // presence is the debug-worthy fact, not their internals.
        f.debug_struct("CodingAgentConfig")
            .field("model", &self.model)
            .field("system", &self.system)
            .field("base_system", &self.base_system)
            .field("workspace", &self.workspace)
            .field("skills", &self.skills.is_some())
            .field("max_turn_steps", &self.max_turn_steps)
            .field("thinking", &self.thinking)
            .field("tools", &self.tools.is_some())
            .field("extra_resolver", &self.extra_resolver.is_some())
            .field("transcript", &self.transcript.is_some())
            .field("summary_worker", &self.summary_worker.is_some())
            .field("steering", &self.steering.is_some())
            .field("shell_env", &self.shell_env.len())
            .field("environment", &self.environment.is_some())
            .field("user_preferences", &self.user_preferences.is_some())
            .field("home", &self.home.is_some())
            .finish()
    }
}

impl CodingAgentConfig {
    /// Default for `max_turn_steps`. Long autonomous sessions — builds,
    /// rebases, multi-package sweeps — routinely run hundreds of steps on
    /// cheap models under a watching eye; the cap exists to end a runaway
    /// loop, not to ration the model. `--max-steps` is the tighter leash
    /// for unattended turns on expensive models.
    pub const DEFAULT_MAX_TURN_STEPS: u32 = 2048;

    /// Config naming `model`, rooted at `workspace`, defaults elsewhere.
    pub fn new(model: impl Into<String>, workspace: impl Into<PathBuf>) -> Self {
        Self {
            model: model.into(),
            system: None,
            base_system: None,
            workspace: workspace.into(),
            skills: None,
            disabled_skills: std::collections::BTreeSet::new(),
            max_turn_steps: Self::DEFAULT_MAX_TURN_STEPS,
            thinking: ThinkingEffort::Default,
            tools: None,
            extra_resolver: None,
            transcript: Some(rhema_transcript::TranscriptSettings::default()),
            summary_worker: None,
            steering: None,
            shell_env: std::collections::BTreeMap::new(),
            mcp_servers: Vec::new(),
            disabled_mcp_servers: std::collections::BTreeSet::new(),
            disabled_subagents: std::collections::BTreeSet::new(),
            subagent_providers: None,
            environment: None,
            user_preferences: None,
            vision: None,
            jobs: None,
            home: None,
        }
    }
}