semora-fork 0.1.0__tar.gz
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.
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
.pytest_cache/
|
|
3
|
+
.mypy_cache/
|
|
4
|
+
.ruff_cache/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*.egg-info/
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Local tool/editor state — machine-specific, never pushed.
|
|
18
|
+
.claude/
|
|
19
|
+
.codecanvas/
|
|
20
|
+
.vscode/
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Superpowers design/spec scratch — working notes, not project documentation.
|
|
24
|
+
docs/superpowers/
|
|
25
|
+
|
|
26
|
+
# 로컬 자격증명 — 절대 커밋 금지.
|
|
27
|
+
a.txt
|
|
28
|
+
*.token
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: semora-fork
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fork a conversation from before one injected input, under different controls.
|
|
5
|
+
Project-URL: Homepage, https://github.com/donggyun112/semora
|
|
6
|
+
Project-URL: Source, https://github.com/donggyun112/semora
|
|
7
|
+
Project-URL: Changelog, https://github.com/donggyun112/semora/blob/main/CHANGELOG.md
|
|
8
|
+
Author: donggyun112
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: langchain-core<2,>=1
|
|
17
|
+
Requires-Dist: semora-store==0.1.0
|
|
18
|
+
Requires-Dist: semora==0.1.0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "semora-fork"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Fork a conversation from before one injected input, under different controls."
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "donggyun112" }]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
urls = { Homepage = "https://github.com/donggyun112/semora", Source = "https://github.com/donggyun112/semora", Changelog = "https://github.com/donggyun112/semora/blob/main/CHANGELOG.md" }
|
|
20
|
+
dependencies = [
|
|
21
|
+
"langchain-core>=1,<2",
|
|
22
|
+
"semora==0.1.0",
|
|
23
|
+
"semora-store==0.1.0",
|
|
24
|
+
]
|
|
25
|
+
# Assembly over the core's public primitives: the ledger keeps the pre-screen original, the
|
|
26
|
+
# transcript rewinds by branch, controls are per-call arguments. This package only walks those
|
|
27
|
+
# seams; putting the walk inside the core would freeze one composition into the contract.
|
|
28
|
+
|
|
29
|
+
[tool.uv.sources]
|
|
30
|
+
semora = { workspace = true }
|
|
31
|
+
semora-store = { workspace = true }
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/semora_fork"]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Fork a conversation from just before one injected input.
|
|
2
|
+
|
|
3
|
+
The core keeps the pieces apart on purpose: the ledger holds each input's pre-screen
|
|
4
|
+
original, the transcript rewinds by preserved branch, and controls arrive as call
|
|
5
|
+
arguments. Forking is therefore composition, not a runtime feature — this package walks
|
|
6
|
+
those seams and adds no authority of its own. The source run's ledger is never touched:
|
|
7
|
+
what actually went out stays the record.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Any, NamedTuple
|
|
12
|
+
|
|
13
|
+
from langchain_core.language_models import BaseChatModel
|
|
14
|
+
from semora import AgentRuntime
|
|
15
|
+
from semora.contracts import Agent, Tools
|
|
16
|
+
from semora.controls import Controls
|
|
17
|
+
from semora.history import decode_pending_input
|
|
18
|
+
from semora.transcript import SCHEMA_VERSION, entry_id, messages_at
|
|
19
|
+
from semora_store import ExecutionStore, Transcript
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"EventCheckpoint",
|
|
23
|
+
"ForkCoordinate",
|
|
24
|
+
"fork_event",
|
|
25
|
+
"fork_run",
|
|
26
|
+
"read_event_checkpoint",
|
|
27
|
+
"record_event_checkpoint",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ForkCoordinate(NamedTuple):
|
|
32
|
+
"""One replayable position attached to an observation edge."""
|
|
33
|
+
|
|
34
|
+
from_run_id: str
|
|
35
|
+
origin_id: str | None
|
|
36
|
+
leaf_uuid: str | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EventCheckpoint(NamedTuple):
|
|
40
|
+
"""Durable before/after transcript coordinates for one observation."""
|
|
41
|
+
|
|
42
|
+
event_id: str
|
|
43
|
+
conversation_id: str
|
|
44
|
+
before: ForkCoordinate
|
|
45
|
+
after: ForkCoordinate
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _coordinate_payload(coordinate: ForkCoordinate) -> dict[str, str | None]:
|
|
49
|
+
return {
|
|
50
|
+
"from_run_id": coordinate.from_run_id,
|
|
51
|
+
"origin_id": coordinate.origin_id,
|
|
52
|
+
"leaf_uuid": coordinate.leaf_uuid,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _decode_coordinate(payload: object) -> ForkCoordinate:
|
|
57
|
+
if not isinstance(payload, dict):
|
|
58
|
+
raise TypeError("fork checkpoint coordinate must be a mapping")
|
|
59
|
+
from_run_id = payload.get("from_run_id")
|
|
60
|
+
origin_id = payload.get("origin_id")
|
|
61
|
+
leaf_uuid = payload.get("leaf_uuid")
|
|
62
|
+
if not isinstance(from_run_id, str) or not from_run_id:
|
|
63
|
+
raise TypeError("fork checkpoint coordinate has no source run")
|
|
64
|
+
if origin_id is not None and not isinstance(origin_id, str):
|
|
65
|
+
raise TypeError("fork checkpoint origin must be a string or None")
|
|
66
|
+
if leaf_uuid is not None and not isinstance(leaf_uuid, str):
|
|
67
|
+
raise TypeError("fork checkpoint leaf must be a string or None")
|
|
68
|
+
return ForkCoordinate(from_run_id, origin_id, leaf_uuid)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def record_event_checkpoint(
|
|
72
|
+
transcript: Transcript,
|
|
73
|
+
checkpoint: EventCheckpoint,
|
|
74
|
+
) -> bool:
|
|
75
|
+
"""Append one immutable event-to-state mapping to the conversation transcript."""
|
|
76
|
+
body = {
|
|
77
|
+
"type": "fork_checkpoint",
|
|
78
|
+
"event_id": checkpoint.event_id,
|
|
79
|
+
"before": _coordinate_payload(checkpoint.before),
|
|
80
|
+
"after": _coordinate_payload(checkpoint.after),
|
|
81
|
+
}
|
|
82
|
+
return await transcript.append(
|
|
83
|
+
{
|
|
84
|
+
"uuid": entry_id(None, body),
|
|
85
|
+
"conversation_id": checkpoint.conversation_id,
|
|
86
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
87
|
+
"schema_version": SCHEMA_VERSION,
|
|
88
|
+
**body,
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def read_event_checkpoint(
|
|
94
|
+
transcript: Transcript,
|
|
95
|
+
conversation_id: str,
|
|
96
|
+
event_id: str,
|
|
97
|
+
) -> EventCheckpoint:
|
|
98
|
+
"""Read the newest durable mapping for one event identity."""
|
|
99
|
+
entries = await transcript.read(conversation_id)
|
|
100
|
+
entry = next(
|
|
101
|
+
(
|
|
102
|
+
item
|
|
103
|
+
for item in reversed(entries)
|
|
104
|
+
if item.get("type") == "fork_checkpoint" and item.get("event_id") == event_id
|
|
105
|
+
),
|
|
106
|
+
None,
|
|
107
|
+
)
|
|
108
|
+
if entry is None:
|
|
109
|
+
raise ValueError(f"conversation {conversation_id!r} has no fork checkpoint {event_id!r}")
|
|
110
|
+
return EventCheckpoint(
|
|
111
|
+
event_id,
|
|
112
|
+
conversation_id,
|
|
113
|
+
_decode_coordinate(entry.get("before")),
|
|
114
|
+
_decode_coordinate(entry.get("after")),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def fork_run(
|
|
119
|
+
runtime: AgentRuntime,
|
|
120
|
+
store: ExecutionStore,
|
|
121
|
+
*,
|
|
122
|
+
from_run_id: str,
|
|
123
|
+
origin_id: str,
|
|
124
|
+
run_id: str,
|
|
125
|
+
model: BaseChatModel | Agent,
|
|
126
|
+
tools: Tools | str | None = None,
|
|
127
|
+
controls: Controls | None = None,
|
|
128
|
+
conversation_id: str | None = None,
|
|
129
|
+
**options: Any,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Re-run the conversation from just before ``origin_id`` entered model context.
|
|
132
|
+
|
|
133
|
+
The pre-screen original is read from ``from_run_id``'s ledger and enqueued for the new
|
|
134
|
+
``run_id``, so it passes through whatever ``controls`` this call supplies — the fork
|
|
135
|
+
screens, records, and announces it like any live input. The rewind is
|
|
136
|
+
``run(history=...)``'s own branch-preserving replace: the source branch stays
|
|
137
|
+
observable, the conversation head moves to the fork.
|
|
138
|
+
|
|
139
|
+
Raises ``ValueError`` when the ledger has no such input or it never reached model
|
|
140
|
+
context — either way there is no injection point to fork from.
|
|
141
|
+
"""
|
|
142
|
+
records = await store.list_inputs(from_run_id)
|
|
143
|
+
record = next((r for r in records if r.input_id == origin_id), None)
|
|
144
|
+
if record is None:
|
|
145
|
+
raise ValueError(f"run {from_run_id!r} has no ledger record for input {origin_id!r}")
|
|
146
|
+
original = decode_pending_input(record.value)
|
|
147
|
+
|
|
148
|
+
conversation = conversation_id or from_run_id
|
|
149
|
+
history = await runtime.committed_history(from_run_id, conversation)
|
|
150
|
+
cut = next((i for i, message in enumerate(history) if message.id == origin_id), None)
|
|
151
|
+
if cut is None:
|
|
152
|
+
raise ValueError(f"input {origin_id!r} never entered the model context of {conversation!r}")
|
|
153
|
+
|
|
154
|
+
await runtime.submit(run_id, original, conversation_id=conversation)
|
|
155
|
+
return await runtime.run(
|
|
156
|
+
run_id,
|
|
157
|
+
model,
|
|
158
|
+
tools,
|
|
159
|
+
controls=controls,
|
|
160
|
+
conversation_id=conversation,
|
|
161
|
+
history=list(history[:cut]),
|
|
162
|
+
**options,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def fork_event(
|
|
167
|
+
runtime: AgentRuntime,
|
|
168
|
+
store: ExecutionStore,
|
|
169
|
+
transcript: Transcript,
|
|
170
|
+
*,
|
|
171
|
+
event_id: str,
|
|
172
|
+
edge: str,
|
|
173
|
+
run_id: str,
|
|
174
|
+
model: BaseChatModel | Agent,
|
|
175
|
+
tools: Tools | str | None = None,
|
|
176
|
+
controls: Controls | None = None,
|
|
177
|
+
conversation_id: str,
|
|
178
|
+
**options: Any,
|
|
179
|
+
) -> dict[str, Any]:
|
|
180
|
+
"""Fork from the durable coordinate attached to one observation edge.
|
|
181
|
+
|
|
182
|
+
A before edge with an input origin reuses :func:`fork_run`, so the source ledger's
|
|
183
|
+
pre-screen original crosses the new controls. Other coordinates continue from their
|
|
184
|
+
explicit transcript leaf without copying source-run effect records.
|
|
185
|
+
"""
|
|
186
|
+
if edge not in {"before", "after"}:
|
|
187
|
+
raise ValueError("edge must be 'before' or 'after'")
|
|
188
|
+
checkpoint = await read_event_checkpoint(transcript, conversation_id, event_id)
|
|
189
|
+
coordinate = checkpoint.before if edge == "before" else checkpoint.after
|
|
190
|
+
if edge == "before" and coordinate.origin_id is not None:
|
|
191
|
+
return await fork_run(
|
|
192
|
+
runtime,
|
|
193
|
+
store,
|
|
194
|
+
from_run_id=coordinate.from_run_id,
|
|
195
|
+
origin_id=coordinate.origin_id,
|
|
196
|
+
run_id=run_id,
|
|
197
|
+
model=model,
|
|
198
|
+
tools=tools,
|
|
199
|
+
controls=controls,
|
|
200
|
+
conversation_id=checkpoint.conversation_id,
|
|
201
|
+
**options,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
entries = await transcript.read(checkpoint.conversation_id)
|
|
205
|
+
history = messages_at(entries, coordinate.leaf_uuid)
|
|
206
|
+
return await runtime.run(
|
|
207
|
+
run_id,
|
|
208
|
+
model,
|
|
209
|
+
tools,
|
|
210
|
+
controls=controls,
|
|
211
|
+
conversation_id=checkpoint.conversation_id,
|
|
212
|
+
history=history,
|
|
213
|
+
**options,
|
|
214
|
+
)
|
|
File without changes
|