Skip to main content

kotonoha_core/
backend.rs

1use std::pin::Pin;
2use std::process::Stdio;
3
4use anyhow::Context as _;
5use async_stream::try_stream;
6use futures::Stream;
7use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
8use tokio::process::Command;
9
10use crate::config::CliBackendConfig;
11use crate::session::Turn;
12
13/// A streaming text source — yields chunks of the teacher's reply.
14pub type ReplyStream = Pin<Box<dyn Stream<Item = anyhow::Result<String>> + Send>>;
15
16/// A single "talk to the teacher" request, in a backend-neutral shape.
17/// CLI backends will flatten this to a single stdin prompt; HTTP API
18/// backends will translate `turns` into the provider's message array.
19#[derive(Debug, Clone)]
20pub struct CompletionRequest {
21    pub system_prompt: String,
22    /// Conversation so far, ending with the latest `Student` turn that
23    /// the backend should respond to.
24    pub turns: Vec<Turn>,
25}
26
27#[async_trait::async_trait]
28pub trait Backend: Send + Sync {
29    async fn complete(&self, req: CompletionRequest) -> anyhow::Result<ReplyStream>;
30}
31
32/// Generic CLI backend — spawns `cmd args...`, writes a flattened prompt
33/// to its stdin, and streams stdout chunks back. Used unchanged for
34/// claude / gemini / codex; the difference is just `cmd` + `args`.
35#[derive(Debug, Clone)]
36pub struct CliBackend {
37    pub cmd: String,
38    pub args: Vec<String>,
39}
40
41impl From<&CliBackendConfig> for CliBackend {
42    fn from(cfg: &CliBackendConfig) -> Self {
43        Self {
44            cmd: cfg.cmd.clone(),
45            args: cfg.args.clone(),
46        }
47    }
48}
49
50#[async_trait::async_trait]
51impl Backend for CliBackend {
52    async fn complete(&self, req: CompletionRequest) -> anyhow::Result<ReplyStream> {
53        let prompt = render_cli_prompt(&req.system_prompt, &req.turns);
54        let (cmd, args) = resolve_cmd(&self.cmd, &self.args);
55
56        let mut child = Command::new(&cmd)
57            .args(&args)
58            .stdin(Stdio::piped())
59            .stdout(Stdio::piped())
60            .stderr(Stdio::piped())
61            .kill_on_drop(true)
62            .spawn()
63            .with_context(|| format!("spawn {cmd}"))?;
64
65        let mut stdin = child.stdin.take().context("take stdin")?;
66        let stdout = child.stdout.take().context("take stdout")?;
67        let stderr = child.stderr.take().context("take stderr")?;
68
69        // Write the prompt and close stdin so the CLI sees EOF and
70        // starts responding.
71        let writer = tokio::spawn(async move {
72            let _ = stdin.write_all(prompt.as_bytes()).await;
73            let _ = stdin.shutdown().await;
74        });
75
76        let cmd_for_log = cmd.clone();
77        tokio::spawn(async move {
78            let mut buf = String::new();
79            let mut reader = BufReader::new(stderr);
80            if reader.read_to_string(&mut buf).await.is_ok() && !buf.trim().is_empty() {
81                tracing::debug!(target: "kotonoha::backend", cmd = %cmd_for_log, "stderr: {buf}");
82            }
83        });
84
85        let stream = try_stream! {
86            let mut reader = BufReader::new(stdout);
87            let mut buf = [0u8; 4096];
88            loop {
89                let n = reader.read(&mut buf).await?;
90                if n == 0 { break; }
91                let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
92                yield chunk;
93            }
94            let status = child.wait().await?;
95            if !status.success() {
96                Err(anyhow::anyhow!("{cmd} exited with status {status}"))?;
97            }
98            let _ = writer.await;
99        };
100
101        Ok(Box::pin(stream))
102    }
103}
104
105/// Flatten a `CompletionRequest` to the `system / conversation / task`
106/// format the CLI backends were already trained on (this is what
107/// `Session::render_prompt` used to do). Public so other crates that
108/// want CLI-style prompts (tests, debug tools) can reuse it.
109pub fn render_cli_prompt(system_prompt: &str, turns: &[Turn]) -> String {
110    let mut buf = String::with_capacity(1024 + turns.len() * 64);
111    buf.push_str("[SYSTEM]\n");
112    buf.push_str(system_prompt.trim());
113    buf.push_str("\n\n[CONVERSATION]\n");
114    if turns.is_empty() {
115        buf.push_str("(this is the first turn — greet the student warmly in English and ask them a simple opening question.)\n");
116    } else {
117        for turn in turns {
118            match turn {
119                Turn::Student(t) => {
120                    buf.push_str("Student: ");
121                    buf.push_str(t.trim());
122                    buf.push('\n');
123                }
124                Turn::Teacher(t) => {
125                    buf.push_str("Teacher: ");
126                    buf.push_str(t.trim());
127                    buf.push('\n');
128                }
129            }
130        }
131    }
132    buf.push_str("\n[TASK]\nReply as Kotonoha-sensei (Teacher). Output only the teacher's next utterance — no labels, no quotes, no stage directions.\n");
133    buf
134}
135
136/// Resolve `cmd` to something `tokio::process::Command` can actually run.
137///
138/// On Windows, `Command::spawn` calls `CreateProcess`, which can NOT
139/// directly execute PowerShell scripts (`.ps1`) or batch files
140/// (`.cmd` / `.bat`).  We resolve via `which::which` (which honors
141/// PATHEXT) and wrap each form appropriately:
142///   - `.ps1` → `powershell -NoProfile -ExecutionPolicy Bypass -File <path>`
143///   - `.cmd` / `.bat` → `cmd.exe /C <path>`
144///   - real `.exe` → use the resolved full path directly
145fn resolve_cmd(cmd: &str, args: &[String]) -> (String, Vec<String>) {
146    match which::which(cmd) {
147        Ok(resolved) => {
148            tracing::info!(
149                target: "kotonoha::backend",
150                "resolved cmd `{}` -> {}",
151                cmd,
152                resolved.display()
153            );
154            let ext = resolved
155                .extension()
156                .and_then(|s| s.to_str())
157                .map(|s| s.to_ascii_lowercase());
158            let resolved_str = resolved.to_string_lossy().into_owned();
159            match ext.as_deref() {
160                Some("ps1") => {
161                    let mut full = vec![
162                        "-NoProfile".into(),
163                        "-ExecutionPolicy".into(),
164                        "Bypass".into(),
165                        "-File".into(),
166                        resolved_str,
167                    ];
168                    full.extend(args.iter().cloned());
169                    tracing::info!(target: "kotonoha::backend", "→ powershell -File wrap");
170                    ("powershell".into(), full)
171                }
172                Some("cmd") | Some("bat") => {
173                    let mut full = vec!["/C".into(), resolved_str];
174                    full.extend(args.iter().cloned());
175                    tracing::info!(target: "kotonoha::backend", "→ cmd.exe /C wrap");
176                    ("cmd.exe".into(), full)
177                }
178                _ => (resolved_str, args.to_vec()),
179            }
180        }
181        Err(e) => {
182            tracing::warn!(
183                target: "kotonoha::backend",
184                "`which({cmd})` failed: {e}. Looked in PATH = {:?}",
185                std::env::var("PATH").unwrap_or_default()
186            );
187            (cmd.to_string(), args.to_vec())
188        }
189    }
190}