Skip to main content

rhema_coding_agent/
config.rs

1//! The agent's configuration: model, prompt, workspace, turn budget.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use rhema_agent_tools::{ShellTools, Skill};
7use rhema_core::ToolResolver;
8use rhema_core::request::ThinkingEffort;
9
10/// Finished-work notifications a turn loop injects into the history.
11///
12/// The contract is deliberately text-shaped: the driver knows nothing
13/// about what the lines announce — a registry of background jobs, an
14/// inbox, anything — and the source knows nothing about turns or
15/// history. `drain` is a query, never a mutation of record: the same
16/// lines may come back again (a crash between the append and the
17/// checkpoint only re-delivers), and the driver deduplicates within a
18/// turn.
19pub trait Steering: Send + Sync {
20    /// The lines to append to the history now, one notification per
21    /// line, in announcement order. Empty means nothing to announce.
22    fn drain(&self) -> Vec<String>;
23}
24
25/// Everything a [`CodingAgent`](crate::CodingAgent) needs besides the
26/// provider.
27#[derive(Clone)]
28pub struct CodingAgentConfig {
29    /// Model identifier, passed through to
30    /// [`ChatParams`](rhema_core::ChatParams).
31    pub model: String,
32    /// System prompt stamped into every request. `None` uses the base
33    /// prompt — [`base_system`](Self::base_system) when set, the
34    /// built-in default otherwise; setting it *replaces* everything, it
35    /// does not append.
36    pub system: Option<String>,
37    /// The base prompt from the user's `SYSTEM.md`, a Jinja template
38    /// rendered by [`render_system`](crate::render_system) — a template
39    /// error fails the agent's construction. `None` uses the built-in
40    /// [`default_system`](crate::default_system).
41    pub base_system: Option<String>,
42    /// Root all tools are scoped to; paths that escape it are rejected
43    /// in-band to the model.
44    pub workspace: PathBuf,
45    /// Skills already collected elsewhere, for the prompt's skills list.
46    /// `None` discovers from the filesystem — the global config
47    /// directory and the workspace's `.rhema/skills`. A frontend with no
48    /// filesystem to discover from (the cloud sandbox) seeds the skill
49    /// files itself and passes the list; the model reads bodies through
50    /// the shell, so each path must resolve inside the workspace.
51    pub skills: Option<Vec<Skill>>,
52    /// Skill folder names to drop from whichever list the prompt carries
53    /// — the persisted off switch a frontend reads out of its config.
54    /// The filter runs after collection and after discovery alike, so
55    /// both supply paths honor it; an empty set (the default) changes
56    /// nothing.
57    pub disabled_skills: std::collections::BTreeSet<String>,
58    /// Cap on steps per turn; a turn still requesting tools past it fails
59    /// with `AgentError::MaxIterations`.
60    pub max_turn_steps: u32,
61    /// How much reasoning the model should do; providers without a knob
62    /// ignore it, and [`ThinkingEffort::Default`] sends nothing.
63    pub thinking: ThinkingEffort,
64    /// The shell resolver the agent's tools run on. `None` builds one
65    /// over `workspace` — the default. A frontend that shares its shell
66    /// with an extension (the subagents extension reads the turn's
67    /// approval hook off it) brings its own.
68    pub tools: Option<Arc<ShellTools>>,
69    /// An extension resolver merged over the shell's tools through
70    /// [`Overlay`](rhema_agent_tools::Overlay): its specs join the
71    /// prompt's tool list and its tools resolve by name, shadowing a
72    /// built-in of the same name. `None` runs the built-ins alone.
73    pub extra_resolver: Option<Arc<dyn ToolResolver>>,
74    /// The transcript: a bounded request view over the unbounded
75    /// session, plus the `zoom` tool to read back through it. On by
76    /// default and below the budget a pass-through, so small sessions
77    /// are byte-identical to a transcript-less run. `None` is the off
78    /// switch: requests carry the whole history. See
79    /// `specs/rhema-transcript.md`.
80    pub transcript: Option<rhema_transcript::TranscriptSettings>,
81    /// The compaction's model fill: a cheap worker the transcript's
82    /// stepped compaction runs at flushes, rewriting each new block
83    /// section's extractive stand-in into a dense markdown block. The
84    /// worker never blocks or fails a request — a failed call leaves
85    /// the stand-in in place. `None` keeps the compaction extractive:
86    /// still stepped, still cache-stable, only less dense. See
87    /// `specs/rhema-transcript.md`.
88    pub summary_worker: Option<Arc<dyn rhema_transcript::Summarizer>>,
89    /// Extra environment the shell tool's commands run with, layered
90    /// over the inherited one — how a frontend provisions its agent's
91    /// client tools (a daemon URL, a scoped token, a PATH addition)
92    /// without touching the process environment.
93    pub shell_env: std::collections::BTreeMap<String, String>,
94    /// A steering source the turn loop drains: one User-role message per
95    /// drain lands in the history at a step boundary, so work that
96    /// finished outside the turn (a background subagent) is announced to
97    /// the model. `None` — the default — runs the turn exactly as
98    /// before: the loop is the provided `run_turn` with no injection.
99    pub steering: Option<Arc<dyn Steering>>,
100    /// The wired MCP servers, as the prompt's `mcp` section carries
101    /// them — the lines a frontend's wire step produced (spec:
102    /// `specs/rhema-mcp.md` §10). Empty renders no section: a
103    /// frontend that wires nothing advertises nothing.
104    pub mcp_servers: Vec<rhema_agent_tools::prompt::McpServerLine>,
105    /// MCP server names to drop from the prompt's section — the
106    /// persisted off switch a frontend reads out of its stored
107    /// settings, the [`disabled_skills`](Self::disabled_skills)
108    /// mechanism. Applied at stamp time over
109    /// [`mcp_servers`](Self::mcp_servers) by config name; the wire
110    /// step filters the same set before anything spawns.
111    pub disabled_mcp_servers: std::collections::BTreeSet<String>,
112    /// The environment section rendered into the system prompt: machine
113    /// facts probed where commands run (see the [`environment`
114    /// module](crate::environment)). `None` renders the section empty.
115    /// A verbatim `system` prompt ignores it, like every template
116    /// section.
117    pub environment: Option<String>,
118    /// The user-preferences section rendered into the system prompt —
119    /// the contents of the user's global `USER.md`, verbatim, a
120    /// frontend that has one reads it and passes it here. `None`
121    /// renders nothing.
122    pub user_preferences: Option<String>,
123    /// The prompt section documenting the wired
124    /// [`vision`](rhema_agent_tools::vision) macro — the `see` bash
125    /// function's documentation, produced by the frontend's
126    /// provisioning and passed verbatim. `None` renders no section: a
127    /// frontend that wired nothing advertises nothing, the same rule
128    /// the `mcp_servers` section follows.
129    pub vision: Option<String>,
130}
131
132impl std::fmt::Debug for CodingAgentConfig {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        // The resolvers are trait objects and stay opaque: their
135        // presence is the debug-worthy fact, not their internals.
136        f.debug_struct("CodingAgentConfig")
137            .field("model", &self.model)
138            .field("system", &self.system)
139            .field("base_system", &self.base_system)
140            .field("workspace", &self.workspace)
141            .field("skills", &self.skills.is_some())
142            .field("max_turn_steps", &self.max_turn_steps)
143            .field("thinking", &self.thinking)
144            .field("tools", &self.tools.is_some())
145            .field("extra_resolver", &self.extra_resolver.is_some())
146            .field("transcript", &self.transcript.is_some())
147            .field("summary_worker", &self.summary_worker.is_some())
148            .field("steering", &self.steering.is_some())
149            .field("shell_env", &self.shell_env.len())
150            .field("environment", &self.environment.is_some())
151            .field("user_preferences", &self.user_preferences.is_some())
152            .finish()
153    }
154}
155
156impl CodingAgentConfig {
157    /// Default for `max_turn_steps`. Long autonomous sessions — builds,
158    /// rebases, multi-package sweeps — routinely run hundreds of steps on
159    /// cheap models under a watching eye; the cap exists to end a runaway
160    /// loop, not to ration the model. `--max-steps` is the tighter leash
161    /// for unattended turns on expensive models.
162    pub const DEFAULT_MAX_TURN_STEPS: u32 = 2048;
163
164    /// Config naming `model`, rooted at `workspace`, defaults elsewhere.
165    pub fn new(model: impl Into<String>, workspace: impl Into<PathBuf>) -> Self {
166        Self {
167            model: model.into(),
168            system: None,
169            base_system: None,
170            workspace: workspace.into(),
171            skills: None,
172            disabled_skills: std::collections::BTreeSet::new(),
173            max_turn_steps: Self::DEFAULT_MAX_TURN_STEPS,
174            thinking: ThinkingEffort::Default,
175            tools: None,
176            extra_resolver: None,
177            transcript: Some(rhema_transcript::TranscriptSettings::default()),
178            summary_worker: None,
179            steering: None,
180            shell_env: std::collections::BTreeMap::new(),
181            mcp_servers: Vec::new(),
182            disabled_mcp_servers: std::collections::BTreeSet::new(),
183            environment: None,
184            user_preferences: None,
185            vision: None,
186        }
187    }
188}