localharness 0.60.18

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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
// =============================================================================
// Bounty economy tools — the in-tab agent participates in the on-chain bounty
// market (BountyFacet) via the SAME sponsored path as send_lh / create_subdomain
// (owner authority: the owner's apex wallet signs, the bundle sponsor pays gas).
// The registry helpers (post_bounty_sponsored, claim_bounty_sponsored,
// submit_result_sponsored, accept_result_sponsored, open_bounties, get_bounty,
// task_of_bounty, discover_bounties) are reused — never re-encoded here.
// =============================================================================

use crate::app::chat::access::{credit_address_existing, credit_signer};
use crate::tools::ClosureTool;

use super::guild::{format_lh, own_token_id};

/// Resolve the (signer, fee_payer) pair for a sponsored bounty write: the
/// owner's local credit key signs the sender_hash, the embedded sponsor pays
/// the fee in AlphaUSD. Mirrors `events::schedule_job_pressed`'s acquisition.
pub(crate) async fn bounty_signers(
) -> Result<(k256::ecdsa::SigningKey, k256::ecdsa::SigningKey), crate::error::Error> {
    let (signer, _) = credit_signer()
        .await
        .ok_or_else(|| crate::error::Error::other("no identity — claim a subdomain first"))?;
    let fee_payer = crate::app::sponsor::signer().map_err(crate::error::Error::other)?;
    Ok((signer, fee_payer))
}

/// `post_bounty(task, reward_lh, ttl_hours?)` — escrow `$LH` behind an on-chain
/// task other agents can claim + fulfil. Reward is a decimal `$LH` figure;
/// `ttl_hours` defaults to 24h. Reuses `registry::post_bounty_sponsored`.
pub(crate) fn post_bounty_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
    let schema = serde_json::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"]
    });
    ClosureTool::new(
        "post_bounty",
        "Post a bounty to the on-chain bounty market: escrow `reward_lh` $LH behind a \
         `task` other agents can discover, claim, and fulfil. Use this to delegate a \
         task to the agent economy when you want it done by whoever can. Escrows from \
         your wallet (sponsored tx); pays out only when you accept a submitted result. \
         Returns { bounty_id, task, reward_lh, ttl_hours, tx_hash }.",
        schema,
        |args: serde_json::Value, _ctx| async move {
            let task = args.get("task").and_then(|v| v.as_str()).unwrap_or("").trim();
            if task.is_empty() {
                return Err(crate::error::Error::other("task cannot be empty"));
            }
            let reward_arg = args
                .get("reward_lh")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .trim()
                .to_string();
            let reward_wei = crate::encoding::parse_token_amount(&reward_arg).ok_or_else(|| {
                crate::error::Error::other(format!(
                    "could not parse reward_lh \"{reward_arg}\" — pass a decimal $LH \
                     figure like \"5\" or \"1.5\""
                ))
            })?;
            if reward_wei == 0 {
                return Err(crate::error::Error::other("reward_lh must be greater than 0"));
            }
            // TTL: hours → seconds. Default 24h.
            let ttl_hours: f64 = match args.get("ttl_hours").and_then(|v| v.as_str()) {
                Some(s) if !s.trim().is_empty() => s.trim().parse::<f64>().map_err(|_| {
                    crate::error::Error::other("ttl_hours must be a number")
                })?,
                _ => 24.0,
            };
            if ttl_hours <= 0.0 {
                return Err(crate::error::Error::other("ttl_hours must be greater than 0"));
            }
            let ttl_secs = (ttl_hours * 3600.0) as u64;
            let (signer, fee_payer) = bounty_signers().await?;
            // Escrow auto-bridge (feedback #63): a wallet shortfall covered by
            // unspent chat-meter credits rides as a withdrawCredits call in the
            // SAME atomic tx as approve+postBounty. Pot-aware error when both
            // pots together are short.
            let from_hex =
                crate::encoding::bytes_to_hex_str(&crate::wallet::address(&signer));
            let bridge_wei = crate::app::chat::escrow_bridge_wei(&from_hex, reward_wei)
                .await
                .map_err(crate::error::Error::other)?;
            let tx_hash = crate::app::registry::post_bounty_sponsored_bridged(
                &signer,
                &fee_payer,
                task.as_bytes(),
                reward_wei,
                ttl_secs,
                crate::app::registry::ALPHA_USD_ADDRESS(),
                bridge_wei,
            )
            .await
            .map_err(|e| crate::error::Error::other(format!("post_bounty failed: {e}")))?;
            // Newest bounty id = caller's last entry in bounties_of (best-effort).
            let bounty_id = match credit_address_existing().await {
                Some(addr) => crate::app::registry::bounties_of(&addr)
                    .await
                    .ok()
                    .and_then(|ids| ids.last().copied()),
                None => None,
            };
            let mut result = serde_json::json!({
                "task": task,
                "reward_lh": reward_arg,
                "ttl_hours": ttl_hours,
                "tx_hash": tx_hash,
            });
            if let Some(id) = bounty_id {
                result["bounty_id"] = serde_json::json!(id);
            }
            Ok(result)
        },
    )
}

/// `claim_bounty(bounty_id)` — claim an open bounty as THIS agent (its on-chain
/// tokenId is resolved automatically as the claimant). Reuses
/// `registry::claim_bounty_sponsored`.
pub(crate) fn claim_bounty_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
    let schema = serde_json::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"]
    });
    ClosureTool::new(
        "claim_bounty",
        "Claim an open bounty to work on it. THIS agent becomes the claimant (its \
         on-chain tokenId is resolved automatically). After claiming, do the work and \
         call submit_result with your deliverable. Returns { bounty_id, claimant_token_id, \
         tx_hash }.",
        schema,
        |args: serde_json::Value, _ctx| async move {
            let bounty_id = args
                .get("bounty_id")
                .and_then(|v| v.as_u64())
                .ok_or_else(|| crate::error::Error::other("bounty_id is required"))?;
            // The claimant is THIS subdomain's own tokenId.
            let claimant_token_id = own_token_id().await?;
            // Surface the SPECIFIC cause (already claimed / doesn't exist / not
            // open) instead of a generic revert (#50) — shared with the CLI.
            if let Err(why) = crate::app::registry::bounty_preflight_check(bounty_id, "claim").await {
                return Err(crate::error::Error::other(why));
            }
            let (signer, fee_payer) = bounty_signers().await?;
            let tx_hash = crate::app::registry::claim_bounty_sponsored(
                &signer,
                &fee_payer,
                bounty_id,
                claimant_token_id,
                crate::app::registry::ALPHA_USD_ADDRESS(),
            )
            .await
            .map_err(|e| crate::error::Error::other(format!("claim_bounty failed: {e}")))?;
            Ok(serde_json::json!({
                "bounty_id": bounty_id,
                "claimant_token_id": claimant_token_id,
                "tx_hash": tx_hash,
            }))
        },
    )
}

/// `submit_result(bounty_id, result)` — submit a deliverable for a bounty this
/// agent has claimed. Reuses `registry::submit_result_sponsored`.
pub(crate) fn submit_result_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
    let schema = serde_json::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"]
    });
    ClosureTool::new(
        "submit_result",
        "Submit your result for a bounty you have claimed. The poster reviews it and, if \
         satisfied, accepts it (which pays out the escrowed $LH to you). Returns \
         { bounty_id, tx_hash }.",
        schema,
        |args: serde_json::Value, _ctx| async move {
            let bounty_id = args
                .get("bounty_id")
                .and_then(|v| v.as_u64())
                .ok_or_else(|| crate::error::Error::other("bounty_id is required"))?;
            let result_text = args
                .get("result")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .trim();
            if result_text.is_empty() {
                return Err(crate::error::Error::other("result cannot be empty"));
            }
            // Specific cause if this bounty isn't in a submittable state (#50).
            if let Err(why) = crate::app::registry::bounty_preflight_check(bounty_id, "submit").await {
                return Err(crate::error::Error::other(why));
            }
            let (signer, fee_payer) = bounty_signers().await?;
            let tx_hash = crate::app::registry::submit_result_sponsored(
                &signer,
                &fee_payer,
                bounty_id,
                result_text.as_bytes(),
                crate::app::registry::ALPHA_USD_ADDRESS(),
            )
            .await
            .map_err(|e| crate::error::Error::other(format!("submit_result failed: {e}")))?;
            Ok(serde_json::json!({
                "bounty_id": bounty_id,
                "tx_hash": tx_hash,
            }))
        },
    )
}

/// `accept_result(bounty_id)` — accept the submitted result for a bounty THIS
/// agent posted, paying out the escrowed `$LH` to the claimant. Reuses
/// `registry::accept_result_sponsored`.
pub(crate) fn accept_result_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
    let schema = serde_json::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"]
    });
    ClosureTool::new(
        "accept_result",
        "Accept the submitted result for a bounty you posted — this RELEASES the \
         escrowed $LH to the claimant. Call it only after reviewing the claimant's \
         submitted result (via discover_bounties / get_bounty). Moves value. Returns \
         { bounty_id, tx_hash }.",
        schema,
        |args: serde_json::Value, _ctx| async move {
            let bounty_id = args
                .get("bounty_id")
                .and_then(|v| v.as_u64())
                .ok_or_else(|| crate::error::Error::other("bounty_id is required"))?;
            // Specific cause if there's no submitted result to accept (#50).
            if let Err(why) = crate::app::registry::bounty_preflight_check(bounty_id, "accept").await {
                return Err(crate::error::Error::other(why));
            }
            let (signer, fee_payer) = bounty_signers().await?;
            let tx_hash = crate::app::registry::accept_result_sponsored(
                &signer,
                &fee_payer,
                bounty_id,
                crate::app::registry::ALPHA_USD_ADDRESS(),
            )
            .await
            .map_err(|e| crate::error::Error::other(format!("accept_result failed: {e}")))?;
            Ok(serde_json::json!({
                "bounty_id": bounty_id,
                "tx_hash": tx_hash,
            }))
        },
    )
}

/// `discover_bounties(query?)` — find open bounties to work on. Read-only:
/// reuses `registry::discover_bounties` (ranked id/task/reward matches), falling
/// back to a plain `open_bounties` scan (resolved via `get_bounty` /
/// `task_of_bounty`) when no query is given.
pub(crate) fn discover_bounties_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
    ClosureTool::new(
        "discover_bounties",
        "Find open bounties in the on-chain bounty market. Read-only registry scan: \
         returns open bounties whose task matches `query` (or the most recent open \
         bounties when `query` is empty), each with its id, task, and reward. Use this \
         to find work you can claim (then claim_bounty + submit_result). Returns \
         { bounties: [ { bounty_id, task, reward_lh } ], count }.",
        serde_json::json!({
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "What to look for — a capability/topic/keyword matched \
                        (case-insensitively) against open-bounty tasks. Empty returns \
                        recent open bounties."
                }
            },
            "required": []
        }),
        |args: serde_json::Value, _ctx| async move {
            let query = args
                .get("query")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .trim()
                .to_string();
            let matches = crate::app::registry::discover_bounties(&query, 64)
                .await
                .map_err(crate::error::Error::other)?;
            let bounties: Vec<_> = matches
                .iter()
                .map(|(id, task, reward_wei)| {
                    serde_json::json!({
                        "bounty_id": id,
                        "task": task,
                        "reward_lh": format_lh(*reward_wei),
                    })
                })
                .collect();
            Ok(serde_json::json!({
                "count": bounties.len(),
                "bounties": bounties,
            }))
        },
    )
}

/// `attest(subject, rating, work_ref?, confirmation)` — write an on-chain
/// REPUTATION attestation: rate another agent's work 1..5 about an optional
/// `work_ref` (a bounty id). Reuses `registry::attest_sponsored`. Confirm-gated:
/// an attestation is a durable, per-`(subject, work_ref)`-one-shot signal that
/// drives hiring/promotion, so the owner confirms it like a value move.
pub(crate) fn attest_tool() -> std::sync::Arc<dyn crate::tools::Tool> {
    let schema = serde_json::json!({
        "type": "object",
        "properties": {
            "subject": {
                "type": "string",
                "description": "Who you are rating — a subdomain NAME (resolved to its \
                    on-chain tokenId) OR a raw numeric tokenId. Cannot be yourself."
            },
            "rating": {
                "type": "integer",
                "minimum": 1,
                "maximum": 5,
                "description": "Quality rating, an integer 1 (worst) to 5 (best)."
            },
            "work_ref": {
                "type": "string",
                "description": "OPTIONAL bounty id this attestation is about (a decimal \
                    integer), so the rating ties to specific work. Omit for a general \
                    attestation."
            },
            "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": ["subject", "rating"]
    });
    ClosureTool::new(
        "attest",
        "Write an on-chain REPUTATION attestation: rate another agent's work 1..5, \
         optionally tied to a bounty id (`work_ref`). Reputation drives hiring + \
         promotion, and each (subject, work_ref) attestation is one-shot + durable, so \
         the first call does NOT execute: it returns a single-use confirmation code \
         (also shown to the owner). State the subject + rating, ask the owner to TYPE \
         the code, then retry with `confirmation` set to it. Reverts on a self-attest, \
         an unknown subject, or a duplicate. Returns { subject, subject_token_id, \
         rating, work_ref, tx_hash }.",
        schema,
        |args: serde_json::Value, _ctx| async move {
            let subject_arg = args
                .get("subject")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .trim()
                .to_string();
            if subject_arg.is_empty() {
                return Err(crate::error::Error::other("subject cannot be empty"));
            }
            // rating: accept an integer or a numeric string (1..=5).
            let rating = args
                .get("rating")
                .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())))
                .ok_or_else(|| crate::error::Error::other("rating is required"))?;
            if !(1..=5).contains(&rating) {
                return Err(crate::error::Error::other("rating must be an integer 1-5"));
            }
            // Belt-and-suspenders: confirm_guard denies any unconfirmed call before
            // this body runs; this guards a path that forgot the hook (attest writes a
            // durable one-shot reputation signal — same posture as the other gated tools).
            let confirmed = args
                .get("confirmation")
                .and_then(|v| v.as_str())
                .map(|s| !s.trim().is_empty())
                .unwrap_or(false);
            if !confirmed {
                return Err(crate::error::Error::other(
                    "attest requires the platform-issued confirmation code",
                ));
            }
            // subject → tokenId: a bare integer is a direct tokenId; otherwise resolve
            // the subdomain name via id_of_name.
            let subject_token_id = match subject_arg.parse::<u64>() {
                Ok(id) if id != 0 => id,
                _ => match crate::app::registry::id_of_name(&subject_arg).await {
                    Ok(id) if id != 0 => id,
                    Ok(_) | Err(_) => {
                        return Err(crate::error::Error::other(format!(
                            "subject \"{subject_arg}\" is not a registered agent (check the name)"
                        )));
                    }
                },
            };
            // work_ref: an optional bounty id left-padded big-endian into the low 8
            // bytes of the 32-byte word (the colony attest convention); empty = zero.
            let work_ref_arg = args.get("work_ref").and_then(|v| v.as_str()).unwrap_or("").trim();
            let mut work_ref = [0u8; 32];
            if !work_ref_arg.is_empty() {
                let id = work_ref_arg.trim_start_matches('#').parse::<u64>().map_err(|_| {
                    crate::error::Error::other(format!(
                        "work_ref \"{work_ref_arg}\" must be a bounty id (integer) — omit it \
                         for a general attestation"
                    ))
                })?;
                work_ref[24..32].copy_from_slice(&id.to_be_bytes());
            }
            let (signer, fee_payer) = bounty_signers().await?;
            let tx_hash = crate::app::registry::attest_sponsored(
                &signer,
                &fee_payer,
                subject_token_id,
                rating as u8,
                work_ref,
                crate::app::registry::ALPHA_USD_ADDRESS(),
            )
            .await
            .map_err(|e| crate::error::Error::other(format!("attest failed: {e}")))?;
            Ok(serde_json::json!({
                "subject": subject_arg,
                "subject_token_id": subject_token_id,
                "rating": rating,
                "work_ref": work_ref_arg,
                "tx_hash": tx_hash,
            }))
        },
    )
}