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.
- locus/__init__.py +241 -0
- locus/_oracle_pool_cache.py +196 -0
- locus/_sync.py +76 -0
- locus/a2a/__init__.py +143 -0
- locus/a2a/protocol.py +1310 -0
- locus/a2a/protocol_v1.py +556 -0
- locus/a2a/spec.py +420 -0
- locus/a2a/spec_v1.py +279 -0
- locus/agent/__init__.py +37 -0
- locus/agent/agent.py +604 -0
- locus/agent/composition.py +352 -0
- locus/agent/config.py +607 -0
- locus/agent/hook_orchestrator.py +167 -0
- locus/agent/initializer.py +250 -0
- locus/agent/result.py +339 -0
- locus/agent/runtime_loop.py +2037 -0
- locus/core/__init__.py +178 -0
- locus/core/command.py +260 -0
- locus/core/config.py +176 -0
- locus/core/errors.py +207 -0
- locus/core/events.py +261 -0
- locus/core/interrupt.py +404 -0
- locus/core/messages.py +151 -0
- locus/core/protocols.py +348 -0
- locus/core/reducers.py +466 -0
- locus/core/send.py +325 -0
- locus/core/state.py +403 -0
- locus/core/structured.py +536 -0
- locus/core/termination.py +237 -0
- locus/core/warnings.py +41 -0
- locus/deepagent/__init__.py +137 -0
- locus/deepagent/backends/__init__.py +37 -0
- locus/deepagent/backends/filesystem.py +243 -0
- locus/deepagent/backends/protocol.py +123 -0
- locus/deepagent/backends/state.py +225 -0
- locus/deepagent/factory.py +400 -0
- locus/deepagent/memory.py +48 -0
- locus/deepagent/protocol.py +127 -0
- locus/deepagent/subagent.py +153 -0
- locus/deepagent/todos.py +122 -0
- locus/deepagent/tools/__init__.py +16 -0
- locus/deepagent/tools/filesystem.py +160 -0
- locus/deepagent/workflow.py +665 -0
- locus/evaluation/__init__.py +26 -0
- locus/evaluation/framework.py +226 -0
- locus/hooks/__init__.py +65 -0
- locus/hooks/builtin/__init__.py +64 -0
- locus/hooks/builtin/guardrails.py +765 -0
- locus/hooks/builtin/logging.py +279 -0
- locus/hooks/builtin/retry.py +130 -0
- locus/hooks/builtin/steering.py +248 -0
- locus/hooks/builtin/telemetry.py +429 -0
- locus/hooks/events.py +86 -0
- locus/hooks/plugin.py +137 -0
- locus/hooks/provider.py +405 -0
- locus/hooks/registry.py +398 -0
- locus/integrations/__init__.py +18 -0
- locus/integrations/fastmcp.py +723 -0
- locus/integrations/osv.py +194 -0
- locus/loop/__init__.py +54 -0
- locus/loop/nodes.py +363 -0
- locus/loop/react.py +279 -0
- locus/loop/router.py +241 -0
- locus/loop/runner.py +307 -0
- locus/memory/__init__.py +115 -0
- locus/memory/backends/__init__.py +87 -0
- locus/memory/backends/_oracle_config.py +89 -0
- locus/memory/backends/adapters.py +704 -0
- locus/memory/backends/file.py +288 -0
- locus/memory/backends/http.py +301 -0
- locus/memory/backends/memory.py +247 -0
- locus/memory/backends/mysql.py +646 -0
- locus/memory/backends/oci_bucket.py +607 -0
- locus/memory/backends/opensearch.py +301 -0
- locus/memory/backends/oracle.py +617 -0
- locus/memory/backends/oracle_sync.py +271 -0
- locus/memory/backends/oracle_versioned.py +792 -0
- locus/memory/backends/postgresql.py +454 -0
- locus/memory/backends/redis.py +129 -0
- locus/memory/checkpointer.py +335 -0
- locus/memory/compactor.py +329 -0
- locus/memory/conversation.py +325 -0
- locus/memory/delta.py +546 -0
- locus/memory/manager.py +584 -0
- locus/memory/managers/__init__.py +47 -0
- locus/memory/managers/oracle_agent_memory.py +468 -0
- locus/memory/registry.py +221 -0
- locus/memory/store.py +935 -0
- locus/memory/store_backends/__init__.py +42 -0
- locus/memory/store_backends/oracle.py +817 -0
- locus/memory/store_backends/oracle_sync.py +167 -0
- locus/models/__init__.py +103 -0
- locus/models/auxiliary.py +56 -0
- locus/models/base.py +121 -0
- locus/models/caching.py +85 -0
- locus/models/credentials.py +212 -0
- locus/models/failover.py +787 -0
- locus/models/metadata.py +357 -0
- locus/models/native/__init__.py +22 -0
- locus/models/native/anthropic.py +418 -0
- locus/models/native/ollama.py +222 -0
- locus/models/native/openai.py +487 -0
- locus/models/pooled.py +212 -0
- locus/models/providers/__init__.py +18 -0
- locus/models/providers/oci/__init__.py +437 -0
- locus/models/providers/oci/_responses_parse.py +295 -0
- locus/models/providers/oci/_signing.py +172 -0
- locus/models/providers/oci/base.py +130 -0
- locus/models/providers/oci/client.py +294 -0
- locus/models/providers/oci/models/__init__.py +14 -0
- locus/models/providers/oci/models/cohere.py +263 -0
- locus/models/providers/oci/models/generic.py +315 -0
- locus/models/providers/oci/openai_compat.py +539 -0
- locus/models/providers/oci/responses.py +522 -0
- locus/models/rate_limits.py +237 -0
- locus/models/registry.py +154 -0
- locus/multiagent/__init__.py +134 -0
- locus/multiagent/functional.py +234 -0
- locus/multiagent/graph.py +1671 -0
- locus/multiagent/handoff.py +637 -0
- locus/multiagent/orchestrator.py +467 -0
- locus/multiagent/specialist.py +495 -0
- locus/multiagent/swarm.py +701 -0
- locus/multiagent/visualize.py +139 -0
- locus/observability/__init__.py +67 -0
- locus/observability/agent_bridge.py +171 -0
- locus/observability/bus_hook.py +175 -0
- locus/observability/context.py +137 -0
- locus/observability/emit.py +216 -0
- locus/observability/event_bus.py +369 -0
- locus/observability/router_events.py +231 -0
- locus/playbooks/__init__.py +45 -0
- locus/playbooks/enforcer.py +403 -0
- locus/playbooks/hook.py +161 -0
- locus/playbooks/loader.py +271 -0
- locus/playbooks/models.py +202 -0
- locus/providers/__init__.py +63 -0
- locus/providers/image.py +115 -0
- locus/providers/speech.py +171 -0
- locus/providers/tools.py +217 -0
- locus/providers/types.py +42 -0
- locus/providers/web_fetch.py +194 -0
- locus/providers/web_search.py +129 -0
- locus/py.typed +0 -0
- locus/rag/__init__.py +180 -0
- locus/rag/chunkers/__init__.py +23 -0
- locus/rag/chunkers/oracle_indb.py +277 -0
- locus/rag/chunkers/oracle_sync.py +97 -0
- locus/rag/embeddings/__init__.py +60 -0
- locus/rag/embeddings/base.py +219 -0
- locus/rag/embeddings/oci.py +441 -0
- locus/rag/embeddings/openai.py +207 -0
- locus/rag/embeddings/oracle_indb.py +511 -0
- locus/rag/embeddings/oracle_sync.py +97 -0
- locus/rag/loaders/__init__.py +17 -0
- locus/rag/loaders/oracle.py +358 -0
- locus/rag/loaders/oracle_sync.py +96 -0
- locus/rag/multimodal.py +586 -0
- locus/rag/reranker/__init__.py +28 -0
- locus/rag/reranker/base.py +57 -0
- locus/rag/reranker/cohere_oci.py +225 -0
- locus/rag/retriever.py +603 -0
- locus/rag/stores/__init__.py +69 -0
- locus/rag/stores/_mmr.py +122 -0
- locus/rag/stores/_oracle_filter.py +178 -0
- locus/rag/stores/base.py +266 -0
- locus/rag/stores/memory.py +155 -0
- locus/rag/stores/opensearch.py +396 -0
- locus/rag/stores/oracle.py +1088 -0
- locus/rag/stores/oracle_sync.py +154 -0
- locus/rag/stores/pgvector.py +608 -0
- locus/rag/tools.py +265 -0
- locus/reasoning/__init__.py +79 -0
- locus/reasoning/causal.py +727 -0
- locus/reasoning/grounding.py +677 -0
- locus/reasoning/gsar.py +392 -0
- locus/reasoning/gsar_evaluator.py +262 -0
- locus/reasoning/gsar_judge.py +336 -0
- locus/reasoning/reflexion.py +515 -0
- locus/router/__init__.py +115 -0
- locus/router/capability.py +133 -0
- locus/router/compiler.py +246 -0
- locus/router/goal_frame.py +145 -0
- locus/router/picker.py +201 -0
- locus/router/policy.py +103 -0
- locus/router/protocol.py +669 -0
- locus/router/runnable.py +267 -0
- locus/router/runtime.py +129 -0
- locus/router/skill_index.py +80 -0
- locus/server/__init__.py +44 -0
- locus/server/adapters.py +178 -0
- locus/server/app.py +456 -0
- locus/skills/__init__.py +37 -0
- locus/skills/models.py +255 -0
- locus/skills/plugin.py +207 -0
- locus/streaming/__init__.py +49 -0
- locus/streaming/console.py +373 -0
- locus/streaming/handler.py +284 -0
- locus/streaming/sse.py +443 -0
- locus/streaming/structured.py +144 -0
- locus/tools/__init__.py +27 -0
- locus/tools/builtins.py +58 -0
- locus/tools/context.py +69 -0
- locus/tools/decorator.py +197 -0
- locus/tools/executor.py +543 -0
- locus/tools/oci.py +642 -0
- locus/tools/path_safety.py +99 -0
- locus/tools/registry.py +87 -0
- locus/tools/result_storage.py +183 -0
- locus/tools/schema.py +206 -0
- locus/tools/url_safety.py +193 -0
- locus/tools/watcher.py +304 -0
- locus_sdk-0.2.0.dist-info/METADATA +640 -0
- locus_sdk-0.2.0.dist-info/RECORD +217 -0
- locus_sdk-0.2.0.dist-info/WHEEL +4 -0
- locus_sdk-0.2.0.dist-info/licenses/LICENSE +17 -0
- 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
|