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