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
6 changes: 4 additions & 2 deletions agent/memory/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ def session_body(session: Session) -> dict[str, Any]:
"wakes": [{"kind": w.kind, "text": w.text, "invoice": w.invoice, "key": w.key}
for w in session.wakes],
"decided": [d.invoice for d in session.decisions],
"alarms": [{"at": a.at.isoformat(), "why": a.why} for a in session.alarms],
"alarms": [{"at": a.at.isoformat(), "why": a.why, "invoice": a.invoice}
for a in session.alarms],
"notes": [{"about": n.about, "text": n.text} for n in session.notes],
"steps": session.steps,
"model": session.model,
Expand Down Expand Up @@ -55,7 +56,8 @@ def session_from(at: datetime, body: dict[str, Any],
summary=body.get("summary", ""),
decisions=tuple(decisions[i] for i in body.get("decided", [])
if decisions and i in decisions),
alarms=tuple(Alarm(datetime.fromisoformat(a["at"]), a["why"], set_at=at)
alarms=tuple(Alarm(datetime.fromisoformat(a["at"]), a["why"], set_at=at,
invoice=a.get("invoice"))
for a in body.get("alarms", [])),
notes=tuple(Note(n["about"], n["text"], at=at) for n in body.get("notes", [])),
steps=int(body.get("steps", 0)),
Expand Down
1 change: 1 addition & 0 deletions agent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class Alarm:
at: datetime
why: str
set_at: datetime | None = None
invoice: str | None = None # the invoice it is about: once paid, the alarm does not ring


@dataclass(frozen=True)
Expand Down
3 changes: 2 additions & 1 deletion agent/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@

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
nothing more. Never add other invoices, other dates or what is still missing elsewhere: the
nothing more; a payment you make on your own is reported to the owner by itself, so send no
notice about it. Never add other invoices, other dates or what is still missing elsewhere: the
dashboard's activity shows all of that. No transaction hashes or wallet addresses: the
dashboard links them. Your ask_owner questions reach the owner by themselves (with yes/no
buttons when only their yes is missing, or a reminder of what to do in the dashboard), so never
Expand Down
21 changes: 12 additions & 9 deletions agent/reflexes/wake.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
Every cycle (every 15 minutes) the reflexes look for a reason: an invoice that is new, changed or
still without the agent's decision, a payment that failed or was blocked, the day the agent
planned to pay something, a due date close by with no payment planned before it, an alarm the
agent set, the daily round, or the heartbeat when the agent has not looked for a while. No
reason, no session: quiet cycles cost nothing.
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.

A one-time reason (an alarm, a due date, the round) fires until a session that finished saw it.
"""
Expand All @@ -18,7 +20,6 @@
from agent.models import AgentDecision, Alarm, Choice, Note, Session, Wake, payment_seen_key
from services.payments import PaymentEvent, PaymentStatus

HEARTBEAT = timedelta(hours=4)
RETRY_AFTER_FAILURE = timedelta(minutes=30)
DUE_SOON_DAYS = (3, 1, 0) # 0: due today or overdue
RECENT_NOTES = 20
Expand Down Expand Up @@ -48,6 +49,8 @@ def wakes_for(
told = set()
for change in changes:
d = change.decision
if d.invoice in done:
continue # paid or sent: paying it is what changed it
if change.kind is ChangeKind.NEW:
wakes.append(Wake("new", f"New invoice {d.invoice} from {d.supplier}, "
f"{fmt(d.amount)} USDC.", d.invoice))
Expand All @@ -73,14 +76,11 @@ def wakes_for(
wakes.extend(_due_soon(d, standing.get(d.invoice), local.date(), fired))
for alarm in due_alarms(sessions, now):
key = _alarm_key(alarm)
if key not in fired:
if key not in fired and alarm.invoice not in done:
wakes.append(Wake("alarm", f"Your alarm: {alarm.why}", key=key))
round_key = f"round:{local.date().isoformat()}"
if local.hour >= round_hour and round_key not in fired:
wakes.append(Wake("round", "Daily round: look over everything.", key=round_key))
if open_ and last and not wakes and now - last.at >= HEARTBEAT:
wakes.append(Wake("heartbeat", f"Heartbeat: you have not looked for "
f"{int((now - last.at).total_seconds() // 3600)} hours."))
return wakes


Expand All @@ -94,8 +94,11 @@ def due_alarms(sessions: Sequence[Session], now: datetime) -> list[Alarm]:
return [a for s in sessions if s.finished for a in s.alarms if a.at <= now]


def pending_alarms(sessions: Sequence[Session], now: datetime) -> list[Alarm]:
return [a for s in sessions if s.finished for a in s.alarms if a.at > now]
def pending_alarms(sessions: Sequence[Session], now: datetime,
done: set[str] = frozenset()) -> list[Alarm]:
"""Alarms still to ring; one about an invoice already paid will not."""
return [a for s in sessions if s.finished for a in s.alarms
if a.at > now and a.invoice not in done]


def recent_notes(sessions: Sequence[Session]) -> list[Note]:
Expand Down
16 changes: 13 additions & 3 deletions agent/think.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def think(
past_notes=tuple(recent_notes(sessions)),
messages=tuple(messages), payer=payer,
can_pay=setup.autonomy is Autonomy.ACT)
text = briefing(toolbox, wakes, pending_alarms(sessions, now), recent_notes(sessions),
text = briefing(toolbox, wakes, pending_alarms(sessions, now, done), recent_notes(sessions),
conversation, already_told(sessions, setup.zone, now))
try:
run = setup.brain.run(toolbox, text)
Expand All @@ -129,7 +129,7 @@ def think(
finished, error, steps, usage = False, "the session crashed", 0, None
session = Session(
at=now, wakes=tuple(wakes), finished=finished, summary=toolbox.summary or "",
money=money,
money=money_after(setup, toolbox, money),
decisions=tuple(toolbox.chosen.values()) if finished else (),
alarms=tuple(toolbox.alarms) if finished else (),
notes=tuple(toolbox.notes) if finished else (),
Expand Down Expand Up @@ -180,7 +180,7 @@ def converse(
past_notes=tuple(recent_notes(sessions)),
messages=tuple(messages), require_all=False, payer=payer,
can_pay=setup.autonomy is Autonomy.ACT)
text = chat_briefing(toolbox, pending_alarms(sessions, now), recent_notes(sessions),
text = chat_briefing(toolbox, pending_alarms(sessions, now, done), recent_notes(sessions),
conversation, already_told(sessions, setup.zone, now))
try:
run = brain.run(toolbox, text)
Expand All @@ -191,6 +191,7 @@ def converse(
session = Session(
at=now, wakes=(Wake("message", f"The owner wrote to you ({len(messages)} message(s))."),),
finished=finished, summary=toolbox.summary or "",
money=money_after(setup, toolbox, None),
decisions=tuple(toolbox.chosen.values()) if finished else (),
alarms=tuple(toolbox.alarms) if finished else (),
notes=tuple(toolbox.notes) if finished else (),
Expand All @@ -206,6 +207,15 @@ def converse(
session=session, last=session, payments=tuple(toolbox.payments))


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:
return before
return setup.funds() or before


def emails_today(sessions: Sequence[Session], zone: tzinfo, now: datetime) -> int:
"""Emails to suppliers already sent today (shop time), for the daily cap."""
today = now.astimezone(zone).date()
Expand Down
4 changes: 3 additions & 1 deletion agent/tools/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ def _text(description: str) -> dict[str, Any]:
_tool("set_alarm",
"Wake yourself at a given time to look at something again.",
at=_text("Shop's local date and time, YYYY-MM-DDTHH:MM."),
why=_text("What to look at then.")),
why=_text("What to look at then."),
invoice=_text("The invoice it is about, exactly as listed, or empty. An alarm about "
"an invoice does not ring once that invoice is paid.")),
_tool("note",
"Keep a short note for your future sessions (about a supplier, an invoice or the shop).",
about=_text("A supplier name, an invoice name, or 'shop'."),
Expand Down
9 changes: 7 additions & 2 deletions agent/tools/toolbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ def _ask_owner(self, invoice: str, question: str, recommendation: str) -> str:
recommendation=recommendation.strip())
return f"{invoice}: the owner will be asked"

def _set_alarm(self, at: str, why: str) -> str:
def _set_alarm(self, at: str, why: str, invoice: str = "") -> str:
if len(self.alarms) >= MAX_ALARMS:
raise ToolError(f"at most {MAX_ALARMS} alarms per session")
try:
Expand All @@ -313,7 +313,12 @@ def _set_alarm(self, at: str, why: str) -> str:
raise ToolError("that time has already passed")
if when > self.now + timedelta(days=MAX_ALARM_DAYS):
raise ToolError(f"set alarms at most {MAX_ALARM_DAYS} days ahead")
self.alarms.append(Alarm(at=when, why=why.strip(), set_at=self.now))
invoice = invoice.strip()
if invoice and invoice not in {d.invoice for d in self.decisions}:
raise ToolError(f"{invoice} is not an open invoice; leave it empty or use the name "
"exactly as listed")
self.alarms.append(Alarm(at=when, why=why.strip(), set_at=self.now,
invoice=invoice or None))
return f"alarm set for {when.astimezone(self.zone):%Y-%m-%d %H:%M}"

def _note(self, about: str, text: str) -> str:
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The contracts live in `contracts/`.
| `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 |
| `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, a heartbeat after 4 hours. 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, and its own payments are not a change of money. 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
2 changes: 2 additions & 0 deletions services/shops/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ def cycle_events(report: CycleReport, interval_minutes: int) -> list[dict[str, A
events = []
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
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
15 changes: 14 additions & 1 deletion services/shops/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from typing import Any

from agent.cost import estimate, shown
from agent.guardrails.controls.base import fmt
from agent.guardrails.rules import PolicyConfig
from agent.memory import JournalError, SqliteJournal
from agent.models import Autonomy, Choice, Wake
Expand All @@ -25,7 +26,7 @@
from services.erp import ErpnextAdapter, LedgerError
from services.erp.erpnext.cache import CachedLedger
from services.hub.chain import DEFAULT_FACTORY, ShopChain, ShopLimits
from services.payments import ArcRpc, ChainError, Payer, PaymentEvent
from services.payments import ArcRpc, ChainError, Payer, PaymentEvent, PaymentStatus
from services.payments.encoding import ref_scope
from services.settings import Settings
from services.shops import activity, chat
Expand All @@ -39,6 +40,7 @@
log = logging.getLogger("embco")

MESSAGE_POLL = 3 # seconds between looks at the inbox while waiting for the next check
SENT = (PaymentStatus.SUBMITTED, PaymentStatus.COMPLETE, PaymentStatus.RECORDED)


def erp_for(settings: Settings) -> ErpnextAdapter:
Expand Down Expand Up @@ -122,6 +124,16 @@ def chat_replies(session, owner_waiting: bool) -> list[dict[str, Any]]:
return []


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."""
if thought is None or owner_waiting:
return []
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]


def not_woken(folder, thought, owner_waiting: bool, at: datetime) -> list[dict[str, Any]]:
"""A word from the service when the agent had a reason to wake but was not woken: always
when the owner is waiting for an answer, and for the daily budget even when not. Said once
Expand Down Expand Up @@ -318,6 +330,7 @@ def cycle() -> None:
chat.append_chat(folder, on_telegram(folder, [
*chat_replies(report.thought.session if report.thought else None, bool(waiting)),
*not_woken(folder, report.thought, bool(waiting), report.at),
*paid_notices(report.thought, bool(waiting)),
*(new_questions(folder, report.decisions, report.thought.agent, report.at,
policy=policy["now"], funds=funds, room=room,
paused=store.payments_paused(shop))
Expand Down
17 changes: 8 additions & 9 deletions tests/test_agent_in_cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from datetime import timedelta, timezone

from agent.agent import SessionRun
from agent.models import Autonomy, Choice, Usage
from agent.models import Autonomy, Choice, Usage, Wake
from agent.reflexes.cycle import run_cycle
from agent.think import BrainSetup
from agent.tools import ToolError
Expand Down Expand Up @@ -35,10 +35,13 @@ def run(self, toolbox, briefing):
return SessionRun(finished=True, steps=1, usage=Usage())


def cycle(journal, payer, brain, at, autonomy=Autonomy.ACT): # noqa: F811
def cycle(journal, payer, brain, at, autonomy=Autonomy.ACT, wakes=()): # noqa: F811
setup = BrainSetup(brain=brain, zone=LAGOS, autonomy=autonomy)
return run_cycle(payer.ledger, journal, POLICY, "TEST Shop", at=at, payments=payer,
brain=setup)
brain=setup, wakes=list(wakes))


AGAIN = (Wake("limits", "The owner changed the limits in the shop contract."),) # wakes it again


def test_what_the_agent_chooses_to_pay_now_is_paid_through_the_contract(journal): # noqa: F811
Expand Down Expand Up @@ -219,11 +222,9 @@ def run(self, toolbox, briefing):


def woken_twice(journal, brain): # noqa: F811
from agent.reflexes.wake import HEARTBEAT

payer, _, _ = make_payer(matching_ledger())
first = cycle(journal, payer, brain, NOW)
second = cycle(journal, payer, brain, NOW + HEARTBEAT) # the heartbeat would wake it
second = cycle(journal, payer, brain, NOW + timedelta(hours=1), wakes=AGAIN)
return first, second


Expand Down Expand Up @@ -292,12 +293,10 @@ def test_the_agent_sees_what_stops_an_invoice_apart_from_what_to_weigh(journal):


def test_the_agent_is_told_what_it_already_said_to_the_owner(journal): # noqa: F811
from agent.reflexes.wake import HEARTBEAT

payer, _, _ = make_payer(matching_ledger())
brain = Reads()
cycle(journal, payer, brain, NOW)
cycle(journal, payer, brain, NOW + HEARTBEAT)
cycle(journal, payer, brain, NOW + timedelta(hours=1), wakes=AGAIN)
assert brain.briefings[0]["already_told_the_owner"] == []
[told] = brain.briefings[1]["already_told_the_owner"]
assert told["summary"] == "Summary 1."
Expand Down
Loading
Loading