use std::collections::VecDeque;
use std::rc::Rc;
use futures_util::StreamExt;
use maud::html;
use wasm_bindgen::JsValue;
use crate::policy;
use crate::tools::ClosureTool;
use crate::{Agent, CapabilitiesConfig, GeminiAgentConfig, StreamChunk};
use super::dom;
use super::templates;
use super::APP;
thread_local! {
static TURN_ACTIVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static TURN_CANCEL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(crate) fn request_stop_turn() {
TURN_CANCEL.with(|c| c.set(true));
}
struct TurnGuard;
impl Drop for TurnGuard {
fn drop(&mut self) {
TURN_ACTIVE.with(|c| c.set(false));
TURN_CANCEL.with(|c| c.set(false));
if dom::by_id("terminal-stop").is_some() {
dom::swap_outer("terminal-stop", &templates::send_button().into_string());
}
}
}
pub(crate) async fn run_send() {
let Some(prompt_area) = dom::textarea_by_id("prompt") else {
dom::set_status("internal: #prompt textarea missing", true);
return;
};
let key = match read_api_key().await {
Some(k) => k,
None => {
super::show_api_key_modal();
return;
}
};
let prompt = prompt_area.value().trim().to_string();
if prompt.is_empty() {
dom::set_status("enter a prompt first.", true);
return;
}
if TURN_ACTIVE.with(|c| c.get()) {
return;
}
TURN_ACTIVE.with(|c| c.set(true));
TURN_CANCEL.with(|c| c.set(false));
let _turn_guard = TurnGuard;
match collect_payment_if_required().await {
Ok(None) => {} Ok(Some(tx_hash)) => {
dom::set_status(
&format!("payment received ({}); sending…", short_hash(&tx_hash)),
false,
);
}
Err(err) => {
dom::set_status(&format!("payment failed: {err}"), true);
return;
}
}
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &key);
}
let session_needs_start = APP.with(|cell| {
let app = cell.borrow();
app.agent.is_none() || app.session_key.as_deref() != Some(key.as_str())
});
if session_needs_start {
if let Err(err) = start_session(&key).await {
dom::set_status(&format!("session start failed: {err:?}"), true);
return;
}
}
let Some(agent) = APP.with(|cell| cell.borrow().agent.clone()) else {
dom::set_status("internal: agent not set after start_session", true);
return;
};
let (user_turn_id, assistant_turn_id, mut seg_id) = APP.with(|cell| {
let mut app = cell.borrow_mut();
(app.alloc_id(), app.alloc_id(), app.alloc_id())
});
dom::append_html(
"transcript",
&templates::turn(user_turn_id, "user", html! { (prompt) }, false).into_string(),
);
dom::append_html(
"transcript",
&templates::turn(
assistant_turn_id,
"assistant",
templates::text_segment(seg_id, ""),
true,
)
.into_string(),
);
dom::scroll_to_bottom("transcript");
let assistant_body_id = format!("turn-body-{assistant_turn_id}");
prompt_area.set_value("");
let _ = prompt_area.focus();
let mut pending_tools: VecDeque<u32> = VecDeque::new();
let mut text_segments: Vec<(u32, String)> = vec![(seg_id, String::new())];
let t0 = js_sys::Date::now();
let mut t_first_chunk: Option<f64> = None;
let response = match agent.chat(prompt).await {
Ok(r) => r,
Err(err) => {
report_turn_error("agent.chat", &format!("{err}"), assistant_turn_id);
return;
}
};
let mut cursor = response.chunks();
dom::swap_outer("terminal-send", &templates::stop_button().into_string());
while let Some(item) = cursor.next().await {
if TURN_CANCEL.with(|c| c.get()) {
break;
}
if t_first_chunk.is_none() {
t_first_chunk = Some(js_sys::Date::now());
}
match item {
Ok(StreamChunk::Text { text, .. }) => {
if !text.is_empty() {
let (cur_id, cur_text) = text_segments
.last_mut()
.expect("text_segments seeded at start of turn");
cur_text.push_str(&text);
let inner = html! { (cur_text) }.into_string();
dom::swap_inner(&format!("seg-{cur_id}"), &inner);
dom::scroll_to_bottom("transcript");
}
}
Ok(StreamChunk::ToolCall(call)) => {
let tool_seg_id = APP.with(|cell| cell.borrow_mut().alloc_id());
dom::append_html(
&assistant_body_id,
&templates::tool_call_block(tool_seg_id, &call).into_string(),
);
pending_tools.push_back(tool_seg_id);
seg_id = APP.with(|cell| cell.borrow_mut().alloc_id());
text_segments.push((seg_id, String::new()));
dom::append_html(
&assistant_body_id,
&templates::text_segment(seg_id, "").into_string(),
);
dom::scroll_to_bottom("transcript");
}
Ok(StreamChunk::ToolResult(result)) => {
if let Some(tool_seg_id) = pending_tools.pop_front() {
let result_target = format!("tool-{tool_seg_id}-result");
dom::swap_inner(
&result_target,
&templates::tool_call_result(&result).into_string(),
);
dom::scroll_to_bottom("transcript");
}
}
Ok(StreamChunk::Thought { .. }) => {
}
Err(err) => {
report_turn_error("stream", &format!("{err}"), assistant_turn_id);
return;
}
}
}
dom::swap_outer("terminal-stop", &templates::send_button().into_string());
for (id, raw) in &text_segments {
if raw.is_empty() {
continue;
}
let html_str = templates::rendered_markdown(raw).into_string();
dom::swap_inner(&format!("seg-{id}"), &html_str);
}
mark_turn_done(assistant_turn_id);
if let Some(total) = agent.cumulative_usage().total_token_count {
APP.with(|cell| cell.borrow_mut().total_tokens = total.max(0) as u64);
}
if TURN_CANCEL.with(|c| c.get()) {
let note_id = APP.with(|cell| cell.borrow_mut().alloc_id());
dom::append_html(
"transcript",
&templates::turn(
note_id,
"assistant",
templates::text_segment(note_id, "Stopped. What should I do instead?"),
false,
)
.into_string(),
);
dom::scroll_to_bottom("transcript");
}
APP.with(|cell| cell.borrow_mut().turn_count += 1);
let turn_count = APP.with(|cell| cell.borrow().turn_count);
let _t_end = js_sys::Date::now();
let _ = (t0, t_first_chunk, turn_count);
super::history::save_from_agent().await;
super::opfs::refresh().await;
}
fn report_turn_error(context: &str, err: &str, assistant_turn_id: u32) {
mark_turn_done(assistant_turn_id);
let lower = err.to_lowercase();
let looks_like_auth = lower.contains("api key")
|| lower.contains("api_key")
|| lower.contains("401")
|| lower.contains("403")
|| lower.contains("permission_denied")
|| lower.contains("unauthenticated");
if looks_like_auth {
dom::set_status("API key rejected — check your Gemini key.", true);
super::show_api_key_modal();
} else {
dom::set_status(&format!("{context}: {err}"), true);
}
}
pub(crate) async fn start_session(key: &str) -> Result<(), JsValue> {
let host = super::tenant::current();
let agent_name = match &host {
super::tenant::Host::Tenant(name) => name.clone(),
_ => "this agent".to_string(),
};
let system_instructions = format!(
"You are {agent_name}, a browser-resident assistant running inside \
the localharness platform — a Rust SDK that compiles to wasm and runs \
in the user's browser tab. You are speaking to your owner, who minted \
this subdomain as an ERC-721 NFT on Tempo Moderato.\n\n\
\
=== Your tools (you DO have all of these) ===\n\
Filesystem (per-origin OPFS sandbox):\n\
• list_directory(path) — list files in a directory.\n\
• view_file(path, range?) — read a file's contents.\n\
• find_file(pattern) — glob search by name.\n\
• search_directory(pattern, path?) — regex search of file contents.\n\
• create_file(path, content) — write a new file.\n\
• edit_file(path, old, new) — exact-string replace in a file.\n\
• delete_file(path) — DELETE a file. You CAN do this; do not say \
otherwise. Irreversible — confirm intent first unless the user \
explicitly told you to delete.\n\
• rename_file(from, to) — move or rename.\n\n\
\
Platform:\n\
• create_subdomain(name) — register a new <name>.localharness.xyz \
on-chain, owned by your owner's master wallet. Returns the tx \
hash. Each subdomain is its own agent tab.\n\
• start_subagent(system_instructions, prompt) — spawn a one-shot \
text-only subagent with no tool access. Use for self-contained \
reasoning / writing tasks you want isolated from your context.\n\
• spawn_recursive_subagent(system_instructions, prompt) — spawn a \
full subagent with the same tool surface YOU have (filesystem, \
create_subdomain, start_subagent, etc.). Use for delegation that \
needs tools. Recursion depth is implicit (each subagent has its \
own context; cost grows with depth — don't chain more than 3 \
levels unless the user asked).\n\
• call_agent(name, message) — send a message to another agent by \
subdomain name and receive its text response. The target agent \
must have an API key configured. Use this for inter-agent \
collaboration, delegation, or multi-agent workflows.\n\
• compile_rustlite(source, function?, args?) — compile Rust-subset \
source code to wasm and execute a function. Supports structs, \
enums, fns, match, if/else, while/loop, let mut. No traits, \
no generics, no references. Returns the i32 result.\n\
• run_cartridge(source) — compile a rustlite cartridge and run it \
on the VISUAL DISPLAY the user sees (a 256x144 pixel framebuffer). \
The cartridge exports `fn frame(t: i32)` (animated, t = elapsed ms) \
or `fn render()`, and draws via `use host::display;`. Drawing: \
clear(rgb), fill_rect(x,y,w,h,rgb), set_pixel(x,y,rgb), \
draw_char(x,y,code,rgb,scale) (ASCII code, e.g. 65='A'), \
draw_number(x,y,value,rgb,scale) (decimal int), present() (call \
last). Input polled each frame: pointer_x(), pointer_y(), \
pointer_down() (1 while pressed). State across frames (no globals \
in rustlite): state_get(slot)/state_set(slot,value), 64 int slots. \
Colors 0xRRGGBB (white = 16777215). Font covers 0-9, A-Z, a-z, \
space, and common punctuation (! ? , : ; ' \" . - + / = etc.). \
You CAN build real interactive apps now — a \
clickable button is a fill_rect + label, hit-tested against \
pointer_down() + pointer position, with state in the slots. \
Use this whenever the user asks for something visual or an app. \
Each run is auto-saved to `cartridge.rl` (visible in files, \
survives reload). To make a cartridge this subdomain's PERMANENT \
app (boots fullscreen on every page load, no IDE chrome), save \
the same source to a file named `app.rl` via create_file.\n\
• render_html(source) — render an HTML document onto the VISUAL \
DISPLAY. The display CAN show HTML: this lays out block-level \
text (h1-h6, p, ul/li, blockquote, br) word-wrapped in the \
bitmap font, monochrome. It is a snapshot — no JavaScript, no \
CSS, no images (headings just render bigger). For interactive or \
animated apps use run_cartridge. Pair with create_file to also \
save the HTML as `index.html`. (Opening an .html file from the \
files panel renders it here too.)\n\
• submit_feedback(text) — submit feedback on-chain via the \
FeedbackFacet. Emits a FeedbackSubmitted event on the registry \
diamond. Use when the user asks to leave feedback or to report \
issues about another agent. Keep it SHORT — a few sentences, \
under ~2000 bytes. Summarize; do NOT paste long multi-paragraph \
reports. Text over 2048 bytes is rejected before it reaches the \
chain.\n\
• generate_image(prompt) — produce an image from a text prompt.\n\
• configure_agent(system_prompt?, tools?, reset?) — read or change \
YOUR OWN config (custom system prompt + tool allowlist), stored in \
`agent.json`. Use this when the user asks you to change your \
personality/role/instructions or restrict your tools. Changes \
apply on your NEXT session. finish/ask_question/configure_agent \
can never be disabled.\n\
• finish(result?) — signal that the task is complete.\n\n\
\
=== Conventions ===\n\
• Files at the OPFS root are the user's. These internal files are \
managed by the platform — read only if asked, NEVER write or delete: \
`.lh_history.json` (conversation history — this is what 'clear \
history' targets), `.lh_api_key`, `.lh_owner`, `.lh_feedback.txt`, \
and `agent.json` (your config — change it via configure_agent, not \
by editing the file).\n\
• Keep responses concise and conversational. The user is on the same \
page; they don't need you restating what you just did.\n\
• Don't speculate about filesystem contents — call list_directory first \
when you actually need to know.\n\
• Don't blindly call tools when the user is just chatting. \"hi\" / \
\"what can you do?\" don't need a tool call.\n\
• When you do call a tool, the call AND its result are visible to the \
user in the transcript — no need to re-narrate either."
);
let system_instructions = match super::system_prompt::load().await {
Some(custom) => {
format!("{system_instructions}\n\n=== Owner instructions ===\n{custom}")
}
None => system_instructions,
};
let capabilities = match super::tool_allowlist::load().await {
Some(mut tools) => {
for golden in super::tool_allowlist::GOLDEN {
if !tools.contains(golden) {
tools.push(*golden);
}
}
let mut caps = CapabilitiesConfig::unrestricted();
caps.enabled_tools = Some(tools);
caps
}
None => CapabilitiesConfig::unrestricted(),
};
let captured_key = key.to_string();
let mut cfg = GeminiAgentConfig::new(key.to_string())
.with_capabilities(capabilities)
.with_policies(vec![policy::allow_all()])
.with_filesystem(super::shared_opfs())
.with_system_instructions(system_instructions)
.with_tool(create_subdomain_tool())
.with_tool(submit_feedback_tool())
.with_tool(spawn_recursive_subagent_tool(captured_key));
if let Some(bytes) = super::history::take_pending() {
cfg = cfg.with_history_bytes(bytes);
}
let agent = 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));
app.session_key = Some(key.to_string());
app.turn_count = 0;
});
Ok(())
}
fn mark_turn_done(turn_id: u32) {
let id = format!("turn-{turn_id}");
if let Some(el) = dom::by_id(&id) {
let cls = el.class_name();
let new_cls: Vec<&str> =
cls.split_whitespace().filter(|c| *c != "streaming").collect();
el.set_class_name(&new_cls.join(" "));
}
}
async fn collect_payment_if_required() -> Result<Option<String>, String> {
use super::VerifyState;
let (price_wei, verify_state, tba) = APP.with(|cell| {
let app = cell.borrow();
(
app.pricing_wei.unwrap_or(0),
app.verify_state.clone(),
app.tba_address.clone(),
)
});
if price_wei == 0 {
return Ok(None);
}
let Some(tba) = tba else {
return Err("agent is priced but its TBA isn't known yet (verification still running?)".into());
};
let visitor_address = match verify_state {
VerifyState::Verified { .. } => return Ok(None), VerifyState::Visitor { visitor_address, .. } => visitor_address,
VerifyState::Pending | VerifyState::Unregistered | VerifyState::Failed { .. } => {
return Err(
"agent is priced but owner verification didn't complete — refresh and retry"
.into(),
);
}
};
let purpose = format!(
"pay {} LH per turn to this agent",
price_wei / 1_000_000_000_000_000_000u128,
);
let tba_bytes = parse_address(&tba)?;
let mut tba_padded = [0u8; 32];
tba_padded[12..].copy_from_slice(&tba_bytes);
let amount_bytes = u256_be(price_wei);
let selector = transfer_selector();
let mut calldata = Vec::with_capacity(4 + 32 + 32);
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(&tba_padded);
calldata.extend_from_slice(&amount_bytes);
let token_addr = parse_address(crate::registry::LOCALHARNESS_TOKEN_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: calldata,
};
dom::set_status("payment: signing via apex…", false);
let tx_hash = super::events::run_sponsored_tempo_call(
&visitor_address,
vec![call],
500_000,
&purpose,
)
.await
.map_err(|e| format!("payment: {e}"))?;
Ok(Some(tx_hash))
}
async fn read_api_key() -> Option<String> {
if let Some(input) = dom::input_by_id("key") {
let v = input.value().trim().to_string();
if !v.is_empty() {
return Some(v);
}
}
if let Ok(Some(storage)) = dom::session_storage() {
if let Ok(Some(cached)) = storage.get_item("gemini_api_key") {
let trimmed = cached.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
if let Some(persisted) = super::key_store::load().await {
let trimmed = persisted.trim().to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
fn parse_address(hex: &str) -> Result<[u8; 20], String> {
let trimmed = hex.trim().trim_start_matches("0x").trim_start_matches("0X");
if trimmed.len() != 40 {
return Err(format!("address must be 20 bytes hex, got {}", trimmed.len()));
}
let mut out = [0u8; 20];
let bytes = trimmed.as_bytes();
for i in 0..20 {
let hi = hex_nibble(bytes[i * 2])?;
let lo = hex_nibble(bytes[i * 2 + 1])?;
out[i] = (hi << 4) | lo;
}
Ok(out)
}
fn hex_nibble(b: u8) -> Result<u8, String> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err(format!("non-hex byte {b}")),
}
}
fn u256_be(value: u128) -> [u8; 32] {
let mut out = [0u8; 32];
out[16..].copy_from_slice(&value.to_be_bytes());
out
}
fn transfer_selector() -> [u8; 4] {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(b"transfer(address,uint256)");
let mut out = [0u8; 4];
out.copy_from_slice(&hasher.finalize()[..4]);
out
}
fn short_hash(hash: &str) -> String {
let stripped = hash.trim_start_matches("0x");
if stripped.len() < 12 {
return hash.to_string();
}
format!("0x{}…{}", &stripped[..6], &stripped[stripped.len() - 4..])
}
fn create_subdomain_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Subdomain to register, e.g. \"alice\" \
becomes alice.localharness.xyz. 3-32 chars; lowercase \
letters, digits, and hyphens only."
}
},
"required": ["name"]
});
ClosureTool::new(
"create_subdomain",
"Register a new <name>.localharness.xyz subdomain on-chain. The owner's master \
wallet pays gas and ends up holding the resulting ERC-721 NFT. Returns the tx hash.",
schema,
|args: serde_json::Value, _ctx| async move {
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("").trim();
let cleaned = super::tenant::sanitize(name);
if cleaned.len() < 3 || cleaned.len() > 32 {
return Err(crate::error::Error::other("invalid name"));
}
match super::verify::claim_name_via_iframe(&cleaned).await {
Ok((owner, tx_hash)) => Ok(serde_json::json!({
"name": cleaned,
"url": format!("https://{cleaned}.localharness.xyz/"),
"owner": owner,
"tx_hash": tx_hash,
})),
Err(e) => Err(crate::error::Error::other(format!("claim failed: {e}"))),
}
},
)
}
fn submit_feedback_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = serde_json::json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Feedback text to submit on-chain. Keep it short — a \
few sentences, under ~2000 bytes. Summarize rather than pasting a \
long multi-paragraph report. Hard cap is 2048 bytes; longer text \
is rejected before the on-chain tx."
}
},
"required": ["text"]
});
ClosureTool::new(
"submit_feedback",
"Submit feedback on-chain via the FeedbackFacet on the localharness registry. \
Emits a FeedbackSubmitted event. Use this when the user asks to leave feedback \
or when you want to report an issue about another agent.",
schema,
|args: serde_json::Value, _ctx| async move {
let text = args.get("text").and_then(|v| v.as_str()).unwrap_or("").trim();
if text.is_empty() {
return Err(crate::error::Error::other("feedback text cannot be empty"));
}
if text.len() > 2048 {
return Err(crate::error::Error::other(format!(
"feedback too long: {} bytes (max 2048) — please shorten",
text.len()
)));
}
let from_hex = super::APP.with(|cell| {
use super::VerifyState;
match &cell.borrow().verify_state {
VerifyState::Verified { address } => Some(address.clone()),
VerifyState::Visitor { visitor_address, .. } => Some(visitor_address.clone()),
_ => cell.borrow().wallet.as_ref().map(|w| w.address_hex()),
}
});
let from_hex = from_hex.ok_or_else(|| {
crate::error::Error::other("no identity — claim a subdomain first")
})?;
match super::events::submit_feedback_onchain(&from_hex, text).await {
Ok(tx_hash) => Ok(serde_json::json!({
"status": "submitted",
"tx_hash": tx_hash,
})),
Err(e) => Err(crate::error::Error::other(format!("feedback failed: {e}"))),
}
},
)
}
fn spawn_recursive_subagent_tool(api_key: String) -> std::sync::Arc<dyn crate::tools::Tool> {
let schema = serde_json::json!({
"type": "object",
"properties": {
"system_instructions": {
"type": "string",
"description": "System prompt for the subagent — describes its persona, \
scope, and any constraints. Often \"you are a focused worker \
that does X and returns just the result\"."
},
"prompt": {
"type": "string",
"description": "The user message to send to the subagent."
}
},
"required": ["system_instructions", "prompt"]
});
ClosureTool::new(
"spawn_recursive_subagent",
"Spawn a subagent with the SAME tool surface as you (filesystem, \
create_subdomain, start_subagent, spawn_recursive_subagent itself). \
The subagent has its own conversation context — it cannot see your \
history. Drives the subagent through one full conversation turn (which \
may itself involve internal tool calls) and returns the subagent's final \
text response.",
schema,
move |args: serde_json::Value, _ctx| {
let api_key = api_key.clone();
async move {
let system = args
.get("system_instructions")
.and_then(|v| v.as_str())
.unwrap_or("");
let prompt = args.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
if prompt.is_empty() {
return Err(crate::error::Error::other(
"spawn_recursive_subagent: prompt cannot be empty",
));
}
let cfg = GeminiAgentConfig::new(api_key.clone())
.with_capabilities(CapabilitiesConfig::unrestricted())
.with_policies(vec![policy::allow_all()])
.with_filesystem(super::shared_opfs())
.with_system_instructions(system.to_string())
.with_tool(create_subdomain_tool())
.with_tool(spawn_recursive_subagent_tool(api_key.clone()));
let sub = Agent::start_gemini(cfg)
.await
.map_err(|e| crate::error::Error::other(format!("start_gemini: {e}")))?;
let response = sub
.chat(prompt.to_string())
.await
.map_err(|e| crate::error::Error::other(format!("subagent chat: {e}")))?;
let mut cursor = response.chunks();
let mut text = String::new();
while let Some(item) = cursor.next().await {
match item {
Ok(StreamChunk::Text { text: t, .. }) => text.push_str(&t),
Ok(_) => {} Err(e) => {
return Err(crate::error::Error::other(format!(
"subagent chunk: {e}"
)))
}
}
}
Ok(serde_json::json!({ "final_response": text }))
}
},
)
}