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.
Files changed (43) hide show
  1. agentlings/__init__.py +3 -0
  2. agentlings/__main__.py +238 -0
  3. agentlings/cli/__init__.py +1 -0
  4. agentlings/cli/_migrations.py +78 -0
  5. agentlings/cli/_templates.py +57 -0
  6. agentlings/cli/_version.py +33 -0
  7. agentlings/cli/init.py +122 -0
  8. agentlings/cli/upgrade.py +89 -0
  9. agentlings/config.py +260 -0
  10. agentlings/core/__init__.py +1 -0
  11. agentlings/core/completion.py +219 -0
  12. agentlings/core/llm.py +509 -0
  13. agentlings/core/loop.py +134 -0
  14. agentlings/core/memory_models.py +97 -0
  15. agentlings/core/memory_store.py +109 -0
  16. agentlings/core/models.py +231 -0
  17. agentlings/core/prompt.py +122 -0
  18. agentlings/core/scheduler.py +141 -0
  19. agentlings/core/sleep.py +393 -0
  20. agentlings/core/store.py +318 -0
  21. agentlings/core/task.py +1087 -0
  22. agentlings/core/telemetry.py +181 -0
  23. agentlings/log.py +23 -0
  24. agentlings/migrations/__init__.py +37 -0
  25. agentlings/migrations/m0001_seed.py +17 -0
  26. agentlings/protocol/__init__.py +1 -0
  27. agentlings/protocol/a2a.py +220 -0
  28. agentlings/protocol/a2a_task_store.py +150 -0
  29. agentlings/protocol/agent_card.py +83 -0
  30. agentlings/protocol/mcp.py +232 -0
  31. agentlings/server.py +247 -0
  32. agentlings/templates/__init__.py +1 -0
  33. agentlings/templates/default/.env.example +16 -0
  34. agentlings/templates/default/agent.yaml +14 -0
  35. agentlings/tools/__init__.py +1 -0
  36. agentlings/tools/builtins.py +307 -0
  37. agentlings/tools/memory.py +104 -0
  38. agentlings/tools/registry.py +154 -0
  39. agentlings-0.2.0.dist-info/METADATA +406 -0
  40. agentlings-0.2.0.dist-info/RECORD +43 -0
  41. agentlings-0.2.0.dist-info/WHEEL +4 -0
  42. agentlings-0.2.0.dist-info/entry_points.txt +2 -0
  43. 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()