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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,17 @@ jobs:
with:
node-version: 22
- run: node --test

unified-balance:
runs-on: ubuntu-latest
defaults:
run:
working-directory: services/unified_balance/kit
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: corepack enable
- run: pnpm install --frozen-lockfile --prod
- run: node --test
2 changes: 2 additions & 0 deletions agent/memory/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def session_body(session: Session) -> dict[str, Any]:
"emails": list(session.emails),
"wallet_notices": list(session.wallet_notices),
"seen_payments": list(session.seen_payments),
"funds_moved": list(session.funds_moved),
}


Expand Down Expand Up @@ -70,6 +71,7 @@ def session_from(at: datetime, body: dict[str, Any],
emails=tuple(body.get("emails", [])),
wallet_notices=tuple(body.get("wallet_notices", [])),
seen_payments=tuple(body.get("seen_payments", [])),
funds_moved=tuple(body.get("funds_moved", [])),
)


Expand Down
1 change: 1 addition & 0 deletions agent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class Session:
emails: tuple[str, ...] = () # suppliers the agent wrote to in this session
wallet_notices: tuple[str, ...] = () # suppliers sent the notice to confirm their wallet
seen_payments: tuple[str, ...] = () # payment results the agent saw in this session
funds_moved: tuple[dict, ...] = () # what the agent brought from the owner's other chains


def payment_seen_key(event) -> str:
Expand Down
5 changes: 4 additions & 1 deletion agent/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@
numbers you read. When to pay is your call: the due date, this week's room in the contract
(cash_position says when it renews), the owner's balance and what else is waiting. Paying late
is the owner's call: when something you would pay cannot go out by its due date, ask, with the
options and your recommendation.
options and your recommendation. When the owner's wallet on Arc holds less than what you would
pay, cash_position shows the USDC they keep on other chains: you may bring what is needed to
their wallet with bring_funds, choosing which chains it comes from (estimate_bring_funds shows
the fees), and then pay.

Talking to the owner. One message, one topic: only what it is about, in one or two short
sentences, plain words. After a payment say what you paid (amount, supplier, invoice) and
Expand Down
13 changes: 8 additions & 5 deletions agent/think.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class BrainSetup:
funds: Callable[[], dict[str, str] | None] = field(default=lambda: None)
mailer: Callable[[str, str, str], str] | None = None # writes to a supplier from the ERP
wallet_notice: Callable[[str], str] | None = None # the standard wallet notice, from the ERP
owner_funds: Any = None # the owner's Unified Balance (services.unified_balance.OwnerFunds)


@dataclass(frozen=True)
Expand Down Expand Up @@ -128,7 +129,7 @@ def think(
toolbox = Toolbox(decisions=list(decisions), ledger=ledger, journal=journal, policy=policy,
now=now, zone=setup.zone, done=set(done), standing=standing,
room=setup.room, funds=setup.funds, mailer=setup.mailer,
wallet_notice=setup.wallet_notice,
wallet_notice=setup.wallet_notice, owner_funds=setup.owner_funds,
emails_today=emails_today(sessions, setup.zone, now),
past_notes=tuple(recent_notes(sessions)),
messages=tuple(messages), payer=payer,
Expand All @@ -152,6 +153,7 @@ def think(
emails=tuple(toolbox.emailed),
wallet_notices=tuple(toolbox.notified_wallets),
seen_payments=tuple(payment_seen_key(e) for e in toolbox.payments),
funds_moved=tuple(toolbox.funds_moved),
**({"usage": usage} if usage else {}),
)
journal.record_session(session)
Expand Down Expand Up @@ -189,7 +191,7 @@ def converse(
toolbox = Toolbox(decisions=list(decisions), ledger=ledger, journal=journal, policy=policy,
now=now, zone=setup.zone, done=set(done), standing=standing,
room=setup.room, funds=setup.funds, mailer=setup.mailer,
wallet_notice=setup.wallet_notice,
wallet_notice=setup.wallet_notice, owner_funds=setup.owner_funds,
emails_today=emails_today(sessions, setup.zone, now),
past_notes=tuple(recent_notes(sessions)),
messages=tuple(messages), require_all=False, payer=payer,
Expand All @@ -214,6 +216,7 @@ def converse(
emails=tuple(toolbox.emailed),
wallet_notices=tuple(toolbox.notified_wallets),
seen_payments=tuple(payment_seen_key(e) for e in toolbox.payments),
funds_moved=tuple(toolbox.funds_moved),
**({"usage": usage} if usage else {}),
)
journal.record_session(session)
Expand All @@ -223,9 +226,9 @@ def converse(

def money_after(setup: BrainSetup, toolbox: Toolbox,
before: dict[str, str] | None) -> dict[str, str] | None:
"""The money the agent leaves the session knowing: read again when it paid, so its own
payments are not a change of money that wakes it in the next check."""
if not toolbox.payments:
"""The money the agent leaves the session knowing: read again when it paid or brought funds,
so what it did itself is not a change of money that wakes it in the next check."""
if not (toolbox.payments or toolbox.funds_moved):
return before
return setup.funds() or before

Expand Down
18 changes: 17 additions & 1 deletion agent/tools/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ def _text(description: str) -> dict[str, Any]:

INVOICE = _text("The invoice name exactly as listed, e.g. ACC-PINV-2026-00012.")
REASON = _text("Why, in one or two plain sentences the owner will read.")
ALLOCATIONS = _text("How much USDC from each chain, as chain:amount separated by commas, e.g. "
"Base_Sepolia:120, Arbitrum_Sepolia:30.5. Chain names as cash_position "
"lists them.")

TOOLS: list[dict[str, Any]] = [
_tool("list_open_invoices",
Expand Down Expand Up @@ -53,7 +56,8 @@ def _text(description: str) -> dict[str, Any]:
item_code=_text("The item code exactly as on the invoice line.")),
_tool("cash_position",
"The shop contract's limits, how much it still lets the shop pay this week, what you "
"chose to pay now in this session, and the payments scheduled or due soon."),
"chose to pay now in this session, the payments scheduled or due soon, and the USDC "
"the owner keeps on other chains (their Unified Balance) with where you may spend it."),
_tool("pay_now",
"Pay this invoice now, for real: the payment goes out right away from the owner's "
"wallet through the shop contract (simulated first, then sent with your Circle "
Expand All @@ -70,6 +74,18 @@ def _text(description: str) -> dict[str, Any]:
_tool("agent_wallet",
"Your own wallet: its address, its balance for Arc network fees (paid in USDC), "
"whether the shop contract has it as its agent, and whether the contract is paused."),
_tool("estimate_bring_funds",
"What it would cost to bring USDC from the owner's Unified Balance on other chains to "
"the owner's wallet on Arc, with the split you give: the fees and what would arrive. "
"Nothing moves.",
allocations=ALLOCATIONS),
_tool("bring_funds",
"Bring USDC from the owner's Unified Balance on other chains to the owner's own wallet "
"on Arc, for real, so the shop contract can pay from it. You choose how much comes "
"from each chain; it always arrives at the owner's wallet, never anywhere else. Only "
"chains where the owner authorized you. Returns what arrived, the fees and the "
"transaction, or the real error.",
allocations=ALLOCATIONS, reason=REASON),
_tool("schedule_payment",
"Plan to pay this invoice on a later date you choose (shop's local date). Only "
"invoices the checks allow. On that day you are woken to pay it with pay_now, or to "
Expand Down
79 changes: 78 additions & 1 deletion agent/tools/toolbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
from services.circle import CircleError
from services.erp import LedgerAdapter, LedgerError
from services.payments import ChainError, PaymentEvent, PaymentStatus
from services.payments.payer import PaymentRefused
from services.payments.payer import PaymentRefused, PaymentSetupError
from services.unified_balance import Allocation, UnifiedBalanceError

MAX_ALARMS = 5
MAX_NOTES = 10
Expand All @@ -38,6 +39,8 @@
MAX_NOTICES = 3
MAX_EMAILS_PER_DAY = 3
MAX_PAYMENTS = 10 # per session; the contract's weekly cap is the real limit
MAX_FUND_MOVES = 3 # bring_funds calls per session
FORWARDER = "forwarder" # the fee taken from what is minted on Arc; the others come off the balance
LINKS = re.compile(r"https?://|www\.|\b[a-z0-9-]+\.(com|net|org|xyz|io|app|link)\b", re.I)
UNKNOWN_FUNDS = {"weekly_room_resets_at_utc": "unknown", "owner_balance": "unknown",
"payments_authorized": "unknown"} # the chain did not answer: never a guess
Expand Down Expand Up @@ -70,6 +73,8 @@ class Toolbox:
payer: Any = None # pays one invoice when the agent chooses to (services.payments.Payer)
can_pay: bool = True # False while the owner has paused payments
payments: list[PaymentEvent] = field(default_factory=list) # what the agent's payments did
owner_funds: Any = None # the owner's Unified Balance (services.unified_balance.OwnerFunds)
funds_moved: list[dict[str, Any]] = field(default_factory=list) # what bring_funds brought
chosen: dict[str, AgentDecision] = field(default_factory=dict)
alarms: list[Alarm] = field(default_factory=list)
notes: list[Note] = field(default_factory=list)
Expand Down Expand Up @@ -215,8 +220,23 @@ def _cash_position(self) -> dict[str, Any]:
"payable_due_within_7_days": fmt(sum((d.amount for d in due_soon), Decimal(0))),
"today": self.today.isoformat(),
**(self._funds_once() or UNKNOWN_FUNDS),
"usdc_on_other_chains": self._other_chains(),
}

def _other_chains(self) -> list[dict[str, str]] | str:
"""The owner's Unified Balance, chain by chain, and where the agent may spend it."""
if self.owner_funds is None:
return "not connected for this shop"
try:
chains = self.owner_funds.chains()
except (UnifiedBalanceError, ChainError, CircleError, PaymentSetupError) as error:
return f"unknown: {error}"
rows = [{"chain": c.chain, "available": fmt(c.confirmed), "arriving": fmt(c.pending),
"you_may_spend_it": "yes" if c.agent_can_spend
else "no: the owner authorizes you on this chain in the dashboard"}
for c in chains if c.confirmed or c.pending or c.agent_can_spend]
return rows or "the owner has no USDC on other chains"

# acting

def _pay_now(self, invoice: str, reason: str) -> str | dict[str, Any]:
Expand Down Expand Up @@ -271,6 +291,47 @@ def _agent_wallet(self) -> dict[str, Any]:
except (ChainError, CircleError) as error:
raise ToolError(f"could not read the agent's wallet: {error}") from None

def _estimate_bring_funds(self, allocations: str) -> dict[str, Any]:
split = self._allocations(allocations)
try:
estimate = self.owner_funds.estimate(split)
except (UnifiedBalanceError, ChainError, CircleError, PaymentSetupError) as error:
raise ToolError(f"could not estimate it: {error}") from None
return _moved(split, estimate.get("fees", []))

def _bring_funds(self, allocations: str, reason: str) -> dict[str, Any]:
split = self._allocations(allocations)
if not self.can_pay:
raise ToolError("payments are paused by the owner; nothing was moved")
if len(self.funds_moved) >= MAX_FUND_MOVES:
raise ToolError(f"at most {MAX_FUND_MOVES} fund moves per session; look again later")
try:
result = self.owner_funds.bring(split)
except (UnifiedBalanceError, ChainError, CircleError, PaymentSetupError) as error:
raise ToolError(f"nothing was brought: {error}") from None
moved = {**_moved(split, result.get("fees", [])), "reason": reason.strip(),
"tx": result.get("txHash") or result.get("transferId") or ""}
self.funds_moved.append(moved)
if hasattr(self, "_funds"):
del self._funds # the owner's balance on Arc changed: read it again when asked
return moved

def _allocations(self, text: str) -> list[Allocation]:
"""chain:amount pairs, each chain once, each amount positive."""
if self.owner_funds is None:
raise ToolError("the owner's Unified Balance is not connected for this shop")
split: dict[str, Decimal] = {}
for part in filter(None, (p.strip() for p in text.split(","))):
chain, _, amount = part.partition(":")
chain, value = chain.strip(), _decimal(amount)
if not chain or value is None or value <= 0 or chain in split:
raise ToolError("allocations are chain:amount pairs, each chain once, each "
"amount above 0, e.g. Base_Sepolia:120, Arbitrum_Sepolia:30.5")
split[chain] = value
if not split:
raise ToolError("say how much from which chain, e.g. Base_Sepolia:120")
return [Allocation(chain, amount) for chain, amount in split.items()]

def _schedule_payment(self, invoice: str, pay_on: str, reason: str) -> str:
decision = self._by_invoice.get(invoice)
day = _parse_day(pay_on)
Expand Down Expand Up @@ -534,3 +595,19 @@ def _parse_day(text: str) -> date:
return date.fromisoformat(text)
except ValueError:
raise ToolError("use a date as YYYY-MM-DD") from None


def _decimal(text: str) -> Decimal | None:
try:
return Decimal(text.strip())
except (ArithmeticError, ValueError):
return None


def _moved(split: list[Allocation], fees: list[dict[str, Any]]) -> dict[str, Any]:
"""What a move takes from each chain, its fees and what reaches the owner's wallet on Arc."""
total = sum((a.amount for a in split), Decimal(0))
fee = {f.get("type", ""): Decimal(str(f.get("amount", "0"))) for f in fees}
return {"from": [{"chain": a.chain, "amount": fmt(a.amount)} for a in split],
"total": fmt(total), "fees": {k: fmt(v) for k, v in fee.items()},
"arrives_on_arc": fmt(total - fee.get(FORWARDER, Decimal(0)))}
10 changes: 10 additions & 0 deletions agent/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from services.payments import ChainError, Payer
from services.payments.encoding import USDC_DECIMALS
from services.settings import Settings
from services.unified_balance import OwnerFunds, UnifiedBalance


def room_of(payer: Payer | None) -> Callable[[], Decimal | None] | None:
Expand Down Expand Up @@ -51,11 +52,19 @@ def funds() -> dict[str, str] | None:
return funds


def owner_funds_of(settings: Settings, payer: Payer | None) -> OwnerFunds | None:
"""The owner's Unified Balance on other chains, when the agent pays with its Circle wallet."""
if payer is None or not (settings.circle_api_key and settings.circle_entity_secret):
return None
return OwnerFunds(UnifiedBalance(settings.circle_api_key, settings.circle_entity_secret), payer)


def brain_setup(
settings: Settings, room: Callable[[], Decimal | None] | None = None,
funds: Callable[[], dict[str, str] | None] | None = None,
mailer: Callable[[str, str, str], str] | None = None,
wallet_notice: Callable[[str], str] | None = None,
owner_funds: OwnerFunds | None = None,
) -> BrainSetup | None:
if not settings.brain:
return None
Expand All @@ -73,4 +82,5 @@ def brain_setup(
**({"funds": funds} if funds else {}),
**({"mailer": mailer} if mailer else {}),
**({"wallet_notice": wallet_notice} if wallet_notice else {}),
**({"owner_funds": owner_funds} if owner_funds else {}),
)
62 changes: 62 additions & 0 deletions dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as chat from "./chat.js";
import * as hub from "./hub.js";
import { agentView, groupTasks, raisedLimits, taskFor } from "./needs.js";
import * as suppliers from "./suppliers.js";
import * as unified from "./unified.js";
import { factory, memoNames, read, shopContract, shopHistory, shopsOf, shopState, usdc } from "./shop.js";
import { AUTHORIZE_ALL, authorizationView, formatUsdc, parseUsdc, shortAddress, ZERO_ADDRESS } from "./units.js";
import { chooseWallet } from "./picker.js";
Expand Down Expand Up @@ -171,6 +172,8 @@ async function renderShop() {
? `${today.sessions} session${today.sessions === 1 ? "" : "s"}${today.cost ? ` · ${today.cost} est.` : ""}`
: "-";
$("agent-address").replaceChildren(hasAgent ? link(shortAddress(state.agent), addressUrl(state.agent)) : "none");
session.agent = hasAgent ? state.agent : null;
renderUnified(isOwner, service);
const telegram = Boolean(service?.telegram_linked);
$("telegram-state").textContent = !service ? "sign in to the embco service first" : telegram ? "linked: /stop and /pause work from your phone" : "not linked";
$("telegram-link").hidden = !isOwner || !service?.connected || telegram;
Expand All @@ -182,6 +185,44 @@ async function renderShop() {
await Promise.all([renderHistory(isOwner), renderApprovals(isOwner), renderErp(isOwner, state)]);
}

// The owner's Unified Balance on other chains: what the agent last read, and the owner's two
// actions there, made with their own wallet through Circle's kit.

function renderUnified(isOwner, service) {
const view = unified.unifiedView(service?.last_run);
$("unified-note").textContent = isOwner ? view.text : "";
$("unified-chains").replaceChildren(
...view.rows
.filter((r) => r.available || r.arriving || r.authorized)
.map((r) => {
const item = document.createElement("li");
const arriving = r.arriving ? ` (+${formatUsdc(r.arriving)} arriving)` : "";
item.textContent = `${r.name}: ${formatUsdc(r.available)} USDC${arriving} · ${r.authorized ? "agent authorized" : "agent not authorized"}`;
return item;
}),
);
const select = $("unified-chain");
const picked = select.value;
select.replaceChildren(...view.rows.map((r) => new Option(r.name, r.chain)));
if (view.rows.some((r) => r.chain === picked)) select.value = picked;
$("unified-form").hidden = !isOwner || view.rows.length === 0;
}

async function unifiedAct(button, label, run) {
button.disabled = true;
try {
notify(`${label}: confirm it in your wallet (it switches to that chain)...`);
await run(wallet.eip1193());
notify(`${label}: done. The agent sees it at its next check.`, "success");
} catch (error) {
notify(describeError(error), "error");
} finally {
button.disabled = false;
await wallet.switchToArc().catch(() => null); // the rest of the page works on Arc
await render();
}
}

// The shop's own agent on the owner's ERPNext, through the hub API.

function showErp(part) {
Expand Down Expand Up @@ -1013,6 +1054,27 @@ function wireActions() {
act(event.currentTarget, "Revoke", (_shop, signer) => usdc(signer).approve(session.shop, 0n)),
);

onSubmit("unified-form", (data, button, form) => {
const chain = data.get("chain");
const amount = String(data.get("amount") ?? "").trim();
try {
parseUsdc(amount);
} catch (error) {
notify(describeError(error), "error");
return;
}
unifiedAct(button, "Deposit", (provider) => unified.deposit(provider, chain, amount)).then(() => form.reset());
});

$("unified-authorize").addEventListener("click", (event) => {
if (!session.agent) {
notify("Set the agent wallet in Settings first.", "error");
return;
}
const chain = $("unified-chain").value;
unifiedAct(event.currentTarget, "Authorize the agent", (provider) => unified.authorizeAgent(provider, chain, session.agent));
});

onSubmit("limits-form", (data, button, form) => {
let maxPerPayment;
let weeklyCap;
Expand Down
Loading
Loading