1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
//! Session bootstrap — `start_session` builds the in-tab Agent: resolves the
//! backend (Gemini / Anthropic / local), assembles the system prompt
//! (`prompt::base_system_prompt` + self-docs digest + owner instructions),
//! registers every closure tool, and seeds prior history.
use std::rc::Rc;
use wasm_bindgen::JsValue;
use crate::app::APP;
use crate::policy;
use crate::types::ThinkingLevel;
use crate::{Agent, CapabilitiesConfig, GeminiAgentConfig};
use super::prompt::base_system_prompt;
use super::tools::bounty::{
accept_result_tool, claim_bounty_tool, discover_bounties_tool, post_bounty_tool,
submit_result_tool,
};
use super::tools::governance::{
cast_vote_tool, execute_proposal_tool, list_proposals_tool, propose_measure_tool,
};
use super::tools::guild::{
create_guild_tool, fund_guild_tool, invite_to_guild_tool, list_my_guilds_tool,
spend_treasury_tool,
};
use super::tools::misc::{
clear_context_tool, compact_context_tool, set_persona_tool, spawn_recursive_subagent_tool,
submit_feedback_tool,
};
use super::tools::platform::{
batch_create_subdomains_tool, bulk_release_subdomains_tool, create_and_publish_app_tool,
create_subdomain_tool, discover_agents_tool, list_subdomains_tool, release_subdomain_tool,
send_lh_tool,
};
use super::{ANTHROPIC_MAX_OUTPUT_TOKENS, GEMINI_MAX_OUTPUT_TOKENS};
pub(crate) async fn start_session(
key: &str,
base_url: Option<url::Url>,
identity: &str,
) -> Result<(), JsValue> {
// System instruction — the agent needs to know what it's running
// inside and what its filesystem looks like. Without this, prompts
// like "what is pricing" produce blind tool calls because the
// model has no priors about the localharness environment.
let host = crate::app::tenant::current();
let agent_name = match &host {
crate::app::tenant::Host::Tenant(name) => name.clone(),
_ => "this agent".to_string(),
};
// Which LLM backend this session uses — needed up front so the prompt
// advertises ONLY the tools the chosen backend actually registers. The
// Anthropic backend reuses the Gemini `register_builtins` with both
// client slots `None`, so the two Gemini-client-coupled builtins
// (`start_subagent`, `generate_image`) do NOT register on Claude. Gate
// their prompt lines on the backend so a Claude agent is never told it
// has tools it can't call.
let model = crate::app::model::load().await;
let on_anthropic = crate::app::model::is_anthropic(&model);
// SELF-EDIT GATE (computed up here so both the prompt line AND the tool
// registration agree). `set_persona` lets the agent rewrite its own system
// instruction — a higher-autonomy tool, so it's only granted when the
// allowlist permits it (unrestricted agents qualify; a restrictive allowlist
// must list `set_persona`). Low-autonomy agents are never told about it.
let set_persona_allowed = crate::app::tool_allowlist::closure_tool_allowed("set_persona").await;
let system_instructions =
base_system_prompt(&agent_name, on_anthropic, set_persona_allowed);
// Self-knowledge: append a concise runtime digest so the agent has
// grounded priors about its OWN platform/SDK every turn (and knows it
// can read the full live spec via read_self_docs). This is the
// always-available, offline half of feature 1b.
let system_instructions = format!(
"{system_instructions}\n\n{}",
crate::app::self_docs::system_prompt_digest()
);
// Owner customization: append the contents of `.lh_system_prompt.txt`
// (if any) under a clear header so the model sees the baked-in
// tooling docs first, then the owner's overrides on top. This is
// the studio-MVP hook — owners differentiate their agent's
// personality / role / constraints without forking the bundle.
let system_instructions = match crate::app::system_prompt::load().await {
Some(custom) => {
format!("{system_instructions}\n\n=== Owner instructions ===\n{custom}")
}
None => system_instructions,
};
let mut capabilities = match crate::app::tool_allowlist::load().await {
Some(mut tools) => {
// Always union the golden tools so neither the owner nor the
// agent can disable recovery (finish / ask_question /
// configure_agent).
for golden in crate::app::tool_allowlist::GOLDEN {
if !tools.contains(golden) {
tools.push(*golden);
}
}
let mut caps = CapabilitiesConfig::unrestricted();
caps.enabled_tools = Some(tools);
caps
}
None => CapabilitiesConfig::unrestricted(),
};
// ENABLE AUTO-COMPACTION. `unrestricted()` leaves `compaction_threshold =
// None`, which DISABLES it (`should_compact` always false) — so the in-tab
// conversation grew unbounded until it overflowed the model's context window
// and the turn came back empty ("(empty response)" reliably after a certain
// length). The backend (gemini/anthropic loop.rs) compares this against
// `usage.prompt_token_count` (the LIVE context size) and, when crossed,
// summarizes the old prefix before the next turn (compaction.rs). Conservative
// ceiling — set well under any plausible window so it ALWAYS trips before an
// overflow; tunable, and a recency-weighted summarization scheme can retain far
// more at this same ceiling.
capabilities.compaction_threshold = Some(128_000);
// `model` (the owner's per-subdomain `.lh_model` choice) was loaded above
// so the prompt could be gated to the backend. A `claude-*` id routes to
// the Anthropic backend; everything else (the default `gemini-*`) to
// Gemini. Both backends go through the SAME credit-proxy `base_url` in
// credits mode (the proxy is multi-provider — Gemini on `/v1beta/*`,
// Anthropic on `/v1/messages`) and carry the SAME `key` (the proxy auth
// token, or a raw key in BYOK). BYOK only routes Gemini directly; a Claude
// model on BYOK would need a raw Anthropic key, so the credit proxy is the
// intended Claude path.
let captured_key = key.to_string();
// History from a previous session (if any), consumed once here so a
// backend switch doesn't lose the transcript.
let pending_history = crate::app::history::take_pending();
let agent = if crate::app::model::is_local(&model) {
// In-browser local model (Gemma 3 270M via Burn-wgpu). No API key, no
// proxy: weights are read from this origin's OPFS (downloaded once via
// the model tab). The local backend speaks plain text — no tools — so
// we pass only the system instructions + filesystem. History from a
// prior session seeds only when it decodes as local history. Gated on
// the heavy `local` feature; without it, the id can't be served here.
#[cfg(feature = "local")]
{
let mut cfg = crate::LocalAgentConfig::new(model.clone())
.with_capabilities(capabilities)
.with_filesystem(crate::app::shared_opfs())
.with_system_instructions(system_instructions);
if let Some(bytes) = pending_history {
if crate::backends::local::connection::decode_transcript_bytes(&bytes).is_ok() {
cfg = cfg.with_history_bytes(bytes);
}
}
Agent::start_local(cfg)
.await
.map_err(|e| JsValue::from_str(&format!("start_local: {e}")))?
}
#[cfg(not(feature = "local"))]
{
// Keep the moved-in bindings live so the borrow checker is happy on
// this (never-taken-in-practice) path, then surface a clear error.
let _ = (&capabilities, &system_instructions, &pending_history);
return Err(JsValue::from_str(
"local model selected but this build was compiled without the `local` feature",
));
}
} else if crate::app::model::is_anthropic(&model) {
let mut cfg = crate::AnthropicAgentConfig::new(key.to_string())
.with_model(model.clone())
.with_capabilities(capabilities)
.with_policies(vec![policy::allow_all()])
.with_filesystem(crate::app::shared_opfs())
.with_system_instructions(system_instructions)
// Parity with the Gemini path: give a hard task room to answer in
// one call (the 8192 default is tight for a long reasoning turn).
.with_max_tokens(ANTHROPIC_MAX_OUTPUT_TOKENS)
.with_tool(create_subdomain_tool())
.with_tool(create_and_publish_app_tool())
.with_tool(batch_create_subdomains_tool())
.with_tool(release_subdomain_tool())
.with_tool(bulk_release_subdomains_tool())
.with_tool(list_subdomains_tool())
.with_tool(discover_agents_tool())
.with_tool(send_lh_tool())
.with_tool(post_bounty_tool())
.with_tool(claim_bounty_tool())
.with_tool(submit_result_tool())
.with_tool(accept_result_tool())
.with_tool(discover_bounties_tool())
.with_tool(create_guild_tool())
.with_tool(invite_to_guild_tool())
.with_tool(fund_guild_tool())
.with_tool(spend_treasury_tool())
.with_tool(list_my_guilds_tool())
.with_tool(propose_measure_tool())
.with_tool(cast_vote_tool())
.with_tool(execute_proposal_tool())
.with_tool(list_proposals_tool())
.with_tool(submit_feedback_tool())
.with_tool(crate::app::self_docs::read_self_docs_tool())
.with_tool(clear_context_tool())
.with_tool(compact_context_tool())
.with_tool(spawn_recursive_subagent_tool(captured_key, base_url.clone()));
// Self-edit tool — gated on the allowlist (see `set_persona_allowed`).
if set_persona_allowed {
cfg = cfg.with_tool(set_persona_tool());
}
// Credits mode: route Anthropic through the credit proxy (it serves
// `/v1/messages`). BYOK has no direct-Anthropic path here, so this is
// a no-op without a proxy base_url and the call would hit
// api.anthropic.com with the raw key.
if let Some(b) = &base_url {
cfg = cfg.with_base_url(b.clone());
}
// The on-disk history is the LAST backend's wire format. Only seed it
// into Anthropic when it actually parses as Anthropic history —
// otherwise (e.g. switching from a Gemini session) start fresh rather
// than failing the whole session start. The mount-time transcript
// paint stays regardless, so the user still sees the prior turns.
if let Some(bytes) = pending_history {
if crate::backends::anthropic::decode_transcript_bytes(&bytes).is_ok() {
cfg = cfg.with_history_bytes(bytes);
}
}
Agent::start_anthropic(cfg)
.await
.map_err(|e| JsValue::from_str(&format!("start_anthropic: {e}")))?
} else {
let mut cfg = GeminiAgentConfig::new(key.to_string())
.with_model(model.clone())
.with_capabilities(capabilities)
.with_policies(vec![policy::allow_all()])
.with_filesystem(crate::app::shared_opfs())
.with_system_instructions(system_instructions)
// Give a hard task room to BOTH reason and answer in one call, and
// bound reasoning (visible thinking) so it can't eat the whole
// window — the fix for "(empty response)" on long tasks.
.with_max_output_tokens(GEMINI_MAX_OUTPUT_TOKENS)
// Deep-think for the coding-heavy in-tab path. High = a 16384 thinking
// budget, which Gemini draws FROM the 32768 output cap above — leaving
// ~16k guaranteed for the final answer / tool calls. So reasoning gets
// real room to PLAN + reason about rustlite WITHOUT starving the output
// (the "(empty response)" fix holds because budget ≥ 2× thinking). The
// visible PLAN-FIRST + compile-in-the-loop discipline below leans on
// this headroom.
.with_thinking(ThinkingLevel::High)
.with_tool(create_subdomain_tool())
.with_tool(create_and_publish_app_tool())
.with_tool(batch_create_subdomains_tool())
.with_tool(release_subdomain_tool())
.with_tool(bulk_release_subdomains_tool())
.with_tool(list_subdomains_tool())
.with_tool(discover_agents_tool())
.with_tool(send_lh_tool())
.with_tool(post_bounty_tool())
.with_tool(claim_bounty_tool())
.with_tool(submit_result_tool())
.with_tool(accept_result_tool())
.with_tool(discover_bounties_tool())
.with_tool(create_guild_tool())
.with_tool(invite_to_guild_tool())
.with_tool(fund_guild_tool())
.with_tool(spend_treasury_tool())
.with_tool(list_my_guilds_tool())
.with_tool(propose_measure_tool())
.with_tool(cast_vote_tool())
.with_tool(execute_proposal_tool())
.with_tool(list_proposals_tool())
.with_tool(submit_feedback_tool())
.with_tool(crate::app::self_docs::read_self_docs_tool())
.with_tool(clear_context_tool())
.with_tool(compact_context_tool())
.with_tool(spawn_recursive_subagent_tool(captured_key, base_url.clone()));
// Self-edit tool — gated on the allowlist (see `set_persona_allowed`).
if set_persona_allowed {
cfg = cfg.with_tool(set_persona_tool());
}
// Credits mode: route the whole agent through the credit proxy. BYOK
// leaves base_url None → direct to generativelanguage.googleapis.com.
if let Some(b) = &base_url {
cfg = cfg.with_base_url(b.clone());
}
// If a previous session left history on OPFS, restore it into the
// new connection. Consumed once — subsequent key changes start
// fresh from the in-memory agent's history. Only seed it when it
// parses as Gemini history (so switching back from a Claude session
// doesn't fail the session start on an incompatible wire format).
if let Some(bytes) = pending_history {
if crate::backends::gemini::decode_transcript_bytes(&bytes).is_ok() {
cfg = cfg.with_history_bytes(bytes);
}
}
Agent::start_gemini(cfg)
.await
.map_err(|e| JsValue::from_str(&format!("start_gemini: {e}")))?
};
APP.with(|cell| {
let mut app = cell.borrow_mut();
app.agent = Some(Rc::new(agent));
// Stable identity (address in credits mode, key in BYOK) — NOT the
// rotating credits token, so the session isn't restarted per turn.
app.session_key = Some(identity.to_string());
app.turn_count = 0;
});
Ok(())
}