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
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}
|
agentlings/core/loop.py
ADDED
|
@@ -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
|
+
)
|