hum-cli 0.0.1__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.
- hum/__init__.py +4 -0
- hum/adapters/__init__.py +0 -0
- hum/adapters/harbor.py +128 -0
- hum/auth.py +112 -0
- hum/cli.py +388 -0
- hum/runtime/__init__.py +10 -0
- hum/runtime/autonomy.py +71 -0
- hum/runtime/config.py +101 -0
- hum/runtime/drivers.py +79 -0
- hum/runtime/executor.py +120 -0
- hum/runtime/grader.py +41 -0
- hum/runtime/llm.py +225 -0
- hum/runtime/loop.py +247 -0
- hum/runtime/models.py +132 -0
- hum/runtime/observe.py +28 -0
- hum/runtime/prompt.py +9 -0
- hum/runtime/session.py +139 -0
- hum/runtime/store.py +26 -0
- hum/runtime/tools.py +284 -0
- hum/runtime/trajectory.py +68 -0
- hum/runtime/workspace.py +48 -0
- hum/sync.py +56 -0
- hum/ui.py +109 -0
- hum_cli-0.0.1.dist-info/METADATA +24 -0
- hum_cli-0.0.1.dist-info/RECORD +27 -0
- hum_cli-0.0.1.dist-info/WHEEL +4 -0
- hum_cli-0.0.1.dist-info/entry_points.txt +2 -0
hum/runtime/loop.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""The loop. One Runner drives one session tree. Policy and human are
|
|
2
|
+
interchangeable turn sources; the difference between them is recorded, not
|
|
3
|
+
special-cased. When a human overrides a gated proposal, the runner forks the
|
|
4
|
+
pre-intervention state and lets the policy finish in shadow, so the world can
|
|
5
|
+
grade both futures.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from .autonomy import AutonomyPolicy, Level
|
|
16
|
+
from .drivers import HumanDriver, PolicyDriver
|
|
17
|
+
from .executor import Executor
|
|
18
|
+
from .grader import Grader
|
|
19
|
+
from .models import Intervention, Node, PreferencePair, Turn, Usage, Verdict
|
|
20
|
+
from .prompt import system_prompt as default_prompt
|
|
21
|
+
from .session import Session
|
|
22
|
+
from .tools import ToolRegistry
|
|
23
|
+
from .workspace import describe_change, fingerprint
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RunConfig(BaseModel):
|
|
27
|
+
# "off": the policy acts; the human steers, edits, interrupts — all recorded.
|
|
28
|
+
# "earned": policy turns are gated by earned autonomy (backend-configured sessions).
|
|
29
|
+
gate: str = "off"
|
|
30
|
+
watch_workspace: bool = True # notice files the human changed by hand between turns
|
|
31
|
+
max_turns: int = 200
|
|
32
|
+
max_wall_s: float = 3600.0
|
|
33
|
+
parallel_tools: bool = True
|
|
34
|
+
shadow: bool = True
|
|
35
|
+
shadow_max_turns: int = 100
|
|
36
|
+
task_class: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RunResult(BaseModel):
|
|
40
|
+
leaf: str
|
|
41
|
+
verdict: Verdict | None = None
|
|
42
|
+
pairs: list[PreferencePair] = Field(default_factory=list)
|
|
43
|
+
turns: int = 0
|
|
44
|
+
usage: Usage = Field(default_factory=Usage)
|
|
45
|
+
stop_reason: str = "done"
|
|
46
|
+
level: Level | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Runner:
|
|
50
|
+
def __init__(self, session: Session, executor: Executor, tools: ToolRegistry, policy: PolicyDriver, *,
|
|
51
|
+
human: HumanDriver | None = None, grader: Grader | None = None, autonomy: AutonomyPolicy | None = None,
|
|
52
|
+
config: RunConfig | None = None, system_prompt: str | None = None,
|
|
53
|
+
shadow_policy_factory: Callable[[], PolicyDriver] | None = None):
|
|
54
|
+
self.session, self.executor, self.tools, self.policy = session, executor, tools, policy
|
|
55
|
+
self.human, self.grader, self.autonomy = human, grader, autonomy
|
|
56
|
+
self.cfg = config or RunConfig()
|
|
57
|
+
self.system_prompt = system_prompt or default_prompt()
|
|
58
|
+
self._shadow_factory = shadow_policy_factory or (lambda: PolicyDriver(self.policy.llm, author="shadow"))
|
|
59
|
+
self._shadows: list[tuple[str, asyncio.Task]] = []
|
|
60
|
+
self._tools_openai = tools.to_openai()
|
|
61
|
+
self.usage = Usage()
|
|
62
|
+
self.turns = 0
|
|
63
|
+
self._t0 = time.monotonic()
|
|
64
|
+
self.interventions = 0
|
|
65
|
+
self.task_class = self.cfg.task_class or session.task.get("task_class")
|
|
66
|
+
self._fp: str | None = None
|
|
67
|
+
|
|
68
|
+
def record_interrupt(self, note: str = "operator interrupted the model") -> Node:
|
|
69
|
+
"""The person stopped a model turn mid-flight. The lost turn is not in the
|
|
70
|
+
tree (it never executed); the stop itself is."""
|
|
71
|
+
head = self.session.head("main")
|
|
72
|
+
n = self.session.append(Node(parent=head.id if head else None, branch="main",
|
|
73
|
+
turn=Turn(author="human", content=f"[{note}]"),
|
|
74
|
+
intervention=Intervention(kind="interrupt", target_node=head.id if head else None, note=note),
|
|
75
|
+
extra={"event": "interrupt"}))
|
|
76
|
+
self.interventions += 1
|
|
77
|
+
return n
|
|
78
|
+
|
|
79
|
+
# -- public -----------------------------------------------------------------
|
|
80
|
+
async def run(self, continue_from: str | None = None, initial: Turn | None = None) -> RunResult:
|
|
81
|
+
"""``continue_from`` = head node of an ongoing session; ``initial`` = a human
|
|
82
|
+
message/action to apply first (a follow-up in a conversation)."""
|
|
83
|
+
leaf, reason = await self._drive("main", continue_from, self.executor, initial, self.policy, self.human, self.cfg.max_turns)
|
|
84
|
+
verdict = await self._grade(leaf, self.executor)
|
|
85
|
+
pairs = await self._collect_shadows(leaf, verdict)
|
|
86
|
+
level = None
|
|
87
|
+
if self.autonomy and self.task_class:
|
|
88
|
+
# Only policy-only outcomes count toward autonomy: an unsupervised main
|
|
89
|
+
# branch, or each shadow continuation.
|
|
90
|
+
if self.interventions == 0 and verdict is not None:
|
|
91
|
+
level = self.autonomy.update(self.task_class, verdict.passed)
|
|
92
|
+
for p in pairs:
|
|
93
|
+
if p.shadow_verdict is not None:
|
|
94
|
+
level = self.autonomy.update(self.task_class, p.shadow_verdict.passed)
|
|
95
|
+
level = level or self.autonomy.level(self.task_class)
|
|
96
|
+
return RunResult(leaf=leaf, verdict=verdict, pairs=pairs, turns=self.turns, usage=self.usage, stop_reason=reason, level=level)
|
|
97
|
+
|
|
98
|
+
# -- core ---------------------------------------------------------------------
|
|
99
|
+
def _level(self) -> Level:
|
|
100
|
+
return self.autonomy.level(self.task_class) if self.autonomy else ("act" if self.human is None else "propose")
|
|
101
|
+
|
|
102
|
+
def _gated(self, turn: Turn) -> bool:
|
|
103
|
+
if self.human is None or not turn.acts or self.cfg.gate != "earned":
|
|
104
|
+
return False # terminal claims are never gated: the grader decides
|
|
105
|
+
mutating = any(self.tools.is_mutating(c.name) for c in turn.tool_calls)
|
|
106
|
+
return AutonomyPolicy.gates(self._level(), mutating)
|
|
107
|
+
|
|
108
|
+
def _timed_out(self) -> bool:
|
|
109
|
+
return (time.monotonic() - self._t0) > self.cfg.max_wall_s
|
|
110
|
+
|
|
111
|
+
async def _drive(self, branch: str, parent: str | None, executor: Executor, initial: Turn | None,
|
|
112
|
+
policy: PolicyDriver, human: HumanDriver | None, max_turns: int) -> tuple[str, str]:
|
|
113
|
+
node_parent = parent
|
|
114
|
+
pending: Turn | None = initial
|
|
115
|
+
intervention: Intervention | None = None
|
|
116
|
+
takeover = False
|
|
117
|
+
turns = 0
|
|
118
|
+
last_id = parent or ""
|
|
119
|
+
while turns < max_turns and not self._timed_out():
|
|
120
|
+
turn: Turn
|
|
121
|
+
if pending is not None:
|
|
122
|
+
turn, pending = pending, None
|
|
123
|
+
elif takeover:
|
|
124
|
+
action = await human.next() # type: ignore[union-attr]
|
|
125
|
+
if action.intervention and action.intervention.kind == "release":
|
|
126
|
+
takeover = False
|
|
127
|
+
continue
|
|
128
|
+
if action.turn is None:
|
|
129
|
+
continue
|
|
130
|
+
turn, intervention = action.turn.model_copy(update={"author": "human"}), None
|
|
131
|
+
else:
|
|
132
|
+
steer = human.pending() if human else None
|
|
133
|
+
if steer is not None:
|
|
134
|
+
if steer.intervention and steer.intervention.kind == "takeover":
|
|
135
|
+
takeover = True
|
|
136
|
+
self.interventions += 1
|
|
137
|
+
if steer.turn is None:
|
|
138
|
+
continue
|
|
139
|
+
turn, intervention = steer.turn.model_copy(update={"author": "human"}), steer.intervention
|
|
140
|
+
elif steer.turn is not None:
|
|
141
|
+
turn, intervention = steer.turn.model_copy(update={"author": "human"}), None
|
|
142
|
+
else:
|
|
143
|
+
continue
|
|
144
|
+
else:
|
|
145
|
+
if self.cfg.watch_workspace and branch == "main":
|
|
146
|
+
fp = await fingerprint(executor)
|
|
147
|
+
if self._fp is not None and fp != self._fp:
|
|
148
|
+
# files changed and no tool did it: the person did. Record it as their turn.
|
|
149
|
+
desc = await describe_change(executor)
|
|
150
|
+
n = self.session.append(Node(parent=node_parent, branch=branch,
|
|
151
|
+
turn=Turn(author="human", content=f"[edited files by hand]\n{desc}".strip()),
|
|
152
|
+
intervention=Intervention(kind="edit", target_node=node_parent, note="workspace changed outside the loop"),
|
|
153
|
+
extra={"event": "human_edit"}))
|
|
154
|
+
self.interventions += 1
|
|
155
|
+
node_parent = last_id = n.id
|
|
156
|
+
self._fp = fp
|
|
157
|
+
msgs = self.session.messages(node_parent, self.system_prompt)
|
|
158
|
+
turn = await policy.next(msgs, self._tools_openai)
|
|
159
|
+
if self.cfg.watch_workspace and branch == "main" and self._fp is not None:
|
|
160
|
+
fp2 = await fingerprint(executor)
|
|
161
|
+
if fp2 != self._fp:
|
|
162
|
+
# the person edited while the model was thinking; the model's turn did not see it
|
|
163
|
+
desc = await describe_change(executor)
|
|
164
|
+
n = self.session.append(Node(parent=node_parent, branch=branch,
|
|
165
|
+
turn=Turn(author="human", content=f"[edited files by hand]\n{desc}".strip()),
|
|
166
|
+
intervention=Intervention(kind="edit", target_node=node_parent, note="workspace changed while the model was thinking"),
|
|
167
|
+
extra={"event": "human_edit"}))
|
|
168
|
+
self.interventions += 1
|
|
169
|
+
node_parent = last_id = n.id
|
|
170
|
+
self._fp = fp2
|
|
171
|
+
if turn.usage:
|
|
172
|
+
self.usage = self.usage + turn.usage
|
|
173
|
+
if self._gated(turn):
|
|
174
|
+
action = await human.review(turn) # type: ignore[union-attr]
|
|
175
|
+
kind = action.intervention.kind if action.intervention else "accept"
|
|
176
|
+
if kind != "accept":
|
|
177
|
+
self.interventions += 1
|
|
178
|
+
proposal_id = await self._spawn_shadow(node_parent, executor, turn) if self.cfg.shadow else None
|
|
179
|
+
intervention = Intervention(kind=kind, target_node=proposal_id, note=action.intervention.note if action.intervention else None)
|
|
180
|
+
if kind == "takeover":
|
|
181
|
+
takeover = True
|
|
182
|
+
if action.turn is None:
|
|
183
|
+
# rejection without a replacement: tell the policy and let it retry
|
|
184
|
+
note = Turn(author="human", content=f"[{kind}] {intervention.note or 'proposal not accepted'}")
|
|
185
|
+
n = self.session.append(Node(parent=node_parent, branch=branch, turn=note, intervention=intervention))
|
|
186
|
+
node_parent, last_id, intervention = n.id, n.id, None
|
|
187
|
+
continue
|
|
188
|
+
turn = action.turn.model_copy(update={"author": "human"})
|
|
189
|
+
# execute
|
|
190
|
+
results = await self.tools.call_many(turn.tool_calls, executor, self.cfg.parallel_tools) if turn.tool_calls else []
|
|
191
|
+
if results and self.cfg.watch_workspace and branch == "main":
|
|
192
|
+
self._fp = await fingerprint(executor) # the loop's own changes are not the human's
|
|
193
|
+
node = self.session.append(Node(parent=node_parent, branch=branch, turn=turn, results=results, intervention=intervention))
|
|
194
|
+
intervention = None
|
|
195
|
+
node_parent = last_id = node.id
|
|
196
|
+
turns += 1
|
|
197
|
+
if branch == "main":
|
|
198
|
+
self.turns = turns
|
|
199
|
+
if not turn.tool_calls and (turn.author in ("policy", "shadow") or turn.done):
|
|
200
|
+
return last_id, "done"
|
|
201
|
+
return last_id, ("timeout" if self._timed_out() else "budget")
|
|
202
|
+
|
|
203
|
+
# -- shadow -------------------------------------------------------------------
|
|
204
|
+
async def _spawn_shadow(self, parent: str | None, executor: Executor, proposal: Turn) -> str:
|
|
205
|
+
"""Fork the pre-intervention state; run the proposal and the policy's
|
|
206
|
+
continuation there. Returns the id of the proposal node on the shadow branch."""
|
|
207
|
+
snap = await executor.snapshot()
|
|
208
|
+
fex = await executor.fork(snap)
|
|
209
|
+
branch = self.session.new_branch_name("shadow")
|
|
210
|
+
shadow_policy = self._shadow_factory()
|
|
211
|
+
ready: asyncio.Future[str] = asyncio.get_event_loop().create_future()
|
|
212
|
+
|
|
213
|
+
async def go() -> tuple[str, Verdict | None]:
|
|
214
|
+
leaf, _ = await self._drive(branch, parent, fex, proposal.model_copy(update={"author": "shadow"}), shadow_policy, None, self.cfg.shadow_max_turns)
|
|
215
|
+
v = await self._grade(leaf, fex)
|
|
216
|
+
await fex.close()
|
|
217
|
+
return leaf, v
|
|
218
|
+
|
|
219
|
+
task = asyncio.create_task(go())
|
|
220
|
+
self._shadows.append((branch, task))
|
|
221
|
+
# the proposal node is appended synchronously on the first iteration of _drive; yield once so it exists
|
|
222
|
+
await asyncio.sleep(0)
|
|
223
|
+
first = self.session.branch_nodes(branch)
|
|
224
|
+
while not first:
|
|
225
|
+
await asyncio.sleep(0)
|
|
226
|
+
first = self.session.branch_nodes(branch)
|
|
227
|
+
return first[0].id
|
|
228
|
+
|
|
229
|
+
async def _collect_shadows(self, main_leaf: str, main_verdict: Verdict | None) -> list[PreferencePair]:
|
|
230
|
+
pairs: list[PreferencePair] = []
|
|
231
|
+
for branch, task in self._shadows:
|
|
232
|
+
leaf, v = await task
|
|
233
|
+
fork = self.session.branch_nodes(branch)[0].id
|
|
234
|
+
pairs.append(self.session.add_pair(PreferencePair(
|
|
235
|
+
fork_node=fork, human_leaf=main_leaf, shadow_leaf=leaf,
|
|
236
|
+
human_verdict=main_verdict, shadow_verdict=v, task_class=self.task_class)))
|
|
237
|
+
return pairs
|
|
238
|
+
|
|
239
|
+
# -- grading ------------------------------------------------------------------
|
|
240
|
+
async def _grade(self, leaf: str, executor: Executor) -> Verdict | None:
|
|
241
|
+
if self.grader is None or not leaf:
|
|
242
|
+
return None
|
|
243
|
+
v = await self.grader.grade(executor, self.session.task)
|
|
244
|
+
node = self.session.nodes[leaf]
|
|
245
|
+
node.verdict = v
|
|
246
|
+
self.session.update(node)
|
|
247
|
+
return v
|
hum/runtime/models.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Data model for one harness session.
|
|
2
|
+
|
|
3
|
+
A session is a TREE of nodes. Each node holds one TURN (who acted, what they
|
|
4
|
+
said, which tools they called), the tool RESULTS that followed, and, when a
|
|
5
|
+
human acted on a policy proposal, the INTERVENTION that relates the two. The
|
|
6
|
+
tree is the unit of data: the main branch is what actually happened; shadow
|
|
7
|
+
branches are what the policy would have done had the human not intervened.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
Author = Literal["policy", "human", "shadow", "system"]
|
|
18
|
+
InterventionKind = Literal["accept", "edit", "reject", "takeover", "release", "interrupt"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def new_id(prefix: str) -> str:
|
|
22
|
+
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolCall(BaseModel):
|
|
26
|
+
id: str
|
|
27
|
+
name: str
|
|
28
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ToolResult(BaseModel):
|
|
32
|
+
call_id: str
|
|
33
|
+
name: str
|
|
34
|
+
content: str
|
|
35
|
+
ok: bool = True
|
|
36
|
+
elapsed_s: float = 0.0
|
|
37
|
+
raw_bytes: int | None = None # size before observation shaping
|
|
38
|
+
extra: dict[str, Any] | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Usage(BaseModel):
|
|
42
|
+
input_tokens: int = 0
|
|
43
|
+
output_tokens: int = 0
|
|
44
|
+
cache_tokens: int = 0
|
|
45
|
+
cost_usd: float = 0.0
|
|
46
|
+
|
|
47
|
+
def __add__(self, o: "Usage") -> "Usage":
|
|
48
|
+
return Usage(
|
|
49
|
+
input_tokens=self.input_tokens + o.input_tokens,
|
|
50
|
+
output_tokens=self.output_tokens + o.output_tokens,
|
|
51
|
+
cache_tokens=self.cache_tokens + o.cache_tokens,
|
|
52
|
+
cost_usd=self.cost_usd + o.cost_usd,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Turn(BaseModel):
|
|
57
|
+
author: Author
|
|
58
|
+
content: str = ""
|
|
59
|
+
reasoning: str | None = None
|
|
60
|
+
tool_calls: list[ToolCall] = Field(default_factory=list)
|
|
61
|
+
model: str | None = None
|
|
62
|
+
usage: Usage | None = None
|
|
63
|
+
# Token custody when the serving side exposes it (training path).
|
|
64
|
+
prompt_token_ids: list[int] | None = None
|
|
65
|
+
completion_token_ids: list[int] | None = None
|
|
66
|
+
logprobs: list[float] | None = None
|
|
67
|
+
# A human turn with no tool calls is steering text, not a terminal turn,
|
|
68
|
+
# unless it says so explicitly.
|
|
69
|
+
done: bool = False
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def acts(self) -> bool:
|
|
73
|
+
return bool(self.tool_calls)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Intervention(BaseModel):
|
|
77
|
+
kind: InterventionKind
|
|
78
|
+
target_node: str | None = None # the policy node acted upon (None for takeover/release)
|
|
79
|
+
note: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Verdict(BaseModel):
|
|
83
|
+
passed: bool
|
|
84
|
+
score: float
|
|
85
|
+
grader: str
|
|
86
|
+
detail: dict[str, Any] = Field(default_factory=dict)
|
|
87
|
+
at: float = Field(default_factory=time.time)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Node(BaseModel):
|
|
91
|
+
id: str = Field(default_factory=lambda: new_id("n"))
|
|
92
|
+
parent: str | None = None
|
|
93
|
+
branch: str = "main"
|
|
94
|
+
turn: Turn
|
|
95
|
+
results: list[ToolResult] = Field(default_factory=list)
|
|
96
|
+
intervention: Intervention | None = None
|
|
97
|
+
# Set on the policy node that a human acted on; points at the branch that
|
|
98
|
+
# carries the counterfactual continuation.
|
|
99
|
+
shadow_branch: str | None = None
|
|
100
|
+
verdict: Verdict | None = None
|
|
101
|
+
at: float = Field(default_factory=time.time)
|
|
102
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PreferencePair(BaseModel):
|
|
106
|
+
"""Two terminal states that share a fork point, each graded by the world.
|
|
107
|
+
|
|
108
|
+
The preference is the verdict, not a rater. Pairs where both sides pass or
|
|
109
|
+
both fail are kept (they are informative about the intervention's cost),
|
|
110
|
+
with ``preferred`` = None.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
id: str = Field(default_factory=lambda: new_id("pp"))
|
|
114
|
+
fork_node: str
|
|
115
|
+
human_leaf: str
|
|
116
|
+
shadow_leaf: str
|
|
117
|
+
human_verdict: Verdict | None = None
|
|
118
|
+
shadow_verdict: Verdict | None = None
|
|
119
|
+
task_class: str | None = None
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def preferred(self) -> Literal["human", "shadow"] | None:
|
|
123
|
+
h, s = self.human_verdict, self.shadow_verdict
|
|
124
|
+
if h is None or s is None:
|
|
125
|
+
return None
|
|
126
|
+
if h.passed and not s.passed:
|
|
127
|
+
return "human"
|
|
128
|
+
if s.passed and not h.passed:
|
|
129
|
+
return "shadow"
|
|
130
|
+
if h.score != s.score:
|
|
131
|
+
return "human" if h.score > s.score else "shadow"
|
|
132
|
+
return None
|
hum/runtime/observe.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Observation shaping. The policy's context is the scarcest resource in the
|
|
2
|
+
loop; what survives from a 40k-line job log is a harness decision, and one of
|
|
3
|
+
the parameters later swept against reward. Defaults here are the v0 guess.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def shape(text: str, max_bytes: int = 16_000, head_fraction: float = 0.6) -> tuple[str, int]:
|
|
13
|
+
"""Return (shaped_text, raw_byte_count). Keeps head and tail; marks the cut."""
|
|
14
|
+
text = _ANSI.sub("", text).replace("\r\n", "\n").replace("\r", "\n")
|
|
15
|
+
raw = len(text.encode("utf-8", errors="replace"))
|
|
16
|
+
if raw <= max_bytes:
|
|
17
|
+
return text, raw
|
|
18
|
+
head_n = int(max_bytes * head_fraction)
|
|
19
|
+
tail_n = max_bytes - head_n
|
|
20
|
+
head = text[:head_n]
|
|
21
|
+
tail = text[-tail_n:] if tail_n > 0 else ""
|
|
22
|
+
# cut on line boundaries where possible
|
|
23
|
+
if "\n" in head:
|
|
24
|
+
head = head[: head.rfind("\n") + 1]
|
|
25
|
+
if "\n" in tail:
|
|
26
|
+
tail = tail[tail.find("\n") + 1 :]
|
|
27
|
+
elided = raw - len(head.encode()) - len(tail.encode())
|
|
28
|
+
return f"{head}\n... [{elided} bytes elided by hum; re-run with a narrower command to see them] ...\n{tail}", raw
|
hum/runtime/prompt.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
SYSTEM_PROMPT = """You are an engineer working inside a real system. You have native tools; call them directly and read their output before acting again. Prefer small, verifiable steps. Do not claim a result you have not observed. When the task is complete, reply with a short summary and no tool calls.
|
|
2
|
+
{tools_note}{world_note}"""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def system_prompt(tools_note: str = "", world_note: str = "") -> str:
|
|
6
|
+
return SYSTEM_PROMPT.format(
|
|
7
|
+
tools_note=(f"\n\nTools beyond the basics:\n{tools_note}" if tools_note else ""),
|
|
8
|
+
world_note=(f"\n\nAbout this system:\n{world_note}" if world_note else ""),
|
|
9
|
+
)
|
hum/runtime/session.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Session tree with append-only JSONL persistence.
|
|
2
|
+
|
|
3
|
+
Every node is written the moment it exists, so a crashed or timed-out run
|
|
4
|
+
still leaves a complete prefix on disk. ``messages()`` rebuilds the chat
|
|
5
|
+
context for any node by walking its lineage, which is how a branch (human or
|
|
6
|
+
shadow) is re-entered from any point.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .models import Node, PreferencePair, Turn, new_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Session:
|
|
18
|
+
def __init__(self, task: dict[str, Any], session_id: str | None = None, path: Path | None = None):
|
|
19
|
+
self.id = session_id or new_id("s")
|
|
20
|
+
self.task = task # {"instruction": str, "task_class": str|None, ...}
|
|
21
|
+
self.path = path
|
|
22
|
+
self.nodes: dict[str, Node] = {}
|
|
23
|
+
self.order: list[str] = []
|
|
24
|
+
self.pairs: list[PreferencePair] = []
|
|
25
|
+
self._branches: dict[str, str] = {} # branch -> head node id
|
|
26
|
+
if path is not None:
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
with path.open("a", encoding="utf-8") as f:
|
|
29
|
+
f.write(json.dumps({"kind": "session", "id": self.id, "task": task}) + "\n")
|
|
30
|
+
|
|
31
|
+
# -- tree -------------------------------------------------------------
|
|
32
|
+
def append(self, node: Node) -> Node:
|
|
33
|
+
if node.parent is not None and node.parent not in self.nodes:
|
|
34
|
+
raise KeyError(f"unknown parent {node.parent}")
|
|
35
|
+
self.nodes[node.id] = node
|
|
36
|
+
self.order.append(node.id)
|
|
37
|
+
self._branches[node.branch] = node.id
|
|
38
|
+
self._persist({"kind": "node", **node.model_dump(mode="json")})
|
|
39
|
+
return node
|
|
40
|
+
|
|
41
|
+
def update(self, node: Node) -> None:
|
|
42
|
+
"""Re-persist a node whose verdict / shadow pointer was set after the fact."""
|
|
43
|
+
self.nodes[node.id] = node
|
|
44
|
+
self._persist({"kind": "node_update", **node.model_dump(mode="json")})
|
|
45
|
+
|
|
46
|
+
def add_pair(self, pair: PreferencePair) -> PreferencePair:
|
|
47
|
+
self.pairs.append(pair)
|
|
48
|
+
self._persist({"kind": "pair", **pair.model_dump(mode="json")})
|
|
49
|
+
return pair
|
|
50
|
+
|
|
51
|
+
def head(self, branch: str = "main") -> Node | None:
|
|
52
|
+
nid = self._branches.get(branch)
|
|
53
|
+
return self.nodes[nid] if nid else None
|
|
54
|
+
|
|
55
|
+
def lineage(self, node_id: str | None) -> list[Node]:
|
|
56
|
+
out: list[Node] = []
|
|
57
|
+
while node_id is not None:
|
|
58
|
+
n = self.nodes[node_id]
|
|
59
|
+
out.append(n)
|
|
60
|
+
node_id = n.parent
|
|
61
|
+
out.reverse()
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
def branch_nodes(self, branch: str) -> list[Node]:
|
|
65
|
+
return [self.nodes[i] for i in self.order if self.nodes[i].branch == branch]
|
|
66
|
+
|
|
67
|
+
def new_branch_name(self, prefix: str = "shadow") -> str:
|
|
68
|
+
n = sum(1 for b in self._branches if b.startswith(prefix))
|
|
69
|
+
return f"{prefix}-{n + 1}"
|
|
70
|
+
|
|
71
|
+
# -- chat reconstruction ---------------------------------------------
|
|
72
|
+
def messages(self, node_id: str | None, system_prompt: str) -> list[dict[str, Any]]:
|
|
73
|
+
"""OpenAI-style messages for the lineage ending at ``node_id``.
|
|
74
|
+
|
|
75
|
+
Policy and shadow turns render as assistant turns. Human turns that act
|
|
76
|
+
(tool calls) also render as assistant turns: from the model's point of
|
|
77
|
+
view the work simply happened. Human turns that only speak render as
|
|
78
|
+
user turns (steering). Authorship is kept on the node, not in the chat.
|
|
79
|
+
"""
|
|
80
|
+
msgs: list[dict[str, Any]] = [
|
|
81
|
+
{"role": "system", "content": system_prompt},
|
|
82
|
+
{"role": "user", "content": self.task["instruction"]},
|
|
83
|
+
]
|
|
84
|
+
for n in self.lineage(node_id):
|
|
85
|
+
t = n.turn
|
|
86
|
+
if t.author == "human" and not t.acts:
|
|
87
|
+
if t.content:
|
|
88
|
+
msgs.append({"role": "user", "content": t.content})
|
|
89
|
+
continue
|
|
90
|
+
if t.author == "system":
|
|
91
|
+
msgs.append({"role": "user", "content": t.content})
|
|
92
|
+
continue
|
|
93
|
+
a: dict[str, Any] = {"role": "assistant", "content": t.content or None}
|
|
94
|
+
if t.tool_calls:
|
|
95
|
+
a["tool_calls"] = [
|
|
96
|
+
{
|
|
97
|
+
"id": c.id,
|
|
98
|
+
"type": "function",
|
|
99
|
+
"function": {"name": c.name, "arguments": json.dumps(c.arguments)},
|
|
100
|
+
}
|
|
101
|
+
for c in t.tool_calls
|
|
102
|
+
]
|
|
103
|
+
msgs.append(a)
|
|
104
|
+
for r in n.results:
|
|
105
|
+
msgs.append({"role": "tool", "tool_call_id": r.call_id, "name": r.name, "content": r.content})
|
|
106
|
+
return msgs
|
|
107
|
+
|
|
108
|
+
# -- persistence ------------------------------------------------------
|
|
109
|
+
def _persist(self, rec: dict[str, Any]) -> None:
|
|
110
|
+
if self.path is None:
|
|
111
|
+
return
|
|
112
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
113
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def load(cls, path: Path) -> "Session":
|
|
117
|
+
s: Session | None = None
|
|
118
|
+
with path.open(encoding="utf-8") as f:
|
|
119
|
+
for line in f:
|
|
120
|
+
rec = json.loads(line)
|
|
121
|
+
kind = rec.pop("kind")
|
|
122
|
+
if kind == "session":
|
|
123
|
+
s = cls(rec["task"], session_id=rec["id"], path=None)
|
|
124
|
+
s.path = None
|
|
125
|
+
elif kind in ("node", "node_update"):
|
|
126
|
+
assert s is not None
|
|
127
|
+
node = Node.model_validate(rec)
|
|
128
|
+
if kind == "node":
|
|
129
|
+
s.nodes[node.id] = node
|
|
130
|
+
s.order.append(node.id)
|
|
131
|
+
s._branches[node.branch] = node.id
|
|
132
|
+
else:
|
|
133
|
+
s.nodes[node.id] = node
|
|
134
|
+
elif kind == "pair":
|
|
135
|
+
assert s is not None
|
|
136
|
+
s.pairs.append(PreferencePair.model_validate(rec))
|
|
137
|
+
assert s is not None, "empty session file"
|
|
138
|
+
s.path = path
|
|
139
|
+
return s
|
hum/runtime/store.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Where sessions and autonomy live on a machine: ``~/.hum`` (override with
|
|
2
|
+
``HUM_HOME``). One JSONL per session, one autonomy file per home.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def home() -> Path:
|
|
12
|
+
p = Path(os.environ.get("HUM_HOME") or Path.home() / ".hum")
|
|
13
|
+
(p / "sessions").mkdir(parents=True, exist_ok=True)
|
|
14
|
+
return p
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def session_path(session_id: str) -> Path:
|
|
18
|
+
return home() / "sessions" / f"{time.strftime('%Y%m%d-%H%M%S')}-{session_id}.jsonl"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def autonomy_path() -> Path:
|
|
22
|
+
return home() / "autonomy.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_sessions() -> list[Path]:
|
|
26
|
+
return sorted((home() / "sessions").glob("*.jsonl"))
|