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,17 @@
1
+ from picux.portals.templates import (
2
+ applyPortalActionTemplate,
3
+ getPortalActionTemplate,
4
+ getPortalRecoveryPlaybook,
5
+ instantiatePortalRecoveryPlaybook,
6
+ portalActionTemplates,
7
+ portalRecoveryPlaybooks,
8
+ )
9
+
10
+ __all__ = [
11
+ "applyPortalActionTemplate",
12
+ "getPortalActionTemplate",
13
+ "getPortalRecoveryPlaybook",
14
+ "instantiatePortalRecoveryPlaybook",
15
+ "portalActionTemplates",
16
+ "portalRecoveryPlaybooks",
17
+ ]
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+ from urllib.parse import urljoin
6
+
7
+
8
+ PORTAL_ACTION_TEMPLATES: tuple[dict[str, Any], ...] = (
9
+ {
10
+ "templateId": "checkStatus",
11
+ "name": "Check Account Status",
12
+ "kind": "checkStatus",
13
+ "description": "Open an authenticated portal status page and capture the latest case, order, claim, or ticket state.",
14
+ "targetPath": "/status",
15
+ "requiredInputs": ["recordRef"],
16
+ "steps": [
17
+ {"label": "Open status page", "action": "navigate", "proofRequired": False},
18
+ {"label": "Search record reference", "action": "fill", "selector": "[name='recordRef']", "valueKey": "recordRef", "proofRequired": False},
19
+ {"label": "Capture status proof", "action": "capture", "proofRequired": True},
20
+ ],
21
+ "proofRequirements": ["domSnapshot", "confirmationOrStatus"],
22
+ },
23
+ {
24
+ "templateId": "retrieveRecord",
25
+ "name": "Retrieve Portal Record",
26
+ "kind": "retrieveRecord",
27
+ "description": "Find and capture a receipt, account record, policy document, order, or claim document from a portal.",
28
+ "targetPath": "/records",
29
+ "requiredInputs": ["recordRef"],
30
+ "steps": [
31
+ {"label": "Open records page", "action": "navigate", "proofRequired": False},
32
+ {"label": "Find record", "action": "fill", "selector": "[name='recordRef']", "valueKey": "recordRef", "proofRequired": False},
33
+ {"label": "Download or capture record", "action": "capture", "proofRequired": True},
34
+ ],
35
+ "proofRequirements": ["downloadedRecordOrDomSnapshot"],
36
+ },
37
+ {
38
+ "templateId": "submitClaim",
39
+ "name": "Submit Claim Form",
40
+ "kind": "submitClaim",
41
+ "description": "Submit a merchant, carrier, utility, or provider claim form and capture a confirmation proof.",
42
+ "targetPath": "/claims/new",
43
+ "requiredInputs": ["claimText"],
44
+ "steps": [
45
+ {"label": "Open claim form", "action": "navigate", "proofRequired": False},
46
+ {"label": "Enter claim details", "action": "fill", "selector": "[name='claim']", "valueKey": "claimText", "proofRequired": False},
47
+ {"label": "Attach evidence", "action": "upload", "selector": "input[type='file']", "valueKey": "attachmentRefs", "proofRequired": False},
48
+ {"label": "Submit claim", "action": "click", "selector": "[type='submit']", "proofRequired": True},
49
+ {"label": "Capture confirmation", "action": "capture", "proofRequired": True},
50
+ ],
51
+ "proofRequirements": ["confirmationNumber", "domSnapshot"],
52
+ },
53
+ {
54
+ "templateId": "uploadDocument",
55
+ "name": "Upload Document",
56
+ "kind": "uploadDocument",
57
+ "description": "Upload one or more evidence documents to an authenticated portal and capture upload proof.",
58
+ "targetPath": "/documents/upload",
59
+ "requiredInputs": ["attachmentRefs"],
60
+ "steps": [
61
+ {"label": "Open upload page", "action": "navigate", "proofRequired": False},
62
+ {"label": "Upload document", "action": "upload", "selector": "input[type='file']", "valueKey": "attachmentRefs", "proofRequired": False},
63
+ {"label": "Submit upload", "action": "click", "selector": "[type='submit']", "proofRequired": True},
64
+ {"label": "Capture upload proof", "action": "capture", "proofRequired": True},
65
+ ],
66
+ "proofRequirements": ["uploadConfirmation", "domSnapshot"],
67
+ },
68
+ )
69
+
70
+ PORTAL_RECOVERY_PLAYBOOKS: tuple[dict[str, Any], ...] = (
71
+ {
72
+ "playbookId": "merchantDamageClaim",
73
+ "name": "Merchant Damage Claim",
74
+ "description": "Retrieve the purchase record, submit a merchant damage claim, and check claim status.",
75
+ "providerTypes": ["merchant", "retailer", "marketplace"],
76
+ "requiredInputs": ["recordRef", "claimText"],
77
+ "optionalInputs": ["attachmentRefs"],
78
+ "actions": [
79
+ {"ref": "retrieveOrderRecord", "templateId": "retrieveRecord", "label": "Retrieve order or receipt record", "targetPath": "/orders", "inputMap": {"recordRef": "recordRef"}},
80
+ {"ref": "submitDamageClaim", "templateId": "submitClaim", "label": "Submit damage claim", "targetPath": "/claims/new", "inputMap": {"claimText": "claimText", "attachmentRefs": "attachmentRefs"}},
81
+ {"ref": "checkClaimStatus", "templateId": "checkStatus", "label": "Check claim status", "targetPath": "/claims/status", "inputMap": {"recordRef": "recordRef"}},
82
+ ],
83
+ "proofRequirements": ["purchaseRecord", "claimConfirmation", "statusSnapshot"],
84
+ },
85
+ {
86
+ "playbookId": "carrierDamageClaim",
87
+ "name": "Carrier Damage Claim",
88
+ "description": "Retrieve shipment records, submit a carrier damage claim, upload evidence, and check claim status.",
89
+ "providerTypes": ["carrier", "logistics", "shipping"],
90
+ "requiredInputs": ["trackingNumber", "claimText", "attachmentRefs"],
91
+ "optionalInputs": ["recordRef"],
92
+ "actions": [
93
+ {"ref": "retrieveShipmentRecord", "templateId": "retrieveRecord", "label": "Retrieve shipment record", "targetPath": "/shipments", "inputMap": {"recordRef": "trackingNumber"}},
94
+ {"ref": "submitCarrierClaim", "templateId": "submitClaim", "label": "Submit carrier damage claim", "targetPath": "/claims/damage/new", "inputMap": {"claimText": "claimText", "attachmentRefs": "attachmentRefs"}},
95
+ {"ref": "uploadDamageEvidence", "templateId": "uploadDocument", "label": "Upload damage evidence", "targetPath": "/claims/documents/upload", "inputMap": {"attachmentRefs": "attachmentRefs"}},
96
+ {"ref": "checkCarrierClaimStatus", "templateId": "checkStatus", "label": "Check carrier claim status", "targetPath": "/claims/status", "inputMap": {"recordRef": "trackingNumber"}},
97
+ ],
98
+ "proofRequirements": ["shipmentRecord", "claimConfirmation", "uploadConfirmation", "statusSnapshot"],
99
+ },
100
+ {
101
+ "playbookId": "utilityBillingDispute",
102
+ "name": "Utility Billing Dispute",
103
+ "description": "Retrieve billing records, submit a utility dispute, upload supporting documents, and check case status.",
104
+ "providerTypes": ["utility", "serviceProvider", "telecom"],
105
+ "requiredInputs": ["accountNumber", "claimText", "attachmentRefs"],
106
+ "optionalInputs": ["recordRef"],
107
+ "actions": [
108
+ {"ref": "retrieveBillingRecord", "templateId": "retrieveRecord", "label": "Retrieve billing record", "targetPath": "/billing/records", "inputMap": {"recordRef": "accountNumber"}},
109
+ {"ref": "submitBillingDispute", "templateId": "submitClaim", "label": "Submit billing dispute", "targetPath": "/billing/disputes/new", "inputMap": {"claimText": "claimText", "attachmentRefs": "attachmentRefs"}},
110
+ {"ref": "uploadDisputeDocuments", "templateId": "uploadDocument", "label": "Upload dispute documents", "targetPath": "/billing/documents/upload", "inputMap": {"attachmentRefs": "attachmentRefs"}},
111
+ {"ref": "checkDisputeStatus", "templateId": "checkStatus", "label": "Check dispute status", "targetPath": "/billing/disputes/status", "inputMap": {"recordRef": "accountNumber"}},
112
+ ],
113
+ "proofRequirements": ["billingRecord", "disputeConfirmation", "uploadConfirmation", "statusSnapshot"],
114
+ },
115
+ )
116
+
117
+
118
+ def portalActionTemplates(filters: dict[str, Any] | None = None) -> dict[str, Any]:
119
+ filters = filters or {}
120
+ kind = str(filters.get("kind", "") or "")
121
+ records = [_templateRecord(item) for item in PORTAL_ACTION_TEMPLATES]
122
+ if kind:
123
+ records = [item for item in records if item.get("kind") == kind]
124
+ return {"ok": True, "templates": records, "count": len(records)}
125
+
126
+
127
+ def getPortalActionTemplate(templateId: str) -> dict[str, Any]:
128
+ requested = str(templateId or "")
129
+ for template in PORTAL_ACTION_TEMPLATES:
130
+ if template["templateId"] == requested:
131
+ return {"ok": True, "template": _templateRecord(template)}
132
+ return {"ok": False, "error": "portalTemplateNotFound", "templateId": requested}
133
+
134
+
135
+ def portalRecoveryPlaybooks(filters: dict[str, Any] | None = None) -> dict[str, Any]:
136
+ filters = filters or {}
137
+ providerType = str(filters.get("providerType", filters.get("provider", "")) or "")
138
+ records = [_playbookRecord(item) for item in PORTAL_RECOVERY_PLAYBOOKS]
139
+ if providerType:
140
+ records = [item for item in records if providerType in item.get("providerTypes", [])]
141
+ return {"ok": True, "playbooks": records, "count": len(records)}
142
+
143
+
144
+ def getPortalRecoveryPlaybook(playbookId: str) -> dict[str, Any]:
145
+ requested = str(playbookId or "")
146
+ for playbook in PORTAL_RECOVERY_PLAYBOOKS:
147
+ if playbook["playbookId"] == requested:
148
+ return {"ok": True, "playbook": _playbookRecord(playbook)}
149
+ return {"ok": False, "error": "portalPlaybookNotFound", "playbookId": requested}
150
+
151
+
152
+ def instantiatePortalRecoveryPlaybook(playbookId: str, payload: dict[str, Any], *, session: dict[str, Any] | None = None) -> dict[str, Any]:
153
+ found = getPortalRecoveryPlaybook(playbookId)
154
+ if not found.get("ok"):
155
+ return found
156
+ playbook = found["playbook"]
157
+ session = session or {}
158
+ inputs = payload.get("inputs", {}) if isinstance(payload.get("inputs"), dict) else {}
159
+ missing = _missingPlaybookInputs(playbook, inputs)
160
+ if missing:
161
+ return {"ok": False, "status": "needsInput", "playbook": playbook, "needsInput": missing}
162
+ baseUrl = str(payload.get("baseUrl", session.get("baseUrl", "")) or "")
163
+ targetUrls = payload.get("targetUrls", {}) if isinstance(payload.get("targetUrls"), dict) else {}
164
+ actions = []
165
+ for idx, item in enumerate(playbook.get("actions", [])):
166
+ if not isinstance(item, dict):
167
+ continue
168
+ ref = str(item.get("ref", f"action{idx + 1}") or f"action{idx + 1}")
169
+ templateId = str(item.get("templateId", "") or "")
170
+ targetUrl = str(targetUrls.get(ref, targetUrls.get(templateId, "")) or "")
171
+ if not targetUrl and baseUrl:
172
+ targetUrl = urljoin(baseUrl.rstrip("/") + "/", str(item.get("targetPath", "") or "").lstrip("/"))
173
+ actionInputs = _mappedInputs(item.get("inputMap", {}), inputs)
174
+ actions.append(
175
+ {
176
+ "portalSessionId": str(payload.get("portalSessionId", session.get("portalSessionId", payload.get("sessionId", ""))) or ""),
177
+ "templateId": templateId,
178
+ "caseId": str(payload.get("caseId", session.get("caseId", "")) or ""),
179
+ "conversationId": str(payload.get("conversationId", session.get("conversationId", "")) or ""),
180
+ "connectorId": str(payload.get("connectorId", session.get("connectorId", "")) or ""),
181
+ "provider": str(payload.get("provider", session.get("provider", "")) or ""),
182
+ "targetUrl": targetUrl,
183
+ "inputs": actionInputs,
184
+ "meta": {
185
+ "playbookId": playbookId,
186
+ "playbookActionRef": ref,
187
+ "playbookActionIndex": idx,
188
+ "playbookActionLabel": str(item.get("label", "") or ""),
189
+ "targetPath": str(item.get("targetPath", "") or ""),
190
+ },
191
+ }
192
+ )
193
+ return {"ok": True, "playbook": playbook, "actions": actions, "count": len(actions)}
194
+
195
+
196
+ def applyPortalActionTemplate(payload: dict[str, Any], *, session: dict[str, Any] | None = None) -> dict[str, Any]:
197
+ templateId = str(payload.get("templateId", "") or "")
198
+ if not templateId:
199
+ return {"ok": True, "action": copy.deepcopy(payload), "template": {}}
200
+ found = getPortalActionTemplate(templateId)
201
+ if not found.get("ok"):
202
+ return found
203
+ template = found["template"]
204
+ session = session or {}
205
+ currentMeta = payload.get("meta", {}) if isinstance(payload.get("meta"), dict) else {}
206
+ targetUrl = str(payload.get("targetUrl", payload.get("url", "")) or "")
207
+ baseUrl = str(session.get("baseUrl", "") or "")
208
+ if not targetUrl and baseUrl:
209
+ targetUrl = urljoin(baseUrl.rstrip("/") + "/", str(template.get("targetPath", "") or "").lstrip("/"))
210
+ action = {
211
+ **payload,
212
+ "templateId": templateId,
213
+ "kind": str(payload.get("kind", "") or template.get("kind", "submitForm")),
214
+ "targetUrl": targetUrl,
215
+ "steps": payload.get("steps") if isinstance(payload.get("steps"), list) and payload.get("steps") else copy.deepcopy(template.get("steps", [])),
216
+ "meta": {
217
+ **currentMeta,
218
+ "templateId": templateId,
219
+ "templateName": template.get("name", ""),
220
+ "requiredInputs": template.get("requiredInputs", []),
221
+ "proofRequirements": template.get("proofRequirements", []),
222
+ },
223
+ }
224
+ return {"ok": True, "action": action, "template": template}
225
+
226
+
227
+ def _templateRecord(template: dict[str, Any]) -> dict[str, Any]:
228
+ return {
229
+ "templateId": str(template.get("templateId", "") or ""),
230
+ "name": str(template.get("name", "") or ""),
231
+ "kind": str(template.get("kind", "") or ""),
232
+ "description": str(template.get("description", "") or ""),
233
+ "targetPath": str(template.get("targetPath", "") or ""),
234
+ "requiredInputs": [str(item) for item in template.get("requiredInputs", []) if str(item)],
235
+ "steps": [copy.deepcopy(item) for item in template.get("steps", []) if isinstance(item, dict)],
236
+ "proofRequirements": [str(item) for item in template.get("proofRequirements", []) if str(item)],
237
+ }
238
+
239
+
240
+ def _playbookRecord(playbook: dict[str, Any]) -> dict[str, Any]:
241
+ return {
242
+ "playbookId": str(playbook.get("playbookId", "") or ""),
243
+ "name": str(playbook.get("name", "") or ""),
244
+ "description": str(playbook.get("description", "") or ""),
245
+ "providerTypes": [str(item) for item in playbook.get("providerTypes", []) if str(item)],
246
+ "requiredInputs": [str(item) for item in playbook.get("requiredInputs", []) if str(item)],
247
+ "optionalInputs": [str(item) for item in playbook.get("optionalInputs", []) if str(item)],
248
+ "actions": [copy.deepcopy(item) for item in playbook.get("actions", []) if isinstance(item, dict)],
249
+ "proofRequirements": [str(item) for item in playbook.get("proofRequirements", []) if str(item)],
250
+ }
251
+
252
+
253
+ def _missingPlaybookInputs(playbook: dict[str, Any], inputs: dict[str, Any]) -> list[dict[str, Any]]:
254
+ missing = []
255
+ for key in playbook.get("requiredInputs", []):
256
+ clean = str(key or "")
257
+ value = inputs.get(clean)
258
+ if clean and (clean not in inputs or value is None or value == "" or value == []):
259
+ missing.append({"key": clean, "label": clean, "secret": False})
260
+ return missing
261
+
262
+
263
+ def _mappedInputs(inputMap: Any, inputs: dict[str, Any]) -> dict[str, Any]:
264
+ if not isinstance(inputMap, dict):
265
+ return dict(inputs)
266
+ mapped = {}
267
+ for targetKey, sourceKey in inputMap.items():
268
+ target = str(targetKey or "")
269
+ source = str(sourceKey or "")
270
+ if target and source in inputs:
271
+ mapped[target] = inputs[source]
272
+ return mapped
@@ -0,0 +1 @@
1
+ """Picux protocol transports and machine-readable envelopes."""
@@ -0,0 +1,6 @@
1
+ """A2A protocol transport."""
2
+
3
+ from .envelope import A2AEnvelope, a2aContract, createEnvelope, validateEnvelope
4
+ from .client import AgentMeshClient
5
+
6
+ __all__ = ["A2AEnvelope", "AgentMeshClient", "a2aContract", "createEnvelope", "validateEnvelope"]
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib import request
7
+
8
+ from picux.config import resolve_env
9
+ from picux.protocols.a2a.envelope import createEnvelope
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AgentMeshClient:
14
+ """Minimal A2A delegation client using Picux env names with legacy fallback."""
15
+
16
+ base_url: str = ""
17
+ token: str = ""
18
+ timeout_seconds: int = 20
19
+
20
+ @classmethod
21
+ def fromEnv(cls) -> "AgentMeshClient":
22
+ return cls(
23
+ base_url=resolve_env("PICUX_A2A_BASE_URL").value.strip().rstrip("/"),
24
+ token=resolve_env("PICUX_A2A_TOKEN").value.strip(),
25
+ )
26
+
27
+ @property
28
+ def enabled(self) -> bool:
29
+ return bool(self.base_url)
30
+
31
+ def delegate(self, *, target_agent: str, payload: dict[str, Any]) -> dict[str, Any]:
32
+ if not self.enabled:
33
+ raise RuntimeError("a2a_client_disabled")
34
+ envelope = createEnvelope({**payload, "toAgent": target_agent})
35
+ if not envelope["ok"]:
36
+ raise RuntimeError(f"a2a_invalid_envelope:{','.join(envelope['errors'])}")
37
+ headers = {"Content-Type": "application/json"}
38
+ if self.token:
39
+ headers["Authorization"] = f"Bearer {self.token}"
40
+ url = f"{self.base_url}/agents/{target_agent}/invoke"
41
+ req = request.Request(
42
+ url,
43
+ data=json.dumps({"envelope": envelope["envelope"], "payload": payload}, ensure_ascii=True).encode("utf-8"),
44
+ headers=headers,
45
+ method="POST",
46
+ )
47
+ with request.urlopen(req, timeout=max(5, self.timeout_seconds)) as response:
48
+ body = json.loads(response.read().decode("utf-8"))
49
+ if not isinstance(body, dict):
50
+ raise RuntimeError("a2a_invalid_response")
51
+ return body
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from picux import __version__
11
+
12
+
13
+ Clock = Callable[[], datetime | str]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class A2AEnvelope:
18
+ msgId: str
19
+ kind: str
20
+ fromAgent: str
21
+ toAgent: str
22
+ task: dict[str, Any]
23
+ caps: tuple[str, ...] = ()
24
+ payload: dict[str, Any] = field(default_factory=dict)
25
+ traceId: str = ""
26
+ createdAt: str = ""
27
+
28
+ @classmethod
29
+ def fromPayload(cls, payload: dict[str, Any], *, clock: Clock | None = None) -> "A2AEnvelope":
30
+ now = _time(clock or (lambda: datetime.now(timezone.utc)))
31
+ task = payload.get("task") if isinstance(payload.get("task"), dict) else {}
32
+ body = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
33
+ fromAgent = str(payload.get("fromAgent", "picux") or "picux")
34
+ toAgent = str(payload.get("toAgent", payload.get("agentId", payload.get("targetAgent", ""))) or "")
35
+ kind = str(payload.get("kind", "delegate") or "delegate")
36
+ caps = tuple(_clean(payload.get("caps", payload.get("capabilities", []))))
37
+ traceId = str(payload.get("traceId", task.get("taskId", "")) or "")
38
+ base = {
39
+ "kind": kind,
40
+ "fromAgent": fromAgent,
41
+ "toAgent": toAgent,
42
+ "task": task,
43
+ "caps": list(caps),
44
+ "payload": body,
45
+ "traceId": traceId,
46
+ "createdAt": now,
47
+ }
48
+ msgId = "msg_" + hashlib.sha256(_json(base).encode("utf-8")).hexdigest()[:24]
49
+ return cls(
50
+ msgId=msgId,
51
+ kind=kind,
52
+ fromAgent=fromAgent,
53
+ toAgent=toAgent,
54
+ task=task,
55
+ caps=caps,
56
+ payload=body,
57
+ traceId=traceId,
58
+ createdAt=now,
59
+ )
60
+
61
+ def toMap(self) -> dict[str, Any]:
62
+ return {
63
+ "msgId": self.msgId,
64
+ "kind": self.kind,
65
+ "fromAgent": self.fromAgent,
66
+ "toAgent": self.toAgent,
67
+ "task": self.task,
68
+ "caps": list(self.caps),
69
+ "payload": self.payload,
70
+ "traceId": self.traceId,
71
+ "createdAt": self.createdAt,
72
+ }
73
+
74
+
75
+ def createEnvelope(payload: dict[str, Any], *, clock: Clock | None = None) -> dict[str, Any]:
76
+ envelope = A2AEnvelope.fromPayload(payload, clock=clock).toMap()
77
+ errors = validateEnvelope(envelope)
78
+ return {"ok": not errors, "errors": errors, "envelope": envelope}
79
+
80
+
81
+ def validateEnvelope(envelope: dict[str, Any]) -> list[str]:
82
+ errors: list[str] = []
83
+ if not str(envelope.get("msgId", "") or "").startswith("msg_"):
84
+ errors.append("invalid:msgId")
85
+ for key in ("kind", "fromAgent", "toAgent", "createdAt"):
86
+ if not str(envelope.get(key, "") or ""):
87
+ errors.append(f"missing:{key}")
88
+ if not isinstance(envelope.get("task"), dict):
89
+ errors.append("invalid:task")
90
+ if not isinstance(envelope.get("payload"), dict):
91
+ errors.append("invalid:payload")
92
+ return errors
93
+
94
+
95
+ def a2aContract() -> dict[str, Any]:
96
+ return {
97
+ "ok": True,
98
+ "protocol": "picux-a2a",
99
+ "version": __version__,
100
+ "transport": "external",
101
+ "envelope": {
102
+ "required": ["msgId", "kind", "fromAgent", "toAgent", "task", "createdAt"],
103
+ "optional": ["caps", "payload", "traceId"],
104
+ "schema": "schemas/protocols/a2a-envelope.schema.json",
105
+ },
106
+ "endpoints": {
107
+ "contract": "/v1/a2a/contract",
108
+ "createEnvelope": "/v1/a2a/envelopes",
109
+ "listEnvelopes": "/v1/a2a/envelopes",
110
+ "getEnvelope": "/v1/a2a/envelopes/{msgId}",
111
+ "updateEnvelope": "/v1/a2a/envelopes/{msgId}/update",
112
+ },
113
+ }
114
+
115
+
116
+ def _clean(value: Any) -> list[str]:
117
+ if isinstance(value, str):
118
+ value = [value]
119
+ if not isinstance(value, (list, tuple, set)):
120
+ return []
121
+ return [str(item).strip().lower() for item in value if str(item).strip()]
122
+
123
+
124
+ def _time(clock: Clock) -> str:
125
+ value = clock()
126
+ if isinstance(value, datetime):
127
+ return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
128
+ return str(value)
129
+
130
+
131
+ def _json(payload: dict[str, Any]) -> str:
132
+ return json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
@@ -0,0 +1,7 @@
1
+ """MCP protocol transport."""
2
+
3
+ from .client import MCPClient
4
+ from .contract import PicuxMCPContract
5
+ from .server import PicuxMCPServer
6
+
7
+ __all__ = ["MCPClient", "PicuxMCPContract", "PicuxMCPServer"]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+ from urllib import request
8
+
9
+ from picux.config import resolve_env
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class MCPClient:
14
+ """Minimal MCP JSON-RPC client using Picux env names with legacy fallback."""
15
+
16
+ base_url: str = ""
17
+ token: str = ""
18
+ timeout_seconds: int = 20
19
+
20
+ @classmethod
21
+ def fromEnv(cls) -> "MCPClient":
22
+ return cls(
23
+ base_url=resolve_env("PICUX_MCP_BASE_URL").value.strip().rstrip("/"),
24
+ token=resolve_env("PICUX_MCP_TOKEN").value.strip(),
25
+ )
26
+
27
+ @property
28
+ def enabled(self) -> bool:
29
+ return bool(self.base_url)
30
+
31
+ def endpoint(self) -> str:
32
+ if not self.base_url:
33
+ raise RuntimeError("mcp_client_disabled")
34
+ if self.base_url.rstrip("/").endswith("/mcp"):
35
+ return self.base_url.rstrip("/")
36
+ return f"{self.base_url.rstrip('/')}/mcp"
37
+
38
+ def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
39
+ payload = {
40
+ "jsonrpc": "2.0",
41
+ "id": f"picux-mcp-{int(time.time() * 1000)}",
42
+ "method": method,
43
+ "params": params or {},
44
+ }
45
+ headers = {"Content-Type": "application/json"}
46
+ if self.token:
47
+ headers["Authorization"] = f"Bearer {self.token}"
48
+ req = request.Request(
49
+ self.endpoint(),
50
+ data=json.dumps(payload, ensure_ascii=True).encode("utf-8"),
51
+ headers=headers,
52
+ method="POST",
53
+ )
54
+ with request.urlopen(req, timeout=max(5, self.timeout_seconds)) as response:
55
+ body = json.loads(response.read().decode("utf-8"))
56
+ if not isinstance(body, dict):
57
+ raise RuntimeError("mcp_invalid_response")
58
+ if "error" in body:
59
+ raise RuntimeError(f"mcp_error:{body['error']}")
60
+ result = body.get("result")
61
+ if not isinstance(result, dict):
62
+ raise RuntimeError("mcp_invalid_result")
63
+ return result
64
+
65
+ def listTools(self) -> dict[str, Any]:
66
+ return self.call("tools/list")
67
+
68
+ def callTool(self, name: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
69
+ return self.call("tools/call", {"name": name, "arguments": args or {}})
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from picux.api import PicuxApiService
6
+ from picux.contracts import CONTRACT_ENDPOINTS, mcpTools
7
+
8
+
9
+ class PicuxMCPContract:
10
+ """MCP-facing contract for external apps, services, and agents."""
11
+
12
+ def __init__(self, *, service: PicuxApiService | None = None) -> None:
13
+ self.service = service or PicuxApiService()
14
+ self._tools = {endpoint.mcpTool: endpoint for endpoint in CONTRACT_ENDPOINTS if endpoint.mcpTool}
15
+
16
+ def listTools(self) -> dict[str, Any]:
17
+ return {"tools": mcpTools()}
18
+
19
+ def callTool(self, name: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
20
+ if name not in self._tools:
21
+ return {"ok": False, "error": "toolNotFound", "tool": name}
22
+ args = args or {}
23
+ endpoint = self._tools[name]
24
+ path = self._path(endpoint.path, args)
25
+ status, payload = self.service.handle(endpoint.method, path, args, internal=True)
26
+ if status >= 400:
27
+ return {"ok": False, "status": status, "payload": payload}
28
+ return {"ok": True, "status": status, "payload": payload}
29
+
30
+ @staticmethod
31
+ def _path(template: str, args: dict[str, Any]) -> str:
32
+ path = template
33
+ for key in (
34
+ "taskId",
35
+ "mandateId",
36
+ "receiptId",
37
+ "artifactId",
38
+ "povId",
39
+ "packId",
40
+ "agentId",
41
+ "connectorId",
42
+ "proxyId",
43
+ "escrowId",
44
+ "railId",
45
+ "challengeId",
46
+ "resourceId",
47
+ "adapterId",
48
+ "cardId",
49
+ "schemaId",
50
+ "msgId",
51
+ "signalId",
52
+ "eventId",
53
+ "subId",
54
+ "deliveryId",
55
+ "templateId",
56
+ "playbookId",
57
+ "sessionId",
58
+ "threadId",
59
+ "touchpointId",
60
+ "actionId",
61
+ "caseId",
62
+ "portalSessionId",
63
+ "portalActionId",
64
+ ):
65
+ if "{" + key + "}" in path:
66
+ path = path.replace("{" + key + "}", str(args.get(key, "")))
67
+ return path