hum-cli 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hum/__init__.py +4 -0
- hum/adapters/__init__.py +0 -0
- hum/adapters/harbor.py +128 -0
- hum/auth.py +112 -0
- hum/cli.py +388 -0
- hum/runtime/__init__.py +10 -0
- hum/runtime/autonomy.py +71 -0
- hum/runtime/config.py +101 -0
- hum/runtime/drivers.py +79 -0
- hum/runtime/executor.py +120 -0
- hum/runtime/grader.py +41 -0
- hum/runtime/llm.py +225 -0
- hum/runtime/loop.py +247 -0
- hum/runtime/models.py +132 -0
- hum/runtime/observe.py +28 -0
- hum/runtime/prompt.py +9 -0
- hum/runtime/session.py +139 -0
- hum/runtime/store.py +26 -0
- hum/runtime/tools.py +284 -0
- hum/runtime/trajectory.py +68 -0
- hum/runtime/workspace.py +48 -0
- hum/sync.py +56 -0
- hum/ui.py +109 -0
- hum_cli-0.0.1.dist-info/METADATA +24 -0
- hum_cli-0.0.1.dist-info/RECORD +27 -0
- hum_cli-0.0.1.dist-info/WHEEL +4 -0
- hum_cli-0.0.1.dist-info/entry_points.txt +2 -0
hum/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
|
+
}
|
hum/runtime/workspace.py
ADDED
|
@@ -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,,
|