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/sleep.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Nightly sleep cycle: journal, consolidate memory, and clean up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentlings.config import AgentConfig, SleepConfig
|
|
15
|
+
from agentlings.core.llm import BaseLLMClient, BatchRequest
|
|
16
|
+
from agentlings.core.memory_models import (
|
|
17
|
+
ConsolidatedMemory,
|
|
18
|
+
ConversationSummary,
|
|
19
|
+
MemoryCandidate,
|
|
20
|
+
strict_json_schema,
|
|
21
|
+
)
|
|
22
|
+
from agentlings.core.telemetry import sleep_span
|
|
23
|
+
from agentlings.core.memory_store import MemoryFileStore
|
|
24
|
+
from agentlings.core.prompt import build_system_prompt
|
|
25
|
+
from agentlings.core.store import JournalStore
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
IDLE_GRACE_SECONDS = 300
|
|
30
|
+
|
|
31
|
+
DEFAULT_SUMMARY_PROMPT = """\
|
|
32
|
+
You are performing a nightly review of a conversation that took place today.
|
|
33
|
+
|
|
34
|
+
Produce a concise summary of what happened: what was asked, what actions were \
|
|
35
|
+
taken, what the outcome was, and anything left unresolved.
|
|
36
|
+
|
|
37
|
+
Extract any facts worth adding to your long-term memory. Only extract NEW facts \
|
|
38
|
+
not already in your current memory. Focus on operational knowledge, patterns, \
|
|
39
|
+
decisions, things that changed. Ignore passing context.
|
|
40
|
+
|
|
41
|
+
If the conversation was trivial or contained nothing new worth remembering, \
|
|
42
|
+
return an empty memory_candidates list."""
|
|
43
|
+
|
|
44
|
+
DEFAULT_CONSOLIDATION_PROMPT = """\
|
|
45
|
+
You are performing nightly memory maintenance.
|
|
46
|
+
|
|
47
|
+
Your job:
|
|
48
|
+
1. Integrate new candidates that add genuine value. Deduplicate against existing entries.
|
|
49
|
+
2. Review every existing entry. Is it still relevant? Has it been superseded by \
|
|
50
|
+
something learned today? Would it help you do your job tomorrow?
|
|
51
|
+
3. Drop anything that is stale, redundant, or no longer operationally useful.
|
|
52
|
+
4. You have a hard limit of {memory_max_entries} entries.
|
|
53
|
+
|
|
54
|
+
Preserve the recorded timestamp for entries you keep unchanged.
|
|
55
|
+
Set recorded to the current date for new or modified entries."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SleepCycle:
|
|
59
|
+
"""Orchestrates the four-phase nightly sleep cycle."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
config: AgentConfig,
|
|
64
|
+
llm: BaseLLMClient,
|
|
65
|
+
memory_store: MemoryFileStore,
|
|
66
|
+
store: JournalStore,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._config = config
|
|
69
|
+
self._llm = llm
|
|
70
|
+
self._memory_store = memory_store
|
|
71
|
+
self._store = store
|
|
72
|
+
self._sleep_config = config.sleep_config or SleepConfig()
|
|
73
|
+
|
|
74
|
+
async def run(self, date: datetime | None = None) -> None:
|
|
75
|
+
"""Execute the full sleep cycle reviewing the previous day's conversations.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
date: Reference timestamp (defaults to now UTC). The cycle reviews
|
|
79
|
+
conversations from the day before this timestamp.
|
|
80
|
+
"""
|
|
81
|
+
date = date or datetime.now(timezone.utc)
|
|
82
|
+
review_date = date - timedelta(days=1)
|
|
83
|
+
date_str = review_date.strftime("%Y-%m-%d")
|
|
84
|
+
start = time.monotonic()
|
|
85
|
+
logger.info("[SLEEP] Starting cycle for %s", date_str)
|
|
86
|
+
|
|
87
|
+
with sleep_span("agentling.sleep", {"agent.name": self._config.agent_name, "sleep.date": date_str}) as root:
|
|
88
|
+
with sleep_span("agentling.sleep.light_sleep", {"sleep.phase": "light_sleep", "sleep.date": date_str}) as ls:
|
|
89
|
+
context_ids = self._light_sleep(date)
|
|
90
|
+
ls.set_attribute("sleep.conversations_found", len(context_ids))
|
|
91
|
+
if not context_ids:
|
|
92
|
+
ls.set_attribute("sleep.skipped", True)
|
|
93
|
+
logger.info("[SLEEP:LIGHT] No conversations found, skipping cycle")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
logger.info("[SLEEP:LIGHT] Found %d conversations, proceeding", len(context_ids))
|
|
97
|
+
|
|
98
|
+
with sleep_span("agentling.sleep.deep_sleep", {"sleep.phase": "deep_sleep", "sleep.date": date_str}):
|
|
99
|
+
summaries, candidates = await self._deep_sleep(context_ids, date_str)
|
|
100
|
+
|
|
101
|
+
if summaries:
|
|
102
|
+
with sleep_span("agentling.sleep.rem", {"sleep.phase": "rem", "sleep.date": date_str}):
|
|
103
|
+
await self._rem(summaries, candidates, date_str)
|
|
104
|
+
|
|
105
|
+
with sleep_span("agentling.sleep.housekeeping", {"sleep.phase": "housekeeping", "sleep.date": date_str}):
|
|
106
|
+
self._housekeeping(date)
|
|
107
|
+
|
|
108
|
+
elapsed = time.monotonic() - start
|
|
109
|
+
logger.info("[SLEEP] Cycle complete in %.1fs", elapsed)
|
|
110
|
+
|
|
111
|
+
def _light_sleep(self, date: datetime) -> list[str]:
|
|
112
|
+
"""Phase 1: Discover conversations from the previous 24 hours.
|
|
113
|
+
|
|
114
|
+
Returns context IDs whose parent journal was modified since yesterday's
|
|
115
|
+
midnight and has been idle long enough to be safe to process. Supports
|
|
116
|
+
both the new per-context directory layout and legacy flat ``*.jsonl``
|
|
117
|
+
files; the store's ``iter_context_ids`` also migrates legacy files as a
|
|
118
|
+
side effect.
|
|
119
|
+
"""
|
|
120
|
+
data_dir = self._config.agent_data_dir
|
|
121
|
+
cutoff_start = date.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
|
122
|
+
grace_cutoff = datetime.now(timezone.utc) - timedelta(seconds=IDLE_GRACE_SECONDS)
|
|
123
|
+
|
|
124
|
+
context_ids: list[str] = []
|
|
125
|
+
for ctx_id in self._store.iter_context_ids():
|
|
126
|
+
path = self._store._path(ctx_id) # noqa: SLF001 — internal path helper
|
|
127
|
+
if not path.exists():
|
|
128
|
+
continue
|
|
129
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
130
|
+
if cutoff_start <= mtime <= grace_cutoff:
|
|
131
|
+
context_ids.append(ctx_id)
|
|
132
|
+
|
|
133
|
+
return context_ids
|
|
134
|
+
|
|
135
|
+
async def _deep_sleep(
|
|
136
|
+
self,
|
|
137
|
+
context_ids: list[str],
|
|
138
|
+
date_str: str,
|
|
139
|
+
) -> tuple[list[str], list[MemoryCandidate]]:
|
|
140
|
+
"""Phase 2: Replay conversations, submit batch summaries, write journal."""
|
|
141
|
+
system = build_system_prompt(self._config)
|
|
142
|
+
memory = self._memory_store.load()
|
|
143
|
+
memory_text = "\n".join(f"- {e.key}: {e.value}" for e in memory.entries)
|
|
144
|
+
sleep_model = self._sleep_config.model
|
|
145
|
+
|
|
146
|
+
summary_prompt = self._sleep_config.summary_prompt or DEFAULT_SUMMARY_PROMPT
|
|
147
|
+
|
|
148
|
+
batch_requests: list[BatchRequest] = []
|
|
149
|
+
for ctx_id in context_ids:
|
|
150
|
+
messages_data = self._store.replay(ctx_id)
|
|
151
|
+
if not messages_data:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
conversation_text = self._format_conversation(messages_data)
|
|
155
|
+
user_content = (
|
|
156
|
+
f"{summary_prompt}\n\n"
|
|
157
|
+
f"Current memory:\n{memory_text}\n\n"
|
|
158
|
+
f"Conversation:\n{conversation_text}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
batch_requests.append(BatchRequest(
|
|
162
|
+
custom_id=ctx_id,
|
|
163
|
+
system=system,
|
|
164
|
+
messages=[{"role": "user", "content": user_content}],
|
|
165
|
+
max_tokens=4096,
|
|
166
|
+
output_schema=strict_json_schema(ConversationSummary),
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
if not batch_requests:
|
|
170
|
+
return [], []
|
|
171
|
+
|
|
172
|
+
logger.info("[SLEEP:DEEP] Submitting batch of %d summary requests", len(batch_requests))
|
|
173
|
+
|
|
174
|
+
batch_ids = await self._llm.batch_create(batch_requests, model=sleep_model)
|
|
175
|
+
|
|
176
|
+
all_results = []
|
|
177
|
+
for batch_id in batch_ids:
|
|
178
|
+
results = await self._poll_batch(batch_id)
|
|
179
|
+
all_results.extend(results)
|
|
180
|
+
|
|
181
|
+
summaries: list[str] = []
|
|
182
|
+
all_candidates: list[MemoryCandidate] = []
|
|
183
|
+
succeeded = 0
|
|
184
|
+
failed = 0
|
|
185
|
+
|
|
186
|
+
for item in all_results:
|
|
187
|
+
if item.status == "failed":
|
|
188
|
+
failed += 1
|
|
189
|
+
logger.warning("[SLEEP:DEEP] Failed: %s — %s", item.custom_id, item.error)
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
succeeded += 1
|
|
193
|
+
text = self._extract_structured_text(item.content)
|
|
194
|
+
try:
|
|
195
|
+
parsed = ConversationSummary.model_validate_json(text)
|
|
196
|
+
summaries.append(f"### {item.custom_id}\n{parsed.summary}")
|
|
197
|
+
all_candidates.extend(parsed.memory_candidates)
|
|
198
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
199
|
+
logger.warning("[SLEEP:DEEP] Parse error for %s: %s", item.custom_id, e)
|
|
200
|
+
summaries.append(f"### {item.custom_id}\n{text}")
|
|
201
|
+
|
|
202
|
+
logger.info(
|
|
203
|
+
"[SLEEP:DEEP] Batch completed: %d succeeded, %d failed",
|
|
204
|
+
succeeded, failed,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
journal_content = f"# Journal — {date_str}\n\n" + "\n\n".join(summaries)
|
|
208
|
+
self._write_journal(date_str, journal_content)
|
|
209
|
+
|
|
210
|
+
if all_candidates:
|
|
211
|
+
logger.info("[SLEEP:DEEP] Extracted %d memory candidates", len(all_candidates))
|
|
212
|
+
|
|
213
|
+
return summaries, all_candidates
|
|
214
|
+
|
|
215
|
+
async def _rem(
|
|
216
|
+
self,
|
|
217
|
+
summaries: list[str],
|
|
218
|
+
candidates: list[MemoryCandidate],
|
|
219
|
+
date_str: str,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Phase 3: Consolidate memory with today's learnings."""
|
|
222
|
+
memory = self._memory_store.load()
|
|
223
|
+
memory_text = "\n".join(
|
|
224
|
+
f"- {e.key}: {e.value} (recorded: {e.recorded.isoformat()})"
|
|
225
|
+
for e in memory.entries
|
|
226
|
+
)
|
|
227
|
+
journal_text = "\n\n".join(summaries)
|
|
228
|
+
candidates_text = "\n".join(
|
|
229
|
+
f"- {c.key}: {c.value}" for c in candidates
|
|
230
|
+
) if candidates else "(none)"
|
|
231
|
+
|
|
232
|
+
consolidation_prompt = (
|
|
233
|
+
self._sleep_config.consolidation_prompt or DEFAULT_CONSOLIDATION_PROMPT
|
|
234
|
+
).format(memory_max_entries=self._sleep_config.memory_max_entries)
|
|
235
|
+
|
|
236
|
+
entries_before = len(memory.entries)
|
|
237
|
+
logger.info(
|
|
238
|
+
"[SLEEP:REM] Consolidating memory: %d existing + %d candidates",
|
|
239
|
+
entries_before, len(candidates),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
system = build_system_prompt(self._config)
|
|
243
|
+
user_content = (
|
|
244
|
+
f"{consolidation_prompt}\n\n"
|
|
245
|
+
f"Current memory:\n{memory_text}\n\n"
|
|
246
|
+
f"Today's journal:\n{journal_text}\n\n"
|
|
247
|
+
f"New candidates:\n{candidates_text}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
response = await self._llm.complete(
|
|
251
|
+
system=system,
|
|
252
|
+
messages=[{"role": "user", "content": user_content}],
|
|
253
|
+
tools=[],
|
|
254
|
+
output_schema=strict_json_schema(ConsolidatedMemory),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
text = self._extract_structured_text(response.content)
|
|
258
|
+
try:
|
|
259
|
+
consolidated = ConsolidatedMemory.model_validate_json(text)
|
|
260
|
+
from agentlings.core.memory_models import MemoryStore
|
|
261
|
+
new_store = MemoryStore(entries=consolidated.entries)
|
|
262
|
+
self._memory_store.save(new_store)
|
|
263
|
+
|
|
264
|
+
entries_after = len(consolidated.entries)
|
|
265
|
+
logger.info(
|
|
266
|
+
"[SLEEP:REM] Memory updated: %d entries (%+d)",
|
|
267
|
+
entries_after, entries_after - entries_before,
|
|
268
|
+
)
|
|
269
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
270
|
+
logger.error("[SLEEP:REM] Failed to parse consolidated memory: %s", e)
|
|
271
|
+
|
|
272
|
+
def _housekeeping(self, date: datetime) -> None:
|
|
273
|
+
"""Phase 4: Delete old conversation and journal files."""
|
|
274
|
+
import shutil
|
|
275
|
+
|
|
276
|
+
data_dir = self._config.agent_data_dir
|
|
277
|
+
journals_dir = data_dir / "journals"
|
|
278
|
+
|
|
279
|
+
conv_cutoff = date - timedelta(days=self._sleep_config.conversation_retention_days)
|
|
280
|
+
journal_cutoff = date - timedelta(days=self._sleep_config.journal_retention_days)
|
|
281
|
+
|
|
282
|
+
conv_deleted = 0
|
|
283
|
+
bytes_reclaimed = 0
|
|
284
|
+
|
|
285
|
+
# New layout: delete the whole context directory if idle past retention.
|
|
286
|
+
for ctx_id in self._store.iter_context_ids():
|
|
287
|
+
ctx_dir = self._store.context_dir(ctx_id)
|
|
288
|
+
parent = self._store._path(ctx_id) # noqa: SLF001
|
|
289
|
+
if parent.exists():
|
|
290
|
+
mtime = datetime.fromtimestamp(parent.stat().st_mtime, tz=timezone.utc)
|
|
291
|
+
if mtime < conv_cutoff:
|
|
292
|
+
size = sum(p.stat().st_size for p in ctx_dir.rglob("*") if p.is_file())
|
|
293
|
+
if ctx_dir.exists():
|
|
294
|
+
shutil.rmtree(ctx_dir)
|
|
295
|
+
conv_deleted += 1
|
|
296
|
+
bytes_reclaimed += size
|
|
297
|
+
|
|
298
|
+
# Legacy flat files that iter_context_ids may have missed because they
|
|
299
|
+
# were never migrated (e.g. context dir still absent).
|
|
300
|
+
for path in data_dir.glob("*.jsonl"):
|
|
301
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
302
|
+
if mtime < conv_cutoff:
|
|
303
|
+
size = path.stat().st_size
|
|
304
|
+
path.unlink()
|
|
305
|
+
conv_deleted += 1
|
|
306
|
+
bytes_reclaimed += size
|
|
307
|
+
|
|
308
|
+
journal_deleted = 0
|
|
309
|
+
if journals_dir.exists():
|
|
310
|
+
for path in journals_dir.glob("*.md"):
|
|
311
|
+
try:
|
|
312
|
+
file_date = datetime.strptime(path.stem, "%Y-%m-%d").replace(
|
|
313
|
+
tzinfo=timezone.utc
|
|
314
|
+
)
|
|
315
|
+
if file_date < journal_cutoff:
|
|
316
|
+
size = path.stat().st_size
|
|
317
|
+
path.unlink()
|
|
318
|
+
journal_deleted += 1
|
|
319
|
+
bytes_reclaimed += size
|
|
320
|
+
except ValueError:
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
if conv_deleted or journal_deleted:
|
|
324
|
+
logger.info(
|
|
325
|
+
"[SLEEP:HOUSEKEEPING] Deleted %d conversations, %d journals, reclaimed %dKB",
|
|
326
|
+
conv_deleted, journal_deleted, bytes_reclaimed // 1024,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def _poll_batch(
|
|
330
|
+
self,
|
|
331
|
+
batch_id: str,
|
|
332
|
+
timeout: float = 7200,
|
|
333
|
+
initial_interval: float = 5,
|
|
334
|
+
max_interval: float = 60,
|
|
335
|
+
) -> list[Any]:
|
|
336
|
+
"""Poll a batch until completion or timeout, using exponential backoff."""
|
|
337
|
+
deadline = time.monotonic() + timeout
|
|
338
|
+
interval = initial_interval
|
|
339
|
+
while time.monotonic() < deadline:
|
|
340
|
+
status = await self._llm.batch_status(batch_id)
|
|
341
|
+
if status.processing_status == "ended":
|
|
342
|
+
return await self._llm.batch_results(batch_id)
|
|
343
|
+
logger.debug(
|
|
344
|
+
"[SLEEP:DEEP] Batch %s: %s (next poll in %.0fs)",
|
|
345
|
+
batch_id, status.processing_status, interval,
|
|
346
|
+
)
|
|
347
|
+
await asyncio.sleep(interval)
|
|
348
|
+
interval = min(interval * 2, max_interval)
|
|
349
|
+
|
|
350
|
+
logger.warning("[SLEEP:DEEP] Batch %s timed out after %.0fs", batch_id, timeout)
|
|
351
|
+
try:
|
|
352
|
+
return await self._llm.batch_results(batch_id)
|
|
353
|
+
except Exception:
|
|
354
|
+
return []
|
|
355
|
+
|
|
356
|
+
def _write_journal(self, date_str: str, content: str) -> None:
|
|
357
|
+
"""Write the daily journal to the journals directory."""
|
|
358
|
+
journals_dir = self._config.agent_data_dir / "journals"
|
|
359
|
+
journals_dir.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
path = journals_dir / f"{date_str}.md"
|
|
361
|
+
path.write_text(content, encoding="utf-8")
|
|
362
|
+
logger.info("[SLEEP:DEEP] Journal written: %s", path)
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _format_conversation(messages: list[dict[str, Any]]) -> str:
|
|
366
|
+
"""Format replayed messages into readable text for the summary prompt."""
|
|
367
|
+
lines = []
|
|
368
|
+
for msg in messages:
|
|
369
|
+
role = msg.get("role", "unknown")
|
|
370
|
+
content = msg.get("content", "")
|
|
371
|
+
if isinstance(content, str):
|
|
372
|
+
lines.append(f"{role}: {content}")
|
|
373
|
+
elif isinstance(content, list):
|
|
374
|
+
parts = []
|
|
375
|
+
for block in content:
|
|
376
|
+
if isinstance(block, dict):
|
|
377
|
+
if block.get("type") == "text":
|
|
378
|
+
parts.append(block.get("text", ""))
|
|
379
|
+
elif block.get("type") == "tool_use":
|
|
380
|
+
parts.append(f"[tool: {block.get('name', '?')}]")
|
|
381
|
+
elif block.get("type") == "tool_result":
|
|
382
|
+
parts.append(f"[result: {str(block.get('content', ''))[:200]}]")
|
|
383
|
+
if parts:
|
|
384
|
+
lines.append(f"{role}: {' '.join(parts)}")
|
|
385
|
+
return "\n".join(lines)
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _extract_structured_text(content: list[dict[str, Any]]) -> str:
|
|
389
|
+
"""Extract text from content blocks (structured output comes as text blocks)."""
|
|
390
|
+
for block in content:
|
|
391
|
+
if block.get("type") == "text":
|
|
392
|
+
return block.get("text", "")
|
|
393
|
+
return ""
|