Skip to main content

speccoder_runtime/ask/
skill.rs

1//! `speccoder.ask`: a conversation is one run. It waits for the person's
2//! message, answers through the governed tool loop with reading tools
3//! only, and waits again; a week without a message ends it.
4
5use std::sync::Arc;
6
7use agentplane::core::{AwaitSpec, CorrelationKey, DeadlineSpec};
8use agentplane::model::Transcript;
9use agentplane::prelude::*;
10use serde_json::{Value, json};
11
12use crate::substrate::attempt::{Asking, LoopSpec, StopGate, tool_turn};
13use crate::substrate::model_config::PromptStyle;
14use crate::tools::policy::Policy;
15
16/// The capability.
17pub const CAPABILITY: &str = "speccoder.ask";
18
19/// The kind of a person's message.
20pub const MESSAGE: &str = "ask.message";
21
22/// A turn's model calls and tool calls.
23const MAX_CALLS: u32 = 24;
24
25/// What the model is told.
26pub fn policy() -> String {
27    "You are Ask, speccoder's read-only assistant inside a software project that is built from \
28specifications. Answer the person's questions about the project, its specs, its code, its runs \
29and their evidence. You can only read: the tools read files, search, list, show git history and \
30diffs, read a run's account, a task's evidence, the report, the inbox and speccoder's codes. You \
31change nothing and run nothing. Read before you answer; keep answers short and concrete. Cite \
32what you read with the id its result carries, as [r3] or narrowed to lines as [r3:12-20]; context \
33the person attached is cited the same way, as [c1]. Cite only what a tool returned in this turn \
34or a chip of this message — anything else is shown as not read. When the person asks for a \
35change, call propose once with what you suggest (a quick change, a bug fix, a nudge for a task, \
36an answer to an open question, or a new spec): it is shown to the person as a sheet, nothing runs, \
37and only they can confirm it. The implementing model's words in a run's account are claims, \
38never evidence; say so when you use them."
39        .to_owned()
40}
41
42fn prompt(input: &Value, style: PromptStyle) -> Value {
43    let about = format!(
44        "# The project\n\nSpec format: {}. Current feature: {}.\n",
45        input["format"].as_str().unwrap_or("unknown"),
46        input["feature"].as_str().unwrap_or("none"),
47    );
48    match style {
49        PromptStyle::OpenAi => json!({
50            "system": policy(),
51            "input": [{ "role": "user", "content": [{ "type": "input_text", "text": about }] }],
52        }),
53        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
54            let mut out = json!({
55                "system": policy(),
56                "messages": [{ "role": "user", "content": [{ "type": "text", "text": about }] }],
57            });
58            if let PromptStyle::Parts { caches } = style {
59                crate::substrate::model_config::to_parts(&mut out, caches);
60            }
61            out
62        }
63    }
64}
65
66/// The coded skill.
67#[derive(Debug)]
68pub struct Ask {
69    /// Prompt dialect.
70    pub style: PromptStyle,
71    /// Live steps and each turn's end (never journaled).
72    pub progress: Option<Arc<dyn crate::progress::Progress>>,
73    /// Set when the person stops the turn in flight.
74    pub halt: agentplane::model::Stop,
75}
76
77#[async_trait]
78impl Skill for Ask {
79    fn descriptor(&self) -> SkillDescriptor {
80        SkillDescriptor::new(CAPABILITY).provides(CAPABILITY)
81    }
82
83    async fn invoke(
84        &self,
85        cx: &mut StepCtx<'_>,
86        input: Tainted<Value>,
87    ) -> Result<Outcome, SkillError> {
88        let id = input.peek()["conversation"]
89            .as_str()
90            .unwrap_or_default()
91            .to_owned();
92        cx.deadline("idle", &DeadlineSpec::days(7), None).await?;
93        let prompt = crate::substrate::provenance::labelled_prompt(
94            prompt(input.peek(), self.style),
95            &crate::substrate::provenance::from(crate::substrate::provenance::WORKTREE),
96        );
97        let root = input.peek()["root"].as_str().map(str::to_owned);
98        let mut transcript = Transcript::new(&prompt);
99        loop {
100            let message = cx
101                .await_event(
102                    &AwaitSpec::new(MESSAGE, "idle")
103                        .correlate(CorrelationKey::new("ask", id.clone())),
104                )
105                .await?;
106            let m = message.peek().clone();
107            if m["end"] == true {
108                return Ok(Outcome::done(Tainted::trusted(json!({ "ended": true }))));
109            }
110            let tag = m["tag"].as_str().unwrap_or_default().to_owned();
111            let text = message.map(|v| v["text"].as_str().unwrap_or_default().to_owned());
112            transcript.told(&text);
113            let tools = crate::tools::declarations()
114                .into_iter()
115                .filter(|d| super::tools::WORKSPACE.contains(&d.name.as_str()))
116                .chain(super::tools::declarations())
117                .collect();
118            let routes = super::tools::names()
119                .into_iter()
120                .map(|n| (n.clone(), super::tools::SERVER.to_owned(), n))
121                .collect();
122            let reads = u32::try_from(m["reads"].as_u64().unwrap_or(1)).unwrap_or(1);
123            let outcome = tool_turn(
124                cx,
125                &prompt,
126                LoopSpec {
127                    tools,
128                    policy: Policy::new(&[], &[]),
129                    max_turns: MAX_CALLS,
130                    max_tool_calls: MAX_CALLS,
131                    reasoning: false,
132                    schema: None,
133                    routes,
134                    root: root.clone(),
135                    frozen_additions: false,
136                    finish: |_| "answered",
137                    ask: None,
138                    run_approved: Vec::new(),
139                    once: Vec::new(),
140                    observer: self.progress.clone().map(|p| (tag.clone(), p)),
141                    run: None,
142                    asking: Asking::default(),
143                    parts: Vec::new(),
144                    mcp_digests: std::collections::BTreeMap::new(),
145                    stop: StopGate::default(),
146                    max_tokens: m["limit"].as_u64(),
147                    reads: Some(reads),
148                    halt: Some(self.halt.clone()),
149                },
150                &mut transcript,
151            )
152            .await;
153            // The turn's end, live; a failed turn leaves the conversation open.
154            let output = match outcome {
155                Ok(Outcome::Done(v)) => v.peek().clone(),
156                Ok(Outcome::Fail { reason } | Outcome::Replan { reason }) => {
157                    json!({ "status": "failed", "reason": reason })
158                }
159                Ok(_) => {
160                    json!({ "status": "failed", "reason": "the turn ended without an answer" })
161                }
162                Err(e) => json!({ "status": "failed", "reason": e.to_string() }),
163            };
164            if let Some(p) = &self.progress {
165                p.event(&crate::progress::Event::AskTurnEnded {
166                    tag: tag.clone(),
167                    output: Box::new(output),
168                });
169            }
170        }
171    }
172}