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