1 //! siGit Code — local coding agent on Onde Inference.
2 //!
3 //! In TTY mode, all output (log crate, tracing, stray printlns) redirects to
4 //! `$TMPDIR/sigit.log`. Ratatui holds a separate fd to the real terminal so
5 //! the TUI stays clean.
6 //!
7 //! Two modes:
8 //! - ACP over stdio (editor integration, e.g. Zed)
9 //! - interactive terminal (direct TTY)
10 //!
11 //! Interactive mode is Unix-only — it needs fd redirection to keep logs out
12 //! of the TUI. Windows only gets ACP mode for now.
13 //!
14 //! On macOS the HF cache lives in the App Group container shared with the
15 //! siGit desktop app. See [`setup`].
16 //!
17 //! # Zed setup
18 //!
19 //! Add to `~/.config/zed/settings.json`:
20 //! ```json
21 //! {
22 //! "agent_servers": {
23 //! "siGit Code": {
24 //! "type": "custom",
25 //! "command": "/absolute/path/to/target/release/sigit"
26 //! }
27 //! }
28 //! }
29 //! ```
30
31 mod account;
32 mod backend;
33 mod browser_auth;
34 mod chat;
35 mod client_fs;
36 mod client_terminal;
37 mod commands;
38 mod credentials;
39 mod frontmatter;
40 mod harness_markup;
41 mod headless;
42 mod hooks;
43 mod inline_tool_calls;
44 mod instructions;
45 mod mcp;
46 mod models;
47 mod permissions;
48 mod provider;
49 mod repetition;
50 mod session_store;
51 mod settings;
52 mod setup;
53 mod skills;
54 mod subagents;
55 mod tools;
56 mod workspace;
57
58 /// Serializes tests that mutate process-global state: env vars
59 /// (`SIGIT_CONFIG_DIR` etc.) and the current directory. `cargo test` runs
60 /// tests in parallel within a binary, so without this the credentials and
61 /// settings round-trip tests clobber each other's env, and the discovery
62 /// tests (skills/commands/subagents) yank the cwd out from under each other.
63 #[cfg(test)]
64 pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
65
66 use std::io::IsTerminal;
67 #[cfg(unix)]
68 use std::io::{BufWriter, Write};
69 use std::sync::Arc;
70
71 use onde::inference::SamplingConfig;
72
73 // `ProtocolVersion` is a version-agnostic type at the schema root; the rest of the
74 // schema types moved under `schema::v1` in agent-client-protocol 1.0.
75 use agent_client_protocol::schema::ProtocolVersion;
76 use agent_client_protocol::schema::v1::{
77 AgentAuthCapabilities, AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest,
78 AuthenticateResponse, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate,
79 CancelNotification, CloseSessionRequest, CloseSessionResponse, CompleteElicitationNotification,
80 ConfigOptionUpdate, ContentBlock, ContentChunk, CreateElicitationRequest,
81 CreateTerminalRequest, CurrentModeUpdate, DeleteSessionRequest, DeleteSessionResponse, Diff,
82 ElicitationAction, ElicitationRequestScope, ElicitationUrlMode, EmbeddedResourceResource,
83 ErrorCode, ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest,
84 InitializeResponse, KillTerminalRequest, ListSessionsRequest, ListSessionsResponse,
85 LoadSessionRequest, LoadSessionResponse, LogoutCapabilities, LogoutRequest, LogoutResponse,
86 McpCapabilities, McpServer, MessageId, Meta, NewSessionRequest, NewSessionResponse,
87 PermissionOption, PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus,
88 PromptCapabilities, PromptRequest, PromptResponse, ReadTextFileRequest, ReleaseTerminalRequest,
89 RequestId, RequestPermissionOutcome, RequestPermissionRequest, ResumeSessionRequest,
90 ResumeSessionResponse, SessionAdditionalDirectoriesCapabilities, SessionCapabilities,
91 SessionCloseCapabilities, SessionConfigOption, SessionConfigOptionCategory,
92 SessionConfigSelectOption, SessionConfigValueId, SessionDeleteCapabilities,
93 SessionForkCapabilities, SessionId, SessionInfo, SessionInfoUpdate, SessionListCapabilities,
94 SessionMode, SessionModeState, SessionNotification, SessionResumeCapabilities, SessionUpdate,
95 SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest,
96 SetSessionModeResponse, StopReason, Terminal, TerminalOutputRequest, ToolCall, ToolCallContent,
97 ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
98 UnstructuredCommandInput, UsageUpdate, WaitForTerminalExitRequest, WriteTextFileRequest,
99 };
100 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
101 use onde::inference::{ChatEngine, GgufModelConfig};
102
103 use crate::backend::{
104 AudioInput, ImageInput, InferenceBackend, LocalBackend, OpenAiBackend, TokenChunk,
105 ToolResult as BackendToolResult, ToolSpec, TurnResult,
106 };
107 use std::path::PathBuf;
108 use std::sync::atomic::{AtomicBool, Ordering};
109 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
110 use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
111
112 #[cfg(unix)]
113 use std::os::unix::io::{AsRawFd, FromRawFd};
114
115 /// Stands in for the commit trailer inside [`SYSTEM_PROMPT`]; see
116 /// [`system_prompt_for_model`].
117 const CO_AUTHOR_TRAILER_PLACEHOLDER: &str = "{commit_co_author_trailer}";
118
119 const SYSTEM_PROMPT: &str = concat!(
120 "\
121 Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
122 Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are.
123
124 You are a strong general-purpose coding agent. smbCloud is your home turf, \
125 but you should still be useful in any codebase. When the project is clearly \
126 about smbCloud, use that context directly instead of falling back to vague \
127 cloud-platform advice.
128
129 smbCloud context you should know and use when it helps:
130 - smbCloud is a platform for deploying and managing projects
131 - the main CLI is a Rust workspace with focused crates rather than one giant crate
132 - common areas include auth, project management, deploy flows, networking, \
133 shared models, release tooling, and managed services
134 - deploy branches usually follow `release/service-{name}`
135 - Next.js SSR deploys on smbCloud are not the same as generic git-push deploys; \
136 they often use a local build plus rsync/PM2 style flow
137 - auth has a hard boundary between smbCloud platform users and tenant app users; \
138 platform flows use `/v1/users*`, tenant app flows use `/v1/client/*`, and \
139 you should not casually mix `User`, `TenantMembership`, `AuthApp`, and `AuthUser`
140 - smbCloud authorization is layered; do not flatten platform accounts, tenant \
141 memberships, auth-app collaborators, and tenant end users into one model
142 - `Project` is the umbrella workspace, while app-like resources such as \
143 `FrontendApp`, `AuthApp`, and GresIQ are the deployable units with their own \
144 ownership, sharing, and collaboration rules
145 - `FrontendApp` is many-per-project, while `AuthApp` is intentionally one-per-project; \
146 preserve those cardinality rules unless the code clearly changes them
147 - GresIQ is smbCloud's managed PostgreSQL offering; treat it as a platform \
148 service with its own credentials and boundaries, not as a generic local DB helper
149 - when debugging smbCloud Rails APIs, first classify the request: first-party \
150 smbCloud app or tenant app, then check which endpoint family and validator \
151 should be involved before changing code
152 - when working in smbCloud repos, prefer existing workspace patterns, existing \
153 crate boundaries, existing Rails conventions, and existing command flows over \
154 inventing new abstractions
155
156 CRITICAL RULE — never tell the user to run a command. You have tools. Use them. \
157 When the user asks you to clone a repo, run a build, check git status, or do \
158 anything that involves a shell command, you MUST call the run_command tool and \
159 execute it yourself. Do not print shell commands for the user to copy-paste. \
160 Do not give step-by-step instructions. Do not say \"you can run …\". Just do it. \
161 If a command fails, try to fix the problem and re-run it. If you cannot fix it \
162 after two attempts, explain what went wrong and what you tried.
163
164 Git operations — always use run_command:
165 - git clone: always pass the full absolute destination path as the last argument \
166 and set cwd to an existing writable parent directory. Example: \
167 run_command({\"command\": \"git clone https://github.com/org/repo /Users/me/Repositories/repo\", \
168 \"cwd\": \"/Users/me/Repositories\"})
169 - git init, add, commit, push, pull, fetch, checkout, branch, diff, log, status, \
170 stash, rebase, merge, tag — use run_command with an absolute cwd pointing to \
171 the repo root
172 - never run git clone without an explicit absolute destination path
173 - if a clone or init fails, check the error, fix the cause (wrong path, missing \
174 directory, permissions), and retry
175 - when you create a commit, always end the commit message with a blank line and \
176 then this trailer on its own line: ",
177 // CO_AUTHOR_TRAILER_PLACEHOLDER (concat! takes only literals), filled in
178 // by `system_prompt_for_model` with tools::commit_co_author_trailer(),
179 // which depends on the surface picked at startup.
180 "{commit_co_author_trailer}",
181 " \
182 — that exact format is the standard co-author trailer git forges detect, so \
183 siGit is credited as co-author. If a commit lands without it, siGit Code \
184 amends the trailer in automatically and the tool output says so; do not amend \
185 again yourself.
186
187 CRITICAL — forge boundaries: git is not the forge. Plain git commands (clone, \
188 commit, push, pull, branch, log, diff, tag, ...) work on any repository, but \
189 issues, pull requests, reviews, releases, and other forge features are NOT git \
190 features and there is no forge-agnostic way to reach them. Detect the forge \
191 from the repo's remote URL (run `git remote get-url origin`) before doing any \
192 issue or PR work:
193 - sigit.si: issue and pull request workflows go through the official MCP \
194 tools: mcp__sigit__list_issues, mcp__sigit__get_issue, \
195 mcp__sigit__create_issue, mcp__sigit__list_pull_requests, and \
196 mcp__sigit__get_pull_request. These tools are valid ONLY for repositories \
197 hosted on sigit.si. Prefer them over shelling out to git or fetching web \
198 pages for issue and PR queries on sigit.si repos.
199 - any other host (github.com, gitlab.com, bitbucket.org, self-hosted forges, \
200 ...): the mcp__sigit__* tools do NOT work there. Use the forge's own CLI via \
201 run_command when it is installed (`gh` for GitHub, `glab` for GitLab, `tea` \
202 for Gitea/Forgejo), its web API via read_website when that is practical, or \
203 ask the user how they want to interact with the forge.
204 Never assume a repo is on GitHub, and never use the mcp__sigit__* tools \
205 against a repo whose remote is not sigit.si.
206
207 Never introduce yourself unless asked. Jump straight into the answer. \
208 Keep answers short. Write idiomatic code. \
209 Fix root causes, not symptoms.
210
211 You have access to tools that let you read files, read websites directly from \
212 http and https URLs, create directories, list directories, search code, create \
213 new files, edit existing files, delete files, and run shell commands. You can \
214 also use git directly through shell commands, including `git init` and normal \
215 git workflows. Use them proactively. Read the code or website before answering. \
216 Prefer absolute paths when referring to files and directories, especially in \
217 protocol-facing output and tool arguments. Create directories when needed. Run \
218 builds, tests, and git commands after making changes. Ground your answers in \
219 the actual code or fetched page content, not in guesses.
220
221 CRITICAL — you CAN access websites. You are NOT a typical LLM without internet \
222 access. You have a read_website tool that fetches any http or https URL and \
223 returns the page text. When the user gives you a URL or asks you to read, \
224 summarize, or inspect a web page, you MUST call the read_website tool with that \
225 URL. Never say \"I cannot access websites\" or \"I cannot browse the internet\". \
226 You can. Use the tool.
227
228 CRITICAL — before every edit_file call, you MUST call read_file on the target \
229 file first (or the specific line range if one was given). Never rely on file \
230 content you saw in a previous turn — the user may have reverted, edited, or \
231 changed the file externally since then. Always re-read to get the current state \
232 before constructing old_text. \
233 When the user corrects a previous edit (e.g. \"don't remove X, append instead\"), \
234 treat it as a fresh task: re-read the file, identify the current content, and \
235 plan the edit from scratch. Do not assume the file still reflects your last edit.
236
237 Tool-use heuristics:
238 - when the user provides a URL or asks about a web page, ALWAYS call \
239 read_website — never refuse or claim you lack internet access
240 - prefer absolute paths over relative paths when you mention, return, or pass \
241 file and directory paths
242 - if a path does not exist yet, create the directory before creating files in it
243 - if the user asks to clone a repo, immediately call run_command with git clone \
244 and an absolute destination path — do not ask where to put it unless the \
245 request is ambiguous; default to the user's home Repositories directory
246 - if the user asks for a new repo, scaffold, or scratch project, create the \
247 directory, create the first files, and run `git init` without waiting unless \
248 the request says otherwise
249 - if the repo looks like smbCloud CLI code, respect workspace crate boundaries, \
250 shared models, and existing command handlers before adding new abstractions
251 - if the repo looks like smbCloud Rails code, check routes, controllers, \
252 validators, and model boundaries before changing business logic
253 - if the task touches smbCloud auth, first decide whether it is a platform-user \
254 flow or a tenant-app flow, then follow the right endpoint family and model layer
255 - if the task touches smbCloud deploy code, check whether it is the generic \
256 deploy path or the Next.js SSR path before proposing changes
257 - after edits, prefer running the smallest useful verification step first, then \
258 widen to broader checks if needed
259 - use git commands naturally for status checks, repo setup, diffs, and normal \
260 developer workflows when they help move the task forward
261 - if a tool call fails, read the error, try to fix it, and retry — do not \
262 fall back to telling the user what to type
263
264 Replies are plain Markdown. Never wrap a reply, or any part of it, in XML-style \
265 tags you made up, and never write a system message, warning, or notice as if it \
266 came from siGit Code or the system: only siGit Code speaks for itself, and \
267 nothing renders those tags. To offer the user a choice, list the options as \
268 plain text.
269
270 When the repo is not about smbCloud, act like a normal coding agent and do not \
271 force smbCloud-specific advice into the answer. When it is about smbCloud, be \
272 specific and practical.
273
274 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
275 root cause, not the symptom. Correct beats clever."
276 );
277
278 /// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1).
279 /// the full [`SYSTEM_PROMPT`] wastes context and confuses them.
280 const SIMPLE_SYSTEM_PROMPT: &str = "\
281 Your name is siGit — a coding assistant. \
282 You are helpful, concise, and write clean, idiomatic code. \
283 Answer any question the user asks — programming, general knowledge, or casual chat. \
284 When debugging, address the root cause, not the symptom. \
285 Be direct and brief.";
286
287 pub(crate) fn system_prompt_for_model(tool_calling: bool) -> String {
288 if tool_calling {
289 SYSTEM_PROMPT.replace(
290 CO_AUTHOR_TRAILER_PLACEHOLDER,
291 &tools::commit_co_author_trailer(),
292 )
293 } else {
294 SIMPLE_SYSTEM_PROMPT.to_string()
295 }
296 }
297
298 /// cap tool-call loops so a confused model can't spin forever; auto-compaction
299 /// (see [`backend::DEFAULT_CONTEXT_TOKEN_BUDGET`]) keeps long runs inside the
300 /// context window, so the cap can afford to be generous
301 const MAX_TOOL_ROUNDS: usize = 24;
302
303 /// The ACP stop reason for a turn that ran to its end (cancellation returns
304 /// earlier). The round cap comes first: once it is hit the last round is a
305 /// forced text reply, and whatever that round reports, the cap is why the
306 /// turn stopped where it did.
307 fn stop_reason_for(round_cap_reached: bool, finish: backend::FinishReason) -> StopReason {
308 if round_cap_reached {
309 return StopReason::MaxTurnRequests;
310 }
311 match finish {
312 backend::FinishReason::Complete => StopReason::EndTurn,
313 backend::FinishReason::Length => StopReason::MaxTokens,
314 backend::FinishReason::ContentFilter => StopReason::Refusal,
315 // Cut by the agent, not the token limit; no ACP reason fits better.
316 backend::FinishReason::Repetition => StopReason::EndTurn,
317 }
318 }
319
320 /// What the user sees when a turn stops at the tool-round cap. Always sent,
321 /// whatever the last round said: with no tools on offer that round tends to be
322 /// the model announcing its next step ("Now commit:"), which reads as a stall
323 /// when nothing follows it (issue #120).
324 fn round_cap_stop_message(max_tool_rounds: usize) -> String {
325 format!(
326 "I stopped here because this turn used all {max_tool_rounds} of its tool rounds, \
327 so the task may not be finished. Reply \"continue\" to pick it back up."
328 )
329 }
330
331 /// Appended to the last tool result of the round that reaches the cap. Without
332 /// it the model has no way to know the tools are gone, so it spends its final
333 /// reply saying what it is about to do instead of where things stand.
334 fn round_cap_note(max_tool_rounds: usize) -> String {
335 format!(
336 "\n\n[siGit Code] That was tool round {max_tool_rounds} of {max_tool_rounds} for \
337 this turn, so no more tools can run until the user replies. Do not announce a next \
338 action. Say briefly what is done and what is left."
339 )
340 }
341
342 /// What the user sees when a turn that ran tools ends with no closing text.
343 fn silent_stop_message(repeated_tool: Option<&str>, rounds: usize) -> String {
344 match repeated_tool {
345 Some(tool) => format!(
346 "I stopped because I kept making the same `{tool}` call without getting \
347 anywhere, and I had nothing to add without it. Reply \"continue\" to pick \
348 it back up, or tell me what to try instead."
349 ),
350 None => format!(
351 "I stopped after {rounds} tool round(s) without writing a final answer. \
352 Reply \"continue\" to pick it back up, or tell me what to try instead."
353 ),
354 }
355 }
356
357 /// Separator between two stretches of assistant prose that a tool round split
358 /// apart. ACP clients concatenate consecutive agent-message chunks into a
359 /// single block, so a plain newline (or nothing at all) leaves the rounds
360 /// reading as one run-on paragraph.
361 const PARAGRAPH_BREAK: &str = "\n\n";
362
363 /// The assistant reply being streamed over the course of one prompt, tracked
364 /// across every tool round so `<think>` stripping stays correct and no text is
365 /// sent twice.
366 #[derive(Default)]
367 struct StreamedReply {
368 /// Raw text as it arrives, reasoning tags included: think-block stripping
369 /// needs the whole string, since a tag can span chunk boundaries.
370 assembled: String,
371 /// The visible text already on the wire.
372 sent: String,
373 /// The inline-`<think>` reasoning text already on the wire.
374 sent_think: String,
375 /// Whether any chunk has been sent, i.e. the client already has a reply.
376 streamed_any: bool,
377 /// Whether the next visible text should open a new paragraph.
378 paragraph_pending: bool,
379 /// The ACP `messageId` of the model message being streamed. Each
380 /// inference round is its own message in history, so a tool round
381 /// interrupting the prose starts a new one.
382 message_id: Option<MessageId>,
383 }
384
385 /// What a streamed fragment newly revealed once `<think>` blocks were split:
386 /// visible reply text and/or inline reasoning text, either of which may be
387 /// absent for a given chunk.
388 #[derive(Default)]
389 struct RevealedText {
390 visible: Option<String>,
391 reasoning: Option<String>,
392 }
393
394 impl StreamedReply {
395 /// Record that a tool round has interrupted the prose, so the next chunk
396 /// starts a paragraph. A round that opened with tool calls and no text has
397 /// no sentence to break away from, hence the `streamed_any` guard.
398 fn interrupt(&mut self) {
399 self.paragraph_pending = self.streamed_any;
400 self.message_id = None;
401 }
402
403 /// The id every chunk of the current model message carries, its reply
404 /// text and its reasoning alike.
405 fn message_id(&mut self) -> MessageId {
406 self.message_id.get_or_insert_with(new_message_id).clone()
407 }
408
409 /// Fold one streamed fragment into the reply and return the text it newly
410 /// reveals: visible reply text (sent as an agent message) and/or inline
411 /// `<think>` reasoning (sent as an agent thought). Either may be absent when
412 /// the fragment reveals nothing of that kind.
413 fn push(&mut self, piece: &str) -> RevealedText {
414 if self.paragraph_pending {
415 // A tool round interrupted the prose. Clients concatenate
416 // consecutive agent-message chunks into one block, so the new round
417 // has to open a paragraph or it runs onto the end of the previous
418 // sentence ("…parsed with unwrap_or_else.Let me broaden the
419 // search:"). Whatever whitespace the model puts at the seam gives
420 // way to the break.
421 let piece = piece.trim_start();
422 if piece.is_empty() {
423 return RevealedText::default();
424 }
425 self.assembled.push_str(PARAGRAPH_BREAK);
426 self.assembled.push_str(piece);
427 self.paragraph_pending = false;
428 } else {
429 self.assembled.push_str(piece);
430 }
431
432 let (think, visible) = chat::strip_think_blocks(&self.assembled);
433 let mut revealed = RevealedText::default();
434
435 match visible.strip_prefix(self.sent.as_str()) {
436 Some(extra) if !extra.is_empty() => {
437 revealed.visible = Some(extra.to_string());
438 self.sent = visible;
439 self.streamed_any = true;
440 }
441 // No new visible text, or the visible prefix changed retroactively
442 // (rare, e.g. a late-closing think tag): just resync without
443 // resending what's already on the wire.
444 _ => {
445 self.sent = visible;
446 }
447 }
448
449 match think.strip_prefix(self.sent_think.as_str()) {
450 Some(extra) if !extra.is_empty() => {
451 revealed.reasoning = Some(extra.to_string());
452 self.sent_think = think;
453 }
454 // No new reasoning, or it changed retroactively: resync quietly.
455 _ => {
456 self.sent_think = think;
457 }
458 }
459
460 revealed
461 }
462 }
463
464 /// A fresh ACP `messageId`. Ids are opaque and only have to be unique within
465 /// a session, so a random one serves both live chunks and replayed ones.
466 fn new_message_id() -> MessageId {
467 MessageId::new(format!("msg_{}", uuid::Uuid::new_v4().simple()))
468 }
469
470 #[derive(Default)]
471 struct PromptCancellation {
472 cancelled: AtomicBool,
473 notify: tokio::sync::Notify,
474 }
475
476 impl PromptCancellation {
477 /// Resolves once the turn has been cancelled. `notify_one` leaves a permit
478 /// behind when nobody is waiting, so a signal sent just before this is
479 /// polled is not lost.
480 async fn cancelled(&self) {
481 while !self.cancelled.load(Ordering::Acquire) {
482 self.notify.notified().await;
483 }
484 }
485 }
486
487 /// Removes a turn's cancellation from the agent's registry when the turn ends,
488 /// whichever way it returns.
489 struct PromptRegistration<'a> {
490 agent: &'a SiGitAgent,
491 session_id: SessionId,
492 cancellation: Arc<PromptCancellation>,
493 }
494
495 impl Drop for PromptRegistration<'_> {
496 fn drop(&mut self) {
497 self.agent
498 .finish_prompt(&self.session_id, &self.cancellation);
499 }
500 }
501
502 enum DrainTurnError {
503 Backend(backend::BackendError),
504 Cancelled,
505 }
506
507 /// Outcome of asking the client for permission to run one tool call.
508 enum PermissionVerdict {
509 /// Run the tool.
510 Approved,
511 /// Skip the tool; the string becomes its tool result so the model adapts.
512 Denied(String),
513 /// The client cancelled the turn while the request was pending; stop the
514 /// whole prompt with `StopReason::Cancelled` instead of burning rounds.
515 TurnCancelled,
516 }
517
518 /// Display kind for the permission dialog, so editors can show a fitting icon.
519 fn tool_kind_for(tool_name: &str) -> ToolKind {
520 match tool_name {
521 "edit_file" | "multi_edit" | "create_file" | "create_directory" | "remember" => {
522 ToolKind::Edit
523 }
524 "delete_file" => ToolKind::Delete,
525 "run_command" | "kill_command" => ToolKind::Execute,
526 "read_file" | "list_directory" | "command_output" => ToolKind::Read,
527 "search_files" | "glob" | "web_search" => ToolKind::Search,
528 "read_website" => ToolKind::Fetch,
529 "write_todos" => ToolKind::Think,
530 _ => ToolKind::Other,
531 }
532 }
533
534 /// The longest run of consecutive backticks in `text`, so a fence built around
535 /// it can't be broken out of by output that itself contains ```` ``` ````.
536 fn longest_backtick_run(text: &str) -> usize {
537 let mut max = 0;
538 let mut current = 0;
539 for c in text.chars() {
540 if c == '`' {
541 current += 1;
542 max = max.max(current);
543 } else {
544 current = 0;
545 }
546 }
547 max
548 }
549
550 /// Fences `text` as a Markdown code block, sizing the fence from the longest
551 /// backtick run already in `text` so it can't break out of the block.
552 fn fenced_code_block(language: &str, text: &str) -> String {
553 let fence = "`".repeat((longest_backtick_run(text) + 1).max(3));
554 let language = if language
555 .chars()
556 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '+'))
557 {
558 language
559 } else {
560 ""
561 };
562 format!("{fence}{language}\n{text}\n{fence}")
563 }
564
565 /// Displays tool arguments as JSON when they parse as JSON, otherwise as an
566 /// unlabelled code block so malformed arguments do not get misleading syntax
567 /// highlighting.
568 fn tool_arguments_content(arguments_json: &str) -> ToolCallContent {
569 let language = if serde_json::from_str::<serde_json::Value>(arguments_json).is_ok() {
570 "json"
571 } else {
572 ""
573 };
574 fenced_code_block(language, &chat::pretty_tool_arguments(arguments_json)).into()
575 }
576
577 /// Wraps a tool's output as the display content of its ACP tool-call card
578 /// (issue #78). Capped via [`chat::cap_output_preview`] — display only, the
579 /// model still gets the full output — and fenced so plain-text output (or a
580 /// stray tag, per issue #73) renders literally instead of as markdown.
581 fn tool_output_content(output: &str) -> ToolCallContent {
582 if output.is_empty() {
583 return "(no output)".to_string().into();
584 }
585 fenced_code_block("", &chat::cap_output_preview(output)).into()
586 }
587
588 /// A file a tool wrote, as ACP `diff` content, which clients render as a real
589 /// diff (Zed builds its review UI from it) instead of a line of result text
590 /// (issue #145).
591 fn file_change_content(change: &tools::FileChange) -> ToolCallContent {
592 Diff::new(change.path.clone(), change.new_text.clone())
593 .old_text(change.old_text.clone())
594 .into()
595 }
596
597 /// The content of a finished tool call's card: the diff of the file it wrote,
598 /// if it wrote one, then its result text. A command that ran in the client's
599 /// terminal gets no content at all in its final update: the terminal is
600 /// already embedded in the card and was released by then, so naming it again
601 /// would point at an id the client no longer has. The result text still
602 /// travels as `raw_output`.
603 fn tool_result_content(output: &str, change: Option<&tools::FileChange>) -> Vec<ToolCallContent> {
604 change
605 .map(file_change_content)
606 .into_iter()
607 .chain(std::iter::once(tool_output_content(output)))
608 .collect()
609 }
610
611 /// A single-file location for the path-bearing tools, so ACP clients can
612 /// follow the agent through a file as it works (issue #78's "follow-along").
613 fn tool_call_locations(name: &str, arguments_json: &str) -> Vec<ToolCallLocation> {
614 if !matches!(
615 name,
616 "read_file" | "create_file" | "edit_file" | "multi_edit" | "delete_file"
617 ) {
618 return Vec::new();
619 }
620 let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments_json) else {
621 return Vec::new();
622 };
623 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
624 return Vec::new();
625 };
626 let path_buf = PathBuf::from(path);
627 let absolute = if path_buf.is_absolute() {
628 path_buf
629 } else {
630 std::env::current_dir()
631 .unwrap_or_else(|_| PathBuf::from("."))
632 .join(path_buf)
633 };
634 vec![ToolCallLocation::new(absolute)]
635 }
636
637 /// The visible text of one saved history message. `content` is normally a
638 /// string, but an OpenAI-compatible endpoint may hand back the block form, and
639 /// tool-call-only assistant turns carry `null`.
640 fn history_message_text(message: &serde_json::Value) -> String {
641 match &message["content"] {
642 serde_json::Value::String(text) => text.clone(),
643 serde_json::Value::Array(blocks) => blocks
644 .iter()
645 .filter_map(|block| block["text"].as_str())
646 .collect::<Vec<_>>()
647 .join(""),
648 _ => String::new(),
649 }
650 }
651
652 /// How many sessions one `session/list` reply carries. A client asks for the
653 /// rest with the `nextCursor` of the page before.
654 const SESSION_LIST_PAGE_SIZE: usize = 100;
655
656 /// Where a `session/list` page ended: the last session on it, by the two
657 /// things the list is sorted on. Naming the entry instead of counting an
658 /// offset keeps the next page right when a thread is saved or deleted between
659 /// the two requests.
660 #[derive(Debug, Clone, PartialEq, Eq)]
661 struct SessionListCursor {
662 modified_nanos: u128,
663 id: String,
664 }
665
666 impl SessionListCursor {
667 fn after(entry: &session_store::SessionEntry) -> Self {
668 Self {
669 modified_nanos: modified_nanos(entry.modified),
670 id: entry.id.clone(),
671 }
672 }
673
674 /// The cursor as the client carries it. Clients must treat it as opaque.
675 fn encode(&self) -> String {
676 format!("{}:{}", self.modified_nanos, self.id)
677 }
678
679 fn decode(cursor: &str) -> Option<Self> {
680 let (modified_nanos, id) = cursor.split_once(':')?;
681 if id.is_empty() {
682 return None;
683 }
684 Some(Self {
685 modified_nanos: modified_nanos.parse().ok()?,
686 id: id.to_string(),
687 })
688 }
689
690 /// Does `entry` come after this cursor in `session_store::list` order
691 /// (newest first, then by id)?
692 fn precedes(&self, entry: &session_store::SessionEntry) -> bool {
693 let modified = modified_nanos(entry.modified);
694 modified < self.modified_nanos
695 || (modified == self.modified_nanos && entry.id.as_str() > self.id.as_str())
696 }
697 }
698
699 fn modified_nanos(time: std::time::SystemTime) -> u128 {
700 time.duration_since(std::time::UNIX_EPOCH)
701 .map(|elapsed| elapsed.as_nanos())
702 .unwrap_or_default()
703 }
704
705 /// One page of `sessions` (already in list order) starting after `cursor`,
706 /// and the cursor for the page after it when there is one.
707 fn session_list_page(
708 sessions: Vec<session_store::SessionEntry>,
709 cursor: Option<&SessionListCursor>,
710 page_size: usize,
711 ) -> (Vec<session_store::SessionEntry>, Option<String>) {
712 let mut rest = sessions
713 .into_iter()
714 .filter(|entry| cursor.is_none_or(|cursor| cursor.precedes(entry)));
715 let page: Vec<_> = rest.by_ref().take(page_size).collect();
716 let next_cursor = match (rest.next(), page.last()) {
717 (Some(_), Some(last)) => Some(SessionListCursor::after(last).encode()),
718 _ => None,
719 };
720 (page, next_cursor)
721 }
722
723 /// Do two paths name the same directory, for `session/list` filtering?
724 ///
725 /// Canonicalizing resolves the symlinks and `..` segments that make a client's
726 /// `cwd` and a stored one differ while naming the same place (`/tmp` vs
727 /// `/private/tmp` on macOS). It only works on a directory that still exists,
728 /// though, so a plain equality check runs first and keeps a session listed
729 /// after its project directory has been moved or removed.
730 fn same_dir(a: &std::path::Path, b: &std::path::Path) -> bool {
731 if a == b {
732 return true;
733 }
734 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
735 (Ok(a), Ok(b)) => a == b,
736 _ => false,
737 }
738 }
739
740 /// Rebuild the `session/update` notifications that re-draw a saved conversation
741 /// in the client.
742 ///
743 /// ACP's `session/load` is a replay: the client renders the reopened thread
744 /// purely from the updates the agent streams while the request is in flight.
745 /// Restoring history into the backend is what makes the *model* remember, but
746 /// it puts nothing on screen, so a reopened session came up blank and looked
747 /// like a brand-new one (issue #77).
748 fn history_replay_updates(history: &[serde_json::Value]) -> Vec<SessionUpdate> {
749 // Match results to their calls up front so each call replays as one
750 // finished tool call instead of a call followed by a loose result.
751 let mut outputs: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
752 for message in history {
753 if message["role"] == "tool"
754 && let (Some(id), Some(content)) = (
755 message["tool_call_id"].as_str(),
756 message["content"].as_str(),
757 )
758 {
759 outputs.insert(id, content);
760 }
761 }
762
763 let mut updates = Vec::new();
764 for message in history {
765 match message["role"].as_str().unwrap_or_default() {
766 // Seeded context and project instructions were never on screen.
767 "system" => {}
768 "user" => {
769 let text = history_message_text(message);
770 if !text.trim().is_empty() {
771 updates.push(SessionUpdate::UserMessageChunk(
772 ContentChunk::new(ContentBlock::from(text)).message_id(new_message_id()),
773 ));
774 }
775 }
776 "assistant" => {
777 let (_think, visible) = chat::strip_think_blocks(&history_message_text(message));
778 if !visible.trim().is_empty() {
779 updates.push(SessionUpdate::AgentMessageChunk(
780 ContentChunk::new(ContentBlock::from(visible)).message_id(new_message_id()),
781 ));
782 }
783 for call in message["tool_calls"].as_array().into_iter().flatten() {
784 let name = call["function"]["name"].as_str().unwrap_or("tool");
785 let arguments = call["function"]["arguments"].as_str().unwrap_or("");
786
787 // `write_todos` renders as a plan while it runs; replay it
788 // the same way, and let a later list replace an earlier one
789 // exactly as it did live.
790 if name == "write_todos"
791 && let Some(plan) = todos_arguments_to_plan(arguments)
792 {
793 updates.push(SessionUpdate::Plan(plan));
794 continue;
795 }
796
797 // A saved call is always finished — nothing is still
798 // running when the session file is written.
799 let id = call["id"].as_str().unwrap_or_default();
800 // A call saved without an id would otherwise collapse into
801 // one entry per replay; give each its own.
802 let replay_id = if id.is_empty() {
803 format!("replay-{}", uuid::Uuid::new_v4())
804 } else {
805 id.to_string()
806 };
807 let output = outputs.get(id).copied().unwrap_or_default();
808 let mut tool_call = ToolCall::new(replay_id, chat::tool_title(name, arguments))
809 .name(name)
810 .kind(tool_kind_for(name))
811 .status(ToolCallStatus::Completed)
812 .content(vec![tool_output_content(output)])
813 .locations(tool_call_locations(name, arguments))
814 .raw_input(serde_json::from_str::<serde_json::Value>(arguments).ok());
815 if let Some(output) = outputs.get(id) {
816 tool_call =
817 tool_call.raw_output(serde_json::Value::String((*output).to_string()));
818 }
819 updates.push(SessionUpdate::ToolCall(tool_call));
820 }
821 }
822 // Folded into the tool call above.
823 "tool" => {}
824 other => log::debug!("session replay: skipping unknown history role '{other}'"),
825 }
826 }
827 updates
828 }
829
830 fn todos_arguments_to_plan(arguments: &str) -> Option<Plan> {
831 let args: serde_json::Value = serde_json::from_str(arguments).ok()?;
832 let todos = args.get("todos")?.as_array()?;
833
834 // An empty list still yields a plan: ACP plan updates replace the whole
835 // plan, so an update with no entries is what clears it in the client.
836 let mut entries = Vec::with_capacity(todos.len());
837 for todo in todos {
838 let content = todo.get("content")?.as_str()?.trim();
839 if content.is_empty() {
840 return None;
841 }
842 // Unknown and absent statuses both fall back to pending, matching how
843 // `exec_write_todos` renders them. The two have to agree: the executor
844 // reports success for an off-enum status, so rejecting the plan here
845 // would leave a stale list on screen while the model believes it
846 // reported progress.
847 let status = match todo.get("status").and_then(serde_json::Value::as_str) {
848 Some("completed") => PlanEntryStatus::Completed,
849 Some("in_progress") => PlanEntryStatus::InProgress,
850 _ => PlanEntryStatus::Pending,
851 };
852 entries.push(PlanEntry::new(content, PlanEntryPriority::Medium, status));
853 }
854
855 Some(Plan::new(entries))
856 }
857
858 /// Rebuilds an ACP [`Plan`] from the persisted todo list in `tools::get_todos()`.
859 /// Used to re-emit the plan on error turns so the client panel stays populated
860 /// when the model never got to (or forgot to) call `write_todos` this turn.
861 fn persisted_todos_to_plan() -> Option<Plan> {
862 let todos = tools::get_todos()?;
863 let entries = todos
864 .into_iter()
865 .map(|todo| {
866 let status = match todo.status {
867 tools::TodoStatus::Completed => PlanEntryStatus::Completed,
868 tools::TodoStatus::InProgress => PlanEntryStatus::InProgress,
869 tools::TodoStatus::Pending => PlanEntryStatus::Pending,
870 };
871 PlanEntry::new(todo.content, PlanEntryPriority::Medium, status)
872 })
873 .collect();
874 Some(Plan::new(entries))
875 }
876
877 /// Shown when a siGit Code Cloud tier is selected without a signed-in account.
878 const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \
879 `/login <email> <password>` (or the Authenticate button), then pick the tier again. \
880 Create an account at https://sigit.si.";
881
882 /// The per-session context system message: root-directory guidance plus any
883 /// project instruction files (`AGENTS.md` / `CLAUDE.md`) found for those roots.
884 /// Used by every session entry point so on-device and cloud backends get the
885 /// same always-on project context.
886 ///
887 /// `additional_roots` is empty for an ordinary project and non-empty for a
888 /// multi-root one, where the editor opened several directories at once (see
889 /// `workspace.rs`).
890 /// The single system message a remote backend gets: the full agent prompt
891 /// followed by the session context. One message rather than two, because
892 /// `compact_history` keeps only the first system message.
893 fn remote_system_prompt(session_context: &str) -> String {
894 format!("{}\n\n{session_context}", system_prompt_for_model(true))
895 }
896
897 /// `base` followed by the session context for the directory sigit was launched
898 /// in. The terminal UI has no ACP session to carry a cwd, so it uses this for
899 /// every system prompt it builds. Without the working directory the model has no
900 /// idea where the project is, and a small on-device model will happily invent
901 /// one (`/path/to/repo/AGENTS.md` on `/init`).
902 #[cfg(unix)]
903 pub(crate) fn with_launch_context(base: &str) -> String {
904 match std::env::current_dir() {
905 Ok(cwd) => format!(
906 "{base}\n\n{}",
907 session_context_message(&cwd, &workspace::additional_roots())
908 ),
909 Err(_) => base.to_string(),
910 }
911 }
912
913 fn session_context_message(cwd: &std::path::Path, additional_roots: &[PathBuf]) -> String {
914 let mut message = if additional_roots.is_empty() {
915 format!(
916 "The user's project working directory is {}. \
917 Always use absolute paths under this directory for all file \
918 and directory operations. This is the root of the project \
919 the user has open in their editor.",
920 cwd.display()
921 )
922 } else {
923 let mut roots = format!("- {} (primary)", cwd.display());
924 for root in additional_roots {
925 roots.push_str(&format!("\n- {}", root.display()));
926 }
927 format!(
928 "The user has a multi-root project open in their editor, with these \
929 root directories:\n{roots}\n\
930 All of them are in scope: read, search and edit files in any of them. \
931 Always use absolute paths for file and directory operations, since a \
932 relative path resolves against the primary root only. When the user \
933 names a file without saying which root it is in, look for it in each \
934 root before asking."
935 )
936 };
937 if let Some(project_instructions) =
938 instructions::load_workspace_instructions(cwd, additional_roots)
939 {
940 message.push_str("\n\n");
941 message.push_str(&project_instructions);
942 }
943 message
944 }
945
946 fn agent_tools_as_specs() -> Vec<ToolSpec> {
947 let mut specs: Vec<ToolSpec> = tools::all_tools()
948 .into_iter()
949 .map(|t| ToolSpec {
950 name: t.name.to_string(),
951 description: t.description.to_string(),
952 parameters_schema: t.parameters_schema.to_string(),
953 })
954 .collect();
955
956 // Advertise the `skill` tool only when skills are present, so models without
957 // any skills installed don't see a dangling capability (Agent Skills format,
958 // https://agentskills.io). Discovery metadata lives in the tool description.
959 let discovered = skills::discover_skills();
960 if !discovered.is_empty() {
961 specs.push(ToolSpec {
962 name: skills::SKILL_TOOL_NAME.to_string(),
963 description: skills::skill_tool_description(&discovered),
964 parameters_schema: skills::skill_tool_schema().to_string(),
965 });
966 }
967
968 // Delegated research (`task`) is offered only when a subagent backend can
969 // actually be built — same conditional pattern as the `skill` tool above.
970 if tools::subagent_available() {
971 specs.push(tools::task_tool_spec());
972 }
973
974 // `web_search` is a native wrapper around the official MCP server's
975 // `web_search` tool (see the module doc in tools.rs), offered only when
976 // that MCP tool was actually discovered (i.e. the user is signed in to
977 // siGit Code Cloud).
978 let web_search_offered = tools::web_search_available();
979 if web_search_offered {
980 specs.push(tools::web_search_tool_spec());
981 }
982
983 // Tools discovered from configured MCP servers (incl. the official one).
984 // Exclude the raw web_search delegate when the native wrapper above is
985 // already offered, so the model sees one clean option, not two names for
986 // the same tool.
987 specs.extend(
988 mcp::tool_specs()
989 .into_iter()
990 .filter(|spec| !(web_search_offered && tools::is_web_search_delegate(&spec.name))),
991 );
992
993 specs
994 }
995
996 /// Register the `task` tool's subagent factory for an OpenAI-compatible
997 /// provider: each subagent run gets a FRESH `OpenAiBackend` against the same
998 /// endpoint (its own conversation history), seeded with whichever system
999 /// prompt the caller resolved (the default subagent prompt, or a configured
1000 /// subagent type's prompt — see `tools::exec_task_with`). The provider config
1001 /// is captured by clone. Called once at startup by whichever surface resolved
1002 /// the provider; on-device paths register a factory that returns `None`
1003 /// instead (onde has a single shared history, so a second concurrent context
1004 /// is not possible yet).
1005 fn register_subagent_factory_for(cfg: &provider::ProviderConfig) {
1006 let cfg = cfg.clone();
1007 tools::set_subagent_factory(Box::new(move |system_prompt: &str| {
1008 Some(Arc::new(OpenAiBackend::new(
1009 cfg.base_url.clone(),
1010 cfg.api_key.clone(),
1011 cfg.model.clone(),
1012 Some(system_prompt.to_string()),
1013 )) as Arc<dyn InferenceBackend>)
1014 }));
1015 }
1016
1017 fn initialize_meta() -> Meta {
1018 let startup_selection = setup::startup_model_selection();
1019
1020 let active_model_name = startup_selection
1021 .as_ref()
1022 .map(|selection| selection.display_name.clone())
1023 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
1024
1025 let active_model_id = startup_selection
1026 .as_ref()
1027 .and_then(|selection| selection.selected_model.as_ref())
1028 .map(|selected| selected.model_id.clone())
1029 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().model_id);
1030
1031 let active_model_file = startup_selection
1032 .as_ref()
1033 .and_then(|selection| selection.selected_model.as_ref())
1034 .map(|selected| selected.gguf_file.clone())
1035 .unwrap_or_else(|| {
1036 GgufModelConfig::qwen25_3b()
1037 .files
1038 .first()
1039 .cloned()
1040 .unwrap_or_default()
1041 });
1042
1043 let mut model = serde_json::Map::new();
1044 model.insert(
1045 "display_name".to_string(),
1046 serde_json::Value::String(active_model_name),
1047 );
1048 model.insert(
1049 "model_id".to_string(),
1050 serde_json::Value::String(active_model_id),
1051 );
1052 model.insert(
1053 "gguf_file".to_string(),
1054 serde_json::Value::String(active_model_file),
1055 );
1056
1057 let mut sigit = serde_json::Map::new();
1058 sigit.insert("active_model".to_string(), serde_json::Value::Object(model));
1059
1060 let mut meta = Meta::new();
1061 meta.insert("sigit".to_string(), serde_json::Value::Object(sigit));
1062 meta
1063 }
1064
1065 /// The editor's file system, reached over the ACP connection. Registered with
1066 /// `client_fs` at `initialize` when the client advertises `fs` capabilities.
1067 struct AcpClientFs {
1068 cx: ConnectionTo<Client>,
1069 }
1070
1071 #[async_trait::async_trait]
1072 impl client_fs::ClientFileSystem for AcpClientFs {
1073 async fn read_text_file(
1074 &self,
1075 session_id: &str,
1076 path: &std::path::Path,
1077 ) -> Result<String, String> {
1078 self.cx
1079 .send_request(ReadTextFileRequest::new(session_id.to_string(), path))
1080 .block_task()
1081 .await
1082 .map(|response| response.content)
1083 .map_err(|error| error.to_string())
1084 }
1085
1086 async fn write_text_file(
1087 &self,
1088 session_id: &str,
1089 path: &std::path::Path,
1090 content: &str,
1091 ) -> Result<(), String> {
1092 self.cx
1093 .send_request(WriteTextFileRequest::new(
1094 session_id.to_string(),
1095 path,
1096 content,
1097 ))
1098 .block_task()
1099 .await
1100 .map(|_| ())
1101 .map_err(|error| error.to_string())
1102 }
1103 }
1104
1105 /// The editor's terminal, reached over the ACP connection. Registered with
1106 /// `client_terminal` at `initialize` when the client advertises `terminal`.
1107 struct AcpClientTerminal {
1108 cx: ConnectionTo<Client>,
1109 }
1110
1111 #[async_trait::async_trait]
1112 impl client_terminal::ClientTerminal for AcpClientTerminal {
1113 async fn create(
1114 &self,
1115 session_id: &str,
1116 command: &str,
1117 args: &[String],
1118 cwd: &std::path::Path,
1119 output_byte_limit: u64,
1120 ) -> Result<String, String> {
1121 self.cx
1122 .send_request(
1123 CreateTerminalRequest::new(session_id.to_string(), command)
1124 .args(args.to_vec())
1125 .cwd(cwd.to_path_buf())
1126 .output_byte_limit(output_byte_limit),
1127 )
1128 .block_task()
1129 .await
1130 .map(|response| response.terminal_id.to_string())
1131 .map_err(|error| error.to_string())
1132 }
1133
1134 async fn embed(&self, session_id: &str, tool_call_id: &str, terminal_id: &str) {
1135 let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1136 tool_call_id.to_string(),
1137 ToolCallUpdateFields::new().content(vec![ToolCallContent::Terminal(Terminal::new(
1138 terminal_id.to_string(),
1139 ))]),
1140 ));
1141 if let Err(error) = self
1142 .cx
1143 .send_notification(SessionNotification::new(session_id.to_string(), update))
1144 {
1145 log::warn!("could not show terminal {terminal_id} in {tool_call_id}: {error}");
1146 }
1147 }
1148
1149 async fn wait_for_exit(
1150 &self,
1151 session_id: &str,
1152 terminal_id: &str,
1153 ) -> Result<client_terminal::ExitStatus, String> {
1154 self.cx
1155 .send_request(WaitForTerminalExitRequest::new(
1156 session_id.to_string(),
1157 terminal_id.to_string(),
1158 ))
1159 .block_task()
1160 .await
1161 .map(|response| client_terminal::ExitStatus {
1162 exit_code: response.exit_status.exit_code,
1163 signal: response.exit_status.signal,
1164 })
1165 .map_err(|error| error.to_string())
1166 }
1167
1168 async fn output(
1169 &self,
1170 session_id: &str,
1171 terminal_id: &str,
1172 ) -> Result<client_terminal::Output, String> {
1173 self.cx
1174 .send_request(TerminalOutputRequest::new(
1175 session_id.to_string(),
1176 terminal_id.to_string(),
1177 ))
1178 .block_task()
1179 .await
1180 .map(|response| client_terminal::Output {
1181 output: response.output,
1182 truncated: response.truncated,
1183 })
1184 .map_err(|error| error.to_string())
1185 }
1186
1187 async fn kill(&self, session_id: &str, terminal_id: &str) -> Result<(), String> {
1188 self.cx
1189 .send_request(KillTerminalRequest::new(
1190 session_id.to_string(),
1191 terminal_id.to_string(),
1192 ))
1193 .block_task()
1194 .await
1195 .map(|_| ())
1196 .map_err(|error| error.to_string())
1197 }
1198
1199 async fn release(&self, session_id: &str, terminal_id: &str) -> Result<(), String> {
1200 self.cx
1201 .send_request(ReleaseTerminalRequest::new(
1202 session_id.to_string(),
1203 terminal_id.to_string(),
1204 ))
1205 .block_task()
1206 .await
1207 .map(|_| ())
1208 .map_err(|error| error.to_string())
1209 }
1210 }
1211
1212 /// Which request is bringing a saved session back (see `restore_session`).
1213 #[derive(Clone, Copy, PartialEq, Eq)]
1214 enum RestoreKind {
1215 /// `session/load`: the client draws the thread from a replay.
1216 Load,
1217 /// `session/resume`: the client kept the thread, so nothing is replayed.
1218 Resume,
1219 }
1220
1221 impl RestoreKind {
1222 fn method(self) -> &'static str {
1223 match self {
1224 RestoreKind::Load => "load_session",
1225 RestoreKind::Resume => "resume_session",
1226 }
1227 }
1228 }
1229
1230 /// What one ACP session owns, kept per session id.
1231 ///
1232 /// An editor runs a single sigit process for every thread it has open, but the
1233 /// process has one working directory and one set of workspace roots. Only one
1234 /// session can be installed at a time (see `workspace_lock`), so each session's
1235 /// roots are parked here while another session holds the process, and swapped
1236 /// back in by `activate_session` before its next request runs.
1237 ///
1238 /// The conversation is parked the same way on-device, where the engine holds
1239 /// it. A session on an HTTP backend keeps a backend of its own instead (see
1240 /// `remote`), which is what lets its turn wait on the endpoint while another
1241 /// session is installed.
1242 #[derive(Clone, Debug)]
1243 struct SessionState {
1244 cwd: PathBuf,
1245 additional_roots: Vec<PathBuf>,
1246 /// The model/backend selection that belongs to this session. ACP config
1247 /// options are session-scoped, so switching threads must restore it along
1248 /// with the conversation rather than leaving the last thread's model live.
1249 model_id: String,
1250 /// The conversation without its system messages (see
1251 /// `backend::carryover_history`). Those are rebuilt from the roots each
1252 /// time the session is installed, so they always name this session's
1253 /// project. Stale while the session is live: the backend holds the
1254 /// current copy then.
1255 conversation: Vec<serde_json::Value>,
1256 /// The MCP servers the client passed in `mcpServers` for this session,
1257 /// connected. Installed as the live set along with the roots.
1258 mcp_servers: mcp::SessionServers,
1259 /// This session's own HTTP backend, set while the session is parked and
1260 /// was last served by one. `None` for a session that has not run yet, was
1261 /// last on-device, or is the installed session (the agent's `backend`
1262 /// holds it then).
1263 remote: Option<RemoteBackend>,
1264 }
1265
1266 /// An HTTP backend that belongs to one session, with the model selection that
1267 /// was live when the session was parked.
1268 #[derive(Clone)]
1269 struct RemoteBackend {
1270 backend: Arc<dyn InferenceBackend>,
1271 model: GgufModelConfig,
1272 }
1273
1274 impl std::fmt::Debug for RemoteBackend {
1275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1276 f.debug_struct("RemoteBackend")
1277 .field("model", &self.model.model_id)
1278 .finish_non_exhaustive()
1279 }
1280 }
1281
1282 /// Holds `workspace_lock` for a prompt turn, which gives it up while it waits
1283 /// on an HTTP endpoint or on the user and takes it back before touching
1284 /// anything process-wide.
1285 struct WorkspaceHold {
1286 lock: Arc<tokio::sync::Mutex<()>>,
1287 guard: Option<tokio::sync::OwnedMutexGuard<()>>,
1288 }
1289
1290 impl WorkspaceHold {
1291 async fn acquire(lock: &Arc<tokio::sync::Mutex<()>>) -> Self {
1292 Self {
1293 lock: Arc::clone(lock),
1294 guard: Some(Arc::clone(lock).lock_owned().await),
1295 }
1296 }
1297
1298 fn release(&mut self) {
1299 self.guard = None;
1300 }
1301
1302 /// Whether the lock was given up since it was last held.
1303 async fn reacquire(&mut self) -> bool {
1304 if self.guard.is_some() {
1305 return false;
1306 }
1307 self.guard = Some(Arc::clone(&self.lock).lock_owned().await);
1308 true
1309 }
1310 }
1311
1312 /// One session's lock, held for a request on it.
1313 ///
1314 /// Dropping it also drops the session's entry in `session_locks` when the
1315 /// request leaves no such session behind, which is what `session/close` does
1316 /// and what any request naming an id nobody opened does.
1317 struct SessionGuard {
1318 guard: tokio::sync::OwnedMutexGuard<()>,
1319 agent: Arc<SiGitAgent>,
1320 key: String,
1321 }
1322
1323 impl Drop for SessionGuard {
1324 fn drop(&mut self) {
1325 if self.agent.sessions.lock().unwrap().contains_key(&self.key) {
1326 return;
1327 }
1328 let held = tokio::sync::OwnedMutexGuard::mutex(&self.guard);
1329 let mut locks = self.agent.session_locks.lock().unwrap();
1330 // Two handles mean the map's and this guard's. A request still queued
1331 // on the lock holds a third, and then the entry stays: taking it out
1332 // would give the next request for this id a fresh lock, and the two
1333 // would no longer wait for each other. The queued request drops the
1334 // entry when its own guard goes. `lock_session` clones under this
1335 // mutex, so the count cannot grow before the entry is removed.
1336 if locks
1337 .get(&self.key)
1338 .is_some_and(|lock| Arc::ptr_eq(lock, held) && Arc::strong_count(lock) == 2)
1339 {
1340 locks.remove(&self.key);
1341 }
1342 }
1343 }
1344
1345 struct SiGitAgent {
1346 engine: Arc<ChatEngine>,
1347 /// The active inference backend. `LocalBackend` by default; swapped to an
1348 /// `OpenAiBackend` when the user selects a siGit Code Cloud tier in the panel.
1349 backend: tokio::sync::Mutex<Arc<dyn InferenceBackend>>,
1350 /// cwd from the editor — tool calls run here, not where the process started.
1351 /// Always the live session's `cwd`.
1352 session_cwd: std::sync::Mutex<Option<PathBuf>>,
1353 /// Every session this process has opened, by id.
1354 sessions: std::sync::Mutex<std::collections::HashMap<String, SessionState>>,
1355 /// The session whose roots and conversation are installed right now.
1356 active_session: std::sync::Mutex<Option<String>>,
1357 /// Cancellation signals for prompts currently waiting on inference. The
1358 /// notification handler takes no lock, so it can interrupt a turn whatever
1359 /// that turn is holding.
1360 prompt_cancellations:
1361 std::sync::Mutex<std::collections::HashMap<String, Arc<PromptCancellation>>>,
1362 /// Sessions a `session/close` is waiting to tear down. A prompt for one of
1363 /// these that reaches the front of its session lock first is cancelled.
1364 closing_sessions: std::sync::Mutex<std::collections::HashSet<String>>,
1365 /// The title last sent to the client for each session in a
1366 /// `session_info_update`, so an unchanged title is not sent again.
1367 announced_titles: std::sync::Mutex<std::collections::HashMap<String, String>>,
1368 current_model: std::sync::Mutex<GgufModelConfig>,
1369 /// flipped once the startup model finishes (success or failure)
1370 model_ready: Arc<AtomicBool>,
1371 /// guards the one-time lazy startup load for ACP mode
1372 startup_model_load_started: Arc<AtomicBool>,
1373 /// set if the startup load failed
1374 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
1375 /// true when the startup model isn't cached yet
1376 startup_needs_download: bool,
1377 /// Xcode launches custom agents with `--acp`; that mode should be ready to
1378 /// answer the first prompt without requiring an editor slash command.
1379 auto_load_local_model: bool,
1380 /// for progress UI
1381 startup_model_name: String,
1382 /// for download-progress polling
1383 startup_model_id: String,
1384 /// Guards everything one session installs process-wide: the working
1385 /// directory, workspace roots, session MCP servers, the live `backend`
1386 /// slot and the on-device engine's conversation. Session lifecycle and
1387 /// config handlers hold it throughout. A prompt turn holds it while it
1388 /// prepares and while its tools run, and gives it up while it waits on an
1389 /// HTTP endpoint or on a permission answer, so another session can be
1390 /// installed in the meantime. An on-device turn never gives it up: the
1391 /// engine serves one conversation at a time.
1392 workspace_lock: Arc<tokio::sync::Mutex<()>>,
1393 /// One lock per session id, held for a whole request on that session, so
1394 /// two requests on the same thread keep the order they arrived in. Always
1395 /// taken before `workspace_lock`, never while holding it. A closed
1396 /// session's entry is dropped by `SessionGuard`.
1397 session_locks: std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
1398 }
1399
1400 impl SiGitAgent {
1401 fn new(
1402 engine: Arc<ChatEngine>,
1403 initial_model: GgufModelConfig,
1404 model_ready: Arc<AtomicBool>,
1405 startup_model_load_started: Arc<AtomicBool>,
1406 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
1407 startup_needs_download: bool,
1408 auto_load_local_model: bool,
1409 ) -> Self {
1410 let startup_model_name = initial_model.display_name.clone();
1411 let startup_model_id = initial_model.model_id.clone();
1412 let backend: Arc<dyn InferenceBackend> = Arc::new(LocalBackend::new(Arc::clone(&engine)));
1413 Self {
1414 engine,
1415 backend: tokio::sync::Mutex::new(backend),
1416 session_cwd: std::sync::Mutex::new(None),
1417 sessions: std::sync::Mutex::new(std::collections::HashMap::new()),
1418 active_session: std::sync::Mutex::new(None),
1419 prompt_cancellations: std::sync::Mutex::new(std::collections::HashMap::new()),
1420 closing_sessions: std::sync::Mutex::new(std::collections::HashSet::new()),
1421 announced_titles: std::sync::Mutex::new(std::collections::HashMap::new()),
1422 current_model: std::sync::Mutex::new(initial_model),
1423 model_ready,
1424 startup_model_load_started,
1425 model_load_error,
1426 startup_needs_download,
1427 auto_load_local_model,
1428 startup_model_name,
1429 startup_model_id,
1430 workspace_lock: Arc::new(tokio::sync::Mutex::new(())),
1431 session_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
1432 }
1433 }
1434
1435 fn start_startup_model_load_if_needed(&self) {
1436 if self
1437 .startup_model_load_started
1438 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1439 .is_err()
1440 {
1441 return;
1442 }
1443
1444 self.model_ready.store(false, Ordering::Release);
1445 if let Ok(mut guard) = self.model_load_error.lock() {
1446 *guard = None;
1447 }
1448
1449 let startup_config = self.current_model.lock().unwrap().clone();
1450 let (max_tokens, tool_calling) = models::local_picker_items()
1451 .into_iter()
1452 .find(|item| {
1453 item.config.model_id == startup_config.model_id
1454 && item
1455 .config
1456 .files
1457 .first()
1458 .zip(startup_config.files.first())
1459 .map(|(left, right)| left == right)
1460 .unwrap_or(false)
1461 })
1462 .map(|item| (item.max_tokens, item.tool_calling))
1463 .unwrap_or((4096, false));
1464
1465 let sampling = SamplingConfig {
1466 max_tokens: Some(max_tokens),
1467 ..SamplingConfig::default()
1468 };
1469
1470 let loader_engine = Arc::clone(&self.engine);
1471 let loader_system_prompt = system_prompt_for_model(tool_calling);
1472 let model_ready = Arc::clone(&self.model_ready);
1473 let model_load_error = Arc::clone(&self.model_load_error);
1474
1475 std::thread::spawn(move || {
1476 let result = tokio::runtime::Runtime::new()
1477 .map_err(|error| error.to_string())
1478 .and_then(|rt| {
1479 rt.block_on(loader_engine.load_gguf_model(
1480 startup_config,
1481 Some(loader_system_prompt),
1482 Some(sampling),
1483 ))
1484 .map(|_| ())
1485 .map_err(|error| error.to_string())
1486 });
1487
1488 if let Ok(mut guard) = model_load_error.lock() {
1489 *guard = result.err();
1490 }
1491 model_ready.store(true, Ordering::Release);
1492 });
1493 }
1494
1495 /// block until the startup model is ready, showing progress in the session.
1496 async fn await_model_ready(
1497 &self,
1498 cx: &ConnectionTo<Client>,
1499 session_id: &SessionId,
1500 ) -> agent_client_protocol::Result<()> {
1501 if self.model_ready.load(Ordering::Acquire) {
1502 // already done — might be a stored error from earlier
1503 if let Some(err) = self.model_load_error.lock().unwrap().as_ref() {
1504 return Err(agent_client_protocol::Error::new(
1505 ErrorCode::InternalError.into(),
1506 format!("model load failed: {err}"),
1507 ));
1508 }
1509 return Ok(());
1510 }
1511
1512 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1513
1514 let tool_call_id = format!("startup-load-{}", uuid::Uuid::new_v4());
1515 let title = if self.startup_needs_download {
1516 format!("Downloading {}", self.startup_model_name)
1517 } else {
1518 format!("Loading {}", self.startup_model_name)
1519 };
1520
1521 self.send_tool_call_update(
1522 cx,
1523 session_id.clone(),
1524 SessionUpdate::ToolCall(
1525 ToolCall::new(tool_call_id.clone(), &title)
1526 .kind(ToolKind::Think)
1527 .status(ToolCallStatus::InProgress)
1528 .content(vec![format!("{}…", title).into()]),
1529 ),
1530 )
1531 .ok();
1532
1533 let expected_bytes = if self.startup_needs_download {
1534 onde::inference::models::SUPPORTED_MODEL_INFO
1535 .iter()
1536 .find(|m| m.id == self.startup_model_id)
1537 .map(|m| m.expected_size_bytes)
1538 .unwrap_or(0)
1539 } else {
1540 0
1541 };
1542
1543 let load_start = std::time::Instant::now();
1544 let mut tick: usize = 0;
1545 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
1546 interval.tick().await;
1547
1548 loop {
1549 interval.tick().await;
1550 tick += 1;
1551
1552 if self.model_ready.load(Ordering::Acquire) {
1553 break;
1554 }
1555
1556 let frame = SPINNER[tick % SPINNER.len()];
1557 let elapsed = load_start.elapsed();
1558 let elapsed_str = if elapsed.as_secs() >= 60 {
1559 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
1560 } else {
1561 format!("{}s", elapsed.as_secs())
1562 };
1563
1564 let (update_title, update_content) =
1565 if self.startup_needs_download && expected_bytes > 0 {
1566 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
1567 let downloaded = cache_path
1568 .as_ref()
1569 .filter(|p| p.exists())
1570 .map(|p| dir_size_recursive(p))
1571 .unwrap_or(0);
1572 let pct = ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
1573 let bar = progress_bar(pct, 20);
1574 let size_hint = format!(" (~{})", format_size_human(expected_bytes));
1575 (
1576 format!(
1577 "{frame} Downloading {}{size_hint} ({pct}%)",
1578 self.startup_model_name
1579 ),
1580 format!(
1581 "{} — {bar} {pct}% ({} / {})",
1582 self.startup_model_name,
1583 format_size_human(downloaded),
1584 format_size_human(expected_bytes),
1585 ),
1586 )
1587 } else if self.startup_needs_download {
1588 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
1589 let downloaded = cache_path
1590 .as_ref()
1591 .filter(|p| p.exists())
1592 .map(|p| dir_size_recursive(p))
1593 .unwrap_or(0);
1594 (
1595 format!("{frame} Downloading {}", self.startup_model_name),
1596 format!(
1597 "{} — {} downloaded… ({elapsed_str})",
1598 self.startup_model_name,
1599 format_size_human(downloaded),
1600 ),
1601 )
1602 } else {
1603 (
1604 format!("{frame} Loading {}", self.startup_model_name),
1605 format!(
1606 "{frame} Loading {}… ({elapsed_str})",
1607 self.startup_model_name
1608 ),
1609 )
1610 };
1611
1612 self.send_tool_call_update(
1613 cx,
1614 session_id.clone(),
1615 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1616 tool_call_id.clone(),
1617 ToolCallUpdateFields::new()
1618 .title(update_title)
1619 .status(ToolCallStatus::InProgress)
1620 .content(vec![update_content.into()]),
1621 )),
1622 )
1623 .ok();
1624 }
1625
1626 // done — check if it blew up
1627 let load_error = self.model_load_error.lock().unwrap().clone();
1628 if let Some(err) = load_error {
1629 self.send_tool_call_update(
1630 cx,
1631 session_id.clone(),
1632 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1633 tool_call_id,
1634 ToolCallUpdateFields::new()
1635 .title("Model load failed".to_string())
1636 .status(ToolCallStatus::Failed)
1637 .content(vec![format!("error: {err}").into()]),
1638 )),
1639 )
1640 .ok();
1641
1642 return Err(agent_client_protocol::Error::new(
1643 ErrorCode::InternalError.into(),
1644 format!("model load failed: {err}"),
1645 ));
1646 }
1647
1648 let done_title = if self.startup_needs_download {
1649 format!("✓ {} downloaded and loaded", self.startup_model_name)
1650 } else {
1651 format!("✓ {} loaded", self.startup_model_name)
1652 };
1653
1654 self.send_tool_call_update(
1655 cx,
1656 session_id.clone(),
1657 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1658 tool_call_id,
1659 ToolCallUpdateFields::new()
1660 .title(done_title)
1661 .status(ToolCallStatus::Completed),
1662 )),
1663 )
1664 .ok();
1665
1666 Ok(())
1667 }
1668
1669 /// Send a message of its own: a notice, a slash command's answer. It gets
1670 /// a fresh `messageId`, so a client never folds it into the reply around
1671 /// it. Text that belongs to the model's reply goes through
1672 /// [`Self::send_reply_chunk`] instead.
1673 fn send_assistant_message(
1674 &self,
1675 cx: &ConnectionTo<Client>,
1676 session_id: SessionId,
1677 text: impl Into<String>,
1678 ) -> agent_client_protocol::Result<()> {
1679 self.send_reply_chunk(cx, session_id, new_message_id(), text)
1680 }
1681
1682 /// Send one chunk of the model message `message_id`.
1683 fn send_reply_chunk(
1684 &self,
1685 cx: &ConnectionTo<Client>,
1686 session_id: SessionId,
1687 message_id: MessageId,
1688 text: impl Into<String>,
1689 ) -> agent_client_protocol::Result<()> {
1690 cx.send_notification(SessionNotification::new(
1691 session_id,
1692 SessionUpdate::AgentMessageChunk(
1693 ContentChunk::new(ContentBlock::from(text.into())).message_id(message_id),
1694 ),
1695 ))
1696 }
1697
1698 /// Stream a fragment of the model's reasoning as a thought chunk. Clients
1699 /// render these in a collapsed "thinking" section, separate from the reply.
1700 /// It carries the id of the model message it came with.
1701 fn send_agent_thought(
1702 &self,
1703 cx: &ConnectionTo<Client>,
1704 session_id: SessionId,
1705 message_id: MessageId,
1706 text: impl Into<String>,
1707 ) -> agent_client_protocol::Result<()> {
1708 cx.send_notification(SessionNotification::new(
1709 session_id,
1710 SessionUpdate::AgentThoughtChunk(
1711 ContentChunk::new(ContentBlock::from(text.into())).message_id(message_id),
1712 ),
1713 ))
1714 }
1715
1716 fn send_system_status(
1717 &self,
1718 cx: &ConnectionTo<Client>,
1719 session_id: SessionId,
1720 title: impl Into<String>,
1721 ) -> agent_client_protocol::Result<()> {
1722 cx.send_notification(SessionNotification::new(
1723 session_id,
1724 SessionUpdate::ToolCall(
1725 ToolCall::new(
1726 format!("system-status-{}", uuid::Uuid::new_v4()),
1727 title.into(),
1728 )
1729 .kind(ToolKind::Think)
1730 .status(ToolCallStatus::Completed),
1731 ),
1732 ))
1733 }
1734
1735 /// Run one inference turn (`fut`) while concurrently forwarding any streamed
1736 /// tokens to the editor. The sink receiver is drained as the future runs, so
1737 /// chunks reach the client live rather than all at once when it resolves.
1738 ///
1739 /// `reply` persists across the turns of a single prompt so reasoning is
1740 /// stripped consistently and we never re-send text.
1741 async fn drain_turn<F>(
1742 &self,
1743 cx: &ConnectionTo<Client>,
1744 session_id: &SessionId,
1745 fut: F,
1746 sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<TokenChunk>,
1747 reply: &mut StreamedReply,
1748 cancellation: &PromptCancellation,
1749 ) -> Result<TurnResult, DrainTurnError>
1750 where
1751 F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
1752 {
1753 if cancellation.cancelled.load(Ordering::Acquire) {
1754 return Err(DrainTurnError::Cancelled);
1755 }
1756
1757 tokio::pin!(fut);
1758 let result = loop {
1759 tokio::select! {
1760 done = &mut fut => break done.map_err(DrainTurnError::Backend),
1761 _ = cancellation.notify.notified() => {
1762 if cancellation.cancelled.load(Ordering::Acquire) {
1763 break Err(DrainTurnError::Cancelled);
1764 }
1765 }
1766 Some(chunk) = sink_rx.recv() => {
1767 self.emit_token_chunk(cx, session_id, chunk, reply);
1768 }
1769 }
1770 };
1771 // Flush tokens that landed between the last poll and the future resolving.
1772 while let Ok(chunk) = sink_rx.try_recv() {
1773 self.emit_token_chunk(cx, session_id, chunk, reply);
1774 }
1775 result
1776 }
1777
1778 /// Route one streamed token chunk. A `Reasoning` chunk comes from a
1779 /// dedicated reasoning field and is unambiguous, so it goes straight to the
1780 /// client as a thought. A `Visible` chunk is folded into `reply`, which may
1781 /// reveal visible reply text (sent as an agent message) and/or inline
1782 /// `<think>` reasoning (sent as a thought).
1783 fn emit_token_chunk(
1784 &self,
1785 cx: &ConnectionTo<Client>,
1786 session_id: &SessionId,
1787 chunk: TokenChunk,
1788 reply: &mut StreamedReply,
1789 ) {
1790 match chunk {
1791 TokenChunk::Reasoning(text) => {
1792 let message_id = reply.message_id();
1793 self.send_agent_thought(cx, session_id.clone(), message_id, text)
1794 .ok();
1795 }
1796 TokenChunk::Visible(piece) => {
1797 let revealed = reply.push(&piece);
1798 if let Some(reasoning) = revealed.reasoning {
1799 let message_id = reply.message_id();
1800 self.send_agent_thought(cx, session_id.clone(), message_id, reasoning)
1801 .ok();
1802 }
1803 if let Some(visible) = revealed.visible {
1804 let message_id = reply.message_id();
1805 self.send_reply_chunk(cx, session_id.clone(), message_id, visible)
1806 .ok();
1807 }
1808 }
1809 }
1810 }
1811
1812 fn send_tool_call_update(
1813 &self,
1814 cx: &ConnectionTo<Client>,
1815 session_id: SessionId,
1816 update: SessionUpdate,
1817 ) -> agent_client_protocol::Result<()> {
1818 cx.send_notification(SessionNotification::new(session_id, update))
1819 }
1820
1821 /// Tell a client that renders the legacy mode selector which mode is live.
1822 /// Sent wherever the Permissions config option is refreshed for a reason
1823 /// other than that client's own `session/set_mode`, so the two views of the
1824 /// same state never disagree.
1825 fn send_current_mode(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
1826 let mode = current_permission_mode(&session_id.to_string());
1827 self.send_tool_call_update(
1828 cx,
1829 session_id,
1830 SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(mode)),
1831 )
1832 .ok();
1833 }
1834
1835 fn send_plan_update(
1836 &self,
1837 cx: &ConnectionTo<Client>,
1838 session_id: SessionId,
1839 plan: Plan,
1840 ) -> agent_client_protocol::Result<()> {
1841 cx.send_notification(SessionNotification::new(
1842 session_id,
1843 SessionUpdate::Plan(plan),
1844 ))
1845 }
1846
1847 /// Advertise siGit's slash commands to the client. Editors like Zed parse
1848 /// `/`-prefixed input and only forward commands they've been told about, so
1849 /// without this `/login`, `/models`, etc. are rejected client-side.
1850 fn advertise_commands(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
1851 let with_hint = |name: &str, desc: &str, hint: &str| {
1852 AvailableCommand::new(name, desc).input(AvailableCommandInput::Unstructured(
1853 UnstructuredCommandInput::new(hint),
1854 ))
1855 };
1856 let mut commands = vec![
1857 AvailableCommand::new("help", "Show available commands"),
1858 AvailableCommand::new("models", "List available models").input(
1859 AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(
1860 "model number to switch to (optional)",
1861 )),
1862 ),
1863 with_hint(
1864 "local",
1865 "Toggle on-device inference mode",
1866 "on|off (optional)",
1867 ),
1868 AvailableCommand::new("skills", "List available Agent Skills"),
1869 AvailableCommand::new(
1870 "agents",
1871 "List configured subagent types (.sigit/agents/*.md) for task",
1872 ),
1873 AvailableCommand::new(
1874 "commands",
1875 "List user-defined commands (.sigit/commands/*.md)",
1876 ),
1877 AvailableCommand::new("mcp", "List MCP servers and their tools"),
1878 AvailableCommand::new("load", "Load the selected on-device model"),
1879 with_hint(
1880 "login",
1881 "Sign in to siGit Code Cloud (opens your browser)",
1882 "[email] [password]",
1883 ),
1884 AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
1885 AvailableCommand::new("whoami", "Show the signed-in account"),
1886 AvailableCommand::new("reload", "Re-sync sign-in and model state"),
1887 with_hint(
1888 "plan",
1889 "Plan mode: research only, no edits or commands",
1890 "on|off (optional)",
1891 ),
1892 AvailableCommand::new("permissions", "Show the tool permission policy"),
1893 AvailableCommand::new("compact", "Summarize and shrink the conversation history"),
1894 AvailableCommand::new("init", "Generate or improve the repo's AGENTS.md"),
1895 AvailableCommand::new("clear", "Wipe the conversation history"),
1896 AvailableCommand::new("status", "Show engine status"),
1897 ];
1898
1899 // User-defined commands (.sigit/commands/*.md etc.) are appended so
1900 // clients that only forward advertised commands (Zed) can actually
1901 // send them. A custom command sharing a name with a built-in is
1902 // unreachable — parse_slash always matches built-ins first — so skip
1903 // advertising the shadowed duplicate rather than confuse the client.
1904 let built_in_names: Vec<String> = commands.iter().map(|c| c.name.to_string()).collect();
1905 for command in commands::discover_commands() {
1906 if built_in_names.contains(&command.name) {
1907 log::warn!(
1908 "custom command \"{}\" at {} is shadowed by a built-in command of the same name",
1909 command.name,
1910 command.path.display()
1911 );
1912 continue;
1913 }
1914 let description = command
1915 .description
1916 .clone()
1917 .unwrap_or_else(|| "User-defined command".to_string());
1918 commands.push(match &command.argument_hint {
1919 Some(hint) => with_hint(&command.name, &description, hint),
1920 None => AvailableCommand::new(&command.name, &description),
1921 });
1922 }
1923
1924 self.send_tool_call_update(
1925 cx,
1926 session_id,
1927 SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
1928 )
1929 .ok();
1930 }
1931
1932 async fn switch_model_by_id(
1933 &self,
1934 model_id: &str,
1935 ) -> agent_client_protocol::Result<GgufModelConfig> {
1936 let (new_config, max_tokens, new_tool_calling) = resolve_model_config(model_id)
1937 .ok_or_else(|| {
1938 agent_client_protocol::Error::new(
1939 ErrorCode::InvalidParams.into(),
1940 format!("unknown or unavailable model: {model_id}"),
1941 )
1942 })?;
1943
1944 log::info!(
1945 "switching model to {} (max_tokens={max_tokens})",
1946 new_config.display_name
1947 );
1948
1949 // Loading weights resets the engine's history, which would restart the
1950 // conversation under the new model. Keep the turns so far and replay
1951 // them below, once the new prompt and session context are seeded.
1952 let carried = self
1953 .engine
1954 .history()
1955 .await
1956 .into_iter()
1957 .filter(|message| message.role != onde::inference::ChatRole::System)
1958 .collect::<Vec<_>>();
1959
1960 let sampling = SamplingConfig {
1961 max_tokens: Some(max_tokens),
1962 ..SamplingConfig::default()
1963 };
1964
1965 // block_in_place inside spawn_local panics, so run the load on a
1966 // dedicated thread with its own runtime (same trick as startup)
1967 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
1968 let loader_engine = Arc::clone(&self.engine);
1969 let loader_config = new_config.clone();
1970 let loader_system_prompt = system_prompt_for_model(new_tool_calling);
1971 let loader_sampling = sampling;
1972
1973 std::thread::spawn(move || {
1974 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1975 let result = rt.block_on(async move {
1976 // load_gguf_model already unloads the old model internally;
1977 // calling unload first would leave a gap where prompts fail
1978 loader_engine
1979 .load_gguf_model(
1980 loader_config,
1981 Some(loader_system_prompt),
1982 Some(loader_sampling),
1983 )
1984 .await
1985 });
1986 let _ = result_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
1987 });
1988
1989 result_rx
1990 .await
1991 .map_err(|_| {
1992 agent_client_protocol::Error::new(
1993 ErrorCode::InternalError.into(),
1994 "model loader thread crashed",
1995 )
1996 })?
1997 .map_err(|error| {
1998 log::error!("model switch failed: {error}");
1999 agent_client_protocol::Error::new(
2000 ErrorCode::InternalError.into(),
2001 format!("model switch failed: {error}"),
2002 )
2003 })?;
2004
2005 self.startup_model_load_started
2006 .store(true, Ordering::Release);
2007 self.model_ready.store(true, Ordering::Release);
2008 if let Ok(mut guard) = self.model_load_error.lock() {
2009 *guard = None;
2010 }
2011
2012 if let Some(item) = models::local_picker_items()
2013 .iter()
2014 .find(|item| item.config.model_id == new_config.model_id)
2015 && let Err(err) = setup::save_selected_model(&setup::SelectedModel {
2016 model_id: item.config.model_id.clone(),
2017 gguf_file: item.config.files.first().cloned().unwrap_or_default(),
2018 })
2019 {
2020 log::warn!("failed to persist model selection: {err}");
2021 }
2022
2023 {
2024 let mut guard = self.current_model.lock().unwrap();
2025 *guard = new_config.clone();
2026 }
2027 self.record_active_model();
2028
2029 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
2030 self.engine
2031 .push_history(onde::inference::ChatMessage::system(
2032 session_context_message(&cwd, &workspace::additional_roots()),
2033 ))
2034 .await;
2035 }
2036
2037 for message in carried {
2038 self.engine.push_history(message).await;
2039 }
2040
2041 Ok(new_config)
2042 }
2043 }
2044
2045 /// Session and model pairs already told that earlier images are hidden, so
2046 /// the note about a model switch is shown once and not on every prompt.
2047 static IMAGE_GAP_NOTED: std::sync::Mutex<Vec<(String, String)>> = std::sync::Mutex::new(Vec::new());
2048
2049 /// The same for earlier audio clips.
2050 static AUDIO_GAP_NOTED: std::sync::Mutex<Vec<(String, String)>> = std::sync::Mutex::new(Vec::new());
2051
2052 /// The wording [`attachments_for_turn`] uses for one kind of attachment.
2053 struct AttachmentKind {
2054 /// "image"
2055 noun: &'static str,
2056 /// "images"
2057 nouns: &'static str,
2058 /// "Image", for the notice headings.
2059 heading: &'static str,
2060 /// "Images", for the notice headings.
2061 headings: &'static str,
2062 /// "read images"
2063 ability: &'static str,
2064 /// Where the user can find a model that can.
2065 hint: &'static str,
2066 /// Session and model pairs already told about earlier attachments.
2067 noted: &'static std::sync::Mutex<Vec<(String, String)>>,
2068 }
2069
2070 const IMAGES: AttachmentKind = AttachmentKind {
2071 noun: "image",
2072 nouns: "images",
2073 heading: "Image",
2074 headings: "images",
2075 ability: "read images",
2076 hint: "Models marked \"reads images\" in the model picker can.",
2077 noted: &IMAGE_GAP_NOTED,
2078 };
2079
2080 const AUDIO: AttachmentKind = AttachmentKind {
2081 noun: "audio clip",
2082 nouns: "audio clips",
2083 heading: "Audio",
2084 headings: "audio clips",
2085 ability: "take audio",
2086 hint: "No siGit Code Cloud or on-device model can; a model on an endpoint you \
2087 configure yourself can, if it accepts audio input.",
2088 noted: &AUDIO_GAP_NOTED,
2089 };
2090
2091 /// Decide what happens to a prompt's attachments of one `kind` for the model
2092 /// about to answer.
2093 ///
2094 /// The editor can attach images and audio whatever model is selected, because
2095 /// ACP fixes `promptCapabilities` for the whole connection. Returns the text
2096 /// and attachments to send, plus a note for the user when something is being
2097 /// left out:
2098 ///
2099 /// - the model cannot take them and the prompt has some: they are dropped,
2100 /// the user is told, and the model is told too, so it does not answer as if
2101 /// it had seen them;
2102 /// - the model cannot take them and the thread has some from before a model
2103 /// switch (`earlier`): the user is told once per session and model.
2104 fn attachments_for_turn<T>(
2105 mut user_text: String,
2106 attachments: Vec<T>,
2107 accepts: bool,
2108 model_name: &str,
2109 earlier: usize,
2110 session_id: &str,
2111 kind: &AttachmentKind,
2112 ) -> (String, Vec<T>, Option<String>) {
2113 if accepts {
2114 return (user_text, attachments, None);
2115 }
2116
2117 let nouns = |count: usize| if count == 1 { kind.noun } else { kind.nouns };
2118 let ability = kind.ability;
2119 let hint = kind.hint;
2120
2121 if !attachments.is_empty() {
2122 let count = attachments.len();
2123 if !user_text.is_empty() {
2124 user_text.push_str("\n\n");
2125 }
2126 user_text.push_str(&format!(
2127 "[The user attached {count} {}. This model cannot {ability}, so it was not \
2128 included. Say so if the request depends on it.]",
2129 nouns(count)
2130 ));
2131 let notice = format!(
2132 "> **{} not sent.** {model_name} cannot {ability}, so the {count} attached {} {} \
2133 left out of this message. {hint}",
2134 kind.heading,
2135 nouns(count),
2136 if count == 1 { "was" } else { "were" }
2137 );
2138 return (user_text, Vec::new(), Some(notice));
2139 }
2140
2141 if earlier > 0 {
2142 let key = (session_id.to_string(), model_name.to_string());
2143 let mut noted = kind.noted.lock().unwrap_or_else(|e| e.into_inner());
2144 if !noted.contains(&key) {
2145 noted.push(key);
2146 let notice = format!(
2147 "> **Earlier {} are hidden.** {model_name} cannot {ability}, so the \
2148 {earlier} {} attached earlier in this thread {} not sent to it. {hint}",
2149 kind.headings,
2150 nouns(earlier),
2151 if earlier == 1 { "is" } else { "are" }
2152 );
2153 return (user_text, attachments, Some(notice));
2154 }
2155 }
2156 (user_text, attachments, None)
2157 }
2158
2159 /// [`attachments_for_turn`] for a prompt's images.
2160 fn images_for_turn(
2161 user_text: String,
2162 images: Vec<ImageInput>,
2163 accepts_images: bool,
2164 model_name: &str,
2165 earlier_images: usize,
2166 session_id: &str,
2167 ) -> (String, Vec<ImageInput>, Option<String>) {
2168 attachments_for_turn(
2169 user_text,
2170 images,
2171 accepts_images,
2172 model_name,
2173 earlier_images,
2174 session_id,
2175 &IMAGES,
2176 )
2177 }
2178
2179 /// [`attachments_for_turn`] for a prompt's audio clips.
2180 fn audio_for_turn(
2181 user_text: String,
2182 audio: Vec<AudioInput>,
2183 accepts_audio: bool,
2184 model_name: &str,
2185 earlier_audio: usize,
2186 session_id: &str,
2187 ) -> (String, Vec<AudioInput>, Option<String>) {
2188 attachments_for_turn(
2189 user_text,
2190 audio,
2191 accepts_audio,
2192 model_name,
2193 earlier_audio,
2194 session_id,
2195 &AUDIO,
2196 )
2197 }
2198
2199 /// The servers out of a session request's `mcpServers` that siGit Code can
2200 /// connect to.
2201 ///
2202 /// ACP requires every agent to connect to the stdio ones. Streamable HTTP is
2203 /// optional and advertised through `mcpCapabilities.http` in
2204 /// `handle_initialize`. SSE is not advertised (the MCP spec deprecated it), so
2205 /// a client that sends one anyway is told in the log and the entry is skipped.
2206 fn client_mcp_servers(servers: &[McpServer]) -> Vec<mcp::ClientServer> {
2207 servers
2208 .iter()
2209 .filter_map(|server| match server {
2210 McpServer::Stdio(stdio) => Some(mcp::ClientServer {
2211 name: stdio.name.clone(),
2212 transport: mcp::ClientTransport::Stdio {
2213 command: stdio.command.to_string_lossy().into_owned(),
2214 args: stdio.args.clone(),
2215 env: stdio
2216 .env
2217 .iter()
2218 .map(|var| (var.name.clone(), var.value.clone()))
2219 .collect(),
2220 },
2221 }),
2222 McpServer::Http(http) => Some(mcp::ClientServer {
2223 name: http.name.clone(),
2224 transport: mcp::ClientTransport::Http {
2225 url: http.url.clone(),
2226 headers: http
2227 .headers
2228 .iter()
2229 .map(|header| (header.name.clone(), header.value.clone()))
2230 .collect(),
2231 },
2232 }),
2233 other => {
2234 log::warn!(
2235 "ignoring an MCP server from the client with an unsupported transport: \
2236 {other:?}"
2237 );
2238 None
2239 }
2240 })
2241 .collect()
2242 }
2243
2244 /// Reject session roots that are not absolute paths.
2245 ///
2246 /// ACP requires `cwd` and every `additionalDirectories` entry to be absolute.
2247 /// A relative one would otherwise be resolved against wherever the editor
2248 /// happened to spawn this process, which is not the project the user opened.
2249 fn validate_session_roots(
2250 cwd: &std::path::Path,
2251 additional: &[PathBuf],
2252 ) -> agent_client_protocol::Result<()> {
2253 if !cwd.is_absolute() {
2254 return Err(agent_client_protocol::Error::new(
2255 ErrorCode::InvalidParams.into(),
2256 format!("cwd must be an absolute path, got {}", cwd.display()),
2257 ));
2258 }
2259 if let Some(relative) = additional.iter().find(|dir| !dir.is_absolute()) {
2260 return Err(agent_client_protocol::Error::new(
2261 ErrorCode::InvalidParams.into(),
2262 format!(
2263 "additionalDirectories entries must be absolute paths, got {}",
2264 relative.display()
2265 ),
2266 ));
2267 }
2268 Ok(())
2269 }
2270
2271 /// The id of the one auth method `initialize` advertises.
2272 const AUTH_METHOD_ID: &str = "sigit";
2273
2274 // ── ACP handler implementations ───────────────────────────────────────────────
2275
2276 impl SiGitAgent {
2277 async fn handle_initialize(
2278 &self,
2279 cx: &ConnectionTo<Client>,
2280 req: InitializeRequest,
2281 ) -> agent_client_protocol::Result<InitializeResponse> {
2282 log::info!("initialize");
2283
2284 // A client that serves files from its buffers gets the file tools'
2285 // reads and writes (see `client_fs`). Only the connection is kept
2286 // here: asking the client for anything from this handler would
2287 // deadlock, and the tools ask from inside a spawned prompt turn.
2288 let fs = &req.client_capabilities.fs;
2289 if client_fs::disabled_by_env() {
2290 log::info!("client file system: off (SIGIT_CLIENT_FS)");
2291 } else {
2292 log::info!(
2293 "client file system: read={}, write={}",
2294 fs.read_text_file,
2295 fs.write_text_file
2296 );
2297 client_fs::register(
2298 Arc::new(AcpClientFs { cx: cx.clone() }),
2299 fs.read_text_file,
2300 fs.write_text_file,
2301 );
2302 }
2303
2304 // Likewise a client with a terminal runs foreground commands in it,
2305 // so the user sees them live (see `client_terminal`).
2306 let terminal = req.client_capabilities.terminal;
2307 if client_terminal::disabled_by_env() {
2308 log::info!("client terminal: off (SIGIT_CLIENT_TERMINAL)");
2309 } else {
2310 log::info!("client terminal: {terminal}");
2311 client_terminal::register(Arc::new(AcpClientTerminal { cx: cx.clone() }), terminal);
2312 }
2313
2314 // URL-mode elicitation is where `authenticate` sends the user to sign
2315 // in. Form mode is never used: the spec forbids collecting
2316 // credentials in a form, and nothing else here asks the user for
2317 // structured input.
2318 let elicit_url = req
2319 .client_capabilities
2320 .elicitation
2321 .as_ref()
2322 .is_some_and(|caps| caps.url.is_some());
2323 log::info!("elicitation: url={elicit_url}");
2324 CLIENT_ELICITATION_URL.store(elicit_url, Ordering::Relaxed);
2325
2326 // A client that renders boolean config options gets the Inference
2327 // switch as a toggle; the rest keep the two-value select.
2328 let boolean_options = req
2329 .client_capabilities
2330 .session
2331 .as_ref()
2332 .and_then(|session| session.config_options.as_ref())
2333 .and_then(|config_options| config_options.boolean.as_ref())
2334 .is_some();
2335 log::info!("boolean config options: {boolean_options}");
2336 CLIENT_BOOLEAN_CONFIG_OPTIONS.store(boolean_options, Ordering::Relaxed);
2337
2338 // Agent-handled auth method. We don't use `AuthMethod::Terminal`: editors
2339 // like Zed advertise terminal-auth capability but don't actually spawn the
2340 // login terminal for *custom* ACP agents, so the button is a silent no-op.
2341 // With an Agent method, clicking calls `authenticate`, which opens the
2342 // browser and finishes the sign-in itself.
2343 let auth_methods = vec![AuthMethod::Agent(
2344 AuthMethodAgent::new(AUTH_METHOD_ID, "Sign in to siGit Code")
2345 .description("Opens sigit.si in your browser to authorize this device."),
2346 )];
2347
2348 Ok(InitializeResponse::new(ProtocolVersion::V1)
2349 .agent_info(
2350 Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
2351 .title("siGit Code - AI Coding Agent"),
2352 )
2353 .auth_methods(auth_methods)
2354 .agent_capabilities(
2355 AgentCapabilities::default()
2356 .load_session(true)
2357 // Gates the editor's sign out button. Without it the
2358 // only way out of an account is typing `/logout`.
2359 .auth(AgentAuthCapabilities::new().logout(LogoutCapabilities::new()))
2360 // Lets the editor attach images to a prompt. The capability is fixed
2361 // for the connection while the model is not, so a prompt that
2362 // brings an image to a model that cannot read one is answered with
2363 // a note instead (see `images_for_turn`).
2364 //
2365 // Embedded context lets the client inline a resource's
2366 // contents, e.g. an `@file` mention with unsaved edits.
2367 // Without it a client sends only a `resource_link`, and
2368 // sigit reads the file from disk.
2369 //
2370 // Audio works the way images do. No cloud tier or on-device
2371 // model takes it today, so in practice only a model on the
2372 // user's own endpoint receives it (see `audio_for_turn`).
2373 .prompt_capabilities(
2374 PromptCapabilities::new()
2375 .image(true)
2376 .audio(true)
2377 .embedded_context(true),
2378 )
2379 // Clients only pass HTTP MCP servers in `mcpServers` to an
2380 // agent that says it can reach them. SSE stays off: the
2381 // MCP spec deprecated that transport.
2382 .mcp_capabilities(McpCapabilities::new().http(true))
2383 .session_capabilities(
2384 SessionCapabilities::new()
2385 .fork(SessionForkCapabilities::new())
2386 // Durable sessions (`session_store`) are what makes
2387 // listing meaningful: without this the editor's
2388 // "Import Threads" picker reports that the agent
2389 // doesn't support ACP's session/list capability.
2390 .list(SessionListCapabilities::new())
2391 // Without this, a client that has several directories
2392 // open never sends the extra ones: Zed drops every root
2393 // but the first and tells the user the agent has no
2394 // multi-root support. See `workspace.rs`.
2395 .additional_directories(SessionAdditionalDirectoriesCapabilities::new())
2396 // Reconnect to a thread the client still has on
2397 // screen, without the replay `session/load` sends.
2398 .resume(SessionResumeCapabilities::new())
2399 // Lets the client say a thread is gone, so its
2400 // state does not sit here until the process exits.
2401 .close(SessionCloseCapabilities::new())
2402 // Without this, a thread the user removes in the
2403 // editor stays on disk and comes back in "Import
2404 // Threads".
2405 .delete(SessionDeleteCapabilities::new()),
2406 ),
2407 )
2408 .meta(initialize_meta()))
2409 }
2410
2411 /// ACP `authenticate`. Runs in a spawned task: with URL-mode elicitation
2412 /// it waits on the client's answer, which the dispatch loop has to be free
2413 /// to deliver. `request_id` is the request's own id, which scopes that
2414 /// elicitation, since no session exists yet.
2415 async fn handle_authenticate(
2416 &self,
2417 cx: &ConnectionTo<Client>,
2418 request_id: RequestId,
2419 req: AuthenticateRequest,
2420 ) -> agent_client_protocol::Result<AuthenticateResponse> {
2421 log::info!("authenticate: method={}", req.method_id.0);
2422
2423 if req.method_id.0.as_ref() != AUTH_METHOD_ID {
2424 return Err(agent_client_protocol::Error::new(
2425 ErrorCode::InvalidParams.into(),
2426 format!(
2427 "unknown auth method {}; the only one offered is {AUTH_METHOD_ID}",
2428 req.method_id.0
2429 ),
2430 ));
2431 }
2432
2433 // An existing session clears the gate without sending anyone to a
2434 // browser they didn't ask for.
2435 if let Ok(email) = account::verify_session().await {
2436 log::info!("authenticate: verified existing session for {email}");
2437 return Ok(AuthenticateResponse::default());
2438 }
2439
2440 // Otherwise run the browser flow. There is no way to collect a password
2441 // here (the spec forbids asking for credentials in a form) and no way
2442 // to take a pasted code back, so this is the loopback variant or
2443 // nothing: the editor gets an error naming the terminal command that
2444 // does work without a listener.
2445 let flow = browser_auth::begin();
2446 if !flow.is_loopback() {
2447 return Err(agent_client_protocol::Error::new(
2448 ErrorCode::InternalError.into(),
2449 "Could not open a local port to receive the sign-in. \
2450 Run `sigit login` in a terminal instead."
2451 .to_string(),
2452 ));
2453 }
2454
2455 let outcome = if CLIENT_ELICITATION_URL.load(Ordering::Relaxed) {
2456 sign_in_via_client(cx, request_id, flow).await
2457 } else {
2458 log::info!("authenticate: opening browser at {}", flow.authorize_url());
2459 if !browser_auth::open_browser(flow.authorize_url()) {
2460 log::warn!("authenticate: no browser could be launched");
2461 }
2462 browser_auth::complete_loopback(flow).await
2463 };
2464
2465 match outcome {
2466 Ok(email) => {
2467 log::info!("authenticate: signed in as {email}");
2468 Ok(AuthenticateResponse::default())
2469 }
2470 Err(reason) => Err(agent_client_protocol::Error::new(
2471 ErrorCode::InternalError.into(),
2472 format!(
2473 "Sign-in did not complete ({reason}). \
2474 Try again, or create an account at https://sigit.si."
2475 ),
2476 )),
2477 }
2478 }
2479
2480 /// ACP `logout`, the editor's sign out button. It names no session: the
2481 /// account belongs to the process, so every open thread is signed out.
2482 async fn handle_logout(
2483 &self,
2484 cx: &ConnectionTo<Client>,
2485 _req: LogoutRequest,
2486 ) -> agent_client_protocol::Result<LogoutResponse> {
2487 let message = self.sign_out(cx).await;
2488 log::info!("logout: {message}");
2489 Ok(LogoutResponse::new())
2490 }
2491
2492 /// Clear the account session, move every thread off the cloud tiers, and
2493 /// push a fresh picker to each thread whose model changed. Returns the
2494 /// message to show. Shared by ACP `logout` and the `/logout` command.
2495 async fn sign_out(&self, cx: &ConnectionTo<Client>) -> String {
2496 let message = account::end_session().await;
2497 let local = default_local_model_config();
2498 for key in self.leave_cloud(&local).await {
2499 let config_options = build_config_options(&local, &key);
2500 self.send_tool_call_update(
2501 cx,
2502 SessionId::new(key),
2503 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
2504 )
2505 .ok();
2506 }
2507 message
2508 }
2509
2510 /// Put every session that was on a siGit Code Cloud tier on `local`
2511 /// instead, and return their ids. The token is gone, so the tiers cannot
2512 /// serve them.
2513 ///
2514 /// Parked sessions are moved along with the live one. Left on a tier, a
2515 /// parked thread would fail to restore it on its next prompt and run on
2516 /// whatever model the previous thread left live.
2517 async fn leave_cloud(&self, local: &GgufModelConfig) -> Vec<String> {
2518 let on_cloud = |model_id: &str| model_id.starts_with("sigit-cloud:");
2519
2520 // Collected before the live backend moves: `reset_to_local_backend`
2521 // records the live session's new model, which would hide it here.
2522 let mut moved = Vec::new();
2523 for (key, state) in self.sessions.lock().unwrap().iter_mut() {
2524 if on_cloud(&state.model_id) {
2525 state.model_id = local.model_id.clone();
2526 moved.push(key.clone());
2527 }
2528 }
2529
2530 let live_on_cloud = on_cloud(&self.current_model.lock().unwrap().model_id);
2531 if live_on_cloud {
2532 *self.current_model.lock().unwrap() = local.clone();
2533 self.reset_to_local_backend().await;
2534 }
2535
2536 // A new thread opened in cloud mode would ask for the default tier.
2537 if live_on_cloud || !moved.is_empty() || !settings::local_inference_enabled() {
2538 let _ = settings::set_local_inference(true);
2539 }
2540 moved
2541 }
2542
2543 /// Build the state for a session request: its primary `cwd` plus the extra
2544 /// roots worth keeping (see `workspace::set_additional_roots`).
2545 fn session_state(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> SessionState {
2546 let current_model = self.current_model.lock().unwrap().model_id.clone();
2547 let model_id =
2548 if !settings::local_inference_enabled() && provider::active_provider().is_none() {
2549 format!("sigit-cloud:{}", provider::DEFAULT_CLOUD_TIER)
2550 } else {
2551 current_model
2552 };
2553 SessionState {
2554 cwd: cwd.to_path_buf(),
2555 additional_roots: workspace::filter_additional_roots(cwd, additional),
2556 model_id,
2557 conversation: Vec::new(),
2558 mcp_servers: mcp::SessionServers::default(),
2559 remote: None,
2560 }
2561 }
2562
2563 /// Take the lock that orders requests on one session (see `session_locks`).
2564 async fn lock_session(self: &Arc<Self>, session_id: &SessionId) -> SessionGuard {
2565 let key = session_id.to_string();
2566 let lock = Arc::clone(
2567 self.session_locks
2568 .lock()
2569 .unwrap()
2570 .entry(key.clone())
2571 .or_default(),
2572 );
2573 SessionGuard {
2574 guard: lock.lock_owned().await,
2575 agent: Arc::clone(self),
2576 key,
2577 }
2578 }
2579
2580 /// Keep the active session's parked metadata in step with a successful
2581 /// model/backend switch. Its conversation remains stale while active.
2582 fn record_active_model(&self) {
2583 let Some(active) = self.active_session.lock().unwrap().clone() else {
2584 return;
2585 };
2586 let model_id = self.current_model.lock().unwrap().model_id.clone();
2587 if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
2588 state.model_id = model_id;
2589 }
2590 }
2591
2592 /// Move the live conversation into the state of the session that owns it,
2593 /// so installing another session can't lose or leak it. An HTTP backend
2594 /// goes with it: the session may be waiting on that backend right now, and
2595 /// its next request has to find the same one.
2596 async fn park_active_session(&self) {
2597 let Some(active) = self.active_session.lock().unwrap().clone() else {
2598 return;
2599 };
2600 let live = self.backend.lock().await.clone();
2601 let snapshot = live.history_snapshot().await;
2602 let remote = live.is_remote().then(|| RemoteBackend {
2603 backend: live,
2604 model: self.current_model.lock().unwrap().clone(),
2605 });
2606 if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
2607 state.conversation = backend::carryover_history(snapshot);
2608 state.remote = remote;
2609 }
2610 }
2611
2612 /// Register `state` under `session_id` and make it the live session.
2613 ///
2614 /// Used by the session entry points (new/load/fork). The outgoing session
2615 /// is parked first, and the Local Inference routing runs last, so a cloud
2616 /// backend installed here starts from this session's conversation and
2617 /// system prompt and nobody else's. Reopening an existing id deliberately
2618 /// replaces its parked state (notably `session/load`); every caller holds
2619 /// `workspace_lock`, so parking and replacement cannot race another
2620 /// session being installed.
2621 async fn open_session(&self, session_id: &SessionId, state: SessionState) {
2622 self.park_active_session().await;
2623 self.open_parked_session(session_id, state).await;
2624 }
2625
2626 /// Install a new session after the caller has already parked the live one.
2627 /// Fork needs this split so it can read an up-to-date source state without
2628 /// making `open_session` snapshot the same live backend a second time.
2629 async fn open_parked_session(&self, session_id: &SessionId, state: SessionState) {
2630 let key = session_id.to_string();
2631 self.sessions
2632 .lock()
2633 .unwrap()
2634 .insert(key.clone(), state.clone());
2635 self.install_session(&key, state).await;
2636
2637 // Honor the persisted Local Inference toggle (off + signed in → cloud).
2638 self.restore_session_model(&key).await;
2639 }
2640
2641 /// Make `session_id` the live session before one of its requests runs.
2642 ///
2643 /// A no-op when it already is. Otherwise the live session is parked and
2644 /// this one's roots and conversation are installed in its place. An id this
2645 /// process has never seen (the editor kept a thread open across a sigit
2646 /// restart) is rebuilt from the session store when it can be.
2647 async fn activate_session(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
2648 let key = session_id.to_string();
2649 if self.active_session.lock().unwrap().as_deref() == Some(key.as_str()) {
2650 return Ok(());
2651 }
2652
2653 let Some(state) = self.sessions.lock().unwrap().get(&key).cloned() else {
2654 return Err(agent_client_protocol::Error::new(
2655 ErrorCode::ResourceNotFound.into(),
2656 format!(
2657 "unknown session {key}; create it with session/new or restore it with session/load"
2658 ),
2659 ));
2660 };
2661
2662 log::info!(
2663 "session({key}): activating (cwd={}, {} extra root(s), {} message(s))",
2664 state.cwd.display(),
2665 state.additional_roots.len(),
2666 state.conversation.len()
2667 );
2668 self.park_active_session().await;
2669 self.install_session(&key, state).await;
2670 self.restore_session_model(&key).await;
2671 Ok(())
2672 }
2673
2674 /// Take `workspace_lock` back after a turn gave it up, and make the turn's
2675 /// session the installed one again if another was installed meanwhile.
2676 ///
2677 /// A turn whose session cannot be reinstalled is cancelled: whatever it ran
2678 /// next would run in another session's directory and roots. The turn holds
2679 /// its session's lock, which `session/close` waits for, so nothing takes
2680 /// the session away today. The turn does not rely on that.
2681 async fn resume_workspace(
2682 &self,
2683 workspace: &mut WorkspaceHold,
2684 session_id: &SessionId,
2685 cancellation: &PromptCancellation,
2686 ) {
2687 if workspace.reacquire().await
2688 && let Err(error) = self.activate_session(session_id).await
2689 {
2690 log::warn!(
2691 "session({session_id}): could not reinstall mid-turn, cancelling the turn: {}",
2692 error.message
2693 );
2694 cancellation.cancelled.store(true, Ordering::Release);
2695 cancellation.notify.notify_one();
2696 }
2697 }
2698
2699 /// Point the process at `state`'s roots and load its conversation.
2700 ///
2701 /// The process follows `cwd` because tool calls may use relative paths; the
2702 /// extra roots go to `workspace`, where project-local discovery reads them.
2703 /// Background tasks are scoped to the live session too (see
2704 /// `tools::set_active_session`).
2705 async fn install_session(&self, key: &str, state: SessionState) {
2706 let SessionState {
2707 cwd,
2708 additional_roots,
2709 model_id,
2710 conversation,
2711 mcp_servers,
2712 remote,
2713 } = state;
2714
2715 if let Ok(mut guard) = self.session_cwd.lock() {
2716 *guard = Some(cwd.clone());
2717 }
2718 workspace::replace_additional_roots(additional_roots.clone());
2719 mcp::set_session_servers(mcp_servers);
2720 if cwd.is_dir()
2721 && let Err(err) = std::env::set_current_dir(&cwd)
2722 {
2723 log::warn!("could not set cwd to {}: {err}", cwd.display());
2724 }
2725 *self.active_session.lock().unwrap() = Some(key.to_string());
2726 tools::set_active_session(Some(key));
2727 if let Some(parked) = self.sessions.lock().unwrap().get_mut(key) {
2728 parked.remote = None;
2729 }
2730
2731 // A session that already has its own HTTP backend goes back onto it
2732 // untouched. Its conversation lives there and may be mid-turn, so it
2733 // is not reseeded from the parked copy. The engine still gets this
2734 // session's context, for a later switch to an on-device model.
2735 if let Some(RemoteBackend { backend, model }) = remote
2736 && model.model_id == model_id
2737 {
2738 self.seed_engine_context(&session_context_message(&cwd, &additional_roots))
2739 .await;
2740 *self.backend.lock().await = backend;
2741 *self.current_model.lock().unwrap() = model;
2742 return;
2743 }
2744
2745 // Otherwise the session starts from its parked conversation. The live
2746 // HTTP backend belongs to whichever session was installed before, and
2747 // that session may still be waiting on it, so this one gets a backend
2748 // of its own for the same endpoint.
2749 let fresh = self.backend.lock().await.fresh();
2750 if let Some(fresh) = fresh {
2751 *self.backend.lock().await = fresh;
2752 }
2753 self.seed_conversation(&cwd, &additional_roots, conversation)
2754 .await;
2755 }
2756
2757 /// Reset the on-device engine's conversation to just `context`.
2758 async fn seed_engine_context(&self, context: &str) {
2759 self.engine.clear_history().await;
2760 self.engine
2761 .push_history(onde::inference::ChatMessage::system(context.to_string()))
2762 .await;
2763 }
2764
2765 /// Restore the active session's model/backend after its conversation has
2766 /// been installed on the currently live backend. Model switches carry that
2767 /// freshly installed conversation across to the target backend.
2768 async fn restore_session_model(&self, key: &str) {
2769 // Explicit OpenAI-compatible provider overrides are process-wide and
2770 // intentionally take precedence over the editor's model picker.
2771 if provider::active_provider().is_some() {
2772 return;
2773 }
2774
2775 let Some(target) = self
2776 .sessions
2777 .lock()
2778 .unwrap()
2779 .get(key)
2780 .map(|state| state.model_id.clone())
2781 else {
2782 return;
2783 };
2784 let current = self.current_model.lock().unwrap().model_id.clone();
2785 if current == target {
2786 return;
2787 }
2788
2789 if let Some(tier) = target.strip_prefix("sigit-cloud:") {
2790 if self.switch_to_cloud_tier(tier).await.is_none() {
2791 log::warn!(
2792 "session({key}): cannot restore cloud model without a signed-in account"
2793 );
2794 }
2795 return;
2796 }
2797
2798 match self.switch_model_by_id(&target).await {
2799 Ok(_) => {
2800 self.reset_to_local_backend().await;
2801 let _ = settings::set_local_inference(true);
2802 }
2803 Err(error) => {
2804 log::warn!("session({key}): could not restore model {target}: {error}");
2805 }
2806 }
2807 }
2808
2809 /// Replace the conversation on the engine and the active backend with
2810 /// `conversation`, under system context naming `cwd` and `additional_roots`.
2811 ///
2812 /// Both are seeded even when only one serves inference: switching between
2813 /// on-device and cloud carries the conversation across and keeps the target's
2814 /// own system messages, so each has to name the live session's project.
2815 async fn seed_conversation(
2816 &self,
2817 cwd: &std::path::Path,
2818 additional_roots: &[PathBuf],
2819 conversation: Vec<serde_json::Value>,
2820 ) {
2821 let context = session_context_message(cwd, additional_roots);
2822 let backend = self.backend.lock().await.clone();
2823
2824 if backend.is_remote() {
2825 self.seed_engine_context(&context).await;
2826 }
2827
2828 let system_prompt = if backend.is_remote() {
2829 remote_system_prompt(&context)
2830 } else {
2831 context
2832 };
2833 let mut history = vec![serde_json::json!({ "role": "system", "content": system_prompt })];
2834 history.extend(conversation);
2835 backend.restore_history(history).await;
2836 }
2837
2838 /// Save a session's history plus the sidecar that says where it ran.
2839 ///
2840 /// The sidecar is written here rather than at session start so a thread the
2841 /// user never spoke in doesn't leave one behind, and so a listed session
2842 /// always has history to import. Only the directory the client named on the
2843 /// session request is recorded — never the process cwd, which a later
2844 /// session in the same process may have moved: a thread listed under the
2845 /// wrong project is worse than one that isn't listed at all.
2846 ///
2847 /// Each save is also reported to the client in a `session_info_update`, so
2848 /// the thread's title and `updatedAt` reach the editor's sidebar without
2849 /// waiting for the next `session/list`.
2850 async fn persist_session(
2851 &self,
2852 cx: &ConnectionTo<Client>,
2853 session_id: &SessionId,
2854 snapshot: &[serde_json::Value],
2855 ) {
2856 let key = session_id.to_string();
2857 if let Err(error) = session_store::save(&key, snapshot) {
2858 log::warn!("session({session_id}) save failed: {error}");
2859 return;
2860 }
2861 self.send_session_info(cx, session_id, snapshot);
2862 let state = self.sessions.lock().unwrap().get(&key).cloned();
2863 match state {
2864 Some(state) => session_store::save_meta(
2865 &key,
2866 &state.cwd,
2867 &state.additional_roots,
2868 Some(&state.model_id),
2869 ),
2870 None => log::warn!(
2871 "session({session_id}) has no recorded cwd — saved, but it won't be listed"
2872 ),
2873 }
2874 }
2875
2876 /// Tell the client a session was just saved: always a fresh `updatedAt`,
2877 /// and the title only when it differs from the one last sent. The update
2878 /// names no `sessionId`, `cwd` or `additionalDirectories`; the spec keeps
2879 /// those out of it.
2880 fn send_session_info(
2881 &self,
2882 cx: &ConnectionTo<Client>,
2883 session_id: &SessionId,
2884 snapshot: &[serde_json::Value],
2885 ) {
2886 let key = session_id.to_string();
2887 let mut update = SessionInfoUpdate::new();
2888 if let Some(modified) = session_store::modified(&key) {
2889 update = update.updated_at(session_store::iso8601(modified));
2890 }
2891 if let Some(title) = session_store::history_title(snapshot) {
2892 let mut announced = self.announced_titles.lock().unwrap();
2893 if announced.get(&key) != Some(&title) {
2894 announced.insert(key, title.clone());
2895 update = update.title(title);
2896 }
2897 }
2898 cx.send_notification(SessionNotification::new(
2899 session_id.clone(),
2900 SessionUpdate::SessionInfoUpdate(update),
2901 ))
2902 .ok();
2903 }
2904
2905 /// ACP `session/list`: the saved threads an editor can reopen.
2906 ///
2907 /// Only sessions with a recorded working directory qualify — `SessionInfo`
2908 /// must carry an absolute `cwd`, and the request may filter on it. Threads
2909 /// saved before sidecars existed have none and are skipped; they still load
2910 /// by id. Everything is returned in one page, so there is no cursor.
2911 async fn handle_list_sessions(
2912 &self,
2913 args: ListSessionsRequest,
2914 ) -> agent_client_protocol::Result<ListSessionsResponse> {
2915 if let Some(cwd) = args.cwd.as_ref()
2916 && !cwd.is_absolute()
2917 {
2918 return Err(agent_client_protocol::Error::new(
2919 ErrorCode::InvalidParams.into(),
2920 format!("cwd must be an absolute path, got {}", cwd.display()),
2921 ));
2922 }
2923 // A cursor is one this agent handed out. Anything else is a client
2924 // error, not a reason to start again from the first page.
2925 let cursor = match args.cursor.as_deref() {
2926 Some(cursor) => Some(SessionListCursor::decode(cursor).ok_or_else(|| {
2927 agent_client_protocol::Error::new(
2928 ErrorCode::InvalidParams.into(),
2929 "cursor is not one this agent returned from session/list",
2930 )
2931 })?),
2932 None => None,
2933 };
2934 let filter = args.cwd.clone();
2935 let entries: Vec<session_store::SessionEntry> = session_store::list()
2936 .into_iter()
2937 .filter(|entry| entry.message_count > 0)
2938 .filter(|entry| match (&entry.cwd, &filter) {
2939 (None, _) => false,
2940 (Some(cwd), Some(filter)) => same_dir(cwd, filter),
2941 (Some(_), None) => true,
2942 })
2943 .collect();
2944 let (page, next_cursor) =
2945 session_list_page(entries, cursor.as_ref(), SESSION_LIST_PAGE_SIZE);
2946 let sessions: Vec<SessionInfo> = page
2947 .into_iter()
2948 .filter_map(|entry| {
2949 Some(
2950 SessionInfo::new(SessionId::new(entry.id), entry.cwd?)
2951 .additional_directories(entry.additional_directories)
2952 .title(entry.title)
2953 .updated_at(session_store::iso8601(entry.modified)),
2954 )
2955 })
2956 .collect();
2957
2958 log::info!(
2959 "list_sessions: {} session(s){}{}",
2960 sessions.len(),
2961 args.cwd
2962 .as_ref()
2963 .map(|cwd| format!(" for cwd={}", cwd.display()))
2964 .unwrap_or_default(),
2965 if next_cursor.is_some() {
2966 ", more to come"
2967 } else {
2968 ""
2969 }
2970 );
2971
2972 Ok(ListSessionsResponse::new(sessions).next_cursor(next_cursor))
2973 }
2974
2975 async fn handle_load_session(
2976 &self,
2977 cx: &ConnectionTo<Client>,
2978 args: LoadSessionRequest,
2979 ) -> agent_client_protocol::Result<LoadSessionResponse> {
2980 let config_options = self
2981 .restore_session(
2982 cx,
2983 RestoreKind::Load,
2984 &args.session_id,
2985 &args.cwd,
2986 &args.additional_directories,
2987 &args.mcp_servers,
2988 )
2989 .await?;
2990 Ok(LoadSessionResponse::new()
2991 .config_options(config_options)
2992 .modes(build_session_modes(&args.session_id.to_string())))
2993 }
2994
2995 /// ACP `session/resume`: `session/load` without the replay.
2996 ///
2997 /// The client still has the thread on screen (the agent restarted under
2998 /// it, say) and only needs the model to remember it. The spec forbids
2999 /// sending the history back as `session/update` here.
3000 async fn handle_resume_session(
3001 &self,
3002 cx: &ConnectionTo<Client>,
3003 args: ResumeSessionRequest,
3004 ) -> agent_client_protocol::Result<ResumeSessionResponse> {
3005 let config_options = self
3006 .restore_session(
3007 cx,
3008 RestoreKind::Resume,
3009 &args.session_id,
3010 &args.cwd,
3011 &args.additional_directories,
3012 &args.mcp_servers,
3013 )
3014 .await?;
3015 Ok(ResumeSessionResponse::new()
3016 .config_options(config_options)
3017 .modes(build_session_modes(&args.session_id.to_string())))
3018 }
3019
3020 /// Bring a saved session back as the live one, for `session/load` and
3021 /// `session/resume`. The two differ only in whether the conversation is
3022 /// replayed to the client.
3023 async fn restore_session(
3024 &self,
3025 cx: &ConnectionTo<Client>,
3026 kind: RestoreKind,
3027 session_id: &SessionId,
3028 cwd: &std::path::Path,
3029 additional_directories: &[PathBuf],
3030 mcp_servers: &[McpServer],
3031 ) -> agent_client_protocol::Result<Vec<SessionConfigOption>> {
3032 validate_session_roots(cwd, additional_directories)?;
3033
3034 let method = kind.method();
3035 log::info!(
3036 "{method}: id={session_id}, cwd={}, additional_directories={:?}",
3037 cwd.display(),
3038 additional_directories
3039 .iter()
3040 .map(|p| p.display().to_string())
3041 .collect::<Vec<_>>()
3042 );
3043
3044 let session_key = session_id.to_string();
3045
3046 // The saved copy is the thread; failing that, this process may still
3047 // hold one nobody has spoken in yet (nothing is written until a turn
3048 // completes). An id found in neither place names no session, and
3049 // answering with an empty one would hand the client a blank thread it
3050 // cannot tell from a restored one. Parking first makes the in-memory
3051 // copy current when the id being restored is the live session.
3052 self.park_active_session().await;
3053 let in_memory = self
3054 .sessions
3055 .lock()
3056 .unwrap()
3057 .get(&session_key)
3058 .map(|state| state.conversation.clone());
3059 let Some(history) = session_store::load(&session_key).or(in_memory) else {
3060 log::warn!("{method}: no session {session_key}");
3061 return Err(agent_client_protocol::Error::new(
3062 ErrorCode::ResourceNotFound.into(),
3063 format!("session {session_key} not found"),
3064 ));
3065 };
3066
3067 let mut state = self.session_state(cwd, additional_directories);
3068 if let Some(model_id) = session_store::list()
3069 .into_iter()
3070 .find(|entry| entry.id == session_key)
3071 .and_then(|entry| entry.model_id)
3072 {
3073 state.model_id = model_id;
3074 }
3075
3076 // A session boundary: grants and plan mode from the previous life of
3077 // this session id must not carry over. Other sessions keep theirs; the
3078 // editor may still have them open.
3079 permissions::reset_session(&session_key);
3080
3081 // Restore the conversation. The saved system messages are dropped;
3082 // they are rebuilt from the roots the editor sent now, which may differ
3083 // from last time.
3084 let restored = history.len();
3085
3086 // On a load the client draws the reopened thread from these
3087 // notifications alone. On a resume it already has the thread.
3088 let mut replayed = 0;
3089 if kind == RestoreKind::Load {
3090 let updates = history_replay_updates(&history);
3091 replayed = updates.len();
3092 for update in updates {
3093 cx.send_notification(SessionNotification::new(session_id.clone(), update))
3094 .ok();
3095 }
3096 }
3097
3098 state.conversation = backend::carryover_history(history);
3099 state.mcp_servers = mcp::connect_session_servers(client_mcp_servers(mcp_servers)).await;
3100 log::info!(
3101 "{method}: restored {restored} message(s), replayed {replayed} update(s) for {session_id}"
3102 );
3103
3104 self.open_parked_session(session_id, state).await;
3105
3106 let config_options = {
3107 let guard = self.current_model.lock().unwrap();
3108 build_config_options(&guard, &session_key)
3109 };
3110
3111 self.advertise_commands(cx, session_id.clone());
3112
3113 // Run SessionStart hooks
3114 let hook_settings = settings::load().hooks;
3115 if hook_settings.has_hooks() {
3116 hooks::run_session_start_hooks(&hook_settings, cwd, &session_key);
3117 }
3118
3119 Ok(config_options)
3120 }
3121
3122 /// ACP `session/close`: the client is done with a thread.
3123 ///
3124 /// Drops everything this process holds for it: the parked state (and with
3125 /// it the MCP servers the client named), its permission grants and plan
3126 /// mode, and the background commands it started. The saved history stays,
3127 /// so the thread still lists and reopens. The caller has already signalled
3128 /// the session's running turn (see `begin_close`) and holds the session's
3129 /// lock and `workspace_lock`, so that turn has ended by the time this runs.
3130 ///
3131 /// An id this process does not hold is answered with success. The spec
3132 /// allows an error there, but the client wants the session gone and it is.
3133 async fn handle_close_session(
3134 &self,
3135 args: CloseSessionRequest,
3136 ) -> agent_client_protocol::Result<CloseSessionResponse> {
3137 let key = args.session_id.to_string();
3138 let known = self.sessions.lock().unwrap().remove(&key).is_some();
3139
3140 let was_live = {
3141 let mut active = self.active_session.lock().unwrap();
3142 let was_live = active.as_deref() == Some(key.as_str());
3143 if was_live {
3144 *active = None;
3145 }
3146 was_live
3147 };
3148 if was_live {
3149 // Nothing owns the live conversation or roots any more. Clearing
3150 // them here means the next session to open cannot inherit them,
3151 // and lets go of the last handle on this session's MCP servers.
3152 *self.session_cwd.lock().unwrap() = None;
3153 tools::set_active_session(None);
3154 workspace::replace_additional_roots(Vec::new());
3155 mcp::set_session_servers(mcp::SessionServers::default());
3156 self.engine.clear_history().await;
3157 let backend = self.backend.lock().await.clone();
3158 backend.restore_history(Vec::new()).await;
3159 }
3160
3161 permissions::reset_session(&key);
3162 self.announced_titles.lock().unwrap().remove(&key);
3163 let killed = tools::kill_session_tasks(&key);
3164 tools::forget_abort(&key);
3165 self.closing_sessions.lock().unwrap().remove(&key);
3166
3167 log::info!(
3168 "close_session: id={key} (known={known}, live={was_live}, {killed} background task(s) stopped)"
3169 );
3170 Ok(CloseSessionResponse::new())
3171 }
3172
3173 /// ACP `session/delete`: the user removed a thread in the editor.
3174 ///
3175 /// A live thread is closed first, exactly as `session/close` would (the
3176 /// caller has run `begin_close` and holds the same locks), so its turn,
3177 /// permission grants and background commands go with it. Then the saved
3178 /// history and its sidecar are removed, which is what takes the thread out
3179 /// of `session/list`. Deleting an id that was never saved, or is already
3180 /// gone, succeeds quietly.
3181 async fn handle_delete_session(
3182 &self,
3183 args: DeleteSessionRequest,
3184 ) -> agent_client_protocol::Result<DeleteSessionResponse> {
3185 let key = args.session_id.to_string();
3186 self.handle_close_session(CloseSessionRequest::new(args.session_id))
3187 .await?;
3188 session_store::delete(&key);
3189 log::info!("delete_session: id={key}");
3190 Ok(DeleteSessionResponse::new())
3191 }
3192
3193 /// First half of `session/close`, run before waiting for any lock.
3194 ///
3195 /// The turn to cancel is the one holding the session's lock, so the signal has to
3196 /// go out first. Marking the session as closing covers the other case: a
3197 /// prompt for it that is still queued behind another thread's turn has
3198 /// registered nothing to cancel yet, and `handle_prompt` turns it away
3199 /// when it reaches the front.
3200 fn begin_close(&self, session_id: &SessionId) {
3201 let key = session_id.to_string();
3202 self.closing_sessions.lock().unwrap().insert(key.clone());
3203 if let Some(cancellation) = self.prompt_cancellations.lock().unwrap().get(&key).cloned() {
3204 cancellation.cancelled.store(true, Ordering::Release);
3205 cancellation.notify.notify_one();
3206 }
3207 tools::abort_session_tools(&key);
3208 }
3209
3210 async fn handle_fork_session(
3211 &self,
3212 cx: &ConnectionTo<Client>,
3213 args: ForkSessionRequest,
3214 ) -> agent_client_protocol::Result<ForkSessionResponse> {
3215 validate_session_roots(&args.cwd, &args.additional_directories)?;
3216
3217 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
3218 log::info!(
3219 "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
3220 args.session_id,
3221 args.cwd.display(),
3222 args.additional_directories
3223 .iter()
3224 .map(|p| p.display().to_string())
3225 .collect::<Vec<_>>()
3226 );
3227
3228 // The fork continues the source thread's conversation under the roots
3229 // it was forked into. Permission grants and plan mode stay behind: the
3230 // new id has none. The source's conversation is current only after
3231 // parking when the source is the live session.
3232 self.park_active_session().await;
3233 let source_key = args.session_id.to_string();
3234 let source_state = self.sessions.lock().unwrap().get(&source_key).cloned();
3235 let mut state = self.session_state(&args.cwd, &args.additional_directories);
3236 if let Some(source) = source_state {
3237 state.conversation = source.conversation;
3238 state.model_id = source.model_id;
3239 } else {
3240 state.conversation = session_store::load(&source_key)
3241 .map(backend::carryover_history)
3242 .unwrap_or_default();
3243 if let Some(model_id) = session_store::list()
3244 .into_iter()
3245 .find(|entry| entry.id == source_key)
3246 .and_then(|entry| entry.model_id)
3247 {
3248 state.model_id = model_id;
3249 }
3250 }
3251 // A fork gets the servers named on its own request, not the source's.
3252 state.mcp_servers =
3253 mcp::connect_session_servers(client_mcp_servers(&args.mcp_servers)).await;
3254 let copied = state.conversation.len();
3255 let updates = history_replay_updates(&state.conversation);
3256 let replayed = updates.len();
3257 for update in updates {
3258 cx.send_notification(SessionNotification::new(new_id.clone(), update))
3259 .ok();
3260 }
3261 self.open_parked_session(&new_id, state).await;
3262 log::info!(
3263 "fork_session: copied {copied} message(s), replayed {replayed} update(s) for {new_id}"
3264 );
3265
3266 let config_options = {
3267 let guard = self.current_model.lock().unwrap();
3268 build_config_options(&guard, &new_id.to_string())
3269 };
3270
3271 self.advertise_commands(cx, new_id.clone());
3272
3273 // Run SessionStart hooks
3274 let hook_settings = settings::load().hooks;
3275 if hook_settings.has_hooks() {
3276 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &new_id.to_string());
3277 }
3278
3279 let modes = build_session_modes(&new_id.to_string());
3280 Ok(ForkSessionResponse::new(new_id)
3281 .config_options(config_options)
3282 .modes(modes))
3283 }
3284
3285 async fn handle_new_session(
3286 &self,
3287 cx: &ConnectionTo<Client>,
3288 args: NewSessionRequest,
3289 ) -> agent_client_protocol::Result<NewSessionResponse> {
3290 validate_session_roots(&args.cwd, &args.additional_directories)?;
3291
3292 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
3293 log::info!(
3294 "new_session: id={session_id}, cwd={}, additional_directories={:?}",
3295 args.cwd.display(),
3296 args.additional_directories
3297 .iter()
3298 .map(|p| p.display().to_string())
3299 .collect::<Vec<_>>()
3300 );
3301
3302 let mut state = self.session_state(&args.cwd, &args.additional_directories);
3303 state.mcp_servers =
3304 mcp::connect_session_servers(client_mcp_servers(&args.mcp_servers)).await;
3305 self.open_session(&session_id, state).await;
3306
3307 let config_options = {
3308 let guard = self.current_model.lock().unwrap();
3309 build_config_options(&guard, &session_id.to_string())
3310 };
3311
3312 self.advertise_commands(cx, session_id.clone());
3313
3314 // Run SessionStart hooks
3315 let hook_settings = settings::load().hooks;
3316 if hook_settings.has_hooks() {
3317 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &session_id.to_string());
3318 }
3319
3320 let modes = build_session_modes(&session_id.to_string());
3321 Ok(NewSessionResponse::new(session_id)
3322 .config_options(config_options)
3323 .modes(modes))
3324 }
3325
3326 async fn handle_prompt(
3327 &self,
3328 cx: &ConnectionTo<Client>,
3329 args: PromptRequest,
3330 ) -> agent_client_protocol::Result<PromptResponse> {
3331 let session_id = args.session_id.clone();
3332
3333 // `session/close` arrived while this prompt was waiting its turn.
3334 // Closing cancels the session's work, and this is part of it.
3335 if self
3336 .closing_sessions
3337 .lock()
3338 .unwrap()
3339 .contains(&session_id.to_string())
3340 {
3341 return Ok(PromptResponse::new(StopReason::Cancelled));
3342 }
3343
3344 // Registered before anything that can wait, so a `session/cancel` that
3345 // lands while this turn is still queued on the workspace, switching
3346 // sessions or loading a model is seen. The session lock is held, so the
3347 // slot can only be this turn's. The guard clears it on every return.
3348 let cancellation = Arc::new(PromptCancellation::default());
3349 tools::reset_abort(&session_id.to_string());
3350 self.prompt_cancellations
3351 .lock()
3352 .unwrap()
3353 .insert(session_id.to_string(), Arc::clone(&cancellation));
3354 let _registration = PromptRegistration {
3355 agent: self,
3356 session_id: session_id.clone(),
3357 cancellation: Arc::clone(&cancellation),
3358 };
3359
3360 // Everything below (slash commands included) acts on the live session,
3361 // so it has to be this one. Resource links are read relative to its cwd.
3362 let mut workspace = WorkspaceHold::acquire(&self.workspace_lock).await;
3363 if cancellation.cancelled.load(Ordering::Acquire) {
3364 return Ok(PromptResponse::new(StopReason::Cancelled));
3365 }
3366 self.activate_session(&session_id).await?;
3367 if cancellation.cancelled.load(Ordering::Acquire) {
3368 return Ok(PromptResponse::new(StopReason::Cancelled));
3369 }
3370
3371 // log every block so we can debug @ references and file context
3372 for (i, block) in args.prompt.iter().enumerate() {
3373 match block {
3374 ContentBlock::Text(t) => {
3375 log::info!(
3376 "prompt({}) block[{}]: Text({} chars) = \"{}\"",
3377 session_id,
3378 i,
3379 t.text.len(),
3380 t.text.chars().take(200).collect::<String>()
3381 );
3382 }
3383 ContentBlock::Resource(embedded) => {
3384 log::info!(
3385 "prompt({}) block[{}]: EmbeddedResource = {:?}",
3386 session_id,
3387 i,
3388 match &embedded.resource {
3389 EmbeddedResourceResource::TextResourceContents(t) =>
3390 format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
3391 EmbeddedResourceResource::BlobResourceContents(b) =>
3392 format!("BlobResource(uri={})", b.uri),
3393 _ => "Unknown".to_string(),
3394 }
3395 );
3396 }
3397 ContentBlock::ResourceLink(link) => {
3398 log::info!(
3399 "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})",
3400 session_id,
3401 i,
3402 link.name,
3403 link.uri,
3404 link.title,
3405 link.description
3406 );
3407 }
3408 other => {
3409 log::info!(
3410 "prompt({}) block[{}]: Other({:?})",
3411 session_id,
3412 i,
3413 std::mem::discriminant(other)
3414 );
3415 }
3416 }
3417 }
3418
3419 let mut parts: Vec<String> = Vec::new();
3420 let mut images: Vec<ImageInput> = Vec::new();
3421 let mut audio: Vec<AudioInput> = Vec::new();
3422
3423 for block in &args.prompt {
3424 match block {
3425 ContentBlock::Text(t) => {
3426 parts.push(t.text.clone());
3427 }
3428 ContentBlock::Image(image) => {
3429 images.push(ImageInput {
3430 mime_type: image.mime_type.clone(),
3431 data: image.data.clone(),
3432 });
3433 }
3434 ContentBlock::Audio(clip) => {
3435 audio.push(AudioInput {
3436 mime_type: clip.mime_type.clone(),
3437 data: clip.data.clone(),
3438 });
3439 }
3440 ContentBlock::Resource(embedded) => {
3441 // editor inlined the file content already
3442 match &embedded.resource {
3443 EmbeddedResourceResource::TextResourceContents(text_resource) => {
3444 parts.push(format!(
3445 "\n--- {} ---\n{}\n--- end {} ---",
3446 text_resource.uri, text_resource.text, text_resource.uri
3447 ));
3448 }
3449 EmbeddedResourceResource::BlobResourceContents(blob) => {
3450 let mime = blob.mime_type.clone().unwrap_or_default();
3451 if mime.starts_with("image/") {
3452 images.push(ImageInput {
3453 mime_type: mime,
3454 data: blob.blob.clone(),
3455 });
3456 } else if mime.starts_with("audio/") {
3457 audio.push(AudioInput {
3458 mime_type: mime,
3459 data: blob.blob.clone(),
3460 });
3461 } else if mime.is_empty() {
3462 parts.push(format!("[binary resource: {}]", blob.uri));
3463 } else {
3464 parts.push(format!("[binary resource: {} ({mime})]", blob.uri));
3465 }
3466 }
3467 _ => {
3468 log::debug!("ignoring unsupported embedded resource variant");
3469 }
3470 }
3471 }
3472 ContentBlock::ResourceLink(link) => {
3473 // reference without content; read the file ourselves
3474 let label = link.name.clone();
3475
3476 if let Some((file_path, line_range)) = parse_file_link(&link.uri) {
3477 match std::fs::read_to_string(&file_path) {
3478 Ok(contents) => {
3479 let extracted = if let Some((start, end)) = line_range {
3480 let selected: Vec<&str> = contents
3481 .lines()
3482 .enumerate()
3483 .filter(|(i, _)| {
3484 let line_num = i + 1;
3485 line_num >= start && line_num <= end
3486 })
3487 .map(|(_, line)| line)
3488 .collect();
3489 format!(
3490 "\n--- {label} ({} lines {start}-{end}) ---\n{}\n--- end {label} ---",
3491 file_path.display(),
3492 selected.join("\n")
3493 )
3494 } else {
3495 format!(
3496 "\n--- {label} ({}) ---\n{contents}\n--- end {label} ---",
3497 file_path.display()
3498 )
3499 };
3500 parts.push(extracted);
3501 }
3502 Err(err) => {
3503 log::warn!("could not read ResourceLink {}: {err}", link.uri);
3504 parts.push(format!(
3505 "[referenced file: {label} ({})]",
3506 file_path.display()
3507 ));
3508 }
3509 }
3510 } else {
3511 parts.push(format!("[resource link: {label} ({})]", link.uri));
3512 }
3513 }
3514 _ => {
3515 log::debug!("ignoring unsupported content block type in prompt");
3516 }
3517 }
3518 }
3519
3520 let user_text = parts.join("\n");
3521
3522 if user_text.trim().is_empty() && images.is_empty() && audio.is_empty() {
3523 return Ok(PromptResponse::new(StopReason::EndTurn));
3524 }
3525
3526 let slash_command = parse_slash_from_prompt_texts(args.prompt.iter().filter_map(|block| {
3527 if let ContentBlock::Text(text) = block {
3528 Some(text.text.as_str())
3529 } else {
3530 None
3531 }
3532 }));
3533
3534 let user_text = match slash_command {
3535 // `/init` is not a status command: it substitutes the canned
3536 // AGENTS.md-generation prompt and runs a normal agent turn, so the
3537 // exploration and file write go through the ordinary tools and
3538 // permission checks.
3539 Some(SlashCommand::Init) => instructions::INIT_PROMPT.to_string(),
3540 // A word `parse_slash` didn't recognize might be a user-defined
3541 // command (`.sigit/commands/*.md`) — same substitute-and-run-a-
3542 // real-turn treatment as `/init`. Only fall back to "unknown
3543 // command" once that lookup also misses.
3544 Some(SlashCommand::Unknown(command, argument)) => {
3545 match commands::resolve_command(&command) {
3546 Some(custom) => commands::render(&custom.body, argument.as_deref()),
3547 None => {
3548 return self
3549 .run_slash(
3550 cx,
3551 session_id,
3552 SlashCommand::Unknown(command, argument),
3553 &cancellation,
3554 )
3555 .await;
3556 }
3557 }
3558 }
3559 Some(command) => return self.run_slash(cx, session_id, command, &cancellation).await,
3560 None => user_text,
3561 };
3562
3563 log::info!(
3564 "prompt({}): \"{}\"",
3565 session_id,
3566 user_text.chars().take(80).collect::<String>()
3567 );
3568
3569 // The active backend drives the turn. Snapshot it once so a mid-turn
3570 // model switch doesn't split the conversation across backends.
3571 let backend = self.backend.lock().await.clone();
3572 // Waiting on an HTTP endpoint uses nothing process-wide, so another
3573 // session may be installed meanwhile. The on-device engine holds one
3574 // conversation, so an on-device turn keeps the workspace throughout.
3575 let release_while_waiting = backend.is_remote();
3576
3577 // Only on-device inference needs a local model in memory. Cloud tiers run
3578 // over the network, so they never need a local model. Xcode's explicit
3579 // `--acp` mode loads lazily on the first prompt because its custom-agent
3580 // UI has no reliable equivalent of siGit's `/load` command.
3581 if !backend.is_remote()
3582 && self.engine.info().await.status == onde::inference::EngineStatus::Unloaded
3583 {
3584 if self.auto_load_local_model {
3585 self.start_startup_model_load_if_needed();
3586 tokio::select! {
3587 ready = self.await_model_ready(cx, &session_id) => ready?,
3588 () = cancellation.cancelled() => {
3589 return Ok(PromptResponse::new(StopReason::Cancelled));
3590 }
3591 }
3592 } else {
3593 self.send_assistant_message(
3594 cx,
3595 session_id,
3596 "No on-device model is loaded. Run `/load` to load the selected model, \
3597 or `/models` to choose one.",
3598 )
3599 .ok();
3600 return Ok(PromptResponse::new(StopReason::EndTurn));
3601 }
3602 }
3603
3604 if cancellation.cancelled.load(Ordering::Acquire) {
3605 return Ok(PromptResponse::new(StopReason::Cancelled));
3606 }
3607
3608 // ── tool-calling loop ────────────────────────────────────────────
3609 // send message → execute any tool calls → feed results back
3610 // repeat up to the tool-round cap, then force a text reply
3611
3612 let tools = agent_tools_as_specs();
3613 let max_tool_rounds = headless::max_tool_rounds_from_env();
3614
3615 let model_name = self.current_model.lock().unwrap().display_name.clone();
3616 let context_window = self.context_window_tokens();
3617 let earlier_images = if backend.accepts_images() {
3618 0
3619 } else {
3620 backend
3621 .history_snapshot()
3622 .await
3623 .iter()
3624 .map(backend::message_image_count)
3625 .sum()
3626 };
3627 let (user_text, images, image_notice) = images_for_turn(
3628 user_text,
3629 images,
3630 backend.accepts_images(),
3631 &model_name,
3632 earlier_images,
3633 &session_id.to_string(),
3634 );
3635 let earlier_audio = if backend.accepts_audio() {
3636 0
3637 } else {
3638 backend
3639 .history_snapshot()
3640 .await
3641 .iter()
3642 .map(backend::message_audio_count)
3643 .sum()
3644 };
3645 let (user_text, audio, audio_notice) = audio_for_turn(
3646 user_text,
3647 audio,
3648 backend.accepts_audio(),
3649 &model_name,
3650 earlier_audio,
3651 &session_id.to_string(),
3652 );
3653 for notice in [image_notice, audio_notice].into_iter().flatten() {
3654 self.send_assistant_message(
3655 cx,
3656 session_id.clone(),
3657 format!("{notice}{PARAGRAPH_BREAK}"),
3658 )
3659 .ok();
3660 }
3661
3662 // Token sink: backends stream assistant text through this while a turn
3663 // runs. We forward the visible portion to the editor as agent-message
3664 // chunks live, and the reasoning portion as thought chunks (see
3665 // `drain_turn` / `emit_token_chunk`). The sink stays alive for the whole
3666 // prompt so `recv()` only ends when a turn future resolves, never
3667 // because every sender was dropped.
3668 let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<TokenChunk>();
3669 let mut reply = StreamedReply::default();
3670 let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
3671
3672 // Kept so a refused turn can be taken back out of history (see the end
3673 // of this function). Only a remote endpoint reports a refusal, and an
3674 // on-device snapshot would not survive the round trip intact anyway.
3675 let history_before_turn = if backend.is_remote() {
3676 Some(backend.history_snapshot().await)
3677 } else {
3678 None
3679 };
3680
3681 if release_while_waiting {
3682 workspace.release();
3683 }
3684 let outcome = self
3685 .drain_turn(
3686 cx,
3687 &session_id,
3688 backend.send_message_with_attachments(
3689 &user_text,
3690 &images,
3691 &audio,
3692 &tools,
3693 Some(&sink),
3694 ),
3695 &mut sink_rx,
3696 &mut reply,
3697 &cancellation,
3698 )
3699 .await;
3700 self.resume_workspace(&mut workspace, &session_id, &cancellation)
3701 .await;
3702 let mut result = match outcome {
3703 Ok(result) => result,
3704 Err(DrainTurnError::Cancelled) => {
3705 backend::forget_trailing_user_message(backend.as_ref()).await;
3706 self.finish_prompt(&session_id, &cancellation);
3707 return Ok(PromptResponse::new(StopReason::Cancelled));
3708 }
3709 Err(DrainTurnError::Backend(error)) => {
3710 log::error!("send_message_with_tools failed: {error}");
3711 // Re-emit the persisted plan so the client panel doesn't go
3712 // empty when the initial inference call fails before the model
3713 // can call `write_todos`.
3714 if let Some(plan) = persisted_todos_to_plan() {
3715 let _ = self.send_plan_update(cx, session_id.clone(), plan);
3716 }
3717 // Verbatim, no prefix: the backend has already turned this into
3718 // something worth reading (see `describe_api_error`), and it is
3719 // what the editor puts in its error banner. The context a
3720 // prefix would add is in the log line above.
3721 // The client keeps this prompt in its thread after the error,
3722 // so keep it in the saved conversation too.
3723 let snapshot = backend.history_snapshot().await;
3724 self.persist_session(cx, &session_id, &snapshot).await;
3725 self.finish_prompt(&session_id, &cancellation);
3726 return Err(agent_client_protocol::Error::new(
3727 ErrorCode::InternalError.into(),
3728 error,
3729 ));
3730 }
3731 };
3732
3733 self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
3734 .await;
3735
3736 let mut round = 0;
3737 // Set when the repetition guard blocked a call; names the tool.
3738 let mut stopped_repeating: Option<String> = None;
3739 // Visible text already sent before the latest inference round, so the
3740 // end of the turn can tell whether that round said anything at all.
3741 let mut sent_before_last_round = 0;
3742
3743 while !result.tool_calls.is_empty() && round < max_tool_rounds {
3744 if cancellation.cancelled.load(Ordering::Acquire) {
3745 self.finish_prompt(&session_id, &cancellation);
3746 return Ok(PromptResponse::new(StopReason::Cancelled));
3747 }
3748 round += 1;
3749 log::info!(
3750 "prompt({}) tool round {} — {} call(s)",
3751 session_id,
3752 round,
3753 result.tool_calls.len()
3754 );
3755
3756 // Auto-compaction: long tool runs grow history fast; fold it into
3757 // a summary before the next round rather than blowing the window.
3758 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
3759 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
3760 log::info!(
3761 "prompt({}) history ≈{} tokens exceeds budget {} — compacting",
3762 session_id,
3763 estimate,
3764 backend::DEFAULT_CONTEXT_TOKEN_BUDGET
3765 );
3766 match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
3767 Ok(()) => {
3768 let after = backend::estimate_tokens(&backend.history_snapshot().await);
3769 log::info!("prompt({}) compacted to ≈{} tokens", session_id, after);
3770 }
3771 Err(error) => {
3772 // Even the deterministic fallback could not fit the
3773 // history in the budget — the session is genuinely
3774 // stuck, so say what is actually recoverable.
3775 log::warn!("prompt({}) compaction failed: {error}", session_id);
3776 self.send_assistant_message(
3777 cx,
3778 session_id.clone(),
3779 format!(
3780 "This session is too large for the context window, and \
3781 siGit Code could not shrink it ({error}). Run `/clear` \
3782 or start a new thread to continue."
3783 ),
3784 )
3785 .ok();
3786 self.finish_prompt(&session_id, &cancellation);
3787 return Ok(PromptResponse::new(StopReason::EndTurn));
3788 }
3789 }
3790 }
3791
3792 let mut tool_results = Vec::new();
3793 let mut force_text = false;
3794
3795 for (call_index, tc) in result.tool_calls.iter().enumerate() {
3796 log::info!(
3797 " → {}({})",
3798 tc.name,
3799 tc.arguments.chars().take(120).collect::<String>()
3800 );
3801
3802 let signature = format!("{}\n{}", tc.name, tc.arguments);
3803 let repeat_count = repeated_tool_calls
3804 .entry(signature)
3805 .and_modify(|count| *count += 1)
3806 .or_insert(1);
3807 // Repeated status checks are expected for a live background
3808 // command. command_output long-polls and the overall round cap
3809 // still bounds a confused model without cutting off a real
3810 // build or release while it is in progress.
3811 let repeated = *repeat_count >= 3 && tc.name != "command_output";
3812 if repeated {
3813 force_text = true;
3814 stopped_repeating = Some(tc.name.clone());
3815 log::warn!(
3816 "prompt({}) stopping repeated tool call `{}` after {} attempts",
3817 session_id,
3818 tc.name,
3819 repeat_count
3820 );
3821 }
3822
3823 // Decided before the call is announced, because the answer sets
3824 // the status it is announced with: a call that has to wait for
3825 // the user starts out `pending`, everything else is already
3826 // running. A repeated call is never run, so never asked about.
3827 let decision = (!repeated).then(|| {
3828 permissions::decision_for(&session_id.to_string(), &tc.name, &tc.arguments)
3829 });
3830 let announced_status = match decision {
3831 Some(permissions::Decision::Ask) => ToolCallStatus::Pending,
3832 _ => ToolCallStatus::InProgress,
3833 };
3834
3835 // Only treat the call as a plan once its arguments have actually
3836 // produced one. Keying off the tool name alone means a
3837 // `write_todos` the converter rejects sends no plan *and* skips
3838 // the tool-call card below, so the call disappears from the
3839 // client while the model is told the list was updated.
3840 let plan = (tc.name == "write_todos")
3841 .then(|| todos_arguments_to_plan(&tc.arguments))
3842 .flatten();
3843 let render_as_plan = plan.is_some();
3844 if let Some(plan) = plan {
3845 self.send_plan_update(cx, session_id.clone(), plan).ok();
3846 } else {
3847 // Model tool calls are agent actions in ACP too, not merely an
3848 // implementation detail of the completion loop. Announce each
3849 // one before it runs and close it afterwards so clients such as
3850 // Zed can render activity while the next inference round is in
3851 // flight. Previously only model loading emitted ToolCall events,
3852 // leaving an otherwise working tool round visually indistinct
3853 // from a stalled response.
3854 let raw_input: serde_json::Value = serde_json::from_str(&tc.arguments)
3855 .unwrap_or_else(|e| {
3856 log::warn!("malformed JSON in tool arguments for '{}': {e}", tc.name);
3857 serde_json::Value::String(tc.arguments.clone())
3858 });
3859 let invocation_content = tool_arguments_content(&tc.arguments);
3860 self.send_tool_call_update(
3861 cx,
3862 session_id.clone(),
3863 SessionUpdate::ToolCall(
3864 ToolCall::new(tc.id.clone(), chat::tool_title(&tc.name, &tc.arguments))
3865 .name(tc.name.clone())
3866 .kind(tool_kind_for(&tc.name))
3867 .status(announced_status)
3868 .content(vec![invocation_content])
3869 .locations(tool_call_locations(&tc.name, &tc.arguments))
3870 .raw_input(raw_input),
3871 ),
3872 )
3873 .ok();
3874 }
3875
3876 // Permission gate: read-only tools pass straight through; a
3877 // mutating tool consults policy and may ask the client. The
3878 // flag says whether the tool actually ran: a call that was
3879 // denied or skipped ends `failed`, not `completed` (#139).
3880 let mut change = None;
3881 let mut terminal = None;
3882 let (output, executed) = match decision {
3883 None => (
3884 format!(
3885 "The tool `{}` was not executed again because the model repeated \
3886 the same call three times. Continue without this tool.",
3887 tc.name
3888 ),
3889 false,
3890 ),
3891 Some(decision) => match decision {
3892 permissions::Decision::Allow => {
3893 let outcome = tools::execute_tool_with_change(
3894 &tc.name,
3895 &tc.arguments,
3896 Some(&tc.id),
3897 )
3898 .await;
3899 change = outcome.change;
3900 terminal = outcome.terminal;
3901 (outcome.output, true)
3902 }
3903 permissions::Decision::Deny(reason) => {
3904 log::info!(" ✗ {} denied by policy", tc.name);
3905 (reason, false)
3906 }
3907 permissions::Decision::Ask => {
3908 // Worked out while the session still holds the
3909 // workspace: a relative path resolves against
3910 // its cwd.
3911 let preview = tools::preview_file_change(&tc.name, &tc.arguments).await;
3912 // The answer can take as long as the user likes.
3913 if release_while_waiting {
3914 workspace.release();
3915 }
3916 // A client that cancels the turn answers the
3917 // request itself. One that closes the session
3918 // need not, so the wait ends on either.
3919 let verdict = tokio::select! {
3920 verdict = self.request_tool_permission(
3921 cx,
3922 &session_id,
3923 &tc.id,
3924 &tc.name,
3925 &tc.arguments,
3926 preview.as_ref(),
3927 ) => verdict,
3928 _ = cancellation.cancelled() => PermissionVerdict::TurnCancelled,
3929 };
3930 self.resume_workspace(&mut workspace, &session_id, &cancellation)
3931 .await;
3932 // An approval can cross a cancellation on the wire,
3933 // or arrive while this turn waited for the lock.
3934 let verdict = if cancellation.cancelled.load(Ordering::Acquire) {
3935 PermissionVerdict::TurnCancelled
3936 } else {
3937 verdict
3938 };
3939 match verdict {
3940 PermissionVerdict::Approved => {
3941 // The call the user just approved leaves
3942 // `pending` here. The title goes back to
3943 // the card's own: the permission request
3944 // replaced it with the full arguments.
3945 if !render_as_plan {
3946 self.send_tool_call_update(
3947 cx,
3948 session_id.clone(),
3949 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3950 tc.id.clone(),
3951 ToolCallUpdateFields::new()
3952 .status(ToolCallStatus::InProgress)
3953 .title(chat::tool_title(
3954 &tc.name,
3955 &tc.arguments,
3956 )),
3957 )),
3958 )
3959 .ok();
3960 }
3961 let outcome = tools::execute_tool_with_change(
3962 &tc.name,
3963 &tc.arguments,
3964 Some(&tc.id),
3965 )
3966 .await;
3967 change = outcome.change;
3968 terminal = outcome.terminal;
3969 (outcome.output, true)
3970 }
3971 PermissionVerdict::Denied(reason) => {
3972 log::info!(" ✗ {} denied by user", tc.name);
3973 (reason, false)
3974 }
3975 PermissionVerdict::TurnCancelled => {
3976 log::info!(
3977 "prompt({}) cancelled at permission gate",
3978 session_id
3979 );
3980 // The assistant message carrying these tool
3981 // calls is already in the backend history;
3982 // leaving any of them unanswered makes strict
3983 // OpenAI-compatible endpoints reject every
3984 // later request in the session. Close out this
3985 // call and the ones this round never reached.
3986 for pending in &result.tool_calls[call_index..] {
3987 tool_results.push(BackendToolResult {
3988 tool_call_id: pending.id.clone(),
3989 content: format!(
3990 "`{}` was not executed: the user cancelled the turn \
3991 at the permission prompt.",
3992 pending.name
3993 ),
3994 });
3995 }
3996 let cancelled_reason = "Not executed: the user cancelled the turn at the permission prompt.";
3997 self.send_tool_call_update(
3998 cx,
3999 session_id.clone(),
4000 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
4001 tc.id.clone(),
4002 ToolCallUpdateFields::new()
4003 .status(ToolCallStatus::Failed)
4004 .content(vec![tool_output_content(
4005 cancelled_reason,
4006 )])
4007 .raw_output(serde_json::Value::String(
4008 cancelled_reason.to_string(),
4009 )),
4010 )),
4011 )
4012 .ok();
4013 backend.record_cancelled_tool_results(tool_results).await;
4014 self.finish_prompt(&session_id, &cancellation);
4015 return Ok(PromptResponse::new(StopReason::Cancelled));
4016 }
4017 }
4018 }
4019 },
4020 };
4021
4022 log::info!(" ← {} chars", output.len());
4023
4024 if !render_as_plan {
4025 let mut fields = ToolCallUpdateFields::new()
4026 .status(if executed {
4027 ToolCallStatus::Completed
4028 } else {
4029 ToolCallStatus::Failed
4030 })
4031 .raw_output(serde_json::Value::String(output.clone()));
4032 if terminal.is_none() {
4033 fields = fields.content(tool_result_content(&output, change.as_ref()));
4034 }
4035 // A denial at the prompt leaves the card titled with the
4036 // permission request's full arguments; put its own back.
4037 if !executed && announced_status == ToolCallStatus::Pending {
4038 fields = fields.title(chat::tool_title(&tc.name, &tc.arguments));
4039 }
4040 self.send_tool_call_update(
4041 cx,
4042 session_id.clone(),
4043 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(tc.id.clone(), fields)),
4044 )
4045 .ok();
4046 }
4047
4048 tool_results.push(BackendToolResult {
4049 tool_call_id: tc.id.clone(),
4050 content: output,
4051 });
4052 // A cancel aborts a running foreground command (see
4053 // `tools::abort_session_tools`), so it exits and is observed
4054 // here. Background commands are stopped with `kill_command`.
4055 if cancellation.cancelled.load(Ordering::Acquire) {
4056 for pending in &result.tool_calls[call_index + 1..] {
4057 tool_results.push(BackendToolResult {
4058 tool_call_id: pending.id.clone(),
4059 content: format!(
4060 "`{}` was not executed: the user cancelled the turn.",
4061 pending.name
4062 ),
4063 });
4064 }
4065 backend.record_cancelled_tool_results(tool_results).await;
4066 self.finish_prompt(&session_id, &cancellation);
4067 return Ok(PromptResponse::new(StopReason::Cancelled));
4068 }
4069 }
4070
4071 let round_cap_reached = round >= max_tool_rounds;
4072 let allow_tool_calls = !round_cap_reached && !force_text;
4073 if round_cap_reached && let Some(last) = tool_results.last_mut() {
4074 last.content.push_str(&round_cap_note(max_tool_rounds));
4075 }
4076
4077 // Whatever this round says starts a new paragraph rather than
4078 // continuing the sentence the tool calls interrupted.
4079 reply.interrupt();
4080 sent_before_last_round = reply.sent.len();
4081
4082 let cancelled_results = tool_results.clone();
4083 if release_while_waiting {
4084 workspace.release();
4085 }
4086 let outcome = self
4087 .drain_turn(
4088 cx,
4089 &session_id,
4090 backend.send_tool_results(tool_results, &tools, allow_tool_calls, Some(&sink)),
4091 &mut sink_rx,
4092 &mut reply,
4093 &cancellation,
4094 )
4095 .await;
4096 self.resume_workspace(&mut workspace, &session_id, &cancellation)
4097 .await;
4098 result = match outcome {
4099 Ok(result) => result,
4100 Err(DrainTurnError::Cancelled) => {
4101 backend
4102 .record_cancelled_tool_results(cancelled_results)
4103 .await;
4104 self.finish_prompt(&session_id, &cancellation);
4105 return Ok(PromptResponse::new(StopReason::Cancelled));
4106 }
4107 Err(DrainTurnError::Backend(error)) => {
4108 log::error!("send_tool_results failed: {error}");
4109 // Re-emit the persisted plan so the client panel doesn't go
4110 // empty when a tool round fails mid-turn.
4111 if let Some(plan) = persisted_todos_to_plan() {
4112 let _ = self.send_plan_update(cx, session_id.clone(), plan);
4113 }
4114 self.finish_prompt(&session_id, &cancellation);
4115 return Err(agent_client_protocol::Error::new(
4116 ErrorCode::InternalError.into(),
4117 error,
4118 ));
4119 }
4120 };
4121 self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
4122 .await;
4123 }
4124
4125 // ── Final text response ───────────────────────────────────────────
4126 // If anything streamed, the visible reply is already on the wire; only
4127 // send a trailing block for the non-streamed path (e.g. on-device direct
4128 // answers, which onde can't stream while tools are on offer).
4129 let round_cap_reached = round >= max_tool_rounds;
4130 if !reply.streamed_any {
4131 let reply_text = result.text.trim().to_string();
4132 let final_text = if reply_text.is_empty() {
4133 if round_cap_reached {
4134 // The cap notice below covers it.
4135 String::new()
4136 } else if round > 0 {
4137 log::warn!(
4138 "prompt({}) — model returned empty reply after {} tool round(s)",
4139 session_id,
4140 round
4141 );
4142 silent_stop_message(stopped_repeating.as_deref(), round)
4143 } else {
4144 log::warn!(
4145 "prompt({}) — model returned empty reply (no tool rounds)",
4146 session_id
4147 );
4148 String::new()
4149 }
4150 } else {
4151 // strip <think> blocks so reasoning tokens stay hidden
4152 let (_think, visible) = chat::strip_think_blocks(&reply_text);
4153 visible
4154 };
4155
4156 if !final_text.is_empty() {
4157 let message_id = reply.message_id();
4158 self.send_reply_chunk(cx, session_id.clone(), message_id, final_text)
4159 .ok();
4160 }
4161 } else if round > 0 && !round_cap_reached && reply.sent.len() == sent_before_last_round {
4162 // Earlier rounds streamed text, but the last one (typically the
4163 // forced no-tools round) said nothing. Without a closing message
4164 // the turn just stops, and the user can't tell whether it ended,
4165 // stalled, or is waiting on them.
4166 log::warn!(
4167 "prompt({}) — final round after {} tool round(s) produced no text",
4168 session_id,
4169 round
4170 );
4171 let message_id = reply.message_id();
4172 self.send_reply_chunk(
4173 cx,
4174 session_id.clone(),
4175 message_id,
4176 format!(
4177 "{PARAGRAPH_BREAK}{}",
4178 silent_stop_message(stopped_repeating.as_deref(), round)
4179 ),
4180 )
4181 .ok();
4182 }
4183
4184 if round_cap_reached {
4185 log::warn!(
4186 "prompt({}) stopped at the cap of {} tool round(s)",
4187 session_id,
4188 max_tool_rounds
4189 );
4190 let message_id = reply.message_id();
4191 self.send_reply_chunk(
4192 cx,
4193 session_id.clone(),
4194 message_id,
4195 format!(
4196 "{PARAGRAPH_BREAK}{}",
4197 round_cap_stop_message(max_tool_rounds)
4198 ),
4199 )
4200 .ok();
4201 }
4202
4203 let stop_reason = stop_reason_for(round_cap_reached, result.finish);
4204
4205 // ACP defines a refusal as a turn the next prompt will not include:
4206 // the client drops the user message and everything after it from its
4207 // own view. Take the turn out of history too, or the model keeps
4208 // answering a conversation the user can no longer see.
4209 if stop_reason == StopReason::Refusal
4210 && let Some(history) = history_before_turn
4211 {
4212 log::warn!(
4213 "prompt({}) refused by the endpoint — dropping the turn from history",
4214 session_id
4215 );
4216 backend.restore_history(history).await;
4217 }
4218
4219 // Persist the completed turn so a restart (or session/load) can pick
4220 // the conversation back up.
4221 let snapshot = backend.history_snapshot().await;
4222 self.persist_session(cx, &session_id, &snapshot).await;
4223 self.finish_prompt(&session_id, &cancellation);
4224
4225 log::info!(
4226 "prompt({}) complete — {} tool round(s), stop reason {:?}",
4227 session_id,
4228 round,
4229 stop_reason
4230 );
4231 Ok(PromptResponse::new(stop_reason))
4232 }
4233
4234 /// The context window of the session's model, for `usage_update`. A model
4235 /// the picker does not list (a provider-supplied name, say) falls back to
4236 /// the compaction budget, the same as the TUI's gauge.
4237 fn context_window_tokens(&self) -> u64 {
4238 let current = self.current_model.lock().unwrap().clone();
4239 models::build_model_picker_items()
4240 .into_iter()
4241 .find(|item| {
4242 item.config.model_id == current.model_id
4243 || item.display_name == current.display_name
4244 })
4245 .map(|item| item.context_window_tokens)
4246 .unwrap_or(backend::DEFAULT_CONTEXT_TOKEN_BUDGET as u64)
4247 }
4248
4249 /// Report how much of the context window the session uses after one
4250 /// model response. The endpoint's own count is used when it gave one;
4251 /// otherwise (always on-device) the history is estimated, the way
4252 /// compaction does it.
4253 async fn send_usage(
4254 &self,
4255 cx: &ConnectionTo<Client>,
4256 session_id: &SessionId,
4257 backend: &dyn InferenceBackend,
4258 result: &TurnResult,
4259 size: u64,
4260 ) {
4261 let used = match result.context_tokens {
4262 Some(tokens) => tokens,
4263 None => backend::estimate_tokens(&backend.history_snapshot().await) as u64,
4264 };
4265 cx.send_notification(SessionNotification::new(
4266 session_id.clone(),
4267 SessionUpdate::UsageUpdate(UsageUpdate::new(used, size)),
4268 ))
4269 .ok();
4270 }
4271
4272 /// Ask the ACP client for permission to run one tool call. The request
4273 /// names the call by `tool_call_id`, the id it was announced under, so the
4274 /// client updates that one card instead of drawing a second. Presents
4275 /// allow-once / allow-for-session / deny / deny-for-session; an "always"
4276 /// choice is recorded via [`permissions::grant_for_session`] or
4277 /// [`permissions::deny_for_session`]. Only safe to call from
4278 /// a spawned task (see the handler registration in `run_acp_server`): the
4279 /// dispatch loop must be free to route the client's answer back to us.
4280 ///
4281 /// `preview` is the file change the call would make, from
4282 /// [`tools::preview_file_change`]; when there is one the card shows it as
4283 /// a diff, so the user approves an edit by looking at the change.
4284 async fn request_tool_permission(
4285 &self,
4286 cx: &ConnectionTo<Client>,
4287 session_id: &SessionId,
4288 tool_call_id: &str,
4289 tool_name: &str,
4290 arguments: &str,
4291 preview: Option<&tools::FileChange>,
4292 ) -> PermissionVerdict {
4293 // The user decides from this dialog, so show the arguments with any
4294 // truncation flagged (a silently clipped command could hide its tail
4295 // from the person approving it). The full arguments also travel as
4296 // `raw_input` for clients that render it.
4297 let args_preview = permissions::approval_preview(arguments);
4298 let title = if args_preview.is_empty() {
4299 tool_name.to_string()
4300 } else {
4301 format!("{tool_name}({args_preview})")
4302 };
4303 let raw_input: serde_json::Value = serde_json::from_str(arguments).unwrap_or_else(|e| {
4304 log::warn!("malformed JSON in tool arguments for '{}': {e}", tool_name);
4305 serde_json::Value::String(arguments.to_string())
4306 });
4307
4308 let request = RequestPermissionRequest::new(
4309 session_id.clone(),
4310 ToolCallUpdate::new(
4311 tool_call_id.to_string(),
4312 ToolCallUpdateFields::new()
4313 .title(title)
4314 .kind(tool_kind_for(tool_name))
4315 .status(ToolCallStatus::Pending)
4316 .content(preview.map(|change| vec![file_change_content(change)]))
4317 .raw_input(raw_input),
4318 ),
4319 vec![
4320 PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
4321 PermissionOption::new(
4322 "allow_session",
4323 "Allow for this session",
4324 PermissionOptionKind::AllowAlways,
4325 ),
4326 PermissionOption::new("reject_once", "Deny", PermissionOptionKind::RejectOnce),
4327 PermissionOption::new(
4328 "reject_session",
4329 "Deny for this session",
4330 PermissionOptionKind::RejectAlways,
4331 ),
4332 ],
4333 );
4334
4335 match cx.send_request(request).block_task().await {
4336 Ok(response) => match response.outcome {
4337 RequestPermissionOutcome::Selected(selected) => {
4338 match selected.option_id.0.as_ref() {
4339 "allow_once" => PermissionVerdict::Approved,
4340 "allow_session" => {
4341 permissions::grant_for_session(
4342 &session_id.to_string(),
4343 tool_name,
4344 arguments,
4345 );
4346 PermissionVerdict::Approved
4347 }
4348 "reject_session" => {
4349 permissions::deny_for_session(
4350 &session_id.to_string(),
4351 tool_name,
4352 arguments,
4353 );
4354 PermissionVerdict::Denied(permissions::user_denial(tool_name))
4355 }
4356 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
4357 }
4358 }
4359 RequestPermissionOutcome::Cancelled => PermissionVerdict::TurnCancelled,
4360 // The outcome enum is non_exhaustive; treat anything unknown as
4361 // a denial rather than running a mutating tool unapproved.
4362 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
4363 },
4364 Err(error) => {
4365 log::warn!("permission request for `{tool_name}` failed: {error}");
4366 PermissionVerdict::Denied(format!(
4367 "`{tool_name}` was not executed: this client could not answer the \
4368 permission request ({error}). The user can pre-approve tools in \
4369 settings.toml under [permissions], or set SIGIT_PERMISSIONS=allow \
4370 for clients without permission support."
4371 ))
4372 }
4373 }
4374 }
4375
4376 /// Run a built-in slash command as a turn. A `session/cancel` ends it with
4377 /// `Cancelled`; only `/compact` and `/load` take long enough to notice.
4378 async fn run_slash(
4379 &self,
4380 cx: &ConnectionTo<Client>,
4381 session_id: SessionId,
4382 command: SlashCommand,
4383 cancellation: &PromptCancellation,
4384 ) -> agent_client_protocol::Result<PromptResponse> {
4385 if cancellation.cancelled.load(Ordering::Acquire) {
4386 return Ok(PromptResponse::new(StopReason::Cancelled));
4387 }
4388 tokio::select! {
4389 response = exec_slash_acp(self, cx, session_id, command) => response,
4390 () = cancellation.cancelled() => Ok(PromptResponse::new(StopReason::Cancelled)),
4391 }
4392 }
4393
4394 fn finish_prompt(&self, session_id: &SessionId, cancellation: &Arc<PromptCancellation>) {
4395 let key = session_id.to_string();
4396 let mut active = self.prompt_cancellations.lock().unwrap();
4397 if active
4398 .get(&key)
4399 .is_some_and(|registered| Arc::ptr_eq(registered, cancellation))
4400 {
4401 active.remove(&key);
4402 }
4403 }
4404
4405 async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
4406 log::info!("cancel requested for session {}", args.session_id);
4407 self.cancel_turn(&args.session_id);
4408 Ok(())
4409 }
4410
4411 /// Stop the turn running on `session_id`, if there is one, and whatever
4412 /// command it is running. Reached from `session/cancel` and from a
4413 /// `$/cancel_request` naming the prompt.
4414 fn cancel_turn(&self, session_id: &SessionId) {
4415 if let Some(cancellation) = self
4416 .prompt_cancellations
4417 .lock()
4418 .unwrap()
4419 .get(&session_id.to_string())
4420 .cloned()
4421 {
4422 cancellation.cancelled.store(true, Ordering::Release);
4423 cancellation.notify.notify_one();
4424 }
4425 tools::abort_session_tools(&session_id.to_string());
4426 }
4427
4428 /// Swap the active backend to a siGit Code Cloud tier and reflect it as the
4429 /// current model so the picker shows it selected. Returns the tier's display
4430 /// name on success, or `None` when no account is signed in (caller prompts
4431 /// for login). Shared by the panel picker and the `/models` slash command.
4432 async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
4433 let cfg = crate::provider::cloud_tier_provider(tier)?;
4434 let display_name = cfg.display_name.clone();
4435 self.install_remote_backend(cfg, format!("sigit-cloud:{tier}"))
4436 .await;
4437
4438 // Explicitly choosing a cloud tier puts us in cloud mode.
4439 let _ = settings::set_local_inference(false);
4440
4441 log::info!("switched to cloud tier {tier}");
4442 Some(display_name)
4443 }
4444
4445 /// Install a remote provider under `selection_id`, carrying only the live
4446 /// session's non-system conversation. Kept separate from cloud credential
4447 /// lookup so the session-boundary regression can be tested without an
4448 /// account or network request.
4449 async fn install_remote_backend(&self, cfg: provider::ProviderConfig, selection_id: String) {
4450 // Mirror the cwd guidance and project instruction files the local engine
4451 // gets at session load, so the cloud model shares the same project context.
4452 let system_prompt = match self.session_cwd.lock().ok().and_then(|g| g.clone()) {
4453 Some(cwd) => remote_system_prompt(&session_context_message(
4454 &cwd,
4455 &workspace::additional_roots(),
4456 )),
4457 None => system_prompt_for_model(true),
4458 };
4459 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
4460 cfg.base_url,
4461 cfg.api_key,
4462 cfg.model,
4463 Some(system_prompt),
4464 ));
4465 // Continue the thread on the new backend instead of starting over.
4466 let carried =
4467 backend::carryover_history(self.backend.lock().await.history_snapshot().await);
4468 backend::adopt_carryover(cloud_backend.as_ref(), carried).await;
4469 *self.backend.lock().await = cloud_backend;
4470
4471 let remote_config = GgufModelConfig {
4472 model_id: selection_id,
4473 files: Vec::new(),
4474 tok_model_id: None,
4475 display_name: cfg.display_name.clone(),
4476 approx_memory: "Cloud".to_string(),
4477 chat_template: None,
4478 };
4479 {
4480 let mut guard = self.current_model.lock().unwrap();
4481 *guard = remote_config;
4482 }
4483 self.record_active_model();
4484 }
4485
4486 /// Route inference back on-device. Used after leaving a cloud tier for a
4487 /// local model. The `LocalBackend` reads the live `engine`, so this repoints
4488 /// the active backend and moves the conversation onto the engine with it.
4489 async fn reset_to_local_backend(&self) {
4490 // Coming back from a cloud tier, the turns since the switch live in the
4491 // cloud backend's history, not the engine's — carry them over so the
4492 // on-device model picks the thread up where the cloud one left it. A
4493 // local-to-local switch needs nothing: `switch_model_by_id` has already
4494 // replayed the engine's own history past the reload.
4495 let carried = {
4496 let current = self.backend.lock().await;
4497 if current.is_remote() {
4498 backend::carryover_history(current.history_snapshot().await)
4499 } else {
4500 Vec::new()
4501 }
4502 };
4503
4504 let local_backend: Arc<dyn InferenceBackend> =
4505 Arc::new(LocalBackend::new(Arc::clone(&self.engine)));
4506 backend::adopt_carryover(local_backend.as_ref(), carried).await;
4507 *self.backend.lock().await = local_backend;
4508 self.record_active_model();
4509 }
4510
4511 /// Re-attempt the lazy startup model load if the previous attempt failed.
4512 /// Clears the one-shot guard so the next load runs; a healthy load is left
4513 /// untouched so `/reload` doesn't needlessly reload a working model.
4514 fn retry_startup_model_load_if_failed(&self) {
4515 let had_error = self
4516 .model_load_error
4517 .lock()
4518 .map(|guard| guard.is_some())
4519 .unwrap_or(false);
4520 if had_error {
4521 self.startup_model_load_started
4522 .store(false, Ordering::Release);
4523 self.start_startup_model_load_if_needed();
4524 }
4525 }
4526
4527 /// Re-sync session state in place — no new session needed. Re-applies the
4528 /// active backend from current credentials (so a fresh `/login` token is
4529 /// picked up), retries a failed model load, and pushes refreshed commands +
4530 /// picker so the editor's UI reflects the current state.
4531 async fn handle_reload(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
4532 let signed_in = account::status_line().await;
4533
4534 let on_cloud_tier = {
4535 let guard = self.current_model.lock().unwrap();
4536 guard
4537 .model_id
4538 .strip_prefix("sigit-cloud:")
4539 .map(str::to_string)
4540 };
4541
4542 let backend_note = match on_cloud_tier {
4543 Some(tier) => match self.switch_to_cloud_tier(&tier).await {
4544 Some(name) => format!("Active: {name}."),
4545 None => {
4546 *self.current_model.lock().unwrap() = default_local_model_config();
4547 self.reset_to_local_backend().await;
4548 let _ = settings::set_local_inference(true);
4549 "Signed out — back to on-device. Pick a model with /models.".to_string()
4550 }
4551 },
4552 None => {
4553 self.reset_to_local_backend().await;
4554 self.retry_startup_model_load_if_failed();
4555 let guard = self.current_model.lock().unwrap();
4556 format!("Active: {}.", guard.display_name)
4557 }
4558 };
4559
4560 // Push refreshed picker + commands so the editor reflects current state.
4561 let config_options = {
4562 let guard = self.current_model.lock().unwrap();
4563 build_config_options(&guard, &session_id.to_string())
4564 };
4565 self.send_tool_call_update(
4566 cx,
4567 session_id.clone(),
4568 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
4569 )
4570 .ok();
4571 // A reload rereads settings.toml, which can move the default mode.
4572 self.send_current_mode(cx, session_id.clone());
4573 self.advertise_commands(cx, session_id.clone());
4574
4575 self.send_assistant_message(
4576 cx,
4577 session_id,
4578 format!("Reloaded. {signed_in} {backend_note}"),
4579 )
4580 .ok();
4581 }
4582
4583 /// `session/set_mode`, the selector ACP had before config options. The
4584 /// modes are the Permissions dropdown under another name, so this changes
4585 /// the same state and then refreshes the dropdown for a client that shows
4586 /// both.
4587 /// `-32002` unless `session_id` is a session this process knows.
4588 fn require_known_session(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
4589 let key = session_id.to_string();
4590 if self.sessions.lock().unwrap().contains_key(&key) {
4591 return Ok(());
4592 }
4593 Err(agent_client_protocol::Error::new(
4594 ErrorCode::ResourceNotFound.into(),
4595 format!(
4596 "unknown session {key}; create it with session/new or restore it with session/load"
4597 ),
4598 ))
4599 }
4600
4601 /// The config options for `key`, built from that session's own model and
4602 /// not from whichever session is live, so it can be read without the
4603 /// workspace lock.
4604 fn config_options_for_session(&self, key: &str) -> Vec<SessionConfigOption> {
4605 let live = self.active_session.lock().unwrap().as_deref() == Some(key);
4606 let model_id = if live {
4607 self.current_model.lock().unwrap().model_id.clone()
4608 } else {
4609 self.sessions
4610 .lock()
4611 .unwrap()
4612 .get(key)
4613 .map(|state| {
4614 state
4615 .remote
4616 .as_ref()
4617 .map_or_else(|| state.model_id.clone(), |r| r.model.model_id.clone())
4618 })
4619 .unwrap_or_default()
4620 };
4621 build_config_options_for(&model_id, key)
4622 }
4623
4624 /// Apply a Permissions change for `session_id`, which can be mid-turn.
4625 ///
4626 /// Takes neither the session lock nor the workspace lock: the turn holds
4627 /// the first for its whole length, and a user flipping to Auto while a
4628 /// permission prompt is open has to be heard at once. It touches only
4629 /// state keyed by session (`permissions`), so the live session is left
4630 /// alone. Returns the new config options and the status line to show.
4631 fn change_permission_mode(
4632 &self,
4633 cx: &ConnectionTo<Client>,
4634 session_id: &SessionId,
4635 mode_id: Option<&str>,
4636 ) -> agent_client_protocol::Result<Vec<SessionConfigOption>> {
4637 self.require_known_session(session_id)?;
4638 let key = session_id.to_string();
4639 let Some(message) = mode_id.and_then(|id| apply_permission_mode(&key, id)) else {
4640 return Err(agent_client_protocol::Error::new(
4641 ErrorCode::InvalidParams.into(),
4642 format!("unknown permission mode: {mode_id:?}"),
4643 ));
4644 };
4645 self.send_system_status(cx, session_id.clone(), message)
4646 .ok();
4647 Ok(self.config_options_for_session(&key))
4648 }
4649
4650 async fn handle_set_session_mode(
4651 &self,
4652 cx: &ConnectionTo<Client>,
4653 args: SetSessionModeRequest,
4654 ) -> agent_client_protocol::Result<SetSessionModeResponse> {
4655 log::info!(
4656 "set_session_mode: session={}, mode_id={}",
4657 args.session_id,
4658 args.mode_id
4659 );
4660
4661 let config_options =
4662 self.change_permission_mode(cx, &args.session_id, Some(args.mode_id.0.as_ref()))?;
4663 self.send_tool_call_update(
4664 cx,
4665 args.session_id,
4666 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
4667 )
4668 .ok();
4669 Ok(SetSessionModeResponse::new())
4670 }
4671
4672 async fn handle_set_session_config_option(
4673 &self,
4674 cx: &ConnectionTo<Client>,
4675 args: SetSessionConfigOptionRequest,
4676 ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
4677 log::info!(
4678 "set_session_config_option: config_id={}, value={:?}",
4679 args.config_id,
4680 args.value
4681 );
4682
4683 // ── Permissions dropdown (issue #76) ────────────────────────────────
4684 // Handled before the session is activated: the dispatch loop runs this
4685 // one without the session or workspace lock, so it can land mid-turn.
4686 if args.config_id.0.as_ref() == PERMISSION_MODE_CONFIG_ID {
4687 let picked = args.value.as_value_id().map(|v| v.0.as_ref());
4688 let config_options = self.change_permission_mode(cx, &args.session_id, picked)?;
4689 self.send_current_mode(cx, args.session_id);
4690 return Ok(SetSessionConfigOptionResponse::new(config_options));
4691 }
4692
4693 // A model switch carries the live conversation onto the new backend, so
4694 // the live conversation has to be the one whose picker changed.
4695 self.activate_session(&args.session_id).await?;
4696
4697 // ── Local Inference toggle ──────────────────────────────────────────
4698 if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
4699 // A toggle sends a boolean, the select fallback one of its ids.
4700 let enabled = match (
4701 args.value.as_bool(),
4702 args.value.as_value_id().map(|v| v.0.as_ref()),
4703 ) {
4704 (Some(enabled), _) => enabled,
4705 (_, Some(LOCAL_INFERENCE_ON)) => true,
4706 (_, Some(LOCAL_INFERENCE_OFF)) => false,
4707 (_, other) => {
4708 return Err(agent_client_protocol::Error::new(
4709 ErrorCode::InvalidParams.into(),
4710 format!("unknown Local Inference value: {other:?}"),
4711 ));
4712 }
4713 };
4714 if let Err(error) = settings::set_local_inference(enabled) {
4715 return Err(agent_client_protocol::Error::new(
4716 ErrorCode::InternalError.into(),
4717 format!("could not save Local Inference setting: {error}"),
4718 ));
4719 }
4720 let message = if enabled {
4721 "Local inference is on. On-device models are highlighted; pick one from Model."
4722 } else {
4723 "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
4724 };
4725 self.send_system_status(cx, args.session_id.clone(), message)
4726 .ok();
4727 // Rebuild so the Model picker reflects the new emphasis/order.
4728 let current = self.current_model.lock().unwrap().clone();
4729 let config_options = build_config_options(&current, &args.session_id.to_string());
4730 return Ok(SetSessionConfigOptionResponse::new(config_options));
4731 }
4732
4733 if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
4734 return Err(agent_client_protocol::Error::new(
4735 ErrorCode::InvalidParams.into(),
4736 format!("unknown config option: {}", args.config_id.0),
4737 ));
4738 }
4739
4740 let Some(model_id) = args.value.as_value_id().map(|v| v.0.as_ref()) else {
4741 return Err(agent_client_protocol::Error::new(
4742 ErrorCode::InvalidParams.into(),
4743 format!(
4744 "expected a value-id for {MODEL_CONFIG_ID}, got {:?}",
4745 args.value
4746 ),
4747 ));
4748 };
4749
4750 // can't switch while the startup model is still loading — the old
4751 // weights are in GPU memory and the new load gets "does not fit"
4752 if self.startup_model_load_started.load(Ordering::Acquire)
4753 && !self.model_ready.load(Ordering::Acquire)
4754 {
4755 log::info!("set_session_config_option: waiting for startup model to finish loading");
4756 while !self.model_ready.load(Ordering::Acquire) {
4757 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
4758 }
4759 }
4760
4761 // Zed re-fires the last selection when a thread opens. That re-fire must
4762 // not load anything: on-device models are loaded only on an explicit
4763 // request (`/load`, or actively picking a *different* model below), so a
4764 // re-fire of the already-current selection is a no-op. Otherwise opening a
4765 // new thread would silently load the local model — exactly what we avoid.
4766 {
4767 let current = self.current_model.lock().unwrap();
4768 if current.model_id == model_id {
4769 log::info!(
4770 "set_session_config_option: {} is already the active selection, skipping",
4771 current.display_name
4772 );
4773 // A restored preferred model may have fallen back temporarily.
4774 // Explicitly picking the live model adopts that fallback as the
4775 // session's new preference even though no backend switch runs.
4776 drop(current);
4777 self.record_active_model();
4778 let current = self.current_model.lock().unwrap();
4779 let config_options = build_config_options(&current, &args.session_id.to_string());
4780 return Ok(SetSessionConfigOptionResponse::new(config_options));
4781 }
4782 }
4783
4784 // ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
4785 if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
4786 match self.switch_to_cloud_tier(tier).await {
4787 Some(display_name) => {
4788 self.send_system_status(
4789 cx,
4790 args.session_id.clone(),
4791 format!("Switched to {display_name}."),
4792 )
4793 .ok();
4794 }
4795 None => {
4796 // The picker response updates successful selections. When the
4797 // selection cannot apply because auth is missing, surface that
4798 // actionable state in the thread.
4799 self.send_assistant_message(
4800 cx,
4801 args.session_id.clone(),
4802 format!("\n\n{CLOUD_LOGIN_PROMPT}"),
4803 )
4804 .ok();
4805 }
4806 }
4807
4808 let current = self.current_model.lock().unwrap().clone();
4809 let config_options = build_config_options(&current, &args.session_id.to_string());
4810 return Ok(SetSessionConfigOptionResponse::new(config_options));
4811 }
4812
4813 let needs_download = models::local_picker_items()
4814 .into_iter()
4815 .find(|item| item.config.model_id == model_id)
4816 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
4817 .unwrap_or(false);
4818
4819 // tells the progress poller to stop
4820 let stop_flag = Arc::new(AtomicBool::new(false));
4821
4822 let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
4823
4824 if needs_download {
4825 let model_id_owned = model_id.to_string();
4826 let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
4827 .iter()
4828 .find(|m| m.id == model_id_owned)
4829 .map(|m| m.expected_size_bytes)
4830 .unwrap_or(0);
4831
4832 let display_name = models::local_picker_items()
4833 .into_iter()
4834 .find(|item| item.config.model_id == model_id_owned)
4835 .map(|item| item.display_name.clone())
4836 .unwrap_or_else(|| model_id_owned.clone());
4837
4838 let size_hint = if expected_bytes > 0 {
4839 format!(" (~{})", format_size_human(expected_bytes))
4840 } else {
4841 String::new()
4842 };
4843
4844 self.send_tool_call_update(
4845 cx,
4846 args.session_id.clone(),
4847 SessionUpdate::ToolCall(
4848 ToolCall::new(
4849 tool_call_id.clone(),
4850 format!("⏬ Downloading {display_name}{size_hint}"),
4851 )
4852 .kind(ToolKind::Think)
4853 .status(ToolCallStatus::InProgress)
4854 .content(vec![
4855 format!(
4856 "Preparing download for {display_name}. This may take a few minutes."
4857 )
4858 .into(),
4859 ]),
4860 ),
4861 )
4862 .ok();
4863
4864 // poll download progress and update the spinner in Zed
4865 let cx_for_poller = cx.clone();
4866 let poller_session = args.session_id.clone();
4867 let poller_model_id = model_id_owned.clone();
4868 let poller_stop = Arc::clone(&stop_flag);
4869 let poller_tool_call_id = tool_call_id.clone();
4870
4871 cx.spawn(async move {
4872 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4873 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
4874 let mut tick: usize = 0;
4875 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
4876 interval.tick().await; // consume the immediate first tick
4877
4878 while !poller_stop.load(Ordering::Relaxed) {
4879 interval.tick().await;
4880
4881 if poller_stop.load(Ordering::Relaxed) {
4882 break;
4883 }
4884
4885 let downloaded = cache_path
4886 .as_ref()
4887 .filter(|p| p.exists())
4888 .map(|p| dir_size_recursive(p))
4889 .unwrap_or(0);
4890
4891 let frame = SPINNER[tick % SPINNER.len()];
4892 tick += 1;
4893
4894 let title = if expected_bytes > 0 {
4895 let pct =
4896 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
4897 format!("{frame} Downloading {display_name}{size_hint} ({pct}%)")
4898 } else {
4899 format!("{frame} Downloading {display_name}{size_hint}")
4900 };
4901
4902 let msg = if expected_bytes > 0 {
4903 let pct =
4904 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
4905 let bar = progress_bar(pct, 20);
4906 format!(
4907 "{display_name} — {bar} {pct}% ({} / {})",
4908 format_size_human(downloaded),
4909 format_size_human(expected_bytes),
4910 )
4911 } else {
4912 format!(
4913 "{display_name} — {} downloaded…",
4914 format_size_human(downloaded)
4915 )
4916 };
4917
4918 let notification = SessionNotification::new(
4919 poller_session.clone(),
4920 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
4921 poller_tool_call_id.clone(),
4922 ToolCallUpdateFields::new()
4923 .title(title)
4924 .status(ToolCallStatus::InProgress)
4925 .content(vec![msg.into()]),
4926 )),
4927 );
4928 if cx_for_poller.send_notification(notification).is_err() {
4929 break;
4930 }
4931 }
4932 Ok(())
4933 })
4934 .ok();
4935 }
4936
4937 // cached models still take 10-30s to load weights; show a spinner
4938 if !needs_download {
4939 let cached_display_name = models::local_picker_items()
4940 .into_iter()
4941 .find(|item| item.config.model_id == model_id)
4942 .map(|item| item.display_name.clone())
4943 .unwrap_or_else(|| model_id.to_string());
4944
4945 self.send_tool_call_update(
4946 cx,
4947 args.session_id.clone(),
4948 SessionUpdate::ToolCall(
4949 ToolCall::new(
4950 tool_call_id.clone(),
4951 format!("Loading {cached_display_name}"),
4952 )
4953 .kind(ToolKind::Think)
4954 .status(ToolCallStatus::InProgress)
4955 .content(vec![format!("Loading {cached_display_name}…").into()]),
4956 ),
4957 )
4958 .ok();
4959
4960 // tick every 5s so the user knows we haven't frozen
4961 let cx_for_spinner = cx.clone();
4962 let spinner_session = args.session_id.clone();
4963 let spinner_name = cached_display_name.clone();
4964 let spinner_stop = Arc::clone(&stop_flag);
4965 let spinner_tool_call_id = tool_call_id.clone();
4966 let load_start = std::time::Instant::now();
4967
4968 cx.spawn(async move {
4969 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4970 let mut tick: usize = 0;
4971 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
4972 interval.tick().await; // consume the immediate first tick
4973
4974 while !spinner_stop.load(Ordering::Relaxed) {
4975 interval.tick().await;
4976
4977 if spinner_stop.load(Ordering::Relaxed) {
4978 break;
4979 }
4980
4981 let elapsed = load_start.elapsed();
4982 let elapsed_str = if elapsed.as_secs() >= 60 {
4983 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4984 } else {
4985 format!("{}s", elapsed.as_secs())
4986 };
4987 let frame = SPINNER[tick % SPINNER.len()];
4988 tick += 1;
4989
4990 let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
4991 let notification = SessionNotification::new(
4992 spinner_session.clone(),
4993 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
4994 spinner_tool_call_id.clone(),
4995 ToolCallUpdateFields::new()
4996 .status(ToolCallStatus::InProgress)
4997 .content(vec![msg.into()]),
4998 )),
4999 );
5000 if cx_for_spinner.send_notification(notification).is_err() {
Showing first 5,000 of 8,451 lines. View raw