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,166 @@
1
+ """recipes — bounded conversation memory: periodic summarization + pruning.
2
+
3
+ Long-running chat memory is just state (§27, §37): message artifacts
4
+ accumulate, a `WindowSummarizer` condenses the recent window into a summary
5
+ artifact every N messages, and a `WindowPruner` keeps the window bounded by
6
+ deleting the oldest messages. Both are plain `Produce`s — drop them into an
7
+ `Agent.produces` list next to whatever else reacts to the message type.
8
+
9
+ Domain owns *how* to summarize (the `summarize` callback) and *what* the
10
+ summary artifact looks like (`build`); this recipe only owns the window size,
11
+ cadence, and idempotency bookkeeping — the same split as `materialize_doc`
12
+ owning provenance while the caller owns the document factory.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Awaitable, Callable
18
+ from typing import Any, Generic, TypeVar
19
+
20
+ from pydantic import BaseModel
21
+
22
+ from ..artifacts import Artifact
23
+ from ..context import Context
24
+ from ..events import Event
25
+ from ..produce import Produce
26
+ from ..structured import OnStructuredError, llm_reply
27
+
28
+ MsgT = TypeVar("MsgT", bound=BaseModel)
29
+ SummaryT = TypeVar("SummaryT", bound=BaseModel)
30
+
31
+
32
+ def _default_render(messages: list[Artifact[Any]]) -> str:
33
+ lines = []
34
+ for a in messages:
35
+ role = getattr(a.data, "role", None)
36
+ text = getattr(a.data, "text", None)
37
+ lines.append(
38
+ f"{role}: {text}" if role is not None and text is not None else str(a.data)
39
+ )
40
+ return "\n".join(lines)
41
+
42
+
43
+ def _default_fallback(history: str) -> str:
44
+ return f"(offline memory) {history[:140]}"
45
+
46
+
47
+ class WindowSummarizer(Produce[SummaryT], Generic[MsgT, SummaryT]):
48
+ """Condenses the recent window of `message_type` artifacts into a summary
49
+ artifact every `every` messages (§27).
50
+
51
+ Idempotent by construction: the summary id is derived from the message
52
+ count, so re-running the same generation never produces a duplicate.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ message_type: type[MsgT],
58
+ artifact_type: type[SummaryT],
59
+ *,
60
+ summarize: Callable[[Context, str], Awaitable[str | None]],
61
+ build: Callable[[int, str], SummaryT],
62
+ window: int = 8,
63
+ every: int = 4,
64
+ render: Callable[[list[Artifact[MsgT]]], str] = _default_render,
65
+ fallback: Callable[[str], str] = _default_fallback,
66
+ order_key: Callable[[Artifact[MsgT]], Any] = lambda a: a.created_at,
67
+ id_of: Callable[[int], str] = lambda round_no: f"summary:{round_no}",
68
+ ):
69
+ super().__init__(artifact_type=artifact_type)
70
+ self.message_type = message_type
71
+ self.summarize = summarize
72
+ self.build = build
73
+ self.window = window
74
+ self.every = every
75
+ self.render = render
76
+ self.fallback = fallback
77
+ self.order_key = order_key
78
+ self.id_of = id_of
79
+
80
+ async def produce(
81
+ self,
82
+ context: Context,
83
+ inputs: list[Artifact[Any]],
84
+ event: Event | None = None,
85
+ ) -> None:
86
+ messages = context.list_artifacts(self.message_type)
87
+ count = len(messages)
88
+ if count == 0 or count % self.every != 0:
89
+ return None
90
+ round_no = count // self.every
91
+ summary_id = self.id_of(round_no)
92
+ if context.get(summary_id) is not None:
93
+ return None # this round's summary already exists (§42)
94
+ recent = sorted(messages, key=self.order_key)[-self.window :]
95
+ history = self.render(recent)
96
+ text = await self.summarize(context, history)
97
+ if text is None:
98
+ text = self.fallback(history)
99
+ self.effects.create(self.build(round_no, text), id=summary_id)
100
+ return None
101
+
102
+
103
+ class WindowPruner(Produce[MsgT], Generic[MsgT]):
104
+ """Deletes `message_type` artifacts older than `keep` (ordered by
105
+ `order_key`, default `created_at`). Standalone-useful — pair it with
106
+ `WindowSummarizer` for full sliding-window memory, or use it alone to
107
+ just bound how many messages a context keeps.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ message_type: type[MsgT],
113
+ *,
114
+ keep: int = 8,
115
+ order_key: Callable[[Artifact[MsgT]], Any] = lambda a: a.created_at,
116
+ ):
117
+ super().__init__(artifact_type=message_type)
118
+ self.message_type = message_type
119
+ self.keep = keep
120
+ self.order_key = order_key
121
+
122
+ async def produce(
123
+ self,
124
+ context: Context,
125
+ inputs: list[Artifact[Any]],
126
+ event: Event | None = None,
127
+ ) -> None:
128
+ messages = sorted(context.list_artifacts(self.message_type), key=self.order_key)
129
+ # `max(0, ...)`: a plain negative slice bound wraps from the end in
130
+ # Python (`messages[:-1]` means "all but the last"), which would prune
131
+ # the wrong messages while the window hasn't filled up yet.
132
+ old = messages[: max(0, len(messages) - self.keep)]
133
+ if not old:
134
+ return None
135
+ for message in old:
136
+ self.effects.delete(message.id)
137
+ return None
138
+
139
+
140
+ def llm_summarizer(
141
+ system: str,
142
+ *,
143
+ attempts: int = 2,
144
+ temperature: float | None = None,
145
+ max_tokens: int | None = None,
146
+ on_error: OnStructuredError | None = None,
147
+ ) -> Callable[[Context, str], Awaitable[str | None]]:
148
+ """Builds a `WindowSummarizer(summarize=...)` callback from a system
149
+ prompt, via `llm_reply` — the common case, no custom schema class needed.
150
+ """
151
+
152
+ async def _summarize(context: Context, history: str) -> str | None:
153
+ return await llm_reply(
154
+ context,
155
+ system=system,
156
+ user=history,
157
+ attempts=attempts,
158
+ temperature=temperature,
159
+ max_tokens=max_tokens,
160
+ on_error=on_error,
161
+ )
162
+
163
+ return _summarize
164
+
165
+
166
+ __all__ = ["WindowSummarizer", "WindowPruner", "llm_summarizer"]
@@ -0,0 +1,51 @@
1
+ """recipes — lazy reference materialization (§6, §34).
2
+
3
+ Effects-based: resolves a ref into a document and writes the create + the
4
+ provenance link into the current produce's `effects` slot. Returns the produced
5
+ document (or None on a failure), so the caller can react to the outcome.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from ..artifacts import Artifact
15
+ from ..context import Context
16
+ from ..sources import SourceRef
17
+ from .search import _active_effects
18
+
19
+
20
+ async def materialize_doc(
21
+ context: Context,
22
+ ref_artifact: Artifact[SourceRef],
23
+ doc_factory: Callable[[Context, Artifact[SourceRef], str], BaseModel],
24
+ *,
25
+ relation: str = "materialized_from",
26
+ ) -> BaseModel | None:
27
+ """Resolves a ref into a document (lazily, §6) with provenance (§34).
28
+
29
+ `doc_factory(context, ref_artifact, content)` builds the domain document;
30
+ the produced document is created with a stable id and linked
31
+ `relation → SourceRef` (default `materialized_from`, pass `resolved_from`
32
+ to match the demos) in the current effect slot. Returns None on a missing
33
+ source / resolve failure — the failure is a None, not a crash.
34
+ """
35
+ effects = _active_effects(context)
36
+ ref = ref_artifact.data
37
+ source = context.resources.get_source(ref.source_id)
38
+ if source is None:
39
+ return None
40
+ try:
41
+ content = await source.resolve(ref)
42
+ except Exception:
43
+ return None
44
+ doc = doc_factory(context, ref_artifact, content)
45
+ doc_id = f"resolved:{ref_artifact.id}"
46
+ handle = effects.create(doc, id=doc_id)
47
+ handle.link(relation, ref_artifact)
48
+ return doc
49
+
50
+
51
+ __all__ = ["materialize_doc"]
@@ -0,0 +1,87 @@
1
+ """recipes.rollback — the "change → rebuild" model (§22, §24).
2
+
3
+ Long multi-stage flows sometimes have to *go back*: the user edits a fact, the
4
+ pipeline must rebuild from the earliest affected stage and reset every
5
+ downstream artifact. The three helpers here are the deterministic mechanism;
6
+ the workflow (stages, field→stage mapping) is the domain's own, passed in:
7
+
8
+ field_stages = {"room": "collect", "area": "plan", "budget": "estimate"}
9
+ order = ("collect", "design_choice", "plan", "estimate")
10
+
11
+ changed = changed_fields(old_info, new_info) # which fields moved
12
+ target = earliest_stage(changed, field_stages=..., order=order)
13
+ reset = downstream_fields(target, field_stages=..., order=order) # what to clear
14
+
15
+ Nothing is guessed: the rebuild target is a pure function of what changed (§24).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Collection, Mapping, Sequence
21
+
22
+ from pydantic import BaseModel
23
+
24
+
25
+ def changed_fields(
26
+ old: BaseModel,
27
+ new: BaseModel,
28
+ *,
29
+ ignore: Collection[str] = (),
30
+ ) -> set[str]:
31
+ """Fields whose value differs in `new` (unknown/`None` = not changed).
32
+
33
+ A field only counts when it was actually *set* in the new state — a `None`
34
+ (the model did not know) is not a change.
35
+ """
36
+ old_dump = old.model_dump()
37
+ new_dump = new.model_dump()
38
+ ignored = set(ignore)
39
+ return {
40
+ key
41
+ for key in old_dump
42
+ if key not in ignored
43
+ and new_dump.get(key) is not None
44
+ and new_dump.get(key) != old_dump.get(key)
45
+ }
46
+
47
+
48
+ def earliest_stage(
49
+ changed: Collection[str],
50
+ *,
51
+ field_stages: Mapping[str, str],
52
+ order: Sequence[str],
53
+ ) -> str | None:
54
+ """The earliest stage (by `order`) that any changed field belongs to.
55
+
56
+ Returns None when nothing changed (the caller decides the no-op behavior).
57
+ """
58
+ for stage in order:
59
+ affected = {
60
+ field for field, maps_to in field_stages.items() if maps_to == stage
61
+ }
62
+ if affected & set(changed):
63
+ return stage
64
+ return None
65
+
66
+
67
+ def downstream_fields(
68
+ target: str,
69
+ *,
70
+ field_stages: Mapping[str, str],
71
+ order: Sequence[str],
72
+ ) -> frozenset[str]:
73
+ """All fields to reset when rebuilding from `target` (target stage inclusive).
74
+
75
+ Everything produced at `target` or later belongs to the rebuild and must be
76
+ cleared; upstream artifacts stay untouched.
77
+ """
78
+ if target not in order:
79
+ return frozenset()
80
+ index = order.index(target)
81
+ rebuild_stages = set(order[index:])
82
+ return frozenset(
83
+ field for field, maps_to in field_stages.items() if maps_to in rebuild_stages
84
+ )
85
+
86
+
87
+ __all__ = ["changed_fields", "downstream_fields", "earliest_stage"]
@@ -0,0 +1,81 @@
1
+ """recipes — ready-made search fan-out (§8, §24, §42).
2
+
3
+ Effects-based: fans out over every configured source and *writes* the ranked,
4
+ idempotent `SourceRef`s into the current produce's `effects` slot instead of
5
+ returning a patch to merge by hand. Returns the scoped refs so the caller can
6
+ inspect/rank them and add its own marker effect (e.g. `SearchDone`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from ..context import Context
15
+ from ..sources import SourceRef
16
+
17
+ if TYPE_CHECKING:
18
+ from ..effects import Effects
19
+
20
+
21
+ def _active_effects(context: Context) -> Effects:
22
+ from ..effects import current_effects
23
+
24
+ effects = current_effects()
25
+ if effects is None:
26
+ raise RuntimeError(
27
+ "fan_out_sources/materialize_doc must run inside a produce "
28
+ "(the runtime provides `self.effects` for the turn)"
29
+ )
30
+ return effects
31
+
32
+
33
+ async def fan_out_sources(
34
+ context: Context,
35
+ query: str,
36
+ owner_id: str,
37
+ *,
38
+ limit: int = 5,
39
+ query_id: str | None = None,
40
+ extra_metadata: dict[str, Any] | None = None,
41
+ on_start: Callable[[str], None] | None = None,
42
+ on_count: Callable[[str, int], None] | None = None,
43
+ ) -> list[SourceRef]:
44
+ """Fans out over every configured source and builds idempotent refs in `effects`.
45
+
46
+ Each `SourceRef` becomes a `create(ref, id=f"ref:{ref.stable_id()}:{owner_id}")`
47
+ in the current produce's effect slot (§42), so a repeated fan-out for the same
48
+ owner re-creates the same ids (create-or-refresh). `on_start` / `on_count`
49
+ receive each source's id and hit count for progress announces. The returned
50
+ list is the scoped, ranked refs — the caller may inspect them and add its
51
+ own "search done" marker effect.
52
+ """
53
+ effects = _active_effects(context)
54
+ refs: list[SourceRef] = []
55
+ for source in context.resources.sources.values():
56
+ if on_start is not None:
57
+ on_start(source.source_id)
58
+ found = await source.asearch(query, limit=limit)
59
+ if on_count is not None:
60
+ on_count(source.source_id, len(found))
61
+ refs.extend(found)
62
+ refs.sort(key=lambda r: r.score or 0.0, reverse=True)
63
+
64
+ scoped_refs: list[SourceRef] = []
65
+ for ref in refs[:limit]:
66
+ scoped = ref.model_copy(
67
+ update={
68
+ "metadata": {
69
+ **ref.metadata,
70
+ "owner_id": owner_id,
71
+ **(extra_metadata or {}),
72
+ },
73
+ "query_id": query_id if query_id is not None else owner_id,
74
+ }
75
+ )
76
+ scoped_refs.append(scoped)
77
+ effects.create(scoped, id=f"ref:{ref.stable_id()}:{owner_id}")
78
+ return scoped_refs
79
+
80
+
81
+ __all__ = ["fan_out_sources"]
@@ -0,0 +1,108 @@
1
+ """recipes.skills — deterministic, keyword-triggered instruction snippets (§67).
2
+
3
+ A "skill" here is the same shape popularized by Claude's Skills: a short
4
+ markdown file with a `name`/`description` frontmatter and a body of
5
+ procedural instructions. It differs from a `Source` in what it is *for* — a
6
+ `Source` is retrieved to answer a question with facts; a skill is loaded to
7
+ change *how* an LLM call for this turn is made (a rule to follow, a format to
8
+ use) once its description matches the situation at hand.
9
+
10
+ Matching is deterministic keyword overlap (`keyword_score`, §67) over
11
+ name+description — no embeddings, no new core primitive (§61): a matched
12
+ skill's `body` is just a string you prepend to a `structured_llm`/`llm_reply`
13
+ prompt. Composition over abstraction.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from .text import keyword_score
23
+
24
+ _FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", re.DOTALL)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Skill:
29
+ """A named, described instruction snippet, loaded on demand (§67)."""
30
+
31
+ name: str
32
+ description: str
33
+ body: str
34
+
35
+
36
+ def parse_skill(content: str, *, default_name: str = "") -> Skill:
37
+ """Parses `---\\nname: ...\\ndescription: ...\\n---\\n<body>` into a `Skill`.
38
+
39
+ Missing or malformed frontmatter is not an error: the whole content
40
+ becomes the body, `name` falls back to `default_name` (typically the file
41
+ stem) and `description` to `""` — a skill with no description simply
42
+ never matches (`match_skills` needs it to score above `threshold`).
43
+ """
44
+ match = _FRONTMATTER_RE.match(content)
45
+ if match is None:
46
+ return Skill(name=default_name, description="", body=content.strip())
47
+ header, body = match.groups()
48
+ fields: dict[str, str] = {}
49
+ for line in header.splitlines():
50
+ key, sep, value = line.partition(":")
51
+ if sep:
52
+ fields[key.strip().lower()] = value.strip()
53
+ return Skill(
54
+ name=fields.get("name", default_name),
55
+ description=fields.get("description", ""),
56
+ body=body.strip(),
57
+ )
58
+
59
+
60
+ def load_skills(
61
+ directory: str | Path, *, extensions: tuple[str, ...] = (".md",)
62
+ ) -> list[Skill]:
63
+ """Parses every skill file under `directory` (honest empty list if absent)."""
64
+ root = Path(directory)
65
+ if not root.exists():
66
+ return []
67
+ skills: list[Skill] = []
68
+ for path in sorted(root.rglob("*")):
69
+ if not path.is_file() or path.suffix not in extensions:
70
+ continue
71
+ try:
72
+ content = path.read_text(encoding="utf-8")
73
+ except OSError:
74
+ continue
75
+ skills.append(parse_skill(content, default_name=path.stem))
76
+ return skills
77
+
78
+
79
+ def match_skills(
80
+ skills: list[Skill],
81
+ situation: str,
82
+ *,
83
+ threshold: float = 0.34,
84
+ limit: int = 1,
85
+ ) -> list[Skill]:
86
+ """The subset of `skills` whose name+description covers `situation` (§67).
87
+
88
+ `situation` is a short, code-written description of what is currently
89
+ being done ("assembling an answer backed by a computed total"), not
90
+ necessarily the user's raw question — the caller characterizes the
91
+ moment, the same way a skill's own description characterizes when to use
92
+ it. Ranked by `keyword_score`, highest first; only matches scoring at or
93
+ above `threshold` are returned, capped at `limit` — a skill should be a
94
+ precise trigger, not a grab-bag fallback that fires on every turn.
95
+ """
96
+ scored = [
97
+ (keyword_score(f"{skill.name} {skill.description}", situation), skill)
98
+ for skill in skills
99
+ ]
100
+ matched = sorted(
101
+ (item for item in scored if item[0] >= threshold),
102
+ key=lambda item: item[0],
103
+ reverse=True,
104
+ )
105
+ return [skill for _, skill in matched[:limit]]
106
+
107
+
108
+ __all__ = ["Skill", "load_skills", "match_skills", "parse_skill"]
@@ -0,0 +1,79 @@
1
+ """recipes — deterministic artifact status lifecycles (§67, §69)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import abstractmethod
6
+ from typing import Any, Generic, TypeVar
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from ..artifacts import Artifact
11
+ from ..context import Context
12
+ from ..events import Event
13
+ from ..produce import Produce
14
+
15
+ StatusT = TypeVar("StatusT", bound=BaseModel)
16
+
17
+
18
+ class StatusMachine(Produce[StatusT], Generic[StatusT]):
19
+ """Deterministic `status` lifecycle for an artifact type (§67).
20
+
21
+ Subclass it, set `artifact_type`/`terminal`, implement `next_status` (and
22
+ override `owner_key` or tweak `query_id_field`/`status_field` when the
23
+ artifact uses a different key/status column). The runtime advances the
24
+ lifecycle in reaction to events — no manual transition graph (§21, §24).
25
+ """
26
+
27
+ artifact_type: type[StatusT] | None = None
28
+ terminal: frozenset[str] = frozenset()
29
+ #: Which field of the artifact data carries the lifecycle key / status.
30
+ query_id_field: str = "query_id"
31
+ status_field: str = "status"
32
+
33
+ def owner_key(self, artifact: Artifact[Any]) -> str | None:
34
+ """Which lifecycle the artifact belongs to (default: its `query_id_field`)."""
35
+ data = artifact.data
36
+ if isinstance(data, BaseModel):
37
+ key = getattr(data, self.query_id_field, None)
38
+ if isinstance(key, str):
39
+ return key
40
+ return artifact.id
41
+
42
+ @abstractmethod
43
+ def next_status(self, context: Context, key: str) -> str | None:
44
+ """The status the lifecycle should move to, or None (no change)."""
45
+
46
+ def on_transition(
47
+ self, context: Context, key: str, old_status: str, new_status: str
48
+ ) -> None:
49
+ """Hook called right before a transition (progress announces, §53)."""
50
+
51
+ async def produce(
52
+ self,
53
+ context: Context,
54
+ inputs: list[Artifact[Any]],
55
+ event: Event | None = None,
56
+ ) -> None:
57
+ artifact = context.get(event.artifact_id) if event is not None else None
58
+ if artifact is None:
59
+ return None
60
+ key = self.owner_key(artifact)
61
+ if key is None:
62
+ return None
63
+ targets = [
64
+ t
65
+ for t in context.list_artifacts(self.artifact_type)
66
+ if self.owner_key(t) == key
67
+ ]
68
+ if not targets:
69
+ return None
70
+ target = targets[0]
71
+ current = getattr(target.data, self.status_field, None)
72
+ if current in self.terminal:
73
+ return None
74
+ expected = self.next_status(context, key)
75
+ if expected is None or expected == current:
76
+ return None
77
+ self.on_transition(context, key, str(current), expected)
78
+ self.effects.update(target, **{self.status_field: expected})
79
+ return None