mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
Raise the `buzz-dev-mcp` shell timeout ceiling to 1,200,000 ms (20 minutes) while keeping the omitted-value default at 120,000 ms (2 minutes), and align the two outer timeout layers that bound every MCP tool call. ## What changed **`crates/buzz-dev-mcp`** — shell timeout cap raised to 1,200,000 ms; tool and parameter descriptions updated to match. **`crates/buzz-agent`** — `BUZZ_AGENT_TOOL_TIMEOUT_SECS` default raised from 660 s to 1,260 s (20-min cap + 60 s cleanup headroom). The old 660 s default would have killed the MCP server at 11 min, before a 20-min shell call could finish. README updated. **`crates/buzz-acp`** — `DEFAULT_IDLE_TIMEOUT_SECS` raised from 900 s to 1,500 s and its doc comment updated (1,200 s max shell + 300 s breathing room). The old value was documented as "600 s max shell + 300 s" — stale arithmetic. ## Tests - Existing boundary tests in `buzz-dev-mcp` (default, exact-cap, over-cap, `u64::MAX`) all pass. - `default_idle_timeout_is_1500_seconds` — pinned constant replaces old 900 s assertion. - `default_tool_timeout_is_1260_seconds` — new pinned constant in `buzz-agent`. - `budget_ordering_invariant_shell_cap_plus_headroom_fits_within_idle_timeout` — new const assertion in `buzz-acp` that encodes the full three-layer ordering: shell cap (1,200 s) ≤ agent tool timeout (1,260 s) < ACP idle timeout (1,500 s) < max turn duration. A future edit that inverts any tier fails to compile. --------- Signed-off-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz> Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
214 lines
8.2 KiB
Rust
214 lines
8.2 KiB
Rust
#![cfg_attr(not(windows), forbid(unsafe_code))]
|
|
#![cfg_attr(windows, deny(unsafe_code))]
|
|
use rmcp::{
|
|
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
|
model::{CallToolResult, ServerCapabilities, ServerInfo},
|
|
tool, tool_handler, tool_router,
|
|
transport::stdio,
|
|
ErrorData, ServerHandler, ServiceExt,
|
|
};
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
mod paths;
|
|
mod read_file;
|
|
mod rg;
|
|
mod shell;
|
|
mod shim;
|
|
mod str_replace;
|
|
mod todo;
|
|
mod tree;
|
|
mod view_image;
|
|
|
|
#[derive(Clone)]
|
|
struct DevMcp {
|
|
state: Arc<shell::SharedState>,
|
|
todos: Arc<todo::TodoState>,
|
|
tool_router: ToolRouter<DevMcp>,
|
|
}
|
|
|
|
#[tool_router]
|
|
impl DevMcp {
|
|
fn new(state: Arc<shell::SharedState>) -> Self {
|
|
Self {
|
|
state,
|
|
todos: Arc::new(todo::TodoState::new()),
|
|
tool_router: Self::tool_router(),
|
|
}
|
|
}
|
|
|
|
#[tool(
|
|
name = "shell",
|
|
description = "Run a shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another shell). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 1,200,000 (20 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files), tree (flags: -d <depth>; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)."
|
|
)]
|
|
async fn shell(
|
|
&self,
|
|
Parameters(p): Parameters<shell::ShellParams>,
|
|
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
shell::run(&self.state, p, context.ct).await
|
|
}
|
|
|
|
#[tool(
|
|
name = "read_file",
|
|
description = "Read a text file and return its contents with line numbers. Returns lines in `{number}:{content}` format. Use `offset` (0-based) and `limit` (default 2000) to window into large files. Path resolved relative to workdir (defaults to server cwd). Prefer over cat/head/tail."
|
|
)]
|
|
async fn read_file(
|
|
&self,
|
|
Parameters(p): Parameters<read_file::ReadFileParams>,
|
|
) -> Result<String, ErrorData> {
|
|
read_file::run(&self.state, p)
|
|
}
|
|
|
|
#[tool(
|
|
name = "view_image",
|
|
description = "Load an image from a file path, http(s) URL, or data: URL and return it as an MCP image content block that multimodal LLMs (Anthropic, OpenAI-compatible, etc.) can see. Resizes to a longest-edge of 1568px by default (override with `max_dim`, range 64..=2048). Pass-through for already-small PNG/JPEG; transcodes oversize input to PNG (if alpha) or JPEG q85. Animated GIF/WebP rejected — provide a still frame. Hard cap 20 MiB source, ~4 MiB on the wire. Relative paths resolve under `workdir` (defaults to server cwd) and may not escape it."
|
|
)]
|
|
async fn view_image(
|
|
&self,
|
|
Parameters(p): Parameters<view_image::ViewImageParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
view_image::run(&self.state, p).await
|
|
}
|
|
|
|
#[tool(
|
|
name = "str_replace",
|
|
description = "Atomic find-and-replace in a file. old_str must occur exactly once unless replace_all is true, in which case all occurrences are replaced. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
|
|
)]
|
|
async fn str_replace(
|
|
&self,
|
|
Parameters(p): Parameters<str_replace::StrReplaceParams>,
|
|
) -> Result<String, ErrorData> {
|
|
str_replace::run(&self.state, p)
|
|
}
|
|
|
|
#[tool(
|
|
name = "todo",
|
|
description = "Session checklist only for work that must continue across turns or survive context compaction. Do not use for work you can finish in the current turn. Omit `todos` to read; provide the full {text, done} list to replace it. Open items let the _Stop hook advise against ending."
|
|
)]
|
|
async fn todo(
|
|
&self,
|
|
Parameters(p): Parameters<todo::TodoParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
match self.todos.handle_todo(p) {
|
|
Ok(text) => todo::text_result(text),
|
|
Err(e) => todo::error_result(format!("Error: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Hook: called by the agent before honoring end_turn. Returns
|
|
/// non-empty objection text iff items remain open.
|
|
#[tool(
|
|
name = "_Stop",
|
|
description = "Returns open todo items if any exist. Used by the agent's _Stop lifecycle hook to advise against ending with incomplete work."
|
|
)]
|
|
async fn stop_hook(
|
|
&self,
|
|
Parameters(_): Parameters<todo::HookParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
todo::text_result(self.todos.stop_objection())
|
|
}
|
|
|
|
/// Hook: called by the agent after context compaction/handoff so the
|
|
/// todo list survives history truncation.
|
|
#[tool(
|
|
name = "_PostCompact",
|
|
description = "Internal hook. Agent invokes after handoff; returns todo state for re-injection."
|
|
)]
|
|
async fn post_compact_hook(
|
|
&self,
|
|
Parameters(_): Parameters<todo::HookParams>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
todo::text_result(self.todos.post_compact())
|
|
}
|
|
}
|
|
|
|
#[tool_handler(router = self.tool_router)]
|
|
impl ServerHandler for DevMcp {
|
|
fn get_info(&self) -> ServerInfo {
|
|
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
|
.with_server_info(rmcp::model::Implementation::new(
|
|
"buzz-dev-mcp",
|
|
env!("CARGO_PKG_VERSION"),
|
|
))
|
|
.with_instructions(self.state.bootstrap_instructions.clone())
|
|
}
|
|
}
|
|
|
|
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|
let argv0 = std::env::args().next().unwrap_or_default();
|
|
let cmd = Path::new(&argv0)
|
|
.file_stem()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase();
|
|
|
|
// Multicall dispatch — sync personalities exit before any runtime is built.
|
|
// No tracing, no tokio, no allocations beyond argv parsing.
|
|
match cmd.as_str() {
|
|
"rg" => std::process::exit(rg::run(std::env::args().skip(1).collect())),
|
|
"tree" => std::process::exit(tree::run(std::env::args().skip(1).collect())),
|
|
"git-credential-nostr" => std::process::exit(git_credential_nostr::run()),
|
|
"git-sign-nostr" => std::process::exit(git_sign_nostr::run()),
|
|
_ => {}
|
|
}
|
|
|
|
// Async personalities and MCP server mode — build the runtime.
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()?
|
|
.block_on(async_main(cmd))
|
|
}
|
|
|
|
async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
|
|
// HTTPS clients invoked through this MCP process need a Rustls provider;
|
|
// repeated installation is harmless.
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
|
|
// buzz CLI needs tokio (async HTTP client).
|
|
if cmd == "buzz" {
|
|
std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
|
|
}
|
|
|
|
// MCP server mode — safe to init tracing now.
|
|
tracing_subscriber::fmt()
|
|
.with_writer(std::io::stderr)
|
|
.with_ansi(false)
|
|
.init();
|
|
|
|
let cwd = std::env::current_dir()?;
|
|
let shim = shim::Shim::install()?;
|
|
let state = Arc::new(shell::SharedState::new(cwd, shim)?);
|
|
|
|
let service = DevMcp::new(state).serve(stdio()).await?;
|
|
service.waiting().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Suppress the console window that Windows otherwise allocates for every
|
|
/// console-subsystem child process spawned from a non-console parent.
|
|
/// No-op on non-Windows platforms.
|
|
pub(crate) fn configure_no_window(cmd: &mut std::process::Command) {
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt as _;
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
#[cfg(not(windows))]
|
|
let _ = cmd;
|
|
}
|
|
|
|
/// Suppress the console window for async (`tokio::process::Command`) spawns.
|
|
/// Equivalent to `configure_no_window` but accepts a tokio command.
|
|
/// No-op on non-Windows platforms.
|
|
pub(crate) fn configure_no_window_async(cmd: &mut tokio::process::Command) {
|
|
#[cfg(windows)]
|
|
{
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
#[cfg(not(windows))]
|
|
let _ = cmd;
|
|
}
|