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.
Files changed (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. 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
@@ -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
+ ]