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
@@ -0,0 +1,407 @@
1
+ """Video generation providers (submit → poll → download).
2
+
3
+ Video backends (Sora, Runway, ...) expose an async, long-running task API:
4
+ you submit a prompt, get a task id, poll until the job completes, then fetch
5
+ the finished mp4. The base `_HttpVideoProvider` keeps that contract uniform
6
+ and testable (inject `transport` like the chat/image providers).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import time
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from ._retry import with_retry
20
+ from .chat import _network_knobs
21
+ from .contracts import auth_value
22
+
23
+ STATUS_MAP = {
24
+ "queued": "pending",
25
+ "queued_processing": "processing",
26
+ "in_progress": "processing",
27
+ "processing": "processing",
28
+ "running": "processing",
29
+ "dreaming": "processing",
30
+ "completed": "completed",
31
+ "succeeded": "completed",
32
+ "success": "completed",
33
+ "failed": "failed",
34
+ "error": "failed",
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class VideoResult:
40
+ """State of a video generation task."""
41
+
42
+ id: str
43
+ status: str = "pending" # pending | processing | completed | failed
44
+ url: str | None = None
45
+ error: str | None = None
46
+ data: bytes | None = None
47
+ extra: dict[str, Any] = field(default_factory=dict)
48
+
49
+
50
+ class VideoProvider(ABC):
51
+ """Async video generation: submit → poll → download.
52
+
53
+ Subclasses that can fetch the finished file from `result.url` override
54
+ `download` with an HTTP client; the embedded-`data` shortcut lives here.
55
+ """
56
+
57
+ _client: httpx.AsyncClient | None = None
58
+
59
+ @abstractmethod
60
+ async def generate(self, prompt: str, **params: Any) -> str:
61
+ """Submits a generation and returns the task id (str)."""
62
+
63
+ @abstractmethod
64
+ async def fetch(self, task_id: str) -> VideoResult:
65
+ """Current state of the task (pending/processing/completed/failed)."""
66
+
67
+ async def poll(
68
+ self, task_id: str, timeout: float = 600.0, interval: float = 5.0
69
+ ) -> VideoResult:
70
+ """Polls until completed/failed or the timeout elapses (best effort).
71
+
72
+ A `fetch()` that still raises after its own internal retry (a longer
73
+ outage, not a single blip) does not abort the poll — a several-
74
+ minute video job in progress is not worth abandoning over a
75
+ transient network problem, so the loop just waits for the next
76
+ interval and tries again. Only running out of `timeout` while every
77
+ recent fetch failed gives up, with an honest `status="failed"`
78
+ result rather than raising out of a best-effort poll.
79
+ """
80
+ deadline = time.monotonic() + timeout
81
+ last_result: VideoResult | None = None
82
+ last_error: str | None = None
83
+ while True:
84
+ try:
85
+ last_result = await self.fetch(task_id)
86
+ last_error = None
87
+ except (httpx.HTTPStatusError, httpx.TransportError) as exc:
88
+ last_error = str(exc)
89
+ else:
90
+ if last_result.status in ("completed", "failed"):
91
+ return last_result
92
+ if time.monotonic() >= deadline:
93
+ if last_result is not None and last_error is None:
94
+ return last_result
95
+ return VideoResult(
96
+ id=task_id,
97
+ status="failed",
98
+ error=f"polling timed out after repeated fetch failures: {last_error}",
99
+ )
100
+ await asyncio.sleep(interval)
101
+
102
+ async def download(self, result: VideoResult) -> bytes | None:
103
+ """Returns the finished video bytes, or None (no embed, no fetch)."""
104
+ return result.data
105
+
106
+ async def aclose(self) -> None:
107
+ if self._client is not None:
108
+ await self._client.aclose()
109
+ self._client = None
110
+
111
+
112
+ class _HttpVideoProvider(VideoProvider):
113
+ """HTTP base for video providers: shared client + auth/proxy knobs."""
114
+
115
+ def __init__(
116
+ self,
117
+ base_url: str,
118
+ api_key: str | None = None,
119
+ model: str | None = None,
120
+ timeout: float = 300.0,
121
+ transport: Any | None = None,
122
+ proxy: str | None = None,
123
+ auth_header: str = "Authorization",
124
+ auth_scheme: str | None = "Bearer",
125
+ extra_headers: dict[str, str] | None = None,
126
+ retry_attempts: int = 3,
127
+ ):
128
+ self.base_url = base_url.rstrip("/")
129
+ self.api_key = api_key
130
+ self.model = model
131
+ self._timeout = timeout
132
+ self._headers = dict(extra_headers or {"Content-Type": "application/json"})
133
+ if api_key:
134
+ self._headers.setdefault(auth_header, auth_value(api_key, auth_scheme))
135
+ self._transport = transport
136
+ self._proxy = proxy
137
+ self.retry_attempts = retry_attempts
138
+ self._client: httpx.AsyncClient | None = None
139
+
140
+ def _get_client(self) -> httpx.AsyncClient:
141
+ if self._client is None:
142
+ self._client = httpx.AsyncClient(
143
+ timeout=self._timeout,
144
+ transport=self._transport,
145
+ headers=self._headers,
146
+ proxy=self._proxy,
147
+ )
148
+ return self._client
149
+
150
+ async def download(self, result: VideoResult) -> bytes | None:
151
+ if result.data is not None:
152
+ return result.data
153
+ if not result.url:
154
+ return None
155
+ url = result.url
156
+
157
+ async def _call() -> bytes:
158
+ response = await self._get_client().get(url)
159
+ response.raise_for_status()
160
+ return response.content
161
+
162
+ return await with_retry(_call, attempts=self.retry_attempts)
163
+
164
+
165
+ class SoraVideoProvider(_HttpVideoProvider):
166
+ """OpenAI Sora — `POST /videos` (submit) and `GET /videos/{id}` (poll)."""
167
+
168
+ def __init__(
169
+ self,
170
+ api_key: str | None = None,
171
+ model: str = "sora-1",
172
+ base_url: str = "https://api.openai.com/v1",
173
+ **kwargs: Any,
174
+ ):
175
+ super().__init__(base_url=base_url, api_key=api_key, model=model, **kwargs)
176
+
177
+ async def generate(self, prompt: str, **params: Any) -> str:
178
+ payload: dict[str, Any] = {"model": self.model, "prompt": prompt}
179
+ for key in ("size", "duration", "quality"):
180
+ if params.get(key):
181
+ payload[key] = params[key]
182
+
183
+ async def _call() -> str:
184
+ response = await self._get_client().post(
185
+ f"{self.base_url}/videos", json=payload
186
+ )
187
+ response.raise_for_status()
188
+ return str(response.json()["id"])
189
+
190
+ return await with_retry(_call, attempts=self.retry_attempts)
191
+
192
+ async def fetch(self, task_id: str) -> VideoResult:
193
+ async def _call() -> VideoResult:
194
+ response = await self._get_client().get(f"{self.base_url}/videos/{task_id}")
195
+ response.raise_for_status()
196
+ data = response.json()
197
+ out = data.get("output") or {}
198
+ return VideoResult(
199
+ id=task_id,
200
+ status=STATUS_MAP.get(
201
+ str(data.get("status", "")).lower(), "processing"
202
+ ),
203
+ url=out.get("url"),
204
+ error=out.get("error") or data.get("error"),
205
+ extra=data,
206
+ )
207
+
208
+ return await with_retry(_call, attempts=self.retry_attempts)
209
+
210
+
211
+ class RunwayVideoProvider(_HttpVideoProvider):
212
+ """Runway ML — `POST /v1/videos` (submit) and `GET /v1/videos/{id}` (poll)."""
213
+
214
+ def __init__(
215
+ self,
216
+ api_key: str | None = None,
217
+ model: str = "gen3a_turbo",
218
+ base_url: str = "https://api.dev.runwayml.com/v1",
219
+ **kwargs: Any,
220
+ ):
221
+ super().__init__(base_url=base_url, api_key=api_key, model=model, **kwargs)
222
+
223
+ async def generate(self, prompt: str, **params: Any) -> str:
224
+ payload: dict[str, Any] = {"model": self.model, "promptText": prompt}
225
+ if params.get("image"):
226
+ payload["promptImage"] = params["image"]
227
+ if params.get("ratio"):
228
+ payload["ratio"] = params["ratio"]
229
+
230
+ async def _call() -> str:
231
+ response = await self._get_client().post(
232
+ f"{self.base_url}/v1/videos", json=payload
233
+ )
234
+ response.raise_for_status()
235
+ return str(response.json()["id"])
236
+
237
+ return await with_retry(_call, attempts=self.retry_attempts)
238
+
239
+ async def fetch(self, task_id: str) -> VideoResult:
240
+ async def _call() -> VideoResult:
241
+ response = await self._get_client().get(
242
+ f"{self.base_url}/v1/videos/{task_id}"
243
+ )
244
+ response.raise_for_status()
245
+ data = response.json()
246
+ output = data.get("output") or {}
247
+ return VideoResult(
248
+ id=task_id,
249
+ status=STATUS_MAP.get(
250
+ str(data.get("status", "")).lower(), "processing"
251
+ ),
252
+ url=output.get("url"),
253
+ error=output.get("error"),
254
+ extra=data,
255
+ )
256
+
257
+ return await with_retry(_call, attempts=self.retry_attempts)
258
+
259
+
260
+ class LumaVideoProvider(_HttpVideoProvider):
261
+ """Luma Dream Machine — `POST /v1/generations` (submit) + GET by id (poll)."""
262
+
263
+ def __init__(
264
+ self,
265
+ api_key: str | None = None,
266
+ model: str = "ray-2",
267
+ base_url: str = "https://api.lumalabs.ai/dream-machine/v1",
268
+ **kwargs: Any,
269
+ ):
270
+ super().__init__(base_url=base_url, api_key=api_key, model=model, **kwargs)
271
+
272
+ async def generate(self, prompt: str, **params: Any) -> str:
273
+ payload: dict[str, Any] = {"model": self.model, "prompt": prompt}
274
+ if params.get("image"):
275
+ payload["promptImage"] = params["image"]
276
+ if params.get("duration"):
277
+ payload["duration"] = params["duration"]
278
+
279
+ async def _call() -> str:
280
+ response = await self._get_client().post(
281
+ f"{self.base_url}/generations", json=payload
282
+ )
283
+ response.raise_for_status()
284
+ return str(response.json()["id"])
285
+
286
+ return await with_retry(_call, attempts=self.retry_attempts)
287
+
288
+ async def fetch(self, task_id: str) -> VideoResult:
289
+ async def _call() -> VideoResult:
290
+ response = await self._get_client().get(
291
+ f"{self.base_url}/generations/{task_id}"
292
+ )
293
+ response.raise_for_status()
294
+ data = response.json()
295
+ assets = data.get("assets") or {}
296
+ return VideoResult(
297
+ id=task_id,
298
+ status=STATUS_MAP.get(str(data.get("state", "")).lower(), "processing"),
299
+ url=assets.get("video"),
300
+ error=data.get("failure_reason"),
301
+ extra=data,
302
+ )
303
+
304
+ return await with_retry(_call, attempts=self.retry_attempts)
305
+
306
+
307
+ class OpenRouterVideoProvider(_HttpVideoProvider):
308
+ """OpenRouter video — Generations API (`POST /api/v1/generations` + poll).
309
+
310
+ Same key as chat/images: `OPENROUTER_API_KEY`.
311
+ """
312
+
313
+ def __init__(
314
+ self,
315
+ api_key: str | None = None,
316
+ model: str = "openai/sora-2",
317
+ base_url: str = "https://openrouter.ai/api/v1",
318
+ **kwargs: Any,
319
+ ):
320
+ super().__init__(base_url=base_url, api_key=api_key, model=model, **kwargs)
321
+
322
+ async def generate(self, prompt: str, **params: Any) -> str:
323
+ payload: dict[str, Any] = {"model": self.model, "prompt": prompt}
324
+ for key in ("negative_prompt", "height", "width", "guidance_scale"):
325
+ if params.get(key):
326
+ payload[key] = params[key]
327
+
328
+ async def _call() -> str:
329
+ response = await self._get_client().post(
330
+ f"{self.base_url}/generations", json=payload
331
+ )
332
+ response.raise_for_status()
333
+ return str(response.json()["id"])
334
+
335
+ return await with_retry(_call, attempts=self.retry_attempts)
336
+
337
+ async def fetch(self, task_id: str) -> VideoResult:
338
+ async def _call() -> VideoResult:
339
+ response = await self._get_client().get(
340
+ f"{self.base_url}/generations/{task_id}"
341
+ )
342
+ response.raise_for_status()
343
+ data = response.json()
344
+ out = data.get("out") or []
345
+ if out:
346
+ first = out[0]
347
+ url = first.get("video_url") or first.get("url")
348
+ return VideoResult(id=task_id, status="completed", url=url, extra=data)
349
+ if data.get("error"):
350
+ return VideoResult(
351
+ id=task_id, status="failed", error=str(data["error"])
352
+ )
353
+ return VideoResult(id=task_id, status="processing", extra=data)
354
+
355
+ return await with_retry(_call, attempts=self.retry_attempts)
356
+
357
+
358
+ def video_from_env(**overrides: Any) -> VideoProvider | None:
359
+ """Builds a video provider from env (VIDEO_PROVIDER=sora|runway|luma|openrouter).
360
+
361
+ Keys: VIDEO_API_KEY (or SORA_API_KEY / RUNWAY_API_KEY / LUMA_API_KEY /
362
+ OPENROUTER_API_KEY / OPENAI_API_KEY), VIDEO_BASE_URL, VIDEO_MODEL, plus the
363
+ usual VIDEO_PROXY / VIDEO_AUTH_HEADER / VIDEO_AUTH_SCHEME knobs.
364
+ """
365
+ import os
366
+
367
+ provider_name = str(
368
+ overrides.get("provider") or os.getenv("VIDEO_PROVIDER") or "sora"
369
+ ).lower()
370
+ api_key = (
371
+ overrides.get("api_key")
372
+ or os.getenv("VIDEO_API_KEY")
373
+ or os.getenv(f"{provider_name.upper()}_API_KEY")
374
+ or os.getenv("OPENAI_API_KEY")
375
+ )
376
+ if not api_key:
377
+ return None
378
+ model = overrides.get("model") or os.getenv("VIDEO_MODEL")
379
+ base_url = overrides.get("base_url") or os.getenv("VIDEO_BASE_URL")
380
+ merged = {**_network_knobs("VIDEO", overrides), **overrides}
381
+ if provider_name == "runway":
382
+ return RunwayVideoProvider(
383
+ api_key=api_key,
384
+ model=model or "gen3a_turbo",
385
+ base_url=base_url or "https://api.dev.runwayml.com/v1",
386
+ **merged,
387
+ )
388
+ if provider_name == "luma":
389
+ return LumaVideoProvider(
390
+ api_key=api_key,
391
+ model=model or "ray-2",
392
+ base_url=base_url or "https://api.lumalabs.ai/dream-machine/v1",
393
+ **merged,
394
+ )
395
+ if provider_name == "openrouter":
396
+ return OpenRouterVideoProvider(
397
+ api_key=api_key,
398
+ model=model or "openai/sora-2",
399
+ base_url=base_url or "https://openrouter.ai/api/v1",
400
+ **merged,
401
+ )
402
+ return SoraVideoProvider(
403
+ api_key=api_key,
404
+ model=model or "sora-1",
405
+ base_url=base_url or "https://api.openai.com/v1",
406
+ **merged,
407
+ )
@@ -0,0 +1,11 @@
1
+ """xAI (Grok) — chat (OpenAI-compatible)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chat import _openai_compat_llm
6
+
7
+ xai_llm = _openai_compat_llm(
8
+ env_prefix="XAI",
9
+ default_model="grok-4",
10
+ default_base_url="https://api.x.ai/v1",
11
+ )
@@ -0,0 +1,11 @@
1
+ """z.ai (Zhipu GLM) — chat (OpenAI-compatible)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chat import _openai_compat_llm
6
+
7
+ zai_llm = _openai_compat_llm(
8
+ env_prefix="ZAI",
9
+ default_model="glm-4.6",
10
+ default_base_url="https://api.z.ai/api/paas/v4",
11
+ )
reactifact/py.typed ADDED
File without changes
@@ -0,0 +1,63 @@
1
+ """Off-the-shelf building blocks for agents — reactive patterns and helpers.
2
+
3
+ These are not core primitives (Context/Artifact/Patch/Produce stay minimal, the
4
+ constitution's primitives-first rule). Instead they are ready-made patterns that
5
+ keep reappearing across agent codebases and the bundled examples:
6
+
7
+ - `find` / `find_all` — pick the typed artifact(s) out of a produce's
8
+ `inputs` without repeating `next(... isinstance ...)` — see `recipes.inputs`;
9
+ - `fan_out_sources` — query all configured sources, emit ranked, idempotent
10
+ `SourceRef`s tagged with an owner (§8, §24, §42) — see `recipes.search`;
11
+ - `materialize_doc` — lazily resolve a `SourceRef` into a document with a
12
+ provenance edge (Reference → Artifact, §6, §34) — see `recipes.resolve`;
13
+ - `StatusMachine` — a `Produce` that deterministically advances an artifact's
14
+ `status` lifecycle driven by a pure `next_status(context, key)` (§67, §69) —
15
+ see `recipes.status`;
16
+ - `WindowSummarizer` / `WindowPruner` / `llm_summarizer` — bounded
17
+ conversation memory: periodic summarization + pruning as two plain
18
+ `Produce`s, domain owns the summarizer callback and the summary artifact
19
+ shape (§27, §37) — see `recipes.memory`;
20
+ - `keyword_score` / `stem_words` — deterministic text scoring without
21
+ embedders (English and Russian) — see `recipes.text`;
22
+ - `changed_fields` / `earliest_stage` / `downstream_fields` — the
23
+ "change → rebuild" model for multi-stage flows — see `recipes.rollback`;
24
+ - `Skill` / `load_skills` / `match_skills` — keyword-triggered instruction
25
+ snippets (Claude-Skills-shaped: name/description frontmatter + body) loaded
26
+ into a prompt when their description matches the situation — see
27
+ `recipes.skills`.
28
+
29
+ Deterministic where it can be, LLM-free by design; expose the domain hook.
30
+ Extend by adding a module here (the package stays import-surface-flat).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from .inputs import find, find_all
36
+ from .memory import WindowPruner, WindowSummarizer, llm_summarizer
37
+ from .resolve import materialize_doc
38
+ from .rollback import changed_fields, downstream_fields, earliest_stage
39
+ from .search import fan_out_sources
40
+ from .skills import Skill, load_skills, match_skills
41
+ from .status import StatusMachine
42
+ from .text import EN_STOPWORDS, keyword_score, stem, stem_words
43
+
44
+ __all__ = [
45
+ "EN_STOPWORDS",
46
+ "Skill",
47
+ "StatusMachine",
48
+ "WindowPruner",
49
+ "WindowSummarizer",
50
+ "changed_fields",
51
+ "downstream_fields",
52
+ "earliest_stage",
53
+ "fan_out_sources",
54
+ "find",
55
+ "find_all",
56
+ "keyword_score",
57
+ "llm_summarizer",
58
+ "load_skills",
59
+ "match_skills",
60
+ "materialize_doc",
61
+ "stem",
62
+ "stem_words",
63
+ ]
@@ -0,0 +1,34 @@
1
+ """recipes — locating typed artifacts among a produce's `inputs`.
2
+
3
+ A produce whose agent declares more than one `Consume` type receives a flat
4
+ `list[Artifact[Any]]` merged across all of them; picking out "the one
5
+ Question" or "all the Evidence" is the same
6
+ `next((a for a in inputs if isinstance(a.data, X)), None)` boilerplate in
7
+ nearly every example. These two helpers replace it without hiding anything —
8
+ `find` is a typed `next(..., None)`, `find_all` is a typed filter.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, TypeVar
14
+
15
+ from pydantic import BaseModel
16
+
17
+ from ..artifacts import Artifact
18
+
19
+ TData = TypeVar("TData", bound=BaseModel)
20
+
21
+
22
+ def find(inputs: list[Artifact[Any]], data_type: type[TData]) -> Artifact[TData] | None:
23
+ """First input artifact whose `.data` is an instance of `data_type`, else `None`."""
24
+ return next((a for a in inputs if isinstance(a.data, data_type)), None)
25
+
26
+
27
+ def find_all(
28
+ inputs: list[Artifact[Any]], data_type: type[TData]
29
+ ) -> list[Artifact[TData]]:
30
+ """All input artifacts whose `.data` is an instance of `data_type`."""
31
+ return [a for a in inputs if isinstance(a.data, data_type)]
32
+
33
+
34
+ __all__ = ["find", "find_all"]