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
26 changes: 22 additions & 4 deletions agent/reflexes/wake.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@
agent set, or the daily round. No reason, no session: quiet cycles cost nothing.

What the agent itself did is not a reason: an invoice it already paid or sent does not wake it
when it changes, and an alarm about it does not ring.
when it changes, and an alarm about it does not ring. Nor is a hold the agent chose that only lost
reasons since (paying another invoice of the supplier drops its "first payment"): it still holds.

A one-time reason (an alarm, a due date, the round) fires until a session that finished saw it.
"""

from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, tzinfo

from agent.guardrails.controls.base import fmt
from agent.guardrails.rules import Action, Decision, fingerprint
from agent.memory import Change, ChangeKind
from agent.memory import Change, ChangeKind, JournalEntry
from agent.models import AgentDecision, Alarm, Choice, Note, Session, Wake, payment_seen_key
from services.payments import PaymentEvent, PaymentStatus

Expand All @@ -38,7 +39,9 @@ def wakes_for(
round_hour: int,
extra: Sequence[Wake] = (),
can_pay: bool = False, # the agent can send payments now (connected and not paused)
decided_on: Mapping[str, JournalEntry] | None = None, # the rule decision each choice was on
) -> list[Wake]:
decided_on = decided_on or {}
last = sessions[-1] if sessions else None
fired = {w.key for s in sessions if s.finished for w in s.wakes if w.key}
local = now.astimezone(zone)
Expand All @@ -55,13 +58,16 @@ def wakes_for(
wakes.append(Wake("new", f"New invoice {d.invoice} from {d.supplier}, "
f"{fmt(d.amount)} USDC.", d.invoice))
elif change.kind is ChangeKind.CHANGED:
if _still_held(standing.get(d.invoice), decided_on.get(d.invoice), d):
continue
wakes.append(Wake("changed", f"{d.invoice} changed: {change.note}", d.invoice))
else:
continue
told.add(d.invoice)
open_ = [d for d in decisions if d.invoice not in done]
for d in open_:
if d.invoice not in told and not _holds(standing.get(d.invoice), d):
if d.invoice not in told and not _holds(standing.get(d.invoice), d) and not (
_still_held(standing.get(d.invoice), decided_on.get(d.invoice), d)):
wakes.append(Wake("undecided", f"{d.invoice} has no decision from you yet.",
d.invoice))
since = last.at if last else None
Expand Down Expand Up @@ -109,6 +115,18 @@ def _holds(standing: AgentDecision | None, decision: Decision) -> bool:
return standing is not None and standing.fingerprint == fingerprint(decision)


def _still_held(standing: AgentDecision | None, decided_on: JournalEntry | None,
decision: Decision) -> bool:
"""The agent chose to hold, the rules still hold it for the same amount, and the only
change since is reasons gone: nothing new to decide."""
if standing is None or decided_on is None or standing.choice is not Choice.HOLD:
return False
return (decided_on.fingerprint == standing.fingerprint
and decided_on.action is Action.HOLD and decision.action is Action.HOLD
and decided_on.supplier == decision.supplier and decided_on.amount == decision.amount
and set(decision.reasons) <= set(decided_on.reasons))


def _scheduled_today(decision: Decision, standing: AgentDecision | None, today,
fired, can_pay: bool) -> list[Wake]:
"""The day the agent chose to pay an invoice, it is woken to pay it itself; and so is a
Expand Down
20 changes: 17 additions & 3 deletions agent/think.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
from agent.agent import Brain
from agent.cost import estimate, shown
from agent.guardrails.rules import Decision, PolicyConfig
from agent.memory import Change, DecisionJournal
from agent.models import AgentDecision, Autonomy, Session, Wake, payment_seen_key
from agent.memory import Change, DecisionJournal, JournalEntry
from agent.models import AgentDecision, Autonomy, Choice, Session, Wake, payment_seen_key
from agent.prompt import already_told, briefing, chat_briefing
from agent.reflexes.funds import funds_wake, money_wake
from agent.reflexes.wake import backing_off, pending_alarms, recent_notes, wakes_for
Expand All @@ -30,6 +30,19 @@
BUDGET_USED = "the agent's daily budget is used up"


def decided_on(journal: DecisionJournal,
standing: Mapping[str, AgentDecision]) -> dict[str, JournalEntry]:
"""The rule decision each of the agent's holds was made on, to tell a hold that only lost
reasons since from one with something new."""
found = {}
for invoice, choice in standing.items():
if choice.choice is Choice.HOLD:
match = [e for e in journal.history(invoice) if e.fingerprint == choice.fingerprint]
if match:
found[invoice] = match[-1]
return found


def over_budget(setup: "BrainSetup", sessions: Sequence[Session], now: datetime) -> str:
"""Why the agent may not be woken again today, or "" when it may: what today's sessions
cost reached the daily budget. Sessions of a model with no known price count by tokens
Expand Down Expand Up @@ -102,7 +115,8 @@ def think(
sessions=sessions, standing=standing, done=done,
payments=journal.latest_payments(), round_hour=setup.round_hour,
extra=[*extra, *([short] if short else []), *([moved] if moved else [])],
can_pay=payer is not None and setup.autonomy is Autonomy.ACT)
can_pay=payer is not None and setup.autonomy is Autonomy.ACT,
decided_on=decided_on(journal, standing))
last = sessions[-1] if sessions else None
if not wakes:
return Thought(agent=standing, last=last)
Expand Down
4 changes: 3 additions & 1 deletion agent/tools/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ def _text(description: str) -> dict[str, Any]:
_tool("ask_owner",
"Ask the owner a question about this invoice; it is not paid until the owner answers. "
"Ask only what the owner really has to decide, so that yes means pay it and no means "
"hold it, and say what you recommend. The question reaches the owner by itself.",
"hold it, and say what you recommend. The question reaches the owner by itself. When "
"the owner must first do something in the dashboard (approve a wallet, raise a limit, "
"add money), their yes cannot do it: ask what they should check before doing it.",
invoice=INVOICE,
question=_text("The question, short and concrete."),
recommendation=_text("What you recommend and why, in one sentence.")),
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ The contracts live in `contracts/`.
| Package | Responsibility |
|---|---|
| `agent/agent.py`, `prompt.py`, `think.py`, `plan.py` | The brain. When a reflex wakes it, the model (today Claude Haiku 5.5, at high effort for its rounds and medium when it answers the owner) reads a short briefing and calls tools until every open invoice has its decision: pay now, pay on a date, hold, or ask the owner, each with a reason, plus alarms and notes for later and a summary for the owner. It pays during the session and reads what happened (paid with its transaction, refused by the contract, or Circle's own error), then acts on it: looks up why, holds, or tells the owner what only they can fix. Capped per session (turns, tokens, 10 payments) and per day (USD 1 of model use by default, estimated from the tokens and the public prices; past it the agent is not woken until the next day, and the owner is told once). A payment it sent stands even if the session is cut off; its other choices count once the session finishes. The rules still decide what may be paid; the agent decides what is paid and when, and pays it. On by default, and nothing is paid without it; `EMBCO_AUTONOMY=observe` decides and explains without paying |
| `services/telegram` | The agent on Telegram, a process of its own (`embco telegram`). A chat is linked with a one-time code from the dashboard (hashed, 10 minutes, once); any other chat can do nothing. `/stop` turns the whole agent off and pauses the contract from the agent's wallet, `/pause` pauses payments while the agent keeps watching and answering, `/status` tells its state: none of them go through the model, and only the dashboard turns it on again. Any other text is a message to the agent, whose answers come back to the chat. The agent's questions about invoices arrive once: with two buttons (yes, pay it / no, hold it) when only the owner's yes is missing, where a press from the linked chat, while the question is still open, is only a message to the agent; as a reminder to use the dashboard when the owner's wallet is needed (approve a wallet, raise a limit, add money, authorize or resume payments). What the agent writes to the owner is one topic, short and without full hashes: a longer text goes back to the model to be rewritten. The bot token never reaches a log or an error |
| `services/telegram` | The agent on Telegram, a process of its own (`embco telegram`). A chat is linked with a one-time code from the dashboard (hashed, 10 minutes, once); any other chat can do nothing. `/stop` turns the whole agent off and pauses the contract from the agent's wallet, `/pause` pauses payments while the agent keeps watching and answering, `/status` tells its state: none of them go through the model, and only the dashboard turns it on again. Any other text is a message to the agent, whose answers come back to the chat. The agent's questions about invoices arrive once: with two buttons (yes, pay it / no, hold it) when only the owner's yes is missing, where a press from the linked chat, while the question is still open, is only a message to the agent; as a reminder to use the dashboard, followed by the agent's question, when the owner's wallet is needed (approve a wallet, raise a limit, add money, authorize or resume payments). What the agent writes to the owner is one topic, short and without full hashes: a longer text goes back to the model to be rewritten. The bot token never reaches a log or an error |
| `agent/tools` | What the agent can call. To read: the open invoices (with the last state of each payment), a search over them (supplier, due date, what the checks say), one invoice in full, a supplier's profile, an item's price history, the cash position, the state of a payment, and its own wallet (balance for network fees, whether the contract has it as its agent, whether the contract is paused). To act: pay now (sent at once, with the result back to the agent), schedule (on that day the agent is woken to pay), hold, ask the owner, set an alarm, keep a note, finish. To talk: reply to the owner's message, notify the owner of something to act on (at most 3 per session), and email a supplier from the shop's ERPNext, only after the owner agreed |
| `agent/guardrails/gate.py` | Refuses a payment the rules do not allow (HOLD, ASK, already paid, over this week's room in the contract) before anything is sent, and the refusal goes back to the agent |
| `agent/reflexes/wake.py` | When to wake the agent: an invoice new, changed or without its decision, a payment failed or blocked (not again for a result it already saw), the day it planned to pay something, a due date close with nothing planned, an owner answer or supplier signature, a limit change, an alarm it set, the daily round. What the agent did itself is not a reason: an invoice it paid does not wake it when it changes, an alarm about it does not ring, and its own payments are not a change of money. No reason, no model call |
| `agent/reflexes/wake.py` | When to wake the agent: an invoice new, changed or without its decision, a payment failed or blocked (not again for a result it already saw), the day it planned to pay something, a due date close with nothing planned, an owner answer or supplier signature, a limit change, an alarm it set, the daily round. What the agent did itself is not a reason: an invoice it paid does not wake it when it changes, an alarm about it does not ring, its own payments are not a change of money, and a hold it chose that only lost reasons since still holds. No reason, no model call |
| `services/erp` | Domain models, `LedgerAdapter` protocol, `erpnext/` adapter (client, mappers, adapter). Reads, plus one write through `PaymentWriter`: a payment final on chain becomes a submitted Payment Entry (on testnet, a draft marked TESTNET, so test USDC never closes a real invoice), drafted by ERPNext from the invoice and checked against what was paid (party, amount, invoice) before it is written, with the transaction hash and payee wallet in its custom fields. The same transaction is never written twice |
| `agent/guardrails/controls` | Context builder and one small module per control: three-way match, payee wallet, duplicates, price anomaly, payment limit, supplier status. Each returns PASS, HOLD (an unproven wallet, a duplicate bill), ASK (a first payment to a new wallet) or NOTE (evidence for the agent to weigh: receipt and quantities, price, limit, disabled supplier). A wallet never paid before is held until the supplier signs a challenge with it (proof the address is right and theirs), then the owner is asked to approve, since only a person knows whether the supplier really asked for the change. The question says who set the wallet in the ERP and when, from the supplier's change history |
| `services/signing` | EIP-712 wallet ownership message bound to embco and Arc testnet, and signer recovery (ordinary wallets; smart contract wallets need EIP-1271 on chain) |
Expand Down
18 changes: 13 additions & 5 deletions services/shops/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
YOU = "you" # what the owner did from the dashboard
SHOWN_WAKES = 3

_SENT = (PaymentStatus.SUBMITTED, PaymentStatus.COMPLETE, PaymentStatus.RECORDED)
_ACTION = {Action.PAY: "nothing stops it", Action.HOLD: "on hold", Action.ASK: "needs you"}


Expand Down Expand Up @@ -149,7 +150,6 @@ def session_events(session) -> list[dict[str, Any]]:
pay_on=d.pay_on.isoformat() if d.pay_on else None))
events.extend(_event(at, AGENT, f"Will look again on {a.at:%Y-%m-%d %H:%M}: {a.why}")
for a in session.alarms)
events.extend(_event(at, AGENT, f"To you: {reply}") for reply in session.replies)
events.extend(_event(at, AGENT, f"Emailed {supplier} from your ERPNext.")
for supplier in session.emails)
events.extend(_event(at, AGENT, f"Sent {supplier} the notice to confirm its wallet.")
Expand All @@ -162,9 +162,13 @@ def session_events(session) -> list[dict[str, Any]]:


def payment_events(at: datetime, payments) -> list[dict[str, Any]]:
"""What payments did, in order: sent, paid with its transaction, booked, or not paid."""
"""What payments did: one line per invoice for how far its payment got (sent, paid with its
transaction, booked in ERPNext), and a line for each one not paid."""
events = []
furthest = {e.invoice: e for e in payments if e.status in _SENT}
for e in payments:
if e.status in _SENT and furthest[e.invoice] is not e:
continue
amount, to = f"{fmt(e.amount)} USDC", _short(e.payee) or e.supplier
if e.status is PaymentStatus.SUBMITTED:
events.append(_event(at, SENDING, f"Sending {amount} to {e.supplier} ({to}) for "
Expand All @@ -173,9 +177,9 @@ def payment_events(at: datetime, payments) -> list[dict[str, Any]]:
events.append(_event(at, PAID, f"Paid {amount} to {e.supplier} for {e.invoice}.",
invoice=e.invoice, tx=e.tx_hash))
elif e.status is PaymentStatus.RECORDED:
events.append(_event(at, RECORDED, f"{e.invoice} recorded in ERPNext as "
f"{e.erp_entry}.", invoice=e.invoice,
tx=e.tx_hash))
events.append(_event(at, RECORDED, f"Paid {amount} to {e.supplier} for {e.invoice}, "
f"recorded in ERPNext as {e.erp_entry}.",
invoice=e.invoice, tx=e.tx_hash))
else:
events.append(_event(at, NOT_PAID, f"Did not pay {e.invoice}: {e.reason}.",
invoice=e.invoice))
Expand All @@ -185,10 +189,14 @@ def payment_events(at: datetime, payments) -> list[dict[str, Any]]:
def cycle_events(report: CycleReport, interval_minutes: int) -> list[dict[str, Any]]:
at = report.at
events = []
session = report.thought.session if report.thought else None
decided = {d.invoice for d in session.decisions} if session and session.finished else set()
for change in report.changes:
d = change.decision
if d.invoice in report.already_paid:
continue # paid or sent: its payment lines say what happened
if change.kind is ChangeKind.CHANGED and d.invoice in decided:
continue # the agent's own line on it says more
why = _why(d.action, d.reasons)
if change.kind is ChangeKind.NEW:
events.append(_event(at, NEW, f"New invoice {d.invoice} from {d.supplier}, "
Expand Down
8 changes: 5 additions & 3 deletions services/shops/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,15 @@ def chat_replies(session, owner_waiting: bool) -> list[dict[str, Any]]:


def paid_notices(thought, owner_waiting: bool) -> list[dict[str, Any]]:
"""One short line per payment the agent made on its own. When the owner wrote, its answer
already says what it paid."""
"""One short line per invoice the agent paid on its own, however many steps its payment
went through (sent, confirmed, booked). When the owner wrote, its answer already says what
it paid."""
if thought is None or owner_waiting:
return []
last = {e.invoice: e for e in thought.payments}
return [{"from": chat.AGENT, "at": e.at.isoformat(),
"text": f"Paid {fmt(e.amount)} USDC to {e.supplier} ({e.invoice})."}
for e in thought.payments if e.status in SENT]
for e in last.values() if e.status in SENT]


def not_woken(folder, thought, owner_waiting: bool, at: datetime) -> list[dict[str, Any]]:
Expand Down
7 changes: 4 additions & 3 deletions services/shops/questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
it). An answer is only a message to the agent, the same words the dashboard's buttons send:
the agent decides, and the contract still sets the limits.
- something needs the owner's wallet (approve the supplier's wallet, raise a limit, add money,
authorize or resume payments): a short reminder to do it in the dashboard, with no buttons.
authorize or resume payments): a short reminder to do it in the dashboard, then the agent's
question, with no buttons.
"""

from collections.abc import Callable, Iterable, Mapping
Expand Down Expand Up @@ -40,7 +41,7 @@ def wallet_steps(decision: Decision, policy: PolicyConfig, funds: Mapping[str, s
"""What the owner has to do with their wallet in the dashboard before this can be paid."""
steps = []
if any(f.control == "payee_wallet" and f.outcome is Outcome.ASK for f in decision.findings):
steps.append(f"approve {decision.supplier}'s wallet")
steps.append("approve its wallet")
if decision.amount > policy.max_per_payment:
steps.append(f"raise the per-payment limit (now {fmt(policy.max_per_payment)} USDC)")
elif room is not None and decision.amount > room:
Expand Down Expand Up @@ -81,7 +82,7 @@ def new_questions(folder, decisions: Iterable[Decision], agent: Mapping[str, Age
amount = f"{fmt(decision.amount)} USDC"
if steps:
text = (f"{decision.supplier}, {amount} ({decision.invoice}): needs you in the "
f"dashboard: {', '.join(steps)}.")
f"dashboard: {', '.join(steps)}.\n{choice.question or choice.reason}")
else:
text = (f"Pay {amount} to {decision.supplier} ({decision.invoice})?\n"
f"{choice.question or choice.reason}")
Expand Down
Loading
Loading