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
agentlings/core/llm.py ADDED
@@ -0,0 +1,509 @@
1
+ """LLM client abstraction with Anthropic and mock backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, AsyncIterator, Literal
11
+ from uuid import uuid4
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ BATCH_MAX_REQUESTS = 100_000
16
+
17
+ # Matches ``delay-<seconds>`` in mock user messages — used by integration
18
+ # tests to produce genuinely slow responses without global configuration.
19
+ # Example: ``"delay-5 please answer"`` sleeps 5 seconds before responding.
20
+ _MOCK_DELAY_RE = re.compile(r"delay-(\d+(?:\.\d+)?)")
21
+
22
+
23
+ @dataclass
24
+ class LLMResponse:
25
+ """Container for an LLM completion response.
26
+
27
+ Attributes:
28
+ content: List of content blocks (text, tool_use, etc.) from the model.
29
+ stop_reason: Why the model stopped generating (e.g. ``"end_turn"``, ``"tool_use"``).
30
+ """
31
+
32
+ content: list[dict[str, Any]]
33
+ stop_reason: str | None = None
34
+
35
+
36
+ @dataclass
37
+ class BatchRequest:
38
+ """A single request within a batch submission.
39
+
40
+ Attributes:
41
+ custom_id: Caller-provided ID for correlating results.
42
+ system: System prompt blocks.
43
+ messages: Conversation messages.
44
+ max_tokens: Maximum response tokens.
45
+ output_schema: JSON Schema for structured output (used with ``output_config``).
46
+ """
47
+
48
+ custom_id: str
49
+ system: list[dict[str, Any]]
50
+ messages: list[dict[str, Any]]
51
+ max_tokens: int = 4096
52
+ output_schema: dict[str, Any] | None = None
53
+
54
+
55
+ @dataclass
56
+ class BatchItemResult:
57
+ """Result for a single request within a completed batch.
58
+
59
+ Attributes:
60
+ custom_id: The caller-provided ID from the request.
61
+ content: Response content blocks (or empty on failure).
62
+ status: Whether this individual request succeeded or failed.
63
+ error: Error message if the request failed.
64
+ """
65
+
66
+ custom_id: str
67
+ content: list[dict[str, Any]] = field(default_factory=list)
68
+ status: Literal["succeeded", "failed"] = "succeeded"
69
+ error: str | None = None
70
+
71
+
72
+ @dataclass
73
+ class BatchStatus:
74
+ """Status of a batch job.
75
+
76
+ Attributes:
77
+ batch_id: The batch identifier.
78
+ processing_status: Current batch state.
79
+ succeeded: Count of succeeded requests.
80
+ failed: Count of failed requests.
81
+ total: Total number of requests.
82
+ """
83
+
84
+ batch_id: str
85
+ processing_status: str
86
+ succeeded: int = 0
87
+ failed: int = 0
88
+ total: int = 0
89
+
90
+
91
+ class BaseLLMClient(ABC):
92
+ """Abstract interface for LLM completion backends."""
93
+
94
+ @abstractmethod
95
+ async def complete(
96
+ self,
97
+ system: list[dict[str, Any]],
98
+ messages: list[dict[str, Any]],
99
+ tools: list[dict[str, Any]],
100
+ output_schema: dict[str, Any] | None = None,
101
+ ) -> LLMResponse:
102
+ """Send a completion request and return the full response.
103
+
104
+ Args:
105
+ system: System prompt blocks.
106
+ messages: Conversation message history.
107
+ tools: Tool schemas available to the model.
108
+ output_schema: JSON Schema for structured output. When provided,
109
+ the model is constrained to return valid JSON matching this schema.
110
+
111
+ Returns:
112
+ The model's response content and stop reason.
113
+ """
114
+ ...
115
+
116
+ @abstractmethod
117
+ async def stream(
118
+ self,
119
+ system: list[dict[str, Any]],
120
+ messages: list[dict[str, Any]],
121
+ tools: list[dict[str, Any]],
122
+ ) -> AsyncIterator[dict[str, Any]]:
123
+ """Stream completion response blocks incrementally.
124
+
125
+ Args:
126
+ system: System prompt blocks.
127
+ messages: Conversation message history.
128
+ tools: Tool schemas available to the model.
129
+
130
+ Yields:
131
+ Individual content blocks as they arrive.
132
+ """
133
+ ...
134
+
135
+ @abstractmethod
136
+ async def count_tokens(self, text: str) -> int:
137
+ """Count the number of tokens in a text string.
138
+
139
+ Args:
140
+ text: The text to tokenize.
141
+
142
+ Returns:
143
+ The token count.
144
+ """
145
+ ...
146
+
147
+ @abstractmethod
148
+ async def batch_create(self, requests: list[BatchRequest], model: str | None = None) -> list[str]:
149
+ """Submit batch requests, chunking at the API limit.
150
+
151
+ Args:
152
+ requests: The batch requests to submit.
153
+ model: Model override (uses client default if ``None``).
154
+
155
+ Returns:
156
+ List of batch IDs (one per chunk).
157
+ """
158
+ ...
159
+
160
+ @abstractmethod
161
+ async def batch_status(self, batch_id: str) -> BatchStatus:
162
+ """Poll the status of a batch job.
163
+
164
+ Args:
165
+ batch_id: The batch identifier.
166
+
167
+ Returns:
168
+ Current batch status.
169
+ """
170
+ ...
171
+
172
+ @abstractmethod
173
+ async def batch_results(self, batch_id: str) -> list[BatchItemResult]:
174
+ """Retrieve results from a completed batch.
175
+
176
+ Args:
177
+ batch_id: The batch identifier.
178
+
179
+ Returns:
180
+ List of per-request results.
181
+ """
182
+ ...
183
+
184
+
185
+ class AnthropicLLMClient(BaseLLMClient):
186
+ """LLM client backed by the Anthropic Messages API.
187
+
188
+ Also works against any Anthropic-compatible endpoint by passing
189
+ ``base_url`` — for example Ollama's ``/v1/messages`` compatibility
190
+ layer at ``http://localhost:11434``. When pointed at such a backend,
191
+ set the model to one served by that backend (e.g. ``"qwen3-coder"``)
192
+ and disable features the backend doesn't implement (batches, the
193
+ sleep cycle, structured output) via configuration.
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ api_key: str,
199
+ model: str,
200
+ max_tokens: int,
201
+ base_url: str | None = None,
202
+ ) -> None:
203
+ import anthropic
204
+
205
+ client_kwargs: dict[str, Any] = {"api_key": api_key or "unset"}
206
+ if base_url:
207
+ client_kwargs["base_url"] = base_url
208
+ self._client = anthropic.AsyncAnthropic(**client_kwargs)
209
+ self._model = model
210
+ self._max_tokens = max_tokens
211
+
212
+ async def complete(
213
+ self,
214
+ system: list[dict[str, Any]],
215
+ messages: list[dict[str, Any]],
216
+ tools: list[dict[str, Any]],
217
+ output_schema: dict[str, Any] | None = None,
218
+ ) -> LLMResponse:
219
+ kwargs: dict[str, Any] = {
220
+ "model": self._model,
221
+ "max_tokens": self._max_tokens,
222
+ "system": system,
223
+ "messages": messages,
224
+ }
225
+ if tools:
226
+ kwargs["tools"] = tools
227
+ if output_schema:
228
+ kwargs["output_config"] = {
229
+ "format": {
230
+ "type": "json_schema",
231
+ "schema": output_schema,
232
+ }
233
+ }
234
+
235
+ response = await self._client.messages.create(**kwargs)
236
+ return LLMResponse(
237
+ content=[block.model_dump() for block in response.content],
238
+ stop_reason=response.stop_reason,
239
+ )
240
+
241
+ async def stream(
242
+ self,
243
+ system: list[dict[str, Any]],
244
+ messages: list[dict[str, Any]],
245
+ tools: list[dict[str, Any]],
246
+ ) -> AsyncIterator[dict[str, Any]]:
247
+ raise NotImplementedError("Streaming not yet implemented")
248
+ yield # pragma: no cover
249
+
250
+ async def count_tokens(self, text: str) -> int:
251
+ result = await self._client.messages.count_tokens(
252
+ model=self._model,
253
+ messages=[{"role": "user", "content": text}],
254
+ )
255
+ return result.input_tokens
256
+
257
+ async def batch_create(self, requests: list[BatchRequest], model: str | None = None) -> list[str]:
258
+ from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
259
+ from anthropic.types.messages.batch_create_params import Request
260
+
261
+ use_model = model or self._model
262
+ batch_ids: list[str] = []
263
+
264
+ for i in range(0, len(requests), BATCH_MAX_REQUESTS):
265
+ chunk = requests[i : i + BATCH_MAX_REQUESTS]
266
+ api_requests = []
267
+ for req in chunk:
268
+ params: dict[str, Any] = {
269
+ "model": use_model,
270
+ "max_tokens": req.max_tokens,
271
+ "system": req.system,
272
+ "messages": req.messages,
273
+ }
274
+ if req.output_schema:
275
+ params["output_config"] = {
276
+ "format": {
277
+ "type": "json_schema",
278
+ "schema": req.output_schema,
279
+ }
280
+ }
281
+ api_requests.append(
282
+ Request(
283
+ custom_id=req.custom_id,
284
+ params=MessageCreateParamsNonStreaming(**params),
285
+ )
286
+ )
287
+
288
+ result = await self._client.messages.batches.create(requests=api_requests)
289
+ batch_ids.append(result.id)
290
+ logger.info("batch submitted: %s (%d requests)", result.id, len(chunk))
291
+
292
+ return batch_ids
293
+
294
+ async def batch_status(self, batch_id: str) -> BatchStatus:
295
+ result = await self._client.messages.batches.retrieve(batch_id)
296
+ counts = result.request_counts
297
+ return BatchStatus(
298
+ batch_id=result.id,
299
+ processing_status=result.processing_status,
300
+ succeeded=counts.succeeded,
301
+ failed=counts.errored + counts.expired,
302
+ total=counts.succeeded + counts.errored + counts.expired + counts.processing,
303
+ )
304
+
305
+ async def batch_results(self, batch_id: str) -> list[BatchItemResult]:
306
+ items: list[BatchItemResult] = []
307
+ async for entry in await self._client.messages.batches.results(batch_id):
308
+ if entry.result.type == "succeeded":
309
+ content = [block.model_dump() for block in entry.result.message.content]
310
+ items.append(BatchItemResult(
311
+ custom_id=entry.custom_id,
312
+ content=content,
313
+ status="succeeded",
314
+ ))
315
+ else:
316
+ error_msg = str(entry.result.error) if hasattr(entry.result, "error") else "unknown error"
317
+ items.append(BatchItemResult(
318
+ custom_id=entry.custom_id,
319
+ status="failed",
320
+ error=error_msg,
321
+ ))
322
+ return items
323
+
324
+
325
+ class MockLLMClient(BaseLLMClient):
326
+ """Deterministic mock LLM client for testing without API calls.
327
+
328
+ Returns canned responses based on pattern matching:
329
+ tool name in input triggers tool_use, tool results get text replies,
330
+ everything else gets an echo response.
331
+
332
+ Integration tests can produce genuinely slow responses by embedding
333
+ ``delay-<seconds>`` in the user message (e.g. ``"delay-5 please
334
+ answer"``). The mock sleeps that many seconds before responding. The
335
+ delay is skipped on tool-result loops so multi-turn flows don't re-sleep.
336
+ """
337
+
338
+ def __init__(
339
+ self,
340
+ tool_names: list[str] | None = None,
341
+ compaction_threshold: int = 10,
342
+ ) -> None:
343
+ self._tool_names = tool_names or []
344
+ self._compaction_threshold = compaction_threshold
345
+ self._call_count = 0
346
+ self._batch_store: dict[str, list[BatchRequest]] = {}
347
+
348
+ async def complete(
349
+ self,
350
+ system: list[dict[str, Any]],
351
+ messages: list[dict[str, Any]],
352
+ tools: list[dict[str, Any]],
353
+ output_schema: dict[str, Any] | None = None,
354
+ ) -> LLMResponse:
355
+ self._call_count += 1
356
+ last_message = messages[-1] if messages else {}
357
+ last_text = _extract_text(last_message)
358
+
359
+ # Honour embedded ``delay-N`` markers, but only on fresh user/assistant
360
+ # turns — never on tool-result loops (those would re-trip the sleep
361
+ # every iteration of the completion loop).
362
+ content = last_message.get("content")
363
+ is_tool_result_turn = (
364
+ isinstance(content, list)
365
+ and any(
366
+ isinstance(b, dict) and b.get("type") == "tool_result"
367
+ for b in content
368
+ )
369
+ )
370
+ if not is_tool_result_turn:
371
+ m = _MOCK_DELAY_RE.search(last_text)
372
+ if m:
373
+ await asyncio.sleep(float(m.group(1)))
374
+
375
+ if last_message.get("role") == "tool":
376
+ return LLMResponse(
377
+ content=[{"type": "text", "text": f"Tool result received: {last_text}"}],
378
+ stop_reason="end_turn",
379
+ )
380
+
381
+ for tool_name in self._tool_names:
382
+ if tool_name in last_text:
383
+ return LLMResponse(
384
+ content=[{
385
+ "type": "tool_use",
386
+ "id": f"toolu_{uuid4().hex[:12]}",
387
+ "name": tool_name,
388
+ "input": _build_mock_tool_input(tool_name, last_text),
389
+ }],
390
+ stop_reason="tool_use",
391
+ )
392
+
393
+ return LLMResponse(
394
+ content=[{"type": "text", "text": f"Mock response to: {last_text}"}],
395
+ stop_reason="end_turn",
396
+ )
397
+
398
+ async def stream(
399
+ self,
400
+ system: list[dict[str, Any]],
401
+ messages: list[dict[str, Any]],
402
+ tools: list[dict[str, Any]],
403
+ ) -> AsyncIterator[dict[str, Any]]:
404
+ response = await self.complete(system, messages, tools)
405
+ for block in response.content:
406
+ yield block
407
+
408
+ async def count_tokens(self, text: str) -> int:
409
+ return len(text) // 4
410
+
411
+ async def batch_create(self, requests: list[BatchRequest], model: str | None = None) -> list[str]:
412
+ batch_id = f"mock_batch_{uuid4().hex[:8]}"
413
+ self._batch_store[batch_id] = requests
414
+ return [batch_id]
415
+
416
+ async def batch_status(self, batch_id: str) -> BatchStatus:
417
+ requests = self._batch_store.get(batch_id, [])
418
+ return BatchStatus(
419
+ batch_id=batch_id,
420
+ processing_status="ended",
421
+ succeeded=len(requests),
422
+ failed=0,
423
+ total=len(requests),
424
+ )
425
+
426
+ async def batch_results(self, batch_id: str) -> list[BatchItemResult]:
427
+ import json
428
+ requests = self._batch_store.get(batch_id, [])
429
+ results: list[BatchItemResult] = []
430
+ for req in requests:
431
+ text = _extract_text(req.messages[-1]) if req.messages else ""
432
+ mock_output = {
433
+ "summary": f"Summary of conversation: {text[:100]}",
434
+ "memory_candidates": [],
435
+ }
436
+ results.append(BatchItemResult(
437
+ custom_id=req.custom_id,
438
+ content=[{"type": "text", "text": json.dumps(mock_output)}],
439
+ status="succeeded",
440
+ ))
441
+ return results
442
+
443
+
444
+ def create_llm_client(
445
+ backend: str,
446
+ api_key: str = "",
447
+ model: str = "claude-sonnet-4-6",
448
+ max_tokens: int = 4096,
449
+ tool_names: list[str] | None = None,
450
+ base_url: str | None = None,
451
+ ) -> BaseLLMClient:
452
+ """Factory that returns the appropriate LLM client for the given backend.
453
+
454
+ Args:
455
+ backend: Either ``"anthropic"`` for the real API or ``"mock"`` for testing.
456
+ api_key: Anthropic API key. Required for the upstream Anthropic API,
457
+ optional when ``base_url`` points at a compatible backend (e.g.
458
+ Ollama) that does not validate the key.
459
+ model: Model identifier to use for completions.
460
+ max_tokens: Maximum tokens in the model response.
461
+ tool_names: Tool names the mock backend should recognize.
462
+ base_url: Optional override for the Anthropic Messages endpoint.
463
+ When set, the client talks to that URL instead of api.anthropic.com.
464
+
465
+ Returns:
466
+ A configured LLM client instance.
467
+ """
468
+ if backend == "mock":
469
+ logger.info("using mock LLM backend")
470
+ return MockLLMClient(tool_names=tool_names)
471
+
472
+ if base_url:
473
+ logger.info("using Anthropic-compatible LLM backend (model=%s, base_url=%s)", model, base_url)
474
+ else:
475
+ logger.info("using Anthropic LLM backend (model=%s)", model)
476
+ return AnthropicLLMClient(
477
+ api_key=api_key, model=model, max_tokens=max_tokens, base_url=base_url,
478
+ )
479
+
480
+
481
+ def _extract_text(message: dict[str, Any]) -> str:
482
+ content = message.get("content", "")
483
+ if isinstance(content, str):
484
+ return content
485
+ if isinstance(content, list):
486
+ parts = []
487
+ for block in content:
488
+ if isinstance(block, dict) and block.get("type") == "text":
489
+ parts.append(block.get("text", ""))
490
+ elif isinstance(block, dict) and block.get("type") == "tool_result":
491
+ parts.append(str(block.get("content", "")))
492
+ return " ".join(parts)
493
+ return str(content)
494
+
495
+
496
+ def _build_mock_tool_input(tool_name: str, text: str) -> dict[str, Any]:
497
+ if tool_name == "bash":
498
+ return {"command": "echo 'mock command'"}
499
+ if tool_name == "read_file":
500
+ return {"path": "/tmp/mock_file.txt"}
501
+ if tool_name == "write_file":
502
+ return {"path": "/tmp/mock_file.txt", "content": "mock content"}
503
+ if tool_name == "edit_file":
504
+ return {"path": "/tmp/mock_file.txt", "old_text": "old", "new_text": "new"}
505
+ if tool_name == "list_directory":
506
+ return {"path": "/tmp"}
507
+ if tool_name == "search_files":
508
+ return {"path": "/tmp", "pattern": "*.txt"}
509
+ return {"input": text}
@@ -0,0 +1,134 @@
1
+ """Message loop — the single entrance point for all agent interactions.
2
+
3
+ In v2, the loop is a thin backwards-compatible facade over ``TaskEngine``.
4
+ Every call to ``process_message`` spawns a task, blocks until it reaches a
5
+ terminal state, and returns the final response as a ``LoopResult``.
6
+
7
+ Protocol adapters that want the full task surface (poll, cancel, slow-path
8
+ yield) should call ``TaskEngine`` directly via the ``engine`` property.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from agentlings.config import AgentConfig
18
+ from agentlings.core.llm import BaseLLMClient
19
+ from agentlings.core.memory_store import MemoryFileStore
20
+ from agentlings.core.store import JournalStore
21
+ from agentlings.core.task import (
22
+ TaskEngine,
23
+ TaskState,
24
+ TaskStatus,
25
+ )
26
+ from agentlings.tools.registry import ToolRegistry
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # "Effectively unlimited" await for callers that expect synchronous semantics.
32
+ # Real deployments rely on ``TaskEngine.spawn`` directly with the configured
33
+ # ``AGENT_TASK_AWAIT_SECONDS`` cap.
34
+ _SYNCHRONOUS_AWAIT_SECONDS = 3600.0
35
+
36
+
37
+ class LoopError(RuntimeError):
38
+ """Raised when the synchronous message-loop facade cannot deliver a result.
39
+
40
+ Occurs when the underlying task failed or was cancelled. The ``state``
41
+ attribute carries the ``TaskState`` for callers that need to inspect.
42
+ """
43
+
44
+ def __init__(self, state: TaskState) -> None:
45
+ super().__init__(
46
+ f"task {state.task_id} ended with status {state.status.value}: {state.error or ''}"
47
+ )
48
+ self.state = state
49
+
50
+
51
+ @dataclass
52
+ class LoopResult:
53
+ """Result of processing a message through the loop.
54
+
55
+ Attributes:
56
+ context_id: The conversation context identifier.
57
+ content: Anthropic-format content blocks from the final LLM response.
58
+ task_id: The task identifier used to execute this message (v2).
59
+ """
60
+
61
+ context_id: str
62
+ content: list[dict[str, Any]]
63
+ task_id: str | None = None
64
+
65
+
66
+ class MessageLoop:
67
+ """Synchronous facade over ``TaskEngine`` for legacy callers.
68
+
69
+ Attributes:
70
+ engine: The underlying task engine. Protocol adapters should prefer
71
+ its API (``spawn``, ``poll``, ``cancel``) when they need the
72
+ task-based surface.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ config: AgentConfig,
78
+ store: JournalStore,
79
+ llm: BaseLLMClient,
80
+ tools: ToolRegistry,
81
+ memory_store: MemoryFileStore | None = None,
82
+ ) -> None:
83
+ self._config = config
84
+ self._store = store
85
+ self._engine = TaskEngine(
86
+ config=config,
87
+ store=store,
88
+ llm=llm,
89
+ tools=tools,
90
+ memory_store=memory_store,
91
+ )
92
+
93
+ @property
94
+ def engine(self) -> TaskEngine:
95
+ """The underlying task engine."""
96
+ return self._engine
97
+
98
+ async def process_message(
99
+ self,
100
+ text: str,
101
+ context_id: str | None = None,
102
+ stream: bool = False,
103
+ via: str = "a2a",
104
+ ) -> LoopResult:
105
+ """Process a user message and return the agent's response.
106
+
107
+ Backward-compatible synchronous API. Internally spawns a task and
108
+ waits for it to reach a terminal state.
109
+
110
+ Args:
111
+ text: The user's message text.
112
+ context_id: Existing context ID to continue, or ``None`` for a new one.
113
+ stream: Legacy flag; currently ignored.
114
+ via: Protocol that originated the request (``"a2a"`` or ``"mcp"``).
115
+
116
+ Returns:
117
+ The context ID, the final response content blocks, and the task ID.
118
+
119
+ Raises:
120
+ LoopError: If the underlying task failed or was cancelled.
121
+ """
122
+ state = await self._engine.spawn(
123
+ message=text,
124
+ context_id=context_id,
125
+ via=via,
126
+ await_seconds=_SYNCHRONOUS_AWAIT_SECONDS,
127
+ )
128
+ if state.status != TaskStatus.COMPLETED:
129
+ raise LoopError(state)
130
+ return LoopResult(
131
+ context_id=state.context_id,
132
+ content=state.content,
133
+ task_id=state.task_id,
134
+ )