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 // The command list is advertised by the dispatcher, after this
3230 // response is written. See the `ForkSessionRequest` handler.
3231
3232 // Run SessionStart hooks
3233 let hook_settings = settings::load().hooks;
3234 if hook_settings.has_hooks() {
3235 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &new_id.to_string());
3236 }
3237
3238 let modes = build_session_modes(&new_id.to_string());
3239 Ok(ForkSessionResponse::new(new_id)
3240 .config_options(config_options)
3241 .modes(modes))
3242 }
3243
3244 async fn handle_new_session(
3245 &self,
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 // The command list is advertised by the dispatcher, after this
3271 // response is written. See the `NewSessionRequest` handler.
3272
3273 // Run SessionStart hooks
3274 let hook_settings = settings::load().hooks;
3275 if hook_settings.has_hooks() {
3276 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &session_id.to_string());
3277 }
3278
3279 let modes = build_session_modes(&session_id.to_string());
3280 Ok(NewSessionResponse::new(session_id)
3281 .config_options(config_options)
3282 .modes(modes))
3283 }
3284
3285 async fn handle_prompt(
3286 &self,
3287 cx: &ConnectionTo<Client>,
3288 args: PromptRequest,
3289 ) -> agent_client_protocol::Result<PromptResponse> {
3290 let session_id = args.session_id.clone();
3291
3292 // `session/close` arrived while this prompt was waiting its turn.
3293 // Closing cancels the session's work, and this is part of it.
3294 if self
3295 .closing_sessions
3296 .lock()
3297 .unwrap()
3298 .contains(&session_id.to_string())
3299 {
3300 return Ok(PromptResponse::new(StopReason::Cancelled));
3301 }
3302
3303 // Registered before anything that can wait, so a `session/cancel` that
3304 // lands while this turn is still queued on the workspace, switching
3305 // sessions or loading a model is seen. The session lock is held, so the
3306 // slot can only be this turn's. The guard clears it on every return.
3307 let cancellation = Arc::new(PromptCancellation::default());
3308 tools::reset_abort(&session_id.to_string());
3309 self.prompt_cancellations
3310 .lock()
3311 .unwrap()
3312 .insert(session_id.to_string(), Arc::clone(&cancellation));
3313 let _registration = PromptRegistration {
3314 agent: self,
3315 session_id: session_id.clone(),
3316 cancellation: Arc::clone(&cancellation),
3317 };
3318
3319 // Everything below (slash commands included) acts on the live session,
3320 // so it has to be this one. Resource links are read relative to its cwd.
3321 let mut workspace = WorkspaceHold::acquire(&self.workspace_lock).await;
3322 if cancellation.cancelled.load(Ordering::Acquire) {
3323 return Ok(PromptResponse::new(StopReason::Cancelled));
3324 }
3325 self.reinstall_for_tools(&session_id).await?;
3326 if cancellation.cancelled.load(Ordering::Acquire) {
3327 return Ok(PromptResponse::new(StopReason::Cancelled));
3328 }
3329
3330 // log every block so we can debug @ references and file context
3331 for (i, block) in args.prompt.iter().enumerate() {
3332 match block {
3333 ContentBlock::Text(t) => {
3334 log::info!(
3335 "prompt({}) block[{}]: Text({} chars) = \"{}\"",
3336 session_id,
3337 i,
3338 t.text.len(),
3339 t.text.chars().take(200).collect::<String>()
3340 );
3341 }
3342 ContentBlock::Resource(embedded) => {
3343 log::info!(
3344 "prompt({}) block[{}]: EmbeddedResource = {:?}",
3345 session_id,
3346 i,
3347 match &embedded.resource {
3348 EmbeddedResourceResource::TextResourceContents(t) =>
3349 format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
3350 EmbeddedResourceResource::BlobResourceContents(b) =>
3351 format!("BlobResource(uri={})", b.uri),
3352 _ => "Unknown".to_string(),
3353 }
3354 );
3355 }
3356 ContentBlock::ResourceLink(link) => {
3357 log::info!(
3358 "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})",
3359 session_id,
3360 i,
3361 link.name,
3362 link.uri,
3363 link.title,
3364 link.description
3365 );
3366 }
3367 other => {
3368 log::info!(
3369 "prompt({}) block[{}]: Other({:?})",
3370 session_id,
3371 i,
3372 std::mem::discriminant(other)
3373 );
3374 }
3375 }
3376 }
3377
3378 let mut parts: Vec<String> = Vec::new();
3379 let mut images: Vec<ImageInput> = Vec::new();
3380 let mut audio: Vec<AudioInput> = Vec::new();
3381
3382 for block in &args.prompt {
3383 match block {
3384 ContentBlock::Text(t) => {
3385 parts.push(t.text.clone());
3386 }
3387 ContentBlock::Image(image) => {
3388 images.push(ImageInput {
3389 mime_type: image.mime_type.clone(),
3390 data: image.data.clone(),
3391 });
3392 }
3393 ContentBlock::Audio(clip) => {
3394 audio.push(AudioInput {
3395 mime_type: clip.mime_type.clone(),
3396 data: clip.data.clone(),
3397 });
3398 }
3399 ContentBlock::Resource(embedded) => {
3400 // editor inlined the file content already
3401 match &embedded.resource {
3402 EmbeddedResourceResource::TextResourceContents(text_resource) => {
3403 parts.push(format!(
3404 "\n--- {} ---\n{}\n--- end {} ---",
3405 text_resource.uri, text_resource.text, text_resource.uri
3406 ));
3407 }
3408 EmbeddedResourceResource::BlobResourceContents(blob) => {
3409 let mime = blob.mime_type.clone().unwrap_or_default();
3410 if mime.starts_with("image/") {
3411 images.push(ImageInput {
3412 mime_type: mime,
3413 data: blob.blob.clone(),
3414 });
3415 } else if mime.starts_with("audio/") {
3416 audio.push(AudioInput {
3417 mime_type: mime,
3418 data: blob.blob.clone(),
3419 });
3420 } else if mime.is_empty() {
3421 parts.push(format!("[binary resource: {}]", blob.uri));
3422 } else {
3423 parts.push(format!("[binary resource: {} ({mime})]", blob.uri));
3424 }
3425 }
3426 _ => {
3427 log::debug!("ignoring unsupported embedded resource variant");
3428 }
3429 }
3430 }
3431 ContentBlock::ResourceLink(link) => {
3432 // reference without content; read the file ourselves
3433 let label = link.name.clone();
3434
3435 if let Some((file_path, line_range)) = parse_file_link(&link.uri) {
3436 match std::fs::read_to_string(&file_path) {
3437 Ok(contents) => {
3438 let extracted = if let Some((start, end)) = line_range {
3439 let selected: Vec<&str> = contents
3440 .lines()
3441 .enumerate()
3442 .filter(|(i, _)| {
3443 let line_num = i + 1;
3444 line_num >= start && line_num <= end
3445 })
3446 .map(|(_, line)| line)
3447 .collect();
3448 format!(
3449 "\n--- {label} ({} lines {start}-{end}) ---\n{}\n--- end {label} ---",
3450 file_path.display(),
3451 selected.join("\n")
3452 )
3453 } else {
3454 format!(
3455 "\n--- {label} ({}) ---\n{contents}\n--- end {label} ---",
3456 file_path.display()
3457 )
3458 };
3459 parts.push(extracted);
3460 }
3461 Err(err) => {
3462 log::warn!("could not read ResourceLink {}: {err}", link.uri);
3463 parts.push(format!(
3464 "[referenced file: {label} ({})]",
3465 file_path.display()
3466 ));
3467 }
3468 }
3469 } else {
3470 parts.push(format!("[resource link: {label} ({})]", link.uri));
3471 }
3472 }
3473 _ => {
3474 log::debug!("ignoring unsupported content block type in prompt");
3475 }
3476 }
3477 }
3478
3479 let user_text = parts.join("\n");
3480
3481 if user_text.trim().is_empty() && images.is_empty() && audio.is_empty() {
3482 return Ok(PromptResponse::new(StopReason::EndTurn));
3483 }
3484
3485 let slash_command = parse_slash_from_prompt_texts(args.prompt.iter().filter_map(|block| {
3486 if let ContentBlock::Text(text) = block {
3487 Some(text.text.as_str())
3488 } else {
3489 None
3490 }
3491 }));
3492
3493 let user_text = match slash_command {
3494 // `/init` is not a status command: it substitutes the canned
3495 // AGENTS.md-generation prompt and runs a normal agent turn, so the
3496 // exploration and file write go through the ordinary tools and
3497 // permission checks.
3498 Some(SlashCommand::Init) => instructions::INIT_PROMPT.to_string(),
3499 // A word `parse_slash` didn't recognize might be a user-defined
3500 // command (`.sigit/commands/*.md`) — same substitute-and-run-a-
3501 // real-turn treatment as `/init`. Only fall back to "unknown
3502 // command" once that lookup also misses.
3503 Some(SlashCommand::Unknown(command, argument)) => {
3504 match commands::resolve_command(&command) {
3505 Some(custom) => commands::render(&custom.body, argument.as_deref()),
3506 None => {
3507 return self
3508 .run_slash(
3509 cx,
3510 session_id,
3511 SlashCommand::Unknown(command, argument),
3512 &cancellation,
3513 )
3514 .await;
3515 }
3516 }
3517 }
3518 Some(command) => return self.run_slash(cx, session_id, command, &cancellation).await,
3519 None => user_text,
3520 };
3521
3522 log::info!(
3523 "prompt({}): \"{}\"",
3524 session_id,
3525 user_text.chars().take(80).collect::<String>()
3526 );
3527
3528 // The active backend drives the turn. Snapshot it once so a mid-turn
3529 // model switch doesn't split the conversation across backends.
3530 let backend = self.backend.lock().await.clone();
3531 // Waiting on an HTTP endpoint uses nothing process-wide, so another
3532 // session may be installed meanwhile. The on-device engine holds one
3533 // conversation, so an on-device turn keeps the workspace throughout.
3534 let release_while_waiting = backend.is_remote();
3535
3536 // Only on-device inference needs a local model in memory. Cloud tiers run
3537 // over the network, so they never need a local model. Xcode's explicit
3538 // `--acp` mode loads lazily on the first prompt because its custom-agent
3539 // UI has no reliable equivalent of siGit's `/load` command.
3540 if !backend.is_remote()
3541 && self.engine.info().await.status == onde::inference::EngineStatus::Unloaded
3542 {
3543 if self.auto_load_local_model {
3544 self.start_startup_model_load_if_needed();
3545 tokio::select! {
3546 ready = self.await_model_ready(cx, &session_id) => ready?,
3547 () = cancellation.cancelled() => {
3548 return Ok(PromptResponse::new(StopReason::Cancelled));
3549 }
3550 }
3551 } else {
3552 self.send_assistant_message(
3553 cx,
3554 session_id,
3555 "No on-device model is loaded. Run `/load` to load the selected model, \
3556 or `/models` to choose one.",
3557 )
3558 .ok();
3559 return Ok(PromptResponse::new(StopReason::EndTurn));
3560 }
3561 }
3562
3563 if cancellation.cancelled.load(Ordering::Acquire) {
3564 return Ok(PromptResponse::new(StopReason::Cancelled));
3565 }
3566
3567 // ── tool-calling loop ────────────────────────────────────────────
3568 // send message → execute any tool calls → feed results back
3569 // repeat up to the tool-round cap, then force a text reply
3570
3571 let tools = agent_tools_as_specs();
3572 let max_tool_rounds = headless::max_tool_rounds_from_env();
3573
3574 let model_name = self.current_model.lock().unwrap().display_name.clone();
3575 let context_window = self.context_window_tokens();
3576 let earlier_images = if backend.accepts_images() {
3577 0
3578 } else {
3579 backend
3580 .history_snapshot()
3581 .await
3582 .iter()
3583 .map(backend::message_image_count)
3584 .sum()
3585 };
3586 let (user_text, images, image_notice) = images_for_turn(
3587 user_text,
3588 images,
3589 backend.accepts_images(),
3590 &model_name,
3591 earlier_images,
3592 &session_id.to_string(),
3593 );
3594 let earlier_audio = if backend.accepts_audio() {
3595 0
3596 } else {
3597 backend
3598 .history_snapshot()
3599 .await
3600 .iter()
3601 .map(backend::message_audio_count)
3602 .sum()
3603 };
3604 let (user_text, audio, audio_notice) = audio_for_turn(
3605 user_text,
3606 audio,
3607 backend.accepts_audio(),
3608 &model_name,
3609 earlier_audio,
3610 &session_id.to_string(),
3611 );
3612 for notice in [image_notice, audio_notice].into_iter().flatten() {
3613 self.send_assistant_message(
3614 cx,
3615 session_id.clone(),
3616 format!("{notice}{PARAGRAPH_BREAK}"),
3617 )
3618 .ok();
3619 }
3620
3621 // Token sink: backends stream assistant text through this while a turn
3622 // runs. We forward the visible portion to the editor as agent-message
3623 // chunks live, and the reasoning portion as thought chunks (see
3624 // `drain_turn` / `emit_token_chunk`). The sink stays alive for the whole
3625 // prompt so `recv()` only ends when a turn future resolves, never
3626 // because every sender was dropped.
3627 let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<TokenChunk>();
3628 let mut reply = StreamedReply::default();
3629 let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
3630
3631 // Kept so a refused turn can be taken back out of history (see the end
3632 // of this function). Only a remote endpoint reports a refusal, and an
3633 // on-device snapshot would not survive the round trip intact anyway.
3634 let history_before_turn = if backend.is_remote() {
3635 Some(backend.history_snapshot().await)
3636 } else {
3637 None
3638 };
3639
3640 if release_while_waiting {
3641 workspace.release();
3642 }
3643 let outcome = self
3644 .drain_turn(
3645 cx,
3646 &session_id,
3647 backend.send_message_with_attachments(
3648 &user_text,
3649 &images,
3650 &audio,
3651 &tools,
3652 Some(&sink),
3653 ),
3654 &mut sink_rx,
3655 &mut reply,
3656 &cancellation,
3657 )
3658 .await;
3659 self.resume_workspace(&mut workspace, &session_id, &cancellation)
3660 .await;
3661 let mut result = match outcome {
3662 Ok(result) => result,
3663 Err(DrainTurnError::Cancelled) => {
3664 backend::forget_trailing_user_message(backend.as_ref()).await;
3665 self.finish_prompt(&session_id, &cancellation);
3666 return Ok(PromptResponse::new(StopReason::Cancelled));
3667 }
3668 Err(DrainTurnError::Backend(error)) => {
3669 log::error!("send_message_with_tools failed: {error}");
3670 // Re-emit the persisted plan so the client panel doesn't go
3671 // empty when the initial inference call fails before the model
3672 // can call `write_todos`.
3673 if let Some(plan) = persisted_todos_to_plan(&session_id) {
3674 let _ = self.send_plan_update(cx, session_id.clone(), plan);
3675 }
3676 // Verbatim, no prefix: the backend has already turned this into
3677 // something worth reading (see `describe_api_error`), and it is
3678 // what the editor puts in its error banner. The context a
3679 // prefix would add is in the log line above.
3680 // The client keeps this prompt in its thread after the error,
3681 // so keep it in the saved conversation too.
3682 let snapshot = backend.history_snapshot().await;
3683 self.persist_session(cx, &session_id, &snapshot).await;
3684 self.finish_prompt(&session_id, &cancellation);
3685 return Err(agent_client_protocol::Error::new(
3686 ErrorCode::InternalError.into(),
3687 error,
3688 ));
3689 }
3690 };
3691
3692 self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
3693 .await;
3694
3695 let mut round = 0;
3696 // Set when the repetition guard blocked a call; names the tool.
3697 let mut stopped_repeating: Option<String> = None;
3698 // Visible text already sent before the latest inference round, so the
3699 // end of the turn can tell whether that round said anything at all.
3700 let mut sent_before_last_round = 0;
3701
3702 while !result.tool_calls.is_empty() && round < max_tool_rounds {
3703 if cancellation.cancelled.load(Ordering::Acquire) {
3704 self.finish_prompt(&session_id, &cancellation);
3705 return Ok(PromptResponse::new(StopReason::Cancelled));
3706 }
3707 round += 1;
3708 log::info!(
3709 "prompt({}) tool round {} — {} call(s)",
3710 session_id,
3711 round,
3712 result.tool_calls.len()
3713 );
3714
3715 // Auto-compaction: long tool runs grow history fast; fold it into
3716 // a summary before the next round rather than blowing the window.
3717 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
3718 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
3719 log::info!(
3720 "prompt({}) history ≈{} tokens exceeds budget {} — compacting",
3721 session_id,
3722 estimate,
3723 backend::DEFAULT_CONTEXT_TOKEN_BUDGET
3724 );
3725 match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
3726 Ok(()) => {
3727 let after = backend::estimate_tokens(&backend.history_snapshot().await);
3728 log::info!("prompt({}) compacted to ≈{} tokens", session_id, after);
3729 }
3730 Err(error) => {
3731 // Even the deterministic fallback could not fit the
3732 // history in the budget — the session is genuinely
3733 // stuck, so say what is actually recoverable.
3734 log::warn!("prompt({}) compaction failed: {error}", session_id);
3735 self.send_assistant_message(
3736 cx,
3737 session_id.clone(),
3738 format!(
3739 "This session is too large for the context window, and \
3740 siGit Code could not shrink it ({error}). Run `/clear` \
3741 or start a new thread to continue."
3742 ),
3743 )
3744 .ok();
3745 self.finish_prompt(&session_id, &cancellation);
3746 return Ok(PromptResponse::new(StopReason::EndTurn));
3747 }
3748 }
3749 }
3750
3751 let mut tool_results = Vec::new();
3752 let mut force_text = false;
3753
3754 for (call_index, tc) in result.tool_calls.iter().enumerate() {
3755 log::info!(
3756 " → {}({})",
3757 tc.name,
3758 tc.arguments.chars().take(120).collect::<String>()
3759 );
3760
3761 let signature = format!("{}\n{}", tc.name, tc.arguments);
3762 let repeat_count = repeated_tool_calls
3763 .entry(signature)
3764 .and_modify(|count| *count += 1)
3765 .or_insert(1);
3766 // Repeated status checks are expected for a live background
3767 // command. command_output and monitor_command wait, and the overall round cap
3768 // still bounds a confused model without cutting off a real
3769 // build or release while it is in progress.
3770 let repeated = *repeat_count >= 3
3771 && tc.name != "command_output"
3772 && tc.name != "monitor_command";
3773 if repeated {
3774 force_text = true;
3775 stopped_repeating = Some(tc.name.clone());
3776 log::warn!(
3777 "prompt({}) stopping repeated tool call `{}` after {} attempts",
3778 session_id,
3779 tc.name,
3780 repeat_count
3781 );
3782 }
3783
3784 // Decided before the call is announced, because the answer sets
3785 // the status it is announced with: a call that has to wait for
3786 // the user starts out `pending`, everything else is already
3787 // running. A repeated call is never run, so never asked about.
3788 let decision = (!repeated).then(|| {
3789 permissions::decision_for(&session_id.to_string(), &tc.name, &tc.arguments)
3790 });
3791 let announced_status = match decision {
3792 Some(permissions::Decision::Ask) => ToolCallStatus::Pending,
3793 _ => ToolCallStatus::InProgress,
3794 };
3795
3796 // Only treat the call as a plan once its arguments have actually
3797 // produced one. Keying off the tool name alone means a
3798 // `write_todos` the converter rejects sends no plan *and* skips
3799 // the tool-call card below, so the call disappears from the
3800 // client while the model is told the list was updated.
3801 let plan = (tc.name == "write_todos")
3802 .then(|| todos_arguments_to_plan(&tc.arguments))
3803 .flatten();
3804 let render_as_plan = plan.is_some();
3805 if let Some(plan) = plan {
3806 self.send_plan_update(cx, session_id.clone(), plan).ok();
3807 } else {
3808 // Model tool calls are agent actions in ACP too, not merely an
3809 // implementation detail of the completion loop. Announce each
3810 // one before it runs and close it afterwards so clients such as
3811 // Zed can render activity while the next inference round is in
3812 // flight. Previously only model loading emitted ToolCall events,
3813 // leaving an otherwise working tool round visually indistinct
3814 // from a stalled response.
3815 let raw_input: serde_json::Value = serde_json::from_str(&tc.arguments)
3816 .unwrap_or_else(|e| {
3817 log::warn!("malformed JSON in tool arguments for '{}': {e}", tc.name);
3818 serde_json::Value::String(tc.arguments.clone())
3819 });
3820 let invocation_content = tool_arguments_content(&tc.arguments);
3821 self.send_tool_call_update(
3822 cx,
3823 session_id.clone(),
3824 SessionUpdate::ToolCall(
3825 ToolCall::new(tc.id.clone(), chat::tool_title(&tc.name, &tc.arguments))
3826 .name(tc.name.clone())
3827 .kind(tool_kind_for(&tc.name))
3828 .status(announced_status)
3829 .content(vec![invocation_content])
3830 .locations(tool_call_locations(&tc.name, &tc.arguments))
3831 .raw_input(raw_input),
3832 ),
3833 )
3834 .ok();
3835 }
3836
3837 // Permission gate: read-only tools pass straight through; a
3838 // mutating tool consults policy and may ask the client. The
3839 // flag says whether the tool actually ran: a call that was
3840 // denied or skipped ends `failed`, not `completed` (#139).
3841 let mut change = None;
3842 let mut terminal = None;
3843 let (output, executed) = match decision {
3844 None => (
3845 format!(
3846 "The tool `{}` was not executed again because the model repeated \
3847 the same call three times. Continue without this tool.",
3848 tc.name
3849 ),
3850 false,
3851 ),
3852 Some(decision) => match decision {
3853 permissions::Decision::Allow => {
3854 let outcome = tools::execute_tool_with_change(
3855 &tc.name,
3856 &tc.arguments,
3857 Some(&tc.id),
3858 )
3859 .await;
3860 change = outcome.change;
3861 terminal = outcome.terminal;
3862 (outcome.output, true)
3863 }
3864 permissions::Decision::Deny(reason) => {
3865 log::info!(" ✗ {} denied by policy", tc.name);
3866 (reason, false)
3867 }
3868 permissions::Decision::Ask => {
3869 // Worked out while the session still holds the
3870 // workspace: a relative path resolves against
3871 // its cwd.
3872 let preview = tools::preview_file_change(&tc.name, &tc.arguments).await;
3873 // The answer can take as long as the user likes.
3874 if release_while_waiting {
3875 workspace.release();
3876 }
3877 // A client that cancels the turn answers the
3878 // request itself. One that closes the session
3879 // need not, so the wait ends on either.
3880 let verdict = tokio::select! {
3881 verdict = self.request_tool_permission(
3882 cx,
3883 &session_id,
3884 &tc.id,
3885 &tc.name,
3886 &tc.arguments,
3887 preview.as_ref(),
3888 ) => verdict,
3889 _ = cancellation.cancelled() => PermissionVerdict::TurnCancelled,
3890 };
3891 self.resume_workspace(&mut workspace, &session_id, &cancellation)
3892 .await;
3893 // An approval can cross a cancellation on the wire,
3894 // or arrive while this turn waited for the lock.
3895 let verdict = if cancellation.cancelled.load(Ordering::Acquire) {
3896 PermissionVerdict::TurnCancelled
3897 } else {
3898 verdict
3899 };
3900 match verdict {
3901 PermissionVerdict::Approved => {
3902 // The call the user just approved leaves
3903 // `pending` here. The title goes back to
3904 // the card's own: the permission request
3905 // replaced it with the full arguments.
3906 if !render_as_plan {
3907 self.send_tool_call_update(
3908 cx,
3909 session_id.clone(),
3910 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3911 tc.id.clone(),
3912 ToolCallUpdateFields::new()
3913 .status(ToolCallStatus::InProgress)
3914 .title(chat::tool_title(
3915 &tc.name,
3916 &tc.arguments,
3917 )),
3918 )),
3919 )
3920 .ok();
3921 }
3922 let outcome = tools::execute_tool_with_change(
3923 &tc.name,
3924 &tc.arguments,
3925 Some(&tc.id),
3926 )
3927 .await;
3928 change = outcome.change;
3929 terminal = outcome.terminal;
3930 (outcome.output, true)
3931 }
3932 PermissionVerdict::Denied(reason) => {
3933 log::info!(" ✗ {} denied by user", tc.name);
3934 (reason, false)
3935 }
3936 PermissionVerdict::TurnCancelled => {
3937 log::info!(
3938 "prompt({}) cancelled at permission gate",
3939 session_id
3940 );
3941 // The assistant message carrying these tool
3942 // calls is already in the backend history;
3943 // leaving any of them unanswered makes strict
3944 // OpenAI-compatible endpoints reject every
3945 // later request in the session. Close out this
3946 // call and the ones this round never reached.
3947 for pending in &result.tool_calls[call_index..] {
3948 tool_results.push(BackendToolResult {
3949 tool_call_id: pending.id.clone(),
3950 content: format!(
3951 "`{}` was not executed: the user cancelled the turn \
3952 at the permission prompt.",
3953 pending.name
3954 ),
3955 });
3956 }
3957 let cancelled_reason = "Not executed: the user cancelled the turn at the permission prompt.";
3958 self.send_tool_call_update(
3959 cx,
3960 session_id.clone(),
3961 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3962 tc.id.clone(),
3963 ToolCallUpdateFields::new()
3964 .status(ToolCallStatus::Failed)
3965 .content(vec![tool_output_content(
3966 cancelled_reason,
3967 )])
3968 .raw_output(serde_json::Value::String(
3969 cancelled_reason.to_string(),
3970 )),
3971 )),
3972 )
3973 .ok();
3974 backend.record_cancelled_tool_results(tool_results).await;
3975 self.finish_prompt(&session_id, &cancellation);
3976 return Ok(PromptResponse::new(StopReason::Cancelled));
3977 }
3978 }
3979 }
3980 },
3981 };
3982
3983 log::info!(" ← {} chars", output.len());
3984
3985 if !render_as_plan {
3986 let mut fields = ToolCallUpdateFields::new()
3987 .status(if executed {
3988 ToolCallStatus::Completed
3989 } else {
3990 ToolCallStatus::Failed
3991 })
3992 .raw_output(serde_json::Value::String(output.clone()));
3993 if terminal.is_none() {
3994 fields = fields.content(tool_result_content(&output, change.as_ref()));
3995 }
3996 // A denial at the prompt leaves the card titled with the
3997 // permission request's full arguments; put its own back.
3998 if !executed && announced_status == ToolCallStatus::Pending {
3999 fields = fields.title(chat::tool_title(&tc.name, &tc.arguments));
4000 }
4001 self.send_tool_call_update(
4002 cx,
4003 session_id.clone(),
4004 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(tc.id.clone(), fields)),
4005 )
4006 .ok();
4007 }
4008
4009 tool_results.push(BackendToolResult {
4010 tool_call_id: tc.id.clone(),
4011 content: output,
4012 });
4013 // A cancel aborts a running foreground command (see
4014 // `tools::abort_session_tools`), so it exits and is observed
4015 // here. Background commands are stopped with `kill_command`.
4016 if cancellation.cancelled.load(Ordering::Acquire) {
4017 for pending in &result.tool_calls[call_index + 1..] {
4018 tool_results.push(BackendToolResult {
4019 tool_call_id: pending.id.clone(),
4020 content: format!(
4021 "`{}` was not executed: the user cancelled the turn.",
4022 pending.name
4023 ),
4024 });
4025 }
4026 backend.record_cancelled_tool_results(tool_results).await;
4027 self.finish_prompt(&session_id, &cancellation);
4028 return Ok(PromptResponse::new(StopReason::Cancelled));
4029 }
4030 }
4031
4032 let round_cap_reached = round >= max_tool_rounds;
4033 let allow_tool_calls = !round_cap_reached && !force_text;
4034 if round_cap_reached && let Some(last) = tool_results.last_mut() {
4035 last.content.push_str(&round_cap_note(max_tool_rounds));
4036 }
4037
4038 // Whatever this round says starts a new paragraph rather than
4039 // continuing the sentence the tool calls interrupted.
4040 reply.interrupt();
4041 sent_before_last_round = reply.sent.len();
4042
4043 let cancelled_results = tool_results.clone();
4044 if release_while_waiting {
4045 workspace.release();
4046 }
4047 let outcome = self
4048 .drain_turn(
4049 cx,
4050 &session_id,
4051 backend.send_tool_results(tool_results, &tools, allow_tool_calls, Some(&sink)),
4052 &mut sink_rx,
4053 &mut reply,
4054 &cancellation,
4055 )
4056 .await;
4057 self.resume_workspace(&mut workspace, &session_id, &cancellation)
4058 .await;
4059 result = match outcome {
4060 Ok(result) => result,
4061 Err(DrainTurnError::Cancelled) => {
4062 backend
4063 .record_cancelled_tool_results(cancelled_results)
4064 .await;
4065 self.finish_prompt(&session_id, &cancellation);
4066 return Ok(PromptResponse::new(StopReason::Cancelled));
4067 }
4068 Err(DrainTurnError::Backend(error)) => {
4069 log::error!("send_tool_results failed: {error}");
4070 // Re-emit the persisted plan so the client panel doesn't go
4071 // empty when a tool round fails mid-turn.
4072 if let Some(plan) = persisted_todos_to_plan(&session_id) {
4073 let _ = self.send_plan_update(cx, session_id.clone(), plan);
4074 }
4075 self.finish_prompt(&session_id, &cancellation);
4076 return Err(agent_client_protocol::Error::new(
4077 ErrorCode::InternalError.into(),
4078 error,
4079 ));
4080 }
4081 };
4082 self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
4083 .await;
4084 }
4085
4086 // ── Final text response ───────────────────────────────────────────
4087 // If anything streamed, the visible reply is already on the wire; only
4088 // send a trailing block for the non-streamed path (e.g. on-device direct
4089 // answers, which onde can't stream while tools are on offer).
4090 let round_cap_reached = round >= max_tool_rounds;
4091 if !reply.streamed_any {
4092 let reply_text = result.text.trim().to_string();
4093 let final_text = if reply_text.is_empty() {
4094 if round_cap_reached {
4095 // The cap notice below covers it.
4096 String::new()
4097 } else if round > 0 {
4098 log::warn!(
4099 "prompt({}) — model returned empty reply after {} tool round(s)",
4100 session_id,
4101 round
4102 );
4103 silent_stop_message(stopped_repeating.as_deref(), round)
4104 } else {
4105 log::warn!(
4106 "prompt({}) — model returned empty reply (no tool rounds)",
4107 session_id
4108 );
4109 String::new()
4110 }
4111 } else {
4112 // strip <think> blocks so reasoning tokens stay hidden
4113 let (_think, visible) = chat::strip_think_blocks(&reply_text);
4114 visible
4115 };
4116
4117 if !final_text.is_empty() {
4118 let message_id = reply.message_id();
4119 self.send_reply_chunk(cx, session_id.clone(), message_id, final_text)
4120 .ok();
4121 }
4122 } else if round > 0 && !round_cap_reached && reply.sent.len() == sent_before_last_round {
4123 // Earlier rounds streamed text, but the last one (typically the
4124 // forced no-tools round) said nothing. Without a closing message
4125 // the turn just stops, and the user can't tell whether it ended,
4126 // stalled, or is waiting on them.
4127 log::warn!(
4128 "prompt({}) — final round after {} tool round(s) produced no text",
4129 session_id,
4130 round
4131 );
4132 let message_id = reply.message_id();
4133 self.send_reply_chunk(
4134 cx,
4135 session_id.clone(),
4136 message_id,
4137 format!(
4138 "{PARAGRAPH_BREAK}{}",
4139 silent_stop_message(stopped_repeating.as_deref(), round)
4140 ),
4141 )
4142 .ok();
4143 }
4144
4145 if round_cap_reached {
4146 log::warn!(
4147 "prompt({}) stopped at the cap of {} tool round(s)",
4148 session_id,
4149 max_tool_rounds
4150 );
4151 let message_id = reply.message_id();
4152 self.send_reply_chunk(
4153 cx,
4154 session_id.clone(),
4155 message_id,
4156 format!(
4157 "{PARAGRAPH_BREAK}{}",
4158 round_cap_stop_message(max_tool_rounds)
4159 ),
4160 )
4161 .ok();
4162 }
4163
4164 let stop_reason = stop_reason_for(round_cap_reached, result.finish);
4165
4166 // ACP defines a refusal as a turn the next prompt will not include:
4167 // the client drops the user message and everything after it from its
4168 // own view. Take the turn out of history too, or the model keeps
4169 // answering a conversation the user can no longer see.
4170 if stop_reason == StopReason::Refusal
4171 && let Some(history) = history_before_turn
4172 {
4173 log::warn!(
4174 "prompt({}) refused by the endpoint — dropping the turn from history",
4175 session_id
4176 );
4177 backend.restore_history(history).await;
4178 }
4179
4180 // Persist the completed turn so a restart (or session/load) can pick
4181 // the conversation back up.
4182 let snapshot = backend.history_snapshot().await;
4183 self.persist_session(cx, &session_id, &snapshot).await;
4184 self.finish_prompt(&session_id, &cancellation);
4185
4186 log::info!(
4187 "prompt({}) complete — {} tool round(s), stop reason {:?}",
4188 session_id,
4189 round,
4190 stop_reason
4191 );
4192 Ok(PromptResponse::new(stop_reason))
4193 }
4194
4195 /// The context window of the session's model, for `usage_update`. A model
4196 /// the picker does not list (a provider-supplied name, say) falls back to
4197 /// the compaction budget, the same as the TUI's gauge.
4198 fn context_window_tokens(&self) -> u64 {
4199 let current = self.current_model.lock().unwrap().clone();
4200 models::build_model_picker_items()
4201 .into_iter()
4202 .find(|item| {
4203 item.config.model_id == current.model_id
4204 || item.display_name == current.display_name
4205 })
4206 .map(|item| item.context_window_tokens)
4207 .unwrap_or(backend::DEFAULT_CONTEXT_TOKEN_BUDGET as u64)
4208 }
4209
4210 /// Report how much of the context window the session uses after one
4211 /// model response. The endpoint's own count is used when it gave one;
4212 /// otherwise (always on-device) the history is estimated, the way
4213 /// compaction does it.
4214 async fn send_usage(
4215 &self,
4216 cx: &ConnectionTo<Client>,
4217 session_id: &SessionId,
4218 backend: &dyn InferenceBackend,
4219 result: &TurnResult,
4220 size: u64,
4221 ) {
4222 let used = match result.context_tokens {
4223 Some(tokens) => tokens,
4224 None => backend::estimate_tokens(&backend.history_snapshot().await) as u64,
4225 };
4226 cx.send_notification(SessionNotification::new(
4227 session_id.clone(),
4228 SessionUpdate::UsageUpdate(UsageUpdate::new(used, size)),
4229 ))
4230 .ok();
4231 }
4232
4233 /// Ask the ACP client for permission to run one tool call. The request
4234 /// names the call by `tool_call_id`, the id it was announced under, so the
4235 /// client updates that one card instead of drawing a second. Presents
4236 /// allow-once / allow-for-session / deny / deny-for-session; an "always"
4237 /// choice is recorded via [`permissions::grant_for_session`] or
4238 /// [`permissions::deny_for_session`]. Only safe to call from
4239 /// a spawned task (see the handler registration in `run_acp_server`): the
4240 /// dispatch loop must be free to route the client's answer back to us.
4241 ///
4242 /// `preview` is the file change the call would make, from
4243 /// [`tools::preview_file_change`]; when there is one the card shows it as
4244 /// a diff, so the user approves an edit by looking at the change.
4245 async fn request_tool_permission(
4246 &self,
4247 cx: &ConnectionTo<Client>,
4248 session_id: &SessionId,
4249 tool_call_id: &str,
4250 tool_name: &str,
4251 arguments: &str,
4252 preview: Option<&tools::FileChange>,
4253 ) -> PermissionVerdict {
4254 // The user decides from this dialog, so show the arguments with any
4255 // truncation flagged (a silently clipped command could hide its tail
4256 // from the person approving it). The full arguments also travel as
4257 // `raw_input` for clients that render it.
4258 let args_preview = permissions::approval_preview(arguments);
4259 let title = if args_preview.is_empty() {
4260 tool_name.to_string()
4261 } else {
4262 format!("{tool_name}({args_preview})")
4263 };
4264 let raw_input: serde_json::Value = serde_json::from_str(arguments).unwrap_or_else(|e| {
4265 log::warn!("malformed JSON in tool arguments for '{}': {e}", tool_name);
4266 serde_json::Value::String(arguments.to_string())
4267 });
4268
4269 let request = RequestPermissionRequest::new(
4270 session_id.clone(),
4271 ToolCallUpdate::new(
4272 tool_call_id.to_string(),
4273 ToolCallUpdateFields::new()
4274 .title(title)
4275 .kind(tool_kind_for(tool_name))
4276 .status(ToolCallStatus::Pending)
4277 .content(preview.map(|change| vec![file_change_content(change)]))
4278 .raw_input(raw_input),
4279 ),
4280 vec![
4281 PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
4282 PermissionOption::new(
4283 "allow_session",
4284 "Allow for this session",
4285 PermissionOptionKind::AllowAlways,
4286 ),
4287 PermissionOption::new("reject_once", "Deny", PermissionOptionKind::RejectOnce),
4288 PermissionOption::new(
4289 "reject_session",
4290 "Deny for this session",
4291 PermissionOptionKind::RejectAlways,
4292 ),
4293 ],
4294 );
4295
4296 match cx.send_request(request).block_task().await {
4297 Ok(response) => match response.outcome {
4298 RequestPermissionOutcome::Selected(selected) => {
4299 match selected.option_id.0.as_ref() {
4300 "allow_once" => PermissionVerdict::Approved,
4301 "allow_session" => {
4302 permissions::grant_for_session(
4303 &session_id.to_string(),
4304 tool_name,
4305 arguments,
4306 );
4307 PermissionVerdict::Approved
4308 }
4309 "reject_session" => {
4310 permissions::deny_for_session(
4311 &session_id.to_string(),
4312 tool_name,
4313 arguments,
4314 );
4315 PermissionVerdict::Denied(permissions::user_denial(tool_name))
4316 }
4317 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
4318 }
4319 }
4320 RequestPermissionOutcome::Cancelled => PermissionVerdict::TurnCancelled,
4321 // The outcome enum is non_exhaustive; treat anything unknown as
4322 // a denial rather than running a mutating tool unapproved.
4323 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
4324 },
4325 Err(error) => {
4326 log::warn!("permission request for `{tool_name}` failed: {error}");
4327 PermissionVerdict::Denied(format!(
4328 "`{tool_name}` was not executed: this client could not answer the \
4329 permission request ({error}). The user can pre-approve tools in \
4330 settings.toml under [permissions], or set SIGIT_PERMISSIONS=allow \
4331 for clients without permission support."
4332 ))
4333 }
4334 }
4335 }
4336
4337 /// Run a built-in slash command as a turn. A `session/cancel` ends it with
4338 /// `Cancelled`; only `/compact` and `/load` take long enough to notice.
4339 async fn run_slash(
4340 &self,
4341 cx: &ConnectionTo<Client>,
4342 session_id: SessionId,
4343 command: SlashCommand,
4344 cancellation: &PromptCancellation,
4345 ) -> agent_client_protocol::Result<PromptResponse> {
4346 if cancellation.cancelled.load(Ordering::Acquire) {
4347 return Ok(PromptResponse::new(StopReason::Cancelled));
4348 }
4349 tokio::select! {
4350 response = exec_slash_acp(self, cx, session_id, command) => response,
4351 () = cancellation.cancelled() => Ok(PromptResponse::new(StopReason::Cancelled)),
4352 }
4353 }
4354
4355 fn finish_prompt(&self, session_id: &SessionId, cancellation: &Arc<PromptCancellation>) {
4356 let key = session_id.to_string();
4357 let mut active = self.prompt_cancellations.lock().unwrap();
4358 if active
4359 .get(&key)
4360 .is_some_and(|registered| Arc::ptr_eq(registered, cancellation))
4361 {
4362 active.remove(&key);
4363 }
4364 }
4365
4366 async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
4367 log::info!("cancel requested for session {}", args.session_id);
4368 self.cancel_turn(&args.session_id);
4369 Ok(())
4370 }
4371
4372 /// Stop the turn running on `session_id`, if there is one, and whatever
4373 /// command it is running. Reached from `session/cancel` and from a
4374 /// `$/cancel_request` naming the prompt.
4375 fn cancel_turn(&self, session_id: &SessionId) {
4376 if let Some(cancellation) = self
4377 .prompt_cancellations
4378 .lock()
4379 .unwrap()
4380 .get(&session_id.to_string())
4381 .cloned()
4382 {
4383 cancellation.cancelled.store(true, Ordering::Release);
4384 cancellation.notify.notify_one();
4385 }
4386 tools::abort_session_tools(&session_id.to_string());
4387 }
4388
4389 /// Swap the active backend to a siGit Code Cloud tier and reflect it as the
4390 /// current model so the picker shows it selected. Returns the tier's display
4391 /// name on success, or `None` when no account is signed in (caller prompts
4392 /// for login). Shared by the panel picker and the `/models` slash command.
4393 async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
4394 let cfg = crate::provider::cloud_tier_provider(tier)?;
4395 let display_name = cfg.display_name.clone();
4396 self.install_remote_backend(cfg, format!("sigit-cloud:{tier}"))
4397 .await;
4398
4399 // Explicitly choosing a cloud tier puts us in cloud mode.
4400 let _ = settings::set_local_inference(false);
4401
4402 log::info!("switched to cloud tier {tier}");
4403 Some(display_name)
4404 }
4405
4406 /// Install a remote provider under `selection_id`, carrying only the live
4407 /// session's non-system conversation. Kept separate from cloud credential
4408 /// lookup so the session-boundary regression can be tested without an
4409 /// account or network request.
4410 async fn install_remote_backend(&self, cfg: provider::ProviderConfig, selection_id: String) {
4411 // Mirror the cwd guidance and project instruction files the local engine
4412 // gets at session load, so the cloud model shares the same project context.
4413 let system_prompt = match self.session_cwd.lock().ok().and_then(|g| g.clone()) {
4414 Some(cwd) => remote_system_prompt(&session_context_message(
4415 &cwd,
4416 &workspace::additional_roots(),
4417 )),
4418 None => system_prompt_for_model(true),
4419 };
4420 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(
4421 OpenAiBackend::new(cfg.base_url, cfg.api_key, cfg.model, Some(system_prompt))
4422 .with_session_token(cfg.session_token),
4423 );
4424 // Continue the thread on the new backend instead of starting over.
4425 let carried =
4426 backend::carryover_history(self.backend.lock().await.history_snapshot().await);
4427 backend::adopt_carryover(cloud_backend.as_ref(), carried).await;
4428 *self.backend.lock().await = cloud_backend;
4429
4430 let remote_config = GgufModelConfig {
4431 model_id: selection_id,
4432 files: Vec::new(),
4433 tok_model_id: None,
4434 display_name: cfg.display_name.clone(),
4435 approx_memory: "Cloud".to_string(),
4436 chat_template: None,
4437 };
4438 {
4439 let mut guard = self.current_model.lock().unwrap();
4440 *guard = remote_config;
4441 }
4442 self.record_active_model();
4443 }
4444
4445 /// Route inference back on-device. Used after leaving a cloud tier for a
4446 /// local model. The `LocalBackend` reads the live `engine`, so this repoints
4447 /// the active backend and moves the conversation onto the engine with it.
4448 async fn reset_to_local_backend(&self) {
4449 // Coming back from a cloud tier, the turns since the switch live in the
4450 // cloud backend's history, not the engine's — carry them over so the
4451 // on-device model picks the thread up where the cloud one left it. A
4452 // local-to-local switch needs nothing: `switch_model_by_id` has already
4453 // replayed the engine's own history past the reload.
4454 let carried = {
4455 let current = self.backend.lock().await;
4456 if current.is_remote() {
4457 backend::carryover_history(current.history_snapshot().await)
4458 } else {
4459 Vec::new()
4460 }
4461 };
4462
4463 let local_backend: Arc<dyn InferenceBackend> =
4464 Arc::new(LocalBackend::new(Arc::clone(&self.engine)));
4465 backend::adopt_carryover(local_backend.as_ref(), carried).await;
4466 *self.backend.lock().await = local_backend;
4467 self.record_active_model();
4468 }
4469
4470 /// Re-attempt the lazy startup model load if the previous attempt failed.
4471 /// Clears the one-shot guard so the next load runs; a healthy load is left
4472 /// untouched so `/reload` doesn't needlessly reload a working model.
4473 fn retry_startup_model_load_if_failed(&self) {
4474 let had_error = self
4475 .model_load_error
4476 .lock()
4477 .map(|guard| guard.is_some())
4478 .unwrap_or(false);
4479 if had_error {
4480 self.startup_model_load_started
4481 .store(false, Ordering::Release);
4482 self.start_startup_model_load_if_needed();
4483 }
4484 }
4485
4486 /// Re-sync session state in place — no new session needed. Re-applies the
4487 /// active backend from current credentials (so a fresh `/login` token is
4488 /// picked up), retries a failed model load, and pushes refreshed commands +
4489 /// picker so the editor's UI reflects the current state.
4490 async fn handle_reload(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
4491 let signed_in = account::status_line().await;
4492
4493 let on_cloud_tier = {
4494 let guard = self.current_model.lock().unwrap();
4495 guard
4496 .model_id
4497 .strip_prefix("sigit-cloud:")
4498 .map(str::to_string)
4499 };
4500
4501 let backend_note = match on_cloud_tier {
4502 Some(tier) => match self.switch_to_cloud_tier(&tier).await {
4503 Some(name) => format!("Active: {name}."),
4504 None => {
4505 *self.current_model.lock().unwrap() = default_local_model_config();
4506 self.reset_to_local_backend().await;
4507 let _ = settings::set_local_inference(true);
4508 "Signed out — back to on-device. Pick a model with /models.".to_string()
4509 }
4510 },
4511 None => {
4512 self.reset_to_local_backend().await;
4513 self.retry_startup_model_load_if_failed();
4514 let guard = self.current_model.lock().unwrap();
4515 format!("Active: {}.", guard.display_name)
4516 }
4517 };
4518
4519 // Push refreshed picker + commands so the editor reflects current state.
4520 let config_options = {
4521 let guard = self.current_model.lock().unwrap();
4522 build_config_options(&guard, &session_id.to_string())
4523 };
4524 self.send_tool_call_update(
4525 cx,
4526 session_id.clone(),
4527 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
4528 )
4529 .ok();
4530 // A reload rereads settings.toml, which can move the default mode.
4531 self.send_current_mode(cx, session_id.clone());
4532 self.advertise_commands(cx, session_id.clone());
4533
4534 self.send_assistant_message(
4535 cx,
4536 session_id,
4537 format!("Reloaded. {signed_in} {backend_note}"),
4538 )
4539 .ok();
4540 }
4541
4542 /// `session/set_mode`, the selector ACP had before config options. The
4543 /// modes are the Permissions dropdown under another name, so this changes
4544 /// the same state and then refreshes the dropdown for a client that shows
4545 /// both.
4546 /// `-32002` unless `session_id` is a session this process knows.
4547 fn require_known_session(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
4548 let key = session_id.to_string();
4549 if self.sessions.lock().unwrap().contains_key(&key) {
4550 return Ok(());
4551 }
4552 Err(agent_client_protocol::Error::new(
4553 ErrorCode::ResourceNotFound.into(),
4554 format!(
4555 "unknown session {key}; create it with session/new or restore it with session/load"
4556 ),
4557 ))
4558 }
4559
4560 /// The config options for `key`, built from that session's own model and
4561 /// not from whichever session is live, so it can be read without the
4562 /// workspace lock.
4563 fn config_options_for_session(&self, key: &str) -> Vec<SessionConfigOption> {
4564 let live = self.active_session.lock().unwrap().as_deref() == Some(key);
4565 let model_id = if live {
4566 self.current_model.lock().unwrap().model_id.clone()
4567 } else {
4568 self.sessions
4569 .lock()
4570 .unwrap()
4571 .get(key)
4572 .map(|state| {
4573 state
4574 .remote
4575 .as_ref()
4576 .map_or_else(|| state.model_id.clone(), |r| r.model.model_id.clone())
4577 })
4578 .unwrap_or_default()
4579 };
4580 build_config_options_for(&model_id, key)
4581 }
4582
4583 /// Apply a Permissions change for `session_id`, which can be mid-turn.
4584 ///
4585 /// Takes neither the session lock nor the workspace lock: the turn holds
4586 /// the first for its whole length, and a user flipping to Auto while a
4587 /// permission prompt is open has to be heard at once. It touches only
4588 /// state keyed by session (`permissions`), so the live session is left
4589 /// alone. Returns the new config options and the status line to show.
4590 fn change_permission_mode(
4591 &self,
4592 cx: &ConnectionTo<Client>,
4593 session_id: &SessionId,
4594 mode_id: Option<&str>,
4595 ) -> agent_client_protocol::Result<Vec<SessionConfigOption>> {
4596 self.require_known_session(session_id)?;
4597 let key = session_id.to_string();
4598 let Some(message) = mode_id.and_then(|id| apply_permission_mode(&key, id)) else {
4599 return Err(agent_client_protocol::Error::new(
4600 ErrorCode::InvalidParams.into(),
4601 format!("unknown permission mode: {mode_id:?}"),
4602 ));
4603 };
4604 self.send_system_status(cx, session_id.clone(), message)
4605 .ok();
4606 Ok(self.config_options_for_session(&key))
4607 }
4608
4609 async fn handle_set_session_mode(
4610 &self,
4611 cx: &ConnectionTo<Client>,
4612 args: SetSessionModeRequest,
4613 ) -> agent_client_protocol::Result<SetSessionModeResponse> {
4614 log::info!(
4615 "set_session_mode: session={}, mode_id={}",
4616 args.session_id,
4617 args.mode_id
4618 );
4619
4620 let config_options =
4621 self.change_permission_mode(cx, &args.session_id, Some(args.mode_id.0.as_ref()))?;
4622 self.send_tool_call_update(
4623 cx,
4624 args.session_id,
4625 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
4626 )
4627 .ok();
4628 Ok(SetSessionModeResponse::new())
4629 }
4630
4631 async fn handle_set_session_config_option(
4632 &self,
4633 cx: &ConnectionTo<Client>,
4634 args: SetSessionConfigOptionRequest,
4635 ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
4636 log::info!(
4637 "set_session_config_option: config_id={}, value={:?}",
4638 args.config_id,
4639 args.value
4640 );
4641
4642 // ── Permissions dropdown (issue #76) ────────────────────────────────
4643 // Handled before the session is activated: the dispatch loop runs this
4644 // one without the session or workspace lock, so it can land mid-turn.
4645 if args.config_id.0.as_ref() == PERMISSION_MODE_CONFIG_ID {
4646 let picked = args.value.as_value_id().map(|v| v.0.as_ref());
4647 let config_options = self.change_permission_mode(cx, &args.session_id, picked)?;
4648 self.send_current_mode(cx, args.session_id);
4649 return Ok(SetSessionConfigOptionResponse::new(config_options));
4650 }
4651
4652 // A model switch carries the live conversation onto the new backend, so
4653 // the live conversation has to be the one whose picker changed.
4654 self.activate_session(&args.session_id).await?;
4655
4656 // ── Local Inference toggle ──────────────────────────────────────────
4657 if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
4658 // A toggle sends a boolean, the select fallback one of its ids.
4659 let enabled = match (
4660 args.value.as_bool(),
4661 args.value.as_value_id().map(|v| v.0.as_ref()),
4662 ) {
4663 (Some(enabled), _) => enabled,
4664 (_, Some(LOCAL_INFERENCE_ON)) => true,
4665 (_, Some(LOCAL_INFERENCE_OFF)) => false,
4666 (_, other) => {
4667 return Err(agent_client_protocol::Error::new(
4668 ErrorCode::InvalidParams.into(),
4669 format!("unknown Local Inference value: {other:?}"),
4670 ));
4671 }
4672 };
4673 if let Err(error) = settings::set_local_inference(enabled) {
4674 return Err(agent_client_protocol::Error::new(
4675 ErrorCode::InternalError.into(),
4676 format!("could not save Local Inference setting: {error}"),
4677 ));
4678 }
4679 let message = if enabled {
4680 "Local inference is on. On-device models are selectable in Model."
4681 } else {
4682 "Local inference is off. siGit Code Cloud tiers are selectable in Model."
4683 };
4684 self.send_system_status(cx, args.session_id.clone(), message)
4685 .ok();
4686 // Rebuild so the Model picker reflects the new emphasis/order.
4687 let current = self.current_model.lock().unwrap().clone();
4688 let config_options = build_config_options(&current, &args.session_id.to_string());
4689 return Ok(SetSessionConfigOptionResponse::new(config_options));
4690 }
4691
4692 if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
4693 return Err(agent_client_protocol::Error::new(
4694 ErrorCode::InvalidParams.into(),
4695 format!("unknown config option: {}", args.config_id.0),
4696 ));
4697 }
4698
4699 let Some(model_id) = args.value.as_value_id().map(|v| v.0.as_ref()) else {
4700 return Err(agent_client_protocol::Error::new(
4701 ErrorCode::InvalidParams.into(),
4702 format!(
4703 "expected a value-id for {MODEL_CONFIG_ID}, got {:?}",
4704 args.value
4705 ),
4706 ));
4707 };
4708
4709 // can't switch while the startup model is still loading — the old
4710 // weights are in GPU memory and the new load gets "does not fit"
4711 if self.startup_model_load_started.load(Ordering::Acquire)
4712 && !self.model_ready.load(Ordering::Acquire)
4713 {
4714 log::info!("set_session_config_option: waiting for startup model to finish loading");
4715 while !self.model_ready.load(Ordering::Acquire) {
4716 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
4717 }
4718 }
4719
4720 // Zed re-fires the last selection when a thread opens. That re-fire must
4721 // not load anything: on-device models are loaded only on an explicit
4722 // request (`/load`, or actively picking a *different* model below), so a
4723 // re-fire of the already-current selection is a no-op. Otherwise opening a
4724 // new thread would silently load the local model — exactly what we avoid.
4725 {
4726 let current = self.current_model.lock().unwrap();
4727 if current.model_id == model_id {
4728 log::info!(
4729 "set_session_config_option: {} is already the active selection, skipping",
4730 current.display_name
4731 );
4732 // A restored preferred model may have fallen back temporarily.
4733 // Explicitly picking the live model adopts that fallback as the
4734 // session's new preference even though no backend switch runs.
4735 drop(current);
4736 self.record_active_model();
4737 let current = self.current_model.lock().unwrap();
4738 let config_options = build_config_options(&current, &args.session_id.to_string());
4739 return Ok(SetSessionConfigOptionResponse::new(config_options));
4740 }
4741 }
4742
4743 // Both groups are listed, but only the one matching Local inference can
4744 // be picked. ACP has no disabled option, so refuse and answer with the
4745 // unchanged options so the picker snaps back.
4746 if model_kind_of(model_id) != models::active_inference_kind() {
4747 let message = match model_id.strip_prefix("sigit-cloud:") {
4748 Some(tier) => format!(
4749 "{} runs on siGit Code Cloud. Turn off Local inference to use it.",
4750 provider::tier_title(tier)
4751 ),
4752 None => {
4753 let name = models::local_picker_items()
4754 .into_iter()
4755 .find(|item| item.config.model_id == model_id)
4756 .map(|item| item.display_name)
4757 .unwrap_or_else(|| model_id.to_string());
4758 format!("{name} runs on-device. Turn on Local inference to use it.")
4759 }
4760 };
4761 self.send_system_status(cx, args.session_id.clone(), message)
4762 .ok();
4763 let current = self.current_model.lock().unwrap().clone();
4764 let config_options = build_config_options(&current, &args.session_id.to_string());
4765 return Ok(SetSessionConfigOptionResponse::new(config_options));
4766 }
4767
4768 // ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
4769 if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
4770 match self.switch_to_cloud_tier(tier).await {
4771 Some(display_name) => {
4772 self.send_system_status(
4773 cx,
4774 args.session_id.clone(),
4775 format!("Switched to {display_name}."),
4776 )
4777 .ok();
4778 }
4779 None => {
4780 // The picker response updates successful selections. When the
4781 // selection cannot apply because auth is missing, surface that
4782 // actionable state in the thread.
4783 self.send_assistant_message(
4784 cx,
4785 args.session_id.clone(),
4786 format!("\n\n{CLOUD_LOGIN_PROMPT}"),
4787 )
4788 .ok();
4789 }
4790 }
4791
4792 let current = self.current_model.lock().unwrap().clone();
4793 let config_options = build_config_options(&current, &args.session_id.to_string());
4794 return Ok(SetSessionConfigOptionResponse::new(config_options));
4795 }
4796
4797 let needs_download = models::local_picker_items()
4798 .into_iter()
4799 .find(|item| item.config.model_id == model_id)
4800 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
4801 .unwrap_or(false);
4802
4803 // tells the progress poller to stop
4804 let stop_flag = Arc::new(AtomicBool::new(false));
4805
4806 let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
4807
4808 if needs_download {
4809 let model_id_owned = model_id.to_string();
4810 let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
4811 .iter()
4812 .find(|m| m.id == model_id_owned)
4813 .map(|m| m.expected_size_bytes)
4814 .unwrap_or(0);
4815
4816 let display_name = models::local_picker_items()
4817 .into_iter()
4818 .find(|item| item.config.model_id == model_id_owned)
4819 .map(|item| item.display_name.clone())
4820 .unwrap_or_else(|| model_id_owned.clone());
4821
4822 let size_hint = if expected_bytes > 0 {
4823 format!(" (~{})", format_size_human(expected_bytes))
4824 } else {
4825 String::new()
4826 };
4827
4828 self.send_tool_call_update(
4829 cx,
4830 args.session_id.clone(),
4831 SessionUpdate::ToolCall(
4832 ToolCall::new(
4833 tool_call_id.clone(),
4834 format!("⏬ Downloading {display_name}{size_hint}"),
4835 )
4836 .kind(ToolKind::Think)
4837 .status(ToolCallStatus::InProgress)
4838 .content(vec![
4839 format!(
4840 "Preparing download for {display_name}. This may take a few minutes."
4841 )
4842 .into(),
4843 ]),
4844 ),
4845 )
4846 .ok();
4847
4848 // poll download progress and update the spinner in Zed
4849 let cx_for_poller = cx.clone();
4850 let poller_session = args.session_id.clone();
4851 let poller_model_id = model_id_owned.clone();
4852 let poller_stop = Arc::clone(&stop_flag);
4853 let poller_tool_call_id = tool_call_id.clone();
4854
4855 cx.spawn(async move {
4856 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4857 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
4858 let mut tick: usize = 0;
4859 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
4860 interval.tick().await; // consume the immediate first tick
4861
4862 while !poller_stop.load(Ordering::Relaxed) {
4863 interval.tick().await;
4864
4865 if poller_stop.load(Ordering::Relaxed) {
4866 break;
4867 }
4868
4869 let downloaded = cache_path
4870 .as_ref()
4871 .filter(|p| p.exists())
4872 .map(|p| dir_size_recursive(p))
4873 .unwrap_or(0);
4874
4875 let frame = SPINNER[tick % SPINNER.len()];
4876 tick += 1;
4877
4878 let title = if expected_bytes > 0 {
4879 let pct =
4880 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
4881 format!("{frame} Downloading {display_name}{size_hint} ({pct}%)")
4882 } else {
4883 format!("{frame} Downloading {display_name}{size_hint}")
4884 };
4885
4886 let msg = if expected_bytes > 0 {
4887 let pct =
4888 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
4889 let bar = progress_bar(pct, 20);
4890 format!(
4891 "{display_name} — {bar} {pct}% ({} / {})",
4892 format_size_human(downloaded),
4893 format_size_human(expected_bytes),
4894 )
4895 } else {
4896 format!(
4897 "{display_name} — {} downloaded…",
4898 format_size_human(downloaded)
4899 )
4900 };
4901
4902 let notification = SessionNotification::new(
4903 poller_session.clone(),
4904 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
4905 poller_tool_call_id.clone(),
4906 ToolCallUpdateFields::new()
4907 .title(title)
4908 .status(ToolCallStatus::InProgress)
4909 .content(vec![msg.into()]),
4910 )),
4911 );
4912 if cx_for_poller.send_notification(notification).is_err() {
4913 break;
4914 }
4915 }
4916 Ok(())
4917 })
4918 .ok();
4919 }
4920
4921 // cached models still take 10-30s to load weights; show a spinner
4922 if !needs_download {
4923 let cached_display_name = models::local_picker_items()
4924 .into_iter()
4925 .find(|item| item.config.model_id == model_id)
4926 .map(|item| item.display_name.clone())
4927 .unwrap_or_else(|| model_id.to_string());
4928
4929 self.send_tool_call_update(
4930 cx,
4931 args.session_id.clone(),
4932 SessionUpdate::ToolCall(
4933 ToolCall::new(
4934 tool_call_id.clone(),
4935 format!("Loading {cached_display_name}"),
4936 )
4937 .kind(ToolKind::Think)
4938 .status(ToolCallStatus::InProgress)
4939 .content(vec![format!("Loading {cached_display_name}…").into()]),
4940 ),
4941 )
4942 .ok();
4943
4944 // tick every 5s so the user knows we haven't frozen
4945 let cx_for_spinner = cx.clone();
4946 let spinner_session = args.session_id.clone();
4947 let spinner_name = cached_display_name.clone();
4948 let spinner_stop = Arc::clone(&stop_flag);
4949 let spinner_tool_call_id = tool_call_id.clone();
4950 let load_start = std::time::Instant::now();
4951
4952 cx.spawn(async move {
4953 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4954 let mut tick: usize = 0;
4955 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
4956 interval.tick().await; // consume the immediate first tick
4957
4958 while !spinner_stop.load(Ordering::Relaxed) {
4959 interval.tick().await;
4960
4961 if spinner_stop.load(Ordering::Relaxed) {
4962 break;
4963 }
4964
4965 let elapsed = load_start.elapsed();
4966 let elapsed_str = if elapsed.as_secs() >= 60 {
4967 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4968 } else {
4969 format!("{}s", elapsed.as_secs())
4970 };
4971 let frame = SPINNER[tick % SPINNER.len()];
4972 tick += 1;
4973
4974 let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
4975 let notification = SessionNotification::new(
4976 spinner_session.clone(),
4977 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
4978 spinner_tool_call_id.clone(),
4979 ToolCallUpdateFields::new()
4980 .status(ToolCallStatus::InProgress)
4981 .content(vec![msg.into()]),
4982 )),
4983 );
4984 if cx_for_spinner.send_notification(notification).is_err() {
4985 break;
4986 }
4987 }
4988 Ok(())
4989 })
4990 .ok();
4991 }
4992
4993 let switch_result = self.switch_model_by_id(model_id).await;
4994
4995 stop_flag.store(true, Ordering::Relaxed);
4996
4997 match switch_result {
4998 Ok(new_config) => {
4999 // Route inference back on-device (in case we were on a cloud tier).
5000 self.reset_to_local_backend().await;
Showing first 5,000 of 8,937 lines. View raw