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 ADDED
@@ -0,0 +1,4 @@
1
+ """Hum — an agent harness built as an RL environment that humans can drive.
2
+
3
+ हम (Hindi): we, us. Human and model, one harness."""
4
+ __version__ = "0.0.1"
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())
@@ -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