Skip to main content

speccoder_runtime/ask/
mod.rs

1//! Ask: a read-only conversation beside every view (L0 of the ladder).
2//!
3//! A conversation is one agentplane run on a plane of its own
4//! (`<project state>/ask`): each message is delivered to it, it answers
5//! with reading tools only and waits again. What a turn showed — reads,
6//! checked citations, a proposal — is kept beside the journal as the
7//! conversation's thread, for the panel and `speccoder ask` to read back.
8//! Nothing here writes to the project; a proposal is performed only when a
9//! person confirms it, through the ordinary actions.
10
11pub mod chips;
12pub mod proposal;
13pub mod skill;
14pub mod tools;
15
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19use agentplane::model::ModelProvider;
20use serde::Deserialize;
21use serde_json::{Value, json};
22use speccoder_core::ask::{
23    AskConversations, AskReport, AskResult, AskThread, AskUsage, Conversation, Read,
24};
25use speccoder_core::{CommandData, CommandResult, CommandStatus};
26
27use crate::progress::{Event, Progress};
28use crate::substrate::{self, model_config::ModelConfig};
29
30pub use chips::ChipRef;
31
32/// The tool that suggests a change.
33pub const PROPOSE: &str = "propose";
34
35/// The longest question.
36pub const MAX_QUESTION: usize = 8_000;
37
38/// A turn whose input alone is this large leaves no room for another: the
39/// next message goes on in a new run (Ask never summarises).
40const FULL_AT: u64 = 150_000;
41
42/// The most of the earlier turns' words a new run carries.
43const CARRIED: usize = 24_000;
44
45/// The run of conversation `id`'s `segment`, as its messages correlate.
46fn segment_key(id: &str, segment: u32) -> String {
47    if segment == 0 {
48        id.to_owned()
49    } else {
50        format!("{id}.{segment}")
51    }
52}
53
54/// The earlier turns, newest kept first, as context for a new run.
55fn earlier(turns: &[AskReport]) -> String {
56    let mut kept: Vec<String> = Vec::new();
57    let mut size = 0;
58    for t in turns.iter().rev() {
59        let answer = if t.answer.is_empty() {
60            "(no answer)"
61        } else {
62            &t.answer
63        };
64        let block = format!("Q: {}\nA: {answer}\n", t.question);
65        size += block.len();
66        if size > CARRIED {
67            break;
68        }
69        kept.push(block);
70    }
71    kept.reverse();
72    format!(
73        "<context id=\"earlier\" kind=\"conversation\">\nThe conversation so far, carried over from \
74its earlier run. What it read then is not read again: read before you cite.\n\n{}</context>\n\n",
75        kept.join("\n")
76    )
77}
78
79/// Turns in flight in this process, by conversation, so a person can
80/// stop one.
81type Halt = agentplane::model::Stop;
82static LIVE: std::sync::LazyLock<Mutex<std::collections::HashMap<String, Halt>>> =
83    std::sync::LazyLock::new(Mutex::default);
84
85/// A turn's place in [`LIVE`], left when it ends.
86struct Live {
87    id: String,
88    halt: Halt,
89    /// It holds the entry: a second request for a conversation whose turn
90    /// is in flight (refused by the plane's claim) never takes it.
91    owned: bool,
92}
93
94impl Live {
95    fn enter(id: &str, halt: &Halt) -> Self {
96        let owned = LIVE.lock().is_ok_and(|mut live| {
97            if live.contains_key(id) {
98                return false;
99            }
100            live.insert(id.to_owned(), halt.clone());
101            true
102        });
103        Self {
104            id: id.to_owned(),
105            halt: halt.clone(),
106            owned,
107        }
108    }
109
110    async fn notified(&self) {
111        self.halt.stopped().await;
112    }
113}
114
115impl Drop for Live {
116    fn drop(&mut self) {
117        if !self.owned {
118            return;
119        }
120        if let Ok(mut live) = LIVE.lock() {
121            live.remove(&self.id);
122        }
123    }
124}
125
126/// Stops conversation `id`'s turn in flight in this process (`None`: every
127/// one — a new conversation has no id until its first turn ends): the
128/// model call streaming ends where it is, recorded and metered to there,
129/// with its words. `false`: none was.
130#[must_use]
131pub fn stop(id: Option<&str>) -> bool {
132    let halts: Vec<Halt> = LIVE.lock().map_or_else(
133        |_| Vec::new(),
134        |live| {
135            live.iter()
136                .filter(|(k, _)| id.is_none_or(|id| *k == id))
137                .map(|(_, h)| h.clone())
138                .collect()
139        },
140    );
141    for halt in &halts {
142        halt.stop();
143    }
144    !halts.is_empty()
145}
146
147/// `[ask]` in `speccoder.toml`.
148#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
149#[serde(deny_unknown_fields)]
150pub struct Settings {
151    /// The model; `[model]`'s when absent.
152    pub model: Option<String>,
153    /// Tokens one turn may use (input and output, cache reads apart).
154    #[serde(default = "turn_default")]
155    pub max_turn_tokens: u64,
156    /// Tokens Ask may use in a UTC day.
157    #[serde(default = "day_default")]
158    pub daily_tokens: u64,
159}
160
161const fn turn_default() -> u64 {
162    100_000
163}
164
165const fn day_default() -> u64 {
166    1_000_000
167}
168
169impl Default for Settings {
170    fn default() -> Self {
171        Self {
172            model: None,
173            max_turn_tokens: turn_default(),
174            daily_tokens: day_default(),
175        }
176    }
177}
178
179/// `[ask]` from the text of `speccoder.toml`.
180///
181/// # Errors
182///
183/// The section is malformed.
184pub fn settings(config: &str) -> Result<Settings, String> {
185    let table: toml::Table = config.parse().map_err(|e: toml::de::Error| e.to_string())?;
186    match table.get("ask") {
187        None => Ok(Settings::default()),
188        Some(v) => v
189            .clone()
190            .try_into()
191            .map_err(|e: toml::de::Error| format!("[ask]: {e}")),
192    }
193}
194
195/// The progress tag of conversation `id`'s turn `n`.
196#[must_use]
197pub fn turn_tag(id: &str, n: u32) -> String {
198    format!("ask:{id}#{n}")
199}
200
201/// What a tool call read, as the answer may cite it.
202#[must_use]
203pub fn read_of(id: &str, tool: &str, arguments: &Value, output: &Value) -> Read {
204    let digest = crate::integrity::sha256_hex(output.to_string().as_bytes());
205    let line = |v: &Value| v.as_u64().and_then(|n| u32::try_from(n).ok());
206    let (path, start, end) = match tool {
207        "read_file" => (
208            output["path"].as_str().map(str::to_owned),
209            line(&output["start_line"]),
210            line(&output["end_line"]),
211        ),
212        // At a revision: not the file as it is now, so it opens nothing.
213        "git_show" => (None, None, None),
214        // A run's account: the turns it read.
215        "journal_account" => (None, line(&arguments["from"]), line(&arguments["to"])),
216        _ => (None, None, None),
217    };
218    let a = |k: &str| arguments[k].as_str().unwrap_or_default().to_owned();
219    let target = match tool {
220        "task_evidence" => format!("evidence of {}", a("task")),
221        "report" => match a("feature") {
222            f if f.is_empty() => "the report".to_owned(),
223            f => format!("the report of {f}"),
224        },
225        "inbox" => "Needs You".to_owned(),
226        "explain" => format!("code {}", a("code")),
227        "journal_account" => format!("account of run {}", a("run")),
228        "git_log" => match a("path") {
229            p if p.is_empty() => "git history".to_owned(),
230            p => format!("git history of {p}"),
231        },
232        "git_show" => format!("{} at {}", a("path"), a("rev")).trim().to_owned(),
233        "git_status" => "the working tree".to_owned(),
234        "git_diff" => format!("diff {}", a("path")).trim().to_owned(),
235        "list_files" => match a("path") {
236            p if p.is_empty() || p == "." => "the files".to_owned(),
237            p => format!("files in {p}"),
238        },
239        _ => crate::progress::target(tool, arguments),
240    };
241    let content_sha256 = (tool == "read_file")
242        .then(|| output["content"].as_str())
243        .flatten()
244        .map(|c| crate::integrity::sha256_hex(c.as_bytes()));
245    let subject = match tool {
246        "task_evidence" => Some(a("task")),
247        "journal_account" => Some(a("run")),
248        _ => None,
249    }
250    .filter(|s| !s.is_empty());
251    Read {
252        id: id.to_owned(),
253        tool: tool.to_owned(),
254        target,
255        path,
256        start,
257        end,
258        digest,
259        content_sha256,
260        subject,
261    }
262}
263
264/// What conversation `id`'s confirmed proposals created, from the origins
265/// they recorded: a quick change or bug fix (`Asked`), a promoted spec that
266/// carried it (`PromotedFrom`). Oldest first.
267#[must_use]
268pub fn results(path: &Path, state: &Path, id: &str) -> Vec<AskResult> {
269    use speccoder_core::ladder::Origin;
270    let plane = crate::worktree::project_state_dir(state, path);
271    let mut out: Vec<(u64, AskResult)> = Vec::new();
272    for (subject, origins) in crate::ladder::origins(&plane) {
273        for o in origins {
274            match o {
275                Origin::Asked {
276                    conversation,
277                    turn,
278                    at,
279                } if conversation == id => {
280                    let (unit, requirement) = subject
281                        .rsplit_once('/')
282                        .map_or((subject.as_str(), None), |(u, r)| (u, Some(r)));
283                    let task = requirement.and_then(|r| task_of(path, unit, r));
284                    out.push((
285                        at,
286                        AskResult {
287                            turn,
288                            kind: "change".to_owned(),
289                            unit: unit.to_owned(),
290                            requirement: requirement.map(str::to_owned),
291                            task,
292                        },
293                    ));
294                }
295                Origin::PromotedFrom {
296                    conversations, at, ..
297                } if conversations.iter().any(|(c, _)| c == id) => out.push((
298                    at,
299                    AskResult {
300                        turn: None,
301                        kind: "spec".to_owned(),
302                        unit: subject.clone(),
303                        requirement: None,
304                        task: None,
305                    },
306                )),
307                _ => {}
308            }
309        }
310    }
311    out.sort_by_key(|(at, _)| *at);
312    out.into_iter().map(|(_, r)| r).collect()
313}
314
315/// The task that implements `requirement` in `unit`, from its trace.
316fn task_of(path: &Path, unit: &str, requirement: &str) -> Option<String> {
317    let r = crate::status::report(path, Some(unit));
318    let Some(CommandData::Report(s)) = r.data() else {
319        return None;
320    };
321    s.trace
322        .iter()
323        .find(|t| t.requirement == requirement && t.inherited.is_none())
324        .and_then(|t| t.task.clone())
325}
326
327/// Whether `path` lines `start`–`end` no longer say what a read returned
328/// (`content_sha256`): read again with the same tool, so masking and the
329/// read cap are the same. `None` when the file cannot be read now.
330#[must_use]
331pub fn changed_since(
332    root: &Path,
333    path: &str,
334    start: Option<u32>,
335    end: Option<u32>,
336    content_sha256: &str,
337) -> Option<bool> {
338    let tools =
339        crate::tools::WorkspaceTools::new(root, crate::tools::policy::Policy::new(&[], &[]))
340            .ok()?;
341    let args = json!({ "path": path, "start_line": start, "end_line": end });
342    let out = crate::pipeline::tokio_runtime()
343        .ok()?
344        .block_on(tools.run("read_file", &args));
345    let now = out["content"].as_str()?;
346    Some(crate::integrity::sha256_hex(now.as_bytes()) != content_sha256)
347}
348
349/// A person's message for a conversation.
350#[derive(Debug, Clone, Default, Deserialize)]
351pub struct Request {
352    /// The conversation to continue; a new one when absent.
353    pub conversation: Option<String>,
354    /// The id a new conversation takes (`c-…`), chosen by the caller so
355    /// its first turn can be stopped before it ends; generated when absent.
356    #[serde(default)]
357    pub id: Option<String>,
358    /// The person's words.
359    pub text: String,
360    /// Context from the screen.
361    #[serde(default)]
362    pub chips: Vec<ChipRef>,
363    /// The feature in view.
364    pub feature: Option<String>,
365}
366
367fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
368    CommandResult::new("ask", status, message).with_code(code)
369}
370
371/// Where the project's Ask plane and threads live.
372fn plane_dir(path: &Path, state: &Path) -> Result<(PathBuf, PathBuf), Box<CommandResult>> {
373    let Some(root) = crate::format::at(path).detect(path) else {
374        return Err(Box::new(refuse(
375            CommandStatus::NotAProject,
376            "NO-PROJECT",
377            "not a spec project here",
378        )));
379    };
380    let dir = crate::worktree::project_state_dir(state, &root).join("ask");
381    Ok((root, dir))
382}
383
384fn state_or(state: Option<&Path>) -> Result<PathBuf, Box<CommandResult>> {
385    state
386        .map(Path::to_path_buf)
387        .or_else(crate::worktree::state_dir)
388        .ok_or_else(|| {
389            Box::new(refuse(
390                CommandStatus::ConfigError,
391                "NO-STATE-DIR",
392                "no state directory (set SPECCODER_STATE_DIR or HOME)",
393            ))
394        })
395}
396
397fn thread_path(dir: &Path, id: &str) -> PathBuf {
398    dir.join("threads").join(format!("{id}.json"))
399}
400
401/// Whether `id` has the shape speccoder gives a conversation (`c-` and
402/// hex): anything else names no file of the plane.
403#[must_use]
404pub fn valid_id(id: &str) -> bool {
405    id.strip_prefix("c-")
406        .is_some_and(|h| !h.is_empty() && h.len() <= 40 && h.bytes().all(|b| b.is_ascii_hexdigit()))
407}
408
409fn load(dir: &Path, id: &str) -> Option<AskThread> {
410    if !valid_id(id) {
411        return None;
412    }
413    serde_json::from_str(&std::fs::read_to_string(thread_path(dir, id)).ok()?).ok()
414}
415
416fn save(dir: &Path, thread: &AskThread) -> Result<(), String> {
417    let path = thread_path(dir, &thread.conversation.id);
418    if let Some(parent) = path.parent() {
419        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
420    }
421    let tmp = path.with_extension("json.tmp");
422    std::fs::write(
423        &tmp,
424        serde_json::to_vec_pretty(thread).map_err(|e| e.to_string())?,
425    )
426    .and_then(|()| std::fs::rename(&tmp, &path))
427    .map_err(|e| e.to_string())
428}
429
430fn now() -> u64 {
431    std::time::SystemTime::now()
432        .duration_since(std::time::UNIX_EPOCH)
433        .map_or(0, |d| d.as_secs())
434}
435
436/// Today's UTC day number and what Ask used in it.
437fn used_today(dir: &Path) -> (u64, u64) {
438    let day = now() / 86_400;
439    let used = std::fs::read_to_string(dir.join("usage.json"))
440        .ok()
441        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
442        .filter(|v| v["day"].as_u64() == Some(day))
443        .and_then(|v| v["tokens"].as_u64())
444        .unwrap_or(0);
445    (day, used)
446}
447
448fn add_today(dir: &Path, tokens: u64) {
449    let (day, used) = used_today(dir);
450    let _ = std::fs::create_dir_all(dir);
451    let _ = std::fs::write(
452        dir.join("usage.json"),
453        json!({ "day": day, "tokens": used + tokens }).to_string(),
454    );
455}
456
457/// A new conversation's id (`c-…`).
458#[must_use]
459pub fn new_id() -> String {
460    let nanos = std::time::SystemTime::now()
461        .duration_since(std::time::UNIX_EPOCH)
462        .map_or(0, |d| d.as_nanos());
463    format!(
464        "c-{:x}{:04x}",
465        nanos / 1_000_000,
466        std::process::id() & 0xffff
467    )
468}
469
470/// Answers `request` in the project at `path` with the model in its
471/// `speccoder.toml` (`[ask] model`, else `[model]`).
472#[must_use]
473pub fn turn(
474    path: &Path,
475    request: &Request,
476    state: Option<&Path>,
477    sink: Option<Arc<dyn Progress>>,
478) -> CommandResult {
479    let state = match state_or(state) {
480        Ok(s) => s,
481        Err(e) => return *e,
482    };
483    let config = crate::verify::find_config(path)
484        .and_then(|root| std::fs::read_to_string(root.join(crate::verify::config::FILE)).ok())
485        .unwrap_or_default();
486    let ask = match settings(&config) {
487        Ok(a) => a,
488        Err(e) => {
489            return refuse(
490                CommandStatus::ConfigError,
491                "CONFIG",
492                &format!("speccoder.toml: {e}"),
493            );
494        }
495    };
496    let model = match substrate::model_config::parse(&config) {
497        Ok(Some(mut m)) => {
498            if let Some(name) = &ask.model {
499                m.model.clone_from(name);
500            }
501            m
502        }
503        Ok(None) => {
504            return refuse(
505                CommandStatus::ConfigError,
506                "ASK-NO-MODEL",
507                "Ask needs a model ([model] in speccoder.toml); \"Make this a…\" works without one",
508            );
509        }
510        Err(e) => {
511            return refuse(
512                CommandStatus::ConfigError,
513                "CONFIG",
514                &format!("speccoder.toml: {e}"),
515            );
516        }
517    };
518    match substrate::model_config::open_driver(&model) {
519        Ok((provider, _)) => turn_with(path, request, &state, &ask, &model, provider, sink),
520        Err(e) => substrate::model_config::refused("ask", &e),
521    }
522}
523
524/// Forwards a turn's events: only this turn's (a resumed run replays the
525/// earlier ones), steps as `ask.step`, and keeps the turn's end.
526#[derive(Debug)]
527struct Collector {
528    conversation: String,
529    turn: u32,
530    tag: String,
531    sink: Option<Arc<dyn Progress>>,
532    ended: Mutex<Option<Value>>,
533}
534
535impl Progress for Collector {
536    fn event(&self, event: &Event) {
537        match event {
538            Event::ToolCall {
539                task,
540                tool,
541                target,
542                decision,
543                ok,
544                ..
545            } if *task == self.tag => {
546                if let Some(sink) = &self.sink {
547                    sink.event(&Event::AskStep {
548                        conversation: self.conversation.clone(),
549                        turn: self.turn,
550                        tool: tool.clone(),
551                        target: target.clone(),
552                        read: None,
553                        refused: decision != "allow" && decision != "proposed"
554                            || *ok == Some(false),
555                    });
556                }
557            }
558            Event::AskTurnEnded { tag, output } if *tag == self.tag => {
559                if let Ok(mut e) = self.ended.lock() {
560                    *e = Some((**output).clone());
561                }
562            }
563            _ => {}
564        }
565    }
566}
567
568/// The answer's words as the model writes them.
569#[derive(Debug)]
570struct Words {
571    conversation: String,
572    turn: u32,
573    sink: Option<Arc<dyn Progress>>,
574    text: Mutex<String>,
575    /// Set by Stop: the words end where the person saw them end.
576    halt: Halt,
577}
578
579impl Words {
580    fn text(&self) -> String {
581        self.text.lock().map(|t| t.clone()).unwrap_or_default()
582    }
583}
584
585impl agentplane::runtime::RunStreamObserver for Words {
586    fn event(
587        &self,
588        _run: agentplane::core::RunId,
589        event: agentplane::core::Tainted<agentplane::model::ModelStreamEvent>,
590    ) {
591        if self.halt.is_stopped() {
592            return;
593        }
594        if let agentplane::model::ModelStreamEvent::TextDelta(delta) = event.peek() {
595            if let Ok(mut t) = self.text.lock() {
596                t.push_str(delta);
597            }
598            if let Some(sink) = &self.sink {
599                sink.event(&Event::AskText {
600                    conversation: self.conversation.clone(),
601                    turn: self.turn,
602                    delta: delta.clone(),
603                });
604            }
605        }
606    }
607}
608
609/// [`turn`] with `provider` answering.
610#[must_use]
611pub fn turn_with(
612    path: &Path,
613    request: &Request,
614    state: &Path,
615    ask: &Settings,
616    model: &ModelConfig,
617    provider: Arc<dyn ModelProvider>,
618    sink: Option<Arc<dyn Progress>>,
619) -> CommandResult {
620    let text = request.text.trim();
621    if !text.chars().any(char::is_alphanumeric) {
622        return refuse(
623            CommandStatus::UsageError,
624            "USAGE",
625            "ask a question first — any words",
626        );
627    }
628    if text.chars().count() > MAX_QUESTION {
629        return refuse(
630            CommandStatus::UsageError,
631            "USAGE",
632            &format!("a question is at most {MAX_QUESTION} characters"),
633        );
634    }
635    if request.chips.len() > chips::MAX {
636        return refuse(
637            CommandStatus::UsageError,
638            "USAGE",
639            &format!("at most {} chips per question", chips::MAX),
640        );
641    }
642    let (root, dir) = match plane_dir(path, state) {
643        Ok(d) => d,
644        Err(e) => return *e,
645    };
646    let mut thread = match &request.conversation {
647        Some(id) => match load(&dir, id) {
648            Some(t) => t,
649            None => {
650                return refuse(
651                    CommandStatus::NotFound,
652                    "NOT-FOUND",
653                    &format!("no conversation {id}"),
654                );
655            }
656        },
657        None if request.id.as_deref().is_some_and(|i| !valid_id(i)) => {
658            return refuse(
659                CommandStatus::UsageError,
660                "USAGE",
661                "a conversation id is `c-` and hex digits",
662            );
663        }
664        None => AskThread {
665            conversation: Conversation {
666                id: request.id.clone().unwrap_or_else(new_id),
667                feature: request.feature.clone(),
668                title: text.chars().take(120).collect(),
669                at: now(),
670                turns: 0,
671                open: true,
672            },
673            turns: Vec::new(),
674            segment: 0,
675            run: None,
676            results: Vec::new(),
677        },
678    };
679    let id = thread.conversation.id.clone();
680    let n = u32::try_from(thread.turns.len()).unwrap_or(u32::MAX - 1) + 1;
681    let reads_from = 1 + thread.turns.iter().map(|t| t.reads.len()).sum::<usize>();
682    let resolved: Result<Vec<_>, String> = request
683        .chips
684        .iter()
685        .enumerate()
686        .map(|(i, c)| chips::resolve(&root, &format!("c{}", i + 1), c))
687        .collect();
688    let resolved = match resolved {
689        Ok(r) => r,
690        Err(e) => {
691            return refuse(
692                CommandStatus::UsageError,
693                "USAGE",
694                &format!("a chip cannot be read: {e}"),
695            );
696        }
697    };
698    let mut report = AskReport {
699        conversation: id.clone(),
700        turn: n,
701        question: text.to_owned(),
702        chips: resolved.iter().map(|(c, _)| c.clone()).collect(),
703        reads: Vec::new(),
704        answer: String::new(),
705        citations: Vec::new(),
706        proposal: None,
707        usage: AskUsage::default(),
708        end: "answered".to_owned(),
709        reason: None,
710        context: None,
711    };
712    let (_, used) = used_today(&dir);
713    let left = ask.daily_tokens.saturating_sub(used);
714    if left == 0 {
715        report.end = "budget".to_owned();
716        report.reason = Some(format!(
717            "Ask's tokens for today are spent ([ask] daily_tokens = {})",
718            ask.daily_tokens
719        ));
720        return finish(&dir, thread, report, sink.as_ref());
721    }
722    let collector = Arc::new(Collector {
723        conversation: id.clone(),
724        turn: n,
725        tag: turn_tag(&id, n),
726        sink: sink.clone(),
727        ended: Mutex::new(None),
728    });
729    let journal = crate::worktree::project_state_dir(state, &root);
730    let workspace =
731        match crate::tools::WorkspaceTools::new(&root, crate::tools::policy::Policy::new(&[], &[]))
732        {
733            Ok(t) => Arc::new(t),
734            Err(e) => return refuse(CommandStatus::Error, "ERROR", &e.to_string()),
735        };
736    let flag = Halt::new();
737    let words = Arc::new(Words {
738        conversation: id.clone(),
739        turn: n,
740        sink: sink.clone(),
741        text: Mutex::new(String::new()),
742        halt: flag.clone(),
743    });
744    let wiring = substrate::ask::AskWiring {
745        halt: flag.clone(),
746        workspace,
747        readers: Arc::new(tools::AskTools::new(&root, &journal)),
748        progress: Some(Arc::clone(&collector) as Arc<dyn Progress>),
749        stream: Some(Arc::clone(&words) as Arc<dyn agentplane::runtime::RunStreamObserver>),
750    };
751    let format = crate::format::at(&root);
752    let rt = match crate::pipeline::tokio_runtime() {
753        Ok(rt) => rt,
754        Err(e) => return refuse(CommandStatus::Error, "ERROR", &e),
755    };
756    let message = chips::message(text, &resolved);
757    // Each chip is an entry of the question's context manifest, journaled
758    // with the message it rides on.
759    let mut manifest = crate::context::Manifest::new();
760    for (chip, content) in &resolved {
761        manifest.push(
762            speccoder_core::context::ContextKind::Chip,
763            &format!("speccoder://ask/chip/{}", chip.id),
764            &chip.label,
765            content.as_bytes(),
766        );
767    }
768    let context = (!resolved.is_empty()).then(|| manifest.finish());
769    report.context.clone_from(&context);
770    let payload = |carried: bool| {
771        let text = if carried {
772            format!("{}{message}", earlier(&thread.turns))
773        } else {
774            message.clone()
775        };
776        json!({
777            "text": text,
778            "tag": turn_tag(&id, n),
779            "limit": ask.max_turn_tokens.min(left),
780            "reads": reads_from,
781            "context": context,
782        })
783    };
784    let start = json!({
785        "root": root.to_string_lossy(),
786        "format": format.kind().name(),
787        "feature": request.feature,
788    });
789    let halt = Live::enter(&id, &flag);
790    let by = crate::approvals::who_in(&root);
791    // A run that no longer waits (stopped, idle, full, or left behind by a
792    // process that died mid-turn) hands over to a new one, carrying the
793    // earlier turns' words: a conversation never dead-ends.
794    let mut segment = thread.segment;
795    let mut run = thread.run.clone().filter(|_| thread.conversation.open);
796    let waited = rt.block_on(async {
797        let plane = substrate::Plane::open_ask(&dir, model, provider, wiring)?;
798        loop {
799            let carried = run.is_none() && !thread.turns.is_empty();
800            let started_now = run.is_none();
801            if run.is_none() {
802                if !thread.turns.is_empty() {
803                    segment += 1;
804                }
805                let key = segment_key(&id, segment);
806                let mut input = start.clone();
807                input["conversation"] = json!(key);
808                let started = plane
809                    .ask_start(&key, agentplane::core::Tainted::trusted(input))
810                    .await?;
811                run = Some(started.run_id.to_string());
812            }
813            let key = segment_key(&id, segment);
814            let message_id = format!("{key}-{n}");
815            let deliver = plane.ask_deliver(&key, &message_id, payload(carried));
816            tokio::pin!(deliver);
817            // A Stop that lands as the answer does still cancels the run.
818            let resumed = tokio::select! {
819                biased;
820                () = halt.notified() => {
821                    if let Some(r) = &run {
822                        plane.ask_stop(r, &by).await?;
823                    }
824                    let _ = deliver.await;
825                    return Ok(true);
826                }
827                r = &mut deliver => r?,
828            };
829            if resumed {
830                return Ok(false);
831            }
832            if started_now {
833                return Err(substrate::PlaneError(
834                    "a new run of the conversation did not take the message".to_owned(),
835                ));
836            }
837            run = None;
838        }
839    });
840    drop(halt);
841    thread.segment = segment;
842    thread.run.clone_from(&run);
843    thread.conversation.open = true;
844    match waited {
845        Ok(false) => {}
846        // Stopped: the run was cancelled with the turn.
847        Ok(true) => thread.conversation.open = false,
848        Err(e) if e.0.contains("another speccoder is working") => {
849            return refuse(
850                CommandStatus::UsageError,
851                "USAGE",
852                "another question is being answered in this project; ask when it is done",
853            );
854        }
855        Err(e) => return refuse(CommandStatus::RunFailed, "RUN-FAILED", &e.0),
856    }
857    let output = collector
858        .ended
859        .lock()
860        .ok()
861        .and_then(|e| e.clone())
862        .unwrap_or(Value::Null);
863    // Stopped, however the race fell: the next message starts a new run.
864    if output["status"] == "stopped" {
865        thread.conversation.open = false;
866    }
867    let u = &output["usage"];
868    let n_of = |k: &str| u[k].as_u64().unwrap_or(0);
869    report.usage = AskUsage {
870        input: n_of("input_tokens"),
871        output: n_of("output_tokens"),
872        cache_read: n_of("cache_read_tokens"),
873    };
874    let log = output["tool_calls"].as_array().cloned().unwrap_or_default();
875    report.reads = log
876        .iter()
877        .filter_map(|e| serde_json::from_value::<Read>(e["read"].clone()).ok())
878        .collect();
879    if let Some(p) = log
880        .iter()
881        .find(|e| e["tool"] == PROPOSE && e["decision"] == "proposed")
882    {
883        let mut p = proposal::validate(&root, request.feature.as_deref(), &id, &p["arguments"]);
884        p.turn = Some(n);
885        report.proposal = Some(proposal::revalidate(&root, request.feature.as_deref(), &p));
886    }
887    match output["status"].as_str() {
888        Some("answered") => {
889            report.answer = output["text"]
890                .as_str()
891                .unwrap_or_default()
892                .trim()
893                .to_owned()
894        }
895        Some("stopped") => {
896            report.end = "stopped".to_owned();
897            // What the person saw when they stopped it, not what the model
898            // went on writing.
899            report.answer = words.text().trim().to_owned();
900            report.reason = Some("stopped by you — ask again to go on".to_owned());
901        }
902        Some("budget") => {
903            report.end = "budget".to_owned();
904            report.reason = Some(format!(
905                "{} ([ask] max_turn_tokens = {})",
906                output["reason"]
907                    .as_str()
908                    .unwrap_or("the turn's tokens are spent"),
909                ask.max_turn_tokens
910            ));
911        }
912        _ => {
913            report.end = "failed".to_owned();
914            report.reason = Some(
915                output["reason"]
916                    .as_str()
917                    .unwrap_or("the turn did not finish")
918                    .to_owned(),
919            );
920        }
921    }
922    report.citations = speccoder_core::ask::check(&report.answer, &report.reads, &report.chips);
923    add_today(&dir, report.usage.billed());
924    // A run whose history alone fills a turn hands over to a new one.
925    if report.usage.input + report.usage.cache_read > FULL_AT {
926        thread.conversation.open = false;
927    }
928    finish(&dir, thread, report, sink.as_ref())
929}
930
931fn finish(
932    dir: &Path,
933    mut thread: AskThread,
934    report: AskReport,
935    sink: Option<&Arc<dyn Progress>>,
936) -> CommandResult {
937    thread.conversation.turns += 1;
938    thread.conversation.at = now();
939    thread.turns.push(report.clone());
940    if let Err(e) = save(dir, &thread) {
941        return refuse(
942            CommandStatus::Error,
943            "ERROR",
944            &format!("the conversation could not be saved: {e}"),
945        );
946    }
947    if let Some(sink) = sink {
948        sink.event(&Event::AskDone {
949            report: Box::new(report.clone()),
950        });
951    }
952    let sources = report
953        .citations
954        .iter()
955        .filter(|c| c.state == "read")
956        .count();
957    let (status, code, message) = match report.end.as_str() {
958        "answered" => (
959            CommandStatus::Ok,
960            None,
961            format!("answered with {sources} sources"),
962        ),
963        "stopped" => (CommandStatus::Ok, None, "stopped".to_owned()),
964        "budget" => (
965            CommandStatus::NeedsHuman,
966            Some("ASK-BUDGET"),
967            report.reason.clone().unwrap_or_default(),
968        ),
969        _ => (
970            CommandStatus::RunFailed,
971            Some("RUN-FAILED"),
972            report.reason.clone().unwrap_or_default(),
973        ),
974    };
975    let mut result = CommandResult::new("ask", status, &message);
976    if let Some(code) = code {
977        result = result.with_code(code);
978    }
979    result.with_data(CommandData::Ask(Box::new(report)))
980}
981
982/// Conversation `id`'s questions and answers, word for word, and their
983/// digest: what promotion carries into a new spec.
984#[must_use]
985pub fn words(path: &Path, state: &Path, id: &str) -> Option<(String, String)> {
986    let (_, dir) = plane_dir(path, state).ok()?;
987    let t = load(&dir, id)?;
988    let text: String = t
989        .turns
990        .iter()
991        .map(|t| format!("Q: {}\nA: {}\n", t.question, t.answer))
992        .collect::<Vec<_>>()
993        .join("\n");
994    let digest = crate::integrity::sha256_hex(text.as_bytes());
995    Some((text, digest))
996}
997
998/// The tokens Ask's conversations about `feature` used: input, output and
999/// cache reads.
1000#[must_use]
1001pub fn spent(path: &Path, state: Option<&Path>, feature: &str) -> AskUsage {
1002    let mut total = AskUsage::default();
1003    let Some(dir) = state_or(state)
1004        .ok()
1005        .and_then(|s| plane_dir(path, &s).ok())
1006        .map(|(_, d)| d)
1007    else {
1008        return total;
1009    };
1010    let threads = std::fs::read_dir(dir.join("threads"))
1011        .into_iter()
1012        .flatten()
1013        .flatten();
1014    for t in threads.filter_map(|e| {
1015        serde_json::from_str::<AskThread>(&std::fs::read_to_string(e.path()).ok()?).ok()
1016    }) {
1017        if t.conversation.feature.as_deref() == Some(feature) {
1018            for turn in &t.turns {
1019                total.input += turn.usage.input;
1020                total.output += turn.usage.output;
1021                total.cache_read += turn.usage.cache_read;
1022            }
1023        }
1024    }
1025    total
1026}
1027
1028/// The project's conversations, newest first; `feature` narrows them.
1029#[must_use]
1030pub fn conversations(path: &Path, state: Option<&Path>, feature: Option<&str>) -> CommandResult {
1031    let state = match state_or(state) {
1032        Ok(s) => s,
1033        Err(e) => return *e,
1034    };
1035    let (_, dir) = match plane_dir(path, &state) {
1036        Ok(d) => d,
1037        Err(e) => return *e,
1038    };
1039    let mut list: Vec<Conversation> = std::fs::read_dir(dir.join("threads"))
1040        .into_iter()
1041        .flatten()
1042        .flatten()
1043        .filter_map(|e| {
1044            let t: AskThread =
1045                serde_json::from_str(&std::fs::read_to_string(e.path()).ok()?).ok()?;
1046            Some(t.conversation)
1047        })
1048        .filter(|c| feature.is_none() || c.feature.as_deref() == feature)
1049        .collect();
1050    list.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| b.id.cmp(&a.id)));
1051    let message = format!("{} conversations", list.len());
1052    CommandResult::new("ask", CommandStatus::Ok, &message).with_data(CommandData::AskConversations(
1053        Box::new(AskConversations {
1054            conversations: list,
1055        }),
1056    ))
1057}
1058
1059/// One conversation with its turns.
1060#[must_use]
1061pub fn thread(path: &Path, state: Option<&Path>, id: &str) -> CommandResult {
1062    let state = match state_or(state) {
1063        Ok(s) => s,
1064        Err(e) => return *e,
1065    };
1066    let (_, dir) = match plane_dir(path, &state) {
1067        Ok(d) => d,
1068        Err(e) => return *e,
1069    };
1070    match load(&dir, id) {
1071        Some(mut t) => {
1072            t.results = results(path, &state, id);
1073            CommandResult::new("ask", CommandStatus::Ok, &t.conversation.title.clone())
1074                .with_data(CommandData::AskThread(Box::new(t)))
1075        }
1076        None => refuse(
1077            CommandStatus::NotFound,
1078            "NOT-FOUND",
1079            &format!("no conversation {id}"),
1080        ),
1081    }
1082}