Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .cursor/environment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Hermes-Function-Calling (CPU dev)",
"user": "ubuntu",
"install": "bash .cursor/install.sh"
}
46 changes: 46 additions & 0 deletions .cursor/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
#
# Idempotent install script for the Hermes-Function-Calling Cloud Agent dev
# environment. Creates a project virtualenv at .venv and installs a CPU-only
# dependency set (see .cursor/requirements-cpu.txt for why this differs from the
# GPU-oriented root requirements.txt).
set -euo pipefail

cd "$(dirname "$0")/.."

PYTHON="${PYTHON:-python3}"
VENV_DIR=".venv"

# Ensure the venv module is available (Debian splits it into python3-venv).
if ! "$PYTHON" -c "import ensurepip" >/dev/null 2>&1; then
echo "==> Installing python3-venv"
sudo apt-get update -qq
sudo apt-get install -y "$("$PYTHON" -c 'import sys; print(f"python{sys.version_info.major}.{sys.version_info.minor}-venv")')" python3-pip
fi

if [ ! -d "$VENV_DIR" ]; then
echo "==> Creating virtualenv at $VENV_DIR"
"$PYTHON" -m venv "$VENV_DIR"
fi

# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"

echo "==> Upgrading pip tooling"
python -m pip install --upgrade pip wheel setuptools

echo "==> Installing CPU-only PyTorch"
pip install --index-url https://download.pytorch.org/whl/cpu torch

echo "==> Installing project dependencies (CPU set)"
pip install -r .cursor/requirements-cpu.txt

echo "==> Verifying imports"
python - <<'PY'
import functions, prompter, validator, utils, schema
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
tools = functions.get_openai_tools()
print(f"OK: imported core modules; {len(tools)} tools available")
PY

echo "==> Install complete. Activate with: source .venv/bin/activate"
10 changes: 10 additions & 0 deletions .cursor/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"mcpServers": {
"huggingface": {
"url": "https://huggingface.co/mcp",
"headers": {
"Authorization": "Bearer ${env:HF_TOKEN}"
}
}
}
}
25 changes: 25 additions & 0 deletions .cursor/requirements-cpu.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# CPU-only dependency set for the Cloud Agent dev environment.
#
# This mirrors the runtime dependencies in the repo-root requirements.txt but is
# adapted for a CPU-only machine running Python 3.12:
# * torch is installed separately in .cursor/install.sh from the PyTorch CPU
# index (the pinned torch==2.1.2 has no Python 3.12 wheels).
# * flash-attn and bitsandbytes are intentionally omitted — both require a
# CUDA GPU, which is not available here. The 8B model generation step in
# functioncall.py / jsonmode.py therefore needs a GPU host; every other part
# of the pipeline runs on CPU.
# * beautifulsoup4, requests and PyYAML are added explicitly: functions.py and
# prompter.py import them but they are missing from the root requirements.txt.
transformers>=4.38.1
accelerate==0.27.2
langchain==0.1.9
pydantic==2.6.2
jsonschema==4.21.1
yfinance==0.2.36
pandas==2.2.0
protobuf
sentencepiece
art
beautifulsoup4
requests
PyYAML
25 changes: 25 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Python virtualenv (created by .cursor/install.sh)
.venv/
venv/

# Python caches
__pycache__/
*.py[cod]
*.egg-info/
.python-version

# Runtime logs generated by utils.py
inference_logs/

# Local model / HF caches
.cache/

# Proof movie build artifacts (proof/)
proof/frames/
proof/narration/
proof/segments/
proof/work/
proof/chrome-profile/
proof/*.mp4
proof/*.srt
proof/*-check/
42 changes: 42 additions & 0 deletions proof/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Proof movie — Hermes Function Calling

A rebuildable movie that proves the function-calling **toolkit** works on
this machine. It does **not** load Hermes-2-Pro (that needs a GPU and
several gigabytes of weights). Yahoo Finance also 429'd from this network,
so the live tool shown is `code_interpreter` rather than a faked stock quote.

Pipeline is the proving-it-works skill (`prime-radiant-inc/proving-it-works`):
terminal capture via ttyd + tmux + Chrome, local narration, burned-in
subtitles, then `check-movie`.

## What the movie shows

| Scene | Real code path |
|---|---|
| tools | `functions.get_openai_tools()` |
| parse | `utils.validate_and_extract_tool_calls()` |
| validate | `validator.validate_function_call_schema()` |
| execute | `functions.code_interpreter.invoke(...)` |

## Rebuild

```bash
# once: lightweight venv (no torch / flash-attn)
uv venv .venv
uv pip install --python .venv/bin/python \
langchain==0.1.9 pydantic==2.6.2 jsonschema==4.21.1 \
yfinance==0.2.36 pandas==2.2.0 beautifulsoup4 requests art pyyaml \
pillow websockets

# ttyd + uv on PATH, Google Chrome installed
proof/make-movie.sh
```

`proof/scripts/` are the proving-it-works helpers (MIT), vendored so the
movie can be re-cut without a plugin install. Title/end cards are PNG
stills from `render_cards.py` (PIL) so assemble does not depend on a
browser.

Frames, narration wavs, and the encoded movie stay out of git — they are
scratch. `scenes.yaml`, `show.py`, `film.py`, `render_cards.py`,
`cards/*.png`, and `make-movie.sh` are the source of truth.
Binary file added proof/cards/end.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added proof/cards/title.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
219 changes: 219 additions & 0 deletions proof/film.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Film a tmux+ttyd terminal of the Hermes toolkit via headless Chrome.

Adapted from proving-it-works examples/film-terminal.py: real characters in
a real shell, screenshotted from the canvas ttyd draws. Software GL is
required or headless Chrome paints that canvas black.
"""

from __future__ import annotations

import asyncio
import base64
import json
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

HERE = Path(__file__).resolve().parent
FPS = 2.0
DEBUG_PORT = 7222
SESSION = "hermes-demo"
CHROME = "/usr/bin/google-chrome"


def pane_command() -> str:
r = subprocess.run(
["tmux", "display-message", "-p", "-t", SESSION, "#{pane_current_command}"],
capture_output=True,
text=True,
)
return r.stdout.strip()


def wait_for_shell(timeout: float = 120) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if pane_command() in ("bash", "sh", "zsh"):
return
time.sleep(0.4)
raise SystemExit("shell never came back; refusing to type into a running program")


def send(keys: str, enter: bool = True) -> None:
cmd = ["tmux", "send-keys", "-t", SESSION, keys]
if enter:
cmd.append("Enter")
subprocess.run(cmd, check=True, capture_output=True)


def send_and_wait(keys: str, timeout: float = 120) -> None:
"""Type a command, wait for it to start, then wait for the shell to return."""
send(keys)
deadline = time.time() + 8
while time.time() < deadline and pane_command() in ("bash", "sh", "zsh"):
time.sleep(0.1)
wait_for_shell(timeout=timeout)


async def cdp(ws, mid, method, params=None):
await ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
while True:
msg = json.loads(await ws.recv())
if msg.get("id") == mid:
return msg.get("result", {})


async def shoot(ws, outdir: Path, stop: asyncio.Event, counter: list[int]) -> None:
import websockets # noqa: F401 — imported by caller

mid = 1000
while not stop.is_set():
mid += 1
try:
res = await asyncio.wait_for(
cdp(ws, mid, "Page.captureScreenshot", {"format": "png"}), timeout=3
)
data = res.get("data")
if data:
(outdir / f"f{counter[0]:05d}.png").write_bytes(base64.b64decode(data))
counter[0] += 1
except asyncio.TimeoutError:
pass
await asyncio.sleep(1 / FPS)


async def preflight(ws) -> None:
wait_for_shell()
send("echo PREFLIGHT_OK")
await asyncio.sleep(1.5)
res = await cdp(ws, 900, "Page.captureScreenshot", {"format": "png"})
png = base64.b64decode(res["data"])
from io import BytesIO

from PIL import Image

im = Image.open(BytesIO(png)).convert("L")
px = list(im.getdata())
lit = sum(1 for v in px if v > 90) / len(px)
if lit < 0.002:
raise SystemExit(
f"preflight failed: terminal renders blank ({lit:.4%} lit pixels). "
"Check software GL flags before filming."
)
print(f"preflight ok: {lit:.2%} of pixels lit")
send("clear")
await asyncio.sleep(0.8)


async def beats_tools() -> None:
await asyncio.sleep(1.2)
send_and_wait("python proof/show.py tools")
await asyncio.sleep(5)


async def beats_parse() -> None:
await asyncio.sleep(1.0)
send("clear")
await asyncio.sleep(0.6)
send_and_wait("python proof/show.py parse")
await asyncio.sleep(5)


async def beats_validate() -> None:
await asyncio.sleep(1.0)
send("clear")
await asyncio.sleep(0.6)
send_and_wait("python proof/show.py validate")
await asyncio.sleep(6)


async def beats_execute() -> None:
await asyncio.sleep(1.0)
send("clear")
await asyncio.sleep(0.6)
send_and_wait("python proof/show.py execute")
await asyncio.sleep(6)


BEATS = {
"tools": beats_tools,
"parse": beats_parse,
"validate": beats_validate,
"execute": beats_execute,
}


async def main(segment: str, url: str) -> None:
import websockets

if segment not in BEATS:
raise SystemExit(f"unknown segment {segment!r}; expected {sorted(BEATS)}")

outdir = HERE / "frames" / segment
outdir.mkdir(parents=True, exist_ok=True)
for old in outdir.glob("*.png"):
old.unlink()

# Drop a leftover debug Chrome so this take owns DEBUG_PORT.
subprocess.run(
["pkill", "-f", f"--remote-debugging-port={DEBUG_PORT}"],
capture_output=True,
)
time.sleep(0.4)

profile = HERE / "chrome-profile"
profile.mkdir(exist_ok=True)
chrome = subprocess.Popen(
[
CHROME,
f"--remote-debugging-port={DEBUG_PORT}",
"--headless=new",
"--user-data-dir=" + str(profile),
"--window-size=1280,800",
"--hide-scrollbars",
"--no-sandbox",
"--force-device-scale-factor=2",
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
time.sleep(3)
tabs = json.loads(urllib.request.urlopen(f"http://127.0.0.1:{DEBUG_PORT}/json").read())
ws_url = [t for t in tabs if t["type"] == "page"][0]["webSocketDebuggerUrl"]
async with websockets.connect(ws_url, max_size=40 * 1024 * 1024) as ws:
await cdp(ws, 1, "Page.enable")
await cdp(ws, 3, "Page.navigate", {"url": url})
await asyncio.sleep(4)
subprocess.run(
["tmux", "refresh-client", "-t", SESSION], capture_output=True
)
await asyncio.sleep(1)
await preflight(ws)

stop = asyncio.Event()
counter = [0]
task = asyncio.create_task(shoot(ws, outdir, stop, counter))
await BEATS[segment]()
stop.set()
await task
print(f"{segment}: {counter[0]} frames -> {outdir}")
finally:
chrome.terminate()
try:
chrome.wait(timeout=5)
except Exception:
chrome.kill()


if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit("usage: film.py <tools|parse|validate|execute> <ttyd-url>")
asyncio.run(main(sys.argv[1], sys.argv[2]))
Loading