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/// How a frontend resolves any provider kind to a turn provider: the
26/// credential policy behind the subagent tool's cross-provider
27/// `model` argument. A `None` answer means that kind has no usable
28/// credentials here — the same shape the model picker's catalog
29/// reports. The registry's `build` does the wiring; what this closure
30/// adds is the resolved credentials themselves.
31pub type SubagentProviders =
32 Arc<dyn Fn(&str) -> Option<Arc<dyn rhema_core::ModelProvider>> + Send + Sync>;
33
34/// Everything a [`CodingAgent`](crate::CodingAgent) needs besides the
35/// provider.
36#[derive(Clone)]
37pub struct CodingAgentConfig {
38 /// Model identifier, passed through to
39 /// [`ChatParams`](rhema_core::ChatParams).
40 pub model: String,
41 /// System prompt stamped into every request. `None` uses the base
42 /// prompt — [`base_system`](Self::base_system) when set, the
43 /// built-in default otherwise; setting it *replaces* everything, it
44 /// does not append.
45 pub system: Option<String>,
46 /// The base prompt from the user's `SYSTEM.md`, a Jinja template
47 /// rendered by [`render_system`](crate::render_system) — a template
48 /// error fails the agent's construction. `None` uses the built-in
49 /// [`default_system`](crate::default_system).
50 pub base_system: Option<String>,
51 /// Root all tools are scoped to; paths that escape it are rejected
52 /// in-band to the model.
53 pub workspace: PathBuf,
54 /// Skills already collected elsewhere, for the prompt's skills list.
55 /// `None` discovers from the filesystem — the global config
56 /// directory and the workspace's `.rhema/skills`. A frontend with no
57 /// filesystem to discover from (the cloud sandbox) seeds the skill
58 /// files itself and passes the list; the model reads bodies through
59 /// the shell, so each path must resolve inside the workspace.
60 pub skills: Option<Vec<Skill>>,
61 /// Skill folder names to drop from whichever list the prompt carries
62 /// — the persisted off switch a frontend reads out of its config.
63 /// The filter runs after collection and after discovery alike, so
64 /// both supply paths honor it; an empty set (the default) changes
65 /// nothing.
66 pub disabled_skills: std::collections::BTreeSet<String>,
67 /// Cap on steps per turn; a turn still requesting tools past it fails
68 /// with `AgentError::MaxIterations`.
69 pub max_turn_steps: u32,
70 /// How much reasoning the model should do; providers without a knob
71 /// ignore it, and [`ThinkingEffort::Default`] sends nothing.
72 pub thinking: ThinkingEffort,
73 /// The shell resolver the agent's tools run on. `None` builds one
74 /// over `workspace` — the default. A frontend that shares its shell
75 /// with an extension (the subagents extension reads the turn's
76 /// approval hook off it) brings its own.
77 pub tools: Option<Arc<ShellTools>>,
78 /// An extension resolver merged over the shell's tools through
79 /// [`Overlay`](rhema_agent_tools::Overlay): its specs join the
80 /// prompt's tool list and its tools resolve by name, shadowing a
81 /// built-in of the same name. `None` runs the built-ins alone.
82 pub extra_resolver: Option<Arc<dyn ToolResolver>>,
83 /// The transcript: a bounded request view over the unbounded
84 /// session, plus the `zoom` tool to read back through it. On by
85 /// default and below the budget a pass-through, so small sessions
86 /// are byte-identical to a transcript-less run. `None` is the off
87 /// switch: requests carry the whole history. See
88 /// `specs/rhema-transcript.md`.
89 pub transcript: Option<rhema_transcript::TranscriptSettings>,
90 /// The compaction's model fill: a cheap worker the transcript's
91 /// stepped compaction runs at flushes, rewriting each new block
92 /// section's extractive stand-in into a dense markdown block. The
93 /// worker never blocks or fails a request — a failed call leaves
94 /// the stand-in in place. `None` keeps the compaction extractive:
95 /// still stepped, still cache-stable, only less dense. See
96 /// `specs/rhema-transcript.md`.
97 pub summary_worker: Option<Arc<dyn rhema_transcript::Summarizer>>,
98 /// Extra environment the shell tool's commands run with, layered
99 /// over the inherited one — how a frontend provisions its agent's
100 /// client tools (a daemon URL, a scoped token, a PATH addition)
101 /// without touching the process environment.
102 pub shell_env: std::collections::BTreeMap<String, String>,
103 /// A steering source the turn loop drains: one User-role message per
104 /// drain lands in the history at a step boundary, so work that
105 /// finished outside the turn (a background subagent) is announced to
106 /// the model. `None` — the default — runs the turn exactly as
107 /// before: the loop is the provided `run_turn` with no injection.
108 pub steering: Option<Arc<dyn Steering>>,
109 /// The wired MCP servers, as the prompt's `mcp` section carries
110 /// them — the lines a frontend's wire step produced (spec:
111 /// `specs/rhema-mcp.md` §10). Empty renders no section: a
112 /// frontend that wires nothing advertises nothing.
113 pub mcp_servers: Vec<rhema_agent_tools::prompt::McpServerLine>,
114 /// MCP server names to drop from the prompt's section — the
115 /// persisted off switch a frontend reads out of its stored
116 /// settings, the [`disabled_skills`](Self::disabled_skills)
117 /// mechanism. Applied at stamp time over
118 /// [`mcp_servers`](Self::mcp_servers) by config name; the wire
119 /// step filters the same set before anything spawns.
120 pub disabled_mcp_servers: std::collections::BTreeSet<String>,
121 /// Subagent definition folder names the subagent wire drops from
122 /// discovery — the persisted off switch a frontend reads out of
123 /// its stored settings, the [`disabled_skills`](Self::disabled_skills)
124 /// mechanism. The filter runs before the catalog builds, so a
125 /// disabled name is in neither the prompt's tool list nor the
126 /// tools array; the built-in `general-purpose` answers to its name
127 /// like any other, and a set holding every discovered name leaves
128 /// no catalog, which drops the tool whole.
129 pub disabled_subagents: std::collections::BTreeSet<String>,
130 /// Resolves a provider kind to a turn provider, for the subagent
131 /// tool's `model` argument (`<provider>/<model>`) and the same
132 /// spelling in a definition's `model` frontmatter: a subagent can
133 /// run on another logged-in provider while the session stays put.
134 /// The wire hands it to the tool; `None` means the session's own
135 /// provider is the only one, and cross-provider requests fail with
136 /// that named. Credential resolution stays with the frontend —
137 /// this carries the answers, not the policy.
138 pub subagent_providers: Option<SubagentProviders>,
139 /// The environment section rendered into the system prompt: machine
140 /// facts probed where commands run (see the [`environment`
141 /// module](crate::environment)). `None` renders the section empty.
142 /// A verbatim `system` prompt ignores it, like every template
143 /// section.
144 pub environment: Option<String>,
145 /// The user-preferences section rendered into the system prompt —
146 /// the contents of the user's global `USER.md`, verbatim, a
147 /// frontend that has one reads it and passes it here. `None`
148 /// renders nothing.
149 pub user_preferences: Option<String>,
150 /// The prompt section documenting the wired
151 /// [`vision`](rhema_agent_tools::vision) macro — the `see` bash
152 /// function's documentation, produced by the frontend's
153 /// provisioning and passed verbatim. `None` renders no section: a
154 /// frontend that wired nothing advertises nothing, the same rule
155 /// the `mcp_servers` section follows.
156 pub vision: Option<String>,
157 /// The prompt section documenting the wired
158 /// [`jobs`](rhema_agent_tools::jobs) macro — the `job` bash
159 /// function's documentation, produced by the frontend's provisioning
160 /// and passed verbatim. `None` renders no section: a frontend that
161 /// wired nothing (a sandbox session, a machine without the backend)
162 /// advertises nothing, the same rule [`vision`](Self::vision)
163 /// follows.
164 pub jobs: Option<String>,
165 /// The directory conventions skill discovery reads when
166 /// [`skills`](Self::skills) is `None`: the user-wide skills directory
167 /// and the cross-agent `.agents/skills` convention. `None` — the
168 /// default — discovers the reference layout
169 /// ([`Home::discover`](rhema_config::Home::discover)); a frontend
170 /// that owns its own directory hands a relocated `Home` instead.
171 pub home: Option<rhema_config::Home>,
172}
173
174impl std::fmt::Debug for CodingAgentConfig {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 // The resolvers are trait objects and stay opaque: their
177 // presence is the debug-worthy fact, not their internals.
178 f.debug_struct("CodingAgentConfig")
179 .field("model", &self.model)
180 .field("system", &self.system)
181 .field("base_system", &self.base_system)
182 .field("workspace", &self.workspace)
183 .field("skills", &self.skills.is_some())
184 .field("max_turn_steps", &self.max_turn_steps)
185 .field("thinking", &self.thinking)
186 .field("tools", &self.tools.is_some())
187 .field("extra_resolver", &self.extra_resolver.is_some())
188 .field("transcript", &self.transcript.is_some())
189 .field("summary_worker", &self.summary_worker.is_some())
190 .field("steering", &self.steering.is_some())
191 .field("shell_env", &self.shell_env.len())
192 .field("environment", &self.environment.is_some())
193 .field("user_preferences", &self.user_preferences.is_some())
194 .field("home", &self.home.is_some())
195 .finish()
196 }
197}
198
199impl CodingAgentConfig {
200 /// Default for `max_turn_steps`. Long autonomous sessions — builds,
201 /// rebases, multi-package sweeps — routinely run hundreds of steps on
202 /// cheap models under a watching eye; the cap exists to end a runaway
203 /// loop, not to ration the model. `--max-steps` is the tighter leash
204 /// for unattended turns on expensive models.
205 pub const DEFAULT_MAX_TURN_STEPS: u32 = 2048;
206
207 /// Config naming `model`, rooted at `workspace`, defaults elsewhere.
208 pub fn new(model: impl Into<String>, workspace: impl Into<PathBuf>) -> Self {
209 Self {
210 model: model.into(),
211 system: None,
212 base_system: None,
213 workspace: workspace.into(),
214 skills: None,
215 disabled_skills: std::collections::BTreeSet::new(),
216 max_turn_steps: Self::DEFAULT_MAX_TURN_STEPS,
217 thinking: ThinkingEffort::Default,
218 tools: None,
219 extra_resolver: None,
220 transcript: Some(rhema_transcript::TranscriptSettings::default()),
221 summary_worker: None,
222 steering: None,
223 shell_env: std::collections::BTreeMap::new(),
224 mcp_servers: Vec::new(),
225 disabled_mcp_servers: std::collections::BTreeSet::new(),
226 disabled_subagents: std::collections::BTreeSet::new(),
227 subagent_providers: None,
228 environment: None,
229 user_preferences: None,
230 vision: None,
231 jobs: None,
232 home: None,
233 }
234 }
235}