Skip to content

Commit 06dcf39

Browse files
bokelleyclaude
andcommitted
feat(accounts)!: ensure returns an AccountOutcome and routes to request_account
client.accounts.ensure is reshaped, not added (DX plan slice P8). - It routes by capability: request_account when the seller advertises account.requests, sync_accounts otherwise (which needs billing=). - It returns an AccountOutcome: AccountReady, AccountPending (with a PendingAction), AccountAmbiguous (with choose(account_id)) or AccountRejected. A sync_accounts pending_approval row with setup maps to AccountPending, and a failed row to AccountRejected. Pending, ambiguous and rejected are values; only a failure to process the call raises AccountRequestError. - It stays idempotent per seller, buyer identity and natural key: a repeat call returns the account, or the open request's current state read back through list_accounts account_request_ids, and never opens a duplicate. currency= and timezone= join the key. - accounts.refresh() re-reads open requests and pending accounts and returns the outcomes whose state moved; accounts.pending() lists what is still waiting without a network call. Both see records across restarts through storage that implements items(seller) (ListableAccountStorage), as InMemoryAccountStorage does. - next_step(outcome) returns wait_for_seller, send_human_to_url, pick_candidate, contact_seller or none, so agents never switch on the extensible kind. format_pending_action(action) renders safe text: the seller message is untrusted, and the URL is for a person, never fetched. AccountRecord gains request_id, request_state and pending_action. BREAKING CHANGE: AccountRegistry.ensure returns an AccountOutcome instead of an AccountRecord, billing= is optional and required only on the sync_accounts path, and a failed or rejected row is returned as AccountRejected instead of raised. AccountReady keeps account_id and status. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 9bcc783 commit 06dcf39

8 files changed

Lines changed: 960 additions & 54 deletions

File tree

‎docs/account-lifecycle.md‎

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@ Start with the seller's capabilities and discover public inventory before
3333
choosing billing and payment terms:
3434

3535
```python
36-
from adcp import ADCPClient
36+
from adcp import (
37+
AccountAmbiguous,
38+
AccountPending,
39+
AccountReady,
40+
AccountRejected,
41+
ADCPClient,
42+
format_pending_action,
43+
)
3744
from adcp.types import GetProductsRequest
3845

3946
client = ADCPClient(agent_config, account_policy="auto", raise_account_errors=True)
@@ -50,20 +57,59 @@ key = {
5057
public = await client.get_products(GetProductsRequest(buying_mode="wholesale", account=key))
5158

5259
# Provision only when you have selected the commercial terms.
53-
account = await client.accounts.ensure(
60+
outcome = await client.accounts.ensure(
5461
key, billing="agent", payment_terms="net_30", billing_entity=billing_entity,
5562
)
56-
print(account.status, account.account_id)
63+
match outcome:
64+
case AccountReady(account_id=account_id, status=status):
65+
print(status, account_id)
66+
case AccountPending(action=action):
67+
print(format_pending_action(action)) # show a person; never fetch action.url
68+
case AccountAmbiguous(candidates=candidates):
69+
outcome = await outcome.choose(candidates[0]["account_id"])
70+
case AccountRejected(errors=errors):
71+
print(errors)
5772

5873
# The provisioned natural key is retained on subsequent requests.
5974
priced = await client.get_products(
6075
GetProductsRequest(buying_mode="wholesale", account=key), account_policy="strict",
6176
)
6277
```
6378

64-
`ensure` calls `sync_accounts` once for each complete key at each seller and
65-
returns an `AccountRecord`. Successful pending-approval or payment-required
66-
accounts are provisioned; they still need buyer action before committing spend.
79+
`ensure` returns an `AccountOutcome` (AdCP 3.3, a breaking change from the
80+
`AccountRecord` it returned before), one of:
81+
82+
- `AccountReady(account_id, status, via, ...)`: the account exists. Its status may
83+
still need buyer action before spend (`payment_required`, for example).
84+
- `AccountPending(action, via, request_id, ...)`: waiting on the seller or a
85+
buyer-side human. `action` is a `PendingAction(waiting_on, kind, message, url,
86+
expires_at)`. `url` is for a person to open; it is not a bearer link and an
87+
agent must never fetch it. Render it with `format_pending_action(action)`,
88+
which treats `message` as untrusted text.
89+
- `AccountAmbiguous(candidates, request_id)`: several of your existing accounts
90+
match; `await outcome.choose(account_id)` settles it.
91+
- `AccountRejected(errors, via, request_id)`: the seller declined, or a buyer
92+
action expired.
93+
94+
Pending, ambiguous and rejected are values, not errors; only a failure to
95+
process the call raises `AccountRequestError`. Agents should switch on
96+
`next_step(outcome)` (`none`, `wait_for_seller`, `send_human_to_url`,
97+
`pick_candidate` or `contact_seller`), not on `action.kind`, which is extensible.
98+
99+
`ensure` routes by capability. When the seller advertises `account.requests`, it
100+
calls `request_account`. Otherwise it calls `sync_accounts` once for each
101+
complete key at each seller, which needs `billing=`; a `pending_approval` row
102+
with `setup` is `AccountPending`. Either way `ensure` is idempotent: a repeat
103+
call returns the account, or the open request's current state read back through
104+
`list_accounts`, and never opens a duplicate. `currency=` and `timezone=` join
105+
the key when it does not carry them.
106+
107+
`await client.accounts.refresh()` re-reads open requests and pending accounts
108+
(call it from a cron job or an `account_request.changed` webhook) and returns
109+
the outcomes whose state moved; `await client.accounts.pending()` lists what is
110+
still waiting without a network call. Both find records across restarts when
111+
the storage implements `items(seller)` (`ListableAccountStorage`), as
112+
`InMemoryAccountStorage` does.
67113
Only active and payment-required accounts can browse account-priced products;
68114
other statuses use public discovery in `auto` mode when allowed, or raise in
69115
`strict` mode.

‎src/adcp/__init__.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,17 @@ def _resolve_version() -> str:
113113
"AccountRegistry",
114114
"AccountStorage",
115115
"InMemoryAccountStorage",
116+
"ListableAccountStorage",
117+
),
118+
"adcp.account_outcomes": (
119+
"AccountAmbiguous",
120+
"AccountOutcome",
121+
"AccountPending",
122+
"AccountReady",
123+
"AccountRejected",
124+
"PendingAction",
125+
"format_pending_action",
126+
"next_step",
116127
),
117128
"adcp.acceptance": (
118129
"AcceptancePolicyAssessment",
@@ -270,6 +281,7 @@ def _resolve_version() -> str:
270281
"AccountNotFoundError",
271282
"AccountSetupRequiredError",
272283
"AccountPaymentRequiredError",
284+
"AccountRequestError",
273285
"ConfigurationError",
274286
"extract_adcp_error_info",
275287
"is_pre_dispatch_error",
@@ -1035,6 +1047,15 @@ def get_adcp_version() -> str:
10351047
"AccountRegistry",
10361048
"AccountStorage",
10371049
"InMemoryAccountStorage",
1050+
"ListableAccountStorage",
1051+
"AccountAmbiguous",
1052+
"AccountOutcome",
1053+
"AccountPending",
1054+
"AccountReady",
1055+
"AccountRejected",
1056+
"PendingAction",
1057+
"format_pending_action",
1058+
"next_step",
10381059
"ADCPClient",
10391060
"ADCPMultiAgentClient",
10401061
"Checkpoint",
@@ -1646,6 +1667,7 @@ def get_adcp_version() -> str:
16461667
"AccountNotFoundError",
16471668
"AccountSetupRequiredError",
16481669
"AccountPaymentRequiredError",
1670+
"AccountRequestError",
16491671
"ConfigurationError",
16501672
"IdempotencyConflictError",
16511673
"IdempotencyExpiredError",
@@ -1819,12 +1841,23 @@ def get_adcp_version() -> str:
18191841
AcceptancePolicyResolver,
18201842
AcceptancePolicySurface,
18211843
)
1844+
from adcp.account_outcomes import (
1845+
AccountAmbiguous,
1846+
AccountOutcome,
1847+
AccountPending,
1848+
AccountReady,
1849+
AccountRejected,
1850+
PendingAction,
1851+
format_pending_action,
1852+
next_step,
1853+
)
18221854
from adcp.accounts import (
18231855
AccountPolicy,
18241856
AccountRecord,
18251857
AccountRegistry,
18261858
AccountStorage,
18271859
InMemoryAccountStorage,
1860+
ListableAccountStorage,
18281861
)
18291862
from adcp.adagents import (
18301863
AdagentsCacheEntry,
@@ -1876,6 +1909,7 @@ def get_adcp_version() -> str:
18761909
from adcp.exceptions import ( # noqa: F401
18771910
AccountNotFoundError,
18781911
AccountPaymentRequiredError,
1912+
AccountRequestError,
18791913
AccountSetupRequiredError,
18801914
AdagentsAccessBlockedError,
18811915
AdagentsHTTPError,

‎src/adcp/account_outcomes.py‎

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""What ``client.accounts.ensure`` returns: a discriminated account outcome (AdCP 3.3).
2+
3+
Pending, ambiguous and rejected are values, not errors. Only a failure to
4+
process the call raises (:class:`~adcp.exceptions.AccountRequestError`)::
5+
6+
outcome = await client.accounts.ensure(key, billing="operator")
7+
match outcome:
8+
case AccountReady(account_id=account_id): use(account_id)
9+
case AccountPending(action=action) if action.waiting_on == "buyer":
10+
show_to_human(format_pending_action(action))
11+
case AccountAmbiguous(candidates=candidates):
12+
outcome = await outcome.choose(pick(candidates))
13+
case AccountRejected(errors=errors): report(errors)
14+
15+
Agents should switch on :func:`next_step`, not on ``PendingAction.kind``,
16+
which is extensible. The TypeScript SDK spells these ``AccountOutcome``,
17+
``nextStep`` and ``formatPendingAction``.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import re
23+
from collections.abc import Awaitable, Callable, Mapping, Sequence
24+
from dataclasses import dataclass, field
25+
from typing import Any, Literal
26+
27+
__all__ = [
28+
"AccountAmbiguous",
29+
"AccountOutcome",
30+
"AccountPending",
31+
"AccountReady",
32+
"AccountRejected",
33+
"NextStep",
34+
"PendingAction",
35+
"format_pending_action",
36+
"next_step",
37+
]
38+
39+
#: The provisioning path an outcome came from.
40+
AccountVia = Literal["sync_accounts", "request_account"]
41+
NextStep = Literal[
42+
"wait_for_seller", "send_human_to_url", "pick_candidate", "contact_seller", "none"
43+
]
44+
45+
46+
@dataclass(frozen=True)
47+
class PendingAction:
48+
"""Whose turn it is and what is needed (the wire ``pending_action``/``setup``).
49+
50+
``url`` is where a *human* completes the action. Show it to a person. It is
51+
not a bearer link, and an agent must never fetch it. ``message`` is
52+
untrusted seller text; render it through :func:`format_pending_action`.
53+
``kind`` is extensible: switch on :func:`next_step` instead.
54+
"""
55+
56+
waiting_on: str
57+
kind: str
58+
message: str
59+
url: str | None = None
60+
expires_at: str | None = None
61+
62+
@classmethod
63+
def from_wire(cls, value: Mapping[str, Any]) -> PendingAction:
64+
url = value.get("url")
65+
waiting_on = value.get("waiting_on") or ("buyer" if url else "seller")
66+
return cls(
67+
waiting_on=str(waiting_on),
68+
kind=str(value.get("kind") or "review"),
69+
message=str(value.get("message") or ""),
70+
url=None if url is None else str(url),
71+
expires_at=None if value.get("expires_at") is None else str(value["expires_at"]),
72+
)
73+
74+
def to_wire(self) -> dict[str, Any]:
75+
payload: dict[str, Any] = {
76+
"waiting_on": self.waiting_on,
77+
"kind": self.kind,
78+
"message": self.message,
79+
}
80+
if self.url is not None:
81+
payload["url"] = self.url
82+
if self.expires_at is not None:
83+
payload["expires_at"] = self.expires_at
84+
return payload
85+
86+
87+
@dataclass(frozen=True)
88+
class AccountReady:
89+
"""The account exists: use ``account_id`` on account-scoped tasks.
90+
91+
``status`` is the account's status, which may still need buyer action
92+
before spend (``payment_required``, for example).
93+
"""
94+
95+
account_id: str | None
96+
status: str | None
97+
via: AccountVia
98+
request_id: str | None = None
99+
account: Mapping[str, Any] | None = None
100+
kind: Literal["ready"] = "ready"
101+
102+
103+
@dataclass(frozen=True)
104+
class AccountPending:
105+
"""Waiting on someone: :attr:`action` says who and what.
106+
107+
``request_id`` identifies an open ``request_account`` request (``None``
108+
on the ``sync_accounts`` path, where ``account_id`` may already exist).
109+
The registry keeps it, so :meth:`~adcp.accounts.AccountRegistry.refresh`
110+
reports when it moves and a repeat ``ensure`` never opens a duplicate.
111+
"""
112+
113+
action: PendingAction
114+
via: AccountVia
115+
request_id: str | None = None
116+
account_id: str | None = None
117+
status: str | None = None
118+
kind: Literal["pending"] = "pending"
119+
120+
121+
@dataclass(frozen=True)
122+
class AccountAmbiguous:
123+
"""Several existing accounts match; pick one with :meth:`choose`.
124+
125+
``candidates`` are the wire ``account_candidate`` objects, limited to
126+
accounts the caller can already reach.
127+
"""
128+
129+
candidates: tuple[Mapping[str, Any], ...]
130+
request_id: str
131+
via: AccountVia = "request_account"
132+
kind: Literal["ambiguous"] = "ambiguous"
133+
_choose: Callable[[str, str], Awaitable[AccountOutcome]] | None = field(
134+
default=None, repr=False, compare=False
135+
)
136+
137+
async def choose(self, account_id: str) -> AccountOutcome:
138+
"""Continue the request with the chosen candidate; returns the new outcome."""
139+
if self._choose is None:
140+
raise RuntimeError("this outcome is not bound to an account registry")
141+
if account_id not in {str(item.get("account_id")) for item in self.candidates}:
142+
raise ValueError(f"{account_id!r} is not one of this request's candidates")
143+
return await self._choose(self.request_id, account_id)
144+
145+
146+
@dataclass(frozen=True)
147+
class AccountRejected:
148+
"""The seller declined, or a buyer action expired. ``errors`` are wire errors."""
149+
150+
errors: tuple[Mapping[str, Any], ...]
151+
via: AccountVia
152+
request_id: str | None = None
153+
kind: Literal["rejected"] = "rejected"
154+
155+
156+
AccountOutcome = AccountReady | AccountPending | AccountAmbiguous | AccountRejected
157+
158+
159+
def next_step(outcome: AccountOutcome) -> NextStep:
160+
"""What to do next, independent of the extensible ``kind`` vocabulary.
161+
162+
* ``none``: the account is ready.
163+
* ``wait_for_seller``: the seller is reviewing; call ``refresh()`` later
164+
or wait for the ``account_request.changed`` webhook.
165+
* ``send_human_to_url``: show ``action.url`` to a person (never fetch it).
166+
* ``pick_candidate``: choose one of ``candidates``.
167+
* ``contact_seller``: rejected, or a buyer action without a URL.
168+
"""
169+
if isinstance(outcome, AccountReady):
170+
return "none"
171+
if isinstance(outcome, AccountAmbiguous):
172+
return "pick_candidate"
173+
if isinstance(outcome, AccountRejected):
174+
return "contact_seller"
175+
if outcome.action.waiting_on == "buyer":
176+
return "send_human_to_url" if outcome.action.url else "contact_seller"
177+
return "wait_for_seller"
178+
179+
180+
_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f​-‏‪-‮⁦-⁩]")
181+
182+
183+
def _untrusted(text: str, *, limit: int = 1000) -> str:
184+
cleaned = " ".join(_CONTROL.sub("", text).split())
185+
if len(cleaned) > limit:
186+
cleaned = cleaned[: limit - 1] + "…"
187+
return cleaned.replace('"', "'")
188+
189+
190+
def format_pending_action(action: PendingAction) -> str:
191+
"""Safe text for a CLI or a chat: who is waiting, what is needed, and where.
192+
193+
The seller's ``message`` is untrusted: control and bidirectional
194+
characters are removed, whitespace is collapsed, it is quoted and capped
195+
at 1000 characters. The URL is shown for a person to open, never followed.
196+
"""
197+
who = "you" if action.waiting_on == "buyer" else "the seller"
198+
lines = [f"Waiting on {who}: {_untrusted(action.kind, limit=64)}."]
199+
if action.message:
200+
lines.append(f'Seller says: "{_untrusted(action.message)}"')
201+
if action.url:
202+
lines.append(f"A person completes it at {_untrusted(action.url, limit=2048)}")
203+
if action.expires_at:
204+
lines.append(f"Expires {_untrusted(action.expires_at, limit=64)}.")
205+
return "\n".join(lines)
206+
207+
208+
def _errors(value: Any) -> tuple[Mapping[str, Any], ...]:
209+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
210+
return ()
211+
return tuple(dict(item) for item in value if isinstance(item, Mapping))

0 commit comments

Comments
 (0)