Skip to main content

perspt_agent/toolloop/
proposal.rs

1//! Canonicalizing provider tool calls into kernel-facing effect proposals
2//! (PSP-9 system 12), plus the shared mutating-effect predicate and the
3//! capability debit applied when a certified effect commits.
4
5use anyhow::{Context, Result};
6use perspt_sdk::{
7    promote, CandidateStateWitness, Capability, EffectProposal, FullAdmissibilityWitness,
8    ProviderToolCall, StateWitness, ToolEntry,
9};
10
11pub(crate) fn proposal_scope(call: &ProviderToolCall, entry: &ToolEntry) -> Vec<String> {
12    if entry.proposal_bindings.is_empty() {
13        return ["path", "to", "from"]
14            .iter()
15            .filter_map(|field| call.arguments.get(*field).and_then(|v| v.as_str()))
16            .map(str::to_string)
17            .collect();
18    }
19    let mut scope = Vec::new();
20    for binding in &entry.proposal_bindings {
21        match binding {
22            perspt_sdk::ProposalBinding::Path { field } => {
23                if let Some(path) = call.arguments.get(field).and_then(|v| v.as_str()) {
24                    scope.push(path.to_string());
25                }
26            }
27            perspt_sdk::ProposalBinding::MultiValue { field, target }
28                if *target == perspt_sdk::MultiValueTarget::Path =>
29            {
30                scope.extend(string_array(call, field));
31            }
32            _ => {}
33        }
34    }
35    scope
36}
37
38/// The string elements of a schema-validated scalar array argument.
39fn string_array(call: &ProviderToolCall, field: &str) -> Vec<String> {
40    call.arguments
41        .get(field)
42        .and_then(|v| v.as_array())
43        .map(|values| {
44            values
45                .iter()
46                .filter_map(|v| v.as_str())
47                .map(str::to_string)
48                .collect()
49        })
50        .unwrap_or_default()
51}
52
53/// The one definition of "this effect mutates the candidate overlay", shared
54/// by the loop's accounting and the executor's journal so they cannot drift.
55pub fn candidate_mutating_effect(effect: perspt_sdk::EffectKind) -> bool {
56    matches!(
57        effect,
58        perspt_sdk::EffectKind::WriteArtifact
59            | perspt_sdk::EffectKind::ApplyPatch
60            | perspt_sdk::EffectKind::MoveFile
61            | perspt_sdk::EffectKind::DeleteFile
62            | perspt_sdk::EffectKind::MutateDependencies
63    )
64}
65
66/// `Some(reason)` when a witness does not certify autonomous commitment.
67pub(crate) fn uncertified_reason(witness: &FullAdmissibilityWitness) -> Option<String> {
68    if witness.allows() && witness.profile == perspt_sdk::AdmissibilityProfile::SrbnCertified {
69        return None;
70    }
71    Some(if witness.allows() {
72        format!(
73            "admissibility profile {:?} is not autonomously committable",
74            witness.profile
75        )
76    } else {
77        format!("{:?}", witness.base.decision)
78    })
79}
80
81pub(crate) fn promote_matching_capability(
82    capabilities: &mut [Capability],
83    witness: &FullAdmissibilityWitness,
84) -> Result<()> {
85    let capability_id = witness.base.capability_id.as_ref();
86    let capability = capabilities
87        .iter_mut()
88        .find(|capability| Some(&capability.capability_id) == capability_id)
89        .context("admissibility witness references a missing capability")?;
90    let mut promoted = capability.clone();
91    promote(&mut promoted, witness).map_err(|error| anyhow::anyhow!(error.to_string()))?;
92    *capability = promoted;
93    Ok(())
94}
95
96/// Canonicalize one provider call into an effect proposal (PSP-9 system 12).
97/// Provenance (which model proposed it) is recorded in the ledger, never in
98/// the kernel's input, so no admissibility decision can depend on a vendor.
99pub(crate) fn proposal_from(
100    call: &ProviderToolCall,
101    entry: &ToolEntry,
102    node_id: &str,
103    generation: u32,
104    before: &CandidateStateWitness,
105) -> EffectProposal {
106    let mut proposal =
107        EffectProposal::new(perspt_sdk::ActorId::new("toolloop"), node_id, entry.effect)
108            .with_generation(generation)
109            .with_risk_class(entry.risk)
110            .with_idempotency_key(format!("{}:{}", call.name, call.arguments))
111            .with_preconditions(vec![StateWitness {
112                resource: "__candidate_root".into(),
113                content_hash: before.state_root.clone(),
114            }]);
115    if entry.proposal_bindings.is_empty() {
116        // Builtins: conventional field names.
117        if let Some(path) = call.arguments.get("path").and_then(|v| v.as_str()) {
118            proposal = proposal.with_path(path);
119        }
120        if let Some(command) = call.arguments.get("command").and_then(|v| v.as_str()) {
121            proposal = proposal.with_command(perspt_sdk::canonicalize(command, "."));
122        }
123        if let Some(path) = call.arguments.get("to").and_then(|v| v.as_str()) {
124            proposal = proposal.with_additional_paths(vec![path.to_string()]);
125        }
126        if let Some(url) = call.arguments.get("url").and_then(|v| v.as_str()) {
127            proposal = proposal.with_network_target(url);
128        }
129        return proposal;
130    }
131    bind_declared(proposal, call, entry)
132}
133
134/// Bind a registered entry's declared proposal channels; the loop holds no
135/// per-tool field knowledge.
136fn bind_declared(
137    mut proposal: EffectProposal,
138    call: &ProviderToolCall,
139    entry: &ToolEntry,
140) -> EffectProposal {
141    let mut primary_path_bound = false;
142    for binding in &entry.proposal_bindings {
143        match binding {
144            perspt_sdk::ProposalBinding::Path { field } => {
145                if let Some(path) = call.arguments.get(field).and_then(|v| v.as_str()) {
146                    if primary_path_bound {
147                        proposal = proposal.with_additional_paths(vec![path.to_string()]);
148                    } else {
149                        proposal = proposal.with_path(path);
150                        primary_path_bound = true;
151                    }
152                }
153            }
154            perspt_sdk::ProposalBinding::Command { field } => {
155                if let Some(command) = call.arguments.get(field).and_then(|v| v.as_str()) {
156                    proposal = proposal.with_command(perspt_sdk::canonicalize(command, "."));
157                }
158            }
159            perspt_sdk::ProposalBinding::Url { field } => {
160                if let Some(url) = call.arguments.get(field).and_then(|v| v.as_str()) {
161                    proposal = proposal.with_network_target(url);
162                }
163            }
164            perspt_sdk::ProposalBinding::MultiValue { field, target } => {
165                let values = string_array(call, field);
166                match target {
167                    perspt_sdk::MultiValueTarget::Path => {
168                        proposal = proposal.with_additional_paths(values);
169                    }
170                    perspt_sdk::MultiValueTarget::Command => {
171                        for value in values {
172                            proposal = proposal.with_command(perspt_sdk::canonicalize(&value, "."));
173                        }
174                    }
175                    perspt_sdk::MultiValueTarget::Url => {
176                        for value in values {
177                            proposal = proposal.with_network_target(&value);
178                        }
179                    }
180                }
181            }
182        }
183    }
184    proposal
185}