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    // Counted too: two conversations of one process in one millisecond
464    // (two tabs of the interface) are two.
465    static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
466    format!(
467        "c-{:x}{:04x}{:x}",
468        nanos / 1_000_000,
469        std::process::id() & 0xffff,
470        N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
471    )
472}
473
474/// Answers `request` in the project at `path` with the model in its
475/// `speccoder.toml` (`[ask] model`, else `[model]`).
476#[must_use]
477pub fn turn(
478    path: &Path,
479    request: &Request,
480    state: Option<&Path>,
481    sink: Option<Arc<dyn Progress>>,
482) -> CommandResult {
483    let state = match state_or(state) {
484        Ok(s) => s,
485        Err(e) => return *e,
486    };
487    let config = crate::verify::find_config(path)
488        .and_then(|root| std::fs::read_to_string(root.join(crate::verify::config::FILE)).ok())
489        .unwrap_or_default();
490    let ask = match settings(&config) {
491        Ok(a) => a,
492        Err(e) => {
493            return refuse(
494                CommandStatus::ConfigError,
495                "CONFIG",
496                &format!("speccoder.toml: {e}"),
497            );
498        }
499    };
500    let model = match substrate::model_config::parse(&config) {
501        Ok(Some(mut m)) => {
502            if let Some(name) = &ask.model {
503                m.model.clone_from(name);
504            }
505            m
506        }
507        Ok(None) => {
508            return refuse(
509                CommandStatus::ConfigError,
510                "ASK-NO-MODEL",
511                "Ask needs a model ([model] in speccoder.toml); \"Make this a…\" works without one",
512            );
513        }
514        Err(e) => {
515            return refuse(
516                CommandStatus::ConfigError,
517                "CONFIG",
518                &format!("speccoder.toml: {e}"),
519            );
520        }
521    };
522    match substrate::model_config::open_driver(&model) {
523        Ok((provider, _)) => turn_with(path, request, &state, &ask, &model, provider, sink),
524        Err(e) => substrate::model_config::refused("ask", &e),
525    }
526}
527
528/// Forwards a turn's events: only this turn's (a resumed run replays the
529/// earlier ones), steps as `ask.step`, and keeps the turn's end.
530#[derive(Debug)]
531struct Collector {
532    conversation: String,
533    turn: u32,
534    tag: String,
535    sink: Option<Arc<dyn Progress>>,
536    ended: Mutex<Option<Value>>,
537}
538
539impl Progress for Collector {
540    fn event(&self, event: &Event) {
541        match event {
542            Event::ToolCall {
543                task,
544                tool,
545                target,
546                decision,
547                ok,
548                ..
549            } if *task == self.tag => {
550                if let Some(sink) = &self.sink {
551                    sink.event(&Event::AskStep {
552                        conversation: self.conversation.clone(),
553                        turn: self.turn,
554                        tool: tool.clone(),
555                        target: target.clone(),
556                        read: None,
557                        refused: decision != "allow" && decision != "proposed"
558                            || *ok == Some(false),
559                    });
560                }
561            }
562            Event::AskTurnEnded { tag, output } if *tag == self.tag => {
563                if let Ok(mut e) = self.ended.lock() {
564                    *e = Some((**output).clone());
565                }
566            }
567            _ => {}
568        }
569    }
570}
571
572/// The answer's words as the model writes them.
573#[derive(Debug)]
574struct Words {
575    conversation: String,
576    turn: u32,
577    sink: Option<Arc<dyn Progress>>,
578    text: Mutex<String>,
579    /// Set by Stop: the words end where the person saw them end.
580    halt: Halt,
581}
582
583impl Words {
584    fn text(&self) -> String {
585        self.text.lock().map(|t| t.clone()).unwrap_or_default()
586    }
587}
588
589impl agentplane::runtime::RunStreamObserver for Words {
590    fn event(
591        &self,
592        _run: agentplane::core::RunId,
593        event: agentplane::core::Tainted<agentplane::model::ModelStreamEvent>,
594    ) {
595        if self.halt.is_stopped() {
596            return;
597        }
598        if let agentplane::model::ModelStreamEvent::TextDelta(delta) = event.peek() {
599            if let Ok(mut t) = self.text.lock() {
600                t.push_str(delta);
601            }
602            if let Some(sink) = &self.sink {
603                sink.event(&Event::AskText {
604                    conversation: self.conversation.clone(),
605                    turn: self.turn,
606                    delta: delta.clone(),
607                });
608            }
609        }
610    }
611}
612
613/// [`turn`] with `provider` answering.
614#[must_use]
615pub fn turn_with(
616    path: &Path,
617    request: &Request,
618    state: &Path,
619    ask: &Settings,
620    model: &ModelConfig,
621    provider: Arc<dyn ModelProvider>,
622    sink: Option<Arc<dyn Progress>>,
623) -> CommandResult {
624    let text = request.text.trim();
625    if !text.chars().any(char::is_alphanumeric) {
626        return refuse(
627            CommandStatus::UsageError,
628            "USAGE",
629            "ask a question first — any words",
630        );
631    }
632    if text.chars().count() > MAX_QUESTION {
633        return refuse(
634            CommandStatus::UsageError,
635            "USAGE",
636            &format!("a question is at most {MAX_QUESTION} characters"),
637        );
638    }
639    if request.chips.len() > chips::MAX {
640        return refuse(
641            CommandStatus::UsageError,
642            "USAGE",
643            &format!("at most {} chips per question", chips::MAX),
644        );
645    }
646    let (root, dir) = match plane_dir(path, state) {
647        Ok(d) => d,
648        Err(e) => return *e,
649    };
650    let mut thread = match &request.conversation {
651        Some(id) => match load(&dir, id) {
652            Some(t) => t,
653            None => {
654                return refuse(
655                    CommandStatus::NotFound,
656                    "NOT-FOUND",
657                    &format!("no conversation {id}"),
658                );
659            }
660        },
661        None if request.id.as_deref().is_some_and(|i| !valid_id(i)) => {
662            return refuse(
663                CommandStatus::UsageError,
664                "USAGE",
665                "a conversation id is `c-` and hex digits",
666            );
667        }
668        None => AskThread {
669            conversation: Conversation {
670                id: request.id.clone().unwrap_or_else(new_id),
671                feature: request.feature.clone(),
672                title: text.chars().take(120).collect(),
673                at: now(),
674                turns: 0,
675                open: true,
676            },
677            turns: Vec::new(),
678            segment: 0,
679            run: None,
680            results: Vec::new(),
681        },
682    };
683    let id = thread.conversation.id.clone();
684    let n = u32::try_from(thread.turns.len()).unwrap_or(u32::MAX - 1) + 1;
685    let reads_from = 1 + thread.turns.iter().map(|t| t.reads.len()).sum::<usize>();
686    let resolved: Result<Vec<_>, String> = request
687        .chips
688        .iter()
689        .enumerate()
690        .map(|(i, c)| chips::resolve(&root, &format!("c{}", i + 1), c))
691        .collect();
692    let resolved = match resolved {
693        Ok(r) => r,
694        Err(e) => {
695            return refuse(
696                CommandStatus::UsageError,
697                "USAGE",
698                &format!("a chip cannot be read: {e}"),
699            );
700        }
701    };
702    let mut report = AskReport {
703        conversation: id.clone(),
704        turn: n,
705        question: text.to_owned(),
706        chips: resolved.iter().map(|(c, _)| c.clone()).collect(),
707        reads: Vec::new(),
708        answer: String::new(),
709        citations: Vec::new(),
710        proposal: None,
711        usage: AskUsage::default(),
712        end: "answered".to_owned(),
713        reason: None,
714        context: None,
715    };
716    let (_, used) = used_today(&dir);
717    let left = ask.daily_tokens.saturating_sub(used);
718    if left == 0 {
719        report.end = "budget".to_owned();
720        report.reason = Some(format!(
721            "Ask's tokens for today are spent ([ask] daily_tokens = {})",
722            ask.daily_tokens
723        ));
724        return finish(&dir, thread, report, sink.as_ref());
725    }
726    let collector = Arc::new(Collector {
727        conversation: id.clone(),
728        turn: n,
729        tag: turn_tag(&id, n),
730        sink: sink.clone(),
731        ended: Mutex::new(None),
732    });
733    let journal = crate::worktree::project_state_dir(state, &root);
734    let workspace =
735        match crate::tools::WorkspaceTools::new(&root, crate::tools::policy::Policy::new(&[], &[]))
736        {
737            Ok(t) => Arc::new(t),
738            Err(e) => return refuse(CommandStatus::Error, "ERROR", &e.to_string()),
739        };
740    let flag = Halt::new();
741    let words = Arc::new(Words {
742        conversation: id.clone(),
743        turn: n,
744        sink: sink.clone(),
745        text: Mutex::new(String::new()),
746        halt: flag.clone(),
747    });
748    let wiring = substrate::ask::AskWiring {
749        halt: flag.clone(),
750        workspace,
751        readers: Arc::new(tools::AskTools::new(&root, &journal)),
752        progress: Some(Arc::clone(&collector) as Arc<dyn Progress>),
753        stream: Some(Arc::clone(&words) as Arc<dyn agentplane::runtime::RunStreamObserver>),
754    };
755    let format = crate::format::at(&root);
756    let rt = match crate::pipeline::tokio_runtime() {
757        Ok(rt) => rt,
758        Err(e) => return refuse(CommandStatus::Error, "ERROR", &e),
759    };
760    let message = chips::message(text, &resolved);
761    // Each chip is an entry of the question's context manifest, journaled
762    // with the message it rides on.
763    let mut manifest = crate::context::Manifest::new();
764    for (chip, content) in &resolved {
765        manifest.push(
766            speccoder_core::context::ContextKind::Chip,
767            &format!("speccoder://ask/chip/{}", chip.id),
768            &chip.label,
769            content.as_bytes(),
770        );
771    }
772    let context = (!resolved.is_empty()).then(|| manifest.finish());
773    report.context.clone_from(&context);
774    let payload = |carried: bool| {
775        let text = if carried {
776            format!("{}{message}", earlier(&thread.turns))
777        } else {
778            message.clone()
779        };
780        json!({
781            "text": text,
782            "tag": turn_tag(&id, n),
783            "limit": ask.max_turn_tokens.min(left),
784            "reads": reads_from,
785            "context": context,
786        })
787    };
788    let start = json!({
789        "root": root.to_string_lossy(),
790        "format": format.kind().name(),
791        "feature": request.feature,
792    });
793    let halt = Live::enter(&id, &flag);
794    let by = crate::approvals::who_in(&root);
795    // A run that no longer waits (stopped, idle, full, or left behind by a
796    // process that died mid-turn) hands over to a new one, carrying the
797    // earlier turns' words: a conversation never dead-ends.
798    let mut segment = thread.segment;
799    let mut run = thread.run.clone().filter(|_| thread.conversation.open);
800    let waited = rt.block_on(async {
801        let plane = substrate::Plane::open_ask(&dir, model, provider, wiring)?;
802        loop {
803            let carried = run.is_none() && !thread.turns.is_empty();
804            let started_now = run.is_none();
805            if run.is_none() {
806                if !thread.turns.is_empty() {
807                    segment += 1;
808                }
809                let key = segment_key(&id, segment);
810                let mut input = start.clone();
811                input["conversation"] = json!(key);
812                let started = plane
813                    .ask_start(&key, agentplane::core::Tainted::trusted(input))
814                    .await?;
815                run = Some(started.run_id.to_string());
816            }
817            let key = segment_key(&id, segment);
818            let message_id = format!("{key}-{n}");
819            let deliver = plane.ask_deliver(&key, &message_id, payload(carried));
820            tokio::pin!(deliver);
821            // A Stop that lands as the answer does still cancels the run.
822            let resumed = tokio::select! {
823                biased;
824                () = halt.notified() => {
825                    if let Some(r) = &run {
826                        plane.ask_stop(r, &by).await?;
827                    }
828                    let _ = deliver.await;
829                    return Ok(true);
830                }
831                r = &mut deliver => r?,
832            };
833            if resumed {
834                return Ok(false);
835            }
836            if started_now {
837                return Err(substrate::PlaneError(
838                    "a new run of the conversation did not take the message".to_owned(),
839                ));
840            }
841            run = None;
842        }
843    });
844    drop(halt);
845    thread.segment = segment;
846    thread.run.clone_from(&run);
847    thread.conversation.open = true;
848    match waited {
849        Ok(false) => {}
850        // Stopped: the run was cancelled with the turn.
851        Ok(true) => thread.conversation.open = false,
852        Err(e) if e.0.contains("another speccoder is working") => {
853            return refuse(
854                CommandStatus::UsageError,
855                "USAGE",
856                "another question is being answered in this project; ask when it is done",
857            );
858        }
859        Err(e) => return refuse(CommandStatus::RunFailed, "RUN-FAILED", &e.0),
860    }
861    let output = collector
862        .ended
863        .lock()
864        .ok()
865        .and_then(|e| e.clone())
866        .unwrap_or(Value::Null);
867    // Stopped, however the race fell: the next message starts a new run.
868    if output["status"] == "stopped" {
869        thread.conversation.open = false;
870    }
871    let u = &output["usage"];
872    let n_of = |k: &str| u[k].as_u64().unwrap_or(0);
873    report.usage = AskUsage {
874        input: n_of("input_tokens"),
875        output: n_of("output_tokens"),
876        cache_read: n_of("cache_read_tokens"),
877    };
878    let log = output["tool_calls"].as_array().cloned().unwrap_or_default();
879    report.reads = log
880        .iter()
881        .filter_map(|e| serde_json::from_value::<Read>(e["read"].clone()).ok())
882        .collect();
883    if let Some(p) = log
884        .iter()
885        .find(|e| e["tool"] == PROPOSE && e["decision"] == "proposed")
886    {
887        let mut p = proposal::validate(&root, request.feature.as_deref(), &id, &p["arguments"]);
888        p.turn = Some(n);
889        report.proposal = Some(proposal::revalidate(&root, request.feature.as_deref(), &p));
890    }
891    match output["status"].as_str() {
892        Some("answered") => {
893            report.answer = output["text"]
894                .as_str()
895                .unwrap_or_default()
896                .trim()
897                .to_owned()
898        }
899        Some("stopped") => {
900            report.end = "stopped".to_owned();
901            // What the person saw when they stopped it, not what the model
902            // went on writing.
903            report.answer = words.text().trim().to_owned();
904            report.reason = Some("stopped by you — ask again to go on".to_owned());
905        }
906        Some("budget") => {
907            report.end = "budget".to_owned();
908            report.reason = Some(format!(
909                "{} ([ask] max_turn_tokens = {})",
910                output["reason"]
911                    .as_str()
912                    .unwrap_or("the turn's tokens are spent"),
913                ask.max_turn_tokens
914            ));
915        }
916        _ => {
917            report.end = "failed".to_owned();
918            report.reason = Some(
919                output["reason"]
920                    .as_str()
921                    .unwrap_or("the turn did not finish")
922                    .to_owned(),
923            );
924        }
925    }
926    report.citations = speccoder_core::ask::check(&report.answer, &report.reads, &report.chips);
927    add_today(&dir, report.usage.billed());
928    // A run whose history alone fills a turn hands over to a new one.
929    if report.usage.input + report.usage.cache_read > FULL_AT {
930        thread.conversation.open = false;
931    }
932    finish(&dir, thread, report, sink.as_ref())
933}
934
935fn finish(
936    dir: &Path,
937    mut thread: AskThread,
938    report: AskReport,
939    sink: Option<&Arc<dyn Progress>>,
940) -> CommandResult {
941    thread.conversation.turns += 1;
942    thread.conversation.at = now();
943    thread.turns.push(report.clone());
944    if let Err(e) = save(dir, &thread) {
945        return refuse(
946            CommandStatus::Error,
947            "ERROR",
948            &format!("the conversation could not be saved: {e}"),
949        );
950    }
951    if let Some(sink) = sink {
952        sink.event(&Event::AskDone {
953            report: Box::new(report.clone()),
954        });
955    }
956    let sources = report
957        .citations
958        .iter()
959        .filter(|c| c.state == "read")
960        .count();
961    let (status, code, message) = match report.end.as_str() {
962        "answered" => (
963            CommandStatus::Ok,
964            None,
965            format!("answered with {sources} sources"),
966        ),
967        "stopped" => (CommandStatus::Ok, None, "stopped".to_owned()),
968        "budget" => (
969            CommandStatus::NeedsHuman,
970            Some("ASK-BUDGET"),
971            report.reason.clone().unwrap_or_default(),
972        ),
973        _ => (
974            CommandStatus::RunFailed,
975            Some("RUN-FAILED"),
976            report.reason.clone().unwrap_or_default(),
977        ),
978    };
979    let mut result = CommandResult::new("ask", status, &message);
980    if let Some(code) = code {
981        result = result.with_code(code);
982    }
983    result.with_data(CommandData::Ask(Box::new(report)))
984}
985
986/// Conversation `id`'s questions and answers, word for word, and their
987/// digest: what promotion carries into a new spec.
988#[must_use]
989pub fn words(path: &Path, state: &Path, id: &str) -> Option<(String, String)> {
990    let (_, dir) = plane_dir(path, state).ok()?;
991    let t = load(&dir, id)?;
992    let text: String = t
993        .turns
994        .iter()
995        .map(|t| format!("Q: {}\nA: {}\n", t.question, t.answer))
996        .collect::<Vec<_>>()
997        .join("\n");
998    let digest = crate::integrity::sha256_hex(text.as_bytes());
999    Some((text, digest))
1000}
1001
1002/// The tokens Ask's conversations about `feature` used: input, output and
1003/// cache reads.
1004#[must_use]
1005pub fn spent(path: &Path, state: Option<&Path>, feature: &str) -> AskUsage {
1006    let mut total = AskUsage::default();
1007    let Some(dir) = state_or(state)
1008        .ok()
1009        .and_then(|s| plane_dir(path, &s).ok())
1010        .map(|(_, d)| d)
1011    else {
1012        return total;
1013    };
1014    let threads = std::fs::read_dir(dir.join("threads"))
1015        .into_iter()
1016        .flatten()
1017        .flatten();
1018    for t in threads.filter_map(|e| {
1019        serde_json::from_str::<AskThread>(&std::fs::read_to_string(e.path()).ok()?).ok()
1020    }) {
1021        if t.conversation.feature.as_deref() == Some(feature) {
1022            for turn in &t.turns {
1023                total.input += turn.usage.input;
1024                total.output += turn.usage.output;
1025                total.cache_read += turn.usage.cache_read;
1026            }
1027        }
1028    }
1029    total
1030}
1031
1032/// The project's conversations, newest first; `feature` narrows them.
1033#[must_use]
1034pub fn conversations(path: &Path, state: Option<&Path>, feature: Option<&str>) -> CommandResult {
1035    let state = match state_or(state) {
1036        Ok(s) => s,
1037        Err(e) => return *e,
1038    };
1039    let (_, dir) = match plane_dir(path, &state) {
1040        Ok(d) => d,
1041        Err(e) => return *e,
1042    };
1043    let mut list: Vec<Conversation> = std::fs::read_dir(dir.join("threads"))
1044        .into_iter()
1045        .flatten()
1046        .flatten()
1047        .filter_map(|e| {
1048            let t: AskThread =
1049                serde_json::from_str(&std::fs::read_to_string(e.path()).ok()?).ok()?;
1050            Some(t.conversation)
1051        })
1052        .filter(|c| feature.is_none() || c.feature.as_deref() == feature)
1053        .collect();
1054    list.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| b.id.cmp(&a.id)));
1055    let message = format!("{} conversations", list.len());
1056    CommandResult::new("ask", CommandStatus::Ok, &message).with_data(CommandData::AskConversations(
1057        Box::new(AskConversations {
1058            conversations: list,
1059        }),
1060    ))
1061}
1062
1063/// One conversation with its turns.
1064#[must_use]
1065pub fn thread(path: &Path, state: Option<&Path>, id: &str) -> CommandResult {
1066    let state = match state_or(state) {
1067        Ok(s) => s,
1068        Err(e) => return *e,
1069    };
1070    let (_, dir) = match plane_dir(path, &state) {
1071        Ok(d) => d,
1072        Err(e) => return *e,
1073    };
1074    match load(&dir, id) {
1075        Some(mut t) => {
1076            t.results = results(path, &state, id);
1077            CommandResult::new("ask", CommandStatus::Ok, &t.conversation.title.clone())
1078                .with_data(CommandData::AskThread(Box::new(t)))
1079        }
1080        None => refuse(
1081            CommandStatus::NotFound,
1082            "NOT-FOUND",
1083            &format!("no conversation {id}"),
1084        ),
1085    }
1086}