agent-runloop 0.1.0__tar.gz → 0.2.1__tar.gz
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.
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/PKG-INFO +38 -5
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/README.md +34 -3
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/__init__.py +3 -3
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/loop.py +13 -1
- agent_runloop-0.2.1/agent_loop/openai.py +243 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/pyproject.toml +3 -2
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/.gitignore +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/LICENSE +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/_trace.py +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/backend.py +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/compose.py +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/hooks.py +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/ollama.py +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/prompts.py +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/py.typed +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/runs.py +0 -0
- {agent_runloop-0.1.0 → agent_runloop-0.2.1}/agent_loop/usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-runloop
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Explicit, durable agent loop: validated tool calls, human approval, budgets, crash recovery, and composable router / waterfall / parallel / handoff / refine blocks.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kroq86/agent-loop
|
|
6
6
|
Project-URL: Repository, https://github.com/kroq86/agent-loop
|
|
@@ -8,7 +8,7 @@ Project-URL: Issues, https://github.com/kroq86/agent-loop/issues
|
|
|
8
8
|
Author-email: KirillOstapenko <djkroq@gmail.com>
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Keywords: LLM,agent,durable,human-in-the-loop,multi-agent,ollama,router,tool-calling
|
|
11
|
+
Keywords: LLM,agent,azure-openai,durable,human-in-the-loop,multi-agent,ollama,openai,router,tool-calling
|
|
12
12
|
Classifier: Development Status :: 3 - Alpha
|
|
13
13
|
Classifier: Framework :: AsyncIO
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
@@ -21,6 +21,8 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
21
21
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
22
|
Requires-Python: >=3.11
|
|
23
23
|
Requires-Dist: pydantic>=2.5
|
|
24
|
+
Provides-Extra: openai
|
|
25
|
+
Requires-Dist: httpx>=0.27; extra == 'openai'
|
|
24
26
|
Provides-Extra: trace
|
|
25
27
|
Requires-Dist: flow-xray>=0.4.0; extra == 'trace'
|
|
26
28
|
Description-Content-Type: text/markdown
|
|
@@ -208,9 +210,40 @@ async def generate(self, messages, format=None, options=None, trace_id=None) ->
|
|
|
208
210
|
|
|
209
211
|
`format` is a JSON schema the reply must follow. A backend should report
|
|
210
212
|
tokens with `agent_loop.usage.add(prompt, completion)`, or `max_tokens`
|
|
211
|
-
is not enforced.
|
|
212
|
-
|
|
213
|
-
|
|
213
|
+
is not enforced. Two are included.
|
|
214
|
+
|
|
215
|
+
**OpenAI-compatible APIs** (`pip install "agent-runloop[openai]"`): OpenAI,
|
|
216
|
+
Azure OpenAI, and servers that speak the same protocol (vLLM, LM Studio,
|
|
217
|
+
OpenRouter, llama.cpp server, Ollama's `/v1`).
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from agent_loop.openai import AzureOpenAIBackend, OpenAIBackend
|
|
221
|
+
|
|
222
|
+
OpenAIBackend("gpt-4.1-mini") # OPENAI_API_KEY
|
|
223
|
+
OpenAIBackend("llama3.2:3b", base_url="http://127.0.0.1:11434/v1", api_key="unused")
|
|
224
|
+
AzureOpenAIBackend("my-deployment", endpoint="https://<resource>.openai.azure.com") # AZURE_OPENAI_API_KEY
|
|
225
|
+
AzureOpenAIBackend("my-deployment", endpoint=..., token_provider=get_token) # Entra ID, refreshed per call
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- **Retries.** Only what can succeed on a second try is retried:
|
|
229
|
+
connection errors, timeouts, 429 and 5xx. The wait honours
|
|
230
|
+
`Retry-After` and `retry-after-ms`, otherwise it is exponential backoff
|
|
231
|
+
with jitter. Any other 4xx fails at once with `ModelRequestError`.
|
|
232
|
+
- **Circuit breaker.** After `breaker_threshold` consecutive failed calls,
|
|
233
|
+
every call fails fast with `ModelUnavailable` for `breaker_reset_s`.
|
|
234
|
+
Then one trial call decides whether it closes. The breaker is per
|
|
235
|
+
backend instance and per process.
|
|
236
|
+
- **Structured output.** The loop's schema is sent as a `json_schema`
|
|
237
|
+
response format. Use `structured="json_object"` for servers that only
|
|
238
|
+
support JSON mode, or `"none"`.
|
|
239
|
+
- **Tokens.** Usage from each response feeds the run's token budget and
|
|
240
|
+
the flow-xray trace.
|
|
241
|
+
|
|
242
|
+
Inside a run, an unavailable model ends the run as `model_unavailable`
|
|
243
|
+
with its state saved. It does not crash the loop.
|
|
244
|
+
|
|
245
|
+
**Ollama** (`agent_loop.ollama.OllamaBackend`) is a dependency-free
|
|
246
|
+
reference backend with no retries.
|
|
214
247
|
|
|
215
248
|
## Observability
|
|
216
249
|
|
|
@@ -181,9 +181,40 @@ async def generate(self, messages, format=None, options=None, trace_id=None) ->
|
|
|
181
181
|
|
|
182
182
|
`format` is a JSON schema the reply must follow. A backend should report
|
|
183
183
|
tokens with `agent_loop.usage.add(prompt, completion)`, or `max_tokens`
|
|
184
|
-
is not enforced.
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
is not enforced. Two are included.
|
|
185
|
+
|
|
186
|
+
**OpenAI-compatible APIs** (`pip install "agent-runloop[openai]"`): OpenAI,
|
|
187
|
+
Azure OpenAI, and servers that speak the same protocol (vLLM, LM Studio,
|
|
188
|
+
OpenRouter, llama.cpp server, Ollama's `/v1`).
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from agent_loop.openai import AzureOpenAIBackend, OpenAIBackend
|
|
192
|
+
|
|
193
|
+
OpenAIBackend("gpt-4.1-mini") # OPENAI_API_KEY
|
|
194
|
+
OpenAIBackend("llama3.2:3b", base_url="http://127.0.0.1:11434/v1", api_key="unused")
|
|
195
|
+
AzureOpenAIBackend("my-deployment", endpoint="https://<resource>.openai.azure.com") # AZURE_OPENAI_API_KEY
|
|
196
|
+
AzureOpenAIBackend("my-deployment", endpoint=..., token_provider=get_token) # Entra ID, refreshed per call
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- **Retries.** Only what can succeed on a second try is retried:
|
|
200
|
+
connection errors, timeouts, 429 and 5xx. The wait honours
|
|
201
|
+
`Retry-After` and `retry-after-ms`, otherwise it is exponential backoff
|
|
202
|
+
with jitter. Any other 4xx fails at once with `ModelRequestError`.
|
|
203
|
+
- **Circuit breaker.** After `breaker_threshold` consecutive failed calls,
|
|
204
|
+
every call fails fast with `ModelUnavailable` for `breaker_reset_s`.
|
|
205
|
+
Then one trial call decides whether it closes. The breaker is per
|
|
206
|
+
backend instance and per process.
|
|
207
|
+
- **Structured output.** The loop's schema is sent as a `json_schema`
|
|
208
|
+
response format. Use `structured="json_object"` for servers that only
|
|
209
|
+
support JSON mode, or `"none"`.
|
|
210
|
+
- **Tokens.** Usage from each response feeds the run's token budget and
|
|
211
|
+
the flow-xray trace.
|
|
212
|
+
|
|
213
|
+
Inside a run, an unavailable model ends the run as `model_unavailable`
|
|
214
|
+
with its state saved. It does not crash the loop.
|
|
215
|
+
|
|
216
|
+
**Ollama** (`agent_loop.ollama.OllamaBackend`) is a dependency-free
|
|
217
|
+
reference backend with no retries.
|
|
187
218
|
|
|
188
219
|
## Observability
|
|
189
220
|
|
|
@@ -13,8 +13,8 @@ from agent_loop.compose import (
|
|
|
13
13
|
Sequence, Step, Timeout, Verdict, chain_task, join_answers, majority_vote, step,
|
|
14
14
|
)
|
|
15
15
|
from agent_loop.loop import (
|
|
16
|
-
AgentDecision, AgentResult, AgentStep, Budget, EventSink, FinalCheck, Status, ToolSpec,
|
|
17
|
-
current_call_id, recover_agent, resume_agent, run_agent, tool_error,
|
|
16
|
+
AgentDecision, AgentResult, AgentStep, Budget, EventSink, FinalCheck, Status, ToolSpec, ToolTimeout,
|
|
17
|
+
cancel_agent, current_call_id, recover_agent, resume_agent, run_agent, tool_error,
|
|
18
18
|
)
|
|
19
19
|
from agent_loop.runs import SqliteAgentRunStore
|
|
20
20
|
|
|
@@ -26,7 +26,7 @@ except PackageNotFoundError: # running from a source checkout
|
|
|
26
26
|
__all__ = [
|
|
27
27
|
"Agent", "AgentDecision", "AgentResult", "AgentStep", "Budget", "Context", "EventSink", "Fallback", "FinalCheck",
|
|
28
28
|
"Handoff", "HandoffNote", "LLMBackend", "LLMClassifier", "LLMCritic", "Outcome", "Parallel", "Refine", "Router",
|
|
29
|
-
"Sequence", "SqliteAgentRunStore", "Status", "Step", "Timeout", "ToolSpec", "Verdict", "cancel_agent",
|
|
29
|
+
"Sequence", "SqliteAgentRunStore", "Status", "Step", "Timeout", "ToolSpec", "ToolTimeout", "Verdict", "cancel_agent",
|
|
30
30
|
"chain_task", "current_call_id", "hooks", "join_answers", "majority_vote", "recover_agent", "resume_agent",
|
|
31
31
|
"run_agent", "step", "tool_error", "usage",
|
|
32
32
|
]
|
|
@@ -244,12 +244,24 @@ def tool_error(code: str, message: str) -> str:
|
|
|
244
244
|
return json.dumps({"error": {"code": code, "message": message}})
|
|
245
245
|
|
|
246
246
|
|
|
247
|
+
class ToolTimeout(Exception):
|
|
248
|
+
"""A tool exceeded its own `timeout_s`. Deliberately not a TimeoutError:
|
|
249
|
+
that one means the RUN's deadline, which ends the run. A slow tool is a
|
|
250
|
+
tool failure the model sees and can work around."""
|
|
251
|
+
|
|
252
|
+
|
|
247
253
|
@trace
|
|
248
254
|
async def execute_tool(spec: ToolSpec, args: BaseModel, trace_id: str) -> Any:
|
|
249
255
|
"""Separate traced node so trace.html shows each tool's validated
|
|
250
256
|
arguments and full result, under the run_agent node."""
|
|
251
257
|
trace.meta(tool=spec.name, arguments=args.model_dump(), tool_version=spec.version)
|
|
252
|
-
|
|
258
|
+
try:
|
|
259
|
+
return await asyncio.wait_for(spec.handler(args, trace_id), timeout=spec.timeout_s)
|
|
260
|
+
except TimeoutError:
|
|
261
|
+
# Only the tool's own timeout lands here: when the run's deadline
|
|
262
|
+
# fires, the wait is cancelled instead, and that surfaces as the
|
|
263
|
+
# run's TimeoutError one level up.
|
|
264
|
+
raise ToolTimeout(f"{spec.name} did not finish within its timeout of {spec.timeout_s}s") from None
|
|
253
265
|
|
|
254
266
|
|
|
255
267
|
async def _execute(state: "_RunState", spec: ToolSpec, args: BaseModel, canonical: dict,
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Backends for OpenAI-compatible chat APIs: OpenAI, Azure OpenAI, and the
|
|
2
|
+
many servers that speak the same protocol (vLLM, LM Studio, OpenRouter,
|
|
3
|
+
Ollama's /v1, llama.cpp server, ...).
|
|
4
|
+
|
|
5
|
+
pip install "agent-runloop[openai]" # adds httpx
|
|
6
|
+
|
|
7
|
+
OpenAIBackend("gpt-4.1-mini") # OPENAI_API_KEY
|
|
8
|
+
OpenAIBackend("llama3.2:3b", base_url="http://127.0.0.1:11434/v1", api_key="unused")
|
|
9
|
+
AzureOpenAIBackend("my-deployment", endpoint="https://<resource>.openai.azure.com")
|
|
10
|
+
# AZURE_OPENAI_API_KEY,
|
|
11
|
+
# or token_provider= for Entra ID
|
|
12
|
+
|
|
13
|
+
Failure handling:
|
|
14
|
+
|
|
15
|
+
retry only what can succeed on a second try: connection errors,
|
|
16
|
+
timeouts, 429 and 5xx. The wait honours Retry-After /
|
|
17
|
+
retry-after-ms, otherwise exponential backoff with jitter,
|
|
18
|
+
capped at `max_delay_s`. Any other 4xx (bad request, auth,
|
|
19
|
+
unknown model) fails at once with ModelRequestError.
|
|
20
|
+
breaker per backend instance: after `breaker_threshold` consecutive
|
|
21
|
+
failed calls every call fails fast with ModelUnavailable for
|
|
22
|
+
`breaker_reset_s`, then one trial call decides. Per process:
|
|
23
|
+
several processes keep separate breakers.
|
|
24
|
+
|
|
25
|
+
Structured output: the loop passes a JSON schema in `format`. It is sent
|
|
26
|
+
as `response_format` "json_schema" (default), "json_object" (servers that
|
|
27
|
+
only know JSON mode; the loop's prompt still spells out the shape), or not
|
|
28
|
+
at all ("none").
|
|
29
|
+
|
|
30
|
+
Token usage from the response is reported to `agent_loop.usage`, so
|
|
31
|
+
`Budget.max_tokens` is enforced.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import email.utils
|
|
37
|
+
import os
|
|
38
|
+
import random
|
|
39
|
+
import time
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from typing import Literal
|
|
42
|
+
|
|
43
|
+
from agent_loop import usage
|
|
44
|
+
from agent_loop._trace import trace
|
|
45
|
+
from agent_loop.hooks import log_step
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
import httpx
|
|
49
|
+
except ImportError as e: # pragma: no cover - depends on the environment
|
|
50
|
+
raise ImportError('agent_loop.openai needs httpx: pip install "agent-runloop[openai]"') from e
|
|
51
|
+
|
|
52
|
+
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
53
|
+
StructuredMode = Literal["json_schema", "json_object", "none"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ModelUnavailable(Exception):
|
|
57
|
+
"""The server could not serve the call after retries, or the circuit
|
|
58
|
+
breaker is open. Distinct from a request the server rejected."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ModelRequestError(Exception):
|
|
62
|
+
"""The server answered and refused the request (4xx other than 429), or
|
|
63
|
+
the model refused to answer. Retrying the same request will not help."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, status: int | None = None):
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.status = status
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CircuitBreaker:
|
|
71
|
+
def __init__(self, threshold: int = 5, reset_s: float = 30.0):
|
|
72
|
+
self.threshold, self.reset_s = threshold, reset_s
|
|
73
|
+
self.failures = 0
|
|
74
|
+
self.opened_at: float | None = None
|
|
75
|
+
|
|
76
|
+
def check(self, target: str) -> None:
|
|
77
|
+
if self.opened_at is not None and time.monotonic() - self.opened_at < self.reset_s:
|
|
78
|
+
raise ModelUnavailable(f"circuit open for {target} ({self.failures} consecutive failures)")
|
|
79
|
+
|
|
80
|
+
def success(self) -> None:
|
|
81
|
+
self.failures, self.opened_at = 0, None
|
|
82
|
+
|
|
83
|
+
def failure(self, target: str) -> None:
|
|
84
|
+
self.failures += 1
|
|
85
|
+
if self.opened_at is not None or self.failures >= self.threshold:
|
|
86
|
+
if self.opened_at is None:
|
|
87
|
+
log_step(None, "circuit_opened", target=target, failures=self.failures)
|
|
88
|
+
self.opened_at = time.monotonic() # opened, or a failed trial call keeps it open
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _retry_after_s(headers: httpx.Headers) -> float | None:
|
|
92
|
+
"""Retry-After in seconds or as an HTTP date; retry-after-ms (OpenAI, Azure)."""
|
|
93
|
+
if (ms := headers.get("retry-after-ms")) is not None:
|
|
94
|
+
try:
|
|
95
|
+
return float(ms) / 1000
|
|
96
|
+
except ValueError:
|
|
97
|
+
pass
|
|
98
|
+
value = headers.get("retry-after")
|
|
99
|
+
if value is None:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
return float(value)
|
|
103
|
+
except ValueError:
|
|
104
|
+
try:
|
|
105
|
+
return max(0.0, email.utils.parsedate_to_datetime(value).timestamp() - time.time())
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Loop options that have an OpenAI equivalent; the rest are dropped.
|
|
111
|
+
_OPTION_NAMES = {"temperature": "temperature", "seed": "seed", "top_p": "top_p",
|
|
112
|
+
"num_predict": "max_tokens", "max_tokens": "max_tokens", "stop": "stop"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class OpenAIBackend:
|
|
116
|
+
def __init__(self, model: str, *, base_url: str | None = None, api_key: str | None = None,
|
|
117
|
+
structured: StructuredMode = "json_schema", timeout_s: float = 60.0, max_attempts: int = 3,
|
|
118
|
+
base_delay_s: float = 0.5, max_delay_s: float = 20.0, breaker_threshold: int = 5,
|
|
119
|
+
breaker_reset_s: float = 30.0, extra_body: dict | None = None,
|
|
120
|
+
extra_headers: dict[str, str] | None = None):
|
|
121
|
+
self.model = model
|
|
122
|
+
self.base_url = (base_url or os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1").rstrip("/")
|
|
123
|
+
self.api_key = api_key if api_key is not None else os.getenv("OPENAI_API_KEY")
|
|
124
|
+
self.structured = structured
|
|
125
|
+
self.timeout_s, self.max_attempts = timeout_s, max_attempts
|
|
126
|
+
self.base_delay_s, self.max_delay_s = base_delay_s, max_delay_s
|
|
127
|
+
self.breaker = CircuitBreaker(breaker_threshold, breaker_reset_s)
|
|
128
|
+
self.extra_body, self.extra_headers = dict(extra_body or {}), dict(extra_headers or {})
|
|
129
|
+
# httpx clients are bound to the event loop they were made on.
|
|
130
|
+
self._clients: dict[int, httpx.AsyncClient] = {}
|
|
131
|
+
|
|
132
|
+
# -- request shape (overridden by AzureOpenAIBackend) -----------------
|
|
133
|
+
|
|
134
|
+
def _url(self) -> str:
|
|
135
|
+
return f"{self.base_url}/chat/completions"
|
|
136
|
+
|
|
137
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
138
|
+
return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
|
139
|
+
|
|
140
|
+
def _target(self) -> str:
|
|
141
|
+
return self.base_url
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def _client(self) -> httpx.AsyncClient:
|
|
146
|
+
loop = id(asyncio.get_running_loop())
|
|
147
|
+
client = self._clients.get(loop)
|
|
148
|
+
if client is None or client.is_closed:
|
|
149
|
+
client = self._clients[loop] = httpx.AsyncClient(
|
|
150
|
+
timeout=self.timeout_s, limits=httpx.Limits(max_connections=20, max_keepalive_connections=10))
|
|
151
|
+
return client
|
|
152
|
+
|
|
153
|
+
def _payload(self, messages: list[dict], format: str | dict | None, options: dict | None) -> dict:
|
|
154
|
+
payload: dict = {"model": self.model, "messages": messages}
|
|
155
|
+
for key, value in (options or {}).items():
|
|
156
|
+
if key in _OPTION_NAMES:
|
|
157
|
+
payload[_OPTION_NAMES[key]] = value
|
|
158
|
+
if format and self.structured == "json_schema" and isinstance(format, dict):
|
|
159
|
+
payload["response_format"] = {"type": "json_schema",
|
|
160
|
+
"json_schema": {"name": str(format.get("title") or "reply"),
|
|
161
|
+
"schema": format}}
|
|
162
|
+
elif format and self.structured != "none":
|
|
163
|
+
payload["response_format"] = {"type": "json_object"}
|
|
164
|
+
return {**payload, **self.extra_body}
|
|
165
|
+
|
|
166
|
+
@trace
|
|
167
|
+
async def generate(self, messages: list[dict], format: str | dict | None = None,
|
|
168
|
+
options: dict | None = None, trace_id: str | None = None) -> str:
|
|
169
|
+
target = self._target()
|
|
170
|
+
self.breaker.check(target)
|
|
171
|
+
payload = self._payload(messages, format, options)
|
|
172
|
+
headers = {**self._auth_headers(), **self.extra_headers}
|
|
173
|
+
last_error = ""
|
|
174
|
+
for attempt in range(self.max_attempts):
|
|
175
|
+
wait: float | None = None
|
|
176
|
+
try:
|
|
177
|
+
response = await self._client().post(self._url(), json=payload, headers=headers)
|
|
178
|
+
except (httpx.TransportError, httpx.TimeoutException) as e:
|
|
179
|
+
last_error = f"{type(e).__name__}: {e}"
|
|
180
|
+
else:
|
|
181
|
+
if response.status_code < 400:
|
|
182
|
+
self.breaker.success()
|
|
183
|
+
return self._read(response.json())
|
|
184
|
+
if response.status_code not in RETRYABLE_STATUS:
|
|
185
|
+
# The server said no: not an availability problem, so no
|
|
186
|
+
# retry and the breaker is not tripped.
|
|
187
|
+
raise ModelRequestError(f"HTTP {response.status_code}: {response.text[:300]}",
|
|
188
|
+
response.status_code)
|
|
189
|
+
last_error = f"HTTP {response.status_code}: {response.text[:200]}"
|
|
190
|
+
wait = _retry_after_s(response.headers)
|
|
191
|
+
if attempt + 1 < self.max_attempts:
|
|
192
|
+
delay = min(self.max_delay_s, wait if wait is not None
|
|
193
|
+
else random.uniform(0, self.base_delay_s * 2 ** attempt) + self.base_delay_s)
|
|
194
|
+
log_step(trace_id, "model_call_retry", target=target, model=self.model, attempt=attempt + 1,
|
|
195
|
+
of=self.max_attempts, error=last_error, sleep_s=round(delay, 3))
|
|
196
|
+
await asyncio.sleep(delay)
|
|
197
|
+
self.breaker.failure(target)
|
|
198
|
+
log_step(trace_id, "model_call_failed", target=target, model=self.model, attempts=self.max_attempts,
|
|
199
|
+
error=last_error)
|
|
200
|
+
raise ModelUnavailable(f"{self.model} at {target}: {last_error} after {self.max_attempts} attempts")
|
|
201
|
+
|
|
202
|
+
def _read(self, data: dict) -> str:
|
|
203
|
+
u = data.get("usage") or {}
|
|
204
|
+
prompt, completion = u.get("prompt_tokens") or 0, u.get("completion_tokens") or 0
|
|
205
|
+
usage.add(prompt, completion)
|
|
206
|
+
trace.meta(model=data.get("model") or self.model, prompt_tokens=prompt, completion_tokens=completion,
|
|
207
|
+
total_tokens=prompt + completion)
|
|
208
|
+
message = (data.get("choices") or [{}])[0].get("message") or {}
|
|
209
|
+
if message.get("content") is None:
|
|
210
|
+
raise ModelRequestError(f"no content in reply (refusal: {message.get('refusal')!r})")
|
|
211
|
+
return message["content"]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class AzureOpenAIBackend(OpenAIBackend):
|
|
215
|
+
"""Azure OpenAI: the deployment name is in the URL, the version is a
|
|
216
|
+
query parameter, and auth is an `api-key` header or an Entra ID bearer
|
|
217
|
+
token (`token_provider() -> str`, e.g. from azure-identity's
|
|
218
|
+
get_bearer_token_provider; called per request, so it can refresh)."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, deployment: str, *, endpoint: str | None = None, api_key: str | None = None,
|
|
221
|
+
api_version: str | None = None, token_provider: Callable[[], str] | None = None, **kwargs):
|
|
222
|
+
endpoint = (endpoint or os.getenv("AZURE_OPENAI_ENDPOINT") or "").rstrip("/")
|
|
223
|
+
if not endpoint:
|
|
224
|
+
raise ValueError("Azure OpenAI needs endpoint= or AZURE_OPENAI_ENDPOINT")
|
|
225
|
+
super().__init__(deployment, base_url=endpoint, api_key="", **kwargs)
|
|
226
|
+
self.deployment = deployment
|
|
227
|
+
self.api_version = api_version or os.getenv("AZURE_OPENAI_API_VERSION") or "2024-10-21"
|
|
228
|
+
self.azure_key = api_key if api_key is not None else os.getenv("AZURE_OPENAI_API_KEY")
|
|
229
|
+
self.token_provider = token_provider
|
|
230
|
+
if not self.azure_key and token_provider is None:
|
|
231
|
+
raise ValueError("Azure OpenAI needs api_key=, AZURE_OPENAI_API_KEY, or token_provider=")
|
|
232
|
+
|
|
233
|
+
def _url(self) -> str:
|
|
234
|
+
return (f"{self.base_url}/openai/deployments/{self.deployment}/chat/completions"
|
|
235
|
+
f"?api-version={self.api_version}")
|
|
236
|
+
|
|
237
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
238
|
+
if self.token_provider is not None:
|
|
239
|
+
return {"Authorization": f"Bearer {self.token_provider()}"}
|
|
240
|
+
return {"api-key": self.azure_key}
|
|
241
|
+
|
|
242
|
+
def _target(self) -> str:
|
|
243
|
+
return f"{self.base_url}/{self.deployment}"
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agent-runloop"
|
|
7
|
-
version = "0.1
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Explicit, durable agent loop: validated tool calls, human approval, budgets, crash recovery, and composable router / waterfall / parallel / handoff / refine blocks."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.11"
|
|
12
12
|
authors = [{ name = "KirillOstapenko", email = "djkroq@gmail.com" }]
|
|
13
|
-
keywords = ["agent", "LLM", "tool-calling", "human-in-the-loop", "router", "multi-agent", "durable", "ollama"]
|
|
13
|
+
keywords = ["agent", "LLM", "tool-calling", "openai", "azure-openai", "human-in-the-loop", "router", "multi-agent", "durable", "ollama"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 3 - Alpha",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
@@ -27,6 +27,7 @@ dependencies = ["pydantic>=2.5"]
|
|
|
27
27
|
|
|
28
28
|
[project.optional-dependencies]
|
|
29
29
|
trace = ["flow-xray>=0.4.0"]
|
|
30
|
+
openai = ["httpx>=0.27"]
|
|
30
31
|
|
|
31
32
|
[project.urls]
|
|
32
33
|
Homepage = "https://github.com/kroq86/agent-loop"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|