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/__init__.py
ADDED
hum/adapters/__init__.py
ADDED
|
File without changes
|
hum/adapters/harbor.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Harbor adapter: the runtime as a Harbor ``BaseAgent``.
|
|
2
|
+
|
|
3
|
+
This is how the harness enters every arena that already speaks Harbor —
|
|
4
|
+
Terminal-Bench, MainframeBench through simhub's task_run, and Slime rollouts
|
|
5
|
+
(the policy is served at an OpenAI-compatible endpoint; Harbor passes it as
|
|
6
|
+
``-m openai/policy`` with ``OPENAI_BASE_URL``). Same class, no human driver.
|
|
7
|
+
|
|
8
|
+
Register with ``harbor run --agent-import-path hum.adapters.harbor:HumAgent``.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import shlex
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from harbor.agents.base import BaseAgent
|
|
20
|
+
from harbor.agents.model_connection import ModelConnectionSpec
|
|
21
|
+
from harbor.environments.base import BaseEnvironment
|
|
22
|
+
from harbor.models.agent.context import AgentContext
|
|
23
|
+
from harbor.models.trajectories import Trajectory
|
|
24
|
+
|
|
25
|
+
from hum import __version__
|
|
26
|
+
from hum.runtime import LiteLLMClient, PolicyDriver, RunConfig, Runner, Session, ToolRegistry, builtin_tools
|
|
27
|
+
from hum.runtime.executor import ExecResult, decode
|
|
28
|
+
from hum.runtime.prompt import system_prompt
|
|
29
|
+
from hum.runtime.tools import MCPToolset
|
|
30
|
+
from hum.runtime.trajectory import to_atif
|
|
31
|
+
|
|
32
|
+
AGENT_NAME = "hum"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HarborExecutor:
|
|
36
|
+
"""Executor over a Harbor environment. Files move as base64 through exec so
|
|
37
|
+
no host-side temp paths are involved; snapshots are tarballs in the sandbox."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, env: BaseEnvironment, cwd: str = "/app"):
|
|
40
|
+
self.env, self.cwd = env, cwd
|
|
41
|
+
|
|
42
|
+
async def exec(self, command: str, cwd: str | None = None, timeout_sec: int | None = None) -> ExecResult:
|
|
43
|
+
r = await self.env.exec(command, cwd=cwd or self.cwd, timeout_sec=timeout_sec)
|
|
44
|
+
return ExecResult(stdout=r.stdout or "", stderr=r.stderr or "", returncode=r.return_code if r.return_code is not None else 0)
|
|
45
|
+
|
|
46
|
+
async def read(self, path: str) -> bytes:
|
|
47
|
+
r = await self.env.exec(f"base64 -w0 {shlex.quote(path)}", cwd=self.cwd, timeout_sec=60)
|
|
48
|
+
if r.return_code not in (0, None):
|
|
49
|
+
raise FileNotFoundError(r.stderr or path)
|
|
50
|
+
return base64.b64decode(r.stdout or "")
|
|
51
|
+
|
|
52
|
+
async def write(self, path: str, data: bytes) -> None:
|
|
53
|
+
b64 = base64.b64encode(data).decode()
|
|
54
|
+
r = await self.env.exec(f"mkdir -p $(dirname {shlex.quote(path)}) && echo {b64} | base64 -d > {shlex.quote(path)}", cwd=self.cwd, timeout_sec=60)
|
|
55
|
+
if r.return_code not in (0, None):
|
|
56
|
+
raise OSError(r.stderr or f"write failed: {path}")
|
|
57
|
+
|
|
58
|
+
async def snapshot(self) -> str:
|
|
59
|
+
sid = uuid.uuid4().hex[:12]
|
|
60
|
+
await self.env.exec(f"mkdir -p /tmp/harness_snap && tar czf /tmp/harness_snap/{sid}.tgz -C {shlex.quote(self.cwd)} .", timeout_sec=600)
|
|
61
|
+
return sid
|
|
62
|
+
|
|
63
|
+
async def fork(self, snapshot_id: str):
|
|
64
|
+
# One sandbox per trial: shadow forks need a second sandbox, which Harbor
|
|
65
|
+
# does not hand out. Unattended runs never fork; attended runs use the
|
|
66
|
+
# hosted-session executor instead.
|
|
67
|
+
raise NotImplementedError("HarborExecutor cannot fork; run attended sessions on a forkable executor")
|
|
68
|
+
|
|
69
|
+
async def close(self) -> None:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class HumAgent(BaseAgent):
|
|
74
|
+
SUPPORTS_ATIF = True
|
|
75
|
+
MODEL_CONNECTION = ModelConnectionSpec() # provider inferred from the model name (openai/..., anthropic/..., openrouter/...)
|
|
76
|
+
|
|
77
|
+
def __init__(self, logs_dir: Path, model_name: str | None = None, *args: Any, temperature: float | None = None,
|
|
78
|
+
reasoning_effort: str | None = None, max_turns: int = 200, max_wall_s: float = 3600.0,
|
|
79
|
+
observation_max_bytes: int = 16_000, **kwargs: Any):
|
|
80
|
+
super().__init__(logs_dir, model_name, *args, **kwargs)
|
|
81
|
+
self._temperature = temperature
|
|
82
|
+
self._reasoning_effort = reasoning_effort
|
|
83
|
+
self._cfg = RunConfig(max_turns=int(max_turns), max_wall_s=float(max_wall_s), shadow=False)
|
|
84
|
+
self._obs_bytes = int(observation_max_bytes)
|
|
85
|
+
self._toolsets: list[MCPToolset] = []
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def name() -> str:
|
|
89
|
+
return AGENT_NAME
|
|
90
|
+
|
|
91
|
+
def version(self) -> str | None:
|
|
92
|
+
return __version__
|
|
93
|
+
|
|
94
|
+
async def setup(self, environment: BaseEnvironment) -> None:
|
|
95
|
+
return None # nothing is installed in the sandbox; the loop runs host-side
|
|
96
|
+
|
|
97
|
+
async def run(self, instruction: str, environment: BaseEnvironment, context: AgentContext) -> None:
|
|
98
|
+
executor = HarborExecutor(environment)
|
|
99
|
+
tools = ToolRegistry(observation_max_bytes=self._obs_bytes).extend(builtin_tools())
|
|
100
|
+
notes = []
|
|
101
|
+
for s in self.mcp_servers or []:
|
|
102
|
+
ts = MCPToolset(s.name, url=s.url, command=s.command, args=s.args) if s.transport == "stdio" or s.url else None
|
|
103
|
+
if ts is None:
|
|
104
|
+
continue
|
|
105
|
+
mounted = await ts.connect()
|
|
106
|
+
tools.extend(mounted)
|
|
107
|
+
self._toolsets.append(ts)
|
|
108
|
+
notes.append(f"- {s.name}: " + ", ".join(t.name for t in mounted))
|
|
109
|
+
conn = self.model_connection
|
|
110
|
+
llm = LiteLLMClient(self.model_name or "", api_base=conn.base_url, api_key=conn.api_key,
|
|
111
|
+
temperature=self._temperature, reasoning_effort=self._reasoning_effort)
|
|
112
|
+
prompt = system_prompt(tools_note="\n".join(notes))
|
|
113
|
+
session = Session({"instruction": instruction}, session_id=self.session_id, path=self.logs_dir / "session.jsonl")
|
|
114
|
+
runner = Runner(session, executor, tools, PolicyDriver(llm), config=self._cfg, system_prompt=prompt)
|
|
115
|
+
try:
|
|
116
|
+
result = await runner.run()
|
|
117
|
+
finally:
|
|
118
|
+
for ts in self._toolsets:
|
|
119
|
+
await ts.close()
|
|
120
|
+
traj = to_atif(session, agent_name=AGENT_NAME, agent_version=__version__ or "0", model_name=self.model_name,
|
|
121
|
+
system_prompt=prompt, tool_definitions=tools.to_openai())
|
|
122
|
+
Trajectory.model_validate(traj) # stay a valid ATIF document
|
|
123
|
+
(self.logs_dir / "trajectory.json").write_text(json.dumps(traj, ensure_ascii=False, indent=1))
|
|
124
|
+
context.n_input_tokens = result.usage.input_tokens
|
|
125
|
+
context.n_output_tokens = result.usage.output_tokens
|
|
126
|
+
context.n_cache_tokens = result.usage.cache_tokens
|
|
127
|
+
context.cost_usd = result.usage.cost_usd
|
|
128
|
+
context.metadata = {"harness": AGENT_NAME, "turns": result.turns, "stop_reason": result.stop_reason}
|
hum/auth.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Sign-in from the terminal: ``hum login`` opens the browser (device-code flow,
|
|
2
|
+
RFC 8628), the person signs in with Google, the terminal receives a Hum token
|
|
3
|
+
plus their key on our model proxy. Credentials live in ~/.hum/credentials.json
|
|
4
|
+
(mode 0600). BYO provider keys live next to them.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from hum import __version__
|
|
17
|
+
from hum.runtime.store import home
|
|
18
|
+
|
|
19
|
+
DEFAULT_AUTH_URL = os.environ.get("HUM_AUTH_URL", "https://metaphi-ai--hum-web.modal.run")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Credentials:
|
|
24
|
+
auth_url: str = DEFAULT_AUTH_URL
|
|
25
|
+
access_token: str | None = None
|
|
26
|
+
email: str | None = None
|
|
27
|
+
name: str | None = None
|
|
28
|
+
api_base: str | None = None # our proxy
|
|
29
|
+
api_key: str | None = None # this person's key on it
|
|
30
|
+
default_model: str | None = None
|
|
31
|
+
byo: dict[str, str] = field(default_factory=dict) # provider -> key, user-supplied
|
|
32
|
+
model: str | None = None # user's chosen model, if any
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def logged_in(self) -> bool:
|
|
36
|
+
return bool(self.access_token and self.email)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def path() -> Path:
|
|
40
|
+
return home() / "credentials.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load() -> Credentials:
|
|
44
|
+
p = path()
|
|
45
|
+
if not p.exists():
|
|
46
|
+
return Credentials()
|
|
47
|
+
try:
|
|
48
|
+
d = json.loads(p.read_text())
|
|
49
|
+
return Credentials(**{k: v for k, v in d.items() if k in Credentials.__dataclass_fields__})
|
|
50
|
+
except Exception: # noqa: BLE001
|
|
51
|
+
return Credentials()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save(c: Credentials) -> None:
|
|
55
|
+
p = path()
|
|
56
|
+
p.write_text(json.dumps(asdict(c), indent=1))
|
|
57
|
+
os.chmod(p, 0o600)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
PROVIDER_ENV = {"openrouter": "OPENROUTER_API_KEY", "anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY", "gemini": "GEMINI_API_KEY"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def provider_of(model: str) -> str:
|
|
64
|
+
return model.split("/", 1)[0] if "/" in model else "openai"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def login(auth_url: str | None = None, open_browser: bool = True, poll_timeout_s: float = 600.0, client: Any = None) -> Credentials:
|
|
68
|
+
"""Device-code flow. ``client`` is an httpx.Client (tests inject one)."""
|
|
69
|
+
import httpx
|
|
70
|
+
|
|
71
|
+
base = (auth_url or load().auth_url or DEFAULT_AUTH_URL).rstrip("/")
|
|
72
|
+
http = client or httpx.Client(timeout=20.0)
|
|
73
|
+
r = http.post(f"{base}/device/code", json={"client": "hum-cli", "version": __version__})
|
|
74
|
+
r.raise_for_status()
|
|
75
|
+
d = r.json()
|
|
76
|
+
uri = d.get("verification_uri_complete") or d["verification_uri"]
|
|
77
|
+
print(f"\nOpen {uri}\nand confirm the code {d['user_code']}\n")
|
|
78
|
+
if open_browser:
|
|
79
|
+
try:
|
|
80
|
+
webbrowser.open(uri)
|
|
81
|
+
except Exception: # noqa: BLE001
|
|
82
|
+
pass
|
|
83
|
+
interval = float(d.get("interval", 3))
|
|
84
|
+
deadline = time.monotonic() + min(poll_timeout_s, float(d.get("expires_in", 600)))
|
|
85
|
+
while time.monotonic() < deadline:
|
|
86
|
+
time.sleep(interval)
|
|
87
|
+
t = http.post(f"{base}/device/token", json={"device_code": d["device_code"]})
|
|
88
|
+
body = t.json()
|
|
89
|
+
if t.status_code == 200 and body.get("access_token"):
|
|
90
|
+
c = load()
|
|
91
|
+
c.auth_url = base
|
|
92
|
+
c.access_token = body["access_token"]
|
|
93
|
+
c.email = body["user"]["email"]
|
|
94
|
+
c.name = body["user"].get("name")
|
|
95
|
+
m = body.get("model") or {}
|
|
96
|
+
c.api_base, c.api_key, c.default_model = m.get("api_base"), m.get("api_key"), m.get("default_model")
|
|
97
|
+
save(c)
|
|
98
|
+
return c
|
|
99
|
+
err = body.get("error")
|
|
100
|
+
if err == "slow_down":
|
|
101
|
+
interval += 2
|
|
102
|
+
elif err in ("authorization_pending", None):
|
|
103
|
+
continue
|
|
104
|
+
else:
|
|
105
|
+
raise RuntimeError(f"login failed: {err}")
|
|
106
|
+
raise TimeoutError("login timed out; run `hum login` again")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def logout() -> None:
|
|
110
|
+
c = load()
|
|
111
|
+
c.access_token = c.email = c.name = c.api_base = c.api_key = c.default_model = None
|
|
112
|
+
save(c)
|
hum/cli.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Hum's terminal client.
|
|
2
|
+
|
|
3
|
+
hum [task] talk to it in the current directory
|
|
4
|
+
hum run "task" one task, unattended, then exit
|
|
5
|
+
hum resume [id] pick up a session
|
|
6
|
+
hum login / logout / whoami
|
|
7
|
+
hum model [name] choose the model (blank: show)
|
|
8
|
+
hum key <provider> <key> bring your own provider key
|
|
9
|
+
hum sessions / show <id> / sync
|
|
10
|
+
|
|
11
|
+
To the person it's a coding agent. Everything they do — a message, a hand
|
|
12
|
+
edit, a command they ran, an interrupt — is recorded as their turn in the
|
|
13
|
+
session tree; the backend learns from that. Configuration is the deployment's
|
|
14
|
+
(~/.hum/config.toml), not the user's.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from hum import __version__
|
|
26
|
+
from hum.runtime import (AutonomyPolicy, HumanAction, HumanDriver, LiteLLMClient, LocalExecutor, OpenAICompatClient, PolicyDriver,
|
|
27
|
+
RunConfig, Runner, Session, ToolCall, ToolRegistry, Turn, builtin_tools)
|
|
28
|
+
from hum.runtime.config import Config, load as load_config
|
|
29
|
+
from hum.runtime.grader import PredicateGrader
|
|
30
|
+
from hum.runtime.prompt import system_prompt
|
|
31
|
+
from hum.runtime.store import autonomy_path, home, list_sessions, session_path
|
|
32
|
+
from hum.runtime.tools import MCPToolset
|
|
33
|
+
from hum.ui import UI
|
|
34
|
+
|
|
35
|
+
HELP = """/help this
|
|
36
|
+
/model [name] show or switch the model for this session
|
|
37
|
+
/tools what's mounted
|
|
38
|
+
/cost tokens and cost so far
|
|
39
|
+
/quit leave (Ctrl-D works too)
|
|
40
|
+
!cmd run a command yourself
|
|
41
|
+
Ctrl-C stop the current turn"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_llm(cfg: Config):
|
|
45
|
+
"""Our proxy (or any OpenAI-compatible base): talk to it directly. BYO providers: litellm."""
|
|
46
|
+
if cfg.api_base:
|
|
47
|
+
return OpenAICompatClient(cfg.model, api_base=cfg.api_base, api_key=cfg.api_key, temperature=cfg.temperature, reasoning_effort=cfg.reasoning_effort)
|
|
48
|
+
return LiteLLMClient(cfg.model, api_key=cfg.api_key, temperature=cfg.temperature, reasoning_effort=cfg.reasoning_effort)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def command_grader(cmd: str) -> PredicateGrader:
|
|
52
|
+
async def fn(ex: Any, task: dict[str, Any]) -> tuple[bool, float, dict[str, Any]]:
|
|
53
|
+
r = await ex.exec(cmd, timeout_sec=900)
|
|
54
|
+
return r.returncode == 0, 1.0 if r.returncode == 0 else 0.0, {"command": cmd, "returncode": r.returncode, "tail": r.text[-2000:]}
|
|
55
|
+
return PredicateGrader(f"cmd:{cmd}", fn)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class VisibleTools(ToolRegistry):
|
|
59
|
+
def __init__(self, ui: UI, **kw: Any):
|
|
60
|
+
super().__init__(**kw)
|
|
61
|
+
self.ui = ui
|
|
62
|
+
|
|
63
|
+
async def call_many(self, calls, executor, parallel=True): # type: ignore[override]
|
|
64
|
+
for c in calls:
|
|
65
|
+
self.ui.call(c)
|
|
66
|
+
results = await super().call_many(calls, executor, parallel)
|
|
67
|
+
for r in results:
|
|
68
|
+
self.ui.result(r)
|
|
69
|
+
return results
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Client:
|
|
73
|
+
def __init__(self, cfg: Config, cwd: Path, ui: UI | None = None):
|
|
74
|
+
self.cfg, self.cwd = cfg, cwd
|
|
75
|
+
self.ui = ui or UI()
|
|
76
|
+
self.executor = LocalExecutor(cwd)
|
|
77
|
+
self.tools = VisibleTools(self.ui).extend(builtin_tools(self.executor.shell))
|
|
78
|
+
self.toolsets: list[MCPToolset] = []
|
|
79
|
+
self.task_class = cfg.task_class or cwd.name
|
|
80
|
+
self.llm = make_llm(cfg)
|
|
81
|
+
|
|
82
|
+
# -- setup ---------------------------------------------------------------------------
|
|
83
|
+
async def mount(self) -> list[str]:
|
|
84
|
+
names = [self.executor.shell, "files", "grep"]
|
|
85
|
+
for m in self.cfg.mcp:
|
|
86
|
+
ts = MCPToolset(m.name, url=m.url, command=m.command, args=m.args, headers=m.headers)
|
|
87
|
+
try:
|
|
88
|
+
mounted = await ts.connect()
|
|
89
|
+
except Exception as e: # noqa: BLE001
|
|
90
|
+
self.ui.warn(f"{m.name}: unavailable ({str(e).splitlines()[0][:120]})")
|
|
91
|
+
continue
|
|
92
|
+
self.tools.extend(mounted)
|
|
93
|
+
self.toolsets.append(ts)
|
|
94
|
+
names.append(m.name)
|
|
95
|
+
return names
|
|
96
|
+
|
|
97
|
+
async def close(self) -> None:
|
|
98
|
+
for ts in self.toolsets:
|
|
99
|
+
await ts.close()
|
|
100
|
+
|
|
101
|
+
def runner(self, session: Session, human: HumanDriver | None) -> Runner:
|
|
102
|
+
policy = PolicyDriver(self.llm, on_text=self.ui.text)
|
|
103
|
+
grader = command_grader(self.cfg.grader) if self.cfg.grader and not self.cfg.grader.startswith("mcp:") else None
|
|
104
|
+
rc = RunConfig(gate=self.cfg.gate, shadow=self.cfg.shadow and human is not None, max_turns=self.cfg.max_turns, task_class=self.task_class)
|
|
105
|
+
notes = "\n".join(f"- {t.name}" for t in self.tools.tools.values() if "__" in t.name)
|
|
106
|
+
return Runner(session, self.executor, self.tools, policy, human=human, grader=grader, autonomy=AutonomyPolicy(autonomy_path()),
|
|
107
|
+
config=rc, system_prompt=system_prompt(tools_note=notes, world_note=f"Working directory: {self.cwd} (shell: {self.executor.shell}, {sys.platform})"))
|
|
108
|
+
|
|
109
|
+
def _session(self, task: str, kind: str) -> Session:
|
|
110
|
+
meta = {"instruction": task, "task_class": self.task_class, "cwd": str(self.cwd), "model": self.cfg.model,
|
|
111
|
+
"byo": self.cfg.byo, "user": self.cfg.user, "client": f"hum-cli/{__version__}"}
|
|
112
|
+
return Session(meta, path=session_path(kind))
|
|
113
|
+
|
|
114
|
+
# -- unattended ----------------------------------------------------------------------------
|
|
115
|
+
async def one_shot(self, task: str) -> int:
|
|
116
|
+
names = await self.mount()
|
|
117
|
+
session = self._session(task, "run")
|
|
118
|
+
self.ui.banner(self.cfg.model, names, session.path.name, self.cfg.user)
|
|
119
|
+
r = self.runner(session, None)
|
|
120
|
+
self.ui.thinking(True)
|
|
121
|
+
try:
|
|
122
|
+
res = await r.run()
|
|
123
|
+
except Exception as e: # noqa: BLE001
|
|
124
|
+
self.ui.error(f"model error: {str(e).splitlines()[0][:300]}")
|
|
125
|
+
return 2
|
|
126
|
+
finally:
|
|
127
|
+
self.ui.thinking(False)
|
|
128
|
+
self.ui.end_text()
|
|
129
|
+
await self.close()
|
|
130
|
+
_sync_quietly(session.path)
|
|
131
|
+
self.ui.summary(res.turns, res.usage.cost_usd, res.verdict)
|
|
132
|
+
return 0 if (res.verdict is None or res.verdict.passed) else 1
|
|
133
|
+
|
|
134
|
+
# -- conversation ------------------------------------------------------------------------------
|
|
135
|
+
async def chat(self, first: str | None, resume: Session | None = None) -> int:
|
|
136
|
+
from prompt_toolkit import PromptSession
|
|
137
|
+
from prompt_toolkit.formatted_text import HTML
|
|
138
|
+
from prompt_toolkit.history import FileHistory
|
|
139
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
140
|
+
|
|
141
|
+
names = await self.mount()
|
|
142
|
+
ps: PromptSession = PromptSession(history=FileHistory(str(home() / "history")))
|
|
143
|
+
session = resume
|
|
144
|
+
pending: Turn | None = None
|
|
145
|
+
with patch_stdout(raw=True):
|
|
146
|
+
if session is None:
|
|
147
|
+
task = first
|
|
148
|
+
if not task:
|
|
149
|
+
try:
|
|
150
|
+
task = (await ps.prompt_async(HTML("<b>›</b> "))).strip()
|
|
151
|
+
except (EOFError, KeyboardInterrupt):
|
|
152
|
+
await self.close(); return 0
|
|
153
|
+
if not task:
|
|
154
|
+
await self.close(); return 0
|
|
155
|
+
session = self._session(task.strip(), "chat")
|
|
156
|
+
else:
|
|
157
|
+
pending = None
|
|
158
|
+
self.ui.banner(self.cfg.model, names, session.path.name, self.cfg.user)
|
|
159
|
+
human = HumanDriver()
|
|
160
|
+
runner = self.runner(session, human)
|
|
161
|
+
try:
|
|
162
|
+
while True:
|
|
163
|
+
head = session.head("main")
|
|
164
|
+
if head is not None and pending is None and (head.turn.author == "policy" or resume is not None):
|
|
165
|
+
# model is idle (resume, or after a finished turn): ask the person
|
|
166
|
+
resume = None
|
|
167
|
+
try:
|
|
168
|
+
line = await ps.prompt_async(HTML("<b>›</b> "))
|
|
169
|
+
except EOFError:
|
|
170
|
+
break
|
|
171
|
+
except KeyboardInterrupt:
|
|
172
|
+
self.ui.info("Ctrl-D or /quit to leave")
|
|
173
|
+
continue
|
|
174
|
+
cmd = self._slash(line, session, runner)
|
|
175
|
+
if cmd == "quit":
|
|
176
|
+
break
|
|
177
|
+
if cmd == "handled" or not line.strip():
|
|
178
|
+
continue
|
|
179
|
+
pending = self._as_turn(line)
|
|
180
|
+
run_task = asyncio.create_task(runner.run(continue_from=head.id if head else None, initial=pending))
|
|
181
|
+
pending = None
|
|
182
|
+
await self._attend(ps, run_task, human, runner)
|
|
183
|
+
except Exception as e: # noqa: BLE001
|
|
184
|
+
self.ui.error(f"model error: {str(e).splitlines()[0][:300]}")
|
|
185
|
+
return 2
|
|
186
|
+
finally:
|
|
187
|
+
await self.close()
|
|
188
|
+
_sync_quietly(session.path)
|
|
189
|
+
self.ui.info(f"session saved: {session.path.name}")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
async def _attend(self, ps: Any, run_task: asyncio.Task, human: HumanDriver, runner: Runner) -> None:
|
|
193
|
+
"""While the model works: the person can type (steering) or Ctrl-C (stop)."""
|
|
194
|
+
from prompt_toolkit.formatted_text import HTML
|
|
195
|
+
while not run_task.done():
|
|
196
|
+
line_task = asyncio.create_task(ps.prompt_async(HTML("<style fg='#888'> </style>")))
|
|
197
|
+
done, _ = await asyncio.wait({run_task, line_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
198
|
+
if line_task in done:
|
|
199
|
+
try:
|
|
200
|
+
line = line_task.result()
|
|
201
|
+
except KeyboardInterrupt:
|
|
202
|
+
run_task.cancel()
|
|
203
|
+
try:
|
|
204
|
+
await run_task
|
|
205
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
206
|
+
pass
|
|
207
|
+
self.ui.end_text()
|
|
208
|
+
runner.record_interrupt()
|
|
209
|
+
self.ui.info("stopped.")
|
|
210
|
+
return
|
|
211
|
+
except EOFError:
|
|
212
|
+
run_task.cancel()
|
|
213
|
+
raise
|
|
214
|
+
if line.strip():
|
|
215
|
+
if self._slash(line, runner.session, runner) == "quit":
|
|
216
|
+
run_task.cancel(); raise EOFError
|
|
217
|
+
human.send(HumanAction(turn=self._as_turn(line)))
|
|
218
|
+
else:
|
|
219
|
+
line_task.cancel()
|
|
220
|
+
try:
|
|
221
|
+
await line_task
|
|
222
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
223
|
+
pass
|
|
224
|
+
try:
|
|
225
|
+
res = await run_task
|
|
226
|
+
except asyncio.CancelledError:
|
|
227
|
+
return
|
|
228
|
+
self.ui.end_text()
|
|
229
|
+
if res.stop_reason != "done":
|
|
230
|
+
self.ui.info(f"— {res.stop_reason}")
|
|
231
|
+
|
|
232
|
+
def _slash(self, line: str, session: Session, runner: Runner) -> str | None:
|
|
233
|
+
s = line.strip()
|
|
234
|
+
if not s.startswith("/"):
|
|
235
|
+
return None
|
|
236
|
+
cmd, _, arg = s[1:].partition(" ")
|
|
237
|
+
if cmd in ("quit", "exit", "q"):
|
|
238
|
+
return "quit"
|
|
239
|
+
if cmd == "help":
|
|
240
|
+
self.ui.info(HELP)
|
|
241
|
+
elif cmd == "model":
|
|
242
|
+
if arg:
|
|
243
|
+
self.cfg.model = arg.strip(); self.llm = make_llm(self.cfg); runner.policy.llm = self.llm
|
|
244
|
+
self.ui.info(f"model → {self.cfg.model}" + (" (your own key)" if self.cfg.byo else ""))
|
|
245
|
+
else:
|
|
246
|
+
self.ui.info(f"model: {self.cfg.model}" + (f" via {self.cfg.api_base}" if self.cfg.api_base else ""))
|
|
247
|
+
elif cmd == "tools":
|
|
248
|
+
self.ui.info(", ".join(sorted(self.tools.tools)))
|
|
249
|
+
elif cmd == "cost":
|
|
250
|
+
u = runner.usage
|
|
251
|
+
self.ui.info(f"{u.input_tokens}+{u.output_tokens} tokens · ${u.cost_usd:.4f} · {runner.turns} turns")
|
|
252
|
+
else:
|
|
253
|
+
self.ui.warn(f"unknown /{cmd} — /help")
|
|
254
|
+
return "handled"
|
|
255
|
+
|
|
256
|
+
def _as_turn(self, line: str) -> Turn:
|
|
257
|
+
s = line.strip()
|
|
258
|
+
if s.startswith("!"):
|
|
259
|
+
return Turn(author="human", tool_calls=[ToolCall(id=f"h{os.urandom(3).hex()}", name=self.executor.shell, arguments={"command": s[1:].strip()})])
|
|
260
|
+
return Turn(author="human", content=s)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _sync_quietly(path: Path) -> None:
|
|
264
|
+
try:
|
|
265
|
+
from hum import sync
|
|
266
|
+
sync.upload(path, timeout=5.0)
|
|
267
|
+
except Exception: # noqa: BLE001
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# --- commands -------------------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def cmd_sessions(ui: UI) -> None:
|
|
274
|
+
for p in list_sessions():
|
|
275
|
+
try:
|
|
276
|
+
s = Session.load(p)
|
|
277
|
+
hv = sum(1 for n in s.nodes.values() if n.intervention)
|
|
278
|
+
ui.console.print(f"[dim]{p.stem}[/] {len(s.order):>3} turns {hv:>2} yours {s.task.get('instruction', '')[:70]}")
|
|
279
|
+
except Exception as e: # noqa: BLE001
|
|
280
|
+
ui.console.print(f"[dim]{p.name}[/] (unreadable: {e})")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _find(name: str) -> Path:
|
|
284
|
+
ms = [p for p in list_sessions() if name in p.name] if name and name != "last" else list_sessions()
|
|
285
|
+
if not ms:
|
|
286
|
+
sys.exit(f"no session matching {name!r}")
|
|
287
|
+
return ms[-1]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def cmd_show(ui: UI, name: str) -> None:
|
|
291
|
+
s = Session.load(_find(name))
|
|
292
|
+
ui.console.print(f"[bold]{s.task.get('instruction')}[/] [dim]{s.task.get('model')} · {s.task.get('cwd')}[/]")
|
|
293
|
+
for nid in s.order:
|
|
294
|
+
n = s.nodes[nid]
|
|
295
|
+
iv = f" [yellow]{n.intervention.kind}[/]" if n.intervention else ""
|
|
296
|
+
vd = "" if not n.verdict else (" [green]✓[/]" if n.verdict.passed else " [red]✗[/]")
|
|
297
|
+
calls = ", ".join(c.name for c in n.turn.tool_calls) or "—"
|
|
298
|
+
ui.console.print(f"[dim]{n.branch:>8}[/] {n.turn.author:<6}{iv}{vd} {calls} [dim]{(n.turn.content or '')[:70]!r}[/]")
|
|
299
|
+
for p in s.pairs:
|
|
300
|
+
ui.console.print(f"[bold]pair[/] preferred={p.preferred}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def cmd_login(ui: UI, auth_url: str | None) -> int:
|
|
304
|
+
from hum import auth
|
|
305
|
+
try:
|
|
306
|
+
c = auth.login(auth_url)
|
|
307
|
+
except Exception as e: # noqa: BLE001
|
|
308
|
+
ui.error(f"login failed: {e}")
|
|
309
|
+
return 1
|
|
310
|
+
ui.info(f"signed in as {c.email}" + (f" · model {c.default_model} via {c.api_base}" if c.api_base else ""))
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def cmd_whoami(ui: UI) -> None:
|
|
315
|
+
from hum import auth
|
|
316
|
+
c = auth.load()
|
|
317
|
+
cfg = load_config()
|
|
318
|
+
ui.info(f"{c.email or 'not signed in'} · model {cfg.model}" + (f" via {cfg.api_base}" if cfg.api_base else (" (your own key)" if cfg.byo else " (no key)")))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_model(ui: UI, name: str | None) -> None:
|
|
322
|
+
from hum import auth
|
|
323
|
+
c = auth.load()
|
|
324
|
+
if name:
|
|
325
|
+
c.model = None if name in ("default", "-") else name
|
|
326
|
+
auth.save(c)
|
|
327
|
+
cfg = load_config()
|
|
328
|
+
ui.info(f"model: {cfg.model}" + (" (your own key)" if cfg.byo else (f" via {cfg.api_base}" if cfg.api_base else " (no key — `hum login` or `hum key <provider> <key>`)")))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def cmd_key(ui: UI, provider: str, key: str) -> None:
|
|
332
|
+
from hum import auth
|
|
333
|
+
c = auth.load(); c.byo[provider] = key; auth.save(c)
|
|
334
|
+
ui.info(f"stored your {provider} key (sessions on it are marked as your own model)")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def main(argv: list[str] | None = None) -> int:
|
|
338
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
339
|
+
subs = ("chat", "run", "resume", "login", "logout", "whoami", "model", "key", "sessions", "show", "sync")
|
|
340
|
+
if not argv or (argv[0] not in subs and argv[0] not in ("-h", "--help", "--version")):
|
|
341
|
+
argv = ["chat", *argv]
|
|
342
|
+
ap = argparse.ArgumentParser(prog="hum", description="Hum — हम. Human and model, one harness.")
|
|
343
|
+
ap.add_argument("--version", action="version", version=f"hum {__version__}")
|
|
344
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
345
|
+
cp = sub.add_parser("chat"); cp.add_argument("task", nargs="?"); cp.add_argument("-C", "--cwd", default=".")
|
|
346
|
+
rp = sub.add_parser("run"); rp.add_argument("task"); rp.add_argument("-C", "--cwd", default=".")
|
|
347
|
+
rs = sub.add_parser("resume"); rs.add_argument("name", nargs="?", default="last")
|
|
348
|
+
lg = sub.add_parser("login"); lg.add_argument("--auth-url")
|
|
349
|
+
sub.add_parser("logout"); sub.add_parser("whoami"); sub.add_parser("sessions"); sub.add_parser("sync")
|
|
350
|
+
mp = sub.add_parser("model"); mp.add_argument("name", nargs="?")
|
|
351
|
+
kp = sub.add_parser("key"); kp.add_argument("provider"); kp.add_argument("key")
|
|
352
|
+
sp = sub.add_parser("show"); sp.add_argument("name")
|
|
353
|
+
a = ap.parse_args(argv)
|
|
354
|
+
ui = UI()
|
|
355
|
+
if a.cmd == "sessions":
|
|
356
|
+
cmd_sessions(ui); return 0
|
|
357
|
+
if a.cmd == "show":
|
|
358
|
+
cmd_show(ui, a.name); return 0
|
|
359
|
+
if a.cmd == "login":
|
|
360
|
+
return cmd_login(ui, a.auth_url)
|
|
361
|
+
if a.cmd == "logout":
|
|
362
|
+
from hum import auth; auth.logout(); ui.info("signed out"); return 0
|
|
363
|
+
if a.cmd == "whoami":
|
|
364
|
+
cmd_whoami(ui); return 0
|
|
365
|
+
if a.cmd == "model":
|
|
366
|
+
cmd_model(ui, a.name); return 0
|
|
367
|
+
if a.cmd == "key":
|
|
368
|
+
cmd_key(ui, a.provider, a.key); return 0
|
|
369
|
+
if a.cmd == "sync":
|
|
370
|
+
from hum import sync
|
|
371
|
+
sent, skipped = sync.sync_all(); ui.info(f"sent {sent}, already there {skipped}"); return 0
|
|
372
|
+
cfg = load_config()
|
|
373
|
+
from hum.auth import PROVIDER_ENV, provider_of
|
|
374
|
+
if cfg.api_key is None and cfg.api_base is None and "/" in cfg.model and provider_of(cfg.model) in PROVIDER_ENV:
|
|
375
|
+
ui.error("no model access — `hum login`, or `hum key openrouter <key>`, or ~/.hum/config.toml"); return 2
|
|
376
|
+
try:
|
|
377
|
+
if a.cmd == "run":
|
|
378
|
+
return asyncio.run(Client(cfg, Path(a.cwd).resolve(), ui).one_shot(a.task))
|
|
379
|
+
if a.cmd == "resume":
|
|
380
|
+
s = Session.load(_find(a.name))
|
|
381
|
+
return asyncio.run(Client(cfg, Path(s.task.get("cwd", ".")).resolve(), ui).chat(None, resume=s))
|
|
382
|
+
return asyncio.run(Client(cfg, Path(a.cwd).resolve(), ui).chat(a.task))
|
|
383
|
+
except KeyboardInterrupt:
|
|
384
|
+
print(); return 130
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
if __name__ == "__main__":
|
|
388
|
+
sys.exit(main())
|
hum/runtime/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .models import (Author, Intervention, InterventionKind, Node, PreferencePair,
|
|
2
|
+
ToolCall, ToolResult, Turn, Usage, Verdict)
|
|
3
|
+
from .session import Session
|
|
4
|
+
from .executor import ExecResult, Executor, LocalExecutor
|
|
5
|
+
from .tools import Tool, ToolRegistry, builtin_tools
|
|
6
|
+
from .llm import LLM, LiteLLMClient, OpenAICompatClient, ScriptedLLM
|
|
7
|
+
from .drivers import HumanAction, HumanDriver, PolicyDriver
|
|
8
|
+
from .grader import Grader
|
|
9
|
+
from .autonomy import AutonomyPolicy, Level
|
|
10
|
+
from .loop import RunConfig, Runner, RunResult
|