@hej / sigit / commits / 501af7e

Delay model loading in ACP mode to keep stdout clean

Add redirect_stdout_to_stderr for ACP mode so stdout contains only ACP JSON messages. Model loading is now delayed until after initialize/auth, preventing model libraries from emitting diagnostics to stdout.

paydii committed Apr 29, 2026 at 23:18 UTC 501af7e62f1e491baa59b346a764af9367f74a3d
1 file changed +40 -66
src/main.rs
+40 -66
@@ -1676,6 +1676,23 @@ fn init_logging(is_tty: bool) {
1676 .try_init();
1677 }
1678
1679 +#[cfg(unix)]
1680 +fn redirect_stdout_to_stderr() -> anyhow::Result<()> {
1681 + let stderr_dup = unsafe { libc::dup(libc::STDERR_FILENO) };
1682 + anyhow::ensure!(
1683 + stderr_dup >= 0,
1684 + "dup(stderr) failed: {}",
1685 + std::io::Error::last_os_error()
1686 + );
1687 +
1688 + unsafe {
1689 + libc::dup2(stderr_dup, libc::STDOUT_FILENO);
1690 + libc::close(stderr_dup);
1691 + }
1692 +
1693 + Ok(())
1694 +}
1695 +
1696 // ── Interactive TUI mode ──────────────────────────────────────────────────────
1697
1698 /// boot the TUI and load the model on a background thread.
@@ -1767,47 +1784,26 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1784 async fn run_acp_server() -> anyhow::Result<()> {
1785 log::info!("ACP mode — starting agent server");
1786
1770 - let engine = Arc::new(ChatEngine::new());
1771 -
1787 let startup_selection = setup::startup_model_selection();
1788 let config = startup_selection
1789 .as_ref()
1790 .and_then(|selection| {
1776 - models::build_model_picker_items()
1777 - .into_iter()
1778 - .find(|item| {
1779 - selection
1780 - .selected_model
1781 - .as_ref()
1782 - .map(|selected| {
1783 - item.config.model_id == selected.model_id
1784 - && item
1785 - .config
1786 - .files
1787 - .iter()
1788 - .any(|file| file == &selected.gguf_file)
1789 - })
1790 - .unwrap_or(false)
1791 - })
1792 - .map(|item| item.config)
1791 + selection.selected_model.as_ref().and_then(|selected| {
1792 + models::build_model_picker_items()
1793 + .into_iter()
1794 + .find(|item| {
1795 + item.config.model_id == selected.model_id
1796 + && item
1797 + .config
1798 + .files
1799 + .iter()
1800 + .any(|file| file == &selected.gguf_file)
1801 + })
1802 + .map(|item| item.config)
1803 + })
1804 })
1805 .unwrap_or_else(GgufModelConfig::qwen25_3b);
1806
1796 - let acp_tool_calling = models::build_model_picker_items()
1797 - .iter()
1798 - .find(|item| item.config.model_id == config.model_id)
1799 - .map(|item| (item.tool_calling, item.max_tokens))
1800 - .unwrap_or((true, 4096));
1801 -
1802 - let max_tokens = acp_tool_calling.1;
1803 - let tool_calling = acp_tool_calling.0;
1804 -
1805 - let sampling = SamplingConfig {
1806 - max_tokens: Some(max_tokens),
1807 - ..SamplingConfig::default()
1808 - };
1809 -
1810 - // do we need to download, or is it cached?
1807 let needs_download = models::build_model_picker_items()
1808 .iter()
1809 .find(|item| item.config.model_id == config.model_id)
@@ -1815,7 +1811,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
1811 .unwrap_or(true);
1812
1813 log::info!(
1818 - "ACP startup model: {} ({})",
1814 + "ACP startup model selected: {} ({})",
1815 config.display_name,
1816 if needs_download {
1817 "needs download"
@@ -1824,44 +1820,19 @@ async fn run_acp_server() -> anyhow::Result<()> {
1820 }
1821 );
1822
1827 - let startup_config = config.clone();
1823 + let engine = Arc::new(ChatEngine::new());
1824
1829 - // loader thread flips model_ready when done; agent polls it
1830 - let model_ready = Arc::new(AtomicBool::new(false));
1825 + // Delay model loading until after initialize/auth so ACP stdout stays clean
1826 + // even if model libraries emit startup diagnostics.
1827 + let model_ready = Arc::new(AtomicBool::new(true));
1828 let model_load_error: Arc<std::sync::Mutex<Option<String>>> =
1829 Arc::new(std::sync::Mutex::new(None));
1830
1834 - // real thread + own runtime: block_in_place panics inside spawn_local
1835 - let loader_engine = Arc::clone(&engine);
1836 - let loader_config = config.clone();
1837 - let loader_ready = Arc::clone(&model_ready);
1838 - let loader_error = Arc::clone(&model_load_error);
1839 - let acp_system_prompt = system_prompt_for_model(tool_calling).to_string();
1840 -
1841 - std::thread::spawn(move || {
1842 - let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1843 - match rt.block_on(loader_engine.load_gguf_model(
1844 - loader_config,
1845 - Some(acp_system_prompt),
1846 - Some(sampling),
1847 - )) {
1848 - Ok(_) => {
1849 - log::info!("startup model loaded and ready");
1850 - loader_ready.store(true, Ordering::Release);
1851 - }
1852 - Err(error) => {
1853 - log::error!("startup model load failed: {error}");
1854 - *loader_error.lock().unwrap() = Some(error.to_string());
1855 - loader_ready.store(true, Ordering::Release);
1856 - }
1857 - }
1858 - });
1859 -
1831 let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
1832 let agent = SiGitAgent::new(
1833 engine,
1834 notification_tx,
1864 - startup_config,
1835 + config,
1836 model_ready,
1837 model_load_error,
1838 needs_download,
@@ -1923,7 +1894,10 @@ async fn main() -> anyhow::Result<()> {
1894 anyhow::bail!("interactive mode requires Unix (macOS / Linux)");
1895 }
1896 } else {
1926 - // ACP mode: no redirect needed, logs go to stderr.
1897 + // ACP mode: stdout must contain only ACP JSON messages.
1898 + #[cfg(unix)]
1899 + redirect_stdout_to_stderr()?;
1900 +
1901 init_logging(false);
1902 setup::setup_shared_model_cache();
1903 log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));