localharness 0.45.0

Agents that own themselves: one Rust crate that's both an agent SDK (streaming, tools, hooks, policies, triggers, MCP) and a wallet-owning, self-sovereign agent that runs in the browser.
Documentation
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
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! 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 +
//! self-recorded lessons), 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::party::{
    complete_party_tool, disband_party_tool, discover_parties_tool, form_party_tool,
    fund_party_tool, get_party_tool, join_party_tool,
};
use super::tools::misc::{
    dwell_tool,
    clear_context_tool, compact_context_tool, consolidate_lessons_tool, notify_tool,
    record_lesson_tool, set_lessons_tool,
    set_persona_tool, spawn_recursive_subagent_tool, submit_feedback_tool, web_fetch_tool,
};
use super::tools::platform::{
    batch_create_subdomains_tool, bulk_release_subdomains_tool, create_and_publish_app_tool,
    create_subdomain_tool, discover_agents_tool, embed_app_tool, list_subdomains_tool,
    query_balance_tool, release_subdomain_tool, batch_send_lh_tool, check_balances_tool, send_lh_tool,
};
use super::tools::room::{
    shared_state_get_tool, shared_state_list_tool, shared_state_set_tool,
};
use super::tools::validation::{
    challenge_validation_tool, get_validation_tool, reclaim_validation_tool,
    resolve_validation_tool, stake_validation_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,
    };

    // Self-recorded lessons: fold in the bounded lessons blob (OPFS working
    // copy, else the on-chain slot) so a mistake corrected once stays
    // corrected across sessions and devices — the read half of the lessons
    // loop (`record_lesson` is the write half).
    let system_instructions = match crate::app::lessons::load()
        .await
        .as_deref()
        .and_then(crate::lessons::compose_section)
    {
        Some(section) => format!("{system_instructions}\n\n{section}"),
        None => system_instructions,
    };

    // The agent's OWN advertised per-call price (GitHub #49) — so it can answer
    // "what do you charge?" accurately instead of guessing or stating a price
    // that mismatches the chain. Effective price = advertised on-chain, else the
    // 0.01 $LH default the proxy enforces as a floor. Tenant-only, best-effort:
    // a failed on-chain read just omits the line (never blocks session start).
    let system_instructions = if let crate::app::tenant::Host::Tenant(_) = &host {
        match crate::registry::id_of_name(&agent_name).await {
            Ok(id) if id != 0 => match crate::registry::x402_ask_price_of(id).await {
                Ok(wei) => format!(
                    "{system_instructions}\n\n=== Your pricing ===\nYour advertised per-call price is {} $LH — what a caller pays to reach you over the hosted x402 route. State this if asked what you charge.",
                    crate::app::chat::tools::guild::format_lh(wei)
                ),
                Err(_) => system_instructions,
            },
            _ => system_instructions,
        }
    } else {
        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(super::COMPACTION_THRESHOLD);

    // `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();

    // Credits mode signs a FRESH proxy auth token for EVERY request. The
    // proxy enforces a 5-minute freshness window on the signed token, so a
    // token baked in at session start dies mid-conversation — every later
    // request 401s "stale or future timestamp" (on-chain feedback #46) and
    // long thinking turns break in the middle. BYOK (no proxy base_url)
    // keeps the static key.
    let auth_provider: Option<crate::backends::KeyProvider> = if base_url.is_some() {
        super::access::credit_signer().await.map(|(signer, _addr)| {
            std::sync::Arc::new(move || {
                let now = (js_sys::Date::now() / 1000.0) as u64;
                crate::registry::proxy_auth_token(&signer, now)
            }) as crate::backends::KeyProvider
        })
    } else {
        None
    };
    // 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_pre_tool_hook(std::sync::Arc::new(
                super::confirm_guard::TypedConfirmationGuard,
            ))
            .with_pre_tool_hook(std::sync::Arc::new(super::dedup::DuplicateActionGuard))
            .with_post_tool_hook(std::sync::Arc::new(
                super::dedup::DuplicateActionGuardCleanup,
            ))
            .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(embed_app_tool())
            .with_tool(send_lh_tool())
            .with_tool(batch_send_lh_tool())
            .with_tool(check_balances_tool())
            .with_tool(query_balance_tool())
            .with_tool(shared_state_set_tool())
            .with_tool(shared_state_get_tool())
            .with_tool(shared_state_list_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(form_party_tool())
            .with_tool(join_party_tool())
            .with_tool(fund_party_tool())
            .with_tool(complete_party_tool())
            .with_tool(disband_party_tool())
            .with_tool(discover_parties_tool())
            .with_tool(get_party_tool())
            .with_tool(stake_validation_tool())
            .with_tool(challenge_validation_tool())
            .with_tool(resolve_validation_tool())
            .with_tool(reclaim_validation_tool())
            .with_tool(get_validation_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(notify_tool())
            .with_tool(record_lesson_tool())
            .with_tool(consolidate_lessons_tool())
            .with_tool(set_lessons_tool())
            .with_tool(crate::app::self_docs::read_self_docs_tool())
            .with_tool(web_fetch_tool())
            .with_tool(dwell_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());
        }
        if let Some(p) = auth_provider.clone() {
            cfg = cfg.with_auth_provider(p);
        }
        // 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_pre_tool_hook(std::sync::Arc::new(
                super::confirm_guard::TypedConfirmationGuard,
            ))
            .with_pre_tool_hook(std::sync::Arc::new(super::dedup::DuplicateActionGuard))
            .with_post_tool_hook(std::sync::Arc::new(
                super::dedup::DuplicateActionGuardCleanup,
            ))
            .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(embed_app_tool())
            .with_tool(send_lh_tool())
            .with_tool(batch_send_lh_tool())
            .with_tool(check_balances_tool())
            .with_tool(query_balance_tool())
            .with_tool(shared_state_set_tool())
            .with_tool(shared_state_get_tool())
            .with_tool(shared_state_list_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(form_party_tool())
            .with_tool(join_party_tool())
            .with_tool(fund_party_tool())
            .with_tool(complete_party_tool())
            .with_tool(disband_party_tool())
            .with_tool(discover_parties_tool())
            .with_tool(get_party_tool())
            .with_tool(stake_validation_tool())
            .with_tool(challenge_validation_tool())
            .with_tool(resolve_validation_tool())
            .with_tool(reclaim_validation_tool())
            .with_tool(get_validation_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(notify_tool())
            .with_tool(record_lesson_tool())
            .with_tool(consolidate_lessons_tool())
            .with_tool(set_lessons_tool())
            .with_tool(crate::app::self_docs::read_self_docs_tool())
            .with_tool(web_fetch_tool())
            .with_tool(dwell_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 let Some(p) = auth_provider.clone() {
            cfg = cfg.with_auth_provider(p);
        }
        // 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(())
}