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}