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