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
|
@@ -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
|