agentlings 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.
- agentlings/__init__.py +3 -0
- agentlings/__main__.py +238 -0
- agentlings/cli/__init__.py +1 -0
- agentlings/cli/_migrations.py +78 -0
- agentlings/cli/_templates.py +57 -0
- agentlings/cli/_version.py +33 -0
- agentlings/cli/init.py +122 -0
- agentlings/cli/upgrade.py +89 -0
- agentlings/config.py +260 -0
- agentlings/core/__init__.py +1 -0
- agentlings/core/completion.py +219 -0
- agentlings/core/llm.py +509 -0
- agentlings/core/loop.py +134 -0
- agentlings/core/memory_models.py +97 -0
- agentlings/core/memory_store.py +109 -0
- agentlings/core/models.py +231 -0
- agentlings/core/prompt.py +122 -0
- agentlings/core/scheduler.py +141 -0
- agentlings/core/sleep.py +393 -0
- agentlings/core/store.py +318 -0
- agentlings/core/task.py +1087 -0
- agentlings/core/telemetry.py +181 -0
- agentlings/log.py +23 -0
- agentlings/migrations/__init__.py +37 -0
- agentlings/migrations/m0001_seed.py +17 -0
- agentlings/protocol/__init__.py +1 -0
- agentlings/protocol/a2a.py +220 -0
- agentlings/protocol/a2a_task_store.py +150 -0
- agentlings/protocol/agent_card.py +83 -0
- agentlings/protocol/mcp.py +232 -0
- agentlings/server.py +247 -0
- agentlings/templates/__init__.py +1 -0
- agentlings/templates/default/.env.example +16 -0
- agentlings/templates/default/agent.yaml +14 -0
- agentlings/tools/__init__.py +1 -0
- agentlings/tools/builtins.py +307 -0
- agentlings/tools/memory.py +104 -0
- agentlings/tools/registry.py +154 -0
- agentlings-0.2.0.dist-info/METADATA +406 -0
- agentlings-0.2.0.dist-info/RECORD +43 -0
- agentlings-0.2.0.dist-info/WHEEL +4 -0
- agentlings-0.2.0.dist-info/entry_points.txt +2 -0
- agentlings-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""OpenTelemetry setup and instrumentation for the agent lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any, Generator
|
|
8
|
+
|
|
9
|
+
from agentlings.config import TelemetryConfig
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_tracer: Any = None
|
|
14
|
+
_meter: Any = None
|
|
15
|
+
_initialized = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def init_telemetry(config: TelemetryConfig) -> None:
|
|
19
|
+
"""Configure OpenTelemetry tracer and meter providers.
|
|
20
|
+
|
|
21
|
+
No-op if telemetry is disabled or already initialized.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
config: Telemetry configuration from the agent YAML.
|
|
25
|
+
"""
|
|
26
|
+
global _tracer, _meter, _initialized
|
|
27
|
+
|
|
28
|
+
if _initialized or not config.enabled:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from opentelemetry import metrics, trace
|
|
33
|
+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
|
34
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
35
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
36
|
+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
|
37
|
+
from opentelemetry.sdk.resources import Resource
|
|
38
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
39
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
40
|
+
|
|
41
|
+
resource = Resource.create({"service.name": config.service_name})
|
|
42
|
+
|
|
43
|
+
headers = config.headers or {}
|
|
44
|
+
|
|
45
|
+
if config.protocol == "grpc":
|
|
46
|
+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
|
|
47
|
+
OTLPMetricExporter as GrpcMetricExporter,
|
|
48
|
+
)
|
|
49
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
50
|
+
OTLPSpanExporter as GrpcSpanExporter,
|
|
51
|
+
)
|
|
52
|
+
span_exporter = GrpcSpanExporter(
|
|
53
|
+
endpoint=config.endpoint,
|
|
54
|
+
insecure=config.insecure,
|
|
55
|
+
headers=headers or None,
|
|
56
|
+
)
|
|
57
|
+
metric_exporter = GrpcMetricExporter(
|
|
58
|
+
endpoint=config.endpoint,
|
|
59
|
+
insecure=config.insecure,
|
|
60
|
+
headers=headers or None,
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
span_exporter = OTLPSpanExporter(
|
|
64
|
+
endpoint=f"{config.endpoint}/v1/traces",
|
|
65
|
+
headers=headers or None,
|
|
66
|
+
)
|
|
67
|
+
metric_exporter = OTLPMetricExporter(
|
|
68
|
+
endpoint=f"{config.endpoint}/v1/metrics",
|
|
69
|
+
headers=headers or None,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
tracer_provider = TracerProvider(resource=resource)
|
|
73
|
+
tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
|
|
74
|
+
trace.set_tracer_provider(tracer_provider)
|
|
75
|
+
|
|
76
|
+
metric_reader = PeriodicExportingMetricReader(metric_exporter)
|
|
77
|
+
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
|
78
|
+
metrics.set_meter_provider(meter_provider)
|
|
79
|
+
|
|
80
|
+
_tracer = trace.get_tracer("agentling")
|
|
81
|
+
_meter = metrics.get_meter("agentling")
|
|
82
|
+
_initialized = True
|
|
83
|
+
|
|
84
|
+
logger.info(
|
|
85
|
+
"telemetry initialized: endpoint=%s, protocol=%s",
|
|
86
|
+
config.endpoint, config.protocol,
|
|
87
|
+
)
|
|
88
|
+
except ImportError:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"opentelemetry packages not installed, telemetry disabled — "
|
|
91
|
+
"install with: pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp"
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
logger.exception("failed to initialize telemetry")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_tracer() -> Any:
|
|
98
|
+
"""Return the configured tracer, or a no-op proxy if telemetry is not initialized."""
|
|
99
|
+
if _tracer is not None:
|
|
100
|
+
return _tracer
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
from opentelemetry import trace
|
|
104
|
+
return trace.get_tracer("agentling")
|
|
105
|
+
except ImportError:
|
|
106
|
+
return _NoOpTracer()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_meter() -> Any:
|
|
110
|
+
"""Return the configured meter, or a no-op proxy if telemetry is not initialized."""
|
|
111
|
+
if _meter is not None:
|
|
112
|
+
return _meter
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
from opentelemetry import metrics
|
|
116
|
+
return metrics.get_meter("agentling")
|
|
117
|
+
except ImportError:
|
|
118
|
+
return _NoOpMeter()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@contextmanager
|
|
122
|
+
def otel_span(name: str, attributes: dict[str, Any] | None = None) -> Generator[Any, None, None]:
|
|
123
|
+
"""Context manager that creates a traced span.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
name: Span name (e.g. ``"agentling.loop.process_message"``).
|
|
127
|
+
attributes: Span attributes to set on creation.
|
|
128
|
+
|
|
129
|
+
Yields:
|
|
130
|
+
The span object (or a no-op if telemetry is disabled).
|
|
131
|
+
"""
|
|
132
|
+
tracer = get_tracer()
|
|
133
|
+
with tracer.start_as_current_span(name) as span:
|
|
134
|
+
if attributes:
|
|
135
|
+
for k, v in attributes.items():
|
|
136
|
+
span.set_attribute(k, v)
|
|
137
|
+
yield span
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
sleep_span = otel_span
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _NoOpSpan:
|
|
144
|
+
def set_attribute(self, key: str, value: Any) -> None:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
def add_event(self, name: str, attributes: dict[str, Any] | None = None) -> None:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def __enter__(self) -> _NoOpSpan:
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def __exit__(self, *args: Any) -> None:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _NoOpTracer:
|
|
158
|
+
def start_as_current_span(self, name: str, **kwargs: Any) -> _NoOpSpan:
|
|
159
|
+
return _NoOpSpan()
|
|
160
|
+
|
|
161
|
+
def start_span(self, name: str, **kwargs: Any) -> _NoOpSpan:
|
|
162
|
+
return _NoOpSpan()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _NoOpMeter:
|
|
166
|
+
def create_histogram(self, name: str, **kwargs: Any) -> _NoOpInstrument:
|
|
167
|
+
return _NoOpInstrument()
|
|
168
|
+
|
|
169
|
+
def create_counter(self, name: str, **kwargs: Any) -> _NoOpInstrument:
|
|
170
|
+
return _NoOpInstrument()
|
|
171
|
+
|
|
172
|
+
def create_up_down_counter(self, name: str, **kwargs: Any) -> _NoOpInstrument:
|
|
173
|
+
return _NoOpInstrument()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _NoOpInstrument:
|
|
177
|
+
def record(self, value: Any, attributes: dict[str, Any] | None = None) -> None:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def add(self, value: Any, attributes: dict[str, Any] | None = None) -> None:
|
|
181
|
+
pass
|
agentlings/log.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Console-first logging configuration.
|
|
2
|
+
|
|
3
|
+
Format: ``datetime - level - path - message``, output to stderr.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_logging(level: str = "INFO") -> None:
|
|
11
|
+
"""Configure the root logger with the agentlings format.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
level: Log level name (e.g. ``"INFO"``, ``"DEBUG"``).
|
|
15
|
+
"""
|
|
16
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
17
|
+
handler.setFormatter(
|
|
18
|
+
logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
root = logging.getLogger()
|
|
22
|
+
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
23
|
+
root.addHandler(handler)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Forward-only data-layout migrations applied by ``agentling upgrade``.
|
|
2
|
+
|
|
3
|
+
Each migration is a module exporting:
|
|
4
|
+
|
|
5
|
+
- ``ID: str`` — a stable identifier (typically the module name)
|
|
6
|
+
- ``DESCRIPTION: str`` — one-line human-readable summary
|
|
7
|
+
- ``apply(data_dir: Path) -> None`` — idempotent transformation
|
|
8
|
+
|
|
9
|
+
Migrations are discovered at import time and ordered by their numeric prefix.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import pkgutil
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Protocol
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Migration(Protocol):
|
|
21
|
+
ID: str
|
|
22
|
+
DESCRIPTION: str
|
|
23
|
+
|
|
24
|
+
def apply(self, data_dir: Path) -> None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def discover() -> list[Migration]:
|
|
28
|
+
"""Return all migration modules in this package, ordered by ID."""
|
|
29
|
+
migrations: list[Migration] = []
|
|
30
|
+
for info in pkgutil.iter_modules(__path__):
|
|
31
|
+
if info.name.startswith("_"):
|
|
32
|
+
continue
|
|
33
|
+
module = importlib.import_module(f"{__name__}.{info.name}")
|
|
34
|
+
if hasattr(module, "ID") and hasattr(module, "apply"):
|
|
35
|
+
migrations.append(module) # type: ignore[arg-type]
|
|
36
|
+
migrations.sort(key=lambda m: m.ID)
|
|
37
|
+
return migrations
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Seed migration — establishes the migration log without changing data.
|
|
2
|
+
|
|
3
|
+
Acts as a no-op so the runner has at least one migration to find on a fresh
|
|
4
|
+
install. Real schema migrations land alongside this one as future framework
|
|
5
|
+
versions ship.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
ID = "0001_seed"
|
|
13
|
+
DESCRIPTION = "Initialise migration log"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def apply(data_dir: Path) -> None: # noqa: ARG001
|
|
17
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Protocol adapters for A2A and MCP, plus Agent Card generation."""
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""A2A protocol executor bridging incoming requests into the task engine.
|
|
2
|
+
|
|
3
|
+
SendMessage spawns a task in the shared engine and awaits up to
|
|
4
|
+
``AGENT_TASK_AWAIT_SECONDS``. If the task completes in the await window, the
|
|
5
|
+
final response is returned as a plain agent ``Message``. Otherwise a native
|
|
6
|
+
A2A ``Task`` object (``status.state == working``) is enqueued so the caller
|
|
7
|
+
can poll via ``GetTask`` — those GetTask calls are routed back to our engine
|
|
8
|
+
by ``EngineTaskStore`` so the SDK's answers always reflect live state.
|
|
9
|
+
|
|
10
|
+
Cancellation (``CancelTask``) hits the engine's cancel path by task id.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
from a2a.helpers.proto_helpers import new_text_message
|
|
19
|
+
from a2a.server.agent_execution import AgentExecutor
|
|
20
|
+
from a2a.server.agent_execution.context import RequestContext
|
|
21
|
+
from a2a.server.events import EventQueue
|
|
22
|
+
from a2a.types import Message, Role
|
|
23
|
+
|
|
24
|
+
from agentlings.config import AgentConfig
|
|
25
|
+
from agentlings.core.loop import MessageLoop
|
|
26
|
+
from agentlings.core.task import (
|
|
27
|
+
ContextBusyError,
|
|
28
|
+
TaskNotFoundError,
|
|
29
|
+
TaskState,
|
|
30
|
+
TaskStatus,
|
|
31
|
+
)
|
|
32
|
+
from agentlings.protocol.a2a_task_store import task_state_to_a2a_task
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _agent_text_message(
|
|
38
|
+
text: str,
|
|
39
|
+
*,
|
|
40
|
+
context_id: str | None = None,
|
|
41
|
+
task_id: str | None = None,
|
|
42
|
+
) -> Message:
|
|
43
|
+
"""Build an agent-role ``Message`` proto carrying a single text part.
|
|
44
|
+
|
|
45
|
+
Replaces the removed ``a2a.utils.new_agent_text_message`` helper. The
|
|
46
|
+
proto ``Message`` requires a ``message_id`` and has no field presence on
|
|
47
|
+
string fields, so empty-string defaults are used when ids are unset.
|
|
48
|
+
"""
|
|
49
|
+
return new_text_message(
|
|
50
|
+
text,
|
|
51
|
+
context_id=context_id or "",
|
|
52
|
+
task_id=task_id or "",
|
|
53
|
+
role=Role.ROLE_AGENT,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AgentlingExecutor(AgentExecutor):
|
|
58
|
+
"""Executes A2A requests by forwarding user input through the shared task engine."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, loop: MessageLoop, config: AgentConfig) -> None:
|
|
61
|
+
self._loop = loop
|
|
62
|
+
self._engine = loop.engine
|
|
63
|
+
self._await_seconds = float(config.agent_task_await_seconds)
|
|
64
|
+
|
|
65
|
+
async def execute(
|
|
66
|
+
self, context: RequestContext, event_queue: EventQueue
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Process an incoming A2A request and enqueue the agent's response.
|
|
69
|
+
|
|
70
|
+
Enqueues either a ``Message`` (fast path, completed) or a native
|
|
71
|
+
``Task`` object (slow path, still working) depending on whether the
|
|
72
|
+
task finished within the configured await window.
|
|
73
|
+
"""
|
|
74
|
+
user_text = context.get_user_input()
|
|
75
|
+
context_id = context.context_id
|
|
76
|
+
# The A2A SDK generates a task_id for every inbound SendMessage. We
|
|
77
|
+
# must use that same id inside the engine so GetTask lookups (routed
|
|
78
|
+
# through EngineTaskStore) and the Task object we enqueue all agree.
|
|
79
|
+
sdk_task_id = context.task_id
|
|
80
|
+
|
|
81
|
+
logger.debug(
|
|
82
|
+
"a2a execute: context_id=%s task_id=%s text=%r",
|
|
83
|
+
context_id,
|
|
84
|
+
sdk_task_id,
|
|
85
|
+
(user_text or "")[:100],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
state = await self._engine.spawn(
|
|
90
|
+
message=user_text,
|
|
91
|
+
context_id=context_id,
|
|
92
|
+
via="a2a",
|
|
93
|
+
await_seconds=self._await_seconds,
|
|
94
|
+
task_id=sdk_task_id,
|
|
95
|
+
)
|
|
96
|
+
except ContextBusyError as e:
|
|
97
|
+
# Surface as a plain agent message — there is no live Task on this
|
|
98
|
+
# context that the caller can latch onto (it's someone else's).
|
|
99
|
+
await event_queue.enqueue_event(
|
|
100
|
+
_agent_text_message(
|
|
101
|
+
_format_busy(e),
|
|
102
|
+
context_id=context_id,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
await event_queue.close()
|
|
106
|
+
return
|
|
107
|
+
except Exception: # noqa: BLE001
|
|
108
|
+
logger.exception("error processing A2A message")
|
|
109
|
+
await event_queue.enqueue_event(
|
|
110
|
+
_agent_text_message(
|
|
111
|
+
"Internal error processing request.",
|
|
112
|
+
context_id=context_id,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
await event_queue.close()
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if state.status == TaskStatus.COMPLETED:
|
|
119
|
+
# Fast path — return the final response text as a Message event.
|
|
120
|
+
response_text = _extract_text(state.content)
|
|
121
|
+
await event_queue.enqueue_event(
|
|
122
|
+
_agent_text_message(
|
|
123
|
+
response_text,
|
|
124
|
+
context_id=state.context_id,
|
|
125
|
+
task_id=state.task_id,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
# Slow path or terminal-with-error — enqueue a native A2A Task so
|
|
130
|
+
# the client's GetTask/CancelTask flows reach our engine.
|
|
131
|
+
a2a_task = task_state_to_a2a_task(state)
|
|
132
|
+
await event_queue.enqueue_event(a2a_task)
|
|
133
|
+
|
|
134
|
+
logger.debug(
|
|
135
|
+
"a2a response: context_id=%s status=%s task_id=%s",
|
|
136
|
+
state.context_id, state.status.value, state.task_id,
|
|
137
|
+
)
|
|
138
|
+
await event_queue.close()
|
|
139
|
+
|
|
140
|
+
async def cancel(
|
|
141
|
+
self, context: RequestContext, event_queue: EventQueue
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Handle A2A ``CancelTask`` by routing to the engine's cancel path.
|
|
144
|
+
|
|
145
|
+
``RequestContext.task_id`` is populated from the inbound request,
|
|
146
|
+
which (for our clients) is the engine's task_id — the Task object
|
|
147
|
+
enqueued on the slow path carries that id directly.
|
|
148
|
+
"""
|
|
149
|
+
task_id = context.task_id
|
|
150
|
+
if not task_id:
|
|
151
|
+
await event_queue.enqueue_event(
|
|
152
|
+
_agent_text_message(
|
|
153
|
+
"CancelTask requires a task_id.",
|
|
154
|
+
context_id=context.context_id,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
await event_queue.close()
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
state = await self._engine.cancel(task_id=task_id)
|
|
162
|
+
except TaskNotFoundError:
|
|
163
|
+
await event_queue.enqueue_event(
|
|
164
|
+
_agent_text_message(
|
|
165
|
+
f"Task {task_id} not found.",
|
|
166
|
+
context_id=context.context_id,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
await event_queue.close()
|
|
170
|
+
return
|
|
171
|
+
except Exception: # noqa: BLE001
|
|
172
|
+
logger.exception("cancel failed for task %s", task_id)
|
|
173
|
+
await event_queue.enqueue_event(
|
|
174
|
+
_agent_text_message(
|
|
175
|
+
"Internal error during cancel.",
|
|
176
|
+
context_id=context.context_id,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
await event_queue.close()
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Enqueue the updated Task so the SDK relays the cancelled state.
|
|
183
|
+
await event_queue.enqueue_event(task_state_to_a2a_task(state))
|
|
184
|
+
await event_queue.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _format_busy(e: ContextBusyError) -> str:
|
|
188
|
+
envelope = {
|
|
189
|
+
"status": "busy",
|
|
190
|
+
"error": "context_busy",
|
|
191
|
+
"contextId": e.context_id,
|
|
192
|
+
"activeTaskId": e.active_task_id,
|
|
193
|
+
"message": (
|
|
194
|
+
f"Context {e.context_id} is busy with task {e.active_task_id}. "
|
|
195
|
+
"Retry shortly."
|
|
196
|
+
),
|
|
197
|
+
}
|
|
198
|
+
return json.dumps(envelope)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _extract_text(content: list[dict]) -> str:
|
|
202
|
+
parts = []
|
|
203
|
+
for block in content:
|
|
204
|
+
if block.get("type") == "text":
|
|
205
|
+
parts.append(block.get("text", ""))
|
|
206
|
+
return "\n".join(parts)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _format_state(state: TaskState, await_seconds: float) -> str:
|
|
210
|
+
"""Legacy helper kept for backwards compatibility in tests."""
|
|
211
|
+
if state.status == TaskStatus.COMPLETED:
|
|
212
|
+
return _extract_text(state.content)
|
|
213
|
+
envelope = {
|
|
214
|
+
"status": state.status.value,
|
|
215
|
+
"taskId": state.task_id,
|
|
216
|
+
"contextId": state.context_id,
|
|
217
|
+
}
|
|
218
|
+
if state.error:
|
|
219
|
+
envelope["error"] = state.error
|
|
220
|
+
return json.dumps(envelope)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Bridge from the A2A SDK's ``TaskStore`` interface to our ``TaskEngine``.
|
|
2
|
+
|
|
3
|
+
The A2A SDK's ``DefaultRequestHandler`` consults an injected ``TaskStore``
|
|
4
|
+
when a client calls ``GetTask``. Rather than duplicate state in an
|
|
5
|
+
``InMemoryTaskStore``, this bridge delegates every lookup to the engine so
|
|
6
|
+
the SDK's answers always reflect live state.
|
|
7
|
+
|
|
8
|
+
- ``get(task_id)`` → polls the engine, translates ``TaskState`` → ``a2a.Task``.
|
|
9
|
+
- ``save(task)`` is a no-op; the engine is authoritative.
|
|
10
|
+
- ``delete(task_id)`` is a no-op; retention follows the engine's lifecycle.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from a2a.server.context import ServerCallContext
|
|
19
|
+
from a2a.server.tasks.task_store import TaskStore
|
|
20
|
+
from a2a.types import (
|
|
21
|
+
ListTasksRequest,
|
|
22
|
+
ListTasksResponse,
|
|
23
|
+
Message,
|
|
24
|
+
Part,
|
|
25
|
+
Role,
|
|
26
|
+
Task,
|
|
27
|
+
TaskState as A2ATaskState,
|
|
28
|
+
TaskStatus as A2ATaskStatus,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from agentlings.core.task import (
|
|
32
|
+
TaskEngine,
|
|
33
|
+
TaskNotFoundError,
|
|
34
|
+
TaskState,
|
|
35
|
+
TaskStatus,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_STATE_MAP: dict[TaskStatus, int] = {
|
|
42
|
+
TaskStatus.WORKING: A2ATaskState.TASK_STATE_WORKING,
|
|
43
|
+
TaskStatus.CANCELLING: A2ATaskState.TASK_STATE_WORKING,
|
|
44
|
+
TaskStatus.COMPLETED: A2ATaskState.TASK_STATE_COMPLETED,
|
|
45
|
+
TaskStatus.FAILED: A2ATaskState.TASK_STATE_FAILED,
|
|
46
|
+
TaskStatus.CANCELLED: A2ATaskState.TASK_STATE_CANCELED,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def task_state_to_a2a_task(state: TaskState) -> Task:
|
|
51
|
+
"""Render an engine ``TaskState`` as an A2A ``Task`` for the wire.
|
|
52
|
+
|
|
53
|
+
Completed tasks carry their final assistant message in ``history`` so
|
|
54
|
+
``GetTask`` responses are self-contained — the client doesn't need a
|
|
55
|
+
separate call to retrieve the result.
|
|
56
|
+
"""
|
|
57
|
+
history: list[Message] = []
|
|
58
|
+
if state.status == TaskStatus.COMPLETED and state.content:
|
|
59
|
+
text = _extract_text(state.content)
|
|
60
|
+
if text:
|
|
61
|
+
history.append(
|
|
62
|
+
Message(
|
|
63
|
+
role=Role.ROLE_AGENT,
|
|
64
|
+
parts=[Part(text=text)],
|
|
65
|
+
context_id=state.context_id,
|
|
66
|
+
task_id=state.task_id,
|
|
67
|
+
message_id=f"{state.task_id}-final",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
status_kwargs: dict[str, Any] = {"state": _STATE_MAP[state.status]}
|
|
72
|
+
if state.error and state.status in (TaskStatus.FAILED, TaskStatus.CANCELLED):
|
|
73
|
+
status_kwargs["message"] = Message(
|
|
74
|
+
role=Role.ROLE_AGENT,
|
|
75
|
+
parts=[Part(text=state.error)],
|
|
76
|
+
context_id=state.context_id,
|
|
77
|
+
task_id=state.task_id,
|
|
78
|
+
message_id=f"{state.task_id}-status",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return Task(
|
|
82
|
+
id=state.task_id,
|
|
83
|
+
context_id=state.context_id,
|
|
84
|
+
status=A2ATaskStatus(**status_kwargs),
|
|
85
|
+
history=history,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_text(content: list[dict[str, Any]]) -> str:
|
|
90
|
+
parts = []
|
|
91
|
+
for block in content:
|
|
92
|
+
if block.get("type") == "text":
|
|
93
|
+
parts.append(block.get("text", ""))
|
|
94
|
+
return "\n".join(parts)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class EngineTaskStore(TaskStore):
|
|
98
|
+
"""A2A SDK ``TaskStore`` backed by our ``TaskEngine``.
|
|
99
|
+
|
|
100
|
+
GetTask requests reaching the SDK's ``DefaultRequestHandler`` are routed
|
|
101
|
+
through this store. The handler calls ``get`` and returns the resulting
|
|
102
|
+
``Task`` to the client.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, engine: TaskEngine) -> None:
|
|
106
|
+
self._engine = engine
|
|
107
|
+
|
|
108
|
+
async def get(
|
|
109
|
+
self, task_id: str, context: ServerCallContext | None = None
|
|
110
|
+
) -> Task | None:
|
|
111
|
+
"""Translate ``engine.poll(task_id)`` into an A2A ``Task``.
|
|
112
|
+
|
|
113
|
+
Returns ``None`` if the engine has no record of the task (so the SDK
|
|
114
|
+
produces its own ``task_not_found`` error).
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
state = await self._engine.poll(task_id=task_id, wait_seconds=0)
|
|
118
|
+
except TaskNotFoundError:
|
|
119
|
+
return None
|
|
120
|
+
except Exception: # noqa: BLE001
|
|
121
|
+
logger.exception("EngineTaskStore.get failed for task %s", task_id)
|
|
122
|
+
return None
|
|
123
|
+
return task_state_to_a2a_task(state)
|
|
124
|
+
|
|
125
|
+
async def save(
|
|
126
|
+
self, task: Task, context: ServerCallContext | None = None
|
|
127
|
+
) -> None:
|
|
128
|
+
"""No-op. The engine is the source of truth; SDK saves are advisory."""
|
|
129
|
+
logger.debug("EngineTaskStore.save ignored for task %s", task.id)
|
|
130
|
+
|
|
131
|
+
async def delete(
|
|
132
|
+
self, task_id: str, context: ServerCallContext | None = None
|
|
133
|
+
) -> None:
|
|
134
|
+
"""No-op. Retention follows the engine's lifecycle, not SDK calls."""
|
|
135
|
+
logger.debug("EngineTaskStore.delete ignored for task %s", task_id)
|
|
136
|
+
|
|
137
|
+
async def list(
|
|
138
|
+
self,
|
|
139
|
+
params: ListTasksRequest,
|
|
140
|
+
context: ServerCallContext | None = None,
|
|
141
|
+
) -> ListTasksResponse:
|
|
142
|
+
"""Return an empty listing — the engine doesn't expose a task index.
|
|
143
|
+
|
|
144
|
+
The SDK's ``ListTasks`` RPC is optional; agentling doesn't advertise
|
|
145
|
+
the capability, so callers shouldn't invoke this. We return an empty
|
|
146
|
+
response rather than raise so the handler stays well-behaved if a
|
|
147
|
+
request slips through.
|
|
148
|
+
"""
|
|
149
|
+
logger.debug("EngineTaskStore.list returning empty response")
|
|
150
|
+
return ListTasksResponse()
|