1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
/// One fully-resolved MCP server entry in a state's effective set.
///
/// `definition` is `Some` when the entry resolves against the merged registry
/// or carries inline fields, and `None` only when the id is unknown — callers
/// treat the latter as a validation error.
#[derive(Debug, Clone)]
struct ResolvedMcpEntry {
id: String,
/// `optional: true` on the declaring entry. Used by Half B to decide
/// whether a failed availability check blocks the agent or is downgraded
/// to a warning. Carried in Half A so the resolution path is complete.
#[allow(dead_code)]
optional: bool,
definition: Option<McpServerProfile>,
}
/// One fully-resolved skill entry in a state's effective set.
#[derive(Debug, Clone)]
struct ResolvedSkillEntry {
id: String,
#[allow(dead_code)]
optional: bool,
definition: Option<SkillProfile>,
}
/// The tooling a state contributes to the agent subprocess.
///
/// Half A: availability is computed from registry resolution only — an entry
/// whose id resolves (or carries an inline definition) is reported available.
/// Half B will hook actual MCP handshake checks and skill-path probes into
/// the same struct, leaving call sites unchanged.
#[derive(Debug, Clone, Default)]
struct ResolvedTooling {
mcp_servers: Vec<ResolvedMcpEntry>,
skills: Vec<ResolvedSkillEntry>,
}
impl ResolvedTooling {
/// Ids whose definition resolved — used for `{mcp.<name>.available}` and
/// the `RHEI_MCP_<NAME>_AVAILABLE` env vars.
fn mcp_available(&self, id: &str) -> bool {
self.mcp_servers.iter().any(|e| e.id == id && e.definition.is_some())
}
fn skill_available(&self, id: &str) -> bool {
self.skills.iter().any(|e| e.id == id && e.definition.is_some())
}
/// Comma-separated ids of resolved MCP servers (available only).
fn mcp_servers_csv(&self) -> String {
self.mcp_servers
.iter()
.filter(|e| e.definition.is_some())
.map(|e| e.id.as_str())
.collect::<Vec<_>>()
.join(",")
}
fn skills_csv(&self) -> String {
self.skills
.iter()
.filter(|e| e.definition.is_some())
.map(|e| e.id.as_str())
.collect::<Vec<_>>()
.join(",")
}
}
/// Normalize an id into the env-var segment used by `RHEI_*_<NAME>_AVAILABLE`.
fn env_id_segment(id: &str) -> String {
id.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' })
.collect()
}
fn slugify_target_value(value: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
last_was_dash = false;
} else if matches!(ch, '.' | '_' | '-') {
slug.push(ch);
last_was_dash = ch == '-';
} else if !last_was_dash {
slug.push('-');
last_was_dash = true;
}
}
slug.trim_matches('-').to_string()
}
/// Compute the effective tooling set for a state given the merged settings.
fn resolve_tooling(
machine: &rhei_validator::StateMachine,
state_name: &str,
settings: &RheiSettings,
) -> ResolvedTooling {
let state_def = machine.states.get(state_name);
// MCP: start from defaults (if any), then override/extend with state-level.
let mcp_entries = effective_mcp_entries(
settings.defaults.mcp_servers.as_deref().unwrap_or(&[]),
state_def.and_then(|d| d.mcp_servers.as_deref()),
);
let mcp_servers: Vec<ResolvedMcpEntry> = mcp_entries
.into_iter()
.map(|entry| resolve_mcp_entry(&entry, &settings.mcp_servers))
.collect();
let skill_entries = effective_skill_entries(
settings.defaults.skills.as_deref().unwrap_or(&[]),
state_def.and_then(|d| d.skills.as_deref()),
);
let skills: Vec<ResolvedSkillEntry> = skill_entries
.into_iter()
.map(|entry| resolve_skill_entry(&entry, &settings.skills))
.collect();
ResolvedTooling { mcp_servers, skills }
}
/// Union `defaults.mcp_servers` with a state's `mcp_servers`, deduped by id.
///
/// `None` on the state = inherit defaults. `Some(empty)` = clear defaults.
/// `Some(non-empty)` = append/override defaults by id (state wins).
fn effective_mcp_entries(
defaults: &[StateMcpEntry],
state: Option<&[StateMcpEntry]>,
) -> Vec<StateMcpEntry> {
match state {
None => defaults.to_vec(),
Some([]) => Vec::new(),
Some(list) => {
let mut out: Vec<StateMcpEntry> = defaults.to_vec();
for entry in list {
if let Some(pos) = out.iter().position(|e| e.id() == entry.id()) {
out[pos] = entry.clone();
} else {
out.push(entry.clone());
}
}
out
}
}
}
fn effective_skill_entries(
defaults: &[StateSkillEntry],
state: Option<&[StateSkillEntry]>,
) -> Vec<StateSkillEntry> {
match state {
None => defaults.to_vec(),
Some([]) => Vec::new(),
Some(list) => {
let mut out: Vec<StateSkillEntry> = defaults.to_vec();
for entry in list {
if let Some(pos) = out.iter().position(|e| e.id() == entry.id()) {
out[pos] = entry.clone();
} else {
out.push(entry.clone());
}
}
out
}
}
}
/// Resolve one entry against the registry. Inline definitions on the entry
/// take precedence over registry lookups.
fn resolve_mcp_entry(
entry: &StateMcpEntry,
registry: &BTreeMap<String, McpServerProfile>,
) -> ResolvedMcpEntry {
let id = entry.id().to_string();
let optional = entry.is_optional();
let inline = match entry {
StateMcpEntry::Object(obj) if obj.command.is_some() || obj.url.is_some() => {
Some(inline_mcp_profile(obj))
}
_ => None,
};
let definition = inline.or_else(|| registry.get(&id).cloned());
ResolvedMcpEntry { id, optional, definition }
}
fn resolve_skill_entry(
entry: &StateSkillEntry,
registry: &BTreeMap<String, SkillProfile>,
) -> ResolvedSkillEntry {
let id = entry.id().to_string();
let optional = entry.is_optional();
let inline = match entry {
StateSkillEntry::Object(obj) if obj.path.is_some() => Some(SkillProfile {
path: obj.path.clone().unwrap_or_default(),
description: obj.description.clone(),
}),
_ => None,
};
let mut definition = inline.or_else(|| registry.get(&id).cloned());
if let Some(def) = definition.as_mut() {
// Expand leading `~` so subsequent existence checks and on-disk
// probes see the absolute path. The expansion happens once, here.
def.path = expand_home(&def.path);
// Best-effort spawn-time availability check: a skill bundle is
// available only when its path exists. When it does not, drop the
// definition so `available` is reported `false` to env vars and
// the `?` suffix appears in the log header. Required-vs-optional
// escalation lives further up the run loop (deferred Half B).
let path = Path::new(&def.path);
if !path.exists() {
definition = None;
}
}
ResolvedSkillEntry { id, optional, definition }
}
/// Expand a leading `~` (or `~/`) into the current user's home directory.
/// Unchanged when no home is set or when the input does not start with `~`.
fn expand_home(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = home_dir() {
return home.join(rest).display().to_string();
}
} else if path == "~" {
if let Ok(home) = home_dir() {
return home.display().to_string();
}
}
path.to_string()
}
fn inline_mcp_profile(obj: &StateMcpEntryObject) -> McpServerProfile {
McpServerProfile {
command: obj.command.clone(),
url: obj.url.clone(),
transport: obj.transport.clone(),
env: obj.env.clone(),
working_directory: obj.working_directory.clone(),
startup_timeout: obj.startup_timeout.clone(),
}
}
/// Resolved agent and model for a specific task invocation.
#[derive(Clone)]
struct ResolvedAgent {
/// Agent id (key into the merged `agents` registry).
agent: AgentConfig,
/// The registry-resolved transport profile for `agent`.
profile: CustomAgentProfile,
/// Resolved mode name, or `None` if the agent has no modes or none was
/// selected.
mode: Option<String>,
/// Inline execution target selector, when the state resolves via `target`
/// or `all_targets`.
target: Option<ExecutionTarget>,
/// Resolved model profile id (the key into `models` if the registry knows
/// it, otherwise the literal string from settings or state). This is what
/// appears in logs, in `RHEI_MODEL`, and in template variables.
model: Option<String>,
/// Resolved provider id (e.g. `anthropic`, `openai`). Comes from the
/// `models` registry, or from an `ExecutionTarget` when one is in play.
model_provider: Option<String>,
/// Resolved concrete provider model name (e.g. `claude-sonnet-4-6`).
/// This is what gets passed to the agent's `model_flag` and exposed as
/// `RHEI_MODEL_NAME`. Falls back to the model id when the registry has
/// no entry.
model_name: Option<String>,
timeout_secs: Option<u64>,
/// `models.<id>.agents.<agent-id>.autonomous_args` — ordered flag list
/// appended after the mode flags when `rhei run` launches the agent
/// §FS-rhei-agents.1.1.3 §FS-rhei-agents.2.2: Autonomous model-agent flags.
autonomous_args: Vec<String>,
}
#[derive(Clone)]
enum ProgramCommand {
Shell(String),
Exec(Vec<String>),
}
#[derive(Clone)]
struct ProgramSpec {
command: ProgramCommand,
env: BTreeMap<String, String>,
working_directory: Option<String>,
shell: bool,
}
#[derive(Clone)]
struct ResolvedProgram {
program: ProgramSpec,
timeout_secs: Option<u64>,
}