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/autonomy.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Earned autonomy. The level a policy runs at on a class of tasks is a number
|
|
2
|
+
the world assigns from graded outcomes, not a mode a user picks.
|
|
3
|
+
|
|
4
|
+
Per task class we keep a Beta posterior over "unsupervised policy run passes".
|
|
5
|
+
The level is chosen from a lower confidence bound on that posterior, so a
|
|
6
|
+
class earns ``act`` only with enough evidence, and loses it when outcomes
|
|
7
|
+
turn. Updated online as verdicts arrive; persisted as JSON so it survives
|
|
8
|
+
the session and follows the weights.
|
|
9
|
+
|
|
10
|
+
Levels
|
|
11
|
+
observe policy drafts; every tool call needs a human decision; mutating
|
|
12
|
+
calls are executed only as the human's own turn
|
|
13
|
+
propose every policy turn is gated (accept / edit / reject)
|
|
14
|
+
review read-only turns run; mutating turns are gated
|
|
15
|
+
act ungated; the human may still steer or take over
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
Level = Literal["observe", "propose", "review", "act"]
|
|
25
|
+
_ORDER: list[Level] = ["observe", "propose", "review", "act"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AutonomyPolicy:
|
|
29
|
+
def __init__(self, path: Path | None = None, *, prior: tuple[float, float] = (1.0, 1.0), z: float = 1.0,
|
|
30
|
+
thresholds: dict[Level, float] | None = None):
|
|
31
|
+
self.path = path
|
|
32
|
+
self.prior = prior
|
|
33
|
+
self.z = z
|
|
34
|
+
self.thresholds: dict[Level, float] = thresholds or {"propose": 0.5, "review": 0.7, "act": 0.9}
|
|
35
|
+
self.counts: dict[str, tuple[int, int]] = {} # task_class -> (passes, fails)
|
|
36
|
+
if path and path.exists():
|
|
37
|
+
self.counts = {k: tuple(v) for k, v in json.loads(path.read_text()).items()} # type: ignore[misc]
|
|
38
|
+
|
|
39
|
+
def update(self, task_class: str, passed: bool) -> Level:
|
|
40
|
+
p, f = self.counts.get(task_class, (0, 0))
|
|
41
|
+
self.counts[task_class] = (p + int(passed), f + int(not passed))
|
|
42
|
+
if self.path:
|
|
43
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
self.path.write_text(json.dumps(self.counts))
|
|
45
|
+
return self.level(task_class)
|
|
46
|
+
|
|
47
|
+
def lower_bound(self, task_class: str) -> float:
|
|
48
|
+
p, f = self.counts.get(task_class, (0, 0))
|
|
49
|
+
a, b = self.prior[0] + p, self.prior[1] + f
|
|
50
|
+
mean = a / (a + b)
|
|
51
|
+
var = (a * b) / ((a + b) ** 2 * (a + b + 1))
|
|
52
|
+
return max(0.0, mean - self.z * math.sqrt(var))
|
|
53
|
+
|
|
54
|
+
def level(self, task_class: str | None) -> Level:
|
|
55
|
+
if task_class is None:
|
|
56
|
+
return "propose"
|
|
57
|
+
lb = self.lower_bound(task_class)
|
|
58
|
+
lvl: Level = "observe"
|
|
59
|
+
for cand in ("propose", "review", "act"):
|
|
60
|
+
if lb >= self.thresholds[cand]:
|
|
61
|
+
lvl = cand
|
|
62
|
+
return lvl
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def gates(level: Level, mutating: bool) -> bool:
|
|
66
|
+
"""Does a policy turn need a human decision before it executes?"""
|
|
67
|
+
if level == "act":
|
|
68
|
+
return False
|
|
69
|
+
if level == "review":
|
|
70
|
+
return mutating
|
|
71
|
+
return True
|
hum/runtime/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Deployment configuration. Metaphi sets this; the SME never sees it.
|
|
2
|
+
|
|
3
|
+
~/.hum/config.toml (or HUM_CONFIG):
|
|
4
|
+
|
|
5
|
+
[model]
|
|
6
|
+
name = "openrouter/z-ai/glm-5.3" # or "policy" behind the proxy
|
|
7
|
+
api_base = "https://proxy.metaphi.ai"
|
|
8
|
+
api_key_env = "HUM_API_KEY"
|
|
9
|
+
|
|
10
|
+
[[mcp]] # native tools mounted into every session
|
|
11
|
+
name = "control"
|
|
12
|
+
url = "https://.../mcp"
|
|
13
|
+
|
|
14
|
+
[policy] # backend behaviour — defaults are "be a great agent"
|
|
15
|
+
gate = "off" # off | earned (earned = autonomy-gated sessions)
|
|
16
|
+
shadow = false # fork-and-grade on intervention (needs a grader + forkable executor)
|
|
17
|
+
grader = "" # shell command or "mcp:<server>__<tool>"
|
|
18
|
+
task_class = "" # autonomy bucket; default = workspace name
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import tomllib
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class MCPConfig:
|
|
32
|
+
name: str
|
|
33
|
+
url: str | None = None
|
|
34
|
+
command: str | None = None
|
|
35
|
+
args: list[str] = field(default_factory=list)
|
|
36
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Config:
|
|
41
|
+
model: str = "openrouter/z-ai/glm-5.3"
|
|
42
|
+
api_base: str | None = None
|
|
43
|
+
api_key: str | None = None
|
|
44
|
+
reasoning_effort: str | None = None
|
|
45
|
+
temperature: float | None = None
|
|
46
|
+
mcp: list[MCPConfig] = field(default_factory=list)
|
|
47
|
+
gate: str = "off"
|
|
48
|
+
shadow: bool = False
|
|
49
|
+
grader: str = ""
|
|
50
|
+
task_class: str = ""
|
|
51
|
+
max_turns: int = 400
|
|
52
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
byo: bool = False # the person's own provider key (model turns not ours to train on)
|
|
54
|
+
user: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _env(name: str | None) -> str | None:
|
|
58
|
+
"""Empty environment variables are absent, not keys."""
|
|
59
|
+
v = os.environ.get(name) if name else None
|
|
60
|
+
return v if v else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _credentials() -> dict[str, Any]:
|
|
64
|
+
p = Path(os.environ.get("HUM_HOME") or Path.home() / ".hum") / "credentials.json"
|
|
65
|
+
try:
|
|
66
|
+
return json.loads(p.read_text()) if p.exists() else {}
|
|
67
|
+
except Exception: # noqa: BLE001
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load(path: Path | None = None) -> Config:
|
|
72
|
+
"""Precedence: env > ~/.hum/config.toml [model] > the person's login (our proxy)
|
|
73
|
+
> their BYO key for the model's provider > defaults."""
|
|
74
|
+
p = path or Path(os.environ.get("HUM_CONFIG") or Path(os.environ.get("HUM_HOME") or Path.home() / ".hum") / "config.toml")
|
|
75
|
+
d: dict[str, Any] = {}
|
|
76
|
+
if p.exists():
|
|
77
|
+
d = tomllib.loads(p.read_text())
|
|
78
|
+
m, pol = d.get("model", {}), d.get("policy", {})
|
|
79
|
+
cred = _credentials()
|
|
80
|
+
key_env = m.get("api_key_env") or "HUM_API_KEY"
|
|
81
|
+
model = _env("HUM_MODEL") or m.get("name") or cred.get("model") or cred.get("default_model") or Config.model
|
|
82
|
+
api_base = _env("HUM_API_BASE") or m.get("api_base")
|
|
83
|
+
api_key = _env(key_env) or _env("HUM_API_KEY")
|
|
84
|
+
byo = False
|
|
85
|
+
if api_base is None and api_key is None and cred.get("api_base") and cred.get("api_key") and not cred.get("model"):
|
|
86
|
+
api_base, api_key = cred["api_base"], cred["api_key"] # logged in: our proxy
|
|
87
|
+
if api_key is None and api_base is None:
|
|
88
|
+
prov = model.split("/", 1)[0] if "/" in model else "openai"
|
|
89
|
+
env_name = {"openrouter": "OPENROUTER_API_KEY", "anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY", "gemini": "GEMINI_API_KEY"}.get(prov)
|
|
90
|
+
api_key = (cred.get("byo") or {}).get(prov) or _env(env_name)
|
|
91
|
+
byo = api_key is not None
|
|
92
|
+
cfg = Config(
|
|
93
|
+
model=model, api_base=api_base, api_key=api_key,
|
|
94
|
+
reasoning_effort=m.get("reasoning_effort"), temperature=m.get("temperature"),
|
|
95
|
+
mcp=[MCPConfig(name=s["name"], url=s.get("url"), command=s.get("command"), args=s.get("args", []), headers=s.get("headers", {})) for s in d.get("mcp", [])],
|
|
96
|
+
gate=pol.get("gate", "off"), shadow=bool(pol.get("shadow", False)), grader=pol.get("grader", ""),
|
|
97
|
+
task_class=pol.get("task_class", ""), max_turns=int(pol.get("max_turns", 400)), raw=d,
|
|
98
|
+
)
|
|
99
|
+
cfg.byo = byo
|
|
100
|
+
cfg.user = cred.get("email")
|
|
101
|
+
return cfg
|
hum/runtime/drivers.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Turn sources. A driver produces the next Turn given the chat so far. The
|
|
2
|
+
loop does not care whether a driver is a model, a person at a web client, or
|
|
3
|
+
a policy continuing in shadow — only who it says it is.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from .llm import LLM
|
|
13
|
+
from .models import Intervention, Turn
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PolicyDriver:
|
|
17
|
+
author = "policy"
|
|
18
|
+
|
|
19
|
+
def __init__(self, llm: LLM, author: str = "policy", on_text: Any = None):
|
|
20
|
+
self.llm = llm
|
|
21
|
+
self.author = author
|
|
22
|
+
self.on_text = on_text
|
|
23
|
+
|
|
24
|
+
async def next(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> Turn:
|
|
25
|
+
t = await self.llm.complete(messages, tools, on_text=self.on_text)
|
|
26
|
+
if t.author != self.author:
|
|
27
|
+
t = t.model_copy(update={"author": self.author})
|
|
28
|
+
return t
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HumanAction(BaseModel):
|
|
32
|
+
"""What a human sends into the loop.
|
|
33
|
+
|
|
34
|
+
- ``intervention`` on a gated proposal: accept | edit | reject | takeover
|
|
35
|
+
- ``turn``: the human's own action (tool calls) or steering text
|
|
36
|
+
- ``release``: hand control back to the policy after a takeover
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
intervention: Intervention | None = None
|
|
40
|
+
turn: Turn | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HumanDriver:
|
|
44
|
+
"""The human's two channels into the loop.
|
|
45
|
+
|
|
46
|
+
``decisions`` answers gated proposals (accept / edit / reject / takeover);
|
|
47
|
+
``steering`` carries unsolicited input while the policy acts ungated
|
|
48
|
+
(text, a human action, a takeover, a release). Keeping them apart means a
|
|
49
|
+
decision sent before the proposal is surfaced is still a decision, and
|
|
50
|
+
steering is never mistaken for one.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
author = "human"
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self.decisions: asyncio.Queue[HumanAction] = asyncio.Queue()
|
|
57
|
+
self.steering: asyncio.Queue[HumanAction] = asyncio.Queue()
|
|
58
|
+
self.proposals: asyncio.Queue[Turn] = asyncio.Queue() # surfaced to the client
|
|
59
|
+
|
|
60
|
+
# loop side
|
|
61
|
+
async def review(self, proposal: Turn) -> HumanAction:
|
|
62
|
+
await self.proposals.put(proposal)
|
|
63
|
+
return await self.decisions.get()
|
|
64
|
+
|
|
65
|
+
async def next(self) -> HumanAction:
|
|
66
|
+
return await self.steering.get()
|
|
67
|
+
|
|
68
|
+
def pending(self) -> HumanAction | None:
|
|
69
|
+
try:
|
|
70
|
+
return self.steering.get_nowait()
|
|
71
|
+
except asyncio.QueueEmpty:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# client side
|
|
75
|
+
def decide(self, action: HumanAction) -> None:
|
|
76
|
+
self.decisions.put_nowait(action)
|
|
77
|
+
|
|
78
|
+
def send(self, action: HumanAction) -> None:
|
|
79
|
+
self.steering.put_nowait(action)
|
hum/runtime/executor.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Where tools run. The runtime never touches a filesystem or a shell directly;
|
|
2
|
+
it goes through an Executor so the same loop drives a local directory, a
|
|
3
|
+
Harbor sandbox, or a hosted SME session.
|
|
4
|
+
|
|
5
|
+
``snapshot``/``fork`` exist for counterfactual shadow: the fork point is the
|
|
6
|
+
state *before* the human's intervention executes, and the shadow branch needs
|
|
7
|
+
its own copy to continue in.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
WINDOWS = os.name == "nt"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pick_shell() -> tuple[str, list[str]]:
|
|
24
|
+
"""(name, argv-prefix). POSIX: bash. Windows: Git Bash if installed, else PowerShell."""
|
|
25
|
+
if not WINDOWS:
|
|
26
|
+
return "bash", ["/bin/bash", "-c"]
|
|
27
|
+
for cand in (shutil.which("bash"), r"C:\Program Files\Git\bin\bash.exe", r"C:\Program Files\Git\usr\bin\bash.exe"):
|
|
28
|
+
if cand and Path(cand).exists() and "System32" not in cand: # System32\bash.exe is the WSL launcher, not a shell for cwd
|
|
29
|
+
return "bash", [cand, "-c"]
|
|
30
|
+
pwsh = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
|
31
|
+
return "powershell", [pwsh, "-NoProfile", "-NonInteractive", "-Command"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExecResult(BaseModel):
|
|
35
|
+
stdout: str
|
|
36
|
+
stderr: str = ""
|
|
37
|
+
returncode: int
|
|
38
|
+
timed_out: bool = False
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def text(self) -> str:
|
|
42
|
+
out = self.stdout
|
|
43
|
+
if self.stderr:
|
|
44
|
+
out = f"{out}\n[stderr]\n{self.stderr}" if out else f"[stderr]\n{self.stderr}"
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decode(data: bytes) -> str:
|
|
49
|
+
"""Estate files are latin-1/CRLF; most of the world is UTF-8. Never raise."""
|
|
50
|
+
try:
|
|
51
|
+
return data.decode("utf-8")
|
|
52
|
+
except UnicodeDecodeError:
|
|
53
|
+
return data.decode("latin-1")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@runtime_checkable
|
|
57
|
+
class Executor(Protocol):
|
|
58
|
+
cwd: str
|
|
59
|
+
|
|
60
|
+
async def exec(self, command: str, cwd: str | None = None, timeout_sec: int | None = None) -> ExecResult: ...
|
|
61
|
+
async def read(self, path: str) -> bytes: ...
|
|
62
|
+
async def write(self, path: str, data: bytes) -> None: ...
|
|
63
|
+
async def snapshot(self) -> str: ...
|
|
64
|
+
async def fork(self, snapshot_id: str) -> "Executor": ...
|
|
65
|
+
async def close(self) -> None: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LocalExecutor:
|
|
69
|
+
"""A directory on this machine. Used by tests, the terminal client, and
|
|
70
|
+
any SME session that runs where the files are."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, root: Path, snapshots_dir: Path | None = None):
|
|
73
|
+
self.root = Path(root).resolve()
|
|
74
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
self.cwd = str(self.root)
|
|
76
|
+
self.snapshots_dir = Path(snapshots_dir) if snapshots_dir else self.root.parent / f".{self.root.name}.snapshots"
|
|
77
|
+
self.shell, self._shell_argv = pick_shell()
|
|
78
|
+
|
|
79
|
+
def _abs(self, path: str) -> Path:
|
|
80
|
+
p = Path(path)
|
|
81
|
+
return p if p.is_absolute() else self.root / p
|
|
82
|
+
|
|
83
|
+
async def exec(self, command: str, cwd: str | None = None, timeout_sec: int | None = None) -> ExecResult:
|
|
84
|
+
proc = await asyncio.create_subprocess_exec(
|
|
85
|
+
*self._shell_argv, command,
|
|
86
|
+
cwd=str(self._abs(cwd)) if cwd else self.cwd,
|
|
87
|
+
stdout=asyncio.subprocess.PIPE,
|
|
88
|
+
stderr=asyncio.subprocess.PIPE,
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
|
|
92
|
+
except asyncio.TimeoutError:
|
|
93
|
+
proc.kill()
|
|
94
|
+
out, err = await proc.communicate()
|
|
95
|
+
return ExecResult(stdout=decode(out), stderr=decode(err), returncode=-9, timed_out=True)
|
|
96
|
+
return ExecResult(stdout=decode(out), stderr=decode(err), returncode=proc.returncode or 0)
|
|
97
|
+
|
|
98
|
+
async def read(self, path: str) -> bytes:
|
|
99
|
+
return self._abs(path).read_bytes()
|
|
100
|
+
|
|
101
|
+
async def write(self, path: str, data: bytes) -> None:
|
|
102
|
+
p = self._abs(path)
|
|
103
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
p.write_bytes(data)
|
|
105
|
+
|
|
106
|
+
async def snapshot(self) -> str:
|
|
107
|
+
sid = uuid.uuid4().hex[:12]
|
|
108
|
+
dst = self.snapshots_dir / sid
|
|
109
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
await asyncio.to_thread(shutil.copytree, self.root, dst, symlinks=True)
|
|
111
|
+
return sid
|
|
112
|
+
|
|
113
|
+
async def fork(self, snapshot_id: str) -> "LocalExecutor":
|
|
114
|
+
src = self.snapshots_dir / snapshot_id
|
|
115
|
+
dst = self.snapshots_dir / f"{snapshot_id}-fork-{uuid.uuid4().hex[:6]}"
|
|
116
|
+
await asyncio.to_thread(shutil.copytree, src, dst, symlinks=True)
|
|
117
|
+
return LocalExecutor(dst, snapshots_dir=self.snapshots_dir)
|
|
118
|
+
|
|
119
|
+
async def close(self) -> None:
|
|
120
|
+
return None
|
hum/runtime/grader.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The world's opinion. A Grader turns a terminal state into a Verdict. In
|
|
2
|
+
production this is CONTROL through its MCP (blind, gated); in tests it is a
|
|
3
|
+
predicate on the workspace. The runtime treats the grader as the reward AND as
|
|
4
|
+
a tool the agent may call mid-run (verifier in the loop) — same instrument,
|
|
5
|
+
two uses.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Awaitable, Callable, Protocol
|
|
10
|
+
|
|
11
|
+
from .executor import Executor
|
|
12
|
+
from .models import Verdict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Grader(Protocol):
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
async def grade(self, executor: Executor, task: dict[str, Any]) -> Verdict: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PredicateGrader:
|
|
22
|
+
def __init__(self, name: str, fn: Callable[[Executor, dict[str, Any]], Awaitable[tuple[bool, float, dict[str, Any]]]]):
|
|
23
|
+
self.name, self.fn = name, fn
|
|
24
|
+
|
|
25
|
+
async def grade(self, executor: Executor, task: dict[str, Any]) -> Verdict:
|
|
26
|
+
passed, score, detail = await self.fn(executor, task)
|
|
27
|
+
return Verdict(passed=passed, score=score, grader=self.name, detail=detail)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MCPGrader:
|
|
31
|
+
"""Grade by calling a tool on an already-mounted MCP toolset (e.g. control__grade_case).
|
|
32
|
+
``to_verdict`` maps the tool's text/JSON output to (passed, score, detail)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, name: str, call: Callable[[dict[str, Any]], Awaitable[str]], args_for: Callable[[dict[str, Any]], dict[str, Any]],
|
|
35
|
+
to_verdict: Callable[[str], tuple[bool, float, dict[str, Any]]]):
|
|
36
|
+
self.name, self._call, self._args_for, self._to_verdict = name, call, args_for, to_verdict
|
|
37
|
+
|
|
38
|
+
async def grade(self, executor: Executor, task: dict[str, Any]) -> Verdict:
|
|
39
|
+
out = await self._call(self._args_for(task))
|
|
40
|
+
passed, score, detail = self._to_verdict(out)
|
|
41
|
+
return Verdict(passed=passed, score=score, grader=self.name, detail=detail)
|
hum/runtime/llm.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Model client with native tool calling over litellm. Anything with an
|
|
2
|
+
OpenAI-compatible endpoint works: frontier APIs, OpenRouter, a LiteLLM proxy
|
|
3
|
+
with per-expert keys, or the policy served from the trainer. When the server
|
|
4
|
+
returns token ids / logprobs they are kept on the Turn for token custody.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Awaitable, Callable, Protocol
|
|
10
|
+
|
|
11
|
+
from .models import ToolCall, Turn, Usage
|
|
12
|
+
|
|
13
|
+
OnText = Callable[[str], None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LLM(Protocol):
|
|
17
|
+
model: str
|
|
18
|
+
|
|
19
|
+
async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, on_text: OnText | None = None) -> Turn: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _raw_usage(resp: Any) -> dict[str, Any]:
|
|
23
|
+
try:
|
|
24
|
+
d = resp.model_dump() if hasattr(resp, "model_dump") else dict(resp)
|
|
25
|
+
return d.get("usage") or {}
|
|
26
|
+
except Exception: # noqa: BLE001
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse(msg: Any, resp: Any, model: str, author: str, cost_hint: float | None = None) -> Turn:
|
|
31
|
+
calls: list[ToolCall] = []
|
|
32
|
+
for tc in getattr(msg, "tool_calls", None) or []:
|
|
33
|
+
try:
|
|
34
|
+
args = json.loads(tc.function.arguments or "{}")
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
args = {"_raw": tc.function.arguments}
|
|
37
|
+
calls.append(ToolCall(id=tc.id, name=tc.function.name, arguments=args))
|
|
38
|
+
u = getattr(resp, "usage", None)
|
|
39
|
+
raw = _raw_usage(resp)
|
|
40
|
+
usage = Usage(
|
|
41
|
+
input_tokens=getattr(u, "prompt_tokens", 0) or 0,
|
|
42
|
+
output_tokens=getattr(u, "completion_tokens", 0) or 0,
|
|
43
|
+
cache_tokens=(getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0),
|
|
44
|
+
cost_usd=float(cost_hint or raw.get("cost_usd") or raw.get("cost")
|
|
45
|
+
or (getattr(resp, "_hidden_params", None) or {}).get("response_cost") or 0.0),
|
|
46
|
+
) if u else None
|
|
47
|
+
return Turn(author=author, content=msg.content or "", reasoning=getattr(msg, "reasoning_content", None), # type: ignore[arg-type]
|
|
48
|
+
tool_calls=calls, model=getattr(resp, "model", model) or model, usage=usage, done=not calls)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LiteLLMClient:
|
|
52
|
+
def __init__(self, model: str, *, api_base: str | None = None, api_key: str | None = None,
|
|
53
|
+
temperature: float | None = None, reasoning_effort: str | None = None,
|
|
54
|
+
max_tokens: int | None = None, timeout: float = 600.0, extra: dict[str, Any] | None = None,
|
|
55
|
+
author: str = "policy", stream: bool = True):
|
|
56
|
+
self.model = model
|
|
57
|
+
self.api_base, self.api_key = api_base, api_key
|
|
58
|
+
self.temperature, self.reasoning_effort, self.max_tokens, self.timeout = temperature, reasoning_effort, max_tokens, timeout
|
|
59
|
+
self.extra = extra or {}
|
|
60
|
+
self.author = author
|
|
61
|
+
self.stream = stream
|
|
62
|
+
|
|
63
|
+
def _kwargs(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None) -> dict[str, Any]:
|
|
64
|
+
kw: dict[str, Any] = dict(model=self.model, messages=messages, timeout=self.timeout, **self.extra)
|
|
65
|
+
if tools:
|
|
66
|
+
kw["tools"], kw["tool_choice"] = tools, "auto"
|
|
67
|
+
if self.api_base:
|
|
68
|
+
kw["api_base"] = self.api_base
|
|
69
|
+
if self.api_key:
|
|
70
|
+
kw["api_key"] = self.api_key
|
|
71
|
+
if self.temperature is not None:
|
|
72
|
+
kw["temperature"] = self.temperature
|
|
73
|
+
if self.reasoning_effort:
|
|
74
|
+
kw["reasoning_effort"] = self.reasoning_effort
|
|
75
|
+
if self.max_tokens:
|
|
76
|
+
kw["max_tokens"] = self.max_tokens
|
|
77
|
+
return kw
|
|
78
|
+
|
|
79
|
+
async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, on_text: OnText | None = None) -> Turn:
|
|
80
|
+
import litellm # lazy: tests never import it
|
|
81
|
+
|
|
82
|
+
litellm.suppress_debug_info = True
|
|
83
|
+
kw = self._kwargs(messages, tools)
|
|
84
|
+
if not (self.stream and on_text):
|
|
85
|
+
resp = await litellm.acompletion(**kw)
|
|
86
|
+
return _parse(resp.choices[0].message, resp, self.model, self.author)
|
|
87
|
+
chunks, cost_hint = [], None
|
|
88
|
+
async for chunk in await litellm.acompletion(stream=True, stream_options={"include_usage": True}, **kw):
|
|
89
|
+
chunks.append(chunk)
|
|
90
|
+
delta = chunk.choices[0].delta if chunk.choices else None
|
|
91
|
+
if delta is not None and getattr(delta, "content", None):
|
|
92
|
+
on_text(delta.content)
|
|
93
|
+
ru = _raw_usage(chunk)
|
|
94
|
+
if ru.get("cost_usd") or ru.get("cost"):
|
|
95
|
+
cost_hint = float(ru.get("cost_usd") or ru.get("cost"))
|
|
96
|
+
resp = litellm.stream_chunk_builder(chunks, messages=messages)
|
|
97
|
+
return _parse(resp.choices[0].message, resp, self.model, self.author, cost_hint=cost_hint)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ScriptedLLM:
|
|
101
|
+
"""Deterministic stand-in for tests and replay: returns pre-written turns in
|
|
102
|
+
order. ``on_call`` runs before each turn is returned (tests use it to
|
|
103
|
+
change the workspace between turns, the way a person would)."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, turns: list[Turn], model: str = "scripted", on_call: Callable[[int], Awaitable[None] | None] | None = None):
|
|
106
|
+
self.turns, self.model, self.calls, self.on_call = list(turns), model, [], on_call
|
|
107
|
+
|
|
108
|
+
async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, on_text: OnText | None = None) -> Turn:
|
|
109
|
+
self.calls.append(messages)
|
|
110
|
+
if self.on_call:
|
|
111
|
+
r = self.on_call(len(self.calls))
|
|
112
|
+
if r is not None:
|
|
113
|
+
await r
|
|
114
|
+
if not self.turns:
|
|
115
|
+
return Turn(author="policy", content="(script exhausted)", done=True)
|
|
116
|
+
t = self.turns.pop(0)
|
|
117
|
+
if on_text and t.content:
|
|
118
|
+
on_text(t.content)
|
|
119
|
+
return t
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class OpenAICompatClient:
|
|
123
|
+
"""Direct OpenAI-compatible client (httpx). Used for our own proxy and any
|
|
124
|
+
OpenAI-compatible base: exact usage/cost passthrough, no provider shims.
|
|
125
|
+
Streams SSE and accumulates tool-call deltas."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, model: str, *, api_base: str, api_key: str | None = None, temperature: float | None = None,
|
|
128
|
+
reasoning_effort: str | None = None, max_tokens: int | None = None, timeout: float = 600.0,
|
|
129
|
+
author: str = "policy", stream: bool = True, transport: Any = None):
|
|
130
|
+
self.model, self.api_base, self.api_key = model.split("/", 1)[1] if model.startswith("openai/") else model, api_base.rstrip("/"), api_key
|
|
131
|
+
self.temperature, self.reasoning_effort, self.max_tokens, self.timeout = temperature, reasoning_effort, max_tokens, timeout
|
|
132
|
+
self.author, self.stream, self._transport = author, stream, transport
|
|
133
|
+
|
|
134
|
+
def _body(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, stream: bool) -> dict[str, Any]:
|
|
135
|
+
b: dict[str, Any] = {"model": self.model, "messages": messages}
|
|
136
|
+
if tools:
|
|
137
|
+
b["tools"], b["tool_choice"] = tools, "auto"
|
|
138
|
+
if self.temperature is not None:
|
|
139
|
+
b["temperature"] = self.temperature
|
|
140
|
+
if self.reasoning_effort:
|
|
141
|
+
b["reasoning_effort"] = self.reasoning_effort
|
|
142
|
+
if self.max_tokens:
|
|
143
|
+
b["max_tokens"] = self.max_tokens
|
|
144
|
+
if stream:
|
|
145
|
+
b["stream"], b["stream_options"] = True, {"include_usage": True}
|
|
146
|
+
return b
|
|
147
|
+
|
|
148
|
+
def _headers(self) -> dict[str, str]:
|
|
149
|
+
h = {"content-type": "application/json"}
|
|
150
|
+
if self.api_key:
|
|
151
|
+
h["authorization"] = f"Bearer {self.api_key}"
|
|
152
|
+
return h
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _usage(u: dict[str, Any] | None) -> Usage | None:
|
|
156
|
+
if not u:
|
|
157
|
+
return None
|
|
158
|
+
return Usage(input_tokens=u.get("prompt_tokens", 0) or 0, output_tokens=u.get("completion_tokens", 0) or 0,
|
|
159
|
+
cache_tokens=((u.get("prompt_tokens_details") or {}).get("cached_tokens", 0) or 0),
|
|
160
|
+
cost_usd=float(u.get("cost_usd") or u.get("cost") or 0.0))
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _calls(raw: list[dict[str, Any]] | None) -> list[ToolCall]:
|
|
164
|
+
out: list[ToolCall] = []
|
|
165
|
+
for tc in raw or []:
|
|
166
|
+
fn = tc.get("function") or {}
|
|
167
|
+
try:
|
|
168
|
+
args = json.loads(fn.get("arguments") or "{}")
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
args = {"_raw": fn.get("arguments")}
|
|
171
|
+
out.append(ToolCall(id=tc.get("id") or f"call_{len(out)}", name=fn.get("name") or "", arguments=args))
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, on_text: OnText | None = None) -> Turn:
|
|
175
|
+
import httpx
|
|
176
|
+
|
|
177
|
+
url = f"{self.api_base}/chat/completions"
|
|
178
|
+
async with httpx.AsyncClient(timeout=self.timeout, transport=self._transport) as http:
|
|
179
|
+
if not (self.stream and on_text):
|
|
180
|
+
r = await http.post(url, json=self._body(messages, tools, False), headers=self._headers())
|
|
181
|
+
if r.status_code != 200:
|
|
182
|
+
raise RuntimeError(f"{r.status_code} {r.text[:300]}")
|
|
183
|
+
d = r.json()
|
|
184
|
+
msg = d["choices"][0]["message"]
|
|
185
|
+
calls = self._calls(msg.get("tool_calls"))
|
|
186
|
+
return Turn(author=self.author, content=msg.get("content") or "", reasoning=msg.get("reasoning_content"), # type: ignore[arg-type]
|
|
187
|
+
tool_calls=calls, model=d.get("model") or self.model, usage=self._usage(d.get("usage")), done=not calls)
|
|
188
|
+
content, reasoning, usage, model = [], [], None, self.model
|
|
189
|
+
acc: dict[int, dict[str, Any]] = {}
|
|
190
|
+
async with http.stream("POST", url, json=self._body(messages, tools, True), headers=self._headers()) as r:
|
|
191
|
+
if r.status_code != 200:
|
|
192
|
+
body = (await r.aread()).decode(errors="replace")
|
|
193
|
+
raise RuntimeError(f"{r.status_code} {body[:300]}")
|
|
194
|
+
async for line in r.aiter_lines():
|
|
195
|
+
if not line.startswith("data:"):
|
|
196
|
+
continue
|
|
197
|
+
data = line[5:].strip()
|
|
198
|
+
if data == "[DONE]":
|
|
199
|
+
break
|
|
200
|
+
try:
|
|
201
|
+
d = json.loads(data)
|
|
202
|
+
except json.JSONDecodeError:
|
|
203
|
+
continue
|
|
204
|
+
model = d.get("model") or model
|
|
205
|
+
if d.get("usage"):
|
|
206
|
+
usage = d["usage"]
|
|
207
|
+
for ch in d.get("choices") or []:
|
|
208
|
+
delta = ch.get("delta") or {}
|
|
209
|
+
if delta.get("content"):
|
|
210
|
+
content.append(delta["content"]); on_text(delta["content"])
|
|
211
|
+
if delta.get("reasoning_content"):
|
|
212
|
+
reasoning.append(delta["reasoning_content"])
|
|
213
|
+
for tc in delta.get("tool_calls") or []:
|
|
214
|
+
i = tc.get("index", 0)
|
|
215
|
+
a = acc.setdefault(i, {"id": None, "function": {"name": "", "arguments": ""}})
|
|
216
|
+
if tc.get("id"):
|
|
217
|
+
a["id"] = tc["id"]
|
|
218
|
+
fn = tc.get("function") or {}
|
|
219
|
+
if fn.get("name"):
|
|
220
|
+
a["function"]["name"] += fn["name"]
|
|
221
|
+
if fn.get("arguments"):
|
|
222
|
+
a["function"]["arguments"] += fn["arguments"]
|
|
223
|
+
calls = self._calls([acc[i] for i in sorted(acc)])
|
|
224
|
+
return Turn(author=self.author, content="".join(content), reasoning="".join(reasoning) or None, # type: ignore[arg-type]
|
|
225
|
+
tool_calls=calls, model=model, usage=self._usage(usage), done=not calls)
|