hum-cli 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ name: publish
2
+ # Trusted publishing to PyPI (no token): tag vX.Y.Z → build → publish.
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ jobs:
7
+ build:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: astral-sh/setup-uv@v5
12
+ - run: uv build
13
+ - uses: actions/upload-artifact@v4
14
+ with: { name: dist, path: dist/ }
15
+ publish:
16
+ needs: build
17
+ runs-on: ubuntu-latest
18
+ environment: pypi
19
+ permissions:
20
+ id-token: write
21
+ steps:
22
+ - uses: actions/download-artifact@v4
23
+ with: { name: dist, path: dist/ }
24
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,16 @@
1
+ name: test
2
+ on:
3
+ push: { branches: [main] }
4
+ pull_request:
5
+ jobs:
6
+ test:
7
+ strategy:
8
+ matrix: { os: [ubuntu-latest, macos-latest, windows-latest] }
9
+ runs-on: ${{ matrix.os }}
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v5
13
+ - run: uv python install 3.12
14
+ - run: uv venv --python 3.12 && uv pip install -e ".[dev,server,harbor]"
15
+ - run: uv run --no-project pytest -q
16
+ shell: bash
hum_cli-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.5
2
+ Name: hum-cli
3
+ Version: 0.0.1
4
+ Summary: An agent harness built as an RL environment that humans can drive: session trees, interchangeable human/policy drivers, counterfactual shadow, earned autonomy, verifier in the loop.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: litellm>=1.70
8
+ Requires-Dist: mcp>=1.2
9
+ Requires-Dist: prompt-toolkit>=3.0
10
+ Requires-Dist: pydantic>=2
11
+ Requires-Dist: rich>=13
12
+ Provides-Extra: dev
13
+ Requires-Dist: fastapi>=0.110; extra == 'dev'
14
+ Requires-Dist: google-auth>=2; extra == 'dev'
15
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Requires-Dist: python-multipart; extra == 'dev'
18
+ Provides-Extra: harbor
19
+ Requires-Dist: harbor>=0.21; extra == 'harbor'
20
+ Provides-Extra: server
21
+ Requires-Dist: fastapi>=0.110; extra == 'server'
22
+ Requires-Dist: google-auth>=2; extra == 'server'
23
+ Requires-Dist: python-multipart; extra == 'server'
24
+ Requires-Dist: uvicorn[standard]; extra == 'server'
@@ -0,0 +1,50 @@
1
+ # Hum — हम
2
+
3
+ *We.* Human and model, one harness.
4
+
5
+ Hum is an agent harness built as an RL environment that humans can drive. The same loop serves a person at a keyboard, a policy in a rollout, or a shadow continuing from a fork — and the loop remembers who did what.
6
+
7
+ What makes it different from a coding agent:
8
+
9
+ - **Interchangeable drivers.** A human turn is a first-class move in the session tree, not a prompt.
10
+ - **Counterfactual shadow.** When a human overrides a proposal, the pre-intervention state is forked, the policy finishes in shadow, and the grader scores both futures. Every override becomes a preference pair whose preference is a verifier, not a rater.
11
+ - **Earned autonomy.** How much the policy may do unsupervised on a class of tasks is a number the world assigns from graded outcomes, updated online — `observe → propose → review → act`.
12
+ - **Verifier in the loop.** The grader is both a tool the agent calls and the reward the trainer reads.
13
+ - **Trajectory-native.** Every session is a valid ATIF trajectory with authorship, interventions, shadow branches, verdicts and token custody — a training sample as it is written.
14
+ - **Model-agnostic, sovereign.** Any OpenAI-compatible endpoint: frontier, open weights, or your own policy served from the trainer. Runs on Modal or on your iron.
15
+
16
+ ## Run it
17
+
18
+ ```bash
19
+ uv tool install git+https://github.com/metaphi-labs/hum-cli
20
+ hum login # browser opens → sign in with Google → back to the terminal
21
+ cd your-repo
22
+ hum # say what we're doing; it works, streams, shows its tool calls
23
+ hum "make the nightly job idempotent"
24
+ hum resume # pick up the last session
25
+ ```
26
+
27
+ While it works, just type: a message steers it, `!cmd` runs a command yourself, Ctrl-C stops the current turn. Edit files in your editor whenever you like. `/help` in a session lists the rest (`/model`, `/tools`, `/cost`).
28
+
29
+ Signing in gives you a key on Metaphi's model proxy — GLM-5.3 by default. Bringing your own model is allowed (`hum model anthropic/claude-fable-5`, `hum key anthropic <key>`); those sessions are marked as running on your model.
30
+
31
+ Other commands: `hum run "task"` (unattended, then exit — the mode rollouts use), `hum sessions`, `hum show <id>`, `hum sync`, `hum whoami`, `hum logout`.
32
+
33
+ ## Deployment (Metaphi)
34
+
35
+ One Modal app, `server/modal_app.py`: sign-in (Firebase Auth in the browser, ID token verified server-side; allowlist), the **model proxy** every client talks to (OpenAI-compatible `/v1/chat/completions`; the person's Hum token is their key; every call metered per person with a monthly budget), and session intake. State is SQLite + session files on the volume `hum-data`. Routes are named — `glm-5.3` on zero-data-retention providers, `policy` = our weights from the trainer, `fable` — so moving the fleet onto our model is one route change, no client update.
36
+
37
+ `~/.hum/config.toml` on a machine can override the model, mount MCP servers (`control`, `run-unit`) as native tools, and turn on backend behaviour — grader, autonomy-gated sessions, fork-and-grade on intervention. Defaults are "be a great agent".
38
+
39
+ ## As a Harbor agent
40
+
41
+ `harbor run -a hum.adapters.harbor:HumAgent -m openrouter/z-ai/glm-5.3 ...` — Terminal-Bench, MainframeBench through simhub `task_run --harness hum`, and Slime rollouts run the same class, with no human driver.
42
+
43
+ ## Layout
44
+
45
+ ```
46
+ hum/runtime/ session tree · drivers · loop (shadow) · autonomy · tools (+MCP) · grader · llm · observe · trajectory (ATIF)
47
+ hum/adapters/ harbor
48
+ hum/cli.py terminal client
49
+ deploy/ litellm proxy config
50
+ ```
@@ -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
@@ -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}
@@ -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)