locus-sdk 0.2.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 (217) hide show
  1. locus/__init__.py +241 -0
  2. locus/_oracle_pool_cache.py +196 -0
  3. locus/_sync.py +76 -0
  4. locus/a2a/__init__.py +143 -0
  5. locus/a2a/protocol.py +1310 -0
  6. locus/a2a/protocol_v1.py +556 -0
  7. locus/a2a/spec.py +420 -0
  8. locus/a2a/spec_v1.py +279 -0
  9. locus/agent/__init__.py +37 -0
  10. locus/agent/agent.py +604 -0
  11. locus/agent/composition.py +352 -0
  12. locus/agent/config.py +607 -0
  13. locus/agent/hook_orchestrator.py +167 -0
  14. locus/agent/initializer.py +250 -0
  15. locus/agent/result.py +339 -0
  16. locus/agent/runtime_loop.py +2037 -0
  17. locus/core/__init__.py +178 -0
  18. locus/core/command.py +260 -0
  19. locus/core/config.py +176 -0
  20. locus/core/errors.py +207 -0
  21. locus/core/events.py +261 -0
  22. locus/core/interrupt.py +404 -0
  23. locus/core/messages.py +151 -0
  24. locus/core/protocols.py +348 -0
  25. locus/core/reducers.py +466 -0
  26. locus/core/send.py +325 -0
  27. locus/core/state.py +403 -0
  28. locus/core/structured.py +536 -0
  29. locus/core/termination.py +237 -0
  30. locus/core/warnings.py +41 -0
  31. locus/deepagent/__init__.py +137 -0
  32. locus/deepagent/backends/__init__.py +37 -0
  33. locus/deepagent/backends/filesystem.py +243 -0
  34. locus/deepagent/backends/protocol.py +123 -0
  35. locus/deepagent/backends/state.py +225 -0
  36. locus/deepagent/factory.py +400 -0
  37. locus/deepagent/memory.py +48 -0
  38. locus/deepagent/protocol.py +127 -0
  39. locus/deepagent/subagent.py +153 -0
  40. locus/deepagent/todos.py +122 -0
  41. locus/deepagent/tools/__init__.py +16 -0
  42. locus/deepagent/tools/filesystem.py +160 -0
  43. locus/deepagent/workflow.py +665 -0
  44. locus/evaluation/__init__.py +26 -0
  45. locus/evaluation/framework.py +226 -0
  46. locus/hooks/__init__.py +65 -0
  47. locus/hooks/builtin/__init__.py +64 -0
  48. locus/hooks/builtin/guardrails.py +765 -0
  49. locus/hooks/builtin/logging.py +279 -0
  50. locus/hooks/builtin/retry.py +130 -0
  51. locus/hooks/builtin/steering.py +248 -0
  52. locus/hooks/builtin/telemetry.py +429 -0
  53. locus/hooks/events.py +86 -0
  54. locus/hooks/plugin.py +137 -0
  55. locus/hooks/provider.py +405 -0
  56. locus/hooks/registry.py +398 -0
  57. locus/integrations/__init__.py +18 -0
  58. locus/integrations/fastmcp.py +723 -0
  59. locus/integrations/osv.py +194 -0
  60. locus/loop/__init__.py +54 -0
  61. locus/loop/nodes.py +363 -0
  62. locus/loop/react.py +279 -0
  63. locus/loop/router.py +241 -0
  64. locus/loop/runner.py +307 -0
  65. locus/memory/__init__.py +115 -0
  66. locus/memory/backends/__init__.py +87 -0
  67. locus/memory/backends/_oracle_config.py +89 -0
  68. locus/memory/backends/adapters.py +704 -0
  69. locus/memory/backends/file.py +288 -0
  70. locus/memory/backends/http.py +301 -0
  71. locus/memory/backends/memory.py +247 -0
  72. locus/memory/backends/mysql.py +646 -0
  73. locus/memory/backends/oci_bucket.py +607 -0
  74. locus/memory/backends/opensearch.py +301 -0
  75. locus/memory/backends/oracle.py +617 -0
  76. locus/memory/backends/oracle_sync.py +271 -0
  77. locus/memory/backends/oracle_versioned.py +792 -0
  78. locus/memory/backends/postgresql.py +454 -0
  79. locus/memory/backends/redis.py +129 -0
  80. locus/memory/checkpointer.py +335 -0
  81. locus/memory/compactor.py +329 -0
  82. locus/memory/conversation.py +325 -0
  83. locus/memory/delta.py +546 -0
  84. locus/memory/manager.py +584 -0
  85. locus/memory/managers/__init__.py +47 -0
  86. locus/memory/managers/oracle_agent_memory.py +468 -0
  87. locus/memory/registry.py +221 -0
  88. locus/memory/store.py +935 -0
  89. locus/memory/store_backends/__init__.py +42 -0
  90. locus/memory/store_backends/oracle.py +817 -0
  91. locus/memory/store_backends/oracle_sync.py +167 -0
  92. locus/models/__init__.py +103 -0
  93. locus/models/auxiliary.py +56 -0
  94. locus/models/base.py +121 -0
  95. locus/models/caching.py +85 -0
  96. locus/models/credentials.py +212 -0
  97. locus/models/failover.py +787 -0
  98. locus/models/metadata.py +357 -0
  99. locus/models/native/__init__.py +22 -0
  100. locus/models/native/anthropic.py +418 -0
  101. locus/models/native/ollama.py +222 -0
  102. locus/models/native/openai.py +487 -0
  103. locus/models/pooled.py +212 -0
  104. locus/models/providers/__init__.py +18 -0
  105. locus/models/providers/oci/__init__.py +437 -0
  106. locus/models/providers/oci/_responses_parse.py +295 -0
  107. locus/models/providers/oci/_signing.py +172 -0
  108. locus/models/providers/oci/base.py +130 -0
  109. locus/models/providers/oci/client.py +294 -0
  110. locus/models/providers/oci/models/__init__.py +14 -0
  111. locus/models/providers/oci/models/cohere.py +263 -0
  112. locus/models/providers/oci/models/generic.py +315 -0
  113. locus/models/providers/oci/openai_compat.py +539 -0
  114. locus/models/providers/oci/responses.py +522 -0
  115. locus/models/rate_limits.py +237 -0
  116. locus/models/registry.py +154 -0
  117. locus/multiagent/__init__.py +134 -0
  118. locus/multiagent/functional.py +234 -0
  119. locus/multiagent/graph.py +1671 -0
  120. locus/multiagent/handoff.py +637 -0
  121. locus/multiagent/orchestrator.py +467 -0
  122. locus/multiagent/specialist.py +495 -0
  123. locus/multiagent/swarm.py +701 -0
  124. locus/multiagent/visualize.py +139 -0
  125. locus/observability/__init__.py +67 -0
  126. locus/observability/agent_bridge.py +171 -0
  127. locus/observability/bus_hook.py +175 -0
  128. locus/observability/context.py +137 -0
  129. locus/observability/emit.py +216 -0
  130. locus/observability/event_bus.py +369 -0
  131. locus/observability/router_events.py +231 -0
  132. locus/playbooks/__init__.py +45 -0
  133. locus/playbooks/enforcer.py +403 -0
  134. locus/playbooks/hook.py +161 -0
  135. locus/playbooks/loader.py +271 -0
  136. locus/playbooks/models.py +202 -0
  137. locus/providers/__init__.py +63 -0
  138. locus/providers/image.py +115 -0
  139. locus/providers/speech.py +171 -0
  140. locus/providers/tools.py +217 -0
  141. locus/providers/types.py +42 -0
  142. locus/providers/web_fetch.py +194 -0
  143. locus/providers/web_search.py +129 -0
  144. locus/py.typed +0 -0
  145. locus/rag/__init__.py +180 -0
  146. locus/rag/chunkers/__init__.py +23 -0
  147. locus/rag/chunkers/oracle_indb.py +277 -0
  148. locus/rag/chunkers/oracle_sync.py +97 -0
  149. locus/rag/embeddings/__init__.py +60 -0
  150. locus/rag/embeddings/base.py +219 -0
  151. locus/rag/embeddings/oci.py +441 -0
  152. locus/rag/embeddings/openai.py +207 -0
  153. locus/rag/embeddings/oracle_indb.py +511 -0
  154. locus/rag/embeddings/oracle_sync.py +97 -0
  155. locus/rag/loaders/__init__.py +17 -0
  156. locus/rag/loaders/oracle.py +358 -0
  157. locus/rag/loaders/oracle_sync.py +96 -0
  158. locus/rag/multimodal.py +586 -0
  159. locus/rag/reranker/__init__.py +28 -0
  160. locus/rag/reranker/base.py +57 -0
  161. locus/rag/reranker/cohere_oci.py +225 -0
  162. locus/rag/retriever.py +603 -0
  163. locus/rag/stores/__init__.py +69 -0
  164. locus/rag/stores/_mmr.py +122 -0
  165. locus/rag/stores/_oracle_filter.py +178 -0
  166. locus/rag/stores/base.py +266 -0
  167. locus/rag/stores/memory.py +155 -0
  168. locus/rag/stores/opensearch.py +396 -0
  169. locus/rag/stores/oracle.py +1088 -0
  170. locus/rag/stores/oracle_sync.py +154 -0
  171. locus/rag/stores/pgvector.py +608 -0
  172. locus/rag/tools.py +265 -0
  173. locus/reasoning/__init__.py +79 -0
  174. locus/reasoning/causal.py +727 -0
  175. locus/reasoning/grounding.py +677 -0
  176. locus/reasoning/gsar.py +392 -0
  177. locus/reasoning/gsar_evaluator.py +262 -0
  178. locus/reasoning/gsar_judge.py +336 -0
  179. locus/reasoning/reflexion.py +515 -0
  180. locus/router/__init__.py +115 -0
  181. locus/router/capability.py +133 -0
  182. locus/router/compiler.py +246 -0
  183. locus/router/goal_frame.py +145 -0
  184. locus/router/picker.py +201 -0
  185. locus/router/policy.py +103 -0
  186. locus/router/protocol.py +669 -0
  187. locus/router/runnable.py +267 -0
  188. locus/router/runtime.py +129 -0
  189. locus/router/skill_index.py +80 -0
  190. locus/server/__init__.py +44 -0
  191. locus/server/adapters.py +178 -0
  192. locus/server/app.py +456 -0
  193. locus/skills/__init__.py +37 -0
  194. locus/skills/models.py +255 -0
  195. locus/skills/plugin.py +207 -0
  196. locus/streaming/__init__.py +49 -0
  197. locus/streaming/console.py +373 -0
  198. locus/streaming/handler.py +284 -0
  199. locus/streaming/sse.py +443 -0
  200. locus/streaming/structured.py +144 -0
  201. locus/tools/__init__.py +27 -0
  202. locus/tools/builtins.py +58 -0
  203. locus/tools/context.py +69 -0
  204. locus/tools/decorator.py +197 -0
  205. locus/tools/executor.py +543 -0
  206. locus/tools/oci.py +642 -0
  207. locus/tools/path_safety.py +99 -0
  208. locus/tools/registry.py +87 -0
  209. locus/tools/result_storage.py +183 -0
  210. locus/tools/schema.py +206 -0
  211. locus/tools/url_safety.py +193 -0
  212. locus/tools/watcher.py +304 -0
  213. locus_sdk-0.2.0.dist-info/METADATA +640 -0
  214. locus_sdk-0.2.0.dist-info/RECORD +217 -0
  215. locus_sdk-0.2.0.dist-info/WHEEL +4 -0
  216. locus_sdk-0.2.0.dist-info/licenses/LICENSE +17 -0
  217. locus_sdk-0.2.0.dist-info/licenses/LICENSE.txt +17 -0
locus/a2a/protocol.py ADDED
@@ -0,0 +1,1310 @@
1
+ # Copyright (c) 2025, 2026 Oracle and/or its affiliates.
2
+ # Licensed under the Universal Permissive License v1.0 as shown at
3
+ # https://oss.oracle.com/licenses/upl/
4
+
5
+ """A2A protocol implementation — spec-compliant agent-to-agent transport.
6
+
7
+ This module exposes a Locus Agent over the public A2A protocol
8
+ (https://a2aproject.github.io/A2A/), so peers from other frameworks
9
+ (Strands, ADK, Google A2A SDKs) can call the agent without an adapter.
10
+
11
+ Wire surface served by :class:`A2AServer`:
12
+
13
+ - ``GET /.well-known/agent-card.json`` — public Agent Card (spec §5.5).
14
+ - ``POST /`` — JSON-RPC 2.0 method dispatch
15
+ with A2A v1.0 methods when ``A2A-Version: 1.0`` is requested.
16
+ - Backwards-compat aliases preserved from the pre-spec implementation:
17
+ ``message/send``, ``message/stream``, ``tasks/get``, ``tasks/cancel``,
18
+ ``GET /agent-card``, ``POST /a2a/invoke``, ``POST /a2a/stream``.
19
+
20
+ Security model
21
+ --------------
22
+ Every route — including the well-known card — requires a bearer
23
+ token when ``api_key`` / ``LOCUS_A2A_API_KEY`` is set. With no key,
24
+ the server refuses to bind to anything other than loopback unless
25
+ ``allow_unauthenticated=True`` is passed explicitly. The agent's tool
26
+ inventory is exposed only via skills that the operator declared at
27
+ construction time, never via tool reflection (CWE-306).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import hmac
34
+ import ipaddress
35
+ import json
36
+ import logging
37
+ import os
38
+ import time
39
+ import uuid
40
+ from collections.abc import AsyncIterator
41
+ from typing import TYPE_CHECKING, Any
42
+
43
+
44
+ if TYPE_CHECKING:
45
+ import httpx
46
+
47
+ from pydantic import BaseModel, Field
48
+
49
+ from locus.a2a.protocol_v1 import (
50
+ A2A_V1_PROTOCOL_VERSION,
51
+ A2A_VERSION_HEADER,
52
+ A2AV1_JSONRPC_METHODS,
53
+ A2AV1ProtocolError,
54
+ A2AV1ServerMixin,
55
+ legacy_message_to_v1,
56
+ task_result_to_legacy_payload,
57
+ v1_stream_response_to_legacy_payload,
58
+ )
59
+ from locus.a2a.spec import (
60
+ CONTENT_TYPE_NOT_SUPPORTED,
61
+ INTERNAL_ERROR,
62
+ INVALID_PARAMS,
63
+ INVALID_REQUEST,
64
+ METHOD_NOT_FOUND,
65
+ PUSH_NOTIFICATION_NOT_SUPPORTED,
66
+ TASK_NOT_CANCELABLE,
67
+ TASK_NOT_FOUND,
68
+ UNSUPPORTED_OPERATION,
69
+ VERSION_NOT_SUPPORTED,
70
+ AgentCapabilities,
71
+ AgentCard,
72
+ AgentInterface,
73
+ AgentProvider,
74
+ AgentSkill,
75
+ Artifact,
76
+ DataPart,
77
+ FilePart,
78
+ JsonRpcError,
79
+ JsonRpcErrorResponse,
80
+ JsonRpcRequest,
81
+ JsonRpcSuccessResponse,
82
+ Message,
83
+ MessageSendParams,
84
+ Part,
85
+ Task,
86
+ TaskArtifactUpdateEvent,
87
+ TaskIdParams,
88
+ TaskQueryParams,
89
+ TaskState,
90
+ TaskStatus,
91
+ TaskStatusUpdateEvent,
92
+ TextPart,
93
+ )
94
+
95
+
96
+ _logger = logging.getLogger(__name__)
97
+
98
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
99
+
100
+
101
+ def _is_loopback(host: str) -> bool:
102
+ if host in _LOOPBACK_HOSTS:
103
+ return True
104
+ try:
105
+ return ipaddress.ip_address(host).is_loopback
106
+ except ValueError:
107
+ return False
108
+
109
+
110
+ def _now_iso() -> str:
111
+ """Spec timestamps use RFC 3339 / ISO-8601 with a ``Z`` suffix."""
112
+ from datetime import UTC, datetime
113
+
114
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Backward-compat models from the pre-spec implementation.
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ class A2AMessage(BaseModel):
123
+ """Legacy flat message — preserved so peers + tests that still call
124
+ ``/a2a/invoke`` keep working. Spec-aware peers should use
125
+ :class:`locus.a2a.spec.Message`."""
126
+
127
+ role: str
128
+ content: str
129
+ metadata: dict[str, Any] = Field(default_factory=dict)
130
+
131
+
132
+ class A2ARequest(BaseModel):
133
+ """Legacy request envelope for ``POST /a2a/invoke``."""
134
+
135
+ messages: list[A2AMessage]
136
+ metadata: dict[str, Any] = Field(default_factory=dict)
137
+
138
+
139
+ class A2AResponse(BaseModel):
140
+ """Legacy response envelope from ``POST /a2a/invoke``."""
141
+
142
+ messages: list[A2AMessage]
143
+ status: str = "completed"
144
+ metadata: dict[str, Any] = Field(default_factory=dict)
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Helpers — Locus Agent ↔ A2A spec types
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ def _extract_user_text(parts: list[Part]) -> str:
153
+ """Concatenate text parts into a single prompt string.
154
+
155
+ File parts are reported as ``[file: name]`` and data parts are
156
+ serialised inline — the conservative default for an Agent that
157
+ speaks Python/text. Specialised agents can override the server's
158
+ ``_run_agent`` hook to handle parts directly.
159
+ """
160
+ chunks: list[str] = []
161
+ for part in parts:
162
+ if isinstance(part, TextPart):
163
+ chunks.append(part.text)
164
+ elif isinstance(part, FilePart):
165
+ file = part.file
166
+ label = getattr(file, "name", None) or getattr(file, "uri", "file")
167
+ chunks.append(f"[file: {label}]")
168
+ elif isinstance(part, DataPart):
169
+ chunks.append(json.dumps(part.data, ensure_ascii=False))
170
+ return "\n".join(chunks)
171
+
172
+
173
+ def _agent_text_message(text: str, *, context_id: str, task_id: str | None) -> Message:
174
+ """Build a spec ``Message`` from an agent's plain-text reply."""
175
+ return Message(
176
+ role="agent",
177
+ parts=[TextPart(text=text)],
178
+ messageId=uuid.uuid4().hex,
179
+ contextId=context_id,
180
+ taskId=task_id,
181
+ )
182
+
183
+
184
+ def _agent_artifact(text: str) -> Artifact:
185
+ """Wrap an agent's final reply in an Artifact for ``Task.artifacts``."""
186
+ return Artifact(
187
+ artifactId=uuid.uuid4().hex,
188
+ name="reply",
189
+ parts=[TextPart(text=text)],
190
+ )
191
+
192
+
193
+ _A2A_ERROR_REASONS = {
194
+ TASK_NOT_FOUND: "TASK_NOT_FOUND",
195
+ TASK_NOT_CANCELABLE: "TASK_NOT_CANCELABLE",
196
+ PUSH_NOTIFICATION_NOT_SUPPORTED: "PUSH_NOTIFICATION_NOT_SUPPORTED",
197
+ UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
198
+ CONTENT_TYPE_NOT_SUPPORTED: "CONTENT_TYPE_NOT_SUPPORTED",
199
+ VERSION_NOT_SUPPORTED: "VERSION_NOT_SUPPORTED",
200
+ }
201
+
202
+
203
+ def _jsonrpc_error_data(code: int, message: str) -> list[dict[str, Any]] | None:
204
+ reason = _A2A_ERROR_REASONS.get(code)
205
+ if reason is None:
206
+ return None
207
+ return [
208
+ {
209
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
210
+ "reason": reason,
211
+ "domain": "a2a-protocol.org",
212
+ "metadata": {
213
+ "message": message,
214
+ "timestamp": _now_iso(),
215
+ },
216
+ }
217
+ ]
218
+
219
+
220
+ def _jsonrpc_error_response(rpc_id: str | int | None, code: int, message: str) -> dict[str, Any]:
221
+ error = JsonRpcError(
222
+ code=code,
223
+ message=message,
224
+ data=_jsonrpc_error_data(code, message),
225
+ )
226
+ return JsonRpcErrorResponse(id=rpc_id, error=error).model_dump(exclude_none=True)
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # In-memory task store
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ class _TaskStore:
235
+ """In-process task store for non-persistent A2A deployments.
236
+
237
+ Production deployments behind a load balancer should swap this out
238
+ for a shared store (Redis / SQL / OCI Object Storage) — the
239
+ ``A2AServer`` accepts a ``store`` parameter that follows this
240
+ duck-typed protocol: ``get``, ``put``, ``cancel``.
241
+ """
242
+
243
+ def __init__(self) -> None:
244
+ self._tasks: dict[str, Task] = {}
245
+ self._cancel_flags: dict[str, bool] = {}
246
+
247
+ def put(self, task: Task) -> None:
248
+ self._tasks[task.id] = task
249
+
250
+ def get(self, task_id: str) -> Task | None:
251
+ return self._tasks.get(task_id)
252
+
253
+ def list(self) -> list[Task]:
254
+ return list(self._tasks.values())
255
+
256
+ def cancel(self, task_id: str) -> bool:
257
+ """Mark a task as cancel-requested.
258
+
259
+ Returns ``True`` if the task exists and was in a cancellable
260
+ state, ``False`` otherwise (caller maps this to the proper
261
+ JSON-RPC error code).
262
+ """
263
+ task = self._tasks.get(task_id)
264
+ if task is None:
265
+ return False
266
+ if task.status.state in {
267
+ TaskState.completed,
268
+ TaskState.canceled,
269
+ TaskState.failed,
270
+ TaskState.rejected,
271
+ }:
272
+ return False
273
+ self._cancel_flags[task_id] = True
274
+ # Eagerly flip status so a subsequent tasks/get reflects the
275
+ # request even if the underlying agent hasn't observed the
276
+ # flag yet.
277
+ task.status = TaskStatus(state=TaskState.canceled, timestamp=_now_iso())
278
+ return True
279
+
280
+ def is_cancel_requested(self, task_id: str) -> bool:
281
+ return self._cancel_flags.get(task_id, False)
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Server
286
+ # ---------------------------------------------------------------------------
287
+
288
+
289
+ class A2AServer(A2AV1ServerMixin):
290
+ """Expose a Locus Agent as a spec-compliant A2A endpoint.
291
+
292
+ Args:
293
+ agent: A Locus ``Agent`` (or anything with ``run(prompt) -> AsyncIterator``).
294
+ name: Display name for the Agent Card.
295
+ description: One-line description for the Agent Card.
296
+ skills: List of :class:`AgentSkill` (preferred) or plain strings
297
+ (legacy — auto-promoted to skills with id == name).
298
+ url: Public URL the agent is reachable at — set this for
299
+ cross-process / cross-host deployments so the card's ``url``
300
+ field is correct. Defaults to a placeholder.
301
+ provider: Optional :class:`AgentProvider` (e.g. ``Oracle``).
302
+ version: Agent semver — useful for capability negotiation.
303
+ api_key: Bearer token required on every route; if ``None``,
304
+ falls back to ``LOCUS_A2A_API_KEY``.
305
+ allow_unauthenticated: Bind to non-loopback without a key.
306
+ Use only behind an upstream proxy that terminates auth.
307
+
308
+ Example::
309
+
310
+ from locus import Agent
311
+ from locus.a2a import A2AServer
312
+ from locus.a2a.spec import AgentSkill
313
+
314
+ server = A2AServer(
315
+ agent=my_agent,
316
+ name="Research Agent",
317
+ description="Open-web research with citations.",
318
+ skills=[
319
+ AgentSkill(
320
+ id="research",
321
+ name="Research",
322
+ description="Answer with cited sources.",
323
+ tags=["search", "summarise"],
324
+ ),
325
+ ],
326
+ url="https://research.example.com",
327
+ api_key="secret",
328
+ )
329
+ server.run(port=8001)
330
+ """
331
+
332
+ def __init__(
333
+ self,
334
+ agent: Any,
335
+ name: str = "Locus Agent",
336
+ description: str = "",
337
+ skills: list[AgentSkill] | list[str] | None = None,
338
+ url: str = "",
339
+ provider: AgentProvider | None = None,
340
+ version: str = "0.1.0",
341
+ api_key: str | None = None,
342
+ allow_unauthenticated: bool = False,
343
+ ) -> None:
344
+ self._agent = agent
345
+ self._name = name
346
+ self._description = description or f"A2A-compatible {name}"
347
+ self._skills = self._normalise_skills(skills)
348
+ self._url = url
349
+ self._provider = provider
350
+ self._version = version
351
+ self._api_key = api_key or os.environ.get("LOCUS_A2A_API_KEY") or None
352
+ self._allow_unauthenticated = allow_unauthenticated
353
+ self._app: Any = None
354
+ self._store = _TaskStore()
355
+
356
+ @staticmethod
357
+ def _normalise_skills(
358
+ skills: list[AgentSkill] | list[str] | None,
359
+ ) -> list[AgentSkill]:
360
+ if not skills:
361
+ return []
362
+ out: list[AgentSkill] = []
363
+ for s in skills:
364
+ if isinstance(s, AgentSkill):
365
+ out.append(s)
366
+ else:
367
+ # Legacy: a plain string becomes a minimal skill with
368
+ # id == name == description so the wire shape is valid.
369
+ out.append(AgentSkill(id=s, name=s, description=s))
370
+ return out
371
+
372
+ def _build_card(self) -> AgentCard:
373
+ # When the server enforces bearer auth on every route (the default
374
+ # when ``api_key`` / ``LOCUS_A2A_API_KEY`` is configured), advertise
375
+ # that requirement in the AgentCard so peers can discover it from
376
+ # ``/.well-known/agent-card.json`` instead of finding out via a
377
+ # 401 on the first call. See A2A spec §5.5 + §5.6 — closes #214.
378
+ security_schemes: dict[str, Any] = {}
379
+ security: list[dict[str, list[str]]] | None = None
380
+ security_requirements: list[dict[str, list[str]]] = []
381
+ if self._api_key is not None:
382
+ security_schemes = {
383
+ "bearerAuth": {
384
+ "type": "http",
385
+ "scheme": "bearer",
386
+ "description": (
387
+ "Bearer token required on every route. "
388
+ "Set via the ``api_key=`` constructor argument or "
389
+ "the ``LOCUS_A2A_API_KEY`` environment variable."
390
+ ),
391
+ }
392
+ }
393
+ security = [{"bearerAuth": []}]
394
+ security_requirements = security
395
+
396
+ skills = [
397
+ skill.model_copy(
398
+ update={
399
+ "inputModes": skill.inputModes or ["text/plain"],
400
+ "outputModes": skill.outputModes or ["text/plain"],
401
+ }
402
+ )
403
+ for skill in self._skills
404
+ ]
405
+
406
+ return AgentCard(
407
+ name=self._name,
408
+ description=self._description,
409
+ url=self._url or "http://localhost",
410
+ provider=self._provider,
411
+ version=self._version,
412
+ protocolVersion=A2A_V1_PROTOCOL_VERSION,
413
+ capabilities=AgentCapabilities(
414
+ streaming=True,
415
+ pushNotifications=False,
416
+ stateTransitionHistory=False,
417
+ ),
418
+ securitySchemes=security_schemes,
419
+ security=security,
420
+ securityRequirements=security_requirements,
421
+ supportedInterfaces=[
422
+ AgentInterface(
423
+ protocolBinding="JSONRPC",
424
+ url=(self._url or "http://localhost").rstrip("/") + "/",
425
+ protocolVersion=A2A_V1_PROTOCOL_VERSION,
426
+ )
427
+ ],
428
+ defaultInputModes=["text/plain"],
429
+ defaultOutputModes=["text/plain"],
430
+ skills=skills,
431
+ )
432
+
433
+ @property
434
+ def app(self) -> Any:
435
+ if self._app is None:
436
+ self._app = self._create_app()
437
+ return self._app
438
+
439
+ def _resolve_docs_enabled(self) -> bool:
440
+ try:
441
+ from locus.core.config import get_settings
442
+
443
+ return bool(get_settings().debug)
444
+ except Exception: # noqa: BLE001 — settings failure must not leak docs
445
+ return False
446
+
447
+ def _require_auth(self) -> Any:
448
+ from fastapi import Header, HTTPException, status
449
+
450
+ expected = self._api_key
451
+
452
+ async def dependency(
453
+ authorization: str | None = Header(default=None),
454
+ ) -> str:
455
+ if expected is None:
456
+ return "anon"
457
+ if not authorization or not authorization.lower().startswith("bearer "):
458
+ raise HTTPException(
459
+ status_code=status.HTTP_401_UNAUTHORIZED,
460
+ detail="Missing bearer token",
461
+ headers={"WWW-Authenticate": "Bearer"},
462
+ )
463
+ presented = authorization.split(" ", 1)[1].strip()
464
+ if not hmac.compare_digest(presented, expected):
465
+ raise HTTPException(
466
+ status_code=status.HTTP_401_UNAUTHORIZED,
467
+ detail="Invalid bearer token",
468
+ headers={"WWW-Authenticate": "Bearer"},
469
+ )
470
+ return "authed"
471
+
472
+ return dependency
473
+
474
+ # ----- agent driver -------------------------------------------------
475
+
476
+ async def _drive_agent(
477
+ self,
478
+ prompt: str,
479
+ *,
480
+ task: Task,
481
+ on_event: Any | None = None,
482
+ ) -> str:
483
+ """Run the wrapped agent and stream events into the task.
484
+
485
+ Calls ``on_event(TaskStatusUpdateEvent | TaskArtifactUpdateEvent)``
486
+ for each transition / final artifact when provided (the
487
+ ``message/stream`` path uses this; ``message/send`` ignores).
488
+ Returns the agent's final text reply.
489
+ """
490
+ from locus.core.events import TerminateEvent, ThinkEvent
491
+
492
+ final = ""
493
+ # Mark task as working.
494
+ task.status = TaskStatus(state=TaskState.working, timestamp=_now_iso())
495
+ if on_event is not None:
496
+ on_event(
497
+ TaskStatusUpdateEvent(
498
+ taskId=task.id,
499
+ contextId=task.contextId,
500
+ status=task.status,
501
+ )
502
+ )
503
+
504
+ async for event in self._agent.run(prompt):
505
+ if self._store.is_cancel_requested(task.id):
506
+ task.status = TaskStatus(state=TaskState.canceled, timestamp=_now_iso())
507
+ if on_event is not None:
508
+ on_event(
509
+ TaskStatusUpdateEvent(
510
+ taskId=task.id,
511
+ contextId=task.contextId,
512
+ status=task.status,
513
+ final=True,
514
+ )
515
+ )
516
+ return final
517
+ if isinstance(event, ThinkEvent) and event.reasoning and on_event is not None:
518
+ # Surface intermediate reasoning as a working-state
519
+ # status update; spec peers can ignore or render it.
520
+ on_event(
521
+ TaskStatusUpdateEvent(
522
+ taskId=task.id,
523
+ contextId=task.contextId,
524
+ status=TaskStatus(
525
+ state=TaskState.working,
526
+ message=_agent_text_message(
527
+ event.reasoning,
528
+ context_id=task.contextId,
529
+ task_id=task.id,
530
+ ),
531
+ timestamp=_now_iso(),
532
+ ),
533
+ )
534
+ )
535
+ elif isinstance(event, TerminateEvent):
536
+ final = event.final_message or final
537
+
538
+ artifact = _agent_artifact(final)
539
+ task.artifacts.append(artifact)
540
+ task.status = TaskStatus(state=TaskState.completed, timestamp=_now_iso())
541
+ if on_event is not None:
542
+ on_event(
543
+ TaskArtifactUpdateEvent(
544
+ taskId=task.id,
545
+ contextId=task.contextId,
546
+ artifact=artifact,
547
+ lastChunk=True,
548
+ )
549
+ )
550
+ on_event(
551
+ TaskStatusUpdateEvent(
552
+ taskId=task.id,
553
+ contextId=task.contextId,
554
+ status=task.status,
555
+ final=True,
556
+ )
557
+ )
558
+ return final
559
+
560
+ # ----- JSON-RPC method handlers ------------------------------------
561
+
562
+ async def _handle_message_send(self, params: dict[str, Any]) -> dict[str, Any]:
563
+ try:
564
+ send = MessageSendParams.model_validate(params)
565
+ except Exception as e: # noqa: BLE001
566
+ raise _RpcError(INVALID_PARAMS, f"invalid params: {e}") from e
567
+ prompt = _extract_user_text(send.message.parts)
568
+ if send.message.taskId:
569
+ task = self._store.get(send.message.taskId)
570
+ if task is None:
571
+ raise _RpcError(TASK_NOT_FOUND, f"task {send.message.taskId} not found")
572
+ if task.status.state in {
573
+ TaskState.completed,
574
+ TaskState.canceled,
575
+ TaskState.failed,
576
+ TaskState.rejected,
577
+ }:
578
+ raise _RpcError(TASK_NOT_CANCELABLE, "task is in a terminal state")
579
+ send.message.contextId = send.message.contextId or task.contextId
580
+ task.history.append(send.message)
581
+ else:
582
+ context_id = send.message.contextId or uuid.uuid4().hex
583
+ task = Task(
584
+ id=uuid.uuid4().hex,
585
+ contextId=context_id,
586
+ status=TaskStatus(state=TaskState.submitted, timestamp=_now_iso()),
587
+ history=[send.message],
588
+ )
589
+ self._store.put(task)
590
+ try:
591
+ await self._drive_agent(prompt, task=task)
592
+ except Exception as e: # noqa: BLE001
593
+ task.status = TaskStatus(
594
+ state=TaskState.failed,
595
+ timestamp=_now_iso(),
596
+ message=_agent_text_message(
597
+ "agent error", context_id=task.contextId, task_id=task.id
598
+ ),
599
+ )
600
+ _logger.exception("A2A message/send failed for task %s", task.id)
601
+ raise _RpcError(INTERNAL_ERROR, "internal error") from e
602
+ return task.model_dump()
603
+
604
+ async def _handle_tasks_get(self, params: dict[str, Any]) -> dict[str, Any]:
605
+ try:
606
+ q = TaskQueryParams.model_validate(params)
607
+ except Exception as e: # noqa: BLE001
608
+ raise _RpcError(INVALID_PARAMS, f"invalid params: {e}") from e
609
+ task = self._store.get(q.id)
610
+ if task is None:
611
+ raise _RpcError(TASK_NOT_FOUND, f"task {q.id} not found")
612
+ if q.historyLength is not None and q.historyLength >= 0:
613
+ trimmed = task.model_copy(deep=True)
614
+ trimmed.history = trimmed.history[-q.historyLength :]
615
+ return trimmed.model_dump()
616
+ return task.model_dump()
617
+
618
+ async def _handle_tasks_cancel(self, params: dict[str, Any]) -> dict[str, Any]:
619
+ try:
620
+ ident = TaskIdParams.model_validate(params)
621
+ except Exception as e: # noqa: BLE001
622
+ raise _RpcError(INVALID_PARAMS, f"invalid params: {e}") from e
623
+ if self._store.get(ident.id) is None:
624
+ raise _RpcError(TASK_NOT_FOUND, f"task {ident.id} not found")
625
+ if not self._store.cancel(ident.id):
626
+ raise _RpcError(
627
+ TASK_NOT_CANCELABLE,
628
+ "task is in a terminal state and cannot be cancelled",
629
+ )
630
+ task = self._store.get(ident.id)
631
+ assert task is not None
632
+ return task.model_dump()
633
+
634
+ async def _stream_message(self, params: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
635
+ """Stream a message run as JSON-RPC responses (one per SSE event)."""
636
+ try:
637
+ send = MessageSendParams.model_validate(params)
638
+ except Exception as e: # noqa: BLE001
639
+ raise _RpcError(INVALID_PARAMS, f"invalid params: {e}") from e
640
+ prompt = _extract_user_text(send.message.parts)
641
+ context_id = send.message.contextId or uuid.uuid4().hex
642
+ task = Task(
643
+ id=uuid.uuid4().hex,
644
+ contextId=context_id,
645
+ status=TaskStatus(state=TaskState.submitted, timestamp=_now_iso()),
646
+ history=[send.message],
647
+ )
648
+ self._store.put(task)
649
+
650
+ # Initial event: the task as it sits in the submitted state.
651
+ yield task.model_dump()
652
+
653
+ # Bridge the synchronous on_event callback to an async queue so
654
+ # SSE consumers see updates as they happen.
655
+ queue: asyncio.Queue[Any] = asyncio.Queue()
656
+ sentinel = object()
657
+
658
+ def emit(ev: Any) -> None:
659
+ queue.put_nowait(ev)
660
+
661
+ async def runner() -> None:
662
+ try:
663
+ await self._drive_agent(prompt, task=task, on_event=emit)
664
+ except Exception: # noqa: BLE001
665
+ task.status = TaskStatus(state=TaskState.failed, timestamp=_now_iso())
666
+ emit(
667
+ TaskStatusUpdateEvent(
668
+ taskId=task.id,
669
+ contextId=task.contextId,
670
+ status=task.status,
671
+ final=True,
672
+ )
673
+ )
674
+ finally:
675
+ queue.put_nowait(sentinel)
676
+
677
+ run_task = asyncio.create_task(runner())
678
+ try:
679
+ while True:
680
+ ev = await queue.get()
681
+ if ev is sentinel:
682
+ break
683
+ yield ev.model_dump() if hasattr(ev, "model_dump") else dict(ev)
684
+ finally:
685
+ await run_task
686
+
687
+ # ----- backward-compat: the legacy A2ARequest invoke shape --------
688
+
689
+ async def _legacy_invoke(self, request: A2ARequest) -> A2AResponse:
690
+ from locus.core.events import TerminateEvent
691
+
692
+ user_msgs = [m for m in request.messages if m.role == "user"]
693
+ prompt = user_msgs[-1].content if user_msgs else ""
694
+ final = ""
695
+ stop_reason = "complete"
696
+ iterations = 0
697
+ async for event in self._agent.run(prompt):
698
+ if isinstance(event, TerminateEvent):
699
+ final = event.final_message or final
700
+ stop_reason = event.reason or stop_reason
701
+ iterations += 1
702
+ return A2AResponse(
703
+ messages=[A2AMessage(role="agent", content=final)],
704
+ status="completed",
705
+ metadata={"stop_reason": stop_reason, "iterations": iterations},
706
+ )
707
+
708
+ # ----- app construction --------------------------------------------
709
+
710
+ def _create_app(self) -> Any:
711
+ try:
712
+ from fastapi import Body, Depends, FastAPI, Header
713
+ from fastapi.responses import JSONResponse, StreamingResponse
714
+ except ImportError as e:
715
+ msg = "FastAPI required. Install with: pip install fastapi uvicorn"
716
+ raise ImportError(msg) from e
717
+
718
+ if self._api_key is None and not self._allow_unauthenticated:
719
+ _logger.warning(
720
+ "A2AServer: no api_key configured; will require "
721
+ "loopback-only binding. Set LOCUS_A2A_API_KEY or pass "
722
+ "allow_unauthenticated=True to override."
723
+ )
724
+
725
+ debug_docs = self._resolve_docs_enabled()
726
+ app = FastAPI(
727
+ title=f"A2A: {self._name}",
728
+ docs_url="/docs" if debug_docs else None,
729
+ redoc_url="/redoc" if debug_docs else None,
730
+ openapi_url="/openapi.json" if debug_docs else None,
731
+ )
732
+
733
+ if self._api_key is not None:
734
+ auth_dep = Depends(self._require_auth())
735
+ else:
736
+
737
+ async def _anon() -> str:
738
+ return "anon"
739
+
740
+ auth_dep = Depends(_anon)
741
+
742
+ # ----- Agent Card -------------------------------------------------
743
+ @app.get("/.well-known/agent-card.json")
744
+ async def well_known_card(_: str = auth_dep) -> dict[str, Any]:
745
+ return self._build_card().model_dump(exclude_none=True)
746
+
747
+ @app.get("/agent-card")
748
+ async def legacy_card(_: str = auth_dep) -> dict[str, Any]:
749
+ # Backwards-compat alias. Matches the old flat shape so
750
+ # peers that still expect ``skills: list[str]`` keep
751
+ # parsing — we re-emit just id/name/description per skill.
752
+ card = self._build_card()
753
+ payload = card.model_dump(exclude_none=True)
754
+ payload["skills"] = [s.name for s in card.skills]
755
+ return payload
756
+
757
+ # ----- JSON-RPC root -----------------------------------------------
758
+ @app.post("/")
759
+ async def jsonrpc(
760
+ body: dict[str, Any] = Body(...), # noqa: B008 — FastAPI dep injection
761
+ a2a_version: str | None = Header(default=None, alias=A2A_VERSION_HEADER),
762
+ _: str = auth_dep,
763
+ ) -> Any:
764
+ try:
765
+ req = JsonRpcRequest.model_validate(body)
766
+ except Exception: # noqa: BLE001
767
+ return JSONResponse(
768
+ _jsonrpc_error_response(
769
+ body.get("id") if isinstance(body, dict) else None,
770
+ INVALID_REQUEST,
771
+ "Invalid Request",
772
+ )
773
+ )
774
+
775
+ params = req.params if isinstance(req.params, dict) else {}
776
+
777
+ # Lazy import — observability is opt-in.
778
+ import time as _time # noqa: PLC0415
779
+
780
+ from locus.observability.emit import ( # noqa: PLC0415
781
+ EV_A2A_TASK_COMPLETED,
782
+ EV_A2A_TASK_RECEIVED,
783
+ emit,
784
+ )
785
+
786
+ await emit(
787
+ EV_A2A_TASK_RECEIVED,
788
+ method=req.method,
789
+ rpc_id=str(req.id) if req.id is not None else None,
790
+ )
791
+ _started = _time.perf_counter()
792
+
793
+ try:
794
+ if a2a_version not in (None, A2A_V1_PROTOCOL_VERSION):
795
+ raise _RpcError(
796
+ VERSION_NOT_SUPPORTED,
797
+ f"A2A version {a2a_version!r} is not supported",
798
+ )
799
+ # Some v1 clients omit the version header; route known v1
800
+ # method names to the v1 handlers to preserve interoperability.
801
+ use_v1 = a2a_version == A2A_V1_PROTOCOL_VERSION or (
802
+ a2a_version is None and req.method in A2AV1_JSONRPC_METHODS
803
+ )
804
+ if use_v1 and req.method == "SendMessage":
805
+ result = await self._handle_v1_send_message(params)
806
+ elif use_v1 and req.method == "GetTask":
807
+ result = await self._handle_v1_get_task(params)
808
+ elif use_v1 and req.method == "ListTasks":
809
+ result = await self._handle_v1_list_tasks(params)
810
+ elif use_v1 and req.method == "CancelTask":
811
+ result = await self._handle_v1_cancel_task(params)
812
+ elif use_v1 and req.method in {
813
+ "CreateTaskPushNotificationConfig",
814
+ "GetTaskPushNotificationConfig",
815
+ "ListTaskPushNotificationConfigs",
816
+ "DeleteTaskPushNotificationConfig",
817
+ }:
818
+ raise _RpcError(
819
+ PUSH_NOTIFICATION_NOT_SUPPORTED,
820
+ "push notifications are not enabled on this agent",
821
+ )
822
+ elif use_v1 and req.method in {"SendStreamingMessage", "SubscribeToTask"}:
823
+ if req.method == "SubscribeToTask":
824
+ self._preflight_v1_task_subscription(params)
825
+ return await self._stream_response(req, v1=True)
826
+ elif use_v1 and req.method == "GetExtendedAgentCard":
827
+ raise _RpcError(
828
+ UNSUPPORTED_OPERATION,
829
+ "authenticated extended agent cards are not enabled on this agent",
830
+ )
831
+ elif req.method == "message/send":
832
+ result = await self._handle_message_send(params)
833
+ elif req.method == "tasks/get":
834
+ result = await self._handle_tasks_get(params)
835
+ elif req.method == "tasks/cancel":
836
+ result = await self._handle_tasks_cancel(params)
837
+ elif req.method in {
838
+ "tasks/pushNotificationConfig/set",
839
+ "tasks/pushNotificationConfig/get",
840
+ "tasks/pushNotificationConfig/list",
841
+ "tasks/pushNotificationConfig/delete",
842
+ }:
843
+ raise _RpcError(
844
+ PUSH_NOTIFICATION_NOT_SUPPORTED,
845
+ "push notifications are not enabled on this agent",
846
+ )
847
+ elif req.method == "message/stream":
848
+ # SSE — peer must POST with Accept: text/event-stream.
849
+ return await self._stream_response(req)
850
+ else:
851
+ raise _RpcError(METHOD_NOT_FOUND, f"unknown method {req.method!r}")
852
+ except (_RpcError, A2AV1ProtocolError) as e:
853
+ await emit(
854
+ EV_A2A_TASK_COMPLETED,
855
+ method=req.method,
856
+ success=False,
857
+ error_code=e.code,
858
+ duration_ms=(_time.perf_counter() - _started) * 1000,
859
+ )
860
+ return JSONResponse(_jsonrpc_error_response(req.id, e.code, e.message))
861
+
862
+ await emit(
863
+ EV_A2A_TASK_COMPLETED,
864
+ method=req.method,
865
+ success=True,
866
+ duration_ms=(_time.perf_counter() - _started) * 1000,
867
+ )
868
+ return JSONResponse(
869
+ JsonRpcSuccessResponse(id=req.id, result=result).model_dump(exclude_none=True)
870
+ )
871
+
872
+ # ----- backwards-compat invoke + stream ----------------------------
873
+ @app.post("/a2a/invoke")
874
+ async def legacy_invoke(req: A2ARequest, _: str = auth_dep) -> dict[str, Any]:
875
+ resp = await self._legacy_invoke(req)
876
+ return resp.model_dump()
877
+
878
+ @app.post("/a2a/stream")
879
+ async def legacy_stream(req: A2ARequest, _: str = auth_dep) -> StreamingResponse:
880
+ response: StreamingResponse = await self._legacy_stream(req)
881
+ return response
882
+
883
+ return app
884
+
885
+ async def _stream_response(self, req: JsonRpcRequest, *, v1: bool = False) -> Any:
886
+ from fastapi.responses import StreamingResponse
887
+
888
+ params = req.params if isinstance(req.params, dict) else {}
889
+
890
+ async def gen() -> AsyncIterator[str]:
891
+ try:
892
+ if v1 and req.method == "SubscribeToTask":
893
+ stream = self._stream_v1_task_subscription(params)
894
+ else:
895
+ stream = self._stream_v1_message(params) if v1 else self._stream_message(params)
896
+ async for result in stream:
897
+ payload = JsonRpcSuccessResponse(id=req.id, result=result).model_dump(
898
+ exclude_none=True
899
+ )
900
+ yield f"data: {json.dumps(payload)}\n\n"
901
+ except (_RpcError, A2AV1ProtocolError) as e:
902
+ payload = _jsonrpc_error_response(req.id, e.code, e.message)
903
+ yield f"data: {json.dumps(payload)}\n\n"
904
+ except Exception: # noqa: BLE001 — sanitise stream errors
905
+ correlation_id = uuid.uuid4().hex
906
+ _logger.exception("A2A stream error (correlation_id=%s)", correlation_id)
907
+ payload = JsonRpcErrorResponse(
908
+ id=req.id,
909
+ error=JsonRpcError(
910
+ code=INTERNAL_ERROR,
911
+ message="internal error",
912
+ data={"correlation_id": correlation_id},
913
+ ),
914
+ ).model_dump(exclude_none=True)
915
+ yield f"data: {json.dumps(payload)}\n\n"
916
+
917
+ return StreamingResponse(gen(), media_type="text/event-stream")
918
+
919
+ async def _legacy_stream(self, request: A2ARequest) -> Any:
920
+ from fastapi.responses import StreamingResponse
921
+
922
+ from locus.core.events import TerminateEvent, ThinkEvent
923
+
924
+ user_msgs = [m for m in request.messages if m.role == "user"]
925
+ prompt = user_msgs[-1].content if user_msgs else ""
926
+
927
+ async def event_generator() -> AsyncIterator[str]:
928
+ try:
929
+ async for event in self._agent.run(prompt):
930
+ if isinstance(event, ThinkEvent):
931
+ data = {"type": "text", "content": event.reasoning or ""}
932
+ elif isinstance(event, TerminateEvent):
933
+ data = {"type": "done", "content": event.final_message or ""}
934
+ else:
935
+ data = {"type": event.event_type}
936
+ yield f"data: {json.dumps(data)}\n\n"
937
+ except Exception: # noqa: BLE001
938
+ correlation_id = uuid.uuid4().hex
939
+ _logger.exception("A2A legacy stream error (correlation_id=%s)", correlation_id)
940
+ yield (
941
+ "data: "
942
+ + json.dumps(
943
+ {
944
+ "type": "error",
945
+ "error": "internal error",
946
+ "correlation_id": correlation_id,
947
+ }
948
+ )
949
+ + "\n\n"
950
+ )
951
+ finally:
952
+ yield "data: [DONE]\n\n"
953
+
954
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
955
+
956
+ def run(self, host: str = "127.0.0.1", port: int = 8001, **kwargs: Any) -> None:
957
+ """Run the A2A server.
958
+
959
+ Defaults to loopback binding. Non-loopback bindings require
960
+ either ``api_key`` to be set or ``allow_unauthenticated=True``.
961
+ """
962
+ if self._api_key is None and not self._allow_unauthenticated and not _is_loopback(host):
963
+ msg = (
964
+ f"Refusing to bind A2AServer to {host!r} without an API "
965
+ "key. Set LOCUS_A2A_API_KEY, pass api_key=... to "
966
+ "A2AServer, or pass allow_unauthenticated=True if an "
967
+ "upstream proxy terminates auth."
968
+ )
969
+ raise RuntimeError(msg)
970
+
971
+ try:
972
+ import uvicorn
973
+ except ImportError as e:
974
+ msg = "uvicorn required. Install with: pip install uvicorn"
975
+ raise ImportError(msg) from e
976
+ uvicorn.run(self.app, host=host, port=port, **kwargs)
977
+
978
+
979
+ class _RpcError(Exception):
980
+ """Internal sentinel — a JSON-RPC method handler raises this to
981
+ short-circuit to a structured error response with the right code."""
982
+
983
+ def __init__(self, code: int, message: str) -> None:
984
+ super().__init__(message)
985
+ self.code = code
986
+ self.message = message
987
+
988
+
989
+ # ---------------------------------------------------------------------------
990
+ # Client
991
+ # ---------------------------------------------------------------------------
992
+
993
+
994
+ class A2AClient:
995
+ """Call a remote A2A agent from Locus.
996
+
997
+ Spec-compliant methods:
998
+
999
+ - :meth:`get_agent_card` — fetches ``/.well-known/agent-card.json``,
1000
+ falling back to the legacy ``/agent-card`` endpoint.
1001
+ - :meth:`send_message` — JSON-RPC ``message/send``; returns a
1002
+ :class:`Task` you can poll with :meth:`get_task`.
1003
+ - :meth:`send_message_streaming` — JSON-RPC ``message/stream``;
1004
+ yields events from the SSE stream.
1005
+ - :meth:`get_task`, :meth:`cancel_task` — task lifecycle.
1006
+
1007
+ Plus the legacy convenience APIs preserved from the pre-spec
1008
+ implementation:
1009
+
1010
+ - :meth:`invoke` — flat string-in / string-out over ``/a2a/invoke``.
1011
+ - :meth:`as_tool` — wrap a remote agent as a Locus ``@tool``.
1012
+ """
1013
+
1014
+ #: Default request timeout (seconds) when caller doesn't override.
1015
+ DEFAULT_TIMEOUT: float = 120.0
1016
+
1017
+ def __init__(
1018
+ self,
1019
+ url: str,
1020
+ api_key: str | None = None,
1021
+ timeout: float | httpx.Timeout | None = None,
1022
+ protocol_version: str | None = A2A_V1_PROTOCOL_VERSION,
1023
+ ) -> None:
1024
+ self._url = url.rstrip("/")
1025
+ self._api_key = api_key
1026
+ self._timeout: Any = self.DEFAULT_TIMEOUT if timeout is None else timeout
1027
+ self._protocol_version = protocol_version
1028
+
1029
+ def _auth_headers(self) -> dict[str, str]:
1030
+ if self._api_key:
1031
+ return {"Authorization": f"Bearer {self._api_key}"}
1032
+ return {}
1033
+
1034
+ def _headers(self, *, include_version: bool = True) -> dict[str, str]:
1035
+ headers = self._auth_headers()
1036
+ if include_version and self._protocol_version:
1037
+ headers[A2A_VERSION_HEADER] = self._protocol_version
1038
+ return headers
1039
+
1040
+ async def get_agent_card(self) -> AgentCard:
1041
+ """Fetch the remote agent's capability card.
1042
+
1043
+ Tries the spec well-known URL first, falls back to the legacy
1044
+ ``/agent-card`` endpoint for older peers.
1045
+ """
1046
+ import httpx
1047
+
1048
+ async with httpx.AsyncClient() as client:
1049
+ for path in ("/.well-known/agent-card.json", "/agent-card"):
1050
+ try:
1051
+ resp = await client.get(f"{self._url}{path}", headers=self._headers())
1052
+ if resp.status_code == 200:
1053
+ data = resp.json()
1054
+ # Legacy peers serve flat string skills — promote.
1055
+ if data.get("skills") and isinstance(data["skills"][0], str):
1056
+ data["skills"] = [
1057
+ {"id": s, "name": s, "description": s} for s in data["skills"]
1058
+ ]
1059
+ # Legacy peers also omit url/capabilities.
1060
+ data.setdefault("url", self._url)
1061
+ return AgentCard.model_validate(data)
1062
+ except httpx.HTTPError:
1063
+ continue
1064
+ msg = f"Could not fetch Agent Card from {self._url}"
1065
+ raise RuntimeError(msg)
1066
+
1067
+ # ---- JSON-RPC client helpers --------------------------------------
1068
+
1069
+ async def _rpc(
1070
+ self,
1071
+ method: str,
1072
+ params: dict[str, Any],
1073
+ *,
1074
+ timeout: float | httpx.Timeout | None = None, # noqa: ASYNC109 — forwarded to httpx, not an asyncio timer
1075
+ ) -> Any:
1076
+ import time as _time
1077
+
1078
+ import httpx
1079
+
1080
+ from locus.observability.emit import ( # noqa: PLC0415
1081
+ EV_A2A_CLIENT_RECEIVED,
1082
+ EV_A2A_CLIENT_SEND,
1083
+ emit,
1084
+ )
1085
+
1086
+ body = {
1087
+ "jsonrpc": "2.0",
1088
+ "id": uuid.uuid4().hex,
1089
+ "method": method,
1090
+ "params": params,
1091
+ }
1092
+ await emit(EV_A2A_CLIENT_SEND, target_url=self._url, method=method)
1093
+ _started = _time.perf_counter()
1094
+ async with httpx.AsyncClient(
1095
+ timeout=self._timeout if timeout is None else timeout
1096
+ ) as client:
1097
+ resp = await client.post(f"{self._url}/", json=body, headers=self._headers())
1098
+ resp.raise_for_status()
1099
+ data = resp.json()
1100
+ await emit(
1101
+ EV_A2A_CLIENT_RECEIVED,
1102
+ target_url=self._url,
1103
+ method=method,
1104
+ status_code=resp.status_code,
1105
+ duration_ms=(_time.perf_counter() - _started) * 1000,
1106
+ content_length=len(resp.content),
1107
+ )
1108
+ if "error" in data:
1109
+ err = data["error"]
1110
+ msg = f"A2A error {err.get('code')}: {err.get('message')}"
1111
+ raise RuntimeError(msg)
1112
+ return data["result"]
1113
+
1114
+ async def send_message(
1115
+ self,
1116
+ message: Message,
1117
+ *,
1118
+ timeout: float | httpx.Timeout | None = None, # noqa: ASYNC109 — forwarded to httpx, not an asyncio timer
1119
+ ) -> Task:
1120
+ """Send a message via JSON-RPC ``message/send`` and return the Task."""
1121
+ if self._protocol_version == A2A_V1_PROTOCOL_VERSION:
1122
+ v1_message = legacy_message_to_v1(message)
1123
+ result = await self._rpc(
1124
+ "SendMessage",
1125
+ {"message": v1_message.model_dump(exclude_none=True)},
1126
+ timeout=timeout,
1127
+ )
1128
+ return Task.model_validate(task_result_to_legacy_payload(result))
1129
+ result = await self._rpc(
1130
+ "message/send",
1131
+ {"message": message.model_dump(exclude_none=True)},
1132
+ timeout=timeout,
1133
+ )
1134
+ return Task.model_validate(result)
1135
+
1136
+ async def send_message_streaming(
1137
+ self,
1138
+ message: Message,
1139
+ *,
1140
+ timeout: float | httpx.Timeout | None = None, # noqa: ASYNC109 — forwarded to httpx, not an asyncio timer
1141
+ ) -> AsyncIterator[dict[str, Any]]:
1142
+ """Send a message via JSON-RPC streaming and yield events."""
1143
+ import httpx
1144
+
1145
+ if self._protocol_version == A2A_V1_PROTOCOL_VERSION:
1146
+ method = "SendStreamingMessage"
1147
+ message_payload = legacy_message_to_v1(message).model_dump(exclude_none=True)
1148
+ else:
1149
+ method = "message/stream"
1150
+ message_payload = message.model_dump(exclude_none=True)
1151
+ body = {
1152
+ "jsonrpc": "2.0",
1153
+ "id": uuid.uuid4().hex,
1154
+ "method": method,
1155
+ "params": {"message": message_payload},
1156
+ }
1157
+ headers = self._headers() | {"Accept": "text/event-stream"}
1158
+ effective_timeout = self._timeout if timeout is None else timeout
1159
+ async with (
1160
+ httpx.AsyncClient(timeout=effective_timeout) as client,
1161
+ client.stream("POST", f"{self._url}/", json=body, headers=headers) as resp,
1162
+ ):
1163
+ resp.raise_for_status()
1164
+ async for raw in resp.aiter_lines():
1165
+ if not raw or not raw.startswith("data: "):
1166
+ continue
1167
+ payload = raw[len("data: ") :]
1168
+ if payload.strip() == "[DONE]":
1169
+ break
1170
+ try:
1171
+ env = json.loads(payload)
1172
+ except json.JSONDecodeError:
1173
+ continue
1174
+ if "error" in env:
1175
+ yield env # surface error envelope to caller
1176
+ break
1177
+ result = env.get("result", env)
1178
+ yield (
1179
+ v1_stream_response_to_legacy_payload(result)
1180
+ if method == "SendStreamingMessage"
1181
+ else result
1182
+ )
1183
+
1184
+ async def get_task(self, task_id: str, history_length: int | None = None) -> Task:
1185
+ """Fetch a task by id (JSON-RPC ``tasks/get``)."""
1186
+ params: dict[str, Any] = {"id": task_id}
1187
+ if history_length is not None:
1188
+ params["historyLength"] = history_length
1189
+ if self._protocol_version == A2A_V1_PROTOCOL_VERSION:
1190
+ result = await self._rpc("GetTask", params)
1191
+ return Task.model_validate(task_result_to_legacy_payload(result))
1192
+ result = await self._rpc("tasks/get", params)
1193
+ return Task.model_validate(result)
1194
+
1195
+ async def list_tasks(
1196
+ self,
1197
+ *,
1198
+ context_id: str | None = None,
1199
+ status: TaskState | str | None = None,
1200
+ page_size: int | None = None,
1201
+ page_token: str | None = None,
1202
+ history_length: int | None = None,
1203
+ include_artifacts: bool | None = None,
1204
+ ) -> tuple[list[Task], str]:
1205
+ """List known tasks.
1206
+
1207
+ Returns ``(tasks, next_page_token)``. The client maps v1.0 wire
1208
+ enum states back into SDK-shaped :class:`Task` objects.
1209
+ """
1210
+ params: dict[str, Any] = {}
1211
+ if context_id is not None:
1212
+ params["contextId"] = context_id
1213
+ if status is not None:
1214
+ state = status.value if isinstance(status, TaskState) else str(status)
1215
+ if self._protocol_version == A2A_V1_PROTOCOL_VERSION and not state.startswith(
1216
+ "TASK_STATE_"
1217
+ ):
1218
+ state = "TASK_STATE_" + state.replace("-", "_").upper()
1219
+ params["status"] = state
1220
+ if page_size is not None:
1221
+ params["pageSize"] = page_size
1222
+ if page_token is not None:
1223
+ params["pageToken"] = page_token
1224
+ if history_length is not None:
1225
+ params["historyLength"] = history_length
1226
+ if include_artifacts is not None:
1227
+ params["includeArtifacts"] = include_artifacts
1228
+
1229
+ if self._protocol_version == A2A_V1_PROTOCOL_VERSION:
1230
+ result = await self._rpc("ListTasks", params)
1231
+ tasks = [
1232
+ Task.model_validate(task_result_to_legacy_payload(task))
1233
+ for task in result.get("tasks", [])
1234
+ ]
1235
+ return tasks, str(result.get("nextPageToken", ""))
1236
+
1237
+ raise RuntimeError("list_tasks requires A2A v1.0 protocol_version")
1238
+
1239
+ async def cancel_task(self, task_id: str) -> Task:
1240
+ """Cancel a task (JSON-RPC ``tasks/cancel``)."""
1241
+ if self._protocol_version == A2A_V1_PROTOCOL_VERSION:
1242
+ result = await self._rpc("CancelTask", {"id": task_id})
1243
+ return Task.model_validate(task_result_to_legacy_payload(result))
1244
+ result = await self._rpc("tasks/cancel", {"id": task_id})
1245
+ return Task.model_validate(result)
1246
+
1247
+ # ---- legacy convenience APIs --------------------------------------
1248
+
1249
+ async def invoke(
1250
+ self,
1251
+ prompt: str,
1252
+ *,
1253
+ timeout: float | httpx.Timeout | None = None, # noqa: ASYNC109 — forwarded to httpx, not an asyncio timer
1254
+ ) -> str:
1255
+ """Send a flat text prompt over the legacy ``/a2a/invoke``.
1256
+
1257
+ Useful when you control both ends of the wire and want a one-line
1258
+ round-trip; spec-compliant peers should prefer
1259
+ :meth:`send_message` so they can read the full :class:`Task`.
1260
+ """
1261
+ import httpx
1262
+
1263
+ request = A2ARequest(messages=[A2AMessage(role="user", content=prompt)])
1264
+ async with httpx.AsyncClient(
1265
+ timeout=self._timeout if timeout is None else timeout
1266
+ ) as client:
1267
+ resp = await client.post(
1268
+ f"{self._url}/a2a/invoke",
1269
+ json=request.model_dump(),
1270
+ headers=self._auth_headers(),
1271
+ )
1272
+ resp.raise_for_status()
1273
+ response = A2AResponse.model_validate(resp.json())
1274
+ agent_msgs = [m for m in response.messages if m.role == "agent"]
1275
+ return agent_msgs[-1].content if agent_msgs else ""
1276
+
1277
+ def as_tool(self, name: str | None = None, description: str | None = None) -> Any:
1278
+ """Wrap this remote agent as a Locus ``@tool``."""
1279
+ from locus.tools.decorator import tool as tool_decorator
1280
+
1281
+ client = self
1282
+ tool_name = name or "remote_agent"
1283
+ tool_desc = description or "Call a remote A2A agent"
1284
+
1285
+ @tool_decorator(name=tool_name, description=tool_desc)
1286
+ def call_remote(prompt: str) -> str:
1287
+ """Send a request to a remote agent."""
1288
+ import asyncio
1289
+
1290
+ return asyncio.run(client.invoke(prompt))
1291
+
1292
+ return call_remote
1293
+
1294
+
1295
+ # Re-export the tasked-out spec types so consumers can keep importing
1296
+ # from ``locus.a2a.protocol`` alone if they prefer (the canonical path
1297
+ # is ``locus.a2a`` after this change).
1298
+ __all__ = [
1299
+ "A2AClient",
1300
+ "A2AMessage",
1301
+ "A2ARequest",
1302
+ "A2AResponse",
1303
+ "A2AServer",
1304
+ "AgentCard",
1305
+ ]
1306
+
1307
+
1308
+ # Force time module reference so ruff/mypy don't drop it on cleanup —
1309
+ # we reference it from the legacy stream path's correlation handling.
1310
+ _ = time