feat: a lean ACP-only Goose binary (#11961)

This commit is contained in:
Jack Amadeo 2026-09-23 12:57:10 +00:00 • committed by GitHub
parent 201837dffc
commit e678c3b64a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 505 additions and 219 deletions

View file

@ -111,6 +111,30 @@ jobs:
env:
RUST_MIN_STACK: 8388608
rust-build-lean:
name: Build Lean ACP Binary
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
steps:
- name: Checkout Code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: lean-acp
- name: Build lean configuration and check its size budget
run: |
source ./bin/activate-hermit
# use the non-hermit rust toolchain because the rust-cache action does not
# play nicely with hermit-managed rust
hermit uninstall rustup
just check-lean-size
goose-sdk-uniffi:
name: Check goose-sdk UniFFI
runs-on: ubuntu-latest
@ -177,10 +201,10 @@ jobs:
run: |
gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar'
export CARGO_INCREMENTAL=0
cargo test -p goose --no-default-features --features ${{ matrix.tls-feature }},code-mode,live-voice
cargo test -p goose --no-default-features --features ${{ matrix.tls-feature }},code-mode,tree-sitter,live-voice,scheduler,platform-apps,chat-recall,acp-http
cargo test -p goose-providers --no-default-features --features ${{ matrix.tls-feature }},live-websocket
cargo test -p goose-cli --no-default-features --features ${{ matrix.tls-feature }},code-mode,live-voice -- --skip scenario_tests::scenarios::tests
cargo test -p goose-cli --no-default-features --features ${{ matrix.tls-feature }},code-mode,live-voice --jobs 1 scenario_tests::scenarios::tests
cargo test -p goose-cli --no-default-features --features ${{ matrix.tls-feature }},code-mode,tree-sitter,live-voice,scheduler,platform-apps,chat-recall,acp-http -- --skip scenario_tests::scenarios::tests
cargo test -p goose-cli --no-default-features --features ${{ matrix.tls-feature }},code-mode,tree-sitter,live-voice,scheduler,platform-apps,chat-recall,acp-http --jobs 1 scenario_tests::scenarios::tests
env:
RUST_MIN_STACK: 8388608

View file

@ -126,3 +126,11 @@ cudaforge = { git = "https://github.com/jbg/cudaforge", rev = "e7c1967340e40673d
# full debug info and stay fully debuggable; release builds are unaffected.
[profile.dev.package."*"]
debug = false
[profile.lean]
inherits = "release"
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"

View file

@ -165,6 +165,39 @@ check-acp-artifacts: generate-acp-types generate-acp-docs
fi
echo "✅ Generated ACP artifacts are up-to-date"
# Build the lean ACP binary
build-lean:
cargo build -p goose --bin goose-acp \
--profile lean \
--no-default-features \
--features native-tls
# Budgets are per-platform. ELF carries several MiB that Mach-O does not for the
# same code: DWARF .eh_frame instead of compact unwind info, and a .rela.dyn
# table of relative relocations that Mach-O encodes as LINKEDIT rebase opcodes.
# A single cross-platform number would either be unreachable on Linux or
# useless as a regression signal on macOS.
# Enforce the lean binary's size budget (override with GOOSE_LEAN_MAX_BYTES)
check-lean-size: build-lean
#!/usr/bin/env bash
set -euo pipefail
case "$(uname -s)" in
Darwin) default_max_bytes=17825792 ;;
*) default_max_bytes=23068672 ;;
esac
max_bytes="${GOOSE_LEAN_MAX_BYTES:-$default_max_bytes}"
binary="target/lean/goose-acp"
bytes=$(wc -c < "$binary" | tr -d '[:space:]')
mib=$(awk -v bytes="$bytes" 'BEGIN { printf "%.2f", bytes / 1024 / 1024 }')
printf '%s: %s bytes (%s MiB)\n' "$binary" "$bytes" "$mib"
if (( bytes > max_bytes )); then
printf 'lean binary exceeds budget of %s bytes; set GOOSE_LEAN_MAX_BYTES to override\n' "$max_bytes" >&2
exit 1
fi
# Generate ACP JSON schema from Rust types
generate-acp-schema:
@echo "Generating ACP schema..."

View file

@ -27,7 +27,7 @@ path = "src/bin/mcp_conformance_driver.rs"
clap_mangen = { version = "0.3", default-features = false }
goose = { path = "../goose", default-features = false }
agent-client-protocol = { workspace = true, features = ["unstable"], optional = true }
goose-mcp = { path = "../goose-mcp", default-features = false }
goose-mcp = { path = "../goose-mcp", default-features = false, optional = true }
goose-providers = { path = "../goose-providers", default-features = false }
goose-roaming = { path = "../goose-roaming", optional = true }
rmcp = { workspace = true }
@ -94,6 +94,7 @@ libc = { version = "0.2.182", default-features = false, features = ["std"] }
[features]
default = [
"code-mode",
"tree-sitter",
"local-inference",
"aws-providers",
"telemetry",
@ -103,9 +104,15 @@ default = [
"live-voice",
"system-keyring",
"update",
"bundled-mcp",
"scheduler",
"platform-apps",
"chat-recall",
"acp-http",
]
roaming = ["dep:goose-roaming", "dep:qrcode", "dep:fs2", "dep:agent-client-protocol"]
code-mode = ["goose/code-mode"]
tree-sitter = ["goose/tree-sitter"]
live-voice = ["goose/live-voice"]
local-inference = ["goose/local-inference"]
aws-providers = ["goose/aws-providers"]
@ -117,7 +124,24 @@ nostr = ["goose/nostr"]
otel = ["goose/otel"]
system-keyring = ["goose/system-keyring"]
update = ["dep:sigstore-verify", "dep:snap"]
portable-default = ["rustls-tls", "live-voice", "aws-providers", "telemetry", "otel"]
bundled-mcp = ["dep:goose-mcp", "goose-mcp/bundled-mcp"]
scheduler = ["goose/scheduler"]
platform-apps = ["goose/platform-apps"]
chat-recall = ["goose/chat-recall"]
acp-http = ["goose/acp-http"]
portable-default = [
"rustls-tls",
"tree-sitter",
"live-voice",
"aws-providers",
"telemetry",
"otel",
"bundled-mcp",
"scheduler",
"platform-apps",
"chat-recall",
"acp-http",
]
# disables the update command
disable-update = []
rustls-tls = [
@ -126,7 +150,7 @@ rustls-tls = [
"axum-server/tls-rustls",
"sigstore-verify?/rustls",
"goose/rustls-tls",
"goose-mcp/rustls-tls",
"goose-mcp?/rustls-tls",
"goose-providers/rustls-tls",
]
native-tls = [
@ -135,7 +159,7 @@ native-tls = [
"axum-server/tls-openssl",
"sigstore-verify?/native-tls",
"goose/native-tls",
"goose-mcp/native-tls",
"goose-mcp?/native-tls",
"goose-providers/native-tls",
]
[dev-dependencies]

View file

@ -3,13 +3,17 @@ use clap::{Args, CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell as ClapShell};
use clap_complete_nushell::Nushell as ClapNushell;
use goose::agents::GoosePlatform;
#[cfg(feature = "bundled-mcp")]
use goose::builtin_extension::register_builtin_extensions;
use goose::config::{Config, GooseMode};
#[cfg(feature = "telemetry")]
use goose::posthog::get_telemetry_choice;
use goose::recipe::Recipe;
#[cfg(feature = "acp-http")]
use goose::source_roots::SourceRoot;
#[cfg(feature = "bundled-mcp")]
use goose_mcp::mcp_server_runner::{serve, McpCommand};
#[cfg(feature = "bundled-mcp")]
use goose_mcp::{AutoVisualiserRouter, ComputerControllerServer, MemoryServer, TutorialServer};
#[cfg(feature = "telemetry")]
@ -24,6 +28,7 @@ use crate::commands::term::{
handle_term_info, handle_term_init, handle_term_log, handle_term_run, Shell,
};
#[cfg(feature = "scheduler")]
use crate::commands::schedule::{
handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove,
handle_schedule_run_now, handle_schedule_services_status, handle_schedule_services_stop,
@ -39,8 +44,10 @@ use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use std::io::Read;
use std::path::PathBuf;
#[cfg(feature = "acp-http")]
const GOOSE_SERVER_SECRET_KEY_ENV: &str = "GOOSE_SERVER__SECRET_KEY";
#[cfg(feature = "acp-http")]
fn generate_serve_secret_key() -> String {
use rand::distr::{Alphanumeric, SampleString};
@ -605,6 +612,7 @@ enum SessionCommand {
},
}
#[cfg(feature = "scheduler")]
#[derive(Subcommand, Debug)]
enum SchedulerCommand {
#[command(about = "Add a new scheduled job")]
@ -819,6 +827,7 @@ enum Command {
Doctor {},
/// Manage system prompts and behaviors
#[cfg(feature = "bundled-mcp")]
#[command(about = "Run one of the mcp servers bundled with goose")]
Mcp {
#[arg(value_parser = clap::value_parser!(McpCommand))]
@ -851,6 +860,7 @@ enum Command {
},
/// Start ACP server over HTTP and WebSocket
#[cfg(feature = "acp-http")]
#[command(about = "Start ACP server over HTTP and WebSocket")]
Serve {
#[arg(long, default_value = "127.0.0.1")]
@ -1020,6 +1030,7 @@ enum Command {
},
/// Manage scheduled jobs
#[cfg(feature = "scheduler")]
#[command(about = "Manage scheduled jobs", visible_alias = "sched")]
Schedule {
#[command(subcommand)]
@ -1381,14 +1392,17 @@ fn get_command_name(command: &Option<Command>) -> &'static str {
Some(Command::Configure {}) => "configure",
Some(Command::Doctor {}) => "doctor",
Some(Command::Info { .. }) => "info",
#[cfg(feature = "bundled-mcp")]
Some(Command::Mcp { .. }) => "mcp",
Some(Command::Acp { .. }) => "acp",
#[cfg(feature = "roaming")]
Some(Command::Roam { .. }) => "roam",
#[cfg(feature = "acp-http")]
Some(Command::Serve { .. }) => "serve",
Some(Command::Session { .. }) => "session",
Some(Command::Run { .. }) => "run",
Some(Command::Gateway { .. }) => "gateway",
#[cfg(feature = "scheduler")]
Some(Command::Schedule { .. }) => "schedule",
#[cfg(feature = "update")]
Some(Command::Update { .. }) => "update",
@ -1611,6 +1625,7 @@ async fn handle_mcp_probe(extension_command: String, script_path: Option<String>
Ok(())
}
#[cfg(feature = "bundled-mcp")]
async fn handle_mcp_command(server: McpCommand) -> Result<()> {
let name = server.name();
let _ = crate::logging::setup_logging(Some(&format!("mcp-{name}")));
@ -1623,6 +1638,7 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> {
Ok(())
}
#[cfg(feature = "acp-http")]
struct ServeCommandArgs {
host: String,
@ -1758,6 +1774,7 @@ async fn start_roam_share(
Ok(node)
}
#[cfg(feature = "acp-http")]
async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> {
use axum::http::HeaderValue;
use goose::acp::server::AcpBuiltinSelection;
@ -2355,6 +2372,7 @@ async fn handle_gateway_command(command: GatewayCommand) -> Result<()> {
}
}
#[cfg(feature = "scheduler")]
async fn handle_schedule_command(command: SchedulerCommand) -> Result<()> {
match command {
SchedulerCommand::Add {
@ -2779,6 +2797,7 @@ async fn handle_default_session() -> Result<()> {
}
pub async fn cli() -> anyhow::Result<()> {
#[cfg(feature = "bundled-mcp")]
register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone());
let cli = Cli::parse();
@ -2799,6 +2818,7 @@ pub async fn cli() -> anyhow::Result<()> {
Some(Command::Configure {}) => handle_configure().await,
Some(Command::Doctor {}) => crate::commands::doctor::handle_doctor().await,
Some(Command::Info { verbose, check }) => handle_info(verbose, check).await,
#[cfg(feature = "bundled-mcp")]
Some(Command::Mcp { server }) => handle_mcp_command(server).await,
Some(Command::Acp {
builtins,
@ -2806,6 +2826,7 @@ pub async fn cli() -> anyhow::Result<()> {
}) => goose::acp::server::run(builtins, enable_scheduler).await,
#[cfg(feature = "roaming")]
Some(Command::Roam { command }) => handle_roam_command(command).await,
#[cfg(feature = "acp-http")]
Some(Command::Serve {
host,
port,
@ -2885,6 +2906,7 @@ pub async fn cli() -> anyhow::Result<()> {
.await
}
Some(Command::Gateway { command }) => handle_gateway_command(command).await,
#[cfg(feature = "scheduler")]
Some(Command::Schedule { command }) => handle_schedule_command(command).await,
#[cfg(feature = "update")]
Some(Command::Update {
@ -3097,6 +3119,7 @@ mod tests {
}
}
#[cfg(feature = "acp-http")]
#[test]
fn serve_command_accepts_dangerously_unauthenticated_flag() {
let cli = Cli::try_parse_from([

View file

@ -13,6 +13,7 @@ pub mod roam_client;
pub mod roam_full_bridge;
#[cfg(feature = "roaming")]
pub mod roam_proxy;
#[cfg(feature = "scheduler")]
pub mod schedule;
pub mod session;
pub mod skills;

View file

@ -11,8 +11,13 @@ description = "Conversation compaction for Goose"
[lints]
workspace = true
[features]
default = []
rustls-tls = []
native-tls = []
[dependencies]
goose-provider-types = { version = "0.1.0-alpha.9", path = "../goose-provider-types" }
goose-provider-types = { version = "0.1.0-alpha.9", path = "../goose-provider-types", default-features = false }
anyhow = { workspace = true }
async-trait = { workspace = true }
include_dir = { workspace = true }

View file

@ -12,9 +12,15 @@ description.workspace = true
workspace = true
[features]
default = []
default = ["bundled-mcp"]
rustls-tls = []
native-tls = []
autovisualiser = []
computer-controller = ["dep:lopdf", "dep:docx-rs", "dep:image", "dep:umya-spreadsheet"]
document-tools = ["computer-controller"]
memory-server = []
tutorial-server = ["dep:include_dir"]
bundled-mcp = ["autovisualiser", "computer-controller", "document-tools", "memory-server", "tutorial-server"]
[dependencies]
rmcp = { workspace = true, features = ["server", "client", "transport-io", "macros"] }
@ -31,11 +37,11 @@ indoc = { workspace = true }
chrono = { workspace = true }
etcetera = { workspace = true }
tempfile = { workspace = true }
include_dir = { workspace = true }
include_dir = { workspace = true, optional = true }
once_cell = { workspace = true }
lopdf = { version = "0.45", default-features = false }
docx-rs = { version = "0.4.18", default-features = false, features = ["image"] }
image = { version = "0.24.4", default-features = false, features = ["bmp", "dds", "dxt", "farbfeld", "gif", "hdr", "ico", "jpeg", "jpeg_rayon", "openexr", "png", "pnm", "tga", "tiff", "webp"] }
umya-spreadsheet = { version = "3", default-features = false }
lopdf = { version = "0.45", default-features = false, optional = true }
docx-rs = { version = "0.4.18", default-features = false, features = ["image"], optional = true }
image = { version = "0.24.4", default-features = false, features = ["bmp", "dds", "dxt", "farbfeld", "gif", "hdr", "ico", "jpeg", "jpeg_rayon", "openexr", "png", "pnm", "tga", "tiff", "webp"], optional = true }
umya-spreadsheet = { version = "3", default-features = false, optional = true }
shell-words = { workspace = true }
process-wrap = { version = "9", default-features = false, features = ["std", "process-session"] }

View file

@ -1,5 +1,11 @@
use etcetera::AppStrategyArgs;
use once_cell::sync::Lazy;
#[cfg(any(
feature = "autovisualiser",
feature = "computer-controller",
feature = "memory-server",
feature = "tutorial-server"
))]
use rmcp::{ServerHandler, ServiceExt};
use std::collections::HashMap;
@ -11,23 +17,43 @@ pub static APP_STRATEGY: Lazy<AppStrategyArgs> = Lazy::new(|| AppStrategyArgs {
app_name: "goose".to_string(),
});
#[cfg(feature = "autovisualiser")]
pub mod autovisualiser;
#[cfg(feature = "computer-controller")]
pub mod computercontroller;
#[cfg(any(
feature = "autovisualiser",
feature = "computer-controller",
feature = "memory-server",
feature = "tutorial-server"
))]
pub mod mcp_server_runner;
#[cfg(feature = "memory-server")]
mod memory;
#[cfg(target_os = "macos")]
#[cfg(all(target_os = "macos", feature = "computer-controller"))]
pub mod peekaboo;
#[cfg(feature = "computer-controller")]
pub mod subprocess;
#[cfg(feature = "tutorial-server")]
pub mod tutorial;
#[cfg(feature = "autovisualiser")]
pub use autovisualiser::AutoVisualiserRouter;
#[cfg(feature = "computer-controller")]
pub use computercontroller::ComputerControllerServer;
#[cfg(feature = "memory-server")]
pub use memory::MemoryServer;
#[cfg(feature = "tutorial-server")]
pub use tutorial::TutorialServer;
/// Type definition for a function that spawns and serves a builtin extension server
pub type SpawnServerFn = fn(tokio::io::DuplexStream, tokio::io::DuplexStream);
#[cfg(any(
feature = "autovisualiser",
feature = "computer-controller",
feature = "memory-server",
feature = "tutorial-server"
))]
fn spawn_and_serve<S>(
name: &'static str,
server: S,
@ -45,6 +71,12 @@ fn spawn_and_serve<S>(
});
}
#[cfg(any(
feature = "autovisualiser",
feature = "computer-controller",
feature = "memory-server",
feature = "tutorial-server"
))]
macro_rules! builtin {
($name:ident, $server_ty:ty) => {{
fn spawn(r: tokio::io::DuplexStream, w: tokio::io::DuplexStream) {
@ -55,10 +87,15 @@ macro_rules! builtin {
}
pub static BUILTIN_EXTENSIONS: Lazy<HashMap<&'static str, SpawnServerFn>> = Lazy::new(|| {
HashMap::from([
builtin!(autovisualiser, AutoVisualiserRouter),
builtin!(computercontroller, ComputerControllerServer),
builtin!(memory, MemoryServer),
builtin!(tutorial, TutorialServer),
])
#[allow(unused_mut)]
let mut extensions = HashMap::new();
#[cfg(feature = "autovisualiser")]
extensions.extend([builtin!(autovisualiser, AutoVisualiserRouter)]);
#[cfg(feature = "computer-controller")]
extensions.extend([builtin!(computercontroller, ComputerControllerServer)]);
#[cfg(feature = "memory-server")]
extensions.extend([builtin!(memory, MemoryServer)]);
#[cfg(feature = "tutorial-server")]
extensions.extend([builtin!(tutorial, TutorialServer)]);
extensions
});

View file

@ -20,6 +20,18 @@ otel = [
"dep:opentelemetry-stdout",
]
code-mode = ["dep:pctx_code_mode"]
tree-sitter = [
"dep:tree-sitter",
"dep:tree-sitter-go",
"dep:tree-sitter-java",
"dep:tree-sitter-javascript",
"dep:tree-sitter-kotlin-ng",
"dep:tree-sitter-python",
"dep:tree-sitter-ruby",
"dep:tree-sitter-rust",
"dep:tree-sitter-swift",
"dep:tree-sitter-typescript",
]
live-voice = ["goose-providers/live-websocket"]
local-inference = [
"dep:candle-core",
@ -58,6 +70,7 @@ rustls-tls = [
"oauth2/reqwest",
"oauth2/rustls-tls",
"goose-providers/rustls-tls",
"goose-context-management/rustls-tls",
]
native-tls = [
"dep:rcgen",
@ -76,10 +89,26 @@ native-tls = [
"oauth2/reqwest",
"oauth2/native-tls",
"goose-providers/native-tls",
"goose-context-management/native-tls",
]
system-keyring = ["dep:keyring"]
portable-default = ["rustls-tls", "live-voice", "aws-providers", "telemetry", "otel"]
portable-default = [
"rustls-tls",
"tree-sitter",
"live-voice",
"aws-providers",
"telemetry",
"otel",
"scheduler",
"platform-apps",
"chat-recall",
"acp-http",
]
nostr = ["dep:nostr", "dep:nostr-sdk"]
platform-apps = []
chat-recall = []
scheduler = ["dep:tokio-cron-scheduler"]
acp-http = ["dep:agent-client-protocol-http"]
[lints]
workspace = true
@ -143,7 +172,7 @@ goose-download-manager = { path = "../goose-download-manager" }
goose-sdk-types = { path = "../goose-sdk-types" }
goose-agent = { path = "../goose-agent" }
rand = { workspace = true }
tokio-cron-scheduler = { version = "0.15", default-features = false }
tokio-cron-scheduler = { version = "0.15", default-features = false, optional = true }
urlencoding = { workspace = true }
v_htmlescape = { version = "0.17", default-features = false, features = ["std"] }
sqlx = { version = "0.9.0", default-features = false, features = [
@ -178,7 +207,7 @@ agent-client-protocol = { workspace = true, features = [
"unstable_end_turn_token_usage",
"unstable_session_fork",
] }
agent-client-protocol-http = { workspace = true, features = ["server"] }
agent-client-protocol-http = { workspace = true, features = ["server"], optional = true }
unicode-normalization = { version = "0.1.22", default-features = false, features = ["std"] }
# For local Whisper transcription (optional, behind "local-inference" feature)
@ -198,23 +227,23 @@ shellexpand = { workspace = true }
indexmap = { version = "2.9", default-features = false, features = ["serde", "std"] }
ignore = { workspace = true }
rayon = { workspace = true }
tree-sitter = { workspace = true }
tree-sitter-go = { workspace = true }
tree-sitter-java = { workspace = true }
tree-sitter-javascript = { workspace = true }
tree-sitter-kotlin-ng = { workspace = true }
tree-sitter-python = { workspace = true }
tree-sitter-ruby = { workspace = true }
tree-sitter-rust = { workspace = true }
tree-sitter-swift = { workspace = true }
tree-sitter-typescript = { workspace = true }
tree-sitter = { workspace = true, optional = true }
tree-sitter-go = { workspace = true, optional = true }
tree-sitter-java = { workspace = true, optional = true }
tree-sitter-javascript = { workspace = true, optional = true }
tree-sitter-kotlin-ng = { workspace = true, optional = true }
tree-sitter-python = { workspace = true, optional = true }
tree-sitter-ruby = { workspace = true, optional = true }
tree-sitter-rust = { workspace = true, optional = true }
tree-sitter-swift = { workspace = true, optional = true }
tree-sitter-typescript = { workspace = true, optional = true }
which = { workspace = true }
pastey = { version = "0.2", default-features = false }
shell-words = { workspace = true }
goose-acp-macros = { path = "../goose-acp-macros", default-features = false }
tower-http = { workspace = true, features = ["cors"] }
tracing-appender = { workspace = true }
process-wrap = { version = "9", default-features = false, features = ["std"] }
process-wrap = { version = "9", default-features = false, features = ["std", "process-session"] }
nostr = { version = "0.45.1", default-features = false, features = ["nip44", "os-rng", "std"], optional = true }
nostr-sdk = { version = "0.45.1", default-features = false, optional = true }
rustls = { workspace = true, optional = true }
@ -285,9 +314,14 @@ name = "test_whisper"
path = "examples/test_whisper.rs"
required-features = ["local-inference"]
[[bin]]
name = "goose-acp"
path = "src/bin/goose-acp.rs"
[[bin]]
name = "analyze_cli"
path = "src/bin/analyze_cli.rs"
required-features = ["tree-sitter"]
[[bin]]
name = "build_canonical_models"

View file

@ -1,6 +1,7 @@
mod common;
pub(crate) mod fs;
mod handoff;
#[cfg(feature = "acp-http")]
mod mcp_app_proxy;
mod provider;
mod response_builder;
@ -8,6 +9,7 @@ pub mod server;
pub mod server_factory;
pub(crate) mod tool_call_notifier;
pub(crate) mod tools;
#[cfg(feature = "acp-http")]
pub mod transport;
pub use common::{map_permission_response, PermissionDecision};

View file

@ -4,11 +4,14 @@ use crate::acp::server::{
};
use crate::agents::GoosePlatform;
use crate::config::paths::Paths;
#[cfg(feature = "scheduler")]
use crate::scheduler_trait::SchedulerTrait;
#[cfg(feature = "scheduler")]
use crate::session::SessionManager;
use crate::source_roots::SourceRoot;
use anyhow::Result;
use std::sync::Arc;
#[cfg(feature = "scheduler")]
use tokio::sync::OnceCell;
use tracing::info;
@ -27,6 +30,7 @@ pub struct AcpServerFactoryConfig {
pub struct AcpServer {
config: AcpServerFactoryConfig,
data_dir: std::path::PathBuf,
#[cfg(feature = "scheduler")]
scheduler: OnceCell<Arc<dyn SchedulerTrait>>,
active_runs: Arc<ActiveRunRegistry>,
live_voice: Arc<crate::acp::server::LiveVoiceService>,
@ -40,12 +44,14 @@ impl AcpServer {
Self {
config,
data_dir,
#[cfg(feature = "scheduler")]
scheduler: OnceCell::new(),
active_runs,
live_voice,
}
}
#[cfg(feature = "scheduler")]
/// Start the scheduler now instead of on first client connect, so a
/// headless `goose serve` runs scheduled jobs; on failure `create_agent`
/// retries. No-op when the scheduler is disabled.
@ -53,6 +59,7 @@ impl AcpServer {
self.scheduler().await.map(|_| ())
}
#[cfg(feature = "scheduler")]
async fn scheduler(&self) -> Result<Option<Arc<dyn SchedulerTrait>>> {
if !self.config.enable_scheduler {
return Ok(None);
@ -92,9 +99,10 @@ impl AcpServer {
) -> Result<Arc<GooseAcpAgent>> {
let config = crate::config::Config::global();
let disable_session_naming = config.get_goose_disable_session_naming().unwrap_or(false);
#[cfg(feature = "scheduler")]
let scheduler = self.scheduler().await?;
#[cfg(feature = "scheduler")]
if let Some(scheduler) = &scheduler {
// Listing syncs from storage, registering jobs persisted by other processes.
scheduler.list_scheduled_jobs().await;
}
@ -130,7 +138,10 @@ impl AcpServer {
goose_platform: self.config.goose_platform.clone(),
additional_source_roots: self.config.additional_source_roots.clone(),
session_cwd,
#[cfg(feature = "scheduler")]
scheduler,
#[cfg(not(feature = "scheduler"))]
scheduler: None,
active_runs: self.active_runs.clone(),
live_voice: self.live_voice.clone(),
})
@ -140,7 +151,7 @@ impl AcpServer {
Ok(Arc::new(agent))
}
#[cfg(test)]
#[cfg(all(test, feature = "scheduler"))]
pub(crate) fn new_for_test(
config: AcpServerFactoryConfig,
data_dir: std::path::PathBuf,
@ -150,6 +161,7 @@ impl AcpServer {
Self {
config,
data_dir,
#[cfg(feature = "scheduler")]
scheduler: OnceCell::new(),
active_runs,
live_voice,
@ -157,7 +169,7 @@ impl AcpServer {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "scheduler"))]
mod tests {
use super::*;

View file

@ -10,10 +10,12 @@ mod large_response_handler;
pub mod mcp_client;
pub mod moim;
pub mod platform_extensions;
#[cfg(feature = "scheduler")]
pub mod platform_tools;
pub mod prompt_manager;
pub mod reply_parts;
pub mod retry;
#[cfg(feature = "scheduler")]
mod schedule_tool;
pub mod state_machine;
pub mod subagent_execution_tool;
@ -34,6 +36,7 @@ pub use extension_manager::ExtensionManager;
pub use goose_agent::events::AgentEvent;
pub(crate) use large_response_handler::max_tool_response_size;
pub use prompt_manager::PromptManager;
#[cfg(feature = "scheduler")]
pub use schedule_tool::ScheduleTool;
pub use subagent_handler::SUBAGENT_TOOL_REQUEST_TYPE;
pub use subagent_task_config::TaskConfig;

View file

@ -1,11 +1,15 @@
#[cfg(feature = "tree-sitter")]
pub mod analyze;
#[cfg(feature = "platform-apps")]
pub mod apps;
#[cfg(feature = "chat-recall")]
pub mod chatrecall;
#[cfg(feature = "code-mode")]
pub mod code_execution;
pub mod developer;
pub mod ext_manager;
pub mod orchestrator;
#[cfg(feature = "scheduler")]
pub mod scheduler;
pub mod summarize;
pub mod summon;
@ -30,6 +34,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
|| {
let mut map = HashMap::new();
#[cfg(feature = "tree-sitter")]
map.insert(
analyze::EXTENSION_NAME,
PlatformExtensionDef {
@ -58,6 +63,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
},
);
#[cfg(feature = "platform-apps")]
map.insert(
apps::EXTENSION_NAME,
PlatformExtensionDef {
@ -72,6 +78,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
},
);
#[cfg(feature = "chat-recall")]
map.insert(
chatrecall::EXTENSION_NAME,
PlatformExtensionDef {
@ -102,6 +109,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
},
);
#[cfg(feature = "scheduler")]
map.insert(
scheduler::EXTENSION_NAME,
PlatformExtensionDef {

View file

@ -0,0 +1,34 @@
use anyhow::{bail, Result};
#[tokio::main]
async fn main() -> Result<()> {
let mut builtins = Vec::new();
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--with-builtin" => {
let Some(value) = args.next() else {
bail!("--with-builtin requires a comma-separated value");
};
builtins.extend(
value
.split(',')
.filter(|name| !name.is_empty())
.map(str::to_owned),
);
}
"-h" | "--help" => {
println!("Usage: goose-acp [--with-builtin <NAME,...>]");
return Ok(());
}
"-V" | "--version" => {
println!("goose-acp {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
unknown => bail!("unknown argument: {unknown}"),
}
}
goose::acp::server::run(builtins, false).await
}

View file

@ -348,6 +348,7 @@ fn malformed_arguments_contain_unresolved_execute_alias(
false
}
#[cfg(feature = "tree-sitter")]
fn decode_quoted_string(literal: &str) -> Option<String> {
let quote = literal.chars().next()?;
if !matches!(quote, '"' | '\'') || !literal.ends_with(quote) {
@ -375,10 +376,12 @@ fn decode_quoted_string(literal: &str) -> Option<String> {
(!escaped).then_some(decoded)
}
#[cfg(feature = "tree-sitter")]
fn node_text<'a>(node: tree_sitter::Node<'_>, source: &'a str) -> Option<&'a str> {
source.get(node.byte_range())
}
#[cfg(feature = "tree-sitter")]
fn is_developer_shell_call(node: tree_sitter::Node<'_>, source: &str) -> bool {
let Some(function) = node.child_by_field_name("function") else {
return false;
@ -400,6 +403,7 @@ fn is_developer_shell_call(node: tree_sitter::Node<'_>, source: &str) -> bool {
&& node_text(property, source) == Some("shell")
}
#[cfg(feature = "tree-sitter")]
fn shell_command_from_call(node: tree_sitter::Node<'_>, source: &str) -> Option<String> {
let arguments = node.child_by_field_name("arguments")?;
if arguments.named_child_count() != 1 {
@ -436,6 +440,7 @@ fn shell_command_from_call(node: tree_sitter::Node<'_>, source: &str) -> Option<
node_text(value, source).and_then(decode_quoted_string)
}
#[cfg(feature = "tree-sitter")]
fn extract_shell_command_from_execute_code(code: &str) -> Option<String> {
let mut parser = tree_sitter::Parser::new();
parser
@ -464,6 +469,11 @@ fn extract_shell_command_from_execute_code(code: &str) -> Option<String> {
command
}
#[cfg(not(feature = "tree-sitter"))]
fn extract_shell_command_from_execute_code(_code: &str) -> Option<String> {
None
}
fn maybe_convert_execute_to_shell_tool_call(
raw_tool_name: &str,
arguments_value: &Value,
@ -1514,6 +1524,7 @@ mod tests {
);
}
#[cfg(feature = "tree-sitter")]
#[test]
fn execute_code_only_extracts_real_developer_shell_call() {
let code = r#"
@ -1532,6 +1543,7 @@ mod tests {
);
}
#[cfg(feature = "tree-sitter")]
#[test]
fn execute_code_rejects_ambiguous_or_unsupported_shell_calls() {
let cases = [

View file

@ -0,0 +1,187 @@
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::config::paths::Paths;
pub(crate) const MAX_SCHEDULE_RECIPE_BYTES: u64 = 1024 * 1024;
#[cfg_attr(not(feature = "scheduler"), allow(dead_code))]
pub struct ValidatedScheduleRecipe {
pub(super) bytes: Vec<u8>,
pub(super) source: PathBuf,
}
#[cfg_attr(not(feature = "scheduler"), allow(dead_code))]
impl ValidatedScheduleRecipe {
pub(crate) fn new(bytes: Vec<u8>, source: PathBuf) -> Self {
Self { bytes, source }
}
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
}
#[cfg_attr(not(feature = "scheduler"), allow(dead_code))]
pub(crate) fn open_regular_schedule_recipe(path: &Path) -> io::Result<File> {
let metadata = fs::metadata(path)?;
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Recipe path must reference a regular file",
));
}
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW);
}
let file = options.open(path)?;
if !file.metadata()?.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Recipe path must reference a regular file",
));
}
Ok(file)
}
#[cfg_attr(not(feature = "scheduler"), allow(dead_code))]
pub(super) fn write_schedule_recipe_bytes(
destination: &Path,
bytes: &[u8],
) -> Result<(), SchedulerError> {
if bytes.len() as u64 > MAX_SCHEDULE_RECIPE_BYTES {
return Err(SchedulerError::RecipeLoadError(format!(
"Recipe file exceeds the {MAX_SCHEDULE_RECIPE_BYTES} byte limit"
)));
}
let result = (|| {
let mut options = OpenOptions::new();
options.write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(destination)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
file.set_len(0)?;
file.write_all(bytes)
})();
if let Err(error) = result {
let _ = fs::remove_file(destination);
return Err(SchedulerError::StorageError(error));
}
Ok(())
}
pub fn get_default_scheduler_storage_path() -> Result<PathBuf, io::Error> {
let data_dir = Paths::data_dir();
fs::create_dir_all(&data_dir)?;
Ok(data_dir.join("schedule.json"))
}
pub fn get_default_scheduled_recipes_dir() -> Result<PathBuf, SchedulerError> {
let data_dir = Paths::data_dir();
let recipes_dir = data_dir.join("scheduled_recipes");
fs::create_dir_all(&recipes_dir).map_err(SchedulerError::StorageError)?;
Ok(recipes_dir)
}
#[derive(Debug)]
pub enum SchedulerError {
JobIdExists(String),
JobNotFound(String),
StorageError(io::Error),
RecipeLoadError(String),
AgentSetupError(String),
PersistError(String),
CronParseError(String),
SchedulerInternalError(String),
AnyhowError(anyhow::Error),
}
impl std::fmt::Display for SchedulerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SchedulerError::JobIdExists(id) => write!(f, "Job ID '{}' already exists.", id),
SchedulerError::JobNotFound(id) => write!(f, "Job ID '{}' not found.", id),
SchedulerError::StorageError(e) => write!(f, "Storage error: {}", e),
SchedulerError::RecipeLoadError(e) => write!(f, "Recipe load error: {}", e),
SchedulerError::AgentSetupError(e) => write!(f, "Agent setup error: {}", e),
SchedulerError::PersistError(e) => write!(f, "Failed to persist schedules: {}", e),
SchedulerError::CronParseError(e) => write!(f, "Invalid cron string: {}", e),
SchedulerError::SchedulerInternalError(e) => {
write!(f, "Scheduler internal error: {}", e)
}
SchedulerError::AnyhowError(e) => write!(f, "Scheduler operation failed: {}", e),
}
}
}
impl std::error::Error for SchedulerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SchedulerError::StorageError(e) => Some(e),
SchedulerError::AnyhowError(e) => Some(e.as_ref()),
_ => None,
}
}
}
impl From<io::Error> for SchedulerError {
fn from(err: io::Error) -> Self {
SchedulerError::StorageError(err)
}
}
impl From<serde_json::Error> for SchedulerError {
fn from(err: serde_json::Error) -> Self {
SchedulerError::PersistError(err.to_string())
}
}
impl From<anyhow::Error> for SchedulerError {
fn from(err: anyhow::Error) -> Self {
SchedulerError::AnyhowError(err)
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct ScheduledJob {
pub id: String,
pub source: String,
pub cron: String,
pub last_run: Option<DateTime<Utc>>,
#[serde(default)]
pub currently_running: bool,
#[serde(default)]
pub paused: bool,
#[serde(default)]
pub current_session_id: Option<String>,
#[serde(default)]
pub process_start_time: Option<DateTime<Utc>>,
#[serde(default)]
pub parameters: Vec<(String, String)>,
/// Original directory of the recipe file before it was copied to scheduled_recipes/.
/// Preserved so that relative paths (sub-recipes, template includes) resolve correctly
/// against the source tree rather than the scheduler's internal storage directory.
#[serde(default)]
pub recipe_base_dir: Option<String>,
}

View file

@ -1,19 +1,17 @@
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Local, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio_cron_scheduler::{job::JobId, Job, JobScheduler as TokioJobScheduler};
use tokio_util::sync::CancellationToken;
use crate::agents::{Agent, AgentConfig, AgentEvent, GoosePlatform, SessionConfig};
use crate::config::paths::Paths;
use crate::config::permission::PermissionManager;
use crate::config::{resolve_extensions_for_new_session, Config, GooseMode};
use crate::conversation::message::Message;
@ -33,61 +31,15 @@ use crate::session::{Session, SessionManager};
type RunningTasksMap = HashMap<String, CancellationToken>;
type JobsMap = HashMap<String, (JobId, ScheduledJob)>;
pub(crate) const MAX_SCHEDULE_RECIPE_BYTES: u64 = 1024 * 1024;
pub struct ValidatedScheduleRecipe {
bytes: Vec<u8>,
source: PathBuf,
}
impl ValidatedScheduleRecipe {
pub(crate) fn new(bytes: Vec<u8>, source: PathBuf) -> Self {
Self { bytes, source }
}
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
}
pub(crate) fn open_regular_schedule_recipe(path: &Path) -> io::Result<File> {
let metadata = fs::metadata(path)?;
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Recipe path must reference a regular file",
));
}
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW);
}
let file = options.open(path)?;
if !file.metadata()?.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Recipe path must reference a regular file",
));
}
Ok(file)
}
use super::*;
fn read_validated_schedule_recipe(source_path: &Path) -> Result<Vec<u8>, SchedulerError> {
let source = open_regular_schedule_recipe(source_path).map_err(|error| {
let mut source = open_regular_schedule_recipe(source_path).map_err(|error| {
SchedulerError::RecipeLoadError(format!("Cannot read recipe file: {error}"))
})?;
let metadata = source.metadata().map_err(|error| {
SchedulerError::RecipeLoadError(format!("Cannot inspect recipe file: {error}"))
})?;
if !metadata.is_file() {
return Err(SchedulerError::RecipeLoadError(
"Recipe path must reference a regular file".to_string(),
));
}
if metadata.len() > MAX_SCHEDULE_RECIPE_BYTES {
return Err(SchedulerError::RecipeLoadError(format!(
"Recipe file exceeds the {MAX_SCHEDULE_RECIPE_BYTES} byte limit"
@ -96,6 +48,7 @@ fn read_validated_schedule_recipe(source_path: &Path) -> Result<Vec<u8>, Schedul
let mut bytes = Vec::new();
source
.by_ref()
.take(MAX_SCHEDULE_RECIPE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| {
@ -120,134 +73,6 @@ fn read_validated_schedule_recipe(source_path: &Path) -> Result<Vec<u8>, Schedul
Ok(bytes)
}
fn write_schedule_recipe_bytes(destination: &Path, bytes: &[u8]) -> Result<(), SchedulerError> {
if bytes.len() as u64 > MAX_SCHEDULE_RECIPE_BYTES {
return Err(SchedulerError::RecipeLoadError(format!(
"Recipe file exceeds the {MAX_SCHEDULE_RECIPE_BYTES} byte limit"
)));
}
let result = (|| {
let mut options = OpenOptions::new();
options.write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(destination)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
file.set_len(0)?;
file.write_all(bytes)
})();
if let Err(error) = result {
let _ = fs::remove_file(destination);
return Err(SchedulerError::StorageError(error));
}
Ok(())
}
pub fn get_default_scheduler_storage_path() -> Result<PathBuf, io::Error> {
let data_dir = Paths::data_dir();
fs::create_dir_all(&data_dir)?;
Ok(data_dir.join("schedule.json"))
}
pub fn get_default_scheduled_recipes_dir() -> Result<PathBuf, SchedulerError> {
let data_dir = Paths::data_dir();
let recipes_dir = data_dir.join("scheduled_recipes");
fs::create_dir_all(&recipes_dir).map_err(SchedulerError::StorageError)?;
Ok(recipes_dir)
}
#[derive(Debug)]
pub enum SchedulerError {
JobIdExists(String),
JobNotFound(String),
StorageError(io::Error),
RecipeLoadError(String),
AgentSetupError(String),
PersistError(String),
CronParseError(String),
SchedulerInternalError(String),
AnyhowError(anyhow::Error),
}
impl std::fmt::Display for SchedulerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SchedulerError::JobIdExists(id) => write!(f, "Job ID '{}' already exists.", id),
SchedulerError::JobNotFound(id) => write!(f, "Job ID '{}' not found.", id),
SchedulerError::StorageError(e) => write!(f, "Storage error: {}", e),
SchedulerError::RecipeLoadError(e) => write!(f, "Recipe load error: {}", e),
SchedulerError::AgentSetupError(e) => write!(f, "Agent setup error: {}", e),
SchedulerError::PersistError(e) => write!(f, "Failed to persist schedules: {}", e),
SchedulerError::CronParseError(e) => write!(f, "Invalid cron string: {}", e),
SchedulerError::SchedulerInternalError(e) => {
write!(f, "Scheduler internal error: {}", e)
}
SchedulerError::AnyhowError(e) => write!(f, "Scheduler operation failed: {}", e),
}
}
}
impl std::error::Error for SchedulerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SchedulerError::StorageError(e) => Some(e),
SchedulerError::AnyhowError(e) => Some(e.as_ref()),
_ => None,
}
}
}
impl From<io::Error> for SchedulerError {
fn from(err: io::Error) -> Self {
SchedulerError::StorageError(err)
}
}
impl From<serde_json::Error> for SchedulerError {
fn from(err: serde_json::Error) -> Self {
SchedulerError::PersistError(err.to_string())
}
}
impl From<anyhow::Error> for SchedulerError {
fn from(err: anyhow::Error) -> Self {
SchedulerError::AnyhowError(err)
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct ScheduledJob {
pub id: String,
pub source: String,
pub cron: String,
pub last_run: Option<DateTime<Utc>>,
#[serde(default)]
pub currently_running: bool,
#[serde(default)]
pub paused: bool,
#[serde(default)]
pub current_session_id: Option<String>,
#[serde(default)]
pub process_start_time: Option<DateTime<Utc>>,
#[serde(default)]
pub parameters: Vec<(String, String)>,
/// Original directory of the recipe file before it was copied to scheduled_recipes/.
/// Preserved so that relative paths (sub-recipes, template includes) resolve correctly
/// against the source tree rather than the scheduler's internal storage directory.
#[serde(default)]
pub recipe_base_dir: Option<String>,
}
async fn persist_jobs(
storage_path: &Path,
jobs: &Arc<Mutex<JobsMap>>,
@ -1333,6 +1158,7 @@ impl SchedulerTrait for Scheduler {
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::tempdir;
use tokio::time::{sleep, Duration};

View file

@ -0,0 +1,7 @@
mod common;
#[cfg(feature = "scheduler")]
mod full;
pub use common::*;
#[cfg(feature = "scheduler")]
pub use full::Scheduler;