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 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)