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.
- agentic_loop/__init__.py +31 -0
- agentic_loop/abort.py +19 -0
- agentic_loop/api.py +119 -0
- agentic_loop/cli.py +299 -0
- agentic_loop/config.py +73 -0
- agentic_loop/connectors/__init__.py +0 -0
- agentic_loop/connectors/base.py +39 -0
- agentic_loop/connectors/mcp.py +99 -0
- agentic_loop/llm/__init__.py +0 -0
- agentic_loop/llm/client.py +44 -0
- agentic_loop/llm/openai_compat.py +163 -0
- agentic_loop/llm/retry.py +41 -0
- agentic_loop/loop.py +228 -0
- agentic_loop/observability/__init__.py +0 -0
- agentic_loop/observability/journal.py +46 -0
- agentic_loop/orchestration/__init__.py +0 -0
- agentic_loop/orchestration/automations.py +54 -0
- agentic_loop/orchestration/goal.py +114 -0
- agentic_loop/orchestration/memory.py +145 -0
- agentic_loop/orchestration/orchestrator.py +119 -0
- agentic_loop/orchestration/subagents.py +66 -0
- agentic_loop/skills/__init__.py +0 -0
- agentic_loop/skills/loader.py +52 -0
- agentic_loop/state.py +28 -0
- agentic_loop/terminal.py +55 -0
- agentic_loop/tools/__init__.py +3 -0
- agentic_loop/tools/builtin.py +3 -0
- agentic_loop/tools/mcp_bridge.py +36 -0
- agentic_loop/tools/registry.py +222 -0
- agentic_loop/worktree/__init__.py +0 -0
- agentic_loop/worktree/manager.py +47 -0
- agentic_loop-0.3.0.dist-info/METADATA +110 -0
- agentic_loop-0.3.0.dist-info/RECORD +37 -0
- agentic_loop-0.3.0.dist-info/WHEEL +5 -0
- agentic_loop-0.3.0.dist-info/entry_points.txt +2 -0
- agentic_loop-0.3.0.dist-info/licenses/LICENSE +21 -0
- agentic_loop-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|