agent-runloop 0.1.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.
- agent_loop/__init__.py +32 -0
- agent_loop/_trace.py +15 -0
- agent_loop/backend.py +17 -0
- agent_loop/compose.py +441 -0
- agent_loop/hooks.py +73 -0
- agent_loop/loop.py +657 -0
- agent_loop/ollama.py +43 -0
- agent_loop/prompts.py +21 -0
- agent_loop/py.typed +0 -0
- agent_loop/runs.py +122 -0
- agent_loop/usage.py +30 -0
- agent_runloop-0.1.0.dist-info/METADATA +230 -0
- agent_runloop-0.1.0.dist-info/RECORD +15 -0
- agent_runloop-0.1.0.dist-info/WHEEL +4 -0
- agent_runloop-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_loop/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""agent-runloop: an explicit, durable agent loop and blocks to compose agents.
|
|
2
|
+
|
|
3
|
+
The model proposes one JSON decision per turn; this code validates and
|
|
4
|
+
executes it. Write tools pause for human approval; runs checkpoint to
|
|
5
|
+
SQLite and can be resumed or recovered in another process.
|
|
6
|
+
"""
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
from agent_loop import hooks, usage
|
|
10
|
+
from agent_loop.backend import LLMBackend
|
|
11
|
+
from agent_loop.compose import (
|
|
12
|
+
Agent, Context, Fallback, Handoff, HandoffNote, LLMClassifier, LLMCritic, Outcome, Parallel, Refine, Router,
|
|
13
|
+
Sequence, Step, Timeout, Verdict, chain_task, join_answers, majority_vote, step,
|
|
14
|
+
)
|
|
15
|
+
from agent_loop.loop import (
|
|
16
|
+
AgentDecision, AgentResult, AgentStep, Budget, EventSink, FinalCheck, Status, ToolSpec, cancel_agent,
|
|
17
|
+
current_call_id, recover_agent, resume_agent, run_agent, tool_error,
|
|
18
|
+
)
|
|
19
|
+
from agent_loop.runs import SqliteAgentRunStore
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
__version__ = version("agent-runloop")
|
|
23
|
+
except PackageNotFoundError: # running from a source checkout
|
|
24
|
+
__version__ = "0.0.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Agent", "AgentDecision", "AgentResult", "AgentStep", "Budget", "Context", "EventSink", "Fallback", "FinalCheck",
|
|
28
|
+
"Handoff", "HandoffNote", "LLMBackend", "LLMClassifier", "LLMCritic", "Outcome", "Parallel", "Refine", "Router",
|
|
29
|
+
"Sequence", "SqliteAgentRunStore", "Status", "Step", "Timeout", "ToolSpec", "Verdict", "cancel_agent",
|
|
30
|
+
"chain_task", "current_call_id", "hooks", "join_answers", "majority_vote", "recover_agent", "resume_agent",
|
|
31
|
+
"run_agent", "step", "tool_error", "usage",
|
|
32
|
+
]
|
agent_loop/_trace.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""flow-xray tracing when it is installed (`pip install agent-runloop[trace]`),
|
|
2
|
+
otherwise a no-op with the same surface."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from flow_xray import trace
|
|
7
|
+
except ImportError: # pragma: no cover - depends on the environment
|
|
8
|
+
class _NoTrace:
|
|
9
|
+
def __call__(self, fn):
|
|
10
|
+
return fn
|
|
11
|
+
|
|
12
|
+
def meta(self, **fields) -> None:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
trace = _NoTrace() # type: ignore[assignment]
|
agent_loop/backend.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The one interface the loop needs from a model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class LLMBackend(Protocol):
|
|
9
|
+
"""`generate` returns the model's reply text for a chat `messages`
|
|
10
|
+
list. `format` is a JSON schema the reply must follow (the loop always
|
|
11
|
+
passes one); `options` carries sampling settings. A backend should
|
|
12
|
+
raise after its own retries when the model is unavailable, and report
|
|
13
|
+
token usage with `agent_loop.usage.add` for token budgets to work.
|
|
14
|
+
An optional `model` attribute names the model in run versions."""
|
|
15
|
+
|
|
16
|
+
async def generate(self, messages: list[dict], format: str | dict | None = None,
|
|
17
|
+
options: dict | None = None, trace_id: str | None = None) -> str: ...
|
agent_loop/compose.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Composing agents: every building block is a step, and every step is
|
|
2
|
+
|
|
3
|
+
async step(task: str, ctx: Context) -> Outcome
|
|
4
|
+
|
|
5
|
+
so blocks nest freely: a Router whose routes are Sequences, a Parallel
|
|
6
|
+
inside a Fallback, an ordinary async function in the middle of a chain.
|
|
7
|
+
|
|
8
|
+
Agent one run of the agent loop (instructions, tools, model, budget)
|
|
9
|
+
Sequence waterfall: each step's answer becomes part of the next task
|
|
10
|
+
Router a classifier (function or model) picks one route
|
|
11
|
+
Parallel steps run concurrently; a combiner merges their answers
|
|
12
|
+
Handoff an agent hands the case to another with a structured message
|
|
13
|
+
Refine worker answers, critic reviews, worker revises (max_rounds)
|
|
14
|
+
Fallback the first step that answers wins
|
|
15
|
+
Timeout a deadline around any step
|
|
16
|
+
step(fn) any `async (task, ctx) -> str | Outcome` function as a step
|
|
17
|
+
|
|
18
|
+
Limits, stated: when an agent inside a composite pauses for human
|
|
19
|
+
approval, the composite stops and returns that `awaiting_approval`
|
|
20
|
+
outcome; resuming the paused agent run (`resume_agent`) does not continue
|
|
21
|
+
the rest of the composite. Token and tool budgets are per agent run;
|
|
22
|
+
`Timeout` bounds wall time across a whole composite.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import inspect
|
|
28
|
+
import json
|
|
29
|
+
from collections import Counter
|
|
30
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
31
|
+
from dataclasses import dataclass, field, replace
|
|
32
|
+
from typing import Any, Literal, Protocol
|
|
33
|
+
|
|
34
|
+
from pydantic import BaseModel, Field, ValidationError, create_model
|
|
35
|
+
|
|
36
|
+
from agent_loop._trace import trace
|
|
37
|
+
from agent_loop.backend import LLMBackend
|
|
38
|
+
from agent_loop.hooks import bind_trace, log_step, new_trace_id
|
|
39
|
+
from agent_loop.loop import AgentResult, Budget, FinalCheck, ToolSpec, run_agent
|
|
40
|
+
from agent_loop.runs import SqliteAgentRunStore
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Context:
|
|
45
|
+
"""What every step of one composite run shares. `context` is the
|
|
46
|
+
caller's trusted data (user, tenant, ...), passed to every agent run
|
|
47
|
+
and to tool authorization; it never comes from model output."""
|
|
48
|
+
backend: LLMBackend
|
|
49
|
+
run_store: SqliteAgentRunStore | None = None
|
|
50
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
trace_id: str = field(default_factory=new_trace_id)
|
|
52
|
+
events: asyncio.Queue | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Outcome:
|
|
57
|
+
status: str # "answered", an agent run status, or a composite one ("unroutable", "failed")
|
|
58
|
+
answer: str | None
|
|
59
|
+
runs: list[AgentResult] = field(default_factory=list) # every agent run inside, in order
|
|
60
|
+
data: dict[str, Any] = field(default_factory=dict) # what the block decided (route, votes, rounds...)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def ok(self) -> bool:
|
|
64
|
+
return self.status == "answered"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Step(Protocol):
|
|
68
|
+
async def __call__(self, task: str, ctx: Context) -> Outcome: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
StepLike = Step | Callable[[str, Context], Awaitable[Any]]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def step(fn: Callable[[str, Context], Awaitable[Any]]) -> Step:
|
|
75
|
+
"""An async function as a step. It may return an Outcome, or a string
|
|
76
|
+
(answered) / None (failed)."""
|
|
77
|
+
if isinstance(fn, _Block):
|
|
78
|
+
return fn
|
|
79
|
+
return _FunctionStep(fn)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Block:
|
|
83
|
+
name: str = ""
|
|
84
|
+
|
|
85
|
+
async def __call__(self, task: str, ctx: Context) -> Outcome:
|
|
86
|
+
with bind_trace(ctx.trace_id):
|
|
87
|
+
return await self.run(task, ctx)
|
|
88
|
+
|
|
89
|
+
async def run(self, task: str, ctx: Context) -> Outcome: # pragma: no cover - abstract
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
def __repr__(self) -> str: # traces show the block, not its whole config
|
|
93
|
+
return f"{type(self).__name__}({self.name!r})" if self.name else f"{type(self).__name__}()"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _FunctionStep(_Block):
|
|
97
|
+
def __init__(self, fn):
|
|
98
|
+
self.fn, self.name = fn, getattr(fn, "__name__", "step")
|
|
99
|
+
|
|
100
|
+
@trace
|
|
101
|
+
async def run(self, task: str, ctx: Context) -> Outcome:
|
|
102
|
+
out = await self.fn(task, ctx)
|
|
103
|
+
if isinstance(out, Outcome):
|
|
104
|
+
return out
|
|
105
|
+
return Outcome("answered" if out is not None else "failed", None if out is None else str(out))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _as_step(s: StepLike) -> Step:
|
|
109
|
+
return s if isinstance(s, _Block) else step(s)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# -- one agent ----------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Agent(_Block):
|
|
116
|
+
"""An agent as configuration; calling it runs the loop once.
|
|
117
|
+
`backend` and `budget` default to the context's backend and Budget()."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, name: str, instructions: str, tools: list[ToolSpec] | None = None, *,
|
|
120
|
+
backend: LLMBackend | None = None, budget: Budget | None = None,
|
|
121
|
+
final_check: FinalCheck | None = None,
|
|
122
|
+
prefetch: Callable[[str], list[tuple[str, dict]]] | None = None):
|
|
123
|
+
self.name, self.instructions, self.tools = name, instructions, list(tools or [])
|
|
124
|
+
self.backend, self.budget, self.final_check, self.prefetch = backend, budget, final_check, prefetch
|
|
125
|
+
|
|
126
|
+
@trace
|
|
127
|
+
async def run(self, task: str, ctx: Context) -> Outcome:
|
|
128
|
+
result = await run_agent(
|
|
129
|
+
task, self.instructions, self.tools, backend=self.backend or ctx.backend,
|
|
130
|
+
trace_id=ctx.trace_id, final_check=self.final_check, run_store=ctx.run_store,
|
|
131
|
+
context={**ctx.context, "agent_role": self.name}, budget=self.budget or Budget(),
|
|
132
|
+
events=ctx.events, prefetch=self.prefetch(task) if self.prefetch else None)
|
|
133
|
+
return Outcome(result.status, result.answer, [result], {"agent": self.name})
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# -- waterfall ----------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def chain_task(task: str, previous: Outcome, index: int) -> str:
|
|
140
|
+
"""Default hand-over between Sequence steps: the original task plus
|
|
141
|
+
the previous step's answer, labelled."""
|
|
142
|
+
return f"{task}\n\nResult of the previous step:\n{previous.answer}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Sequence(_Block):
|
|
146
|
+
"""Waterfall. Stops at the first step that does not answer and
|
|
147
|
+
returns its status. `carry(task, previous_outcome, index) -> next task`."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, *steps: StepLike, name: str = "", carry: Callable[[str, Outcome, int], str] = chain_task):
|
|
150
|
+
if not steps:
|
|
151
|
+
raise ValueError("Sequence needs at least one step")
|
|
152
|
+
self.steps, self.name, self.carry = [_as_step(s) for s in steps], name, carry
|
|
153
|
+
|
|
154
|
+
@trace
|
|
155
|
+
async def run(self, task: str, ctx: Context) -> Outcome:
|
|
156
|
+
runs: list[AgentResult] = []
|
|
157
|
+
current, outcome = task, None
|
|
158
|
+
for i, s in enumerate(self.steps):
|
|
159
|
+
outcome = await s(current, ctx)
|
|
160
|
+
runs += outcome.runs
|
|
161
|
+
if not outcome.ok:
|
|
162
|
+
return replace(outcome, runs=runs, data={**outcome.data, "stopped_at": i})
|
|
163
|
+
if i + 1 < len(self.steps):
|
|
164
|
+
current = self.carry(task, outcome, i)
|
|
165
|
+
return replace(outcome, runs=runs, data={**outcome.data, "steps": len(self.steps)})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# -- router -------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class LLMClassifier:
|
|
172
|
+
"""A model picks one route by name, constrained to the route names by
|
|
173
|
+
the JSON schema (an enum), with a reason. `descriptions` tell the
|
|
174
|
+
model what each route is for."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, descriptions: Mapping[str, str], backend: LLMBackend | None = None,
|
|
177
|
+
instructions: str = "Pick the one route that should handle the task."):
|
|
178
|
+
self.descriptions, self.backend, self.instructions = dict(descriptions), backend, instructions
|
|
179
|
+
names = tuple(self.descriptions)
|
|
180
|
+
self.schema = create_model("RouteChoice", route=(Literal[names], ...), reason=(str, "")) # type: ignore[valid-type]
|
|
181
|
+
|
|
182
|
+
@trace
|
|
183
|
+
async def __call__(self, task: str, ctx: Context) -> str | None:
|
|
184
|
+
from agent_loop.prompts import build_system_message
|
|
185
|
+
catalog = "\n".join(f"- {k}: {v}" for k, v in self.descriptions.items())
|
|
186
|
+
messages = [build_system_message(f"{self.instructions}\nRoutes:\n{catalog}\n"
|
|
187
|
+
'Reply with one JSON object: {"route": "<route name>", "reason": "..."}'),
|
|
188
|
+
{"role": "user", "content": task}]
|
|
189
|
+
raw = await (self.backend or ctx.backend).generate(
|
|
190
|
+
messages, format=self.schema.model_json_schema(), options={"temperature": 0, "seed": 42},
|
|
191
|
+
trace_id=ctx.trace_id)
|
|
192
|
+
try:
|
|
193
|
+
choice = self.schema.model_validate_json(raw)
|
|
194
|
+
except ValidationError as e:
|
|
195
|
+
log_step(ctx.trace_id, "route_invalid", raw=raw[:300], detail=str(e)[:300])
|
|
196
|
+
return None
|
|
197
|
+
log_step(ctx.trace_id, "route_chosen", route=choice.route, reason=choice.reason)
|
|
198
|
+
return choice.route
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Router(_Block):
|
|
202
|
+
"""`classify(task, ctx) -> route name` (sync or async; e.g. keyword
|
|
203
|
+
rules, an embedding classifier, or LLMClassifier). An unknown or
|
|
204
|
+
missing route goes to `default`, or ends as status "unroutable"."""
|
|
205
|
+
|
|
206
|
+
def __init__(self, routes: Mapping[str, StepLike], classify: Callable[[str, Context], Any], *,
|
|
207
|
+
default: str | None = None, name: str = ""):
|
|
208
|
+
if default is not None and default not in routes:
|
|
209
|
+
raise ValueError(f"default route {default!r} is not one of {sorted(routes)}")
|
|
210
|
+
self.routes = {k: _as_step(v) for k, v in routes.items()}
|
|
211
|
+
self.classify, self.default, self.name = classify, default, name
|
|
212
|
+
|
|
213
|
+
@trace
|
|
214
|
+
async def run(self, task: str, ctx: Context) -> Outcome:
|
|
215
|
+
chosen = self.classify(task, ctx)
|
|
216
|
+
if inspect.isawaitable(chosen):
|
|
217
|
+
chosen = await chosen
|
|
218
|
+
route = chosen if chosen in self.routes else self.default
|
|
219
|
+
log_step(ctx.trace_id, "routed", chosen=chosen, route=route)
|
|
220
|
+
if route is None:
|
|
221
|
+
return Outcome("unroutable", None, data={"route": None, "chosen": chosen})
|
|
222
|
+
outcome = await self.routes[route](task, ctx)
|
|
223
|
+
return replace(outcome, data={**outcome.data, "route": route, "chosen": chosen})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# -- parallel -----------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def join_answers(task: str, outcomes: list[Outcome]) -> Outcome:
|
|
230
|
+
"""Default combiner: the answered results, labelled by position.
|
|
231
|
+
Fails only when none answered."""
|
|
232
|
+
answered = [(i, o) for i, o in enumerate(outcomes) if o.ok]
|
|
233
|
+
if not answered:
|
|
234
|
+
return Outcome("failed", None, data={"statuses": [o.status for o in outcomes]})
|
|
235
|
+
return Outcome("answered", "\n\n".join(f"[{i + 1}] {o.answer}" for i, o in answered))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def majority_vote(task: str, outcomes: list[Outcome]) -> Outcome:
|
|
239
|
+
"""The answer most branches gave (exact match after strip/lower); for
|
|
240
|
+
short labels or numbers, not free text."""
|
|
241
|
+
votes = Counter((o.answer or "").strip().lower() for o in outcomes if o.ok)
|
|
242
|
+
if not votes:
|
|
243
|
+
return Outcome("failed", None, data={"votes": {}})
|
|
244
|
+
winner, count = votes.most_common(1)[0]
|
|
245
|
+
return Outcome("answered", winner, data={"votes": dict(votes), "agreement": count / len(outcomes)})
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class Parallel(_Block):
|
|
249
|
+
"""Steps run concurrently on the same task. `combine` is a function
|
|
250
|
+
`(task, outcomes) -> Outcome` (join_answers, majority_vote, your own)
|
|
251
|
+
or a step (e.g. an aggregator Agent) that gets the task plus every
|
|
252
|
+
branch's answer. A branch that raises counts as a "failed" outcome."""
|
|
253
|
+
|
|
254
|
+
def __init__(self, *steps: StepLike, combine: Callable[[str, list[Outcome]], Outcome] | StepLike = join_answers,
|
|
255
|
+
name: str = ""):
|
|
256
|
+
if not steps:
|
|
257
|
+
raise ValueError("Parallel needs at least one step")
|
|
258
|
+
self.steps, self.combine, self.name = [_as_step(s) for s in steps], combine, name
|
|
259
|
+
|
|
260
|
+
@trace
|
|
261
|
+
async def run(self, task: str, ctx: Context) -> Outcome:
|
|
262
|
+
results = await asyncio.gather(*(s(task, ctx) for s in self.steps), return_exceptions=True)
|
|
263
|
+
outcomes = []
|
|
264
|
+
for r in results:
|
|
265
|
+
if isinstance(r, BaseException):
|
|
266
|
+
if isinstance(r, asyncio.CancelledError):
|
|
267
|
+
raise r
|
|
268
|
+
log_step(ctx.trace_id, "branch_failed", detail=f"{type(r).__name__}: {r}")
|
|
269
|
+
outcomes.append(Outcome("failed", None, data={"error": f"{type(r).__name__}: {r}"}))
|
|
270
|
+
else:
|
|
271
|
+
outcomes.append(r)
|
|
272
|
+
runs = [run for o in outcomes for run in o.runs]
|
|
273
|
+
if isinstance(self.combine, _Block):
|
|
274
|
+
listing = "\n\n".join(f"Answer {i + 1} ({o.status}):\n{o.answer}" for i, o in enumerate(outcomes))
|
|
275
|
+
merged = await self.combine(f"{task}\n\nCandidate answers:\n{listing}", ctx)
|
|
276
|
+
else:
|
|
277
|
+
merged = self.combine(task, outcomes)
|
|
278
|
+
return replace(merged, runs=runs + merged.runs,
|
|
279
|
+
data={**merged.data, "branches": [o.status for o in outcomes]})
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# -- handoff ------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class HandoffNote(BaseModel):
|
|
286
|
+
"""Default structured message from the source agent to the target.
|
|
287
|
+
Flat strings on purpose: with a `dict` field, llama3.2:3b sent the
|
|
288
|
+
string "{}" on every attempt and never got a valid handoff through."""
|
|
289
|
+
summary: str = Field(min_length=10, max_length=1000)
|
|
290
|
+
details: str = Field(default="", max_length=2000)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def render_note(note: BaseModel) -> str:
|
|
294
|
+
return "Case handed over by another agent:\n" + json.dumps(note.model_dump(), ensure_ascii=False, indent=2)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Handoff(_Block):
|
|
298
|
+
"""The source agent gets one `handoff_to_<target>` tool per target. If
|
|
299
|
+
it calls one, the target step runs on the structured message only
|
|
300
|
+
(not the source's conversation). At most one handoff per run: targets
|
|
301
|
+
get no handoff tools from here, so agents cannot bounce a case back
|
|
302
|
+
and forth. If the source never hands off, its own outcome is returned.
|
|
303
|
+
|
|
304
|
+
`message` is the pydantic model of the note (validated like any tool
|
|
305
|
+
argument); `render(note) -> task text` for the target."""
|
|
306
|
+
|
|
307
|
+
def __init__(self, source: Agent, targets: Mapping[str, StepLike], *, message: type[BaseModel] = HandoffNote,
|
|
308
|
+
render: Callable[[BaseModel], str] = render_note, descriptions: Mapping[str, str] | None = None,
|
|
309
|
+
name: str = ""):
|
|
310
|
+
self.source, self.targets = source, {k: _as_step(v) for k, v in targets.items()}
|
|
311
|
+
self.message, self.render, self.name = message, render, name or source.name
|
|
312
|
+
self.descriptions = dict(descriptions or {})
|
|
313
|
+
|
|
314
|
+
def _tools(self) -> list[ToolSpec]:
|
|
315
|
+
async def accepted(args: BaseModel, trace_id: str) -> Any:
|
|
316
|
+
return {"accepted": True, "note": "Another agent will take over. Tell the user it is being handled."}
|
|
317
|
+
return [ToolSpec(f"handoff_to_{k}", self.descriptions.get(k, f"Hand the case over to the {k} agent."),
|
|
318
|
+
self.message, accepted) for k in self.targets]
|
|
319
|
+
|
|
320
|
+
@trace
|
|
321
|
+
async def run(self, task: str, ctx: Context) -> Outcome:
|
|
322
|
+
source = Agent(self.source.name, self.source.instructions, self.source.tools + self._tools(),
|
|
323
|
+
backend=self.source.backend, budget=self.source.budget,
|
|
324
|
+
final_check=self.source.final_check, prefetch=self.source.prefetch)
|
|
325
|
+
first = await source(task, ctx)
|
|
326
|
+
steps = first.runs[-1].steps if first.runs else []
|
|
327
|
+
call = next((s for s in steps if s.kind == "tool_call" and (s.tool or "").startswith("handoff_to_")), None)
|
|
328
|
+
if call is None:
|
|
329
|
+
return replace(first, data={**first.data, "handoff": None})
|
|
330
|
+
target = call.tool.removeprefix("handoff_to_")
|
|
331
|
+
note = self.message.model_validate(call.arguments)
|
|
332
|
+
log_step(ctx.trace_id, "handoff", source=self.source.name, target=target, message=note.model_dump())
|
|
333
|
+
second = await self.targets[target](self.render(note), ctx)
|
|
334
|
+
return replace(second, runs=first.runs + second.runs, data={**second.data, "handoff": target})
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# -- evaluator / optimizer ----------------------------------------------------
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class Verdict(BaseModel):
|
|
341
|
+
ok: bool
|
|
342
|
+
feedback: str = ""
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class LLMCritic:
|
|
346
|
+
"""A model reviews an answer against `criteria` and returns a Verdict."""
|
|
347
|
+
|
|
348
|
+
def __init__(self, criteria: str, backend: LLMBackend | None = None):
|
|
349
|
+
self.criteria, self.backend = criteria, backend
|
|
350
|
+
|
|
351
|
+
@trace
|
|
352
|
+
async def __call__(self, task: str, answer: str, ctx: Context) -> Verdict:
|
|
353
|
+
from agent_loop.prompts import build_system_message
|
|
354
|
+
messages = [build_system_message(
|
|
355
|
+
"You review an answer to a task against the criteria below. Reply with one JSON object: "
|
|
356
|
+
'{"ok": true|false, "feedback": "<what must change, empty if ok>"}.\n'
|
|
357
|
+
f"Criteria:\n{self.criteria}"),
|
|
358
|
+
{"role": "user", "content": f"Task:\n{task}\n\nAnswer to review:\n{answer}"}]
|
|
359
|
+
raw = await (self.backend or ctx.backend).generate(
|
|
360
|
+
messages, format=Verdict.model_json_schema(), options={"temperature": 0, "seed": 42},
|
|
361
|
+
trace_id=ctx.trace_id)
|
|
362
|
+
try:
|
|
363
|
+
return Verdict.model_validate_json(raw)
|
|
364
|
+
except ValidationError:
|
|
365
|
+
return Verdict(ok=False, feedback="the review could not be parsed; answer again, precisely")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class Refine(_Block):
|
|
369
|
+
"""Worker answers; `critic(task, answer, ctx) -> Verdict` (sync or
|
|
370
|
+
async; a rule function or LLMCritic) reviews it; on a rejection the
|
|
371
|
+
worker answers again with the feedback, up to `max_rounds` answers.
|
|
372
|
+
Ends "answered" when the critic accepts, else "rejected_final" with
|
|
373
|
+
the last answer kept in data["last_answer"]."""
|
|
374
|
+
|
|
375
|
+
def __init__(self, worker: StepLike, critic: Callable[..., Any], *, max_rounds: int = 3, name: str = ""):
|
|
376
|
+
if max_rounds < 1:
|
|
377
|
+
raise ValueError("max_rounds must be at least 1")
|
|
378
|
+
self.worker, self.critic, self.max_rounds, self.name = _as_step(worker), critic, max_rounds, name
|
|
379
|
+
|
|
380
|
+
@trace
|
|
381
|
+
async def run(self, task: str, ctx: Context) -> Outcome:
|
|
382
|
+
runs: list[AgentResult] = []
|
|
383
|
+
current, history = task, []
|
|
384
|
+
for round_ in range(1, self.max_rounds + 1):
|
|
385
|
+
outcome = await self.worker(current, ctx)
|
|
386
|
+
runs += outcome.runs
|
|
387
|
+
if not outcome.ok:
|
|
388
|
+
return replace(outcome, runs=runs, data={**outcome.data, "rounds": round_, "reviews": history})
|
|
389
|
+
verdict = self.critic(task, outcome.answer, ctx)
|
|
390
|
+
if inspect.isawaitable(verdict):
|
|
391
|
+
verdict = await verdict
|
|
392
|
+
history.append({"round": round_, "ok": verdict.ok, "feedback": verdict.feedback})
|
|
393
|
+
log_step(ctx.trace_id, "review", round=round_, ok=verdict.ok, feedback=verdict.feedback[:300])
|
|
394
|
+
if verdict.ok:
|
|
395
|
+
return replace(outcome, runs=runs, data={**outcome.data, "rounds": round_, "reviews": history})
|
|
396
|
+
current = (f"{task}\n\nYour previous answer:\n{outcome.answer}\n\n"
|
|
397
|
+
f"A reviewer rejected it:\n{verdict.feedback}\nAnswer again, fixing that.")
|
|
398
|
+
return Outcome("rejected_final", None, runs,
|
|
399
|
+
{"rounds": self.max_rounds, "reviews": history, "last_answer": outcome.answer})
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# -- fallback, timeout --------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class Fallback(_Block):
|
|
406
|
+
"""Tries steps in order; the first that answers wins. A step that
|
|
407
|
+
pauses for approval stops the chain (a human is now involved)."""
|
|
408
|
+
|
|
409
|
+
def __init__(self, *steps: StepLike, name: str = ""):
|
|
410
|
+
if not steps:
|
|
411
|
+
raise ValueError("Fallback needs at least one step")
|
|
412
|
+
self.steps, self.name = [_as_step(s) for s in steps], name
|
|
413
|
+
|
|
414
|
+
@trace
|
|
415
|
+
async def run(self, task: str, ctx: Context) -> Outcome:
|
|
416
|
+
runs: list[AgentResult] = []
|
|
417
|
+
tried = []
|
|
418
|
+
for i, s in enumerate(self.steps):
|
|
419
|
+
outcome = await s(task, ctx)
|
|
420
|
+
runs += outcome.runs
|
|
421
|
+
tried.append(outcome.status)
|
|
422
|
+
if outcome.ok or outcome.status == "awaiting_approval":
|
|
423
|
+
return replace(outcome, runs=runs, data={**outcome.data, "used": i, "tried": tried})
|
|
424
|
+
return replace(outcome, runs=runs, data={**outcome.data, "used": None, "tried": tried})
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class Timeout(_Block):
|
|
428
|
+
"""Wall-time limit around any step; on expiry the inner work is
|
|
429
|
+
cancelled (agent runs save their state as "cancelled")."""
|
|
430
|
+
|
|
431
|
+
def __init__(self, inner: StepLike, seconds: float, name: str = ""):
|
|
432
|
+
self.inner, self.seconds, self.name = _as_step(inner), seconds, name
|
|
433
|
+
|
|
434
|
+
@trace
|
|
435
|
+
async def run(self, task: str, ctx: Context) -> Outcome:
|
|
436
|
+
try:
|
|
437
|
+
async with asyncio.timeout(self.seconds):
|
|
438
|
+
return await self.inner(task, ctx)
|
|
439
|
+
except TimeoutError:
|
|
440
|
+
log_step(ctx.trace_id, "composite_timeout", seconds=self.seconds)
|
|
441
|
+
return Outcome("timeout", None, data={"seconds": self.seconds})
|
agent_loop/hooks.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Where the loop's step events go, and how a run's trace id is bound
|
|
2
|
+
for the code it calls (tools, backends).
|
|
3
|
+
|
|
4
|
+
By default events go to the standard `logging` logger "agent_loop" at
|
|
5
|
+
DEBUG level as one JSON object each, and binding a trace id only sets
|
|
6
|
+
`current_trace_id()`. An application plugs in its own:
|
|
7
|
+
|
|
8
|
+
from agent_loop import hooks
|
|
9
|
+
hooks.configure(log=my_log_step, bind_trace=my_bind_trace)
|
|
10
|
+
|
|
11
|
+
`log(trace_id, step, **fields)` must not raise and should not block
|
|
12
|
+
(it is called on the event loop). `bind_trace(trace_id)` returns a
|
|
13
|
+
context manager entered around each run segment.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import uuid
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
22
|
+
from contextvars import ContextVar
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
_logger = logging.getLogger("agent_loop")
|
|
26
|
+
_current: ContextVar[str | None] = ContextVar("agent_loop_trace_id", default=None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def new_trace_id() -> str:
|
|
30
|
+
return str(uuid.uuid4())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def current_trace_id() -> str | None:
|
|
34
|
+
return _current.get()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _default_log(trace_id: str | None, step: str, **fields: Any) -> None:
|
|
38
|
+
if _logger.isEnabledFor(logging.DEBUG):
|
|
39
|
+
_logger.debug(json.dumps({"trace_id": trace_id, "step": step, **fields}, default=str))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@contextmanager
|
|
43
|
+
def _default_bind(trace_id: str):
|
|
44
|
+
yield trace_id
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_log: Callable[..., None] = _default_log
|
|
48
|
+
_bind: Callable[[str], AbstractContextManager] = _default_bind
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def configure(*, log: Callable[..., None] | None = None,
|
|
52
|
+
bind_trace: Callable[[str], AbstractContextManager] | None = None) -> None:
|
|
53
|
+
"""Replace the event sink and/or the trace binding; None restores the default."""
|
|
54
|
+
global _log, _bind
|
|
55
|
+
_log = log or _default_log
|
|
56
|
+
_bind = bind_trace or _default_bind
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def log_step(trace_id: str | None, step: str, **fields: Any) -> None:
|
|
60
|
+
try:
|
|
61
|
+
_log(trace_id or _current.get(), step, **fields)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass # observability must never break the run
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@contextmanager
|
|
67
|
+
def bind_trace(trace_id: str):
|
|
68
|
+
token = _current.set(trace_id)
|
|
69
|
+
try:
|
|
70
|
+
with _bind(trace_id):
|
|
71
|
+
yield trace_id
|
|
72
|
+
finally:
|
|
73
|
+
_current.reset(token)
|