reactifact 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- reactifact-0.6.0.dist-info/top_level.txt +1 -0
reactifact/llm_agent.py
ADDED
|
@@ -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)
|
reactifact/operations.py
ADDED
|
@@ -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
|