1use 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
16pub const CAPABILITY: &str = "speccoder.ask";
18
19pub const MESSAGE: &str = "ask.message";
21
22const MAX_CALLS: u32 = 24;
24
25pub 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#[derive(Debug)]
68pub struct Ask {
69 pub style: PromptStyle,
71 pub progress: Option<Arc<dyn crate::progress::Progress>>,
73 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 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}