From 44ff2550e791400b607e73b041474c8dc1cea0d9 Mon Sep 17 00:00:00 2001 From: JA VI <127710324+JaDi03@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:52:40 -0600 Subject: [PATCH] fix: tell the owner each thing once and keep the agent's question in reminders --- agent/reflexes/wake.py | 26 ++++- agent/think.py | 20 +++- agent/tools/definitions.py | 4 +- docs/architecture.md | 4 +- services/shops/activity.py | 18 ++- services/shops/agent.py | 8 +- services/shops/questions.py | 7 +- tests/test_activity.py | 7 +- tests/test_owner_notices_once.py | 185 +++++++++++++++++++++++++++++++ tests/test_questions.py | 7 +- 10 files changed, 260 insertions(+), 26 deletions(-) create mode 100644 tests/test_owner_notices_once.py diff --git a/agent/reflexes/wake.py b/agent/reflexes/wake.py index cd429a4..d624f3a 100644 --- a/agent/reflexes/wake.py +++ b/agent/reflexes/wake.py @@ -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 @@ -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) @@ -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 @@ -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 diff --git a/agent/think.py b/agent/think.py index fea6af3..9349a2f 100644 --- a/agent/think.py +++ b/agent/think.py @@ -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 @@ -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 @@ -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) diff --git a/agent/tools/definitions.py b/agent/tools/definitions.py index 2309340..a978b02 100644 --- a/agent/tools/definitions.py +++ b/agent/tools/definitions.py @@ -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.")), diff --git a/docs/architecture.md b/docs/architecture.md index 9cfc4f6..2c3ad40 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) | diff --git a/services/shops/activity.py b/services/shops/activity.py index 9112d45..83aff73 100644 --- a/services/shops/activity.py +++ b/services/shops/activity.py @@ -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"} @@ -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.") @@ -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 " @@ -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)) @@ -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}, " diff --git a/services/shops/agent.py b/services/shops/agent.py index 2ce05a5..15e2f4f 100644 --- a/services/shops/agent.py +++ b/services/shops/agent.py @@ -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]]: diff --git a/services/shops/questions.py b/services/shops/questions.py index d4365c8..f8504f5 100644 --- a/services/shops/questions.py +++ b/services/shops/questions.py @@ -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 @@ -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: @@ -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}") diff --git a/tests/test_activity.py b/tests/test_activity.py index 358960d..20ecc4d 100644 --- a/tests/test_activity.py +++ b/tests/test_activity.py @@ -72,9 +72,10 @@ def test_payments_show_with_their_transaction(tmp_path): report = replace(report, settlement=Settlement(events=(paid, recorded, blocked))) events = activity.cycle_events(report, 15) by_kind = {e["kind"]: e for e in events} - assert by_kind["paid"]["text"] == "Paid 1000 USDC to S for PINV-1." - assert by_kind["paid"]["tx"] == TX - assert by_kind["recorded"]["text"] == "PINV-1 recorded in ERPNext as ACC-PAY-1." + assert "paid" not in by_kind # one line for the payment: how far it got + assert by_kind["recorded"]["text"] == ( + "Paid 1000 USDC to S for PINV-1, recorded in ERPNext as ACC-PAY-1.") + assert by_kind["recorded"]["tx"] == TX assert by_kind["not_paid"]["text"] == "Did not pay PINV-1: not approved." assert "1000 USDC paid" in by_kind["done"]["text"] diff --git a/tests/test_owner_notices_once.py b/tests/test_owner_notices_once.py new file mode 100644 index 0000000..da420ae --- /dev/null +++ b/tests/test_owner_notices_once.py @@ -0,0 +1,185 @@ +"""What reaches the owner is said once: a payment is one line in the chat and in the activity, +however many steps it went through; a reminder keeps the agent's question; and a hold that only +lost reasons does not wake the agent. Built on the pilot's 14-Oct checks (Ogun Dairy 00015/00016). +""" + +import secrets +from dataclasses import replace +from datetime import UTC, date, datetime, timedelta, timezone +from decimal import Decimal + +from agent.agent import SessionRun +from agent.guardrails.controls.base import Finding, Outcome +from agent.guardrails.rules import Action, Decision, PolicyConfig, fingerprint +from agent.memory import Change, ChangeKind, SqliteJournal +from agent.memory.changes import compare +from agent.models import AgentDecision, Choice, Session, Wake +from agent.reflexes.cycle import run_cycle +from agent.think import BrainSetup, Thought, think +from services.payments import PaymentEvent, PaymentStatus +from services.shops import activity +from services.shops.agent import paid_notices +from services.shops.questions import new_questions +from support import WALLET_A, FakeLedger + +NOW = datetime(2026, 10, 14, 1, 21, tzinfo=UTC) +LAGOS = timezone(timedelta(hours=1)) +POLICY = PolicyConfig(max_per_payment=Decimal(250), weekly_budget=Decimal(2000)) +OGUN, PAID, TWIN = "Ogun Dairy & Provisions", "ACC-PINV-2026-00015", "ACC-PINV-2026-00016" +SAME_BILL = f"same supplier invoice number as {PAID}" +FIRST = "first payment to this supplier, wallet 0x5487...6e80" + + +def payment(status, invoice=PAID): + return PaymentEvent(invoice=invoice, status=status, at=NOW, supplier=OGUN, payee=WALLET_A, + amount=Decimal("128.0"), invoice_ref="0x" + secrets.token_hex(32), + tx_hash="0x" + "ab" * 32, erp_entry="ACC-PAY-2026-00013") + + +STEPS = [payment(PaymentStatus.SUBMITTED), payment(PaymentStatus.COMPLETE), + payment(PaymentStatus.RECORDED)] + + +# ---- a payment is one line + + +def test_a_payment_on_its_own_is_one_chat_line_not_one_per_step(): + out = Thought(agent={}, payments=tuple(STEPS)) + assert [e["text"] for e in paid_notices(out, owner_waiting=False)] == [ + "Paid 128 USDC to Ogun Dairy & Provisions (ACC-PINV-2026-00015)."] + + +def test_a_payment_that_failed_after_it_was_sent_is_not_told_as_paid(): + out = Thought(agent={}, payments=(STEPS[0], payment(PaymentStatus.FAILED))) + assert paid_notices(out, owner_waiting=False) == [] + + +def test_a_payment_is_one_line_in_the_activity_with_its_transaction(): + lines = activity.payment_events(NOW, STEPS) + assert [e["text"] for e in lines] == [ + "Paid 128 USDC to Ogun Dairy & Provisions for ACC-PINV-2026-00015, recorded in ERPNext " + "as ACC-PAY-2026-00013."] + assert lines[0]["tx"] == STEPS[-1].tx_hash + + +def test_a_payment_still_on_its_way_shows_how_far_it_got(): + lines = activity.payment_events(NOW, STEPS[:1]) + assert [e["kind"] for e in lines] == ["sending"] + + +# ---- the activity does not repeat the agent + + +def agent_choice(invoice, choice=Choice.HOLD, decision=None): + return AgentDecision(invoice=invoice, fingerprint=fingerprint(decision) if decision else "x", + choice=choice, reason="Same Ogun bill entered twice.") + + +def test_the_agent_answer_is_in_the_chat_not_again_in_the_activity(): + session = Session(at=NOW, wakes=(Wake("message", "The owner wrote to you."),), + finished=True, replies=("Done. I paid ACC-PINV-2026-00015.",), + summary="Paid Ogun Dairy ACC-PINV-2026-00015 (128 USDC).") + texts = [e["text"] for e in activity.session_events(session)] + assert not any(t.startswith("To you") for t in texts) + assert "Paid Ogun Dairy ACC-PINV-2026-00015 (128 USDC)." in texts + + +def test_a_changed_invoice_the_agent_decided_on_has_only_the_agent_line(tmp_path): + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + first = run_cycle(FakeLedger(), journal, POLICY, "TEST Shop", at=NOW) + decided, other = TWIN, "ACC-PINV-2026-00017" + changed = [Change(twin(SAME_BILL), ChangeKind.CHANGED, None, "a reason went"), + Change(replace(twin(SAME_BILL), invoice=other), ChangeKind.CHANGED, None, "c")] + session = Session(at=NOW, wakes=(Wake("changed", "c"),), finished=True, + decisions=(agent_choice(decided),)) + report = replace(first, changes=tuple(changed), + thought=Thought(agent={}, session=session)) + shown = {e.get("invoice") for e in activity.cycle_events(report, 15) if e["kind"] == "changed"} + assert decided not in shown + assert other in shown + + +# ---- the reminder keeps the agent's question + + +QUESTION = ("Ogun Dairy's new payout wallet 0x5487...6e80 was set in the ERP on 13 Oct at 03:38. " + "Did Ogun ask you to change its wallet?") + + +def test_a_reminder_names_the_supplier_once_and_keeps_the_question(tmp_path): + d = Decision(invoice=PAID, supplier=OGUN, amount=Decimal(128), due_date=date(2026, 10, 23), + action=Action.ASK, reasons=(FIRST,), + findings=(Finding("payee_wallet", Outcome.ASK, FIRST),)) + choice = replace(agent_choice(PAID, Choice.ASK, d), question=QUESTION) + [entry] = new_questions(tmp_path, [d], {PAID: choice}, NOW, policy=POLICY, + funds=lambda: {"owner_balance": "165.94", + "available_to_pay": "165.94"}) + assert entry["text"] == ( + "Ogun Dairy & Provisions, 128 USDC (ACC-PINV-2026-00015): needs you in the dashboard: " + f"approve its wallet.\n{QUESTION}") + assert entry["buttons"] is False + + +# ---- a hold that only lost reasons does not wake the agent + + +def twin(*reasons): + return Decision(invoice=TWIN, supplier=OGUN, amount=Decimal(128), due_date=date(2026, 10, 23), + action=Action.HOLD, reasons=reasons, + findings=(Finding("duplicate", Outcome.HOLD, SAME_BILL),)) + + +class Holds: + """Holds every invoice it has to decide, and counts its sessions.""" + + model = "scripted" + + def __init__(self): + self.sessions = 0 + + def run(self, toolbox, briefing): + self.sessions += 1 + for invoice in toolbox.undecided(): + toolbox.call("hold", {"invoice": invoice, "reason": "Same Ogun bill entered twice."}) + toolbox.call("finish", {"summary": "Held the twin."}) + return SessionRun(finished=True, steps=1, usage=None) + + +def check(journal, brain, decision, at): + """One check: compare with the last decision, record it, and let the reflexes decide.""" + change = compare(decision, journal.last_entry(decision.invoice)) + journal.record_run([change], [], POLICY, at) + changes = [change] if change.kind is not ChangeKind.SAME else [] + setup = BrainSetup(brain=brain, zone=LAGOS, funds=lambda: None) + return think(setup, journal=journal, ledger=None, decisions=[decision], changes=changes, + policy=POLICY, done=set(), now=at) + + +def test_a_hold_that_only_lost_a_reason_does_not_wake_the_agent(tmp_path): + brain = Holds() + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + check(journal, brain, twin(SAME_BILL, FIRST), NOW) + assert brain.sessions == 1 + # 01:36: paying 00015 dropped "first payment"; the twin is held for the same bill + quiet = check(journal, brain, twin(SAME_BILL), NOW + timedelta(minutes=15)) + assert quiet.session is None and brain.sessions == 1 + later = check(journal, brain, twin(SAME_BILL), NOW + timedelta(minutes=30)) + assert later.session is None and brain.sessions == 1 # not "undecided" either + + +def test_a_hold_with_a_new_reason_still_wakes_the_agent(tmp_path): + brain = Holds() + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + check(journal, brain, twin(SAME_BILL), NOW) + woke = check(journal, brain, twin(SAME_BILL, FIRST), NOW + timedelta(minutes=15)) + assert brain.sessions == 2 + assert [w.kind for w in woke.session.wakes] == ["changed"] + + +def test_a_hold_that_turns_payable_still_wakes_the_agent(tmp_path): + brain = Holds() + payable = replace(twin(), action=Action.PAY, reasons=("all passed",)) + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + check(journal, brain, twin(SAME_BILL), NOW) + check(journal, brain, payable, NOW + timedelta(minutes=15)) + assert brain.sessions == 2 diff --git a/tests/test_questions.py b/tests/test_questions.py index ae23bfd..19d6b6c 100644 --- a/tests/test_questions.py +++ b/tests/test_questions.py @@ -50,13 +50,16 @@ def test_what_needs_the_wallet_is_a_reminder_to_use_the_dashboard(tmp_path): assert entry["buttons"] is False assert entry["text"] == ( "Northern Grains, 476 USDC (ACC-PINV-2026-00017): needs you in the dashboard: raise the " - "per-payment limit (now 250 USDC), add USDC to your wallet (165.95 there).") + "per-payment limit (now 250 USDC), add USDC to your wallet (165.95 there).\n" + "Did the flour arrive?") def test_each_wallet_step_is_named(tmp_path): first = decision(action=Action.ASK, findings=( Finding("payee_wallet", Outcome.ASK, "first payment to this supplier"),)) - assert "approve Northern Grains's wallet" in questions(tmp_path, first)[0]["text"] + wallet = questions(tmp_path, first)[0]["text"] + assert "needs you in the dashboard: approve its wallet." in wallet + assert wallet.count("Northern Grains") == 1 week = questions(tmp_path / "w", decision(), room=Decimal("100.004"))[0]["text"] assert "raise the weekly limit or wait for it to renew (100 USDC left)" in week owed = questions(tmp_path / "a", decision(),