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,3 @@
1
+ """Standalone shopping-cli consultation runtime."""
2
+
3
+ VERSION = "3.0.0"
@@ -0,0 +1 @@
1
+ """Buyer and merchant agent helpers."""
@@ -0,0 +1,31 @@
1
+ """Console entry point for running a resident merchant agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from shopping_cli.agents import merchant_agent, merchant_daemon
9
+ from shopping_cli.cli_common import emit
10
+ from shopping_cli.config import DEFAULT_DB_PATH
11
+ from shopping_cli.db.session import db_session
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> None:
15
+ parser = argparse.ArgumentParser(description="Run a resident shopping-cli merchant agent.")
16
+ parser.add_argument("--db", default=str(DEFAULT_DB_PATH), help="SQLite database path")
17
+ parser.add_argument("--merchant", required=True)
18
+ parser.add_argument("--once", action="store_true")
19
+ parser.add_argument("--interval", type=float, default=3.0)
20
+ parser.add_argument("--format", choices=["text", "json"], default="text")
21
+ args = parser.parse_args(argv if argv is not None else sys.argv[1:])
22
+ if not args.once:
23
+ merchant_daemon.run_forever(args.db, args.merchant, interval=args.interval)
24
+ return
25
+ with db_session(args.db) as conn:
26
+ result = merchant_agent.process_once(conn, args.merchant)
27
+ emit(result, args.format)
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
@@ -0,0 +1,163 @@
1
+ """Deterministic buyer-side consultation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from typing import Any
7
+
8
+ from shopping_cli.core.catalog import product_summary, search_products
9
+ from shopping_cli.core.conversations import append_message, conversation_summary, ensure_conversation
10
+ from shopping_cli.core.errors import NotFoundError, ValidationError
11
+ from shopping_cli.core.risk import infer_intent
12
+
13
+ MVP_WARNINGS = [
14
+ "MVP records consultation only; no order is created.",
15
+ "No stock is reserved by shopping-cli.",
16
+ "Payment, refund, escrow, and delivery-success handling are outside this version.",
17
+ ]
18
+
19
+
20
+ def status_guidance(conversation: dict[str, Any]) -> dict[str, Any]:
21
+ status = str(conversation.get("status") or "")
22
+ if status == "waiting_merchant":
23
+ return {
24
+ "pending": True,
25
+ "next_action": "Wait for merchant agent response.",
26
+ "status_hint": "merchant_agent_pending",
27
+ }
28
+ if status == "human_required":
29
+ return {
30
+ "pending": True,
31
+ "next_action": "Wait for merchant human review.",
32
+ "status_hint": "merchant_human_pending",
33
+ }
34
+ if status == "waiting_buyer":
35
+ return {
36
+ "pending": False,
37
+ "next_action": "Review the merchant reply and continue the consultation if needed.",
38
+ "status_hint": "buyer_turn",
39
+ }
40
+ if status == "closed":
41
+ return {
42
+ "pending": False,
43
+ "next_action": "Conversation is closed.",
44
+ "status_hint": "closed",
45
+ }
46
+ return {
47
+ "pending": False,
48
+ "next_action": "Continue the consultation without creating orders or payments.",
49
+ "status_hint": "open",
50
+ }
51
+
52
+
53
+ def status_warnings(conversation: dict[str, Any]) -> list[str]:
54
+ status = str(conversation.get("status") or "")
55
+ if status == "waiting_merchant":
56
+ return ["Merchant agent response is pending; keep the conversation open."]
57
+ if status == "human_required":
58
+ return ["Merchant human review is required before any commitment."]
59
+ return []
60
+
61
+
62
+ def ask(
63
+ conn: sqlite3.Connection,
64
+ buyer_id: str,
65
+ text: str,
66
+ city: str = "",
67
+ area: str = "",
68
+ limit: int = 3,
69
+ source_id: str = "buyer-cli",
70
+ host: str = "",
71
+ session_id: str = "",
72
+ reuse_open: bool = True,
73
+ ) -> dict[str, Any]:
74
+ buyer_id = str(buyer_id or "").strip()
75
+ if not buyer_id:
76
+ raise ValidationError("buyer id is required")
77
+ candidates = search_products(conn, query=text, city=city, area=area, limit=limit)
78
+ if not candidates:
79
+ return {
80
+ "ok": True,
81
+ "buyer_id": buyer_id,
82
+ "candidates": [],
83
+ "conversation": None,
84
+ "warnings": ["No matching merchant or product found.", *MVP_WARNINGS],
85
+ "missing_facts": ["merchant", "product"],
86
+ }
87
+ selected = candidates[0]
88
+ conversation = ensure_conversation(conn, buyer_id, selected["merchant_id"], selected["sku"], reuse_open=reuse_open)
89
+ message = append_message(
90
+ conn,
91
+ conversation["id"],
92
+ "buyer",
93
+ infer_intent(text),
94
+ text,
95
+ structured_payload={
96
+ "city": city,
97
+ "area": area,
98
+ "selected_sku": selected["sku"],
99
+ "source_id": source_id or "buyer-cli",
100
+ "host": host or "",
101
+ "session_id": session_id or "",
102
+ },
103
+ )
104
+ summary = conversation_summary(conn, conversation["id"])
105
+ return {
106
+ "ok": True,
107
+ "buyer_id": buyer_id,
108
+ "candidates": candidates,
109
+ "selected": selected,
110
+ "conversation": summary,
111
+ "message": message,
112
+ "warnings": [*MVP_WARNINGS, *status_warnings(summary)],
113
+ **status_guidance(summary),
114
+ }
115
+
116
+
117
+ def summarize(conn: sqlite3.Connection, conversation_id: str) -> dict[str, Any]:
118
+ conversation = conversation_summary(conn, conversation_id)
119
+ option = conversation.get("product")
120
+ if option is None and conversation.get("sku"):
121
+ try:
122
+ option = product_summary(conn, conversation["sku"])
123
+ except NotFoundError:
124
+ option = None
125
+ missing_facts: list[str] = []
126
+ warnings = list(MVP_WARNINGS)
127
+ if option is None:
128
+ missing_facts.append("product")
129
+ else:
130
+ if not option["merchant"].get("contact"):
131
+ missing_facts.append("merchant contact")
132
+ if not option["delivery"].get("service_area"):
133
+ missing_facts.append("delivery rule")
134
+ if option["stock"] <= 0:
135
+ warnings.append("Product is out of stock.")
136
+ warnings.extend(status_warnings(conversation))
137
+ for flag in conversation["flags"]:
138
+ warnings.append(f"Human review flag: {flag['reason']}")
139
+ guidance = status_guidance(conversation)
140
+ return {
141
+ "ok": True,
142
+ "conversation": conversation,
143
+ "option": option,
144
+ "missing_facts": missing_facts,
145
+ "warnings": warnings,
146
+ **guidance,
147
+ "no_order_created": True,
148
+ "no_stock_reserved": True,
149
+ }
150
+
151
+
152
+ def record_intent(conn: sqlite3.Connection, conversation_id: str, intent: str, text: str) -> dict[str, Any]:
153
+ if intent not in {"purchase_intent", "quote_request"}:
154
+ raise ValidationError("--intent must be purchase_intent or quote_request")
155
+ message = append_message(conn, conversation_id, "buyer", intent, text, structured_payload={"source_id": "buyer-cli"})
156
+ conversation = conversation_summary(conn, conversation_id)
157
+ return {
158
+ "ok": True,
159
+ "message": message,
160
+ "conversation": conversation,
161
+ "warnings": [*MVP_WARNINGS, *status_warnings(conversation)],
162
+ **status_guidance(conversation),
163
+ }
@@ -0,0 +1,263 @@
1
+ """Resident deterministic merchant agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sqlite3
8
+ from typing import Any, cast
9
+
10
+ from shopping_cli import VERSION
11
+ from shopping_cli.agents.buyer_cli import MVP_WARNINGS
12
+ from shopping_cli.agents.tools import MerchantAgentTools, SQLiteMerchantAgentTools, record_heartbeat
13
+ from shopping_cli.core.errors import NotFoundError
14
+ from shopping_cli.core.harness import MAX_STALE_TTL_SECONDS, message_idempotency_key
15
+ from shopping_cli.core.risk import human_review_reason
16
+ from shopping_cli.core.limits import safe_non_negative_float as _safe_non_negative_float, safe_non_negative_int as _safe_non_negative_int
17
+
18
+
19
+ def _positive_message_id(value: Any) -> int:
20
+ if isinstance(value, bool):
21
+ raise ValueError("buyer message id must be a positive integer")
22
+ try:
23
+ message_id = int(value)
24
+ except (OverflowError, TypeError, ValueError) as exc:
25
+ raise ValueError("buyer message id must be a positive integer") from exc
26
+ if message_id <= 0:
27
+ raise ValueError("buyer message id must be a positive integer")
28
+ return message_id
29
+
30
+
31
+ def _claim_ttl_seconds_from_env() -> int:
32
+ try:
33
+ seconds = int(os.environ.get("SHOPPING_AGENT_CLAIM_TTL_SECONDS") or "300")
34
+ except (OverflowError, TypeError, ValueError):
35
+ return 300
36
+ return seconds if 0 < seconds <= MAX_STALE_TTL_SECONDS else 300
37
+
38
+
39
+ def heartbeat(
40
+ conn: sqlite3.Connection,
41
+ merchant_id: str,
42
+ status: str = "online",
43
+ capabilities: list[str] | None = None,
44
+ pid: int = 0,
45
+ version: str = VERSION,
46
+ last_error: str = "",
47
+ checked_count: int = 0,
48
+ replied_count: int = 0,
49
+ ) -> dict[str, Any]:
50
+ return record_heartbeat(
51
+ conn,
52
+ merchant_id,
53
+ status=status,
54
+ capabilities=capabilities,
55
+ pid=pid,
56
+ version=version,
57
+ last_error=last_error,
58
+ checked_count=checked_count,
59
+ replied_count=replied_count,
60
+ )
61
+
62
+
63
+ def latest_buyer_message(conversation: dict[str, Any]) -> dict[str, Any] | None:
64
+ for message in reversed(conversation.get("messages", [])):
65
+ if message["sender"] == "buyer":
66
+ return message
67
+ return None
68
+
69
+
70
+ def has_agent_reply_after(conversation: dict[str, Any], buyer_message_id: int) -> bool:
71
+ for message in conversation.get("messages", []):
72
+ try:
73
+ message_id = _positive_message_id(message.get("id"))
74
+ except ValueError:
75
+ continue
76
+ if message_id <= buyer_message_id:
77
+ continue
78
+ if message["sender"] in {"merchant_agent", "merchant"}:
79
+ return True
80
+ return False
81
+
82
+
83
+ def _automation_boundaries(product: dict[str, Any]) -> str:
84
+ merchant_value = product.get("merchant")
85
+ merchant = cast(dict[str, Any], merchant_value) if isinstance(merchant_value, dict) else {}
86
+ return str(merchant.get("automation_boundaries") or product.get("automation_boundaries") or "").strip()
87
+
88
+
89
+ def _authorized_bargain_amount(product: dict[str, Any]) -> str:
90
+ boundaries = _automation_boundaries(product)
91
+ if not boundaries:
92
+ return ""
93
+ lower = boundaries.lower()
94
+ if not any(term in lower or term in boundaries for term in ("discount", "lowest", "final price", "砍价", "优惠", "实付", "成交")):
95
+ return ""
96
+ title = str(product.get("title") or "").strip()
97
+ sku = str(product.get("sku") or "").strip()
98
+ segments: list[str] = []
99
+ for marker in (title, sku):
100
+ if marker and marker in boundaries:
101
+ start = boundaries.find(marker)
102
+ segments.append(boundaries[start : start + 140])
103
+ if "两款均适用" in boundaries or "all products" in lower:
104
+ segments.append(boundaries)
105
+ patterns = [
106
+ r"(?:实付|最低可成交价|最低价|成交价)[^\d]{0,16}(\d+(?:\.\d+)?)\s*(?:元|cny|CNY)?",
107
+ r"(?:lowest price|final price)[^\d]{0,16}(\d+(?:\.\d+)?)\s*(?:cny|CNY|元)?",
108
+ r"(\d+(?:\.\d+)?)\s*(?:元|cny|CNY)[^\n。;;]{0,16}(?:成交|可成交)",
109
+ ]
110
+ for segment in segments:
111
+ for pattern in patterns:
112
+ match = re.search(pattern, segment, flags=re.IGNORECASE)
113
+ if match:
114
+ return match.group(1)
115
+ return ""
116
+
117
+
118
+ def generate_reply(
119
+ tools: MerchantAgentTools,
120
+ conversation: dict[str, Any],
121
+ buyer_message: dict[str, Any],
122
+ ) -> tuple[str, bool, str]:
123
+ product = None
124
+ if conversation.get("sku"):
125
+ try:
126
+ product = tools.product_summary(conversation["sku"])
127
+ except (NotFoundError, SystemExit):
128
+ product = None
129
+ reason = human_review_reason(buyer_message["text"], product_found=product is not None)
130
+ if not reason and buyer_message.get("intent") in {"negotiate", "quote_request"}:
131
+ reason = "bargaining"
132
+ disclaimer = " ".join(MVP_WARNINGS)
133
+ if product is None:
134
+ return f"I need a merchant human to confirm which product this consultation refers to. {disclaimer}", True, reason
135
+ delivery = product["delivery"]
136
+ price = _safe_non_negative_float(product.get("price"))
137
+ stock = _safe_non_negative_int(product.get("stock"))
138
+ if not reason and stock <= 2:
139
+ reason = "low_stock"
140
+ if not reason and buyer_message["intent"] == "ask_delivery" and not delivery.get("service_area"):
141
+ reason = "unclear_delivery"
142
+ delivery_text = "delivery rule is missing"
143
+ if delivery.get("service_area"):
144
+ delivery_text = (
145
+ f"delivery area {delivery['service_area']}, ETA {_safe_non_negative_int(delivery.get('eta_minutes'))} minutes, "
146
+ f"fee {_safe_non_negative_float(delivery.get('fee')):.2f} {delivery['currency']}"
147
+ )
148
+ if reason == "bargaining":
149
+ # 议价底价是隐私——negotiation 路径以 _leaks_private_threshold 守护,
150
+ # resident 若直接报价(_authorized_bargain_amount)会把授权底价写进
151
+ # 公开会话。一致处理:议价请求一律路由人工审查,不自动报价。
152
+ return (
153
+ f"{product['title']} needs merchant human review for price negotiation. "
154
+ f"Catalog price {price:.2f} {product['currency']}, stock {stock}; {delivery_text}. {disclaimer}",
155
+ True,
156
+ "bargaining",
157
+ )
158
+ if reason:
159
+ return (
160
+ f"{product['title']} is listed at {price:.2f} {product['currency']} with "
161
+ f"{stock} in stock. This request needs merchant human review because: {reason}. {disclaimer}",
162
+ True,
163
+ reason,
164
+ )
165
+ return (
166
+ f"{product['title']} has stock {stock} and current price "
167
+ f"{price:.2f} {product['currency']}; {delivery_text}. {disclaimer}",
168
+ False,
169
+ "",
170
+ )
171
+
172
+
173
+ def process_once_with_tools(tools: MerchantAgentTools, merchant_id: str) -> dict[str, Any]:
174
+ agent = tools.heartbeat(merchant_id)
175
+ abandoned: list[dict[str, Any]] = []
176
+ if hasattr(tools, "abandon_stale_messages"):
177
+ abandoned = tools.abandon_stale_messages(agent["id"], stale_after_seconds=_claim_ttl_seconds_from_env())
178
+ conversations = tools.waiting_merchant_conversations(merchant_id)
179
+ replied: list[dict[str, Any]] = []
180
+ failed: list[dict[str, Any]] = []
181
+ for conversation in conversations:
182
+ buyer_message = latest_buyer_message(conversation)
183
+ if buyer_message is None:
184
+ continue
185
+ # Negotiation-protocol 消息由 kiwi merchant 运行时经 negotiation API
186
+ # 驱动——resident 抢 claim 会打断买家的谈判回合(协商方 claim 失败),
187
+ # 且 generate_reply 曾把授权底价写进公开会话。协商消息一律跳过。
188
+ if (buyer_message.get("structured_payload") or {}).get("protocol_version"):
189
+ continue
190
+ try:
191
+ buyer_message_id = _positive_message_id(buyer_message.get("id"))
192
+ except ValueError as exc:
193
+ error = f"{type(exc).__name__}: {exc}"
194
+ tools.heartbeat(
195
+ merchant_id,
196
+ status="online",
197
+ last_error=error,
198
+ checked_count=len(conversations),
199
+ replied_count=len(replied),
200
+ )
201
+ failed.append({"conversation_id": conversation["id"], "message_id": 0, "error": error})
202
+ continue
203
+ if has_agent_reply_after(conversation, buyer_message_id):
204
+ continue
205
+ idempotency_key = message_idempotency_key(agent["id"], buyer_message_id)
206
+ claim = tools.claim_message(agent["id"], conversation["id"], buyer_message_id, idempotency_key)
207
+ if not claim.get("claimed"):
208
+ continue
209
+ try:
210
+ reply, needs_human, reason = generate_reply(tools, conversation, buyer_message)
211
+ status = "human_required" if needs_human else "waiting_buyer"
212
+ message = tools.append_message(
213
+ conversation["id"],
214
+ "merchant_agent",
215
+ buyer_message["intent"],
216
+ reply,
217
+ structured_payload={
218
+ "human_required": needs_human,
219
+ "reason": reason,
220
+ "source_id": agent["id"],
221
+ "processed_message_id": buyer_message_id,
222
+ "idempotency_key": idempotency_key,
223
+ },
224
+ status=status,
225
+ )
226
+ tools.complete_message(agent["id"], buyer_message_id)
227
+ flags = []
228
+ if needs_human:
229
+ flags.append(tools.add_flag(conversation["id"], reason or "human_required", sku=conversation.get("sku", "")))
230
+ tools.heartbeat(merchant_id, status="human_required")
231
+ replied.append(
232
+ {
233
+ "conversation_id": conversation["id"],
234
+ "message_id": message["id"],
235
+ "human_required": needs_human,
236
+ "reason": reason,
237
+ "flags": flags,
238
+ }
239
+ )
240
+ except Exception as exc: # pragma: no cover - exercised through fake tools
241
+ error = f"{type(exc).__name__}: {exc}"
242
+ tools.fail_message(agent["id"], buyer_message_id, error)
243
+ tools.heartbeat(
244
+ merchant_id,
245
+ status="online",
246
+ last_error=error,
247
+ checked_count=len(conversations),
248
+ replied_count=len(replied),
249
+ )
250
+ failed.append({"conversation_id": conversation["id"], "message_id": buyer_message_id, "error": error})
251
+ return {
252
+ "ok": True,
253
+ "merchant_id": merchant_id,
254
+ "agent": agent,
255
+ "checked": len(conversations),
256
+ "replied": replied,
257
+ "failed": failed,
258
+ "abandoned": abandoned,
259
+ }
260
+
261
+
262
+ def process_once(conn: sqlite3.Connection, merchant_id: str) -> dict[str, Any]:
263
+ return process_once_with_tools(SQLiteMerchantAgentTools(conn), merchant_id)