Skip to main content

speccoder_runtime/approvals/
tasks.rs

1//! A person's decision as an agentplane human task.
2//!
3//! Where a call needs a person, the attempt waits on a task whose
4//! justification is the call exactly as the prompt shows it. Whoever answers
5//! — at the terminal, in an editor, or later with `speccoder task approve` —
6//! decides that task with an operator; the decision is journal history,
7//! bound to the digest of what was shown.
8
9use agentplane::core::{Decided, Decision, Justification, Operator, Tainted, TaskSpec};
10use serde_json::{Value, json};
11
12use super::{Answer, Decision as Said, PresentContext, Presentation, Request, Scope, Who};
13
14/// The obligation every wait for a person is bounded by.
15pub const DEADLINE: &str = "approval";
16
17/// The task kind of a call waiting for approval.
18pub const APPROVAL: &str = "approval";
19
20/// The task kind of a question to a person.
21pub const QUESTION: &str = "question";
22
23/// The waiting task for a call: the exact call, the question as the
24/// journal holds it, and the object an approval would cover. Only journaled
25/// facts go in — the approval is bound to this task's digest, so replay
26/// must rebuild it byte for byte; the presentation (the file as it is, the
27/// sandbox) is built live by whoever asks.
28#[must_use]
29pub fn approval_task(request: &Request, log: &[Value]) -> TaskSpec {
30    TaskSpec::new(
31        APPROVAL,
32        Justification::new(
33            Tainted::trusted(format!("{} — {}", request.task, request.covers())),
34            json!({
35                "tool": request.tool,
36                "arguments": request.arguments,
37                "task": request.task,
38                "object": request.object,
39                "request": request,
40                // What the attempt did before it stopped here, as journaled.
41                "log": log,
42            }),
43        ),
44        DEADLINE,
45    )
46}
47
48/// The waiting task for a question from `task` (`skill/<name>`).
49#[must_use]
50pub fn question_task(task: &str, question: &str) -> TaskSpec {
51    TaskSpec::new(
52        QUESTION,
53        Justification::new(
54            Tainted::trusted(format!("{task} asks")),
55            json!({ "task": task, "question": question }),
56        ),
57        DEADLINE,
58    )
59}
60
61/// The question a waiting approval task carries.
62#[must_use]
63pub fn request_of(proposed_action: &Value) -> Option<Request> {
64    serde_json::from_value(proposed_action["request"].clone()).ok()
65}
66
67/// The waiting call as the person sees it here, now.
68#[must_use]
69pub fn presentation_of(proposed_action: &Value, context: &PresentContext) -> Option<Presentation> {
70    request_of(proposed_action).map(|r| super::present(&r, context))
71}
72
73/// A person's answer as the decision recorded for its task: the scope, note,
74/// channel and any answer text travel in the amendment.
75#[must_use]
76pub fn decision_for(answer: &Answer, by: &Who, text: Option<&str>) -> Option<Decision> {
77    let operator: Operator = by.operator()?;
78    let mut amendment = json!({ "scope": answer.scope.name(), "via": by.via });
79    if answer.always {
80        amendment["always"] = json!(true);
81    }
82    if answer.resume_asking {
83        amendment["resume_asking"] = json!(true);
84    }
85    if let Some(client) = &by.client {
86        amendment["client"] = json!(client);
87    }
88    if let Some(note) = &answer.note {
89        amendment["note"] = json!(note);
90    }
91    if let Some(text) = text {
92        amendment["answer"] = json!(text);
93    }
94    Some(
95        match answer.decision {
96            Said::Approve => Decision::approve(operator, answer.note.clone().unwrap_or_default()),
97            Said::Reject | Said::Stop => Decision::reject(
98                operator,
99                answer
100                    .reason
101                    .clone()
102                    .unwrap_or_else(|| "no reason given".to_owned()),
103            ),
104        }
105        .amend(amendment),
106    )
107}
108
109/// Asks `approver` about a waiting `task` now. `None` when the person
110/// answers "later" (the call keeps waiting), the call waits in its run's
111/// batch (the asking budget is used: nobody is interrupted), or the task is
112/// not one of ours. The answer comes with the decision.
113#[must_use]
114pub fn ask(
115    approver: &dyn super::Approver,
116    task: &agentplane::core::Task,
117    context: &PresentContext,
118) -> Option<(Decision, Answer)> {
119    let action = &task.justification.proposed_action;
120    match task.kind.as_str() {
121        APPROVAL => {
122            let presentation = presentation_of(action, context)?;
123            if presentation.request.batched {
124                return None;
125            }
126            let mut answer = approver.ask(&presentation);
127            if answer.decision == Said::Stop {
128                return None;
129            }
130            // Always Allow only where it was offered.
131            if answer.always && !presentation.request.options.iter().any(|o| o == "always") {
132                answer.always = false;
133            }
134            let by = named(answer.by.clone(), approver, context);
135            answer.by = Some(by.clone());
136            decision_for(&answer, &by, None).map(|d| (d, answer))
137        }
138        QUESTION => {
139            let by = named(None, approver, context);
140            let answer = match approver.answer(action["question"].as_str()?) {
141                Some(text) => {
142                    let a = Answer::approve(Scope::Once, None, Some(by.clone()));
143                    return decision_for(&a, &by, Some(&text)).map(|d| (d, a));
144                }
145                None => Answer::reject("declined to answer", Some(by.clone())),
146            };
147            decision_for(&answer, &by, None).map(|d| (d, answer))
148        }
149        _ => None,
150    }
151}
152
153/// Who answered, named as git names them in the project they decide for.
154fn named(by: Option<Who>, approver: &dyn super::Approver, context: &PresentContext) -> Who {
155    let mut by = by.unwrap_or_else(|| match approver.client() {
156        Some(client) => Who::connected(&client),
157        None => Who::local(approver.via()),
158    });
159    if let Some(root) = &context.root {
160        by.name = super::who_in(root);
161    }
162    by
163}
164
165/// What the attempt reads from a recorded decision. An expiry is a refusal
166/// nobody made: `by` is `None` and `expired` is set.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Read {
169    /// The answer as the tool loop applies it.
170    pub answer: Answer,
171    /// Who decided; `None` for an expiry.
172    pub by: Option<Who>,
173    /// The deadline passed and the call was denied.
174    pub expired: bool,
175    /// A question's answer text.
176    pub text: Option<String>,
177    /// The person chose to start the run's asking budget again with this
178    /// decision (a batch decision's *resume asking*).
179    pub resume_asking: bool,
180}
181
182/// Reads a recorded decision back.
183#[must_use]
184pub fn read(d: &Decision) -> Read {
185    let a = &d.amendment;
186    let scope = match a["scope"].as_str() {
187        Some("once") => Scope::Once,
188        Some("run") => Scope::Run,
189        _ => Scope::Task,
190    };
191    // Read as every record is (`cli`, `editor <name>` before origins were
192    // one vocabulary).
193    let by = d.decided.operator().and_then(|op| {
194        serde_json::from_value::<Who>(json!({
195            "name": op.actor(),
196            "via": a["via"].as_str().unwrap_or("terminal"),
197            "client": a["client"],
198            "basis": op.basis().to_string(),
199        }))
200        .ok()
201    });
202    let expired = matches!(d.decided, Decided::OnExpiry(_));
203    let answer = if d.approved {
204        let mut answer = Answer::approve(scope, a["note"].as_str().map(str::to_owned), by.clone());
205        answer.always = a["always"] == true;
206        answer
207    } else {
208        Answer::reject(
209            if expired {
210                "not approved before its deadline"
211            } else {
212                d.reason.as_str()
213            },
214            by.clone(),
215        )
216    };
217    Read {
218        answer,
219        by,
220        expired,
221        text: a["answer"].as_str().map(str::to_owned),
222        resume_asking: a["resume_asking"] == true,
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn who() -> Who {
231        Who {
232            name: "dana".to_owned(),
233            via: "terminal".to_owned(),
234            client: None,
235            basis: "asserted".to_owned(),
236        }
237    }
238
239    #[test]
240    fn a_decision_reads_back_with_its_origin_also_one_recorded_before() {
241        let editor = Who {
242            name: "dana".to_owned(),
243            via: "acp".to_owned(),
244            client: Some("Zed".to_owned()),
245            basis: "connected".to_owned(),
246        };
247        let answer = Answer::approve(Scope::Run, None, Some(editor.clone()));
248        let mut d = decision_for(&answer, &editor, None).unwrap();
249        assert_eq!(read(&d).by.as_ref(), Some(&editor));
250        // As an editor's decision was recorded before one origin vocabulary.
251        d.amendment = json!({ "scope": "run", "via": "editor Zed" });
252        let old = read(&d).by.unwrap();
253        assert_eq!(
254            (old.via.as_str(), old.client.as_deref()),
255            ("acp", Some("Zed"))
256        );
257        d.amendment = json!({ "scope": "run", "via": "dashboard" });
258        assert_eq!(read(&d).by.unwrap().via, "terminal");
259    }
260
261    #[test]
262    fn every_scope_and_note_survive_the_record() {
263        for scope in [Scope::Once, Scope::Task, Scope::Run] {
264            let answer = Answer::approve(scope, Some("keep it small".to_owned()), Some(who()));
265            let d = decision_for(&answer, &who(), None).unwrap();
266            let back = read(&d);
267            assert_eq!(back.answer.scope, scope);
268            assert_eq!(back.answer.note.as_deref(), Some("keep it small"));
269            assert_eq!(back.by, Some(who()));
270            assert!(!back.expired);
271        }
272        let rejected = Answer::reject("not that file", Some(who()));
273        let back = read(&decision_for(&rejected, &who(), None).unwrap());
274        assert_eq!(back.answer.decision, Said::Reject);
275        assert_eq!(back.answer.reason.as_deref(), Some("not that file"));
276    }
277
278    #[test]
279    fn an_expiry_is_a_refusal_nobody_made() {
280        let d = Decision::expired(agentplane::core::OnExpiry::Deny);
281        let back = read(&d);
282        assert!(back.expired && back.by.is_none());
283        assert_eq!(back.answer.decision, Said::Reject);
284    }
285
286    #[test]
287    fn the_task_carries_the_exact_call() {
288        let request = super::super::Request {
289            task: "001-x/T001".to_owned(),
290            task_title: "t".to_owned(),
291            turn: 1,
292            tool: "write_file".to_owned(),
293            arguments: json!({ "path": "src/a.rs", "content": "fn a() {}\n" }),
294            kind: "write".to_owned(),
295            reason: "r".to_owned(),
296            object: Some(crate::tools::policy::Object::Path {
297                canonical: "src/a.rs".to_owned(),
298                asked: "src/a.rs".to_owned(),
299            }),
300            risk: "low".to_owned(),
301            options: vec!["once".to_owned()],
302            ..super::super::Request::default()
303        };
304        let spec = approval_task(&request, &[]);
305        assert_eq!(spec.kind, APPROVAL);
306        let action = &spec.justification.proposed_action;
307        assert_eq!(action["arguments"]["content"], "fn a() {}\n");
308        assert_eq!(request_of(action), Some(request.clone()));
309        // Only journaled facts: the same request gives the same digest.
310        assert_eq!(
311            spec.justification.digest(),
312            approval_task(&request, &[]).justification.digest()
313        );
314    }
315
316    #[test]
317    fn a_question_recorded_before_objects_never_matches_the_rebuilt_one() {
318        let request = super::super::Request {
319            task: "001-x/T001".to_owned(),
320            tool: "write_file".to_owned(),
321            arguments: json!({ "path": "package.json", "content": "{}" }),
322            kind: "write".to_owned(),
323            reason: "a dependency manifest".to_owned(),
324            object: Some(crate::tools::policy::Object::Path {
325                canonical: "package.json".to_owned(),
326                asked: "package.json".to_owned(),
327            }),
328            risk: "medium".to_owned(),
329            ..super::super::Request::default()
330        };
331        let now = approval_task(&request, &[]);
332        // As 0.1.0 recorded it: the class in `grant`, no object.
333        let old = Justification::new(
334            Tainted::trusted(format!("{} — write `package.json`", request.task)),
335            json!({
336                "tool": request.tool,
337                "arguments": request.arguments,
338                "task": request.task,
339                "grant": { "kind": "write", "path": "package.json" },
340                "request": request,
341                "log": [],
342            }),
343        );
344        assert_ne!(old.digest(), now.justification.digest());
345    }
346}