reactifact 0.6.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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- reactifact-0.6.0.dist-info/top_level.txt +1 -0
reactifact/runtime.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import AsyncIterator, Callable
|
|
6
|
+
|
|
7
|
+
from .agents import Agent
|
|
8
|
+
from .budget import Budget, RunOutcome, RunStats
|
|
9
|
+
from .commit import Commit, Read, Write
|
|
10
|
+
from .context import Context
|
|
11
|
+
from .effects import Effects, current_effects, reset_effects, set_effects
|
|
12
|
+
from .events import Event
|
|
13
|
+
from .patches import Create, Delete, Link, Patch, Unlink, Update
|
|
14
|
+
from .scheduler import Scheduler
|
|
15
|
+
from .session import Session
|
|
16
|
+
from .streaming import ProgressEvent
|
|
17
|
+
from .tracing.models import AgentSpan
|
|
18
|
+
from .tracing.tracer import CompositeTracer, RunTracer, Tracer
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
#: A scheduled patch with its trigger reads and (optional) trace span.
|
|
23
|
+
PatchWork = tuple[Patch, Agent, list[Read], AgentSpan | None]
|
|
24
|
+
#: One agent execution result: patch (if any), the agent/event/reads that
|
|
25
|
+
#: produced it, latency, and the exception if the produce raised and
|
|
26
|
+
#: `isolate_errors` was set.
|
|
27
|
+
AgentResult = tuple[Patch | None, Agent, Event, list[Read], float, BaseException | None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Runtime:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
context: Context,
|
|
34
|
+
agents: list[Agent] | None = None,
|
|
35
|
+
max_concurrency: int | None = None,
|
|
36
|
+
session: "Session | None" = None,
|
|
37
|
+
budget: Budget | None = None,
|
|
38
|
+
tracer: Tracer | list[Tracer] | None = None,
|
|
39
|
+
scheduler: Scheduler | None = None,
|
|
40
|
+
isolate_errors: bool = False,
|
|
41
|
+
on_agent_error: Callable[[Agent, Event, BaseException], None] | None = None,
|
|
42
|
+
):
|
|
43
|
+
self.context = context
|
|
44
|
+
self.agents = agents or []
|
|
45
|
+
self.max_concurrency = max_concurrency
|
|
46
|
+
self.session = session
|
|
47
|
+
self.budget = budget
|
|
48
|
+
self.scheduler = scheduler
|
|
49
|
+
# §69 "make illegal states visible" default: an agent's exception still
|
|
50
|
+
# propagates out of arun()/astream() unless isolate_errors=True — opt in
|
|
51
|
+
# to resilience explicitly rather than silently swallowing bugs.
|
|
52
|
+
self.isolate_errors = isolate_errors
|
|
53
|
+
self.on_agent_error = on_agent_error
|
|
54
|
+
self.tracer: Tracer | CompositeTracer | None = (
|
|
55
|
+
tracer
|
|
56
|
+
if isinstance(tracer, Tracer) or tracer is None
|
|
57
|
+
else CompositeTracer(tracer)
|
|
58
|
+
)
|
|
59
|
+
# Tracing is fully delegated to RunTracer (§54): span/trace building,
|
|
60
|
+
# the RecordingLLM wrap, and the task→agent attribution it needs all
|
|
61
|
+
# live there — Runtime just calls into it at a few points below.
|
|
62
|
+
self._trace = RunTracer(context, self.tracer)
|
|
63
|
+
self.outcome: RunOutcome = RunOutcome.COMPLETED
|
|
64
|
+
self.last_stats: RunStats | None = None
|
|
65
|
+
self._runs_used = 0
|
|
66
|
+
self._deadline: float | None = None
|
|
67
|
+
self._active_budget: Budget | None = None
|
|
68
|
+
self._turn_started = False
|
|
69
|
+
self._turn_started_at = 0.0
|
|
70
|
+
self._no_runs_warned = False
|
|
71
|
+
self._errors_used = 0
|
|
72
|
+
|
|
73
|
+
def register(self, agent: Agent) -> None:
|
|
74
|
+
self.agents.append(agent)
|
|
75
|
+
|
|
76
|
+
def _begin_turn(self, budget: Budget | None) -> None:
|
|
77
|
+
self._runs_used = 0
|
|
78
|
+
self._errors_used = 0
|
|
79
|
+
self.outcome = RunOutcome.COMPLETED
|
|
80
|
+
self._deadline = None
|
|
81
|
+
self._turn_started_at = time.monotonic()
|
|
82
|
+
self._active_budget = budget or self.budget
|
|
83
|
+
if (
|
|
84
|
+
self._active_budget is not None
|
|
85
|
+
and self._active_budget.max_seconds is not None
|
|
86
|
+
):
|
|
87
|
+
self._deadline = self._turn_started_at + self._active_budget.max_seconds
|
|
88
|
+
# expose budget visibility to agents (LLM agent counts max_tool_calls;
|
|
89
|
+
# a multi-step blocking loop like ToolUse._loop checks `budget_deadline`
|
|
90
|
+
# between its own internal steps — the runtime only enforces max_seconds
|
|
91
|
+
# *between* agent runs, so a produce with its own internal loop would
|
|
92
|
+
# otherwise never see it until the whole produce() returns).
|
|
93
|
+
self.context.resources.set("budget", self._active_budget)
|
|
94
|
+
self.context.resources.set("budget_deadline", self._deadline)
|
|
95
|
+
self._turn_started = True
|
|
96
|
+
self._trace.begin_turn(
|
|
97
|
+
session_id=self.session.session_id if self.session is not None else ""
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _budget_exhausted(self) -> bool:
|
|
101
|
+
if self._deadline is not None and time.monotonic() >= self._deadline:
|
|
102
|
+
self.outcome = RunOutcome.BUDGET_TIME_EXCEEDED
|
|
103
|
+
return True
|
|
104
|
+
if (
|
|
105
|
+
self._active_budget is not None
|
|
106
|
+
and self._active_budget.max_runs is not None
|
|
107
|
+
and self._runs_used >= self._active_budget.max_runs
|
|
108
|
+
):
|
|
109
|
+
self.outcome = RunOutcome.BUDGET_RUNS_EXCEEDED
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
def _validate_patch_types(self, patch: Patch, agent: Agent) -> None:
|
|
114
|
+
"""Checks that all Create operations match the agent's produces."""
|
|
115
|
+
if agent.produces is None:
|
|
116
|
+
return # no restrictions
|
|
117
|
+
allowed_types = {
|
|
118
|
+
p.artifact_type for p in agent.produces if p.artifact_type is not None
|
|
119
|
+
}
|
|
120
|
+
if not allowed_types:
|
|
121
|
+
return
|
|
122
|
+
for op in patch.operations:
|
|
123
|
+
if isinstance(op, Create) and type(op.data) not in allowed_types:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"Agent '{agent.name}' created artifact of type {type(op.data).__name__}, "
|
|
126
|
+
f"which is not declared in produces: {[t.__name__ for t in allowed_types]}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def arun_once(self, budget: Budget | None = None) -> int:
|
|
130
|
+
if not self._turn_started:
|
|
131
|
+
self._begin_turn(budget)
|
|
132
|
+
events = self.context.drain_events()
|
|
133
|
+
if not events:
|
|
134
|
+
return 0
|
|
135
|
+
if self._budget_exhausted():
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
# Collect work (event, agent), accounting for priority: agents with lower
|
|
139
|
+
# values run earlier, "finishers" last.
|
|
140
|
+
work: list[tuple[Agent, Event, list[Read]]] = []
|
|
141
|
+
ordered_agents = sorted(self.agents, key=lambda a: a.priority)
|
|
142
|
+
for event in events:
|
|
143
|
+
if self._budget_exhausted():
|
|
144
|
+
break
|
|
145
|
+
for agent in ordered_agents:
|
|
146
|
+
if self._budget_exhausted():
|
|
147
|
+
break
|
|
148
|
+
if agent.matches(event, self.context):
|
|
149
|
+
reads = self._collect_reads(agent, event)
|
|
150
|
+
work.append((agent, event, reads))
|
|
151
|
+
|
|
152
|
+
# Limit the number of runs by the max_runs budget. Set the budget_runs_exceeded
|
|
153
|
+
# outcome only when the limit is actually reached, not when the event simply
|
|
154
|
+
# has no subscribed agents.
|
|
155
|
+
if self.scheduler is not None and work:
|
|
156
|
+
work = await self.scheduler(self.context, work)
|
|
157
|
+
|
|
158
|
+
active = self._active_budget
|
|
159
|
+
if active is not None and active.max_runs is not None:
|
|
160
|
+
remaining = active.max_runs - self._runs_used
|
|
161
|
+
if remaining <= 0:
|
|
162
|
+
self.outcome = RunOutcome.BUDGET_RUNS_EXCEEDED
|
|
163
|
+
work = []
|
|
164
|
+
else:
|
|
165
|
+
work = work[:remaining]
|
|
166
|
+
|
|
167
|
+
results = await self._dispatch(work)
|
|
168
|
+
patches_to_apply, runs = self._get_patches_to_apply(results)
|
|
169
|
+
await self._commit_patches_to_apply(patches_to_apply)
|
|
170
|
+
return runs
|
|
171
|
+
|
|
172
|
+
async def _dispatch(
|
|
173
|
+
self, work: list[tuple[Agent, Event, list[Read]]]
|
|
174
|
+
) -> list[AgentResult]:
|
|
175
|
+
"""Runs the generation's workers.
|
|
176
|
+
|
|
177
|
+
Sequential when there is nothing to parallelize (no runtime cap and no
|
|
178
|
+
per-agent limits); otherwise concurrent with: the global
|
|
179
|
+
`max_concurrency` cap plus per-agent `concurrency_limit` tiers — so
|
|
180
|
+
LLM-bound producers can be throttled separately from cheap I/O.
|
|
181
|
+
Semaphores are acquired global-first (fixed order avoids deadlocks) and
|
|
182
|
+
released in reverse.
|
|
183
|
+
"""
|
|
184
|
+
if not work:
|
|
185
|
+
return []
|
|
186
|
+
limiters = {
|
|
187
|
+
agent.concurrency_limit
|
|
188
|
+
for agent, _, _ in work
|
|
189
|
+
if agent.concurrency_limit is not None and agent.concurrency_limit > 0
|
|
190
|
+
}
|
|
191
|
+
if self.max_concurrency is None and not limiters:
|
|
192
|
+
return [await self._execute(item) for item in work]
|
|
193
|
+
|
|
194
|
+
global_semaphore = (
|
|
195
|
+
asyncio.Semaphore(self.max_concurrency)
|
|
196
|
+
if self.max_concurrency is not None
|
|
197
|
+
else None
|
|
198
|
+
)
|
|
199
|
+
limit_semaphores = {limit: asyncio.Semaphore(limit) for limit in limiters}
|
|
200
|
+
|
|
201
|
+
async def _worker(item: tuple[Agent, Event, list[Read]]) -> AgentResult:
|
|
202
|
+
agent = item[0]
|
|
203
|
+
acquired: list[asyncio.Semaphore] = []
|
|
204
|
+
tier = agent.concurrency_limit
|
|
205
|
+
if tier is not None and tier > 0:
|
|
206
|
+
acquired.append(limit_semaphores[tier])
|
|
207
|
+
if global_semaphore is not None:
|
|
208
|
+
acquired.append(global_semaphore)
|
|
209
|
+
for semaphore in acquired:
|
|
210
|
+
await semaphore.acquire()
|
|
211
|
+
try:
|
|
212
|
+
return await self._execute(item, None)
|
|
213
|
+
finally:
|
|
214
|
+
for semaphore in reversed(acquired):
|
|
215
|
+
semaphore.release()
|
|
216
|
+
|
|
217
|
+
return await asyncio.gather(*(_worker(item) for item in work))
|
|
218
|
+
|
|
219
|
+
def _get_patches_to_apply(
|
|
220
|
+
self, results: list[AgentResult]
|
|
221
|
+
) -> tuple[list[PatchWork], int]:
|
|
222
|
+
"""Turns agent results into the patches to apply (+ the run count).
|
|
223
|
+
|
|
224
|
+
Executions that changed nothing are not applied and not traced (a
|
|
225
|
+
monotonic flood of "checked, no work" spans would make traces
|
|
226
|
+
unreadable, §54), but they still count toward the run budget.
|
|
227
|
+
"""
|
|
228
|
+
patches_to_apply: list[PatchWork] = []
|
|
229
|
+
runs = 0
|
|
230
|
+
for patch, agent, event, reads, latency, error in results:
|
|
231
|
+
if self._budget_exhausted():
|
|
232
|
+
break
|
|
233
|
+
runs += 1
|
|
234
|
+
self._runs_used += 1
|
|
235
|
+
if error is not None:
|
|
236
|
+
self._errors_used += 1
|
|
237
|
+
self._trace.record_span(agent, event, reads, latency, error=error)
|
|
238
|
+
continue
|
|
239
|
+
if patch is None or patch.is_empty():
|
|
240
|
+
continue
|
|
241
|
+
span = self._trace.record_span(agent, event, reads, latency)
|
|
242
|
+
self._validate_patch_types(patch, agent)
|
|
243
|
+
patches_to_apply.append((patch, agent, reads, span))
|
|
244
|
+
return patches_to_apply, runs
|
|
245
|
+
|
|
246
|
+
async def _commit_patches_to_apply(self, patches_to_apply: list[PatchWork]) -> None:
|
|
247
|
+
"""Applies each patch as a commit: provenance, span writes, persistence."""
|
|
248
|
+
for patch, agent, reads, span in patches_to_apply:
|
|
249
|
+
commit = Commit(
|
|
250
|
+
author=agent.name,
|
|
251
|
+
message=f"Applied patch from agent '{agent.name}'",
|
|
252
|
+
operations=patch.operations,
|
|
253
|
+
reads=reads,
|
|
254
|
+
)
|
|
255
|
+
commit.writes = self._apply_patch(patch, commit)
|
|
256
|
+
if span is not None:
|
|
257
|
+
span.writes = self._trace.write_refs(patch, commit.writes)
|
|
258
|
+
span.relations = self._trace.relation_refs(patch)
|
|
259
|
+
self.context.log_commit(commit)
|
|
260
|
+
if self.session is not None:
|
|
261
|
+
# git-like persist after each commit: the session survives a crash
|
|
262
|
+
# at the boundary of any agent generation. Session backends are
|
|
263
|
+
# async-native (checkpoints.py) — a slow file/SQLite write yields
|
|
264
|
+
# to other concurrent agent runs instead of blocking a thread.
|
|
265
|
+
await self.session.save()
|
|
266
|
+
|
|
267
|
+
def _collect_reads(self, agent: Agent, event: Event) -> list[Read]:
|
|
268
|
+
"""Records consumed artifacts: the trigger event + inputs per consumes.
|
|
269
|
+
|
|
270
|
+
This is the actual link of an agent to its ancestors (git-like provenance),
|
|
271
|
+
built by the runtime rather than by the graph author.
|
|
272
|
+
"""
|
|
273
|
+
reads: list[Read] = []
|
|
274
|
+
seen: set[str] = set()
|
|
275
|
+
trigger_artifact = self.context.get(event.artifact_id)
|
|
276
|
+
if trigger_artifact is not None:
|
|
277
|
+
reads.append(Read(trigger_artifact.id, trigger_artifact.version))
|
|
278
|
+
seen.add(trigger_artifact.id)
|
|
279
|
+
for artifact in agent.collect_inputs(self.context):
|
|
280
|
+
if artifact.id not in seen:
|
|
281
|
+
reads.append(Read(artifact.id, artifact.version))
|
|
282
|
+
seen.add(artifact.id)
|
|
283
|
+
return reads
|
|
284
|
+
|
|
285
|
+
async def _execute(
|
|
286
|
+
self,
|
|
287
|
+
item: tuple[Agent, Event, list[Read]],
|
|
288
|
+
semaphore: asyncio.Semaphore | None = None,
|
|
289
|
+
) -> AgentResult:
|
|
290
|
+
"""Runs a single agent (parallel section of a generation).
|
|
291
|
+
|
|
292
|
+
Agents in the same generation work on the same snapshot:
|
|
293
|
+
patches are applied only after all runs finish, so a parallel
|
|
294
|
+
fan-out is safe for provenance (§42, §34).
|
|
295
|
+
|
|
296
|
+
By default an exception raised by a produce propagates out of this
|
|
297
|
+
call (and from `arun`/`astream`) — a bug in one agent is not hidden.
|
|
298
|
+
With `isolate_errors=True`, the exception is caught here instead: the
|
|
299
|
+
agent contributes no patch this generation, `on_agent_error` (if set)
|
|
300
|
+
is called, and the run continues so unrelated agents still make
|
|
301
|
+
progress.
|
|
302
|
+
"""
|
|
303
|
+
agent, event, reads = item
|
|
304
|
+
started = time.monotonic()
|
|
305
|
+
task = asyncio.current_task()
|
|
306
|
+
self._trace.register_task(task, agent.name)
|
|
307
|
+
effects_token = set_effects(Effects(self.context))
|
|
308
|
+
slot: Effects | None = None
|
|
309
|
+
patch: Patch | None = None
|
|
310
|
+
error: BaseException | None = None
|
|
311
|
+
try:
|
|
312
|
+
try:
|
|
313
|
+
if semaphore is not None:
|
|
314
|
+
async with semaphore:
|
|
315
|
+
patch = await agent.run(event, self.context)
|
|
316
|
+
else:
|
|
317
|
+
patch = await agent.run(event, self.context)
|
|
318
|
+
slot = current_effects()
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
if not self.isolate_errors:
|
|
321
|
+
raise
|
|
322
|
+
error = exc
|
|
323
|
+
logger.warning(
|
|
324
|
+
"Agent %r raised %r; isolated (isolate_errors=True)",
|
|
325
|
+
agent.name,
|
|
326
|
+
exc,
|
|
327
|
+
)
|
|
328
|
+
if self.on_agent_error is not None:
|
|
329
|
+
self.on_agent_error(agent, event, exc)
|
|
330
|
+
finally:
|
|
331
|
+
reset_effects(effects_token)
|
|
332
|
+
self._trace.unregister_task(task)
|
|
333
|
+
latency = (time.monotonic() - started) * 1000
|
|
334
|
+
if error is not None:
|
|
335
|
+
return None, agent, event, reads, latency, error
|
|
336
|
+
# Effects authored in produce() compile to the patch. A produce's own
|
|
337
|
+
# effects happen *after* its returned patch (produce order), so an
|
|
338
|
+
# update that captured the pre-effect state cannot regress later effects.
|
|
339
|
+
if slot is not None and not slot.is_empty():
|
|
340
|
+
combined = Patch()
|
|
341
|
+
if patch is not None:
|
|
342
|
+
combined.merge(patch)
|
|
343
|
+
combined.merge(slot.to_patch())
|
|
344
|
+
patch = combined
|
|
345
|
+
return patch, agent, event, reads, latency, None
|
|
346
|
+
|
|
347
|
+
async def arun(
|
|
348
|
+
self,
|
|
349
|
+
max_iterations: int = 100,
|
|
350
|
+
budget: Budget | None = None,
|
|
351
|
+
) -> int:
|
|
352
|
+
self._begin_turn(budget)
|
|
353
|
+
active = self._active_budget
|
|
354
|
+
limit = (
|
|
355
|
+
active.max_iterations
|
|
356
|
+
if active is not None and active.max_iterations is not None
|
|
357
|
+
else max_iterations
|
|
358
|
+
)
|
|
359
|
+
total_runs = 0
|
|
360
|
+
for _ in range(limit):
|
|
361
|
+
if self._budget_exhausted():
|
|
362
|
+
break
|
|
363
|
+
runs = await self.arun_once()
|
|
364
|
+
total_runs += runs
|
|
365
|
+
if runs == 0:
|
|
366
|
+
break
|
|
367
|
+
else:
|
|
368
|
+
if self.outcome == RunOutcome.COMPLETED:
|
|
369
|
+
self.outcome = RunOutcome.ITERATIONS_EXHAUSTED
|
|
370
|
+
self.last_stats = RunStats(
|
|
371
|
+
runs=total_runs,
|
|
372
|
+
iterations=limit,
|
|
373
|
+
outcome=self.outcome,
|
|
374
|
+
duration=time.monotonic() - self._turn_started_at,
|
|
375
|
+
errors=self._errors_used,
|
|
376
|
+
)
|
|
377
|
+
if total_runs == 0:
|
|
378
|
+
self._warn_no_runs()
|
|
379
|
+
await self._trace.end_turn(
|
|
380
|
+
session_id=self.session.session_id if self.session is not None else "",
|
|
381
|
+
duration_ms=time.monotonic() - self._turn_started_at,
|
|
382
|
+
outcome=self.outcome.value,
|
|
383
|
+
)
|
|
384
|
+
return total_runs
|
|
385
|
+
|
|
386
|
+
def run_once(self) -> int:
|
|
387
|
+
return asyncio.run(self.arun_once())
|
|
388
|
+
|
|
389
|
+
def run(self, max_iterations: int = 100, budget: Budget | None = None) -> int:
|
|
390
|
+
return asyncio.run(self.arun(max_iterations, budget))
|
|
391
|
+
|
|
392
|
+
def _warn_no_runs(self) -> None:
|
|
393
|
+
if self._no_runs_warned:
|
|
394
|
+
return
|
|
395
|
+
self._no_runs_warned = True
|
|
396
|
+
if not self.agents:
|
|
397
|
+
return
|
|
398
|
+
consumed = sorted(
|
|
399
|
+
{
|
|
400
|
+
c.artifact_type.__name__
|
|
401
|
+
for agent in self.agents
|
|
402
|
+
for c in (agent.consumes or [])
|
|
403
|
+
if c.artifact_type is not None
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
hint = (
|
|
407
|
+
f" None of the {len(self.agents)} agents ran — nothing consumed the"
|
|
408
|
+
" artifacts present."
|
|
409
|
+
)
|
|
410
|
+
if consumed:
|
|
411
|
+
hint += f" Agents consume: {', '.join(consumed)}."
|
|
412
|
+
hint += " Check your Consume(...) target types or the create()'d artifact type."
|
|
413
|
+
print(hint, file=sys.stderr)
|
|
414
|
+
|
|
415
|
+
async def astream(
|
|
416
|
+
self,
|
|
417
|
+
budget: Budget | None = None,
|
|
418
|
+
max_iterations: int = 1000,
|
|
419
|
+
) -> AsyncIterator[ProgressEvent]:
|
|
420
|
+
"""Stream of a run: run_start → status (agent announces) → run_end.
|
|
421
|
+
|
|
422
|
+
Agents publish statuses via `context.announce(...)`; the app
|
|
423
|
+
re-renders them in the chat ("Thinking…", "Searching docs…", "Found N…").
|
|
424
|
+
At the end a run_end with a summary (outcome/runs/duration) is emitted.
|
|
425
|
+
"""
|
|
426
|
+
queue = self.context.subscribe()
|
|
427
|
+
done = asyncio.Event()
|
|
428
|
+
|
|
429
|
+
async def _runner() -> None:
|
|
430
|
+
try:
|
|
431
|
+
await self.arun(max_iterations=max_iterations, budget=budget)
|
|
432
|
+
finally:
|
|
433
|
+
done.set()
|
|
434
|
+
|
|
435
|
+
task = asyncio.create_task(_runner())
|
|
436
|
+
try:
|
|
437
|
+
yield ProgressEvent(kind="run_start", message="Processing started")
|
|
438
|
+
while True:
|
|
439
|
+
if done.is_set() and queue.empty():
|
|
440
|
+
break
|
|
441
|
+
get_event = asyncio.ensure_future(queue.get())
|
|
442
|
+
wait_done = asyncio.ensure_future(done.wait())
|
|
443
|
+
finished, _ = await asyncio.wait(
|
|
444
|
+
{get_event, wait_done}, return_when=asyncio.FIRST_COMPLETED
|
|
445
|
+
)
|
|
446
|
+
if get_event in finished:
|
|
447
|
+
yield get_event.result()
|
|
448
|
+
else:
|
|
449
|
+
get_event.cancel()
|
|
450
|
+
# Re-raise any agent/runtime exception instead of silently dropping it:
|
|
451
|
+
# an error inside a run must reach the caller, not hide in the task.
|
|
452
|
+
await task
|
|
453
|
+
stats = self.last_stats
|
|
454
|
+
yield ProgressEvent(
|
|
455
|
+
kind="run_end",
|
|
456
|
+
message="Processing finished",
|
|
457
|
+
data={
|
|
458
|
+
"outcome": stats.outcome.value if stats is not None else None,
|
|
459
|
+
"runs": stats.runs if stats is not None else 0,
|
|
460
|
+
"duration": stats.duration if stats is not None else 0.0,
|
|
461
|
+
},
|
|
462
|
+
)
|
|
463
|
+
finally:
|
|
464
|
+
task.cancel()
|
|
465
|
+
self.context.unsubscribe(queue)
|
|
466
|
+
|
|
467
|
+
def _apply_patch(self, patch: Patch, commit: Commit) -> list[Write]:
|
|
468
|
+
writes: list[Write] = []
|
|
469
|
+
for op in patch.operations:
|
|
470
|
+
if isinstance(op, Create):
|
|
471
|
+
if op.id is not None and self.context.get(op.id) is not None:
|
|
472
|
+
# create-or-refresh: after re-derivation update the same logical
|
|
473
|
+
# entity (new revision) rather than creating a duplicate (§42, §43)
|
|
474
|
+
upserted = self.context.update(op.id, op.data)
|
|
475
|
+
assert upserted is not None
|
|
476
|
+
op.artifact_id = op.id
|
|
477
|
+
writes.append(Write(op.id, upserted.version, "update"))
|
|
478
|
+
else:
|
|
479
|
+
created = self.context.create(op.data, id=op.id)
|
|
480
|
+
op.artifact_id = created.id
|
|
481
|
+
created.created_by_commit = commit.id
|
|
482
|
+
writes.append(Write(op.artifact_id, created.version, "create"))
|
|
483
|
+
elif isinstance(op, Update):
|
|
484
|
+
updated = self.context.update(op.artifact_id, op.new_data)
|
|
485
|
+
if updated is not None:
|
|
486
|
+
writes.append(Write(op.artifact_id, updated.version, "update"))
|
|
487
|
+
elif isinstance(op, Delete):
|
|
488
|
+
removed = self.context.get(op.artifact_id)
|
|
489
|
+
version = removed.version if removed is not None else 0
|
|
490
|
+
self.context.delete(op.artifact_id)
|
|
491
|
+
writes.append(Write(op.artifact_id, version, "delete"))
|
|
492
|
+
elif isinstance(op, Link):
|
|
493
|
+
self.context.link(op.artifact_id, op.relation, op.target_id)
|
|
494
|
+
elif isinstance(op, Unlink):
|
|
495
|
+
self.context.unlink(op.artifact_id, op.relation, op.target_id)
|
|
496
|
+
else:
|
|
497
|
+
raise ValueError(f"Unknown operation: {op}")
|
|
498
|
+
return writes
|
reactifact/scheduler.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""reactifact.scheduler — adaptive candidate policy (§26, §24).
|
|
2
|
+
|
|
3
|
+
A hybrid scheduler for a reactive runtime. It runs on the *candidate* work list
|
|
4
|
+
of one iteration and consists of three stages, none of which can starve the run:
|
|
5
|
+
|
|
6
|
+
1. `filter` — hard, cheap, rule-based **pruning** (may drop candidates that
|
|
7
|
+
don't fit the domain rules at all, so they never reach ranking);
|
|
8
|
+
2. `rank` — metric-based **ordering only** (never drops, §26: not every
|
|
9
|
+
scheduling decision needs an LLM);
|
|
10
|
+
3. LLM **tie-break** — one structured call when the top candidates are within
|
|
11
|
+
`llm_tie_break` of each other and a model is available (rare, budgeted).
|
|
12
|
+
|
|
13
|
+
Plus two correctness guards baked in:
|
|
14
|
+
|
|
15
|
+
- **HITL pin** — any candidate that unblocks an *answered* `PendingQuestion`
|
|
16
|
+
(resume/approval) is forced to the front, so a human approval can never lose
|
|
17
|
+
to ranking (§60);
|
|
18
|
+
- **No-starvation fallback** — if filtering would empty the candidate set, the
|
|
19
|
+
policy keeps the original list (the only path to progress must survive).
|
|
20
|
+
|
|
21
|
+
runtime = Runtime(ctx, agents=[...], scheduler=uncertainty_policy(
|
|
22
|
+
rules=[not_legacy, not_refuted],
|
|
23
|
+
metric=support_split,
|
|
24
|
+
llm_tie_break=0.05,
|
|
25
|
+
))
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Callable, Sequence
|
|
31
|
+
from typing import TYPE_CHECKING, Any
|
|
32
|
+
|
|
33
|
+
from .interrupt import PendingQuestion
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from .agents import Agent
|
|
37
|
+
from .context import Context
|
|
38
|
+
from .events import Event
|
|
39
|
+
|
|
40
|
+
#: One scheduled candidate: (agent, event, reads).
|
|
41
|
+
WorkItem = tuple["Agent", "Event", list[Any]]
|
|
42
|
+
|
|
43
|
+
Rule = Callable[["Context", "Agent", "Event"], bool]
|
|
44
|
+
Metric = Callable[["Context", "Agent", "Event"], float]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _stable_metric(context: Context, agent: Agent, event: Event) -> float:
|
|
48
|
+
return 0.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_resume(context: Context, agent: Agent, event: Event) -> bool:
|
|
52
|
+
artifact = context.get(event.artifact_id)
|
|
53
|
+
return isinstance(getattr(artifact, "data", None), PendingQuestion) and bool(
|
|
54
|
+
artifact.data.answered # type: ignore[union-attr]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
DEFAULT_TIE_BREAK_SYSTEM = (
|
|
59
|
+
"You rank which scheduled agent should run FIRST to most reduce "
|
|
60
|
+
"uncertainty about the current question. Reply with the index."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Scheduler:
|
|
65
|
+
"""Filter → rank → LLM tie-break; callable from the runtime each iteration.
|
|
66
|
+
|
|
67
|
+
`llm_system` is the (overrideable) system prompt for the tie-break call —
|
|
68
|
+
the app can phrase the decision the way its domain wants, or disable it by
|
|
69
|
+
leaving `llm_tie_break=None` (deterministic only).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
rules: Sequence[Rule] = (),
|
|
76
|
+
metric: Metric = _stable_metric,
|
|
77
|
+
llm_tie_break: float | None = None,
|
|
78
|
+
llm_system: str | None = None,
|
|
79
|
+
rank_limit: int | None = None,
|
|
80
|
+
):
|
|
81
|
+
self.rules = list(rules)
|
|
82
|
+
self.metric = metric
|
|
83
|
+
self.llm_tie_break = llm_tie_break
|
|
84
|
+
self.llm_system = llm_system or DEFAULT_TIE_BREAK_SYSTEM
|
|
85
|
+
#: Optional "choose top-k": keep only the k best *ranked* candidates.
|
|
86
|
+
#: Pinned (HITL-resume) candidates are never trimmed, and the list is
|
|
87
|
+
#: never emptied (at least the top candidate survives) — §24.
|
|
88
|
+
self.rank_limit = rank_limit
|
|
89
|
+
|
|
90
|
+
async def __call__(self, context: Context, work: list[WorkItem]) -> list[WorkItem]:
|
|
91
|
+
return await self.run(context, work)
|
|
92
|
+
|
|
93
|
+
async def run(self, context: Context, work: list[WorkItem]) -> list[WorkItem]:
|
|
94
|
+
if len(work) <= 1:
|
|
95
|
+
return work
|
|
96
|
+
|
|
97
|
+
# pin HITL-resume candidates first (§60); they must never lose to ranking
|
|
98
|
+
pinned: list[WorkItem] = []
|
|
99
|
+
rest: list[WorkItem] = []
|
|
100
|
+
for item in work:
|
|
101
|
+
(pinned if _is_resume(context, item[0], item[1]) else rest).append(item)
|
|
102
|
+
|
|
103
|
+
# 1) hard filter (rules) — with no-starvation fallback
|
|
104
|
+
filtered, dropped = [], 0
|
|
105
|
+
for agent, event, reads in rest:
|
|
106
|
+
if all(rule(context, agent, event) for rule in self.rules):
|
|
107
|
+
filtered.append((agent, event, reads))
|
|
108
|
+
else:
|
|
109
|
+
dropped += 1
|
|
110
|
+
if not filtered:
|
|
111
|
+
filtered = rest # never starve the run (§24)
|
|
112
|
+
|
|
113
|
+
# 2) deterministic metric ranking (stable for ties)
|
|
114
|
+
scored = sorted(
|
|
115
|
+
(
|
|
116
|
+
(item, self.metric(context, item[0], item[1]), i)
|
|
117
|
+
for i, item in enumerate(filtered)
|
|
118
|
+
),
|
|
119
|
+
key=lambda row: (-row[1], row[2]),
|
|
120
|
+
)
|
|
121
|
+
ranked = [row[0] for row in scored]
|
|
122
|
+
|
|
123
|
+
# 3) LLM tie-break: only the top pair, only when close and a model exists
|
|
124
|
+
if (
|
|
125
|
+
self.llm_tie_break is not None
|
|
126
|
+
and len(ranked) >= 2
|
|
127
|
+
and context.resources.llm is not None
|
|
128
|
+
and scored[0][1] - scored[1][1] <= self.llm_tie_break
|
|
129
|
+
):
|
|
130
|
+
ranked = await self._llm_order(context, ranked[:2]) + ranked[2:]
|
|
131
|
+
|
|
132
|
+
# 4) optional "choose top-k" — never empties a non-empty ranked list
|
|
133
|
+
if self.rank_limit is not None and len(ranked) > self.rank_limit:
|
|
134
|
+
ranked = ranked[: self.rank_limit]
|
|
135
|
+
|
|
136
|
+
return pinned + ranked
|
|
137
|
+
|
|
138
|
+
async def _llm_order(self, context: Context, top: list[WorkItem]) -> list[WorkItem]:
|
|
139
|
+
from pydantic import BaseModel
|
|
140
|
+
|
|
141
|
+
from .structured import structured_llm
|
|
142
|
+
|
|
143
|
+
class _Order(BaseModel):
|
|
144
|
+
first: int
|
|
145
|
+
|
|
146
|
+
description = ", ".join(
|
|
147
|
+
f"[{i}] agent={item[0].name} "
|
|
148
|
+
f"capabilities={list(item[0].capabilities)} "
|
|
149
|
+
f"event={item[1].type.value}"
|
|
150
|
+
for i, item in enumerate(top)
|
|
151
|
+
)
|
|
152
|
+
body = await structured_llm(
|
|
153
|
+
context,
|
|
154
|
+
schema=_Order,
|
|
155
|
+
system=self.llm_system,
|
|
156
|
+
user=description,
|
|
157
|
+
)
|
|
158
|
+
if body is None or body.first not in (0, 1):
|
|
159
|
+
return top # honest fallback: keep the deterministic order
|
|
160
|
+
return [top[body.first], top[1 - body.first]]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def uncertainty_policy(
|
|
164
|
+
*,
|
|
165
|
+
rules: Sequence[Rule] = (),
|
|
166
|
+
metric: Metric = _stable_metric,
|
|
167
|
+
llm_tie_break: float | None = None,
|
|
168
|
+
llm_system: str | None = None,
|
|
169
|
+
rank_limit: int | None = None,
|
|
170
|
+
) -> Scheduler:
|
|
171
|
+
"""The built-in hybrid policy (filter → rank → LLM tie-break → top-k)."""
|
|
172
|
+
return Scheduler(
|
|
173
|
+
rules=rules,
|
|
174
|
+
metric=metric,
|
|
175
|
+
llm_tie_break=llm_tie_break,
|
|
176
|
+
llm_system=llm_system,
|
|
177
|
+
rank_limit=rank_limit,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = [
|
|
182
|
+
"DEFAULT_TIE_BREAK_SYSTEM",
|
|
183
|
+
"Metric",
|
|
184
|
+
"Rule",
|
|
185
|
+
"Scheduler",
|
|
186
|
+
"WorkItem",
|
|
187
|
+
"uncertainty_policy",
|
|
188
|
+
]
|