Skip to main content

sim_lib_agent/atelier/
tools.rs

1//! Typed, capability-checked Atelier tool adapters.
2
3use super::{
4    AgentMission, AtelierAction, GuardCapability, GuardDecision, GuardRefusal, guard_action,
5};
6use sim_kernel::{Expr, Result, Symbol};
7use sim_lib_stream_core::{DevCassette, DevEvent, LatencyClass};
8
9/// Static descriptor for one auditable agent tool.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct AtelierToolDescriptor {
12    /// Stable descriptor id.
13    pub id: String,
14    /// Human-facing title.
15    pub title: String,
16    /// Exact command or command template.
17    pub command: String,
18    /// Guard capability required before action.
19    pub required_capability: GuardCapability,
20    /// DevEnvelope event kind recorded on success.
21    pub evidence_kind: String,
22    /// Optional repository scope.
23    pub repo: Option<String>,
24}
25
26impl AtelierToolDescriptor {
27    fn new(
28        id: impl Into<String>,
29        title: impl Into<String>,
30        command: impl Into<String>,
31        required_capability: GuardCapability,
32        evidence_kind: impl Into<String>,
33        repo: Option<String>,
34    ) -> Self {
35        Self {
36            id: id.into(),
37            title: title.into(),
38            command: command.into(),
39            required_capability,
40            evidence_kind: evidence_kind.into(),
41            repo,
42        }
43    }
44
45    /// Encodes this descriptor as ordinary SIM expression data.
46    pub fn as_expr(&self) -> Expr {
47        let mut entries = vec![
48            key("id", Expr::String(self.id.clone())),
49            key("title", Expr::String(self.title.clone())),
50            key("command", Expr::String(self.command.clone())),
51            key(
52                "capability",
53                Expr::String(capability_label(&self.required_capability)),
54            ),
55            key("evidence-kind", Expr::String(self.evidence_kind.clone())),
56        ];
57        if let Some(repo) = &self.repo {
58            entries.push(key("repo", Expr::String(repo.clone())));
59        }
60        Expr::Map(entries)
61    }
62}
63
64/// Request to update one `repos.toml` commit pin.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct PinUpdateRequest {
67    /// Repository whose pin is being updated.
68    pub repo: String,
69    /// Currently pinned commit.
70    pub current_commit: String,
71    /// Commit the pin should move to.
72    pub new_commit: String,
73    /// Whether the new commit already exists on the pushed upstream remote.
74    pub pushed_commit_exists: bool,
75}
76
77/// Docs operation requested by an agent.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub enum DocsOperation {
80    /// Regenerate docs via tooling.
81    Regenerate,
82    /// Hand-edit docs source directly.
83    HandEdit,
84}
85
86/// Request to run docs tooling or edit docs source.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct DocsRegenerationRequest {
89    /// Repository whose docs are affected.
90    pub repo: String,
91    /// Docs source path being run or edited.
92    pub path: String,
93    /// Command that regenerates the docs.
94    pub docs_command: String,
95    /// Whether the path is a generated public doc.
96    pub generated_public_doc: bool,
97    /// Whether docs are regenerated or hand-edited.
98    pub operation: DocsOperation,
99}
100
101/// Evidence emitted by a completed validation or docs tool run.
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct ToolRunEvidence {
104    /// Descriptor of the tool that ran.
105    pub descriptor: AtelierToolDescriptor,
106    /// Process exit status of the run.
107    pub exit_status: i32,
108    /// Path to the captured run log.
109    pub log_path: String,
110}
111
112impl ToolRunEvidence {
113    /// Builds evidence for one command run.
114    pub fn new(
115        descriptor: AtelierToolDescriptor,
116        exit_status: i32,
117        log_path: impl Into<String>,
118    ) -> Self {
119        Self {
120            descriptor,
121            exit_status,
122            log_path: log_path.into(),
123        }
124    }
125}
126
127/// One typed tool action requested by an agent.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum AtelierToolAction {
130    /// Run a `simctl` control-surface command.
131    Simctl(AtelierToolDescriptor),
132    /// Record evidence from a validation tool run.
133    Validation(ToolRunEvidence),
134    /// Record evidence from a docs tool run.
135    Docs(ToolRunEvidence),
136    /// Plan a `repos.toml` commit pin update.
137    PinUpdate(PinUpdateRequest),
138    /// Run or edit docs through a regeneration request.
139    DocsRegeneration(DocsRegenerationRequest),
140}
141
142/// Result of one tool action evaluation and its evidence cassette.
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct AtelierToolEvaluation {
145    /// Descriptor of the evaluated tool action.
146    pub descriptor: AtelierToolDescriptor,
147    /// Guard decision for the action.
148    pub decision: GuardDecision,
149    /// Evidence cassette recording the decision.
150    pub cassette: DevCassette,
151}
152
153/// Returns descriptors for the committed `simctl` control surface used by agents.
154///
155/// The caller supplies the control-plane repository (`control_repo`, whose
156/// validation command gates most control actions) and the front-page docs
157/// repository (`docs_repo`, whose docs the `site` command regenerates).
158pub fn simctl_tool_descriptors(
159    control_repo: impl Into<String>,
160    docs_repo: impl Into<String>,
161) -> Vec<AtelierToolDescriptor> {
162    let control_repo = control_repo.into();
163    let docs_repo = docs_repo.into();
164    [
165        ("clone", "Clone or update sibling repos"),
166        ("meta-build", "Regenerate the validation meta workspace"),
167        ("audit", "Run the private-data audit"),
168        ("no-github-check", "Assert no GitHub work is enabled"),
169        ("site", "Regenerate the public front page"),
170        ("repos", "List the constellation manifest"),
171        ("atelier-site", "Emit the Atelier Site graph"),
172        ("atelier-index", "Refresh the Atelier index"),
173        ("atelier-radar", "Query ranked Atelier hints"),
174        ("atelier-guard", "Run the Guideline Firewall"),
175    ]
176    .into_iter()
177    .map(|(command, title)| {
178        let capability = if command == "site" {
179            GuardCapability::RegenDocs(docs_repo.clone())
180        } else {
181            GuardCapability::RunValidation(control_repo.clone())
182        };
183        AtelierToolDescriptor::new(
184            format!("simctl/{command}"),
185            title,
186            format!("sh bin/simctl {command}"),
187            capability,
188            "control",
189            Some(control_repo.clone()),
190        )
191    })
192    .collect()
193}
194
195/// Builds a descriptor for a repo `validation_command`.
196pub fn repo_validation_descriptor(
197    repo: impl Into<String>,
198    validation_command: impl Into<String>,
199) -> AtelierToolDescriptor {
200    let repo = repo.into();
201    AtelierToolDescriptor::new(
202        format!("validation/{repo}"),
203        format!("Validate {repo}"),
204        validation_command,
205        GuardCapability::RunValidation(repo.clone()),
206        "validate",
207        Some(repo),
208    )
209}
210
211/// Builds a descriptor for a repo `docs_command`.
212pub fn repo_docs_descriptor(
213    repo: impl Into<String>,
214    docs_command: impl Into<String>,
215) -> AtelierToolDescriptor {
216    let repo = repo.into();
217    AtelierToolDescriptor::new(
218        format!("docs/{repo}"),
219        format!("Regenerate docs for {repo}"),
220        docs_command,
221        GuardCapability::RegenDocs(repo.clone()),
222        "docs",
223        Some(repo),
224    )
225}
226
227/// Evaluates a typed tool action, enforcing its guard and recording evidence.
228pub fn evaluate_atelier_tool(
229    mission: &AgentMission,
230    action: AtelierToolAction,
231    atelier_node: Symbol,
232    stream_id: Symbol,
233) -> Result<AtelierToolEvaluation> {
234    let descriptor = action.descriptor();
235    let guard = action.guard_action();
236    let decision = match guard_action(mission, guard.clone()) {
237        GuardDecision::Granted => action.post_guard_decision(guard),
238        refused @ GuardDecision::Refused(_) => refused,
239    };
240    let event = match &decision {
241        GuardDecision::Granted => DevEvent::new(
242            &descriptor.evidence_kind,
243            atelier_node,
244            LatencyClass::OfflineRender,
245            action.payload_expr(&descriptor),
246        )?,
247        GuardDecision::Refused(refusal) => refusal.dev_event(atelier_node)?,
248    };
249    Ok(AtelierToolEvaluation {
250        descriptor,
251        decision,
252        cassette: DevCassette::from_events(stream_id, vec![event])?,
253    })
254}
255
256impl AtelierToolAction {
257    fn descriptor(&self) -> AtelierToolDescriptor {
258        match self {
259            Self::Simctl(descriptor) => descriptor.clone(),
260            Self::Validation(evidence) | Self::Docs(evidence) => evidence.descriptor.clone(),
261            Self::PinUpdate(request) => AtelierToolDescriptor::new(
262                format!("pin/{}", request.repo),
263                format!("Update {} pin", request.repo),
264                format!(
265                    "set repos.toml {} commit {}",
266                    request.repo, request.new_commit
267                ),
268                GuardCapability::PlanPin,
269                "pin",
270                Some(request.repo.clone()),
271            ),
272            Self::DocsRegeneration(request) => {
273                repo_docs_descriptor(request.repo.clone(), request.docs_command.clone())
274            }
275        }
276    }
277
278    fn guard_action(&self) -> AtelierAction {
279        match self {
280            Self::Simctl(descriptor) => guard_action_for_descriptor(descriptor),
281            Self::Validation(evidence) | Self::Docs(evidence) => {
282                guard_action_for_descriptor(&evidence.descriptor)
283            }
284            Self::PinUpdate(request) => AtelierAction::PlanPin {
285                repo: request.repo.clone(),
286            },
287            Self::DocsRegeneration(request) => AtelierAction::RegenDocs {
288                repo: request.repo.clone(),
289            },
290        }
291    }
292
293    fn post_guard_decision(&self, guard: AtelierAction) -> GuardDecision {
294        match self {
295            Self::PinUpdate(request) if !request.pushed_commit_exists => refused(
296                guard,
297                "pin update requires an existing pushed upstream commit",
298            ),
299            Self::DocsRegeneration(request)
300                if request.generated_public_doc && request.operation == DocsOperation::HandEdit =>
301            {
302                refused(
303                    guard,
304                    "generated public docs must be regenerated, not hand-edited",
305                )
306            }
307            _ => GuardDecision::Granted,
308        }
309    }
310
311    fn payload_expr(&self, descriptor: &AtelierToolDescriptor) -> Expr {
312        let mut entries = vec![
313            key("tool", Expr::String(descriptor.id.clone())),
314            key("command", Expr::String(descriptor.command.clone())),
315            key(
316                "capability",
317                Expr::String(capability_label(&descriptor.required_capability)),
318            ),
319        ];
320        match self {
321            Self::Validation(evidence) | Self::Docs(evidence) => {
322                entries.push(key(
323                    "exit-status",
324                    Expr::String(evidence.exit_status.to_string()),
325                ));
326                entries.push(key("log-path", Expr::String(evidence.log_path.clone())));
327            }
328            Self::PinUpdate(request) => {
329                entries.push(key(
330                    "current-commit",
331                    Expr::String(request.current_commit.clone()),
332                ));
333                entries.push(key("new-commit", Expr::String(request.new_commit.clone())));
334            }
335            Self::DocsRegeneration(request) => {
336                entries.push(key("path", Expr::String(request.path.clone())));
337                entries.push(key(
338                    "generated-public-doc",
339                    Expr::Bool(request.generated_public_doc),
340                ));
341            }
342            Self::Simctl(_) => {}
343        }
344        Expr::Map(entries)
345    }
346}
347
348fn guard_action_for_descriptor(descriptor: &AtelierToolDescriptor) -> AtelierAction {
349    match &descriptor.required_capability {
350        GuardCapability::RegenDocs(repo) => AtelierAction::RegenDocs { repo: repo.clone() },
351        GuardCapability::RunValidation(repo) => AtelierAction::RunValidation { repo: repo.clone() },
352        GuardCapability::PlanPin => AtelierAction::PlanPin {
353            repo: descriptor.repo.clone().unwrap_or_default(),
354        },
355        GuardCapability::EvalGated => AtelierAction::Eval {
356            label: descriptor.id.clone(),
357        },
358        GuardCapability::EditRepo(repo) => AtelierAction::EditFile {
359            repo: repo.clone(),
360            path: descriptor.command.clone(),
361        },
362    }
363}
364
365fn refused(action: AtelierAction, reason: impl Into<String>) -> GuardDecision {
366    GuardDecision::Refused(GuardRefusal::new(action, reason))
367}
368
369fn capability_label(capability: &GuardCapability) -> String {
370    match capability {
371        GuardCapability::EditRepo(repo) => format!("EditRepo({repo})"),
372        GuardCapability::RegenDocs(repo) => format!("RegenDocs({repo})"),
373        GuardCapability::PlanPin => "PlanPin".to_owned(),
374        GuardCapability::RunValidation(repo) => format!("RunValidation({repo})"),
375        GuardCapability::EvalGated => "EvalGated".to_owned(),
376    }
377}
378
379fn key(name: &str, value: Expr) -> (Expr, Expr) {
380    (Expr::Symbol(Symbol::new(name)), value)
381}