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