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 Ok(AuthenticateResponse::default())
2395 }
2396 Err(reason) => Err(agent_client_protocol::Error::new(
2397 ErrorCode::InternalError.into(),
2398 format!(
2399 "Sign-in did not complete ({reason}). \
2400 Try again, or create an account at https://sigit.si."
2401 ),
2402 )),
2403 }
2404 }
2405
2406 /// ACP `logout`, the editor's sign out button. It names no session: the
2407 /// account belongs to the process, so every open thread is signed out.
2408 async fn handle_logout(
2409 &self,
2410 cx: &ConnectionTo<Client>,
2411 _req: LogoutRequest,
2412 ) -> agent_client_protocol::Result<LogoutResponse> {
2413 let message = self.sign_out(cx).await;
2414 log::info!("logout: {message}");
2415 Ok(LogoutResponse::new())
2416 }
2417
2418 /// Clear the account session, move every thread off the cloud tiers, and
2419 /// push a fresh picker to each thread whose model changed. Returns the
2420 /// message to show. Shared by ACP `logout` and the `/logout` command.
2421 async fn sign_out(&self, cx: &ConnectionTo<Client>) -> String {
2422 let message = account::end_session().await;
2423 let local = default_local_model_config();
2424 for key in self.leave_cloud(&local).await {
2425 let config_options = build_config_options(&local, &key);
2426 self.send_tool_call_update(
2427 cx,
2428 SessionId::new(key),
2429 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
2430 )
2431 .ok();
2432 }
2433 message
2434 }
2435
2436 /// Put every session that was on a siGit Code Cloud tier on `local`
2437 /// instead, and return their ids. The token is gone, so the tiers cannot
2438 /// serve them.
2439 ///
2440 /// Parked sessions are moved along with the live one. Left on a tier, a
2441 /// parked thread would fail to restore it on its next prompt and run on
2442 /// whatever model the previous thread left live.
2443 async fn leave_cloud(&self, local: &GgufModelConfig) -> Vec<String> {
2444 let on_cloud = |model_id: &str| model_id.starts_with("sigit-cloud:");
2445
2446 // Collected before the live backend moves: `reset_to_local_backend`
2447 // records the live session's new model, which would hide it here.
2448 let mut moved = Vec::new();
2449 for (key, state) in self.sessions.lock().unwrap().iter_mut() {
2450 if on_cloud(&state.model_id) {
2451 state.model_id = local.model_id.clone();
2452 moved.push(key.clone());
2453 }
2454 }
2455
2456 let live_on_cloud = on_cloud(&self.current_model.lock().unwrap().model_id);
2457 if live_on_cloud {
2458 *self.current_model.lock().unwrap() = local.clone();
2459 self.reset_to_local_backend().await;
2460 }
2461
2462 // A new thread opened in cloud mode would ask for the default tier.
2463 if live_on_cloud || !moved.is_empty() || !settings::local_inference_enabled() {
2464 let _ = settings::set_local_inference(true);
2465 }
2466 moved
2467 }
2468
2469 /// Build the state for a session request: its primary `cwd` plus the extra
2470 /// roots worth keeping (see `workspace::set_additional_roots`).
2471 fn session_state(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> SessionState {
2472 let current_model = self.current_model.lock().unwrap().model_id.clone();
2473 let model_id =
2474 if !settings::local_inference_enabled() && provider::active_provider().is_none() {
2475 format!("sigit-cloud:{}", provider::DEFAULT_CLOUD_TIER)
2476 } else {
2477 current_model
2478 };
2479 SessionState {
2480 cwd: cwd.to_path_buf(),
2481 additional_roots: workspace::filter_additional_roots(cwd, additional),
2482 model_id,
2483 conversation: Vec::new(),
2484 mcp_servers: mcp::SessionServers::default(),
2485 remote: None,
2486 }
2487 }
2488
2489 /// Take the lock that orders requests on one session (see `session_locks`).
2490 async fn lock_session(self: &Arc<Self>, session_id: &SessionId) -> SessionGuard {
2491 let key = session_id.to_string();
2492 let lock = Arc::clone(
2493 self.session_locks
2494 .lock()
2495 .unwrap()
2496 .entry(key.clone())
2497 .or_default(),
2498 );
2499 SessionGuard {
2500 guard: lock.lock_owned().await,
2501 agent: Arc::clone(self),
2502 key,
2503 }
2504 }
2505
2506 /// Keep the active session's parked metadata in step with a successful
2507 /// model/backend switch. Its conversation remains stale while active.
2508 fn record_active_model(&self) {
2509 let Some(active) = self.active_session.lock().unwrap().clone() else {
2510 return;
2511 };
2512 let model_id = self.current_model.lock().unwrap().model_id.clone();
2513 if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
2514 state.model_id = model_id;
2515 }
2516 }
2517
2518 /// Move the live conversation into the state of the session that owns it,
2519 /// so installing another session can't lose or leak it. An HTTP backend
2520 /// goes with it: the session may be waiting on that backend right now, and
2521 /// its next request has to find the same one.
2522 async fn park_active_session(&self) {
2523 let Some(active) = self.active_session.lock().unwrap().clone() else {
2524 return;
2525 };
2526 let live = self.backend.lock().await.clone();
2527 let snapshot = live.history_snapshot().await;
2528 let remote = live.is_remote().then(|| RemoteBackend {
2529 backend: live,
2530 model: self.current_model.lock().unwrap().clone(),
2531 });
2532 if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
2533 state.conversation = backend::carryover_history(snapshot);
2534 state.remote = remote;
2535 }
2536 }
2537
2538 /// Register `state` under `session_id` and make it the live session.
2539 ///
2540 /// Used by the session entry points (new/load/fork). The outgoing session
2541 /// is parked first, and the Local Inference routing runs last, so a cloud
2542 /// backend installed here starts from this session's conversation and
2543 /// system prompt and nobody else's. Reopening an existing id deliberately
2544 /// replaces its parked state (notably `session/load`); every caller holds
2545 /// `workspace_lock`, so parking and replacement cannot race another
2546 /// session being installed.
2547 async fn open_session(&self, session_id: &SessionId, state: SessionState) {
2548 self.park_active_session().await;
2549 self.open_parked_session(session_id, state).await;
2550 }
2551
2552 /// Install a new session after the caller has already parked the live one.
2553 /// Fork needs this split so it can read an up-to-date source state without
2554 /// making `open_session` snapshot the same live backend a second time.
2555 async fn open_parked_session(&self, session_id: &SessionId, state: SessionState) {
2556 let key = session_id.to_string();
2557 self.sessions
2558 .lock()
2559 .unwrap()
2560 .insert(key.clone(), state.clone());
2561 self.install_session(&key, state).await;
2562
2563 // Honor the persisted Local Inference toggle (off + signed in → cloud).
2564 self.restore_session_model(&key).await;
2565 }
2566
2567 /// Make `session_id` the live session before one of its requests runs.
2568 ///
2569 /// A no-op when it already is. Otherwise the live session is parked and
2570 /// this one's roots and conversation are installed in its place. An id this
2571 /// process has never seen (the editor kept a thread open across a sigit
2572 /// restart) is rebuilt from the session store when it can be.
2573 async fn activate_session(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
2574 let key = session_id.to_string();
2575 if self.active_session.lock().unwrap().as_deref() == Some(key.as_str()) {
2576 return Ok(());
2577 }
2578
2579 let Some(state) = self.sessions.lock().unwrap().get(&key).cloned() else {
2580 return Err(agent_client_protocol::Error::new(
2581 ErrorCode::ResourceNotFound.into(),
2582 format!(
2583 "unknown session {key}; create it with session/new or restore it with session/load"
2584 ),
2585 ));
2586 };
2587
2588 log::info!(
2589 "session({key}): activating (cwd={}, {} extra root(s), {} message(s))",
2590 state.cwd.display(),
2591 state.additional_roots.len(),
2592 state.conversation.len()
2593 );
2594 self.park_active_session().await;
2595 self.install_session(&key, state).await;
2596 self.restore_session_model(&key).await;
2597 Ok(())
2598 }
2599
2600 /// Take `workspace_lock` back after a turn gave it up, and make the turn's
2601 /// session the installed one again if another was installed meanwhile.
2602 ///
2603 /// A turn whose session cannot be reinstalled is cancelled: whatever it ran
2604 /// next would run in another session's directory and roots. The turn holds
2605 /// its session's lock, which `session/close` waits for, so nothing takes
2606 /// the session away today. The turn does not rely on that.
2607 async fn resume_workspace(
2608 &self,
2609 workspace: &mut WorkspaceHold,
2610 session_id: &SessionId,
2611 cancellation: &PromptCancellation,
2612 ) {
2613 if workspace.reacquire().await
2614 && let Err(error) = self.reinstall_for_tools(session_id).await
2615 {
2616 log::warn!(
2617 "session({session_id}): could not reinstall mid-turn, cancelling the turn: {}",
2618 error.message
2619 );
2620 cancellation.cancelled.store(true, Ordering::Release);
2621 cancellation.notify.notify_one();
2622 }
2623 }
2624
2625 /// Make `session_id` the live session and check that its directory is still
2626 /// there. Tools resolve relative paths against the process directory, and
2627 /// `install_session` leaves that alone when the session's `cwd` is gone, so
2628 /// a prompt or a tool run on such a session would act in the previous
2629 /// session's directory. Load, resume and fork do not call this: they
2630 /// succeed for a deleted project so a client can still show its history.
2631 async fn reinstall_for_tools(
2632 &self,
2633 session_id: &SessionId,
2634 ) -> agent_client_protocol::Result<()> {
2635 self.activate_session(session_id).await?;
2636 let cwd = self.session_cwd.lock().unwrap().clone();
2637 match cwd {
2638 Some(cwd) if !cwd.is_dir() => Err(agent_client_protocol::Error::new(
2639 ErrorCode::ResourceNotFound.into(),
2640 format!(
2641 "the working directory of session {session_id} no longer exists: {}",
2642 cwd.display()
2643 ),
2644 )),
2645 _ => Ok(()),
2646 }
2647 }
2648
2649 /// Point the process at `state`'s roots and load its conversation.
2650 ///
2651 /// The process follows `cwd` because tool calls may use relative paths; the
2652 /// extra roots go to `workspace`, where project-local discovery reads them.
2653 /// Background tasks are scoped to the live session too (see
2654 /// `tools::set_active_session`).
2655 async fn install_session(&self, key: &str, state: SessionState) {
2656 let SessionState {
2657 cwd,
2658 additional_roots,
2659 model_id,
2660 conversation,
2661 mcp_servers,
2662 remote,
2663 } = state;
2664
2665 if let Ok(mut guard) = self.session_cwd.lock() {
2666 *guard = Some(cwd.clone());
2667 }
2668 workspace::replace_additional_roots(additional_roots.clone());
2669 mcp::set_session_servers(mcp_servers);
2670 if !cwd.is_dir() {
2671 log::warn!(
2672 "session({key}): cwd {} no longer exists, prompts and tools on it will fail",
2673 cwd.display()
2674 );
2675 } else if let Err(err) = std::env::set_current_dir(&cwd) {
2676 log::warn!("could not set cwd to {}: {err}", cwd.display());
2677 }
2678 *self.active_session.lock().unwrap() = Some(key.to_string());
2679 tools::set_active_session(Some(key));
2680 if let Some(parked) = self.sessions.lock().unwrap().get_mut(key) {
2681 parked.remote = None;
2682 }
2683
2684 // A session that already has its own HTTP backend goes back onto it
2685 // untouched. Its conversation lives there and may be mid-turn, so it
2686 // is not reseeded from the parked copy. The engine still gets this
2687 // session's context, for a later switch to an on-device model.
2688 if let Some(RemoteBackend { backend, model }) = remote
2689 && model.model_id == model_id
2690 {
2691 self.seed_engine_context(&session_context_message(&cwd, &additional_roots))
2692 .await;
2693 *self.backend.lock().await = backend;
2694 *self.current_model.lock().unwrap() = model;
2695 return;
2696 }
2697
2698 // Otherwise the session starts from its parked conversation. The live
2699 // HTTP backend belongs to whichever session was installed before, and
2700 // that session may still be waiting on it, so this one gets a backend
2701 // of its own for the same endpoint.
2702 let fresh = self.backend.lock().await.fresh();
2703 if let Some(fresh) = fresh {
2704 *self.backend.lock().await = fresh;
2705 }
2706 self.seed_conversation(&cwd, &additional_roots, conversation)
2707 .await;
2708 }
2709
2710 /// Reset the on-device engine's conversation to just `context`.
2711 async fn seed_engine_context(&self, context: &str) {
2712 self.engine.clear_history().await;
2713 self.engine
2714 .push_history(onde::inference::ChatMessage::system(context.to_string()))
2715 .await;
2716 }
2717
2718 /// Restore the active session's model/backend after its conversation has
2719 /// been installed on the currently live backend. Model switches carry that
2720 /// freshly installed conversation across to the target backend.
2721 async fn restore_session_model(&self, key: &str) {
2722 // Explicit OpenAI-compatible provider overrides are process-wide and
2723 // intentionally take precedence over the editor's model picker.
2724 if provider::active_provider().is_some() {
2725 return;
2726 }
2727
2728 let Some(target) = self
2729 .sessions
2730 .lock()
2731 .unwrap()
2732 .get(key)
2733 .map(|state| state.model_id.clone())
2734 else {
2735 return;
2736 };
2737 let current = self.current_model.lock().unwrap().model_id.clone();
2738 if current == target {
2739 return;
2740 }
2741
2742 if let Some(tier) = target.strip_prefix("sigit-cloud:") {
2743 if self.switch_to_cloud_tier(tier).await.is_none() {
2744 log::warn!(
2745 "session({key}): cannot restore cloud model without a signed-in account"
2746 );
2747 }
2748 return;
2749 }
2750
2751 match self.switch_model_by_id(&target).await {
2752 Ok(_) => {
2753 self.reset_to_local_backend().await;
2754 let _ = settings::set_local_inference(true);
2755 }
2756 Err(error) => {
2757 log::warn!("session({key}): could not restore model {target}: {error}");
2758 }
2759 }
2760 }
2761
2762 /// Replace the conversation on the engine and the active backend with
2763 /// `conversation`, under system context naming `cwd` and `additional_roots`.
2764 ///
2765 /// Both are seeded even when only one serves inference: switching between
2766 /// on-device and cloud carries the conversation across and keeps the target's
2767 /// own system messages, so each has to name the live session's project.
2768 async fn seed_conversation(
2769 &self,
2770 cwd: &std::path::Path,
2771 additional_roots: &[PathBuf],
2772 conversation: Vec<serde_json::Value>,
2773 ) {
2774 let context = session_context_message(cwd, additional_roots);
2775 let backend = self.backend.lock().await.clone();
2776
2777 if backend.is_remote() {
2778 self.seed_engine_context(&context).await;
2779 }
2780
2781 let system_prompt = if backend.is_remote() {
2782 remote_system_prompt(&context)
2783 } else {
2784 context
2785 };
2786 let mut history = vec![serde_json::json!({ "role": "system", "content": system_prompt })];
2787 history.extend(conversation);
2788 backend.restore_history(history).await;
2789 }
2790
2791 /// Save a session's history plus the sidecar that says where it ran.
2792 ///
2793 /// The sidecar is written here rather than at session start so a thread the
2794 /// user never spoke in doesn't leave one behind, and so a listed session
2795 /// always has history to import. Only the directory the client named on the
2796 /// session request is recorded — never the process cwd, which a later
2797 /// session in the same process may have moved: a thread listed under the
2798 /// wrong project is worse than one that isn't listed at all.
2799 ///
2800 /// Each save is also reported to the client in a `session_info_update`, so
2801 /// the thread's title and `updatedAt` reach the editor's sidebar without
2802 /// waiting for the next `session/list`.
2803 async fn persist_session(
2804 &self,
2805 cx: &ConnectionTo<Client>,
2806 session_id: &SessionId,
2807 snapshot: &[serde_json::Value],
2808 ) {
2809 let key = session_id.to_string();
2810 if let Err(error) = session_store::save(&key, snapshot) {
2811 log::warn!("session({session_id}) save failed: {error}");
2812 return;
2813 }
2814 self.send_session_info(cx, session_id, snapshot);
2815 let state = self.sessions.lock().unwrap().get(&key).cloned();
2816 match state {
2817 Some(state) => session_store::save_meta(
2818 &key,
2819 &state.cwd,
2820 &state.additional_roots,
2821 Some(&state.model_id),
2822 ),
2823 None => log::warn!(
2824 "session({session_id}) has no recorded cwd — saved, but it won't be listed"
2825 ),
2826 }
2827 }
2828
2829 /// Tell the client a session was just saved: always a fresh `updatedAt`,
2830 /// and the title only when it differs from the one last sent. The update
2831 /// names no `sessionId`, `cwd` or `additionalDirectories`; the spec keeps
2832 /// those out of it.
2833 fn send_session_info(
2834 &self,
2835 cx: &ConnectionTo<Client>,
2836 session_id: &SessionId,
2837 snapshot: &[serde_json::Value],
2838 ) {
2839 let key = session_id.to_string();
2840 let mut update = SessionInfoUpdate::new();
2841 if let Some(modified) = session_store::modified(&key) {
2842 update = update.updated_at(session_store::iso8601(modified));
2843 }
2844 if let Some(title) = session_store::history_title(snapshot) {
2845 let mut announced = self.announced_titles.lock().unwrap();
2846 if announced.get(&key) != Some(&title) {
2847 announced.insert(key, title.clone());
2848 update = update.title(title);
2849 }
2850 }
2851 cx.send_notification(SessionNotification::new(
2852 session_id.clone(),
2853 SessionUpdate::SessionInfoUpdate(update),
2854 ))
2855 .ok();
2856 }
2857
2858 /// ACP `session/list`: the saved threads an editor can reopen.
2859 ///
2860 /// Only sessions with a recorded working directory qualify — `SessionInfo`
2861 /// must carry an absolute `cwd`, and the request may filter on it. Threads
2862 /// saved before sidecars existed have none and are skipped; they still load
2863 /// by id. Everything is returned in one page, so there is no cursor.
2864 async fn handle_list_sessions(
2865 &self,
2866 args: ListSessionsRequest,
2867 ) -> agent_client_protocol::Result<ListSessionsResponse> {
2868 if let Some(cwd) = args.cwd.as_ref()
2869 && !cwd.is_absolute()
2870 {
2871 return Err(agent_client_protocol::Error::new(
2872 ErrorCode::InvalidParams.into(),
2873 format!("cwd must be an absolute path, got {}", cwd.display()),
2874 ));
2875 }
2876 // A cursor is one this agent handed out. Anything else is a client
2877 // error, not a reason to start again from the first page.
2878 let cursor = match args.cursor.as_deref() {
2879 Some(cursor) => Some(SessionListCursor::decode(cursor).ok_or_else(|| {
2880 agent_client_protocol::Error::new(
2881 ErrorCode::InvalidParams.into(),
2882 "cursor is not one this agent returned from session/list",
2883 )
2884 })?),
2885 None => None,
2886 };
2887 let filter = args.cwd.clone();
2888 let entries: Vec<session_store::SessionEntry> = session_store::list()
2889 .into_iter()
2890 .filter(|entry| entry.message_count > 0)
2891 .filter(|entry| match (&entry.cwd, &filter) {
2892 (None, _) => false,
2893 (Some(cwd), Some(filter)) => same_dir(cwd, filter),
2894 (Some(_), None) => true,
2895 })
2896 .collect();
2897 let (page, next_cursor) =
2898 session_list_page(entries, cursor.as_ref(), SESSION_LIST_PAGE_SIZE);
2899 let sessions: Vec<SessionInfo> = page
2900 .into_iter()
2901 .filter_map(|entry| {
2902 Some(
2903 SessionInfo::new(SessionId::new(entry.id), entry.cwd?)
2904 .additional_directories(entry.additional_directories)
2905 .title(entry.title)
2906 .updated_at(session_store::iso8601(entry.modified)),
2907 )
2908 })
2909 .collect();
2910
2911 log::info!(
2912 "list_sessions: {} session(s){}{}",
2913 sessions.len(),
2914 args.cwd
2915 .as_ref()
2916 .map(|cwd| format!(" for cwd={}", cwd.display()))
2917 .unwrap_or_default(),
2918 if next_cursor.is_some() {
2919 ", more to come"
2920 } else {
2921 ""
2922 }
2923 );
2924
2925 Ok(ListSessionsResponse::new(sessions).next_cursor(next_cursor))
2926 }
2927
2928 async fn handle_load_session(
2929 &self,
2930 cx: &ConnectionTo<Client>,
2931 args: LoadSessionRequest,
2932 ) -> agent_client_protocol::Result<LoadSessionResponse> {
2933 let config_options = self
2934 .restore_session(
2935 cx,
2936 RestoreKind::Load,
2937 &args.session_id,
2938 &args.cwd,
2939 &args.additional_directories,
2940 &args.mcp_servers,
2941 )
2942 .await?;
2943 Ok(LoadSessionResponse::new()
2944 .config_options(config_options)
2945 .modes(build_session_modes(&args.session_id.to_string())))
2946 }
2947
2948 /// ACP `session/resume`: `session/load` without the replay.
2949 ///
2950 /// The client still has the thread on screen (the agent restarted under
2951 /// it, say) and only needs the model to remember it. The spec forbids
2952 /// sending the history back as `session/update` here.
2953 async fn handle_resume_session(
2954 &self,
2955 cx: &ConnectionTo<Client>,
2956 args: ResumeSessionRequest,
2957 ) -> agent_client_protocol::Result<ResumeSessionResponse> {
2958 let config_options = self
2959 .restore_session(
2960 cx,
2961 RestoreKind::Resume,
2962 &args.session_id,
2963 &args.cwd,
2964 &args.additional_directories,
2965 &args.mcp_servers,
2966 )
2967 .await?;
2968 Ok(ResumeSessionResponse::new()
2969 .config_options(config_options)
2970 .modes(build_session_modes(&args.session_id.to_string())))
2971 }
2972
2973 /// Bring a saved session back as the live one, for `session/load` and
2974 /// `session/resume`. The two differ only in whether the conversation is
2975 /// replayed to the client.
2976 async fn restore_session(
2977 &self,
2978 cx: &ConnectionTo<Client>,
2979 kind: RestoreKind,
2980 session_id: &SessionId,
2981 cwd: &std::path::Path,
2982 additional_directories: &[PathBuf],
2983 mcp_servers: &[McpServer],
2984 ) -> agent_client_protocol::Result<Vec<SessionConfigOption>> {
2985 validate_session_roots(cwd, additional_directories)?;
2986
2987 let method = kind.method();
2988 log::info!(
2989 "{method}: id={session_id}, cwd={}, additional_directories={:?}",
2990 cwd.display(),
2991 additional_directories
2992 .iter()
2993 .map(|p| p.display().to_string())
2994 .collect::<Vec<_>>()
2995 );
2996
2997 let session_key = session_id.to_string();
2998
2999 // The saved copy is the thread; failing that, this process may still
3000 // hold one nobody has spoken in yet (nothing is written until a turn
3001 // completes). An id found in neither place names no session, and
3002 // answering with an empty one would hand the client a blank thread it
3003 // cannot tell from a restored one. Parking first makes the in-memory
3004 // copy current when the id being restored is the live session.
3005 self.park_active_session().await;
3006 let in_memory = self
3007 .sessions
3008 .lock()
3009 .unwrap()
3010 .get(&session_key)
3011 .map(|state| state.conversation.clone());
3012 let Some(history) = session_store::load(&session_key).or(in_memory) else {
3013 log::warn!("{method}: no session {session_key}");
3014 return Err(agent_client_protocol::Error::new(
3015 ErrorCode::ResourceNotFound.into(),
3016 format!("session {session_key} not found"),
3017 ));
3018 };
3019
3020 let mut state = self.session_state(cwd, additional_directories);
3021 if let Some(model_id) = session_store::list()
3022 .into_iter()
3023 .find(|entry| entry.id == session_key)
3024 .and_then(|entry| entry.model_id)
3025 {
3026 state.model_id = model_id;
3027 }
3028
3029 // A session boundary: grants and plan mode from the previous life of
3030 // this session id must not carry over. Other sessions keep theirs; the
3031 // editor may still have them open.
3032 permissions::reset_session(&session_key);
3033
3034 // Restore the conversation. The saved system messages are dropped;
3035 // they are rebuilt from the roots the editor sent now, which may differ
3036 // from last time.
3037 let restored = history.len();
3038
3039 // On a load the client draws the reopened thread from these
3040 // notifications alone. On a resume it already has the thread.
3041 let mut replayed = 0;
3042 if kind == RestoreKind::Load {
3043 let updates = history_replay_updates(&history);
3044 replayed = updates.len();
3045 for update in updates {
3046 cx.send_notification(SessionNotification::new(session_id.clone(), update))
3047 .ok();
3048 }
3049 }
3050
3051 state.conversation = backend::carryover_history(history);
3052 state.mcp_servers = mcp::connect_session_servers(client_mcp_servers(mcp_servers)).await;
3053 log::info!(
3054 "{method}: restored {restored} message(s), replayed {replayed} update(s) for {session_id}"
3055 );
3056
3057 self.open_parked_session(session_id, state).await;
3058
3059 let config_options = {
3060 let guard = self.current_model.lock().unwrap();
3061 build_config_options(&guard, &session_key)
3062 };
3063
3064 self.advertise_commands(cx, session_id.clone());
3065
3066 // Run SessionStart hooks
3067 let hook_settings = settings::load().hooks;
3068 if hook_settings.has_hooks() {
3069 hooks::run_session_start_hooks(&hook_settings, cwd, &session_key);
3070 }
3071
3072 Ok(config_options)
3073 }
3074
3075 /// ACP `session/close`: the client is done with a thread.
3076 ///
3077 /// Drops everything this process holds for it: the parked state (and with
3078 /// it the MCP servers the client named), its permission grants and plan
3079 /// mode, its todo list, and the background commands it started. The saved
3080 /// history stays, so the thread still lists and reopens. The caller has
3081 /// already signalled the session's running turn (see `begin_close`) and
3082 /// holds the session's lock and `workspace_lock`, so that turn has ended by
3083 /// the time this runs.
3084 ///
3085 /// An id this process does not hold is answered with success. The spec
3086 /// allows an error there, but the client wants the session gone and it is.
3087 async fn handle_close_session(
3088 &self,
3089 args: CloseSessionRequest,
3090 ) -> agent_client_protocol::Result<CloseSessionResponse> {
3091 let key = args.session_id.to_string();
3092 let known = self.sessions.lock().unwrap().remove(&key).is_some();
3093
3094 let was_live = {
3095 let mut active = self.active_session.lock().unwrap();
3096 let was_live = active.as_deref() == Some(key.as_str());
3097 if was_live {
3098 *active = None;
3099 }
3100 was_live
3101 };
3102 if was_live {
3103 // Nothing owns the live conversation or roots any more. Clearing
3104 // them here means the next session to open cannot inherit them,
3105 // and lets go of the last handle on this session's MCP servers.
3106 *self.session_cwd.lock().unwrap() = None;
3107 tools::set_active_session(None);
3108 workspace::replace_additional_roots(Vec::new());
3109 mcp::set_session_servers(mcp::SessionServers::default());
3110 self.engine.clear_history().await;
3111 let backend = self.backend.lock().await.clone();
3112 backend.restore_history(Vec::new()).await;
3113 }
3114
3115 permissions::reset_session(&key);
3116 self.announced_titles.lock().unwrap().remove(&key);
3117 let killed = tools::kill_session_tasks(&key);
3118 tools::forget_abort(&key);
3119 // The plan is the conversation's, so it goes with the conversation:
3120 // an id that comes back later starts with an empty panel rather than
3121 // the previous life's list.
3122 tools::clear_todos(&key);
3123 self.closing_sessions.lock().unwrap().remove(&key);
3124
3125 log::info!(
3126 "close_session: id={key} (known={known}, live={was_live}, {killed} background task(s) stopped)"
3127 );
3128 Ok(CloseSessionResponse::new())
3129 }
3130
3131 /// ACP `session/delete`: the user removed a thread in the editor.
3132 ///
3133 /// A live thread is closed first, exactly as `session/close` would (the
3134 /// caller has run `begin_close` and holds the same locks), so its turn,
3135 /// permission grants and background commands go with it. Then the saved
3136 /// history and its sidecar are removed, which is what takes the thread out
3137 /// of `session/list`. Deleting an id that was never saved, or is already
3138 /// gone, succeeds quietly.
3139 async fn handle_delete_session(
3140 &self,
3141 args: DeleteSessionRequest,
3142 ) -> agent_client_protocol::Result<DeleteSessionResponse> {
3143 let key = args.session_id.to_string();
3144 self.handle_close_session(CloseSessionRequest::new(args.session_id))
3145 .await?;
3146 session_store::delete(&key);
3147 log::info!("delete_session: id={key}");
3148 Ok(DeleteSessionResponse::new())
3149 }
3150
3151 /// First half of `session/close`, run before waiting for any lock.
3152 ///
3153 /// The turn to cancel is the one holding the session's lock, so the signal has to
3154 /// go out first. Marking the session as closing covers the other case: a
3155 /// prompt for it that is still queued behind another thread's turn has
3156 /// registered nothing to cancel yet, and `handle_prompt` turns it away
3157 /// when it reaches the front.
3158 fn begin_close(&self, session_id: &SessionId) {
3159 let key = session_id.to_string();
3160 self.closing_sessions.lock().unwrap().insert(key.clone());
3161 if let Some(cancellation) = self.prompt_cancellations.lock().unwrap().get(&key).cloned() {
3162 cancellation.cancelled.store(true, Ordering::Release);
3163 cancellation.notify.notify_one();
3164 }
3165 tools::abort_session_tools(&key);
3166 }
3167
3168 async fn handle_fork_session(
3169 &self,
3170 cx: &ConnectionTo<Client>,
3171 args: ForkSessionRequest,
3172 ) -> agent_client_protocol::Result<ForkSessionResponse> {
3173 validate_session_roots(&args.cwd, &args.additional_directories)?;
3174
3175 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
3176 log::info!(
3177 "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
3178 args.session_id,
3179 args.cwd.display(),
3180 args.additional_directories
3181 .iter()
3182 .map(|p| p.display().to_string())
3183 .collect::<Vec<_>>()
3184 );
3185
3186 // The fork continues the source thread's conversation under the roots
3187 // it was forked into. Permission grants and plan mode stay behind: the
3188 // new id has none. The source's conversation is current only after
3189 // parking when the source is the live session.
3190 self.park_active_session().await;
3191 let source_key = args.session_id.to_string();
3192 let source_state = self.sessions.lock().unwrap().get(&source_key).cloned();
3193 let mut state = self.session_state(&args.cwd, &args.additional_directories);
3194 if let Some(source) = source_state {
3195 state.conversation = source.conversation;
3196 state.model_id = source.model_id;
3197 } else {
3198 state.conversation = session_store::load(&source_key)
3199 .map(backend::carryover_history)
3200 .unwrap_or_default();
3201 if let Some(model_id) = session_store::list()
3202 .into_iter()
3203 .find(|entry| entry.id == source_key)
3204 .and_then(|entry| entry.model_id)
3205 {
3206 state.model_id = model_id;
3207 }
3208 }
3209 // A fork gets the servers named on its own request, not the source's.
3210 state.mcp_servers =
3211 mcp::connect_session_servers(client_mcp_servers(&args.mcp_servers)).await;
3212 let copied = state.conversation.len();
3213 let updates = history_replay_updates(&state.conversation);
3214 let replayed = updates.len();
3215 for update in updates {
3216 cx.send_notification(SessionNotification::new(new_id.clone(), update))
3217 .ok();
3218 }
3219 self.open_parked_session(&new_id, state).await;
3220 log::info!(
3221 "fork_session: copied {copied} message(s), replayed {replayed} update(s) for {new_id}"
3222 );
3223
3224 let config_options = {
3225 let guard = self.current_model.lock().unwrap();
3226 build_config_options(&guard, &new_id.to_string())
3227 };
3228
3229 self.advertise_commands(cx, new_id.clone());
3230
3231 // Run SessionStart hooks
3232 let hook_settings = settings::load().hooks;
3233 if hook_settings.has_hooks() {
3234 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &new_id.to_string());
3235 }
3236
3237 let modes = build_session_modes(&new_id.to_string());
3238 Ok(ForkSessionResponse::new(new_id)
3239 .config_options(config_options)
3240 .modes(modes))
3241 }
3242
3243 async fn handle_new_session(
3244 &self,
3245 cx: &ConnectionTo<Client>,
3246 args: NewSessionRequest,
3247 ) -> agent_client_protocol::Result<NewSessionResponse> {
3248 validate_session_roots(&args.cwd, &args.additional_directories)?;
3249
3250 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
3251 log::info!(
3252 "new_session: id={session_id}, cwd={}, additional_directories={:?}",
3253 args.cwd.display(),
3254 args.additional_directories
3255 .iter()
3256 .map(|p| p.display().to_string())
3257 .collect::<Vec<_>>()
3258 );
3259
3260 let mut state = self.session_state(&args.cwd, &args.additional_directories);
3261 state.mcp_servers =
3262 mcp::connect_session_servers(client_mcp_servers(&args.mcp_servers)).await;
3263 self.open_session(&session_id, state).await;
3264
3265 let config_options = {
3266 let guard = self.current_model.lock().unwrap();
3267 build_config_options(&guard, &session_id.to_string())
3268 };
3269
3270 self.advertise_commands(cx, session_id.clone());
3271
3272 // Run SessionStart hooks
3273 let hook_settings = settings::load().hooks;
3274 if hook_settings.has_hooks() {
3275 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &session_id.to_string());
3276 }
3277
3278 let modes = build_session_modes(&session_id.to_string());
3279 Ok(NewSessionResponse::new(session_id)
3280 .config_options(config_options)
3281 .modes(modes))
3282 }
3283
3284 async fn handle_prompt(
3285 &self,
3286 cx: &ConnectionTo<Client>,
3287 args: PromptRequest,
3288 ) -> agent_client_protocol::Result<PromptResponse> {
3289 let session_id = args.session_id.clone();
3290
3291 // `session/close` arrived while this prompt was waiting its turn.
3292 // Closing cancels the session's work, and this is part of it.
3293 if self
3294 .closing_sessions
3295 .lock()
3296 .unwrap()
3297 .contains(&session_id.to_string())
3298 {
3299 return Ok(PromptResponse::new(StopReason::Cancelled));
3300 }
3301
3302 // Registered before anything that can wait, so a `session/cancel` that
3303 // lands while this turn is still queued on the workspace, switching
3304 // sessions or loading a model is seen. The session lock is held, so the
3305 // slot can only be this turn's. The guard clears it on every return.
3306 let cancellation = Arc::new(PromptCancellation::default());
3307 tools::reset_abort(&session_id.to_string());
3308 self.prompt_cancellations
3309 .lock()
3310 .unwrap()
3311 .insert(session_id.to_string(), Arc::clone(&cancellation));
3312 let _registration = PromptRegistration {
3313 agent: self,
3314 session_id: session_id.clone(),
3315 cancellation: Arc::clone(&cancellation),
3316 };
3317
3318 // Everything below (slash commands included) acts on the live session,
3319 // so it has to be this one. Resource links are read relative to its cwd.
3320 let mut workspace = WorkspaceHold::acquire(&self.workspace_lock).await;
3321 if cancellation.cancelled.load(Ordering::Acquire) {
3322 return Ok(PromptResponse::new(StopReason::Cancelled));
3323 }
3324 self.reinstall_for_tools(&session_id).await?;
3325 if cancellation.cancelled.load(Ordering::Acquire) {
3326 return Ok(PromptResponse::new(StopReason::Cancelled));
3327 }
3328
3329 // log every block so we can debug @ references and file context
3330 for (i, block) in args.prompt.iter().enumerate() {
3331 match block {
3332 ContentBlock::Text(t) => {
3333 log::info!(
3334 "prompt({}) block[{}]: Text({} chars) = \"{}\"",
3335 session_id,
3336 i,
3337 t.text.len(),
3338 t.text.chars().take(200).collect::<String>()
3339 );
3340 }
3341 ContentBlock::Resource(embedded) => {
3342 log::info!(
3343 "prompt({}) block[{}]: EmbeddedResource = {:?}",
3344 session_id,
3345 i,
3346 match &embedded.resource {
3347 EmbeddedResourceResource::TextResourceContents(t) =>
3348 format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
3349 EmbeddedResourceResource::BlobResourceContents(b) =>
3350 format!("BlobResource(uri={})", b.uri),
3351 _ => "Unknown".to_string(),
3352 }
3353 );
3354 }
3355 ContentBlock::ResourceLink(link) => {
3356 log::info!(
3357 "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})",
3358 session_id,
3359 i,
3360 link.name,
3361 link.uri,
3362 link.title,
3363 link.description
3364 );
3365 }
3366 other => {
3367 log::info!(
3368 "prompt({}) block[{}]: Other({:?})",
3369 session_id,
3370 i,
3371 std::mem::discriminant(other)
3372 );
3373 }
3374 }
3375 }
3376
3377 let mut parts: Vec<String> = Vec::new();
3378 let mut images: Vec<ImageInput> = Vec::new();
3379 let mut audio: Vec<AudioInput> = Vec::new();
3380
3381 for block in &args.prompt {
3382 match block {
3383 ContentBlock::Text(t) => {
3384 parts.push(t.text.clone());
3385 }
3386 ContentBlock::Image(image) => {
3387 images.push(ImageInput {
3388 mime_type: image.mime_type.clone(),
3389 data: image.data.clone(),
3390 });
3391 }
3392 ContentBlock::Audio(clip) => {
3393 audio.push(AudioInput {
3394 mime_type: clip.mime_type.clone(),
3395 data: clip.data.clone(),
3396 });
3397 }
3398 ContentBlock::Resource(embedded) => {
3399 // editor inlined the file content already
3400 match &embedded.resource {
3401 EmbeddedResourceResource::TextResourceContents(text_resource) => {
3402 parts.push(format!(
3403 "\n--- {} ---\n{}\n--- end {} ---",
3404 text_resource.uri, text_resource.text, text_resource.uri
3405 ));
3406 }
3407 EmbeddedResourceResource::BlobResourceContents(blob) => {
3408 let mime = blob.mime_type.clone().unwrap_or_default();
3409 if mime.starts_with("image/") {
3410 images.push(ImageInput {
3411 mime_type: mime,
3412 data: blob.blob.clone(),
3413 });
3414 } else if mime.starts_with("audio/") {
3415 audio.push(AudioInput {
3416 mime_type: mime,
3417 data: blob.blob.clone(),
3418 });
3419 } else if mime.is_empty() {
3420 parts.push(format!("[binary resource: {}]", blob.uri));
3421 } else {
3422 parts.push(format!("[binary resource: {} ({mime})]", blob.uri));
3423 }
3424 }
3425 _ => {
3426 log::debug!("ignoring unsupported embedded resource variant");
3427 }
3428 }
3429 }
3430 ContentBlock::ResourceLink(link) => {
3431 // reference without content; read the file ourselves
3432 let label = link.name.clone();
3433
3434 if let Some((file_path, line_range)) = parse_file_link(&link.uri) {
3435 match std::fs::read_to_string(&file_path) {
3436 Ok(contents) => {
3437 let extracted = if let Some((start, end)) = line_range {
3438 let selected: Vec<&str> = contents
3439 .lines()
3440 .enumerate()
3441 .filter(|(i, _)| {
3442 let line_num = i + 1;
3443 line_num >= start && line_num <= end
3444 })
3445 .map(|(_, line)| line)
3446 .collect();
3447 format!(
3448 "\n--- {label} ({} lines {start}-{end}) ---\n{}\n--- end {label} ---",
3449 file_path.display(),
3450 selected.join("\n")
3451 )
3452 } else {
3453 format!(
3454 "\n--- {label} ({}) ---\n{contents}\n--- end {label} ---",
3455 file_path.display()
3456 )
3457 };
3458 parts.push(extracted);
3459 }
3460 Err(err) => {
3461 log::warn!("could not read ResourceLink {}: {err}", link.uri);
3462 parts.push(format!(
3463 "[referenced file: {label} ({})]",
3464 file_path.display()
3465 ));
3466 }
3467 }
3468 } else {
3469 parts.push(format!("[resource link: {label} ({})]", link.uri));
3470 }
3471 }
3472 _ => {
3473 log::debug!("ignoring unsupported content block type in prompt");
3474 }
3475 }
3476 }
3477
3478 let user_text = parts.join("\n");
3479
3480 if user_text.trim().is_empty() && images.is_empty() && audio.is_empty() {
3481 return Ok(PromptResponse::new(StopReason::EndTurn));
3482 }
3483
3484 let slash_command = parse_slash_from_prompt_texts(args.prompt.iter().filter_map(|block| {
3485 if let ContentBlock::Text(text) = block {
3486 Some(text.text.as_str())
3487 } else {
3488 None
3489 }
3490 }));
3491
3492 let user_text = match slash_command {
3493 // `/init` is not a status command: it substitutes the canned
3494 // AGENTS.md-generation prompt and runs a normal agent turn, so the
3495 // exploration and file write go through the ordinary tools and
3496 // permission checks.
3497 Some(SlashCommand::Init) => instructions::INIT_PROMPT.to_string(),
3498 // A word `parse_slash` didn't recognize might be a user-defined
3499 // command (`.sigit/commands/*.md`) — same substitute-and-run-a-
3500 // real-turn treatment as `/init`. Only fall back to "unknown
3501 // command" once that lookup also misses.
3502 Some(SlashCommand::Unknown(command, argument)) => {
3503 match commands::resolve_command(&command) {
3504 Some(custom) => commands::render(&custom.body, argument.as_deref()),
3505 None => {
3506 return self
3507 .run_slash(
3508 cx,
3509 session_id,
3510 SlashCommand::Unknown(command, argument),
3511 &cancellation,
3512 )
3513 .await;
3514 }
3515 }
3516 }
3517 Some(command) => return self.run_slash(cx, session_id, command, &cancellation).await,
3518 None => user_text,
3519 };
3520
3521 log::info!(
3522 "prompt({}): \"{}\"",
3523 session_id,
3524 user_text.chars().take(80).collect::<String>()
3525 );
3526
3527 // The active backend drives the turn. Snapshot it once so a mid-turn
3528 // model switch doesn't split the conversation across backends.
3529 let backend = self.backend.lock().await.clone();
3530 // Waiting on an HTTP endpoint uses nothing process-wide, so another
3531 // session may be installed meanwhile. The on-device engine holds one
3532 // conversation, so an on-device turn keeps the workspace throughout.
3533 let release_while_waiting = backend.is_remote();
3534
3535 // Only on-device inference needs a local model in memory. Cloud tiers run
3536 // over the network, so they never need a local model. Xcode's explicit
3537 // `--acp` mode loads lazily on the first prompt because its custom-agent
3538 // UI has no reliable equivalent of siGit's `/load` command.
3539 if !backend.is_remote()
3540 && self.engine.info().await.status == onde::inference::EngineStatus::Unloaded
3541 {
3542 if self.auto_load_local_model {
3543 self.start_startup_model_load_if_needed();
3544 tokio::select! {
3545 ready = self.await_model_ready(cx, &session_id) => ready?,
3546 () = cancellation.cancelled() => {
3547 return Ok(PromptResponse::new(StopReason::Cancelled));
3548 }
3549 }
3550 } else {
3551 self.send_assistant_message(
3552 cx,
3553 session_id,
3554 "No on-device model is loaded. Run `/load` to load the selected model, \
3555 or `/models` to choose one.",
3556 )
3557 .ok();
3558 return Ok(PromptResponse::new(StopReason::EndTurn));
3559 }
3560 }
3561
3562 if cancellation.cancelled.load(Ordering::Acquire) {
3563 return Ok(PromptResponse::new(StopReason::Cancelled));
3564 }
3565
3566 // ── tool-calling loop ────────────────────────────────────────────
3567 // send message → execute any tool calls → feed results back
3568 // repeat up to the tool-round cap, then force a text reply
3569
3570 let tools = agent_tools_as_specs();
3571 let max_tool_rounds = headless::max_tool_rounds_from_env();
3572
3573 let model_name = self.current_model.lock().unwrap().display_name.clone();
3574 let context_window = self.context_window_tokens();
3575 let earlier_images = if backend.accepts_images() {
3576 0
3577 } else {
3578 backend
3579 .history_snapshot()
3580 .await
3581 .iter()
3582 .map(backend::message_image_count)
3583 .sum()
3584 };
3585 let (user_text, images, image_notice) = images_for_turn(
3586 user_text,
3587 images,
3588 backend.accepts_images(),
3589 &model_name,
3590 earlier_images,
3591 &session_id.to_string(),
3592 );
3593 let earlier_audio = if backend.accepts_audio() {
3594 0
3595 } else {
3596 backend
3597 .history_snapshot()
3598 .await
3599 .iter()
3600 .map(backend::message_audio_count)
3601 .sum()
3602 };
3603 let (user_text, audio, audio_notice) = audio_for_turn(
3604 user_text,
3605 audio,
3606 backend.accepts_audio(),
3607 &model_name,
3608 earlier_audio,
3609 &session_id.to_string(),
3610 );
3611 for notice in [image_notice, audio_notice].into_iter().flatten() {
3612 self.send_assistant_message(
3613 cx,
3614 session_id.clone(),
3615 format!("{notice}{PARAGRAPH_BREAK}"),
3616 )
3617 .ok();
3618 }
3619
3620 // Token sink: backends stream assistant text through this while a turn
3621 // runs. We forward the visible portion to the editor as agent-message
3622 // chunks live, and the reasoning portion as thought chunks (see
3623 // `drain_turn` / `emit_token_chunk`). The sink stays alive for the whole
3624 // prompt so `recv()` only ends when a turn future resolves, never
3625 // because every sender was dropped.
3626 let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<TokenChunk>();
3627 let mut reply = StreamedReply::default();
3628 let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
3629
3630 // Kept so a refused turn can be taken back out of history (see the end
3631 // of this function). Only a remote endpoint reports a refusal, and an
3632 // on-device snapshot would not survive the round trip intact anyway.
3633 let history_before_turn = if backend.is_remote() {
3634 Some(backend.history_snapshot().await)
3635 } else {
3636 None
3637 };
3638
3639 if release_while_waiting {
3640 workspace.release();
3641 }
3642 let outcome = self
3643 .drain_turn(
3644 cx,
3645 &session_id,
3646 backend.send_message_with_attachments(
3647 &user_text,
3648 &images,
3649 &audio,
3650 &tools,
3651 Some(&sink),
3652 ),
3653 &mut sink_rx,
3654 &mut reply,
3655 &cancellation,
3656 )
3657 .await;
3658 self.resume_workspace(&mut workspace, &session_id, &cancellation)
3659 .await;
3660 let mut result = match outcome {
3661 Ok(result) => result,
3662 Err(DrainTurnError::Cancelled) => {
3663 backend::forget_trailing_user_message(backend.as_ref()).await;
3664 self.finish_prompt(&session_id, &cancellation);
3665 return Ok(PromptResponse::new(StopReason::Cancelled));
3666 }
3667 Err(DrainTurnError::Backend(error)) => {
3668 log::error!("send_message_with_tools failed: {error}");
3669 // Re-emit the persisted plan so the client panel doesn't go
3670 // empty when the initial inference call fails before the model
3671 // can call `write_todos`.
3672 if let Some(plan) = persisted_todos_to_plan(&session_id) {
3673 let _ = self.send_plan_update(cx, session_id.clone(), plan);
3674 }
3675 // Verbatim, no prefix: the backend has already turned this into
3676 // something worth reading (see `describe_api_error`), and it is
3677 // what the editor puts in its error banner. The context a
3678 // prefix would add is in the log line above.
3679 // The client keeps this prompt in its thread after the error,
3680 // so keep it in the saved conversation too.
3681 let snapshot = backend.history_snapshot().await;
3682 self.persist_session(cx, &session_id, &snapshot).await;
3683 self.finish_prompt(&session_id, &cancellation);
3684 return Err(agent_client_protocol::Error::new(
3685 ErrorCode::InternalError.into(),
3686 error,
3687 ));
3688 }
3689 };
3690
3691 self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
3692 .await;
3693
3694 let mut round = 0;
3695 // Set when the repetition guard blocked a call; names the tool.
3696 let mut stopped_repeating: Option<String> = None;
3697 // Visible text already sent before the latest inference round, so the
3698 // end of the turn can tell whether that round said anything at all.
3699 let mut sent_before_last_round = 0;
3700
3701 while !result.tool_calls.is_empty() && round < max_tool_rounds {
3702 if cancellation.cancelled.load(Ordering::Acquire) {
3703 self.finish_prompt(&session_id, &cancellation);
3704 return Ok(PromptResponse::new(StopReason::Cancelled));
3705 }
3706 round += 1;
3707 log::info!(
3708 "prompt({}) tool round {} — {} call(s)",
3709 session_id,
3710 round,
3711 result.tool_calls.len()
3712 );
3713
3714 // Auto-compaction: long tool runs grow history fast; fold it into
3715 // a summary before the next round rather than blowing the window.
3716 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
3717 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
3718 log::info!(
3719 "prompt({}) history ≈{} tokens exceeds budget {} — compacting",
3720 session_id,
3721 estimate,
3722 backend::DEFAULT_CONTEXT_TOKEN_BUDGET
3723 );
3724 match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
3725 Ok(()) => {
3726 let after = backend::estimate_tokens(&backend.history_snapshot().await);
3727 log::info!("prompt({}) compacted to ≈{} tokens", session_id, after);
3728 }
3729 Err(error) => {
3730 // Even the deterministic fallback could not fit the
3731 // history in the budget — the session is genuinely
3732 // stuck, so say what is actually recoverable.
3733 log::warn!("prompt({}) compaction failed: {error}", session_id);
3734 self.send_assistant_message(
3735 cx,
3736 session_id.clone(),
3737 format!(
3738 "This session is too large for the context window, and \
3739 siGit Code could not shrink it ({error}). Run `/clear` \
3740 or start a new thread to continue."
3741 ),
3742 )
3743 .ok();
3744 self.finish_prompt(&session_id, &cancellation);
3745 return Ok(PromptResponse::new(StopReason::EndTurn));
3746 }
3747 }
3748 }
3749
3750 let mut tool_results = Vec::new();
3751 let mut force_text = false;
3752
3753 for (call_index, tc) in result.tool_calls.iter().enumerate() {
3754 log::info!(
3755 " → {}({})",
3756 tc.name,
3757 tc.arguments.chars().take(120).collect::<String>()
3758 );
3759
3760 let signature = format!("{}\n{}", tc.name, tc.arguments);
3761 let repeat_count = repeated_tool_calls
3762 .entry(signature)
3763 .and_modify(|count| *count += 1)
3764 .or_insert(1);
3765 // Repeated status checks are expected for a live background
3766 // command. command_output and monitor_command wait, and the overall round cap
3767 // still bounds a confused model without cutting off a real
3768 // build or release while it is in progress.
3769 let repeated = *repeat_count >= 3
3770 && tc.name != "command_output"
3771 && tc.name != "monitor_command";
3772 if repeated {
3773 force_text = true;
3774 stopped_repeating = Some(tc.name.clone());
3775 log::warn!(
3776 "prompt({}) stopping repeated tool call `{}` after {} attempts",
3777 session_id,
3778 tc.name,
3779 repeat_count
3780 );
3781 }
3782
3783 // Decided before the call is announced, because the answer sets
3784 // the status it is announced with: a call that has to wait for
3785 // the user starts out `pending`, everything else is already
3786 // running. A repeated call is never run, so never asked about.
3787 let decision = (!repeated).then(|| {
3788 permissions::decision_for(&session_id.to_string(), &tc.name, &tc.arguments)
3789 });
3790 let announced_status = match decision {
3791 Some(permissions::Decision::Ask) => ToolCallStatus::Pending,
3792 _ => ToolCallStatus::InProgress,
3793 };
3794
3795 // Only treat the call as a plan once its arguments have actually
3796 // produced one. Keying off the tool name alone means a
3797 // `write_todos` the converter rejects sends no plan *and* skips
3798 // the tool-call card below, so the call disappears from the
3799 // client while the model is told the list was updated.
3800 let plan = (tc.name == "write_todos")
3801 .then(|| todos_arguments_to_plan(&tc.arguments))
3802 .flatten();
3803 let render_as_plan = plan.is_some();
3804 if let Some(plan) = plan {
3805 self.send_plan_update(cx, session_id.clone(), plan).ok();
3806 } else {
3807 // Model tool calls are agent actions in ACP too, not merely an
3808 // implementation detail of the completion loop. Announce each
3809 // one before it runs and close it afterwards so clients such as
3810 // Zed can render activity while the next inference round is in
3811 // flight. Previously only model loading emitted ToolCall events,
3812 // leaving an otherwise working tool round visually indistinct
3813 // from a stalled response.
3814 let raw_input: serde_json::Value = serde_json::from_str(&tc.arguments)
3815 .unwrap_or_else(|e| {
3816 log::warn!("malformed JSON in tool arguments for '{}': {e}", tc.name);
3817 serde_json::Value::String(tc.arguments.clone())
3818 });
3819 let invocation_content = tool_arguments_content(&tc.arguments);
3820 self.send_tool_call_update(
3821 cx,
3822 session_id.clone(),
3823 SessionUpdate::ToolCall(
3824 ToolCall::new(tc.id.clone(), chat::tool_title(&tc.name, &tc.arguments))
3825 .name(tc.name.clone())
3826 .kind(tool_kind_for(&tc.name))
3827 .status(announced_status)
3828 .content(vec![invocation_content])
3829 .locations(tool_call_locations(&tc.name, &tc.arguments))
3830 .raw_input(raw_input),
3831 ),
3832 )
3833 .ok();
3834 }
3835
3836 // Permission gate: read-only tools pass straight through; a
3837 // mutating tool consults policy and may ask the client. The
3838 // flag says whether the tool actually ran: a call that was
3839 // denied or skipped ends `failed`, not `completed` (#139).
3840 let mut change = None;
3841 let mut terminal = None;
3842 let (output, executed) = match decision {
3843 None => (
3844 format!(
3845 "The tool `{}` was not executed again because the model repeated \
3846 the same call three times. Continue without this tool.",
3847 tc.name
3848 ),
3849 false,
3850 ),
3851 Some(decision) => match decision {
3852 permissions::Decision::Allow => {
3853 let outcome = tools::execute_tool_with_change(
3854 &tc.name,
3855 &tc.arguments,
3856 Some(&tc.id),
3857 )
3858 .await;
3859 change = outcome.change;
3860 terminal = outcome.terminal;
3861 (outcome.output, true)
3862 }
3863 permissions::Decision::Deny(reason) => {
3864 log::info!(" ✗ {} denied by policy", tc.name);
3865 (reason, false)
3866 }
3867 permissions::Decision::Ask => {
3868 // Worked out while the session still holds the
3869 // workspace: a relative path resolves against
3870 // its cwd.
3871 let preview = tools::preview_file_change(&tc.name, &tc.arguments).await;
3872 // The answer can take as long as the user likes.
3873 if release_while_waiting {
3874 workspace.release();
3875 }
3876 // A client that cancels the turn answers the
3877 // request itself. One that closes the session
3878 // need not, so the wait ends on either.
3879 let verdict = tokio::select! {
3880 verdict = self.request_tool_permission(
3881 cx,
3882 &session_id,
3883 &tc.id,
3884 &tc.name,
3885 &tc.arguments,
3886 preview.as_ref(),
3887 ) => verdict,
3888 _ = cancellation.cancelled() => PermissionVerdict::TurnCancelled,
3889 };
3890 self.resume_workspace(&mut workspace, &session_id, &cancellation)
3891 .await;
3892 // An approval can cross a cancellation on the wire,
3893 // or arrive while this turn waited for the lock.
3894 let verdict = if cancellation.cancelled.load(Ordering::Acquire) {
3895 PermissionVerdict::TurnCancelled
3896 } else {
3897 verdict
3898 };
3899 match verdict {
3900 PermissionVerdict::Approved => {
3901 // The call the user just approved leaves
3902 // `pending` here. The title goes back to
3903 // the card's own: the permission request
3904 // replaced it with the full arguments.
3905 if !render_as_plan {
3906 self.send_tool_call_update(
3907 cx,
3908 session_id.clone(),
3909 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3910 tc.id.clone(),
3911 ToolCallUpdateFields::new()
3912 .status(ToolCallStatus::InProgress)
3913 .title(chat::tool_title(
3914 &tc.name,
3915 &tc.arguments,
3916 )),
3917 )),
3918 )
3919 .ok();
3920 }
3921 let outcome = tools::execute_tool_with_change(
3922 &tc.name,
3923 &tc.arguments,
3924 Some(&tc.id),
3925 )
3926 .await;
3927 change = outcome.change;
3928 terminal = outcome.terminal;
3929 (outcome.output, true)
3930 }
3931 PermissionVerdict::Denied(reason) => {
3932 log::info!(" ✗ {} denied by user", tc.name);
3933 (reason, false)
3934 }
3935 PermissionVerdict::TurnCancelled => {
3936 log::info!(
3937 "prompt({}) cancelled at permission gate",
3938 session_id
3939 );
3940 // The assistant message carrying these tool
3941 // calls is already in the backend history;
3942 // leaving any of them unanswered makes strict
3943 // OpenAI-compatible endpoints reject every
3944 // later request in the session. Close out this
3945 // call and the ones this round never reached.
3946 for pending in &result.tool_calls[call_index..] {
3947 tool_results.push(BackendToolResult {
3948 tool_call_id: pending.id.clone(),
3949 content: format!(
3950 "`{}` was not executed: the user cancelled the turn \
3951 at the permission prompt.",
3952 pending.name
3953 ),
3954 });
3955 }
3956 let cancelled_reason = "Not executed: the user cancelled the turn at the permission prompt.";
3957 self.send_tool_call_update(
3958 cx,
3959 session_id.clone(),
3960 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3961 tc.id.clone(),
3962 ToolCallUpdateFields::new()
3963 .status(ToolCallStatus::Failed)
3964 .content(vec![tool_output_content(
3965 cancelled_reason,
3966 )])
3967 .raw_output(serde_json::Value::String(
3968 cancelled_reason.to_string(),
3969 )),
3970 )),
3971 )
3972 .ok();
3973 backend.record_cancelled_tool_results(tool_results).await;
3974 self.finish_prompt(&session_id, &cancellation);
3975 return Ok(PromptResponse::new(StopReason::Cancelled));
3976 }
3977 }
3978 }
3979 },
3980 };
3981
3982 log::info!(" ← {} chars", output.len());
3983
3984 if !render_as_plan {
3985 let mut fields = ToolCallUpdateFields::new()
3986 .status(if executed {
3987 ToolCallStatus::Completed
3988 } else {
3989 ToolCallStatus::Failed
3990 })
3991 .raw_output(serde_json::Value::String(output.clone()));
3992 if terminal.is_none() {
3993 fields = fields.content(tool_result_content(&output, change.as_ref()));
3994 }
3995 // A denial at the prompt leaves the card titled with the
3996 // permission request's full arguments; put its own back.
3997 if !executed && announced_status == ToolCallStatus::Pending {
3998 fields = fields.title(chat::tool_title(&tc.name, &tc.arguments));
3999 }
4000 self.send_tool_call_update(
4001 cx,
4002 session_id.clone(),
4003 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(tc.id.clone(), fields)),
4004 )
4005 .ok();
4006 }
4007
4008 tool_results.push(BackendToolResult {
4009 tool_call_id: tc.id.clone(),
4010 content: output,
4011 });
4012 // A cancel aborts a running foreground command (see
4013 // `tools::abort_session_tools`), so it exits and is observed
4014 // here. Background commands are stopped with `kill_command`.
4015 if cancellation.cancelled.load(Ordering::Acquire) {
4016 for pending in &result.tool_calls[call_index + 1..] {
4017 tool_results.push(BackendToolResult {
4018 tool_call_id: pending.id.clone(),
4019 content: format!(
4020 "`{}` was not executed: the user cancelled the turn.",
4021 pending.name
4022 ),
4023 });
4024 }
4025 backend.record_cancelled_tool_results(tool_results).await;
4026 self.finish_prompt(&session_id, &cancellation);
4027 return Ok(PromptResponse::new(StopReason::Cancelled));
4028 }
4029 }
4030
4031 let round_cap_reached = round >= max_tool_rounds;
4032 let allow_tool_calls = !round_cap_reached && !force_text;
4033 if round_cap_reached && let Some(last) = tool_results.last_mut() {
4034 last.content.push_str(&round_cap_note(max_tool_rounds));
4035 }
4036
4037 // Whatever this round says starts a new paragraph rather than
4038 // continuing the sentence the tool calls interrupted.
4039 reply.interrupt();
4040 sent_before_last_round = reply.sent.len();
4041
4042 let cancelled_results = tool_results.clone();
4043 if release_while_waiting {
4044 workspace.release();
4045 }
4046 let outcome = self
4047 .drain_turn(
4048 cx,
4049 &session_id,
4050 backend.send_tool_results(tool_results, &tools, allow_tool_calls, Some(&sink)),
4051 &mut sink_rx,
4052 &mut reply,
4053 &cancellation,
4054 )
4055 .await;
4056 self.resume_workspace(&mut workspace, &session_id, &cancellation)
4057 .await;
4058 result = match outcome {
4059 Ok(result) => result,
4060 Err(DrainTurnError::Cancelled) => {
4061 backend
4062 .record_cancelled_tool_results(cancelled_results)
4063 .await;
4064 self.finish_prompt(&session_id, &cancellation);
4065 return Ok(PromptResponse::new(StopReason::Cancelled));
4066 }
4067 Err(DrainTurnError::Backend(error)) => {
4068 log::error!("send_tool_results failed: {error}");
4069 // Re-emit the persisted plan so the client panel doesn't go
4070 // empty when a tool round fails mid-turn.
4071 if let Some(plan) = persisted_todos_to_plan(&session_id) {
4072 let _ = self.send_plan_update(cx, session_id.clone(), plan);
4073 }
4074 self.finish_prompt(&session_id, &cancellation);
4075 return Err(agent_client_protocol::Error::new(
4076 ErrorCode::InternalError.into(),
4077 error,
4078 ));
4079 }
4080 };
4081 self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
4082 .await;
4083 }
4084
4085 // ── Final text response ───────────────────────────────────────────
4086 // If anything streamed, the visible reply is already on the wire; only
4087 // send a trailing block for the non-streamed path (e.g. on-device direct
4088 // answers, which onde can't stream while tools are on offer).
4089 let round_cap_reached = round >= max_tool_rounds;
4090 if !reply.streamed_any {
4091 let reply_text = result.text.trim().to_string();
4092 let final_text = if reply_text.is_empty() {
4093 if round_cap_reached {
4094 // The cap notice below covers it.
4095 String::new()
4096 } else if round > 0 {
4097 log::warn!(
4098 "prompt({}) — model returned empty reply after {} tool round(s)",
4099 session_id,
4100 round
4101 );
4102 silent_stop_message(stopped_repeating.as_deref(), round)
4103 } else {
4104 log::warn!(
4105 "prompt({}) — model returned empty reply (no tool rounds)",
4106 session_id
4107 );
4108 String::new()
4109 }
4110 } else {
4111 // strip <think> blocks so reasoning tokens stay hidden
4112 let (_think, visible) = chat::strip_think_blocks(&reply_text);
4113 visible
4114 };
4115
4116 if !final_text.is_empty() {
4117 let message_id = reply.message_id();
4118 self.send_reply_chunk(cx, session_id.clone(), message_id, final_text)
4119 .ok();
4120 }
4121 } else if round > 0 && !round_cap_reached && reply.sent.len() == sent_before_last_round {
4122 // Earlier rounds streamed text, but the last one (typically the
4123 // forced no-tools round) said nothing. Without a closing message
4124 // the turn just stops, and the user can't tell whether it ended,
4125 // stalled, or is waiting on them.
4126 log::warn!(
4127 "prompt({}) — final round after {} tool round(s) produced no text",
4128 session_id,
4129 round
4130 );
4131 let message_id = reply.message_id();
4132 self.send_reply_chunk(
4133 cx,
4134 session_id.clone(),
4135 message_id,
4136 format!(
4137 "{PARAGRAPH_BREAK}{}",
4138 silent_stop_message(stopped_repeating.as_deref(), round)
4139 ),
4140 )
4141 .ok();
4142 }
4143
4144 if round_cap_reached {
4145 log::warn!(
4146 "prompt({}) stopped at the cap of {} tool round(s)",
4147 session_id,
4148 max_tool_rounds
4149 );
4150 let message_id = reply.message_id();
4151 self.send_reply_chunk(
4152 cx,
4153 session_id.clone(),
4154 message_id,
4155 format!(
4156 "{PARAGRAPH_BREAK}{}",
4157 round_cap_stop_message(max_tool_rounds)
4158 ),
4159 )
4160 .ok();
4161 }
4162
4163 let stop_reason = stop_reason_for(round_cap_reached, result.finish);
4164
4165 // ACP defines a refusal as a turn the next prompt will not include:
4166 // the client drops the user message and everything after it from its
4167 // own view. Take the turn out of history too, or the model keeps
4168 // answering a conversation the user can no longer see.
4169 if stop_reason == StopReason::Refusal
4170 && let Some(history) = history_before_turn
4171 {
4172 log::warn!(
4173 "prompt({}) refused by the endpoint — dropping the turn from history",
4174 session_id
4175 );
4176 backend.restore_history(history).await;
4177 }
4178
4179 // Persist the completed turn so a restart (or session/load) can pick
4180 // the conversation back up.
4181 let snapshot = backend.history_snapshot().await;
4182 self.persist_session(cx, &session_id, &snapshot).await;
4183 self.finish_prompt(&session_id, &cancellation);
4184
4185 log::info!(
4186 "prompt({}) complete — {} tool round(s), stop reason {:?}",
4187 session_id,
4188 round,
4189 stop_reason
4190 );
4191 Ok(PromptResponse::new(stop_reason))
4192 }
4193
4194 /// The context window of the session's model, for `usage_update`. A model
4195 /// the picker does not list (a provider-supplied name, say) falls back to
4196 /// the compaction budget, the same as the TUI's gauge.
4197 fn context_window_tokens(&self) -> u64 {
4198 let current = self.current_model.lock().unwrap().clone();
4199 models::build_model_picker_items()
4200 .into_iter()
4201 .find(|item| {
4202 item.config.model_id == current.model_id
4203 || item.display_name == current.display_name
4204 })
4205 .map(|item| item.context_window_tokens)
4206 .unwrap_or(backend::DEFAULT_CONTEXT_TOKEN_BUDGET as u64)
4207 }
4208
4209 /// Report how much of the context window the session uses after one
4210 /// model response. The endpoint's own count is used when it gave one;
4211 /// otherwise (always on-device) the history is estimated, the way
4212 /// compaction does it.
4213 async fn send_usage(
4214 &self,
4215 cx: &ConnectionTo<Client>,
4216 session_id: &SessionId,
4217 backend: &dyn InferenceBackend,
4218 result: &TurnResult,
4219 size: u64,
4220 ) {
4221 let used = match result.context_tokens {
4222 Some(tokens) => tokens,
4223 None => backend::estimate_tokens(&backend.history_snapshot().await) as u64,
4224 };
4225 cx.send_notification(SessionNotification::new(
4226 session_id.clone(),
4227 SessionUpdate::UsageUpdate(UsageUpdate::new(used, size)),
4228 ))
4229 .ok();
4230 }
4231
4232 /// Ask the ACP client for permission to run one tool call. The request
4233 /// names the call by `tool_call_id`, the id it was announced under, so the
4234 /// client updates that one card instead of drawing a second. Presents
4235 /// allow-once / allow-for-session / deny / deny-for-session; an "always"
4236 /// choice is recorded via [`permissions::grant_for_session`] or
4237 /// [`permissions::deny_for_session`]. Only safe to call from
4238 /// a spawned task (see the handler registration in `run_acp_server`): the
4239 /// dispatch loop must be free to route the client's answer back to us.
4240 ///
4241 /// `preview` is the file change the call would make, from
4242 /// [`tools::preview_file_change`]; when there is one the card shows it as
4243 /// a diff, so the user approves an edit by looking at the change.
4244 async fn request_tool_permission(
4245 &self,
4246 cx: &ConnectionTo<Client>,
4247 session_id: &SessionId,
4248 tool_call_id: &str,
4249 tool_name: &str,
4250 arguments: &str,
4251 preview: Option<&tools::FileChange>,
4252 ) -> PermissionVerdict {
4253 // The user decides from this dialog, so show the arguments with any
4254 // truncation flagged (a silently clipped command could hide its tail
4255 // from the person approving it). The full arguments also travel as
4256 // `raw_input` for clients that render it.
4257 let args_preview = permissions::approval_preview(arguments);
4258 let title = if args_preview.is_empty() {
4259 tool_name.to_string()
4260 } else {
4261 format!("{tool_name}({args_preview})")
4262 };
4263 let raw_input: serde_json::Value = serde_json::from_str(arguments).unwrap_or_else(|e| {
4264 log::warn!("malformed JSON in tool arguments for '{}': {e}", tool_name);
4265 serde_json::Value::String(arguments.to_string())
4266 });
4267
4268 let request = RequestPermissionRequest::new(
4269 session_id.clone(),
4270 ToolCallUpdate::new(
4271 tool_call_id.to_string(),
4272 ToolCallUpdateFields::new()
4273 .title(title)
4274 .kind(tool_kind_for(tool_name))
4275 .status(ToolCallStatus::Pending)
4276 .content(preview.map(|change| vec![file_change_content(change)]))
4277 .raw_input(raw_input),
4278 ),
4279 vec![
4280 PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
4281 PermissionOption::new(
4282 "allow_session",
4283 "Allow for this session",
4284 PermissionOptionKind::AllowAlways,
4285 ),
4286 PermissionOption::new("reject_once", "Deny", PermissionOptionKind::RejectOnce),
4287 PermissionOption::new(
4288 "reject_session",
4289 "Deny for this session",
4290 PermissionOptionKind::RejectAlways,
4291 ),
4292 ],
4293 );
4294
4295 match cx.send_request(request).block_task().await {
4296 Ok(response) => match response.outcome {
4297 RequestPermissionOutcome::Selected(selected) => {
4298 match selected.option_id.0.as_ref() {
4299 "allow_once" => PermissionVerdict::Approved,
4300 "allow_session" => {
4301 permissions::grant_for_session(
4302 &session_id.to_string(),
4303 tool_name,
4304 arguments,
4305 );
4306 PermissionVerdict::Approved
4307 }
4308 "reject_session" => {
4309 permissions::deny_for_session(
4310 &session_id.to_string(),
4311 tool_name,
4312 arguments,
4313 );
4314 PermissionVerdict::Denied(permissions::user_denial(tool_name))
4315 }
4316 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
4317 }
4318 }
4319 RequestPermissionOutcome::Cancelled => PermissionVerdict::TurnCancelled,
4320 // The outcome enum is non_exhaustive; treat anything unknown as
4321 // a denial rather than running a mutating tool unapproved.
4322 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
4323 },
4324 Err(error) => {
4325 log::warn!("permission request for `{tool_name}` failed: {error}");
4326 PermissionVerdict::Denied(format!(
4327 "`{tool_name}` was not executed: this client could not answer the \
4328 permission request ({error}). The user can pre-approve tools in \
4329 settings.toml under [permissions], or set SIGIT_PERMISSIONS=allow \
4330 for clients without permission support."
4331 ))
4332 }
4333 }
4334 }
4335
4336 /// Run a built-in slash command as a turn. A `session/cancel` ends it with
4337 /// `Cancelled`; only `/compact` and `/load` take long enough to notice.
4338 async fn run_slash(
4339 &self,
4340 cx: &ConnectionTo<Client>,
4341 session_id: SessionId,
4342 command: SlashCommand,
4343 cancellation: &PromptCancellation,
4344 ) -> agent_client_protocol::Result<PromptResponse> {
4345 if cancellation.cancelled.load(Ordering::Acquire) {
4346 return Ok(PromptResponse::new(StopReason::Cancelled));
4347 }
4348 tokio::select! {
4349 response = exec_slash_acp(self, cx, session_id, command) => response,
4350 () = cancellation.cancelled() => Ok(PromptResponse::new(StopReason::Cancelled)),
4351 }
4352 }
4353
4354 fn finish_prompt(&self, session_id: &SessionId, cancellation: &Arc<PromptCancellation>) {
4355 let key = session_id.to_string();
4356 let mut active = self.prompt_cancellations.lock().unwrap();
4357 if active
4358 .get(&key)
4359 .is_some_and(|registered| Arc::ptr_eq(registered, cancellation))
4360 {
4361 active.remove(&key);
4362 }
4363 }
4364
4365 async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
4366 log::info!("cancel requested for session {}", args.session_id);
4367 self.cancel_turn(&args.session_id);
4368 Ok(())
4369 }
4370
4371 /// Stop the turn running on `session_id`, if there is one, and whatever
4372 /// command it is running. Reached from `session/cancel` and from a
4373 /// `$/cancel_request` naming the prompt.
4374 fn cancel_turn(&self, session_id: &SessionId) {
4375 if let Some(cancellation) = self
4376 .prompt_cancellations
4377 .lock()
4378 .unwrap()
4379 .get(&session_id.to_string())
4380 .cloned()
4381 {
4382 cancellation.cancelled.store(true, Ordering::Release);
4383 cancellation.notify.notify_one();
4384 }
4385 tools::abort_session_tools(&session_id.to_string());
4386 }
4387
4388 /// Swap the active backend to a siGit Code Cloud tier and reflect it as the
4389 /// current model so the picker shows it selected. Returns the tier's display
4390 /// name on success, or `None` when no account is signed in (caller prompts
4391 /// for login). Shared by the panel picker and the `/models` slash command.
4392 async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
4393 let cfg = crate::provider::cloud_tier_provider(tier)?;
4394 let display_name = cfg.display_name.clone();
4395 self.install_remote_backend(cfg, format!("sigit-cloud:{tier}"))
4396 .await;
4397
4398 // Explicitly choosing a cloud tier puts us in cloud mode.
4399 let _ = settings::set_local_inference(false);
4400
4401 log::info!("switched to cloud tier {tier}");
4402 Some(display_name)
4403 }
4404
4405 /// Install a remote provider under `selection_id`, carrying only the live
4406 /// session's non-system conversation. Kept separate from cloud credential
4407 /// lookup so the session-boundary regression can be tested without an
4408 /// account or network request.
4409 async fn install_remote_backend(&self, cfg: provider::ProviderConfig, selection_id: String) {
4410 // Mirror the cwd guidance and project instruction files the local engine
4411 // gets at session load, so the cloud model shares the same project context.
4412 let system_prompt = match self.session_cwd.lock().ok().and_then(|g| g.clone()) {
4413 Some(cwd) => remote_system_prompt(&session_context_message(
4414 &cwd,
4415 &workspace::additional_roots(),
4416 )),
4417 None => system_prompt_for_model(true),
4418 };
4419 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(
4420 OpenAiBackend::new(cfg.base_url, cfg.api_key, cfg.model, Some(system_prompt))
4421 .with_session_token(cfg.session_token),
4422 );
4423 // Continue the thread on the new backend instead of starting over.
4424 let carried =
4425 backend::carryover_history(self.backend.lock().await.history_snapshot().await);
4426 backend::adopt_carryover(cloud_backend.as_ref(), carried).await;
4427 *self.backend.lock().await = cloud_backend;
4428
4429 let remote_config = GgufModelConfig {
4430 model_id: selection_id,
4431 files: Vec::new(),
4432 tok_model_id: None,
4433 display_name: cfg.display_name.clone(),
4434 approx_memory: "Cloud".to_string(),
4435 chat_template: None,
4436 };
4437 {
4438 let mut guard = self.current_model.lock().unwrap();
4439 *guard = remote_config;
4440 }
4441 self.record_active_model();
4442 }
4443
4444 /// Route inference back on-device. Used after leaving a cloud tier for a
4445 /// local model. The `LocalBackend` reads the live `engine`, so this repoints
4446 /// the active backend and moves the conversation onto the engine with it.
4447 async fn reset_to_local_backend(&self) {
4448 // Coming back from a cloud tier, the turns since the switch live in the
4449 // cloud backend's history, not the engine's — carry them over so the
4450 // on-device model picks the thread up where the cloud one left it. A
4451 // local-to-local switch needs nothing: `switch_model_by_id` has already
4452 // replayed the engine's own history past the reload.
4453 let carried = {
4454 let current = self.backend.lock().await;
4455 if current.is_remote() {
4456 backend::carryover_history(current.history_snapshot().await)
4457 } else {
4458 Vec::new()
4459 }
4460 };
4461
4462 let local_backend: Arc<dyn InferenceBackend> =
4463 Arc::new(LocalBackend::new(Arc::clone(&self.engine)));
4464 backend::adopt_carryover(local_backend.as_ref(), carried).await;
4465 *self.backend.lock().await = local_backend;
4466 self.record_active_model();
4467 }
4468
4469 /// Re-attempt the lazy startup model load if the previous attempt failed.
4470 /// Clears the one-shot guard so the next load runs; a healthy load is left
4471 /// untouched so `/reload` doesn't needlessly reload a working model.
4472 fn retry_startup_model_load_if_failed(&self) {
4473 let had_error = self
4474 .model_load_error
4475 .lock()
4476 .map(|guard| guard.is_some())
4477 .unwrap_or(false);
4478 if had_error {
4479 self.startup_model_load_started
4480 .store(false, Ordering::Release);
4481 self.start_startup_model_load_if_needed();
4482 }
4483 }
4484
4485 /// Re-sync session state in place — no new session needed. Re-applies the
4486 /// active backend from current credentials (so a fresh `/login` token is
4487 /// picked up), retries a failed model load, and pushes refreshed commands +
4488 /// picker so the editor's UI reflects the current state.
4489 async fn handle_reload(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
4490 let signed_in = account::status_line().await;
4491
4492 let on_cloud_tier = {
4493 let guard = self.current_model.lock().unwrap();
4494 guard
4495 .model_id
4496 .strip_prefix("sigit-cloud:")
4497 .map(str::to_string)
4498 };
4499
4500 let backend_note = match on_cloud_tier {
4501 Some(tier) => match self.switch_to_cloud_tier(&tier).await {
4502 Some(name) => format!("Active: {name}."),
4503 None => {
4504 *self.current_model.lock().unwrap() = default_local_model_config();
4505 self.reset_to_local_backend().await;
4506 let _ = settings::set_local_inference(true);
4507 "Signed out — back to on-device. Pick a model with /models.".to_string()
4508 }
4509 },
4510 None => {
4511 self.reset_to_local_backend().await;
4512 self.retry_startup_model_load_if_failed();
4513 let guard = self.current_model.lock().unwrap();
4514 format!("Active: {}.", guard.display_name)
4515 }
4516 };
4517
4518 // Push refreshed picker + commands so the editor reflects current state.
4519 let config_options = {
4520 let guard = self.current_model.lock().unwrap();
4521 build_config_options(&guard, &session_id.to_string())
4522 };
4523 self.send_tool_call_update(
4524 cx,
4525 session_id.clone(),
4526 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
4527 )
4528 .ok();
4529 // A reload rereads settings.toml, which can move the default mode.
4530 self.send_current_mode(cx, session_id.clone());
4531 self.advertise_commands(cx, session_id.clone());
4532
4533 self.send_assistant_message(
4534 cx,
4535 session_id,
4536 format!("Reloaded. {signed_in} {backend_note}"),
4537 )
4538 .ok();
4539 }
4540
4541 /// `session/set_mode`, the selector ACP had before config options. The
4542 /// modes are the Permissions dropdown under another name, so this changes
4543 /// the same state and then refreshes the dropdown for a client that shows
4544 /// both.
4545 /// `-32002` unless `session_id` is a session this process knows.
4546 fn require_known_session(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
4547 let key = session_id.to_string();
4548 if self.sessions.lock().unwrap().contains_key(&key) {
4549 return Ok(());
4550 }
4551 Err(agent_client_protocol::Error::new(
4552 ErrorCode::ResourceNotFound.into(),
4553 format!(
4554 "unknown session {key}; create it with session/new or restore it with session/load"
4555 ),
4556 ))
4557 }
4558
4559 /// The config options for `key`, built from that session's own model and
4560 /// not from whichever session is live, so it can be read without the
4561 /// workspace lock.
4562 fn config_options_for_session(&self, key: &str) -> Vec<SessionConfigOption> {
4563 let live = self.active_session.lock().unwrap().as_deref() == Some(key);
4564 let model_id = if live {
4565 self.current_model.lock().unwrap().model_id.clone()
4566 } else {
4567 self.sessions
4568 .lock()
4569 .unwrap()
4570 .get(key)
4571 .map(|state| {
4572 state
4573 .remote
4574 .as_ref()
4575 .map_or_else(|| state.model_id.clone(), |r| r.model.model_id.clone())
4576 })
4577 .unwrap_or_default()
4578 };
4579 build_config_options_for(&model_id, key)
4580 }
4581
4582 /// Apply a Permissions change for `session_id`, which can be mid-turn.
4583 ///
4584 /// Takes neither the session lock nor the workspace lock: the turn holds
4585 /// the first for its whole length, and a user flipping to Auto while a
4586 /// permission prompt is open has to be heard at once. It touches only
4587 /// state keyed by session (`permissions`), so the live session is left
4588 /// alone. Returns the new config options and the status line to show.
4589 fn change_permission_mode(
4590 &self,
4591 cx: &ConnectionTo<Client>,
4592 session_id: &SessionId,
4593 mode_id: Option<&str>,
4594 ) -> agent_client_protocol::Result<Vec<SessionConfigOption>> {
4595 self.require_known_session(session_id)?;
4596 let key = session_id.to_string();
4597 let Some(message) = mode_id.and_then(|id| apply_permission_mode(&key, id)) else {
4598 return Err(agent_client_protocol::Error::new(
4599 ErrorCode::InvalidParams.into(),
4600 format!("unknown permission mode: {mode_id:?}"),
4601 ));
4602 };
4603 self.send_system_status(cx, session_id.clone(), message)
4604 .ok();
4605 Ok(self.config_options_for_session(&key))
4606 }
4607
4608 async fn handle_set_session_mode(
4609 &self,
4610 cx: &ConnectionTo<Client>,
4611 args: SetSessionModeRequest,
4612 ) -> agent_client_protocol::Result<SetSessionModeResponse> {
4613 log::info!(
4614 "set_session_mode: session={}, mode_id={}",
4615 args.session_id,
4616 args.mode_id
4617 );
4618
4619 let config_options =
4620 self.change_permission_mode(cx, &args.session_id, Some(args.mode_id.0.as_ref()))?;
4621 self.send_tool_call_update(
4622 cx,
4623 args.session_id,
4624 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
4625 )
4626 .ok();
4627 Ok(SetSessionModeResponse::new())
4628 }
4629
4630 async fn handle_set_session_config_option(
4631 &self,
4632 cx: &ConnectionTo<Client>,
4633 args: SetSessionConfigOptionRequest,
4634 ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
4635 log::info!(
4636 "set_session_config_option: config_id={}, value={:?}",
4637 args.config_id,
4638 args.value
4639 );
4640
4641 // ── Permissions dropdown (issue #76) ────────────────────────────────
4642 // Handled before the session is activated: the dispatch loop runs this
4643 // one without the session or workspace lock, so it can land mid-turn.
4644 if args.config_id.0.as_ref() == PERMISSION_MODE_CONFIG_ID {
4645 let picked = args.value.as_value_id().map(|v| v.0.as_ref());
4646 let config_options = self.change_permission_mode(cx, &args.session_id, picked)?;
4647 self.send_current_mode(cx, args.session_id);
4648 return Ok(SetSessionConfigOptionResponse::new(config_options));
4649 }
4650
4651 // A model switch carries the live conversation onto the new backend, so
4652 // the live conversation has to be the one whose picker changed.
4653 self.activate_session(&args.session_id).await?;
4654
4655 // ── Local Inference toggle ──────────────────────────────────────────
4656 if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
4657 // A toggle sends a boolean, the select fallback one of its ids.
4658 let enabled = match (
4659 args.value.as_bool(),
4660 args.value.as_value_id().map(|v| v.0.as_ref()),
4661 ) {
4662 (Some(enabled), _) => enabled,
4663 (_, Some(LOCAL_INFERENCE_ON)) => true,
4664 (_, Some(LOCAL_INFERENCE_OFF)) => false,
4665 (_, other) => {
4666 return Err(agent_client_protocol::Error::new(
4667 ErrorCode::InvalidParams.into(),
4668 format!("unknown Local Inference value: {other:?}"),
4669 ));
4670 }
4671 };
4672 if let Err(error) = settings::set_local_inference(enabled) {
4673 return Err(agent_client_protocol::Error::new(
4674 ErrorCode::InternalError.into(),
4675 format!("could not save Local Inference setting: {error}"),
4676 ));
4677 }
4678 let message = if enabled {
4679 "Local inference is on. On-device models are selectable in Model."
4680 } else {
4681 "Local inference is off. siGit Code Cloud tiers are selectable in Model."
4682 };
4683 self.send_system_status(cx, args.session_id.clone(), message)
4684 .ok();
4685 // Rebuild so the Model picker reflects the new emphasis/order.
4686 let current = self.current_model.lock().unwrap().clone();
4687 let config_options = build_config_options(&current, &args.session_id.to_string());
4688 return Ok(SetSessionConfigOptionResponse::new(config_options));
4689 }
4690
4691 if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
4692 return Err(agent_client_protocol::Error::new(
4693 ErrorCode::InvalidParams.into(),
4694 format!("unknown config option: {}", args.config_id.0),
4695 ));
4696 }
4697
4698 let Some(model_id) = args.value.as_value_id().map(|v| v.0.as_ref()) else {
4699 return Err(agent_client_protocol::Error::new(
4700 ErrorCode::InvalidParams.into(),
4701 format!(
4702 "expected a value-id for {MODEL_CONFIG_ID}, got {:?}",
4703 args.value
4704 ),
4705 ));
4706 };
4707
4708 // can't switch while the startup model is still loading — the old
4709 // weights are in GPU memory and the new load gets "does not fit"
4710 if self.startup_model_load_started.load(Ordering::Acquire)
4711 && !self.model_ready.load(Ordering::Acquire)
4712 {
4713 log::info!("set_session_config_option: waiting for startup model to finish loading");
4714 while !self.model_ready.load(Ordering::Acquire) {
4715 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
4716 }
4717 }
4718
4719 // Zed re-fires the last selection when a thread opens. That re-fire must
4720 // not load anything: on-device models are loaded only on an explicit
4721 // request (`/load`, or actively picking a *different* model below), so a
4722 // re-fire of the already-current selection is a no-op. Otherwise opening a
4723 // new thread would silently load the local model — exactly what we avoid.
4724 {
4725 let current = self.current_model.lock().unwrap();
4726 if current.model_id == model_id {
4727 log::info!(
4728 "set_session_config_option: {} is already the active selection, skipping",
4729 current.display_name
4730 );
4731 // A restored preferred model may have fallen back temporarily.
4732 // Explicitly picking the live model adopts that fallback as the
4733 // session's new preference even though no backend switch runs.
4734 drop(current);
4735 self.record_active_model();
4736 let current = self.current_model.lock().unwrap();
4737 let config_options = build_config_options(&current, &args.session_id.to_string());
4738 return Ok(SetSessionConfigOptionResponse::new(config_options));
4739 }
4740 }
4741
4742 // Both groups are listed, but only the one matching Local inference can
4743 // be picked. ACP has no disabled option, so refuse and answer with the
4744 // unchanged options so the picker snaps back.
4745 if model_kind_of(model_id) != models::active_inference_kind() {
4746 let message = match model_id.strip_prefix("sigit-cloud:") {
4747 Some(tier) => format!(
4748 "{} runs on siGit Code Cloud. Turn off Local inference to use it.",
4749 provider::tier_title(tier)
4750 ),
4751 None => {
4752 let name = models::local_picker_items()
4753 .into_iter()
4754 .find(|item| item.config.model_id == model_id)
4755 .map(|item| item.display_name)
4756 .unwrap_or_else(|| model_id.to_string());
4757 format!("{name} runs on-device. Turn on Local inference to use it.")
4758 }
4759 };
4760 self.send_system_status(cx, args.session_id.clone(), message)
4761 .ok();
4762 let current = self.current_model.lock().unwrap().clone();
4763 let config_options = build_config_options(&current, &args.session_id.to_string());
4764 return Ok(SetSessionConfigOptionResponse::new(config_options));
4765 }
4766
4767 // ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
4768 if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
4769 match self.switch_to_cloud_tier(tier).await {
4770 Some(display_name) => {
4771 self.send_system_status(
4772 cx,
4773 args.session_id.clone(),
4774 format!("Switched to {display_name}."),
4775 )
4776 .ok();
4777 }
4778 None => {
4779 // The picker response updates successful selections. When the
4780 // selection cannot apply because auth is missing, surface that
4781 // actionable state in the thread.
4782 self.send_assistant_message(
4783 cx,
4784 args.session_id.clone(),
4785 format!("\n\n{CLOUD_LOGIN_PROMPT}"),
4786 )
4787 .ok();
4788 }
4789 }
4790
4791 let current = self.current_model.lock().unwrap().clone();
4792 let config_options = build_config_options(&current, &args.session_id.to_string());
4793 return Ok(SetSessionConfigOptionResponse::new(config_options));
4794 }
4795
4796 let needs_download = models::local_picker_items()
4797 .into_iter()
4798 .find(|item| item.config.model_id == model_id)
4799 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
4800 .unwrap_or(false);
4801
4802 // tells the progress poller to stop
4803 let stop_flag = Arc::new(AtomicBool::new(false));
4804
4805 let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
4806
4807 if needs_download {
4808 let model_id_owned = model_id.to_string();
4809 let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
4810 .iter()
4811 .find(|m| m.id == model_id_owned)
4812 .map(|m| m.expected_size_bytes)
4813 .unwrap_or(0);
4814
4815 let display_name = models::local_picker_items()
4816 .into_iter()
4817 .find(|item| item.config.model_id == model_id_owned)
4818 .map(|item| item.display_name.clone())
4819 .unwrap_or_else(|| model_id_owned.clone());
4820
4821 let size_hint = if expected_bytes > 0 {
4822 format!(" (~{})", format_size_human(expected_bytes))
4823 } else {
4824 String::new()
4825 };
4826
4827 self.send_tool_call_update(
4828 cx,
4829 args.session_id.clone(),
4830 SessionUpdate::ToolCall(
4831 ToolCall::new(
4832 tool_call_id.clone(),
4833 format!("⏬ Downloading {display_name}{size_hint}"),
4834 )
4835 .kind(ToolKind::Think)
4836 .status(ToolCallStatus::InProgress)
4837 .content(vec![
4838 format!(
4839 "Preparing download for {display_name}. This may take a few minutes."
4840 )
4841 .into(),
4842 ]),
4843 ),
4844 )
4845 .ok();
4846
4847 // poll download progress and update the spinner in Zed
4848 let cx_for_poller = cx.clone();
4849 let poller_session = args.session_id.clone();
4850 let poller_model_id = model_id_owned.clone();
4851 let poller_stop = Arc::clone(&stop_flag);
4852 let poller_tool_call_id = tool_call_id.clone();
4853
4854 cx.spawn(async move {
4855 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4856 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
4857 let mut tick: usize = 0;
4858 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
4859 interval.tick().await; // consume the immediate first tick
4860
4861 while !poller_stop.load(Ordering::Relaxed) {
4862 interval.tick().await;
4863
4864 if poller_stop.load(Ordering::Relaxed) {
4865 break;
4866 }
4867
4868 let downloaded = cache_path
4869 .as_ref()
4870 .filter(|p| p.exists())
4871 .map(|p| dir_size_recursive(p))
4872 .unwrap_or(0);
4873
4874 let frame = SPINNER[tick % SPINNER.len()];
4875 tick += 1;
4876
4877 let title = if expected_bytes > 0 {
4878 let pct =
4879 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
4880 format!("{frame} Downloading {display_name}{size_hint} ({pct}%)")
4881 } else {
4882 format!("{frame} Downloading {display_name}{size_hint}")
4883 };
4884
4885 let msg = if expected_bytes > 0 {
4886 let pct =
4887 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
4888 let bar = progress_bar(pct, 20);
4889 format!(
4890 "{display_name} — {bar} {pct}% ({} / {})",
4891 format_size_human(downloaded),
4892 format_size_human(expected_bytes),
4893 )
4894 } else {
4895 format!(
4896 "{display_name} — {} downloaded…",
4897 format_size_human(downloaded)
4898 )
4899 };
4900
4901 let notification = SessionNotification::new(
4902 poller_session.clone(),
4903 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
4904 poller_tool_call_id.clone(),
4905 ToolCallUpdateFields::new()
4906 .title(title)
4907 .status(ToolCallStatus::InProgress)
4908 .content(vec![msg.into()]),
4909 )),
4910 );
4911 if cx_for_poller.send_notification(notification).is_err() {
4912 break;
4913 }
4914 }
4915 Ok(())
4916 })
4917 .ok();
4918 }
4919
4920 // cached models still take 10-30s to load weights; show a spinner
4921 if !needs_download {
4922 let cached_display_name = models::local_picker_items()
4923 .into_iter()
4924 .find(|item| item.config.model_id == model_id)
4925 .map(|item| item.display_name.clone())
4926 .unwrap_or_else(|| model_id.to_string());
4927
4928 self.send_tool_call_update(
4929 cx,
4930 args.session_id.clone(),
4931 SessionUpdate::ToolCall(
4932 ToolCall::new(
4933 tool_call_id.clone(),
4934 format!("Loading {cached_display_name}"),
4935 )
4936 .kind(ToolKind::Think)
4937 .status(ToolCallStatus::InProgress)
4938 .content(vec![format!("Loading {cached_display_name}…").into()]),
4939 ),
4940 )
4941 .ok();
4942
4943 // tick every 5s so the user knows we haven't frozen
4944 let cx_for_spinner = cx.clone();
4945 let spinner_session = args.session_id.clone();
4946 let spinner_name = cached_display_name.clone();
4947 let spinner_stop = Arc::clone(&stop_flag);
4948 let spinner_tool_call_id = tool_call_id.clone();
4949 let load_start = std::time::Instant::now();
4950
4951 cx.spawn(async move {
4952 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4953 let mut tick: usize = 0;
4954 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
4955 interval.tick().await; // consume the immediate first tick
4956
4957 while !spinner_stop.load(Ordering::Relaxed) {
4958 interval.tick().await;
4959
4960 if spinner_stop.load(Ordering::Relaxed) {
4961 break;
4962 }
4963
4964 let elapsed = load_start.elapsed();
4965 let elapsed_str = if elapsed.as_secs() >= 60 {
4966 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4967 } else {
4968 format!("{}s", elapsed.as_secs())
4969 };
4970 let frame = SPINNER[tick % SPINNER.len()];
4971 tick += 1;
4972
4973 let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
4974 let notification = SessionNotification::new(
4975 spinner_session.clone(),
4976 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
4977 spinner_tool_call_id.clone(),
4978 ToolCallUpdateFields::new()
4979 .status(ToolCallStatus::InProgress)
4980 .content(vec![msg.into()]),
4981 )),
4982 );
4983 if cx_for_spinner.send_notification(notification).is_err() {
4984 break;
4985 }
4986 }
4987 Ok(())
4988 })
4989 .ok();
4990 }
4991
4992 let switch_result = self.switch_model_by_id(model_id).await;
4993
4994 stop_flag.store(true, Ordering::Relaxed);
4995
4996 match switch_result {
4997 Ok(new_config) => {
4998 // Route inference back on-device (in case we were on a cloud tier).
4999 self.reset_to_local_backend().await;
5000 // Selecting an on-device model puts us in local mode.
Showing first 5,000 of 8,914 lines. View raw