I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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