shopping-cli 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. shopping_cli/__init__.py +3 -0
  2. shopping_cli/agents/__init__.py +1 -0
  3. shopping_cli/agents/agent_cli.py +31 -0
  4. shopping_cli/agents/buyer_cli.py +163 -0
  5. shopping_cli/agents/merchant_agent.py +263 -0
  6. shopping_cli/agents/merchant_daemon.py +872 -0
  7. shopping_cli/agents/tools.py +488 -0
  8. shopping_cli/api/__init__.py +1 -0
  9. shopping_cli/api/app.py +1353 -0
  10. shopping_cli/api/auth.py +125 -0
  11. shopping_cli/api/fallback_asgi.py +219 -0
  12. shopping_cli/api/handlers/__init__.py +1 -0
  13. shopping_cli/api/handlers/agents.py +277 -0
  14. shopping_cli/api/handlers/audit.py +97 -0
  15. shopping_cli/api/handlers/buyer.py +291 -0
  16. shopping_cli/api/handlers/catalog.py +392 -0
  17. shopping_cli/api/handlers/common.py +76 -0
  18. shopping_cli/api/handlers/conversations.py +172 -0
  19. shopping_cli/api/handlers/erp.py +127 -0
  20. shopping_cli/api/handlers/human_review.py +293 -0
  21. shopping_cli/api/handlers/listings_projection.py +42 -0
  22. shopping_cli/api/handlers/negotiation.py +121 -0
  23. shopping_cli/api/idempotency.py +291 -0
  24. shopping_cli/api/limits.py +59 -0
  25. shopping_cli/api/route_registry.py +105 -0
  26. shopping_cli/api/routes_agents.py +13 -0
  27. shopping_cli/api/routes_conversations.py +13 -0
  28. shopping_cli/api/routes_marketplace.py +13 -0
  29. shopping_cli/api/routes_merchants.py +13 -0
  30. shopping_cli/api/server.py +31 -0
  31. shopping_cli/cli.py +897 -0
  32. shopping_cli/cli_agent_commands.py +386 -0
  33. shopping_cli/cli_buyer_commands.py +231 -0
  34. shopping_cli/cli_catalog_commands.py +281 -0
  35. shopping_cli/cli_common.py +135 -0
  36. shopping_cli/cli_conversation_commands.py +167 -0
  37. shopping_cli/cli_erp_commands.py +60 -0
  38. shopping_cli/cli_listing_commands.py +35 -0
  39. shopping_cli/commerce/__init__.py +6 -0
  40. shopping_cli/commerce/adapters.py +310 -0
  41. shopping_cli/commerce/data_source.py +95 -0
  42. shopping_cli/config.py +262 -0
  43. shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +47 -0
  44. shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +120 -0
  45. shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +52 -0
  46. shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +172 -0
  47. shopping_cli/core/__init__.py +1 -0
  48. shopping_cli/core/catalog.py +1281 -0
  49. shopping_cli/core/channels.py +329 -0
  50. shopping_cli/core/conversations.py +502 -0
  51. shopping_cli/core/delivery.py +7 -0
  52. shopping_cli/core/errors.py +43 -0
  53. shopping_cli/core/harness.py +345 -0
  54. shopping_cli/core/limits.py +137 -0
  55. shopping_cli/core/negotiation.py +273 -0
  56. shopping_cli/core/policies.py +356 -0
  57. shopping_cli/core/risk.py +101 -0
  58. shopping_cli/core/tokens.py +33 -0
  59. shopping_cli/data_sources/__init__.py +12 -0
  60. shopping_cli/data_sources/erp_source.py +440 -0
  61. shopping_cli/db/__init__.py +1 -0
  62. shopping_cli/db/migrations.py +415 -0
  63. shopping_cli/db/models.py +325 -0
  64. shopping_cli/db/provenance.py +21 -0
  65. shopping_cli/db/session.py +108 -0
  66. shopping_cli/http_client.py +149 -0
  67. shopping_cli/listings/__init__.py +4 -0
  68. shopping_cli/listings/projection.py +148 -0
  69. shopping_cli/services/__init__.py +1 -0
  70. shopping_cli/services/agents.py +244 -0
  71. shopping_cli/services/audit.py +34 -0
  72. shopping_cli/services/buyer_bootstrap.py +23 -0
  73. shopping_cli/services/conversations.py +209 -0
  74. shopping_cli/services/human_review.py +165 -0
  75. shopping_cli/services/negotiation.py +929 -0
  76. shopping_cli/services/tokens.py +318 -0
  77. shopping_cli-3.0.0.dist-info/METADATA +219 -0
  78. shopping_cli-3.0.0.dist-info/RECORD +82 -0
  79. shopping_cli-3.0.0.dist-info/WHEEL +5 -0
  80. shopping_cli-3.0.0.dist-info/entry_points.txt +4 -0
  81. shopping_cli-3.0.0.dist-info/licenses/LICENSE +201 -0
  82. shopping_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,125 @@
1
+ """API authentication and bearer-token payload helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+ from shopping_cli.core.channels import normalize_channel
10
+ from shopping_cli.core.errors import AuthError
11
+ from shopping_cli.core.tokens import token_matches
12
+ from shopping_cli.config import secret_is_usable
13
+ from shopping_cli.api.limits import validate_payload
14
+
15
+
16
+ def payload_token(payload: dict[str, Any]) -> str:
17
+ return str(
18
+ payload.get("merchant_token")
19
+ or payload.get("agent_token")
20
+ or payload.get("buyer_token")
21
+ or payload.get("_auth_token")
22
+ or ""
23
+ )
24
+
25
+
26
+ def payload_admin_token(payload: dict[str, Any]) -> str:
27
+ return str(payload.get("admin_token") or payload.get("_auth_token") or "")
28
+
29
+
30
+ def payload_channel_token(payload: dict[str, Any]) -> str:
31
+ return str(payload.get("channel_token") or payload.get("_auth_token") or "")
32
+
33
+
34
+ def payload_buyer_bootstrap_token(payload: dict[str, Any]) -> str:
35
+ return str(payload.get("buyer_bootstrap_token") or payload.get("_auth_token") or "")
36
+
37
+
38
+ def payload_with_auth(
39
+ payload: dict[str, Any],
40
+ authorization: Any = "",
41
+ idempotency_key: Any = "",
42
+ ) -> dict[str, Any]:
43
+ validate_payload(payload)
44
+ merged = dict(payload or {})
45
+ if isinstance(authorization, str) and authorization.lower().startswith("bearer "):
46
+ merged["_auth_token"] = authorization.split(" ", 1)[1].strip()
47
+ if isinstance(idempotency_key, str) and idempotency_key.strip():
48
+ merged["_idempotency_key"] = idempotency_key.strip()
49
+ return merged
50
+
51
+
52
+ def configured_admin_token() -> str:
53
+ return str(os.environ.get("SHOPPING_ADMIN_TOKEN") or "").strip()
54
+
55
+
56
+ def configured_buyer_bootstrap_token() -> str:
57
+ return str(os.environ.get("SHOPPING_BUYER_BOOTSTRAP_TOKEN") or "").strip()
58
+
59
+
60
+ def require_admin_token(payload: dict[str, Any]) -> None:
61
+ expected = configured_admin_token()
62
+ if not secret_is_usable(expected):
63
+ raise AuthError("admin bootstrap token is not configured")
64
+ token = payload_admin_token(payload)
65
+ if not token:
66
+ raise AuthError("admin bootstrap token required")
67
+ if not token_matches(token, expected):
68
+ raise AuthError("invalid admin bootstrap token")
69
+
70
+
71
+ def require_buyer_bootstrap_token(payload: dict[str, Any], digest_fn: Any) -> str:
72
+ expected = configured_buyer_bootstrap_token()
73
+ if not secret_is_usable(expected):
74
+ raise AuthError("buyer bootstrap token is not configured")
75
+ token = payload_buyer_bootstrap_token(payload)
76
+ if not token:
77
+ raise AuthError("buyer bootstrap token required")
78
+ if not token_matches(token, expected):
79
+ raise AuthError("invalid buyer bootstrap token")
80
+ return digest_fn(token)
81
+
82
+
83
+ def channel_token_map() -> dict[str, str]:
84
+ tokens: dict[str, str] = {}
85
+ global_token = str(os.environ.get("SHOPPING_CHANNEL_TOKEN") or "").strip()
86
+ if secret_is_usable(global_token):
87
+ tokens["*"] = global_token
88
+ raw = str(os.environ.get("SHOPPING_CHANNEL_TOKENS") or "").strip()
89
+ if not raw:
90
+ return tokens
91
+ try:
92
+ decoded = json.loads(raw)
93
+ except json.JSONDecodeError:
94
+ decoded = None
95
+ if isinstance(decoded, dict):
96
+ for channel, token in decoded.items():
97
+ normalized = normalize_channel(str(channel))
98
+ if normalized and secret_is_usable(str(token or "")):
99
+ tokens[normalized] = str(token).strip()
100
+ return tokens
101
+ for part in raw.replace("\n", ",").split(","):
102
+ text = part.strip()
103
+ if not text:
104
+ continue
105
+ separator = ":" if ":" in text else "=" if "=" in text else ""
106
+ if not separator:
107
+ continue
108
+ channel, token = text.split(separator, 1)
109
+ normalized = normalize_channel(channel)
110
+ if normalized and secret_is_usable(token):
111
+ tokens[normalized] = token.strip()
112
+ return tokens
113
+
114
+
115
+ def require_channel_token(channel: str, payload: dict[str, Any]) -> None:
116
+ normalized = normalize_channel(channel)
117
+ tokens = channel_token_map()
118
+ expected = tokens.get(normalized) or tokens.get("*") or ""
119
+ if not expected:
120
+ raise AuthError(f"channel token is not configured for {normalized or '-'}")
121
+ token = payload_channel_token(payload)
122
+ if not token:
123
+ raise AuthError("channel token required")
124
+ if not token_matches(token, expected):
125
+ raise AuthError("invalid channel token")
@@ -0,0 +1,219 @@
1
+ """Lightweight ASGI fallback used when FastAPI is unavailable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+ from typing import Any, Callable
11
+ from urllib.parse import parse_qs
12
+
13
+ from shopping_cli.api import auth as api_auth
14
+ from shopping_cli.api.limits import max_request_body_bytes
15
+
16
+
17
+ def compute_etag(content: str | bytes) -> str:
18
+ """Compute a strong ETag (quoted content hash) for an HTTP response body.
19
+
20
+ Same semantics as the discovery cache module used before v3.0 (the Agent
21
+ Catalog subsystem moved to the standalone kiwi-catalog service): the same
22
+ content always yields the same ETag, and the value is opaque to clients.
23
+ """
24
+ if isinstance(content, str):
25
+ content = content.encode("utf-8")
26
+ return f'"{hashlib.sha256(content).hexdigest()}"'
27
+
28
+
29
+ def etag_matches(if_none_match: str, etag: str) -> bool:
30
+ """True when an ``If-None-Match`` header value matches *etag*.
31
+
32
+ Handles strong and weak ETags and the ``*`` wildcard. The header is
33
+ untrusted request data; every comparison is against the server's own
34
+ computed *etag*, so a malformed header simply never matches.
35
+ """
36
+ expected = etag.strip('"')
37
+ for raw in if_none_match.split(","):
38
+ token = raw.strip()
39
+ if token == "*":
40
+ return True
41
+ if token.startswith("W/"):
42
+ token = token[2:].strip()
43
+ token = token.strip('"')
44
+ if token and token == expected:
45
+ return True
46
+ return False
47
+
48
+
49
+ HandleRequest = Callable[[str | Path, str, str, dict[str, Any] | None, dict[str, Any] | None], tuple[int, dict[str, Any]]]
50
+ RouteProvider = Callable[[], list[Any]]
51
+ RouteResolver = Callable[[str, str], tuple[bool, bool]]
52
+
53
+
54
+ class MarketplaceASGIApp:
55
+ title = "shopping-cli Marketplace API"
56
+
57
+ def __init__(
58
+ self,
59
+ db_path: str | Path,
60
+ handle_request_fn: HandleRequest | None = None,
61
+ route_provider: RouteProvider | None = None,
62
+ route_resolver: RouteResolver | None = None,
63
+ ):
64
+ self.state = SimpleNamespace(db_path=str(db_path), fastapi_available=False)
65
+ self._handle_request = handle_request_fn
66
+ self._route_provider = route_provider
67
+ self._route_resolver = route_resolver
68
+ self.routes = self._routes()
69
+
70
+ def _routes(self) -> list[Any]:
71
+ provider = self._route_provider
72
+ if provider is None:
73
+ from shopping_cli.api.route_registry import route_info
74
+
75
+ provider = route_info
76
+ return provider()
77
+
78
+ def _handler(self) -> HandleRequest:
79
+ if self._handle_request is None:
80
+ from shopping_cli.api.app import handle_request
81
+
82
+ self._handle_request = handle_request
83
+ return self._handle_request
84
+
85
+ def _resolver(self) -> RouteResolver:
86
+ if self._route_resolver is None:
87
+ if self._handle_request is not None:
88
+ # Custom handlers own their routing; stay permissive for them.
89
+ return lambda _method, _path: (True, True)
90
+ from shopping_cli.api.app import resolve_route
91
+
92
+ self._route_resolver = resolve_route
93
+ return self._route_resolver
94
+
95
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
96
+ if scope.get("type") != "http":
97
+ await send({"type": "http.response.start", "status": 404, "headers": [(b"content-type", b"application/json")]})
98
+ await send({"type": "http.response.body", "body": b'{"ok":false,"error":"unsupported scope"}'})
99
+ return
100
+ headers = {
101
+ key.decode("latin1").lower(): value.decode("latin1")
102
+ for key, value in scope.get("headers", [])
103
+ }
104
+ maximum = max_request_body_bytes()
105
+ try:
106
+ content_length = int(headers.get("content-length", "0") or 0)
107
+ except ValueError:
108
+ content_length = 0
109
+ if content_length > maximum:
110
+ await self._send_json(send, 413, {"ok": False, "error": "request body is too large"})
111
+ return
112
+ method = str(scope.get("method") or "GET").upper()
113
+ path = str(scope.get("path") or "/")
114
+ path_known, method_allowed = self._resolver()(method, path)
115
+ if not path_known:
116
+ await self._send_json(send, 404, {"ok": False, "error": f"No route for {method} {path}"})
117
+ return
118
+ if not method_allowed:
119
+ await self._send_json(send, 405, {"ok": False, "error": f"Method not allowed for {method} {path}"})
120
+ return
121
+ chunks: list[bytes] = []
122
+ body_size = 0
123
+ while True:
124
+ message = await receive()
125
+ chunk = message.get("body", b"")
126
+ body_size += len(chunk)
127
+ if body_size > maximum:
128
+ await self._send_json(send, 413, {"ok": False, "error": "request body is too large"})
129
+ return
130
+ chunks.append(chunk)
131
+ if not message.get("more_body", False):
132
+ break
133
+ try:
134
+ decoded_payload = json.loads(b"".join(chunks).decode("utf-8") or "{}")
135
+ except (UnicodeDecodeError, json.JSONDecodeError):
136
+ body = json.dumps(
137
+ {"ok": False, "error": "invalid JSON request body"},
138
+ ensure_ascii=False,
139
+ sort_keys=True,
140
+ ).encode("utf-8")
141
+ await send({"type": "http.response.start", "status": 400, "headers": [(b"content-type", b"application/json")]})
142
+ await send({"type": "http.response.body", "body": body})
143
+ return
144
+ if not isinstance(decoded_payload, dict):
145
+ body = json.dumps(
146
+ {"ok": False, "error": "JSON request body must be an object"},
147
+ ensure_ascii=False,
148
+ sort_keys=True,
149
+ ).encode("utf-8")
150
+ await send({"type": "http.response.start", "status": 400, "headers": [(b"content-type", b"application/json")]})
151
+ await send({"type": "http.response.body", "body": body})
152
+ return
153
+ payload = decoded_payload
154
+ payload = api_auth.payload_with_auth(
155
+ payload,
156
+ authorization=headers.get("authorization", ""),
157
+ idempotency_key=headers.get("idempotency-key", ""),
158
+ )
159
+ try:
160
+ raw_query = scope.get("query_string", b"").decode("utf-8")
161
+ except UnicodeDecodeError:
162
+ raw_query = ""
163
+ query = parse_qs(raw_query, keep_blank_values=True)
164
+ status, response = await asyncio.to_thread(
165
+ self._handler(),
166
+ self.state.db_path,
167
+ method,
168
+ path,
169
+ payload,
170
+ {key: values[-1] if values else "" for key, values in query.items()},
171
+ )
172
+ await self._send_json(
173
+ send,
174
+ status,
175
+ response,
176
+ if_none_match=headers.get("if-none-match", ""),
177
+ allow_304=method == "GET",
178
+ )
179
+
180
+ @staticmethod
181
+ async def _send_json(
182
+ send: Any,
183
+ status: int,
184
+ response: dict[str, Any],
185
+ *,
186
+ if_none_match: str = "",
187
+ allow_304: bool = False,
188
+ ) -> None:
189
+ """Serialize *response* with a §18 server-side ETag.
190
+
191
+ When the caller allows conditional GET and the client's
192
+ ``If-None-Match`` matches the computed ETag, a body-less 304 is sent.
193
+ """
194
+ body = json.dumps(response, ensure_ascii=False, sort_keys=True).encode("utf-8")
195
+ etag = compute_etag(body)
196
+ # A conditional GET only revalidates a *successful* representation:
197
+ # never 304 an error body (e.g. a 404 for If-None-Match: *).
198
+ if allow_304 and status == 200 and if_none_match and etag_matches(if_none_match, etag):
199
+ await send(
200
+ {
201
+ "type": "http.response.start",
202
+ "status": 304,
203
+ "headers": [(b"etag", etag.encode("ascii"))],
204
+ }
205
+ )
206
+ await send({"type": "http.response.body", "body": b""})
207
+ return
208
+ await send(
209
+ {
210
+ "type": "http.response.start",
211
+ "status": status,
212
+ "headers": [
213
+ (b"content-type", b"application/json"),
214
+ (b"etag", etag.encode("ascii")),
215
+ (b"content-length", str(len(body)).encode("ascii")),
216
+ ],
217
+ }
218
+ )
219
+ await send({"type": "http.response.body", "body": body})
@@ -0,0 +1 @@
1
+ """HTTP handler modules for marketplace API domains."""
@@ -0,0 +1,277 @@
1
+ """Agent API handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from shopping_cli.agents import merchant_agent
10
+ from shopping_cli.api import auth as api_auth
11
+ from shopping_cli.api.handlers.common import (
12
+ DEFAULT_RESULT_LIMIT,
13
+ non_negative_whole_int,
14
+ positive_whole_int,
15
+ require_field,
16
+ result_limit,
17
+ result_offset,
18
+ )
19
+ from shopping_cli.config import agent_stale_ttl_seconds_from
20
+ from shopping_cli.core.conversations import conversation_summary
21
+ from shopping_cli.core.errors import AuthError, ValidationError
22
+ from shopping_cli.core.harness import (
23
+ abandon_agent_message,
24
+ abandon_stale_agent_messages,
25
+ agent_message_process_summary,
26
+ claim_agent_message,
27
+ complete_agent_message,
28
+ fail_agent_message,
29
+ )
30
+ from shopping_cli.db.session import db_session
31
+ from shopping_cli.services import agents as agent_service
32
+ from shopping_cli.services import tokens as token_service
33
+
34
+
35
+ def _payload_token(payload: dict[str, Any]) -> str:
36
+ return api_auth.payload_token(payload)
37
+
38
+
39
+ def _positive_whole_seconds(value: Any, field_name: str) -> int | None:
40
+ if value in (None, ""):
41
+ return None
42
+ if isinstance(value, bool):
43
+ raise ValidationError(f"{field_name} must be a whole number")
44
+ if isinstance(value, int):
45
+ seconds = value
46
+ elif isinstance(value, float):
47
+ if not math.isfinite(value) or not value.is_integer():
48
+ raise ValidationError(f"{field_name} must be a whole number")
49
+ seconds = int(value)
50
+ else:
51
+ try:
52
+ seconds = int(str(value).strip())
53
+ except ValueError as exc:
54
+ raise ValidationError(f"{field_name} must be a whole number") from exc
55
+ if seconds <= 0:
56
+ raise ValidationError(f"{field_name} must be greater than 0")
57
+ return seconds
58
+
59
+
60
+ def default_merchant_agent_id(merchant_id: str) -> str:
61
+ return token_service.default_merchant_agent_id(merchant_id)
62
+
63
+
64
+ def agent_heartbeat(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
65
+ with db_session(db_path) as conn:
66
+ merchant_id = str(require_field(payload, "merchant_id"))
67
+ agent_id = default_merchant_agent_id(merchant_id)
68
+ token_service.require_agent_or_merchant_token(conn, merchant_id, agent_id, _payload_token(payload))
69
+ agent = merchant_agent.heartbeat(
70
+ conn,
71
+ merchant_id=merchant_id,
72
+ status=str(payload.get("status") or "online"),
73
+ capabilities=payload.get("capabilities"),
74
+ pid=non_negative_whole_int(payload.get("pid"), "pid"),
75
+ version=str(payload.get("version") or ""),
76
+ last_error=str(payload.get("last_error") or ""),
77
+ checked_count=non_negative_whole_int(payload.get("checked_count"), "checked_count"),
78
+ replied_count=non_negative_whole_int(payload.get("replied_count"), "replied_count"),
79
+ )
80
+ return {"ok": True, "agent": agent}
81
+
82
+
83
+ def create_agent_token(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
84
+ with db_session(db_path) as conn:
85
+ merchant_id = str(require_field(payload, "merchant_id"))
86
+ token_service.require_merchant_token(conn, merchant_id, _payload_token(payload))
87
+ return agent_service.issue_agent_token_for_merchant(
88
+ conn,
89
+ merchant_id,
90
+ agent_id=str(payload.get("agent_id") or ""),
91
+ ttl_seconds=payload.get("ttl_seconds"),
92
+ positive_whole_seconds=_positive_whole_seconds,
93
+ )
94
+
95
+
96
+ def list_agent_tokens(
97
+ db_path: str | Path,
98
+ payload: dict[str, Any],
99
+ merchant_id: str = "",
100
+ limit: Any = DEFAULT_RESULT_LIMIT,
101
+ offset: Any = 0,
102
+ ) -> dict[str, Any]:
103
+ with db_session(db_path) as conn:
104
+ resolved_merchant_id = str(merchant_id or payload.get("merchant_id") or "")
105
+ if not resolved_merchant_id:
106
+ raise ValidationError("merchant_id is required")
107
+ token_service.require_merchant_token(conn, resolved_merchant_id, _payload_token(payload))
108
+ return agent_service.list_agent_tokens(
109
+ conn,
110
+ resolved_merchant_id,
111
+ limit=result_limit(limit),
112
+ offset=result_offset(offset),
113
+ )
114
+
115
+
116
+ def revoke_agent_token(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
117
+ with db_session(db_path) as conn:
118
+ merchant_id = str(require_field(payload, "merchant_id"))
119
+ token_service.require_merchant_token(conn, merchant_id, _payload_token(payload))
120
+ return agent_service.revoke_agent_token(
121
+ conn,
122
+ merchant_id,
123
+ token=payload.get("token"),
124
+ token_prefix=payload.get("token_prefix"),
125
+ )
126
+
127
+
128
+ def rotate_agent_token(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
129
+ with db_session(db_path) as conn:
130
+ merchant_id = str(require_field(payload, "merchant_id"))
131
+ token_service.require_merchant_token(conn, merchant_id, _payload_token(payload))
132
+ return agent_service.rotate_agent_token(
133
+ conn,
134
+ merchant_id,
135
+ token=payload.get("token"),
136
+ token_prefix=payload.get("token_prefix"),
137
+ ttl_seconds=payload.get("ttl_seconds"),
138
+ positive_whole_seconds=_positive_whole_seconds,
139
+ )
140
+
141
+
142
+ def require_agent_payload(conn: Any, payload: dict[str, Any]) -> tuple[str, str]:
143
+ merchant_id = str(require_field(payload, "merchant_id"))
144
+ agent_id = str(payload.get("agent_id") or default_merchant_agent_id(merchant_id))
145
+ token_service.require_agent_or_merchant_token(conn, merchant_id, agent_id, _payload_token(payload))
146
+ return merchant_id, agent_id
147
+
148
+
149
+ def require_agent_conversation(conn: Any, merchant_id: str, conversation_id: str) -> dict[str, Any]:
150
+ conversation = conversation_summary(conn, conversation_id)
151
+ if conversation["merchant_id"] != merchant_id:
152
+ raise AuthError(f"Merchant {merchant_id} cannot access conversation {conversation_id}")
153
+ return conversation
154
+
155
+
156
+ def require_message_in_conversation(conn: Any, conversation_id: str, message_id: int) -> None:
157
+ agent_service.validate_buyer_message_for_claim(conn, conversation_id, message_id)
158
+
159
+
160
+ def require_agent_process_scope(conn: Any, merchant_id: str, agent_id: str, message_id: int) -> dict[str, Any]:
161
+ process = agent_message_process_summary(conn, agent_id, message_id)
162
+ require_agent_conversation(conn, merchant_id, process["conversation_id"])
163
+ return process
164
+
165
+
166
+ def claim_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
167
+ with db_session(db_path) as conn:
168
+ merchant_id, agent_id = require_agent_payload(conn, payload)
169
+ conversation_id = str(require_field(payload, "conversation_id"))
170
+ require_agent_conversation(conn, merchant_id, conversation_id)
171
+ message_id = positive_whole_int(require_field(payload, "message_id"), "message_id")
172
+ require_message_in_conversation(conn, conversation_id, message_id)
173
+ # 回合/乱序防护(与 negotiation claim 一致):只能 claim 处于
174
+ # waiting_merchant 且是**最新**买家消息——否则回答会落在旧问题上,
175
+ # 更新的买家消息还会被再次派发(跨消息跳答)。
176
+ conversation = conversation_summary(conn, conversation_id)
177
+ if str(conversation["status"] or "") != "waiting_merchant":
178
+ raise ConflictError(
179
+ f"Conversation {conversation_id} is not waiting for the merchant agent "
180
+ f"(status={conversation['status']!r})"
181
+ )
182
+ latest_row = conn.execute(
183
+ "select id from messages where conversation_id = ? and sender = 'buyer' "
184
+ "order by id desc limit 1",
185
+ (conversation_id,),
186
+ ).fetchone()
187
+ if latest_row is None or int(latest_row["id"]) != message_id:
188
+ raise ConflictError(f"Message {message_id} is not the latest buyer message")
189
+ claim = claim_agent_message(
190
+ conn,
191
+ agent_id,
192
+ conversation_id,
193
+ message_id,
194
+ str(require_field(payload, "idempotency_key")),
195
+ )
196
+ return {"ok": True, "claim": claim}
197
+
198
+
199
+ def complete_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
200
+ with db_session(db_path) as conn:
201
+ merchant_id, agent_id = require_agent_payload(conn, payload)
202
+ message_id = positive_whole_int(require_field(payload, "message_id"), "message_id")
203
+ require_agent_process_scope(conn, merchant_id, agent_id, message_id)
204
+ process = complete_agent_message(conn, agent_id, message_id)
205
+ return {"ok": True, "process": process}
206
+
207
+
208
+ def fail_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
209
+ with db_session(db_path) as conn:
210
+ merchant_id, agent_id = require_agent_payload(conn, payload)
211
+ message_id = positive_whole_int(require_field(payload, "message_id"), "message_id")
212
+ require_agent_process_scope(conn, merchant_id, agent_id, message_id)
213
+ process = fail_agent_message(conn, agent_id, message_id, str(payload.get("error") or "agent failure"))
214
+ return {"ok": True, "process": process}
215
+
216
+
217
+ def abandon_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
218
+ with db_session(db_path) as conn:
219
+ merchant_id, agent_id = require_agent_payload(conn, payload)
220
+ message_id = positive_whole_int(require_field(payload, "message_id"), "message_id")
221
+ require_agent_process_scope(conn, merchant_id, agent_id, message_id)
222
+ process = abandon_agent_message(conn, agent_id, message_id, str(payload.get("error") or "agent abandoned claim"))
223
+ return {"ok": True, "process": process}
224
+
225
+
226
+ def abandon_stale_messages(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
227
+ with db_session(db_path) as conn:
228
+ _merchant_id, agent_id = require_agent_payload(conn, payload)
229
+ abandoned = abandon_stale_agent_messages(
230
+ conn,
231
+ agent_id,
232
+ stale_after_seconds=(
233
+ _positive_whole_seconds(payload.get("stale_after_seconds", 300), "stale_after_seconds") or 300
234
+ ),
235
+ )
236
+ return {"ok": True, "abandoned": abandoned}
237
+
238
+
239
+ def list_agents(
240
+ db_path: str | Path,
241
+ payload: dict[str, Any],
242
+ owner_id: str = "",
243
+ limit: Any = DEFAULT_RESULT_LIMIT,
244
+ offset: Any = 0,
245
+ ) -> dict[str, Any]:
246
+ with db_session(db_path) as conn:
247
+ scoped_owner_id = str(owner_id or "")
248
+ if scoped_owner_id:
249
+ token_service.require_merchant_read_token(conn, scoped_owner_id, _payload_token(payload))
250
+ else:
251
+ token_row = token_service.require_api_token(conn, _payload_token(payload), "agent read token required")
252
+ if token_row["role"] not in {"merchant", "agent"} or not token_row["merchant_id"]:
253
+ raise AuthError("invalid agent read token")
254
+ scoped_owner_id = str(token_row["merchant_id"])
255
+ return {
256
+ "ok": True,
257
+ "agents": agent_service.list_agent_summaries(
258
+ conn,
259
+ owner_id=scoped_owner_id,
260
+ limit=result_limit(limit),
261
+ offset=result_offset(offset),
262
+ include_stale=True,
263
+ stale_ttl_seconds=agent_stale_ttl_seconds_from(),
264
+ ),
265
+ }
266
+
267
+
268
+ def get_agent(db_path: str | Path, agent_id: str, payload: dict[str, Any]) -> dict[str, Any]:
269
+ with db_session(db_path) as conn:
270
+ summary = agent_service.get_agent_summary(
271
+ conn,
272
+ agent_id,
273
+ include_stale=True,
274
+ stale_ttl_seconds=agent_stale_ttl_seconds_from(),
275
+ )
276
+ token_service.require_merchant_read_token(conn, summary["owner_id"], _payload_token(payload))
277
+ return {"ok": True, "agent": summary}