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