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