hum-cli 0.0.1__tar.gz → 0.1.0__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.
- hum_cli-0.1.0/.github/workflows/publish.yml +41 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/.github/workflows/test.yml +4 -0
- hum_cli-0.1.0/.gitignore +8 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/PKG-INFO +1 -1
- hum_cli-0.1.0/README.md +57 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/__init__.py +1 -1
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/adapters/harbor.py +4 -1
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/auth.py +27 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/cli.py +206 -36
- hum_cli-0.1.0/hum/engine.py +322 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/__init__.py +3 -1
- hum_cli-0.1.0/hum/runtime/agents.py +103 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/config.py +20 -2
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/drivers.py +25 -4
- hum_cli-0.1.0/hum/runtime/llm.py +343 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/loop.py +128 -34
- hum_cli-0.1.0/hum/runtime/prompt.py +70 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/session.py +30 -4
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/tools.py +25 -1
- hum_cli-0.1.0/hum/runtime/workspace.py +128 -0
- hum_cli-0.1.0/hum/ui.py +210 -0
- hum_cli-0.1.0/npm/bin/hum.js +8 -0
- hum_cli-0.1.0/npm/package.json +12 -0
- hum_cli-0.1.0/npm/scripts/postinstall.js +23 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/pyproject.toml +3 -1
- {hum_cli-0.0.1 → hum_cli-0.1.0}/server/app.py +114 -9
- {hum_cli-0.0.1 → hum_cli-0.1.0}/server/modal_app.py +3 -1
- {hum_cli-0.0.1 → hum_cli-0.1.0}/tests/test_runtime.py +1 -1
- {hum_cli-0.0.1 → hum_cli-0.1.0}/tests/test_server_and_auth.py +18 -1
- hum_cli-0.1.0/tests/test_sme_ready.py +391 -0
- hum_cli-0.1.0/tui/package-lock.json +1562 -0
- hum_cli-0.1.0/tui/package.json +24 -0
- hum_cli-0.1.0/tui/src/app.jsx +369 -0
- hum_cli-0.1.0/tui/src/cli.jsx +49 -0
- hum_cli-0.1.0/tui/src/devtools-stub.js +3 -0
- hum_cli-0.1.0/tui/src/engine.js +80 -0
- hum_cli-0.1.0/tui/src/input.jsx +116 -0
- hum_cli-0.1.0/tui/src/markdown.js +224 -0
- hum_cli-0.0.1/.github/workflows/publish.yml +0 -24
- hum_cli-0.0.1/README.md +0 -50
- hum_cli-0.0.1/hum/runtime/llm.py +0 -225
- hum_cli-0.0.1/hum/runtime/prompt.py +0 -9
- hum_cli-0.0.1/hum/runtime/workspace.py +0 -48
- hum_cli-0.0.1/hum/ui.py +0 -109
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/adapters/__init__.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/autonomy.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/executor.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/grader.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/models.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/observe.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/store.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/trajectory.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/sync.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/server/__init__.py +0 -0
- {hum_cli-0.0.1 → hum_cli-0.1.0}/tests/test_cli.py +0 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
# Tag vX.Y.Z → build the terminal UI bundle, build the Python package (bundle inside),
|
|
3
|
+
# publish to PyPI (trusted publishing, no token) and to npm (@metaphi/hum, NPM_TOKEN secret).
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-node@v4
|
|
13
|
+
with: { node-version: 20 }
|
|
14
|
+
- run: cd tui && npm ci && npm run build
|
|
15
|
+
- uses: astral-sh/setup-uv@v5
|
|
16
|
+
- run: uv build
|
|
17
|
+
- uses: actions/upload-artifact@v4
|
|
18
|
+
with: { name: dist, path: dist/ }
|
|
19
|
+
- uses: actions/upload-artifact@v4
|
|
20
|
+
with: { name: npm, path: npm/ }
|
|
21
|
+
publish-pypi:
|
|
22
|
+
needs: build
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
environment: pypi
|
|
25
|
+
permissions:
|
|
26
|
+
id-token: write
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/download-artifact@v4
|
|
29
|
+
with: { name: dist, path: dist/ }
|
|
30
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
31
|
+
publish-npm:
|
|
32
|
+
needs: build
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
continue-on-error: true # until the npm org + NPM_TOKEN exist, PyPI still ships
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/download-artifact@v4
|
|
37
|
+
with: { name: npm, path: npm/ }
|
|
38
|
+
- uses: actions/setup-node@v4
|
|
39
|
+
with: { node-version: 20, registry-url: "https://registry.npmjs.org" }
|
|
40
|
+
- run: cd npm && npm publish --access public
|
|
41
|
+
env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" }
|
|
@@ -9,6 +9,10 @@ jobs:
|
|
|
9
9
|
runs-on: ${{ matrix.os }}
|
|
10
10
|
steps:
|
|
11
11
|
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-node@v4
|
|
13
|
+
with: { node-version: 20 }
|
|
14
|
+
- run: cd tui && npm ci && npm run build
|
|
15
|
+
shell: bash
|
|
12
16
|
- uses: astral-sh/setup-uv@v5
|
|
13
17
|
- run: uv python install 3.12
|
|
14
18
|
- run: uv venv --python 3.12 && uv pip install -e ".[dev,server,harbor]"
|
hum_cli-0.1.0/.gitignore
ADDED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hum-cli
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
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
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: httpx>=0.27
|
hum_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
npm install -g @metaphi/hum # or: uv tool install hum-cli (either one installs both halves)
|
|
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
|
+
Two halves, one command: the terminal UI is Ink (Node ≥ 18) and the loop is Python — the npm package installs the engine through uv on first install; the Python package ships the UI bundle and uses it when Node is present (`hum --plain` is the Rich fallback; `HUM_PLAIN=1` too).
|
|
28
|
+
|
|
29
|
+
While it works you see its thinking (dim), its words, and each tool call as it is being written — `writing src/PAYPOST.cbl…` appears the moment the path is known. Just type: a message steers it, `!cmd` runs a command yourself, Ctrl-C stops the current turn (what it had produced so far is kept). Edit files in your editor whenever you like — the session records the exact patch as your turn. Long sessions stay affordable: old tool output fades from the prompt and the context is summarised and restarted when it gets large (`/cost` shows how often). `/help` in a session lists the rest (`/model`, `/tools`, `/cost`).
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
Other commands: `hum run "task"` (unattended, then exit — the mode rollouts use), `hum sessions`, `hum show <id>`, `hum sync`, `hum whoami`, `hum logout`.
|
|
34
|
+
|
|
35
|
+
## Deployment (Metaphi)
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
Tool servers (`control`, `run-unit`, …) are mounted for everyone from the server: `HUM_MCP_JSON` names the upstream MCP endpoints and their credentials; the client learns the list at login and reaches each one at `/mcp/<name>` with the person's own Hum token, so no upstream credential ever leaves the server. `~/.hum/config.toml` on a machine can additionally override the model, mount local MCP servers, and turn on backend behaviour — grader, autonomy-gated sessions, fork-and-grade on intervention, compaction threshold. Defaults are "be a great agent".
|
|
40
|
+
|
|
41
|
+
## What a session records
|
|
42
|
+
|
|
43
|
+
One JSONL per session, a tree of nodes. Every node: who acted (policy / human / shadow / system), what was said and called, the tool results, and the workspace **tree id** after it executed — Hum keeps a private git directory per workspace (`~/.hum/shadow/…`, never the project's `.git`) and snapshots into it, so any state in the session can be diffed against any other. A hand edit is the tree moving when no tool moved it: recorded as the person's turn with the **full patch**. An interrupted generation is kept on an `aborted` branch next to what the person did instead. A compaction is a `system` node whose summary restarts the context; nothing before it leaves the tree. The file is uploaded while the session runs (every 20 s) and at the end.
|
|
44
|
+
|
|
45
|
+
## As a Harbor agent
|
|
46
|
+
|
|
47
|
+
`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.
|
|
48
|
+
|
|
49
|
+
## Layout
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
hum/runtime/ session tree · drivers · loop (shadow, compaction) · workspace (tree ids, patches) · autonomy · tools (+MCP) · grader · llm (streams) · observe · trajectory (ATIF)
|
|
53
|
+
hum/adapters/ harbor
|
|
54
|
+
hum/cli.py commands, the Rich client, the TUI launcher · hum/engine.py the loop as a line protocol for a front-end
|
|
55
|
+
tui/ the Ink terminal UI (builds to hum/tui/cli.mjs and npm/dist) · npm/ the npm package (@metaphi/hum)
|
|
56
|
+
server/ sign-in · model proxy · MCP proxy · session intake (one Modal app)
|
|
57
|
+
```
|
|
@@ -28,6 +28,7 @@ from hum.runtime.executor import ExecResult, decode
|
|
|
28
28
|
from hum.runtime.prompt import system_prompt
|
|
29
29
|
from hum.runtime.tools import MCPToolset
|
|
30
30
|
from hum.runtime.trajectory import to_atif
|
|
31
|
+
from hum.runtime.workspace import Workspace
|
|
31
32
|
|
|
32
33
|
AGENT_NAME = "hum"
|
|
33
34
|
|
|
@@ -111,7 +112,9 @@ class HumAgent(BaseAgent):
|
|
|
111
112
|
temperature=self._temperature, reasoning_effort=self._reasoning_effort)
|
|
112
113
|
prompt = system_prompt(tools_note="\n".join(notes))
|
|
113
114
|
session = Session({"instruction": instruction}, session_id=self.session_id, path=self.logs_dir / "session.jsonl")
|
|
114
|
-
|
|
115
|
+
# every node carries the sandbox tree id; the shadow git dir lives in the sandbox, outside /app
|
|
116
|
+
runner = Runner(session, executor, tools, PolicyDriver(llm), config=self._cfg, system_prompt=prompt,
|
|
117
|
+
workspace=Workspace(executor, "/tmp/hum-shadow"))
|
|
115
118
|
try:
|
|
116
119
|
result = await runner.run()
|
|
117
120
|
finally:
|
|
@@ -30,6 +30,7 @@ class Credentials:
|
|
|
30
30
|
default_model: str | None = None
|
|
31
31
|
byo: dict[str, str] = field(default_factory=dict) # provider -> key, user-supplied
|
|
32
32
|
model: str | None = None # user's chosen model, if any
|
|
33
|
+
mcp: list[dict[str, Any]] = field(default_factory=list) # tool servers the deployment mounts for this person
|
|
33
34
|
|
|
34
35
|
@property
|
|
35
36
|
def logged_in(self) -> bool:
|
|
@@ -94,6 +95,7 @@ def login(auth_url: str | None = None, open_browser: bool = True, poll_timeout_s
|
|
|
94
95
|
c.name = body["user"].get("name")
|
|
95
96
|
m = body.get("model") or {}
|
|
96
97
|
c.api_base, c.api_key, c.default_model = m.get("api_base"), m.get("api_key"), m.get("default_model")
|
|
98
|
+
c.mcp = list(body.get("mcp") or [])
|
|
97
99
|
save(c)
|
|
98
100
|
return c
|
|
99
101
|
err = body.get("error")
|
|
@@ -106,7 +108,32 @@ def login(auth_url: str | None = None, open_browser: bool = True, poll_timeout_s
|
|
|
106
108
|
raise TimeoutError("login timed out; run `hum login` again")
|
|
107
109
|
|
|
108
110
|
|
|
111
|
+
def refresh(c: Credentials | None = None, timeout: float = 3.0, client: Any = None) -> Credentials:
|
|
112
|
+
"""Best-effort: ask the server what this person has today (default model, tool
|
|
113
|
+
servers, budget). Never raises; on any failure the saved credentials stand."""
|
|
114
|
+
import httpx
|
|
115
|
+
|
|
116
|
+
c = c or load()
|
|
117
|
+
if not c.logged_in:
|
|
118
|
+
return c
|
|
119
|
+
try:
|
|
120
|
+
http = client or httpx.Client(timeout=timeout)
|
|
121
|
+
r = http.get(f"{c.auth_url}/me", headers={"Authorization": f"Bearer {c.access_token}"})
|
|
122
|
+
if r.status_code != 200:
|
|
123
|
+
return c
|
|
124
|
+
d = r.json()
|
|
125
|
+
m = d.get("model") or {}
|
|
126
|
+
c.default_model = m.get("default_model") or c.default_model
|
|
127
|
+
c.api_base = m.get("api_base") or c.api_base
|
|
128
|
+
c.mcp = list(d.get("mcp") or [])
|
|
129
|
+
save(c)
|
|
130
|
+
except Exception: # noqa: BLE001
|
|
131
|
+
pass
|
|
132
|
+
return c
|
|
133
|
+
|
|
134
|
+
|
|
109
135
|
def logout() -> None:
|
|
110
136
|
c = load()
|
|
111
137
|
c.access_token = c.email = c.name = c.api_base = c.api_key = c.default_model = None
|
|
138
|
+
c.mcp = []
|
|
112
139
|
save(c)
|
|
@@ -23,11 +23,12 @@ from pathlib import Path
|
|
|
23
23
|
from typing import Any
|
|
24
24
|
|
|
25
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
|
|
26
|
+
from hum.runtime import (AutonomyPolicy, Delegation, HumanAction, HumanDriver, LiteLLMClient, LocalExecutor, OpenAICompatClient, PolicyDriver,
|
|
27
|
+
RunConfig, Runner, Session, StreamEvents, ToolCall, ToolRegistry, Turn, Workspace, builtin_tools, shadow_dir_for)
|
|
28
|
+
from hum.runtime.config import Config, MCPConfig, load as load_config
|
|
29
29
|
from hum.runtime.grader import PredicateGrader
|
|
30
|
-
from hum.runtime.
|
|
30
|
+
from hum.runtime.llm import error_text
|
|
31
|
+
from hum.runtime.prompt import project_instructions, system_prompt
|
|
31
32
|
from hum.runtime.store import autonomy_path, home, list_sessions, session_path
|
|
32
33
|
from hum.runtime.tools import MCPToolset
|
|
33
34
|
from hum.ui import UI
|
|
@@ -40,11 +41,14 @@ HELP = """/help this
|
|
|
40
41
|
!cmd run a command yourself
|
|
41
42
|
Ctrl-C stop the current turn"""
|
|
42
43
|
|
|
44
|
+
UPLOAD_EVERY_S = 20.0
|
|
45
|
+
|
|
43
46
|
|
|
44
47
|
def make_llm(cfg: Config):
|
|
45
48
|
"""Our proxy (or any OpenAI-compatible base): talk to it directly. BYO providers: litellm."""
|
|
46
49
|
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
|
|
50
|
+
return OpenAICompatClient(cfg.model, api_base=cfg.api_base, api_key=cfg.api_key, temperature=cfg.temperature, reasoning_effort=cfg.reasoning_effort,
|
|
51
|
+
idle_timeout=cfg.idle_timeout_s)
|
|
48
52
|
return LiteLLMClient(cfg.model, api_key=cfg.api_key, temperature=cfg.temperature, reasoning_effort=cfg.reasoning_effort)
|
|
49
53
|
|
|
50
54
|
|
|
@@ -69,20 +73,77 @@ class VisibleTools(ToolRegistry):
|
|
|
69
73
|
return results
|
|
70
74
|
|
|
71
75
|
|
|
76
|
+
class VisiblePolicy(PolicyDriver):
|
|
77
|
+
"""The policy driver with the person watching: thinking, text and tool calls stream as they happen."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, llm: Any, ui: UI):
|
|
80
|
+
super().__init__(llm, events=StreamEvents(text=ui.text, reasoning=ui.reasoning, tool=ui.tool_delta))
|
|
81
|
+
self.ui = ui
|
|
82
|
+
self.on_retry = lambda attempt, e: ui.warn(f"model connection: {error_text(e)} — retrying ({attempt})")
|
|
83
|
+
|
|
84
|
+
async def next(self, messages, tools): # type: ignore[override]
|
|
85
|
+
self.ui.turn_started()
|
|
86
|
+
t = await super().next(messages, tools)
|
|
87
|
+
self.ui.end_text()
|
|
88
|
+
return t
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Uploader:
|
|
92
|
+
"""Sessions go home while they happen, not only at the end: every ``UPLOAD_EVERY_S``
|
|
93
|
+
the file is re-sent if it grew. A crashed terminal loses at most that window."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, path: Path):
|
|
96
|
+
self.path = path
|
|
97
|
+
self._sent = -1
|
|
98
|
+
self._task: asyncio.Task | None = None
|
|
99
|
+
|
|
100
|
+
def start(self) -> None:
|
|
101
|
+
self._task = asyncio.create_task(self._loop())
|
|
102
|
+
|
|
103
|
+
async def _loop(self) -> None:
|
|
104
|
+
while True:
|
|
105
|
+
await asyncio.sleep(UPLOAD_EVERY_S)
|
|
106
|
+
await self.push()
|
|
107
|
+
|
|
108
|
+
async def push(self) -> None:
|
|
109
|
+
try:
|
|
110
|
+
size = self.path.stat().st_size
|
|
111
|
+
except OSError:
|
|
112
|
+
return
|
|
113
|
+
if size == self._sent:
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
from hum import sync
|
|
117
|
+
if await asyncio.to_thread(sync.upload, self.path, None, None, 8.0):
|
|
118
|
+
self._sent = size
|
|
119
|
+
except Exception: # noqa: BLE001
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
async def stop(self) -> None:
|
|
123
|
+
if self._task:
|
|
124
|
+
self._task.cancel()
|
|
125
|
+
try:
|
|
126
|
+
await self._task
|
|
127
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
128
|
+
pass
|
|
129
|
+
await self.push()
|
|
130
|
+
|
|
131
|
+
|
|
72
132
|
class Client:
|
|
73
133
|
def __init__(self, cfg: Config, cwd: Path, ui: UI | None = None):
|
|
74
134
|
self.cfg, self.cwd = cfg, cwd
|
|
75
|
-
self.ui = ui or UI()
|
|
135
|
+
self.ui = ui or UI(show_reasoning=cfg.show_reasoning)
|
|
76
136
|
self.executor = LocalExecutor(cwd)
|
|
77
137
|
self.tools = VisibleTools(self.ui).extend(builtin_tools(self.executor.shell))
|
|
78
138
|
self.toolsets: list[MCPToolset] = []
|
|
79
139
|
self.task_class = cfg.task_class or cwd.name
|
|
80
140
|
self.llm = make_llm(cfg)
|
|
141
|
+
self.workspace = Workspace(self.executor, shadow_dir_for(cwd, home()))
|
|
81
142
|
|
|
82
143
|
# -- setup ---------------------------------------------------------------------------
|
|
83
144
|
async def mount(self) -> list[str]:
|
|
84
145
|
names = [self.executor.shell, "files", "grep"]
|
|
85
|
-
for m in self.cfg.mcp:
|
|
146
|
+
for m in [*self.cfg.mcp, *self.cfg.remote_mcp]:
|
|
86
147
|
ts = MCPToolset(m.name, url=m.url, command=m.command, args=m.args, headers=m.headers)
|
|
87
148
|
try:
|
|
88
149
|
mounted = await ts.connect()
|
|
@@ -98,13 +159,35 @@ class Client:
|
|
|
98
159
|
for ts in self.toolsets:
|
|
99
160
|
await ts.close()
|
|
100
161
|
|
|
162
|
+
def _delegation(self, session: Session) -> Delegation:
|
|
163
|
+
ui = self.ui
|
|
164
|
+
|
|
165
|
+
def events_for(n: int) -> StreamEvents:
|
|
166
|
+
return StreamEvents(text=ui.text, reasoning=ui.reasoning, tool=ui.tool_delta)
|
|
167
|
+
|
|
168
|
+
def wrap(reg: ToolRegistry) -> ToolRegistry:
|
|
169
|
+
v = VisibleTools(ui, observation_max_bytes=reg.observation_max_bytes)
|
|
170
|
+
for t in reg.tools.values():
|
|
171
|
+
v.add(t)
|
|
172
|
+
return v
|
|
173
|
+
|
|
174
|
+
return Delegation(session=session, executor=self.executor, tools=self.tools, llm_factory=lambda: make_llm(self.cfg),
|
|
175
|
+
events_for=events_for, tools_wrapper=wrap, on_start=ui.agent_start, on_end=ui.agent_end)
|
|
176
|
+
|
|
101
177
|
def runner(self, session: Session, human: HumanDriver | None) -> Runner:
|
|
102
|
-
policy =
|
|
178
|
+
policy = VisiblePolicy(self.llm, self.ui)
|
|
103
179
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
180
|
+
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,
|
|
181
|
+
compact_at_tokens=self.cfg.compact_at_tokens)
|
|
182
|
+
if "delegate" not in self.tools.tools:
|
|
183
|
+
self.delegation = self._delegation(session)
|
|
184
|
+
self.tools.add(self.delegation.tool())
|
|
185
|
+
notes = "\n".join(f"- {t.name}: {t.description[:120]}" for t in self.tools.tools.values() if "__" in t.name)
|
|
186
|
+
r = Runner(session, self.executor, self.tools, policy, human=human, grader=grader, autonomy=AutonomyPolicy(autonomy_path()),
|
|
187
|
+
config=rc, workspace=self.workspace,
|
|
188
|
+
system_prompt=system_prompt(tools_note=notes, world_note=f"Working directory: {self.cwd} (shell: {self.executor.shell}, {sys.platform})",
|
|
189
|
+
instructions=project_instructions(self.cwd)))
|
|
190
|
+
return r
|
|
108
191
|
|
|
109
192
|
def _session(self, task: str, kind: str) -> Session:
|
|
110
193
|
meta = {"instruction": task, "task_class": self.task_class, "cwd": str(self.cwd), "model": self.cfg.model,
|
|
@@ -115,19 +198,20 @@ class Client:
|
|
|
115
198
|
async def one_shot(self, task: str) -> int:
|
|
116
199
|
names = await self.mount()
|
|
117
200
|
session = self._session(task, "run")
|
|
201
|
+
up = Uploader(session.path); up.start()
|
|
118
202
|
self.ui.banner(self.cfg.model, names, session.path.name, self.cfg.user)
|
|
119
203
|
r = self.runner(session, None)
|
|
120
204
|
self.ui.thinking(True)
|
|
121
205
|
try:
|
|
122
206
|
res = await r.run()
|
|
123
207
|
except Exception as e: # noqa: BLE001
|
|
124
|
-
self.ui.error(f"model error: {
|
|
208
|
+
self.ui.error(f"model error: {error_text(e)} — the session is saved; `hum resume` picks it up")
|
|
125
209
|
return 2
|
|
126
210
|
finally:
|
|
127
211
|
self.ui.thinking(False)
|
|
128
212
|
self.ui.end_text()
|
|
129
213
|
await self.close()
|
|
130
|
-
|
|
214
|
+
await up.stop()
|
|
131
215
|
self.ui.summary(res.turns, res.usage.cost_usd, res.verdict)
|
|
132
216
|
return 0 if (res.verdict is None or res.verdict.passed) else 1
|
|
133
217
|
|
|
@@ -139,9 +223,11 @@ class Client:
|
|
|
139
223
|
from prompt_toolkit.patch_stdout import patch_stdout
|
|
140
224
|
|
|
141
225
|
names = await self.mount()
|
|
142
|
-
ps: PromptSession = PromptSession(history=FileHistory(str(home() / "history"))
|
|
226
|
+
ps: PromptSession = PromptSession(history=FileHistory(str(home() / "history")),
|
|
227
|
+
bottom_toolbar=lambda: self.ui.toolbar() or None, refresh_interval=0.5)
|
|
143
228
|
session = resume
|
|
144
229
|
pending: Turn | None = None
|
|
230
|
+
up: Uploader | None = None
|
|
145
231
|
with patch_stdout(raw=True):
|
|
146
232
|
if session is None:
|
|
147
233
|
task = first
|
|
@@ -155,6 +241,7 @@ class Client:
|
|
|
155
241
|
session = self._session(task.strip(), "chat")
|
|
156
242
|
else:
|
|
157
243
|
pending = None
|
|
244
|
+
up = Uploader(session.path); up.start()
|
|
158
245
|
self.ui.banner(self.cfg.model, names, session.path.name, self.cfg.user)
|
|
159
246
|
human = HumanDriver()
|
|
160
247
|
runner = self.runner(session, human)
|
|
@@ -164,6 +251,7 @@ class Client:
|
|
|
164
251
|
if head is not None and pending is None and (head.turn.author == "policy" or resume is not None):
|
|
165
252
|
# model is idle (resume, or after a finished turn): ask the person
|
|
166
253
|
resume = None
|
|
254
|
+
self.ui.idle()
|
|
167
255
|
try:
|
|
168
256
|
line = await ps.prompt_async(HTML("<b>›</b> "))
|
|
169
257
|
except EOFError:
|
|
@@ -181,11 +269,12 @@ class Client:
|
|
|
181
269
|
pending = None
|
|
182
270
|
await self._attend(ps, run_task, human, runner)
|
|
183
271
|
except Exception as e: # noqa: BLE001
|
|
184
|
-
self.ui.error(f"model error: {
|
|
272
|
+
self.ui.error(f"model error: {error_text(e)} — the session is saved; `hum resume` picks it up")
|
|
185
273
|
return 2
|
|
186
274
|
finally:
|
|
275
|
+
self.ui.idle()
|
|
187
276
|
await self.close()
|
|
188
|
-
|
|
277
|
+
await up.stop()
|
|
189
278
|
self.ui.info(f"session saved: {session.path.name}")
|
|
190
279
|
return 0
|
|
191
280
|
|
|
@@ -199,14 +288,15 @@ class Client:
|
|
|
199
288
|
try:
|
|
200
289
|
line = line_task.result()
|
|
201
290
|
except KeyboardInterrupt:
|
|
291
|
+
partial = runner.policy.partial()
|
|
202
292
|
run_task.cancel()
|
|
203
293
|
try:
|
|
204
294
|
await run_task
|
|
205
295
|
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
206
296
|
pass
|
|
207
297
|
self.ui.end_text()
|
|
208
|
-
runner.record_interrupt()
|
|
209
|
-
self.ui.info("stopped.")
|
|
298
|
+
runner.record_interrupt(partial=partial)
|
|
299
|
+
self.ui.info("stopped." + (" (what it had so far is kept)" if partial else ""))
|
|
210
300
|
return
|
|
211
301
|
except EOFError:
|
|
212
302
|
run_task.cancel()
|
|
@@ -247,8 +337,9 @@ class Client:
|
|
|
247
337
|
elif cmd == "tools":
|
|
248
338
|
self.ui.info(", ".join(sorted(self.tools.tools)))
|
|
249
339
|
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"
|
|
340
|
+
u = runner.usage + getattr(getattr(self, "delegation", None), "usage", u.__class__())
|
|
341
|
+
self.ui.info(f"{u.input_tokens}+{u.output_tokens} tokens · ${u.cost_usd:.4f} · {runner.turns} turns" +
|
|
342
|
+
(f" · context compacted {runner.compactions}×" if runner.compactions else ""))
|
|
252
343
|
else:
|
|
253
344
|
self.ui.warn(f"unknown /{cmd} — /help")
|
|
254
345
|
return "handled"
|
|
@@ -260,14 +351,6 @@ class Client:
|
|
|
260
351
|
return Turn(author="human", content=s)
|
|
261
352
|
|
|
262
353
|
|
|
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
354
|
# --- commands -------------------------------------------------------------------------------------
|
|
272
355
|
|
|
273
356
|
def cmd_sessions(ui: UI) -> None:
|
|
@@ -294,8 +377,10 @@ def cmd_show(ui: UI, name: str) -> None:
|
|
|
294
377
|
n = s.nodes[nid]
|
|
295
378
|
iv = f" [yellow]{n.intervention.kind}[/]" if n.intervention else ""
|
|
296
379
|
vd = "" if not n.verdict else (" [green]✓[/]" if n.verdict.passed else " [red]✗[/]")
|
|
380
|
+
ev = f" [magenta]{n.extra['event']}[/]" if n.extra.get("event") in ("compaction", "aborted", "human_edit") else ""
|
|
297
381
|
calls = ", ".join(c.name for c in n.turn.tool_calls) or "—"
|
|
298
|
-
|
|
382
|
+
tree = f" [dim]{n.extra['tree'][:7]}[/]" if n.extra.get("tree") else ""
|
|
383
|
+
ui.console.print(f"[dim]{n.branch:>9}[/] {n.turn.author:<6}{iv}{vd}{ev} {calls}{tree} [dim]{(n.turn.content or '')[:70]!r}[/]")
|
|
299
384
|
for p in s.pairs:
|
|
300
385
|
ui.console.print(f"[bold]pair[/] preferred={p.preferred}")
|
|
301
386
|
|
|
@@ -307,7 +392,8 @@ def cmd_login(ui: UI, auth_url: str | None) -> int:
|
|
|
307
392
|
except Exception as e: # noqa: BLE001
|
|
308
393
|
ui.error(f"login failed: {e}")
|
|
309
394
|
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 "")
|
|
395
|
+
ui.info(f"signed in as {c.email}" + (f" · model {c.default_model} via {c.api_base}" if c.api_base else "") +
|
|
396
|
+
(f" · tools: {', '.join(m['name'] for m in c.mcp)}" if c.mcp else ""))
|
|
311
397
|
return 0
|
|
312
398
|
|
|
313
399
|
|
|
@@ -315,7 +401,8 @@ def cmd_whoami(ui: UI) -> None:
|
|
|
315
401
|
from hum import auth
|
|
316
402
|
c = auth.load()
|
|
317
403
|
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)"))
|
|
404
|
+
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)")) +
|
|
405
|
+
(f" · tools: {', '.join(m.name for m in [*cfg.mcp, *cfg.remote_mcp])}" if cfg.mcp or cfg.remote_mcp else ""))
|
|
319
406
|
|
|
320
407
|
|
|
321
408
|
def cmd_model(ui: UI, name: str | None) -> None:
|
|
@@ -334,22 +421,86 @@ def cmd_key(ui: UI, provider: str, key: str) -> None:
|
|
|
334
421
|
ui.info(f"stored your {provider} key (sessions on it are marked as your own model)")
|
|
335
422
|
|
|
336
423
|
|
|
424
|
+
TUI_BUNDLE = Path(__file__).parent / "tui" / "cli.mjs"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _node() -> str | None:
|
|
428
|
+
"""A Node ≥ 18 on PATH, or None (then the Rich client runs)."""
|
|
429
|
+
import shutil
|
|
430
|
+
import subprocess
|
|
431
|
+
exe = shutil.which("node")
|
|
432
|
+
if not exe:
|
|
433
|
+
return None
|
|
434
|
+
try:
|
|
435
|
+
v = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=5).stdout.strip().lstrip("v")
|
|
436
|
+
return exe if int(v.split(".")[0]) >= 18 else None
|
|
437
|
+
except Exception: # noqa: BLE001
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def launch_tui(cfg: Config, cwd: Path, task: str | None, resume: str | None) -> int:
|
|
442
|
+
"""Run the loop here; hand the terminal to the Ink front-end, which connects back over localhost."""
|
|
443
|
+
import subprocess
|
|
444
|
+
import threading
|
|
445
|
+
|
|
446
|
+
node = _node()
|
|
447
|
+
if node is None or not TUI_BUNDLE.exists() or os.environ.get("HUM_PLAIN"):
|
|
448
|
+
return -1
|
|
449
|
+
ready: dict[str, int] = {}
|
|
450
|
+
evt = threading.Event()
|
|
451
|
+
|
|
452
|
+
async def run() -> int:
|
|
453
|
+
from hum.engine import Engine, Wire
|
|
454
|
+
done: asyncio.Future[int] = asyncio.get_event_loop().create_future()
|
|
455
|
+
|
|
456
|
+
async def on_conn(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
457
|
+
try:
|
|
458
|
+
rc = await Engine(cfg, cwd, Wire(reader, writer)).serve()
|
|
459
|
+
finally:
|
|
460
|
+
if not done.done():
|
|
461
|
+
done.set_result(rc)
|
|
462
|
+
server = await asyncio.start_server(on_conn, "127.0.0.1", 0)
|
|
463
|
+
ready["port"] = server.sockets[0].getsockname()[1]
|
|
464
|
+
evt.set()
|
|
465
|
+
async with server:
|
|
466
|
+
return await done
|
|
467
|
+
|
|
468
|
+
result: dict[str, int] = {}
|
|
469
|
+
t = threading.Thread(target=lambda: result.setdefault("rc", asyncio.run(run())), daemon=True)
|
|
470
|
+
t.start()
|
|
471
|
+
evt.wait(10)
|
|
472
|
+
argv = [node, str(TUI_BUNDLE)]
|
|
473
|
+
if resume:
|
|
474
|
+
argv += ["resume", resume]
|
|
475
|
+
elif task:
|
|
476
|
+
argv.append(task)
|
|
477
|
+
env = {**os.environ, "HUM_ENGINE_PORT": str(ready.get("port", 0))}
|
|
478
|
+
try:
|
|
479
|
+
rc = subprocess.run(argv, cwd=str(cwd), env=env).returncode
|
|
480
|
+
except KeyboardInterrupt:
|
|
481
|
+
rc = 130
|
|
482
|
+
t.join(15)
|
|
483
|
+
return rc
|
|
484
|
+
|
|
485
|
+
|
|
337
486
|
def main(argv: list[str] | None = None) -> int:
|
|
338
487
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
339
|
-
subs = ("chat", "run", "resume", "login", "logout", "whoami", "model", "key", "sessions", "show", "sync")
|
|
488
|
+
subs = ("chat", "run", "resume", "login", "logout", "whoami", "model", "key", "sessions", "show", "sync", "engine")
|
|
340
489
|
if not argv or (argv[0] not in subs and argv[0] not in ("-h", "--help", "--version")):
|
|
341
490
|
argv = ["chat", *argv]
|
|
342
491
|
ap = argparse.ArgumentParser(prog="hum", description="Hum — हम. Human and model, one harness.")
|
|
343
492
|
ap.add_argument("--version", action="version", version=f"hum {__version__}")
|
|
344
493
|
sub = ap.add_subparsers(dest="cmd")
|
|
345
|
-
cp = sub.add_parser("chat"); cp.add_argument("task", nargs="?"); cp.add_argument("-C", "--cwd", default=".")
|
|
494
|
+
cp = sub.add_parser("chat"); cp.add_argument("task", nargs="?"); cp.add_argument("-C", "--cwd", default="."); cp.add_argument("--plain", action="store_true", help="the Rich client, no Ink")
|
|
346
495
|
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")
|
|
496
|
+
rs = sub.add_parser("resume"); rs.add_argument("name", nargs="?", default="last"); rs.add_argument("--plain", action="store_true")
|
|
348
497
|
lg = sub.add_parser("login"); lg.add_argument("--auth-url")
|
|
349
498
|
sub.add_parser("logout"); sub.add_parser("whoami"); sub.add_parser("sessions"); sub.add_parser("sync")
|
|
350
499
|
mp = sub.add_parser("model"); mp.add_argument("name", nargs="?")
|
|
351
500
|
kp = sub.add_parser("key"); kp.add_argument("provider"); kp.add_argument("key")
|
|
352
501
|
sp = sub.add_parser("show"); sp.add_argument("name")
|
|
502
|
+
ep = sub.add_parser("engine", help="serve the loop to a front-end over a localhost socket")
|
|
503
|
+
ep.add_argument("-C", "--cwd", default="."); ep.add_argument("--listen", action="store_true"); ep.add_argument("--port", type=int, default=int(os.environ.get("HUM_ENGINE_PORT", "0") or 0))
|
|
353
504
|
a = ap.parse_args(argv)
|
|
354
505
|
ui = UI()
|
|
355
506
|
if a.cmd == "sessions":
|
|
@@ -369,20 +520,39 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
369
520
|
if a.cmd == "sync":
|
|
370
521
|
from hum import sync
|
|
371
522
|
sent, skipped = sync.sync_all(); ui.info(f"sent {sent}, already there {skipped}"); return 0
|
|
523
|
+
from hum import auth
|
|
524
|
+
auth.refresh() # what the deployment gives this person today (model, tool servers); quiet on failure
|
|
372
525
|
cfg = load_config()
|
|
373
526
|
from hum.auth import PROVIDER_ENV, provider_of
|
|
374
527
|
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
528
|
ui.error("no model access — `hum login`, or `hum key openrouter <key>`, or ~/.hum/config.toml"); return 2
|
|
376
529
|
try:
|
|
530
|
+
if a.cmd == "engine":
|
|
531
|
+
from hum.engine import serve
|
|
532
|
+
return asyncio.run(serve(cfg, Path(a.cwd).resolve(), listen=a.listen or not a.port, port=a.port or None))
|
|
377
533
|
if a.cmd == "run":
|
|
378
534
|
return asyncio.run(Client(cfg, Path(a.cwd).resolve(), ui).one_shot(a.task))
|
|
379
535
|
if a.cmd == "resume":
|
|
380
536
|
s = Session.load(_find(a.name))
|
|
381
|
-
|
|
537
|
+
cwd = Path(s.task.get("cwd", ".")).resolve()
|
|
538
|
+
if not a.plain and sys.stdin.isatty():
|
|
539
|
+
rc = launch_tui(cfg, cwd, None, a.name)
|
|
540
|
+
if rc >= 0:
|
|
541
|
+
return rc
|
|
542
|
+
return asyncio.run(Client(cfg, cwd, ui).chat(None, resume=s))
|
|
543
|
+
if not a.plain and sys.stdin.isatty():
|
|
544
|
+
rc = launch_tui(cfg, Path(a.cwd).resolve(), a.task, None)
|
|
545
|
+
if rc >= 0:
|
|
546
|
+
return rc
|
|
382
547
|
return asyncio.run(Client(cfg, Path(a.cwd).resolve(), ui).chat(a.task))
|
|
383
548
|
except KeyboardInterrupt:
|
|
384
549
|
print(); return 130
|
|
385
550
|
|
|
386
551
|
|
|
552
|
+
def main_engine(argv: list[str] | None = None) -> int:
|
|
553
|
+
"""``hum-engine``: the loop as a service for a front-end (the npm package's entry spawns this)."""
|
|
554
|
+
return main(["engine", *(sys.argv[1:] if argv is None else argv)])
|
|
555
|
+
|
|
556
|
+
|
|
387
557
|
if __name__ == "__main__":
|
|
388
558
|
sys.exit(main())
|