Fix loading siGit Code on Xcode custom ACP agent

Seto Elkahfi committed Jul 27, 2026 at 18:45 UTC 9044eebddef34e8b17943a68c63e3d66060bc990
3 files changed +176 -64
README.md
+21
@@ -61,6 +61,27 @@ Add this to `~/.config/zed/settings.json`:
61
62 Use the full absolute path. `~` does not expand here.
63
64 +## Xcode custom agent
65 +
66 +In Xcode, open **Settings > Intelligence > Agents**, add a custom agent, and set:
67 +
68 +- **Executable:** the absolute path to `sigit`
69 +- **Arguments:** `--acp`
70 +
71 +The explicit `--acp` mode is designed for Xcode: it loads the selected on-device
72 +model on the first prompt, so you do not need to send `/load` from the Xcode chat.
73 +
74 +To let siGit use Xcode's build, test, and project tools, enable **Allow external
75 +agents to use Xcode tools** in Xcode's Intelligence settings, keep the project
76 +open, and add this to `~/.config/sigit/mcp.toml`:
77 +
78 +```toml
79 +[[server]]
80 +name = "xcode"
81 +command = "xcrun"
82 +args = ["mcpbridge"]
83 +```
84 +
85 ## VS Code
86
87 ### With siGit Code Extension
src/main.rs
+133 -58
@@ -433,6 +433,9 @@ struct SiGitAgent {
433 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
434 /// true when the startup model isn't cached yet
435 startup_needs_download: bool,
436 + /// Xcode launches custom agents with `--acp`; that mode should be ready to
437 + /// answer the first prompt without requiring an editor slash command.
438 + auto_load_local_model: bool,
439 /// for progress UI
440 startup_model_name: String,
441 /// for download-progress polling
@@ -453,6 +456,7 @@ impl SiGitAgent {
456 startup_model_load_started: Arc<AtomicBool>,
457 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
458 startup_needs_download: bool,
459 + auto_load_local_model: bool,
460 ) -> Self {
461 let startup_model_name = initial_model.display_name.clone();
462 let startup_model_id = initial_model.model_id.clone();
@@ -466,6 +470,7 @@ impl SiGitAgent {
470 startup_model_load_started,
471 model_load_error,
472 startup_needs_download,
473 + auto_load_local_model,
474 startup_model_name,
475 startup_model_id,
476 turn_lock: Arc::new(tokio::sync::Mutex::new(())),
@@ -1390,21 +1395,25 @@ impl SiGitAgent {
1395 let backend = self.backend.lock().await.clone();
1396
1397 // Only on-device inference needs a local model in memory. Cloud tiers run
1393 - // over the network, so they never need a local model. We never load the
1394 - // on-device model implicitly: the user loads it explicitly with `/load`
1395 - // (or by picking one in `/models`). If a prompt arrives before that, guide
1396 - // them rather than blocking on a multi-minute download/load.
1398 + // over the network, so they never need a local model. Xcode's explicit
1399 + // `--acp` mode loads lazily on the first prompt because its custom-agent
1400 + // UI has no reliable equivalent of siGit's `/load` command.
1401 if !backend.is_remote()
1402 && self.engine.info().await.status == onde::inference::EngineStatus::Unloaded
1403 {
1400 - self.send_assistant_message(
1401 - cx,
1402 - session_id,
1403 - "No on-device model is loaded. Run `/load` to load the selected model, \
1404 - or `/models` to choose one.",
1405 - )
1406 - .ok();
1407 - return Ok(PromptResponse::new(StopReason::EndTurn));
1404 + if self.auto_load_local_model {
1405 + self.start_startup_model_load_if_needed();
1406 + self.await_model_ready(cx, &session_id).await?;
1407 + } else {
1408 + self.send_assistant_message(
1409 + cx,
1410 + session_id,
1411 + "No on-device model is loaded. Run `/load` to load the selected model, \
1412 + or `/models` to choose one.",
1413 + )
1414 + .ok();
1415 + return Ok(PromptResponse::new(StopReason::EndTurn));
1416 + }
1417 }
1418
1419 // ── tool-calling loop ────────────────────────────────────────────
@@ -1422,6 +1431,7 @@ impl SiGitAgent {
1431 let mut assembled = String::new();
1432 let mut sent = String::new();
1433 let mut streamed_any = false;
1434 + let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
1435
1436 let mut result = self
1437 .drain_turn(
@@ -1470,6 +1480,7 @@ impl SiGitAgent {
1480 }
1481
1482 let mut tool_results = Vec::new();
1483 + let mut force_text = false;
1484
1485 for (call_index, tc) in result.tool_calls.iter().enumerate() {
1486 log::info!(
@@ -1478,52 +1489,79 @@ impl SiGitAgent {
1489 tc.arguments.chars().take(120).collect::<String>()
1490 );
1491
1492 + let signature = format!("{}\n{}", tc.name, tc.arguments);
1493 + let repeat_count = repeated_tool_calls
1494 + .entry(signature)
1495 + .and_modify(|count| *count += 1)
1496 + .or_insert(1);
1497 + let repeated = *repeat_count >= 3;
1498 + if repeated {
1499 + force_text = true;
1500 + log::warn!(
1501 + "prompt({}) stopping repeated tool call `{}` after {} attempts",
1502 + session_id,
1503 + tc.name,
1504 + repeat_count
1505 + );
1506 + }
1507 +
1508 // Permission gate: read-only tools pass straight through; a
1509 // mutating tool consults policy and may ask the client.
1483 - let output = match permissions::decision_for(
1484 - &session_id.to_string(),
1485 - &tc.name,
1486 - &tc.arguments,
1487 - ) {
1488 - permissions::Decision::Allow => {
1489 - tools::execute_tool(&tc.name, &tc.arguments).await
1490 - }
1491 - permissions::Decision::Deny(reason) => {
1492 - log::info!(" ✗ {} denied by policy", tc.name);
1493 - reason
1494 - }
1495 - permissions::Decision::Ask => {
1496 - match self
1497 - .request_tool_permission(cx, &session_id, &tc.name, &tc.arguments)
1498 - .await
1499 - {
1500 - PermissionVerdict::Approved => {
1501 - tools::execute_tool(&tc.name, &tc.arguments).await
1502 - }
1503 - PermissionVerdict::Denied(reason) => {
1504 - log::info!(" ✗ {} denied by user", tc.name);
1505 - reason
1506 - }
1507 - PermissionVerdict::TurnCancelled => {
1508 - log::info!("prompt({}) cancelled at permission gate", session_id);
1509 - // The assistant message carrying these tool
1510 - // calls is already in the backend history;
1511 - // leaving any of them unanswered makes strict
1512 - // OpenAI-compatible endpoints reject every
1513 - // later request in the session. Close out this
1514 - // call and the ones this round never reached.
1515 - for pending in &result.tool_calls[call_index..] {
1516 - tool_results.push(BackendToolResult {
1517 - tool_call_id: pending.id.clone(),
1518 - content: format!(
1519 - "`{}` was not executed: the user cancelled the turn \
1520 - at the permission prompt.",
1521 - pending.name
1522 - ),
1523 - });
1510 + let output = if repeated {
1511 + format!(
1512 + "The tool `{}` was not executed again because the model repeated \
1513 + the same call three times. Continue without this tool.",
1514 + tc.name
1515 + )
1516 + } else {
1517 + match permissions::decision_for(
1518 + &session_id.to_string(),
1519 + &tc.name,
1520 + &tc.arguments,
1521 + ) {
1522 + permissions::Decision::Allow => {
1523 + tools::execute_tool(&tc.name, &tc.arguments).await
1524 + }
1525 + permissions::Decision::Deny(reason) => {
1526 + log::info!(" ✗ {} denied by policy", tc.name);
1527 + reason
1528 + }
1529 + permissions::Decision::Ask => {
1530 + match self
1531 + .request_tool_permission(cx, &session_id, &tc.name, &tc.arguments)
1532 + .await
1533 + {
1534 + PermissionVerdict::Approved => {
1535 + tools::execute_tool(&tc.name, &tc.arguments).await
1536 + }
1537 + PermissionVerdict::Denied(reason) => {
1538 + log::info!(" ✗ {} denied by user", tc.name);
1539 + reason
1540 + }
1541 + PermissionVerdict::TurnCancelled => {
1542 + log::info!(
1543 + "prompt({}) cancelled at permission gate",
1544 + session_id
1545 + );
1546 + // The assistant message carrying these tool
1547 + // calls is already in the backend history;
1548 + // leaving any of them unanswered makes strict
1549 + // OpenAI-compatible endpoints reject every
1550 + // later request in the session. Close out this
1551 + // call and the ones this round never reached.
1552 + for pending in &result.tool_calls[call_index..] {
1553 + tool_results.push(BackendToolResult {
1554 + tool_call_id: pending.id.clone(),
1555 + content: format!(
1556 + "`{}` was not executed: the user cancelled the turn \
1557 + at the permission prompt.",
1558 + pending.name
1559 + ),
1560 + });
1561 + }
1562 + backend.record_cancelled_tool_results(tool_results).await;
1563 + return Ok(PromptResponse::new(StopReason::Cancelled));
1564 }
1525 - backend.record_cancelled_tool_results(tool_results).await;
1526 - return Ok(PromptResponse::new(StopReason::Cancelled));
1565 }
1566 }
1567 }
@@ -1537,7 +1575,7 @@ impl SiGitAgent {
1575 });
1576 }
1577
1540 - let next_tools = if round < MAX_TOOL_ROUNDS {
1578 + let next_tools = if round < MAX_TOOL_ROUNDS && !force_text {
1579 Some(tools.as_slice())
1580 } else {
1581 None // last round: force text
@@ -3064,7 +3102,17 @@ fn default_local_model_config() -> GgufModelConfig {
3102 .unwrap_or_else(GgufModelConfig::qwen25_3b)
3103 }
3104
3067 -async fn run_acp_server() -> anyhow::Result<()> {
3105 +fn parse_explicit_acp(args: &[String]) -> Result<bool, String> {
3106 + if !args.iter().any(|arg| arg == "--acp") {
3107 + return Ok(false);
3108 + }
3109 + if args.iter().any(|arg| arg != "--acp") {
3110 + return Err("--acp does not accept additional arguments".to_string());
3111 + }
3112 + Ok(true)
3113 +}
3114 +
3115 +async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
3116 log::info!("ACP mode — starting agent server");
3117
3118 let config = default_local_model_config();
@@ -3102,6 +3150,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
3150 startup_model_load_started,
3151 model_load_error,
3152 needs_download,
3153 + auto_load_local_model,
3154 ));
3155
3156 // Honor the explicit provider override (OPENAI_BASE_URL/OPENAI_API_KEY or
@@ -3294,6 +3343,25 @@ async fn main() -> anyhow::Result<()> {
3343 // is dispatched here, before the TTY/ACP split, like the account
3344 // subcommands above.
3345 let cli_args: Vec<String> = std::env::args().skip(1).collect();
3346 + // Xcode's custom-agent UI launches ACP agents with an explicit `--acp`
3347 + // argument. Honor it even when Xcode gives the child a terminal-like stdin.
3348 + match parse_explicit_acp(&cli_args) {
3349 + Err(error) => {
3350 + eprintln!("sigit: {error}");
3351 + std::process::exit(2);
3352 + }
3353 + Ok(true) => {
3354 + init_logging(false);
3355 + setup::setup_shared_model_cache();
3356 + mcp::init().await;
3357 + log::info!(
3358 + "siGit v{} starting (explicit ACP mode)",
3359 + env!("CARGO_PKG_VERSION")
3360 + );
3361 + return run_acp_server(true).await;
3362 + }
3363 + Ok(false) => {}
3364 + }
3365 match headless::parse_args(&cli_args) {
3366 Ok(None) => {}
3367 Ok(Some(config)) => {
@@ -3350,7 +3418,7 @@ async fn main() -> anyhow::Result<()> {
3418 // Best-effort MCP discovery (incl. the official server) before serving.
3419 mcp::init().await;
3420 log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));
3353 - run_acp_server().await
3421 + run_acp_server(false).await
3422 }
3423 }
3424
@@ -3358,6 +3426,13 @@ async fn main() -> anyhow::Result<()> {
3426 mod tests {
3427 use super::*;
3428
3429 + #[test]
3430 + fn explicit_acp_flag_is_accepted_without_other_arguments() {
3431 + assert_eq!(parse_explicit_acp(&["--acp".to_string()]), Ok(true));
3432 + assert_eq!(parse_explicit_acp(&[]), Ok(false));
3433 + assert!(parse_explicit_acp(&["--acp".to_string(), "--quiet".to_string()]).is_err());
3434 + }
3435 +
3436 #[test]
3437 fn system_prompt_advertises_the_commit_co_author_trailer() {
3438 // The prompt instructs the model with the exact trailer that
src/mcp.rs
+22 -6
@@ -127,9 +127,12 @@ const PROTOCOL_VERSION: &str = "2025-06-18";
127 /// sum.
128 const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(8);
129
130 -/// Overall request timeout for an individual `tools/call`. Generous, since an
131 -/// MCP tool may do real work server-side.
130 +/// Overall request timeout for an individual `tools/call`. Generous for build
131 +/// and test tools, while the Xcode bridge gets a shorter bound below because
132 +/// it otherwise leaves an ACP prompt looking permanently busy when Xcode
133 +/// cannot service the request.
134 const CALL_TIMEOUT: Duration = Duration::from_secs(120);
135 +const XCODE_CALL_TIMEOUT: Duration = Duration::from_secs(30);
136
137 /// Cap on the characters returned from a single tool call, so a chatty server
138 /// can't blow up the model's context. Matches the spirit of the file-read cap.
@@ -818,7 +821,12 @@ impl Mcp {
821 let result = match &server.transport {
822 None => return Err(format!("server '{}' is not connected", server.name)),
823 Some(Transport::Stdio(stdio)) => {
821 - stdio.request("tools/call", params, CALL_TIMEOUT).await?
824 + let timeout = if server.name == "xcode" {
825 + XCODE_CALL_TIMEOUT
826 + } else {
827 + CALL_TIMEOUT
828 + };
829 + stdio.request("tools/call", params, timeout).await?
830 }
831 Some(Transport::Http(http_conn)) => {
832 let body = json!({
@@ -827,14 +835,19 @@ impl Mcp {
835 "method": "tools/call",
836 "params": params
837 });
830 - match post_rpc(&self.http, &server.name, http_conn, &body, CALL_TIMEOUT).await {
838 + let timeout = if server.name == "xcode" {
839 + XCODE_CALL_TIMEOUT
840 + } else {
841 + CALL_TIMEOUT
842 + };
843 + match post_rpc(&self.http, &server.name, http_conn, &body, timeout).await {
844 Ok(result) => result,
845 Err(error) if error.contains("returned 404") => {
846 // Session expired — drop it, re-handshake, and retry once.
847 *http_conn.session_id.lock().await = None;
848 initialize(&self.http, server).await?;
849 notify_initialized(&self.http, server).await?;
837 - post_rpc(&self.http, &server.name, http_conn, &body, CALL_TIMEOUT).await?
850 + post_rpc(&self.http, &server.name, http_conn, &body, timeout).await?
851 }
852 Err(error) => return Err(error),
853 }
@@ -1027,7 +1040,10 @@ async fn stdio_reader(mut stdout: BufReader<ChildStdout>, shared: Arc<StdioShare
1040 );
1041 continue;
1042 }
1030 - let Some(id) = message.get("id").and_then(Value::as_i64) else {
1043 + let Some(id) = message
1044 + .get("id")
1045 + .and_then(|value| value.as_i64().or_else(|| value.as_str()?.parse().ok()))
1046 + else {
1047 log::warn!(
1048 "mcp: '{}' sent a response without a usable id; ignoring",
1049 shared.name