perspt_agent/toolloop/
proposal.rs1use 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
38fn 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
53pub 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
66pub(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
96pub(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 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
134fn 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}