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/runtime/tools.py ADDED
@@ -0,0 +1,284 @@
1
+ """Native tools. Every tool is a JSON-schema function the model calls directly;
2
+ MCP servers are mounted into the same registry so ``control`` and ``run-unit``
3
+ look to the model exactly like ``bash`` does.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import fnmatch
9
+ import json
10
+ import os
11
+ import re
12
+ import shlex
13
+ import time
14
+ from pathlib import Path
15
+ from contextlib import AsyncExitStack
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Awaitable, Callable
18
+
19
+ from .executor import Executor, decode
20
+ from .models import ToolCall, ToolResult
21
+ from .observe import shape
22
+
23
+ Handler = Callable[[Executor, dict[str, Any]], Awaitable[str]]
24
+
25
+
26
+ @dataclass
27
+ class Tool:
28
+ name: str
29
+ description: str
30
+ parameters: dict[str, Any]
31
+ handler: Handler
32
+ mutating: bool = False # used by autonomy gating: read-only tools may run ungated
33
+
34
+ def to_openai(self) -> dict[str, Any]:
35
+ return {"type": "function", "function": {"name": self.name, "description": self.description, "parameters": self.parameters}}
36
+
37
+
38
+ @dataclass
39
+ class ToolRegistry:
40
+ tools: dict[str, Tool] = field(default_factory=dict)
41
+ observation_max_bytes: int = 16_000
42
+
43
+ def add(self, tool: Tool) -> None:
44
+ if tool.name in self.tools:
45
+ raise ValueError(f"duplicate tool {tool.name}")
46
+ self.tools[tool.name] = tool
47
+
48
+ def extend(self, tools: list[Tool]) -> "ToolRegistry":
49
+ for t in tools:
50
+ self.add(t)
51
+ return self
52
+
53
+ def to_openai(self) -> list[dict[str, Any]]:
54
+ return [t.to_openai() for t in self.tools.values()]
55
+
56
+ def is_mutating(self, name: str) -> bool:
57
+ t = self.tools.get(name)
58
+ return True if t is None else t.mutating
59
+
60
+ async def call(self, call: ToolCall, executor: Executor) -> ToolResult:
61
+ t0 = time.monotonic()
62
+ tool = self.tools.get(call.name)
63
+ if tool is None:
64
+ return ToolResult(call_id=call.id, name=call.name, ok=False, content=f"unknown tool {call.name!r}; available: {sorted(self.tools)}")
65
+ try:
66
+ raw = await tool.handler(executor, call.arguments)
67
+ text, nbytes = shape(raw, self.observation_max_bytes)
68
+ return ToolResult(call_id=call.id, name=call.name, content=text, raw_bytes=nbytes, elapsed_s=time.monotonic() - t0)
69
+ except Exception as e: # the model gets the error as an observation, never a crash
70
+ return ToolResult(call_id=call.id, name=call.name, ok=False, content=f"{type(e).__name__}: {e}", elapsed_s=time.monotonic() - t0)
71
+
72
+ async def call_many(self, calls: list[ToolCall], executor: Executor, parallel: bool = True) -> list[ToolResult]:
73
+ if parallel and len(calls) > 1 and all(not self.is_mutating(c.name) for c in calls):
74
+ return list(await asyncio.gather(*(self.call(c, executor) for c in calls)))
75
+ out = []
76
+ for c in calls:
77
+ out.append(await self.call(c, executor))
78
+ return out
79
+
80
+
81
+ # --- builtins -----------------------------------------------------------------
82
+
83
+ async def _bash(ex: Executor, a: dict[str, Any]) -> str:
84
+ r = await ex.exec(a["command"], timeout_sec=int(a.get("timeout_sec", 120)))
85
+ tail = f"\n[exit {r.returncode}{' (timed out)' if r.timed_out else ''}]"
86
+ return (r.text or "(no output)") + tail
87
+
88
+
89
+ async def _read_file(ex: Executor, a: dict[str, Any]) -> str:
90
+ text = decode(await ex.read(a["path"]))
91
+ lines = text.splitlines()
92
+ off = max(int(a.get("offset", 1)), 1)
93
+ lim = int(a.get("limit", 400))
94
+ chunk = lines[off - 1 : off - 1 + lim]
95
+ width = len(str(off + len(chunk)))
96
+ body = "\n".join(f"{i:>{width}}\t{l}" for i, l in enumerate(chunk, start=off))
97
+ more = len(lines) - (off - 1 + len(chunk))
98
+ return body + (f"\n... ({more} more lines)" if more > 0 else "")
99
+
100
+
101
+ async def _write_file(ex: Executor, a: dict[str, Any]) -> str:
102
+ data = a["content"].encode("utf-8")
103
+ await ex.write(a["path"], data)
104
+ return f"wrote {len(data)} bytes to {a['path']}"
105
+
106
+
107
+ async def _edit_file(ex: Executor, a: dict[str, Any]) -> str:
108
+ raw = await ex.read(a["path"])
109
+ text = decode(raw)
110
+ old, new = a["old_string"], a["new_string"]
111
+ n = text.count(old)
112
+ if n == 0:
113
+ return "old_string not found; no change"
114
+ if n > 1 and not a.get("replace_all", False):
115
+ return f"old_string occurs {n} times; pass replace_all=true or make it unique; no change"
116
+ text = text.replace(old, new) if a.get("replace_all") else text.replace(old, new, 1)
117
+ enc = "utf-8"
118
+ try:
119
+ raw.decode("utf-8")
120
+ except UnicodeDecodeError:
121
+ enc = "latin-1"
122
+ await ex.write(a["path"], text.encode(enc))
123
+ return f"replaced {n if a.get('replace_all') else 1} occurrence(s) in {a['path']}"
124
+
125
+
126
+ _SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".hum", ".idea", ".vscode", "dist", "build"}
127
+
128
+
129
+ def _walk(root: Path, max_files: int = 50_000):
130
+ n = 0
131
+ for dirpath, dirnames, filenames in os.walk(root):
132
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
133
+ for f in filenames:
134
+ yield Path(dirpath) / f
135
+ n += 1
136
+ if n >= max_files:
137
+ return
138
+
139
+
140
+ def _root_of(ex: Executor, path: str) -> Path:
141
+ base = Path(getattr(ex, "root", ex.cwd))
142
+ p = Path(path)
143
+ return p if p.is_absolute() else base / p
144
+
145
+
146
+ async def _grep(ex: Executor, a: dict[str, Any]) -> str:
147
+ """Byte-wise search in Python: same results on macOS, Linux and Windows; latin-1 estate files are searchable."""
148
+ root = _root_of(ex, a.get("path", "."))
149
+ flags = re.IGNORECASE if a.get("ignore_case") else 0
150
+ try:
151
+ pat = re.compile(a["pattern"].encode("latin-1", errors="replace"), flags)
152
+ except re.error as e:
153
+ return f"bad pattern: {e}"
154
+ glob = a.get("glob")
155
+ limit = int(a.get("max_results", 200))
156
+ base = Path(getattr(ex, "root", ex.cwd))
157
+ out: list[str] = []
158
+
159
+ def run() -> list[str]:
160
+ files = [root] if root.is_file() else _walk(root)
161
+ for f in files:
162
+ if glob and not fnmatch.fnmatch(f.name, glob):
163
+ continue
164
+ try:
165
+ data = f.read_bytes()
166
+ except OSError:
167
+ continue
168
+ if b"\x00" in data[:8000]:
169
+ continue # binary
170
+ for i, line in enumerate(data.splitlines(), 1):
171
+ if pat.search(line):
172
+ rel = os.path.relpath(f, base).replace(os.sep, "/") # one path style on every OS
173
+ out.append(f"{rel}:{i}:{decode(line)[:400]}")
174
+ if len(out) >= limit:
175
+ return out
176
+ return out
177
+
178
+ res = await asyncio.to_thread(run)
179
+ return "\n".join(res) if res else "(no matches)"
180
+
181
+
182
+ async def _glob(ex: Executor, a: dict[str, Any]) -> str:
183
+ root = _root_of(ex, a.get("path", "."))
184
+ pattern = a["pattern"]
185
+ limit = int(a.get("max_results", 500))
186
+ base = Path(getattr(ex, "root", ex.cwd))
187
+
188
+ # fnmatch has no '**': accept the pattern with '**/' meaning "zero or more directories".
189
+ cands = {pattern, pattern.replace("**/", ""), pattern.replace("/**", ""), "*/" + pattern.lstrip("*/")}
190
+
191
+ def match(rel: str, name: str) -> bool:
192
+ return any(fnmatch.fnmatch(rel, c) for c in cands) or fnmatch.fnmatch(name, pattern)
193
+
194
+ def run() -> list[str]:
195
+ out = []
196
+ for f in _walk(root):
197
+ rel = os.path.relpath(f, base).replace(os.sep, "/")
198
+ if match(rel, f.name):
199
+ out.append(rel)
200
+ if len(out) >= limit:
201
+ break
202
+ return sorted(out)
203
+
204
+ res = await asyncio.to_thread(run)
205
+ return "\n".join(res) if res else "(no files)"
206
+
207
+
208
+ def builtin_tools(shell: str = "bash") -> list[Tool]:
209
+ """``shell`` names the workspace shell ("bash" or "powershell") so the model writes the right syntax."""
210
+ S = lambda **p: {"type": "object", "properties": p, "required": [k for k, v in p.items() if v.pop("required", False)]}
211
+ shell_desc = ("Run a bash command in the workspace." if shell == "bash"
212
+ else "Run a PowerShell command in the workspace (Windows; no bash available).")
213
+ return [
214
+ Tool(shell, shell_desc + " Output is returned; long output is head/tail trimmed.",
215
+ S(command={"type": "string", "required": True}, timeout_sec={"type": "integer", "default": 120}), _bash, mutating=True),
216
+ Tool("read_file", "Read a file with line numbers. Use offset/limit for large files.",
217
+ S(path={"type": "string", "required": True}, offset={"type": "integer", "default": 1}, limit={"type": "integer", "default": 400}), _read_file),
218
+ Tool("write_file", "Create or overwrite a file with the given content.",
219
+ S(path={"type": "string", "required": True}, content={"type": "string", "required": True}), _write_file, mutating=True),
220
+ Tool("edit_file", "Replace an exact string in a file. old_string must be unique unless replace_all.",
221
+ S(path={"type": "string", "required": True}, old_string={"type": "string", "required": True}, new_string={"type": "string", "required": True}, replace_all={"type": "boolean", "default": False}), _edit_file, mutating=True),
222
+ Tool("grep", "Search file contents recursively with a regex (byte-wise; latin-1 files are searchable).",
223
+ S(pattern={"type": "string", "required": True}, path={"type": "string", "default": "."}, glob={"type": "string"}, ignore_case={"type": "boolean", "default": False}, max_results={"type": "integer", "default": 200}), _grep),
224
+ Tool("glob", "Find files by name or path pattern, e.g. '*.cbl' or 'src/**/*.py'.",
225
+ S(pattern={"type": "string", "required": True}, path={"type": "string", "default": "."}, max_results={"type": "integer", "default": 500}), _glob),
226
+ ]
227
+
228
+
229
+ # --- MCP bridge ----------------------------------------------------------------
230
+
231
+ class MCPToolset:
232
+ """Mounts an MCP server's tools into a registry as native tools named
233
+ ``<server>__<tool>``. Transport: stdio or streamable-http."""
234
+
235
+ def __init__(self, name: str, *, url: str | None = None, command: str | None = None, args: list[str] | None = None, headers: dict[str, str] | None = None):
236
+ self.name, self.url, self.command, self.args, self.headers = name, url, command, args or [], headers or {}
237
+ self._stack: AsyncExitStack | None = None
238
+ self._session = None
239
+
240
+ async def connect(self) -> list[Tool]:
241
+ from mcp import ClientSession # type: ignore
242
+
243
+ self._stack = AsyncExitStack()
244
+ if self.command:
245
+ from mcp import StdioServerParameters # type: ignore
246
+ from mcp.client.stdio import stdio_client # type: ignore
247
+ read, write = await self._stack.enter_async_context(stdio_client(StdioServerParameters(command=self.command, args=self.args)))
248
+ elif self.url:
249
+ from mcp.client.streamable_http import streamablehttp_client # type: ignore
250
+ read, write, _ = await self._stack.enter_async_context(streamablehttp_client(self.url, headers=self.headers or None))
251
+ else:
252
+ raise ValueError("MCPToolset needs url or command")
253
+ self._session = await self._stack.enter_async_context(ClientSession(read, write))
254
+ await self._session.initialize()
255
+ listed = await self._session.list_tools()
256
+ tools: list[Tool] = []
257
+ for t in listed.tools:
258
+ tools.append(Tool(
259
+ name=f"{self.name}__{t.name}",
260
+ description=t.description or "",
261
+ parameters=t.inputSchema or {"type": "object", "properties": {}},
262
+ handler=self._make_handler(t.name),
263
+ mutating=not (getattr(t, "annotations", None) and getattr(t.annotations, "readOnlyHint", False)),
264
+ ))
265
+ return tools
266
+
267
+ def _make_handler(self, tool_name: str) -> Handler:
268
+ async def h(_ex: Executor, args: dict[str, Any]) -> str:
269
+ res = await self._session.call_tool(tool_name, args) # type: ignore[union-attr]
270
+ parts = []
271
+ for c in res.content:
272
+ if getattr(c, "type", "") == "text":
273
+ parts.append(c.text)
274
+ else:
275
+ parts.append(json.dumps(c.model_dump(mode="json")))
276
+ out = "\n".join(parts)
277
+ if getattr(res, "isError", False):
278
+ out = f"[tool error]\n{out}"
279
+ return out
280
+ return h
281
+
282
+ async def close(self) -> None:
283
+ if self._stack:
284
+ await self._stack.aclose()
@@ -0,0 +1,68 @@
1
+ """ATIF export. The main branch becomes the trajectory; authorship,
2
+ interventions, shadow branches and verdicts ride in ``extra`` so the file
3
+ stays a valid ATIF-v1.7 document for every consumer that doesn't know us.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from .session import Session
11
+
12
+
13
+ def _ts(t: float) -> str:
14
+ return datetime.fromtimestamp(t, tz=timezone.utc).isoformat()
15
+
16
+
17
+ def to_atif(session: Session, *, agent_name: str, agent_version: str, model_name: str | None, system_prompt: str,
18
+ branch: str = "main", tool_definitions: list[dict[str, Any]] | None = None) -> dict[str, Any]:
19
+ leaf = session.head(branch)
20
+ steps: list[dict[str, Any]] = [
21
+ {"step_id": 1, "source": "system", "message": system_prompt, "timestamp": _ts(leaf.at if leaf else 0)},
22
+ {"step_id": 2, "source": "user", "message": session.task["instruction"], "timestamp": _ts(leaf.at if leaf else 0)},
23
+ ]
24
+ tot_in = tot_out = tot_cache = 0
25
+ tot_cost = 0.0
26
+ for n in session.lineage(leaf.id if leaf else None):
27
+ t = n.turn
28
+ src = "agent" if t.author in ("policy", "shadow") else "user"
29
+ step: dict[str, Any] = {
30
+ "step_id": len(steps) + 1,
31
+ "timestamp": _ts(n.at),
32
+ "source": src,
33
+ "message": t.content or "",
34
+ "extra": {"node_id": n.id, "parent": n.parent, "branch": n.branch, "author": t.author,
35
+ "intervention": n.intervention.model_dump() if n.intervention else None,
36
+ "verdict": n.verdict.model_dump() if n.verdict else None},
37
+ }
38
+ if src == "agent":
39
+ step["model_name"] = t.model or model_name
40
+ if t.reasoning:
41
+ step["reasoning_content"] = t.reasoning
42
+ if t.tool_calls:
43
+ step["tool_calls"] = [{"tool_call_id": c.id, "function_name": c.name, "arguments": c.arguments} for c in t.tool_calls]
44
+ if n.results:
45
+ step["observation"] = {"results": [{"source_call_id": r.call_id, "content": r.content,
46
+ "extra": {"ok": r.ok, "elapsed_s": r.elapsed_s, "raw_bytes": r.raw_bytes}} for r in n.results]}
47
+ if t.usage:
48
+ step["metrics"] = {"prompt_tokens": t.usage.input_tokens, "completion_tokens": t.usage.output_tokens,
49
+ "cached_tokens": t.usage.cache_tokens, "cost_usd": t.usage.cost_usd,
50
+ "prompt_token_ids": t.prompt_token_ids, "completion_token_ids": t.completion_token_ids, "logprobs": t.logprobs}
51
+ tot_in += t.usage.input_tokens; tot_out += t.usage.output_tokens; tot_cache += t.usage.cache_tokens; tot_cost += t.usage.cost_usd
52
+ steps.append(step)
53
+ shadows = sorted({n.branch for n in session.nodes.values() if n.branch != branch})
54
+ return {
55
+ "schema_version": "ATIF-v1.7",
56
+ "session_id": session.id,
57
+ "agent": {"name": agent_name, "version": agent_version, "model_name": model_name, "tool_definitions": tool_definitions},
58
+ "steps": steps,
59
+ "final_metrics": {"total_prompt_tokens": tot_in, "total_completion_tokens": tot_out, "total_cached_tokens": tot_cache,
60
+ "total_cost_usd": tot_cost, "total_steps": len(steps)},
61
+ "extra": {
62
+ "harness": agent_name,
63
+ "task": {k: v for k, v in session.task.items() if k != "instruction"},
64
+ "shadow_branches": {b: [n.id for n in session.branch_nodes(b)] for b in shadows},
65
+ "preference_pairs": [p.model_dump() for p in session.pairs],
66
+ "leaf_verdict": leaf.verdict.model_dump() if leaf and leaf.verdict else None,
67
+ },
68
+ }
@@ -0,0 +1,48 @@
1
+ """Notice what the human changed with their own hands.
2
+
3
+ Between two model turns, if files in the workspace changed and no tool call
4
+ made the change, a person did. That is the most natural intervention there is
5
+ and the SME never has to announce it. Uses git when the workspace is a repo
6
+ (cheap, exact), otherwise an mtime scan.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from .executor import Executor
15
+
16
+ _SKIP = {".git", "node_modules", ".venv", "__pycache__", ".hum"}
17
+
18
+
19
+ async def fingerprint(ex: Executor) -> str:
20
+ r = await ex.exec("git status --porcelain=v1 --untracked-files=all && git diff", timeout_sec=30)
21
+ if r.returncode == 0:
22
+ return hashlib.sha1(r.stdout.encode("utf-8", errors="replace")).hexdigest()
23
+ return await _mtime_scan(ex)
24
+
25
+
26
+ async def _mtime_scan(ex: Executor) -> str:
27
+ root = Path(getattr(ex, "root", ex.cwd))
28
+ h = hashlib.sha1()
29
+ n = 0
30
+ for dirpath, dirnames, filenames in os.walk(root):
31
+ dirnames[:] = [d for d in dirnames if d not in _SKIP]
32
+ for f in filenames:
33
+ p = Path(dirpath) / f
34
+ try:
35
+ st = p.stat()
36
+ except OSError:
37
+ continue
38
+ h.update(f"{p.relative_to(root)}:{st.st_mtime_ns}:{st.st_size}".encode())
39
+ n += 1
40
+ if n > 20000:
41
+ break
42
+ return h.hexdigest()
43
+
44
+
45
+ async def describe_change(ex: Executor) -> str:
46
+ r = await ex.exec("git status --porcelain=v1 && git diff --stat", timeout_sec=30)
47
+ lines = (r.stdout or "").strip().splitlines()
48
+ return "\n".join(lines[:60])
hum/sync.py ADDED
@@ -0,0 +1,56 @@
1
+ """Sessions go home. After a run, the session file is uploaded to the auth
2
+ server (bearer = the person's Hum token). Best-effort and quick; ``hum sync``
3
+ retries anything that didn't make it. A ledger in ~/.hum/synced.json records
4
+ what has been sent.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from hum.auth import Credentials, load as load_creds
13
+ from hum.runtime.store import home, list_sessions
14
+
15
+
16
+ def _ledger() -> dict[str, Any]:
17
+ p = home() / "synced.json"
18
+ return json.loads(p.read_text()) if p.exists() else {}
19
+
20
+
21
+ def _mark(name: str, info: dict[str, Any]) -> None:
22
+ p = home() / "synced.json"
23
+ d = _ledger()
24
+ d[name] = info
25
+ p.write_text(json.dumps(d, indent=1))
26
+
27
+
28
+ def upload(path: Path, creds: Credentials | None = None, client: Any = None, timeout: float = 10.0) -> bool:
29
+ import httpx
30
+
31
+ c = creds or load_creds()
32
+ if not c.logged_in:
33
+ return False
34
+ http = client or httpx.Client(timeout=timeout)
35
+ size = path.stat().st_size
36
+ r = http.post(f"{c.auth_url}/sessions", content=path.read_bytes(),
37
+ headers={"Authorization": f"Bearer {c.access_token}", "Content-Type": "application/x-ndjson", "X-Hum-Session": path.name})
38
+ if r.status_code in (200, 201):
39
+ _mark(path.name, {"size": size, "id": r.json().get("id")})
40
+ return True
41
+ return False
42
+
43
+
44
+ def sync_all(client: Any = None) -> tuple[int, int]:
45
+ c = load_creds()
46
+ if not c.logged_in:
47
+ return 0, 0
48
+ done = _ledger()
49
+ sent = skipped = 0
50
+ for p in list_sessions():
51
+ if done.get(p.name, {}).get("size") == p.stat().st_size:
52
+ skipped += 1
53
+ continue
54
+ if upload(p, c, client=client):
55
+ sent += 1
56
+ return sent, skipped
hum/ui.py ADDED
@@ -0,0 +1,109 @@
1
+ """What the person sees. Rich for rendering; nothing here knows about training."""
2
+ from __future__ import annotations
3
+
4
+ import difflib
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+ from rich.console import Console
10
+ from rich.markdown import Markdown
11
+ from rich.syntax import Syntax
12
+ from rich.text import Text
13
+
14
+ from hum.runtime.models import ToolCall
15
+
16
+
17
+ class UI:
18
+ def __init__(self, console: Console | None = None):
19
+ self.console = console or Console(highlight=False, soft_wrap=True)
20
+ self._streaming = False
21
+ self._buf = ""
22
+ self._status: Any = None
23
+
24
+ # -- lifecycle ------------------------------------------------------------------
25
+ def banner(self, model: str, tools: list[str], session: str, user: str | None = None) -> None:
26
+ who = f" · {user}" if user else ""
27
+ self.console.print(Text(f"hum · {model}{who} · tools: {', '.join(tools)} · {session}", style="dim"))
28
+
29
+ def thinking(self, on: bool) -> None:
30
+ """Spinner for unattended runs only; in chat the prompt owns the bottom line."""
31
+ if on and self._status is None:
32
+ self._status = self.console.status("[dim]thinking…", spinner="dots")
33
+ self._status.start()
34
+ elif not on and self._status is not None:
35
+ self._status.stop()
36
+ self._status = None
37
+
38
+ # -- model text -------------------------------------------------------------------
39
+ def text(self, delta: str) -> None:
40
+ self.thinking(False)
41
+ if not self._streaming:
42
+ self.console.print(Text("hum ", style="bold"), end="")
43
+ self._streaming = True
44
+ self._buf += delta
45
+ self.console.print(Text(delta), end="")
46
+ sys.stdout.flush()
47
+
48
+ def end_text(self) -> None:
49
+ if self._streaming:
50
+ self.console.print()
51
+ self._streaming = False
52
+ self._buf = ""
53
+
54
+ def final(self, content: str) -> None:
55
+ """Unattended runs: the whole answer at once, rendered."""
56
+ if content.strip():
57
+ self.console.print(Markdown(content))
58
+
59
+ # -- tools ---------------------------------------------------------------------------
60
+ def call(self, c: ToolCall) -> None:
61
+ self.thinking(False)
62
+ self.end_text()
63
+ a = c.arguments
64
+ if c.name in ("bash", "powershell"):
65
+ self.console.print(Text(" $ " if c.name == "bash" else " PS> ", style="cyan") + Text(str(a.get("command", "")).replace("\n", " ⏎ ")[:300]))
66
+ elif c.name == "edit_file":
67
+ self.console.print(Text(f" ✎ {a.get('path', '')}", style="cyan"))
68
+ old, new = str(a.get("old_string", "")), str(a.get("new_string", ""))
69
+ diff = "\n".join(difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="", n=1))
70
+ diff = "\n".join(l for l in diff.splitlines() if not l.startswith(("---", "+++")))
71
+ if diff:
72
+ self.console.print(Syntax(diff, "diff", theme="ansi_dark", word_wrap=True, padding=(0, 4)))
73
+ elif c.name == "write_file":
74
+ content = str(a.get("content", ""))
75
+ self.console.print(Text(f" + {a.get('path', '')}", style="cyan") + Text(f" ({len(content.splitlines())} lines)", style="dim"))
76
+ elif c.name == "read_file":
77
+ self.console.print(Text(f" ◦ read {a.get('path', '')}", style="dim cyan"))
78
+ elif c.name in ("grep", "glob"):
79
+ self.console.print(Text(f" ◦ {c.name} {a.get('pattern', '')}" + (f" in {a['path']}" if a.get("path") else ""), style="dim cyan"))
80
+ else:
81
+ self.console.print(Text(f" ▸ {c.name} ", style="cyan") + Text(json.dumps(a, ensure_ascii=False)[:300], style="dim"))
82
+
83
+ def result(self, r: Any, max_lines: int = 10) -> None:
84
+ if r.name in ("read_file", "grep", "glob") and r.ok:
85
+ n = len(r.content.splitlines())
86
+ self.console.print(Text(f" {n} lines", style="dim"))
87
+ return
88
+ lines = r.content.splitlines() or [""]
89
+ style = "dim" if r.ok else "red"
90
+ for l in lines[:max_lines]:
91
+ self.console.print(Text(" " + l[:220], style=style))
92
+ if len(lines) > max_lines:
93
+ self.console.print(Text(f" … {len(lines) - max_lines} more lines", style="dim"))
94
+
95
+ # -- notes -------------------------------------------------------------------------------
96
+ def info(self, msg: str) -> None:
97
+ self.console.print(Text(msg, style="dim"))
98
+
99
+ def warn(self, msg: str) -> None:
100
+ self.console.print(Text(msg, style="yellow"))
101
+
102
+ def error(self, msg: str) -> None:
103
+ self.end_text()
104
+ self.console.print(Text(msg, style="red"))
105
+
106
+ def summary(self, turns: int, cost: float, verdict: Any) -> None:
107
+ v = "" if verdict is None else (" · [green]pass[/]" if verdict.passed else " · [red]fail[/]")
108
+ c = f"${cost:.2f}" if cost >= 0.01 else (f"${cost:.4f}" if cost > 0 else "$0")
109
+ self.console.print(f"[dim]— {turns} turns · {c}[/]{v}")
@@ -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,27 @@
1
+ hum/__init__.py,sha256=lxYx2Ie0s5xhhxj3lKz_l2WR5vUOizJ8C8wRDTVyoQA,158
2
+ hum/auth.py,sha256=bHn5PCf3zT_WtQBlhx63VNO4baPQsCRmaA2qDVQRLwM,3887
3
+ hum/cli.py,sha256=VGldokS9Tr4qJSurValnN6bO_ksBGy8j9EgwOItb_js,17572
4
+ hum/sync.py,sha256=Xuhbw6ndWxh-ikC82Qygg8_XQG84rzb8W_v44KhIZlU,1773
5
+ hum/ui.py,sha256=m1so_szp1Fm6s2WLBmNvTJPzFQITs3Lpwd5P_AieT7g,4981
6
+ hum/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ hum/adapters/harbor.py,sha256=ofJKz54tcsWKzVUokHsb2Lfuhc3DVlR-V9JAlxxUQpQ,6286
8
+ hum/runtime/__init__.py,sha256=PyyENODOaQUjj_Kiq_MnhadaOlN0XYgi-ovlnogTsgI,535
9
+ hum/runtime/autonomy.py,sha256=ZNV5VlTeRkZAtJi-mp_T9DidVMD2hyL2IFVRKIRErac,2909
10
+ hum/runtime/config.py,sha256=HZHpNDlRDFaRdjPyZjvWRm2tokwxujgDwRSdvlDanjw,4203
11
+ hum/runtime/drivers.py,sha256=EJ9VzGPRs5HshEqwM8ulcoJ-mIlZmVQh0OmFuTD3EgI,2559
12
+ hum/runtime/executor.py,sha256=JUArGC1obW8XVftPfi3kuZO-25n13LJqvXCXNjFO7g8,4552
13
+ hum/runtime/grader.py,sha256=4rt1DmklCvqEKQWYMLQne7PAnSBIhOHul54EQ_2oEqc,1799
14
+ hum/runtime/llm.py,sha256=HMERV01oBfkoyFDwzKH2jYO2JZlWVjgwYIu-7hYOBXk,11478
15
+ hum/runtime/loop.py,sha256=kRbvabfji2VqlBrcp-3MhMnPYT-9wbQieNtcWko-bXU,13599
16
+ hum/runtime/models.py,sha256=JU8dH9JJ36snqgBsq7Evc7olXa9_DJvosSy38XHtjqc,4125
17
+ hum/runtime/observe.py,sha256=FRtkmIkRlqB17G6pN7V2goXAvJqdHLvqnc0Wb-Ujd0s,1186
18
+ hum/runtime/prompt.py,sha256=NHiurSRiXysoxqp5KqNc5DGGcfBDdlQHigTXZWK8YrE,617
19
+ hum/runtime/session.py,sha256=Q6dgHtGff58PdCZNiSgthMzErUGtarGXz-TzQ-yvsRo,5840
20
+ hum/runtime/store.py,sha256=RKnyqW2O8H6DT7yuTMoq67EHxke5dkJKCOjCxo7jFtA,679
21
+ hum/runtime/tools.py,sha256=6oTcSnsch4uo52UVPyNP38X7Vzr4ap5JiQfPl6DOy0o,12496
22
+ hum/runtime/trajectory.py,sha256=IC13W5AgE43TzkRnMtjrskM-g5ef6MX02Hg3lLOUdyQ,3709
23
+ hum/runtime/workspace.py,sha256=rb6ZBWDbtmU-hvUh43cQsfDIbLbessQjKtfDyo0d6TM,1605
24
+ hum_cli-0.0.1.dist-info/METADATA,sha256=_O8E3Xv1uZDBqmPWVuGPWjgeIymLLDs2qZLgoTMbH-I,979
25
+ hum_cli-0.0.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
26
+ hum_cli-0.0.1.dist-info/entry_points.txt,sha256=lCjo9VqrgXPe_0O_lMxEV73ZY91gqnI9mQ3WJKY4VH4,37
27
+ hum_cli-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hum = hum.cli:main