agentic-loop 0.3.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.
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+
8
+ @dataclass
9
+ class ToolCall:
10
+ id: str
11
+ name: str
12
+ arguments: str
13
+
14
+
15
+ @dataclass
16
+ class LLMResponse:
17
+ content: str | None
18
+ tool_calls: list[ToolCall]
19
+ raw_message: dict[str, Any]
20
+ finish_reason: str | None = None
21
+
22
+
23
+ @dataclass
24
+ class StreamChunk:
25
+ kind: str
26
+ text: str = ""
27
+ response: LLMResponse | None = None
28
+
29
+
30
+ @runtime_checkable
31
+ class LLMClient(Protocol):
32
+ async def chat(
33
+ self,
34
+ messages: list[dict[str, Any]],
35
+ *,
36
+ tools: list[dict[str, Any]] | None = None,
37
+ ) -> LLMResponse: ...
38
+
39
+ def stream_chat(
40
+ self,
41
+ messages: list[dict[str, Any]],
42
+ *,
43
+ tools: list[dict[str, Any]] | None = None,
44
+ ) -> AsyncIterator[StreamChunk]: ...
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+ from typing import Any
6
+
7
+ from openai import AsyncOpenAI
8
+
9
+ from agentic_loop.llm.client import LLMResponse, StreamChunk, ToolCall
10
+ from agentic_loop.llm.retry import with_retry
11
+
12
+
13
+ def parse_tool_arguments(arguments: str) -> dict[str, Any]:
14
+ try:
15
+ parsed = json.loads(arguments or "{}")
16
+ except json.JSONDecodeError as exc:
17
+ raise ValueError(f"Invalid tool arguments JSON: {exc}") from exc
18
+ if not isinstance(parsed, dict):
19
+ raise ValueError("Tool arguments must be a JSON object")
20
+ return parsed
21
+
22
+
23
+ def _build_raw_message(content: str | None, tool_calls: list[ToolCall]) -> dict[str, Any]:
24
+ raw: dict[str, Any] = {"role": "assistant", "content": content}
25
+ if tool_calls:
26
+ raw["tool_calls"] = [
27
+ {
28
+ "id": tc.id,
29
+ "type": "function",
30
+ "function": {"name": tc.name, "arguments": tc.arguments},
31
+ }
32
+ for tc in tool_calls
33
+ ]
34
+ return raw
35
+
36
+
37
+ def _parse_tool_calls(message_tool_calls: Any) -> list[ToolCall]:
38
+ tool_calls: list[ToolCall] = []
39
+ if not message_tool_calls:
40
+ return tool_calls
41
+ for call in message_tool_calls:
42
+ tool_calls.append(
43
+ ToolCall(
44
+ id=call.id,
45
+ name=call.function.name,
46
+ arguments=call.function.arguments or "{}",
47
+ )
48
+ )
49
+ return tool_calls
50
+
51
+
52
+ class OpenAICompatClient:
53
+ def __init__(
54
+ self,
55
+ *,
56
+ api_key: str,
57
+ base_url: str | None = None,
58
+ model: str,
59
+ timeout: float = 120.0,
60
+ max_retries: int = 3,
61
+ ) -> None:
62
+ self.model = model
63
+ self.max_retries = max_retries
64
+ self._client = AsyncOpenAI(api_key=api_key, base_url=base_url, timeout=timeout)
65
+
66
+ def _request_kwargs(
67
+ self,
68
+ messages: list[dict[str, Any]],
69
+ *,
70
+ tools: list[dict[str, Any]] | None,
71
+ stream: bool,
72
+ ) -> dict[str, Any]:
73
+ kwargs: dict[str, Any] = {
74
+ "model": self.model,
75
+ "messages": messages,
76
+ "stream": stream,
77
+ }
78
+ if tools:
79
+ kwargs["tools"] = tools
80
+ kwargs["tool_choice"] = "auto"
81
+ return kwargs
82
+
83
+ async def chat(
84
+ self,
85
+ messages: list[dict[str, Any]],
86
+ *,
87
+ tools: list[dict[str, Any]] | None = None,
88
+ ) -> LLMResponse:
89
+ async def _call() -> LLMResponse:
90
+ completion = await self._client.chat.completions.create(
91
+ **self._request_kwargs(messages, tools=tools, stream=False)
92
+ )
93
+ message = completion.choices[0].message
94
+ tool_calls = _parse_tool_calls(message.tool_calls)
95
+ return LLMResponse(
96
+ content=message.content,
97
+ tool_calls=tool_calls,
98
+ raw_message=_build_raw_message(message.content, tool_calls),
99
+ finish_reason=completion.choices[0].finish_reason,
100
+ )
101
+
102
+ return await with_retry(_call, max_retries=self.max_retries)
103
+
104
+ async def stream_chat(
105
+ self,
106
+ messages: list[dict[str, Any]],
107
+ *,
108
+ tools: list[dict[str, Any]] | None = None,
109
+ ) -> AsyncIterator[StreamChunk]:
110
+ async def _create_stream():
111
+ return await self._client.chat.completions.create(
112
+ **self._request_kwargs(messages, tools=tools, stream=True)
113
+ )
114
+
115
+ stream = await with_retry(_create_stream, max_retries=self.max_retries)
116
+
117
+ content_parts: list[str] = []
118
+ tool_calls_by_index: dict[int, dict[str, str]] = {}
119
+ finish_reason: str | None = None
120
+
121
+ async for chunk in stream:
122
+ if not chunk.choices:
123
+ continue
124
+ choice = chunk.choices[0]
125
+ finish_reason = choice.finish_reason or finish_reason
126
+ delta = choice.delta
127
+
128
+ if delta.content:
129
+ content_parts.append(delta.content)
130
+ yield StreamChunk(kind="text_delta", text=delta.content)
131
+
132
+ if delta.tool_calls:
133
+ for tool_delta in delta.tool_calls:
134
+ idx = tool_delta.index
135
+ entry = tool_calls_by_index.setdefault(
136
+ idx,
137
+ {"id": "", "name": "", "arguments": ""},
138
+ )
139
+ if tool_delta.id:
140
+ entry["id"] = tool_delta.id
141
+ if tool_delta.function:
142
+ if tool_delta.function.name:
143
+ entry["name"] = tool_delta.function.name
144
+ if tool_delta.function.arguments:
145
+ entry["arguments"] += tool_delta.function.arguments
146
+
147
+ tool_calls = [
148
+ ToolCall(
149
+ id=entry["id"] or f"call_{index}",
150
+ name=entry["name"],
151
+ arguments=entry["arguments"] or "{}",
152
+ )
153
+ for index, entry in sorted(tool_calls_by_index.items())
154
+ if entry["name"]
155
+ ]
156
+ content = "".join(content_parts) or None
157
+ response = LLMResponse(
158
+ content=content,
159
+ tool_calls=tool_calls,
160
+ raw_message=_build_raw_message(content, tool_calls),
161
+ finish_reason=finish_reason,
162
+ )
163
+ yield StreamChunk(kind="done", response=response)
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import TypeVar
6
+
7
+ from openai import APIStatusError, APITimeoutError, RateLimitError
8
+
9
+ T = TypeVar("T")
10
+
11
+ RETRYABLE_EXCEPTIONS = (RateLimitError, APITimeoutError, APIStatusError)
12
+
13
+
14
+ def _is_retryable(exc: Exception) -> bool:
15
+ if isinstance(exc, RateLimitError | APITimeoutError):
16
+ return True
17
+ if isinstance(exc, APIStatusError) and exc.status_code >= 500:
18
+ return True
19
+ return False
20
+
21
+
22
+ async def with_retry(
23
+ operation: Callable[[], Awaitable[T]],
24
+ *,
25
+ max_retries: int = 3,
26
+ base_delay: float = 1.0,
27
+ ) -> T:
28
+ delay = base_delay
29
+ last_exc: Exception | None = None
30
+ for attempt in range(max_retries):
31
+ try:
32
+ return await operation()
33
+ except RETRYABLE_EXCEPTIONS as exc:
34
+ last_exc = exc
35
+ if not _is_retryable(exc) or attempt >= max_retries - 1:
36
+ raise
37
+ await asyncio.sleep(delay)
38
+ delay *= 2
39
+ if last_exc:
40
+ raise last_exc
41
+ raise RuntimeError("with_retry exhausted without result")
agentic_loop/loop.py ADDED
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import AsyncIterator
6
+ from dataclasses import dataclass
7
+ from typing import Any, Protocol, runtime_checkable
8
+
9
+ from agentic_loop.llm.client import LLMClient, LLMResponse, ToolCall
10
+ from agentic_loop.observability.journal import RunJournal
11
+ from agentic_loop.state import LoopState
12
+ from agentic_loop.terminal import Terminal, TerminalKind
13
+ from agentic_loop.tools.registry import ToolRegistry
14
+
15
+ TRUNCATION_NUDGE = (
16
+ "Your previous response was truncated due to output length limits. "
17
+ "Continue directly from where you stopped. Do not repeat earlier content."
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class LoopEvent:
23
+ kind: str
24
+ data: dict[str, Any]
25
+
26
+
27
+ @runtime_checkable
28
+ class AbortSignal(Protocol):
29
+ def is_set(self) -> bool: ...
30
+
31
+
32
+ def _tool_result_message(call: ToolCall, content: str) -> dict[str, Any]:
33
+ return {
34
+ "role": "tool",
35
+ "tool_call_id": call.id,
36
+ "content": content,
37
+ }
38
+
39
+
40
+ def terminal_from_event(data: dict[str, Any]) -> Terminal:
41
+ return Terminal(
42
+ kind=TerminalKind(data["kind"]),
43
+ content=data.get("content"),
44
+ error=data.get("error"),
45
+ turns=data.get("turns", 0),
46
+ )
47
+
48
+
49
+ async def _call_llm(
50
+ llm: LLMClient,
51
+ messages: list[dict[str, Any]],
52
+ *,
53
+ tools: ToolRegistry,
54
+ stream: bool,
55
+ ) -> tuple[LLMResponse, list[str]]:
56
+ deltas: list[str] = []
57
+ if stream and hasattr(llm, "stream_chat"):
58
+ response: LLMResponse | None = None
59
+ async for chunk in llm.stream_chat(messages, tools=tools.schemas()):
60
+ if chunk.kind == "text_delta" and chunk.text:
61
+ deltas.append(chunk.text)
62
+ elif chunk.kind == "done" and chunk.response:
63
+ response = chunk.response
64
+ if response is None:
65
+ raise RuntimeError("Stream ended without a final response")
66
+ return response, deltas
67
+
68
+ response = await llm.chat(messages, tools=tools.schemas())
69
+ if response.content:
70
+ deltas.append(response.content)
71
+ return response, deltas
72
+
73
+
74
+ async def _execute_tools_parallel(
75
+ calls: list[ToolCall],
76
+ *,
77
+ tools: ToolRegistry,
78
+ turn: int,
79
+ journal: RunJournal | None,
80
+ abort: AbortSignal | None,
81
+ tool_timeout: float,
82
+ ) -> list[tuple[ToolCall, str]] | Terminal:
83
+ async def _one(call: ToolCall) -> tuple[ToolCall, str]:
84
+ started = time.perf_counter()
85
+ try:
86
+ result = await asyncio.wait_for(
87
+ tools.execute(call.name, call.arguments),
88
+ timeout=tool_timeout,
89
+ )
90
+ except asyncio.TimeoutError:
91
+ result = f"Error: tool '{call.name}' timed out after {tool_timeout}s"
92
+ duration_ms = (time.perf_counter() - started) * 1000
93
+ if journal:
94
+ journal.tool_result(
95
+ turn=turn,
96
+ name=call.name,
97
+ duration_ms=duration_ms,
98
+ preview=result,
99
+ )
100
+ return call, result
101
+
102
+ if abort and abort.is_set():
103
+ return Terminal.aborted(turns=turn)
104
+
105
+ return await asyncio.gather(*[_one(call) for call in calls])
106
+
107
+
108
+ async def query_loop(
109
+ messages: list[dict[str, Any]],
110
+ *,
111
+ tools: ToolRegistry,
112
+ llm: LLMClient,
113
+ max_turns: int = 20,
114
+ journal: RunJournal | None = None,
115
+ abort: AbortSignal | None = None,
116
+ stream: bool = True,
117
+ tool_timeout: float = 120.0,
118
+ max_truncation_retries: int = 2,
119
+ ) -> AsyncIterator[LoopEvent]:
120
+ state = LoopState(messages=list(messages))
121
+
122
+ for turn in range(1, max_turns + 1):
123
+ if abort and abort.is_set():
124
+ yield LoopEvent(kind="terminal", data=Terminal.aborted(turns=turn - 1).to_dict())
125
+ return
126
+
127
+ yield LoopEvent(kind="turn_start", data={"turn": turn})
128
+
129
+ truncation_attempts = 0
130
+ while True:
131
+ try:
132
+ response, deltas = await _call_llm(
133
+ llm,
134
+ state.messages,
135
+ tools=tools,
136
+ stream=stream,
137
+ )
138
+ except Exception as exc: # noqa: BLE001
139
+ yield LoopEvent(
140
+ kind="terminal",
141
+ data=Terminal.model_error(str(exc), turns=turn - 1).to_dict(),
142
+ )
143
+ return
144
+
145
+ for delta in deltas:
146
+ if stream and delta:
147
+ yield LoopEvent(kind="assistant_delta", data={"turn": turn, "text": delta})
148
+
149
+ if response.finish_reason == "length" and truncation_attempts < max_truncation_retries:
150
+ truncation_attempts += 1
151
+ state = state.with_messages(
152
+ [
153
+ *state.messages,
154
+ {"role": "system", "content": TRUNCATION_NUDGE},
155
+ ]
156
+ )
157
+ continue
158
+ break
159
+
160
+ state = state.with_messages([*state.messages, response.raw_message])
161
+
162
+ if not response.tool_calls:
163
+ if journal:
164
+ journal.turn(turn=turn, tool_names=[])
165
+ terminal = Terminal.completed(response.content, turns=turn)
166
+ yield LoopEvent(kind="terminal", data=terminal.to_dict())
167
+ return
168
+
169
+ tool_names = [call.name for call in response.tool_calls]
170
+ if journal:
171
+ journal.turn(turn=turn, tool_names=tool_names)
172
+
173
+ tool_outcome = await _execute_tools_parallel(
174
+ response.tool_calls,
175
+ tools=tools,
176
+ turn=turn,
177
+ journal=journal,
178
+ abort=abort,
179
+ tool_timeout=tool_timeout,
180
+ )
181
+ if isinstance(tool_outcome, Terminal):
182
+ yield LoopEvent(kind="terminal", data=tool_outcome.to_dict())
183
+ return
184
+
185
+ for call, result in tool_outcome:
186
+ yield LoopEvent(
187
+ kind="tool_result",
188
+ data={"turn": turn, "tool": call.name, "preview": result[:500]},
189
+ )
190
+ state = state.with_messages([*state.messages, _tool_result_message(call, result)])
191
+
192
+ state = state.next_turn(state.messages)
193
+
194
+ last_content = None
195
+ for msg in reversed(state.messages):
196
+ if msg.get("role") == "assistant" and msg.get("content"):
197
+ last_content = msg["content"]
198
+ break
199
+ yield LoopEvent(
200
+ kind="terminal",
201
+ data=Terminal.max_turns(turns=max_turns, last_content=last_content).to_dict(),
202
+ )
203
+
204
+
205
+ async def run_loop(
206
+ messages: list[dict[str, Any]],
207
+ *,
208
+ tools: ToolRegistry,
209
+ llm: LLMClient,
210
+ max_turns: int = 20,
211
+ journal: RunJournal | None = None,
212
+ abort: AbortSignal | None = None,
213
+ stream: bool = False,
214
+ tool_timeout: float = 120.0,
215
+ ) -> Terminal:
216
+ async for event in query_loop(
217
+ messages,
218
+ tools=tools,
219
+ llm=llm,
220
+ max_turns=max_turns,
221
+ journal=journal,
222
+ abort=abort,
223
+ stream=stream,
224
+ tool_timeout=tool_timeout,
225
+ ):
226
+ if event.kind == "terminal":
227
+ return terminal_from_event(event.data)
228
+ return Terminal.failed("Loop ended without terminal event", turns=0)
File without changes
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class RunJournal:
11
+ def __init__(self, runs_dir: Path, run_id: str | None = None) -> None:
12
+ self.run_id = run_id or uuid.uuid4().hex[:12]
13
+ self.runs_dir = runs_dir
14
+ self.path = runs_dir / f"{self.run_id}.jsonl"
15
+ self.runs_dir.mkdir(parents=True, exist_ok=True)
16
+
17
+ def _write(self, event: dict[str, Any]) -> None:
18
+ event = {"ts": datetime.now(timezone.utc).isoformat(), **event}
19
+ with self.path.open("a", encoding="utf-8") as handle:
20
+ handle.write(json.dumps(event, ensure_ascii=False) + "\n")
21
+
22
+ def started(self, *, prompt: str, config: dict[str, Any]) -> None:
23
+ self._write({"event": "run_started", "prompt": prompt, "config": config})
24
+
25
+ def turn(self, *, turn: int, tool_names: list[str]) -> None:
26
+ self._write({"event": "turn", "turn": turn, "tools": tool_names})
27
+
28
+ def tool_result(self, *, turn: int, name: str, duration_ms: float, preview: str) -> None:
29
+ self._write(
30
+ {
31
+ "event": "tool_result",
32
+ "turn": turn,
33
+ "tool": name,
34
+ "duration_ms": round(duration_ms, 2),
35
+ "preview": preview[:500],
36
+ }
37
+ )
38
+
39
+ def finished(self, *, terminal: dict[str, Any], duration_ms: float) -> None:
40
+ self._write(
41
+ {
42
+ "event": "run_finished",
43
+ "terminal": terminal,
44
+ "duration_ms": round(duration_ms, 2),
45
+ }
46
+ )
File without changes
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ _INTERVAL_RE = re.compile(r"^(\d+)(s|m|h|d)$", re.IGNORECASE)
11
+
12
+
13
+ def parse_interval(value: str) -> float:
14
+ """Parse interval like 30s, 5m, 2h into seconds."""
15
+ match = _INTERVAL_RE.match(value.strip())
16
+ if not match:
17
+ raise ValueError(
18
+ f"Invalid interval '{value}'. Use formats like 30s, 5m, 2h, 1d.\n"
19
+ "Example: agentic-loop loop --every 5m \"triage open issues\""
20
+ )
21
+ amount = int(match.group(1))
22
+ unit = match.group(2).lower()
23
+ multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
24
+ return float(amount * multipliers[unit])
25
+
26
+
27
+ @dataclass
28
+ class AutomationResult:
29
+ runs: int
30
+ last_error: str | None = None
31
+
32
+
33
+ async def run_interval(
34
+ *,
35
+ every: str,
36
+ task: Callable[[], Awaitable[Any]],
37
+ once: bool = False,
38
+ max_runs: int | None = None,
39
+ ) -> AutomationResult:
40
+ seconds = parse_interval(every)
41
+ runs = 0
42
+ last_error: str | None = None
43
+
44
+ while True:
45
+ try:
46
+ await task()
47
+ except Exception as exc: # noqa: BLE001
48
+ last_error = str(exc)
49
+ runs += 1
50
+ if once or (max_runs is not None and runs >= max_runs):
51
+ break
52
+ await asyncio.sleep(seconds)
53
+
54
+ return AutomationResult(runs=runs, last_error=last_error)
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from agentic_loop.config import RunConfig
9
+ from agentic_loop.llm.openai_compat import OpenAICompatClient
10
+ from agentic_loop.terminal import Terminal
11
+
12
+
13
+ @dataclass
14
+ class GoalEvaluation:
15
+ satisfied: bool
16
+ reason: str
17
+
18
+
19
+ EVALUATOR_SYSTEM = """You are an independent goal evaluator for an agent loop.
20
+ You do NOT execute tools. You judge whether a stopping condition is satisfied based on:
21
+ 1) the goal condition text, and
22
+ 2) the worker agent's final response and run summary.
23
+
24
+ Reply with JSON only: {"satisfied": true|false, "reason": "..."}"""
25
+
26
+
27
+ class GoalRunner:
28
+ def __init__(self, *, config: RunConfig) -> None:
29
+ self.config = config
30
+
31
+ async def evaluate(self, *, condition: str, worker_result: str, turns: int) -> GoalEvaluation:
32
+ if self.config.dry_run:
33
+ return GoalEvaluation(satisfied=True, reason="[dry-run] goal assumed satisfied")
34
+
35
+ self.config.require_api_key()
36
+ client = OpenAICompatClient(
37
+ api_key=self.config.api_key or "",
38
+ base_url=self.config.base_url,
39
+ model=self.config.effective_evaluator_model,
40
+ max_retries=self.config.max_retries,
41
+ )
42
+ user_content = (
43
+ f"Goal condition:\n{condition}\n\n"
44
+ f"Worker turns: {turns}\n\n"
45
+ f"Worker final output:\n{worker_result}\n\n"
46
+ "Is the goal condition satisfied?"
47
+ )
48
+ response = await client.chat(
49
+ [
50
+ {"role": "system", "content": EVALUATOR_SYSTEM},
51
+ {"role": "user", "content": user_content},
52
+ ]
53
+ )
54
+ text = (response.content or "").strip()
55
+ match = re.search(r"\{.*\}", text, re.DOTALL)
56
+ if match:
57
+ try:
58
+ data = json.loads(match.group(0))
59
+ return GoalEvaluation(
60
+ satisfied=bool(data.get("satisfied")),
61
+ reason=str(data.get("reason", "")),
62
+ )
63
+ except json.JSONDecodeError:
64
+ pass
65
+ lowered = text.lower()
66
+ satisfied = any(word in lowered for word in ("true", "satisfied", "yes", "complete", "passed"))
67
+ return GoalEvaluation(satisfied=satisfied, reason=text[:500] or "Could not parse evaluator output")
68
+
69
+ async def run_until_goal(
70
+ self,
71
+ *,
72
+ condition: str,
73
+ prompt: str,
74
+ system_prompt: str | None = None,
75
+ max_rounds: int = 10,
76
+ on_event=None,
77
+ ) -> tuple[Terminal, list[GoalEvaluation]]:
78
+ from agentic_loop.api import execute_run
79
+
80
+ evaluations: list[GoalEvaluation] = []
81
+ last_terminal: Terminal | None = None
82
+
83
+ for round_idx in range(1, max_rounds + 1):
84
+ round_prompt = prompt
85
+ if round_idx > 1 and last_terminal and last_terminal.content:
86
+ round_prompt = (
87
+ f"{prompt}\n\nPrevious attempt summary:\n{last_terminal.content}\n"
88
+ f"Continue working toward the goal."
89
+ )
90
+
91
+ terminal, _journal = await execute_run(
92
+ round_prompt,
93
+ config=self.config,
94
+ system_prompt=system_prompt,
95
+ on_event=on_event,
96
+ )
97
+ last_terminal = terminal
98
+ evaluation = await self.evaluate(
99
+ condition=condition,
100
+ worker_result=terminal.content or terminal.error or "",
101
+ turns=terminal.turns,
102
+ )
103
+ evaluations.append(evaluation)
104
+ if evaluation.satisfied:
105
+ return Terminal.completed(
106
+ f"Goal satisfied after round {round_idx}: {evaluation.reason}\n\n"
107
+ f"{terminal.content or ''}",
108
+ turns=terminal.turns,
109
+ ), evaluations
110
+
111
+ return Terminal.failed(
112
+ f"Goal not satisfied after {max_rounds} rounds. Last reason: {evaluations[-1].reason if evaluations else 'none'}",
113
+ turns=last_terminal.turns if last_terminal else 0,
114
+ ), evaluations