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