46
47
use agent_client_protocol::schema::{
48
AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
49
+ AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, UnstructuredCommandInput,
50
CancelNotification, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
51
ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
52
LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest,
57
ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
58
};
59
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
59
-use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
60
+use onde::inference::{ChatEngine, GgufModelConfig};
61
61
-// These back the interactive client (`run_interactive`), which is `#[cfg(unix)]`;
62
-// the import is unused on non-Unix targets that run ACP-only.
63
-#[cfg_attr(not(unix), allow(unused_imports))]
64
-use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend};
62
+use crate::backend::{
63
+ InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult, ToolSpec,
64
+};
65
use std::path::PathBuf;
66
use std::sync::atomic::{AtomicBool, Ordering};
67
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
210
/// cap tool-call loops so a confused model can't spin forever
211
const MAX_TOOL_ROUNDS: usize = 10;
212
213
-fn agent_tools_as_onde() -> Vec<ToolDefinition> {
213
+/// Shown when a siGit Code Cloud tier is selected without a signed-in account.
214
+const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \
215
+ `/login <email> <password>` (or the Authenticate button), then pick the tier again. \
216
+ Create an account at https://sigit.si.";
217
+
218
+fn agent_tools_as_specs() -> Vec<ToolSpec> {
219
tools::all_tools()
220
.into_iter()
216
- .map(|t| ToolDefinition {
221
+ .map(|t| ToolSpec {
222
name: t.name.to_string(),
223
description: t.description.to_string(),
224
parameters_schema: t.parameters_schema.to_string(),
276
277
struct SiGitAgent {
278
engine: Arc<ChatEngine>,
279
+ /// The active inference backend. `LocalBackend` by default; swapped to an
280
+ /// `OpenAiBackend` when the user selects a siGit Code Cloud tier in the panel.
281
+ backend: tokio::sync::Mutex<Arc<dyn InferenceBackend>>,
282
/// cwd from the editor — tool calls run here, not where the process started
283
session_cwd: std::sync::Mutex<Option<PathBuf>>,
284
current_model: std::sync::Mutex<GgufModelConfig>,
307
) -> Self {
308
let startup_model_name = initial_model.display_name.clone();
309
let startup_model_id = initial_model.model_id.clone();
310
+ let backend: Arc<dyn InferenceBackend> =
311
+ Arc::new(LocalBackend::new(Arc::clone(&engine)));
312
Self {
313
engine,
314
+ backend: tokio::sync::Mutex::new(backend),
315
session_cwd: std::sync::Mutex::new(None),
316
current_model: std::sync::Mutex::new(initial_model),
317
model_ready,
578
cx.send_notification(SessionNotification::new(session_id, update))
579
}
580
581
+ /// Advertise siGit's slash commands to the client. Editors like Zed parse
582
+ /// `/`-prefixed input and only forward commands they've been told about, so
583
+ /// without this `/login`, `/models`, etc. are rejected client-side.
584
+ fn advertise_commands(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
585
+ let with_hint = |name: &str, desc: &str, hint: &str| {
586
+ AvailableCommand::new(name, desc).input(AvailableCommandInput::Unstructured(
587
+ UnstructuredCommandInput::new(hint),
588
+ ))
589
+ };
590
+ let commands = vec![
591
+ AvailableCommand::new("help", "Show available commands"),
592
+ AvailableCommand::new("models", "List available models")
593
+ .input(AvailableCommandInput::Unstructured(
594
+ UnstructuredCommandInput::new("model number to switch to (optional)"),
595
+ )),
596
+ with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"),
597
+ AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
598
+ AvailableCommand::new("whoami", "Show the signed-in account"),
599
+ AvailableCommand::new("clear", "Wipe the conversation history"),
600
+ AvailableCommand::new("status", "Show engine status"),
601
+ ];
602
+ self.send_tool_call_update(
603
+ cx,
604
+ session_id,
605
+ SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
606
+ )
607
+ .ok();
608
+ }
609
+
610
async fn switch_model_by_id(
611
&self,
612
model_id: &str,
709
) -> agent_client_protocol::Result<InitializeResponse> {
710
log::info!("initialize");
711
712
+ // Agent-handled auth method. We don't use `AuthMethod::Terminal`: editors
713
+ // like Zed advertise terminal-auth capability but don't actually spawn the
714
+ // login terminal for *custom* ACP agents, so the button is a silent no-op.
715
+ // With an Agent method, clicking calls `authenticate`, which returns either
716
+ // confirmation (already signed in via `/login`) or a message telling the
717
+ // user to run `/login <email> <password>` — so the button does something.
718
+ let auth_methods = vec![AuthMethod::Agent(
719
+ AuthMethodAgent::new("sigit", "Sign in to siGit Code")
720
+ .description("Sign in with `/login <email> <password>` in the message box."),
721
+ )];
722
+
723
Ok(InitializeResponse::new(ProtocolVersion::V1)
724
.agent_info(
725
Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
726
.title("siGit Code - AI Coding Agent"),
727
)
677
- .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(
678
- "sigit",
679
- "siGit Code",
680
- ))])
728
+ .auth_methods(auth_methods)
729
.agent_capabilities(
730
AgentCapabilities::default()
731
.load_session(true)
738
739
async fn handle_authenticate(
740
&self,
693
- _req: AuthenticateRequest,
741
+ req: AuthenticateRequest,
742
) -> agent_client_protocol::Result<AuthenticateResponse> {
695
- log::info!("authenticate");
696
- Ok(AuthenticateResponse::default())
743
+ log::info!("authenticate: method={}", req.method_id.0);
744
+
745
+ // Confirm the stored token works. The button can't collect a password,
746
+ // so an unsigned-in user is pointed at the `/login` slash command; a user
747
+ // already signed in via `/login` gets the gate cleared.
748
+ match account::verify_session().await {
749
+ Ok(email) => {
750
+ log::info!("authenticate: verified session for {email}");
751
+ Ok(AuthenticateResponse::default())
752
+ }
753
+ Err(reason) => Err(agent_client_protocol::Error::new(
754
+ -32000,
755
+ format!(
756
+ "Not signed in to siGit Code Cloud ({reason}). \
757
+ Sign in with `/login <email> <password>` in the message box, \
758
+ or create an account at https://sigit.si."
759
+ ),
760
+ )),
761
+ }
762
}
763
764
async fn handle_load_session(
765
&self,
766
+ cx: &ConnectionTo<Client>,
767
args: LoadSessionRequest,
768
) -> agent_client_protocol::Result<LoadSessionResponse> {
769
log::info!(
805
build_model_config_options(&guard)
806
};
807
808
+ self.advertise_commands(cx, args.session_id.clone());
809
+
810
Ok(LoadSessionResponse::new().config_options(config_options))
811
}
812
813
async fn handle_fork_session(
814
&self,
815
+ cx: &ConnectionTo<Client>,
816
args: ForkSessionRequest,
817
) -> agent_client_protocol::Result<ForkSessionResponse> {
818
let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
853
build_model_config_options(&guard)
854
};
855
856
+ self.advertise_commands(cx, new_id.clone());
857
+
858
Ok(ForkSessionResponse::new(new_id).config_options(config_options))
859
}
860
861
async fn handle_new_session(
862
&self,
863
+ cx: &ConnectionTo<Client>,
864
args: NewSessionRequest,
865
) -> agent_client_protocol::Result<NewSessionResponse> {
866
let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
899
build_model_config_options(&guard)
900
};
901
902
+ self.advertise_commands(cx, session_id.clone());
903
+
904
Ok(NewSessionResponse::new(session_id).config_options(config_options))
905
}
906
1058
user_text.chars().take(80).collect::<String>()
1059
);
1060
987
- // load the default ACP model lazily so initialize/session/new stay clean
988
- // for registry validation and editor startup.
989
- self.start_startup_model_load_if_needed();
990
- self.await_model_ready(cx, &session_id).await?;
1061
+ // The active backend drives the turn. Snapshot it once so a mid-turn
1062
+ // model switch doesn't split the conversation across backends.
1063
+ let backend = self.backend.lock().await.clone();
1064
+
1065
+ // Only on-device inference needs a local model in memory. Cloud tiers run
1066
+ // over the network, so skip the lazy load and the readiness wait for them.
1067
+ if !backend.is_remote() {
1068
+ self.start_startup_model_load_if_needed();
1069
+ self.await_model_ready(cx, &session_id).await?;
1070
+ }
1071
1072
// ── tool-calling loop ────────────────────────────────────────────
1073
// send message → execute any tool calls → feed results back
1074
// repeat up to MAX_TOOL_ROUNDS, then force a text reply
1075
996
- let onde_tools = agent_tools_as_onde();
1076
+ let tools = agent_tools_as_specs();
1077
998
- let mut result = self
999
- .engine
1000
- .send_message_with_tools(&user_text, &onde_tools)
1078
+ let mut result = backend
1079
+ .send_message_with_tools(&user_text, &tools)
1080
.await
1081
.map_err(|error| {
1082
log::error!("send_message_with_tools failed: {error}");
1099
for tc in &result.tool_calls {
1100
log::info!(
1101
" → {}({})",
1023
- tc.function_name,
1102
+ tc.name,
1103
tc.arguments.chars().take(120).collect::<String>()
1104
);
1105
1027
- let output = tools::execute_tool(&tc.function_name, &tc.arguments).await;
1106
+ let output = tools::execute_tool(&tc.name, &tc.arguments).await;
1107
1108
log::info!(" ← {} chars", output.len());
1109
1031
- tool_results.push(ToolResult {
1110
+ tool_results.push(BackendToolResult {
1111
tool_call_id: tc.id.clone(),
1112
content: output,
1113
});
1114
}
1115
1116
let next_tools = if round < MAX_TOOL_ROUNDS {
1038
- Some(onde_tools.as_slice())
1117
+ Some(tools.as_slice())
1118
} else {
1119
None // last round: force text
1120
};
1121
1043
- result = self
1044
- .engine
1122
+ result = backend
1123
.send_tool_results(tool_results, next_tools)
1124
.await
1125
.map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
1163
Ok(())
1164
}
1165
1166
+ /// Swap the active backend to a siGit Code Cloud tier and reflect it as the
1167
+ /// current model so the picker shows it selected. Returns the tier's display
1168
+ /// name on success, or `None` when no account is signed in (caller prompts
1169
+ /// for login). Shared by the panel picker and the `/models` slash command.
1170
+ async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
1171
+ let cfg = crate::provider::cloud_tier_provider(tier)?;
1172
+ let mut system_prompt = system_prompt_for_model(true).to_string();
1173
+ // Mirror the cwd guidance the local engine gets at session load, so the
1174
+ // cloud model also uses absolute paths under the editor's project root.
1175
+ if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1176
+ system_prompt.push_str(&format!(
1177
+ "\n\nThe user's project working directory is {}. \
1178
+ Always use absolute paths under this directory for all file \
1179
+ and directory operations.",
1180
+ cwd.display()
1181
+ ));
1182
+ }
1183
+ let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
1184
+ cfg.base_url,
1185
+ cfg.api_key,
1186
+ cfg.model,
1187
+ Some(system_prompt),
1188
+ ));
1189
+ *self.backend.lock().await = cloud_backend;
1190
+
1191
+ let cloud_config = GgufModelConfig {
1192
+ model_id: format!("sigit-cloud:{tier}"),
1193
+ files: Vec::new(),
1194
+ tok_model_id: None,
1195
+ display_name: cfg.display_name.clone(),
1196
+ approx_memory: "Cloud".to_string(),
1197
+ chat_template: None,
1198
+ };
1199
+ {
1200
+ let mut guard = self.current_model.lock().unwrap();
1201
+ *guard = cloud_config;
1202
+ }
1203
+
1204
+ log::info!("switched to cloud tier {tier}");
1205
+ Some(cfg.display_name)
1206
+ }
1207
+
1208
+ /// Route inference back on-device. Used after leaving a cloud tier for a
1209
+ /// local model. The `LocalBackend` reads the live `engine`, so this just
1210
+ /// repoints the active backend.
1211
+ async fn reset_to_local_backend(&self) {
1212
+ let local_backend: Arc<dyn InferenceBackend> =
1213
+ Arc::new(LocalBackend::new(Arc::clone(&self.engine)));
1214
+ *self.backend.lock().await = local_backend;
1215
+ }
1216
+
1217
async fn handle_set_session_config_option(
1218
&self,
1219
cx: &ConnectionTo<Client>,
1258
}
1259
}
1260
1261
+ // ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
1262
+ if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
1263
+ let message = match self.switch_to_cloud_tier(tier).await {
1264
+ Some(display_name) => format!("Switched to {display_name}."),
1265
+ None => CLOUD_LOGIN_PROMPT.to_string(),
1266
+ };
1267
+ self.send_assistant_message(cx, args.session_id.clone(), message)
1268
+ .ok();
1269
+
1270
+ let current = self.current_model.lock().unwrap().clone();
1271
+ let config_options = build_model_config_options(¤t);
1272
+ return Ok(SetSessionConfigOptionResponse::new(config_options));
1273
+ }
1274
+
1275
let needs_download = models::local_picker_items()
1276
.into_iter()
1277
.find(|item| item.config.model_id == model_id)
1474
1475
match switch_result {
1476
Ok(new_config) => {
1477
+ // Route inference back on-device (in case we were on a cloud tier).
1478
+ self.reset_to_local_backend().await;
1479
+
1480
let completion_title = if needs_download {
1481
format!("✓ {} downloaded and loaded", new_config.display_name)
1482
} else {
1535
const MODEL_CONFIG_ID: &str = "sigit-model";
1536
1537
fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
1392
- let items = models::local_picker_items();
1538
+ // The full list, including the siGit Code Cloud tiers, so the panel picker
1539
+ // mirrors the TUI `/models`. Cloud entries are sign-in gated at selection.
1540
+ let items = models::build_model_picker_items();
1541
1542
let options: Vec<SessionConfigSelectOption> = items
1543
.iter()
1552
desc_parts.push("↓ download on select".to_string());
1553
}
1554
let description = desc_parts.join(" - ");
1407
- let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
1555
+ let source_badge = if item.cloud_tier.is_some() {
1556
+ " [☁ siGit Code Cloud]"
1557
+ } else if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
1558
" [↓ Onde]"
1559
} else {
1560
match item.source_label.as_str() {
1581
vec![
1582
SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
1583
.category(SessionConfigOptionCategory::Model)
1434
- .description("Select the local LLM model for inference"),
1584
+ .description("Select an on-device model or a siGit Code Cloud tier"),
1585
]
1586
}
1587
1635
}
1636
1637
fn format_models_list(current_model: &GgufModelConfig) -> String {
1488
- let items = models::local_picker_items();
1638
+ let items = models::build_model_picker_items();
1639
if items.is_empty() {
1640
return "No local models found. siGit will use the platform default model.".to_string();
1641
}
1647
let source_key = match item.source_label.as_str() {
1648
"Onde" => "Onde",
1649
"HuggingFace" => "HuggingFace",
1650
+ "siGit Code Cloud" => "Cloud",
1651
_ => "Fallback",
1652
};
1653
1658
let section = match source_key {
1659
"Onde" => "Onde Inference",
1660
"HuggingFace" => "Hugging Face cache",
1661
+ "Cloud" => "siGit Code Cloud",
1662
_ => "Fallback",
1663
};
1664
lines.push(section.to_string());
1665
+ // Blank line so the following "N." items render as an ordered list.
1666
+ // CommonMark only lets an ordered list interrupt a paragraph when it
1667
+ // starts at 1, so without this the cloud section (items 9+) would be
1668
+ // absorbed into the header paragraph.
1669
+ lines.push(String::new());
1670
last_source = Some(source_key);
1671
}
1672
1689
let source = match source_key {
1690
"Onde" => " [Onde]",
1691
"HuggingFace" => " [HuggingFace]",
1692
+ "Cloud" => " [☁ Cloud]",
1693
_ => " [default]",
1694
};
1695
1760
.ok();
1761
}
1762
SlashCommand::Models(Some(number)) => {
1605
- let items = models::local_picker_items();
1763
+ let items = models::build_model_picker_items();
1764
let index = number.saturating_sub(1);
1765
match items.get(index).cloned() {
1766
None => {
1772
)
1773
.ok();
1774
}
1775
+ Some(model) if model.cloud_tier.is_some() => {
1776
+ // siGit Code Cloud tier: swap backend, sign-in gated.
1777
+ let tier = model.cloud_tier.clone().unwrap_or_default();
1778
+ let message = match agent.switch_to_cloud_tier(&tier).await {
1779
+ Some(display_name) => format!("Switched to {display_name}."),
1780
+ None => CLOUD_LOGIN_PROMPT.to_string(),
1781
+ };
1782
+ agent.send_assistant_message(cx, session_id, message).ok();
1783
+ }
1784
Some(model) => {
1785
if model.cache_health == setup::ModelCacheHealth::Incomplete {
1786
agent
1807
1808
match agent.switch_model_by_id(&model.config.model_id).await {
1809
Ok(new_config) => {
1810
+ agent.reset_to_local_backend().await;
1811
agent.engine.clear_history().await;
1812
agent
1813
.send_assistant_message(
1840
.ok();
1841
1842
let switched = agent.switch_model_by_id(&model.config.model_id).await?;
1843
+ agent.reset_to_local_backend().await;
1844
agent.engine.clear_history().await;
1845
1846
agent
2209
.on_receive_request(
2210
{
2211
let state = Arc::clone(&state);
2043
- async move |req: LoadSessionRequest, responder, _cx: ConnectionTo<Client>| {
2044
- handle_response(responder, state.handle_load_session(req).await)
2212
+ async move |req: LoadSessionRequest, responder, cx: ConnectionTo<Client>| {
2213
+ handle_response(responder, state.handle_load_session(&cx, req).await)
2214
}
2215
},
2216
agent_client_protocol::on_receive_request!(),
2218
.on_receive_request(
2219
{
2220
let state = Arc::clone(&state);
2052
- async move |req: ForkSessionRequest, responder, _cx: ConnectionTo<Client>| {
2053
- handle_response(responder, state.handle_fork_session(req).await)
2221
+ async move |req: ForkSessionRequest, responder, cx: ConnectionTo<Client>| {
2222
+ handle_response(responder, state.handle_fork_session(&cx, req).await)
2223
}
2224
},
2225
agent_client_protocol::on_receive_request!(),
2227
.on_receive_request(
2228
{
2229
let state = Arc::clone(&state);
2061
- async move |req: NewSessionRequest, responder, _cx: ConnectionTo<Client>| {
2062
- handle_response(responder, state.handle_new_session(req).await)
2230
+ async move |req: NewSessionRequest, responder, cx: ConnectionTo<Client>| {
2231
+ handle_response(responder, state.handle_new_session(&cx, req).await)
2232
}
2233
},
2234
agent_client_protocol::on_receive_request!(),
2277
2278
#[tokio::main]
2279
async fn main() -> anyhow::Result<()> {
2280
+ // Account subcommands. The editor launches `sigit login` in an embedded
2281
+ // terminal for ACP terminal-based authentication; the same verbs are handy
2282
+ // directly from a shell. These must be handled before the TTY/ACP split.
2283
+ if let Some(verb) = std::env::args().nth(1) {
2284
+ match verb.as_str() {
2285
+ "login" => {
2286
+ init_logging(true);
2287
+ match account::interactive_login().await {
2288
+ Ok(email) => {
2289
+ println!("Signed in to siGit Code Cloud as {email}.");
2290
+ return Ok(());
2291
+ }
2292
+ Err(error) => {
2293
+ eprintln!("Login failed: {error}");
2294
+ std::process::exit(1);
2295
+ }
2296
+ }
2297
+ }
2298
+ "logout" => {
2299
+ init_logging(true);
2300
+ println!("{}", account::end_session().await);
2301
+ return Ok(());
2302
+ }
2303
+ "whoami" => {
2304
+ init_logging(true);
2305
+ println!("{}", account::status_line().await);
2306
+ return Ok(());
2307
+ }
2308
+ _ => {}
2309
+ }
2310
+ }
2311
+
2312
let is_tty = std::io::stdin().is_terminal();
2313
2314
if is_tty {