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,172 @@
1
+ """LLM agent containers: thin, without logic.
2
+
3
+ - `LLMAgent` — ordinary: reactive/blocking? no — simple. Uses
4
+ `ToolUse` (blocking loop without HITL).
5
+ - `HITLLMAgent` — can ask clarifying questions to a human through
6
+ `ToolUseHITL` (reactive loop, `PendingQuestion`).
7
+
8
+ Both add boilerplate: `Consume.by_field(ToolAnswer, "agent", name)`,
9
+ `ToolUse(...)`, and the HITL variant also `Consume(Observation)`,
10
+ `Consume(PendingQuestion)` + produces Observation/PendingQuestion.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable, Sequence
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from .agents import Agent
21
+ from .artifacts import Artifact
22
+ from .consume import Consume
23
+ from .context import Context
24
+ from .events import Event
25
+ from .interrupt import PendingQuestion
26
+ from .produce import Produce
27
+ from .structured import SYSTEM_STRUCTURED, structured_llm
28
+ from .tool_use import Observation, ToolAnswer, ToolUse, ToolUseHITL
29
+ from .tools import Tool
30
+
31
+
32
+ def _require_consumes(
33
+ cls_name: str, instance_name: str, user_consumes: list[Any]
34
+ ) -> None:
35
+ if not user_consumes:
36
+ raise ValueError(
37
+ f"{cls_name} {instance_name!r} has no consumes: the tool loop needs a "
38
+ "trigger. Specify at least one Consume(<question artifact>), e.g. "
39
+ "consumes=[Consume(Question)]."
40
+ )
41
+
42
+
43
+ class StructuredGenerateAgent(Agent):
44
+ """Base agent: LLM → pydantic schema → Artifact (reads/commits as provenance).
45
+
46
+ Declare `schema`, override `build_prompt(inputs)`; optionally
47
+ `fallback(inputs)` for a deterministic fallback variant (§67).
48
+ """
49
+
50
+ schema: type[BaseModel] | None = None
51
+ system_prompt: str = SYSTEM_STRUCTURED
52
+ attempts: int = 2
53
+ temperature: float | None = None
54
+ max_tokens: int | None = None
55
+
56
+ def build_prompt(self, inputs: list[Artifact[Any]]) -> str:
57
+ raise NotImplementedError
58
+
59
+ def fallback(self, inputs: list[Artifact[Any]]) -> BaseModel | None:
60
+ return None
61
+
62
+ async def run(self, event: Event, context: Context) -> None:
63
+ inputs = self.collect_inputs(context)
64
+ if not inputs or self.schema is None:
65
+ return None
66
+ text = self.build_prompt(inputs)
67
+ result = await structured_llm(
68
+ context,
69
+ schema=self.schema,
70
+ system=self.system_prompt,
71
+ user=text,
72
+ attempts=self.attempts,
73
+ temperature=self.temperature,
74
+ max_tokens=self.max_tokens,
75
+ )
76
+ if result is None:
77
+ result = self.fallback(inputs)
78
+ if result is None:
79
+ return None
80
+ from .effects import current_effects
81
+
82
+ effects = current_effects()
83
+ if effects is None: # running outside the runtime — nothing to commit to
84
+ return None
85
+ effects.create(result)
86
+ return None
87
+
88
+
89
+ class LLMAgent(Agent):
90
+ """Ordinary LLM agent with tools: blocking loop, no HITL.
91
+
92
+ In a subclass set: `system`, `tools`, `consumes` (questions), `produces`
93
+ (handling of the final answer).
94
+ """
95
+
96
+ system: str = ""
97
+ tools: Sequence[Tool] | dict[str, Tool] = ()
98
+ max_steps: int = 8
99
+ temperature: float | None = None
100
+ max_tokens: int | None = None
101
+
102
+ def __init__(self, *, name: str | None = None, **kwargs: Any):
103
+ llm_name = name or self.name or self.__class__.__name__.lower()
104
+ user_consumes = list(self.consumes) if self.consumes else []
105
+ _require_consumes(self.__class__.__name__, llm_name, user_consumes)
106
+ self.consumes = [
107
+ *user_consumes,
108
+ Consume.by_field(ToolAnswer, "agent", llm_name),
109
+ ]
110
+ user_produces = list(self.produces) if self.produces else []
111
+ self.produces = [
112
+ ToolUse(
113
+ name=llm_name,
114
+ system=self.system,
115
+ tools=self.tools,
116
+ max_steps=self.max_steps,
117
+ temperature=self.temperature,
118
+ max_tokens=self.max_tokens,
119
+ ),
120
+ Produce(ToolAnswer),
121
+ *user_produces,
122
+ ]
123
+ super().__init__(name=name, **kwargs)
124
+
125
+
126
+ class HITLLMAgent(Agent):
127
+ """LLM agent with HITL: reactive loop that can ask a human.
128
+
129
+ Same as `LLMAgent`, plus: the LLM can return `type:"ask"` — a `PendingQuestion`
130
+ (kind="clarify") is created, the loop waits for the answer, and the answer is
131
+ returned as `Observation(source="user")` for the loop to continue.
132
+ """
133
+
134
+ system: str = ""
135
+ tools: Sequence[Tool] | dict[str, Tool] = ()
136
+ max_steps: int = 8
137
+ max_asks: int = 2
138
+ resume_announce: Callable[[str], str] | None = None
139
+ temperature: float | None = None
140
+ max_tokens: int | None = None
141
+
142
+ def __init__(self, *, name: str | None = None, **kwargs: Any):
143
+ llm_name = name or self.name or self.__class__.__name__.lower()
144
+ user_consumes = list(self.consumes) if self.consumes else []
145
+ _require_consumes(self.__class__.__name__, llm_name, user_consumes)
146
+ self.consumes = [
147
+ *user_consumes,
148
+ Consume.by_field(ToolAnswer, "agent", llm_name),
149
+ Consume.by_field(Observation, "agent", llm_name),
150
+ Consume(
151
+ PendingQuestion,
152
+ condition=lambda a: a.data.notes.get("agent") == llm_name,
153
+ ),
154
+ ]
155
+ user_produces = list(self.produces) if self.produces else []
156
+ self.produces = [
157
+ ToolUseHITL(
158
+ name=llm_name,
159
+ system=self.system,
160
+ tools=self.tools,
161
+ max_steps=self.max_steps,
162
+ max_asks=self.max_asks,
163
+ resume_announce=self.resume_announce,
164
+ temperature=self.temperature,
165
+ max_tokens=self.max_tokens,
166
+ ),
167
+ Produce(ToolAnswer),
168
+ Produce(Observation),
169
+ Produce(PendingQuestion),
170
+ *user_produces,
171
+ ]
172
+ super().__init__(name=name, **kwargs)
@@ -0,0 +1,192 @@
1
+ """reactifact.operations — the runtime's compiled operations (§12, §15, §24).
2
+
3
+ `Patch` (the container) and `Effects` (the authoring surface) both ultimately
4
+ reduce to these dataclasses. They are what the runtime applies, validates,
5
+ commits and replays — the *compiled* form of "what changed". Applications
6
+ rarely touch them directly (they write `self.effects.*` instead).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+
16
+ def _import_class(full_name: str) -> Any:
17
+ module_name, class_name = full_name.rsplit(".", 1)
18
+ module = importlib.import_module(module_name)
19
+ return getattr(module, class_name)
20
+
21
+
22
+ @dataclass
23
+ class Operation:
24
+ """Base operation in a patch."""
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ return {}
28
+
29
+
30
+ @dataclass
31
+ class Create(Operation):
32
+ data: Any # Pydantic model for the new artifact
33
+
34
+ data_type: str = ""
35
+ # Filled in by the runtime when the patch is applied: the real id of the created artifact.
36
+ # Required for replay and rebuilding state from commits.
37
+ artifact_id: str | None = None
38
+ # Stable id (§42): if an artifact with such an id already exists, create
39
+ # idempotently returns the existing one and does not create a duplicate.
40
+ # Used for re-resolving sources without multiplying evidence/links.
41
+ id: str | None = None
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.data_type and hasattr(self.data, "__class__"):
45
+ self.data_type = (
46
+ f"{self.data.__class__.__module__}.{self.data.__class__.__qualname__}"
47
+ )
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ return {
51
+ "type": "create",
52
+ "data": self.data.model_dump(mode="json"),
53
+ "data_type": self.data_type,
54
+ "artifact_id": self.artifact_id,
55
+ "id": self.id,
56
+ }
57
+
58
+
59
+ @dataclass
60
+ class Update(Operation):
61
+ artifact_id: str
62
+ new_data: Any # Pydantic model with updated data
63
+ data_type: str = ""
64
+
65
+ def __post_init__(self) -> None:
66
+ if not self.data_type and hasattr(self.new_data, "__class__"):
67
+ self.data_type = f"{self.new_data.__class__.__module__}.{self.new_data.__class__.__qualname__}"
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "type": "update",
72
+ "artifact_id": self.artifact_id,
73
+ "data": self.new_data.model_dump(mode="json"),
74
+ "data_type": self.data_type,
75
+ }
76
+
77
+
78
+ @dataclass
79
+ class Delete(Operation):
80
+ artifact_id: str
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ return {
84
+ "type": "delete",
85
+ "artifact_id": self.artifact_id,
86
+ }
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Relation:
91
+ """Edges of the artifact graph (§15): a directed link `source —relation→ target`.
92
+
93
+ Not an artifact, but a first-class dimension of the Context state: created/removed
94
+ by Link/Unlink operations, serialized into commits and snapshots.
95
+ """
96
+
97
+ source_id: str
98
+ relation: str
99
+ target_id: str
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ return {
103
+ "source_id": self.source_id,
104
+ "relation": self.relation,
105
+ "target_id": self.target_id,
106
+ }
107
+
108
+ @classmethod
109
+ def from_dict(cls, d: dict[str, Any]) -> Relation:
110
+ return cls(
111
+ source_id=d["source_id"],
112
+ relation=d["relation"],
113
+ target_id=d["target_id"],
114
+ )
115
+
116
+
117
+ @dataclass
118
+ class Link(Operation):
119
+ """Sets the link `artifact_id —relation→ target_id` (§12, §15)."""
120
+
121
+ artifact_id: str
122
+ relation: str
123
+ target_id: str
124
+
125
+ def to_dict(self) -> dict[str, Any]:
126
+ return {
127
+ "type": "link",
128
+ "artifact_id": self.artifact_id,
129
+ "relation": self.relation,
130
+ "target_id": self.target_id,
131
+ }
132
+
133
+
134
+ @dataclass
135
+ class Unlink(Operation):
136
+ """Removes links `artifact_id —relation→ *`.
137
+
138
+ `relation`/`target_id` are optional: None = any. The pattern is resolved at
139
+ apply time, so replayability is preserved.
140
+ """
141
+
142
+ artifact_id: str
143
+ relation: str | None = None
144
+ target_id: str | None = None
145
+
146
+ def to_dict(self) -> dict[str, Any]:
147
+ return {
148
+ "type": "unlink",
149
+ "artifact_id": self.artifact_id,
150
+ "relation": self.relation,
151
+ "target_id": self.target_id,
152
+ }
153
+
154
+
155
+ def operation_from_dict(d: dict[str, Any]) -> Operation:
156
+ op_type = d["type"]
157
+ if op_type == "create":
158
+ model_class = _import_class(d["data_type"])
159
+ data = model_class.model_validate(d["data"])
160
+ return Create(data=data, id=d.get("id"), artifact_id=d.get("artifact_id"))
161
+ elif op_type == "update":
162
+ model_class = _import_class(d["data_type"])
163
+ new_data = model_class.model_validate(d["data"])
164
+ return Update(artifact_id=d["artifact_id"], new_data=new_data)
165
+ elif op_type == "delete":
166
+ return Delete(artifact_id=d["artifact_id"])
167
+ elif op_type == "link":
168
+ return Link(
169
+ artifact_id=d["artifact_id"],
170
+ relation=d["relation"],
171
+ target_id=d["target_id"],
172
+ )
173
+ elif op_type == "unlink":
174
+ return Unlink(
175
+ artifact_id=d["artifact_id"],
176
+ relation=d.get("relation"),
177
+ target_id=d.get("target_id"),
178
+ )
179
+ else:
180
+ raise ValueError(f"Unknown operation type: {op_type}")
181
+
182
+
183
+ __all__ = [
184
+ "Create",
185
+ "Delete",
186
+ "Link",
187
+ "Operation",
188
+ "Relation",
189
+ "Unlink",
190
+ "Update",
191
+ "operation_from_dict",
192
+ ]
reactifact/patches.py ADDED
@@ -0,0 +1,112 @@
1
+ """reactifact.patches — the `Patch` container (§12, §24).
2
+
3
+ `Patch` is the runtime's *transport*: an ordered set of `operations` (defined in
4
+ `reactifact.operations`) that gets applied as one commit. The authoring surface of
5
+ produces is `self.effects`; a `Patch` is built by the runtime (compiling the
6
+ effects slot), by `Effects.to_patch`, and by custom `Agent.run` implementations
7
+ that assemble a change-set by hand (the escape hatch).
8
+
9
+ The Operation types and `operation_from_dict` live in `reactifact.operations` and
10
+ are re-exported here for backward compatibility.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import uuid
16
+ from typing import TYPE_CHECKING, Any, cast
17
+
18
+ from .operations import (
19
+ Create,
20
+ Delete,
21
+ Link,
22
+ Operation,
23
+ Relation,
24
+ Unlink,
25
+ Update,
26
+ operation_from_dict,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from .artifacts import Artifact
31
+
32
+
33
+ def _id_of(value: Any) -> str:
34
+ """Resolves an id from a string or an Artifact (or an effects handle)."""
35
+ if isinstance(value, str):
36
+ return value
37
+ return cast(str, value.id) # Artifact | effect Handle
38
+
39
+
40
+ def _auto_id(model_name: str) -> str:
41
+ return f"{model_name.lower()}:{uuid.uuid4().hex[:8]}"
42
+
43
+
44
+ class Patch:
45
+ """An ordered set of operations to apply to the Context (§12)."""
46
+
47
+ def __init__(self, operations: list[Operation] | None = None):
48
+ self.operations: list[Operation] = operations if operations is not None else []
49
+
50
+ def add(self, op: Operation) -> Patch:
51
+ self.operations.append(op)
52
+ return self
53
+
54
+ def create(self, data: Any, id: str | None = None) -> Patch:
55
+ return self.add(Create(data, id=id))
56
+
57
+ def update(self, artifact_id: Any, new_data: Any) -> Patch:
58
+ return self.add(Update(_id_of(artifact_id), new_data))
59
+
60
+ def update_fields(self, artifact: Artifact[Any], **fields: Any) -> Patch:
61
+ """Update artifact fields without rebuilding the model.
62
+
63
+ Sugar over `update`: it does the `model_copy(update=fields)` itself and puts
64
+ the full new model into `Update` (preserving commit replayability).
65
+ """
66
+ return self.update(artifact.id, artifact.data.model_copy(update=fields))
67
+
68
+ def delete(self, artifact_id: Any) -> Patch:
69
+ return self.add(Delete(_id_of(artifact_id)))
70
+
71
+ def link(self, artifact_id: Any, relation: str, target_id: Any) -> Patch:
72
+ return self.add(Link(_id_of(artifact_id), relation, _id_of(target_id)))
73
+
74
+ def unlink(
75
+ self,
76
+ artifact_id: Any,
77
+ relation: str | None = None,
78
+ target_id: Any | None = None,
79
+ ) -> Patch:
80
+ return self.add(
81
+ Unlink(
82
+ _id_of(artifact_id),
83
+ relation,
84
+ _id_of(target_id) if target_id is not None else None,
85
+ )
86
+ )
87
+
88
+ def merge(self, *patches: Patch | None) -> Patch:
89
+ """Adds operations from `patches` to this patch; None are skipped.
90
+
91
+ Returns `self` (chaining, like `add`/`create`/`update`/`delete`).
92
+ """
93
+ for patch in patches:
94
+ if patch is not None:
95
+ self.operations.extend(patch.operations)
96
+ return self
97
+
98
+ def is_empty(self) -> bool:
99
+ return len(self.operations) == 0
100
+
101
+
102
+ __all__ = [
103
+ "Create",
104
+ "Delete",
105
+ "Link",
106
+ "Operation",
107
+ "Patch",
108
+ "Relation",
109
+ "Unlink",
110
+ "Update",
111
+ "operation_from_dict",
112
+ ]
reactifact/produce.py ADDED
@@ -0,0 +1,226 @@
1
+ """reactifact.produce — how a produce writes an artifact (§24).
2
+
3
+ Two styles cover almost everything and are the two canonical ones — reach
4
+ for one of these first:
5
+
6
+ - **Subclass + effects** — `class X(Produce[Model]): async def produce(self,
7
+ context, inputs, event=None): self.effects.create(...); return None`. Use
8
+ this whenever the produce has its own state-free logic worth naming as a
9
+ class (the common case in every example under `examples/`).
10
+ - **`@produce(Model)` function** — `@produce(Model)\\ndef f(context, inputs,
11
+ effects): effects.create(...)`, or the plain return-style
12
+ `def f(context, inputs): return Model(...)` (also accepts `event`). One
13
+ decorator, both signatures recognized by parameter name — pick this for a
14
+ short, one-off produce with no class ceremony.
15
+
16
+ Two other things `Produce` accepts are *not* on that list on purpose:
17
+
18
+ - `Produce(Model, factory=fn)` — a bare two-argument-factory constructor
19
+ kwarg, **deprecated** (raises `DeprecationWarning`). It predates the
20
+ `@produce` decorator, only supports `(context, inputs[, event]) -> Model |
21
+ list | Patch | None`, and can't see the effects slot at all — strictly
22
+ weaker than `@produce(Model)`, which covers the same signature plus
23
+ `effects`. Kept only so old code doesn't break; port it to `@produce`.
24
+ - Overriding `Agent.run(self, event, context) -> Patch | None` directly,
25
+ bypassing `Produce` entirely (though not necessarily `effects` — the
26
+ runtime still merges whatever `current_effects()` collected during the
27
+ call, same as for a normal produce). This is a low-level, internal escape
28
+ hatch for cases a `Produce` genuinely can't express — not a third everyday
29
+ style to reach for on a first pass. `reactifact.llm_agent.StructuredGenerateAgent`
30
+ is the one built-in exception (writes via `current_effects()` directly
31
+ instead of `self.effects`, since `Agent` — unlike `Produce` — has no
32
+ `effects` property); no example under `examples/` overrides `run()`.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import inspect
39
+ import warnings
40
+ from collections.abc import Callable
41
+ from typing import Any, Generic, TypeVar, get_args, get_origin
42
+
43
+ from pydantic import BaseModel
44
+
45
+ from .artifacts import Artifact, ArtifactType
46
+ from .context import Context
47
+ from .effects import Effects
48
+ from .events import Event
49
+ from .patches import Patch
50
+
51
+ TOut = TypeVar("TOut", bound=BaseModel)
52
+
53
+
54
+ class Produce(Generic[TOut]):
55
+ """Describes the produced artifact type and how it is created.
56
+
57
+ `artifact_type` is auto-derived from the generic when a subclass is written
58
+ as `class X(Produce[Foo])` — write it explicitly only to override or when
59
+ the class has no generic (e.g. programmatic `Produce(Foo)`).
60
+ """
61
+
62
+ def __init_subclass__(cls, **kwargs: Any) -> None:
63
+ super().__init_subclass__(**kwargs)
64
+ if "artifact_type" in cls.__dict__ or cls.artifact_type is not None:
65
+ return
66
+ for base in getattr(cls, "__orig_bases__", ()):
67
+ if get_origin(base) is Produce:
68
+ args = get_args(base)
69
+ if args and isinstance(args[0], type):
70
+ cls.artifact_type = args[0]
71
+ return
72
+
73
+ artifact_type: ArtifactType | None = None
74
+ factory: Callable[..., Any] | None = None
75
+
76
+ def __init__(
77
+ self,
78
+ artifact_type: ArtifactType | None = None,
79
+ factory: Callable[..., Any] | None = None,
80
+ ):
81
+ self.artifact_type = artifact_type or self.__class__.artifact_type
82
+ if self.artifact_type is None:
83
+ raise ValueError(
84
+ "artifact_type must be provided either as class attribute or constructor argument"
85
+ )
86
+ self.factory = factory if factory is not None else self.__class__.factory
87
+ if self.factory is not None:
88
+ warnings.warn(
89
+ "Produce(..., factory=...) is deprecated: it only supports "
90
+ "(context, inputs[, event]) -> Model | list | Patch | None and "
91
+ "can't see the effects slot (no `effects` param support). Use "
92
+ "the @produce(Type) decorator instead — same return-style "
93
+ "signature, plus optional effects/event params when you need "
94
+ "them. See the reactifact.produce module docstring.",
95
+ DeprecationWarning,
96
+ stacklevel=2,
97
+ )
98
+ self._accepts_event = False
99
+ if self.factory is not None:
100
+ try:
101
+ signature = inspect.signature(self.factory)
102
+ except (TypeError, ValueError):
103
+ signature = None
104
+ if signature is not None:
105
+ positional = [
106
+ p
107
+ for p in signature.parameters.values()
108
+ if not p.kind & inspect.Parameter.VAR_POSITIONAL
109
+ ]
110
+ self._accepts_event = len(positional) >= 3
111
+
112
+ @property
113
+ def effects(self) -> Effects:
114
+ """The produce-scoped effect slot (authoring surface, §24).
115
+
116
+ Only meaningful *inside* `produce()`: the runtime pushes a fresh slot
117
+ per execution. Returns an error outside a run.
118
+ """
119
+ from .effects import current_effects
120
+
121
+ slot = current_effects()
122
+ if slot is None:
123
+ raise RuntimeError(
124
+ "Produce.effects is only available while the runtime executes "
125
+ "this produce — write effects inside produce(), not before it."
126
+ )
127
+ return slot
128
+
129
+ async def produce(
130
+ self,
131
+ context: Context,
132
+ inputs: list[Artifact[Any]],
133
+ event: Event | None = None,
134
+ ) -> None:
135
+ """Runs the factory and writes its effect into the slot (§24).
136
+
137
+ Subclass-style overrides write `self.effects.*` and return None;
138
+ a `None` return means "no work". (The progress nest the old
139
+ `Patch | None` return — the runtime compiles the slot now.)
140
+ """
141
+ if self.factory is None:
142
+ return None
143
+
144
+ if self._accepts_event:
145
+ result = self.factory(context, inputs, event)
146
+ else:
147
+ result = self.factory(context, inputs)
148
+ if asyncio.iscoroutine(result):
149
+ result = await result
150
+
151
+ self._apply_result(result)
152
+
153
+ def _apply_result(self, result: Any) -> None:
154
+ """Writes a factory result into the effect slot (§24).
155
+
156
+ `None` — nothing; a model or a list of models — creates; a Patch — the
157
+ factory-level legacy escape (its operations are appended to the effects).
158
+ """
159
+ if result is None:
160
+ return
161
+ if isinstance(result, Patch):
162
+ for op in result.operations:
163
+ self.effects.add(op)
164
+ return
165
+ if isinstance(result, list):
166
+ for item in result:
167
+ self.effects.create(item)
168
+ else:
169
+ self.effects.create(result)
170
+
171
+
172
+ def produce(
173
+ artifact_type: ArtifactType,
174
+ ) -> Callable[[Callable[..., Any]], Produce[Any]]:
175
+ """Decorator to create a Produce from a function.
176
+
177
+ The function must accept ``(context, inputs)`` plus, optionally, ``event``
178
+ and/or ``effects`` (each recognized by name):
179
+
180
+ - ``def f(context, inputs)`` — return style (no event, no slot);
181
+ - ``def f(context, inputs, event)`` — return style with the event;
182
+ - ``def f(context, inputs, effects)`` — author the produce-scoped
183
+ ``Effects`` slot directly (the same surface as ``self.effects``);
184
+ - ``def f(context, inputs, event, effects)`` — both.
185
+
186
+ Return style: return a model / a list of models / a ``Patch`` / ``None``;
187
+ the runtime writes them into the slot (reate/append) as usual. Effects
188
+ style: write ``effects.create/update/link/ask/...`` and return ``None`` —
189
+ the function behaves exactly like a class produce with ``self.effects``.
190
+ """
191
+
192
+ def decorator(func: Callable[..., Any]) -> Produce[Any]:
193
+ # Recognize which optional parameters the function declares.
194
+ params = list(inspect.signature(func).parameters.values())
195
+ names = [p.name for p in params]
196
+ if len(params) < 2:
197
+ raise TypeError(
198
+ f"Function {func.__name__} must accept at least (context, inputs)"
199
+ )
200
+ accepts_event = "event" in names
201
+ accepts_effects = "effects" in names
202
+
203
+ class _FunctionProduce(Produce[Any]):
204
+ async def produce(
205
+ self,
206
+ context: Context,
207
+ inputs: list[Artifact[Any]],
208
+ event: Event | None = None,
209
+ ) -> None:
210
+ args: list[Any] = [context, inputs]
211
+ if accepts_event:
212
+ args.append(event)
213
+ result = (
214
+ func(*args, effects=self.effects)
215
+ if accepts_effects
216
+ else func(*args)
217
+ )
218
+ if asyncio.iscoroutine(result):
219
+ result = await result
220
+ self._apply_result(result)
221
+
222
+ # Return an instance of the Produce class with the required artifact_type
223
+ instance = _FunctionProduce(artifact_type=artifact_type)
224
+ return instance
225
+
226
+ return decorator