Skip to main content

speccoder_core/
ask.rs

1//! Ask: a read-only conversation about the project. What a turn shows —
2//! the model's words, the reads it made, the citations code checked
3//! against those reads, and at most one proposal the person may confirm.
4//!
5//! Citations are `[r3]` or `[r3:12-20]` in the answer, naming a read the
6//! engine issued; a citation counts only when that read happened in the
7//! same turn and the range lies inside it.
8
9use serde::{Deserialize, Serialize};
10
11/// One Ask turn: the question, what was read, the answer and its sources.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13pub struct AskReport {
14    /// The conversation (`c-…`).
15    pub conversation: String,
16    /// The turn's number in the conversation, from 1.
17    pub turn: u32,
18    /// The person's words.
19    pub question: String,
20    /// What was on screen or added as context.
21    pub chips: Vec<Chip>,
22    /// What the model read, in order.
23    pub reads: Vec<Read>,
24    /// The model's words (a claim), citation markers included.
25    pub answer: String,
26    /// Every marker in the answer, checked.
27    pub citations: Vec<Citation>,
28    /// A change the model suggests; nothing ran.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub proposal: Option<Proposal>,
31    /// Tokens the turn used (input and output, cache reads apart).
32    pub usage: AskUsage,
33    /// How the turn ended: `answered`, `stopped`, `budget` or `failed`.
34    pub end: String,
35    /// Why it ended so, when it did not answer.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub reason: Option<String>,
38    /// What the question was handed: one `chip` entry per chip, journaled
39    /// with the message.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub context: Option<crate::context::ContextManifest>,
42}
43
44/// Tokens a turn used.
45#[derive(
46    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
47)]
48pub struct AskUsage {
49    /// Input tokens, cache reads not counted.
50    pub input: u64,
51    /// Output tokens.
52    pub output: u64,
53    /// Input read from the provider's cache.
54    pub cache_read: u64,
55}
56
57impl AskUsage {
58    /// What counts against Ask's limits.
59    #[must_use]
60    pub const fn billed(&self) -> u64 {
61        self.input + self.output
62    }
63}
64
65/// Something on screen given to the model as context.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
67pub struct Chip {
68    /// Its id in the turn (`c1`…), citable like a read.
69    pub id: String,
70    /// `feature`, `task`, `file_range`, `requirement`, `question`,
71    /// `inbox_item`, `evidence_row`, `diff_hunk`, `report_row`, `finding`.
72    pub kind: String,
73    /// What a person reads on the chip.
74    pub label: String,
75    /// Where it points: `feature`, `task`, `path`, `start`, `end`, …
76    pub reference: serde_json::Value,
77    /// The content's digest when it was sent.
78    pub digest: String,
79    /// The current view's subject, attached by itself.
80    #[serde(default)]
81    pub from_view: bool,
82    /// Its content changed since it was added; the current one was sent.
83    #[serde(default)]
84    pub changed: bool,
85}
86
87/// A read the engine issued for a tool call.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
89pub struct Read {
90    /// `r1`, `r2`, … across the conversation.
91    pub id: String,
92    /// The tool.
93    pub tool: String,
94    /// What it read, in one line.
95    pub target: String,
96    /// The file it read, when it read one.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub path: Option<String>,
99    /// The first line it returned (a run's account: its first turn).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub start: Option<u32>,
102    /// The last line it returned (a run's account: its last turn).
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub end: Option<u32>,
105    /// The digest of what it returned.
106    pub digest: String,
107    /// The digest of the lines a file read returned, to tell whether the
108    /// file still says that.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub content_sha256: Option<String>,
111    /// The task (`task_evidence`) or run (`journal_account`) it read.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub subject: Option<String>,
114}
115
116/// A citation marker in the answer, checked against the turn's reads.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
118pub struct Citation {
119    /// The marker as written (`[r3:12-20]`).
120    pub marker: String,
121    /// The read or chip it names.
122    pub id: String,
123    /// The lines it narrows to.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub lines: Option<(u32, u32)>,
126    /// `read`: it names what this turn read; `not_read`: it does not.
127    pub state: String,
128    /// The file, when it names one.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub path: Option<String>,
131    /// What a person reads for it ("crates/x.rs 12–20").
132    pub label: String,
133}
134
135/// A change the model suggests, as code made it a person's action.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
137pub struct Proposal {
138    /// `quick_change`, `bug_fix`, `nudge`, `answer` or `start_spec`.
139    pub kind: String,
140    /// One line.
141    pub title: String,
142    /// The editable words, by kind.
143    pub fields: ProposalFields,
144    /// What confirming performs; `None` when it cannot be done now.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub runnable: Option<crate::actions::Runnable>,
147    /// The words the action records (the input of a nudge or an answer).
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub input: Option<String>,
150    /// Why it cannot be done now.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub blocked: Option<String>,
153    /// The same action as a command.
154    pub command: String,
155    /// The conversation that suggested it.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub conversation: Option<String>,
158    /// The turn that suggested it.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub turn: Option<u32>,
161}
162
163/// A proposal's words; which ones a kind uses is the kind's.
164#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
165pub struct ProposalFields {
166    /// The task (nudge) or question (answer) it is about.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub target: Option<String>,
169    /// A quick change, in the person's words.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub request: Option<String>,
172    /// When a quick change is done.
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub checks: Vec<String>,
175    /// A nudge's note.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub note: Option<String>,
178    /// A question's answer.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub answer: Option<String>,
181    /// A new spec's description.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub description: Option<String>,
184    /// A bug's current behaviour.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub symptom: Option<String>,
187    /// What should happen instead.
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub expected: Vec<String>,
190    /// What must stay as it is.
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub unchanged: Vec<String>,
193}
194
195/// One conversation in a list.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
197pub struct Conversation {
198    /// `c-…`.
199    pub id: String,
200    /// The feature it was about, when one was current.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub feature: Option<String>,
203    /// Its first question.
204    pub title: String,
205    /// When its last turn ended (seconds since the epoch).
206    pub at: u64,
207    /// How many turns it has.
208    pub turns: u32,
209    /// Whether its run waits for the next message (`false`: stopped, idle
210    /// or full — the next message goes on in a new run that carries the
211    /// earlier turns' words).
212    pub open: bool,
213}
214
215/// `ask --list` / `GET /api/ask/conversations`.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
217pub struct AskConversations {
218    /// Newest first.
219    pub conversations: Vec<Conversation>,
220}
221
222/// One conversation with its turns.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
224pub struct AskThread {
225    /// The conversation.
226    pub conversation: Conversation,
227    /// Its turns, in order.
228    pub turns: Vec<AskReport>,
229    /// How many runs it has handed over to a new one.
230    #[serde(default)]
231    pub segment: u32,
232    /// The run that takes the next message.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub run: Option<String>,
235    /// What its confirmed proposals created, read from their origins.
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub results: Vec<AskResult>,
238}
239
240/// What a proposal confirmed from a conversation created.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
242pub struct AskResult {
243    /// The turn that proposed it, when known.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub turn: Option<u32>,
246    /// `change` (a quick change or bug fix) or `spec` (a promoted unit).
247    pub kind: String,
248    /// The unit it is in.
249    pub unit: String,
250    /// The requirement it recorded.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub requirement: Option<String>,
253    /// The task that implements it.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub task: Option<String>,
256}
257
258/// A marker found in an answer.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct Marker {
261    /// As written.
262    pub text: String,
263    /// The id it names.
264    pub id: String,
265    /// The lines it narrows to.
266    pub lines: Option<(u32, u32)>,
267}
268
269/// Every `[r3]`, `[r3:12-20]`, `[c1]` or `[c1:4]` in `answer`, in order.
270#[must_use]
271pub fn markers(answer: &str) -> Vec<Marker> {
272    let mut out = Vec::new();
273    let mut rest = answer;
274    while let Some(open) = rest.find('[') {
275        rest = &rest[open + 1..];
276        let Some(close) = rest.find(']') else { break };
277        let inner = &rest[..close];
278        if let Some(m) = marker(inner) {
279            out.push(m);
280        }
281        rest = &rest[close + 1..];
282    }
283    out
284}
285
286fn marker(inner: &str) -> Option<Marker> {
287    let (id, lines) = match inner.split_once(':') {
288        Some((id, range)) => {
289            let (a, b) = range.split_once('-').unwrap_or((range, range));
290            let (a, b) = (a.trim().parse().ok()?, b.trim().parse().ok()?);
291            (id, Some((a, b)))
292        }
293        None => (inner, None),
294    };
295    let mut chars = id.chars();
296    let kind = chars.next()?;
297    if !matches!(kind, 'r' | 'c') || id.len() < 2 || !chars.all(|c| c.is_ascii_digit()) {
298        return None;
299    }
300    Some(Marker {
301        text: format!("[{inner}]"),
302        id: id.to_owned(),
303        lines,
304    })
305}
306
307/// Each marker of `answer` against what this turn read and was given: a
308/// citation is `read` only when its id is one of them and its lines lie
309/// inside what was returned.
310#[must_use]
311pub fn check(answer: &str, reads: &[Read], chips: &[Chip]) -> Vec<Citation> {
312    let mut out: Vec<Citation> = Vec::new();
313    for m in markers(answer) {
314        if out.iter().any(|c| c.marker == m.text) {
315            continue;
316        }
317        let read = reads.iter().find(|r| r.id == m.id);
318        let chip = chips.iter().find(|c| c.id == m.id);
319        let (state, path, label) = match (read, chip) {
320            (Some(r), _) => {
321                let inside = match (m.lines, r.start, r.end) {
322                    (None, ..) => true,
323                    (Some((a, b)), Some(s), Some(e)) => a <= b && a >= s && b <= e,
324                    // Lines of something that has none (a list, a log).
325                    (Some(_), ..) => false,
326                };
327                let label = match (&r.path, m.lines.or(r.start.zip(r.end))) {
328                    (Some(p), Some((a, b))) if a == b => format!("{p} {a}"),
329                    (Some(p), Some((a, b))) => format!("{p} {a}–{b}"),
330                    (Some(p), None) => p.clone(),
331                    (None, _) => r.target.clone(),
332                };
333                (inside, r.path.clone(), label)
334            }
335            (None, Some(c)) => {
336                // Lines only of a file chip, and only inside its own range.
337                let at = |k: &str| c.reference[k].as_u64().and_then(|n| u32::try_from(n).ok());
338                let inside = match m.lines {
339                    None => true,
340                    Some((a, b)) if c.kind == "file_range" => {
341                        a <= b && a >= at("start").unwrap_or(1) && at("end").is_none_or(|e| b <= e)
342                    }
343                    Some(_) => false,
344                };
345                (
346                    inside,
347                    c.reference["path"].as_str().map(str::to_owned),
348                    c.label.clone(),
349                )
350            }
351            (None, None) => (false, None, m.text.clone()),
352        };
353        out.push(Citation {
354            marker: m.text,
355            id: m.id,
356            lines: m.lines,
357            state: if state { "read" } else { "not_read" }.to_owned(),
358            path,
359            label,
360        });
361    }
362    out
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn read(id: &str, path: &str, start: u32, end: u32) -> Read {
370        Read {
371            id: id.to_owned(),
372            tool: "read_file".to_owned(),
373            target: path.to_owned(),
374            path: Some(path.to_owned()),
375            start: Some(start),
376            end: Some(end),
377            digest: "d".to_owned(),
378            content_sha256: None,
379            subject: None,
380        }
381    }
382
383    #[test]
384    fn markers_are_ids_the_engine_issues_and_nothing_else() {
385        let found: Vec<_> = markers("see [r3], [r12:4-9], [c1:2], [FR-001], [x] and [r]")
386            .into_iter()
387            .map(|m| (m.id, m.lines))
388            .collect();
389        assert_eq!(
390            found,
391            [
392                ("r3".to_owned(), None),
393                ("r12".to_owned(), Some((4, 9))),
394                ("c1".to_owned(), Some((2, 2))),
395            ]
396        );
397    }
398
399    /// A citation is a read of this turn, inside what it returned; an id
400    /// never issued, or lines it never returned, is not among what was read.
401    #[test]
402    fn a_citation_counts_only_inside_a_read_of_the_turn() {
403        let reads = [read("r1", "src/a.rs", 10, 40)];
404        let states: Vec<_> = check("[r1] [r1:12-20] [r1:5-12] [r9] [r1:20-12]", &reads, &[])
405            .into_iter()
406            .map(|c| (c.marker, c.state, c.label))
407            .collect();
408        assert_eq!(
409            states,
410            [
411                ("[r1]".into(), "read".into(), "src/a.rs 10–40".into()),
412                ("[r1:12-20]".into(), "read".into(), "src/a.rs 12–20".into()),
413                (
414                    "[r1:5-12]".into(),
415                    "not_read".into(),
416                    "src/a.rs 5–12".into()
417                ),
418                ("[r9]".into(), "not_read".into(), "[r9]".into()),
419                (
420                    "[r1:20-12]".into(),
421                    "not_read".into(),
422                    "src/a.rs 20–12".into()
423                ),
424            ]
425        );
426    }
427
428    #[test]
429    fn a_chip_is_citable_like_a_read() {
430        let chip = Chip {
431            id: "c1".to_owned(),
432            kind: "task".to_owned(),
433            label: "T004 Handle Unicode".to_owned(),
434            reference: serde_json::json!({ "task": "T004" }),
435            digest: "d".to_owned(),
436            from_view: true,
437            changed: false,
438        };
439        let c = check("as [c1] says, not [c1:3]", &[], &[chip]);
440        assert_eq!(
441            (c[0].state.as_str(), c[0].label.as_str()),
442            ("read", "T004 Handle Unicode")
443        );
444        assert_eq!(c[1].state, "not_read", "a task chip has no lines");
445        // A file chip's lines: inside its range only.
446        let file = Chip {
447            id: "c2".to_owned(),
448            kind: "file_range".to_owned(),
449            label: "src/a.rs 10–20".to_owned(),
450            reference: serde_json::json!({ "path": "src/a.rs", "start": 10, "end": 20 }),
451            digest: "d".to_owned(),
452            from_view: false,
453            changed: false,
454        };
455        let c = check("[c2:12-14] and [c2:500-600]", &[], &[file]);
456        assert_eq!(
457            (c[0].state.as_str(), c[1].state.as_str()),
458            ("read", "not_read")
459        );
460    }
461}