#[macro_export]
macro_rules! tool_params {
(@ty req_str) => { ::std::string::String };
(@ty opt_str) => { ::core::option::Option<::std::string::String> };
(@ty req_u32) => { u32 };
(@ty req_u64) => { ::core::option::Option<u64> };
(@ty opt_u32) => { ::core::option::Option<u32> };
(@ty opt_bool) => { ::core::option::Option<bool> };
(@json_ty req_str) => { "string" };
(@json_ty opt_str) => { "string" };
(@json_ty req_u32) => { "integer" };
(@json_ty req_u64) => { "integer" };
(@json_ty opt_u32) => { "integer" };
(@json_ty opt_bool) => { "boolean" };
(@required req_str) => { true };
(@required req_u32) => { true };
(@required req_u64) => { true };
(@required opt_str) => { false };
(@required opt_u32) => { false };
(@required opt_bool) => { false };
(@lenient req_str, $args:expr, $name:expr) => {
$args.get($name).and_then(|v| v.as_str()).unwrap_or("").to_string()
};
(@lenient opt_str, $args:expr, $name:expr) => {
$args.get($name).and_then(|v| v.as_str()).map(|s| s.to_string())
};
(@lenient req_u32, $args:expr, $name:expr) => {
$args
.get($name)
.and_then(|v| v.as_u64())
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0)
};
(@lenient req_u64, $args:expr, $name:expr) => {
$args.get($name).and_then(|v| v.as_u64())
};
(@lenient opt_u32, $args:expr, $name:expr) => {
$args
.get($name)
.and_then(|v| v.as_u64())
.and_then(|n| u32::try_from(n).ok())
};
(@lenient opt_bool, $args:expr, $name:expr) => {
$args.get($name).and_then(|v| v.as_bool())
};
(@req_accessor $vis:vis, req_u64, $field:ident) => {
$vis fn $field(&self) -> ::core::result::Result<u64, $crate::error::Error> {
self.$field.ok_or_else(|| {
$crate::error::Error::other(concat!(stringify!($field), " is required"))
})
}
};
(@req_accessor $vis:vis, $other:ident, $field:ident) => {};
(@schema $vis:vis, $( $field:ident : $kind:ident $(min $min:literal)? = $desc:literal ),+) => {
$vis fn schema() -> $crate::__private::serde_json::Value {
use $crate::__private::serde_json::{Map, Value};
let mut props = Map::new();
$(
let mut f = Map::new();
f.insert(
"type".to_string(),
Value::String($crate::tool_params!(@json_ty $kind).to_string()),
);
$( f.insert("minimum".to_string(), Value::from($min)); )?
f.insert("description".to_string(), Value::String($desc.to_string()));
props.insert(stringify!($field).to_string(), Value::Object(f));
)+
let flags: &[(&str, bool)] =
&[ $( (stringify!($field), $crate::tool_params!(@required $kind)) ),+ ];
let required: ::std::vec::Vec<Value> = flags
.iter()
.filter(|(_, req)| *req)
.map(|(name, _)| Value::String((*name).to_string()))
.collect();
let mut root = Map::new();
root.insert("type".to_string(), Value::String("object".to_string()));
root.insert("properties".to_string(), Value::Object(props));
if !required.is_empty() {
root.insert("required".to_string(), Value::Array(required));
}
Value::Object(root)
}
};
(
$(#[$meta:meta])*
$vis:vis struct $name:ident : serde {
$( $field:ident : $kind:ident $(min $min:literal)? = $desc:literal ),+ $(,)?
}
) => {
$(#[$meta])*
#[derive(::serde::Deserialize)]
$vis struct $name {
$( #[doc = $desc] $vis $field: $crate::tool_params!(@ty $kind), )+
}
impl $name {
$crate::tool_params!(@schema $vis, $( $field : $kind $(min $min)? = $desc ),+);
$( $crate::tool_params!(@req_accessor $vis, $kind, $field); )+
}
};
(
$(#[$meta:meta])*
$vis:vis struct $name:ident : lenient {
$( $field:ident : $kind:ident $(min $min:literal)? = $desc:literal ),+ $(,)?
}
) => {
$(#[$meta])*
$vis struct $name {
$( #[doc = $desc] $vis $field: $crate::tool_params!(@ty $kind), )+
}
impl $name {
$crate::tool_params!(@schema $vis, $( $field : $kind $(min $min)? = $desc ),+);
$( $crate::tool_params!(@req_accessor $vis, $kind, $field); )+
$vis fn lenient(args: &$crate::__private::serde_json::Value) -> Self {
Self {
$( $field: $crate::tool_params!(@lenient $kind, args, stringify!($field)), )+
}
}
}
};
}
crate::tool_params! {
pub struct SendLhParams: lenient {
recipient: req_str = "Who receives the $LH: either a raw 0x… 20-byte \
address, or a subdomain name like \"alice\" (the funds go to \
that subdomain's on-chain OWNER address).",
amount: req_str = "Amount of $LH to send, as a decimal string \
(e.g. \"5\", \"1.5\", \"0.01\"). Must be greater than 0.",
confirmation: opt_str = "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code shown to the owner. Relay \
it, wait for the owner to TYPE the code in chat, then retry with it. \
Never invent it; only the platform issues it.",
}
}
crate::tool_params! {
pub struct CreateSubdomainParams: lenient {
name: req_str = "Subdomain to register, e.g. \"alice\" becomes \
alice.localharness.xyz. 3-32 chars; lowercase letters, digits, \
and hyphens only.",
persona: opt_str = "OPTIONAL system instruction / persona for the new \
agent — published on-chain as its system prompt (the persona \
that headless `call`s and the public face read). Omit to leave \
the default.",
prefund_lh: opt_str = "OPTIONAL amount of $LH to prefund the new agent with, \
as a decimal string (\"5\", \"1.5\"). Transferred from YOUR \
wallet to the new subdomain's token-bound account (its own \
spendable wallet — used to pay other agents via x402). Omit, or \
pass \"0\", to skip. Must not exceed your $LH balance.",
}
}
crate::tool_params! {
pub struct CreateAndPublishAppParams: lenient {
name: req_str = "Subdomain to register, e.g. \"clock\" becomes \
clock.localharness.xyz. 3-32 chars; lowercase letters, digits, \
and hyphens only.",
source: req_str = "rustlite cartridge source — the SAME dialect as \
run_cartridge. Exports `fn frame(t: i32)` (animated) or \
`fn render()` and draws via `use host::display;`. This becomes \
the subdomain's fullscreen public face.",
persona: opt_str = "OPTIONAL system instruction / persona for the new \
agent — published on-chain as its system prompt (read by \
headless `call`s). Omit to leave the default.",
prefund_lh: opt_str = "OPTIONAL amount of $LH to prefund the new agent with, \
as a decimal string (\"5\", \"1.5\"). Transferred from YOUR \
wallet to the new subdomain's token-bound account (its own \
spendable wallet). Omit, or pass \"0\", to skip. Must not exceed \
your $LH balance.",
}
}
crate::tool_params! {
pub struct PublishAppToParams: lenient {
name: req_str = "The subdomain to publish to — MUST be one you already \
own (e.g. \"clock\" → clock.localharness.xyz). Can be different from \
the subdomain you are currently on. To create a NEW subdomain, use \
create_and_publish_app instead.",
source: req_str = "rustlite cartridge source — the SAME dialect as \
run_cartridge / create_and_publish_app. Exports `fn frame(t: i32)` \
(animated) or `fn render()` and draws via `use host::display;`. \
Becomes the target subdomain's fullscreen public face.",
confirmation: opt_str = "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code shown to the owner. State \
which subdomain you will update, ask the owner to TYPE the code in \
chat, then retry with it. Never invent it; only the platform issues it.",
}
}
crate::tool_params! {
pub struct EmbedAppParams: lenient {
name: req_str = "Subdomain whose published cartridge to embed, \
e.g. \"pong\" embeds pong.localharness.xyz's app inline.",
}
}
crate::tool_params! {
pub struct PublishPublicFaceParams: lenient {
choice: req_str = "Which face to publish: \"app\" (compile + publish \
this device's local app.rl as a fullscreen cartridge), \
\"html\" (publish local index.html), or \"directory\" (a \
profile landing listing your sibling agents).",
}
}
crate::tool_params! {
pub struct ReleaseSubdomainParams: lenient {
name: req_str = "Subdomain to release/recycle — burns the NFT, frees the name.",
confirmation: opt_str = "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code that is shown to the owner. \
Relay it, wait for the owner to TYPE that code in chat, then retry \
with the code here. Never invent it; only the platform issues it.",
}
}
crate::tool_params! {
pub struct DiscoverAgentsParams: lenient {
query: req_str = "What to look for — capabilities, topics, or \
keywords matched (case-insensitively) against agent names \
and personas. Several keywords are ORed and ranked by \
overlap (e.g. \"solidity audit security\"). \
Empty returns recent agents.",
}
}
crate::tool_params! {
pub struct QueryBalanceParams: lenient {
target: req_str = "an agent NAME (e.g. \"binglescan\") or a 0x address",
}
}
crate::tool_params! {
pub struct PostBountyParams: lenient {
task: req_str = "The task to be done — a clear, self-contained \
description of what a claimant must deliver to earn the reward.",
reward_lh: req_str = "Reward in $LH, as a decimal string (\"5\", \"1.5\"). \
Escrowed from YOUR wallet when the bounty is posted; paid out to \
the claimant when you accept their result. Must be > 0.",
ttl_hours: opt_str = "OPTIONAL lifetime in hours before the bounty expires \
(decimal). Omit for the 24h default.",
}
}
crate::tool_params! {
pub struct SetPersonaParams: lenient {
text: req_str = "The new system instruction / persona for YOURSELF — \
your role, personality, and constraints. This becomes both your \
on-chain published persona AND your local custom system prompt; it \
takes effect on your next session. Keep it focused.",
}
}
crate::tool_params! {
pub struct RecordLessonParams: lenient {
lesson: req_str = "ONE short lesson (a single sentence, max 240 chars) \
learned from a REAL error, failed tool call, or user correction. \
Make it concrete and actionable (what to do differently next \
time), not a description of what happened.",
}
}
crate::tool_params! {
pub struct NotifyParams: lenient {
title: req_str = "Short notification title, e.g. \"timer done\" or \
\"new message from dex\".",
body: opt_str = "Optional body text shown under the title. Keep it \
to a sentence.",
vibrate: opt_bool = "Also vibrate the device (mobile only; silently \
ignored where unsupported).",
to: opt_str = "CROSS-AGENT: deliver to ANOTHER agent's \
notification inbox instead of this device — the target \
subdomain name, e.g. \"krafto\". Routed via the platform \
proxy (costs the per-request $LH like a model call); the \
push title is stamped with YOUR identity so the recipient \
sees who pinged them. Omit for a local notification on \
this device.",
}
}
crate::tool_params! {
pub struct ClaimBountyParams: lenient {
bounty_id: req_u64 min 0 = "The id of the open bounty to claim (from \
discover_bounties / the bounty board).",
}
}
crate::tool_params! {
pub struct SubmitResultParams: lenient {
bounty_id: req_u64 min 0 = "The id of the bounty you previously claimed.",
result: req_str = "Your deliverable / result for the bounty — the work \
product the poster will review before accepting + paying out.",
}
}
crate::tool_params! {
pub struct AcceptResultParams: lenient {
bounty_id: req_u64 min 0 = "The id of a bounty YOU posted whose submitted result \
you want to accept (releases the escrowed $LH to the claimant).",
}
}
crate::tool_params! {
pub struct CreateGuildParams: lenient {
name: req_str = "Display name for the guild (a short label for the org).",
}
}
crate::tool_params! {
pub struct InviteToGuildParams: lenient {
guild_id: req_u64 min 0 = "The id of the guild you administer.",
member: req_str = "Who to invite — a raw 0x… address OR a subdomain name \
(resolved to that name's on-chain owner).",
}
}
crate::tool_params! {
pub struct FundGuildParams: lenient {
guild_id: req_u64 min 0 = "The id of the guild to fund.",
amount_lh: req_str = "Amount of $LH to contribute, as a decimal string \
(\"5\", \"1.5\"). Pulled from YOUR wallet into the shared treasury. \
Must be > 0.",
}
}
crate::tool_params! {
pub struct SpendTreasuryParams: lenient {
guild_id: req_u64 min 0 = "The id of the guild whose treasury to spend from.",
to: req_str = "Recipient — a raw 0x… address OR a subdomain name \
(resolved to that name's on-chain owner).",
amount_lh: req_str = "Amount of $LH to pay out, as a decimal string. Must be > 0.",
memo: opt_str = "OPTIONAL note recorded with the payment (what it's for).",
confirmation: opt_str = "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code shown to the owner. Relay \
it, wait for the owner to TYPE the code in chat, then retry with it. \
Never invent it; only the platform issues it.",
}
}
crate::tool_params! {
pub struct ProposeMeasureParams: lenient {
guild_id: req_u64 min 0 = "The id of the guild whose treasury the proposal would spend from.",
to: req_str = "Spend recipient if the proposal passes — a raw 0x… \
address OR a subdomain name (resolved to that name's on-chain owner).",
amount_lh: req_str = "Amount of $LH the proposal would pay out from the \
treasury, as a decimal string (\"5\", \"1.5\"). Must be > 0.",
memo: opt_str = "OPTIONAL description of what the spend is for — recorded \
on-chain so voters know what they're approving.",
period_hours: opt_str = "OPTIONAL voting window in hours (decimal). Omit for the \
48h default. Members can vote until the deadline; only then can a \
passing proposal be executed.",
}
}
crate::tool_params! {
pub struct ExecuteProposalParams: lenient {
proposal_id: req_u64 min 0 = "The id of a passed proposal whose voting deadline has \
elapsed (executing it pays out the treasury spend).",
}
}
crate::tool_params! {
pub struct ListProposalsParams: lenient {
guild_id: req_u64 min 0 = "The id of the guild whose proposals to list.",
}
}
crate::tool_params! {
pub struct WebFetchParams: lenient {
url: req_str = "Absolute https: GitHub README (use raw.githubusercontent.com for raw \
content), or JSON API endpoint. http: hosts, and raw-IP targets are rejected.",
}
}
crate::tool_params! {
pub struct SubmitFeedbackParams: lenient {
text: req_str = "The feedback text. Filed off-chain with full \
conversation + device/settings context. (If the owner enabled \
on-chain mirroring, the SHORT note is also written on-chain, where \
a 2048-byte cap applies — summarize rather than pasting a long report.)",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Value};
crate::tool_params! {
struct AllKinds: serde {
name: req_str = "A required string.",
note: opt_str = "An optional string.",
count: req_u32 min 1 = "A required integer.",
limit: opt_u32 min 2 = "An optional integer.",
flag: opt_bool = "An optional boolean.",
}
}
crate::tool_params! {
struct AllKindsLenient: lenient {
name: req_str = "A required string.",
note: opt_str = "An optional string.",
count: req_u32 min 1 = "A required integer.",
limit: opt_u32 min 2 = "An optional integer.",
flag: opt_bool = "An optional boolean.",
}
}
fn assert_gemini_safe(v: &Value, path: &str) {
const FORBIDDEN: [&str; 6] =
["oneOf", "anyOf", "allOf", "additionalProperties", "$ref", "$schema"];
match v {
Value::Object(map) => {
if let Some(t) = map.get("type") {
assert!(!t.is_array(), "array-valued type at {path}: {t}");
}
for k in FORBIDDEN {
assert!(!map.contains_key(k), "forbidden key `{k}` at {path}");
}
for (k, val) in map {
assert_gemini_safe(val, &format!("{path}.{k}"));
}
}
Value::Array(arr) => {
for (i, val) in arr.iter().enumerate() {
assert_gemini_safe(val, &format!("{path}[{i}]"));
}
}
_ => {}
}
}
#[test]
fn generated_schemas_are_gemini_safe() {
for (name, schema) in [
("AllKinds", AllKinds::schema()),
("AllKindsLenient", AllKindsLenient::schema()),
("SendLhParams", SendLhParams::schema()),
("CreateSubdomainParams", CreateSubdomainParams::schema()),
("CreateAndPublishAppParams", CreateAndPublishAppParams::schema()),
("PublishAppToParams", PublishAppToParams::schema()),
("EmbedAppParams", EmbedAppParams::schema()),
("PublishPublicFaceParams", PublishPublicFaceParams::schema()),
("ReleaseSubdomainParams", ReleaseSubdomainParams::schema()),
("DiscoverAgentsParams", DiscoverAgentsParams::schema()),
("QueryBalanceParams", QueryBalanceParams::schema()),
("PostBountyParams", PostBountyParams::schema()),
("SetPersonaParams", SetPersonaParams::schema()),
("RecordLessonParams", RecordLessonParams::schema()),
("NotifyParams", NotifyParams::schema()),
("ClaimBountyParams", ClaimBountyParams::schema()),
("SubmitResultParams", SubmitResultParams::schema()),
("AcceptResultParams", AcceptResultParams::schema()),
("CreateGuildParams", CreateGuildParams::schema()),
("InviteToGuildParams", InviteToGuildParams::schema()),
("FundGuildParams", FundGuildParams::schema()),
("SpendTreasuryParams", SpendTreasuryParams::schema()),
("ProposeMeasureParams", ProposeMeasureParams::schema()),
("ExecuteProposalParams", ExecuteProposalParams::schema()),
("ListProposalsParams", ListProposalsParams::schema()),
("WebFetchParams", WebFetchParams::schema()),
("SubmitFeedbackParams", SubmitFeedbackParams::schema()),
] {
assert_gemini_safe(&schema, name);
}
}
crate::tool_params! {
struct ReqIntLenient: lenient {
id: req_u64 min 0 = "A required integer id.",
note: opt_str = "An optional string.",
}
}
#[test]
fn req_u64_errors_on_missing_or_invalid_instead_of_defaulting() {
let p = ReqIntLenient::lenient(&json!({}));
assert_eq!(p.id().unwrap_err().to_string(), "id is required");
assert!(ReqIntLenient::lenient(&json!({"id": "7"})).id().is_err());
assert!(ReqIntLenient::lenient(&json!({"id": true})).id().is_err());
assert!(ReqIntLenient::lenient(&json!({"id": 1.5})).id().is_err());
assert!(ReqIntLenient::lenient(&json!({"id": -1})).id().is_err());
assert_eq!(ReqIntLenient::lenient(&json!({"id": 0})).id().unwrap(), 0);
let p = ReqIntLenient::lenient(&json!({"id": 3, "note": "n"}));
assert_eq!((p.id().unwrap(), p.note.as_deref()), (3, Some("n")));
assert_eq!(
ReqIntLenient::lenient(&json!({"id": u64::MAX})).id().unwrap(),
u64::MAX
);
let s = ReqIntLenient::schema();
assert_eq!(s["properties"]["id"]["type"], "integer");
assert_eq!(s["properties"]["id"]["minimum"], 0);
assert_eq!(s["required"], json!(["id"]));
}
#[test]
fn schema_shape_covers_all_kinds() {
let s = AllKinds::schema();
assert_eq!(s["properties"]["name"]["type"], "string");
assert_eq!(s["properties"]["note"]["type"], "string");
assert_eq!(s["properties"]["count"]["type"], "integer");
assert_eq!(s["properties"]["count"]["minimum"], 1);
assert_eq!(s["properties"]["limit"]["minimum"], 2);
assert_eq!(s["properties"]["flag"]["type"], "boolean");
assert_eq!(s["properties"]["name"]["description"], "A required string.");
assert_eq!(s["required"], json!(["name", "count"]));
assert_eq!(s.to_string(), AllKindsLenient::schema().to_string());
}
crate::tool_params! {
struct AllOptional: serde {
note: opt_str = "An optional string.",
}
}
#[test]
fn required_key_omitted_when_no_field_is_required() {
assert!(AllOptional::schema().get("required").is_none());
let p: AllOptional = serde_json::from_value(json!({"note": "x"})).unwrap();
assert_eq!(p.note.as_deref(), Some("x"));
}
#[test]
fn serde_mode_parse_matches_builtin_semantics() {
let ok: AllKinds =
serde_json::from_value(json!({"name": "x", "count": 3})).unwrap();
assert_eq!(ok.name, "x");
assert_eq!(ok.count, 3);
assert_eq!(ok.note, None);
assert_eq!(ok.limit, None);
assert_eq!(ok.flag, None);
let err = match serde_json::from_value::<AllKinds>(json!({"count": 3})) {
Err(e) => e,
Ok(_) => panic!("missing required `name` must error"),
};
assert!(err.to_string().contains("name"), "{err}");
}
#[test]
fn lenient_mode_matches_historical_chat_tool_semantics() {
let p = AllKindsLenient::lenient(&json!({}));
assert_eq!(p.name, "");
assert_eq!(p.note, None);
assert_eq!(p.count, 0);
assert_eq!(p.limit, None);
assert_eq!(p.flag, None);
let p = AllKindsLenient::lenient(&json!({"name": 5, "count": "x", "flag": 1}));
assert_eq!(p.name, "");
assert_eq!(p.count, 0);
assert_eq!(p.flag, None);
let p = AllKindsLenient::lenient(
&json!({"name": "a", "note": "n", "count": 2, "limit": 7, "flag": true}),
);
assert_eq!((p.name.as_str(), p.count, p.limit, p.flag), ("a", 2, Some(7), Some(true)));
}
#[test]
fn send_lh_schema_is_byte_identical_to_the_frozen_original() {
let frozen = json!({
"type": "object",
"properties": {
"recipient": {
"type": "string",
"description": "Who receives the $LH: either a raw 0x… 20-byte \
address, or a subdomain name like \"alice\" (the funds go to \
that subdomain's on-chain OWNER address)."
},
"amount": {
"type": "string",
"description": "Amount of $LH to send, as a decimal string \
(e.g. \"5\", \"1.5\", \"0.01\"). Must be greater than 0."
},
"confirmation": {
"type": "string",
"description": "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code shown to the owner. Relay \
it, wait for the owner to TYPE the code in chat, then retry with it. \
Never invent it; only the platform issues it."
}
},
"required": ["recipient", "amount"]
});
assert_eq!(SendLhParams::schema().to_string(), frozen.to_string());
}
#[test]
fn send_lh_lenient_matches_the_old_inline_extraction() {
let p = SendLhParams::lenient(&json!({}));
assert_eq!((p.recipient.as_str(), p.amount.as_str(), p.confirmation), ("", "", None));
let p = SendLhParams::lenient(&json!({"recipient": 5, "amount": true}));
assert_eq!((p.recipient.as_str(), p.amount.as_str()), ("", ""));
let p = SendLhParams::lenient(
&json!({"recipient": " alice ", "amount": "1.5", "confirmation": " "}),
);
assert_eq!(p.recipient, " alice "); assert_eq!(p.amount, "1.5");
assert!(!p.confirmation.as_deref().map(|s| !s.trim().is_empty()).unwrap_or(false));
}
#[test]
fn chat_tool_schemas_are_byte_identical_to_the_frozen_originals() {
let cases: [(&str, Value, Value); 12] = [
("create_subdomain", CreateSubdomainParams::schema(), 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."
},
"persona": {
"type": "string",
"description": "OPTIONAL system instruction / persona for the new \
agent — published on-chain as its system prompt (the persona \
that headless `call`s and the public face read). Omit to leave \
the default."
},
"prefund_lh": {
"type": "string",
"description": "OPTIONAL amount of $LH to prefund the new agent with, \
as a decimal string (\"5\", \"1.5\"). Transferred from YOUR \
wallet to the new subdomain's token-bound account (its own \
spendable wallet — used to pay other agents via x402). Omit, or \
pass \"0\", to skip. Must not exceed your $LH balance."
}
},
"required": ["name"]
})),
("create_and_publish_app", CreateAndPublishAppParams::schema(), json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Subdomain to register, e.g. \"clock\" \
becomes clock.localharness.xyz. 3-32 chars; lowercase \
letters, digits, and hyphens only."
},
"source": {
"type": "string",
"description": "rustlite cartridge source — the SAME dialect as \
run_cartridge. Exports `fn frame(t: i32)` (animated) or \
`fn render()` and draws via `use host::display;`. This becomes \
the subdomain's fullscreen public face."
},
"persona": {
"type": "string",
"description": "OPTIONAL system instruction / persona for the new \
agent — published on-chain as its system prompt (read by \
headless `call`s). Omit to leave the default."
},
"prefund_lh": {
"type": "string",
"description": "OPTIONAL amount of $LH to prefund the new agent with, \
as a decimal string (\"5\", \"1.5\"). Transferred from YOUR \
wallet to the new subdomain's token-bound account (its own \
spendable wallet). Omit, or pass \"0\", to skip. Must not exceed \
your $LH balance."
}
},
"required": ["name", "source"]
})),
("publish_app_to", PublishAppToParams::schema(), json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The subdomain to publish to — MUST be one you already \
own (e.g. \"clock\" → clock.localharness.xyz). Can be different from \
the subdomain you are currently on. To create a NEW subdomain, use \
create_and_publish_app instead."
},
"source": {
"type": "string",
"description": "rustlite cartridge source — the SAME dialect as \
run_cartridge / create_and_publish_app. Exports `fn frame(t: i32)` \
(animated) or `fn render()` and draws via `use host::display;`. \
Becomes the target subdomain's fullscreen public face."
},
"confirmation": {
"type": "string",
"description": "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code shown to the owner. State \
which subdomain you will update, ask the owner to TYPE the code in \
chat, then retry with it. Never invent it; only the platform issues it."
}
},
"required": ["name", "source"]
})),
("embed_app", EmbedAppParams::schema(), json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Subdomain whose published cartridge to embed, \
e.g. \"pong\" embeds pong.localharness.xyz's app inline."
}
},
"required": ["name"]
})),
("publish_public_face", PublishPublicFaceParams::schema(), json!({
"type": "object",
"properties": {
"choice": {
"type": "string",
"description": "Which face to publish: \"app\" (compile + publish \
this device's local app.rl as a fullscreen cartridge), \
\"html\" (publish local index.html), or \"directory\" (a \
profile landing listing your sibling agents)."
}
},
"required": ["choice"]
})),
("release_subdomain", ReleaseSubdomainParams::schema(), json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Subdomain to release/recycle — burns the NFT, frees the name."
},
"confirmation": {
"type": "string",
"description": "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code that is shown to the owner. \
Relay it, wait for the owner to TYPE that code in chat, then retry \
with the code here. Never invent it; only the platform issues it."
}
},
"required": ["name"]
})),
("discover_agents", DiscoverAgentsParams::schema(), json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What to look for — capabilities, topics, or \
keywords matched (case-insensitively) against agent names \
and personas. Several keywords are ORed and ranked by \
overlap (e.g. \"solidity audit security\"). \
Empty returns recent agents."
}
},
"required": ["query"]
})),
("query_balance", QueryBalanceParams::schema(), json!({
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "an agent NAME (e.g. \"binglescan\") or a 0x address"
}
},
"required": ["target"]
})),
("post_bounty", PostBountyParams::schema(), json!({
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The task to be done — a clear, self-contained \
description of what a claimant must deliver to earn the reward."
},
"reward_lh": {
"type": "string",
"description": "Reward in $LH, as a decimal string (\"5\", \"1.5\"). \
Escrowed from YOUR wallet when the bounty is posted; paid out to \
the claimant when you accept their result. Must be > 0."
},
"ttl_hours": {
"type": "string",
"description": "OPTIONAL lifetime in hours before the bounty expires \
(decimal). Omit for the 24h default."
}
},
"required": ["task", "reward_lh"]
})),
("set_persona", SetPersonaParams::schema(), json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The new system instruction / persona for YOURSELF — \
your role, personality, and constraints. This becomes both your \
on-chain published persona AND your local custom system prompt; it \
takes effect on your next session. Keep it focused."
}
},
"required": ["text"]
})),
("record_lesson", RecordLessonParams::schema(), json!({
"type": "object",
"properties": {
"lesson": {
"type": "string",
"description": "ONE short lesson (a single sentence, max 240 chars) \
learned from a REAL error, failed tool call, or user correction. \
Make it concrete and actionable (what to do differently next \
time), not a description of what happened."
}
},
"required": ["lesson"]
})),
("notify", NotifyParams::schema(), json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Short notification title, e.g. \"timer done\" or \
\"new message from dex\"."
},
"body": {
"type": "string",
"description": "Optional body text shown under the title. Keep it \
to a sentence."
},
"vibrate": {
"type": "boolean",
"description": "Also vibrate the device (mobile only; silently \
ignored where unsupported)."
},
"to": {
"type": "string",
"description": "CROSS-AGENT: deliver to ANOTHER agent's \
notification inbox instead of this device — the target \
subdomain name, e.g. \"krafto\". Routed via the platform \
proxy (costs the per-request $LH like a model call); the \
push title is stamped with YOUR identity so the recipient \
sees who pinged them. Omit for a local notification on \
this device."
}
},
"required": ["title"]
})),
];
for (name, generated, frozen) in cases {
assert_eq!(generated.to_string(), frozen.to_string(), "schema drift: {name}");
}
}
#[test]
fn chat_tool_lenient_matches_the_old_inline_extraction() {
let p = CreateSubdomainParams::lenient(&json!({"name": 7, "persona": true}));
assert_eq!((p.name.as_str(), p.persona, p.prefund_lh), ("", None, None));
let p = CreateSubdomainParams::lenient(
&json!({"name": " alice ", "persona": " ", "prefund_lh": "0"}),
);
assert_eq!(p.name.trim(), "alice");
assert!(!p.persona.as_deref().map(|s| !s.trim().is_empty()).unwrap_or(false));
let t = p.prefund_lh.as_deref().unwrap().trim();
assert!(t.is_empty() || t == "0");
let p = CreateAndPublishAppParams::lenient(&json!({"name": "x"}));
assert_eq!(p.source, "");
let p = PublishAppToParams::lenient(&json!({"name": "x", "source": "s"}));
assert!(!p.confirmation.as_deref().map(|s| !s.trim().is_empty()).unwrap_or(false));
let p = PublishAppToParams::lenient(&json!({"confirmation": "c0de"}));
assert!(p.confirmation.as_deref().map(|s| !s.trim().is_empty()).unwrap_or(false));
assert_eq!(EmbedAppParams::lenient(&json!({})).name, "");
assert_eq!(PublishPublicFaceParams::lenient(&json!({"choice": 3})).choice, "");
assert_eq!(PublishPublicFaceParams::lenient(&json!({"choice": "APP "})).choice, "APP ");
assert_eq!(DiscoverAgentsParams::lenient(&json!({})).query, "");
assert_eq!(QueryBalanceParams::lenient(&json!({"target": " k "})).target, " k ");
assert_eq!(SetPersonaParams::lenient(&json!({"text": 1})).text, "");
assert_eq!(RecordLessonParams::lenient(&json!({})).lesson, "");
let p = ReleaseSubdomainParams::lenient(&json!({"name": " z "}));
assert_eq!(p.name.trim().to_string(), "z");
assert_eq!(p.confirmation, None);
let p = PostBountyParams::lenient(&json!({"task": " t ", "reward_lh": "1.5"}));
assert_eq!((p.task.trim(), p.reward_lh.trim()), ("t", "1.5"));
assert!(p.ttl_hours.is_none());
let p = PostBountyParams::lenient(&json!({"ttl_hours": " "}));
assert!(!matches!(p.ttl_hours.as_deref(), Some(s) if !s.trim().is_empty()));
let p = NotifyParams::lenient(&json!({"title": "hi", "vibrate": 1, "to": ""}));
assert_eq!(p.body.as_deref().unwrap_or(""), "");
assert!(!p.vibrate.unwrap_or(false));
assert_eq!(
p.to.map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()),
None
);
let p = NotifyParams::lenient(&json!({"to": " Krafto ", "vibrate": true}));
assert_eq!(
p.to.map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()),
Some("krafto".to_string())
);
assert!(p.vibrate.unwrap_or(false));
}
#[test]
fn chat_tool_wave2_schemas_are_byte_identical_to_the_frozen_originals() {
let cases: [(&str, Value, Value); 12] = [
("claim_bounty", ClaimBountyParams::schema(), json!({
"type": "object",
"properties": {
"bounty_id": {
"type": "integer",
"minimum": 0,
"description": "The id of the open bounty to claim (from \
discover_bounties / the bounty board)."
}
},
"required": ["bounty_id"]
})),
("submit_result", SubmitResultParams::schema(), json!({
"type": "object",
"properties": {
"bounty_id": {
"type": "integer",
"minimum": 0,
"description": "The id of the bounty you previously claimed."
},
"result": {
"type": "string",
"description": "Your deliverable / result for the bounty — the work \
product the poster will review before accepting + paying out."
}
},
"required": ["bounty_id", "result"]
})),
("accept_result", AcceptResultParams::schema(), json!({
"type": "object",
"properties": {
"bounty_id": {
"type": "integer",
"minimum": 0,
"description": "The id of a bounty YOU posted whose submitted result \
you want to accept (releases the escrowed $LH to the claimant)."
}
},
"required": ["bounty_id"]
})),
("create_guild", CreateGuildParams::schema(), json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Display name for the guild (a short label for the org)."
}
},
"required": ["name"]
})),
("invite_to_guild", InviteToGuildParams::schema(), json!({
"type": "object",
"properties": {
"guild_id": {
"type": "integer",
"minimum": 0,
"description": "The id of the guild you administer."
},
"member": {
"type": "string",
"description": "Who to invite — a raw 0x… address OR a subdomain name \
(resolved to that name's on-chain owner)."
}
},
"required": ["guild_id", "member"]
})),
("fund_guild", FundGuildParams::schema(), json!({
"type": "object",
"properties": {
"guild_id": {
"type": "integer",
"minimum": 0,
"description": "The id of the guild to fund."
},
"amount_lh": {
"type": "string",
"description": "Amount of $LH to contribute, as a decimal string \
(\"5\", \"1.5\"). Pulled from YOUR wallet into the shared treasury. \
Must be > 0."
}
},
"required": ["guild_id", "amount_lh"]
})),
("spend_treasury", SpendTreasuryParams::schema(), json!({
"type": "object",
"properties": {
"guild_id": {
"type": "integer",
"minimum": 0,
"description": "The id of the guild whose treasury to spend from."
},
"to": {
"type": "string",
"description": "Recipient — a raw 0x… address OR a subdomain name \
(resolved to that name's on-chain owner)."
},
"amount_lh": {
"type": "string",
"description": "Amount of $LH to pay out, as a decimal string. Must be > 0."
},
"memo": {
"type": "string",
"description": "OPTIONAL note recorded with the payment (what it's for)."
},
"confirmation": {
"type": "string",
"description": "Single-use confirmation code. OMIT (or pass \"\") on the \
first call — it returns a challenge code shown to the owner. Relay \
it, wait for the owner to TYPE the code in chat, then retry with it. \
Never invent it; only the platform issues it."
}
},
"required": ["guild_id", "to", "amount_lh"]
})),
("propose_measure", ProposeMeasureParams::schema(), json!({
"type": "object",
"properties": {
"guild_id": {
"type": "integer",
"minimum": 0,
"description": "The id of the guild whose treasury the proposal would spend from."
},
"to": {
"type": "string",
"description": "Spend recipient if the proposal passes — a raw 0x… \
address OR a subdomain name (resolved to that name's on-chain owner)."
},
"amount_lh": {
"type": "string",
"description": "Amount of $LH the proposal would pay out from the \
treasury, as a decimal string (\"5\", \"1.5\"). Must be > 0."
},
"memo": {
"type": "string",
"description": "OPTIONAL description of what the spend is for — recorded \
on-chain so voters know what they're approving."
},
"period_hours": {
"type": "string",
"description": "OPTIONAL voting window in hours (decimal). Omit for the \
48h default. Members can vote until the deadline; only then can a \
passing proposal be executed."
}
},
"required": ["guild_id", "to", "amount_lh"]
})),
("execute_proposal", ExecuteProposalParams::schema(), json!({
"type": "object",
"properties": {
"proposal_id": {
"type": "integer",
"minimum": 0,
"description": "The id of a passed proposal whose voting deadline has \
elapsed (executing it pays out the treasury spend)."
}
},
"required": ["proposal_id"]
})),
("list_proposals", ListProposalsParams::schema(), json!({
"type": "object",
"properties": {
"guild_id": {
"type": "integer",
"minimum": 0,
"description": "The id of the guild whose proposals to list."
}
},
"required": ["guild_id"]
})),
("web_fetch", WebFetchParams::schema(), json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Absolute https:// URL to fetch — a docs page, \
GitHub README (use raw.githubusercontent.com for raw \
content), or JSON API endpoint. http://, private/internal \
hosts, and raw-IP targets are rejected."
}
},
"required": ["url"]
})),
("submit_feedback", SubmitFeedbackParams::schema(), json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The feedback text. Filed off-chain with full \
conversation + device/settings context. (If the owner enabled \
on-chain mirroring, the SHORT note is also written on-chain, where \
a 2048-byte cap applies — summarize rather than pasting a long report.)"
}
},
"required": ["text"]
})),
];
for (name, generated, frozen) in cases {
assert_eq!(generated.to_string(), frozen.to_string(), "schema drift: {name}");
}
}
#[test]
fn chat_tool_wave2_lenient_matches_the_old_inline_extraction() {
let p = ClaimBountyParams::lenient(&json!({}));
assert_eq!(p.bounty_id().unwrap_err().to_string(), "bounty_id is required");
assert_eq!(ClaimBountyParams::lenient(&json!({"bounty_id": 0})).bounty_id().unwrap(), 0);
let p = SubmitResultParams::lenient(&json!({"bounty_id": "3", "result": " r "}));
assert!(p.bounty_id().is_err()); assert_eq!(p.result, " r "); let p = SubmitResultParams::lenient(&json!({"bounty_id": 7}));
assert_eq!((p.bounty_id().unwrap(), p.result.as_str()), (7, ""));
assert_eq!(
AcceptResultParams::lenient(&json!({})).bounty_id().unwrap_err().to_string(),
"bounty_id is required"
);
assert_eq!(CreateGuildParams::lenient(&json!({"name": 9})).name, "");
let p = InviteToGuildParams::lenient(&json!({"member": " Alice "}));
assert_eq!(p.guild_id().unwrap_err().to_string(), "guild_id is required");
assert_eq!(p.member, " Alice "); let p = FundGuildParams::lenient(&json!({"guild_id": 2, "amount_lh": " 1.5 "}));
assert_eq!((p.guild_id().unwrap(), p.amount_lh.trim()), (2, "1.5"));
let p = SpendTreasuryParams::lenient(&json!({"guild_id": 1, "to": "bob", "amount_lh": "2"}));
assert_eq!(p.memo.as_deref().unwrap_or(""), "");
assert!(!p.confirmation.as_deref().map(|s| !s.trim().is_empty()).unwrap_or(false));
let p = ProposeMeasureParams::lenient(&json!({"guild_id": 4, "period_hours": " "}));
assert_eq!(p.guild_id().unwrap(), 4);
assert!(!matches!(p.period_hours.as_deref(), Some(s) if !s.trim().is_empty()));
assert_eq!(
ExecuteProposalParams::lenient(&json!({"proposal_id": true}))
.proposal_id()
.unwrap_err()
.to_string(),
"proposal_id is required"
);
assert_eq!(ListProposalsParams::lenient(&json!({"guild_id": 11})).guild_id().unwrap(), 11);
assert_eq!(WebFetchParams::lenient(&json!({})).url, "");
assert_eq!(WebFetchParams::lenient(&json!({"url": " https://x "})).url.trim(), "https://x");
assert_eq!(SubmitFeedbackParams::lenient(&json!({"text": 1})).text, "");
assert_eq!(SubmitFeedbackParams::lenient(&json!({"text": " ok "})).text.trim(), "ok");
}
}