Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
"@toon-protocol/arweave": "^0.2.0",
"@toon-protocol/client": "^0.25.1",
"@toon-protocol/client": "^0.26.0",
"@toon-protocol/core": "^3.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
14 changes: 14 additions & 0 deletions desktop/src-tauri/src/commands/agent_provisioning.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//! Frontend-facing read of the account index `create_managed_agent` already
//! assigns (buzz#79) — the desktop UI needs it to derive an agent's TOON
//! payment address (owner mnemonic + this index) before it can provision
//! that agent's wallet (buzz#74).

use tauri::AppHandle;

#[tauri::command]
pub fn get_managed_agent_account_index(
pubkey: String,
app: AppHandle,
) -> Result<Option<u32>, String> {
crate::managed_agents::find_account_index(&app, &pubkey)
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod agent_model_process;
mod agent_models;
mod agent_models_env;
mod agent_providers;
mod agent_provisioning;
mod agent_settings;
mod agent_update_rollback;
mod agents;
Expand Down Expand Up @@ -71,6 +72,7 @@ pub use agent_logs::*;
pub use agent_metric_archive::*;
pub use agent_models::*;
pub use agent_providers::*;
pub use agent_provisioning::*;
pub use agent_settings::*;
pub use agents::*;
pub use canvas::*;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,7 @@ pub fn run() {
set_managed_agent_start_on_app_launch,
set_managed_agent_auto_restart,
delete_managed_agent,
get_managed_agent_account_index,
get_managed_agent_log,
get_agent_models,
discover_agent_models,
Expand Down
44 changes: 44 additions & 0 deletions desktop/src-tauri/src/managed_agents/account_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ fn assign_index_in(entries: &mut Vec<AccountIndexEntry>, pubkey: &str, agent_nam
next_index
}

/// Pure lookup: `pubkey`'s already-assigned index, or `None` if it has never
/// been assigned one (never mutates `entries` — unlike [`assign_index_in`],
/// this must not conjure an index just because a caller asked to look).
fn find_index_in(entries: &[AccountIndexEntry], pubkey: &str) -> Option<u32> {
entries
.iter()
.find(|entry| entry.pubkey == pubkey)
.map(|entry| entry.account_index)
}

/// Tombstone `pubkey`'s entry in place. Returns `true` if an entry was found
/// (tombstoned or already tombstoned); `false` if `pubkey` has no entry
/// (agent deleted before it ever spawned under this registry).
Expand Down Expand Up @@ -132,6 +142,19 @@ pub fn assign_account_index(
Ok(index)
}

/// Look up `pubkey`'s already-assigned account index without assigning one
/// (buzz#74: the provisioning flow needs to derive the agent's payment
/// address before it can fund it, and `create_managed_agent` already
/// assigns the index synchronously at creation — this is a read, not a
/// fallback path). `None` when `pubkey` has no entry yet, which the caller
/// should treat as "not provisionable yet" rather than an error.
pub fn find_account_index(app: &AppHandle, pubkey: &str) -> Result<Option<u32>, String> {
let path = account_index_registry_path(app)?;
let _guard = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let entries = load_registry_from_path(&path)?;
Ok(find_index_in(&entries, pubkey))
}

/// Tombstone `pubkey`'s registry entry on agent deletion. The entry (and its
/// index) is retained, never removed — see the module doc. No-op if
/// `pubkey` was never assigned an index.
Expand Down Expand Up @@ -211,6 +234,27 @@ mod tests {
assert_eq!(entries.len(), 1);
}

#[test]
fn find_returns_none_for_an_unassigned_pubkey_without_mutating() {
let entries = Vec::new();
assert_eq!(find_index_in(&entries, "agent-a"), None);
}

#[test]
fn find_returns_the_assigned_index() {
let mut entries = Vec::new();
let a = assign_index_in(&mut entries, "agent-a", "Agent A");
assert_eq!(find_index_in(&entries, "agent-a"), Some(a));
}

#[test]
fn find_still_returns_a_tombstoned_agents_index() {
let mut entries = Vec::new();
let a = assign_index_in(&mut entries, "agent-a", "Agent A");
tombstone_index_in(&mut entries, "agent-a");
assert_eq!(find_index_in(&entries, "agent-a"), Some(a));
}

#[test]
fn tombstoned_index_is_never_reused() {
let mut entries = Vec::new();
Expand Down
3 changes: 2 additions & 1 deletion desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> {
}

pub(crate) use account_index::{
apply_account_index_env, assign_account_index, tombstone_account_index_best_effort,
apply_account_index_env, assign_account_index, find_account_index,
tombstone_account_index_best_effort,
};
pub use backend::*;
pub use discovery::*;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import test from "node:test";

import { DEFAULT_CHANNEL_COLLATERAL_BASE_UNITS } from "../../onboarding/toon/toonOnboardingFormat.ts";
import {
FALLBACK_RUNWAY_DAYS,
FALLBACK_WRITES_PER_DAY,
deriveInitialAllowanceBaseUnits,
} from "./agentProvisioningAllowance.ts";

test("no history, no quote — falls back to the devnet channel-open default", () => {
const amount = deriveInitialAllowanceBaseUnits({
measuredBurnRateBaseUnitsPerSec: null,
quotedWritePriceBaseUnits: null,
});
assert.equal(amount, DEFAULT_CHANNEL_COLLATERAL_BASE_UNITS);
});

test("no history — sizes from the quoted per-write price and the fallback runway", () => {
const amount = deriveInitialAllowanceBaseUnits({
measuredBurnRateBaseUnitsPerSec: null,
quotedWritePriceBaseUnits: 1_000n,
});
assert.equal(
amount,
1_000n * BigInt(FALLBACK_WRITES_PER_DAY) * BigInt(FALLBACK_RUNWAY_DAYS),
);
});

test("no history — a custom runwayDays scales the fallback estimate", () => {
const amount = deriveInitialAllowanceBaseUnits({
measuredBurnRateBaseUnitsPerSec: null,
quotedWritePriceBaseUnits: 1_000n,
runwayDays: 1,
});
assert.equal(amount, 1_000n * BigInt(FALLBACK_WRITES_PER_DAY));
});

test("a measured burn rate wins over the quoted-price fallback", () => {
const amount = deriveInitialAllowanceBaseUnits({
// 10 base units/sec sustained.
measuredBurnRateBaseUnitsPerSec: 10,
quotedWritePriceBaseUnits: 1_000_000n,
runwayDays: 1,
});
assert.equal(amount, 10n * 24n * 60n * 60n);
});

test("a zero measured burn rate is not trusted — falls through to the quote", () => {
// A perfectly idle sample must not size a zero allowance; the agent still
// needs enough to make its next write.
const amount = deriveInitialAllowanceBaseUnits({
measuredBurnRateBaseUnitsPerSec: 0,
quotedWritePriceBaseUnits: 1_000n,
runwayDays: 1,
});
assert.equal(amount, 1_000n * BigInt(FALLBACK_WRITES_PER_DAY));
});

test("a fractional burn-rate*runway amount rounds up, never under-funds", () => {
const amount = deriveInitialAllowanceBaseUnits({
measuredBurnRateBaseUnitsPerSec: 1.5,
quotedWritePriceBaseUnits: null,
runwayDays: 1,
});
assert.equal(amount, BigInt(Math.ceil(1.5 * 24 * 60 * 60)));
});
88 changes: 88 additions & 0 deletions desktop/src/features/agents/lib/agentProvisioningAllowance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { DEFAULT_CHANNEL_COLLATERAL_BASE_UNITS } from "@/features/onboarding/toon/toonOnboardingFormat";

/**
* Initial payment-channel allowance for a freshly-provisioned agent (buzz#74,
* toon-meta#262 decision 8): sized from the agent's own measured burn rate
* so the default survives an operator price change, rather than a fixed
* USDC number.
*
* A brand-new agent has no burn history by construction — provisioning at
* creation always takes the days-of-runway fallback below. The burn-rate
* branch is here for a later re-provisioning/top-up caller once an agent has
* spend history: it reuses `agentNetworkFlow.ts`'s `NetworkFlowRead.burnRateBaseUnitsPerSec`
* shape (passed as a plain number rather than the whole read, since this
* function needs nothing else from it) so both callers agree on what "burn
* rate" means. That per-agent live read does not exist yet — same documented
* blocker `agentNetworkFlow.ts` already carries (toon-client#494's
* `getClaimState()`, and the Network spend block, #80) — so today every
* caller passes `null` here and takes the fallback.
*/

/** Conservative runway target when there is no spend history to measure. */
export const FALLBACK_RUNWAY_DAYS = 7;

/**
* A first-cut estimate of write volume for an agent with no history yet —
* deliberately generous (epic decision 8's "generous default") rather than
* bare-minimum, so a freshly provisioned agent is not starved on day one.
* Revisit once real per-agent write-rate data exists to calibrate against.
*/
export const FALLBACK_WRITES_PER_DAY = 2_000;

const SECONDS_PER_DAY = 24 * 60 * 60;

/**
* Native gas to send an agent on provisioning — not sized from burn rate
* (gas pays for the one channel-open transaction, not per-write spend).
* 0.002 ETH on Base Sepolia: comfortably covers a channel-open with room for
* gas-price movement, without being large enough to matter if it sits idle.
* A first-cut estimate — revisit if devnet gas prices move materially.
*/
export const DEFAULT_AGENT_NATIVE_GAS_BASE_UNITS = 2_000_000_000_000_000n;

/**
* The channel-open collateral to request for a freshly provisioned agent.
*
* Prefers `measuredBurnRateBaseUnitsPerSec` (a positive measured rate) when
* available; otherwise falls back to `quotedWritePriceBaseUnits ×
* FALLBACK_WRITES_PER_DAY × runwayDays`; and if neither input is known at
* all, falls back to the flat devnet channel-open default the onboarding
* wizard already shows, so this never returns an amount too small to open a
* channel with.
*/
export function deriveInitialAllowanceBaseUnits(params: {
/** The agent's own measured burn rate, or null/zero with no trusted history yet. */
measuredBurnRateBaseUnitsPerSec: number | null;
/** The connector's quoted per-write price, or null if unquoted. Only consulted with no burn-rate history. */
quotedWritePriceBaseUnits: bigint | null;
/** How many days of runway the allowance should cover. */
runwayDays?: number;
}): bigint {
const runwayDays = params.runwayDays ?? FALLBACK_RUNWAY_DAYS;

if (
params.measuredBurnRateBaseUnitsPerSec !== null &&
params.measuredBurnRateBaseUnitsPerSec > 0
) {
// Round up — an allowance that undershoots the measured rate by a
// fraction of a base unit is worse than one that overshoots by one.
return BigInt(
Math.ceil(
params.measuredBurnRateBaseUnitsPerSec * SECONDS_PER_DAY * runwayDays,
),
);
}

if (
params.quotedWritePriceBaseUnits !== null &&
params.quotedWritePriceBaseUnits > 0n
) {
return (
params.quotedWritePriceBaseUnits *
BigInt(FALLBACK_WRITES_PER_DAY) *
BigInt(runwayDays)
);
}

return DEFAULT_CHANNEL_COLLATERAL_BASE_UNITS;
}
114 changes: 114 additions & 0 deletions desktop/src/features/agents/lib/agentProvisioningState.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
AGENT_PROVISIONING_STEP_ORDER,
agentProvisioningStepNumber,
deriveAgentProvisioningStatus,
} from "./agentProvisioningState.ts";

const BASE = {
accountIndex: null,
usdcBaseUnits: null,
nativeBaseUnits: null,
channelConfirmed: false,
};

test("no account index yet lands on the key step, regardless of everything else", () => {
const status = deriveAgentProvisioningStatus({
...BASE,
usdcBaseUnits: 1_000_000n,
nativeBaseUnits: 1_000_000_000_000_000n,
channelConfirmed: true,
});
assert.equal(status.step, "key");
});

test("an assigned index with an unread balance stays on fund, not funded", () => {
// null must not read as zero — an unread balance is not an empty wallet.
const status = deriveAgentProvisioningStatus({ ...BASE, accountIndex: 1 });
assert.equal(status.step, "fund");
assert.equal(status.fundedForToken, false);
assert.equal(status.hasNativeGas, false);
});

test("zero of both balances stays on fund", () => {
const status = deriveAgentProvisioningStatus({
...BASE,
accountIndex: 1,
usdcBaseUnits: 0n,
nativeBaseUnits: 0n,
});
assert.equal(status.step, "fund");
});

test("token landed but gas did not — fund step, flagged for manual top-up", () => {
const status = deriveAgentProvisioningStatus({
...BASE,
accountIndex: 1,
usdcBaseUnits: 1_000_000n,
nativeBaseUnits: 0n,
});
assert.equal(status.step, "fund");
assert.equal(status.fundedForToken, true);
assert.equal(status.hasNativeGas, false);
assert.equal(status.needsManualGasTopUp, true);
});

test("gas landed but token did not — fund step, no manual-gas flag", () => {
const status = deriveAgentProvisioningStatus({
...BASE,
accountIndex: 1,
usdcBaseUnits: 0n,
nativeBaseUnits: 1_000_000_000_000_000n,
});
assert.equal(status.step, "fund");
assert.equal(status.needsManualGasTopUp, false);
});

test("funded on both moves to the channel step", () => {
const status = deriveAgentProvisioningStatus({
...BASE,
accountIndex: 1,
usdcBaseUnits: 1_000_000n,
nativeBaseUnits: 1_000_000_000_000_000n,
});
assert.equal(status.step, "channel");
assert.equal(status.needsManualGasTopUp, false);
});

test("funded and channel-confirmed is done", () => {
const status = deriveAgentProvisioningStatus({
...BASE,
accountIndex: 1,
usdcBaseUnits: 1_000_000n,
nativeBaseUnits: 1_000_000_000_000_000n,
channelConfirmed: true,
});
assert.equal(status.step, "done");
});

test("re-entrancy: a lost channel-confirmed flag re-quotes the channel step, not funding", () => {
// Funding is derived from a live balance read, never from the flag, so a
// lost flag (quit right after opening, or a reinstall) cannot strand the
// agent back on the fund step it already cleared.
const status = deriveAgentProvisioningStatus({
...BASE,
accountIndex: 1,
usdcBaseUnits: 1_000_000n,
nativeBaseUnits: 1_000_000_000_000_000n,
channelConfirmed: false,
});
assert.equal(status.step, "channel");
});

test("step numbers are 1-based and stable", () => {
assert.equal(agentProvisioningStepNumber("key"), 1);
assert.equal(agentProvisioningStepNumber("fund"), 2);
assert.equal(agentProvisioningStepNumber("channel"), 3);
});

test("done clamps to the last step number", () => {
assert.equal(agentProvisioningStepNumber("done"), 3);
assert.equal(AGENT_PROVISIONING_STEP_ORDER.length, 3);
});
Loading