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 Forgejo 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.
154pub fn simctl_tool_descriptors() -> Vec<AtelierToolDescriptor> {
155    [
156        ("clone", "Clone or update sibling repos"),
157        ("meta-build", "Regenerate the validation meta workspace"),
158        ("audit", "Run the private-data audit"),
159        ("no-github-check", "Assert no GitHub work is enabled"),
160        ("site", "Regenerate the public front page"),
161        ("repos", "List the constellation manifest"),
162        ("atelier-site", "Emit the Atelier Site graph"),
163        ("atelier-index", "Refresh the Atelier index"),
164        ("atelier-radar", "Query ranked Atelier hints"),
165        ("atelier-guard", "Run the Guideline Firewall"),
166    ]
167    .into_iter()
168    .map(|(command, title)| {
169        let capability = if command == "site" {
170            GuardCapability::RegenDocs("sim-say".to_owned())
171        } else {
172            GuardCapability::RunValidation("sim-private".to_owned())
173        };
174        AtelierToolDescriptor::new(
175            format!("simctl/{command}"),
176            title,
177            format!("sh bin/simctl {command}"),
178            capability,
179            "control",
180            Some("sim-private".to_owned()),
181        )
182    })
183    .collect()
184}
185
186/// Builds a descriptor for a repo `validation_command`.
187pub fn repo_validation_descriptor(
188    repo: impl Into<String>,
189    validation_command: impl Into<String>,
190) -> AtelierToolDescriptor {
191    let repo = repo.into();
192    AtelierToolDescriptor::new(
193        format!("validation/{repo}"),
194        format!("Validate {repo}"),
195        validation_command,
196        GuardCapability::RunValidation(repo.clone()),
197        "validate",
198        Some(repo),
199    )
200}
201
202/// Builds a descriptor for a repo `docs_command`.
203pub fn repo_docs_descriptor(
204    repo: impl Into<String>,
205    docs_command: impl Into<String>,
206) -> AtelierToolDescriptor {
207    let repo = repo.into();
208    AtelierToolDescriptor::new(
209        format!("docs/{repo}"),
210        format!("Regenerate docs for {repo}"),
211        docs_command,
212        GuardCapability::RegenDocs(repo.clone()),
213        "docs",
214        Some(repo),
215    )
216}
217
218/// Evaluates a typed tool action, enforcing its guard and recording evidence.
219pub fn evaluate_atelier_tool(
220    mission: &AgentMission,
221    action: AtelierToolAction,
222    atelier_node: Symbol,
223    stream_id: Symbol,
224) -> Result<AtelierToolEvaluation> {
225    let descriptor = action.descriptor();
226    let guard = action.guard_action();
227    let decision = match guard_action(mission, guard.clone()) {
228        GuardDecision::Granted => action.post_guard_decision(guard),
229        refused @ GuardDecision::Refused(_) => refused,
230    };
231    let event = match &decision {
232        GuardDecision::Granted => DevEvent::new(
233            &descriptor.evidence_kind,
234            atelier_node,
235            LatencyClass::OfflineRender,
236            action.payload_expr(&descriptor),
237        )?,
238        GuardDecision::Refused(refusal) => refusal.dev_event(atelier_node)?,
239    };
240    Ok(AtelierToolEvaluation {
241        descriptor,
242        decision,
243        cassette: DevCassette::from_events(stream_id, vec![event])?,
244    })
245}
246
247impl AtelierToolAction {
248    fn descriptor(&self) -> AtelierToolDescriptor {
249        match self {
250            Self::Simctl(descriptor) => descriptor.clone(),
251            Self::Validation(evidence) | Self::Docs(evidence) => evidence.descriptor.clone(),
252            Self::PinUpdate(request) => AtelierToolDescriptor::new(
253                format!("pin/{}", request.repo),
254                format!("Update {} pin", request.repo),
255                format!(
256                    "set repos.toml {} commit {}",
257                    request.repo, request.new_commit
258                ),
259                GuardCapability::PlanPin,
260                "pin",
261                Some(request.repo.clone()),
262            ),
263            Self::DocsRegeneration(request) => {
264                repo_docs_descriptor(request.repo.clone(), request.docs_command.clone())
265            }
266        }
267    }
268
269    fn guard_action(&self) -> AtelierAction {
270        match self {
271            Self::Simctl(descriptor) => guard_action_for_descriptor(descriptor),
272            Self::Validation(evidence) | Self::Docs(evidence) => {
273                guard_action_for_descriptor(&evidence.descriptor)
274            }
275            Self::PinUpdate(request) => AtelierAction::PlanPin {
276                repo: request.repo.clone(),
277            },
278            Self::DocsRegeneration(request) => AtelierAction::RegenDocs {
279                repo: request.repo.clone(),
280            },
281        }
282    }
283
284    fn post_guard_decision(&self, guard: AtelierAction) -> GuardDecision {
285        match self {
286            Self::PinUpdate(request) if !request.pushed_commit_exists => refused(
287                guard,
288                "pin update requires an existing pushed Forgejo commit",
289            ),
290            Self::DocsRegeneration(request)
291                if request.generated_public_doc && request.operation == DocsOperation::HandEdit =>
292            {
293                refused(
294                    guard,
295                    "generated public docs must be regenerated, not hand-edited",
296                )
297            }
298            _ => GuardDecision::Granted,
299        }
300    }
301
302    fn payload_expr(&self, descriptor: &AtelierToolDescriptor) -> Expr {
303        let mut entries = vec![
304            key("tool", Expr::String(descriptor.id.clone())),
305            key("command", Expr::String(descriptor.command.clone())),
306            key(
307                "capability",
308                Expr::String(capability_label(&descriptor.required_capability)),
309            ),
310        ];
311        match self {
312            Self::Validation(evidence) | Self::Docs(evidence) => {
313                entries.push(key(
314                    "exit-status",
315                    Expr::String(evidence.exit_status.to_string()),
316                ));
317                entries.push(key("log-path", Expr::String(evidence.log_path.clone())));
318            }
319            Self::PinUpdate(request) => {
320                entries.push(key(
321                    "current-commit",
322                    Expr::String(request.current_commit.clone()),
323                ));
324                entries.push(key("new-commit", Expr::String(request.new_commit.clone())));
325            }
326            Self::DocsRegeneration(request) => {
327                entries.push(key("path", Expr::String(request.path.clone())));
328                entries.push(key(
329                    "generated-public-doc",
330                    Expr::Bool(request.generated_public_doc),
331                ));
332            }
333            Self::Simctl(_) => {}
334        }
335        Expr::Map(entries)
336    }
337}
338
339fn guard_action_for_descriptor(descriptor: &AtelierToolDescriptor) -> AtelierAction {
340    match &descriptor.required_capability {
341        GuardCapability::RegenDocs(repo) => AtelierAction::RegenDocs { repo: repo.clone() },
342        GuardCapability::RunValidation(repo) => AtelierAction::RunValidation { repo: repo.clone() },
343        GuardCapability::PlanPin => AtelierAction::PlanPin {
344            repo: descriptor.repo.clone().unwrap_or_default(),
345        },
346        GuardCapability::EvalGated => AtelierAction::Eval {
347            label: descriptor.id.clone(),
348        },
349        GuardCapability::EditRepo(repo) => AtelierAction::EditFile {
350            repo: repo.clone(),
351            path: descriptor.command.clone(),
352        },
353    }
354}
355
356fn refused(action: AtelierAction, reason: impl Into<String>) -> GuardDecision {
357    GuardDecision::Refused(GuardRefusal::new(action, reason))
358}
359
360fn capability_label(capability: &GuardCapability) -> String {
361    match capability {
362        GuardCapability::EditRepo(repo) => format!("EditRepo({repo})"),
363        GuardCapability::RegenDocs(repo) => format!("RegenDocs({repo})"),
364        GuardCapability::PlanPin => "PlanPin".to_owned(),
365        GuardCapability::RunValidation(repo) => format!("RunValidation({repo})"),
366        GuardCapability::EvalGated => "EvalGated".to_owned(),
367    }
368}
369
370fn key(name: &str, value: Expr) -> (Expr, Expr) {
371    (Expr::Symbol(Symbol::new(name)), value)
372}