Merge remote-tracking branch 'origin/main' into tui_gem

* origin/main: (21 commits)
  feat(ui): show token counts directly for "free" providers (#7383)
  Update creator note (#7384)
  Remove display_name from local model API and use model ID everywhere (#7382)
  fix(summon): stop MOIM from telling models to sleep while waiting for tasks (#7377)
  Completely pointless ascii art (#7329)
  feat: add Neighborhood extension to the Extensions Library (#7328)
  feat: computer controller overhaul, adding peekaboo (#7342)
  Add blog post: Gastown Explained: How to Use Goosetown for Parallel Agentic Engineering (#7372)
  docs: type-to-search goose configure lists (#7371)
  docs: search conversation history (#7370)
  fix: stderr noise (#7346)
  chore(deps): bump hono from 4.11.9 to 4.12.0 in /ui/desktop (#7369)
  Include 3rd-party license copy for JavaScript/CSS minified files (#7352)
  docs for reasoning env var (#7367)
  docs: update skills detail page to reference Goose Summon extension (#7350)
  fix(apps): restore MCP app sampling support reverted by #6933 (#7366)
  feat: TUI client of goose-acp (#7362)
  docs: agent variable (#7365)
  docs: pass env vars to shell (#7361)
  docs: update sandbox topic (#7336)
  ...

# Conflicts:
#	Cargo.lock
This commit is contained in:
Tyler Longwell
2026-02-20 10:36:18 -05:00
87 changed files with 11520 additions and 1242 deletions
Generated
+190 -124
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,5 +1,6 @@
use anyhow::Result;
use clap::Parser;
use goose::builtin_extension::register_builtin_extensions;
use goose::config::paths::Paths;
use goose_acp::server_factory::{AcpServer, AcpServerFactoryConfig};
use std::net::SocketAddr;
@@ -29,6 +30,8 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer().with_target(true))
.init();
register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone());
let cli = Cli::parse();
let builtins = if cli.builtins.is_empty() {
+202
View File
@@ -854,6 +854,13 @@ enum Command {
#[command(subcommand)]
command: TermCommand,
},
/// Manage local inference models
#[command(about = "Manage local inference models", visible_alias = "lm")]
LocalModels {
#[command(subcommand)]
command: LocalModelsCommand,
},
/// Generate completions for various shells
#[command(about = "Generate the autocompletion script for the specified shell")]
Completion {
@@ -875,6 +882,38 @@ enum Command {
},
}
#[derive(Subcommand)]
enum LocalModelsCommand {
/// Search HuggingFace for GGUF models
#[command(about = "Search HuggingFace for GGUF models")]
Search {
/// Search query
query: String,
/// Maximum number of results
#[arg(short, long, default_value = "10")]
limit: usize,
},
/// Download a model from HuggingFace
#[command(about = "Download a GGUF model (e.g. bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M)")]
Download {
/// Model spec in user/repo:quantization format
spec: String,
},
/// List downloaded local models
#[command(about = "List downloaded local models")]
List,
/// Delete a downloaded model
#[command(about = "Delete a downloaded local model")]
Delete {
/// Model ID to delete
id: String,
},
}
#[derive(Subcommand)]
enum TermCommand {
/// Print shell initialization script
@@ -964,6 +1003,7 @@ fn get_command_name(command: &Option<Command>) -> &'static str {
Some(Command::Recipe { .. }) => "recipe",
Some(Command::Web { .. }) => "web",
Some(Command::Term { .. }) => "term",
Some(Command::LocalModels { .. }) => "local-models",
Some(Command::Completion { .. }) => "completion",
Some(Command::ValidateExtensions { .. }) => "validate-extensions",
None => "default_session",
@@ -1400,6 +1440,167 @@ async fn handle_term_subcommand(command: TermCommand) -> Result<()> {
}
}
async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> {
use goose::providers::local_inference::hf_models;
use goose::providers::local_inference::local_model_registry::{
get_registry, model_id_from_repo, LocalModelEntry,
};
match command {
LocalModelsCommand::Search { query, limit } => {
println!("Searching HuggingFace for '{}'...", query);
let results = hf_models::search_gguf_models(&query, limit).await?;
if results.is_empty() {
println!("No GGUF models found.");
return Ok(());
}
for model in &results {
println!(
"\n{} (by {}) — {} downloads",
model.model_name, model.author, model.downloads
);
for file in &model.gguf_files {
let size = if file.size_bytes > 0 {
format!(
"{:.1}GB",
file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
)
} else {
"unknown".to_string()
};
println!(" {} — {}", file.quantization, size);
}
println!(
" Download: goose local-models download {}:<quantization>",
model.repo_id
);
}
}
LocalModelsCommand::Download { spec } => {
println!("Resolving {}...", spec);
let (repo_id, file) = hf_models::resolve_model_spec(&spec).await?;
let model_id = model_id_from_repo(&repo_id, &file.quantization);
let local_path =
goose::config::paths::Paths::in_data_dir("models").join(&file.filename);
println!(
"Downloading {} ({})...",
model_id,
if file.size_bytes > 0 {
format!(
"{:.1}GB",
file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
)
} else {
"unknown size".to_string()
}
);
// Register
let entry = LocalModelEntry {
id: model_id.clone(),
repo_id: repo_id.clone(),
filename: file.filename.clone(),
quantization: file.quantization.clone(),
local_path: local_path.clone(),
source_url: file.download_url.clone(),
settings: Default::default(),
size_bytes: file.size_bytes,
};
{
let mut registry = get_registry()
.lock()
.map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?;
registry.add_model(entry)?;
}
// Download
let manager = goose::download_manager::get_download_manager();
manager
.download_model(
format!("{}-model", model_id),
file.download_url,
local_path,
None,
)
.await?;
// Poll progress
loop {
if let Some(progress) = manager.get_progress(&format!("{}-model", model_id)) {
match progress.status {
goose::download_manager::DownloadStatus::Downloading => {
print!(
"\r {:.1}% ({:.0}MB / {:.0}MB)",
progress.progress_percent,
progress.bytes_downloaded as f64 / (1024.0 * 1024.0),
progress.total_bytes as f64 / (1024.0 * 1024.0),
);
use std::io::Write;
std::io::stdout().flush().ok();
}
goose::download_manager::DownloadStatus::Completed => {
println!("\nDownloaded: {}", model_id);
break;
}
goose::download_manager::DownloadStatus::Failed => {
let err = progress.error.unwrap_or_default();
anyhow::bail!("Download failed: {}", err);
}
goose::download_manager::DownloadStatus::Cancelled => {
println!("\nDownload cancelled.");
break;
}
}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
LocalModelsCommand::List => {
let registry = get_registry()
.lock()
.map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?;
let models = registry.list_models();
if models.is_empty() {
println!("No local models downloaded.");
return Ok(());
}
println!("{:<50} {:<10} Downloaded", "ID", "Quant");
println!("{}", "-".repeat(70));
for m in models {
println!(
"{:<50} {:<10} {}",
m.id,
m.quantization,
if m.is_downloaded() { "✓" } else { "✗" }
);
}
}
LocalModelsCommand::Delete { id } => {
let mut registry = get_registry()
.lock()
.map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?;
if let Some(entry) = registry.get_model(&id) {
if entry.local_path.exists() {
std::fs::remove_file(&entry.local_path)?;
}
registry.remove_model(&id)?;
println!("Deleted model: {}", id);
} else {
println!("Model not found: {}", id);
}
}
}
Ok(())
}
async fn handle_default_session() -> Result<()> {
if !Config::global().exists() {
return handle_configure().await;
@@ -1530,6 +1731,7 @@ pub async fn cli() -> anyhow::Result<()> {
no_auth,
}) => crate::commands::web::handle_web(port, host, open, auth_token, no_auth).await,
Some(Command::Term { command }) => handle_term_subcommand(command).await,
Some(Command::LocalModels { command }) => handle_local_models_command(command).await,
Some(Command::ValidateExtensions { file }) => {
use goose::agents::validate_extensions::validate_bundled_extensions;
match validate_bundled_extensions(&file) {
-1
View File
@@ -479,7 +479,6 @@ impl CliSession {
let history_manager = HistoryManager::new();
history_manager.load(&mut editor);
output::display_greeting();
loop {
self.display_context_usage().await?;
+23 -20
View File
@@ -261,7 +261,7 @@ pub fn render_message(message: &Message, debug: bool) {
}
}
_ => {
println!("WARNING: Message content type could not be rendered");
eprintln!("WARNING: Message content type could not be rendered");
}
}
}
@@ -349,7 +349,7 @@ pub fn render_message_streaming(
}
_ => {
flush_markdown_buffer(buffer, theme);
println!("WARNING: Message content type could not be rendered");
eprintln!("WARNING: Message content type could not be rendered");
}
}
}
@@ -1252,6 +1252,8 @@ pub fn display_session_info(
session_id: &Option<String>,
provider_instance: Option<&Arc<dyn goose::providers::base::Provider>>,
) {
set_terminal_title();
let status = if resume {
"resuming"
} else if session_id.is_none() {
@@ -1271,8 +1273,16 @@ pub fn display_session_info(
model.to_string()
};
let cwd_display = std::env::current_dir()
.ok()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "unknown".to_string());
// ASCII art goose with session info on the right
println!();
println!(
"\n {} {} {} {} {}",
" {} {} {} {} {} {}",
style(" __( O)>").white(),
style("●").green(),
style(status).dim(),
style("·").dim(),
@@ -1280,28 +1290,30 @@ pub fn display_session_info(
style(&model_display).cyan(),
);
let cwd_display = std::env::current_dir()
.ok()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "unknown".to_string());
if let Some(id) = session_id {
println!(
" {} {} {}",
" {} {} {} {}",
style(r" \____)").white(),
style(" ").dim(),
style(id).dim(),
style(format!("· {}", cwd_display)).dim(),
);
} else {
println!(
" {} {}",
" {} {} {}",
style(r" \____)").white(),
style(" ").dim(),
style(format!(" {}", cwd_display)).dim(),
);
}
println!(
" {} {}",
style(" L L").white(),
style(" goose is ready").white()
);
}
pub fn set_terminal_title() {
fn set_terminal_title() {
if !std::io::stdout().is_terminal() {
return;
}
@@ -1316,15 +1328,6 @@ pub fn set_terminal_title() {
let _ = std::io::stdout().flush();
}
pub fn display_greeting() {
set_terminal_title();
println!(
"\n{} {}\n",
style("🪿 goose").bold(),
style("ready — type a message to get started").dim()
);
}
pub fn display_context_usage(total_tokens: usize, context_limit: usize) {
use console::style;
+11
View File
@@ -7,6 +7,16 @@ license.workspace = true
repository.workspace = true
description.workspace = true
# Include 3rd-party licenses for JavaScript/CSS minified files
include = [
"licenses/chart-js.license",
"licenses/d3-js.license",
"licenses/d3-sankey.license",
"licenses/leaflet.license",
"licenses/leaflet-markercluster.license",
"licenses/mermaid.license"
]
[lints]
workspace = true
@@ -56,6 +66,7 @@ libc = "0.2"
# ~1000 downloads). Pinned to exact version to prevent supply chain attacks.
mpatch = "=0.2.0"
tokio-util = { workspace = true }
shell-words = "1.1.1"
[dev-dependencies]
serial_test = { workspace = true }
@@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright (c) 2014-2024 Chart.js Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+13
View File
@@ -0,0 +1,13 @@
Copyright 2010-2023 Mike Bostock
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
@@ -0,0 +1,27 @@
Copyright 2015, Mike Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the author nor the names of contributors may be used to
endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,20 @@
Copyright 2012 David Leaver
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+26
View File
@@ -0,0 +1,26 @@
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
+327 -42
View File
@@ -1,4 +1,6 @@
use crate::subprocess::SubprocessExt;
#[cfg(target_os = "macos")]
use base64::Engine;
use etcetera::{choose_app_strategy, AppStrategy};
use indoc::{formatdoc, indoc};
use reqwest::{Client, Url};
@@ -17,6 +19,11 @@ use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, path::PathBuf, sync::Arc, sync::Mutex};
use tokio::process::Command;
#[cfg(target_os = "macos")]
use rmcp::model::Role;
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
@@ -91,16 +98,38 @@ pub struct AutomationScriptParams {
pub save_output: bool,
}
/// Parameters for the computer_control tool
/// Parameters for the computer_control tool (Windows, Linux)
#[cfg(not(target_os = "macos"))]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ComputerControlParams {
/// The automation script content (PowerShell for Windows, AppleScript for macOS)
/// The automation script content (PowerShell for Windows, shell for Linux)
pub script: String,
/// Whether to save the script output to a file
#[serde(default)]
pub save_output: bool,
}
/// Parameters for the computer_control tool (macOS — Peekaboo CLI passthrough)
#[cfg(target_os = "macos")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ComputerControlParams {
/// The peekaboo subcommand and arguments as a single string.
/// Examples:
/// "see --app Safari --annotate"
/// "click --on B1"
/// "type --text \"hello\" --return"
/// "hotkey --keys cmd,c"
/// "app launch Safari --open https://example.com"
/// "window list --app Safari --json"
/// "press tab --count 3"
/// "clipboard --action get"
pub command: String,
/// Whether to capture and return a screenshot as part of the result.
/// Useful after click/type actions to see the updated UI state.
#[serde(default)]
pub capture_screenshot: bool,
}
/// Parameters for the cache tool
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CacheParams {
@@ -285,6 +314,8 @@ pub struct ComputerControllerServer {
http_client: Client,
instructions: String,
system_automation: Arc<Box<dyn SystemAutomation + Send + Sync>>,
#[cfg(target_os = "macos")]
peekaboo_installed: Arc<AtomicBool>,
}
impl Default for ComputerControllerServer {
@@ -334,25 +365,96 @@ impl ComputerControllerServer {
"macos" => indoc! {r#"
Here are some extra tools:
automation_script
- Create and run Shell and Ruby scripts
- Shell (bash) is recommended for most tasks
- Create and run Shell, Ruby, or AppleScript scripts
- Scripts can save their output to files
- macOS-specific features:
- AppleScript for system and UI control
- Integration with macOS apps and services
- Use the screenshot tool if needed to help with tasks
computer_control
- System automation using AppleScript
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
computer_control — Peekaboo CLI for macOS UI automation (auto-installed via Homebrew).
Peekaboo captures/inspects screens, targets UI elements, drives input, and manages
apps/windows/menus. Pass a peekaboo subcommand string as the `command` parameter.
Set `capture_screenshot: true` to capture the screen after actions (click, type, etc.).
Commands support `--json`/`-j` for structured output. Run `peekaboo <cmd> --help` for
full flags if needed.
When you need to interact with websites or web applications, consider using the computer_control tool with AppleScript, which can automate Safari or other browsers to:
- Open specific URLs
- Fill in forms
- Click buttons
- Extract content
- Handle web-based workflows
This is often more reliable than web scraping for modern web applications.
Quickstart (most reliable flow):
1. command: "see --app Safari --annotate" — get annotated screenshot with element IDs
2. command: "click --on B3 --app Safari" — click element B3
3. command: "type \"user@example.com\" --app Safari" — type text
4. command: "press tab --count 1 --app Safari" — press tab
5. command: "type \"password\" --app Safari --return" — type and press enter
Vision:
- see — annotated UI map with element IDs and optional AI analysis
`see --app Safari --annotate`, `see --mode screen --screen-index 0`
`see --app Notes --analyze "describe what's on screen"`
- image — capture screenshots without annotation
`image --mode frontmost`, `image --mode screen --screen-index 1 --retina`
`image --app Safari --window-title "Dashboard" --analyze "Summarize KPIs"`
- capture — live motion-aware capture
`capture live --mode region --region 100,100,800,600 --duration 30`
Interaction:
- click — by element ID, query, or coordinates with smart waits
`click --on B1`, `click --coords 100,200`, `click --on B1 --double`, `click --on B1 --right`
- type — text input with optional control keys
`type "hello" --return`, `type "text" --clear --app Notes`, `type "slow" --wpm 80`
- press — special key sequences with repeats
`press tab --count 3`, `press escape`, `press return`, `press space`
- hotkey — modifier key combos (comma-separated)
`hotkey --keys cmd,c`, `hotkey --keys cmd,shift,t`, `hotkey --keys cmd,a`
- paste — set clipboard then paste (more reliable than type for long text)
`paste --text "long multi-line content"`
- scroll — directional scrolling with optional targeting
`scroll --direction down --amount 5 --smooth`, `scroll --direction up --amount 3`
- drag — drag between elements or coordinates
`drag --from B1 --to T2`, `drag --from-coords 100,100 --to-coords 500,300`
- swipe — gesture-style drags
`swipe --from-coords 100,500 --to-coords 100,200 --duration 800`
- move — cursor positioning
`move 500,300 --smooth`
Apps & Windows:
- app — launch, quit, switch, list applications
`app launch Safari --open https://example.com`, `app quit Safari`
`app switch Safari`, `app list`, `app hide Safari`, `app unhide Safari`
- window — manage window position, size, focus, list
`window list --app Safari --json`, `window focus --app Safari`
`window set-bounds --app Safari --x 50 --y 50 --width 1200 --height 800`
`window close --app Safari`, `window minimize --app Safari`
- list — enumerate apps, windows, screens
`list apps --json`, `list windows --json`, `list screens --json`
- space — macOS Spaces (virtual desktops)
`space list`, `space switch --index 2`
Menus & System:
- menu — click application menu items
`menu click --app Safari --item "New Window"`
`menu click --app TextEdit --path "Format > Font > Show Fonts"`
- menubar — status bar / menu extras
`menubar list --json`, `menubar click --title "WiFi"`
- dock — Dock items
`dock launch Safari`, `dock list --json`
- dialog — system dialogs and alerts
`dialog click --button "OK"`, `dialog list`
- clipboard — read/write clipboard
`clipboard --action get`, `clipboard --action set --text "content"`
- open — open URLs or files with app targeting
`open https://example.com --app Safari`
- permissions — check Screen Recording / Accessibility status
`permissions status`
Common targeting parameters (work across most commands):
- App/window: `--app Name`, `--pid 1234`, `--window-title "title"`, `--window-id 5678`, `--window-index 0`
- Elements: `--on B1` (element ID from see), `--coords 100,200`
- Snapshot reuse: `--snapshot <id>` (reuse a previous see result without re-capturing)
- Focus: `--no-auto-focus`, `--space-switch`, `--bring-to-current-space`
Tips:
- Always `see --annotate` first to identify element IDs before clicking
- Use `--json` for structured output on list/query commands
- Use `paste` over `type` for long or multi-line text
- Use `--screen-index` for multi-monitor setups
- If something fails, check `permissions status` for missing permissions
- Use `capture_screenshot: true` on click/type/press actions to verify the result
"#},
_ => indoc! {r#"
Here are some extra tools:
@@ -421,6 +523,8 @@ impl ComputerControllerServer {
http_client: Client::builder().user_agent("goose/1.0").build().unwrap(),
instructions,
system_automation,
#[cfg(target_os = "macos")]
peekaboo_installed: Arc::new(AtomicBool::new(crate::peekaboo::is_peekaboo_installed())),
}
}
@@ -592,20 +696,36 @@ impl ComputerControllerServer {
}
/// Create and run small scripts for automation tasks
#[cfg(not(target_os = "windows"))]
#[cfg(target_os = "macos")]
#[tool(
name = "automation_script",
description = "
Create and run small scripts for automation tasks.
Supports Shell and Ruby (on macOS).
Create and run Shell, Ruby, or AppleScript (via osascript) scripts.
Use shell (bash) for most tasks. AppleScript for app scripting and system settings.
Examples:
- sort file.txt | uniq
- awk -F ',' '{ print $2}' file.csv
- osascript -e 'tell app \"Finder\" to get name of every window'
"
)]
pub async fn automation_script(
&self,
params: Parameters<AutomationScriptParams>,
) -> Result<CallToolResult, ErrorData> {
self.automation_script_impl(params).await
}
The script is saved to a temporary file and executed.
/// Create and run small scripts for automation tasks
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
#[tool(
name = "automation_script",
description = "
Create and run Shell scripts for automation tasks.
Consider using shell script (bash) for most simple tasks first.
Ruby is useful for text processing or when you need more sophisticated scripting capabilities.
Some examples of shell:
- create a sorted list of unique lines: sort file.txt | uniq
- extract 2nd column in csv: awk -F ',' '{ print $2}'
- pattern matching: grep pattern file.txt
Examples:
- sort file.txt | uniq
- awk -F ',' '{ print $2}' file.csv
- grep pattern file.txt
"
)]
pub async fn automation_script(
@@ -788,33 +908,33 @@ impl ComputerControllerServer {
self.computer_control_impl(params).await
}
/// Control the computer using system automation
/// Control the computer using Peekaboo CLI for macOS GUI automation.
/// Auto-installs via Homebrew on first use.
#[cfg(target_os = "macos")]
#[tool(
name = "computer_control",
description = "
Control the computer using AppleScript (macOS only). Automate applications and system features.
macOS UI automation via Peekaboo CLI. Pass a subcommand string as `command`.
Key capabilities:
- Control Applications: Launch, quit, manage apps (Mail, Safari, iTunes, etc)
- Interact with app-specific feature: (e.g, edit documents, process photos)
- Perform tasks in third-party apps that support AppleScript
- UI Automation: Simulate user interactions like, clicking buttons, select menus, type text, filling out forms
- System Control: Manage settings (volume, brightness, wifi), shutdown/restart, monitor events
- Web & Email: Open URLs, web automation, send/organize emails, handle attachments
- Media: Manage music libraries, photo collections, playlists
- File Operations: Organize files/folders
- Integration: Calendar, reminders, messages
- Data: Interact with spreadsheets and documents
Core workflow: see → click → type
1. see --app Safari --annotate (get annotated screenshot with element IDs)
2. click --on B3 (click element by ID)
3. type \"hello\" --return (type text, press enter)
Can be combined with screenshot tool for visual task assistance.
Key commands: see, image, click, type, press, hotkey, paste, scroll, drag,
swipe, move, app, window, list, menu, menubar, dock, dialog, clipboard,
space, open, permissions.
Targeting: --app Name, --window-title, --window-id, --on ID, --coords x,y
Set capture_screenshot=true to verify UI state after actions.
See extension instructions for full command reference and examples.
"
)]
pub async fn computer_control(
&self,
params: Parameters<ComputerControlParams>,
) -> Result<CallToolResult, ErrorData> {
self.computer_control_impl(params).await
self.peekaboo_impl(params).await
}
/// Control the computer using system automation
@@ -856,6 +976,7 @@ impl ComputerControllerServer {
self.computer_control_impl(params).await
}
#[cfg(not(target_os = "macos"))]
async fn computer_control_impl(
&self,
params: Parameters<ComputerControlParams>,
@@ -892,6 +1013,170 @@ impl ComputerControllerServer {
Ok(CallToolResult::success(vec![Content::text(result)]))
}
#[cfg(target_os = "macos")]
fn ensure_peekaboo(&self) -> Result<(), ErrorData> {
if self.peekaboo_installed.load(Ordering::Relaxed) {
return Ok(());
}
if crate::peekaboo::is_peekaboo_installed() {
self.peekaboo_installed.store(true, Ordering::Relaxed);
return Ok(());
}
tracing::info!("Peekaboo not found, attempting auto-install via brew");
match crate::peekaboo::auto_install_peekaboo() {
Ok(()) => {
self.peekaboo_installed.store(true, Ordering::Relaxed);
tracing::info!("Peekaboo installed successfully");
Ok(())
}
Err(msg) => Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!(
"Peekaboo is not installed and auto-install failed: {}\n\
Install manually with: brew install steipete/tap/peekaboo\n\
Peekaboo requires macOS 15+ (Sequoia) with Screen Recording and Accessibility permissions.",
msg
),
None,
)),
}
}
#[cfg(target_os = "macos")]
fn run_peekaboo_cmd(&self, args: &[&str]) -> Result<String, ErrorData> {
let output = std::process::Command::new("peekaboo")
.args(args)
.output()
.map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to run peekaboo: {}", e),
None,
)
})?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !output.status.success() {
return Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!(
"peekaboo {} failed (exit {}):\n{}\n{}",
args.first().unwrap_or(&""),
output.status,
stderr.trim(),
stdout.trim()
),
None,
));
}
Ok(stdout)
}
#[cfg(target_os = "macos")]
async fn peekaboo_impl(
&self,
params: Parameters<ComputerControlParams>,
) -> Result<CallToolResult, ErrorData> {
self.ensure_peekaboo()?;
let params = params.0;
let args = shell_words::split(&params.command).map_err(|e| {
ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!("Failed to parse command: {}", e),
None,
)
})?;
if args.is_empty() {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
"Command cannot be empty".to_string(),
None,
));
}
let is_see = args[0] == "see";
let is_image = args[0] == "image";
let screenshot_path = if is_see || is_image {
Some(self.get_cache_path(&args[0], "png"))
} else {
None
};
let mut full_args: Vec<String> = args.clone();
if let Some(ref path) = screenshot_path {
if !full_args.iter().any(|a| a == "--path") {
full_args.push("--path".to_string());
full_args.push(path.to_string_lossy().to_string());
}
}
if is_see && !full_args.iter().any(|a| a == "--json-output") {
full_args.push("--json-output".to_string());
}
let wants_json = matches!(
args[0].as_str(),
"list" | "window" | "menubar" | "permissions" | "clipboard"
);
if wants_json
&& !full_args.iter().any(|a| a == "--json" || a == "-j")
&& !full_args.iter().any(|a| a == "--json-output")
{
full_args.push("--json".to_string());
}
let arg_refs: Vec<&str> = full_args.iter().map(|s| s.as_str()).collect();
let stdout = self.run_peekaboo_cmd(&arg_refs)?;
let mut contents = Vec::new();
if let Some(ref path) = screenshot_path {
let annotated = path.to_string_lossy().replace(".png", "_annotated.png");
let image_path = if is_see && std::path::Path::new(&annotated).exists() {
PathBuf::from(&annotated)
} else {
path.clone()
};
if image_path.exists() {
if let Ok(bytes) = fs::read(&image_path) {
let data = base64::prelude::BASE64_STANDARD.encode(&bytes);
contents.push(Content::image(data, "image/png").with_priority(0.0));
}
}
}
if params.capture_screenshot && screenshot_path.is_none() {
let cap_path = self.get_cache_path("peekaboo_capture", "png");
let cap_path_str = cap_path.to_string_lossy().to_string();
if self
.run_peekaboo_cmd(&["image", "--mode", "frontmost", "--path", &cap_path_str])
.is_ok()
&& cap_path.exists()
{
if let Ok(bytes) = fs::read(&cap_path) {
let data = base64::prelude::BASE64_STANDARD.encode(&bytes);
contents.push(Content::image(data, "image/png").with_priority(0.0));
}
}
}
let text = if stdout.len() > 12000 {
let truncated: String = stdout.chars().take(12000).collect();
format!(
"{}\n\n[Output truncated. {} total chars.]",
truncated,
stdout.len()
)
} else {
stdout
};
contents.insert(0, Content::text(&text).with_audience(vec![Role::Assistant]));
Ok(CallToolResult::success(contents))
}
/// Process Excel (XLSX) files to read and manipulate spreadsheet data
#[tool(
name = "xlsx_tool",
@@ -14,6 +14,7 @@ pub use self::macos::MacOSAutomation;
#[cfg(any(target_os = "linux", target_os = "android"))]
pub use self::linux::LinuxAutomation;
#[allow(dead_code)]
pub trait SystemAutomation: Send + Sync {
fn execute_system_script(&self, script: &str) -> std::io::Result<String>;
fn get_shell_command(&self) -> (&'static str, &'static str); // (shell, arg)
+2
View File
@@ -14,6 +14,8 @@ pub mod computercontroller;
pub mod developer;
pub mod mcp_server_runner;
mod memory;
#[cfg(target_os = "macos")]
pub mod peekaboo;
pub mod subprocess;
pub mod tutorial;
+85
View File
@@ -0,0 +1,85 @@
//! Peekaboo helper functions for macOS GUI automation via the Peekaboo CLI.
//!
//! These are used by `ComputerControllerServer` on macOS to auto-install
//! and invoke peekaboo. This module does not expose its own MCP server —
//! peekaboo is accessed through the `computer_control` tool on macOS.
const BREW_FORMULA: &str = "steipete/tap/peekaboo";
pub fn is_peekaboo_installed() -> bool {
std::process::Command::new("which")
.arg("peekaboo")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn resolve_brew() -> Option<String> {
if let Ok(output) = std::process::Command::new("which").arg("brew").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(path);
}
}
}
for candidate in &["/opt/homebrew/bin/brew", "/usr/local/bin/brew"] {
if std::path::Path::new(candidate).exists() {
return Some(candidate.to_string());
}
}
None
}
pub fn auto_install_peekaboo() -> Result<(), String> {
let brew = resolve_brew().ok_or_else(|| {
"Homebrew is not installed. Install Homebrew first (https://brew.sh), then run: brew install steipete/tap/peekaboo".to_string()
})?;
tracing::info!("Running: {} install {}", brew, BREW_FORMULA);
let output = std::process::Command::new(&brew)
.args(["install", BREW_FORMULA])
.output()
.map_err(|e| format!("Failed to run brew: {}", e))?;
if output.status.success() {
if is_peekaboo_installed() {
return Ok(());
}
// brew succeeded but binary not on PATH — try adding brew bin
if let Ok(prefix_output) = std::process::Command::new(&brew)
.args(["--prefix"])
.output()
{
let prefix = String::from_utf8_lossy(&prefix_output.stdout)
.trim()
.to_string();
let bin_path = format!("{}/bin/peekaboo", prefix);
if std::path::Path::new(&bin_path).exists() {
if let Ok(current_path) = std::env::var("PATH") {
std::env::set_var("PATH", format!("{}/bin:{}", prefix, current_path));
if is_peekaboo_installed() {
return Ok(());
}
}
}
}
Err("brew install succeeded but peekaboo binary not found on PATH".to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(format!(
"brew install failed (exit {}):\n{}{}",
output.status,
stderr.trim(),
if stdout.trim().is_empty() {
String::new()
} else {
format!("\n{}", stdout.trim())
}
))
}
}
+19 -1
View File
@@ -4,7 +4,7 @@ use goose::agents::ExtensionConfig;
use goose::config::permission::PermissionLevel;
use goose::config::ExtensionEntry;
use goose::conversation::Conversation;
use goose::dictation::download_manager::{DownloadProgress, DownloadStatus};
use goose::download_manager::{DownloadProgress, DownloadStatus};
use goose::model::ModelConfig;
use goose::permission::permission_confirmation::{Permission, PrincipalType};
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType};
@@ -424,6 +424,15 @@ derive_utoipa!(Icon as IconSchema);
super::routes::dictation::get_download_progress,
super::routes::dictation::cancel_download,
super::routes::dictation::delete_model,
super::routes::local_inference::list_local_models,
super::routes::local_inference::search_hf_models,
super::routes::local_inference::get_repo_files,
super::routes::local_inference::download_hf_model,
super::routes::local_inference::get_local_model_download_progress,
super::routes::local_inference::cancel_local_model_download,
super::routes::local_inference::delete_local_model,
super::routes::local_inference::get_model_settings,
super::routes::local_inference::update_model_settings,
),
components(schemas(
super::routes::config_management::UpsertConfigQuery,
@@ -592,6 +601,15 @@ derive_utoipa!(Icon as IconSchema);
goose::dictation::providers::DictationProvider,
super::routes::dictation::DictationProviderStatus,
super::routes::dictation::WhisperModelResponse,
super::routes::local_inference::LocalModelResponse,
super::routes::local_inference::ModelDownloadStatus,
super::routes::local_inference::DownloadModelRequest,
goose::providers::local_inference::hf_models::HfModelInfo,
goose::providers::local_inference::hf_models::HfGgufFile,
goose::providers::local_inference::hf_models::HfQuantVariant,
super::routes::local_inference::RepoVariantsResponse,
goose::providers::local_inference::local_model_registry::ModelSettings,
goose::providers::local_inference::local_model_registry::SamplingConfig,
DownloadProgress,
DownloadStatus,
))
+6 -1
View File
@@ -7,11 +7,11 @@ use axum::{
Json, Router,
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use goose::dictation::download_manager::{get_download_manager, DownloadProgress};
use goose::dictation::providers::{
is_configured, transcribe_local, transcribe_with_provider, DictationProvider, PROVIDERS,
};
use goose::dictation::whisper;
use goose::download_manager::{get_download_manager, DownloadProgress};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
@@ -257,11 +257,16 @@ pub async fn download_model(Path(model_id): Path<String>) -> Result<StatusCode,
.ok_or_else(|| ErrorResponse::bad_request("Model not found"))?;
let manager = get_download_manager();
let model_id_for_config = model.id.to_string();
manager
.download_model(
model.id.to_string(),
model.url.to_string(),
model.local_path(),
Some(Box::new(move || {
let _ = goose::config::Config::global()
.set_param(whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY, model_id_for_config);
})),
)
.await
.map_err(convert_error)?;
@@ -0,0 +1,461 @@
use crate::routes::errors::ErrorResponse;
use crate::state::AppState;
use axum::{
extract::{Path, Query},
http::StatusCode,
routing::{delete, get, post},
Json, Router,
};
use goose::config::paths::Paths;
use goose::download_manager::{get_download_manager, DownloadProgress};
use goose::providers::local_inference::hf_models::{self, HfModelInfo, HfQuantVariant};
use goose::providers::local_inference::{
available_inference_memory_bytes,
hf_models::{resolve_model_spec, HfGgufFile},
local_model_registry::{
get_registry, is_featured_model, model_id_from_repo, LocalModelEntry,
ModelDownloadStatus as RegistryDownloadStatus, ModelSettings, FEATURED_MODELS,
},
recommend_local_model,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::debug;
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "state")]
pub enum ModelDownloadStatus {
NotDownloaded,
Downloading {
progress_percent: f32,
bytes_downloaded: u64,
total_bytes: u64,
speed_bps: Option<u64>,
},
Downloaded,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct LocalModelResponse {
pub id: String,
pub repo_id: String,
pub filename: String,
pub quantization: String,
pub size_bytes: u64,
pub status: ModelDownloadStatus,
pub recommended: bool,
pub settings: ModelSettings,
}
async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> {
let mut entries_to_add = Vec::new();
for spec in FEATURED_MODELS {
let (repo_id, quantization) = match hf_models::parse_model_spec(spec) {
Ok(parts) => parts,
Err(_) => continue,
};
let model_id = model_id_from_repo(&repo_id, &quantization);
{
let registry = get_registry()
.lock()
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
if registry.has_model(&model_id) {
continue;
}
}
let hf_file = match resolve_model_spec(spec).await {
Ok((_repo, file)) => file,
Err(_) => {
let filename = format!(
"{}-{}.gguf",
repo_id.split('/').next_back().unwrap_or("model"),
quantization
);
HfGgufFile {
filename: filename.clone(),
size_bytes: 0,
quantization: quantization.to_string(),
download_url: format!(
"https://huggingface.co/{}/resolve/main/{}",
repo_id, filename
),
}
}
};
let local_path = Paths::in_data_dir("models").join(&hf_file.filename);
entries_to_add.push(LocalModelEntry {
id: model_id,
repo_id,
filename: hf_file.filename,
quantization,
local_path,
source_url: hf_file.download_url,
settings: ModelSettings::default(),
size_bytes: hf_file.size_bytes,
});
}
if !entries_to_add.is_empty() {
let mut registry = get_registry()
.lock()
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
registry.sync_with_featured(entries_to_add);
}
Ok(())
}
#[utoipa::path(
get,
path = "/local-inference/models",
responses(
(status = 200, description = "List of available local LLM models", body = Vec<LocalModelResponse>)
)
)]
pub async fn list_local_models(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
) -> Result<Json<Vec<LocalModelResponse>>, ErrorResponse> {
ensure_featured_models_in_registry().await?;
let recommended_id = recommend_local_model(&state.inference_runtime);
let registry = get_registry()
.lock()
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
let mut models: Vec<LocalModelResponse> = Vec::new();
for entry in registry.list_models() {
let goose_status = entry.download_status();
let status = match goose_status {
RegistryDownloadStatus::NotDownloaded => ModelDownloadStatus::NotDownloaded,
RegistryDownloadStatus::Downloading {
progress_percent,
bytes_downloaded,
total_bytes,
speed_bps,
} => ModelDownloadStatus::Downloading {
progress_percent,
bytes_downloaded,
total_bytes,
speed_bps: Some(speed_bps),
},
RegistryDownloadStatus::Downloaded => ModelDownloadStatus::Downloaded,
};
let size_bytes = entry.file_size();
models.push(LocalModelResponse {
id: entry.id.clone(),
repo_id: entry.repo_id.clone(),
filename: entry.filename.clone(),
quantization: entry.quantization.clone(),
size_bytes,
status,
recommended: recommended_id == entry.id,
settings: entry.settings.clone(),
});
}
models.sort_by(|a, b| {
let a_downloaded = matches!(a.status, ModelDownloadStatus::Downloaded);
let b_downloaded = matches!(b.status, ModelDownloadStatus::Downloaded);
match (b_downloaded, a_downloaded) {
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
_ => a.id.cmp(&b.id),
}
});
Ok(Json(models))
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: String,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct RepoVariantsResponse {
pub variants: Vec<HfQuantVariant>,
pub recommended_index: Option<usize>,
}
#[utoipa::path(
get,
path = "/local-inference/search",
params(
("q" = String, Query, description = "Search query"),
("limit" = Option<usize>, Query, description = "Max results")
),
responses(
(status = 200, description = "Search results", body = Vec<HfModelInfo>),
(status = 500, description = "Search failed")
)
)]
pub async fn search_hf_models(
Query(params): Query<SearchQuery>,
) -> Result<Json<Vec<HfModelInfo>>, ErrorResponse> {
let limit = params.limit.unwrap_or(20).min(50);
let results = hf_models::search_gguf_models(&params.q, limit)
.await
.map_err(|e| ErrorResponse::internal(format!("Search failed: {}", e)))?;
Ok(Json(results))
}
#[utoipa::path(
get,
path = "/local-inference/repo/{author}/{repo}/files",
responses(
(status = 200, description = "GGUF files in the repo", body = RepoVariantsResponse)
)
)]
pub async fn get_repo_files(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
Path((author, repo)): Path<(String, String)>,
) -> Result<Json<RepoVariantsResponse>, ErrorResponse> {
let repo_id = format!("{}/{}", author, repo);
let variants = hf_models::get_repo_gguf_variants(&repo_id)
.await
.map_err(|e| ErrorResponse::internal(format!("Failed to fetch repo files: {}", e)))?;
let available_memory = available_inference_memory_bytes(&state.inference_runtime);
let recommended_index = hf_models::recommend_variant(&variants, available_memory);
Ok(Json(RepoVariantsResponse {
variants,
recommended_index,
}))
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct DownloadModelRequest {
/// Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M"
pub spec: String,
}
#[utoipa::path(
post,
path = "/local-inference/download",
request_body = DownloadModelRequest,
responses(
(status = 202, description = "Download started", body = String),
(status = 400, description = "Invalid request")
)
)]
pub async fn download_hf_model(
Json(req): Json<DownloadModelRequest>,
) -> Result<(StatusCode, Json<String>), ErrorResponse> {
let (repo_id, quantization) = hf_models::parse_model_spec(&req.spec)
.map_err(|e| ErrorResponse::bad_request(format!("Invalid spec format: {e}")))?;
let (_repo, hf_file) = resolve_model_spec(&req.spec)
.await
.map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?;
let model_id = model_id_from_repo(&repo_id, &quantization);
let local_path = Paths::in_data_dir("models").join(&hf_file.filename);
let download_url = hf_file.download_url.clone();
let entry = LocalModelEntry {
id: model_id.clone(),
repo_id,
filename: hf_file.filename,
quantization,
local_path: local_path.clone(),
source_url: download_url.clone(),
settings: ModelSettings::default(),
size_bytes: hf_file.size_bytes,
};
{
let mut registry = get_registry()
.lock()
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
registry
.add_model(entry)
.map_err(|e| ErrorResponse::internal(format!("{}", e)))?;
}
let dm = get_download_manager();
dm.download_model(
format!("{}-model", model_id),
download_url,
local_path,
None,
)
.await
.map_err(|e| ErrorResponse::internal(format!("Download failed: {}", e)))?;
Ok((StatusCode::ACCEPTED, Json(model_id)))
}
#[utoipa::path(
get,
path = "/local-inference/models/{model_id}/download",
responses(
(status = 200, description = "Download progress", body = DownloadProgress),
(status = 404, description = "No active download")
)
)]
pub async fn get_local_model_download_progress(
Path(model_id): Path<String>,
) -> Result<Json<DownloadProgress>, ErrorResponse> {
let download_id = format!("{}-model", model_id);
debug!(model_id = %model_id, download_id = %download_id, "Getting download progress");
let manager = get_download_manager();
let model_progress = manager
.get_progress(&download_id)
.ok_or_else(|| ErrorResponse::not_found("No active download"))?;
Ok(Json(model_progress))
}
#[utoipa::path(
delete,
path = "/local-inference/models/{model_id}/download",
responses(
(status = 200, description = "Download cancelled"),
(status = 404, description = "No active download")
)
)]
pub async fn cancel_local_model_download(
Path(model_id): Path<String>,
) -> Result<StatusCode, ErrorResponse> {
let manager = get_download_manager();
manager
.cancel_download(&format!("{}-model", model_id))
.map_err(|e| ErrorResponse::internal(format!("{}", e)))?;
Ok(StatusCode::OK)
}
#[utoipa::path(
delete,
path = "/local-inference/models/{model_id}",
responses(
(status = 200, description = "Model deleted"),
(status = 404, description = "Model not found")
)
)]
pub async fn delete_local_model(Path(model_id): Path<String>) -> Result<StatusCode, ErrorResponse> {
let local_path = {
let registry = get_registry()
.lock()
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
let entry = registry
.get_model(&model_id)
.ok_or_else(|| ErrorResponse::not_found("Model not found"))?;
entry.local_path.clone()
};
if local_path.exists() {
tokio::fs::remove_file(&local_path)
.await
.map_err(|e| ErrorResponse::internal(format!("Failed to delete: {}", e)))?;
}
// Only remove non-featured models from registry (featured ones stay as placeholders)
if !is_featured_model(&model_id) {
let mut registry = get_registry()
.lock()
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
registry
.remove_model(&model_id)
.map_err(|e| ErrorResponse::internal(format!("{}", e)))?;
}
Ok(StatusCode::OK)
}
#[utoipa::path(
get,
path = "/local-inference/models/{model_id}/settings",
responses(
(status = 200, description = "Model settings", body = ModelSettings),
(status = 404, description = "Model not found")
)
)]
pub async fn get_model_settings(
Path(model_id): Path<String>,
) -> Result<Json<ModelSettings>, ErrorResponse> {
let registry = get_registry()
.lock()
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
if let Some(settings) = registry.get_model_settings(&model_id) {
return Ok(Json(settings.clone()));
}
Err(ErrorResponse::not_found("Model not found"))
}
#[utoipa::path(
put,
path = "/local-inference/models/{model_id}/settings",
request_body = ModelSettings,
responses(
(status = 200, description = "Settings updated", body = ModelSettings),
(status = 404, description = "Model not found"),
(status = 500, description = "Failed to save settings")
)
)]
pub async fn update_model_settings(
Path(model_id): Path<String>,
Json(settings): Json<ModelSettings>,
) -> Result<Json<ModelSettings>, ErrorResponse> {
let mut registry = get_registry()
.lock()
.map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?;
registry
.update_model_settings(&model_id, settings.clone())
.map_err(|e| ErrorResponse::not_found(format!("{}", e)))?;
Ok(Json(settings))
}
pub fn routes(state: Arc<AppState>) -> Router {
goose::download_manager::cleanup_partial_downloads(&Paths::in_data_dir("models"));
Router::new()
.route("/local-inference/models", get(list_local_models))
.route("/local-inference/search", get(search_hf_models))
.route(
"/local-inference/repo/{author}/{repo}/files",
get(get_repo_files),
)
.route("/local-inference/download", post(download_hf_model))
.route(
"/local-inference/models/{model_id}/download",
get(get_local_model_download_progress),
)
.route(
"/local-inference/models/{model_id}/download",
delete(cancel_local_model_download),
)
.route(
"/local-inference/models/{model_id}",
delete(delete_local_model),
)
.route(
"/local-inference/models/{model_id}/settings",
get(get_model_settings),
)
.route(
"/local-inference/models/{model_id}/settings",
axum::routing::put(update_model_settings),
)
.with_state(state)
}
+2
View File
@@ -3,6 +3,7 @@ pub mod agent;
pub mod config_management;
pub mod dictation;
pub mod errors;
pub mod local_inference;
pub mod mcp_app_proxy;
pub mod mcp_ui_proxy;
pub mod prompts;
@@ -30,6 +31,7 @@ pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Rout
.merge(action_required::routes(state.clone()))
.merge(agent::routes(state.clone()))
.merge(dictation::routes(state.clone()))
.merge(local_inference::routes(state.clone()))
.merge(config_management::routes(state.clone()))
.merge(prompts::routes())
.merge(recipe::routes(state.clone()))
+5
View File
@@ -94,6 +94,11 @@ pub fn inspect_keys(
pub fn check_provider_configured(metadata: &ProviderMetadata, provider_type: ProviderType) -> bool {
let config = Config::global();
// Special override
if metadata.name == "local" {
return true;
}
if provider_type == ProviderType::Custom || provider_type == ProviderType::Declarative {
if let Ok(loaded_provider) = load_provider(metadata.name.as_str()) {
if !loaded_provider.config.requires_auth {
+3
View File
@@ -14,6 +14,7 @@ use tokio::task::JoinHandle;
use crate::tunnel::TunnelManager;
use goose::agents::ExtensionLoadResult;
use goose::providers::local_inference::InferenceRuntime;
type ExtensionLoadingTasks =
Arc<Mutex<HashMap<String, Arc<Mutex<Option<JoinHandle<Vec<ExtensionLoadResult>>>>>>>>;
@@ -26,6 +27,7 @@ pub struct AppState {
recipe_session_tracker: Arc<Mutex<HashSet<String>>>,
pub tunnel_manager: Arc<TunnelManager>,
pub extension_loading_tasks: ExtensionLoadingTasks,
pub inference_runtime: Arc<InferenceRuntime>,
}
fn spawn_developer(r: tokio::io::DuplexStream, w: tokio::io::DuplexStream) {
@@ -57,6 +59,7 @@ impl AppState {
recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())),
tunnel_manager,
extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())),
inference_runtime: InferenceRuntime::get_or_init(),
}))
}
+6 -2
View File
@@ -9,7 +9,7 @@ description.workspace = true
[features]
default = []
cuda = ["candle-core/cuda", "candle-nn/cuda"]
cuda = ["candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"]
[lints]
workspace = true
@@ -126,14 +126,17 @@ ignore = { workspace = true }
which = { workspace = true }
pctx_code_mode = "^0.2.3"
unbinder = "0.1.7"
llama-cpp-2 = { version = "0.1.133", features = ["sampler"] }
encoding_rs = "0.8.35"
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }
# Platform-specific GPU acceleration for Whisper
# Platform-specific GPU acceleration for Whisper and local inference
[target.'cfg(target_os = "macos")'.dependencies]
candle-core = { version = "0.9", default-features = false, features = ["metal"] }
candle-nn = { version = "0.9", default-features = false, features = ["metal"] }
llama-cpp-2 = { version = "0.1.133", features = ["sampler", "metal"] }
[dev-dependencies]
serial_test = { workspace = true }
@@ -156,6 +159,7 @@ path = "examples/agent.rs"
name = "databricks_oauth"
path = "examples/databricks_oauth.rs"
[[bin]]
name = "build_canonical_models"
path = "src/providers/canonical/build_canonical_models.rs"
+17 -1
View File
@@ -236,7 +236,12 @@ async fn child_process_client(
command.env("PATH", path);
}
if let Some(dir) = working_dir {
// Use explicitly passed working_dir, falling back to GOOSE_WORKING_DIR env var
let effective_working_dir = working_dir
.map(|p| p.to_path_buf())
.or_else(|| std::env::var("GOOSE_WORKING_DIR").ok().map(PathBuf::from));
if let Some(ref dir) = effective_working_dir {
if dir.exists() && dir.is_dir() {
tracing::info!("Setting MCP process working directory: {:?}", dir);
command.current_dir(dir);
@@ -710,6 +715,7 @@ impl ExtensionManager {
})?;
let mut context = self.context.clone();
context.extension_manager = Some(Arc::downgrade(self));
(def.client_factory)(context)
}
ExtensionConfig::InlinePython {
@@ -1584,6 +1590,16 @@ impl ExtensionManager {
session_id: &str,
working_dir: &std::path::Path,
) -> Option<String> {
// Skip MOIM for models with small context windows to avoid consuming limited context
const MIN_CONTEXT_FOR_MOIM: usize = 32_000;
if let Ok(provider_guard) = self.provider.try_lock() {
if let Some(provider) = provider_guard.as_ref() {
if provider.get_model_config().context_limit() < MIN_CONTEXT_FOR_MOIM {
return None;
}
}
}
// Use minute-level granularity to prevent conversation changes every second
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:00").to_string();
let mut content = format!(
@@ -330,10 +330,12 @@ impl SummonClient {
"Load knowledge into your current context or discover available sources.\n\n\
Call with no arguments to list all available sources (subrecipes, recipes, skills, agents).\n\
Call with a source name to load its content into your context.\n\
For background tasks: load(source: \"task_id\", cancel: true) stops and returns output.\n\n\
For background tasks: load(source: \"task_id\") waits for the task and returns the result.\n\
To cancel a running task: load(source: \"task_id\", cancel: true) stops and returns output.\n\n\
Examples:\n\
- load() → Lists available sources\n\
- load(source: \"rust-patterns\") → Loads the rust-patterns skill"
- load(source: \"rust-patterns\") → Loads the rust-patterns skill\n\
- load(source: \"20260219_1\") → Waits for background task, then returns result"
.to_string(),
schema.as_object().unwrap().clone(),
)
@@ -880,7 +882,7 @@ impl SummonClient {
// Wait for the running task to complete, keeping the tool call
// alive so notifications (subagent tool calls) stream in real time.
let task = running.remove(task_id).unwrap();
let mut task = running.remove(task_id).unwrap();
drop(running);
let buffered = {
@@ -896,45 +898,37 @@ impl SummonClient {
}
}
let description = task.description.clone();
let mut handle = task.handle;
let (output, timed_out) = tokio::select! {
result = &mut handle => {
let s = match result {
tokio::select! {
result = &mut task.handle => {
let output = match result {
Ok(Ok(s)) => s,
Ok(Err(e)) => format!("Error: {}", e),
Err(e) => format!("Task panicked: {}", e),
};
(s, false)
return Ok(vec![Content::text(format!(
"# Background Task Result: {}\n\n\
**Task:** {}\n\
**Status:** ✓ Completed\n\
**Duration:** {} ({} turns)\n\n\
## Output\n\n{}",
task_id,
task.description,
round_duration(task.started_at.elapsed()),
task.turns.load(Ordering::Relaxed),
output
))]);
}
_ = tokio::time::sleep(Duration::from_secs(300)) => {
handle.abort();
("Task timed out waiting for completion (aborted after 5 min)".to_string(), true)
self.background_tasks.lock().await.insert(task_id.to_string(), task);
return Err(format!(
"Task '{task_id}' is still running after waiting 5 min. \
Use load(source: \"{task_id}\") to wait again, or \
load(source: \"{task_id}\", cancel: true) to stop."
));
}
};
let duration = task.started_at.elapsed();
let turns_taken = task.turns.load(Ordering::Relaxed);
let status = if timed_out {
"⏱ Timed out"
} else {
"✓ Completed"
};
return Ok(vec![Content::text(format!(
"# Background Task Result: {}\n\n\
**Task:** {}\n\
**Status:** {}\n\
**Duration:** {} ({} turns)\n\n\
## Output\n\n{}",
task_id,
description,
status,
round_duration(duration),
turns_taken,
output
))]);
}
}
Err(format!("Task '{}' not found.", task_id))
@@ -1628,7 +1622,8 @@ impl SummonClient {
.insert(task_id.clone(), task);
Ok(vec![Content::text(format!(
"Task {} started in background: \"{}\"\nUse load(source: \"{}\") to wait for the result (it will block until complete).",
"Task {} started in background: \"{}\"\n\
Continue with other work. When you need the result, use load(source: \"{}\").",
task_id, description, task_id
))])
}
@@ -1714,8 +1709,6 @@ impl McpClientTrait for SummonClient {
let mut lines = vec!["Background tasks:".to_string()];
let now = current_epoch_millis();
let mut shortest_elapsed_secs: Option<u64> = None;
let mut sorted_running: Vec<_> = running.values().collect();
sorted_running.sort_by_key(|t| &t.id);
@@ -1723,10 +1716,6 @@ impl McpClientTrait for SummonClient {
let elapsed = task.started_at.elapsed();
let idle_ms = now.saturating_sub(task.last_activity.load(Ordering::Relaxed));
let elapsed_secs = elapsed.as_secs();
shortest_elapsed_secs =
Some(shortest_elapsed_secs.map_or(elapsed_secs, |s| s.min(elapsed_secs)));
lines.push(format!(
"• {}: \"{}\" - running {}, {} turns, idle {}",
task.id,
@@ -1757,12 +1746,11 @@ impl McpClientTrait for SummonClient {
));
}
if let Some(shortest) = shortest_elapsed_secs {
let sleep_secs = 300u64.saturating_sub(shortest).max(10);
lines.push(format!(
"\n→ sleep {} to wait, or load(source: \"id\", cancel: true) to stop",
sleep_secs
));
if !running.is_empty() {
lines.push(
"\n→ Use load(source: \"<id>\") to wait for a task, or load(source: \"<id>\", cancel: true) to stop it"
.to_string(),
);
}
Some(lines.join("\n"))
-1
View File
@@ -1,3 +1,2 @@
pub mod download_manager;
pub mod providers;
pub mod whisper;
@@ -1,12 +1,33 @@
use crate::dictation::whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::io::AsyncWriteExt;
use tracing::info;
use utoipa::ToSchema;
fn partial_path_for(destination: &Path) -> PathBuf {
destination.with_extension(
destination
.extension()
.map(|e| format!("{}.part", e.to_string_lossy()))
.unwrap_or_else(|| "part".to_string()),
)
}
/// Remove any leftover `.part` files in the given directory.
pub fn cleanup_partial_downloads(dir: &Path) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "part") {
let _ = std::fs::remove_file(&path);
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DownloadProgress {
/// Model ID being downloaded
@@ -78,8 +99,9 @@ impl DownloadManager {
model_id: String,
url: String,
destination: PathBuf,
on_complete: Option<Box<dyn FnOnce() + Send + 'static>>,
) -> Result<()> {
// Initialize progress
info!(model_id = %model_id, url = %url, destination = ?destination, "Starting model download");
{
let mut downloads = self
.downloads
@@ -115,10 +137,13 @@ impl DownloadManager {
let downloads = self.downloads.clone();
let model_id_clone = model_id.clone();
let destination_for_cleanup = destination.clone();
// Download in background task
tokio::spawn(async move {
match Self::download_file(&url, &destination, &downloads, &model_id_clone).await {
Ok(_) => {
info!(model_id = %model_id_clone, "Download completed successfully");
if let Ok(mut downloads) = downloads.lock() {
if let Some(progress) = downloads.get_mut(&model_id_clone) {
progress.status = DownloadStatus::Completed;
@@ -126,10 +151,15 @@ impl DownloadManager {
}
}
let _ = crate::config::Config::global()
.set_param(LOCAL_WHISPER_MODEL_CONFIG_KEY, model_id_clone.clone());
if let Some(callback) = on_complete {
callback();
}
}
Err(e) => {
// Clean up partial file on failure
let partial = partial_path_for(&destination_for_cleanup);
let _ = tokio::fs::remove_file(&partial).await;
if let Ok(mut downloads) = downloads.lock() {
if let Some(progress) = downloads.get_mut(&model_id_clone) {
progress.status = DownloadStatus::Failed;
@@ -166,7 +196,8 @@ impl DownloadManager {
}
}
let mut file = tokio::fs::File::create(destination).await?;
let partial_path = partial_path_for(destination);
let mut file = tokio::fs::File::create(&partial_path).await?;
let mut bytes_downloaded = 0u64;
let start_time = std::time::Instant::now();
@@ -185,8 +216,7 @@ impl DownloadManager {
};
if should_cancel {
// Clean up partial download
let _ = tokio::fs::remove_file(destination).await;
let _ = tokio::fs::remove_file(&partial_path).await;
return Ok(());
}
@@ -226,6 +256,8 @@ impl DownloadManager {
}
file.flush().await?;
drop(file);
tokio::fs::rename(&partial_path, destination).await?;
Ok(())
}
+1
View File
@@ -5,6 +5,7 @@ pub mod config;
pub mod context_mgmt;
pub mod conversation;
pub mod dictation;
pub mod download_manager;
pub mod execution;
pub mod goose_apps;
pub mod hints;
+8
View File
@@ -39,6 +39,14 @@ static TEMPLATE_REGISTRY: &[(&str, &str)] = &[
"plan.md",
"Prompt used when goose creates step-by-step plans. CLI only",
),
(
"tiny_model_system.md",
"System prompt for tiny local models using shell command emulation",
),
(
"session_name.md",
"System prompt for generating short session names from conversation history",
),
];
/// Information about a template including its content and customization status
+6
View File
@@ -0,0 +1,6 @@
Generate a short title (four words or less) that describes the topic of the user's messages. Reply with only the title, nothing else.
Examples:
- "how do I reverse a list in python?" → Python list reversal
- "what's the weather in Tokyo?" → Tokyo weather
- "explain how transformers work in ML" → ML transformers explained
@@ -0,0 +1,22 @@
You are goose, an autonomous AI agent created by Block. You act on the user's
behalf — you do not explain how to do things, you DO them directly.
The OS is {{os}}, the shell is {{shell}}, and the working directory is {{working_directory}}
When the user asks you to do something, take action immediately. Do not describe
what you would do or give instructions — execute the commands yourself.
To run a shell command, start a new line with $:
$ ls
Keep your responses brief. State what you are doing, then do it. For example:
User: how many files are in /tmp?
You: Let me check.
$ ls -1 /tmp | wc -l
After a command runs, you will see its output. Use the output to answer the user
or take the next step. Do not repeat commands you have already run.
Do not use shell commands if you already know the answer.
+67 -27
View File
@@ -17,10 +17,22 @@ use rmcp::model::Tool;
use utoipa::ToSchema;
use once_cell::sync::Lazy;
use regex::Regex;
use std::ops::{Add, AddAssign};
use std::pin::Pin;
use std::sync::LazyLock;
use std::sync::Mutex;
fn strip_xml_tags(text: &str) -> String {
static BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)<([a-zA-Z][a-zA-Z0-9_]*)[^>]*>.*?</[a-zA-Z][a-zA-Z0-9_]*>").unwrap()
});
static TAG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"</?[a-zA-Z][a-zA-Z0-9_]*[^>]*>").unwrap());
let pass1 = BLOCK_RE.replace_all(text, "");
TAG_RE.replace_all(&pass1, "").into_owned()
}
/// A global store for the current model being used, we use this as when a provider returns, it tells us the real model, not an alias
pub static CURRENT_MODEL: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
@@ -594,20 +606,28 @@ pub trait Provider: Send + Sync {
messages: &Conversation,
) -> Result<String, ProviderError> {
let context = self.get_initial_user_messages(messages);
let prompt = self.create_session_name_prompt(&context);
let message = Message::user().with_text(&prompt);
let system = crate::prompt_template::render_template(
"session_name.md",
&std::collections::HashMap::<String, String>::new(),
)
.map_err(|e| ProviderError::ContextLengthExceeded(e.to_string()))?;
let user_text = format!(
"---BEGIN USER MESSAGES---\n{}\n---END USER MESSAGES---\n\nGenerate a short title for the above messages.",
context.join("\n")
);
let message = Message::user().with_text(&user_text);
let result = self
.complete_fast(
session_id,
"Reply with only a description in four words or less",
&[message],
&[],
)
.complete_fast(session_id, &system, &[message], &[])
.await?;
let description = result
let raw: String = result
.0
.as_concat_text()
.content
.iter()
.filter_map(|c| c.as_text())
.collect();
let description = strip_xml_tags(&raw)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
@@ -615,21 +635,6 @@ pub trait Provider: Send + Sync {
Ok(safe_truncate(&description, 100))
}
// Generate a prompt for a session name based on the conversation history
fn create_session_name_prompt(&self, context: &[String]) -> String {
// Create a prompt for a concise description
let mut prompt = "Based on the conversation so far, provide a concise description of this session in 4 words or less. This will be used for finding the session later in a UI with limited space - reply *ONLY* with the description".to_string();
if !context.is_empty() {
prompt = format!(
"Here are the first few user messages:\n{}\n\n{}",
context.join("\n"),
prompt
);
}
prompt
}
/// Configure OAuth authentication for this provider
///
/// This method is called when a provider has configuration keys marked with oauth_flow = true.
@@ -719,6 +724,42 @@ mod tests {
use test_case::test_case;
use serde_json::json;
#[test]
fn test_strip_xml_tags() {
assert_eq!(strip_xml_tags("<think>reasoning</think>answer"), "answer");
assert_eq!(strip_xml_tags("before<t>mid</t>after"), "beforeafter");
assert_eq!(strip_xml_tags("<a>x</a><b>y</b>z"), "z");
assert_eq!(strip_xml_tags("no tags here"), "no tags here");
assert_eq!(strip_xml_tags("a < b > c"), "a < b > c");
assert_eq!(strip_xml_tags("<think>über</think>ok"), "ok");
assert_eq!(strip_xml_tags("<think>日本語</think>hello"), "hello");
assert_eq!(strip_xml_tags(""), "");
assert_eq!(strip_xml_tags("<>stuff</>"), "<>stuff</>");
// attributes
assert_eq!(
strip_xml_tags(r#"<think class="deep">reasoning</think>answer"#),
"answer"
);
// self-closing tags
assert_eq!(strip_xml_tags("<br/>self closing"), "self closing");
// orphan closing tags
assert_eq!(strip_xml_tags("orphan </think> tag"), "orphan tag");
// multiline content
assert_eq!(
strip_xml_tags("<think>\nline1\nline2\n</think>result"),
"result"
);
}
#[test]
fn test_usage_creation() {
let usage = Usage::new(Some(10), Some(20), Some(30));
assert_eq!(usage.input_tokens, Some(10));
assert_eq!(usage.output_tokens, Some(20));
assert_eq!(usage.total_tokens, Some(30));
}
fn content_from_str(s: String) -> MessageContent {
if let Some(img_data) = s.strip_prefix("*img:") {
MessageContent::image(format!("http://example.com/{}", img_data), "image/png")
@@ -798,11 +839,10 @@ mod tests {
#[tokio::test]
async fn test_collect_stream_defaults_usage() {
// Should not error when usage is missing
let stream = create_test_stream(vec!["Hello".to_string()]);
let (msg, usage) = collect_stream(Box::pin(stream)).await.unwrap();
assert_eq!(content_to_strings(&msg), vec!["Hello"]);
assert_eq!(usage.model, "unknown"); // Default usage
assert_eq!(usage.model, "unknown");
}
#[test]
+2
View File
@@ -16,6 +16,7 @@ use super::{
google::GoogleProvider,
lead_worker::LeadWorkerProvider,
litellm::LiteLLMProvider,
local_inference::LocalInferenceProvider,
ollama::OllamaProvider,
openai::OpenAiProvider,
openrouter::OpenRouterProvider,
@@ -47,6 +48,7 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
registry.register::<AnthropicProvider>(true);
registry.register::<AzureProvider>(false);
registry.register::<BedrockProvider>(false);
registry.register::<LocalInferenceProvider>(false);
registry.register::<ChatGptCodexProvider>(true);
registry.register::<ClaudeCodeProvider>(true);
registry.register::<CodexProvider>(true);
@@ -0,0 +1,647 @@
pub mod hf_models;
mod inference_emulated_tools;
mod inference_engine;
mod inference_native_tools;
pub mod local_model_registry;
mod tool_parsing;
use inference_emulated_tools::{
build_emulator_tool_description, generate_with_emulated_tools, load_tiny_model_prompt,
};
use inference_engine::GenerationContext;
use inference_engine::LoadedModel;
use inference_native_tools::generate_with_native_tools;
use tool_parsing::compact_tools_json;
use crate::config::ExtensionConfig;
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::providers::base::{
MessageStream, Provider, ProviderDef, ProviderMetadata, ProviderUsage, Usage,
};
use crate::providers::errors::ProviderError;
use crate::providers::formats::openai::format_tools;
use crate::providers::utils::RequestLog;
use anyhow::Result;
use async_stream::try_stream;
use async_trait::async_trait;
use futures::future::BoxFuture;
use llama_cpp_2::llama_backend::LlamaBackend;
use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::model::{LlamaChatMessage, LlamaChatTemplate, LlamaModel};
use llama_cpp_2::{list_llama_ggml_backend_devices, LlamaBackendDeviceType};
use rmcp::model::{Role, Tool};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex, Weak};
use tokio::sync::Mutex;
use uuid::Uuid;
const SHELL_TOOL: &str = "developer__shell";
const CODE_EXECUTION_TOOL: &str = "code_execution__execute";
type ModelSlot = Arc<Mutex<Option<LoadedModel>>>;
/// Owns the llama backend and all cached models. Field order matters:
/// `models` is declared before `backend` so Rust drops all loaded models
/// (and their Metal/GPU resources) before the backend calls
/// `llama_backend_free()`, avoiding the ggml-metal assertion on shutdown.
pub struct InferenceRuntime {
models: StdMutex<HashMap<String, ModelSlot>>,
backend: LlamaBackend,
}
/// Global weak reference used to share a single `InferenceRuntime` across
/// all providers and server routes. Only a `Weak` is stored — strong `Arc`s
/// live in providers and `AppState`. When all strong refs drop (normal
/// shutdown), the runtime is deallocated and the backend freed. The `Weak`
/// left behind is inert during `__cxa_finalize`, so no ggml statics race.
static RUNTIME: StdMutex<Weak<InferenceRuntime>> = StdMutex::new(Weak::new());
impl InferenceRuntime {
pub fn get_or_init() -> Arc<Self> {
let mut guard = RUNTIME.lock().expect("runtime lock poisoned");
if let Some(runtime) = guard.upgrade() {
return runtime;
}
// Safety invariant: the Weak::upgrade() check and LlamaBackend::init()
// both execute inside this same mutex guard, so there is no window where
// another thread could drop the Arc and re-enter concurrently.
// BackendAlreadyInitialized therefore means LlamaBackend::drop() did not
// reset the C library's init flag — a llama-cpp-rs bug, not a race.
let backend = match LlamaBackend::init() {
Ok(b) => b,
Err(llama_cpp_2::LlamaCppError::BackendAlreadyInitialized) => {
unreachable!(
"LlamaBackend already initialized but Weak was dead; \
the mutex guard prevents concurrent re-init"
)
}
Err(e) => panic!("Failed to init llama backend: {}", e),
};
let runtime = Arc::new(Self {
models: StdMutex::new(HashMap::new()),
backend,
});
*guard = Arc::downgrade(&runtime);
runtime
}
pub fn backend(&self) -> &LlamaBackend {
&self.backend
}
fn get_or_create_model_slot(&self, model_id: &str) -> ModelSlot {
let mut map = self.models.lock().expect("model cache lock poisoned");
map.entry(model_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(None)))
.clone()
}
fn other_model_slots(&self, keep_model_id: &str) -> Vec<ModelSlot> {
let map = self.models.lock().expect("model cache lock poisoned");
map.iter()
.filter(|(id, _)| id.as_str() != keep_model_id)
.map(|(_, slot)| slot.clone())
.collect()
}
}
const PROVIDER_NAME: &str = "local";
const DEFAULT_MODEL: &str = "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M";
pub const LOCAL_LLM_MODEL_CONFIG_KEY: &str = "LOCAL_LLM_MODEL";
/// Resolve model path, context limit, and settings for a model ID from the registry.
pub fn resolve_model_path(
model_id: &str,
) -> Option<(
PathBuf,
usize,
crate::providers::local_inference::local_model_registry::ModelSettings,
)> {
use crate::providers::local_inference::local_model_registry::get_registry;
if let Ok(registry) = get_registry().lock() {
if let Some(entry) = registry.get_model(model_id) {
let ctx = entry.settings.context_size.unwrap_or(0) as usize;
return Some((entry.local_path.clone(), ctx, entry.settings.clone()));
}
}
None
}
pub fn available_inference_memory_bytes(runtime: &InferenceRuntime) -> u64 {
let _ = &runtime.backend;
let devices = list_llama_ggml_backend_devices();
let accel_memory = devices
.iter()
.filter(|d| {
matches!(
d.device_type,
LlamaBackendDeviceType::Gpu
| LlamaBackendDeviceType::IntegratedGpu
| LlamaBackendDeviceType::Accelerator
)
})
.map(|d| d.memory_free as u64)
.max()
.unwrap_or(0);
if accel_memory > 0 {
accel_memory
} else {
devices
.iter()
.filter(|d| d.device_type == LlamaBackendDeviceType::Cpu)
.map(|d| d.memory_free as u64)
.max()
.unwrap_or(0)
}
}
pub fn recommend_local_model(runtime: &InferenceRuntime) -> String {
use local_model_registry::{get_registry, is_featured_model, FEATURED_MODELS};
let available_memory = available_inference_memory_bytes(runtime);
if let Ok(registry) = get_registry().lock() {
let mut models: Vec<_> = registry
.list_models()
.iter()
.filter(|m| is_featured_model(&m.id) && m.size_bytes > 0)
.collect();
models.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
// Return largest that fits in available memory
for model in &models {
if available_memory >= model.size_bytes {
return model.id.clone();
}
}
// If nothing fits, return smallest
if let Some(smallest) = models.last() {
return smallest.id.clone();
}
}
// Fallback to first featured model
FEATURED_MODELS[0].to_string()
}
fn build_openai_messages_json(system: &str, messages: &[Message]) -> String {
use crate::providers::formats::openai::format_messages;
use crate::providers::utils::ImageFormat;
let mut arr: Vec<Value> = vec![json!({"role": "system", "content": system})];
arr.extend(format_messages(messages, &ImageFormat::OpenAi));
serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
}
/// Convert a message into plain text for the emulator path's chat history.
///
/// This is the emulator-path counterpart of [`format_messages`] used by the native
/// path. It reconstructs the text-based tool syntax that the emulator prompt teaches
/// the model:
///
/// - `ToolRequest` with a `"command"` argument → `$ command`
/// - `ToolRequest` with a `"code"` argument → `` ```execute\n…\n``` ``
/// - `ToolResponse` → `Command output:\n…`
///
/// Only `developer__shell` and `code_execution__execute` style tool calls are
/// recognized (by argument shape, not tool name). Tool calls from other extensions
/// (e.g. custom MCP tools made by a native-tool-calling model earlier in the
/// conversation) are silently dropped, since the emulator path has no syntax to
/// represent them.
fn extract_text_content(msg: &Message) -> String {
let mut parts = Vec::new();
for content in &msg.content {
match content {
MessageContent::Text(text) => {
parts.push(text.text.clone());
}
MessageContent::ToolRequest(req) => {
if let Ok(call) = &req.tool_call {
if let Some(cmd) = call
.arguments
.as_ref()
.and_then(|a| a.get("command"))
.and_then(|v| v.as_str())
{
parts.push(format!("$ {}", cmd));
} else if let Some(code) = call
.arguments
.as_ref()
.and_then(|a| a.get("code"))
.and_then(|v| v.as_str())
{
parts.push(format!("```execute\n{}\n```", code));
}
}
}
MessageContent::ToolResponse(response) => match &response.tool_result {
Ok(result) => {
let mut output_parts = Vec::new();
for content_item in &result.content {
if let Some(text_content) = content_item.as_text() {
output_parts.push(text_content.text.to_string());
}
}
if !output_parts.is_empty() {
parts.push(format!("Command output:\n{}", output_parts.join("\n")));
}
}
Err(e) => {
parts.push(format!("Command error: {}", e));
}
},
_ => {}
}
}
parts.join("\n")
}
/// Build a `ProviderUsage` and write the request log entry.
fn finalize_usage(
log: &mut RequestLog,
model_name: String,
path_label: &str,
prompt_token_count: usize,
output_token_count: i32,
extra_log_fields: Option<(&str, &str)>,
) -> ProviderUsage {
let input_tokens = prompt_token_count as i32;
let total_tokens = input_tokens + output_token_count;
let usage = Usage::new(
Some(input_tokens),
Some(output_token_count),
Some(total_tokens),
);
let mut log_json = serde_json::json!({
"path": path_label,
"prompt_tokens": input_tokens,
"output_tokens": output_token_count,
});
if let Some((key, value)) = extra_log_fields {
log_json[key] = serde_json::json!(value);
}
let _ = log.write(&log_json, Some(&usage));
ProviderUsage::new(model_name, usage)
}
type StreamSender =
tokio::sync::mpsc::Sender<Result<(Option<Message>, Option<ProviderUsage>), ProviderError>>;
pub struct LocalInferenceProvider {
runtime: Arc<InferenceRuntime>,
model: ModelSlot,
model_config: ModelConfig,
name: String,
}
impl LocalInferenceProvider {
pub async fn from_env(model: ModelConfig, _extensions: Vec<ExtensionConfig>) -> Result<Self> {
let runtime = InferenceRuntime::get_or_init();
let model_slot = runtime.get_or_create_model_slot(&model.model_name);
Ok(Self {
runtime,
model: model_slot,
model_config: model,
name: PROVIDER_NAME.to_string(),
})
}
fn load_model_sync(
runtime: &InferenceRuntime,
model_id: &str,
settings: &crate::providers::local_inference::local_model_registry::ModelSettings,
) -> Result<LoadedModel, ProviderError> {
let (model_path, _context_limit, _) = resolve_model_path(model_id)
.ok_or_else(|| ProviderError::ExecutionError(format!("Unknown model: {}", model_id)))?;
if !model_path.exists() {
return Err(ProviderError::ExecutionError(format!(
"Model not downloaded: {}. Please download it from Settings > Local Inference.",
model_id
)));
}
tracing::info!("Loading {} from: {}", model_id, model_path.display());
let backend = runtime.backend();
let mut params = LlamaModelParams::default();
if let Some(n_gpu_layers) = settings.n_gpu_layers {
params = params.with_n_gpu_layers(n_gpu_layers);
}
if settings.use_mlock {
params = params.with_use_mlock(true);
}
let model = LlamaModel::load_from_file(backend, &model_path, &params)
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?;
let template = match model.chat_template(None) {
Ok(t) => t,
Err(_) => {
tracing::warn!("Model has no embedded chat template, falling back to chatml");
LlamaChatTemplate::new("chatml").map_err(|e| {
ProviderError::ExecutionError(format!(
"Failed to create fallback chat template: {}",
e
))
})?
}
};
tracing::info!("Model loaded successfully");
Ok(LoadedModel { model, template })
}
}
impl ProviderDef for LocalInferenceProvider {
type Provider = Self;
fn metadata() -> ProviderMetadata
where
Self: Sized,
{
use crate::providers::local_inference::local_model_registry::{
get_registry, FEATURED_MODELS,
};
let mut known_models: Vec<&str> = FEATURED_MODELS.to_vec();
// Add any registry models not already in the featured list
let mut dynamic_models = Vec::new();
if let Ok(registry) = get_registry().lock() {
for entry in registry.list_models() {
if !known_models.contains(&entry.id.as_str()) {
dynamic_models.push(entry.id.clone());
}
}
}
let dynamic_refs: Vec<&str> = dynamic_models.iter().map(|s| s.as_str()).collect();
known_models.extend(dynamic_refs);
ProviderMetadata::new(
PROVIDER_NAME,
"Local Inference",
"Local inference using quantized GGUF models (llama.cpp)",
DEFAULT_MODEL,
known_models,
"https://github.com/utilityai/llama-cpp-rs",
vec![],
)
}
fn from_env(
model: ModelConfig,
extensions: Vec<ExtensionConfig>,
) -> BoxFuture<'static, Result<Self::Provider>>
where
Self: Sized,
{
Box::pin(Self::from_env(model, extensions))
}
}
#[async_trait]
impl Provider for LocalInferenceProvider {
fn get_name(&self) -> &str {
&self.name
}
fn get_model_config(&self) -> ModelConfig {
self.model_config.clone()
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
use crate::providers::local_inference::local_model_registry::get_registry;
let mut all_models: Vec<String> = Vec::new();
if let Ok(registry) = get_registry().lock() {
for entry in registry.list_models() {
all_models.push(entry.id.clone());
}
}
Ok(all_models)
}
async fn stream(
&self,
model_config: &ModelConfig,
_session_id: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let (_model_path, model_context_limit, model_settings) =
resolve_model_path(&model_config.model_name).ok_or_else(|| {
ProviderError::ExecutionError(format!(
"Model not found: {}",
model_config.model_name
))
})?;
// Ensure model is loaded — unload any other models first to free memory.
{
let mut model_lock = self.model.lock().await;
if model_lock.is_none() {
for slot in self.runtime.other_model_slots(&model_config.model_name) {
let mut other = slot.lock().await;
if other.is_some() {
tracing::info!("Unloading previous model to free memory");
*other = None;
}
}
let model_id = model_config.model_name.clone();
let settings_for_load = model_settings.clone();
let runtime_for_load = self.runtime.clone();
let loaded = tokio::task::spawn_blocking(move || {
Self::load_model_sync(&runtime_for_load, &model_id, &settings_for_load)
})
.await
.map_err(|e| ProviderError::ExecutionError(e.to_string()))??;
*model_lock = Some(loaded);
}
}
// Models that support native OpenAI-compatible tool-call JSON use the
// native path (template-based tool calling with JSON output). All other
// models use the emulator which parses `$ command` and ```execute blocks.
// Only use emulator when there are actually tools to emulate - utility calls
// like compaction and session naming pass empty tools and should preserve
// their system prompts.
let use_emulator = !model_settings.native_tool_calling && !tools.is_empty();
let system_prompt = if use_emulator {
load_tiny_model_prompt()
} else {
system.to_string()
};
// Build chat messages for the template
let mut chat_messages =
vec![
LlamaChatMessage::new("system".to_string(), system_prompt.clone()).map_err(
|e| {
ProviderError::ExecutionError(format!(
"Failed to create system message: {}",
e
))
},
)?,
];
let code_mode_enabled = tools.iter().any(|t| t.name == CODE_EXECUTION_TOOL);
if use_emulator && !tools.is_empty() {
let tool_desc = build_emulator_tool_description(tools, code_mode_enabled);
chat_messages = vec![LlamaChatMessage::new(
"system".to_string(),
format!("{}{}", system_prompt, tool_desc),
)
.map_err(|e| {
ProviderError::ExecutionError(format!("Failed to create system message: {}", e))
})?];
}
for msg in messages {
let role = match msg.role {
Role::User => "user",
Role::Assistant => "assistant",
};
let content = extract_text_content(msg);
if !content.trim().is_empty() {
chat_messages.push(LlamaChatMessage::new(role.to_string(), content).map_err(
|e| ProviderError::ExecutionError(format!("Failed to create message: {}", e)),
)?);
}
}
let (full_tools_json, compact_tools) = if !use_emulator && !tools.is_empty() {
let full = format_tools(tools)
.ok()
.and_then(|spec| serde_json::to_string(&spec).ok());
let compact = compact_tools_json(tools);
(full, compact)
} else {
(None, None)
};
let oai_messages_json = if model_settings.use_jinja {
Some(build_openai_messages_json(&system_prompt, messages))
} else {
None
};
let model_arc = self.model.clone();
let runtime = self.runtime.clone();
let model_name = model_config.model_name.clone();
let context_limit = model_context_limit;
let settings = model_settings;
let log_payload = serde_json::json!({
"system": &system_prompt,
"messages": messages.iter().map(|m| {
serde_json::json!({
"role": match m.role { Role::User => "user", Role::Assistant => "assistant" },
"content": extract_text_content(m),
})
}).collect::<Vec<_>>(),
"tools": tools.iter().map(|t| &t.name).collect::<Vec<_>>(),
"settings": {
"use_jinja": settings.use_jinja,
"native_tool_calling": settings.native_tool_calling,
"context_size": settings.context_size,
"sampling": settings.sampling,
},
});
let mut log = RequestLog::start(&self.model_config, &log_payload)
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?;
let (tx, mut rx) = tokio::sync::mpsc::channel::<
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
>(32);
tokio::task::spawn_blocking(move || {
// Macro to log errors before sending them through the channel
macro_rules! send_err {
($err:expr) => {{
let err = $err;
let msg = match &err {
ProviderError::ExecutionError(s) => s.as_str(),
ProviderError::ContextLengthExceeded(s) => s.as_str(),
_ => "unknown error",
};
let _ = log.error(msg);
let _ = tx.blocking_send(Err(err));
return;
}};
}
let model_guard = model_arc.blocking_lock();
let loaded = match model_guard.as_ref() {
Some(l) => l,
None => {
send_err!(ProviderError::ExecutionError(
"Model not loaded".to_string()
));
}
};
let message_id = Uuid::new_v4().to_string();
let mut gen_ctx = GenerationContext {
loaded,
runtime: &runtime,
chat_messages: &chat_messages,
settings: &settings,
context_limit,
model_name,
message_id: &message_id,
tx: &tx,
log: &mut log,
};
let result = if use_emulator {
generate_with_emulated_tools(&mut gen_ctx, code_mode_enabled)
} else {
generate_with_native_tools(
&mut gen_ctx,
&oai_messages_json,
full_tools_json.as_deref(),
compact_tools.as_deref(),
)
};
if let Err(err) = result {
let msg = match &err {
ProviderError::ExecutionError(s) => s.as_str(),
ProviderError::ContextLengthExceeded(s) => s.as_str(),
_ => "unknown error",
};
let _ = log.error(msg);
let _ = tx.blocking_send(Err(err));
}
});
Ok(Box::pin(try_stream! {
while let Some(result) = rx.recv().await {
let item = result?;
yield item;
}
}))
}
}
@@ -0,0 +1,486 @@
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
const HF_API_BASE: &str = "https://huggingface.co/api/models";
const HF_DOWNLOAD_BASE: &str = "https://huggingface.co";
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HfModelInfo {
pub repo_id: String,
pub author: String,
pub model_name: String,
pub downloads: u64,
pub gguf_files: Vec<HfGgufFile>,
}
/// A single downloadable GGUF file (used internally and for downloads).
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HfGgufFile {
pub filename: String,
pub size_bytes: u64,
pub quantization: String,
pub download_url: String,
}
/// A quantization variant — groups sharded files into one logical entry.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HfQuantVariant {
pub quantization: String,
pub size_bytes: u64,
pub filename: String,
pub download_url: String,
pub description: &'static str,
pub quality_rank: u8,
}
#[derive(Debug, Deserialize)]
struct HfApiModel {
id: Option<String>,
author: Option<String>,
downloads: Option<u64>,
siblings: Option<Vec<HfApiSibling>>,
}
#[derive(Debug, Deserialize)]
struct HfApiSibling {
rfilename: String,
#[serde(default)]
size: Option<u64>,
}
struct QuantInfo {
description: &'static str,
quality_rank: u8,
}
const QUANT_TABLE: &[(&str, &str, u8)] = &[
("IQ1_S", "Extremely small, very low quality", 1),
("IQ1_M", "Extremely small, very low quality", 2),
("IQ2_XXS", "Very small, low quality", 3),
("IQ2_XS", "Very small, low quality", 4),
("IQ2_S", "Very small, low quality", 5),
("IQ2_M", "Very small, low quality", 6),
("Q2_K", "Small, low quality", 7),
("Q2_K_S", "Small, low quality", 7),
("IQ3_XXS", "Very small, moderate quality loss", 8),
("IQ3_XS", "Small, moderate quality loss", 9),
("IQ3_S", "Small, moderate quality loss", 9),
("Q3_K_S", "Small, moderate quality loss", 10),
("IQ3_M", "Small, moderate quality loss", 11),
("Q3_K_M", "Small, balanced quality/size", 12),
("Q3_K_L", "Medium-small, decent quality", 13),
("IQ4_XS", "Medium, good quality", 14),
("IQ4_NL", "Medium, good quality", 15),
("Q4_0", "Medium, good quality", 16),
("Q4_1", "Medium, good quality", 17),
("Q4_K_S", "Medium, good quality/size balance", 18),
(
"Q4_K_M",
"Medium, recommended balance of quality and size",
19,
),
("Q5_0", "Medium-large, high quality", 20),
("Q5_1", "Medium-large, high quality", 21),
("Q5_K_S", "Medium-large, high quality", 22),
("Q5_K_M", "Medium-large, very high quality", 23),
("Q6_K", "Large, near-lossless quality", 24),
("Q8_0", "Large, near-lossless quality", 25),
("F16", "Full size, original quality (16-bit)", 26),
("BF16", "Full size, original quality (bfloat16)", 27),
("F32", "Full size, original quality (32-bit)", 28),
(
"MXFP4_MOE",
"Medium, mixed-precision 4-bit for MoE models",
18,
),
("TQ1_0", "Tiny, ternary quantization", 1),
("Q2_K_XL", "Extended-layer variant", 15),
("Q3_K_XL", "Extended-layer variant", 15),
("Q4_K_XL", "Extended-layer variant", 15),
("Q2_K_L", "Small, low quality (large variant)", 8),
("Q4_K_L", "Medium, good quality (large variant)", 20),
];
fn quant_info(quant: &str) -> QuantInfo {
QUANT_TABLE
.iter()
.find(|(name, _, _)| *name == quant)
.map(|(_, description, quality_rank)| QuantInfo {
description,
quality_rank: *quality_rank,
})
.unwrap_or(QuantInfo {
description: "",
quality_rank: 15,
})
}
pub fn parse_quantization_from_filename(filename: &str) -> String {
parse_quantization(filename)
}
fn parse_quantization(filename: &str) -> String {
// Strip directory prefix (e.g. "Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf")
let basename = filename.rsplit('/').next().unwrap_or(filename);
let stem = basename.trim_end_matches(".gguf");
// Strip shard suffix like "-00001-of-00004"
let stem = if let Some(pos) = stem.rfind("-of-") {
stem.get(..pos)
.and_then(|s| s.rsplit_once('-').map(|(prefix, _)| prefix))
.unwrap_or(stem)
} else {
stem
};
// The quantization tag is typically the last hyphen-separated component
// that looks like a quant identifier (starts with Q, IQ, F, BF, TQ, MXFP, etc.)
// e.g. "Qwen3-Coder-Next-Q4_K_M" -> "Q4_K_M"
// "Model-UD-IQ1_M" -> "IQ1_M"
if let Some((_, candidate)) = stem.rsplit_once('-') {
if looks_like_quant(candidate) {
return candidate.to_string();
}
}
// Fallback: try dot separator (e.g. "model.Q4_K_M")
if let Some((_, candidate)) = stem.rsplit_once('.') {
if looks_like_quant(candidate) {
return candidate.to_string();
}
}
"unknown".to_string()
}
fn looks_like_quant(s: &str) -> bool {
let upper = s.to_uppercase();
upper.starts_with("Q")
|| upper.starts_with("IQ")
|| upper.starts_with("TQ")
|| upper.starts_with("MXFP")
|| upper == "F16"
|| upper == "F32"
|| upper == "BF16"
}
fn is_shard_file(filename: &str) -> bool {
// Matches patterns like "-00001-of-00003.gguf"
let basename = filename.rsplit('/').next().unwrap_or(filename);
let stem = basename.trim_end_matches(".gguf");
if let Some(pos) = stem.rfind("-of-") {
stem.get(..pos)
.and_then(|before| before.rsplit('-').next())
.map(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or(false)
} else {
false
}
}
fn build_download_url(repo_id: &str, filename: &str) -> String {
format!("{}/{}/resolve/main/{}", HF_DOWNLOAD_BASE, repo_id, filename)
}
/// Collect single-file GGUFs into quantization variants (sharded files are excluded).
fn group_into_variants(repo_id: &str, files: Vec<HfApiSibling>) -> Vec<HfQuantVariant> {
let mut variants: Vec<HfQuantVariant> = files
.into_iter()
.filter(|s| {
s.rfilename.ends_with(".gguf")
&& !is_shard_file(&s.rfilename)
&& parse_quantization(&s.rfilename) != "unknown"
})
.map(|s| {
let quant = parse_quantization(&s.rfilename);
let info = quant_info(&quant);
let download_url = build_download_url(repo_id, &s.rfilename);
HfQuantVariant {
quantization: quant,
size_bytes: s.size.unwrap_or(0),
filename: s.rfilename,
download_url,
description: info.description,
quality_rank: info.quality_rank,
}
})
.collect();
variants.sort_by_key(|v| v.quality_rank);
variants
}
pub async fn search_gguf_models(query: &str, limit: usize) -> Result<Vec<HfModelInfo>> {
let client = reqwest::Client::new();
let url = format!(
"{}?search={}&filter=gguf&sort=downloads&direction=-1&limit={}",
HF_API_BASE, query, limit
);
let response = client
.get(&url)
.header("User-Agent", "goose-ai-agent")
.send()
.await?;
if !response.status().is_success() {
bail!("HuggingFace API returned status {}", response.status());
}
let models: Vec<HfApiModel> = response.json().await?;
let results = models
.into_iter()
.filter_map(|m| {
let repo_id = m.id?;
let siblings = m.siblings.unwrap_or_default();
// The search endpoint may not include `siblings`; parse whatever
// is available. Files are fetched on-demand via `get_repo_gguf_variants`.
let gguf_files: Vec<HfGgufFile> = siblings
.into_iter()
.filter(|s| s.rfilename.ends_with(".gguf"))
.map(|s| {
let quantization = parse_quantization(&s.rfilename);
let download_url = build_download_url(&repo_id, &s.rfilename);
HfGgufFile {
filename: s.rfilename,
size_bytes: s.size.unwrap_or(0),
quantization,
download_url,
}
})
.collect();
let author = m
.author
.unwrap_or_else(|| repo_id.split('/').next().unwrap_or_default().to_string());
let model_name = repo_id
.split('/')
.next_back()
.unwrap_or(&repo_id)
.to_string();
Some(HfModelInfo {
repo_id,
author,
model_name,
downloads: m.downloads.unwrap_or(0),
gguf_files,
})
})
.collect();
Ok(results)
}
/// Fetch GGUF files for a repo and return them grouped by quantization.
pub async fn get_repo_gguf_variants(repo_id: &str) -> Result<Vec<HfQuantVariant>> {
let client = reqwest::Client::new();
let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id);
let response = client
.get(&url)
.header("User-Agent", "goose-ai-agent")
.send()
.await?;
if !response.status().is_success() {
bail!(
"HuggingFace API returned status {} for repo {}",
response.status(),
repo_id
);
}
let model: HfApiModel = response.json().await?;
let siblings = model.siblings.unwrap_or_default();
Ok(group_into_variants(repo_id, siblings))
}
/// Fetch raw GGUF files (kept for resolve_model_spec).
pub async fn get_repo_gguf_files(repo_id: &str) -> Result<Vec<HfGgufFile>> {
let client = reqwest::Client::new();
let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id);
let response = client
.get(&url)
.header("User-Agent", "goose-ai-agent")
.send()
.await?;
if !response.status().is_success() {
bail!(
"HuggingFace API returned status {} for repo {}",
response.status(),
repo_id
);
}
let model: HfApiModel = response.json().await?;
let siblings = model.siblings.unwrap_or_default();
let files = siblings
.into_iter()
.filter(|s| s.rfilename.ends_with(".gguf"))
.filter(|s| !is_shard_file(&s.rfilename))
.map(|s| {
let quantization = parse_quantization(&s.rfilename);
let download_url = build_download_url(repo_id, &s.rfilename);
HfGgufFile {
filename: s.rfilename,
size_bytes: s.size.unwrap_or(0),
quantization,
download_url,
}
})
.collect();
Ok(files)
}
/// Parse a model spec like "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M" into (repo_id, quantization).
pub fn parse_model_spec(spec: &str) -> Result<(String, String)> {
let (repo_id, quant) = spec.rsplit_once(':').ok_or_else(|| {
anyhow::anyhow!(
"Invalid model spec '{}': expected format 'user/repo:quantization'",
spec
)
})?;
if !repo_id.contains('/') {
bail!("Invalid repo_id '{}': expected format 'user/repo'", repo_id);
}
Ok((repo_id.to_string(), quant.to_string()))
}
/// Resolve a model spec to a specific GGUF file from the repo.
pub async fn resolve_model_spec(spec: &str) -> Result<(String, HfGgufFile)> {
let (repo_id, quant) = parse_model_spec(spec)?;
let files = get_repo_gguf_files(&repo_id).await?;
let file = files
.into_iter()
.find(|f| f.quantization.eq_ignore_ascii_case(&quant))
.ok_or_else(|| {
anyhow::anyhow!(
"No GGUF file with quantization '{}' found in {}",
quant,
repo_id
)
})?;
Ok((repo_id, file))
}
/// Recommend which quantization variant to use based on available memory.
pub fn recommend_variant(
variants: &[HfQuantVariant],
available_memory_bytes: u64,
) -> Option<usize> {
// We need ~10-20% overhead beyond model size for inference context.
// Pick the highest-quality variant that fits.
let usable = (available_memory_bytes as f64 * 0.85) as u64;
let mut best: Option<usize> = None;
for (i, v) in variants.iter().enumerate() {
if v.size_bytes <= usable {
match best {
Some(bi) if variants[bi].quality_rank < v.quality_rank => best = Some(i),
None => best = Some(i),
_ => {}
}
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_quantization() {
assert_eq!(parse_quantization("Model-Q4_K_M.gguf"), "Q4_K_M");
assert_eq!(parse_quantization("Model-Q8_0.gguf"), "Q8_0");
assert_eq!(parse_quantization("Model-IQ4_NL.gguf"), "IQ4_NL");
assert_eq!(parse_quantization("Model-F16.gguf"), "F16");
assert_eq!(parse_quantization("random-name.gguf"), "unknown");
}
#[test]
fn test_parse_quantization_with_directory() {
assert_eq!(
parse_quantization("Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf"),
"Q5_K_M"
);
}
#[test]
fn test_parse_quantization_extended_tags() {
assert_eq!(parse_quantization("Model-MXFP4_MOE.gguf"), "MXFP4_MOE");
assert_eq!(parse_quantization("Model-UD-TQ1_0.gguf"), "TQ1_0");
assert_eq!(parse_quantization("Model-Q2_K_L.gguf"), "Q2_K_L");
assert_eq!(parse_quantization("Model-UD-Q4_K_XL.gguf"), "Q4_K_XL");
assert_eq!(parse_quantization("Model-UD-IQ1_M.gguf"), "IQ1_M");
}
#[test]
fn test_is_shard_file() {
assert!(is_shard_file("Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf"));
assert!(is_shard_file("Model-BF16-00003-of-00004.gguf"));
assert!(!is_shard_file("Model-Q4_K_M.gguf"));
}
#[test]
fn test_parse_model_spec() {
let (repo, quant) =
parse_model_spec("bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M").unwrap();
assert_eq!(repo, "bartowski/Llama-3.2-1B-Instruct-GGUF");
assert_eq!(quant, "Q4_K_M");
}
#[test]
fn test_parse_model_spec_invalid() {
assert!(parse_model_spec("no-colon").is_err());
assert!(parse_model_spec("noslash:Q4_K_M").is_err());
}
#[test]
fn test_recommend_variant() {
let variants = vec![
HfQuantVariant {
quantization: "Q2_K".into(),
size_bytes: 2_000_000_000,
filename: "m-Q2_K.gguf".into(),
download_url: String::new(),
description: "Small",
quality_rank: 7,
},
HfQuantVariant {
quantization: "Q4_K_M".into(),
size_bytes: 4_000_000_000,
filename: "m-Q4_K_M.gguf".into(),
download_url: String::new(),
description: "Medium",
quality_rank: 19,
},
HfQuantVariant {
quantization: "Q8_0".into(),
size_bytes: 8_000_000_000,
filename: "m-Q8_0.gguf".into(),
download_url: String::new(),
description: "Large",
quality_rank: 25,
},
];
assert_eq!(recommend_variant(&variants, 5_000_000_000), Some(1));
assert_eq!(recommend_variant(&variants, 10_000_000_000), Some(2));
assert_eq!(recommend_variant(&variants, 1_000_000_000), None);
}
}
@@ -0,0 +1,685 @@
//! Tool call emulation for models without native tool-calling support.
//!
//! The model is prompted to emit shell commands as `$ command` on a new line and
//! code blocks as `` ```execute `` fenced blocks. A streaming parser detects these
//! patterns and converts them into tool-call messages.
//!
//! # Known false-positive scenarios
//!
//! Because detection is purely text-based, the parser can misinterpret model output:
//!
//! - **`$` at line start in explanatory text.** If the model writes a line starting
//! with `$` as an example (e.g. "$ is the jQuery selector"), it will be treated as
//! a shell command. Mid-sentence `$` (e.g. "costs $50") is safe — only `\n$` or
//! `$` at the very start of output triggers command detection.
//!
//! - **`` ```execute `` in explanatory code fences.** If the model uses this exact
//! fence tag in prose, the content will be executed. Standard `` ```js `` or
//! `` ```python `` fences are not affected.
//!
//! These are inherent to text-based tool emulation. Models with native tool-calling
//! support should use the `inference_native_tools` path instead.
use crate::conversation::message::{Message, MessageContent};
use crate::providers::errors::ProviderError;
use llama_cpp_2::model::AddBos;
use rmcp::model::{CallToolRequestParams, Tool};
use serde_json::json;
use std::borrow::Cow;
use uuid::Uuid;
use super::inference_engine::{
create_and_prefill_context, generation_loop, validate_and_compute_context, GenerationContext,
TokenAction,
};
use super::{finalize_usage, StreamSender, CODE_EXECUTION_TOOL, SHELL_TOOL};
const HOLD_BACK_CODE_MODE: usize = " ```execute\n".len();
const HOLD_BACK_SHELL_ONLY: usize = "\n$".len();
pub(super) fn load_tiny_model_prompt() -> String {
use std::env;
let os = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"unknown"
};
let working_directory = env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let context = json!({
"os": os,
"working_directory": working_directory,
"shell": shell,
});
crate::prompt_template::render_template("tiny_model_system.md", &context).unwrap_or_else(|e| {
tracing::warn!("Failed to load tiny_model_system.md: {:?}", e);
"You are Goose, an AI assistant. You can execute shell commands by starting lines with $."
.to_string()
})
}
pub(super) fn build_emulator_tool_description(tools: &[Tool], code_mode_enabled: bool) -> String {
let mut tool_desc = String::new();
if code_mode_enabled {
tool_desc.push_str("\n\n# Running Code\n\n");
tool_desc.push_str(
"You can call tools by writing code in a ```execute block. \
The code runs immediately — do not explain it, just run it.\n\n",
);
tool_desc.push_str("Example — counting files in /tmp:\n\n");
tool_desc.push_str("```execute\nasync function run() {\n");
tool_desc.push_str(
" const result = await Developer.shell({ command: \"ls -1 /tmp | wc -l\" });\n",
);
tool_desc.push_str(" return result;\n}\n```\n\n");
tool_desc.push_str("Rules:\n");
tool_desc.push_str("- Code MUST define async function run() and return a result\n");
tool_desc.push_str("- All function calls are async — use await\n");
tool_desc.push_str("- Use ```execute for tool calls, $ for simple shell one-liners\n\n");
tool_desc.push_str("Available functions:\n\n");
for tool in tools {
if tool.name.starts_with("code_execution__") {
continue;
}
let parts: Vec<&str> = tool.name.splitn(2, "__").collect();
if parts.len() == 2 {
let namespace = {
let mut c = parts[0].chars();
match c.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(c).collect::<String>(),
}
};
let camel_name: String = parts[1]
.split('_')
.enumerate()
.map(|(i, part)| {
if i == 0 {
part.to_string()
} else {
let mut c = part.chars();
match c.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(c).collect(),
}
}
})
.collect();
let desc = tool.description.as_ref().map(|d| d.as_ref()).unwrap_or("");
tool_desc.push_str(&format!("- {namespace}.{camel_name}(): {desc}\n"));
}
}
} else {
tool_desc.push_str("\n\n# Tools\n\nYou have access to the following tools:\n\n");
for tool in tools {
let desc = tool
.description
.as_ref()
.map(|d| d.as_ref())
.unwrap_or("No description");
tool_desc.push_str(&format!("- {}: {}\n", tool.name, desc));
}
}
tool_desc
}
enum EmulatorAction {
Text(String),
ShellCommand(String),
ExecuteCode(String),
}
enum ParserState {
Normal,
InCommand,
InExecuteBlock,
}
struct StreamingEmulatorParser {
buffer: String,
state: ParserState,
code_mode_enabled: bool,
}
impl StreamingEmulatorParser {
fn new(code_mode_enabled: bool) -> Self {
Self {
buffer: String::new(),
state: ParserState::Normal,
code_mode_enabled,
}
}
fn process_chunk(&mut self, chunk: &str) -> Vec<EmulatorAction> {
self.buffer.push_str(chunk);
let mut results = Vec::new();
loop {
match self.state {
ParserState::InCommand => {
if let Some((command_line, rest)) = self.buffer.split_once('\n') {
if let Some(command) = command_line.strip_prefix('$') {
let command = command.trim();
if !command.is_empty() {
results.push(EmulatorAction::ShellCommand(command.to_string()));
}
}
self.buffer = rest.to_string();
self.state = ParserState::Normal;
} else {
break;
}
}
ParserState::InExecuteBlock => {
// Look for closing ``` to end the execute block
if let Some(end_idx) = self.buffer.find("\n```") {
#[allow(clippy::string_slice)]
let code = self.buffer[..end_idx].to_string();
// Skip past the closing ``` and any trailing newline
#[allow(clippy::string_slice)]
let rest = &self.buffer[end_idx + 4..];
let rest = rest.strip_prefix('\n').unwrap_or(rest);
self.buffer = rest.to_string();
self.state = ParserState::Normal;
if !code.trim().is_empty() {
results.push(EmulatorAction::ExecuteCode(code));
}
} else {
// Still accumulating code — wait for closing fence
break;
}
}
ParserState::Normal => {
// Check for ```execute block (code mode)
if self.code_mode_enabled {
if let Some((before, after)) = self.buffer.split_once("```execute\n") {
if !before.trim().is_empty() {
results.push(EmulatorAction::Text(before.to_string()));
}
self.buffer = after.to_string();
self.state = ParserState::InExecuteBlock;
continue;
}
// Also handle without newline after tag (accumulating)
if self.buffer.ends_with("```execute") {
let before = self.buffer.trim_end_matches("```execute");
if !before.trim().is_empty() {
results.push(EmulatorAction::Text(before.to_string()));
}
self.buffer.clear();
self.state = ParserState::InExecuteBlock;
continue;
}
}
// Check for $ command
if let Some((before_dollar, from_dollar)) = self.buffer.split_once("\n$") {
let text = format!("{}\n", before_dollar);
if !text.trim().is_empty() {
results.push(EmulatorAction::Text(text));
}
self.buffer = format!("${}", from_dollar);
self.state = ParserState::InCommand;
} else if self.buffer.starts_with('$') && self.buffer.len() == chunk.len() {
self.state = ParserState::InCommand;
} else {
let hold_back = if self.code_mode_enabled {
HOLD_BACK_CODE_MODE
} else {
HOLD_BACK_SHELL_ONLY
};
let char_count = self.buffer.chars().count();
if char_count > hold_back && !self.buffer.ends_with('\n') {
let mut chars = self.buffer.chars();
let emit_count = char_count - hold_back;
let emit_text: String = chars.by_ref().take(emit_count).collect();
let keep_text: String = chars.collect();
if !emit_text.is_empty() {
results.push(EmulatorAction::Text(emit_text));
}
self.buffer = keep_text;
}
break;
}
}
}
}
results
}
fn flush(&mut self) -> Vec<EmulatorAction> {
let mut results = Vec::new();
if !self.buffer.is_empty() {
match self.state {
ParserState::InCommand => {
let command_line = self.buffer.trim();
if let Some(command) = command_line.strip_prefix('$') {
let command = command.trim();
if !command.is_empty() {
results.push(EmulatorAction::ShellCommand(command.to_string()));
}
} else if !command_line.is_empty() {
results.push(EmulatorAction::Text(self.buffer.clone()));
}
}
ParserState::InExecuteBlock => {
let code = self.buffer.trim();
if !code.is_empty() {
results.push(EmulatorAction::ExecuteCode(code.to_string()));
}
}
ParserState::Normal => {
results.push(EmulatorAction::Text(self.buffer.clone()));
}
}
self.buffer.clear();
self.state = ParserState::Normal;
}
results
}
}
fn send_emulator_action(
action: &EmulatorAction,
message_id: &str,
tx: &StreamSender,
) -> Result<bool, ()> {
match action {
EmulatorAction::Text(text) => {
let mut message = Message::assistant().with_text(text);
message.id = Some(message_id.to_string());
tx.blocking_send(Ok((Some(message), None)))
.map_err(|_| ())?;
Ok(false)
}
EmulatorAction::ShellCommand(command) => {
let tool_id = Uuid::new_v4().to_string();
let mut args = serde_json::Map::new();
args.insert("command".to_string(), json!(command));
let tool_call = CallToolRequestParams {
meta: None,
task: None,
name: Cow::Borrowed(SHELL_TOOL),
arguments: Some(args),
};
let mut message = Message::assistant();
message
.content
.push(MessageContent::tool_request(tool_id, Ok(tool_call)));
message.id = Some(message_id.to_string());
tx.blocking_send(Ok((Some(message), None)))
.map_err(|_| ())?;
Ok(true)
}
EmulatorAction::ExecuteCode(code) => {
let tool_id = Uuid::new_v4().to_string();
let wrapped = if code.contains("async function run()") {
code.clone()
} else {
format!("async function run() {{\n{}\n}}", code)
};
let mut args = serde_json::Map::new();
args.insert("code".to_string(), json!(wrapped));
let tool_call = CallToolRequestParams {
meta: None,
task: None,
name: Cow::Borrowed(CODE_EXECUTION_TOOL),
arguments: Some(args),
};
let mut message = Message::assistant();
message
.content
.push(MessageContent::tool_request(tool_id, Ok(tool_call)));
message.id = Some(message_id.to_string());
tx.blocking_send(Ok((Some(message), None)))
.map_err(|_| ())?;
Ok(true)
}
}
}
pub(super) fn generate_with_emulated_tools(
ctx: &mut GenerationContext<'_>,
code_mode_enabled: bool,
) -> Result<(), ProviderError> {
let prompt = ctx
.loaded
.model
.apply_chat_template(&ctx.loaded.template, ctx.chat_messages, true)
.map_err(|e| {
ProviderError::ExecutionError(format!("Failed to apply chat template: {}", e))
})?;
let tokens = ctx
.loaded
.model
.str_to_token(&prompt, AddBos::Never)
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?;
let (prompt_token_count, effective_ctx) = validate_and_compute_context(
ctx.loaded,
ctx.runtime,
tokens.len(),
ctx.context_limit,
ctx.settings,
)?;
let mut llama_ctx = create_and_prefill_context(
ctx.loaded,
ctx.runtime,
&tokens,
effective_ctx,
ctx.settings,
)?;
let message_id = ctx.message_id;
let tx = ctx.tx;
let mut emulator_parser = StreamingEmulatorParser::new(code_mode_enabled);
let mut tool_call_emitted = false;
let mut send_failed = false;
let output_token_count = generation_loop(
&ctx.loaded.model,
&mut llama_ctx,
ctx.settings,
prompt_token_count,
effective_ctx,
|piece| {
let actions = emulator_parser.process_chunk(piece);
for action in actions {
match send_emulator_action(&action, message_id, tx) {
Ok(is_tool) => {
if is_tool {
tool_call_emitted = true;
}
}
Err(_) => {
send_failed = true;
return Ok(TokenAction::Stop);
}
}
}
if tool_call_emitted {
Ok(TokenAction::Stop)
} else {
Ok(TokenAction::Continue)
}
},
)?;
if !send_failed {
for action in emulator_parser.flush() {
if send_emulator_action(&action, message_id, tx).is_err() {
break;
}
}
}
let provider_usage = finalize_usage(
ctx.log,
std::mem::take(&mut ctx.model_name),
"emulator",
prompt_token_count,
output_token_count,
None,
);
let _ = ctx.tx.blocking_send(Ok((None, Some(provider_usage))));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// Collect all actions from feeding chunks through the parser, then flushing.
fn parse_chunks(chunks: &[&str], code_mode: bool) -> Vec<EmulatorAction> {
let mut parser = StreamingEmulatorParser::new(code_mode);
let mut actions = Vec::new();
for chunk in chunks {
actions.extend(parser.process_chunk(chunk));
}
actions.extend(parser.flush());
actions
}
fn parse_all(input: &str, code_mode: bool) -> Vec<EmulatorAction> {
parse_chunks(&[input], code_mode)
}
fn assert_text(action: &EmulatorAction, expected: &str) {
match action {
EmulatorAction::Text(t) => assert_eq!(t.trim(), expected.trim(), "text mismatch"),
other => panic!("expected Text, got {:?}", action_label(other)),
}
}
fn assert_shell(action: &EmulatorAction, expected: &str) {
match action {
EmulatorAction::ShellCommand(cmd) => {
assert_eq!(cmd, expected, "shell command mismatch")
}
other => panic!("expected ShellCommand, got {:?}", action_label(other)),
}
}
fn assert_execute(action: &EmulatorAction, expected: &str) {
match action {
EmulatorAction::ExecuteCode(code) => {
assert_eq!(code.trim(), expected.trim(), "execute code mismatch")
}
other => panic!("expected ExecuteCode, got {:?}", action_label(other)),
}
}
fn action_label(a: &EmulatorAction) -> &'static str {
match a {
EmulatorAction::Text(_) => "Text",
EmulatorAction::ShellCommand(_) => "ShellCommand",
EmulatorAction::ExecuteCode(_) => "ExecuteCode",
}
}
#[test]
fn plain_text_no_tools() {
let actions = parse_all("Hello, world!", false);
// Hold-back may split text across actions; concatenate all text
let all_text: String = actions
.iter()
.map(|a| match a {
EmulatorAction::Text(t) => t.as_str(),
_ => panic!("expected only Text actions"),
})
.collect();
assert_eq!(all_text.trim(), "Hello, world!");
}
#[test]
fn single_shell_command() {
let actions = parse_all("$ ls -la\n", false);
assert_eq!(actions.len(), 1);
assert_shell(&actions[0], "ls -la");
}
#[test]
fn text_then_shell_command() {
let actions = parse_all("Let me check:\n$ ls -la\n", false);
assert!(actions.len() >= 2);
assert_text(&actions[0], "Let me check:");
assert_shell(&actions[actions.len() - 1], "ls -la");
}
#[test]
fn shell_command_at_start_of_output() {
let actions = parse_all("$ whoami\n", false);
assert_eq!(actions.len(), 1);
assert_shell(&actions[0], "whoami");
}
#[test]
fn shell_command_without_trailing_newline() {
// Flush should handle unterminated command
let actions = parse_all("$ whoami", false);
assert_eq!(actions.len(), 1);
assert_shell(&actions[0], "whoami");
}
#[test]
fn dollar_sign_mid_sentence_is_not_command() {
let actions = parse_all("It costs $50 per month", false);
for action in &actions {
assert!(
matches!(action, EmulatorAction::Text(_)),
"mid-sentence $ should not trigger a shell command"
);
}
let all_text: String = actions
.iter()
.filter_map(|a| match a {
EmulatorAction::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(all_text.trim(), "It costs $50 per month");
}
#[test]
fn execute_block() {
let input = "Here's the code:\n```execute\nconsole.log('hi');\n```\n";
let actions = parse_all(input, true);
assert!(actions.len() >= 2);
assert_text(&actions[0], "Here's the code:");
assert_execute(&actions[actions.len() - 1], "console.log('hi');");
}
#[test]
fn execute_block_not_detected_without_code_mode() {
let input = "```execute\nconsole.log('hi');\n```\n";
let actions = parse_all(input, false);
// Should be treated as plain text
for action in &actions {
assert!(matches!(action, EmulatorAction::Text(_)));
}
}
#[test]
fn dollar_split_across_chunks() {
// The \n and $ arrive in separate chunks
let actions = parse_chunks(&["Let me check\n", "$ ls -la\n"], false);
let shells: Vec<_> = actions
.iter()
.filter(|a| matches!(a, EmulatorAction::ShellCommand(_)))
.collect();
assert_eq!(shells.len(), 1);
assert_shell(shells[0], "ls -la");
}
#[test]
fn execute_fence_split_across_chunks() {
let actions = parse_chunks(&["Here:\n```ex", "ecute\nlet x = 1;\n", "```\n"], true);
let executes: Vec<_> = actions
.iter()
.filter(|a| matches!(a, EmulatorAction::ExecuteCode(_)))
.collect();
assert_eq!(executes.len(), 1);
assert_execute(executes[0], "let x = 1;");
}
#[test]
fn multiple_commands_on_separate_lines() {
// In practice, generation stops after the first tool call. But the
// parser should detect commands separated by \n$ when fed as chunks.
let actions = parse_chunks(&["Here:\n$ cd /tmp\n", "Done.\n$ ls\n"], false);
let shells: Vec<_> = actions
.iter()
.filter(|a| matches!(a, EmulatorAction::ShellCommand(_)))
.collect();
assert_eq!(shells.len(), 2);
assert_shell(shells[0], "cd /tmp");
assert_shell(shells[1], "ls");
}
#[test]
fn regular_code_fence_not_treated_as_execute() {
let input = "```python\nprint('hi')\n```\n";
let actions = parse_all(input, true);
for action in &actions {
assert!(
matches!(action, EmulatorAction::Text(_)),
"regular code fence should be text"
);
}
}
#[test]
fn empty_command_ignored() {
let actions = parse_all("$\n", false);
// Empty command after $ should not produce a ShellCommand
let shells: Vec<_> = actions
.iter()
.filter(|a| matches!(a, EmulatorAction::ShellCommand(_)))
.collect();
assert_eq!(shells.len(), 0);
}
#[test]
fn token_by_token_streaming() {
// Simulate LLM generating one token at a time
let input = "$ echo hello\n";
let chars: Vec<String> = input.chars().map(|c| c.to_string()).collect();
let chunks: Vec<&str> = chars.iter().map(|s| s.as_str()).collect();
let actions = parse_chunks(&chunks, false);
let shells: Vec<_> = actions
.iter()
.filter(|a| matches!(a, EmulatorAction::ShellCommand(_)))
.collect();
assert_eq!(shells.len(), 1);
assert_shell(shells[0], "echo hello");
}
#[test]
fn execute_block_with_multiline_code() {
let input = "```execute\nasync function run() {\n const r = await Developer.shell({ command: \"ls\" });\n return r;\n}\n```\n";
let actions = parse_all(input, true);
let executes: Vec<_> = actions
.iter()
.filter(|a| matches!(a, EmulatorAction::ExecuteCode(_)))
.collect();
assert_eq!(executes.len(), 1);
match executes[0] {
EmulatorAction::ExecuteCode(code) => {
assert!(code.contains("async function run()"));
assert!(code.contains("Developer.shell"));
}
_ => unreachable!(),
}
}
#[test]
fn unclosed_execute_block_flushed() {
// Model stops generating mid-block
let input = "```execute\nlet x = 1;";
let actions = parse_all(input, true);
let executes: Vec<_> = actions
.iter()
.filter(|a| matches!(a, EmulatorAction::ExecuteCode(_)))
.collect();
assert_eq!(executes.len(), 1);
assert_execute(executes[0], "let x = 1;");
}
}
@@ -0,0 +1,387 @@
use crate::providers::errors::ProviderError;
use crate::providers::local_inference::local_model_registry::ModelSettings;
use crate::providers::utils::RequestLog;
use llama_cpp_2::context::params::LlamaContextParams;
use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::{LlamaChatMessage, LlamaChatTemplate, LlamaModel};
use llama_cpp_2::sampling::LlamaSampler;
use std::num::NonZeroU32;
use super::{InferenceRuntime, StreamSender};
pub(super) struct GenerationContext<'a> {
pub loaded: &'a LoadedModel,
pub runtime: &'a InferenceRuntime,
pub chat_messages: &'a [LlamaChatMessage],
pub settings: &'a ModelSettings,
pub context_limit: usize,
pub model_name: String,
pub message_id: &'a str,
pub tx: &'a StreamSender,
pub log: &'a mut RequestLog,
}
pub(super) struct LoadedModel {
pub model: LlamaModel,
pub template: LlamaChatTemplate,
}
/// Estimate the maximum context length that can fit in available accelerator/CPU
/// memory based on the model's KV cache requirements.
///
/// Returns `None` if the model architecture values are unavailable.
pub(super) fn estimate_max_context_for_memory(
model: &LlamaModel,
runtime: &InferenceRuntime,
) -> Option<usize> {
let available = super::available_inference_memory_bytes(runtime);
if available == 0 {
return None;
}
// Reserve memory for computation scratch buffers (attention, etc.) and other overhead.
// The compute buffer can be 40-50% of the KV cache size for large models, so we
// conservatively use only half the available memory for the KV cache.
let usable = (available as f64 * 0.5) as u64;
let n_layer = model.n_layer() as u64;
let n_head_kv = model.n_head_kv() as u64;
let n_head = model.n_head() as u64;
let n_embd = model.n_embd() as u64;
if n_head == 0 || n_layer == 0 || n_head_kv == 0 || n_embd == 0 {
return None;
}
// For MLA (Multi-head Latent Attention) models like DeepSeek/GLM, the actual KV cache
// dimensions differ from n_head_kv * head_dim. Read the true dimensions from GGUF metadata.
let arch = model
.meta_val_str("general.architecture")
.unwrap_or_default();
let head_dim = n_embd / n_head;
let k_per_head = model
.meta_val_str(&format!("{arch}.attention.key_length"))
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(head_dim);
let v_per_head = model
.meta_val_str(&format!("{arch}.attention.value_length"))
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(head_dim);
// Total KV dimensions across all KV heads, times n_layer, times 2 bytes (f16) per element
let bytes_per_token = (k_per_head + v_per_head) * n_head_kv * n_layer * 2;
if bytes_per_token == 0 {
return None;
}
Some((usable / bytes_per_token) as usize)
}
pub(super) fn context_cap(
settings: &crate::providers::local_inference::local_model_registry::ModelSettings,
context_limit: usize,
n_ctx_train: usize,
memory_max_ctx: Option<usize>,
) -> usize {
if let Some(ctx_size) = settings.context_size {
return ctx_size as usize;
}
let limit = if context_limit > 0 {
context_limit
} else {
n_ctx_train
};
match memory_max_ctx {
Some(mem_max) if mem_max < limit => {
tracing::info!(
"Capping context from {} to {} based on available memory",
limit,
mem_max,
);
mem_max
}
_ => limit,
}
}
pub(super) fn effective_context_size(
prompt_token_count: usize,
settings: &crate::providers::local_inference::local_model_registry::ModelSettings,
context_limit: usize,
n_ctx_train: usize,
memory_max_ctx: Option<usize>,
) -> usize {
let limit = context_cap(settings, context_limit, n_ctx_train, memory_max_ctx);
let min_generation_headroom = 512;
let needed = prompt_token_count + min_generation_headroom;
if needed > limit {
tracing::warn!(
"Prompt ({} tokens) + headroom exceeds context limit ({}), capping to limit",
prompt_token_count,
limit,
);
}
needed.min(limit)
}
pub(super) fn build_context_params(
ctx_size: u32,
settings: &crate::providers::local_inference::local_model_registry::ModelSettings,
) -> LlamaContextParams {
let mut params = LlamaContextParams::default().with_n_ctx(NonZeroU32::new(ctx_size));
if let Some(n_batch) = settings.n_batch {
params = params.with_n_batch(n_batch);
}
if let Some(n_threads) = settings.n_threads {
params = params.with_n_threads(n_threads);
params = params.with_n_threads_batch(n_threads);
}
if let Some(flash_attn) = settings.flash_attention {
let policy = if flash_attn { 1 } else { 0 };
params = params.with_flash_attention_policy(policy);
}
params
}
pub(super) fn build_sampler(
settings: &crate::providers::local_inference::local_model_registry::ModelSettings,
) -> LlamaSampler {
use crate::providers::local_inference::local_model_registry::SamplingConfig;
let has_penalties = settings.repeat_penalty != 1.0
|| settings.frequency_penalty != 0.0
|| settings.presence_penalty != 0.0;
let mut samplers: Vec<LlamaSampler> = Vec::new();
if has_penalties {
samplers.push(LlamaSampler::penalties(
settings.repeat_last_n,
settings.repeat_penalty,
settings.frequency_penalty,
settings.presence_penalty,
));
}
match &settings.sampling {
SamplingConfig::Greedy => {
samplers.push(LlamaSampler::greedy());
}
SamplingConfig::Temperature {
temperature,
top_k,
top_p,
min_p,
seed,
} => {
samplers.push(LlamaSampler::top_k(*top_k));
samplers.push(LlamaSampler::top_p(*top_p, 1));
samplers.push(LlamaSampler::min_p(*min_p, 1));
samplers.push(LlamaSampler::temp(*temperature));
samplers.push(LlamaSampler::dist(seed.unwrap_or(0)));
}
SamplingConfig::MirostatV2 { tau, eta, seed } => {
samplers.push(LlamaSampler::mirostat_v2(seed.unwrap_or(0), *tau, *eta));
}
}
if samplers.len() == 1 {
samplers.pop().unwrap()
} else {
LlamaSampler::chain_simple(samplers)
}
}
/// Validate prompt tokens against memory limits and compute the effective
/// context size. Returns `(prompt_token_count, effective_ctx)`.
pub(super) fn validate_and_compute_context(
loaded: &LoadedModel,
runtime: &InferenceRuntime,
prompt_token_count: usize,
context_limit: usize,
settings: &crate::providers::local_inference::local_model_registry::ModelSettings,
) -> Result<(usize, usize), ProviderError> {
let n_ctx_train = loaded.model.n_ctx_train() as usize;
let memory_max_ctx = estimate_max_context_for_memory(&loaded.model, runtime);
let effective_ctx = effective_context_size(
prompt_token_count,
settings,
context_limit,
n_ctx_train,
memory_max_ctx,
);
if let Some(mem_max) = memory_max_ctx {
if prompt_token_count > mem_max {
return Err(ProviderError::ContextLengthExceeded(format!(
"Prompt ({} tokens) exceeds estimated memory capacity ({} tokens). \
Try a smaller model or reduce conversation length.",
prompt_token_count, mem_max,
)));
}
}
Ok((prompt_token_count, effective_ctx))
}
/// Create a llama context and prefill (decode) all prompt tokens.
pub(super) fn create_and_prefill_context<'model>(
loaded: &'model LoadedModel,
runtime: &InferenceRuntime,
tokens: &[llama_cpp_2::token::LlamaToken],
effective_ctx: usize,
settings: &crate::providers::local_inference::local_model_registry::ModelSettings,
) -> Result<llama_cpp_2::context::LlamaContext<'model>, ProviderError> {
let ctx_params = build_context_params(effective_ctx as u32, settings);
let mut ctx = loaded
.model
.new_context(runtime.backend(), ctx_params)
.map_err(|e| ProviderError::ExecutionError(format!("Failed to create context: {}", e)))?;
let n_batch = ctx.n_batch() as usize;
for chunk in tokens.chunks(n_batch) {
let mut batch = LlamaBatch::get_one(chunk)
.map_err(|e| ProviderError::ExecutionError(format!("Failed to create batch: {}", e)))?;
ctx.decode(&mut batch)
.map_err(|e| ProviderError::ExecutionError(format!("Prefill decode failed: {}", e)))?;
}
Ok(ctx)
}
/// Action to take after processing a generated token piece.
pub(super) enum TokenAction {
Continue,
Stop,
}
/// Run the autoregressive generation loop. Calls `on_piece` for each non-empty
/// token piece. The callback returns `TokenAction::Stop` to break early.
/// Returns the total number of generated tokens.
pub(super) fn generation_loop(
model: &LlamaModel,
ctx: &mut llama_cpp_2::context::LlamaContext<'_>,
settings: &crate::providers::local_inference::local_model_registry::ModelSettings,
prompt_token_count: usize,
effective_ctx: usize,
mut on_piece: impl FnMut(&str) -> Result<TokenAction, ProviderError>,
) -> Result<i32, ProviderError> {
let mut sampler = build_sampler(settings);
let max_output = if let Some(max) = settings.max_output_tokens {
effective_ctx.saturating_sub(prompt_token_count).min(max)
} else {
effective_ctx.saturating_sub(prompt_token_count)
};
let mut decoder = encoding_rs::UTF_8.new_decoder();
let mut output_token_count: i32 = 0;
for _ in 0..max_output {
let token = sampler.sample(ctx, -1);
sampler.accept(token);
if model.is_eog_token(token) {
break;
}
output_token_count += 1;
let piece = model
.token_to_piece(token, &mut decoder, true, None)
.map_err(|e| ProviderError::ExecutionError(format!("Failed to decode token: {}", e)))?;
if !piece.is_empty() && matches!(on_piece(&piece)?, TokenAction::Stop) {
break;
}
let next_tokens = [token];
let mut next_batch = LlamaBatch::get_one(&next_tokens)
.map_err(|e| ProviderError::ExecutionError(format!("Failed to create batch: {}", e)))?;
ctx.decode(&mut next_batch)
.map_err(|e| ProviderError::ExecutionError(format!("Decode failed: {}", e)))?;
}
Ok(output_token_count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::local_inference::local_model_registry::ModelSettings;
fn default_settings() -> ModelSettings {
ModelSettings::default()
}
#[test]
fn test_effective_context_size_basic() {
assert_eq!(
effective_context_size(100, &default_settings(), 4096, 4096, None),
612
);
}
#[test]
fn test_effective_context_size_capped_by_limit() {
assert_eq!(
effective_context_size(100, &default_settings(), 1024, 8192, None),
612
);
}
#[test]
fn test_effective_context_size_capped_by_memory() {
assert_eq!(
effective_context_size(100, &default_settings(), 4096, 4096, Some(800)),
612
);
}
#[test]
fn test_effective_context_size_memory_smaller_than_needed() {
assert_eq!(
effective_context_size(600, &default_settings(), 4096, 4096, Some(700)),
700
);
}
#[test]
fn test_effective_context_size_zero_limit_uses_train() {
assert_eq!(
effective_context_size(100, &default_settings(), 0, 2048, None),
612
);
}
#[test]
fn test_effective_context_size_prompt_exceeds_all_limits() {
assert_eq!(
effective_context_size(5000, &default_settings(), 4096, 4096, None),
4096
);
}
#[test]
fn test_context_cap_with_settings_override() {
let mut settings = default_settings();
settings.context_size = Some(2048);
assert_eq!(context_cap(&settings, 4096, 8192, Some(1024)), 2048);
}
#[test]
fn test_context_cap_without_override() {
assert_eq!(context_cap(&default_settings(), 4096, 8192, None), 4096);
}
#[test]
fn test_context_cap_memory_limited() {
assert_eq!(
context_cap(&default_settings(), 4096, 8192, Some(2048)),
2048
);
}
}
@@ -0,0 +1,197 @@
use crate::conversation::message::Message;
use crate::providers::errors::ProviderError;
use llama_cpp_2::model::AddBos;
use llama_cpp_2::openai::OpenAIChatTemplateParams;
use super::finalize_usage;
use super::inference_engine::{
context_cap, create_and_prefill_context, estimate_max_context_for_memory, generation_loop,
validate_and_compute_context, GenerationContext, TokenAction,
};
use super::tool_parsing::{
extract_tool_call_messages, extract_xml_tool_call_messages, safe_stream_end,
split_content_and_tool_calls, split_content_and_xml_tool_calls,
};
pub(super) fn generate_with_native_tools(
ctx: &mut GenerationContext<'_>,
oai_messages_json: &Option<String>,
full_tools_json: Option<&str>,
compact_tools: Option<&str>,
) -> Result<(), ProviderError> {
let min_generation_headroom = 512;
let n_ctx_train = ctx.loaded.model.n_ctx_train() as usize;
let memory_max_ctx = estimate_max_context_for_memory(&ctx.loaded.model, ctx.runtime);
let cap = context_cap(ctx.settings, ctx.context_limit, n_ctx_train, memory_max_ctx);
let token_budget = cap.saturating_sub(min_generation_headroom);
let apply_template = |tools: Option<&str>| {
if let Some(ref messages_json) = oai_messages_json {
let params = OpenAIChatTemplateParams {
messages_json: messages_json.as_str(),
tools_json: tools,
tool_choice: None,
json_schema: None,
grammar: None,
reasoning_format: None,
chat_template_kwargs: None,
add_generation_prompt: true,
use_jinja: true,
parallel_tool_calls: false,
enable_thinking: false,
add_bos: false,
add_eos: false,
parse_tool_calls: true,
};
ctx.loaded
.model
.apply_chat_template_oaicompat(&ctx.loaded.template, &params)
} else {
ctx.loaded.model.apply_chat_template_with_tools_oaicompat(
&ctx.loaded.template,
ctx.chat_messages,
tools,
None,
true,
)
}
};
let template_result = match apply_template(full_tools_json) {
Ok(r) => {
let token_count = ctx
.loaded
.model
.str_to_token(&r.prompt, AddBos::Never)
.map(|t| t.len())
.unwrap_or(0);
if token_count > token_budget {
apply_template(compact_tools).unwrap_or(r)
} else {
r
}
}
Err(_) => apply_template(compact_tools).map_err(|e| {
ProviderError::ExecutionError(format!("Failed to apply chat template: {}", e))
})?,
};
let _ = ctx.log.write(
&serde_json::json!({"applied_prompt": &template_result.prompt}),
None,
);
let tokens = ctx
.loaded
.model
.str_to_token(&template_result.prompt, AddBos::Never)
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?;
let (prompt_token_count, effective_ctx) = validate_and_compute_context(
ctx.loaded,
ctx.runtime,
tokens.len(),
ctx.context_limit,
ctx.settings,
)?;
let mut llama_ctx = create_and_prefill_context(
ctx.loaded,
ctx.runtime,
&tokens,
effective_ctx,
ctx.settings,
)?;
let message_id = ctx.message_id;
let tx = ctx.tx;
let mut generated_text = String::new();
let mut streamed_len: usize = 0;
let output_token_count = generation_loop(
&ctx.loaded.model,
&mut llama_ctx,
ctx.settings,
prompt_token_count,
effective_ctx,
|piece| {
generated_text.push_str(piece);
let has_xml_tc = split_content_and_xml_tool_calls(&generated_text).is_some();
let (content, tc) = split_content_and_tool_calls(&generated_text);
let stream_up_to = if tc.is_some() {
content.len()
} else if has_xml_tc {
split_content_and_xml_tool_calls(&generated_text)
.map(|(c, _)| c.len())
.unwrap_or(0)
} else {
safe_stream_end(&generated_text)
};
if stream_up_to > streamed_len {
#[allow(clippy::string_slice)]
let new_text = &generated_text[streamed_len..stream_up_to];
if !new_text.is_empty() {
let mut msg = Message::assistant().with_text(new_text);
msg.id = Some(message_id.to_string());
if tx.blocking_send(Ok((Some(msg), None))).is_err() {
return Ok(TokenAction::Stop);
}
}
streamed_len = stream_up_to;
}
let should_stop = template_result
.additional_stops
.iter()
.any(|stop| generated_text.ends_with(stop));
if should_stop {
Ok(TokenAction::Stop)
} else {
Ok(TokenAction::Continue)
}
},
)?;
let (content, tool_call_msgs) =
if let Some((xml_content, xml_calls)) = split_content_and_xml_tool_calls(&generated_text) {
let msgs = extract_xml_tool_call_messages(xml_calls, message_id);
(xml_content, msgs)
} else {
let (json_content, tool_calls_json) = split_content_and_tool_calls(&generated_text);
let msgs = tool_calls_json
.map(|tc| extract_tool_call_messages(&tc, message_id))
.unwrap_or_default();
(json_content, msgs)
};
if content.len() > streamed_len {
#[allow(clippy::string_slice)]
let remaining = &content[streamed_len..];
if !remaining.is_empty() {
let mut msg = Message::assistant().with_text(remaining);
msg.id = Some(message_id.to_string());
let _ = tx.blocking_send(Ok((Some(msg), None)));
}
}
if !tool_call_msgs.is_empty() {
for msg in tool_call_msgs {
let _ = tx.blocking_send(Ok((Some(msg), None)));
}
} else if content.is_empty() && !generated_text.is_empty() {
let mut msg = Message::assistant().with_text(&generated_text);
msg.id = Some(message_id.to_string());
let _ = tx.blocking_send(Ok((Some(msg), None)));
}
let provider_usage = finalize_usage(
ctx.log,
std::mem::take(&mut ctx.model_name),
"native",
prompt_token_count,
output_token_count,
Some(("generated_text", &generated_text)),
);
let _ = ctx.tx.blocking_send(Ok((None, Some(provider_usage))));
Ok(())
}
@@ -0,0 +1,307 @@
use crate::config::paths::Paths;
use crate::download_manager::{get_download_manager, DownloadStatus};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "type")]
pub enum SamplingConfig {
Greedy,
Temperature {
temperature: f32,
top_k: i32,
top_p: f32,
min_p: f32,
seed: Option<u32>,
},
MirostatV2 {
tau: f32,
eta: f32,
seed: Option<u32>,
},
}
impl Default for SamplingConfig {
fn default() -> Self {
SamplingConfig::Temperature {
temperature: 0.8,
top_k: 40,
top_p: 0.95,
min_p: 0.05,
seed: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ModelSettings {
pub context_size: Option<u32>,
pub max_output_tokens: Option<usize>,
#[serde(default)]
pub sampling: SamplingConfig,
#[serde(default = "default_repeat_penalty")]
pub repeat_penalty: f32,
#[serde(default = "default_repeat_last_n")]
pub repeat_last_n: i32,
#[serde(default)]
pub frequency_penalty: f32,
#[serde(default)]
pub presence_penalty: f32,
pub n_batch: Option<u32>,
pub n_gpu_layers: Option<u32>,
#[serde(default)]
pub use_mlock: bool,
pub flash_attention: Option<bool>,
pub n_threads: Option<i32>,
#[serde(default)]
pub native_tool_calling: bool,
#[serde(default)]
pub use_jinja: bool,
}
fn default_repeat_penalty() -> f32 {
1.0
}
fn default_repeat_last_n() -> i32 {
64
}
impl Default for ModelSettings {
fn default() -> Self {
Self {
context_size: None,
max_output_tokens: None,
sampling: SamplingConfig::default(),
repeat_penalty: 1.0,
repeat_last_n: 64,
frequency_penalty: 0.0,
presence_penalty: 0.0,
n_batch: None,
n_gpu_layers: None,
use_mlock: false,
flash_attention: None,
n_threads: None,
native_tool_calling: false,
use_jinja: false,
}
}
}
/// Featured models — HuggingFace specs in "author/repo-GGUF:quantization" format.
pub const FEATURED_MODELS: &[&str] = &[
"bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M",
"bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M",
"bartowski/Hermes-2-Pro-Mistral-7B-GGUF:Q4_K_M",
"bartowski/Mistral-Small-24B-Instruct-2501-GGUF:Q4_K_M",
];
/// Check if a model ID corresponds to a featured model.
pub fn is_featured_model(model_id: &str) -> bool {
use super::hf_models::parse_model_spec;
FEATURED_MODELS.iter().any(|spec| {
if let Ok((repo_id, quant)) = parse_model_spec(spec) {
model_id_from_repo(&repo_id, &quant) == model_id
} else {
false
}
})
}
static REGISTRY: OnceLock<Mutex<LocalModelRegistry>> = OnceLock::new();
pub fn get_registry() -> &'static Mutex<LocalModelRegistry> {
REGISTRY.get_or_init(|| {
let registry = LocalModelRegistry::load().unwrap_or_default();
Mutex::new(registry)
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalModelEntry {
pub id: String,
pub repo_id: String,
pub filename: String,
pub quantization: String,
pub local_path: PathBuf,
pub source_url: String,
#[serde(default)]
pub settings: ModelSettings,
#[serde(default)]
pub size_bytes: u64,
}
impl LocalModelEntry {
pub fn is_downloaded(&self) -> bool {
self.local_path.exists()
}
pub fn is_downloading(&self) -> bool {
let download_id = format!("{}-model", self.id);
let manager = get_download_manager();
manager.get_progress(&download_id).is_some()
}
pub fn download_status(&self) -> ModelDownloadStatus {
if self.local_path.exists() {
return ModelDownloadStatus::Downloaded;
}
let download_id = format!("{}-model", self.id);
let manager = get_download_manager();
if let Some(progress) = manager.get_progress(&download_id) {
return match progress.status {
DownloadStatus::Downloading => ModelDownloadStatus::Downloading {
progress_percent: progress.progress_percent,
bytes_downloaded: progress.bytes_downloaded,
total_bytes: progress.total_bytes,
speed_bps: progress.speed_bps.unwrap_or(0),
},
DownloadStatus::Completed => ModelDownloadStatus::Downloaded,
DownloadStatus::Failed | DownloadStatus::Cancelled => {
ModelDownloadStatus::NotDownloaded
}
};
}
ModelDownloadStatus::NotDownloaded
}
pub fn file_size(&self) -> u64 {
if self.size_bytes > 0 {
return self.size_bytes;
}
std::fs::metadata(&self.local_path)
.map(|m| m.len())
.unwrap_or(0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ModelDownloadStatus {
NotDownloaded,
Downloading {
progress_percent: f32,
bytes_downloaded: u64,
total_bytes: u64,
speed_bps: u64,
},
Downloaded,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LocalModelRegistry {
pub models: Vec<LocalModelEntry>,
}
impl LocalModelRegistry {
fn registry_path() -> PathBuf {
Paths::in_data_dir("models/registry.json")
}
pub fn load() -> Result<Self> {
let path = Self::registry_path();
if path.exists() {
let lock_path = path.with_extension("json.lock");
let lock_file = std::fs::File::create(&lock_path)?;
fs2::FileExt::lock_shared(&lock_file)?;
let contents = std::fs::read_to_string(&path)?;
fs2::FileExt::unlock(&lock_file)?;
let registry: LocalModelRegistry = serde_json::from_str(&contents)?;
Ok(registry)
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
let path = Self::registry_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let lock_path = path.with_extension("json.lock");
let lock_file = std::fs::File::create(&lock_path)?;
fs2::FileExt::lock_exclusive(&lock_file)?;
let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
let contents = serde_json::to_string_pretty(self)?;
std::io::Write::write_all(&mut tmp, contents.as_bytes())?;
tmp.persist(&path)?;
fs2::FileExt::unlock(&lock_file)?;
Ok(())
}
/// Sync registry with featured models:
/// add any featured models that are missing, remove non-downloaded non-featured models.
pub fn sync_with_featured(&mut self, featured_entries: Vec<LocalModelEntry>) {
let mut changed = false;
for entry in featured_entries {
if !self.models.iter().any(|m| m.id == entry.id) {
self.models.push(entry);
changed = true;
}
}
let before_len = self.models.len();
self.models
.retain(|m| m.is_downloaded() || m.is_downloading() || is_featured_model(&m.id));
if self.models.len() != before_len {
changed = true;
}
if changed {
let _ = self.save();
}
}
pub fn add_model(&mut self, entry: LocalModelEntry) -> Result<()> {
if let Some(existing) = self.models.iter_mut().find(|m| m.id == entry.id) {
*existing = entry;
} else {
self.models.push(entry);
}
self.save()
}
pub fn remove_model(&mut self, id: &str) -> Result<()> {
self.models.retain(|m| m.id != id);
self.save()
}
pub fn get_model(&self, id: &str) -> Option<&LocalModelEntry> {
self.models.iter().find(|m| m.id == id)
}
pub fn has_model(&self, id: &str) -> bool {
self.models.iter().any(|m| m.id == id)
}
pub fn get_model_settings(&self, id: &str) -> Option<&ModelSettings> {
self.models.iter().find(|m| m.id == id).map(|m| &m.settings)
}
pub fn update_model_settings(&mut self, id: &str, settings: ModelSettings) -> Result<()> {
let entry = self
.models
.iter_mut()
.find(|m| m.id == id)
.ok_or_else(|| anyhow::anyhow!("Model not found: {}", id))?;
entry.settings = settings;
self.save()
}
pub fn list_models(&self) -> &[LocalModelEntry] {
&self.models
}
}
/// Generate a unique ID for a model from its repo_id and quantization.
pub fn model_id_from_repo(repo_id: &str, quantization: &str) -> String {
format!("{}:{}", repo_id, quantization)
}
@@ -0,0 +1,601 @@
use crate::conversation::message::{Message, MessageContent};
use rmcp::model::{CallToolRequestParams, Tool};
use serde_json::{json, Value};
use std::borrow::Cow;
use uuid::Uuid;
pub(super) fn compact_tools_json(tools: &[Tool]) -> Option<String> {
let compact: Vec<Value> = tools
.iter()
.map(|t| {
json!({
"type": "function",
"function": {
"name": t.name,
"description": t.description.as_ref().map(|d| d.as_ref()).unwrap_or(""),
}
})
})
.collect();
serde_json::to_string(&compact).ok()
}
/// Split generated text into (content, tool_calls_json).
/// Looks for the last top-level JSON object containing `"tool_calls"`.
/// Returns the text before it as content, and the JSON string if found.
#[allow(clippy::string_slice)]
pub(super) fn split_content_and_tool_calls(text: &str) -> (String, Option<String>) {
let trimmed = text.trim_end();
if !trimmed.ends_with('}') {
return (text.to_string(), None);
}
// Scan backwards for the matching '{' of the final '}'.
// We only match on ASCII braces so `start` is always a char boundary.
let bytes = trimmed.as_bytes();
let mut depth = 0i32;
let mut json_start = None;
for i in (0..bytes.len()).rev() {
match bytes[i] {
b'}' => depth += 1,
b'{' => {
depth -= 1;
if depth == 0 {
json_start = Some(i);
break;
}
}
_ => {}
}
}
let Some(start) = json_start else {
return (text.to_string(), None);
};
let json_str = &trimmed[start..];
let parsed: Value = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(_) => return (text.to_string(), None),
};
if parsed
.get("tool_calls")
.and_then(|v| v.as_array())
.is_none()
{
return (text.to_string(), None);
}
let content = trimmed[..start].trim_end().to_string();
(content, Some(json_str.to_string()))
}
/// Return the byte length of text that is safe to stream.
/// Everything before the last unmatched top-level `{` is safe — the `{` could
/// be the start of a tool-call JSON block still being generated.
/// If all braces are balanced the entire text is safe.
pub(super) fn safe_stream_end(text: &str) -> usize {
// Hold back from the start of any incomplete <tool_call> tag.
// If we find an unmatched opening, nothing from that point should be streamed.
let xml_hold = text.find("<tool_call>").unwrap_or(text.len());
let bytes = text.as_bytes();
let mut safe_end = bytes.len();
let mut depth = 0i32;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'{' => {
if depth == 0 {
safe_end = i;
}
depth += 1;
}
b'}' => {
depth -= 1;
if depth == 0 {
safe_end = i + 1;
}
}
_ => {
if depth == 0 {
safe_end = i + 1;
}
}
}
}
// Also hold back a partial `<tool_call` prefix at the end of the text.
// The tag is 11 chars; if the last N chars are a prefix of `<tool_call>`, hold them.
let tag = b"<tool_call>";
let tail_hold = {
let mut hold = safe_end;
let check_len = tag.len().min(bytes.len());
for start in (safe_end.saturating_sub(check_len))..safe_end {
let tail = &bytes[start..safe_end];
if tag.starts_with(tail) {
hold = start;
break;
}
}
hold
};
safe_end.min(xml_hold).min(tail_hold)
}
/// Extract tool call messages from a JSON object containing "tool_calls".
/// Handles both the model's native format (name/arguments at top level)
/// and the OpenAI format (function.name/function.arguments).
pub(super) fn extract_tool_call_messages(tool_calls_json: &str, message_id: &str) -> Vec<Message> {
let parsed: Value = match serde_json::from_str(tool_calls_json) {
Ok(v) => v,
Err(_) => return vec![],
};
let Some(tool_calls) = parsed.get("tool_calls").and_then(|v| v.as_array()) else {
return vec![];
};
let mut messages = Vec::new();
for tc in tool_calls {
// Try OpenAI format first: {"function": {"name": ..., "arguments": ...}, "id": ...}
// Then model's native format: {"name": ..., "arguments": {...}, "id": ...}
let (name, arguments) = if let Some(func) = tc.get("function") {
let n = func.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args_str = func
.get("arguments")
.and_then(|v| v.as_str())
.unwrap_or("{}");
let args: Option<serde_json::Map<String, Value>> = serde_json::from_str(args_str).ok();
(n.to_string(), args)
} else {
let n = tc.get("name").and_then(|v| v.as_str()).unwrap_or("");
// Arguments may be an object directly (model format) or a string (OAI format)
let args = if let Some(obj) = tc.get("arguments").and_then(|v| v.as_object()) {
Some(obj.clone())
} else if let Some(s) = tc.get("arguments").and_then(|v| v.as_str()) {
serde_json::from_str(s).ok()
} else {
None
};
(n.to_string(), args)
};
if name.is_empty() {
continue;
}
let id = tc
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| Uuid::new_v4().to_string());
let tool_call = CallToolRequestParams {
meta: None,
task: None,
name: Cow::Owned(name),
arguments,
};
let mut msg = Message::assistant();
msg.content
.push(MessageContent::tool_request(id, Ok(tool_call)));
msg.id = Some(message_id.to_string());
messages.push(msg);
}
messages
}
/// Parse XML-style tool calls used by models like qwen3-coder.
/// Format:
/// ```text
/// <tool_call>
/// <function=tool_name>
/// <parameter=param1>value1</parameter>
/// <parameter=param2>value2</parameter>
/// </function>
/// </tool_call>
/// ```
/// Returns (content_before_tool_calls, vec_of_tool_calls) or None if no XML tool calls found.
#[allow(clippy::type_complexity)]
pub(super) fn split_content_and_xml_tool_calls(
text: &str,
) -> Option<(String, Vec<(String, serde_json::Map<String, Value>)>)> {
let (content, first_block_and_rest) = text.split_once("<tool_call>")?;
let content = content.trim_end().to_string();
let mut tool_calls = Vec::new();
// Process the first block, then keep splitting on subsequent <tool_call> tags
let mut remaining = first_block_and_rest;
loop {
// Split off the block up to </tool_call> (or take the rest if unclosed)
let (block, after_close) = remaining
.split_once("</tool_call>")
.unwrap_or((remaining, ""));
if let Some(tool_call) = parse_single_xml_tool_call(block) {
tool_calls.push(tool_call);
}
// Try to find the next <tool_call> in what remains
match after_close.split_once("<tool_call>") {
Some((_between, next_remaining)) => remaining = next_remaining,
None => break,
}
}
if tool_calls.is_empty() {
None
} else {
Some((content, tool_calls))
}
}
fn parse_single_xml_tool_call(block: &str) -> Option<(String, serde_json::Map<String, Value>)> {
// Try <function=NAME><parameter=K>V</parameter>...</function> format first
if let Some(result) = parse_xml_function_format(block) {
return Some(result);
}
// Try GLM-style: TOOL_NAME<arg_key>K</arg_key><arg_value>V</arg_value>...
parse_xml_arg_key_value_format(block)
}
fn parse_xml_function_format(block: &str) -> Option<(String, serde_json::Map<String, Value>)> {
let (_, after_func_eq) = block.split_once("<function=")?;
let (func_name, func_body) = after_func_eq.split_once('>')?;
let func_name = func_name.trim().to_string();
let mut args = serde_json::Map::new();
let mut rest = func_body;
while let Some((_, after_param_eq)) = rest.split_once("<parameter=") {
let Some((param_name, after_name_close)) = after_param_eq.split_once('>') else {
break;
};
let param_name = param_name.trim().to_string();
let (value, after_value) = after_name_close
.split_once("</parameter>")
.unwrap_or((after_name_close, ""));
let value = value.trim();
let json_value =
serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string()));
args.insert(param_name, json_value);
rest = after_value;
}
Some((func_name, args))
}
/// Parse GLM-style tool calls: `NAME<arg_key>K</arg_key><arg_value>V</arg_value>...`
/// Also handles zero-argument calls like just `NAME`.
fn parse_xml_arg_key_value_format(block: &str) -> Option<(String, serde_json::Map<String, Value>)> {
let func_name_end = block.find("<arg_key>").unwrap_or(block.len());
// Safe: find returns a byte offset at the start of an ASCII '<' character,
// and block.len() is always a valid boundary.
#[allow(clippy::string_slice)]
let func_name = block[..func_name_end].trim().to_string();
if func_name.is_empty() {
return None;
}
let mut args = serde_json::Map::new();
#[allow(clippy::string_slice)]
let mut rest = &block[func_name_end..];
while let Some((_, after_key_open)) = rest.split_once("<arg_key>") {
let Some((key, after_key_close)) = after_key_open.split_once("</arg_key>") else {
break;
};
let key = key.trim().to_string();
let Some((_, after_val_open)) = after_key_close.split_once("<arg_value>") else {
break;
};
let (value, after_val_close) = after_val_open
.split_once("</arg_value>")
.unwrap_or((after_val_open, ""));
let value = value.trim();
let json_value =
serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string()));
args.insert(key, json_value);
rest = after_val_close;
}
Some((func_name, args))
}
pub(super) fn extract_xml_tool_call_messages(
tool_calls: Vec<(String, serde_json::Map<String, Value>)>,
message_id: &str,
) -> Vec<Message> {
tool_calls
.into_iter()
.map(|(name, args)| {
let tool_call = CallToolRequestParams {
meta: None,
task: None,
name: Cow::Owned(name),
arguments: if args.is_empty() { None } else { Some(args) },
};
let mut msg = Message::assistant();
msg.content.push(MessageContent::tool_request(
Uuid::new_v4().to_string(),
Ok(tool_call),
));
msg.id = Some(message_id.to_string());
msg
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const SHELL_TOOL: &str = "developer__shell";
#[test]
fn test_parse_xml_tool_call_single() {
let text = "I'll search for that.\n\n<tool_call>\n<function=search__files>\n<parameter=pattern>local.*inference</parameter>\n</function>\n</tool_call>";
let result = split_content_and_xml_tool_calls(text);
assert!(result.is_some());
let (content, calls) = result.unwrap();
assert_eq!(content, "I'll search for that.");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "search__files");
assert_eq!(calls[0].1.get("pattern").unwrap(), "local.*inference");
}
#[test]
fn test_parse_xml_tool_call_multiple_params() {
let text = "<tool_call>\n<function=developer__shell>\n<parameter=command>ls -la</parameter>\n<parameter=timeout>30</parameter>\n</function>\n</tool_call>";
let result = split_content_and_xml_tool_calls(text);
assert!(result.is_some());
let (content, calls) = result.unwrap();
assert!(content.is_empty());
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, SHELL_TOOL);
assert_eq!(calls[0].1.get("command").unwrap(), "ls -la");
// 30 should be parsed as a number
assert_eq!(calls[0].1.get("timeout").unwrap(), &json!(30));
}
#[test]
fn test_parse_xml_tool_call_no_tool_call() {
let text = "Just some regular text with no tool calls.";
assert!(split_content_and_xml_tool_calls(text).is_none());
}
#[test]
fn test_parse_xml_tool_call_multiple_calls() {
let text = "Doing two things:\n<tool_call>\n<function=foo__bar>\n<parameter=x>1</parameter>\n</function>\n</tool_call>\n<tool_call>\n<function=baz__qux>\n<parameter=y>hello</parameter>\n</function>\n</tool_call>";
let result = split_content_and_xml_tool_calls(text);
assert!(result.is_some());
let (content, calls) = result.unwrap();
assert_eq!(content, "Doing two things:");
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].0, "foo__bar");
assert_eq!(calls[1].0, "baz__qux");
}
#[test]
fn test_parse_xml_tool_call_multiline_value() {
let text = "<tool_call>\n<function=developer__write_file>\n<parameter=path>test.py</parameter>\n<parameter=content>def hello():\n print(\"world\")</parameter>\n</function>\n</tool_call>";
let result = split_content_and_xml_tool_calls(text);
assert!(result.is_some());
let (_content, calls) = result.unwrap();
assert_eq!(calls[0].0, "developer__write_file");
assert_eq!(
calls[0].1.get("content").unwrap(),
"def hello():\n print(\"world\")"
);
}
#[test]
fn test_safe_stream_end_holds_back_tool_call_tag() {
let text = "Some text before <tool_call>\n<function=foo>";
let safe = safe_stream_end(text);
assert!(safe <= text.find("<tool_call>").unwrap());
}
#[test]
fn test_safe_stream_end_holds_back_partial_tag() {
let text = "Some text <tool_ca";
let safe = safe_stream_end(text);
// Should hold back the partial tag
assert!(safe <= text.find('<').unwrap());
}
#[test]
fn test_parse_glm_style_tool_call() {
let text = "<tool_call>developer__shell<arg_key>command</arg_key><arg_value>ls -la</arg_value></tool_call>";
let result = split_content_and_xml_tool_calls(text);
assert!(result.is_some());
let (content, calls) = result.unwrap();
assert!(content.is_empty());
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, SHELL_TOOL);
assert_eq!(calls[0].1.get("command").unwrap(), "ls -la");
}
#[test]
fn test_parse_glm_style_tool_call_no_args() {
let text = "Some text\n<tool_call>load</tool_call>";
let result = split_content_and_xml_tool_calls(text);
assert!(result.is_some());
let (content, calls) = result.unwrap();
assert_eq!(content, "Some text");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "load");
assert!(calls[0].1.is_empty());
}
#[test]
fn test_parse_glm_style_tool_call_multiple_args() {
let text = "Let me check.\n<tool_call>execute<arg_key>code</arg_key><arg_value>async function run() { return 1; }</arg_value><arg_key>tool_graph</arg_key><arg_value>[{\"tool\": \"shell\"}]</arg_value></tool_call>";
let result = split_content_and_xml_tool_calls(text);
assert!(result.is_some());
let (content, calls) = result.unwrap();
assert_eq!(content, "Let me check.");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "execute");
assert_eq!(
calls[0].1.get("code").unwrap(),
"async function run() { return 1; }"
);
// tool_graph should be parsed as JSON array
assert!(calls[0].1.get("tool_graph").unwrap().is_array());
}
#[test]
fn test_extract_xml_tool_call_messages() {
let calls = vec![(
SHELL_TOOL.to_string(),
serde_json::Map::from_iter(vec![("command".to_string(), json!("ls"))]),
)];
let msgs = extract_xml_tool_call_messages(calls, "test-id");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].id, Some("test-id".to_string()));
match &msgs[0].content[0] {
MessageContent::ToolRequest(req) => {
let call = req.tool_call.as_ref().unwrap();
assert_eq!(&*call.name, SHELL_TOOL);
assert_eq!(
call.arguments.as_ref().unwrap().get("command").unwrap(),
"ls"
);
}
_ => panic!("Expected ToolRequest"),
}
}
#[test]
fn test_split_content_and_tool_calls_with_tool() {
let text = "Here is the result.\n{\"tool_calls\": [{\"function\": {\"name\": \"shell\", \"arguments\": \"{}\"}, \"id\": \"abc\"}]}";
let (content, tc) = split_content_and_tool_calls(text);
assert_eq!(content, "Here is the result.");
assert!(tc.is_some());
let parsed: serde_json::Value = serde_json::from_str(&tc.unwrap()).unwrap();
assert_eq!(parsed["tool_calls"].as_array().unwrap().len(), 1);
}
#[test]
fn test_split_content_and_tool_calls_no_tool() {
let text = "Just regular text, no JSON.";
let (content, tc) = split_content_and_tool_calls(text);
assert_eq!(content, text);
assert!(tc.is_none());
}
#[test]
fn test_split_content_and_tool_calls_json_without_tool_calls_key() {
let text = "{\"key\": \"value\"}";
let (content, tc) = split_content_and_tool_calls(text);
assert_eq!(content, text);
assert!(tc.is_none());
}
#[test]
fn test_extract_tool_call_messages_openai_format() {
let json_str = r#"{"tool_calls": [{"function": {"name": "developer__shell", "arguments": "{\"command\": \"ls\"}"}, "id": "call-1"}]}"#;
let msgs = extract_tool_call_messages(json_str, "msg-1");
assert_eq!(msgs.len(), 1);
match &msgs[0].content[0] {
MessageContent::ToolRequest(req) => {
let call = req.tool_call.as_ref().unwrap();
assert_eq!(&*call.name, SHELL_TOOL);
assert_eq!(
call.arguments.as_ref().unwrap().get("command").unwrap(),
"ls"
);
}
_ => panic!("Expected ToolRequest"),
}
}
#[test]
fn test_extract_tool_call_messages_native_format() {
let json_str = r#"{"tool_calls": [{"name": "developer__shell", "arguments": {"command": "ls"}, "id": "call-2"}]}"#;
let msgs = extract_tool_call_messages(json_str, "msg-2");
assert_eq!(msgs.len(), 1);
match &msgs[0].content[0] {
MessageContent::ToolRequest(req) => {
let call = req.tool_call.as_ref().unwrap();
assert_eq!(&*call.name, SHELL_TOOL);
}
_ => panic!("Expected ToolRequest"),
}
}
#[test]
fn test_extract_tool_call_messages_invalid_json() {
assert!(extract_tool_call_messages("not json", "msg-3").is_empty());
}
#[test]
fn test_extract_tool_call_messages_empty_name_skipped() {
let json_str = r#"{"tool_calls": [{"name": "", "arguments": {}, "id": "x"}]}"#;
assert!(extract_tool_call_messages(json_str, "msg-4").is_empty());
}
#[test]
fn test_compact_tools_json_produces_minimal_output() {
use rmcp::model::Tool;
use rmcp::object;
let tools = vec![Tool::new(
"developer__shell".to_string(),
"Run shell commands".to_string(),
object!({"type": "object", "properties": {"command": {"type": "string"}}}),
)];
let result = compact_tools_json(&tools);
assert!(result.is_some());
let parsed: Vec<serde_json::Value> = serde_json::from_str(&result.unwrap()).unwrap();
assert_eq!(parsed.len(), 1);
let func = &parsed[0]["function"];
assert_eq!(func["name"], "developer__shell");
assert_eq!(func["description"], "Run shell commands");
// Should not contain full parameter schemas
assert!(func.get("parameters").is_none());
}
#[test]
fn test_compact_tools_json_empty() {
let result = compact_tools_json(&[]);
assert!(result.is_some());
let parsed: Vec<serde_json::Value> = serde_json::from_str(&result.unwrap()).unwrap();
assert!(parsed.is_empty());
}
#[test]
fn test_safe_stream_end_balanced_braces() {
let text = "Result: {\"key\": \"value\"} done";
assert_eq!(safe_stream_end(text), text.len());
}
#[test]
fn test_safe_stream_end_unbalanced_open_brace() {
let text = "Some text {\"tool_calls\": [";
assert_eq!(safe_stream_end(text), "Some text ".len());
}
#[test]
fn test_safe_stream_end_empty() {
assert_eq!(safe_stream_end(""), 0);
}
#[test]
fn test_safe_stream_end_no_braces() {
let text = "plain text here";
assert_eq!(safe_stream_end(text), text.len());
}
}
+1
View File
@@ -23,6 +23,7 @@ pub mod google;
mod init;
pub mod lead_worker;
pub mod litellm;
pub mod local_inference;
pub mod oauth;
pub mod ollama;
pub mod openai;
-67
View File
@@ -7,16 +7,13 @@ use super::utils::{ImageFormat, RequestLog};
use crate::config::declarative_providers::DeclarativeProviderConfig;
use crate::config::GooseMode;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::model::ModelConfig;
use crate::providers::formats::ollama::{create_request, response_to_streaming_message_ollama};
use crate::utils::safe_truncate;
use anyhow::{Error, Result};
use async_stream::try_stream;
use async_trait::async_trait;
use futures::future::BoxFuture;
use futures::TryStreamExt;
use regex::Regex;
use reqwest::Response;
use rmcp::model::Tool;
use serde_json::{json, Value};
@@ -215,30 +212,6 @@ impl Provider for OllamaProvider {
self.model.clone()
}
async fn generate_session_name(
&self,
session_id: &str,
messages: &Conversation,
) -> Result<String, ProviderError> {
let context = self.get_initial_user_messages(messages);
let message = Message::user().with_text(self.create_session_name_prompt(&context));
let model_config = self.get_model_config();
let result = self
.complete(
&model_config,
session_id,
"You are a title generator. Output only the requested title of 4 words or less, with no additional text, reasoning, or explanations.",
&[message],
&[],
)
.await?;
let mut description = result.0.as_concat_text();
description = Self::filter_reasoning_tokens(&description);
Ok(safe_truncate(&description, 100))
}
async fn stream(
&self,
model_config: &ModelConfig,
@@ -318,46 +291,6 @@ impl Provider for OllamaProvider {
}
}
impl OllamaProvider {
fn filter_reasoning_tokens(text: &str) -> String {
let mut filtered = text.to_string();
let reasoning_patterns = [
r"<think>.*?</think>",
r"<thinking>.*?</thinking>",
r"Let me think.*?\n",
r"I need to.*?\n",
r"First, I.*?\n",
r"Okay, .*?\n",
r"So, .*?\n",
r"Well, .*?\n",
r"Hmm, .*?\n",
r"Actually, .*?\n",
r"Based on.*?I think",
r"Looking at.*?I would say",
];
for pattern in reasoning_patterns {
if let Ok(re) = Regex::new(pattern) {
filtered = re.replace_all(&filtered, "").to_string();
}
}
filtered = filtered
.replace("<think>", "")
.replace("</think>", "")
.replace("<thinking>", "")
.replace("</thinking>", "");
filtered = filtered
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
filtered
}
}
/// Ollama-specific streaming handler with XML tool call fallback.
/// Uses the Ollama format module which buffers text when XML tool calls are detected,
/// preventing duplicate content from being emitted to the UI.
@@ -0,0 +1,128 @@
//! Integration tests for LocalInferenceProvider.
//!
//! These tests require a downloaded GGUF model and are ignored by default.
//! Run with: cargo test -p goose --test local_inference_integration -- --ignored
use futures::StreamExt;
use goose::conversation::message::Message;
use goose::model::ModelConfig;
use goose::providers::create;
use std::time::Instant;
const TEST_MODEL: &str = "llama-3.2-1b";
#[tokio::test]
#[ignore]
async fn test_local_inference_stream_produces_output() {
let model_config = ModelConfig::new(TEST_MODEL).expect("valid model config");
let provider = create("local", model_config.clone(), Vec::new())
.await
.expect("provider creation should succeed");
let system = "You are a helpful assistant. Be brief.";
let messages = vec![Message::user().with_text("Say hello.")];
let mut stream = provider
.stream(&model_config, "test-session", system, &messages, &[])
.await
.expect("stream should start");
let mut got_text = false;
let mut got_usage = false;
while let Some(result) = stream.next().await {
let (msg, usage) = result.expect("stream item should be Ok");
if msg.is_some() {
got_text = true;
}
if let Some(u) = usage {
got_usage = true;
let usage_inner = u.usage;
assert!(
usage_inner.input_tokens.unwrap_or(0) > 0,
"should have input tokens"
);
assert!(
usage_inner.output_tokens.unwrap_or(0) > 0,
"should have output tokens"
);
}
}
assert!(got_text, "stream should produce at least one text message");
assert!(got_usage, "stream should produce usage info");
}
#[tokio::test]
#[ignore]
async fn test_local_inference_cold_and_warm_performance() {
let model_config = ModelConfig::new(TEST_MODEL).expect("valid model config");
let provider = create("local", model_config.clone(), Vec::new())
.await
.expect("provider creation should succeed");
// Cold start (includes model loading)
let messages = vec![Message::user().with_text("what is the capital of Moldova?")];
let start = Instant::now();
let (response, _usage) = provider
.complete(&model_config, "test-session", "", &messages, &[])
.await
.expect("cold completion should succeed");
let cold_elapsed = start.elapsed();
let text = response.as_concat_text();
assert!(!text.is_empty(), "cold start should produce a response");
println!(
"Cold start: {cold_elapsed:.2?}, response length: {}",
text.len()
);
// Warm run (model already loaded)
let messages2 = vec![Message::user().with_text("what is the capital of France?")];
let start2 = Instant::now();
let (response2, _usage2) = provider
.complete(&model_config, "test-session", "", &messages2, &[])
.await
.expect("warm completion should succeed");
let warm_elapsed = start2.elapsed();
let text2 = response2.as_concat_text();
assert!(!text2.is_empty(), "warm run should produce a response");
println!(
"Warm run: {warm_elapsed:.2?}, response length: {}",
text2.len()
);
assert!(
warm_elapsed < cold_elapsed,
"warm run ({warm_elapsed:.2?}) should be faster than cold start ({cold_elapsed:.2?})"
);
}
#[tokio::test]
#[ignore]
async fn test_local_inference_large_prompt() {
let model_config = ModelConfig::new(TEST_MODEL).expect("valid model config");
let provider = create("local", model_config.clone(), Vec::new())
.await
.expect("provider creation should succeed");
// Build a large prompt (~3500 tokens) to exercise prefill performance
let padding = "You are Goose, a highly capable AI assistant.\n".repeat(80);
let prompt = format!("{padding}\nNow answer this: what is the capital of Moldova?");
let messages = vec![Message::user().with_text(&prompt)];
let start = Instant::now();
let (response, _usage) = provider
.complete(&model_config, "test-session", "", &messages, &[])
.await
.expect("large prompt completion should succeed");
let elapsed = start.elapsed();
let text = response.as_concat_text();
assert!(!text.is_empty(), "large prompt should produce a response");
println!(
"Large prompt: {elapsed:.2?}, prompt ~{} chars, response length: {}",
prompt.len(),
text.len()
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,118 @@
---
title: "Gas Town Explained: How to Use Goosetown for Parallel Agentic Engineering"
description: "Learn how Gas Town and Goosetown lead the industrial coding revolution by teaching AI agents to work together in a team. This beginner guide explains the infrastructure we're using to move from talking to one AI to coordinating many agents at once."
authors:
- rizel
- tyler
---
![Goosetown](goosetown.png)
On New Year's Day 2026, while many were recovering from the night before, a different kind of hangover took hold of every AI-pilled, chronically online software engineer. Steve Yegge published a new blog post: "[Welcome to Gas Town](https://steve-yegge.medium.com/welcome-to-gas-town-4f25ee16dd04)." Some walked away inspired to finally use their agents optimally; others were just plain confused. If you're like me, you felt a bit of both.
Yegge's 34 minute post is a sprawling vision filled with futuristic ideas, playful characters, and enough side tangents to make your head spin. But underneath the lore is a massive architectural shift. I want to take a step back and simplify the "Big Idea" for everyone: Gas Town is a philosophy and a proof of concept to help people coordinate multiple agents working together.
<!-- truncate -->
## The Paradigm Shift of Agentic Engineering
Most people use AI agents sequentially. The workflow can look like this:
* **1:00 PM:** You: "goose, build the API endpoint."
* *[Wait 10 minutes, check back.]*
* **1:10 PM:** You: "Now build the frontend."
* *[Wait 10 minutes, check back.]*
* **1:20 PM:** You: "Now write the tests."
* *[Wait 10 minutes, check back.]*
* **1:30 PM:** Project complete.
You've built a project in 30 minutes, which is fast, but you spent most of that time just watching a progress bar. Some engineers started to realize that if we are running one agent, we can run another five at the same time.
For example, Agent A builds the API, Agent B can start the frontend, Agent C can write tests, and Agent D can investigate a bug in that legacy codebase you've been avoiding.
This is how people are buying their time back. They're getting entire sprints done in an hour by running parallel threads. (Just don't tell your boss because the reward for finishing work is always more work.)
However, since agents don't communicate with each other, this approach introduces new problems:
* **Merge conflicts**: Two agents change the same line in the same file and break everything.
* **Lost context**: Sessions crash or the agent starts hallucinating because it's been talking too long, and suddenly an hour of "work" vanishes.
* **Human bottleneck**: You end up constantly checking your phone at a party on the weekend or in bed to see if your agents are still on track making you a babysitter for agents.
## Gas Town Explained
Gas Town is designed to stop the babysitting. It coordinates the distribution of tasks among parallel agents so you don't have to. The system uses:
* **Worktrees**: These automatically put each agent in its own separate workspace so they don't step on each other's toes.
* **Beads**: It uses beads to track progress. If a session crashes, the next agent session can pick up exactly where the last agent left off.
* **Communication**: Each agent reports aloud what it's up to or observing, so other agents gain the necessary context.
This system also introduces a cast of characters:
* **The Mayor**: your main agent interface that coordinates all the other agents
* **The Polecat(s)**: These are worker agents. They work on separate work trees, and take instruction from the Mayor.
* **The Witness**: Observes the worker agents, nudges them when they get stuck, and escalates issues to keep the system running
I won't list every single character here (it gets deep), but the takeaway is: Gas Town creates a chain of command with a shared way to communicate.
## Introducing Goosetown
This is exactly the kind of futuristic thinking we're building toward at [goose](https://block.github.io/goose). So the goose team, specifically Tyler Longwell, built our own take on this called [Goosetown](https://github.com/block/goosetown).
Goosetown is a multi-agent orchestration layer built on top of goose. Like Gas Town, it coordinates parallel agents. Unlike Gas Town, it's deliberately minimal and built for research-first parallel work.
When you give Goosetown a task, the main agent acts as an Orchestrator, breaking the job into phases: research, build, and review. Then, it spawns parallel delegates to get it done. Each delegate communicates via a shared Town Wall, an append-only log where every agent posts what they're doing and what they've found.
Here's a real Town Wall snippet from a session where parallel researchers converged on a pivot quickly:
* **[10:14] researcher-api-auth** - 🚨 POTENTIAL SHOWSTOPPER: Service callers have EMPTY capabilities. Planned auth path will silently reject every request. This needs a code change, not just config.
* **[10:14] researcher-endpoints** - 💡 Found: native endpoint already exists with minimal deps. Alternative path viable.
* **[10:15] researcher-source** - ✅ Done. Confirmed: native path requires zero new dependencies. Recommending pivot.
Goosetown operates on 4 components: [skills](/docs/guides/context-engineering/using-skills), [subagents](/docs/guides/subagents), [beads](https://github.com/steveyegge/beads), and a [gtwall](https://github.com/block/goosetown/blob/main/gtwall).
### Skills
[Skills](/docs/guides/context-engineering/using-skills) are Markdown files that describe how to do something like "how to deploy to production." Goosetown uses these to tell each Delegate how to do its specific job. When a Delegate spawns, it's "pre-loaded" with the skill for its role (Orchestrator, Researcher, Writer, Reviewer).
### Subagents
Instead of doing everything in one long conversation that eventually hits a "context cliff," Goosetown uses [subagents](/docs/guides/subagents), ephemeral agent instances. These are triggered by the [summon extension](/docs/mcp/summon-mcp), using `delegate()` to hand off work to a fresh agent instance. They do the work in their own clean context and return a summary, keeping your main session fast and focused.
### Beads
Goosetown uses [Beads](https://github.com/steveyegge/beads) to track progress so work survives crashes. It's a local issue tracker based on Git. The Orchestrator creates issues, delegates update them, and if a session fails, the next agent picks up the "bead" and continues the work.
### gtwall
[gtwall](https://github.com/block/goosetown/blob/main/gtwall) is an append-only log that delegates use to communicate and coordinate. All delegates post and read activity.
## A Note from the Creator
> *Goosetown started as a fun experiment to push the subagent upgrade I was working on for goose to its limits. I had spent some time with Gas Town and thought that a much less sprawling riff on it would be a whimsical way to show off background subagents and how good modern goose is at orchestrating and managing projects at a very high level. I was just totally surprised at how well it worked when I started using Goosetown as my daily driver.*
>
> *Watching the subagents chatter to each other, each one given a personality and task by the orchestrator, was eye-opening. And funny. They rubber-duck and go back and forth just like I do with my colleagues. Even just having one model getting to bounce ideas off itself automatically in the form of different agents with different contexts makes the output better.*
>
> *Running flocks (swarms) of agents is obviously expensive, but the overall quality of the work is higher and much less of my time is required to get it right. Definitely a tradeoff there. goose does help with this by allowing you to set the default subagent model to a less pricey one ahead of time and by allowing your main agent to select the models it wants to use for subagents explicitly, ad-hoc.*
>
> *There are always new and exciting features being added to goose. Refactors and refinements. For Goosetown, I'll continue to make its artifact (memory) system more robust, make communications inside Goosetown flow more smoothly, and keep it just a little silly.*
>
> — Tyler
## Get Started
Ready to try parallel agentic engineering for yourself? [Goosetown](https://github.com/block/goosetown) is open source and available on GitHub. Clone the [repo](https://github.com/block/goosetown), follow the setup instructions in the README, and you'll be orchestrating multiple agents in no time. If you're new to this workflow, watching the video below is a great way to see what a real session looks like before diving in.
<iframe class="aspect-ratio" src="https://www.youtube.com/embed/H2hJjNmvEEA" title="Rizel's first time using Goosetown" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<head>
<meta property="og:title" content="Gas Town Explained: How to Use Goosetown for Parallel Agentic Engineering" />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://block.github.io/goose/blog/2026/02/19/gastown-explained-goosetown" />
<meta property="og:description" content="Learn how Gas Town and Goosetown lead the industrial coding revolution by teaching AI agents to work together in a team. This beginner guide explains the infrastructure we're using to move from talking to one AI to coordinating many agents at once." />
<meta property="og:image" content="https://block.github.io/goose/assets/images/goosetown-6e1bda1a4bd160c0c01cfc58c118492e.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="block.github.io/goose" />
<meta name="twitter:title" content="Gas Town Explained: How to Use Goosetown for Parallel Agentic Engineering" />
<meta name="twitter:description" content="Learn how Gas Town and Goosetown lead the industrial coding revolution by teaching AI agents to work together in a team." />
<meta name="twitter:image" content="https://block.github.io/goose/assets/images/goosetown-6e1bda1a4bd160c0c01cfc58c118492e.png" />
</head>
@@ -168,7 +168,7 @@ To configure your chosen provider, see available options, or select a model, vis
│ ○ goose Settings
└
```
3. Choose a model provider and press `Enter`. Use the arrow keys (↑/↓) to move through the options.
3. Choose a model provider and press `Enter`. Use the arrow keys (↑/↓) to move through the options, or start typing to filter the list.
```
┌ goose-configure
@@ -526,7 +526,7 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T
└
```
3. Select the custom provider you want to update and press `Enter`. Use the arrow keys (↑/↓) to move through the options.
3. Select the custom provider you want to update and press `Enter`. Use the arrow keys (↑/↓) to move through the options, or start typing to filter the list.
```sh
┌ goose-configure
@@ -1156,6 +1156,10 @@ Gemini 3 models support configurable thinking levels to balance response latency
- **Low** (default) - Faster responses, lighter reasoning
- **High** - Deeper reasoning, higher latency
:::tip
When thinking is enabled, you can view the model's reasoning process. See [Viewing Model Reasoning](#viewing-model-reasoning) for details.
:::
<Tabs groupId="interface">
<TabItem value="ui" label="goose Desktop" default>
When selecting a Gemini 3 model, a "Thinking Level" dropdown appears automatically. Select your preference and the setting persists across sessions.
@@ -1181,6 +1185,41 @@ The thinking level is determined in this order (highest to lowest priority):
3. Default value: `low`
:::
## Viewing Model Reasoning
Some models expose their internal reasoning or "chain of thought" as part of their response. goose automatically captures this reasoning output and makes it available to you. The following models and providers support reasoning output:
| Provider / Model | How It Works |
|---|---|
| **DeepSeek-R1** (via OpenAI, Ollama, OpenRouter, OVHcloud, etc.) | Reasoning captured from the `reasoning_content` field in the API response |
| **Kimi** (via Groq or other OpenAI-compatible endpoints) | Reasoning captured from the `reasoning_content` field in the API response |
| **Gemini CLI** (Google Gemini models with thinking enabled) | Thinking blocks captured from the streaming response |
| **Claude** (Anthropic, with [extended thinking](/docs/guides/environment-variables#claude-extended-thinking) enabled) | Thinking blocks captured from the API response |
<Tabs groupId="interface">
<TabItem value="ui" label="goose Desktop" default>
Reasoning output appears automatically in a collapsible **"Show reasoning"** toggle above the model's response. Click it to expand and view the model's thought process.
</TabItem>
<TabItem value="cli" label="goose CLI">
Reasoning output is **hidden by default** in the CLI. To display it, set the `GOOSE_CLI_SHOW_THINKING` environment variable:
```bash
export GOOSE_CLI_SHOW_THINKING=1
```
When enabled, reasoning appears under a "Thinking:" header in dimmed text before the model's main response.
:::note
This requires stdout to be a terminal (reasoning output won't appear when piping output to a file or another command).
:::
</TabItem>
</Tabs>
:::tip
Reasoning output can be useful for understanding how the model arrived at its answer, debugging unexpected behavior, or learning from the model's problem-solving approach. However, it can also be verbose — toggle it on only when you need it.
:::
---
If you have any questions or need help with a specific provider, feel free to reach out to us on [Discord](https://discord.gg/goose-oss) or on the [goose repo](https://github.com/block/goose).
@@ -188,6 +188,10 @@ export CLAUDE_THINKING_ENABLED=1
export CLAUDE_THINKING_BUDGET=8000
```
:::tip Viewing Thinking Output
To see Claude's thinking output in the **CLI**, you also need to set `GOOSE_CLI_SHOW_THINKING=1`. In **goose Desktop**, thinking output is shown automatically in a collapsible "Show reasoning" toggle.
:::
### Planning Mode Configuration
These variables control goose's [planning functionality](/docs/guides/creating-plans).
@@ -262,6 +266,7 @@ These variables control how goose manages conversation sessions and context.
| `GOOSE_CLI_LIGHT_THEME` | Custom [bat theme](https://github.com/sharkdp/bat#adding-new-themes) for syntax highlighting when using light mode | bat theme name (e.g., "Solarized (light)", "OneHalfLight") | "GitHub" |
| `GOOSE_CLI_DARK_THEME` | Custom [bat theme](https://github.com/sharkdp/bat#adding-new-themes) for syntax highlighting when using dark mode | bat theme name (e.g., "Dracula", "Nord") | "zenburn" |
| `GOOSE_CLI_NEWLINE_KEY` | Customize the keyboard shortcut for [inserting newlines in CLI input](/docs/guides/goose-cli-commands#keyboard-shortcuts) | Single character (e.g., "n", "m") | "j" (Ctrl+J) |
| `GOOSE_CLI_SHOW_THINKING` | Shows model reasoning/thinking output in CLI responses. Some models (e.g., DeepSeek-R1, Kimi, Gemini) expose their internal reasoning process — this variable makes it visible in the CLI. | Set to any value to enable | Disabled |
| `GOOSE_RANDOM_THINKING_MESSAGES` | Controls whether to show amusing random messages during processing | "true", "false" | "true" |
| `GOOSE_CLI_SHOW_COST` | Toggles display of model cost estimates in CLI output | "1", "true" (case-insensitive) to enable | false |
| `GOOSE_AUTO_COMPACT_THRESHOLD` | Set the percentage threshold at which goose [automatically summarizes your session](/docs/guides/sessions/smart-context-management#automatic-compaction). | Float between 0.0 and 1.0 (disabled at 0.0) | 0.8 |
@@ -313,6 +318,9 @@ export GOOSE_CLI_NEWLINE_KEY=n
# Disable random thinking messages for less distraction
export GOOSE_RANDOM_THINKING_MESSAGES=false
# Show reasoning/thinking output from models that support it (e.g., DeepSeek-R1, Kimi, Gemini)
export GOOSE_CLI_SHOW_THINKING=1
# Enable model cost display in CLI
export GOOSE_CLI_SHOW_COST=true
@@ -460,6 +468,14 @@ When the keyring is disabled (or cannot be accessed and goose [falls back to fil
* Windows: `%APPDATA%\Block\goose\config\secrets.yaml`
:::
### macOS Sandbox for goose Desktop
Optional [macOS sandbox](/docs/guides/sandbox) for goose Desktop that restricts file access, network connections, and process execution using Apple's `sandbox-exec` technology.
| Variable | Purpose | Values | Default |
|----------|---------|--------|---------|
| `GOOSE_SANDBOX` | Enable the sandbox with [customizable security controls](/docs/guides/sandbox#configuration) | `true` or `1` to enable | `false` |
## Network Configuration
These variables configure network proxy settings for goose.
@@ -611,14 +627,27 @@ These variables are automatically set by goose during command execution.
| Variable | Purpose | Values | Default |
|----------|---------|---------|---------|
| `GOOSE_TERMINAL` | Indicates that a command is being executed by goose, enables [customizing shell behavior](#customizing-shell-behavior) | "1" when set | Unset |
| `AGENT` | Generic agent identifier for cross-tool compatibility, enables tools and scripts to detect when they're being run by goose | "goose" when set | Unset |
| `AGENT_SESSION_ID` | The current session ID for [session-isolated workflows](#using-session-ids-in-workflows), automatically available to STDIO extensions and the Developer extension shell commands | Session ID string (e.g., `20260217_5`) | Unset (only set in extension/shell contexts) |
### Customizing Shell Behavior
Sometimes you want goose to use different commands or have different shell behavior than your normal terminal usage. For example, you might want goose to use a different tool, prevent goose from running `git commit`, or block long-running development servers that could hang the AI agent. This is most useful when using goose CLI, where shell commands are executed directly in your terminal environment.
Sometimes you want goose to use different commands or have different shell behavior than your normal terminal usage. Common use cases include:
- Skipping expensive shell initialization (e.g. syntax highlighting, custom prompts)
- Blocking interactive commands that would hang the agent (e.g., `git commit`)
- Redirecting to agent-friendly tools (e.g., `rg` instead of `find`)
- Building cross-agent tools and scripts that detect AI agent execution
- Integrating with MCP servers and LLM gateways
This is most useful when using goose CLI, where shell commands are executed directly in your terminal environment.
**How it works:**
1. When goose runs commands, `GOOSE_TERMINAL` is automatically set to "1"
goose provides the `GOOSE_TERMINAL` and `AGENT` variables you can use to detect whether goose is the executing agent.
1. When goose runs commands:
- `GOOSE_TERMINAL` is automatically set to "1"
- `AGENT` is automatically set to "goose"
2. Your shell configuration can detect this and change behavior while keeping your normal terminal usage unchanged
**Examples:**
@@ -645,6 +674,17 @@ if [[ -n "$GOOSE_TERMINAL" ]]; then
fi
```
```bash
# Detect AI agent execution using standard naming convention
if [[ -n "$AGENT" ]]; then
echo "Running under AI agent: $AGENT"
# Apply agent-specific behavior if needed
if [[ "$AGENT" == "goose" ]]; then
echo "Detected goose - applying goose-specific settings"
fi
fi
```
### Using Session IDs in Workflows
STDIO extensions (local extensions that communicate via standard input/output) and the Developer extension's shell commands automatically receive the `AGENT_SESSION_ID` environment variable. This enables you to create session-isolated workflows and make it easier to:
@@ -663,6 +703,12 @@ echo "Results from step 1" > ~/Desktop/${AGENT_SESSION_ID}/handoff/output.txt
cat ~/Desktop/${AGENT_SESSION_ID}/handoff/output.txt
```
## Environment Variable Passthrough
The Developer extension's `shell` tool inherits environment variables from your session. This enables workflows that depend on environment configuration, such as authenticated CLI operations and build processes.
See [Environment Variables in Shell Commands](/docs/mcp/developer-mcp#environment-variables-in-shell-commands) for details.
## Enterprise Environments
When deploying goose in enterprise environments, administrators might need to control behavior and infrastructure, or enforce consistent settings across teams. The following environment variables are commonly used:
@@ -45,6 +45,10 @@ Configure goose settings - providers, extensions, etc.
goose configure
```
:::tip Type to Filter
When selecting from menus in `goose configure`, start typing to filter options in real-time. This works for lists of providers, extensions, and tools.
:::
---
#### info [options]
+115 -75
View File
@@ -1,83 +1,116 @@
# macOS Sandbox for goosed
---
title: macOS Sandbox for goose Desktop
sidebar_label: Sandbox for goose Desktop
description: Optional sandboxing for goose Desktop to control file access, filter network traffic, and enforce security policies on macOS
---
goose includes an optional macOS sandbox that restricts the goosed process using Apple's seatbelt (`sandbox-exec`) and routes all network traffic through a local egress proxy. This limits what the agent can do on your system — blocking sensitive file writes, raw sockets, tunneling tools, and unapproved network destinations.
goose Desktop includes an optional macOS sandbox that you can enable when you need stricter control and visibility over what goose can access on your system. Use it to:
> **Requirements:** macOS only. The sandbox relies on `/usr/bin/sandbox-exec` which is only available on macOS.
- **Restrict file system access** — Block writes to SSH keys, shell configs, and goose configuration files
- **Control network connections** — Force all traffic through a filtering proxy that blocks unapproved domains
- **Prevent security bypasses** — Block tunneling tools, raw sockets, and other techniques that could circumvent restrictions
- **Audit and enforce policies** — Log all network activity and enforce compliance requirements
goose runs with full tool access, but the sandbox uses two layers of protection:
- **File access control** - Apple's `sandbox-exec` restricts file and network access at the system level
- **Outbound connections** - A local egress proxy filters and logs outgoing connections
:::info macOS Requirement
The sandbox relies on `/usr/bin/sandbox-exec`, which is only available on macOS and is also known as Apple's seatbelt technology.
:::
## Quick Start
Set the environment variable before launching the goose desktop app:
To enable the sandbox, launch goose Desktop from the terminal with the environment variable set. For example:
```bash
GOOSE_SANDBOX=true
export GOOSE_SANDBOX=true
open -a Goose
```
Then start the desktop app as normal. goose will:
When the app starts with sandboxing enabled, it will:
1. Generate a seatbelt sandbox profile
2. Start a local HTTP CONNECT proxy on localhost
3. Launch goosed inside `sandbox-exec`, forcing all traffic through the proxy
3. Launch the `goosed` backend for goose Desktop inside `sandbox-exec`, forcing all traffic through the proxy
If `sandbox-exec` is not available (e.g. you're on Linux), goose will fail fast with a clear error rather than running unsandboxed.
## What Gets Restricted
### File System (seatbelt)
By default, the sandbox blocks writes to:
| Path | Purpose |
|------|---------|
| `~/.ssh/` | Prevent SSH key tampering |
| `~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, `~/.zprofile` | Prevent shell config injection |
| `~/.config/goose/sandbox/` | Protect sandbox config from the sandboxed process |
| `~/.config/goose/config.yaml` | Protect goose config |
### Network (seatbelt)
All direct network access is denied. The only allowed paths are:
- **Localhost** — so the process can reach the egress proxy and its own server port
- **Unix sockets** — for local IPC
- **mDNSResponder** — for DNS resolution
Everything else must go through the proxy.
### Process Restrictions (seatbelt)
- **Tunneling tools blocked:** `nc`, `ncat`, `netcat`, `socat`, `telnet` — prevents the agent from bypassing the proxy
- **Raw sockets blocked:** `SOCK_RAW` on `AF_INET`/`AF_INET6` — prevents raw packet crafting
- **Kernel extensions blocked:** `system-kext-load` denied
### Network (proxy)
The egress proxy checks connections in this order:
1. **Loopback detection** — prevents using the proxy as a relay back to localhost
2. **Raw IP blocking** — connections to bare IP addresses (no domain) are blocked
3. **Domain blocklist** — domains listed in `blocked.txt` are denied (including all subdomains)
4. **SSH/Git host restrictions** — SSH ports (22, 2222, 7999) are restricted to known git hosts
5. **LaunchDarkly allowlist** (optional) — dynamic egress control via feature flag
The sandbox remains active until you quit goose Desktop. To disable it, quit the app and relaunch normally (or set `GOOSE_SANDBOX=false` when opening from the terminal).
## Configuration
All configuration is via environment variables. Defaults are designed to be secure out of the box.
All configuration is via environment variables. Defaults are designed to be secure out of the box, but you can adjust them to match your security requirements.
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `GOOSE_SANDBOX` | `false` | Set to `true` or `1` to enable the sandbox |
| `GOOSE_SANDBOX` | `false` | Set to `true` or `1` to enable the sandbox. See [Quick Start](#quick-start) for launch instructions. |
### Seatbelt Profile
----
### File System
The [seatbelt sandbox profile](https://github.com/block/goose/blob/main/ui/desktop/src/sandbox/index.ts) blocks write operations to these sensitive files:
- `~/.ssh/` - Prevent SSH key tampering
- `~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, `~/.zprofile` - Prevent shell config injection
- `~/.config/goose/sandbox/` - Protect sandbox config from the sandboxed process
- `~/.config/goose/config.yaml` - Protect goose config
#### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `GOOSE_SANDBOX_PROTECT_FILES` | `true` | Write-protect sensitive files listed above. Set to `false` to disable |
----
### Direct Network Access
The seatbelt sandbox denies all direct network access, forcing traffic through the proxy. The only allowed connections are:
- **Localhost** — Allows the `goosed` process to reach the egress proxy and its own server port
- **Unix sockets** — For local inter-process communication (IPC)
- **mDNSResponder** — For DNS resolution
:::info Not Configurable
These restrictions are always active when the sandbox is enabled.
:::
----
### Process Restrictions
The seatbelt sandbox blocks tools and system calls that could bypass security controls:
- **Tunneling tools** — `nc`, `ncat`, `netcat`, `socat`, `telnet` are blocked to prevent bypassing the proxy
- **Raw sockets** — `SOCK_RAW` on `AF_INET`/`AF_INET6` is blocked to prevent raw packet crafting
- **Kernel extensions** — `system-kext-load` is denied
#### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `GOOSE_SANDBOX_PROTECT_FILES` | `true` | Write-protect `~/.ssh` and shell configs. Set to `false` to disable |
| `GOOSE_SANDBOX_BLOCK_RAW_SOCKETS` | `true` | Block `SOCK_RAW`. Set to `false` to disable |
| `GOOSE_SANDBOX_BLOCK_TUNNELING` | `true` | Block `nc`/`netcat`/`socat`/`telnet`. Set to `false` to disable |
### Proxy
----
### Network Filtering
The egress proxy inspects and filters all outgoing connections. You can customize filtering rules through the blocklist file and configuration variables.
The egress proxy checks connections in this order:
1. **Loopback detection** — Prevents using the proxy as a relay back to localhost
2. **Raw IP blocking** — Connections to bare IP addresses (no domain) are blocked
3. **Domain blocklist** — Domains listed in `blocked.txt` are denied (including all subdomains)
4. **SSH/Git host restrictions** — SSH ports (22, 2222, 7999) are restricted to known git hosts
For optional LaunchDarkly-based egress control, see [LaunchDarkly](#launchdarkly-optional).
#### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
@@ -87,16 +120,7 @@ All configuration is via environment variables. Defaults are designed to be secu
| `GOOSE_SANDBOX_GIT_HOSTS` | built-in list | Comma-separated list of allowed SSH git hosts (e.g. `github.com,gitlab.com`) |
| `GOOSE_SANDBOX_SSH_ALL_HOSTS` | `false` | Set to `true` to allow SSH to any host (not just git hosts) |
### LaunchDarkly (optional — not required)
LaunchDarkly is **not required**. The sandbox works fully without it using the local `blocked.txt` blocklist. These settings only apply if your organization uses LaunchDarkly for dynamic egress control.
| Variable | Default | Description |
|----------|---------|-------------|
| `LAUNCHDARKLY_CLIENT_ID` | — | LD client SDK key to enable dynamic egress control |
| `GOOSE_SANDBOX_LD_FAILOVER` | — | Failover mode if LD is unreachable: `allow`, `deny`, or `blocklist` |
## Domain Blocklist
#### Managing the Domain Blocklist
The file `~/.config/goose/sandbox/blocked.txt` controls which domains are blocked by the proxy. It's created automatically on first run from a bundled template.
@@ -109,13 +133,15 @@ transfer.sh
webhook.site
```
**Live reload:** Changes to `blocked.txt` take effect immediately — the proxy watches the file with `fs.watch` and reloads it automatically. No restart needed.
:::tip Live Reload
Changes to `blocked.txt` take effect immediately — the proxy watches the file with `fs.watch` and reloads it automatically. No restart needed.
:::
## SSH and Git
#### Using Git Over SSH
SSH git operations (`git clone git@github.com:...`) work through the sandbox via a bundled `connect-proxy.pl` script that acts as an SSH `ProxyCommand`. This routes SSH connections through the egress proxy, which then applies the same allowlist rules.
SSH git operations (e.g. `git clone git@github.com:...`) work through the sandbox via a bundled `connect-proxy.pl` script that acts as an SSH `ProxyCommand`. This routes SSH connections through the egress proxy, which then applies the same allowlist rules.
By default, SSH is only allowed to well-known git hosting domains (GitHub, GitLab, Bitbucket, etc.). To customise:
By default, SSH is only allowed to well-known git hosting domains (e.g. GitHub, GitLab, Bitbucket). To customize:
```bash
# Add custom git hosts
@@ -125,6 +151,19 @@ export GOOSE_SANDBOX_GIT_HOSTS="github.com,gitlab.com,your-gitea.internal.com"
export GOOSE_SANDBOX_SSH_ALL_HOSTS=true
```
----
### LaunchDarkly (Optional)
For enterprise environments, LaunchDarkly provides optional dynamic egress control. If not configured, the sandbox uses the local `blocked.txt` blocklist.
#### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `LAUNCHDARKLY_CLIENT_ID` | — | LD client SDK key to enable dynamic egress control |
| `GOOSE_SANDBOX_LD_FAILOVER` | — | Failover mode if LD is unreachable: `allow`, `deny`, or `blocklist` |
## Example Configurations
### Maximum security
@@ -148,7 +187,7 @@ export GOOSE_SANDBOX=true
export GOOSE_SANDBOX_ALLOW_SSH=false
```
### Relaxed mode (sandbox on, fewer restrictions)
### Relaxed mode (fewer restrictions)
```bash
export GOOSE_SANDBOX=true
@@ -169,14 +208,15 @@ export GOOSE_SANDBOX_LD_FAILOVER=blocklist # fall back to local blocklist if LD
## Troubleshooting
**"GOOSE_SANDBOX=true but sandbox-exec is not available (macOS only)"**
You're not on macOS, or `/usr/bin/sandbox-exec` is missing. The sandbox only works on macOS.
- **Error: "GOOSE_SANDBOX=true but sandbox-exec is not available (macOS only)"**
You're not on macOS, or `/usr/bin/sandbox-exec` is missing. The sandbox only works on macOS.
**Extensions or tools can't reach the network**
Check if the destination domain is in `~/.config/goose/sandbox/blocked.txt`, or if you need to enable `GOOSE_SANDBOX_ALLOW_IP=true` for IP-based endpoints.
- **Extensions or tools can't reach the network**
Check if the destination domain is in `~/.config/goose/sandbox/blocked.txt`, or if you need to enable `GOOSE_SANDBOX_ALLOW_IP=true` for IP-based endpoints.
**Git clone over SSH fails**
The target host may not be in the default git hosts allowlist. Add it with `GOOSE_SANDBOX_GIT_HOSTS=your-host.com` or set `GOOSE_SANDBOX_SSH_ALL_HOSTS=true`.
- **git clone over SSH fails**
The target host may not be in the default Git hosts allowlist. Add it with `GOOSE_SANDBOX_GIT_HOSTS=your-host.com` or set `GOOSE_SANDBOX_SSH_ALL_HOSTS=true`.
- **Want to inspect what the proxy is blocking?**
Check the [Desktop application logs](/docs/guides/logs#desktop-application-log). Blocked connections are logged with the prefix `[sandbox-proxy]` and include the reason for blocking.
**Want to inspect what the proxy is blocking?**
Check the Electron/goosed logs — blocked connections are logged with the reason.
@@ -25,6 +25,11 @@ import styles from '@site/src/components/Card/styles.module.css';
description="API specification for self-hosting ML-based prompt injection detection endpoints."
link="/docs/guides/security/classification-api-spec"
/>
<Card
title="macOS Sandbox for goose Desktop"
description="Control file access, network connections, and process restrictions for goose Desktop using Apple's sandbox technology."
link="/docs/guides/sandbox"
/>
</div>
</div>
@@ -182,35 +182,22 @@ Search allows you to find specific content within sessions or find specific sess
2. Enter your search term
3. Use shortcuts and search bar buttons to navigate the results
#### Search For Session By Name or Path
#### Search Across Sessions
To search all your sessions by name or working directory path:
To search conversation history across all your sessions:
1. Click the <PanelLeft className="inline" size={16} /> button in the top-left to open the sidebar
2. Click `View All` at the bottom of the `Chat` section
3. Use `Cmd+F` to open the search bar
3. Use `Cmd+F` (or `Ctrl+F`) to open the search bar
4. Enter your search term
5. Use keyboard shortcuts and search bar buttons to navigate the results (`Cmd+E` not supported)
This is a metadata-only search. It doesn't search conversation content. Note that searching by session ID (e.g. `20251108_1`) is supported, but this property isn't displayed in the UI.
:::tip
You can [rename sessions](#name-session) to give them descriptive names that you'll remember later.
:::
#### Search Across All Session Content
To search conversation content across all your sessions, ask goose directly in any chat session. For example:
This searches the content of messages in your conversations. The search is limited to the 10 most recent matching messages across sessions. If your search term appears in many messages, the search will only return a subset of sessions.
:::tip Ask goose directly
You can also use the built-in [Chat Recall extension](/docs/mcp/chatrecall-mcp) to ask goose to search your conversation history:
- "Find my earlier conversation about React hooks from last week"
- "Show me sessions where I worked on database migrations"
goose will search your session history and show relevant conversations with context from matching sessions.
:::info
This functionality requires the built-in [Chatrecall extension](/docs/mcp/chatrecall-mcp) to be enabled. You can enable it manually or, if [Extension Manager](/docs/mcp/extension-manager-mcp) is enabled, simply ask goose to use it:
- "Use chatrecall to find the latest status of the database migration project"
:::
</TabItem>
+18 -1
View File
@@ -172,6 +172,23 @@ The Developer extension provides these tools:
| `screen_capture` | Take screenshots | Debugging UI issues, documenting state | ✅ Low<br />Visual information only |
| `image_processor` | Process and resize images | Optimizing assets, format conversion | ✅ Low<br />Image manipulation only |
### Environment Variables in Shell Commands
Shell commands executed by the `shell` tool inherit the environment of the running goose process. This typically includes:
- System variables like `PATH`, `HOME`, and `USER`
- Environment variables present in the process that launched goose (for example, your terminal's environment when you start goose from a shell)
- Session-specific variables injected by goose, such as `AGENT_SESSION_ID` for [session-isolated workflows](/docs/guides/environment-variables#using-session-ids-in-workflows)
This enables workflows that depend on environment configuration, such as authenticated CLI operations and build processes.
:::info
goose Desktop or launcher-based starts may use a different environment and may not load your shell startup files.
:::
:::warning Sensitive Information
Environment variables may contain sensitive values like API keys and tokens (e.g., `GITHUB_TOKEN`, `AWS_ACCESS_KEY_ID`).
:::
### Access Control Features
You can layer multiple controls to match your risk tolerance and workflow:
@@ -222,4 +239,4 @@ As you become more comfortable with goose's behavior, you can adjust these setti
:::info
Also see the [Security Guide](/docs/guides/security/) for information about using goose safely.
:::
:::
+237
View File
@@ -0,0 +1,237 @@
---
title: Neighborhood Extension
description: Add Neighborhood as a goose Extension
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import GooseDesktopInstaller from '@site/src/components/GooseDesktopInstaller';
import CLIExtensionInstructions from '@site/src/components/CLIExtensionInstructions';
This tutorial covers how to add the Neighborhood extension to goose, enabling you to discover nearby restaurants, browse menus, and place takeout orders through natural conversation. Sellers are US-based.
:::tip TLDR
<Tabs groupId="interface">
<TabItem value="ui" label="goose Desktop" default>
[Launch the installer](goose://extension?type=streamable_http&url=https%3A%2F%2Fconnect.squareup.com%2Fv2%2Fmcp%2Fneighborhood&id=neighborhood&name=Neighborhood&description=Discover%20nearby%20restaurants%2C%20browse%20menus%2C%20and%20place%20takeout%20orders%20through%20natural%20conversation.)
</TabItem>
<TabItem value="cli" label="goose CLI">
1. Run the `configure` command:
```sh
goose configure
```
2. Choose to add a `Remote Extension`
```sh
┌ goose-configure
│
◇ What would you like to configure?
│ Add Extension (Connect to a new extension)
│
◆ What type of extension would you like to add?
│ ○ Built-in Extension
│ ○ Command-line Extension (Run a local command or script)
// highlight-start
│ ● Remote Extension
// highlight-end
└
```
3. Give your extension a name
```sh
┌ goose-configure
│
◇ What would you like to configure?
│ Add Extension (Connect to a new extension)
│
◇ What type of extension would you like to add?
│ Remote Extension
│
// highlight-start
◆ What would you like to call this extension?
│ Neighborhood
// highlight-end
└
```
4. Enter the remote endpoint URL
```sh
┌ goose-configure
│
◇ What would you like to configure?
│ Add Extension (Connect to a new extension)
│
◇ What type of extension would you like to add?
│ Remote Extension
│
◇ What would you like to call this extension?
│ Neighborhood
│
// highlight-start
◆ What is the SSE endpoint URI?
│ https://connect.squareup.com/v2/mcp/neighborhood
// highlight-end
└
```
5. Enter the number of seconds goose should wait for actions to complete before timing out. Default is 300s
```sh
┌ goose-configure
│
◇ What would you like to configure?
│ Add Extension (Connect to a new extension)
│
◇ What type of extension would you like to add?
│ Remote Extension
│
◇ What would you like to call this extension?
│ Neighborhood
│
◇ What is the SSE endpoint URI?
│ https://connect.squareup.com/v2/mcp/neighborhood
│
// highlight-start
◆ Please set the timeout for this tool (in secs):
│ 300
// highlight-end
└
```
6. Add a description if you want, otherwise select No to leave it blank.
```sh
┌ goose-configure
│
◇ What would you like to configure?
│ Add Extension (Connect to a new extension)
│
◇ What type of extension would you like to add?
│ Remote Extension
│
◇ What would you like to call this extension?
│ Neighborhood
│
◇ What is the SSE endpoint URI?
│ https://connect.squareup.com/v2/mcp/neighborhood
│
◇ Please set the timeout for this tool (in secs):
│ 300
│
// highlight-start
◆ Would you like to add a description?
│ ○ Yes / ● No
// highlight-end
│
└
```
7. No environment variables are needed, so select No.
```sh
┌ goose-configure
│
◇ What would you like to configure?
│ Add Extension (Connect to a new extension)
│
◇ What type of extension would you like to add?
│ Remote Extension
│
◇ What would you like to call this extension?
│ Neighborhood
│
◇ What is the SSE endpoint URI?
│ https://connect.squareup.com/v2/mcp/neighborhood
│
◇ Please set the timeout for this tool (in secs):
│ 300
│
◇ Would you like to add a description?
│ No
│
// highlight-start
◆ Would you like to add environment variables?
│ ○ Yes / ● No
// highlight-end
└ Added Neighborhood extension
```
</TabItem>
</Tabs>
:::
## Configuration
<Tabs groupId="interface">
<TabItem value="ui" label="goose Desktop" default>
<GooseDesktopInstaller
extensionId="neighborhood"
extensionName="Neighborhood"
description="Discover nearby restaurants, browse menus, and place takeout orders through natural conversation."
type="http"
url="https://connect.squareup.com/v2/mcp/neighborhood"
/>
</TabItem>
<TabItem value="cli" label="goose CLI">
<CLIExtensionInstructions
name="Neighborhood"
description="Discover nearby restaurants, browse menus, and place takeout orders through natural conversation."
type="http"
url="https://connect.squareup.com/v2/mcp/neighborhood"
/>
</TabItem>
</Tabs>
## Example Usage
Neighborhood is most powerful when goose combines it with context from your day — your schedule, your goals, your dietary needs. Instead of just searching for food, you can ask goose to reason about what to eat based on what you're doing.
### Finding a meal that fits your activity
#### goose Prompt
```
Use the neighborhood extension for this. I'm looking to get lunch today.
I'm at 375 W Broadway in New York. I'm playing tennis later tonight.
Could you suggest some options so I can play well?
```
#### goose Output
```
Here are some restaurants near 375 W Broadway in SoHo! For pre-tennis fuel,
I'd recommend these options:
Best picks for athletic performance:
Kale & Things - Perfect for a healthy, nutrient-dense meal with greens and
lean proteins to give you sustained energy without feeling heavy
Simply Natural Grill House NY (684 Broadway) - Great for grilled proteins
and wholesome meals that'll fuel your game
Ba'al Falafel (71 Sullivan St) - Mediterranean food with falafel, hummus,
and fresh veggies provides good carbs and protein
Tips for eating before tennis:
- Eat 2-3 hours before playing to allow digestion
- Focus on complex carbs + lean protein (avoid heavy, fatty foods)
- Stay hydrated - consider grabbing a drink from Steam & Sip or Drip Drop Café
Would you like me to pull up the menu for any of these restaurants so you can
see what they offer? Kale & Things sounds like it could be ideal for your
pre-match meal!
```
### More Prompt Ideas
Try combining Neighborhood with other goose extensions for even more useful workflows:
- **Pair with your calendar:** *"Check my calendar for today and find somewhere I can grab a quick pickup lunch before my 1pm meeting. I'm near Union Square."*
- **Track what you eat:** *"Order me a chicken bowl from that place on Sullivan St, and log the macros to my food diary."*
- **Plan for a group:** *"I'm meeting 3 friends near Bryant Park tonight. Find somewhere with good vegetarian options that's not too loud."*
+1 -1
View File
@@ -227,7 +227,7 @@ export default function SkillDetailPage(): JSX.Element {
</Button>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-500">
Requires <a href="https://block.github.io/goose/docs/mcp/skills-mcp" className="text-purple-600 hover:underline">Goose Skills extension</a> enabled
Requires <a href="https://block.github.io/goose/docs/mcp/summon-mcp" className="text-purple-600 hover:underline">Summon extension</a> enabled
</p>
</div>
+12
View File
@@ -756,5 +756,17 @@
"is_builtin": false,
"endorsed": false,
"environmentVariables": []
},
{
"id": "neighborhood",
"name": "Neighborhood",
"description": "Discover nearby restaurants, browse menus, and place takeout orders through natural conversation. Sellers are US-based.",
"type": "streamable-http",
"url": "https://connect.squareup.com/v2/mcp/neighborhood",
"link": "",
"installation_notes": "No local setup required. This extension connects to a remote service. Use the install button or run the command below.",
"is_builtin": false,
"endorsed": true,
"environmentVariables": []
}
]
+1 -21
View File
@@ -28,7 +28,6 @@ main().catch((err) => {
async function main() {
const schemaSrc = await fs.readFile(SCHEMA_PATH, "utf8");
const jsonSchema = JSON.parse(
// Convert JSON Schema $defs refs to OpenAPI component refs
schemaSrc.replaceAll("#/$defs/", "#/components/schemas/"),
);
@@ -63,7 +62,6 @@ async function main() {
async function postProcessTypes() {
const tsPath = resolve(OUTPUT_DIR, "types.gen.ts");
let src = await fs.readFile(tsPath, "utf8");
// Remove the ClientOptions type block injected by @hey-api (not part of our schema)
src = src.replace(/\nexport type ClientOptions =[\s\S]*?^};\n/m, "\n");
await fs.writeFile(tsPath, src);
}
@@ -72,17 +70,14 @@ async function postProcessIndex(meta: { methods: unknown[] }) {
const indexPath = resolve(OUTPUT_DIR, "index.ts");
let src = await fs.readFile(indexPath, "utf8");
// Strip ClientOptions from re-exports
src = src.replace(/,?\s*ClientOptions\s*,?/g, (match) => {
if (match.startsWith(",") && match.endsWith(",")) return ",";
if (match.startsWith(",")) return "";
return "";
});
// Fix bare relative imports to use .js extensions (required by nodenext consumers)
src = fixRelativeImports(src);
// Append method constants
const methodConstants = await prettier.format(
`
export const GOOSE_EXT_METHODS = ${JSON.stringify(meta.methods, null, 2)} as const;
@@ -94,7 +89,6 @@ export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number];
await fs.writeFile(indexPath, `${src}\n${methodConstants}`);
// Also fix imports in zod.gen.ts (it may import from types.gen)
for (const file of ["zod.gen.ts", "types.gen.ts"]) {
const filePath = resolve(OUTPUT_DIR, file);
try {
@@ -127,9 +121,6 @@ interface MethodMeta {
responseType: string | null;
}
/**
* Convert a method path like "session/list" or "working_dir/update" to camelCase "sessionList", "workingDirUpdate".
*/
function methodToCamelCase(method: string): string {
return method
.split(/[/_]/)
@@ -139,10 +130,6 @@ function methodToCamelCase(method: string): string {
.join("");
}
/**
* Generate a typed GooseClient class that wraps ClientSideConnection.extMethod()
* with proper TypeScript types and Zod runtime validation.
*/
async function generateClient(meta: { methods: MethodMeta[] }) {
const typeImports = new Set<string>();
const zodImports = new Set<string>();
@@ -153,7 +140,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
const fnName = methodToCamelCase(m.method);
const fullMethod = `_goose/${m.method}`;
// Build param type and arg
let paramType = "";
let paramArg = "";
let callParams = "{}";
@@ -164,7 +150,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
callParams = "params";
}
// Build return type and validation
let returnType: string;
let bodyLines: string[];
@@ -183,7 +168,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
`await this.conn.extMethod("${fullMethod}", ${callParams});`,
];
} else {
// Both request and response are untyped (serde_json::Value)
returnType = "Record<string, unknown>";
bodyLines = [
`return await this.conn.extMethod("${fullMethod}", ${callParams ? callParams : "{}"});`,
@@ -212,11 +196,7 @@ export interface ExtMethodProvider {
${typeImportLine}
${zodImportLine}
/**
* Typed client for Goose custom extension methods.
* Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation.
*/
export class GooseClient {
export class GooseExtClient {
constructor(private conn: ExtMethodProvider) {}
${methodDefs.join("\n")}
}
+1 -29
View File
@@ -39,7 +39,7 @@ import {
* Typed client for Goose custom extension methods.
* Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation.
*/
export class GooseClient {
export class GooseExtClient {
constructor(private conn: ExtMethodProvider) {}
async extensionsAdd(params: AddExtensionRequest): Promise<void> {
@@ -98,32 +98,4 @@ export class GooseClient {
const raw = await this.conn.extMethod("_goose/config/extensions", {});
return zGetExtensionsResponse.parse(raw) as GetExtensionsResponse;
}
async toolCall(): Promise<Record<string, unknown>> {
return await this.conn.extMethod("_goose/tool/call", {});
}
async providerUpdate(): Promise<Record<string, unknown>> {
return await this.conn.extMethod("_goose/provider/update", {});
}
async containerSet(): Promise<Record<string, unknown>> {
return await this.conn.extMethod("_goose/container/set", {});
}
async appsList(): Promise<Record<string, unknown>> {
return await this.conn.extMethod("_goose/apps/list", {});
}
async appsExport(): Promise<Record<string, unknown>> {
return await this.conn.extMethod("_goose/apps/export", {});
}
async appsImport(): Promise<Record<string, unknown>> {
return await this.conn.extMethod("_goose/apps/import", {});
}
async configProviders(): Promise<Record<string, unknown>> {
return await this.conn.extMethod("_goose/config/providers", {});
}
}
-35
View File
@@ -58,41 +58,6 @@ export const GOOSE_EXT_METHODS = [
requestType: null,
responseType: "GetExtensionsResponse",
},
{
method: "tool/call",
requestType: null,
responseType: null,
},
{
method: "provider/update",
requestType: null,
responseType: null,
},
{
method: "container/set",
requestType: null,
responseType: null,
},
{
method: "apps/list",
requestType: null,
responseType: null,
},
{
method: "apps/export",
requestType: null,
responseType: null,
},
{
method: "apps/import",
requestType: null,
responseType: null,
},
{
method: "config/providers",
requestType: null,
responseType: null,
},
] as const;
export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number];
+118
View File
@@ -0,0 +1,118 @@
import {
ClientSideConnection,
type Client,
type Stream,
type InitializeRequest,
type InitializeResponse,
type NewSessionRequest,
type NewSessionResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type PromptRequest,
type PromptResponse,
type CancelNotification,
type AuthenticateRequest,
type AuthenticateResponse,
type SetSessionModeRequest,
type SetSessionModeResponse,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type ForkSessionRequest,
type ForkSessionResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type ResumeSessionRequest,
type ResumeSessionResponse,
type SetSessionModelRequest,
type SetSessionModelResponse,
} from "@agentclientprotocol/sdk";
import { GooseExtClient } from "./generated/client.gen.js";
export class GooseClient {
private conn: ClientSideConnection;
private ext: GooseExtClient;
constructor(toClient: () => Client, stream: Stream) {
this.conn = new ClientSideConnection(toClient, stream);
this.ext = new GooseExtClient(this.conn);
}
get signal(): AbortSignal {
return this.conn.signal;
}
get closed(): Promise<void> {
return this.conn.closed;
}
initialize(params: InitializeRequest): Promise<InitializeResponse> {
return this.conn.initialize(params);
}
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
return this.conn.newSession(params);
}
loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
return this.conn.loadSession(params);
}
prompt(params: PromptRequest): Promise<PromptResponse> {
return this.conn.prompt(params);
}
cancel(params: CancelNotification): Promise<void> {
return this.conn.cancel(params);
}
authenticate(params: AuthenticateRequest): Promise<AuthenticateResponse> {
return this.conn.authenticate(params);
}
setSessionMode(
params: SetSessionModeRequest,
): Promise<SetSessionModeResponse> {
return this.conn.setSessionMode(params);
}
setSessionConfigOption(
params: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse> {
return this.conn.setSessionConfigOption(params);
}
unstable_forkSession(
params: ForkSessionRequest,
): Promise<ForkSessionResponse> {
return this.conn.unstable_forkSession(params);
}
unstable_listSessions(
params: ListSessionsRequest,
): Promise<ListSessionsResponse> {
return this.conn.unstable_listSessions(params);
}
unstable_resumeSession(
params: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
return this.conn.unstable_resumeSession(params);
}
unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse> {
return this.conn.unstable_setSessionModel(params);
}
extMethod(
method: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return this.conn.extMethod(method, params);
}
get goose(): GooseExtClient {
return this.ext;
}
}
+8 -2
View File
@@ -1,3 +1,9 @@
export * from "./generated/index.js";
export * from "./generated/types.gen.js";
export * from "./generated/zod.gen.js";
export { GooseClient } from "./generated/client.gen.js";
export { GooseClient } from "./goose-client.js";
export {
ClientSideConnection,
type Client,
type Stream,
} from "@agentclientprotocol/sdk";
+696
View File
@@ -1827,6 +1827,308 @@
}
}
},
"/local-inference/download": {
"post": {
"tags": [
"super::routes::local_inference"
],
"operationId": "download_hf_model",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DownloadModelRequest"
}
}
},
"required": true
},
"responses": {
"202": {
"description": "Download started",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid request"
}
}
}
},
"/local-inference/models": {
"get": {
"tags": [
"super::routes::local_inference"
],
"operationId": "list_local_models",
"responses": {
"200": {
"description": "List of available local LLM models",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LocalModelResponse"
}
}
}
}
}
}
}
},
"/local-inference/models/{model_id}": {
"delete": {
"tags": [
"super::routes::local_inference"
],
"operationId": "delete_local_model",
"parameters": [
{
"name": "model_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Model deleted"
},
"404": {
"description": "Model not found"
}
}
}
},
"/local-inference/models/{model_id}/download": {
"get": {
"tags": [
"super::routes::local_inference"
],
"operationId": "get_local_model_download_progress",
"parameters": [
{
"name": "model_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Download progress",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DownloadProgress"
}
}
}
},
"404": {
"description": "No active download"
}
}
},
"delete": {
"tags": [
"super::routes::local_inference"
],
"operationId": "cancel_local_model_download",
"parameters": [
{
"name": "model_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Download cancelled"
},
"404": {
"description": "No active download"
}
}
}
},
"/local-inference/models/{model_id}/settings": {
"get": {
"tags": [
"super::routes::local_inference"
],
"operationId": "get_model_settings",
"parameters": [
{
"name": "model_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Model settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelSettings"
}
}
}
},
"404": {
"description": "Model not found"
}
}
},
"put": {
"tags": [
"super::routes::local_inference"
],
"operationId": "update_model_settings",
"parameters": [
{
"name": "model_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelSettings"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Settings updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelSettings"
}
}
}
},
"404": {
"description": "Model not found"
},
"500": {
"description": "Failed to save settings"
}
}
}
},
"/local-inference/repo/{author}/{repo}/files": {
"get": {
"tags": [
"super::routes::local_inference"
],
"operationId": "get_repo_files",
"parameters": [
{
"name": "author",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "repo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "GGUF files in the repo",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepoVariantsResponse"
}
}
}
}
}
}
},
"/local-inference/search": {
"get": {
"tags": [
"super::routes::local_inference"
],
"operationId": "search_hf_models",
"parameters": [
{
"name": "q",
"in": "query",
"description": "Search query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "Max results",
"required": false,
"schema": {
"type": "integer",
"nullable": true,
"minimum": 0
}
}
],
"responses": {
"200": {
"description": "Search results",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HfModelInfo"
}
}
}
}
},
"500": {
"description": "Search failed"
}
}
}
},
"/mcp-ui-proxy": {
"get": {
"tags": [
@@ -3954,6 +4256,18 @@
}
}
},
"DownloadModelRequest": {
"type": "object",
"required": [
"spec"
],
"properties": {
"spec": {
"type": "string",
"description": "Model spec like \"bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M\""
}
}
},
"DownloadProgress": {
"type": "object",
"required": [
@@ -4588,6 +4902,100 @@
}
]
},
"HfGgufFile": {
"type": "object",
"description": "A single downloadable GGUF file (used internally and for downloads).",
"required": [
"filename",
"size_bytes",
"quantization",
"download_url"
],
"properties": {
"download_url": {
"type": "string"
},
"filename": {
"type": "string"
},
"quantization": {
"type": "string"
},
"size_bytes": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"HfModelInfo": {
"type": "object",
"required": [
"repo_id",
"author",
"model_name",
"downloads",
"gguf_files"
],
"properties": {
"author": {
"type": "string"
},
"downloads": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"gguf_files": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HfGgufFile"
}
},
"model_name": {
"type": "string"
},
"repo_id": {
"type": "string"
}
}
},
"HfQuantVariant": {
"type": "object",
"description": "A quantization variant — groups sharded files into one logical entry.",
"required": [
"quantization",
"size_bytes",
"filename",
"download_url",
"description",
"quality_rank"
],
"properties": {
"description": {
"type": "string"
},
"download_url": {
"type": "string"
},
"filename": {
"type": "string"
},
"quality_rank": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"quantization": {
"type": "string"
},
"size_bytes": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"Icon": {
"type": "object",
"required": [
@@ -4773,6 +5181,47 @@
}
}
},
"LocalModelResponse": {
"type": "object",
"required": [
"id",
"repo_id",
"filename",
"quantization",
"size_bytes",
"status",
"recommended",
"settings"
],
"properties": {
"filename": {
"type": "string"
},
"id": {
"type": "string"
},
"quantization": {
"type": "string"
},
"recommended": {
"type": "boolean"
},
"repo_id": {
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/ModelSettings"
},
"size_bytes": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"status": {
"$ref": "#/components/schemas/ModelDownloadStatus"
}
}
},
"McpAppResource": {
"type": "object",
"description": "MCP App Resource\nRepresents a UI resource that can be rendered in an MCP App",
@@ -5293,6 +5742,78 @@
}
}
},
"ModelDownloadStatus": {
"oneOf": [
{
"type": "object",
"required": [
"state"
],
"properties": {
"state": {
"type": "string",
"enum": [
"NotDownloaded"
]
}
}
},
{
"type": "object",
"required": [
"progress_percent",
"bytes_downloaded",
"total_bytes",
"state"
],
"properties": {
"bytes_downloaded": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"progress_percent": {
"type": "number",
"format": "float"
},
"speed_bps": {
"type": "integer",
"format": "int64",
"nullable": true,
"minimum": 0
},
"state": {
"type": "string",
"enum": [
"Downloading"
]
},
"total_bytes": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
{
"type": "object",
"required": [
"state"
],
"properties": {
"state": {
"type": "string",
"enum": [
"Downloaded"
]
}
}
}
],
"discriminator": {
"propertyName": "state"
}
},
"ModelInfo": {
"type": "object",
"description": "Information about a model's capabilities",
@@ -5417,6 +5938,71 @@
}
}
},
"ModelSettings": {
"type": "object",
"properties": {
"context_size": {
"type": "integer",
"format": "int32",
"nullable": true,
"minimum": 0
},
"flash_attention": {
"type": "boolean",
"nullable": true
},
"frequency_penalty": {
"type": "number",
"format": "float"
},
"max_output_tokens": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"n_batch": {
"type": "integer",
"format": "int32",
"nullable": true,
"minimum": 0
},
"n_gpu_layers": {
"type": "integer",
"format": "int32",
"nullable": true,
"minimum": 0
},
"n_threads": {
"type": "integer",
"format": "int32",
"nullable": true
},
"native_tool_calling": {
"type": "boolean"
},
"presence_penalty": {
"type": "number",
"format": "float"
},
"repeat_last_n": {
"type": "integer",
"format": "int32"
},
"repeat_penalty": {
"type": "number",
"format": "float"
},
"sampling": {
"$ref": "#/components/schemas/SamplingConfig"
},
"use_jinja": {
"type": "boolean"
},
"use_mlock": {
"type": "boolean"
}
}
},
"ParseRecipeRequest": {
"type": "object",
"required": [
@@ -6005,6 +6591,25 @@
}
}
},
"RepoVariantsResponse": {
"type": "object",
"required": [
"variants"
],
"properties": {
"recommended_index": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"variants": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HfQuantVariant"
}
}
}
},
"ResourceContents": {
"anyOf": [
{
@@ -6196,6 +6801,97 @@
}
}
},
"SamplingConfig": {
"oneOf": [
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"Greedy"
]
}
}
},
{
"type": "object",
"required": [
"temperature",
"top_k",
"top_p",
"min_p",
"type"
],
"properties": {
"min_p": {
"type": "number",
"format": "float"
},
"seed": {
"type": "integer",
"format": "int32",
"nullable": true,
"minimum": 0
},
"temperature": {
"type": "number",
"format": "float"
},
"top_k": {
"type": "integer",
"format": "int32"
},
"top_p": {
"type": "number",
"format": "float"
},
"type": {
"type": "string",
"enum": [
"Temperature"
]
}
}
},
{
"type": "object",
"required": [
"tau",
"eta",
"type"
],
"properties": {
"eta": {
"type": "number",
"format": "float"
},
"seed": {
"type": "integer",
"format": "int32",
"nullable": true,
"minimum": 0
},
"tau": {
"type": "number",
"format": "float"
},
"type": {
"type": "string",
"enum": [
"MirostatV2"
]
}
}
}
],
"discriminator": {
"propertyName": "type"
}
},
"SavePromptRequest": {
"type": "object",
"required": [
+39 -13
View File
@@ -128,10 +128,14 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@hey-api/openapi-ts": "^0.92.3",
"prettier": "^3.8.1",
"tsx": "^4.21.0",
"typescript": "~5.9.3"
},
"peerDependencies": {
"@agentclientprotocol/sdk": "*"
}
},
"node_modules/@acemir/cssom": {
@@ -233,6 +237,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -602,6 +607,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -642,6 +648,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1099,6 +1106,7 @@
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^4.1.1",
"fs-extra": "^9.0.1",
@@ -2732,6 +2740,7 @@
"integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^3.0.1",
"@inquirer/confirm": "^4.0.1",
@@ -3207,6 +3216,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -6531,8 +6541,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -6820,6 +6829,7 @@
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -6861,6 +6871,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -6871,6 +6882,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -7011,6 +7023,7 @@
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
@@ -7396,6 +7409,7 @@
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "4.0.18",
"fflate": "^0.8.2",
@@ -7644,6 +7658,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7716,6 +7731,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -8256,6 +8272,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -9558,8 +9575,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@@ -9617,6 +9633,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^24.9.0",
@@ -10624,6 +10641,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -11107,6 +11125,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -12336,10 +12355,11 @@
"license": "MIT"
},
"node_modules/hono": {
"version": "4.11.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz",
"integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -13396,6 +13416,7 @@
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -14638,7 +14659,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -14659,6 +14679,7 @@
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
@@ -17034,6 +17055,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -17135,7 +17157,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -17151,7 +17172,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -17507,6 +17527,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -17516,6 +17537,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -17537,8 +17559,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
@@ -19455,7 +19476,8 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -19986,6 +20008,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -20410,6 +20433,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -20500,6 +20524,7 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
@@ -21148,6 +21173,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+313
View File
@@ -232,6 +232,13 @@ export type DictationProviderStatus = {
uses_provider_config: boolean;
};
export type DownloadModelRequest = {
/**
* Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M"
*/
spec: string;
};
export type DownloadProgress = {
/**
* Bytes downloaded so far
@@ -446,6 +453,36 @@ export type GooseApp = McpAppResource & (WindowProps | null) & {
prd?: string | null;
};
/**
* A single downloadable GGUF file (used internally and for downloads).
*/
export type HfGgufFile = {
download_url: string;
filename: string;
quantization: string;
size_bytes: number;
};
export type HfModelInfo = {
author: string;
downloads: number;
gguf_files: Array<HfGgufFile>;
model_name: string;
repo_id: string;
};
/**
* A quantization variant — groups sharded files into one logical entry.
*/
export type HfQuantVariant = {
description: string;
download_url: string;
filename: string;
quality_rank: number;
quantization: string;
size_bytes: number;
};
export type Icon = {
mimeType?: string;
sizes?: Array<string>;
@@ -511,6 +548,17 @@ export type LoadedProvider = {
is_editable: boolean;
};
export type LocalModelResponse = {
filename: string;
id: string;
quantization: string;
recommended: boolean;
repo_id: string;
settings: ModelSettings;
size_bytes: number;
status: ModelDownloadStatus;
};
/**
* MCP App Resource
* Represents a UI resource that can be rendered in an MCP App
@@ -638,6 +686,18 @@ export type ModelConfig = {
toolshim_model?: string | null;
};
export type ModelDownloadStatus = {
state: 'NotDownloaded';
} | {
bytes_downloaded: number;
progress_percent: number;
speed_bps?: number | null;
state: 'Downloading';
total_bytes: number;
} | {
state: 'Downloaded';
};
/**
* Information about a model's capabilities
*/
@@ -690,6 +750,23 @@ export type ModelInfoResponse = {
source: string;
};
export type ModelSettings = {
context_size?: number | null;
flash_attention?: boolean | null;
frequency_penalty?: number;
max_output_tokens?: number | null;
n_batch?: number | null;
n_gpu_layers?: number | null;
n_threads?: number | null;
native_tool_calling?: boolean;
presence_penalty?: number;
repeat_last_n?: number;
repeat_penalty?: number;
sampling?: SamplingConfig;
use_jinja?: boolean;
use_mlock?: boolean;
};
export type ParseRecipeRequest = {
content: string;
};
@@ -909,6 +986,11 @@ export type RemoveExtensionRequest = {
session_id: string;
};
export type RepoVariantsResponse = {
recommended_index?: number | null;
variants: Array<HfQuantVariant>;
};
export type ResourceContents = {
_meta?: {
[key: string]: unknown;
@@ -986,6 +1068,22 @@ export type RunNowResponse = {
session_id: string;
};
export type SamplingConfig = {
type: 'Greedy';
} | {
min_p: number;
seed?: number | null;
temperature: number;
top_k: number;
top_p: number;
type: 'Temperature';
} | {
eta: number;
seed?: number | null;
tau: number;
type: 'MirostatV2';
};
export type SavePromptRequest = {
content: string;
};
@@ -2836,6 +2934,221 @@ export type StartTetrateSetupResponses = {
export type StartTetrateSetupResponse = StartTetrateSetupResponses[keyof StartTetrateSetupResponses];
export type DownloadHfModelData = {
body: DownloadModelRequest;
path?: never;
query?: never;
url: '/local-inference/download';
};
export type DownloadHfModelErrors = {
/**
* Invalid request
*/
400: unknown;
};
export type DownloadHfModelResponses = {
/**
* Download started
*/
202: string;
};
export type DownloadHfModelResponse = DownloadHfModelResponses[keyof DownloadHfModelResponses];
export type ListLocalModelsData = {
body?: never;
path?: never;
query?: never;
url: '/local-inference/models';
};
export type ListLocalModelsResponses = {
/**
* List of available local LLM models
*/
200: Array<LocalModelResponse>;
};
export type ListLocalModelsResponse = ListLocalModelsResponses[keyof ListLocalModelsResponses];
export type DeleteLocalModelData = {
body?: never;
path: {
model_id: string;
};
query?: never;
url: '/local-inference/models/{model_id}';
};
export type DeleteLocalModelErrors = {
/**
* Model not found
*/
404: unknown;
};
export type DeleteLocalModelResponses = {
/**
* Model deleted
*/
200: unknown;
};
export type CancelLocalModelDownloadData = {
body?: never;
path: {
model_id: string;
};
query?: never;
url: '/local-inference/models/{model_id}/download';
};
export type CancelLocalModelDownloadErrors = {
/**
* No active download
*/
404: unknown;
};
export type CancelLocalModelDownloadResponses = {
/**
* Download cancelled
*/
200: unknown;
};
export type GetLocalModelDownloadProgressData = {
body?: never;
path: {
model_id: string;
};
query?: never;
url: '/local-inference/models/{model_id}/download';
};
export type GetLocalModelDownloadProgressErrors = {
/**
* No active download
*/
404: unknown;
};
export type GetLocalModelDownloadProgressResponses = {
/**
* Download progress
*/
200: DownloadProgress;
};
export type GetLocalModelDownloadProgressResponse = GetLocalModelDownloadProgressResponses[keyof GetLocalModelDownloadProgressResponses];
export type GetModelSettingsData = {
body?: never;
path: {
model_id: string;
};
query?: never;
url: '/local-inference/models/{model_id}/settings';
};
export type GetModelSettingsErrors = {
/**
* Model not found
*/
404: unknown;
};
export type GetModelSettingsResponses = {
/**
* Model settings
*/
200: ModelSettings;
};
export type GetModelSettingsResponse = GetModelSettingsResponses[keyof GetModelSettingsResponses];
export type UpdateModelSettingsData = {
body: ModelSettings;
path: {
model_id: string;
};
query?: never;
url: '/local-inference/models/{model_id}/settings';
};
export type UpdateModelSettingsErrors = {
/**
* Model not found
*/
404: unknown;
/**
* Failed to save settings
*/
500: unknown;
};
export type UpdateModelSettingsResponses = {
/**
* Settings updated
*/
200: ModelSettings;
};
export type UpdateModelSettingsResponse = UpdateModelSettingsResponses[keyof UpdateModelSettingsResponses];
export type GetRepoFilesData = {
body?: never;
path: {
author: string;
repo: string;
};
query?: never;
url: '/local-inference/repo/{author}/{repo}/files';
};
export type GetRepoFilesResponses = {
/**
* GGUF files in the repo
*/
200: RepoVariantsResponse;
};
export type GetRepoFilesResponse = GetRepoFilesResponses[keyof GetRepoFilesResponses];
export type SearchHfModelsData = {
body?: never;
path?: never;
query: {
/**
* Search query
*/
q: string;
/**
* Max results
*/
limit?: number | null;
};
url: '/local-inference/search';
};
export type SearchHfModelsErrors = {
/**
* Search failed
*/
500: unknown;
};
export type SearchHfModelsResponses = {
/**
* Search results
*/
200: Array<HfModelInfo>;
};
export type SearchHfModelsResponse = SearchHfModelsResponses[keyof SearchHfModelsResponses];
export type McpUiProxyData = {
body?: never;
path?: never;
@@ -0,0 +1,397 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useConfig } from './ConfigContext';
import {
listLocalModels,
downloadHfModel,
getLocalModelDownloadProgress,
cancelLocalModelDownload,
type DownloadProgress,
type LocalModelResponse,
} from '../api';
import { toastService } from '../toasts';
import { trackOnboardingSetupFailed } from '../utils/analytics';
import { Goose } from './icons';
interface LocalModelSetupProps {
onSuccess: () => void;
onCancel: () => void;
}
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
};
const formatSize = (bytes: number): string => {
const mb = bytes / (1024 * 1024);
return mb >= 1024 ? `${(mb / 1024).toFixed(1)}GB` : `${mb.toFixed(0)}MB`;
};
type SetupPhase = 'loading' | 'select' | 'downloading' | 'error';
export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) {
const { upsert } = useConfig();
const [phase, setPhase] = useState<SetupPhase>('loading');
const [models, setModels] = useState<LocalModelResponse[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showAllModels, setShowAllModels] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cleanup = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
useEffect(() => cleanup, [cleanup]);
useEffect(() => {
const load = async () => {
try {
const response = await listLocalModels();
if (response.data) {
setModels(response.data);
const alreadyDownloaded = response.data.find((m) => m.status.state === 'Downloaded');
if (alreadyDownloaded) {
setSelectedModelId(alreadyDownloaded.id);
} else {
const recommended = response.data.find((m: LocalModelResponse) => m.recommended);
if (recommended) setSelectedModelId(recommended.id);
}
}
} catch (error) {
console.error('Failed to load local models:', error);
setErrorMessage('Failed to load available models. Please try again.');
setPhase('error');
return;
}
setPhase('select');
};
load();
}, []);
const finishSetup = async (modelId: string) => {
await upsert('GOOSE_PROVIDER', 'local', false);
await upsert('GOOSE_MODEL', modelId, false);
toastService.success({
title: 'Local Model Ready',
msg: `Running entirely on your machine with ${modelId}.`,
});
onSuccess();
};
const startDownload = async (modelId: string) => {
setPhase('downloading');
setDownloadProgress(null);
setErrorMessage(null);
const model = models.find((m) => m.id === modelId);
if (!model) {
setErrorMessage('Model not found');
setPhase('error');
return;
}
try {
await downloadHfModel({ body: { spec: model.id } });
} catch (error) {
console.error('Failed to start download:', error);
setErrorMessage('Failed to start download. Please try again.');
trackOnboardingSetupFailed('local', 'download_start_failed');
setPhase('error');
return;
}
pollRef.current = setInterval(async () => {
try {
const response = await getLocalModelDownloadProgress({ path: { model_id: modelId } });
if (response.data) {
setDownloadProgress(response.data);
if (response.data.status === 'completed') {
cleanup();
await finishSetup(modelId);
} else if (response.data.status === 'failed') {
cleanup();
setErrorMessage(response.data.error || 'Download failed.');
trackOnboardingSetupFailed('local', response.data.error || 'download_failed');
setPhase('error');
} else if (response.data.status === 'cancelled') {
cleanup();
setPhase('select');
}
}
} catch {
cleanup();
setErrorMessage('Lost connection to download. Please try again.');
trackOnboardingSetupFailed('local', 'progress_poll_failed');
setPhase('error');
}
}, 500);
};
const handleCancel = async () => {
if (phase === 'downloading' && selectedModelId) {
cleanup();
try {
await cancelLocalModelDownload({ path: { model_id: selectedModelId } });
} catch {
// best-effort
}
setDownloadProgress(null);
setPhase('select');
} else {
onCancel();
}
};
const handlePrimaryAction = async () => {
if (!selectedModelId) return;
const model = models.find((m) => m.id === selectedModelId);
if (!model) return;
if (model.status.state === 'Downloaded') {
await finishSetup(model.id);
} else {
await startDownload(model.id);
}
};
const recommended = models.find((m) => m.recommended);
const otherModels = models.filter((m) => m.id !== recommended?.id);
const selectedModel = models.find((m) => m.id === selectedModelId);
if (phase === 'loading') {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-text-muted mb-4"></div>
<p className="text-text-muted text-sm">Checking available models...</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="text-left space-y-3">
<div className="origin-bottom-left goose-icon-animation">
<Goose className="size-6 sm:size-8" />
</div>
<h1 className="text-2xl sm:text-4xl font-light">Run Locally</h1>
<p className="text-text-muted text-base sm:text-lg">
Download a model to run Goose entirely on your machine — no API keys, no accounts, completely free and private.
</p>
</div>
{/* Error state */}
{phase === 'error' && (
<div className="space-y-4">
<div className="border border-red-500/30 rounded-xl p-4 bg-red-500/5">
<p className="text-sm text-red-400">{errorMessage}</p>
</div>
<button
onClick={() => {
setErrorMessage(null);
setPhase('select');
}}
className="w-full px-6 py-3 bg-background-muted text-text-default rounded-lg transition-colors font-medium hover:bg-background-muted/80"
>
Try Again
</button>
<button
onClick={onCancel}
className="w-full px-6 py-3 bg-transparent text-text-muted rounded-lg hover:bg-background-muted transition-colors"
>
Back
</button>
</div>
)}
{/* Model selection */}
{phase === 'select' && (
<div className="space-y-5">
{/* Recommended model card */}
{recommended && (
<div
onClick={() => setSelectedModelId(recommended.id)}
className={`relative w-full p-4 sm:p-6 border rounded-xl cursor-pointer transition-all duration-200 group ${
selectedModelId === recommended.id
? 'border-blue-500 bg-blue-500/5'
: 'border-border-subtle hover:border-border-default'
}`}
>
<div className="absolute -top-2 -right-2 sm:-top-3 sm:-right-3 z-10">
<span className="inline-block px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded-full">
Best for your machine
</span>
</div>
<div className="flex items-start gap-3">
<input
type="radio"
checked={selectedModelId === recommended.id}
onChange={() => setSelectedModelId(recommended.id)}
className="cursor-pointer flex-shrink-0 mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-text-default text-sm sm:text-base">
{recommended.id}
</span>
{recommended.status.state === 'Downloaded' && (
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
Ready
</span>
)}
</div>
<p className="text-text-muted text-xs mt-1">
{formatSize(recommended.size_bytes)}
</p>
</div>
</div>
</div>
)}
{/* Expandable other models */}
{otherModels.length > 0 && (
<div>
<button
onClick={() => setShowAllModels(!showAllModels)}
className="text-sm text-blue-500 hover:text-blue-400 transition-colors flex items-center gap-1"
>
{showAllModels ? 'Hide other sizes' : `Show ${otherModels.length} other sizes`}
<svg
className={`w-3.5 h-3.5 transition-transform ${showAllModels ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showAllModels && (
<div className="mt-3 space-y-2">
{otherModels.map((model) => (
<div
key={model.id}
onClick={() => setSelectedModelId(model.id)}
className={`w-full p-4 border rounded-xl cursor-pointer transition-all duration-200 ${
selectedModelId === model.id
? 'border-blue-500 bg-blue-500/5'
: 'border-border-subtle hover:border-border-default'
}`}
>
<div className="flex items-start gap-3">
<input
type="radio"
checked={selectedModelId === model.id}
onChange={() => setSelectedModelId(model.id)}
className="cursor-pointer flex-shrink-0 mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-text-default text-sm">{model.id}</span>
<span className="text-xs text-text-muted">{formatSize(model.size_bytes)}</span>
{model.status.state === 'Downloaded' && (
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
Ready
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Primary action */}
<button
onClick={handlePrimaryAction}
disabled={!selectedModelId}
className="w-full px-6 py-3 bg-background-muted text-text-default rounded-lg transition-colors font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-background-muted/80"
>
{selectedModel?.status.state === 'Downloaded'
? `Use ${selectedModel.id}`
: selectedModel
? `Download ${selectedModel.id} (${formatSize(selectedModel.size_bytes)})`
: 'Select a model'}
</button>
<button
onClick={onCancel}
className="w-full px-6 py-3 bg-transparent text-text-muted rounded-lg hover:bg-background-muted transition-colors"
>
Back
</button>
</div>
)}
{/* Downloading state */}
{phase === 'downloading' && selectedModel && (
<div className="space-y-6">
<div className="border border-border-subtle rounded-xl p-5 sm:p-6 bg-background-default">
<p className="font-medium text-text-default text-sm sm:text-base mb-4">
Downloading {selectedModel.id}
</p>
{downloadProgress ? (
<div className="space-y-3">
{/* Progress bar */}
<div className="w-full bg-background-subtle rounded-full h-2 overflow-hidden">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-500 ease-out"
style={{ width: `${downloadProgress.progress_percent}%` }}
/>
</div>
{/* Stats row */}
<div className="flex justify-between text-xs text-text-muted">
<span>
{formatBytes(downloadProgress.bytes_downloaded)} of{' '}
{formatBytes(downloadProgress.total_bytes)}
</span>
<span>{downloadProgress.progress_percent.toFixed(0)}%</span>
</div>
<div className="flex justify-between text-xs text-text-muted">
{downloadProgress.speed_bps ? (
<span>{formatBytes(downloadProgress.speed_bps)}/s</span>
) : (
<span />
)}
{downloadProgress.eta_seconds != null && downloadProgress.eta_seconds > 0 && (
<span>
~{downloadProgress.eta_seconds < 60
? `${Math.round(downloadProgress.eta_seconds)}s`
: `${Math.round(downloadProgress.eta_seconds / 60)}m`}{' '}
remaining
</span>
)}
</div>
</div>
) : (
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-text-muted"></div>
<span className="text-sm text-text-muted">Starting download...</span>
</div>
)}
</div>
<button
onClick={handleCancel}
className="w-full px-6 py-3 bg-transparent text-text-muted rounded-lg hover:bg-background-muted transition-colors border border-border-subtle"
>
Cancel Download
</button>
</div>
)}
</div>
);
}
@@ -8,6 +8,7 @@ import { startChatGptCodexSetup } from '../utils/chatgptCodexSetup';
import WelcomeGooseLogo from './WelcomeGooseLogo';
import { toastService } from '../toasts';
import { OllamaSetup } from './OllamaSetup';
import { LocalModelSetup } from './LocalModelSetup';
import ApiKeyTester from './ApiKeyTester';
import { SwitchModelModal } from './settings/models/subcomponents/SwitchModelModal';
import { createNavigationHandler } from '../utils/navigationUtils';
@@ -34,6 +35,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
const [hasProvider, setHasProvider] = useState(false);
const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false);
const [showOllamaSetup, setShowOllamaSetup] = useState(false);
const [showLocalModelSetup, setShowLocalModelSetup] = useState(false);
const [userInActiveSetup, setUserInActiveSetup] = useState(false);
const [showSwitchModelModal, setShowSwitchModelModal] = useState(false);
const [switchModelProvider, setSwitchModelProvider] = useState<string | null>(null);
@@ -200,6 +202,19 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
setShowOllamaSetup(false);
};
const handleLocalModelComplete = () => {
trackOnboardingCompleted('local');
setShowLocalModelSetup(false);
setShowFirstTimeSetup(false);
setHasProvider(true);
navigate('/', { replace: true });
};
const handleLocalModelCancel = () => {
trackOnboardingAbandoned('local_model_setup');
setShowLocalModelSetup(false);
};
const handleRetrySetup = (setupType: 'openrouter' | 'tetrate' | 'chatgpt_codex') => {
if (setupType === 'openrouter') {
setOpenRouterSetupState(null);
@@ -285,6 +300,23 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
return <OllamaSetup onSuccess={handleOllamaComplete} onCancel={handleOllamaCancel} />;
}
if (showLocalModelSetup) {
return (
<div className="h-screen w-full bg-background-default overflow-hidden">
<div className="h-full overflow-y-auto">
<div className="min-h-full flex flex-col items-center justify-center p-4 py-8">
<div className="max-w-2xl w-full mx-auto p-8">
<LocalModelSetup
onSuccess={handleLocalModelComplete}
onCancel={handleLocalModelCancel}
/>
</div>
</div>
</div>
</div>
);
}
if (!hasProvider && showFirstTimeSetup) {
return (
<div className="h-screen w-full bg-background-default overflow-hidden relative">
@@ -316,6 +348,48 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
}}
/>
{/* Run Locally Card */}
<div className="relative w-full mb-4">
<div className="absolute -top-2 -right-2 sm:-top-3 sm:-right-3 z-20">
<span className="inline-block px-2 py-1 text-xs font-medium bg-green-600 text-white rounded-full">
Free &amp; Private
</span>
</div>
<div
onClick={() => {
trackOnboardingProviderSelected('local');
setShowLocalModelSetup(true);
}}
className="w-full p-4 sm:p-6 bg-transparent border rounded-xl transition-all duration-200 cursor-pointer group"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-medium text-text-default text-sm sm:text-base">
Run Locally
</span>
</div>
<div className="text-text-muted group-hover:text-text-default transition-colors">
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
<p className="text-text-muted text-sm sm:text-base">
Download a model and run entirely on your machine. No API keys, no accounts.
</p>
</div>
</div>
{/* ChatGPT Subscription Card - Full Width */}
<div className="relative w-full mb-4">
<div className="absolute -top-2 -right-2 sm:-top-3 sm:-right-3 z-20">
@@ -134,22 +134,15 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
!costInfo ||
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined)
) {
// If it's a known free/local provider, show $0.000000 without "not available" message
const freeProviders = ['ollama', 'local', 'localhost'];
if (freeProviders.includes(currentProvider.toLowerCase())) {
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center h-full text-text-default/70 hover:text-text-default transition-colors cursor-default translate-y-[1px]">
<CoinIcon className="mr-1" size={16} />
<span className="text-xs font-mono">0.0000</span>
</div>
</TooltipTrigger>
<TooltipContent>
{`Local model (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`}
</TooltipContent>
</Tooltip>
<div className="flex items-center justify-center h-full text-text-default/70 transition-colors cursor-default translate-y-[1px]">
<span className="text-xs font-mono">
{inputTokens.toLocaleString()}↑ {outputTokens.toLocaleString()}↓
</span>
</div>
<div className="w-px h-4 bg-border-default mx-2" />
</>
);
@@ -9,10 +9,11 @@ import ConfigSettings from './config/ConfigSettings';
import PromptsSettingsSection from './PromptsSettingsSection';
import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Bot, Share2, Monitor, MessageSquare, FileText, Keyboard } from 'lucide-react';
import { Bot, Share2, Monitor, MessageSquare, FileText, Keyboard, HardDrive } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import ChatSettingsSection from './chat/ChatSettingsSection';
import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection';
import LocalInferenceSection from './localInference/LocalInferenceSection';
import { CONFIGURATION_ENABLED } from '../../updates';
import { trackSettingsTabViewed } from '../../utils/analytics';
@@ -54,6 +55,7 @@ export default function SettingsView({
chat: 'chat',
prompts: 'prompts',
keyboard: 'keyboard',
'local-inference': 'local-inference',
};
const targetTab = sectionToTab[viewOptions.section];
@@ -112,6 +114,14 @@ export default function SettingsView({
<Bot className="h-4 w-4" />
Models
</TabsTrigger>
<TabsTrigger
value="local-inference"
className="flex gap-2"
data-testid="settings-local-inference-tab"
>
<HardDrive className="h-4 w-4" />
Local Inference
</TabsTrigger>
<TabsTrigger value="chat" className="flex gap-2" data-testid="settings-chat-tab">
<MessageSquare className="h-4 w-4" />
Chat
@@ -155,6 +165,13 @@ export default function SettingsView({
<ModelsSection setView={setView} />
</TabsContent>
<TabsContent
value="local-inference"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<LocalInferenceSection />
</TabsContent>
<TabsContent
value="chat"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
@@ -38,19 +38,6 @@ export const LocalModelManager = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Determine if we should show all models by default (if non-recommended models are downloaded)
useEffect(() => {
if (models.length === 0) return;
const hasDownloadedNonRecommended = models.some(
(model) => model.downloaded && !model.recommended
);
if (hasDownloadedNonRecommended && !showAllModels) {
setShowAllModels(true);
}
}, [models, showAllModels]);
const loadSelectedModel = async () => {
try {
const value = await read(LOCAL_WHISPER_MODEL_CONFIG_KEY, false);
@@ -145,8 +132,14 @@ export const LocalModelManager = () => {
}
};
const displayedModels = showAllModels ? models : models.filter((m) => m.recommended);
const hasDownloadedNonRecommended = models.some(
(model) => model.downloaded && !model.recommended
);
const displayedModels = showAllModels || hasDownloadedNonRecommended
? models
: models.filter((m) => m.recommended);
const hasNonRecommendedModels = models.some((m) => !m.recommended);
const showToggleButton = hasNonRecommendedModels && !hasDownloadedNonRecommended;
return (
<div className="space-y-3">
@@ -267,7 +260,7 @@ export const LocalModelManager = () => {
})}
</div>
{hasNonRecommendedModels && (
{showToggleButton && (
<Button
variant="ghost"
size="sm"
@@ -282,7 +275,7 @@ export const LocalModelManager = () => {
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Show all models
Show all models ({models.length - displayedModels.length} more)
</>
)}
</Button>
@@ -0,0 +1,358 @@
import { useState, useCallback, useRef } from 'react';
import { Search, Download, ChevronDown, ChevronUp, Loader2, Star } from 'lucide-react';
import { Button } from '../../ui/button';
import {
searchHfModels,
getRepoFiles,
downloadHfModel,
type HfModelInfo,
type HfQuantVariant,
} from '../../../api';
const formatBytes = (bytes: number): string => {
if (bytes === 0) return 'unknown';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
};
const formatDownloads = (n: number): string => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return `${n}`;
};
interface RepoData {
variants: HfQuantVariant[];
recommendedIndex: number | null;
}
interface Props {
onDownloadStarted: (modelId: string) => void;
}
export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<HfModelInfo[]>([]);
const [expandedRepo, setExpandedRepo] = useState<string | null>(null);
const [repoData, setRepoData] = useState<Record<string, RepoData>>({});
const [searching, setSearching] = useState(false);
const [downloading, setDownloading] = useState<Set<string>>(new Set());
const [loadingFiles, setLoadingFiles] = useState<Set<string>>(new Set());
const [directSpec, setDirectSpec] = useState('');
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
setError(null);
return;
}
setSearching(true);
setError(null);
try {
const response = await searchHfModels({
query: { q, limit: 20 },
});
if (response.data) {
// Pre-fetch variants for all results and filter out repos with no suitable quantizations
const modelsWithVariants = await Promise.all(
response.data.map(async (model) => {
try {
const [author, repo] = model.repo_id.split('/');
const filesResponse = await getRepoFiles({ path: { author, repo } });
if (filesResponse.data && filesResponse.data.variants.length > 0) {
return { model, data: filesResponse.data };
}
} catch {
// Skip repos we can't fetch
}
return null;
})
);
const validResults = modelsWithVariants.filter(Boolean) as {
model: HfModelInfo;
data: { variants: HfQuantVariant[]; recommended_index?: number | null };
}[];
setResults(validResults.map((r) => r.model));
setRepoData((prev) => {
const next = { ...prev };
for (const r of validResults) {
next[r.model.repo_id] = {
variants: r.data.variants,
recommendedIndex: r.data.recommended_index ?? null,
};
}
return next;
});
if (validResults.length === 0) {
setError('No GGUF models found for this query.');
}
} else {
console.error('Search response:', response);
const errMsg = response.error
? `Search error: ${JSON.stringify(response.error)}`
: 'Search returned no data.';
setError(errMsg);
}
} catch (e) {
console.error('Search failed:', e);
setError('Search failed. Please try again.');
} finally {
setSearching(false);
}
}, []);
const handleQueryChange = (value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 300);
};
const toggleRepo = async (repoId: string) => {
if (expandedRepo === repoId) {
setExpandedRepo(null);
return;
}
setExpandedRepo(repoId);
if (!repoData[repoId]?.variants.length) {
setLoadingFiles((prev) => new Set(prev).add(repoId));
try {
const [author, repo] = repoId.split('/');
const response = await getRepoFiles({
path: { author, repo },
});
if (response.data) {
const variants = response.data.variants;
setRepoData((prev) => ({
...prev,
[repoId]: {
variants,
recommendedIndex: response.data!.recommended_index ?? null,
},
}));
}
} catch (e) {
console.error('Failed to fetch repo files:', e);
} finally {
setLoadingFiles((prev) => {
const next = new Set(prev);
next.delete(repoId);
return next;
});
}
}
};
const startDownload = async (repoId: string, quantization: string) => {
const spec = `${repoId}:${quantization}`;
setDownloading((prev) => new Set(prev).add(spec));
try {
const response = await downloadHfModel({
body: { spec },
});
if (response.data) {
onDownloadStarted(response.data);
}
} catch (e) {
console.error('Download failed:', e);
} finally {
setDownloading((prev) => {
const next = new Set(prev);
next.delete(spec);
return next;
});
}
};
const startDirectDownload = async () => {
const spec = directSpec.trim();
if (!spec) return;
const key = `direct:${spec}`;
setDownloading((prev) => new Set(prev).add(key));
try {
const response = await downloadHfModel({
body: { spec },
});
if (response.data) {
onDownloadStarted(response.data);
setDirectSpec('');
}
} catch (e) {
console.error('Direct download failed:', e);
} finally {
setDownloading((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}
};
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-text-default mb-2">Search HuggingFace</h4>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder="Search for GGUF models..."
className="w-full pl-9 pr-4 py-2 text-sm border border-border-subtle rounded-lg bg-background-default text-text-default placeholder:text-text-muted focus:outline-none focus:border-accent-primary"
/>
{searching && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted animate-spin" />
)}
</div>
</div>
{error && !searching && (
<p className="text-xs text-text-muted">{error}</p>
)}
{results.length > 0 && (
<div className="space-y-1 max-h-96 overflow-y-auto">
{results.map((model) => {
const isExpanded = expandedRepo === model.repo_id;
const data = repoData[model.repo_id];
const variants = data?.variants || [];
const recommendedIndex = data?.recommendedIndex ?? null;
return (
<div key={model.repo_id} className="border border-border-subtle rounded-lg">
<button
onClick={() => toggleRepo(model.repo_id)}
className="w-full flex items-center justify-between p-3 text-left hover:bg-background-subtle rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-default truncate">
{model.repo_id}
</span>
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-text-muted">
↓ {formatDownloads(model.downloads)}
</span>
</div>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-text-muted flex-shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-text-muted flex-shrink-0" />
)}
</button>
{isExpanded && (
<div className="border-t border-border-subtle px-3 pb-3 space-y-1">
{loadingFiles.has(model.repo_id) && (
<div className="flex items-center gap-2 py-2 text-xs text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
Loading variants...
</div>
)}
{variants.map((variant, idx) => {
const dlKey = `${model.repo_id}:${variant.quantization}`;
const isStarting = downloading.has(dlKey);
const isRecommended = idx === recommendedIndex;
return (
<div
key={variant.quantization}
className={`flex items-center justify-between py-2 px-2 rounded ${
isRecommended
? 'bg-blue-500/5 border border-blue-500/20'
: 'hover:bg-background-subtle'
}`}
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1 mr-3">
<div className="flex items-center gap-2">
<span className="text-xs font-mono font-medium text-text-default">
{variant.quantization}
</span>
<span className="text-xs text-text-muted">
{formatBytes(variant.size_bytes)}
</span>
{isRecommended && (
<span className="inline-flex items-center gap-1 text-xs bg-blue-500 text-white px-1.5 py-0.5 rounded">
<Star className="w-3 h-3" />
Recommended
</span>
)}
</div>
{variant.description && (
<span className="text-xs text-text-muted">
{variant.description}
</span>
)}
</div>
<Button
variant="outline"
size="sm"
disabled={isStarting}
onClick={() => startDownload(model.repo_id, variant.quantization)}
>
{isStarting ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<>
<Download className="w-3 h-3 mr-1" />
Download
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
<div>
<h4 className="text-sm font-medium text-text-default mb-2">Direct Download</h4>
<p className="text-xs text-text-muted mb-2">
Specify a model directly: <code className="bg-background-subtle px-1 rounded">user/repo:quantization</code>
</p>
<div className="flex gap-2">
<input
type="text"
value={directSpec}
onChange={(e) => setDirectSpec(e.target.value)}
placeholder="bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M"
className="flex-1 px-3 py-2 text-sm border border-border-subtle rounded-lg bg-background-default text-text-default placeholder:text-text-muted focus:outline-none focus:border-accent-primary"
onKeyDown={(e) => {
if (e.key === 'Enter') startDirectDownload();
}}
/>
<Button
variant="outline"
size="sm"
disabled={!directSpec.trim() || downloading.has(`direct:${directSpec}`)}
onClick={startDirectDownload}
>
{downloading.has(`direct:${directSpec}`) ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Download className="w-4 h-4 mr-1" />
Download
</>
)}
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,9 @@
import { LocalInferenceSettings } from './LocalInferenceSettings';
export default function LocalInferenceSection() {
return (
<section id="local-inference" className="space-y-4 pr-4 pb-8">
<LocalInferenceSettings />
</section>
);
}
@@ -0,0 +1,401 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Download, Trash2, X, ChevronDown, ChevronUp, Settings2 } from 'lucide-react';
import { Button } from '../../ui/button';
import { useModelAndProvider } from '../../ModelAndProviderContext';
import {
listLocalModels,
downloadHfModel,
getLocalModelDownloadProgress,
cancelLocalModelDownload,
deleteLocalModel,
setConfigProvider,
type DownloadProgress,
type LocalModelResponse,
} from '../../../api';
import { HuggingFaceModelSearch } from './HuggingFaceModelSearch';
import { ModelSettingsPanel } from './ModelSettingsPanel';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '../../ui/dialog';
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
};
export const LocalInferenceSettings = () => {
const [models, setModels] = useState<LocalModelResponse[]>([]);
const [downloads, setDownloads] = useState<Map<string, DownloadProgress>>(new Map());
const [showAllFeatured, setShowAllFeatured] = useState(false);
const [settingsOpenFor, setSettingsOpenFor] = useState<string | null>(null);
const { currentModel, currentProvider, setProviderAndModel } = useModelAndProvider();
const downloadSectionRef = useRef<HTMLDivElement>(null);
const selectedModelId = currentProvider === 'local' ? currentModel : null;
const loadModels = useCallback(async () => {
try {
const response = await listLocalModels();
if (response.data) {
setModels(response.data);
}
} catch (error) {
console.error('Failed to load models:', error);
}
}, []);
// Check for any in-progress downloads when models list changes
const detectActiveDownloads = useCallback(async () => {
for (const model of models) {
if (downloads.has(model.id)) continue;
// Check models that the API reports as downloading
if (model.status.state === 'Downloading') {
pollDownloadProgress(model.id);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [models, downloads]);
useEffect(() => {
loadModels();
}, [loadModels]);
useEffect(() => {
if (models.length > 0) {
detectActiveDownloads();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [models]);
const selectModel = async (modelId: string) => {
setProviderAndModel('local', modelId);
try {
await setConfigProvider({
body: { provider: 'local', model: modelId },
throwOnError: true,
});
} catch (error) {
console.error('Failed to select model:', error);
}
};
const startFeaturedDownload = async (modelId: string) => {
const model = models.find((m) => m.id === modelId);
if (!model) return;
try {
await downloadHfModel({ body: { spec: model.id } });
pollDownloadProgress(modelId);
scrollToDownloads();
} catch (error) {
console.error('Failed to start download:', error);
}
};
const scrollToDownloads = useCallback(() => {
requestAnimationFrame(() => {
downloadSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}, []);
const pollDownloadProgress = (modelId: string) => {
const interval = setInterval(async () => {
try {
const response = await getLocalModelDownloadProgress({ path: { model_id: modelId } });
if (response.data) {
const progress = response.data;
setDownloads((prev) => new Map(prev).set(modelId, progress));
if (progress.status === 'completed') {
clearInterval(interval);
setDownloads((prev) => {
const next = new Map(prev);
next.delete(modelId);
return next;
});
await loadModels();
await selectModel(modelId);
} else if (progress.status === 'failed') {
clearInterval(interval);
await loadModels();
}
} else {
clearInterval(interval);
}
} catch {
clearInterval(interval);
}
}, 1000);
};
const cancelDownload = async (modelId: string) => {
try {
await cancelLocalModelDownload({ path: { model_id: modelId } });
setDownloads((prev) => {
const next = new Map(prev);
next.delete(modelId);
return next;
});
} catch (error) {
console.error('Failed to cancel download:', error);
}
};
const handleDeleteModel = async (modelId: string) => {
if (!window.confirm('Delete this model? You can re-download it later.')) return;
try {
await deleteLocalModel({ path: { model_id: modelId } });
await loadModels();
} catch (error) {
console.error('Failed to delete model:', error);
}
};
const handleHfDownloadStarted = (modelId: string) => {
pollDownloadProgress(modelId);
loadModels();
scrollToDownloads();
};
const isDownloaded = (model: LocalModelResponse) => model.status.state === 'Downloaded';
const isNotDownloaded = (model: LocalModelResponse) =>
model.status.state === 'NotDownloaded' && !downloads.has(model.id);
const downloadedModels = models.filter(isDownloaded);
const notDownloadedModels = models.filter(isNotDownloaded);
const recommendedModels = notDownloadedModels.filter((m) => m.recommended);
const displayedFeatured = showAllFeatured ? notDownloadedModels : recommendedModels;
const showFeaturedToggle = notDownloadedModels.length > recommendedModels.length;
return (
<div className="space-y-6">
<div>
<h3 className="text-text-default font-medium">Local Inference Models</h3>
<p className="text-xs text-text-muted max-w-2xl mt-1">
Download and manage local LLM models for inference without API keys. Search HuggingFace
for any GGUF model or use the featured picks below.
</p>
</div>
{/* Active Downloads */}
{downloads.size > 0 && (
<div ref={downloadSectionRef}>
<h4 className="text-sm font-medium text-text-default mb-2">Downloading</h4>
<div className="space-y-2">
{Array.from(downloads.entries()).map(([modelId, progress]) => {
if (progress.status === 'completed') return null;
return (
<div
key={modelId}
className="border rounded-lg p-3 border-border-subtle bg-background-default"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-default truncate">
{modelId}
</span>
{progress.status === 'downloading' && (
<Button
variant="ghost"
size="sm"
onClick={() => cancelDownload(modelId)}
className="text-destructive hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
{progress.status === 'downloading' && (
<div className="space-y-1">
<div className="w-full bg-background-subtle rounded-full h-2">
<div
className="bg-accent-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${progress.progress_percent}%` }}
/>
</div>
<div className="flex justify-between text-xs text-text-muted">
<span>
{formatBytes(progress.bytes_downloaded)} /{' '}
{formatBytes(progress.total_bytes)} (
{progress.progress_percent.toFixed(0)}%)
</span>
<span className="flex gap-2">
{progress.eta_seconds != null && progress.eta_seconds > 0 && (
<span>
{progress.eta_seconds < 60
? `${Math.round(progress.eta_seconds)}s`
: `${Math.round(progress.eta_seconds / 60)}m`}{' '}
remaining
</span>
)}
{progress.speed_bps != null && progress.speed_bps > 0 && (
<span>{formatBytes(progress.speed_bps)}/s</span>
)}
</span>
</div>
</div>
)}
{progress.status === 'failed' && (
<p className="text-xs text-destructive">
{progress.error || 'Download failed'}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Downloaded Models */}
{downloadedModels.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-default mb-2">Downloaded Models</h4>
<div className="space-y-2">
{downloadedModels.map((model) => {
const isSelected = selectedModelId === model.id;
return (
<div
key={model.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? 'border-accent-primary bg-accent-primary/5'
: 'border-border-subtle bg-background-default hover:border-border-default'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<input
type="radio"
checked={isSelected}
onChange={() => selectModel(model.id)}
className="cursor-pointer"
/>
<span className="text-sm font-medium text-text-default">
{model.id}
</span>
<span className="text-xs text-text-muted">
{formatBytes(model.size_bytes)}
</span>
{model.recommended && (
<span className="text-xs bg-blue-500 text-white px-2 py-0.5 rounded">
Recommended
</span>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setSettingsOpenFor(model.id)}
title="Model settings"
>
<Settings2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteModel(model.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Featured Models (not yet downloaded) */}
{displayedFeatured.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-default mb-2">Featured Models</h4>
<div className="space-y-2">
{displayedFeatured.map((model) => (
<div
key={model.id}
className="border rounded-lg p-3 border-border-subtle bg-background-default hover:border-border-default"
>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-sm font-medium text-text-default">
{model.id}
</h4>
<span className="text-xs text-text-muted">
{formatBytes(model.size_bytes)}
</span>
{model.recommended && (
<span className="text-xs bg-blue-500 text-white px-2 py-0.5 rounded">
Recommended
</span>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => startFeaturedDownload(model.id)}
>
<Download className="w-4 h-4 mr-1" />
Download
</Button>
</div>
</div>
))}
</div>
{showFeaturedToggle && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllFeatured(!showAllFeatured)}
className="w-full text-text-muted hover:text-text-default mt-2"
>
{showAllFeatured ? (
<>
<ChevronUp className="w-4 h-4 mr-1" />
Show recommended only
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Show all featured ({notDownloadedModels.length - displayedFeatured.length} more)
</>
)}
</Button>
)}
</div>
)}
{/* HuggingFace Search */}
<div className="border-t border-border-subtle pt-4">
<HuggingFaceModelSearch onDownloadStarted={handleHfDownloadStarted} />
</div>
{models.length === 0 && (
<div className="text-center py-6 text-text-muted text-sm">No models available</div>
)}
<Dialog
open={!!settingsOpenFor}
onOpenChange={(open) => {
if (!open) setSettingsOpenFor(null);
}}
>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Model Settings</DialogTitle>
<p className="text-sm text-text-muted">{settingsOpenFor || ''}</p>
</DialogHeader>
{settingsOpenFor && <ModelSettingsPanel modelId={settingsOpenFor} />}
</DialogContent>
</Dialog>
</div>
);
};
@@ -0,0 +1,415 @@
import { useState, useEffect, useCallback } from 'react';
import { RotateCcw } from 'lucide-react';
import { Button } from '../../ui/button';
import { Switch } from '../../ui/switch';
import {
getModelSettings,
updateModelSettings,
type ModelSettings,
type SamplingConfig,
} from '../../../api';
const DEFAULT_SETTINGS: ModelSettings = {
context_size: null,
max_output_tokens: null,
sampling: { type: 'Temperature', temperature: 0.8, top_k: 40, top_p: 0.95, min_p: 0.05, seed: null },
repeat_penalty: 1.0,
repeat_last_n: 64,
frequency_penalty: 0.0,
presence_penalty: 0.0,
n_batch: null,
n_gpu_layers: null,
use_mlock: false,
flash_attention: null,
n_threads: null,
native_tool_calling: false,
};
type SamplingType = SamplingConfig['type'];
function NumberField({
label,
description,
value,
onChange,
placeholder,
min,
max,
step,
allowNull,
}: {
label: string;
description?: string;
value: number | null | undefined;
onChange: (v: number | null) => void;
placeholder?: string;
min?: number;
max?: number;
step?: number;
allowNull?: boolean;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-text-default">{label}</label>
{description && <span className="text-xs text-text-muted">{description}</span>}
<input
type="number"
className="w-full rounded border border-border-subtle bg-background-default px-2 py-1 text-sm text-text-default"
value={value ?? ''}
onChange={(e) => {
const raw = e.target.value;
if (raw === '' && allowNull) {
onChange(null);
} else {
const n = step && step < 1 ? parseFloat(raw) : parseInt(raw, 10);
if (!isNaN(n)) onChange(n);
}
}}
placeholder={placeholder ?? 'Auto'}
min={min}
max={max}
step={step}
/>
</div>
);
}
function ToggleField({
label,
description,
value,
onChange,
}: {
label: string;
description?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-text-default">{label}</div>
{description && <span className="text-xs text-text-muted">{description}</span>}
</div>
<Switch checked={value} onCheckedChange={onChange} variant="mono" />
</div>
);
}
function SelectField<T extends string>({
label,
description,
value,
options,
onChange,
}: {
label: string;
description?: string;
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-text-default">{label}</div>
{description && <span className="text-xs text-text-muted">{description}</span>}
</div>
<select
value={value}
onChange={(e) => onChange(e.target.value as T)}
className="rounded border border-border-subtle bg-background-default px-2 py-1 text-xs text-text-default"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
const [settings, setSettings] = useState<ModelSettings>(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const res = await getModelSettings({ path: { model_id: modelId } });
if (res.data) setSettings(res.data);
} catch {
// use defaults
} finally {
setLoading(false);
}
}, [modelId]);
useEffect(() => {
load();
}, [load]);
const save = async (updated: ModelSettings) => {
setSettings(updated);
setSaving(true);
try {
await updateModelSettings({ path: { model_id: modelId }, body: updated });
} catch (e) {
console.error('Failed to save settings:', e);
} finally {
setSaving(false);
}
};
const resetDefaults = () => save(DEFAULT_SETTINGS);
const updateField = <K extends keyof ModelSettings>(key: K, value: ModelSettings[K]) => {
save({ ...settings, [key]: value });
};
const samplingType: SamplingType = settings.sampling?.type ?? 'Temperature';
const setSamplingType = (type: SamplingType) => {
let sampling: SamplingConfig;
if (type === 'Greedy') {
sampling = { type: 'Greedy' };
} else if (type === 'MirostatV2') {
sampling = { type: 'MirostatV2', tau: 5.0, eta: 0.1, seed: null };
} else {
sampling = { type: 'Temperature', temperature: 0.8, top_k: 40, top_p: 0.95, min_p: 0.05, seed: null };
}
save({ ...settings, sampling });
};
const updateSampling = (partial: Partial<SamplingConfig>) => {
save({ ...settings, sampling: { ...settings.sampling!, ...partial } as SamplingConfig });
};
if (loading) {
return <div className="py-2 text-xs text-text-muted">Loading settings...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
{saving && <span className="text-xs text-text-muted mr-auto">Saving...</span>}
<Button variant="ghost" size="sm" onClick={resetDefaults} title="Reset to defaults">
<RotateCcw className="w-3.5 h-3.5 mr-1" />
<span className="text-xs">Reset</span>
</Button>
</div>
{/* Context & Generation */}
<div className="space-y-2">
<h5 className="text-xs font-medium text-text-default">Context & Generation</h5>
<div className="grid grid-cols-2 gap-3">
<NumberField
label="Context size"
description="Max context window (0 = model default)"
value={settings.context_size}
onChange={(v) => updateField('context_size', v)}
placeholder="Auto"
min={0}
allowNull
/>
<NumberField
label="Max output tokens"
description="Cap on generated tokens"
value={settings.max_output_tokens}
onChange={(v) => updateField('max_output_tokens', v)}
placeholder="No limit"
min={1}
allowNull
/>
</div>
</div>
{/* Sampling */}
<div className="space-y-2">
<SelectField
label="Sampling Strategy"
value={samplingType}
options={[
{ value: 'Greedy' as SamplingType, label: 'Greedy' },
{ value: 'Temperature' as SamplingType, label: 'Temperature' },
{ value: 'MirostatV2' as SamplingType, label: 'Mirostat v2' },
]}
onChange={(v) => setSamplingType(v)}
/>
{samplingType === 'Temperature' && settings.sampling?.type === 'Temperature' && (
<div className="grid grid-cols-2 gap-3">
<NumberField
label="Temperature"
value={settings.sampling.temperature}
onChange={(v) => updateSampling({ temperature: v ?? 0.8 })}
min={0}
max={2}
step={0.05}
/>
<NumberField
label="Top K"
value={settings.sampling.top_k}
onChange={(v) => updateSampling({ top_k: v ?? 40 })}
min={0}
/>
<NumberField
label="Top P"
value={settings.sampling.top_p}
onChange={(v) => updateSampling({ top_p: v ?? 0.95 })}
min={0}
max={1}
step={0.01}
/>
<NumberField
label="Min P"
value={settings.sampling.min_p}
onChange={(v) => updateSampling({ min_p: v ?? 0.05 })}
min={0}
max={1}
step={0.01}
/>
<NumberField
label="Seed"
value={settings.sampling.seed}
onChange={(v) => updateSampling({ seed: v })}
placeholder="Random"
min={0}
allowNull
/>
</div>
)}
{samplingType === 'MirostatV2' && settings.sampling?.type === 'MirostatV2' && (
<div className="grid grid-cols-2 gap-3">
<NumberField
label="Tau (target entropy)"
value={settings.sampling.tau}
onChange={(v) => updateSampling({ tau: v ?? 5.0 })}
min={0}
step={0.1}
/>
<NumberField
label="Eta (learning rate)"
value={settings.sampling.eta}
onChange={(v) => updateSampling({ eta: v ?? 0.1 })}
min={0}
max={1}
step={0.01}
/>
<NumberField
label="Seed"
value={settings.sampling.seed}
onChange={(v) => updateSampling({ seed: v })}
placeholder="Random"
min={0}
allowNull
/>
</div>
)}
</div>
{/* Repetition Penalty */}
<div className="space-y-2">
<h5 className="text-xs font-medium text-text-default">Repetition Penalty</h5>
<div className="grid grid-cols-2 gap-3">
<NumberField
label="Repeat penalty"
description="1.0 = off"
value={settings.repeat_penalty}
onChange={(v) => updateField('repeat_penalty', v ?? 1.0)}
min={0}
step={0.05}
/>
<NumberField
label="Repeat window"
description="Tokens to look back"
value={settings.repeat_last_n}
onChange={(v) => updateField('repeat_last_n', v ?? 64)}
min={0}
/>
<NumberField
label="Frequency penalty"
description="0.0 = off"
value={settings.frequency_penalty}
onChange={(v) => updateField('frequency_penalty', v ?? 0.0)}
min={0}
max={2}
step={0.05}
/>
<NumberField
label="Presence penalty"
description="0.0 = off"
value={settings.presence_penalty}
onChange={(v) => updateField('presence_penalty', v ?? 0.0)}
min={0}
max={2}
step={0.05}
/>
</div>
</div>
{/* Performance */}
<div className="space-y-2">
<h5 className="text-xs font-medium text-text-default">Performance</h5>
<div className="grid grid-cols-2 gap-3">
<NumberField
label="Batch size"
description="Prompt processing batch"
value={settings.n_batch}
onChange={(v) => updateField('n_batch', v)}
placeholder="Auto"
min={1}
allowNull
/>
<NumberField
label="GPU layers"
description="Layers to offload to GPU"
value={settings.n_gpu_layers}
onChange={(v) => updateField('n_gpu_layers', v)}
placeholder="All"
min={0}
allowNull
/>
<NumberField
label="Threads"
description="CPU threads for generation"
value={settings.n_threads}
onChange={(v) => updateField('n_threads', v)}
placeholder="Auto"
min={1}
allowNull
/>
</div>
<ToggleField
label="Lock model in RAM (mlock)"
description="Prevent model from being swapped to disk"
value={settings.use_mlock ?? false}
onChange={(v) => updateField('use_mlock', v)}
/>
<SelectField
label="Flash attention"
description="Enable flash attention optimization"
value={settings.flash_attention === null || settings.flash_attention === undefined ? 'auto' : settings.flash_attention ? 'on' : 'off'}
options={[
{ value: 'auto', label: 'Auto' },
{ value: 'on', label: 'On' },
{ value: 'off', label: 'Off' },
]}
onChange={(v) => updateField('flash_attention', v === 'auto' ? null : v === 'on')}
/>
</div>
{/* Tool Calling */}
<div className="space-y-2">
<h5 className="text-xs font-medium text-text-default">Tool Calling</h5>
<ToggleField
label="Native tool calling"
description="Use the model's built-in tool-call format instead of the shell-command emulator. Enable for large models that reliably support tool calling."
value={settings.native_tool_calling ?? false}
onChange={(v) => updateField('native_tool_calling', v)}
/>
</div>
</div>
);
};
@@ -1,4 +1,4 @@
import { ProviderDetails, getProviderModels } from '../../../api';
import { ProviderDetails, getProviderModels, listLocalModels } from '../../../api';
import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils';
export default interface Model {
@@ -54,6 +54,16 @@ export async function fetchModelsForProviders(
): Promise<ProviderModelsResult[]> {
const modelPromises = activeProviders.map(async (p) => {
try {
// For local provider, use listLocalModels and filter to only downloaded models
if (p.name === 'local') {
const response = await listLocalModels();
const allModels = response.data || [];
const downloadedModels = allModels
.filter((m) => m.status.state === 'Downloaded')
.map((m) => m.id);
return { provider: p, models: downloadedModels, error: null };
}
const response = await getProviderModels({
path: { name: p.name },
throwOnError: true,
@@ -486,7 +486,36 @@ export const SwitchModelModal = ({
{provider && (
<>
{providerErrors[provider] ? (
{provider === 'local' &&
!loadingModels &&
filteredModelOptions.flatMap((g) => g.options).filter((o) => o.value !== 'custom')
.length === 0 ? (
/* Show special UI for local provider when no models are downloaded */
<div className="rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-4">
<div className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Local models need to be downloaded first
</h3>
<div className="mt-1 text-sm text-blue-700 dark:text-blue-300">
To use local inference, you need to download a model to your computer
first. Go to Settings → Models to manage local models.
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setView('settings');
onClose();
}}
className="self-start border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/40"
>
Go to Settings
</Button>
</div>
</div>
) : providerErrors[provider] ? (
/* Show error message when provider failed to connect */
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3">
<div className="flex items-start">
+4 -4
View File
@@ -70,7 +70,7 @@ export type AnalyticsEvent =
| {
name: 'onboarding_provider_selected';
properties: {
method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'other';
method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'local' | 'other';
};
}
| {
@@ -80,7 +80,7 @@ export type AnalyticsEvent =
| { name: 'onboarding_abandoned'; properties: { step: string; duration_seconds?: number } }
| {
name: 'onboarding_setup_failed';
properties: { provider: 'openrouter' | 'tetrate' | 'chatgpt_codex'; error_message?: string };
properties: { provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local'; error_message?: string };
}
| {
name: 'error_occurred';
@@ -284,7 +284,7 @@ export function trackOnboardingStarted(): void {
}
export function trackOnboardingProviderSelected(
method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'other'
method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'local' | 'other'
): void {
trackEvent({
name: 'onboarding_provider_selected',
@@ -317,7 +317,7 @@ export function trackOnboardingAbandoned(step: string): void {
}
export function trackOnboardingSetupFailed(
provider: 'openrouter' | 'tetrate' | 'chatgpt_codex',
provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local',
errorMessage?: string
): void {
trackEvent({
+1
View File
@@ -0,0 +1 @@
dist
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
+16
View File
@@ -0,0 +1,16 @@
# goose ACP TUI
Early stage and part of goose's broader move to ACP
https://github.com/block/goose/issues/6642
https://github.com/block/goose/discussions/7309
## Running
1. Run the server `cargo run -p goose-acp --bin goose-acp-server`
2. Run the tui
```
cd ui/text
npm i
npm run start
```
+1244
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "goose-text",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"start": "tsx src/cli.tsx",
"lint": "tsc --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@goose/acp": "file:../acp",
"ink": "^5.1.0",
"ink-text-input": "^6.0.0",
"meow": "^13.2.0",
"react": "^18.3.1"
},
"devDependencies": {
"@types/node": "^25.2.3",
"@types/react": "^18.3.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
+764
View File
@@ -0,0 +1,764 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Box, Text, useApp, useInput, useStdout } from "ink";
import TextInput from "ink-text-input";
import type {
SessionNotification,
RequestPermissionRequest,
RequestPermissionResponse,
} from "@agentclientprotocol/sdk";
import { GooseClient } from "@goose/acp";
import { createHttpStream } from "./transport.js";
interface PendingPermission {
toolTitle: string;
options: Array<{ optionId: string; name: string; kind: string }>;
resolve: (response: RequestPermissionResponse) => void;
}
const CRANBERRY_BRIGHT = "#C0354A";
const HARBOR_NAVY = "#1B2A4A";
const DEEP_SLATE = "#3A4F6F";
const SLATE = "#6B7F99";
const LIGHT_SLATE = "#8FA4BD";
const AUTUMN_GOLD = "#C4883A";
const OCEAN_TEAL = "#3A7D7B";
const CEDAR_BROWN = "#6B5344";
const FOG_WHITE = "#E8E4DF";
const PARCHMENT = "#D4CFC8";
const GOOSE_FRAMES = [
[
" ,_",
" (o >",
" //\\",
" \\\\ \\",
" \\\\_/",
" | |",
" ^ ^",
],
[
" ,_",
" (o >",
" //\\",
" \\\\ \\",
" \\\\_/",
" / |",
" ^ ^",
],
[
" ,_",
" (o >",
" //\\",
" \\\\ \\",
" \\\\_/",
" | |",
" ^ ^",
],
[
" ,_",
" (o >",
" //\\",
" \\\\ \\",
" \\\\_/",
" | \\",
" ^ ^",
],
];
const TITLE_TEXT = "goose";
const GREETING_MESSAGES = [
"What would you like to work on?",
"Ready to build something amazing?",
"What would you like to explore?",
"What's on your mind?",
"What shall we create today?",
"What project needs attention?",
"What would you like to tackle?",
"What needs to be done?",
"What's the plan for today?",
"Ready to create something great?",
"What can be built today?",
"What's the next challenge?",
"What progress can be made?",
"What would you like to accomplish?",
"What task awaits?",
"What's the mission today?",
"What can be achieved?",
"What project is ready to begin?",
];
const INITIAL_GREETING =
GREETING_MESSAGES[Math.floor(Math.random() * GREETING_MESSAGES.length)]!;
const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"];
const PERMISSION_LABELS: Record<string, string> = {
allow_once: "Allow once",
allow_always: "Always allow",
reject_once: "Reject once",
reject_always: "Always reject",
};
const PERMISSION_KEYS: Record<string, string> = {
allow_once: "y",
allow_always: "a",
reject_once: "n",
reject_always: "N",
};
interface TextMessage {
kind: "text";
role: "user" | "agent";
text: string;
}
interface ToolCallMessage {
kind: "tool_call";
title: string;
}
type Message = TextMessage | ToolCallMessage;
function HRule({ width, color }: { width: number; color?: string }) {
return (
<Box>
<Text color={color ?? DEEP_SLATE} dimColor>
{"─".repeat(Math.max(width, 1))}
</Text>
</Box>
);
}
function HeaderBar({
width,
status,
loading,
spinIdx,
hasPendingPermission,
}: {
width: number;
status: string;
loading: boolean;
spinIdx: number;
hasPendingPermission: boolean;
}) {
const statusColor =
status === "ready"
? OCEAN_TEAL
: status.startsWith("error") || status.startsWith("failed")
? CRANBERRY_BRIGHT
: SLATE;
const leftContent = ` ${TITLE_TEXT} `;
const spinner =
loading && !hasPendingPermission
? ` ${SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]} `
: "";
return (
<Box flexDirection="column" width={width}>
<Box justifyContent="space-between" width={width}>
<Box>
<Text color={FOG_WHITE} bold>
{leftContent}
</Text>
<Text color={DEEP_SLATE}>│</Text>
<Text color={statusColor}> {status}</Text>
{spinner && <Text color={CRANBERRY_BRIGHT}>{spinner}</Text>}
</Box>
<Box>
<Text color={DEEP_SLATE} dimColor>
ctrl+c to exit{" "}
</Text>
</Box>
</Box>
<HRule width={width} color={DEEP_SLATE} />
</Box>
);
}
function ToolCallBlock({ title, width }: { title: string; width: number }) {
return (
<Box
marginLeft={3}
marginY={0}
paddingX={1}
borderStyle="round"
borderColor={CEDAR_BROWN}
borderDimColor
width={Math.min(width - 6, 72)}
>
<Text color={OCEAN_TEAL}>⚙ </Text>
<Text color={LIGHT_SLATE} italic>
{title}
</Text>
</Box>
);
}
function UserMessage({ text, width }: { text: string; width: number }) {
return (
<Box flexDirection="column" width={width}>
<Box paddingLeft={1} paddingY={0}>
<Text color={CRANBERRY_BRIGHT} bold>
{"❯ "}
</Text>
<Text color={FOG_WHITE} bold>
{text}
</Text>
</Box>
</Box>
);
}
function AgentMessage({ text, width }: { text: string; width: number }) {
return (
<Box paddingLeft={3} paddingRight={2} width={width}>
<Text color={PARCHMENT}>{text}</Text>
</Box>
);
}
function PermissionPrompt({
toolTitle,
options,
selectedIdx,
width,
}: {
toolTitle: string;
options: Array<{ optionId: string; name: string; kind: string }>;
selectedIdx: number;
width: number;
}) {
return (
<Box
flexDirection="column"
marginLeft={3}
marginY={0}
paddingX={2}
paddingY={1}
borderStyle="round"
borderColor={AUTUMN_GOLD}
width={Math.min(width - 6, 64)}
>
<Text color={AUTUMN_GOLD} bold>
🔒 Permission required
</Text>
<Text color={FOG_WHITE}>{toolTitle}</Text>
<Box marginTop={1} flexDirection="column">
{options.map((opt, i) => {
const key = PERMISSION_KEYS[opt.kind] ?? String(i + 1);
const label = PERMISSION_LABELS[opt.kind] ?? opt.name;
const selected = i === selectedIdx;
return (
<Box key={opt.optionId}>
<Text color={selected ? AUTUMN_GOLD : DEEP_SLATE}>
{selected ? " ▸ " : " "}
</Text>
<Text color={selected ? FOG_WHITE : LIGHT_SLATE} bold={selected}>
[{key}] {label}
</Text>
</Box>
);
})}
</Box>
<Box marginTop={1}>
<Text color={SLATE} dimColor>
↑↓ select · enter confirm · esc cancel
</Text>
</Box>
</Box>
);
}
function SplashScreen({
animFrame,
width,
height,
status,
loading,
spinIdx,
showInput,
input,
onInputChange,
onInputSubmit,
}: {
animFrame: number;
width: number;
height: number;
status: string;
loading: boolean;
spinIdx: number;
showInput: boolean;
input: string;
onInputChange: (v: string) => void;
onInputSubmit: (v: string) => void;
}) {
const frame = GOOSE_FRAMES[animFrame % GOOSE_FRAMES.length]!;
const statusColor =
status === "ready"
? OCEAN_TEAL
: status.startsWith("error") || status.startsWith("failed")
? CRANBERRY_BRIGHT
: SLATE;
const inputWidth = Math.min(60, width - 8);
return (
<Box
flexDirection="column"
alignItems="center"
justifyContent="center"
width={width}
height={height}
>
<Box flexDirection="column" alignItems="center">
{frame.map((line, i) => (
<Text key={i} color={FOG_WHITE}>
{line}
</Text>
))}
</Box>
<Box marginTop={1}>
<Text color={FOG_WHITE} bold>
{TITLE_TEXT}
</Text>
</Box>
<Box marginTop={0}>
<Text color={SLATE}>your on-machine AI agent</Text>
</Box>
{showInput ? (
<>
<Box marginTop={2}>
<HRule width={inputWidth} color={DEEP_SLATE} />
</Box>
<Box marginTop={0}>
<Text color={CRANBERRY_BRIGHT} bold>
{"❯ "}
</Text>
<TextInput
value={input}
placeholder={INITIAL_GREETING}
onChange={onInputChange}
onSubmit={onInputSubmit}
showCursor
/>
</Box>
<Box marginTop={0}>
<HRule width={inputWidth} color={DEEP_SLATE} />
</Box>
</>
) : (
<>
<Box marginTop={2}>
<HRule width={Math.min(40, width - 4)} color={DEEP_SLATE} />
</Box>
<Box marginTop={1}>
{loading && (
<Text color={CRANBERRY_BRIGHT}>
{SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]}{" "}
</Text>
)}
<Text color={statusColor}>{status}</Text>
</Box>
</>
)}
</Box>
);
}
function InputBar({
width,
input,
onChange,
onSubmit,
}: {
width: number;
input: string;
onChange: (v: string) => void;
onSubmit: (v: string) => void;
}) {
return (
<Box flexDirection="column" width={width}>
<HRule width={width} color={DEEP_SLATE} />
<Box paddingLeft={1} paddingY={0}>
<Text color={CRANBERRY_BRIGHT} bold>
{"❯ "}
</Text>
<TextInput value={input} onChange={onChange} onSubmit={onSubmit} />
</Box>
</Box>
);
}
function LoadingIndicator({
status,
spinIdx,
}: {
status: string;
spinIdx: number;
}) {
return (
<Box paddingLeft={3} marginTop={0}>
<Text color={CRANBERRY_BRIGHT}>
{SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]}{" "}
</Text>
<Text color={SLATE} italic>
{status}
</Text>
</Box>
);
}
export default function App({
serverUrl,
initialPrompt,
}: {
serverUrl: string;
initialPrompt?: string;
}) {
const { exit } = useApp();
const { stdout } = useStdout();
const termWidth = stdout?.columns ?? 80;
const termHeight = stdout?.rows ?? 24;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("connecting...");
const [spinIdx, setSpinIdx] = useState(0);
const [gooseFrame, setGooseFrame] = useState(0);
const [bannerVisible, setBannerVisible] = useState(true);
const [pendingPermission, setPendingPermission] =
useState<PendingPermission | null>(null);
const [permissionIdx, setPermissionIdx] = useState(0);
const clientRef = useRef<GooseClient | null>(null);
const sessionIdRef = useRef<string | null>(null);
const streamBuf = useRef("");
const sentInitialPrompt = useRef(false);
useEffect(() => {
const t = setInterval(() => {
setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length);
setGooseFrame((f) => f + 1);
}, 300);
return () => clearInterval(t);
}, []);
useEffect(() => {
if (messages.length > 0) {
setBannerVisible(false);
}
}, [messages]);
const appendAgent = useCallback((text: string) => {
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "text" && last.role === "agent") {
return [
...prev.slice(0, -1),
{
kind: "text" as const,
role: "agent" as const,
text: last.text + text,
},
];
}
return [...prev, { kind: "text" as const, role: "agent" as const, text }];
});
}, []);
const appendToolCall = useCallback((title: string) => {
setMessages((prev) => [...prev, { kind: "tool_call" as const, title }]);
}, []);
const resolvePermission = useCallback(
(option: { optionId: string } | "cancelled") => {
if (!pendingPermission) return;
const { resolve } = pendingPermission;
if (option === "cancelled") {
resolve({ outcome: { outcome: "cancelled" } });
} else {
resolve({
outcome: { outcome: "selected", optionId: option.optionId },
});
}
setPendingPermission(null);
setPermissionIdx(0);
},
[pendingPermission],
);
const sendPrompt = useCallback(
async (text: string) => {
const client = clientRef.current;
const sid = sessionIdRef.current;
if (!client || !sid) return;
setMessages((prev) => [
...prev,
{ kind: "text" as const, role: "user" as const, text },
]);
setLoading(true);
setStatus("thinking...");
streamBuf.current = "";
try {
const result = await client.prompt({
sessionId: sid,
prompt: [{ type: "text", text }],
});
if (streamBuf.current) {
appendAgent("");
}
setStatus(
result.stopReason === "end_turn"
? "ready"
: `stopped: ${result.stopReason}`,
);
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setStatus(`error: ${errMsg}`);
} finally {
setLoading(false);
}
},
[appendAgent],
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setStatus("initializing...");
const stream = createHttpStream(serverUrl);
const client = new GooseClient(
() => ({
sessionUpdate: async (params: SessionNotification) => {
const update = params.update;
if (update.sessionUpdate === "agent_message_chunk") {
if (update.content.type === "text") {
streamBuf.current += update.content.text;
appendAgent(update.content.text);
}
} else if (update.sessionUpdate === "tool_call") {
appendToolCall(update.title || "tool");
}
},
requestPermission: async (
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> => {
return new Promise<RequestPermissionResponse>((resolve) => {
const toolTitle = params.toolCall.title ?? "unknown tool";
const options = params.options.map((opt) => ({
optionId: opt.optionId,
name: opt.name,
kind: opt.kind,
}));
setPendingPermission({ toolTitle, options, resolve });
setPermissionIdx(0);
});
},
}),
stream,
);
if (cancelled) return;
clientRef.current = client;
setStatus("handshaking...");
await client.initialize({
protocolVersion: 0,
clientInfo: { name: "goose-text", version: "0.1.0" },
clientCapabilities: {},
});
if (cancelled) return;
setStatus("creating session...");
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
});
if (cancelled) return;
sessionIdRef.current = session.sessionId;
setLoading(false);
setStatus("ready");
if (initialPrompt && !sentInitialPrompt.current) {
sentInitialPrompt.current = true;
await sendPrompt(initialPrompt);
if (initialPrompt) {
setTimeout(() => exit(), 100);
}
}
} catch (e: unknown) {
if (cancelled) return;
const errMsg = e instanceof Error ? e.message : String(e);
setStatus(`failed: ${errMsg}`);
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [serverUrl, initialPrompt, sendPrompt, appendAgent, appendToolCall, exit]);
const handleSubmit = useCallback(
(value: string) => {
const trimmed = value.trim();
if (!trimmed || loading) return;
setInput("");
sendPrompt(trimmed);
},
[loading, sendPrompt],
);
useInput((ch, key) => {
if (key.escape || (ch === "c" && key.ctrl)) {
if (pendingPermission) {
resolvePermission("cancelled");
return;
}
exit();
}
if (pendingPermission) {
const opts = pendingPermission.options;
if (key.upArrow) {
setPermissionIdx((i) => (i - 1 + opts.length) % opts.length);
return;
}
if (key.downArrow) {
setPermissionIdx((i) => (i + 1) % opts.length);
return;
}
if (key.return) {
const selected = opts[permissionIdx];
if (selected) {
resolvePermission({ optionId: selected.optionId });
}
return;
}
const keyMap: Record<string, string> = {
y: "allow_once",
a: "allow_always",
n: "reject_once",
N: "reject_always",
};
const targetKind = keyMap[ch];
if (targetKind) {
const match = opts.find((o) => o.kind === targetKind);
if (match) {
resolvePermission({ optionId: match.optionId });
return;
}
}
}
});
const PAD_X = 2;
const PAD_BOTTOM = 1;
const innerWidth = Math.max(termWidth - PAD_X * 2, 20);
const headerHeight = 2;
const inputBarHeight = initialPrompt ? 0 : 2;
const bodyHeight = Math.max(termHeight - headerHeight - inputBarHeight - PAD_BOTTOM, 3);
if (bannerVisible) {
return (
<Box
flexDirection="column"
width={termWidth}
height={termHeight}
>
<SplashScreen
animFrame={gooseFrame}
width={termWidth}
height={termHeight}
status={status}
loading={loading}
spinIdx={spinIdx}
showInput={!loading && !initialPrompt}
input={input}
onInputChange={setInput}
onInputSubmit={handleSubmit}
/>
</Box>
);
}
return (
<Box
flexDirection="column"
width={termWidth}
height={termHeight}
paddingX={PAD_X}
paddingBottom={PAD_BOTTOM}
>
<HeaderBar
width={innerWidth}
status={status}
loading={loading}
spinIdx={spinIdx}
hasPendingPermission={!!pendingPermission}
/>
<Box
flexDirection="column"
flexGrow={1}
height={bodyHeight}
overflowY="hidden"
paddingY={0}
>
{messages.map((msg, i) => {
if (msg.kind === "tool_call") {
return <ToolCallBlock key={i} title={msg.title} width={innerWidth} />;
}
if (msg.role === "user") {
return (
<React.Fragment key={i}>
{i > 0 && <Box height={1} />}
<UserMessage text={msg.text} width={innerWidth} />
<HRule width={innerWidth} color={HARBOR_NAVY} />
</React.Fragment>
);
}
return <AgentMessage key={i} text={msg.text} width={innerWidth} />;
})}
{pendingPermission && (
<PermissionPrompt
toolTitle={pendingPermission.toolTitle}
options={pendingPermission.options}
selectedIdx={permissionIdx}
width={innerWidth}
/>
)}
{loading && !pendingPermission && messages.length > 0 && (
<LoadingIndicator status={status} spinIdx={spinIdx} />
)}
</Box>
{!loading && !pendingPermission && !initialPrompt && (
<InputBar
width={innerWidth}
input={input}
onChange={setInput}
onSubmit={handleSubmit}
/>
)}
</Box>
);
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import React from "react";
import { render } from "ink";
import meow from "meow";
import App from "./app.js";
const cli = meow(
`
Usage
$ goose-text
Options
--server, -s Server URL (default: http://127.0.0.1:3284)
--text, -t Send a single prompt and exit
`,
{
importMeta: import.meta,
flags: {
server: { type: "string", shortFlag: "s", default: "http://127.0.0.1:3284" },
text: { type: "string", shortFlag: "t" },
},
}
);
render(<App serverUrl={cli.flags.server} initialPrompt={cli.flags.text} />);
+120
View File
@@ -0,0 +1,120 @@
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
const ACP_SESSION_HEADER = "Acp-Session-Id";
export function createHttpStream(serverUrl: string): Stream {
let sessionId: string | null = null;
const incoming: AnyMessage[] = [];
const waiters: Array<() => void> = [];
const sseAbort = new AbortController();
function pushMessage(msg: AnyMessage) {
incoming.push(msg);
const w = waiters.shift();
if (w) w();
}
function waitForMessage(): Promise<void> {
if (incoming.length > 0) return Promise.resolve();
return new Promise<void>((r) => waiters.push(r));
}
async function consumeSSE(response: Response) {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
for (const line of part.split("\n")) {
if (line.startsWith("data: ")) {
try {
const msg = JSON.parse(line.slice(6)) as AnyMessage;
pushMessage(msg);
} catch {
}
}
}
}
}
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") return;
}
}
// POST initialize (no session header) opens a long-lived SSE stream that receives
// ALL subsequent responses and notifications. Later POSTs with the session header
// are fire-and-forget for requests (responses arrive on the first stream) or
// return 202 immediately for notifications/responses.
let isFirstRequest = true;
const readable = new ReadableStream<AnyMessage>({
async pull(controller) {
await waitForMessage();
while (incoming.length > 0) {
controller.enqueue(incoming.shift()!);
}
},
});
const writable = new WritableStream<AnyMessage>({
async write(msg) {
const isRequest =
"method" in msg && "id" in msg && msg.id !== undefined && msg.id !== null;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
};
if (sessionId) {
headers[ACP_SESSION_HEADER] = sessionId;
}
if (isFirstRequest && isRequest) {
isFirstRequest = false;
const response = await fetch(`${serverUrl}/acp`, {
method: "POST",
headers,
body: JSON.stringify(msg),
signal: sseAbort.signal,
});
const sid = response.headers.get(ACP_SESSION_HEADER);
if (sid) sessionId = sid;
consumeSSE(response);
} else if (isRequest) {
const abort = new AbortController();
fetch(`${serverUrl}/acp`, {
method: "POST",
headers,
body: JSON.stringify(msg),
signal: abort.signal,
}).catch(() => {});
setTimeout(() => abort.abort(), 200);
} else {
await fetch(`${serverUrl}/acp`, {
method: "POST",
headers,
body: JSON.stringify(msg),
});
}
},
close() {
sseAbort.abort();
},
});
return { readable, writable };
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src"]
}