patchr 0.1.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 (116) hide show
  1. apps/__init__.py +2 -0
  2. apps/api/__init__.py +2 -0
  3. apps/api/main.py +652 -0
  4. apps/benchmarks/__init__.py +1 -0
  5. apps/benchmarks/main.py +20 -0
  6. apps/sandbox/__init__.py +1 -0
  7. apps/sandbox/main.py +20 -0
  8. apps/worker/__init__.py +2 -0
  9. apps/worker/main.py +15 -0
  10. apps/worker/verify.py +14 -0
  11. patchr/__init__.py +12 -0
  12. patchr/sdk/__init__.py +20 -0
  13. patchr/sdk/client.py +12 -0
  14. patchr-0.1.0.dist-info/METADATA +137 -0
  15. patchr-0.1.0.dist-info/RECORD +116 -0
  16. patchr-0.1.0.dist-info/WHEEL +5 -0
  17. patchr-0.1.0.dist-info/entry_points.txt +5 -0
  18. patchr-0.1.0.dist-info/licenses/LICENSE +17 -0
  19. patchr-0.1.0.dist-info/top_level.txt +3 -0
  20. picux/__init__.py +6 -0
  21. picux/agents/__init__.py +5 -0
  22. picux/agents/registry.py +204 -0
  23. picux/api/__init__.py +5 -0
  24. picux/api/service.py +5075 -0
  25. picux/audit/__init__.py +31 -0
  26. picux/audit/activity.py +97 -0
  27. picux/audit/observability.py +55 -0
  28. picux/audit/verification/__init__.py +21 -0
  29. picux/audit/verification/ledger.py +633 -0
  30. picux/benchmarks/__init__.py +5 -0
  31. picux/benchmarks/local.py +286 -0
  32. picux/config.py +140 -0
  33. picux/contracts/__init__.py +22 -0
  34. picux/contracts/handshake.py +122 -0
  35. picux/contracts/integration.py +385 -0
  36. picux/contracts/openapi.py +187 -0
  37. picux/contracts/protocol_map.py +152 -0
  38. picux/contracts/routes.py +980 -0
  39. picux/contracts/schema_catalog.py +125 -0
  40. picux/core/__init__.py +17 -0
  41. picux/core/models.py +148 -0
  42. picux/core/router.py +131 -0
  43. picux/core/runtime.py +42 -0
  44. picux/core/state_machine.py +38 -0
  45. picux/domains/__init__.py +2 -0
  46. picux/domains/bridge/HostRun.py +1104 -0
  47. picux/domains/bridge/__init__.py +6 -0
  48. picux/domains/bridge/engine.py +345 -0
  49. picux/domains/hunt/__init__.py +6 -0
  50. picux/domains/hunt/engine.py +307 -0
  51. picux/domains/hunt/models.py +88 -0
  52. picux/domains/pay/__init__.py +16 -0
  53. picux/domains/pay/adapters.py +607 -0
  54. picux/domains/pay/engine.py +950 -0
  55. picux/domains/pay/models.py +95 -0
  56. picux/domains/proxy/__init__.py +5 -0
  57. picux/domains/proxy/engine.py +466 -0
  58. picux/domains/resolve/__init__.py +5 -0
  59. picux/domains/resolve/engine.py +546 -0
  60. picux/orchestrator/__init__.py +3 -0
  61. picux/orchestrator/engine.py +2840 -0
  62. picux/portals/__init__.py +17 -0
  63. picux/portals/templates.py +272 -0
  64. picux/protocols/__init__.py +1 -0
  65. picux/protocols/a2a/__init__.py +6 -0
  66. picux/protocols/a2a/client.py +51 -0
  67. picux/protocols/a2a/envelope.py +132 -0
  68. picux/protocols/mcp/__init__.py +7 -0
  69. picux/protocols/mcp/client.py +69 -0
  70. picux/protocols/mcp/contract.py +67 -0
  71. picux/protocols/mcp/server.py +76 -0
  72. picux/sandbox/__init__.py +6 -0
  73. picux/sandbox/midnight_arbitrage.py +215 -0
  74. picux/sandbox/models.py +90 -0
  75. picux/sdk/__init__.py +13 -0
  76. picux/sdk/client.py +768 -0
  77. picux/sdk/external.py +245 -0
  78. picux/security/__init__.py +18 -0
  79. picux/security/auth.py +86 -0
  80. picux/security/config_validator.py +58 -0
  81. picux/security/policy.py +158 -0
  82. picux/security/secrets.py +144 -0
  83. picux/signals/__init__.py +1 -0
  84. picux/signals/community/__init__.py +24 -0
  85. picux/signals/community/adapters/__init__.py +7 -0
  86. picux/signals/community/adapters/reddit.py +37 -0
  87. picux/signals/community/adapters/shopify.py +23 -0
  88. picux/signals/community/adapters/web.py +23 -0
  89. picux/signals/community/disambiguation.py +51 -0
  90. picux/signals/community/intake.py +227 -0
  91. picux/signals/community/models.py +102 -0
  92. picux/signals/community/rules.py +91 -0
  93. picux/signals/community/scoring.py +64 -0
  94. picux/storage/__init__.py +41 -0
  95. picux/storage/agents.py +50 -0
  96. picux/storage/cases.py +440 -0
  97. picux/storage/channels.py +476 -0
  98. picux/storage/connectors.py +411 -0
  99. picux/storage/envelopes.py +137 -0
  100. picux/storage/escrows.py +168 -0
  101. picux/storage/events.py +989 -0
  102. picux/storage/keyspace.py +60 -0
  103. picux/storage/mandates.py +107 -0
  104. picux/storage/portals.py +222 -0
  105. picux/storage/postgres.py +2049 -0
  106. picux/storage/providers.py +148 -0
  107. picux/storage/proxy.py +231 -0
  108. picux/storage/receipts.py +131 -0
  109. picux/storage/signals.py +147 -0
  110. picux/storage/tasks.py +179 -0
  111. picux/tools/__init__.py +11 -0
  112. picux/tools/shared.py +2048 -0
  113. picux/verification/__init__.py +5 -0
  114. picux/verification/rollout.py +183 -0
  115. picux/workflows/__init__.py +5 -0
  116. picux/workflows/templates.py +74 -0
@@ -0,0 +1,2840 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import time
7
+ from typing import Any
8
+
9
+ from picux.core import ProtocolTaskStatus
10
+
11
+
12
+ class PicuxMultiAgentOrchestrator:
13
+ """Core Picux multi-agent orchestrator used by API, SDK, and test clients."""
14
+
15
+ domains = {"hunt", "bridge", "resolve", "proxy", "pay"}
16
+
17
+ def __init__(self, host: Any) -> None:
18
+ self.host = host
19
+
20
+ def run(self, payload: dict[str, Any], onEvent: Any | None = None) -> dict[str, Any]:
21
+ host = self.host
22
+ resumeAction = self._followUpAction(payload)
23
+ request = str(payload.get("request", payload.get("goal", payload.get("query", ""))) or "").strip()
24
+ requestedConversationId = self._conversationId(payload)
25
+ if not resumeAction and requestedConversationId and self._hasConversationState(host, requestedConversationId):
26
+ resumeAction = self._followUpTextAction(request)
27
+ if not request:
28
+ request = self._resumeRequestLabel(resumeAction, payload) if resumeAction else "Find my best-fit options and return an auditable Picux proof card."
29
+
30
+ route = self._route(request, payload)
31
+ start = time.time()
32
+ trace: list[dict[str, Any]] = []
33
+ agentConversation = [f"[User] {request}"]
34
+ clientId = str(payload.get("clientId", payload.get("userId", "externalClient")) or "externalClient")
35
+ channel = str(payload.get("channel", "api") or "api")
36
+ metadata = payload.get("metadata", {}) if isinstance(payload.get("metadata"), dict) else {}
37
+
38
+ def stream(kind: str, data: dict[str, Any]) -> None:
39
+ if onEvent is None:
40
+ return
41
+ try:
42
+ onEvent({"type": kind, **data})
43
+ except Exception:
44
+ return
45
+
46
+ def event(phase: str, name: str, status: str, msg: str, data: dict[str, Any] | None = None) -> None:
47
+ traceEvent = {
48
+ "seq": len(trace) + 1,
49
+ "phase": phase,
50
+ "name": name,
51
+ "status": status,
52
+ "msg": msg,
53
+ "elapsedMs": int((time.time() - start) * 1000),
54
+ "data": data or {},
55
+ }
56
+ trace.append(traceEvent)
57
+ stream("trace", {"event": traceEvent})
58
+
59
+ if resumeAction:
60
+ route = self._resumeRoute(resumeAction)
61
+ nlp = host.tools.nlp({"request": request, "context": payload.get("context", {}), "taskId": requestedConversationId})
62
+ nlp = {
63
+ **nlp,
64
+ "isFollowUp": True,
65
+ "route": route,
66
+ "followUp": {
67
+ "action": resumeAction,
68
+ "conversationId": requestedConversationId,
69
+ "itemId": self._selectedItemId(payload),
70
+ },
71
+ }
72
+ toolResults = {"nlpTool": nlp}
73
+ event(
74
+ "tools",
75
+ "nlpTool",
76
+ "followUp",
77
+ "NLPTool classified this payload as a conversation resume request.",
78
+ {"intent": nlp.get("intent", {}), "route": route, "isFollowUp": True, "followUp": nlp["followUp"]},
79
+ )
80
+ return self._runResume(
81
+ payload=payload,
82
+ request=request,
83
+ clientId=clientId,
84
+ channel=channel,
85
+ metadata=metadata,
86
+ route=route,
87
+ toolResults=toolResults,
88
+ trace=trace,
89
+ agentConversation=agentConversation,
90
+ event=event,
91
+ stream=stream,
92
+ )
93
+
94
+ nlp = host.tools.nlp({"request": request, "context": payload.get("context", {}), "taskId": payload.get("taskId", "")})
95
+ route = [item for item in nlp.get("route", []) if item in self.domains] or route
96
+ toolResults: dict[str, Any] = {"nlpTool": nlp}
97
+ event(
98
+ "tools",
99
+ "nlpTool",
100
+ "classified",
101
+ "NLPTool classified the user request and selected the orchestration route.",
102
+ {"intent": nlp.get("intent", {}), "route": route, "isFollowUp": nlp.get("isFollowUp", False)},
103
+ )
104
+
105
+ taskResult = host.createTask(
106
+ {
107
+ "userId": clientId,
108
+ "goal": request,
109
+ "domain": route[0] if route else "hunt",
110
+ "channel": channel,
111
+ "inData": {"request": request, "route": route},
112
+ "meta": {**metadata, "clientId": clientId, "channel": channel, "conversationId": requestedConversationId},
113
+ }
114
+ )
115
+ task = taskResult.get("task", {})
116
+ taskId = str(task.get("taskId", "") or self._stableId("task", {"request": request, "route": route}))
117
+ conversationId = requestedConversationId or taskId
118
+ if taskId:
119
+ nextMeta = {**(task.get("meta", {}) if isinstance(task.get("meta"), dict) else {}), "clientId": clientId, "channel": channel, "conversationId": conversationId}
120
+ savedTask = host.taskBook.updateTask(taskId, {"meta": nextMeta})
121
+ if savedTask.get("ok"):
122
+ task = savedTask["task"]
123
+ event("orchestrator", "taskCreate", "created", "Picux task created from client input.", {"taskId": taskId, "clientId": clientId, "route": route})
124
+
125
+ results: dict[str, Any] = {"tools": toolResults}
126
+ selectedVendor = ""
127
+ selectedAmount = 0.0
128
+ mandate: dict[str, Any] = {}
129
+ proxyCreated = False
130
+ localServiceFlow = False
131
+ localServiceContacts: dict[str, dict[str, Any]] = {}
132
+
133
+ index = 0
134
+ while index < len(route):
135
+ domain = route[index]
136
+ if domain == "hunt":
137
+ if self._isLocalServiceRequest(request, payload, nlp):
138
+ localServiceFlow = True
139
+ mapResult = self._verifiedLocalServiceMapResult(host, request, payload, nlp) or host.tools.searchMap(
140
+ {
141
+ "request": request,
142
+ "entities": nlp.get("entities", {}),
143
+ "allowNetwork": payload.get("allowNetwork", False),
144
+ "places": payload.get("places", payload.get("mapResults", [])),
145
+ "entity": payload.get("entity", payload.get("service", "")),
146
+ "location": payload.get("location", metadata.get("location", "")),
147
+ "radiusKm": payload.get("radiusKm", payload.get("distanceKm", "")),
148
+ "googlePlacesApiKey": payload.get("googlePlacesApiKey", metadata.get("googlePlacesApiKey", "")),
149
+ "googleMapsApiKey": payload.get("googleMapsApiKey", metadata.get("googleMapsApiKey", "")),
150
+ "googlePlacesBaseUrl": payload.get("googlePlacesBaseUrl", metadata.get("googlePlacesBaseUrl", "")),
151
+ }
152
+ )
153
+ mapOffers = self._placesToOffers(request, mapResult)
154
+ localServiceContacts = self._localServiceContactRefs(mapResult, mapOffers)
155
+ self._cacheLocalServiceLeads(host, localServiceContacts)
156
+ browser = self._mapBrowserTelemetry(mapResult, mapOffers, country=str(payload.get("country", metadata.get("country", "")) or ""))
157
+ toolResults["mapTool"] = self._publicMapResult(mapResult)
158
+ event(
159
+ "tools",
160
+ "mapTool",
161
+ str(mapResult.get("status", "searched")),
162
+ "MapTool searched source-bound local places for HUNT.",
163
+ {
164
+ "provider": mapResult.get("provider", ""),
165
+ "entity": mapResult.get("query", {}).get("entity", "") if isinstance(mapResult.get("query"), dict) else "",
166
+ "location": mapResult.get("query", {}).get("location", "") if isinstance(mapResult.get("query"), dict) else "",
167
+ "radiusKm": mapResult.get("query", {}).get("radiusKm", 0) if isinstance(mapResult.get("query"), dict) else 0,
168
+ "placeCount": len(mapResult.get("places", [])) if isinstance(mapResult.get("places"), list) else 0,
169
+ "offerCount": len(mapOffers),
170
+ "errors": mapResult.get("errors", []),
171
+ },
172
+ )
173
+ else:
174
+ browser = host.tools.readBrowser(
175
+ {
176
+ "request": request,
177
+ "entities": nlp.get("entities", {}),
178
+ "urls": payload.get("urls", []),
179
+ "marketplaces": payload.get("marketplaces", payload.get("sources", [])),
180
+ "allowNetwork": payload.get("allowNetwork", False),
181
+ "country": payload.get("country", metadata.get("country", "")),
182
+ "region": payload.get("region", metadata.get("region", "")),
183
+ "locale": payload.get("locale", metadata.get("locale", "")),
184
+ "location": payload.get("location", metadata.get("location", "")),
185
+ "currency": payload.get("currency", metadata.get("currency", "")),
186
+ "budgetCurrency": payload.get("budgetCurrency", metadata.get("budgetCurrency", "")),
187
+ "fxRates": payload.get("fxRates", metadata.get("fxRates", {})),
188
+ }
189
+ )
190
+ toolResults["browserReader"] = browser
191
+ event(
192
+ "tools",
193
+ "browserReader",
194
+ str(browser.get("status", "read")),
195
+ "BrowserReader attempted source-bound discovery without fabricating offers.",
196
+ {
197
+ "targetCount": len(browser.get("searchTargets", [])),
198
+ "observationCount": len(browser.get("observations", [])),
199
+ "offerCount": len(browser.get("offers", [])),
200
+ "errorCount": len(browser.get("errors", [])),
201
+ "adapter": browser.get("adapter", ""),
202
+ "rendered": browser.get("rendered", {}),
203
+ "marketplaceSet": browser.get("marketplaceSet", {}),
204
+ "sourceSearches": [
205
+ {
206
+ "source": item.get("source", ""),
207
+ "search": item.get("search", {}),
208
+ "listingCount": item.get("listingCount", 0),
209
+ }
210
+ for item in browser.get("marketplaceAttempts", [])
211
+ if isinstance(item, dict)
212
+ ],
213
+ "attemptedSources": [item.get("source", "") for item in browser.get("marketplaceAttempts", []) if isinstance(item, dict)],
214
+ "failedSources": [item.get("source", "") for item in browser.get("marketplaceAttempts", []) if isinstance(item, dict) and not item.get("ok")],
215
+ },
216
+ )
217
+ huntPayload = self._huntPayload(request, payload, taskId=taskId, browser=browser, clientId=clientId, channel=channel)
218
+ event("hunt", "discover.request", "sent", "HUNT received discovery criteria and source telemetry.", {"endpoint": "/v1/hunt/discover"})
219
+ hunt = host.huntDiscover(huntPayload)
220
+ results["hunt"] = hunt
221
+ selected = hunt.get("selected", {}) if isinstance(hunt.get("selected"), dict) else {}
222
+ selectedVendor = str(selected.get("sourceId", "") or "")
223
+ selectedAmount = float(selected.get("netUsd", 0.0) or 0.0)
224
+ status = "selected" if hunt.get("ok") else "needsSources"
225
+ msg = f"HUNT selected {selectedVendor}." if selectedVendor else "HUNT could not rank options without source-bound offer telemetry."
226
+ sourceResponse = hunt.get("sourceResponse", {}) if isinstance(hunt.get("sourceResponse"), dict) else {}
227
+ event("hunt", "discover.response", status, msg, {"selected": selected, "candidateCount": len(hunt.get("candidates", [])), "sourceResponse": sourceResponse})
228
+ huntStream = hunt.get("stream", []) if isinstance(hunt.get("stream"), list) else []
229
+ aggregate = hunt.get("sourceAggregate", {}) if isinstance(hunt.get("sourceAggregate"), dict) else {}
230
+ if huntStream:
231
+ event(
232
+ "hunt",
233
+ "sourceAggregate.stream",
234
+ "returned",
235
+ "HUNT streamed aggregated source and result events back to the orchestrator or handoff source.",
236
+ {
237
+ "eventCount": len(huntStream),
238
+ "aggregateId": aggregate.get("aggregateId", ""),
239
+ "delivery": aggregate.get("delivery", {}),
240
+ "handoff": hunt.get("handoff", {}),
241
+ },
242
+ )
243
+ for streamEvent in huntStream[:50]:
244
+ if isinstance(streamEvent, dict):
245
+ stream("domainStream", {"domain": "hunt", "event": streamEvent})
246
+ agentConversation.append("[Hunt] Collecting signals and ranking...")
247
+ huntSummary = self._huntConversationSummary(request, hunt)
248
+ if huntSummary:
249
+ agentConversation.append(f"[Hunt] {huntSummary}")
250
+ stream("conversation", {"line": agentConversation[-1]})
251
+ if aggregate:
252
+ delivery = aggregate.get("delivery", {}) if isinstance(aggregate.get("delivery"), dict) else {}
253
+ agentConversation.append("[Hunt] Sending aggregated result back to source.")
254
+ stream("conversation", {"line": agentConversation[-1]})
255
+ agentConversation.append("[Hunt] Selected the best source-bound match for review." if selectedVendor else "[Hunt] I could not find a source-backed match under your filters yet.")
256
+ stream("conversation", {"line": agentConversation[-1]})
257
+ if localServiceFlow and hunt.get("ok"):
258
+ self._ensureRouteAfter(route, after="hunt", domains=["bridge", "proxy"])
259
+ elif domain == "bridge":
260
+ installed = host._ensureReadyConnectors()
261
+ localServiceContact = self._selectedLocalServiceContact(results.get("hunt", {}), localServiceContacts)
262
+ if localServiceContact:
263
+ event("bridge", "connectorMatch.request", "sent", "BRIDGE checked client-registered voice connector capability for Proxy availability confirmation.", {"endpoint": "/v1/bridge/connectors/match", "caps": ["voice.call", "call.create", "availability.confirm"]})
264
+ bridge = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["voice.call", "call.create", "availability.confirm"]})
265
+ connector = self._selectBridgeConnector(bridge, preferred=("twilioVoice", "whatsapp"))
266
+ connectorId = str(connector.get("connectorId", "") or "")
267
+ preflight = self._connectorPreflight(host, connectorId, action="call", resource="twilio.calls") if connectorId else {"ok": False, "status": "blocked", "errors": ["missingConnector"]}
268
+ publicProvider = self._publicLocalServiceContact(localServiceContact)
269
+ bridgeStatus = "ready" if preflight.get("ok") else "needsClientCredentials"
270
+ contactPlan = {
271
+ "mode": "proxyAvailabilityConfirmation",
272
+ "target": publicProvider,
273
+ "connectorId": connectorId,
274
+ "connectorStatus": bridgeStatus,
275
+ "credentialContract": {
276
+ "mode": "clientRegisteredBridgeResource",
277
+ "registerEndpoint": "/v1/bridge/connectors",
278
+ "preflightEndpoint": f"/v1/bridge/connectors/{connectorId or '{connectorId}'}/preflight",
279
+ "requiredCaps": ["voice.call", "call.create", "availability.confirm"],
280
+ "requiredSecretRefs": ["PICUX_TWILIO_ACCOUNT_SID", "PICUX_TWILIO_AUTH_TOKEN", "PICUX_TWILIO_VOICE_FROM", "PICUX_VOICE_PUBLIC_BASE_URL"],
281
+ },
282
+ }
283
+ bridgePayload = {
284
+ "ok": bool(connectorId),
285
+ "status": bridgeStatus,
286
+ "mode": "clientConnector",
287
+ "readyConnectors": installed,
288
+ "connectors": bridge,
289
+ "preflight": preflight,
290
+ "contactPlan": contactPlan,
291
+ "handoffs": [{"from": "bridge", "to": "proxy", "connectorId": connectorId, "action": "confirmAvailabilityByVoice", "status": "sent" if connectorId else "missingConnector"}],
292
+ }
293
+ results["bridge"] = bridgePayload
294
+ event(
295
+ "bridge",
296
+ "connectorMatch.response",
297
+ bridgeStatus,
298
+ "BRIDGE prepared the voice connector contract for PROXY without exposing provider phone details.",
299
+ {"connectorId": connectorId, "connectorCount": bridge.get("count", 0), "preflight": {"ok": preflight.get("ok"), "status": preflight.get("status"), "errors": preflight.get("errors", [])}, "target": publicProvider},
300
+ )
301
+ event("bridge", "handoff.proxy", "sent" if connectorId else "missingConnector", "BRIDGE handed the local provider contact reference to PROXY for availability confirmation.", {"connectorId": connectorId, "contactRef": publicProvider.get("contactRef", ""), "action": "confirmAvailabilityByVoice"})
302
+ agentConversation.append("[Bridge] Preparing a voice connector so Proxy can confirm availability.")
303
+ else:
304
+ event("bridge", "connectorMatch.request", "sent", "BRIDGE checked connector capability for outbound contact.", {"endpoint": "/v1/bridge/connectors/match"})
305
+ bridge = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["contact.write", "messages.send", "voice.call"]})
306
+ target = self._target(request, payload)
307
+ contactPlan = host.resolve.contactPlan(request={"preferredChannel": self._preferredChannel(request)}, target=target)
308
+ bridgePayload: dict[str, Any] = {"connectors": bridge, "contactPlan": contactPlan, "readyConnectors": installed}
309
+ resolveResult = results.get("resolve", {}) if isinstance(results.get("resolve"), dict) else {}
310
+ claimDraft = resolveResult.get("claimDraft", {}) if isinstance(resolveResult.get("claimDraft"), dict) else {}
311
+ recovery = resolveResult.get("recovery", {}) if isinstance(resolveResult.get("recovery"), dict) else {}
312
+ if claimDraft:
313
+ bridgePayload["claimDraft"] = claimDraft
314
+ bridgePayload["handoffs"] = [{"from": "resolve", "to": "bridge", "action": "routeClaimDraft", "status": "sent", "target": contactPlan.get("target", {})}]
315
+ if recovery:
316
+ bridgePayload["recovery"] = recovery
317
+ bridgePayload.setdefault("handoffs", []).append({"from": "resolve", "to": "bridge", "action": "launchRecoveryWorkflow", "status": str(recovery.get("status", "queued") or "queued")})
318
+ results["bridge"] = bridgePayload
319
+ status = "ready" if bridge.get("count", 0) else "adapterNeutral"
320
+ event("bridge", "connectorMatch.response", status, "BRIDGE returned contact routing without binding to a vendor adapter.", {"contactPlan": contactPlan, "connectorCount": bridge.get("count", 0), "hasClaimDraft": bool(claimDraft)})
321
+ agentConversation.append("[Bridge] Routing the Resolve claim draft to the right contact channel and connector contract..." if claimDraft else "[Bridge] Finding the right contact channel and connector contract...")
322
+ elif domain == "resolve":
323
+ documents = payload.get("attachments", payload.get("documents", []))
324
+ documents = documents if isinstance(documents, list) else []
325
+ image = host.tools.readImage({"request": request, "images": payload.get("images", []), "attachments": documents, "documents": documents})
326
+ if image.get("count", 0):
327
+ toolResults["imageReader"] = image
328
+ documents = [*documents, *image.get("evidence", [])]
329
+ event(
330
+ "tools",
331
+ "imageReader",
332
+ "read",
333
+ "ImageReader extracted query-relevant evidence from image attachments.",
334
+ {"imageCount": image.get("count", 0), "evidenceCount": len(image.get("evidence", []))},
335
+ )
336
+ event("resolve", "scamTriage.request", "sent", "RESOLVE received claim text and evidence references.", {"endpoint": "/v1/resolve/scamTriage", "documentCount": len(documents)})
337
+ resolve = host.resolveScamTriage({"claim": request, "text": request, "documents": documents, "attachments": documents, "taskId": taskId})
338
+ target = self._target(request, payload)
339
+ if target:
340
+ resolve["claimDraft"] = host.resolve.draftClaim(request={"issueType": self._issueType(request), "attachments": documents}, audit={}, target=target)
341
+ recovery = self._startResolveRecovery(host, request=request, payload=payload, target=target, documents=documents, taskId=taskId, conversationId=conversationId)
342
+ if recovery:
343
+ resolve["recovery"] = recovery
344
+ event("resolve", "recovery.queue", str(recovery.get("status", "queued") or "queued"), "RESOLVE launched the recovery workflow and queued Bridge or portal work from the case.", {"recovery": recovery})
345
+ results["resolve"] = resolve
346
+ triage = resolve.get("triage", {}) if isinstance(resolve.get("triage"), dict) else {}
347
+ nextAction = str(triage.get("nextAction", "prepareReviewableRecord") or "prepareReviewableRecord")
348
+ event("resolve", "scamTriage.response", nextAction, "RESOLVE produced a reviewable evidence map and next action.", {"risk": triage.get("risk", {}), "openQuestions": triage.get("openQuestions", [])})
349
+ agentConversation.append("[Resolve] Checking risk, evidence, chronology, and safe next action...")
350
+ escalation = triage.get("escalation", {}) if isinstance(triage.get("escalation"), dict) else {}
351
+ if escalation.get("recommended"):
352
+ event("resolve", "escalation.check", "recommended", "RESOLVE recommended human review; PROXY opens when the request needs follow-up, voice, logistics, or explicit human review.", escalation)
353
+ if escalation.get("recommended") and "proxy" not in route and self._shouldCreateProxy(request, payload):
354
+ insertAt = route.index("bridge") + 1 if "bridge" in route and route.index("bridge") > index else index + 1
355
+ route.insert(insertAt, "proxy")
356
+ elif domain == "proxy":
357
+ localServiceContact = self._selectedLocalServiceContact(results.get("hunt", {}), localServiceContacts)
358
+ if localServiceContact:
359
+ bridgeResult = results.get("bridge", {}) if isinstance(results.get("bridge"), dict) else {}
360
+ publicProvider = self._publicLocalServiceContact(localServiceContact)
361
+ contactRef = str(localServiceContact.get("contactRef", publicProvider.get("contactRef", "")) or "")
362
+ connectorId = str(bridgeResult.get("contactPlan", {}).get("connectorId", "") if isinstance(bridgeResult.get("contactPlan"), dict) else "")
363
+ preflight = bridgeResult.get("preflight", {}) if isinstance(bridgeResult.get("preflight"), dict) else {}
364
+ hasPrivateContact = bool(str(localServiceContact.get("phone", "") or ""))
365
+ callStatus = "readyToCall" if hasPrivateContact and preflight.get("ok") else ("needsClientCredentials" if connectorId else "missingVoiceConnector")
366
+ if not hasPrivateContact:
367
+ callStatus = "needsProviderContact"
368
+ voiceCall = {
369
+ "phoneRef": contactRef,
370
+ "status": callStatus,
371
+ "connectorId": connectorId,
372
+ "prompt": f"Confirm availability for this Picux local service request. Provider: {publicProvider.get('name', '')}. User need: {request}",
373
+ }
374
+ event("proxy", "mission.request", "sent", "PROXY received the local provider as a callable human/business node.", {"endpoint": "/v1/proxy/missions", "reason": "localServiceAvailability", "contactRef": contactRef, "connectorId": connectorId, "callStatus": callStatus})
375
+ proxyStatus, proxy = host.createProxyMission(
376
+ {
377
+ "taskId": taskId,
378
+ "userId": clientId,
379
+ "kind": "call",
380
+ "reason": "localServiceAvailability",
381
+ "task": f"Call {publicProvider.get('name') or 'the selected provider'} to confirm availability.",
382
+ "context": {
383
+ "missionState": {"route": route, "request": request, "clientId": clientId, "conversationId": conversationId},
384
+ "decisionFork": {"options": ["providerAvailable", "providerUnavailable", "requestAnotherProvider"]},
385
+ "logisticsBrief": {
386
+ "why": "HUNT found a local service provider; PROXY must confirm real-world availability before presenting the provider as verified.",
387
+ "selectedProvider": publicProvider,
388
+ },
389
+ "reason": "localServiceAvailability",
390
+ "selectedProvider": publicProvider,
391
+ "contactRef": contactRef,
392
+ "connectorId": connectorId,
393
+ "bridgePreflight": {"ok": preflight.get("ok"), "status": preflight.get("status"), "errors": preflight.get("errors", [])},
394
+ "availabilityVerification": {"status": callStatus, "providerName": publicProvider.get("name", ""), "contactRef": contactRef},
395
+ },
396
+ "voiceCall": voiceCall,
397
+ "proofReq": ["callTranscript", "availabilityWindow", "mechanicName", "consentRecord"],
398
+ "resume": {"conversationId": conversationId, "candidateId": str(publicProvider.get("providerId", "") or contactRef), "action": "proxyOutcome"},
399
+ }
400
+ )
401
+ proxyCreated = proxyStatus < 400 and bool(proxy.get("ok"))
402
+ results["proxy"] = proxy
403
+ event("proxy", "mission.response", "awaitingProxy" if proxyCreated else "blocked", "PROXY opened the availability-confirmation mission for the selected provider." if proxyCreated else "PROXY mission could not be created.", {"proxyId": proxy.get("proxyId", ""), "status": proxyStatus, "callStatus": callStatus})
404
+ agentConversation.append("[Proxy] Taking over the availability call and waiting for the provider's confirmation proof...")
405
+ else:
406
+ resolveResult = results.get("resolve", {}) if isinstance(results.get("resolve"), dict) else {}
407
+ bridgeResult = results.get("bridge", {}) if isinstance(results.get("bridge"), dict) else {}
408
+ triage = resolveResult.get("triage", {}) if isinstance(resolveResult.get("triage"), dict) else {}
409
+ claimDraft = resolveResult.get("claimDraft", {}) if isinstance(resolveResult.get("claimDraft"), dict) else {}
410
+ contactPlan = bridgeResult.get("contactPlan", {}) if isinstance(bridgeResult.get("contactPlan"), dict) else {}
411
+ proxyReason = "resolveEscalationReview" if resolveResult else "humanReviewOrContact"
412
+ event("proxy", "mission.request", "sent", "PROXY received a suspend-resume mission for human review or contact.", {"endpoint": "/v1/proxy/missions", "reason": proxyReason})
413
+ proxyStatus, proxy = host.createProxyMission(
414
+ {
415
+ "taskId": taskId,
416
+ "userId": clientId,
417
+ "kind": "call" if self._hasPhone(request, payload) else "review",
418
+ "reason": proxyReason,
419
+ "task": request,
420
+ "context": {
421
+ "missionState": {"route": route, "request": request, "clientId": clientId},
422
+ "decisionFork": {"options": ["continue", "requestMoreEvidence", "stop"]},
423
+ "logisticsBrief": {"why": "Human judgment or real-world contact is required before the agent can continue."},
424
+ "phone": self._phone(request, payload),
425
+ "resolveTriage": triage,
426
+ "claimDraft": claimDraft,
427
+ "contactPlan": contactPlan,
428
+ },
429
+ "voiceCall": {"phone": self._phone(request, payload), "prompt": request},
430
+ }
431
+ )
432
+ proxyCreated = proxyStatus < 400 and bool(proxy.get("ok"))
433
+ results["proxy"] = proxy
434
+ event("proxy", "mission.response", "awaitingProxy" if proxyCreated else "blocked", "PROXY suspended the run until a human outcome is returned." if proxyCreated else "PROXY mission could not be created.", {"proxyId": proxy.get("proxyId", ""), "status": proxyStatus})
435
+ agentConversation.append("[Proxy] Human review, voice, chat, or logistics handoff is waiting for completion proof...")
436
+ elif domain == "pay":
437
+ amount = selectedAmount or self._budget(request, payload) or 0.0
438
+ if not selectedVendor and not payload.get("payeeId"):
439
+ results["pay"] = {"ok": False, "error": "missingSourceBoundPayee", "reason": "PAY requires a selected source-bound payee or explicit payeeId."}
440
+ event("pay", "prepare.blocked", "blocked", "PAY refused to prepare a mandate without a selected source-bound payee.", {"amount": amount})
441
+ agentConversation.append("[Pay] Skipped mandate preparation because no source-bound payee was selected.")
442
+ index += 1
443
+ continue
444
+ selectedVendor = selectedVendor or str(payload.get("payeeId", "") or "")
445
+ mandate = host.createMandate(
446
+ {
447
+ "mandateId": self._stableId("mandate", {"taskId": taskId, "request": request}),
448
+ "issuer": {
449
+ "entityId": clientId,
450
+ "publicKey": str(payload.get("publicKey", "clientPublicKey") or "clientPublicKey"),
451
+ },
452
+ "constraints": {
453
+ "maxSpend": {"amount": self._budget(request, payload) or max(amount, 1.0), "currency": "USD"},
454
+ "allowedVendors": [selectedVendor],
455
+ "resolveRules": ["sourceBound", "proofBeforeSettlement"],
456
+ },
457
+ "validUntil": "2099-01-01T00:00:00Z",
458
+ "signature": str(payload.get("signature", "clientSignature") or "clientSignature"),
459
+ }
460
+ )
461
+ escrow = host.createEscrow(
462
+ {
463
+ "taskId": taskId,
464
+ "mandateId": mandate.get("mandateId", ""),
465
+ "status": "prepared",
466
+ "amount": {"amount": amount, "currency": "USD"},
467
+ "request": {"taskId": taskId, "vendorId": selectedVendor, "amount": {"amount": amount, "currency": "USD"}},
468
+ }
469
+ )
470
+ results["pay"] = {"mandate": mandate, "escrow": escrow}
471
+ event("pay", "prepare.response", "prepared", "PAY prepared a mandate and escrow boundary; settlement remains proof-gated.", {"mandateId": mandate.get("mandateId", ""), "escrowId": escrow.get("escrow", {}).get("escrowId", "")})
472
+ agentConversation.append("[Pay] Preparing mandate, settlement boundary, and proof-gated escrow...")
473
+ index += 1
474
+
475
+ status = "awaitingProxy" if proxyCreated else "ready"
476
+ if results.get("hunt") and not results["hunt"].get("ok"):
477
+ status = "needsSourceTelemetry"
478
+ if isinstance(results.get("pay"), dict) and results["pay"].get("ok") is False:
479
+ status = "blocked"
480
+ event("orchestrator", "proofPack", "created", "Picux created a portable proof card for the orchestrated run.", {"status": status})
481
+ proof = host._proofPackForRun(
482
+ taskId=taskId,
483
+ domain="orchestrator",
484
+ title="Picux orchestrated run",
485
+ summary=f"Picux routed the request through {', '.join(route).upper()} and produced a reviewable case file.",
486
+ payload={"request": request, "route": route, "results": results, "client": {"clientId": clientId, "channel": channel}},
487
+ decisions=[{"label": item["name"], "reason": item["status"]} for item in trace if item["phase"] != "orchestrator"],
488
+ evidenceStatus="sourceBound" if results else "needsEvidence",
489
+ confidence=0.72 if status == "ready" else 0.58,
490
+ openQuestions=["awaitingProxyOutcome"] if proxyCreated else ([] if status == "ready" else ["sourceTelemetryRequired"]),
491
+ escalation={"recommended": proxyCreated or status != "ready", "routeDomain": "proxy" if proxyCreated else "", "reason": status},
492
+ )
493
+ agentConversation.append("[Picux] I prepared the result with evidence status, open questions, and next steps.")
494
+ stream("conversation", {"line": agentConversation[-1]})
495
+ host.taskBook.updateTask(
496
+ taskId,
497
+ {
498
+ "status": ProtocolTaskStatus.AWAITING_PROXY.value if proxyCreated else (ProtocolTaskStatus.FAILED.value if status in {"blocked", "needsSourceTelemetry"} else ProtocolTaskStatus.COMPLETED.value),
499
+ "outData": {"conversationId": conversationId, "route": route, "status": status, "results": results, "decisionTree": self._decisionTree(results.get("hunt", {})), "proofPack": proof.get("proofPack", {})},
500
+ },
501
+ )
502
+ return {
503
+ "ok": True,
504
+ "scenario": "picuxOrchestrator",
505
+ "status": status,
506
+ "conversationId": conversationId,
507
+ "request": request,
508
+ "route": route,
509
+ "task": task,
510
+ "trace": trace,
511
+ "agentConversation": agentConversation,
512
+ "results": results,
513
+ "decisionTree": self._decisionTree(results.get("hunt", {})),
514
+ "mandate": mandate.get("mandate", mandate),
515
+ "receipt": None,
516
+ "freezeReceipt": None,
517
+ "proofPack": proof.get("proofPack", {}),
518
+ "proofCard": proof.get("proofPack", {}).get("proofCard", {}),
519
+ }
520
+
521
+ def _runResume(
522
+ self,
523
+ *,
524
+ payload: dict[str, Any],
525
+ request: str,
526
+ clientId: str,
527
+ channel: str,
528
+ metadata: dict[str, Any],
529
+ route: list[str],
530
+ toolResults: dict[str, Any],
531
+ trace: list[dict[str, Any]],
532
+ agentConversation: list[str],
533
+ event: Any,
534
+ stream: Any,
535
+ ) -> dict[str, Any]:
536
+ host = self.host
537
+ action = self._followUpAction(payload)
538
+ conversationId = self._conversationId(payload)
539
+ selectedItemId = self._selectedItemId(payload)
540
+ if not conversationId:
541
+ event("orchestrator", "conversation.load", "missing", "Picux could not resume because conversationId is missing.", {"action": action, "itemId": selectedItemId})
542
+ return self._resumeFailure(
543
+ request=request,
544
+ route=route,
545
+ status="conversationNotFound",
546
+ error="missing:conversationId",
547
+ conversationId="",
548
+ clientId=clientId,
549
+ channel=channel,
550
+ trace=trace,
551
+ agentConversation=agentConversation,
552
+ results={"tools": toolResults},
553
+ event=event,
554
+ stream=stream,
555
+ )
556
+
557
+ taskLookup = host.taskBook.getTaskByConversationId(conversationId) if hasattr(host.taskBook, "getTaskByConversationId") else host.taskBook.getTask(conversationId)
558
+ if not taskLookup.get("ok"):
559
+ event("orchestrator", "conversation.load", "missing", "Picux could not find the conversation state for this follow-up.", {"conversationId": conversationId, "error": taskLookup.get("error", "")})
560
+ return self._resumeFailure(
561
+ request=request,
562
+ route=route,
563
+ status="conversationNotFound",
564
+ error=str(taskLookup.get("error", "conversationNotFound") or "conversationNotFound"),
565
+ conversationId=conversationId,
566
+ clientId=clientId,
567
+ channel=channel,
568
+ trace=trace,
569
+ agentConversation=agentConversation,
570
+ results={"tools": toolResults},
571
+ event=event,
572
+ stream=stream,
573
+ )
574
+
575
+ task = taskLookup.get("task", {}) if isinstance(taskLookup.get("task"), dict) else {}
576
+ taskId = str(task.get("taskId", "") or conversationId)
577
+ priorOut = task.get("outData", {}) if isinstance(task.get("outData"), dict) else {}
578
+ priorResults = priorOut.get("results", {}) if isinstance(priorOut.get("results"), dict) else {}
579
+ hunt = priorResults.get("hunt", {}) if isinstance(priorResults.get("hunt"), dict) else {}
580
+ event("orchestrator", "conversation.load", "loaded", "Picux loaded the previous conversation state.", {"conversationId": conversationId, "taskId": taskId, "action": action})
581
+ if not hunt:
582
+ resume = {"action": action, "status": "missingHuntState", "conversationId": conversationId, "requestedItemId": selectedItemId}
583
+ results = {"tools": toolResults, "hunt": {"ok": False, "error": "missingHuntState", "conversationId": conversationId}}
584
+ results["resume"] = resume
585
+ event("hunt", "candidate.validate", "blocked", "HUNT has no previous candidate state to validate.", {"conversationId": conversationId})
586
+ return self._finalizeResume(
587
+ task=task,
588
+ priorOut=priorOut,
589
+ request=request,
590
+ conversationId=conversationId,
591
+ route=["hunt"],
592
+ status="missingHuntState",
593
+ ok=False,
594
+ results=results,
595
+ trace=trace,
596
+ agentConversation=agentConversation,
597
+ event=event,
598
+ stream=stream,
599
+ taskStatus=ProtocolTaskStatus.PROCESSING.value,
600
+ evidenceStatus="needsEvidence",
601
+ confidence=0.35,
602
+ openQuestions=["initialHuntResultRequired"],
603
+ escalation={"recommended": True, "routeDomain": "hunt", "reason": "missingHuntState"},
604
+ )
605
+
606
+ if not selectedItemId:
607
+ selectedItemId = self._selectedItemId(payload, hunt=hunt, request=request)
608
+
609
+ candidate = self._candidateById(hunt, selectedItemId)
610
+ if not candidate:
611
+ refs = self._candidateRefs(hunt)
612
+ resume = {
613
+ "action": action,
614
+ "status": "invalidCandidate",
615
+ "conversationId": conversationId,
616
+ "requestedItemId": selectedItemId,
617
+ "availableCandidates": refs,
618
+ }
619
+ results = {
620
+ "tools": toolResults,
621
+ "hunt": {
622
+ **hunt,
623
+ "resume": resume,
624
+ },
625
+ "resume": resume,
626
+ }
627
+ event("hunt", "candidate.validate", "invalid", "HUNT rejected the follow-up because the selected item does not belong to this conversation.", {"itemId": selectedItemId, "availableCandidates": refs[:8]})
628
+ agentConversation.append("[Hunt] I could not match that selected item to the source-bound options in this conversation.")
629
+ stream("conversation", {"line": agentConversation[-1]})
630
+ return self._finalizeResume(
631
+ task=task,
632
+ priorOut=priorOut,
633
+ request=request,
634
+ conversationId=conversationId,
635
+ route=["hunt"],
636
+ status="invalidCandidate",
637
+ ok=False,
638
+ results=results,
639
+ trace=trace,
640
+ agentConversation=agentConversation,
641
+ event=event,
642
+ stream=stream,
643
+ taskStatus=ProtocolTaskStatus.PROCESSING.value,
644
+ evidenceStatus="sourceBound",
645
+ confidence=0.42,
646
+ openQuestions=["validCandidateIdRequired"],
647
+ escalation={"recommended": True, "routeDomain": "hunt", "reason": "invalidCandidate"},
648
+ )
649
+
650
+ candidateRef = self._candidateRef(candidate)
651
+ event("hunt", "candidate.validate", "valid", "HUNT validated the selected source-bound candidate.", {"conversationId": conversationId, "candidate": candidateRef})
652
+ agentConversation.append(f"[Hunt] Resumed this conversation and validated {candidateRef.get('title') or candidateRef.get('candidateId')} from {self._displaySource(str(candidateRef.get('source', '') or 'the source'))}.")
653
+ stream("conversation", {"line": agentConversation[-1]})
654
+
655
+ if action == "pass":
656
+ rejected = self._rejectedCandidateIds(priorOut)
657
+ if candidateRef["candidateId"] not in rejected:
658
+ rejected.append(candidateRef["candidateId"])
659
+ nextCandidate = self._nextCandidateRef(hunt, rejected)
660
+ resume = {
661
+ "action": "pass",
662
+ "status": "candidatePassed",
663
+ "conversationId": conversationId,
664
+ "passedCandidate": candidateRef,
665
+ "nextCandidate": nextCandidate,
666
+ "rejectedCandidateIds": rejected,
667
+ }
668
+ results = {"tools": toolResults, "hunt": {**hunt, "resume": resume}, "resume": resume}
669
+ if nextCandidate:
670
+ agentConversation.append(f"[Hunt] You passed on that option. The next source-bound option is {nextCandidate.get('title') or nextCandidate.get('candidateId')}.")
671
+ else:
672
+ agentConversation.append("[Hunt] You passed on that option. I do not have another source-bound candidate from the current result set.")
673
+ stream("conversation", {"line": agentConversation[-1]})
674
+ return self._finalizeResume(
675
+ task=task,
676
+ priorOut=priorOut,
677
+ request=request,
678
+ conversationId=conversationId,
679
+ route=["hunt"],
680
+ status="candidatePassed",
681
+ ok=True,
682
+ results=results,
683
+ trace=trace,
684
+ agentConversation=agentConversation,
685
+ event=event,
686
+ stream=stream,
687
+ taskStatus=ProtocolTaskStatus.PROCESSING.value,
688
+ evidenceStatus="sourceBound",
689
+ confidence=0.68,
690
+ openQuestions=[] if nextCandidate else ["noMoreSourceBoundOptions"],
691
+ escalation={"recommended": not bool(nextCandidate), "routeDomain": "hunt" if not nextCandidate else "", "reason": "candidatePassed"},
692
+ )
693
+
694
+ if self._isLocalServiceCandidate(candidateRef):
695
+ return self._runLocalServiceResume(
696
+ payload=payload,
697
+ task=task,
698
+ priorOut=priorOut,
699
+ hunt=hunt,
700
+ candidateRef=candidateRef,
701
+ request=request,
702
+ conversationId=conversationId,
703
+ taskId=taskId,
704
+ clientId=clientId,
705
+ channel=channel,
706
+ toolResults=toolResults,
707
+ trace=trace,
708
+ agentConversation=agentConversation,
709
+ event=event,
710
+ stream=stream,
711
+ )
712
+
713
+ if action == "contact":
714
+ return self._runCandidateContactResume(
715
+ payload=payload,
716
+ task=task,
717
+ priorOut=priorOut,
718
+ hunt=hunt,
719
+ candidateRef=candidateRef,
720
+ request=request,
721
+ conversationId=conversationId,
722
+ taskId=taskId,
723
+ clientId=clientId,
724
+ toolResults=toolResults,
725
+ trace=trace,
726
+ agentConversation=agentConversation,
727
+ event=event,
728
+ stream=stream,
729
+ )
730
+
731
+ if action == "refine":
732
+ resume = {
733
+ "action": "refine",
734
+ "status": "NEEDS_INPUT",
735
+ "conversationId": conversationId,
736
+ "selectedCandidate": candidateRef,
737
+ "requestedKeys": ["refinement"],
738
+ }
739
+ results = {"tools": toolResults, "hunt": {**hunt, "resume": resume}, "resume": resume}
740
+ event("hunt", "refine.awaitingInput", "NEEDS_INPUT", "HUNT needs the updated filter, budget, or constraint for this follow-up.", {"candidate": candidateRef})
741
+ agentConversation.append("[Hunt] Tell me what to change in the search: budget, location, source, condition, or another filter.")
742
+ stream("conversation", {"line": agentConversation[-1]})
743
+ return self._finalizeResume(
744
+ task=task,
745
+ priorOut=priorOut,
746
+ request=request,
747
+ conversationId=conversationId,
748
+ route=["hunt"],
749
+ status="NEEDS_INPUT",
750
+ ok=True,
751
+ results=results,
752
+ trace=trace,
753
+ agentConversation=agentConversation,
754
+ event=event,
755
+ stream=stream,
756
+ taskStatus=ProtocolTaskStatus.PROCESSING.value,
757
+ evidenceStatus="sourceBound",
758
+ confidence=0.62,
759
+ openQuestions=["refinement"],
760
+ escalation={"recommended": False, "routeDomain": "hunt", "reason": "awaitingRefinement"},
761
+ )
762
+
763
+ purchaseInputs = self._purchaseInputs(payload)
764
+ missingContactKeys = self._missingContactKeys(purchaseInputs)
765
+ missingPaymentKeys = [] if missingContactKeys else self._missingPaymentKeys(purchaseInputs)
766
+ missingKeys = missingContactKeys or missingPaymentKeys
767
+ resumeStatus = "NEEDS_INPUT" if missingContactKeys else ("NEEDS_PAYMENT_INFO" if missingPaymentKeys else "purchasePrepared")
768
+ purchaseIntent = self._purchaseIntent(conversationId, taskId, candidateRef, purchaseInputs, missingKeys, resumeStatus)
769
+ resume = {
770
+ "action": "buy",
771
+ "status": resumeStatus,
772
+ "conversationId": conversationId,
773
+ "selectedCandidate": candidateRef,
774
+ "purchaseIntent": purchaseIntent,
775
+ "requestedKeys": missingKeys,
776
+ "resumeToken": self._stableId("resume", {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "action": "buy"}),
777
+ }
778
+ results = {
779
+ "tools": toolResults,
780
+ "hunt": {**hunt, "resume": {"action": "buy", "status": "candidateValidated", "selectedCandidate": candidateRef}},
781
+ "purchaseIntent": purchaseIntent,
782
+ }
783
+ bridgeRoute = ["hunt", "bridge", "pay"]
784
+ if missingContactKeys:
785
+ bridge = self._bridgeNeedsInput(candidateRef, missingKeys)
786
+ pay = {
787
+ "ok": False,
788
+ "status": "AWAITING_INPUT",
789
+ "requestedKeys": [],
790
+ "supportedPaymentMethods": self._supportedPaymentMethods(),
791
+ "paymentSchema": self._paymentSchema(),
792
+ "settlementGate": {"status": "locked", "reason": "missingContactOrLogisticsInput", "proofRequired": True},
793
+ }
794
+ results["bridge"] = bridge
795
+ results["pay"] = pay
796
+ results["resume"] = resume
797
+ event("bridge", "checkout.prepare", "NEEDS_INPUT", "BRIDGE paused checkout routing until the client supplies required purchase inputs.", {"requestedKeys": missingKeys, "candidate": candidateRef})
798
+ event("pay", "prepare.awaitingInput", "AWAITING_INPUT", "PAY discovered supported payment methods but is waiting for contact and logistics input before collecting payment data.", {"supportedPaymentMethods": self._supportedPaymentMethods()})
799
+ agentConversation.append(f"[Bridge] I need {', '.join(missingKeys)} before I can prepare checkout routing.")
800
+ stream("conversation", {"line": agentConversation[-1]})
801
+ agentConversation.append("[Pay] I found the supported payment methods, but payment collection waits until the required contact and logistics input is complete.")
802
+ stream("conversation", {"line": agentConversation[-1]})
803
+ return self._finalizeResume(
804
+ task=task,
805
+ priorOut=priorOut,
806
+ request=request,
807
+ conversationId=conversationId,
808
+ route=bridgeRoute,
809
+ status="NEEDS_INPUT",
810
+ ok=True,
811
+ results=results,
812
+ trace=trace,
813
+ agentConversation=agentConversation,
814
+ event=event,
815
+ stream=stream,
816
+ taskStatus=ProtocolTaskStatus.PROCESSING.value,
817
+ evidenceStatus="sourceBound",
818
+ confidence=0.64,
819
+ openQuestions=missingKeys,
820
+ escalation={"recommended": True, "routeDomain": "bridge", "reason": "missingPurchaseInput"},
821
+ )
822
+
823
+ if missingPaymentKeys:
824
+ bridge = {
825
+ "ok": True,
826
+ "status": "inputReady",
827
+ "mode": "adapterNeutral",
828
+ "checkout": {
829
+ "action": "preparePaymentInfo",
830
+ "source": candidateRef.get("source", ""),
831
+ "url": candidateRef.get("url", ""),
832
+ "candidateId": candidateRef.get("candidateId", ""),
833
+ },
834
+ }
835
+ pay = {
836
+ "ok": False,
837
+ "status": "NEEDS_PAYMENT_INFO",
838
+ "requestedKeys": missingPaymentKeys,
839
+ "supportedPaymentMethods": self._supportedPaymentMethods(),
840
+ "paymentSchema": self._paymentSchema(),
841
+ "settlementGate": {"status": "locked", "reason": "missingPaymentInfo", "proofRequired": True},
842
+ }
843
+ results["bridge"] = bridge
844
+ results["pay"] = pay
845
+ results["resume"] = resume
846
+ event("bridge", "checkout.prepare", "inputReady", "BRIDGE has enough contact and logistics input to prepare checkout routing.", {"candidate": candidateRef})
847
+ event("pay", "prepare.needsPaymentInfo", "NEEDS_PAYMENT_INFO", "PAY discovered supported payment methods and requested method-specific payment information.", {"requestedKeys": missingPaymentKeys, "supportedPaymentMethods": self._supportedPaymentMethods()})
848
+ agentConversation.append("[Bridge] Contact and logistics input is ready for checkout routing.")
849
+ stream("conversation", {"line": agentConversation[-1]})
850
+ agentConversation.append("[Pay] Choose a supported payment method and provide the method-specific payment details.")
851
+ stream("conversation", {"line": agentConversation[-1]})
852
+ return self._finalizeResume(
853
+ task=task,
854
+ priorOut=priorOut,
855
+ request=request,
856
+ conversationId=conversationId,
857
+ route=bridgeRoute,
858
+ status="NEEDS_PAYMENT_INFO",
859
+ ok=True,
860
+ results=results,
861
+ trace=trace,
862
+ agentConversation=agentConversation,
863
+ event=event,
864
+ stream=stream,
865
+ taskStatus=ProtocolTaskStatus.PROCESSING.value,
866
+ evidenceStatus="sourceBound",
867
+ confidence=0.68,
868
+ openQuestions=missingPaymentKeys,
869
+ escalation={"recommended": True, "routeDomain": "pay", "reason": "missingPaymentInfo"},
870
+ )
871
+
872
+ installed = host._ensureReadyConnectors()
873
+ checkoutConnectors = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["marketplace.checkout", "browser.checkout", "purchase.execute"]})
874
+ connectorRecords = checkoutConnectors.get("connectors", []) if isinstance(checkoutConnectors.get("connectors"), list) else []
875
+ checkoutConnector = connectorRecords[0] if connectorRecords and isinstance(connectorRecords[0], dict) else {}
876
+ checkoutConnectorId = str(checkoutConnector.get("connectorId", "") or "")
877
+ bridge = {
878
+ "ok": True,
879
+ "status": "purchaseAttempting",
880
+ "mode": "browserCheckout",
881
+ "readyConnectors": installed,
882
+ "connectors": checkoutConnectors,
883
+ "checkout": {
884
+ "action": "browserPurchase",
885
+ "connectorId": checkoutConnectorId,
886
+ "source": candidateRef.get("source", ""),
887
+ "url": candidateRef.get("url", ""),
888
+ "candidateId": candidateRef["candidateId"],
889
+ "note": "Picux is attempting source-bound checkout through the browser tool and will fail closed if purchase confirmation is not observed.",
890
+ },
891
+ "handoffs": [
892
+ {"from": "bridge", "to": "pay", "action": "prepareMandateEscrow", "status": "sent"},
893
+ {"from": "bridge", "to": "browserCheckout", "connectorId": checkoutConnectorId, "action": "executePurchase", "status": "sent" if checkoutConnectorId else "blocked"},
894
+ ],
895
+ }
896
+ event("bridge", "checkout.connectorMatch", "matched" if checkoutConnectorId else "missingConnector", "BRIDGE matched a checkout connector for marketplace purchase execution." if checkoutConnectorId else "BRIDGE could not find a checkout connector for marketplace purchase execution.", {"connectorId": checkoutConnectorId, "connectorCount": checkoutConnectors.get("count", 0), "caps": ["marketplace.checkout", "browser.checkout", "purchase.execute"]})
897
+ event("bridge", "handoff.pay", "sent", "BRIDGE handed the purchase intent to PAY for mandate and escrow preparation.", {"target": "pay", "purchaseIntentId": purchaseIntent["intentId"], "candidateId": candidateRef["candidateId"]})
898
+ event("bridge", "checkout.prepare", "purchaseAttempting", "BRIDGE prepared the source-bound browser checkout attempt.", {"connectorCount": checkoutConnectors.get("count", 0), "connectorId": checkoutConnectorId, "candidate": candidateRef})
899
+ amount = float(candidateRef.get("netUsd", 0.0) or 0.0)
900
+ vendorId = str(candidateRef.get("sourceId", "") or candidateRef["candidateId"])
901
+ event("pay", "prepare.request", "received", "PAY received the purchase intent from BRIDGE and is preparing mandate plus escrow.", {"purchaseIntentId": purchaseIntent["intentId"], "amount": amount, "vendorId": vendorId})
902
+ mandate = host.createMandate(
903
+ {
904
+ "mandateId": self._stableId("mandate", {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "intentId": purchaseIntent["intentId"]}),
905
+ "issuer": {
906
+ "entityId": clientId,
907
+ "publicKey": str(payload.get("publicKey", "clientPublicKey") or "clientPublicKey"),
908
+ },
909
+ "constraints": {
910
+ "maxSpend": {"amount": max(amount, 1.0), "currency": "USD"},
911
+ "allowedVendors": [vendorId],
912
+ "resolveRules": ["sourceBound", "purchaseIntent", "verifiedCompletionProof", "userConfirmedPurchase"],
913
+ },
914
+ "validUntil": "2099-01-01T00:00:00Z",
915
+ "signature": str(payload.get("signature", "clientSignature") or "clientSignature"),
916
+ "meta": {"conversationId": conversationId, "purchaseIntentId": purchaseIntent["intentId"]},
917
+ }
918
+ )
919
+ escrow = host.createEscrow(
920
+ {
921
+ "taskId": taskId,
922
+ "mandateId": mandate.get("mandateId", ""),
923
+ "status": "prepared",
924
+ "amount": {"amount": amount, "currency": "USD"},
925
+ "request": {
926
+ "taskId": taskId,
927
+ "conversationId": conversationId,
928
+ "purchaseIntentId": purchaseIntent["intentId"],
929
+ "vendorId": vendorId,
930
+ "amount": {"amount": amount, "currency": "USD"},
931
+ },
932
+ }
933
+ )
934
+ pay = {
935
+ "ok": True,
936
+ "status": "prepared",
937
+ "mandate": mandate,
938
+ "escrow": escrow,
939
+ "settlementGate": {"status": "locked", "reason": "awaitingVerifiedCompletionProof", "proofRequired": True},
940
+ }
941
+ if checkoutConnectorId:
942
+ event("bridge", "handoff.browserCheckout", "sent", "BRIDGE handed purchase execution to the Browser Checkout connector.", {"connectorId": checkoutConnectorId, "endpoint": checkoutConnector.get("endpoint", ""), "purchaseIntentId": purchaseIntent["intentId"], "candidateId": candidateRef["candidateId"]})
943
+ checkout = host.tools.checkoutBrowser(
944
+ {
945
+ **self._safePurchaseInputs(purchaseInputs),
946
+ "connectorId": checkoutConnectorId,
947
+ "url": candidateRef.get("url", ""),
948
+ "source": candidateRef.get("source", ""),
949
+ "candidateId": candidateRef["candidateId"],
950
+ "purchaseIntentId": purchaseIntent["intentId"],
951
+ "executePurchase": bool(payload.get("executePurchase", payload.get("allowPurchaseExecution", False))),
952
+ }
953
+ )
954
+ else:
955
+ checkout = {
956
+ "ok": False,
957
+ "tool": "browserCheckout",
958
+ "status": "blocked",
959
+ "reason": "bridgeCheckoutConnectorMissing",
960
+ "message": "Sorry, I am unable to make the purchase due to bridgeCheckoutConnectorMissing.",
961
+ "candidateId": candidateRef["candidateId"],
962
+ "source": candidateRef.get("source", ""),
963
+ "url": candidateRef.get("url", ""),
964
+ "executePurchase": bool(payload.get("executePurchase", payload.get("allowPurchaseExecution", False))),
965
+ }
966
+ toolResults["browserCheckout"] = checkout
967
+ purchaseOk = bool(checkout.get("ok"))
968
+ checkoutNeedsInput = self._checkoutNeedsInput(checkout)
969
+ purchaseStatus = "purchaseCompleted" if purchaseOk else ("NEEDS_INPUT" if checkoutNeedsInput else "purchaseBlocked")
970
+ purchaseOutcome = {
971
+ "ok": purchaseOk,
972
+ "status": "purchased" if purchaseOk else ("needsInput" if checkoutNeedsInput else "blocked"),
973
+ "message": "Item successfully purchased" if purchaseOk else f"Sorry, I am unable to make the purchase due to {checkout.get('reason', 'purchaseFailed')}.",
974
+ "reason": str(checkout.get("reason", "") or ""),
975
+ "checkout": checkout,
976
+ }
977
+ payExecution: dict[str, Any] = {}
978
+ if purchaseOk:
979
+ payExecution = self._settleCheckoutProof(
980
+ host,
981
+ escrow=escrow,
982
+ purchaseIntent=purchaseIntent,
983
+ candidateRef=candidateRef,
984
+ checkout=checkout,
985
+ event=event,
986
+ )
987
+ if payExecution:
988
+ pay = {**pay, **payExecution}
989
+ else:
990
+ pay["settlementGate"] = {"status": "proofReady", "reason": "browserPurchaseConfirmed", "proofRequired": True}
991
+ else:
992
+ pay["settlementGate"] = {"status": "locked", "reason": str(checkout.get("reason", "purchaseFailed") or "purchaseFailed"), "proofRequired": True}
993
+ if checkoutNeedsInput:
994
+ needs = checkout.get("needsInput", []) if isinstance(checkout.get("needsInput"), list) else []
995
+ requested = [str(item.get("key", "") or "") for item in needs if isinstance(item, dict) and str(item.get("key", "") or "")]
996
+ purchaseIntent = {**purchaseIntent, "status": "needsInput", "requestedKeys": requested, "needsInput": needs}
997
+ bridge = {**bridge, "status": "NEEDS_INPUT", "needsInput": needs, "requestedKeys": requested}
998
+ pay = {**pay, "status": "AWAITING_CHECKOUT_INPUT", "requestedKeys": requested, "settlementGate": {"status": "locked", "reason": str(checkout.get("reason", "checkoutInputRequired") or "checkoutInputRequired"), "proofRequired": True}}
999
+ results["bridge"] = bridge
1000
+ results["pay"] = pay
1001
+ results["browserCheckout"] = checkout
1002
+ results["purchaseIntent"] = purchaseIntent
1003
+ results["hunt"] = {**(results.get("hunt", {}) if isinstance(results.get("hunt"), dict) else {}), "purchaseOutcome": purchaseOutcome}
1004
+ results["resume"] = resume
1005
+ event("pay", "prepare.response", "prepared", "PAY prepared a mandate and proof-gated escrow for the purchase intent.", {"mandateId": mandate.get("mandateId", ""), "escrowId": escrow.get("escrow", {}).get("escrowId", "")})
1006
+ event("tools", "browserCheckout", str(checkout.get("status", "blocked")), "Browser tool attempted source-bound purchase execution and returned a concrete outcome.", {"ok": checkout.get("ok"), "reason": checkout.get("reason", ""), "candidateId": candidateRef["candidateId"]})
1007
+ event("hunt", "purchase.outcome", "purchased" if purchaseOk else "blocked", "HUNT received the purchase outcome from the browser checkout attempt.", purchaseOutcome)
1008
+ agentConversation.append("[Bridge] Making your purchase...")
1009
+ stream("conversation", {"line": agentConversation[-1]})
1010
+ if purchaseOk and pay.get("status") == "settled":
1011
+ agentConversation.append("[Pay] Payment settled against the verified checkout proof.")
1012
+ elif purchaseOk and payExecution:
1013
+ agentConversation.append(f"[Pay] Payment is still locked: {payExecution.get('reason', 'settlementNotReleased')}.")
1014
+ elif checkoutNeedsInput:
1015
+ agentConversation.append("[Pay] Payment remains locked while checkout waits for the required input.")
1016
+ else:
1017
+ agentConversation.append("[Pay] Mandate and escrow are prepared; settlement remains proof-gated.")
1018
+ stream("conversation", {"line": agentConversation[-1]})
1019
+ agentConversation.append("[Hunt] Item successfully purchased." if purchaseOk else purchaseOutcome["message"])
1020
+ stream("conversation", {"line": agentConversation[-1]})
1021
+ return self._finalizeResume(
1022
+ task=task,
1023
+ priorOut=priorOut,
1024
+ request=request,
1025
+ conversationId=conversationId,
1026
+ route=bridgeRoute,
1027
+ status=purchaseStatus,
1028
+ ok=True,
1029
+ results=results,
1030
+ trace=trace,
1031
+ agentConversation=agentConversation,
1032
+ event=event,
1033
+ stream=stream,
1034
+ taskStatus=ProtocolTaskStatus.PROCESSING.value,
1035
+ evidenceStatus="verified" if purchaseOk else "sourceBound",
1036
+ confidence=0.82 if purchaseOk else 0.58,
1037
+ openQuestions=[] if purchaseOk else ([str(item.get("key", "") or "") for item in checkout.get("needsInput", []) if isinstance(item, dict) and str(item.get("key", "") or "")] or [str(checkout.get("reason", "purchaseFailed") or "purchaseFailed")]),
1038
+ escalation={"recommended": not purchaseOk, "routeDomain": "bridge" if checkoutNeedsInput else ("hunt" if not purchaseOk else ""), "reason": "purchaseCompleted" if purchaseOk else str(checkout.get("reason", "purchaseFailed") or "purchaseFailed")},
1039
+ )
1040
+
1041
+ def _runLocalServiceResume(
1042
+ self,
1043
+ *,
1044
+ payload: dict[str, Any],
1045
+ task: dict[str, Any],
1046
+ priorOut: dict[str, Any],
1047
+ hunt: dict[str, Any],
1048
+ candidateRef: dict[str, Any],
1049
+ request: str,
1050
+ conversationId: str,
1051
+ taskId: str,
1052
+ clientId: str,
1053
+ channel: str,
1054
+ toolResults: dict[str, Any],
1055
+ trace: list[dict[str, Any]],
1056
+ agentConversation: list[str],
1057
+ event: Any,
1058
+ stream: Any,
1059
+ ) -> dict[str, Any]:
1060
+ host = self.host
1061
+ originalRequest = self._originalTaskRequest(task) or request
1062
+ inputs = self._localServiceInputs(payload, candidateRef, defaultBrief=originalRequest)
1063
+ missingKeys = self._missingLocalServiceKeys(inputs, candidateRef)
1064
+ route = ["hunt", "bridge", "proxy"]
1065
+ proxyIntent = self._proxyContactIntent(conversationId, taskId, candidateRef, inputs, missingKeys)
1066
+ resumeStatus = "NEEDS_INPUT" if missingKeys else "awaitingProxy"
1067
+ resume = {
1068
+ "action": "contact",
1069
+ "status": resumeStatus,
1070
+ "conversationId": conversationId,
1071
+ "selectedCandidate": candidateRef,
1072
+ "proxyIntent": proxyIntent,
1073
+ "requestedKeys": missingKeys,
1074
+ "resumeToken": self._stableId("resume", {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "action": "contact"}),
1075
+ }
1076
+ installed = host._ensureReadyConnectors()
1077
+ bridgeConnectors = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["messages.send", "identity.verify", "consent.capture", "voice.call"]})
1078
+ connectorRecords = bridgeConnectors.get("connectors", []) if isinstance(bridgeConnectors.get("connectors"), list) else []
1079
+ connector = next((item for item in connectorRecords if isinstance(item, dict) and item.get("connectorId") == "whatsapp"), connectorRecords[0] if connectorRecords and isinstance(connectorRecords[0], dict) else {})
1080
+ connectorId = str(connector.get("connectorId", "") or "")
1081
+ event(
1082
+ "bridge",
1083
+ "connectorMatch.response",
1084
+ "matched" if connectorId else "missingConnector",
1085
+ "BRIDGE matched a human-contact connector for Proxy handoff." if connectorId else "BRIDGE could not match a human-contact connector for Proxy handoff.",
1086
+ {"connectorId": connectorId, "connectorCount": bridgeConnectors.get("count", 0), "caps": ["messages.send", "identity.verify", "consent.capture", "voice.call"]},
1087
+ )
1088
+ results: dict[str, Any] = {
1089
+ "tools": toolResults,
1090
+ "hunt": {**hunt, "resume": {"action": "contact", "status": "candidateValidated", "selectedCandidate": candidateRef}},
1091
+ "proxyIntent": proxyIntent,
1092
+ "resume": resume,
1093
+ }
1094
+ if missingKeys:
1095
+ bridge = {
1096
+ "ok": False,
1097
+ "status": "NEEDS_INPUT",
1098
+ "mode": "proxyContact",
1099
+ "requestedKeys": missingKeys,
1100
+ "readyConnectors": installed,
1101
+ "connectors": bridgeConnectors,
1102
+ "contact": {
1103
+ "action": "requestProxyContactInput",
1104
+ "connectorId": connectorId,
1105
+ "candidateId": candidateRef.get("candidateId", ""),
1106
+ "businessPhone": candidateRef.get("phone", ""),
1107
+ "preferredChannel": inputs.get("preferredChannel", "whatsapp"),
1108
+ },
1109
+ }
1110
+ proxy = {
1111
+ "ok": False,
1112
+ "status": "NEEDS_INPUT",
1113
+ "requestedKeys": missingKeys,
1114
+ "reason": "identityVerificationAndConsentRequiresInput",
1115
+ "proxyIntent": proxyIntent,
1116
+ }
1117
+ results["bridge"] = bridge
1118
+ results["proxy"] = proxy
1119
+ event("bridge", "proxy.prepare", "NEEDS_INPUT", "BRIDGE paused Proxy handoff until required consent and contact inputs are supplied.", {"requestedKeys": missingKeys, "candidate": candidateRef})
1120
+ event("proxy", "mission.awaitingInput", "NEEDS_INPUT", "PROXY is waiting for human-contact details before making a call or WhatsApp handoff.", {"requestedKeys": missingKeys})
1121
+ agentConversation.append(f"[Bridge] I need {', '.join(missingKeys)} before Proxy can contact the selected service.")
1122
+ stream("conversation", {"line": agentConversation[-1]})
1123
+ agentConversation.append("[Proxy] Waiting for consent and contact details before calling the selected human or business.")
1124
+ stream("conversation", {"line": agentConversation[-1]})
1125
+ return self._finalizeResume(
1126
+ task=task,
1127
+ priorOut=priorOut,
1128
+ request=request,
1129
+ conversationId=conversationId,
1130
+ route=route,
1131
+ status="NEEDS_INPUT",
1132
+ ok=True,
1133
+ results=results,
1134
+ trace=trace,
1135
+ agentConversation=agentConversation,
1136
+ event=event,
1137
+ stream=stream,
1138
+ taskStatus=ProtocolTaskStatus.PROCESSING.value,
1139
+ evidenceStatus="sourceBound",
1140
+ confidence=0.66,
1141
+ openQuestions=missingKeys,
1142
+ escalation={"recommended": True, "routeDomain": "proxy", "reason": "missingProxyContactInput"},
1143
+ )
1144
+
1145
+ businessPhone = str(inputs.get("businessPhone", candidateRef.get("phone", "")) or "")
1146
+ channelMsg = {
1147
+ "channel": str(inputs.get("preferredChannel", "whatsapp") or "whatsapp"),
1148
+ "to": businessPhone,
1149
+ "text": f"Picux request: verify identity and consent for {candidateRef.get('title', 'selected service')}. User need: {inputs.get('issueBrief', originalRequest)}",
1150
+ "connectorId": connectorId,
1151
+ }
1152
+ voiceCall = {
1153
+ "phone": businessPhone,
1154
+ "status": "readyToCall",
1155
+ "prompt": f"Verify identity, availability, and consent for a Picux user request. Service: {candidateRef.get('title', '')}. Brief: {inputs.get('issueBrief', originalRequest)}",
1156
+ "connectorId": connectorId,
1157
+ }
1158
+ bridge = {
1159
+ "ok": True,
1160
+ "status": "proxyHandoffReady",
1161
+ "mode": "proxyContact",
1162
+ "readyConnectors": installed,
1163
+ "connectors": bridgeConnectors,
1164
+ "handoffs": [{"from": "bridge", "to": "proxy", "connectorId": connectorId, "action": "identityVerificationAndConsent", "status": "sent" if connectorId else "adapterNeutral"}],
1165
+ "contact": {"connectorId": connectorId, "channelMsg": channelMsg, "voiceCall": voiceCall, "candidate": candidateRef},
1166
+ }
1167
+ event("bridge", "handoff.proxy", "sent", "BRIDGE handed selected service contact to PROXY for identity verification and consent.", {"connectorId": connectorId, "candidateId": candidateRef["candidateId"], "businessPhone": businessPhone})
1168
+ event("proxy", "mission.request", "sent", "PROXY received the selected service as a callable human/business node.", {"endpoint": "/v1/proxy/missions", "candidateId": candidateRef["candidateId"]})
1169
+ proxyStatus, proxy = host.createProxyMission(
1170
+ {
1171
+ "taskId": taskId,
1172
+ "userId": clientId,
1173
+ "kind": "call",
1174
+ "reason": "identityVerificationAndConsent",
1175
+ "task": f"Contact {candidateRef.get('title') or candidateRef.get('candidateId')} for identity verification and consent.",
1176
+ "context": {
1177
+ "missionState": {"route": route, "request": originalRequest, "clientId": clientId, "conversationId": conversationId},
1178
+ "decisionFork": {"options": ["verifyIdentityAndAvailability", "requestAnotherProvider", "stop"]},
1179
+ "logisticsBrief": {
1180
+ "why": "The user selected a local service candidate; a human/business must confirm identity, availability, and consent before the workflow can proceed.",
1181
+ "selectedService": candidateRef,
1182
+ "userPhone": inputs.get("userPhone", ""),
1183
+ "issueBrief": inputs.get("issueBrief", originalRequest),
1184
+ },
1185
+ "reason": "identityVerificationAndConsent",
1186
+ "channelMsg": channelMsg,
1187
+ "voiceCall": voiceCall,
1188
+ "connectorId": connectorId,
1189
+ },
1190
+ "channelMsg": channelMsg,
1191
+ "voiceCall": voiceCall,
1192
+ "executeProxy": True,
1193
+ "proofReq": ["callTranscript", "consentRecord", "identityConfirmation"],
1194
+ "resume": {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "action": "proxyOutcome"},
1195
+ }
1196
+ )
1197
+ proxyCreated = proxyStatus < 400 and bool(proxy.get("ok"))
1198
+ results["bridge"] = bridge
1199
+ results["proxy"] = proxy
1200
+ event("proxy", "mission.response", "awaitingProxy" if proxyCreated else "blocked", "PROXY suspended the run until the selected human/business responds." if proxyCreated else "PROXY mission could not be created.", {"proxyId": proxy.get("proxyId", ""), "status": proxyStatus})
1201
+ agentConversation.append("[Bridge] Routing the selected service through WhatsApp or voice contact.")
1202
+ stream("conversation", {"line": agentConversation[-1]})
1203
+ agentConversation.append("[Proxy] Calling the selected human or business for identity verification and consent.")
1204
+ stream("conversation", {"line": agentConversation[-1]})
1205
+ return self._finalizeResume(
1206
+ task=task,
1207
+ priorOut=priorOut,
1208
+ request=request,
1209
+ conversationId=conversationId,
1210
+ route=route,
1211
+ status="awaitingProxy" if proxyCreated else "proxyBlocked",
1212
+ ok=proxyCreated,
1213
+ results=results,
1214
+ trace=trace,
1215
+ agentConversation=agentConversation,
1216
+ event=event,
1217
+ stream=stream,
1218
+ taskStatus=ProtocolTaskStatus.AWAITING_PROXY.value if proxyCreated else ProtocolTaskStatus.PROCESSING.value,
1219
+ evidenceStatus="sourceBound",
1220
+ confidence=0.74 if proxyCreated else 0.5,
1221
+ openQuestions=["awaitingProxyOutcome"] if proxyCreated else ["proxyMissionRequired"],
1222
+ escalation={"recommended": True, "routeDomain": "proxy", "reason": "awaitingProxy" if proxyCreated else "proxyBlocked"},
1223
+ )
1224
+
1225
+ def _runCandidateContactResume(
1226
+ self,
1227
+ *,
1228
+ payload: dict[str, Any],
1229
+ task: dict[str, Any],
1230
+ priorOut: dict[str, Any],
1231
+ hunt: dict[str, Any],
1232
+ candidateRef: dict[str, Any],
1233
+ request: str,
1234
+ conversationId: str,
1235
+ taskId: str,
1236
+ clientId: str,
1237
+ toolResults: dict[str, Any],
1238
+ trace: list[dict[str, Any]],
1239
+ agentConversation: list[str],
1240
+ event: Any,
1241
+ stream: Any,
1242
+ ) -> dict[str, Any]:
1243
+ host = self.host
1244
+ contact = str(payload.get("sellerContact", payload.get("contact", payload.get("to", ""))) or "")
1245
+ message = str(payload.get("message", payload.get("text", request)) or request)
1246
+ missing = []
1247
+ if not contact:
1248
+ missing.append("sellerContact")
1249
+ if not message.strip():
1250
+ missing.append("message")
1251
+ route = ["hunt", "bridge", "proxy"]
1252
+ resume = {
1253
+ "action": "contact",
1254
+ "status": "NEEDS_INPUT" if missing else "awaitingProxy",
1255
+ "conversationId": conversationId,
1256
+ "selectedCandidate": candidateRef,
1257
+ "requestedKeys": missing,
1258
+ }
1259
+ results: dict[str, Any] = {"tools": toolResults, "hunt": {**hunt, "resume": resume}, "resume": resume}
1260
+ if missing:
1261
+ results["bridge"] = {"ok": False, "status": "NEEDS_INPUT", "requestedKeys": missing, "candidate": candidateRef}
1262
+ results["proxy"] = {"ok": False, "status": "NEEDS_INPUT", "requestedKeys": missing, "reason": "sellerContactRequired"}
1263
+ event("bridge", "sellerContact.awaitingInput", "NEEDS_INPUT", "BRIDGE needs a seller contact or channel reference before Proxy can contact this source.", {"candidate": candidateRef, "requestedKeys": missing})
1264
+ agentConversation.append("[Bridge] I need the seller contact or channel reference before Proxy can contact this option.")
1265
+ stream("conversation", {"line": agentConversation[-1]})
1266
+ return self._finalizeResume(
1267
+ task=task,
1268
+ priorOut=priorOut,
1269
+ request=request,
1270
+ conversationId=conversationId,
1271
+ route=route,
1272
+ status="NEEDS_INPUT",
1273
+ ok=True,
1274
+ results=results,
1275
+ trace=trace,
1276
+ agentConversation=agentConversation,
1277
+ event=event,
1278
+ stream=stream,
1279
+ taskStatus=ProtocolTaskStatus.PROCESSING.value,
1280
+ evidenceStatus="sourceBound",
1281
+ confidence=0.6,
1282
+ openQuestions=missing,
1283
+ escalation={"recommended": True, "routeDomain": "bridge", "reason": "sellerContactRequired"},
1284
+ )
1285
+ installed = host._ensureReadyConnectors()
1286
+ isPhone = bool(re.search(r"\+?\d[\d\s().-]{6,}", contact))
1287
+ bridgeConnectors = host.connectorBook.matchConnectors({"domain": "bridge", "caps": ["voice.call" if isPhone else "messages.send"]})
1288
+ connectorRecords = bridgeConnectors.get("connectors", []) if isinstance(bridgeConnectors.get("connectors"), list) else []
1289
+ preferred = "twilioVoice" if isPhone else "whatsapp"
1290
+ connector = next((item for item in connectorRecords if isinstance(item, dict) and item.get("connectorId") == preferred), connectorRecords[0] if connectorRecords and isinstance(connectorRecords[0], dict) else {})
1291
+ connectorId = str(connector.get("connectorId", "") or "")
1292
+ voiceCall = {"phone": contact, "status": "readyToCall", "prompt": message, "connectorId": connectorId} if isPhone else {}
1293
+ channelMsg = {"channel": "message", "to": contact, "text": message, "connectorId": connectorId} if not isPhone else {}
1294
+ proxyStatus, proxy = host.createProxyMission(
1295
+ {
1296
+ "taskId": taskId,
1297
+ "userId": clientId,
1298
+ "kind": "call" if isPhone else "message",
1299
+ "reason": "sellerContact",
1300
+ "task": f"Contact the selected source-bound option: {candidateRef.get('title') or candidateRef.get('candidateId')}.",
1301
+ "context": {
1302
+ "missionState": {"route": route, "request": request, "clientId": clientId, "conversationId": conversationId},
1303
+ "selectedCandidate": candidateRef,
1304
+ "sellerContact": contact,
1305
+ "reason": "sellerContact",
1306
+ "connectorId": connectorId,
1307
+ },
1308
+ "voiceCall": voiceCall,
1309
+ "channelMsg": channelMsg,
1310
+ "executeProxy": True,
1311
+ "proofReq": ["messageTranscript", "availabilityConfirmation", "consentRecord"],
1312
+ "resume": {"conversationId": conversationId, "candidateId": candidateRef["candidateId"], "action": "proxyOutcome"},
1313
+ }
1314
+ )
1315
+ proxyCreated = proxyStatus < 400 and bool(proxy.get("ok"))
1316
+ results["bridge"] = {"ok": bool(connectorId), "status": "proxyHandoffReady" if connectorId else "missingConnector", "readyConnectors": installed, "connectors": bridgeConnectors}
1317
+ results["proxy"] = proxy
1318
+ event("proxy", "mission.response", "awaitingProxy" if proxyCreated else "blocked", "PROXY started seller contact for the selected source-bound option." if proxyCreated else "PROXY could not start seller contact.", {"proxyId": proxy.get("proxyId", ""), "connectorId": connectorId})
1319
+ agentConversation.append("[Proxy] Contacting the selected source and waiting for confirmation proof.")
1320
+ stream("conversation", {"line": agentConversation[-1]})
1321
+ return self._finalizeResume(
1322
+ task=task,
1323
+ priorOut=priorOut,
1324
+ request=request,
1325
+ conversationId=conversationId,
1326
+ route=route,
1327
+ status="awaitingProxy" if proxyCreated else "proxyBlocked",
1328
+ ok=proxyCreated,
1329
+ results=results,
1330
+ trace=trace,
1331
+ agentConversation=agentConversation,
1332
+ event=event,
1333
+ stream=stream,
1334
+ taskStatus=ProtocolTaskStatus.AWAITING_PROXY.value if proxyCreated else ProtocolTaskStatus.PROCESSING.value,
1335
+ evidenceStatus="sourceBound",
1336
+ confidence=0.7 if proxyCreated else 0.48,
1337
+ openQuestions=["awaitingProxyOutcome"] if proxyCreated else ["proxyMissionRequired"],
1338
+ escalation={"recommended": True, "routeDomain": "proxy", "reason": "awaitingProxy" if proxyCreated else "proxyBlocked"},
1339
+ )
1340
+
1341
+ def _finalizeResume(
1342
+ self,
1343
+ *,
1344
+ task: dict[str, Any],
1345
+ priorOut: dict[str, Any],
1346
+ request: str,
1347
+ conversationId: str,
1348
+ route: list[str],
1349
+ status: str,
1350
+ ok: bool,
1351
+ results: dict[str, Any],
1352
+ trace: list[dict[str, Any]],
1353
+ agentConversation: list[str],
1354
+ event: Any,
1355
+ stream: Any,
1356
+ taskStatus: str,
1357
+ evidenceStatus: str,
1358
+ confidence: float,
1359
+ openQuestions: list[str],
1360
+ escalation: dict[str, Any],
1361
+ ) -> dict[str, Any]:
1362
+ host = self.host
1363
+ taskId = str(task.get("taskId", "") or conversationId or self._stableId("task", {"request": request, "status": status}))
1364
+ priorResults = priorOut.get("results", {}) if isinstance(priorOut.get("results"), dict) else {}
1365
+ mergedResults = {**priorResults, **results}
1366
+ decisionTree = self._decisionTree(mergedResults.get("hunt", {}))
1367
+ event("orchestrator", "proofPack", "created", "Picux updated the portable proof card for the resumed conversation.", {"status": status, "conversationId": conversationId})
1368
+ proof = host._proofPackForRun(
1369
+ taskId=taskId,
1370
+ domain="orchestrator",
1371
+ title="Picux resumed conversation",
1372
+ summary=f"Picux resumed {conversationId or taskId} through {', '.join(route).upper()} with status {status}.",
1373
+ payload={"request": request, "conversationId": conversationId, "route": route, "results": mergedResults},
1374
+ decisions=[{"label": item["name"], "reason": item["status"]} for item in trace if item["phase"] != "orchestrator"],
1375
+ evidenceStatus=evidenceStatus,
1376
+ confidence=confidence,
1377
+ openQuestions=openQuestions,
1378
+ escalation=escalation,
1379
+ )
1380
+ agentConversation.append("[Picux] I updated the conversation state, next action, evidence status, open questions, and proof card.")
1381
+ stream("conversation", {"line": agentConversation[-1]})
1382
+ resumeState = results.get("resume", {}) if isinstance(results.get("resume"), dict) else {}
1383
+ nextOut = {
1384
+ **priorOut,
1385
+ "conversationId": conversationId,
1386
+ "route": route,
1387
+ "status": status,
1388
+ "results": mergedResults,
1389
+ "resume": resumeState,
1390
+ "decisionTree": decisionTree,
1391
+ "proofPack": proof.get("proofPack", {}),
1392
+ }
1393
+ nextMeta = {**(task.get("meta", {}) if isinstance(task.get("meta"), dict) else {}), "conversationId": conversationId}
1394
+ updated = host.taskBook.updateTask(taskId, {"status": taskStatus, "outData": nextOut, "meta": nextMeta})
1395
+ if updated.get("ok"):
1396
+ task = updated["task"]
1397
+ pay = mergedResults.get("pay", {}) if isinstance(mergedResults.get("pay"), dict) else {}
1398
+ mandateResult = pay.get("mandate", {}) if isinstance(pay.get("mandate"), dict) else {}
1399
+ return {
1400
+ "ok": ok,
1401
+ "scenario": "picuxOrchestrator",
1402
+ "status": status,
1403
+ "conversationId": conversationId,
1404
+ "request": request,
1405
+ "route": route,
1406
+ "task": task,
1407
+ "trace": trace,
1408
+ "agentConversation": agentConversation,
1409
+ "results": mergedResults,
1410
+ "decisionTree": decisionTree,
1411
+ "mandate": mandateResult.get("mandate", mandateResult) if isinstance(mandateResult, dict) else {},
1412
+ "receipt": None,
1413
+ "freezeReceipt": None,
1414
+ "proofPack": proof.get("proofPack", {}),
1415
+ "proofCard": proof.get("proofPack", {}).get("proofCard", {}),
1416
+ }
1417
+
1418
+ def _resumeFailure(
1419
+ self,
1420
+ *,
1421
+ request: str,
1422
+ route: list[str],
1423
+ status: str,
1424
+ error: str,
1425
+ conversationId: str,
1426
+ clientId: str,
1427
+ channel: str,
1428
+ trace: list[dict[str, Any]],
1429
+ agentConversation: list[str],
1430
+ results: dict[str, Any],
1431
+ event: Any,
1432
+ stream: Any,
1433
+ ) -> dict[str, Any]:
1434
+ taskId = self._stableId("resume", {"request": request, "conversationId": conversationId, "error": error})
1435
+ event("orchestrator", "proofPack", "created", "Picux created a proof card for the failed resume attempt.", {"status": status, "error": error})
1436
+ proof = self.host._proofPackForRun(
1437
+ taskId=taskId,
1438
+ domain="orchestrator",
1439
+ title="Picux resume failed",
1440
+ summary=f"Picux could not resume the conversation: {error}.",
1441
+ payload={"request": request, "conversationId": conversationId, "client": {"clientId": clientId, "channel": channel}, "results": results},
1442
+ decisions=[{"label": item["name"], "reason": item["status"]} for item in trace if item["phase"] != "orchestrator"],
1443
+ evidenceStatus="needsEvidence",
1444
+ confidence=0.2,
1445
+ openQuestions=["validConversationIdRequired"],
1446
+ escalation={"recommended": True, "routeDomain": "orchestrator", "reason": error},
1447
+ )
1448
+ agentConversation.append("[Picux] I could not resume that conversation. Send a valid conversationId from the previous result.")
1449
+ stream("conversation", {"line": agentConversation[-1]})
1450
+ return {
1451
+ "ok": False,
1452
+ "scenario": "picuxOrchestrator",
1453
+ "status": status,
1454
+ "conversationId": conversationId,
1455
+ "request": request,
1456
+ "route": route,
1457
+ "task": {},
1458
+ "trace": trace,
1459
+ "agentConversation": agentConversation,
1460
+ "results": {**results, "resume": {"ok": False, "error": error, "conversationId": conversationId}},
1461
+ "decisionTree": {"root": "userRequest", "selected": "", "paths": []},
1462
+ "mandate": {},
1463
+ "receipt": None,
1464
+ "freezeReceipt": None,
1465
+ "proofPack": proof.get("proofPack", {}),
1466
+ "proofCard": proof.get("proofPack", {}).get("proofCard", {}),
1467
+ }
1468
+
1469
+ def _route(self, request: str, payload: dict[str, Any]) -> list[str]:
1470
+ rawRoute = payload.get("domainRoute", payload.get("route", []))
1471
+ route = [str(item).strip().lower() for item in rawRoute if isinstance(item, str)] if isinstance(rawRoute, list) else []
1472
+ route = [item for item in route if item in self.domains]
1473
+ resolveCase = self._isResolveCase(request)
1474
+ if not route:
1475
+ seed = self.host.router.classify(request).domain.value
1476
+ explicitResolve = resolveCase
1477
+ explicitPay = any(token in request.lower() for token in ("pay", "payment", "settle", "settlement", "escrow"))
1478
+ if seed == "pay" and not explicitPay:
1479
+ seed = "resolve" if explicitResolve else "hunt"
1480
+ if seed == "proxy" and not self._shouldCreateProxy(request, payload):
1481
+ seed = "resolve" if explicitResolve else "hunt"
1482
+ if explicitResolve and seed in {"hunt", "bridge"}:
1483
+ seed = "resolve"
1484
+ route = [seed]
1485
+
1486
+ lowered = request.lower()
1487
+ if not resolveCase and self._isLocalServiceRequest(request, payload, {"entities": {}}) and "hunt" not in route:
1488
+ route.insert(0, "hunt")
1489
+ if not resolveCase and any(token in lowered for token in ("find", "near", "within", "km", "buy", "shop", "shopping", "best", "option", "lead", "opportunity", "source", "market", "mechanic", "plumber", "electrician", "repair")) and "hunt" not in route:
1490
+ route.insert(0, "hunt")
1491
+ if resolveCase and "resolve" not in route:
1492
+ route.append("resolve")
1493
+ if any(token in lowered for token in ("email", "merchant", "bank", "service", "support", "api", "webhook", "connector", "whatsapp", "chat", "representative", "followup", "follow up")) and "bridge" not in route:
1494
+ insertAt = route.index("resolve") + 1 if "resolve" in route else len(route)
1495
+ route.insert(insertAt, "bridge")
1496
+ if self._shouldCreateProxy(request, payload) and "proxy" not in route:
1497
+ route.append("proxy")
1498
+ if any(token in lowered for token in ("pay", "payment", "settle", "settlement", "escrow")) and "pay" not in route:
1499
+ route.append("pay")
1500
+
1501
+ if "pay" in route and "resolve" not in route:
1502
+ route.insert(max(1, route.index("pay")), "resolve")
1503
+ route = list(dict.fromkeys(route or ["hunt"]))
1504
+ if resolveCase and "hunt" not in route:
1505
+ order = ["resolve", "bridge", "proxy", "pay"]
1506
+ return [domain for domain in order if domain in route]
1507
+ return route
1508
+
1509
+ @staticmethod
1510
+ def _ensureRouteAfter(route: list[str], *, after: str, domains: list[str]) -> None:
1511
+ insertAt = route.index(after) + 1 if after in route else len(route)
1512
+ for domain in domains:
1513
+ if domain in route:
1514
+ continue
1515
+ route.insert(insertAt, domain)
1516
+ insertAt += 1
1517
+
1518
+ def _verifiedLocalServiceMapResult(self, host: Any, request: str, payload: dict[str, Any], nlp: dict[str, Any]) -> dict[str, Any]:
1519
+ if not hasattr(host, "localProviderBook"):
1520
+ return {}
1521
+ entities = nlp.get("entities", {}) if isinstance(nlp.get("entities"), dict) else {}
1522
+ entity = str(payload.get("entity", payload.get("service", entities.get("service", entities.get("entity", "")))) or "")
1523
+ location = str(payload.get("location", payload.get("near", entities.get("location", ""))) or "")
1524
+ radiusKm = self._float(payload.get("radiusKm", payload.get("distanceKm", entities.get("radiusKm", 10.0))))
1525
+ places = host.localProviderBook.toPlaces(service=entity, location=location, limit=10)
1526
+ if not places:
1527
+ return {}
1528
+ return {
1529
+ "ok": True,
1530
+ "tool": "mapTool",
1531
+ "provider": "verifiedProviders",
1532
+ "status": "found",
1533
+ "query": {
1534
+ "text": request,
1535
+ "entity": entity or self._itemId(request),
1536
+ "location": location,
1537
+ "radiusKm": radiusKm or 10.0,
1538
+ "radiusMeters": int(max(1.0, radiusKm or 10.0) * 1000),
1539
+ },
1540
+ "places": places,
1541
+ "errors": [],
1542
+ "networkAttempted": False,
1543
+ }
1544
+
1545
+ @classmethod
1546
+ def _publicMapResult(cls, mapResult: dict[str, Any]) -> dict[str, Any]:
1547
+ public = json.loads(json.dumps(mapResult, ensure_ascii=True, default=str))
1548
+ places = public.get("places", []) if isinstance(public.get("places"), list) else []
1549
+ public["places"] = [cls._publicPlace(place) for place in places if isinstance(place, dict)]
1550
+ return public
1551
+
1552
+ @staticmethod
1553
+ def _publicPlace(place: dict[str, Any]) -> dict[str, Any]:
1554
+ clean = dict(place)
1555
+ for key in ("phone", "phoneNumber", "international_phone_number", "formatted_phone_number"):
1556
+ clean.pop(key, None)
1557
+ return clean
1558
+
1559
+ @classmethod
1560
+ def _localServiceContactRefs(cls, mapResult: dict[str, Any], offers: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
1561
+ places = mapResult.get("places", []) if isinstance(mapResult.get("places"), list) else []
1562
+ query = mapResult.get("query", {}) if isinstance(mapResult.get("query"), dict) else {}
1563
+ offersByPlaceId = {}
1564
+ offersByIndex = {}
1565
+ for offer in offers:
1566
+ meta = offer.get("meta", {}) if isinstance(offer.get("meta"), dict) else {}
1567
+ placeId = str(meta.get("placeId", "") or "")
1568
+ if placeId:
1569
+ offersByPlaceId[placeId] = offer
1570
+ offersByIndex[int(meta.get("placeIndex", 0) or 0)] = offer
1571
+ contacts: dict[str, dict[str, Any]] = {}
1572
+ for index, place in enumerate(places[:10], start=1):
1573
+ if not isinstance(place, dict):
1574
+ continue
1575
+ placeId = str(place.get("placeId", "") or "")
1576
+ offer = offersByPlaceId.get(placeId) or offersByIndex.get(index) or {}
1577
+ meta = offer.get("meta", {}) if isinstance(offer.get("meta"), dict) else {}
1578
+ candidateId = str(offer.get("candidateId", offer.get("sourceId", meta.get("candidateId", ""))) or "")
1579
+ contactRef = str(meta.get("contactRef", place.get("contactRef", "")) or cls._contactRefForPlace(place, candidateId))
1580
+ contact = {
1581
+ "providerId": contactRef,
1582
+ "contactRef": contactRef,
1583
+ "candidateId": candidateId or contactRef,
1584
+ "service": str(query.get("entity", place.get("entity", "")) or ""),
1585
+ "entity": str(query.get("entity", place.get("entity", "")) or ""),
1586
+ "location": str(query.get("location", "") or ""),
1587
+ "name": str(place.get("name", "") or ""),
1588
+ "category": str(place.get("category", "") or ""),
1589
+ "address": str(place.get("address", "") or ""),
1590
+ "phone": str(place.get("phone", "") or ""),
1591
+ "website": str(place.get("website", "") or ""),
1592
+ "rating": place.get("rating", 0),
1593
+ "lat": place.get("lat", 0),
1594
+ "lng": place.get("lng", 0),
1595
+ "distanceKm": place.get("distanceKm", 0),
1596
+ "source": str(place.get("source", mapResult.get("provider", "")) or ""),
1597
+ "sourceUrl": str(place.get("sourceUrl", "") or ""),
1598
+ "meta": {"placeId": placeId, "provider": mapResult.get("provider", ""), "sourceBound": bool(place.get("sourceBound"))},
1599
+ }
1600
+ contacts[contact["candidateId"]] = contact
1601
+ contacts[contactRef] = contact
1602
+ if placeId:
1603
+ contacts[placeId] = contact
1604
+ return contacts
1605
+
1606
+ @staticmethod
1607
+ def _cacheLocalServiceLeads(host: Any, contacts: dict[str, dict[str, Any]]) -> None:
1608
+ if not hasattr(host, "localProviderBook"):
1609
+ return
1610
+ seen: set[str] = set()
1611
+ for contact in contacts.values():
1612
+ contactRef = str(contact.get("contactRef", "") or "")
1613
+ if not contactRef or contactRef in seen:
1614
+ continue
1615
+ seen.add(contactRef)
1616
+ try:
1617
+ host.localProviderBook.saveLead(contact)
1618
+ except Exception:
1619
+ continue
1620
+
1621
+ @classmethod
1622
+ def _selectedLocalServiceContact(cls, hunt: dict[str, Any], contacts: dict[str, dict[str, Any]]) -> dict[str, Any]:
1623
+ if not isinstance(hunt, dict):
1624
+ return {}
1625
+ selected = hunt.get("selected", {}) if isinstance(hunt.get("selected"), dict) else {}
1626
+ meta = selected.get("meta", {}) if isinstance(selected.get("meta"), dict) else {}
1627
+ keys = [
1628
+ str(selected.get("candidateId", "") or ""),
1629
+ str(selected.get("sourceId", "") or ""),
1630
+ str(meta.get("contactRef", "") or ""),
1631
+ str(meta.get("placeId", "") or ""),
1632
+ ]
1633
+ for key in keys:
1634
+ if key and key in contacts:
1635
+ return contacts[key]
1636
+ if selected and cls._isLocalServiceCandidate(cls._candidateRef(selected)):
1637
+ ref = cls._candidateRef(selected)
1638
+ contactRef = str(meta.get("contactRef", ref.get("candidateId", "")) or "")
1639
+ return {
1640
+ "providerId": contactRef or ref.get("candidateId", ""),
1641
+ "contactRef": contactRef or ref.get("candidateId", ""),
1642
+ "candidateId": ref.get("candidateId", ""),
1643
+ "service": str(meta.get("entity", selected.get("itemId", "")) or ""),
1644
+ "entity": str(meta.get("entity", selected.get("itemId", "")) or ""),
1645
+ "location": "",
1646
+ "name": ref.get("title", ""),
1647
+ "category": "",
1648
+ "address": ref.get("address", ""),
1649
+ "website": ref.get("website", ""),
1650
+ "rating": ref.get("rating", 0),
1651
+ "source": ref.get("source", ""),
1652
+ "sourceUrl": ref.get("url", ""),
1653
+ "meta": {"sourceBound": True},
1654
+ }
1655
+ return {}
1656
+
1657
+ @staticmethod
1658
+ def _publicLocalServiceContact(contact: dict[str, Any]) -> dict[str, Any]:
1659
+ return {
1660
+ "providerId": str(contact.get("providerId", contact.get("contactRef", "")) or ""),
1661
+ "contactRef": str(contact.get("contactRef", contact.get("providerId", "")) or ""),
1662
+ "candidateId": str(contact.get("candidateId", "") or ""),
1663
+ "service": str(contact.get("service", contact.get("entity", "")) or ""),
1664
+ "location": str(contact.get("location", "") or ""),
1665
+ "name": str(contact.get("name", "") or ""),
1666
+ "category": str(contact.get("category", "") or ""),
1667
+ "address": str(contact.get("address", "") or ""),
1668
+ "website": str(contact.get("website", "") or ""),
1669
+ "rating": contact.get("rating", 0),
1670
+ "distanceKm": contact.get("distanceKm", 0),
1671
+ "source": str(contact.get("source", "") or ""),
1672
+ "sourceUrl": str(contact.get("sourceUrl", "") or ""),
1673
+ "status": str(contact.get("status", "lead") or "lead"),
1674
+ }
1675
+
1676
+ @staticmethod
1677
+ def _selectBridgeConnector(match: dict[str, Any], *, preferred: tuple[str, ...]) -> dict[str, Any]:
1678
+ records = [item for item in match.get("connectors", []) if isinstance(item, dict)] if isinstance(match, dict) else []
1679
+ if not records:
1680
+ return {}
1681
+ preferredIndex = {connectorId: index for index, connectorId in enumerate(preferred)}
1682
+
1683
+ def score(item: dict[str, Any]) -> tuple[int, int, int]:
1684
+ meta = item.get("meta", {}) if isinstance(item.get("meta"), dict) else {}
1685
+ connectorId = str(item.get("connectorId", "") or "")
1686
+ return (
1687
+ 1 if not meta.get("readyMade") else 0,
1688
+ len(preferred) - preferredIndex.get(connectorId, len(preferred)),
1689
+ int(item.get("createdAt", 0) or 0),
1690
+ )
1691
+
1692
+ records.sort(key=score, reverse=True)
1693
+ return records[0]
1694
+
1695
+ @staticmethod
1696
+ def _connectorPreflight(host: Any, connectorId: str, *, action: str, resource: str) -> dict[str, Any]:
1697
+ if not connectorId or not hasattr(host, "preflightConnector"):
1698
+ return {"ok": False, "status": "blocked", "connectorId": connectorId, "errors": ["missingConnector"]}
1699
+ try:
1700
+ return host.preflightConnector(connectorId, {"action": action, "resource": resource})
1701
+ except Exception as exc:
1702
+ return {"ok": False, "status": "blocked", "connectorId": connectorId, "errors": [type(exc).__name__]}
1703
+
1704
+ @staticmethod
1705
+ def _followUpAction(payload: dict[str, Any]) -> str:
1706
+ raw = payload.get("action", payload.get("intent", payload.get("key", "")))
1707
+ if isinstance(raw, bool):
1708
+ raw = "buy" if raw else ""
1709
+ action = str(raw or "").strip().lower()
1710
+ if not action and payload.get("buy") is not None:
1711
+ action = "buy" if bool(payload.get("buy")) else ""
1712
+ if not action and payload.get("pass") is not None:
1713
+ action = "pass" if bool(payload.get("pass")) else ""
1714
+ aliases = {
1715
+ "purchase": "buy",
1716
+ "checkout": "buy",
1717
+ "select": "buy",
1718
+ "selected": "buy",
1719
+ "accept": "buy",
1720
+ "ask": "contact",
1721
+ "message": "contact",
1722
+ "call": "contact",
1723
+ "contact": "contact",
1724
+ "availability": "contact",
1725
+ "pay": "paymentInfo",
1726
+ "payment": "paymentInfo",
1727
+ "refine": "refine",
1728
+ "change": "refine",
1729
+ "skip": "pass",
1730
+ "reject": "pass",
1731
+ "decline": "pass",
1732
+ }
1733
+ action = aliases.get(action, action)
1734
+ return action if action in {"buy", "pass", "contact", "paymentInfo", "refine"} else ""
1735
+
1736
+ @staticmethod
1737
+ def _followUpTextAction(request: str) -> str:
1738
+ text = f" {str(request or '').strip().lower()} "
1739
+ if not text.strip():
1740
+ return ""
1741
+ if re.search(r"\b(buy|purchase|checkout|order|get it|take it|select|choose|pick)\b", text):
1742
+ return "buy"
1743
+ if re.search(r"\b(ask|message|contact|call|availability|available|deliver|delivery|negotiate)\b", text):
1744
+ return "contact"
1745
+ if re.search(r"\b(pay|payment|swish|stripe|paypal|card|bank|bitcoin|wallet)\b", text):
1746
+ return "paymentInfo"
1747
+ if re.search(r"\b(change|increase|decrease|budget|cheaper|closer|different|refine|filter)\b", text):
1748
+ return "refine"
1749
+ if re.search(r"\b(pass|skip|decline|reject|next option|not this|no thanks)\b", text):
1750
+ return "pass"
1751
+ return ""
1752
+
1753
+ @staticmethod
1754
+ def _hasConversationState(host: Any, conversationId: str) -> bool:
1755
+ if not conversationId or not hasattr(host, "taskBook"):
1756
+ return False
1757
+ try:
1758
+ lookup = host.taskBook.getTaskByConversationId(conversationId) if hasattr(host.taskBook, "getTaskByConversationId") else host.taskBook.getTask(conversationId)
1759
+ except Exception:
1760
+ return False
1761
+ return bool(lookup.get("ok"))
1762
+
1763
+ @staticmethod
1764
+ def _resumeRequestLabel(action: str, payload: dict[str, Any]) -> str:
1765
+ itemId = PicuxMultiAgentOrchestrator._selectedItemId(payload)
1766
+ if action == "pass":
1767
+ return f"Pass on selected item {itemId}".strip()
1768
+ if action == "buy":
1769
+ return f"Buy selected item {itemId}".strip()
1770
+ return "Resume Picux conversation"
1771
+
1772
+ @staticmethod
1773
+ def _conversationId(payload: dict[str, Any]) -> str:
1774
+ raw = (
1775
+ payload.get("conversationId")
1776
+ or payload.get("conversationid")
1777
+ or payload.get("conversationID")
1778
+ or payload.get("taskId")
1779
+ or payload.get("resumeToken")
1780
+ or ""
1781
+ )
1782
+ return str(raw or "").strip()
1783
+
1784
+ @classmethod
1785
+ def _selectedItemId(cls, payload: dict[str, Any], hunt: dict[str, Any] | None = None, request: str = "") -> str:
1786
+ raw = payload.get("itemId") or payload.get("candidateId") or payload.get("sourceId") or payload.get("vendorId") or payload.get("selectedId") or ""
1787
+ selected = payload.get("selected") if isinstance(payload.get("selected"), dict) else {}
1788
+ if not raw and selected:
1789
+ raw = selected.get("itemId") or selected.get("candidateId") or selected.get("sourceId") or selected.get("vendorId") or ""
1790
+ if raw:
1791
+ return str(raw or "").strip()
1792
+ if hunt:
1793
+ ordinal = cls._selectedOrdinal(payload, request=request)
1794
+ if ordinal:
1795
+ candidates = cls._candidateList(hunt)
1796
+ if 0 < ordinal <= len(candidates):
1797
+ ref = cls._candidateRef(candidates[ordinal - 1])
1798
+ return str(ref.get("candidateId", ref.get("sourceId", ref.get("itemId", ""))) or "")
1799
+ return ""
1800
+
1801
+ @staticmethod
1802
+ def _selectedOrdinal(payload: dict[str, Any], request: str = "") -> int:
1803
+ raw = payload.get("index", payload.get("itemIndex", payload.get("rank", payload.get("position", ""))))
1804
+ if isinstance(raw, int):
1805
+ return raw
1806
+ text = f" {str(raw or '')} {str(request or payload.get('request', payload.get('query', payload.get('message', ''))) or '')} ".lower()
1807
+ if not text.strip():
1808
+ return 0
1809
+ numeric = re.search(r"(?:#|item|option|result|candidate|the)?\s*(\d+)(?:st|nd|rd|th)?\b", text)
1810
+ if numeric:
1811
+ try:
1812
+ return int(numeric.group(1))
1813
+ except ValueError:
1814
+ return 0
1815
+ words = {
1816
+ "first": 1,
1817
+ "second": 2,
1818
+ "third": 3,
1819
+ "fourth": 4,
1820
+ "fifth": 5,
1821
+ "sixth": 6,
1822
+ "seventh": 7,
1823
+ "eighth": 8,
1824
+ "ninth": 9,
1825
+ "tenth": 10,
1826
+ "one": 1,
1827
+ "two": 2,
1828
+ "three": 3,
1829
+ "four": 4,
1830
+ "five": 5,
1831
+ "six": 6,
1832
+ "seven": 7,
1833
+ "eight": 8,
1834
+ "nine": 9,
1835
+ "ten": 10,
1836
+ }
1837
+ for word, ordinal in words.items():
1838
+ if re.search(rf"\b{word}\b", text):
1839
+ return ordinal
1840
+ return 0
1841
+
1842
+ @staticmethod
1843
+ def _resumeRoute(action: str) -> list[str]:
1844
+ if action == "buy":
1845
+ return ["hunt", "bridge", "pay"]
1846
+ if action == "contact":
1847
+ return ["hunt", "bridge", "proxy"]
1848
+ if action == "paymentInfo":
1849
+ return ["hunt", "pay"]
1850
+ if action == "refine":
1851
+ return ["hunt"]
1852
+ return ["hunt"]
1853
+
1854
+ @classmethod
1855
+ def _candidateById(cls, hunt: dict[str, Any], itemId: str) -> dict[str, Any] | None:
1856
+ itemId = str(itemId or "").strip()
1857
+ if not itemId:
1858
+ return None
1859
+ candidates = cls._candidateList(hunt)
1860
+ exactKeys = ("candidateId", "sourceId", "vendorId", "id")
1861
+ for candidate in candidates:
1862
+ meta = candidate.get("meta", {}) if isinstance(candidate.get("meta"), dict) else {}
1863
+ values = [str(candidate.get(key, "") or "") for key in exactKeys]
1864
+ values.extend(str(meta.get(key, "") or "") for key in ("candidateId", "sourceId", "vendorId", "id"))
1865
+ if itemId in values:
1866
+ return candidate
1867
+ semanticMatches = [candidate for candidate in candidates if str(candidate.get("itemId", "") or "") == itemId]
1868
+ return semanticMatches[0] if len(semanticMatches) == 1 else None
1869
+
1870
+ @staticmethod
1871
+ def _candidateList(hunt: dict[str, Any]) -> list[dict[str, Any]]:
1872
+ candidates = hunt.get("candidates", []) if isinstance(hunt.get("candidates"), list) else []
1873
+ clean = [item for item in candidates if isinstance(item, dict)]
1874
+ if clean:
1875
+ return clean
1876
+ selected = hunt.get("selected", {}) if isinstance(hunt.get("selected"), dict) else {}
1877
+ return [selected] if selected else []
1878
+
1879
+ @classmethod
1880
+ def _candidateRefs(cls, hunt: dict[str, Any]) -> list[dict[str, Any]]:
1881
+ return [cls._candidateRef(candidate) for candidate in cls._candidateList(hunt)]
1882
+
1883
+ @staticmethod
1884
+ def _candidateRef(candidate: dict[str, Any]) -> dict[str, Any]:
1885
+ meta = candidate.get("meta", {}) if isinstance(candidate.get("meta"), dict) else {}
1886
+ price = meta.get("price", {}) if isinstance(meta.get("price"), dict) else {}
1887
+ sourceId = str(candidate.get("sourceId", candidate.get("vendorId", "")) or "")
1888
+ candidateId = str(candidate.get("candidateId", meta.get("candidateId", sourceId)) or sourceId)
1889
+ return {
1890
+ "candidateId": candidateId,
1891
+ "sourceId": sourceId,
1892
+ "itemId": str(candidate.get("itemId", "") or ""),
1893
+ "title": str(candidate.get("title", meta.get("listingTitle", candidate.get("itemId", ""))) or ""),
1894
+ "source": str(meta.get("source", "") or ""),
1895
+ "url": str(candidate.get("url", meta.get("sourceUrl", "")) or ""),
1896
+ "kind": str(meta.get("kind", meta.get("placeKind", "")) or ""),
1897
+ "address": str(meta.get("address", "") or ""),
1898
+ "website": str(meta.get("website", "") or ""),
1899
+ "rating": meta.get("rating", 0),
1900
+ "distanceKm": meta.get("distanceKm", 0),
1901
+ "price": price,
1902
+ "netUsd": float(candidate.get("netUsd", candidate.get("offerUsd", 0.0)) or 0.0),
1903
+ "eligible": bool(candidate.get("eligible", True)),
1904
+ "reasons": candidate.get("reasons", []) if isinstance(candidate.get("reasons"), list) else [],
1905
+ }
1906
+
1907
+ @staticmethod
1908
+ def _rejectedCandidateIds(priorOut: dict[str, Any]) -> list[str]:
1909
+ resume = priorOut.get("resume", {}) if isinstance(priorOut.get("resume"), dict) else {}
1910
+ rejected = resume.get("rejectedCandidateIds", []) if isinstance(resume.get("rejectedCandidateIds"), list) else []
1911
+ return [str(item) for item in rejected if str(item)]
1912
+
1913
+ @classmethod
1914
+ def _nextCandidateRef(cls, hunt: dict[str, Any], rejected: list[str]) -> dict[str, Any]:
1915
+ rejectedSet = set(rejected)
1916
+ for candidate in cls._candidateList(hunt):
1917
+ ref = cls._candidateRef(candidate)
1918
+ if ref["candidateId"] in rejectedSet:
1919
+ continue
1920
+ if candidate.get("eligible", True):
1921
+ return ref
1922
+ return {}
1923
+
1924
+ @staticmethod
1925
+ def _purchaseInputs(payload: dict[str, Any]) -> dict[str, Any]:
1926
+ rawInputs = payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}
1927
+ values = payload.get("values") if isinstance(payload.get("values"), dict) else {}
1928
+ contact = payload.get("contact") if isinstance(payload.get("contact"), dict) else {}
1929
+ shipping = payload.get("shipping") if isinstance(payload.get("shipping"), dict) else {}
1930
+ billing = payload.get("billing") if isinstance(payload.get("billing"), dict) else {}
1931
+ payment = payload.get("payment") if isinstance(payload.get("payment"), dict) else {}
1932
+ inputs = {**rawInputs, **values}
1933
+ email = payload.get("email") or inputs.get("email") or contact.get("email") or ""
1934
+ phone = payload.get("phone") or inputs.get("phone") or contact.get("phone") or ""
1935
+ shippingAddress = payload.get("shippingAddress") or inputs.get("shippingAddress") or payload.get("address") or inputs.get("address") or shipping.get("address") or shipping.get("shippingAddress") or ""
1936
+ billingAddress = payload.get("billingAddress") or inputs.get("billingAddress") or billing.get("address") or billing.get("billingAddress") or ""
1937
+ if not billingAddress and bool(payload.get("billingSameAsShipping", inputs.get("billingSameAsShipping", False))):
1938
+ billingAddress = shippingAddress
1939
+ return {
1940
+ "email": email,
1941
+ "phone": phone,
1942
+ "shippingAddress": shippingAddress,
1943
+ "billingAddress": billingAddress,
1944
+ "paymentMethod": payload.get("paymentMethod") or inputs.get("paymentMethod") or payment.get("method") or payment.get("paymentMethod") or "",
1945
+ "walletAddress": payload.get("walletAddress") or inputs.get("walletAddress") or payment.get("walletAddress") or "",
1946
+ "chainType": payload.get("chainType") or inputs.get("chainType") or payment.get("chainType") or payment.get("chain") or "",
1947
+ "cardNumber": payload.get("cardNumber") or inputs.get("cardNumber") or payment.get("cardNumber") or "",
1948
+ "expiry": payload.get("expiry") or inputs.get("expiry") or payment.get("expiry") or "",
1949
+ "cvv": payload.get("cvv") or inputs.get("cvv") or payment.get("cvv") or "",
1950
+ "account": payload.get("account") or inputs.get("account") or payment.get("account") or "",
1951
+ "number": payload.get("number") or inputs.get("number") or payment.get("number") or "",
1952
+ "bank": payload.get("bank") or inputs.get("bank") or payment.get("bank") or "",
1953
+ "card": payload.get("card") or inputs.get("card") or payment.get("card") or "",
1954
+ "paymentToken": payload.get("paymentToken") or inputs.get("paymentToken") or payment.get("token") or "",
1955
+ "mandateId": payload.get("mandateId") or inputs.get("mandateId") or payment.get("mandateId") or "",
1956
+ }
1957
+
1958
+ @staticmethod
1959
+ def _missingContactKeys(inputs: dict[str, Any]) -> list[str]:
1960
+ missing = []
1961
+ if not str(inputs.get("email", "") or "").strip():
1962
+ missing.append("email")
1963
+ if not str(inputs.get("shippingAddress", "") or "").strip():
1964
+ missing.append("shippingAddress")
1965
+ if not str(inputs.get("billingAddress", "") or "").strip():
1966
+ missing.append("billingAddress")
1967
+ return missing
1968
+
1969
+ @classmethod
1970
+ def _missingPaymentKeys(cls, inputs: dict[str, Any]) -> list[str]:
1971
+ method = str(inputs.get("paymentMethod", "") or "").strip().lower()
1972
+ if not method or method not in cls._supportedPaymentMethods():
1973
+ return ["paymentMethod"]
1974
+ required = cls._paymentFieldsFor(method)
1975
+ return [key for key in required if not str(inputs.get(key, "") or "").strip()]
1976
+
1977
+ @classmethod
1978
+ def _purchaseIntent(cls, conversationId: str, taskId: str, candidateRef: dict[str, Any], inputs: dict[str, Any], missingKeys: list[str], resumeStatus: str) -> dict[str, Any]:
1979
+ intentId = cls._stableId("purchase", {"conversationId": conversationId, "taskId": taskId, "candidateId": candidateRef.get("candidateId", "")})
1980
+ status = {"NEEDS_INPUT": "needsInput", "NEEDS_PAYMENT_INFO": "needsPaymentInfo"}.get(resumeStatus, "readyForCheckout")
1981
+ return {
1982
+ "intentId": intentId,
1983
+ "conversationId": conversationId,
1984
+ "taskId": taskId,
1985
+ "status": status,
1986
+ "candidate": candidateRef,
1987
+ "requestedKeys": missingKeys,
1988
+ "requiredKeys": ["email", "shippingAddress", "billingAddress"],
1989
+ "supportedPaymentMethods": cls._supportedPaymentMethods(),
1990
+ "paymentSchema": cls._paymentSchema(),
1991
+ "inputs": cls._safePurchaseInputs(inputs),
1992
+ }
1993
+
1994
+ @staticmethod
1995
+ def _safePurchaseInputs(inputs: dict[str, Any]) -> dict[str, Any]:
1996
+ safe: dict[str, Any] = {}
1997
+ for key in ("email", "phone", "shippingAddress", "billingAddress", "paymentMethod", "walletAddress", "chainType", "account", "number", "bank", "mandateId"):
1998
+ if inputs.get(key):
1999
+ safe[key] = inputs[key]
2000
+ for key in ("card", "cardNumber", "expiry", "cvv", "paymentToken"):
2001
+ if inputs.get(key):
2002
+ safe[key] = "[redacted]"
2003
+ return safe
2004
+
2005
+ @classmethod
2006
+ def _settleCheckoutProof(
2007
+ cls,
2008
+ host: Any,
2009
+ *,
2010
+ escrow: dict[str, Any],
2011
+ purchaseIntent: dict[str, Any],
2012
+ candidateRef: dict[str, Any],
2013
+ checkout: dict[str, Any],
2014
+ event: Any,
2015
+ ) -> dict[str, Any]:
2016
+ escrowRecord = escrow.get("escrow", {}) if isinstance(escrow.get("escrow"), dict) else {}
2017
+ escrowId = str(escrowRecord.get("escrowId", "") or "")
2018
+ receipt = checkout.get("receipt", {}) if isinstance(checkout.get("receipt"), dict) else {}
2019
+ receiptId = str(receipt.get("receiptId", "") or "")
2020
+ if not escrowId or not receiptId or not hasattr(host, "submitEscrowProof"):
2021
+ return {}
2022
+ rules = ["sourceBound", "purchaseIntent", "verifiedCompletionProof", "userConfirmedPurchase"]
2023
+ proof = {
2024
+ "proofRef": receiptId,
2025
+ "kind": "checkoutReceipt",
2026
+ "status": "verified",
2027
+ "rules": rules,
2028
+ "source": "bridge.browserCheckout",
2029
+ "payload": {
2030
+ "purchaseIntentId": purchaseIntent.get("intentId", ""),
2031
+ "candidateId": candidateRef.get("candidateId", ""),
2032
+ "source": candidateRef.get("source", ""),
2033
+ "sourceUrl": candidateRef.get("url", ""),
2034
+ "checkout": checkout,
2035
+ },
2036
+ }
2037
+ submitted = host.submitEscrowProof(escrowId, {"proof": proof})
2038
+ event(
2039
+ "pay",
2040
+ "proof.submit",
2041
+ "verified" if submitted.get("ok") else "proofRejected",
2042
+ "PAY received the browser checkout proof and attached it to escrow.",
2043
+ {"escrowId": escrowId, "proofRef": receiptId, "rules": rules},
2044
+ )
2045
+ if not submitted.get("ok"):
2046
+ return {
2047
+ "ok": False,
2048
+ "status": "proofRejected",
2049
+ "reason": str(submitted.get("error", "proofRejected") or "proofRejected"),
2050
+ "proof": proof,
2051
+ "proofSubmission": submitted,
2052
+ "settlementGate": {"status": "locked", "reason": str(submitted.get("error", "proofRejected") or "proofRejected"), "proofRequired": True},
2053
+ }
2054
+ prepared = (
2055
+ host.prepareSettlement({"escrowId": escrowId, "adapter": {"adapterId": "picuxLedgerAdapter", "rail": "picuxLedger", "mode": "execute"}})
2056
+ if hasattr(host, "prepareSettlement")
2057
+ else {}
2058
+ )
2059
+ prepareStatus = "ready" if prepared.get("ok") else "rejected"
2060
+ prepareReason = cls._payFailureReason(prepared)
2061
+ event(
2062
+ "pay",
2063
+ "settlement.prepare",
2064
+ prepareStatus,
2065
+ "PAY prepared settlement from the verified checkout proof." if prepared.get("ok") else f"PAY could not prepare settlement: {prepareReason}.",
2066
+ {"escrowId": escrowId, "settlementId": (prepared.get("instruction", {}) if isinstance(prepared.get("instruction"), dict) else {}).get("settlementId", ""), "errors": cls._payErrors(prepared)},
2067
+ )
2068
+ settlement: dict[str, Any] = {}
2069
+ if prepared.get("ok") and hasattr(host, "settle"):
2070
+ settlement = host.settle({"escrowId": escrowId})
2071
+ event(
2072
+ "pay",
2073
+ "settlement.response",
2074
+ "settled" if settlement.get("ok") else "rejected",
2075
+ "PAY released settlement against the verified checkout proof." if settlement.get("ok") else f"PAY rejected settlement: {cls._payFailureReason(settlement)}.",
2076
+ {"escrowId": escrowId, "receiptId": (settlement.get("receipt", {}) if isinstance(settlement.get("receipt"), dict) else {}).get("receiptId", ""), "errors": cls._payErrors(settlement)},
2077
+ )
2078
+ ok = bool(settlement.get("ok"))
2079
+ reason = "" if ok else prepareReason or cls._payFailureReason(settlement) or "settlementNotReleased"
2080
+ return {
2081
+ "ok": ok,
2082
+ "status": "settled" if ok else "settlementRejected",
2083
+ "reason": reason,
2084
+ "proof": proof,
2085
+ "proofSubmission": submitted,
2086
+ "prepare": prepared,
2087
+ "settlement": settlement,
2088
+ "settlementGate": {"status": "released" if ok else "locked", "reason": "checkoutProofSettled" if ok else reason, "proofRequired": True},
2089
+ }
2090
+
2091
+ @staticmethod
2092
+ def _checkoutNeedsInput(checkout: dict[str, Any]) -> bool:
2093
+ needs = checkout.get("needsInput", []) if isinstance(checkout.get("needsInput"), list) else []
2094
+ return bool(needs) or str(checkout.get("status", "") or "") in {"needsInput", "needsProxy"}
2095
+
2096
+ @staticmethod
2097
+ def _payErrors(result: dict[str, Any]) -> list[str]:
2098
+ if not isinstance(result, dict):
2099
+ return []
2100
+ decision = result.get("decision", {}) if isinstance(result.get("decision"), dict) else {}
2101
+ errors = decision.get("errors", result.get("errors", []))
2102
+ return [str(item) for item in errors if str(item)] if isinstance(errors, list) else ([str(errors)] if str(errors or "") else [])
2103
+
2104
+ @classmethod
2105
+ def _payFailureReason(cls, result: dict[str, Any]) -> str:
2106
+ return ",".join(cls._payErrors(result)) or str(result.get("error", result.get("status", "")) or "")
2107
+
2108
+ @staticmethod
2109
+ def _supportedPaymentMethods() -> list[str]:
2110
+ return ["stripe", "paypal", "card", "bank", "swish", "bitcoin"]
2111
+
2112
+ @classmethod
2113
+ def _paymentSchema(cls) -> dict[str, Any]:
2114
+ return {
2115
+ "methods": [
2116
+ {"method": method, "fields": cls._paymentFieldsFor(method)}
2117
+ for method in cls._supportedPaymentMethods()
2118
+ ]
2119
+ }
2120
+
2121
+ @staticmethod
2122
+ def _paymentFieldsFor(method: str) -> list[str]:
2123
+ return {
2124
+ "stripe": ["account"],
2125
+ "paypal": ["account"],
2126
+ "card": ["cardNumber", "expiry", "cvv"],
2127
+ "bank": ["bank", "account", "number"],
2128
+ "swish": ["number"],
2129
+ "bitcoin": ["walletAddress", "chainType"],
2130
+ }.get(str(method or "").lower(), [])
2131
+
2132
+ @staticmethod
2133
+ def _bridgeNeedsInput(candidateRef: dict[str, Any], missingKeys: list[str]) -> dict[str, Any]:
2134
+ return {
2135
+ "ok": False,
2136
+ "status": "NEEDS_INPUT",
2137
+ "mode": "adapterNeutral",
2138
+ "requestedKeys": missingKeys,
2139
+ "checkout": {
2140
+ "action": "requestInput",
2141
+ "source": candidateRef.get("source", ""),
2142
+ "url": candidateRef.get("url", ""),
2143
+ "candidateId": candidateRef.get("candidateId", ""),
2144
+ },
2145
+ }
2146
+
2147
+ @staticmethod
2148
+ def _isLocalServiceCandidate(candidateRef: dict[str, Any]) -> bool:
2149
+ kind = str(candidateRef.get("kind", "") or "").lower()
2150
+ item = str(candidateRef.get("itemId", "") or "").lower()
2151
+ source = str(candidateRef.get("source", "") or "").lower()
2152
+ return kind == "localservice" or source in {"googleplaces", "openstreetmapnominatim", "clientplaces"} or any(token in item for token in ("mechanic", "plumber", "electrician", "garage", "workshop", "repair"))
2153
+
2154
+ @staticmethod
2155
+ def _originalTaskRequest(task: dict[str, Any]) -> str:
2156
+ inData = task.get("inData", {}) if isinstance(task.get("inData"), dict) else {}
2157
+ return str(inData.get("request", task.get("goal", "")) or "").strip()
2158
+
2159
+ @staticmethod
2160
+ def _localServiceInputs(payload: dict[str, Any], candidateRef: dict[str, Any], *, defaultBrief: str) -> dict[str, Any]:
2161
+ rawInputs = payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {}
2162
+ values = payload.get("values") if isinstance(payload.get("values"), dict) else {}
2163
+ contact = payload.get("contact") if isinstance(payload.get("contact"), dict) else {}
2164
+ inputs = {**rawInputs, **values}
2165
+ return {
2166
+ "userName": payload.get("userName") or inputs.get("userName") or contact.get("name") or "",
2167
+ "userPhone": payload.get("userPhone") or payload.get("phone") or inputs.get("userPhone") or inputs.get("phone") or contact.get("phone") or "",
2168
+ "businessPhone": payload.get("businessPhone") or payload.get("selectedPhone") or inputs.get("businessPhone") or candidateRef.get("phone", "") or "",
2169
+ "issueBrief": payload.get("issueBrief") or payload.get("brief") or inputs.get("issueBrief") or inputs.get("brief") or defaultBrief,
2170
+ "preferredChannel": payload.get("preferredChannel") or inputs.get("preferredChannel") or "whatsapp",
2171
+ "consentToCall": payload.get("consentToCall", inputs.get("consentToCall", payload.get("consent", inputs.get("consent", "")))),
2172
+ }
2173
+
2174
+ @classmethod
2175
+ def _missingLocalServiceKeys(cls, inputs: dict[str, Any], candidateRef: dict[str, Any]) -> list[str]:
2176
+ missing = []
2177
+ if not str(inputs.get("userPhone", "") or "").strip():
2178
+ missing.append("userPhone")
2179
+ if not cls._truthy(inputs.get("consentToCall", "")):
2180
+ missing.append("consentToCall")
2181
+ return missing
2182
+
2183
+ @classmethod
2184
+ def _proxyContactIntent(cls, conversationId: str, taskId: str, candidateRef: dict[str, Any], inputs: dict[str, Any], missingKeys: list[str]) -> dict[str, Any]:
2185
+ intentId = cls._stableId("proxyIntent", {"conversationId": conversationId, "taskId": taskId, "candidateId": candidateRef.get("candidateId", "")})
2186
+ return {
2187
+ "intentId": intentId,
2188
+ "conversationId": conversationId,
2189
+ "taskId": taskId,
2190
+ "status": "needsInput" if missingKeys else "readyForProxy",
2191
+ "candidate": candidateRef,
2192
+ "requestedKeys": missingKeys,
2193
+ "requiredKeys": ["userPhone", "consentToCall"],
2194
+ "channel": str(inputs.get("preferredChannel", "whatsapp") or "whatsapp"),
2195
+ "inputs": {
2196
+ key: value
2197
+ for key, value in {
2198
+ "userName": inputs.get("userName", ""),
2199
+ "userPhone": inputs.get("userPhone", ""),
2200
+ "businessPhone": inputs.get("businessPhone", ""),
2201
+ "issueBrief": inputs.get("issueBrief", ""),
2202
+ "preferredChannel": inputs.get("preferredChannel", "whatsapp"),
2203
+ "consentToCall": bool(cls._truthy(inputs.get("consentToCall", ""))),
2204
+ }.items()
2205
+ if value not in {"", None}
2206
+ },
2207
+ }
2208
+
2209
+ @staticmethod
2210
+ def _truthy(value: Any) -> bool:
2211
+ if isinstance(value, bool):
2212
+ return value
2213
+ return str(value or "").strip().lower() in {"1", "true", "yes", "y", "approved", "approve", "consent", "consented", "ok"}
2214
+
2215
+ @staticmethod
2216
+ def _isLocalServiceRequest(request: str, payload: dict[str, Any], nlp: dict[str, Any]) -> bool:
2217
+ entities = nlp.get("entities", {}) if isinstance(nlp.get("entities"), dict) else {}
2218
+ if str(entities.get("market", "") or "") == "localService":
2219
+ return True
2220
+ if str(payload.get("market", "") or "").lower() in {"localservice", "local_service", "places", "maps"}:
2221
+ return True
2222
+ lowered = request.lower()
2223
+ service = str(entities.get("service", entities.get("entity", "")) or "").lower()
2224
+ placeHints = ("near", "nearby", "within", "around", "close to", "km", "kilometer", "kilometre", "goteborg", "gothenburg", "hisingen", "hisigen")
2225
+ serviceHints = ("mechanic", "plumber", "electrician", "repair", "garage", "workshop", "lawyer", "consultant", "doctor", "dentist", "cleaner", "contractor", "installer", "handyman")
2226
+ return bool(service or any(token in lowered for token in serviceHints)) and any(token in lowered for token in placeHints)
2227
+
2228
+ @classmethod
2229
+ def _placesToOffers(cls, request: str, mapResult: dict[str, Any]) -> list[dict[str, Any]]:
2230
+ places = mapResult.get("places", []) if isinstance(mapResult.get("places"), list) else []
2231
+ query = mapResult.get("query", {}) if isinstance(mapResult.get("query"), dict) else {}
2232
+ itemId = str(query.get("entity", "") or cls._itemId(request) or "localService")
2233
+ offers = []
2234
+ for index, place in enumerate(places[:10], start=1):
2235
+ if not isinstance(place, dict):
2236
+ continue
2237
+ name = str(place.get("name", "") or "").strip()
2238
+ if not name:
2239
+ continue
2240
+ source = str(place.get("source", mapResult.get("provider", "mapTool")) or "mapTool")
2241
+ sourceUrl = str(place.get("sourceUrl", "") or "")
2242
+ placeId = str(place.get("placeId", "") or "")
2243
+ candidateId = placeId if placeId.startswith("place_") else cls._stableId("place", {"placeId": placeId, "name": name, "source": source, "sourceUrl": sourceUrl})
2244
+ contactRef = cls._contactRefForPlace(place, candidateId)
2245
+ rating = cls._float(place.get("rating", 0.0))
2246
+ distanceKm = cls._float(place.get("distanceKm", 0.0))
2247
+ stock = 1 if bool(place.get("sourceBound")) else 0
2248
+ offers.append(
2249
+ {
2250
+ "sourceId": candidateId,
2251
+ "candidateId": candidateId,
2252
+ "itemId": itemId,
2253
+ "listUsd": 0.0,
2254
+ "offerUsd": 0.0,
2255
+ "feesUsd": 0.0,
2256
+ "stock": stock,
2257
+ "meta": {
2258
+ "kind": "localService",
2259
+ "placeKind": "localService",
2260
+ "reader": "mapTool",
2261
+ "source": source,
2262
+ "sourceUrl": sourceUrl,
2263
+ "listingTitle": name,
2264
+ "address": str(place.get("address", "") or ""),
2265
+ "website": str(place.get("website", "") or ""),
2266
+ "rating": rating,
2267
+ "distanceKm": distanceKm,
2268
+ "placeId": placeId,
2269
+ "contactRef": contactRef,
2270
+ "contactStatus": "private",
2271
+ "verified": bool(place.get("verified")),
2272
+ "placeIndex": index,
2273
+ "entity": itemId,
2274
+ "price": {},
2275
+ "sourceBound": bool(place.get("sourceBound")),
2276
+ },
2277
+ }
2278
+ )
2279
+ return offers
2280
+
2281
+ @classmethod
2282
+ def _contactRefForPlace(cls, place: dict[str, Any], candidateId: str) -> str:
2283
+ explicit = str(place.get("contactRef", "") or "")
2284
+ if explicit:
2285
+ return explicit
2286
+ return cls._stableId(
2287
+ "localContact",
2288
+ {
2289
+ "candidateId": candidateId,
2290
+ "placeId": str(place.get("placeId", "") or ""),
2291
+ "name": str(place.get("name", "") or ""),
2292
+ "source": str(place.get("source", "") or ""),
2293
+ },
2294
+ )
2295
+
2296
+ @staticmethod
2297
+ def _mapBrowserTelemetry(mapResult: dict[str, Any], offers: list[dict[str, Any]], *, country: str) -> dict[str, Any]:
2298
+ provider = str(mapResult.get("provider", "mapTool") or "mapTool")
2299
+ query = mapResult.get("query", {}) if isinstance(mapResult.get("query"), dict) else {}
2300
+ places = mapResult.get("places", []) if isinstance(mapResult.get("places"), list) else []
2301
+ ok = bool(places) or str(mapResult.get("status", "") or "") == "searchedNoPlaces"
2302
+ search = {
2303
+ "mode": "placesSearch",
2304
+ "query": " ".join(str(query.get(key, "") or "") for key in ("entity", "location")).strip(),
2305
+ "submitted": bool(mapResult.get("networkAttempted") or places),
2306
+ "radiusKm": query.get("radiusKm", 0),
2307
+ }
2308
+ return {
2309
+ "adapter": provider,
2310
+ "rendered": {},
2311
+ "marketplaceSet": {
2312
+ "market": "localService",
2313
+ "country": country.lower(),
2314
+ "sources": [provider],
2315
+ "localSources": [provider],
2316
+ "entity": str(query.get("entity", "") or ""),
2317
+ "location": str(query.get("location", "") or ""),
2318
+ "radiusKm": query.get("radiusKm", 0),
2319
+ },
2320
+ "budget": {},
2321
+ "fx": {},
2322
+ "networkAttempted": bool(mapResult.get("networkAttempted")),
2323
+ "searchTargets": [{"source": provider, "url": "googlePlaces://textSearch" if provider == "googlePlaces" else provider, "kind": "placesSearch"}],
2324
+ "marketplaceAttempts": [
2325
+ {
2326
+ "source": provider,
2327
+ "url": "googlePlaces://textSearch" if provider == "googlePlaces" else provider,
2328
+ "ok": ok,
2329
+ "adapter": provider,
2330
+ "statusCode": 200 if ok else 0,
2331
+ "title": f"{query.get('entity', 'places')} near {query.get('location', '')}".strip(),
2332
+ "matched": ok,
2333
+ "listingCount": len(places),
2334
+ "filteredListingCount": len(offers),
2335
+ "search": search,
2336
+ "error": "; ".join(str(item) for item in mapResult.get("errors", []) if item),
2337
+ }
2338
+ ],
2339
+ "observations": [
2340
+ {
2341
+ "source": provider,
2342
+ "url": "googlePlaces://textSearch" if provider == "googlePlaces" else provider,
2343
+ "ok": ok,
2344
+ "adapter": provider,
2345
+ "statusCode": 200 if ok else 0,
2346
+ "title": f"{query.get('entity', 'places')} near {query.get('location', '')}".strip(),
2347
+ "matched": ok,
2348
+ "listingCount": len(places),
2349
+ "search": search,
2350
+ "error": "; ".join(str(item) for item in mapResult.get("errors", []) if item),
2351
+ }
2352
+ ],
2353
+ "offers": offers,
2354
+ "errors": [] if ok else [{"source": provider, "ok": False, "error": "; ".join(str(item) for item in mapResult.get("errors", []) if item)}],
2355
+ "status": str(mapResult.get("status", "")),
2356
+ }
2357
+
2358
+ @staticmethod
2359
+ def _float(value: Any) -> float:
2360
+ try:
2361
+ return float(value or 0.0)
2362
+ except Exception:
2363
+ return 0.0
2364
+
2365
+ def _huntPayload(self, request: str, payload: dict[str, Any], *, taskId: str, browser: dict[str, Any] | None = None, clientId: str = "orchestrator", channel: str = "api") -> dict[str, Any]:
2366
+ criteria = payload.get("criteria") if isinstance(payload.get("criteria"), dict) else {}
2367
+ budget = self._budget(request, payload)
2368
+ itemId = str(criteria.get("itemId", "") or self._itemId(request) or "requestedItem")
2369
+ maxNetUsd = float(criteria.get("maxNetUsd", budget) or budget or 0.0)
2370
+ rawOffers = payload.get("offers", payload.get("telemetry", []))
2371
+ offers = rawOffers if isinstance(rawOffers, list) else []
2372
+ browser = browser or {}
2373
+ browserOffers = browser.get("offers", []) if isinstance(browser.get("offers"), list) else []
2374
+ if not offers and browserOffers:
2375
+ offers = browserOffers
2376
+ return {
2377
+ "taskId": taskId,
2378
+ "criteria": {
2379
+ "itemId": itemId,
2380
+ "minDiscountPct": float(criteria.get("minDiscountPct")) if "minDiscountPct" in criteria else 0.0,
2381
+ "maxNetUsd": maxNetUsd,
2382
+ "minStock": int(criteria.get("minStock", 1) or 1),
2383
+ "region": str(criteria.get("region", "") or ""),
2384
+ },
2385
+ "telemetry": offers,
2386
+ "sourceTelemetry": self._sourceTelemetry(browser),
2387
+ "handoff": self._handoff(payload, clientId, channel),
2388
+ }
2389
+
2390
+ @staticmethod
2391
+ def _sourceTelemetry(browser: dict[str, Any]) -> dict[str, Any]:
2392
+ attempts = browser.get("marketplaceAttempts", []) if isinstance(browser.get("marketplaceAttempts"), list) else []
2393
+ targets = browser.get("searchTargets", []) if isinstance(browser.get("searchTargets"), list) else []
2394
+ return {
2395
+ "adapter": browser.get("adapter", ""),
2396
+ "rendered": browser.get("rendered", {}),
2397
+ "marketplaceSet": browser.get("marketplaceSet", {}),
2398
+ "budget": browser.get("budget", {}),
2399
+ "fx": browser.get("fx", {}),
2400
+ "networkAttempted": bool(browser.get("networkAttempted")),
2401
+ "targetSources": [item.get("source", "") for item in targets if isinstance(item, dict)],
2402
+ "attempts": attempts,
2403
+ "observations": PicuxMultiAgentOrchestrator._sourceObservationSummaries(browser),
2404
+ "offerCount": len(browser.get("offers", [])) if isinstance(browser.get("offers"), list) else 0,
2405
+ "observationCount": len(browser.get("observations", [])) if isinstance(browser.get("observations"), list) else 0,
2406
+ "status": browser.get("status", ""),
2407
+ }
2408
+
2409
+ @staticmethod
2410
+ def _sourceObservationSummaries(browser: dict[str, Any]) -> list[dict[str, Any]]:
2411
+ observations = browser.get("observations", []) if isinstance(browser.get("observations"), list) else []
2412
+ summaries = []
2413
+ for observation in observations:
2414
+ if not isinstance(observation, dict):
2415
+ continue
2416
+ listings = observation.get("listings", []) if isinstance(observation.get("listings"), list) else []
2417
+ summaries.append(
2418
+ {
2419
+ "source": observation.get("source", ""),
2420
+ "url": observation.get("url", ""),
2421
+ "ok": bool(observation.get("ok")),
2422
+ "adapter": observation.get("adapter", ""),
2423
+ "statusCode": observation.get("statusCode", 0),
2424
+ "title": observation.get("title", ""),
2425
+ "matched": bool(observation.get("matched")),
2426
+ "listingCount": len(listings),
2427
+ "search": observation.get("search", {}) if isinstance(observation.get("search"), dict) else {},
2428
+ "error": observation.get("error", ""),
2429
+ }
2430
+ )
2431
+ return summaries
2432
+
2433
+ @staticmethod
2434
+ def _handoff(payload: dict[str, Any], clientId: str, channel: str) -> dict[str, str]:
2435
+ raw = payload.get("handoff", payload.get("handsoff", {}))
2436
+ raw = raw if isinstance(raw, dict) else {}
2437
+ explicitSource = raw.get("source") or payload.get("handoffSource") or payload.get("handsoffSource")
2438
+ source = str(explicitSource or clientId or "externalClient")
2439
+ explicitTarget = raw.get("target") or payload.get("handoffTarget") or payload.get("handsoffTarget")
2440
+ target = str(explicitTarget or (source if explicitSource else "orchestrator") or "orchestrator")
2441
+ if target in {"source", "handoffSource", "handsoffSource"}:
2442
+ target = source
2443
+ return {
2444
+ "source": source,
2445
+ "target": target,
2446
+ "channel": str(raw.get("channel", payload.get("handoffChannel", channel)) or channel),
2447
+ "mode": str(raw.get("mode", payload.get("handoffMode", "return")) or "return"),
2448
+ }
2449
+
2450
+ @staticmethod
2451
+ def _decisionTree(hunt: dict[str, Any]) -> dict[str, Any]:
2452
+ candidates = hunt.get("candidates", []) if isinstance(hunt.get("candidates"), list) else []
2453
+ selected = hunt.get("selected", {}) if isinstance(hunt.get("selected"), dict) else {}
2454
+ paths = []
2455
+ for item in candidates:
2456
+ if not isinstance(item, dict):
2457
+ continue
2458
+ sourceId = str(item.get("sourceId", "") or "")
2459
+ candidateId = str(item.get("candidateId", "") or sourceId)
2460
+ meta = item.get("meta", {}) if isinstance(item.get("meta"), dict) else {}
2461
+ price = meta.get("price", {}) if isinstance(meta.get("price"), dict) else {}
2462
+ paths.append(
2463
+ {
2464
+ "candidateId": candidateId,
2465
+ "vendorId": sourceId,
2466
+ "source": str(meta.get("source", "") or ""),
2467
+ "title": str(meta.get("listingTitle", item.get("itemId", "")) or ""),
2468
+ "url": str(meta.get("sourceUrl", "") or ""),
2469
+ "kind": str(meta.get("kind", meta.get("placeKind", "")) or ""),
2470
+ "address": str(meta.get("address", "") or ""),
2471
+ "website": str(meta.get("website", "") or ""),
2472
+ "rating": meta.get("rating", 0),
2473
+ "distanceKm": meta.get("distanceKm", 0),
2474
+ "price": price,
2475
+ "status": "selected" if selected.get("sourceId") == sourceId else "pruned",
2476
+ "reasons": item.get("reasons", []) if isinstance(item.get("reasons"), list) else [],
2477
+ "netUsd": item.get("netUsd", 0),
2478
+ }
2479
+ )
2480
+ return {"root": "userRequest", "selected": str(selected.get("sourceId", "") or ""), "paths": paths}
2481
+
2482
+ @staticmethod
2483
+ def _itemId(request: str) -> str:
2484
+ match = re.search(r"\b(i\s*phone\s*\d+|iphone\s*\d+|iphone|smartphone|phone|tv|laptop|car|apartment|house|property|mechanic|lead|option)\b", request, re.IGNORECASE)
2485
+ if match:
2486
+ return re.sub(r"\s+", "", match.group(1).lower())
2487
+ subject = re.search(
2488
+ r"\b(?:buy|find|get|search(?: for)?|shop(?: for)?|source)\s+(?:a|an|the|my)?\s*(.+?)(?:\s+(?:under|less than|below|budget|for)\b|\s+(?:in|near|around)\s+[A-Z][A-Za-z]+|$)",
2489
+ request,
2490
+ re.IGNORECASE,
2491
+ )
2492
+ if subject:
2493
+ clean = re.sub(r"\b(?:used|best|cheap|affordable|new)\b", " ", subject.group(1), flags=re.IGNORECASE)
2494
+ clean = re.sub(r"[^A-Za-z0-9 ]+", " ", clean)
2495
+ clean = re.sub(r"\s+", " ", clean).strip().lower()
2496
+ if clean:
2497
+ return clean[:80]
2498
+ words = [word for word in re.findall(r"[A-Za-z0-9]+", request) if len(word) > 2]
2499
+ return words[0].lower() if words else "requestedItem"
2500
+
2501
+ @staticmethod
2502
+ def _conversationItemLabel(request: str) -> str:
2503
+ itemId = PicuxMultiAgentOrchestrator._itemId(request)
2504
+ if itemId and itemId != "requestedItem":
2505
+ return PicuxMultiAgentOrchestrator._displayItem(itemId)
2506
+ subject = re.search(
2507
+ r"\b(?:buy|find|get|search(?: for)?|shop(?: for)?|source)\s+(?:a|an|the|my)?\s*(.+?)(?:\s+(?:under|less than|below|budget|for)\b|\s+(?:in|near|around)\s+[A-Z][A-Za-z]+|$)",
2508
+ request,
2509
+ re.IGNORECASE,
2510
+ )
2511
+ if not subject:
2512
+ return "matching options"
2513
+ clean = re.sub(r"\b(?:used|best|cheap|affordable|new)\b", " ", subject.group(1), flags=re.IGNORECASE)
2514
+ clean = re.sub(r"[^A-Za-z0-9 ]+", " ", clean)
2515
+ clean = re.sub(r"\s+", " ", clean).strip()
2516
+ return clean[:80] or "matching options"
2517
+
2518
+ @classmethod
2519
+ def _huntConversationSummary(cls, request: str, hunt: dict[str, Any]) -> str:
2520
+ sourceResponse = hunt.get("sourceResponse", {}) if isinstance(hunt.get("sourceResponse"), dict) else {}
2521
+ aggregate = hunt.get("sourceAggregate", {}) if isinstance(hunt.get("sourceAggregate"), dict) else {}
2522
+ sources = aggregate.get("sources", []) if isinstance(aggregate.get("sources"), list) else []
2523
+ candidates = hunt.get("candidates", []) if isinstance(hunt.get("candidates"), list) else []
2524
+ offers = aggregate.get("offers", []) if isinstance(aggregate.get("offers"), list) else []
2525
+ label = cls._conversationItemLabel(request)
2526
+ marketplaceSet = sourceResponse.get("marketplaceSet", {}) if isinstance(sourceResponse.get("marketplaceSet"), dict) else {}
2527
+ localService = str(marketplaceSet.get("market", "") or "") == "localService"
2528
+ resultSources = cls._resultSources(candidates, offers, sources)
2529
+ if candidates:
2530
+ if localService:
2531
+ entity = str(marketplaceSet.get("entity", "") or label or "local service")
2532
+ location = str(marketplaceSet.get("location", "") or "").strip()
2533
+ noun = cls._pluralService(entity)
2534
+ line = f"Found {len(candidates)} {noun}"
2535
+ if location:
2536
+ line += f" in {location}"
2537
+ return line + "."
2538
+ top = cls._topListingSummaries(candidates)
2539
+ sourceText = cls._sourceList(resultSources)
2540
+ listingWord = ("place" if len(candidates) == 1 else "places") if localService else ("listing" if len(candidates) == 1 else "listings")
2541
+ line = f"Found {len(candidates)} source-bound {label} {listingWord}"
2542
+ if sourceText:
2543
+ marketWord = "map source" if localService and len(resultSources) == 1 else ("map sources" if localService else ("marketplace" if len(resultSources) == 1 else "marketplaces"))
2544
+ line += f" from {len(resultSources)} {marketWord}: {sourceText}"
2545
+ if top:
2546
+ line += f". Top matches: {'; '.join(top)}"
2547
+ return line + "."
2548
+ listingSources = [source for source in sources if int(source.get("listingCount", 0) or 0) > 0]
2549
+ if listingSources:
2550
+ listingCount = sum(int(source.get("listingCount", 0) or 0) for source in listingSources)
2551
+ sourceText = cls._sourceList([str(source.get("source", "") or "") for source in listingSources])
2552
+ return f"Found {listingCount} raw {label} listings on {sourceText}, but none passed the model, price, and evidence filters yet."
2553
+ attempted = sourceResponse.get("attemptedSources", []) if isinstance(sourceResponse.get("attemptedSources"), list) else []
2554
+ failed = sourceResponse.get("failedSources", []) if isinstance(sourceResponse.get("failedSources"), list) else []
2555
+ if attempted:
2556
+ sourceText = cls._sourceList([str(source) for source in attempted])
2557
+ if len(failed) == len(attempted):
2558
+ return f"Searched {sourceText}, but every source returned an I/O error or blocked page."
2559
+ if localService:
2560
+ return f"Searched {sourceText}, but no source returned a source-bound {label} place yet."
2561
+ return f"Searched {sourceText}, but no source returned a price-bound {label} listing yet."
2562
+ targets = sourceResponse.get("targetSources", []) if isinstance(sourceResponse.get("targetSources"), list) else []
2563
+ if targets:
2564
+ return f"Prepared {len(targets)} source targets for {label}: {cls._sourceList([str(source) for source in targets])}."
2565
+ return ""
2566
+
2567
+ @staticmethod
2568
+ def _pluralService(entity: str) -> str:
2569
+ clean = re.sub(r"\s+", " ", str(entity or "local service").strip().lower())
2570
+ if clean in {"mechanic", "car repair", "bilverkstad"}:
2571
+ return "mechanics"
2572
+ if clean.endswith("s"):
2573
+ return clean
2574
+ if clean.endswith("y"):
2575
+ return clean[:-1] + "ies"
2576
+ return clean + "s"
2577
+
2578
+ @staticmethod
2579
+ def _resultSources(candidates: list[Any], offers: list[Any], sources: list[Any]) -> list[str]:
2580
+ result: list[str] = []
2581
+ for item in [*candidates, *offers]:
2582
+ if not isinstance(item, dict):
2583
+ continue
2584
+ meta = item.get("meta", {}) if isinstance(item.get("meta"), dict) else {}
2585
+ source = str(meta.get("source", "") or "")
2586
+ if source:
2587
+ result.append(source)
2588
+ if result:
2589
+ return list(dict.fromkeys(result))
2590
+ return list(
2591
+ dict.fromkeys(
2592
+ str(source.get("source", "") or "")
2593
+ for source in sources
2594
+ if isinstance(source, dict) and int(source.get("listingCount", 0) or 0) > 0
2595
+ )
2596
+ )
2597
+
2598
+ @classmethod
2599
+ def _topListingSummaries(cls, candidates: list[Any]) -> list[str]:
2600
+ summaries = []
2601
+ ordered = [item for item in candidates if isinstance(item, dict)]
2602
+ ordered.sort(key=lambda item: (not bool(item.get("eligible")), float(item.get("netUsd", item.get("offerUsd", 0.0)) or 0.0)))
2603
+ for item in ordered[:3]:
2604
+ meta = item.get("meta", {}) if isinstance(item.get("meta"), dict) else {}
2605
+ title = str(meta.get("listingTitle", "") or item.get("itemId", "") or "listing").strip()
2606
+ source = cls._displaySource(str(meta.get("source", "") or ""))
2607
+ price = meta.get("price", {}) if isinstance(meta.get("price"), dict) else {}
2608
+ priceText = cls._formatPrice(price, float(item.get("netUsd", item.get("offerUsd", 0.0)) or 0.0))
2609
+ reasons = item.get("reasons", []) if isinstance(item.get("reasons"), list) else []
2610
+ suffix = ""
2611
+ if reasons:
2612
+ suffix = f" ({', '.join(str(reason) for reason in reasons[:2])})"
2613
+ summary = f"{title}"
2614
+ if priceText:
2615
+ summary += f" - {priceText}"
2616
+ if source:
2617
+ summary += f" on {source}"
2618
+ summaries.append(summary + suffix)
2619
+ return summaries
2620
+
2621
+ @staticmethod
2622
+ def _formatPrice(price: dict[str, Any], usd: float) -> str:
2623
+ amount = price.get("amount")
2624
+ currency = str(price.get("currency", "") or "").upper()
2625
+ if amount and currency:
2626
+ try:
2627
+ amountText = f"{float(amount):,.0f}" if float(amount).is_integer() else f"{float(amount):,.2f}"
2628
+ except Exception:
2629
+ amountText = str(amount)
2630
+ if currency == "USD":
2631
+ return f"${amountText}"
2632
+ return f"{amountText} {currency} (about ${usd:,.0f})"
2633
+ if usd > 0:
2634
+ return f"about ${usd:,.0f}"
2635
+ return ""
2636
+
2637
+ @staticmethod
2638
+ def _sourceList(sources: list[str]) -> str:
2639
+ names = [PicuxMultiAgentOrchestrator._displaySource(source) for source in sources if source]
2640
+ return ", ".join(list(dict.fromkeys(name for name in names if name))[:8])
2641
+
2642
+ @staticmethod
2643
+ def _displaySource(source: str) -> str:
2644
+ labels = {
2645
+ "blocket": "Blocket",
2646
+ "tradera": "Tradera",
2647
+ "facebook": "Facebook Marketplace",
2648
+ "ebay": "eBay",
2649
+ "alibaba": "Alibaba",
2650
+ "amazon": "Amazon",
2651
+ "shopify": "Shop",
2652
+ "jumia": "Jumia",
2653
+ "konga": "Konga",
2654
+ "jiji": "Jiji",
2655
+ "googleplaces": "Google Places",
2656
+ "openstreetmapnominatim": "OpenStreetMap",
2657
+ "clientplaces": "Client Places",
2658
+ }
2659
+ return labels.get(source.lower(), source)
2660
+
2661
+ @staticmethod
2662
+ def _displayItem(itemId: str) -> str:
2663
+ compact = re.sub(r"\s+", "", str(itemId or "")).lower()
2664
+ phone = re.match(r"iphone(\d+)$", compact)
2665
+ if phone:
2666
+ return f"iPhone {phone.group(1)}"
2667
+ if compact == "iphone":
2668
+ return "iPhone"
2669
+ if compact == "smartphone":
2670
+ return "smartphone"
2671
+ if compact == "tv":
2672
+ return "TV"
2673
+ return re.sub(r"\s+", " ", str(itemId or "")).strip()
2674
+
2675
+ @staticmethod
2676
+ def _budget(request: str, payload: dict[str, Any]) -> float:
2677
+ raw = payload.get("budget", payload.get("maxSpend", 0))
2678
+ try:
2679
+ if raw:
2680
+ return float(raw)
2681
+ except Exception:
2682
+ pass
2683
+ patterns = (
2684
+ r"(?:under|less than|below|budget|for)\s*\$?\s*([0-9]+(?:\.[0-9]{1,2})?)",
2685
+ r"\$\s*([0-9]+(?:\.[0-9]{1,2})?)",
2686
+ r"\b([0-9]+(?:\.[0-9]{1,2})?)\s*(?:dollar|usd)\b",
2687
+ )
2688
+ for pattern in patterns:
2689
+ match = re.search(pattern, request, re.IGNORECASE)
2690
+ if not match:
2691
+ continue
2692
+ try:
2693
+ return float(match.group(1))
2694
+ except Exception:
2695
+ return 0.0
2696
+ return 0.0
2697
+
2698
+ @classmethod
2699
+ def _startResolveRecovery(
2700
+ cls,
2701
+ host: Any,
2702
+ *,
2703
+ request: str,
2704
+ payload: dict[str, Any],
2705
+ target: dict[str, Any],
2706
+ documents: list[Any],
2707
+ taskId: str,
2708
+ conversationId: str,
2709
+ ) -> dict[str, Any]:
2710
+ if not target or not hasattr(host, "resolveRecovery"):
2711
+ return {}
2712
+ if hasattr(host, "_ensureReadyConnectors"):
2713
+ try:
2714
+ host._ensureReadyConnectors()
2715
+ except Exception:
2716
+ pass
2717
+ recoveryPayload = {
2718
+ **payload,
2719
+ "claim": request,
2720
+ "text": request,
2721
+ "taskId": taskId,
2722
+ "conversationId": conversationId,
2723
+ "targetEntity": target,
2724
+ "attachments": documents,
2725
+ "request": {
2726
+ "issueType": cls._issueType(request),
2727
+ "attachments": documents,
2728
+ **(payload.get("request", {}) if isinstance(payload.get("request"), dict) else {}),
2729
+ },
2730
+ }
2731
+ try:
2732
+ result = host.resolveRecovery(recoveryPayload)
2733
+ except Exception as exc:
2734
+ return {"ok": False, "status": "blocked", "reason": type(exc).__name__}
2735
+ recovery = result.get("recovery", {}) if isinstance(result.get("recovery"), dict) else {}
2736
+ if not recovery:
2737
+ return {}
2738
+ return {
2739
+ **recovery,
2740
+ "taskId": str(result.get("taskId", taskId) or taskId),
2741
+ "case": result.get("case", {}) if isinstance(result.get("case"), dict) else {},
2742
+ "proofCard": result.get("proofCard", {}) if isinstance(result.get("proofCard"), dict) else {},
2743
+ }
2744
+
2745
+ @classmethod
2746
+ def _target(cls, request: str, payload: dict[str, Any]) -> dict[str, Any]:
2747
+ target = payload.get("target") if isinstance(payload.get("target"), dict) else payload.get("targetEntity", {})
2748
+ if isinstance(target, dict) and target:
2749
+ return target
2750
+ lowered = request.lower()
2751
+ if "bank" in lowered:
2752
+ return {"entityId": "bank", "name": "bank", "type": "bank"}
2753
+ domainMerchant = re.search(r"\b(?:to|from|with|at)\s*([A-Z]?[A-Za-z0-9-]+\.(?:se|com|net|org|io|co|ng|uk))\b", request, re.IGNORECASE)
2754
+ if domainMerchant:
2755
+ name = domainMerchant.group(1).strip()
2756
+ return {"entityId": cls._stableId("entity", {"name": name}), "name": name, "type": "merchant"}
2757
+ merchant = re.search(r"\bfrom\s+([A-Z][A-Za-z0-9.& -]{2,40})", request)
2758
+ if merchant:
2759
+ name = merchant.group(1).strip()
2760
+ return {"entityId": cls._stableId("entity", {"name": name}), "name": name, "type": "merchant"}
2761
+ if any(token in lowered for token in ("merchant", "shop", "store", "support")):
2762
+ return {"entityId": "merchant", "name": "merchant support", "type": "merchant"}
2763
+ return {}
2764
+
2765
+ @staticmethod
2766
+ def _preferredChannel(request: str) -> str:
2767
+ lowered = request.lower()
2768
+ if "email" in lowered:
2769
+ return "email"
2770
+ if "chat" in lowered or "whatsapp" in lowered:
2771
+ return "chat"
2772
+ if "phone" in lowered or "call" in lowered:
2773
+ return "phone"
2774
+ return ""
2775
+
2776
+ @staticmethod
2777
+ def _issueType(request: str) -> str:
2778
+ lowered = request.lower()
2779
+ if "broken" in lowered or "damaged" in lowered:
2780
+ return "damaged goods claim"
2781
+ if "fake" in lowered or "scam" in lowered:
2782
+ return "scam or fake offer claim"
2783
+ if "used" in lowered:
2784
+ return "used item verification"
2785
+ return "consumer claim"
2786
+
2787
+ @classmethod
2788
+ def _hasPhone(cls, request: str, payload: dict[str, Any]) -> bool:
2789
+ return bool(cls._phone(request, payload))
2790
+
2791
+ @staticmethod
2792
+ def _phone(request: str, payload: dict[str, Any]) -> str:
2793
+ raw = str(payload.get("phone", "") or "")
2794
+ if raw:
2795
+ return raw
2796
+ match = re.search(r"\+?\d[\d\s().-]{7,}\d", request)
2797
+ return match.group(0).strip() if match else ""
2798
+
2799
+ @staticmethod
2800
+ def _shouldCreateProxy(request: str, payload: dict[str, Any]) -> bool:
2801
+ if bool(payload.get("requiresProxy", payload.get("humanRequired", False))):
2802
+ return True
2803
+ lowered = request.lower()
2804
+ return any(
2805
+ token in lowered
2806
+ for token in (
2807
+ "place a call",
2808
+ "make a call",
2809
+ "call merchant",
2810
+ "call support",
2811
+ "human handoff",
2812
+ "manual review",
2813
+ "lawyer",
2814
+ "consultant",
2815
+ "customer representative",
2816
+ "customer represent",
2817
+ "representative",
2818
+ "followup",
2819
+ "follow up",
2820
+ "did not follow",
2821
+ "no response",
2822
+ "logistics",
2823
+ "pickup",
2824
+ "delivery",
2825
+ )
2826
+ )
2827
+
2828
+ @staticmethod
2829
+ def _isResolveCase(request: str) -> bool:
2830
+ lowered = str(request or "").lower()
2831
+ if any(token in lowered for token in ("dispute", "claim", "complaint", "broken", "damaged", "defective", "fake", "scam", "verify", "audit", "evidence", "receipt", "screenshot")):
2832
+ return True
2833
+ damagedDelivery = any(token in lowered for token in ("in-transit", "in transit", "transit", "delivery", "delivered")) and any(token in lowered for token in ("tv", "item", "goods", "package", "order"))
2834
+ merchantNoFollowup = any(token in lowered for token in ("followup", "follow up", "follow-up", "did not follow", "no response", "customer representative", "customer represent"))
2835
+ return damagedDelivery or merchantNoFollowup
2836
+
2837
+ @staticmethod
2838
+ def _stableId(prefix: str, payload: dict[str, Any]) -> str:
2839
+ digest = hashlib.sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True).encode("utf-8")).hexdigest()
2840
+ return f"{prefix}_{digest[:24]}"