hugpy-agent 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.
Files changed (52) hide show
  1. hugpy_agent-0.1.0/LICENSE +21 -0
  2. hugpy_agent-0.1.0/PKG-INFO +330 -0
  3. hugpy_agent-0.1.0/README.md +309 -0
  4. hugpy_agent-0.1.0/pyproject.toml +95 -0
  5. hugpy_agent-0.1.0/setup.cfg +4 -0
  6. hugpy_agent-0.1.0/src/hugpy_agent/__init__.py +10 -0
  7. hugpy_agent-0.1.0/src/hugpy_agent/adapter.py +464 -0
  8. hugpy_agent-0.1.0/src/hugpy_agent/audit.py +105 -0
  9. hugpy_agent-0.1.0/src/hugpy_agent/cli.py +376 -0
  10. hugpy_agent-0.1.0/src/hugpy_agent/comms.py +223 -0
  11. hugpy_agent-0.1.0/src/hugpy_agent/config.py +383 -0
  12. hugpy_agent-0.1.0/src/hugpy_agent/eval.py +510 -0
  13. hugpy_agent-0.1.0/src/hugpy_agent/gateway.py +435 -0
  14. hugpy_agent-0.1.0/src/hugpy_agent/install.py +276 -0
  15. hugpy_agent-0.1.0/src/hugpy_agent/journal.py +292 -0
  16. hugpy_agent-0.1.0/src/hugpy_agent/loop.py +654 -0
  17. hugpy_agent-0.1.0/src/hugpy_agent/memory.py +51 -0
  18. hugpy_agent-0.1.0/src/hugpy_agent/node.py +536 -0
  19. hugpy_agent-0.1.0/src/hugpy_agent/policy.py +61 -0
  20. hugpy_agent-0.1.0/src/hugpy_agent/rag.py +194 -0
  21. hugpy_agent-0.1.0/src/hugpy_agent/serve.py +306 -0
  22. hugpy_agent-0.1.0/src/hugpy_agent/subagent.py +270 -0
  23. hugpy_agent-0.1.0/src/hugpy_agent/tools/__init__.py +297 -0
  24. hugpy_agent-0.1.0/src/hugpy_agent/tools/fleet.py +633 -0
  25. hugpy_agent-0.1.0/src/hugpy_agent/tools/fs.py +114 -0
  26. hugpy_agent-0.1.0/src/hugpy_agent/tools/http.py +41 -0
  27. hugpy_agent-0.1.0/src/hugpy_agent/tools/shell.py +56 -0
  28. hugpy_agent-0.1.0/src/hugpy_agent.egg-info/PKG-INFO +330 -0
  29. hugpy_agent-0.1.0/src/hugpy_agent.egg-info/SOURCES.txt +50 -0
  30. hugpy_agent-0.1.0/src/hugpy_agent.egg-info/dependency_links.txt +1 -0
  31. hugpy_agent-0.1.0/src/hugpy_agent.egg-info/entry_points.txt +2 -0
  32. hugpy_agent-0.1.0/src/hugpy_agent.egg-info/top_level.txt +1 -0
  33. hugpy_agent-0.1.0/tests/test_adapter.py +174 -0
  34. hugpy_agent-0.1.0/tests/test_audit.py +277 -0
  35. hugpy_agent-0.1.0/tests/test_comms.py +521 -0
  36. hugpy_agent-0.1.0/tests/test_config.py +103 -0
  37. hugpy_agent-0.1.0/tests/test_eval.py +265 -0
  38. hugpy_agent-0.1.0/tests/test_fleet.py +373 -0
  39. hugpy_agent-0.1.0/tests/test_fs_jail.py +91 -0
  40. hugpy_agent-0.1.0/tests/test_gateway.py +71 -0
  41. hugpy_agent-0.1.0/tests/test_install.py +287 -0
  42. hugpy_agent-0.1.0/tests/test_journal.py +81 -0
  43. hugpy_agent-0.1.0/tests/test_loop.py +381 -0
  44. hugpy_agent-0.1.0/tests/test_loop_guard.py +134 -0
  45. hugpy_agent-0.1.0/tests/test_memory.py +41 -0
  46. hugpy_agent-0.1.0/tests/test_node.py +763 -0
  47. hugpy_agent-0.1.0/tests/test_policy.py +216 -0
  48. hugpy_agent-0.1.0/tests/test_probe.py +202 -0
  49. hugpy_agent-0.1.0/tests/test_rag.py +279 -0
  50. hugpy_agent-0.1.0/tests/test_serve.py +398 -0
  51. hugpy_agent-0.1.0/tests/test_subagent.py +353 -0
  52. hugpy_agent-0.1.0/tests/test_think.py +286 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 putkoff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,330 @@
1
+ Metadata-Version: 2.4
2
+ Name: hugpy-agent
3
+ Version: 0.1.0
4
+ Summary: Portable agent runtime powered by the Hugpy self-hosted LLM fleet
5
+ Author-email: putkoff <support@hugpy.ai>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://hugpy.ai
8
+ Project-URL: Repository, https://github.com/hugpy/hugpy-agent
9
+ Project-URL: Issues, https://github.com/hugpy/hugpy-agent/issues
10
+ Keywords: hugpy,agent,llm,ai,automation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # hugpy-agent
23
+
24
+ Portable agent runtime that uses the [hugpy] self-hosted LLM fleet as its
25
+ inference brain. Phase 1 of `AGENT-SYSTEM-DESIGN.md`: gateway + tool-call
26
+ adapter, assess→act→observe loop with a crash-safe SQLite journal, a
27
+ workspace-jailed toolset, markdown memory, and a CLI. **Python ≥ 3.10,
28
+ stdlib only — zero dependencies.**
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ python3 -m venv .venv && . .venv/bin/activate
34
+ pip install -e .
35
+ hugpy-agent models # lists fleet models from the configured base
36
+ ```
37
+
38
+ ### Install as a service (one-command enrollment)
39
+
40
+ `bootstrap.sh` takes a bare box to a running systemd **user** service
41
+ (idempotent — re-run to upgrade):
42
+
43
+ ```sh
44
+ export HUGPY_API_KEY=<key> # prefer env over flags for secrets
45
+ bash bootstrap.sh --central https://dev.hugpy.ai/api \
46
+ [--session <discord-session-endpoint-url>] \
47
+ [--task-source discord-inbox|queue] [--workspace <dir>]
48
+ journalctl --user -u hugpy-agent -f # watch it heartbeat / run tasks
49
+ ```
50
+
51
+ It creates `~/hugpy-agent/venv`, pip-installs the package (from the local
52
+ checkout for now; PyPI later), then runs `python -m hugpy_agent.install`,
53
+ which writes `~/.config/systemd/user/hugpy-agent.service`
54
+ (`Restart=on-failure`, `%h`-portable, `ExecStart=… serve`), writes all
55
+ config **including the key** to the **0600** env file
56
+ `~/.config/hugpy-agent/agent.env` (never the unit file, never argv), enables
57
+ linger, and enables + starts the unit.
58
+
59
+ The `serve` daemon polls a task source every `HUGPY_POLL_INTERVAL` (10s)
60
+ and runs each task as a normal journaled run (policy, audit, escalation all
61
+ apply). Sources (`HUGPY_TASK_SOURCE`):
62
+
63
+ - `discord-inbox` — polls the operator session (`HUGPY_DISCORD_SESSION`)
64
+ for inbound messages starting with **`task:`**; the rest of the message is
65
+ the task. The outcome is replied into the channel
66
+ (`task finished (run <id>): outcome=… steps=…`). On startup the first poll
67
+ only sets the message watermark — historical `task:` messages are never
68
+ replayed (re-send a task that landed while the daemon was down).
69
+ - `queue` — a local file (`HUGPY_TASK_QUEUE`, default
70
+ `<workspace>/.hugpy_agent/tasks.queue`), one task per line; append lines
71
+ to dispatch. One task is consumed (atomically) per poll cycle.
72
+ - *(unset)* — **fail-closed idle**: the daemon heartbeats and does nothing.
73
+
74
+ SIGTERM (`systemctl --user stop hugpy-agent`) finishes the current task,
75
+ then exits cleanly.
76
+
77
+ ### Agent node mode (`serve --node`)
78
+
79
+ `serve --node` (or `HUGPY_AGENT_NODE=true`) makes the daemon a **fleet node**:
80
+ it enrolls once with central's `/agent/register`, heartbeats every ~30s, and
81
+ pulls **operator-dispatched** tasks — running each exactly like any other task
82
+ (policy, audit, escalation, journal all apply). It composes with the local
83
+ task source: `serve --node` alone runs node tasks only; `serve --node
84
+ --task-source queue` runs **both** (a queue file *and* dispatched tasks). Node
85
+ config:
86
+
87
+ - `HUGPY_AGENT_CENTRAL` (or `--central`) — base URL of central's `/agent/*`
88
+ routes; empty falls back to `HUGPY_BASE` (the `/api` dual-mount serves
89
+ `/agent` there). When central's site API-key policy is on, register needs a
90
+ console key — set `HUGPY_API_KEY` (it rides as the `Bearer` on register).
91
+ - `HUGPY_AGENT_NAME` / `HUGPY_AGENT_CAPABILITIES` — what the node advertises at
92
+ register (defaults: the box hostname / `chat,tools`).
93
+
94
+ The node's enroll token is minted **once** by register and persisted, with the
95
+ node id and pull cursor, to `<workspace>/.hugpy_agent/node_state.json` (mode
96
+ **0600**, gitignored) — never logged, never committed. If central forgets the
97
+ node (a `410`, e.g. a db reset) the daemon silently re-enrolls; a revoked node
98
+ (`403`) drops its dead token and idles until re-enrolled. An unreachable
99
+ central never crashes the daemon — it backs off (exponential, capped) and keeps
100
+ heartbeating to `journalctl`.
101
+
102
+ ## Configure
103
+
104
+ Precedence: **env > `.env` in the workspace > `agent.toml` in the workspace**.
105
+ CLI flags (`--base`, `--model`, `--workspace`, …) beat everything.
106
+
107
+ Each variable is two rows: the top row is `variable · default · values` (the
108
+ shape/enum it accepts), and the row beneath it is the full purpose.
109
+
110
+ | variable | default | values |
111
+ |---|---|---|
112
+ | `HUGPY_BASE` | `https://dev.hugpy.ai/api` | URL |
113
+ | **purpose** | fleet base URL — may end in `/api`, `/v1`, `/api/v1`, or be a bare origin; routes are normalized and probed | |
114
+ | `HUGPY_API_KEY` | *(none)* | `hp_…` token |
115
+ | **purpose** | Bearer token, sent whenever set. **Required for chat since 2026-07-14**: dev's `/v1` family now enforces keys (mint in the console under API access); the `/api/ml/*` amenities and `/api/models` catalog were still open at that time | |
116
+ | `HUGPY_MODEL` | `Qwen~Qwen3-Coder-Next-GGUF` | model id |
117
+ | **purpose** | a model id from `/v1/models` (default = the coder brain, 2026-07-17 switch: reliability over speed; for thinking-family brains see `HUGPY_NO_THINK`) | |
118
+ | `HUGPY_AGENT_BRAIN` | *(unset)* | model id |
119
+ | **purpose** | the DEDICATED agent-brain override — same effect as `HUGPY_MODEL` but named so it can't be conflated with other components' model knobs on a shared box; wins over `HUGPY_MODEL` when both are set. Central's copy of this default lives in `constants.DEFAULT_AGENT_BRAIN` | |
120
+ | `HUGPY_WORKSPACE` | cwd | path |
121
+ | **purpose** | the directory the agent works in (and is jailed to) | |
122
+ | `HUGPY_MAX_STEPS` | `25` | int |
123
+ | **purpose** | step cap per run | |
124
+ | `HUGPY_MAX_GENERATIONS` | `2` | int |
125
+ | **purpose** | per-run cap on async GPU generation jobs (`generate_image` / `generate_scene`); exceeding it returns a structured refusal to the model, not an exception | |
126
+ | `HUGPY_NO_THINK` | `false` | bool |
127
+ | **purpose** | suppress model "thinking": append ` /no_think` to the **wire copy** of the latest user turn on every chat call (never to stored history). Required for Qwen3-family brains, which otherwise spend the whole token budget inside `<think>` and never emit a tool call. Off by default since the 2026-07-17 coder-brain switch (~10% faster without the suffix); enable it when pointing at a thinking-family brain. Regardless of this knob, `<think>…</think>` spans are always stripped from output before parsing | |
128
+ | `HUGPY_TOOLS_MODE` | `prompted` | `prompted` \| `constrained` \| `auto` \| `native` |
129
+ | **purpose** | how tool-calls are delivered. The default makes **zero probe traffic**; only `auto`/`native` run the native-tools probe — one tiny live call per (base, model) per box, cached 7 days in `~/.cache/hugpy_agent/probe.json` (honors `XDG_CACHE_HOME`). The probe is expensive server-side today (the `/v1` shim drops `max_chunks` on the central→worker hop, 2026-07-14 capture), so it never fires unasked | |
130
+
131
+ `agent.toml` uses bare attribute names (`model = "..."`, optionally under
132
+ `[agent]`). Secrets belong in env or `.env` (gitignored), never in
133
+ `agent.toml` or the source.
134
+
135
+ ## Use
136
+
137
+ ```sh
138
+ hugpy-agent run "Read the files in this workspace, describe what the project \
139
+ does, and write your findings to report.md" --model Qwen2.5-3B-Instruct-GGUF
140
+ hugpy-agent chat # interactive REPL, same tools
141
+ hugpy-agent runs # journaled runs in this workspace
142
+ hugpy-agent resume <run_id> # continue an interrupted run
143
+ ```
144
+
145
+ Progress streams to stderr; the final structured report
146
+ `{outcome, steps, tool_calls, est_tokens, answer}` prints to stdout (pipeable
147
+ to `jq`).
148
+
149
+ ### Tools
150
+
151
+ | group | tools | notes |
152
+ |---|---|---|
153
+ | local | `fs_read` / `fs_write` / `fs_glob`, `shell`, `http_fetch` | workspace-jailed (symlink-safe); `shell` risk-classed destructive; fetch is GET-only, 64KB cap |
154
+ | fleet: text ML | `summarize`, `keywords`, `embed`, `similarity` | sync `POST /api/ml/*`; risk class `remote_compute` |
155
+ | fleet: file ML | `transcribe`, `classify`, `detect`, `segment`, `depth`, `vision` | multipart upload of a workspace file; image-ref results (depth maps, masks) are fetched into `artifacts/` and the path returned |
156
+ | fleet: generation | `generate_image`, `generate_scene` | async job: enqueue → poll (2s cadence, 10min ceiling, SIGINT-safe) → artifact saved as `artifacts/<job_id>.<ext>`; **capped per run** via `HUGPY_MAX_GENERATIONS`; the `job_id` is journaled the moment enqueue returns, so `resume` re-polls the same job and never enqueues a duplicate |
157
+ | meta | `models_list`, `remember`, `final_answer` | `final_answer` is the schema'd termination signal |
158
+
159
+ Every fleet ML tool takes an optional `model_key`; when omitted it is
160
+ resolved from the `/api/models` catalog by task (cached per run). An
161
+ unresolvable task, a capacity gap (`local_serving_disabled`), or any job
162
+ failure comes back to the model verbatim as structured data — never an
163
+ exception, never masked.
164
+
165
+ ### Tool calling on a fleet that ignores `tools`
166
+
167
+ The `/v1` seam silently drops the OpenAI `tools` field today, so the harness
168
+ owns tool-calling (design §3.1): tool JSON schemas are injected into the
169
+ system prompt using the Qwen/Hermes `<tool_call>{…}</tool_call>` convention,
170
+ parsed, schema-validated (with benign string→number/bool coercion), given
171
+ ONE repair round-trip on invalid output, and aborted with a structured error
172
+ if the model keeps failing. A native passthrough tier exists but is opt-in:
173
+ set `HUGPY_TOOLS_MODE=auto` (or `native`) to probe for seam support. The
174
+ probe result is cached per box — not per workspace — with a 7-day TTL, so a
175
+ machine probes each (base, model) pair at most once; under the default
176
+ `prompted` mode no probe request is ever sent.
177
+
178
+ Every chat payload carries `"max_chunks": 1` (kills the known
179
+ continuation-prompt leak) and known leak strings are scrubbed defensively.
180
+ `usage` is null at the seam today, so token accounting is a client-side
181
+ estimate (`gateway.estimate_tokens`).
182
+
183
+ ## Terminal dispatch client
184
+
185
+ `hugpy-dispatch` is a **separate, standalone tool** from the agent runtime
186
+ above: a pure-bash operator client (bash + curl + python3 for JSON — no
187
+ Python client, no venv) for dispatching a task to an already-enrolled
188
+ **agent node** (a box running `hugpy-agent serve --node`) and watching it run,
189
+ entirely from a terminal on any box — no browser, no console.
190
+
191
+ ### Install (one line, any box)
192
+
193
+ ```sh
194
+ curl -fsSL https://dev.hugpy.ai/api/agent/client.sh | bash -s install
195
+ ```
196
+
197
+ This downloads the client and installs it to `$HOME/.local/bin/hugpy-dispatch`
198
+ (pass `--prefix DIR` to install elsewhere), and creates a config template at
199
+ `~/.config/hugpy/dispatch.env` (mode `600`) if one doesn't already exist.
200
+ Re-running is safe — it reinstalls the script but never overwrites an
201
+ existing config. Add `$HOME/.local/bin` to your `PATH` if it isn't already
202
+ (the installer warns if it's missing).
203
+
204
+ ### Configure
205
+
206
+ Edit `~/.config/hugpy/dispatch.env` (keep it mode `600` — it holds a secret).
207
+ Real environment variables always win over this file:
208
+
209
+ | variable | default | purpose |
210
+ |---|---|---|
211
+ | `HUGPY_CENTRAL` | `https://dev.hugpy.ai/api` | central's API base URL |
212
+ | `HUGPY_OPERATOR_TOKEN` | *(none)* | **required** for every call — the same operator token the console uses (`X-Operator-Token`) |
213
+ | `HUGPY_DISPATCH_NODE` | *(none)* | default target node id (`agn_...`) or name, used when `-n` is omitted |
214
+
215
+ ### Use
216
+
217
+ ```sh
218
+ hugpy-dispatch "reply with the single word pong" # dispatch to the default node
219
+ hugpy-dispatch -n demo-node-vm "summarize report.md" # dispatch to a specific node, by name or agn_ id
220
+ hugpy-dispatch --timeout 600 "a longer task..." # override the 300s poll ceiling
221
+ hugpy-dispatch nodes # table of every enrolled node
222
+ ```
223
+
224
+ `hugpy-dispatch` (default action) dispatches, then polls every 2s until the
225
+ task finishes:
226
+
227
+ - **done** → result prints to stdout, exit `0`
228
+ - **error** → result prints to stderr, exit `1`
229
+ - **timeout** (default 300s, `--timeout SECS`) → the node id and task seq
230
+ print to stderr so you can poll it again later, exit `2`
231
+
232
+ `-n/--node` accepts either a node's exact id (`agn_...`) or its name; a name
233
+ is resolved via `GET /agent/nodes`, preferring a non-revoked node and, if more
234
+ than one node shares the name, the most recently seen one.
235
+
236
+ `hugpy-dispatch nodes` prints id, name, status, revoked, `last_seen` (as
237
+ "Ns/m/h/d ago"), and current_task for every enrolled node.
238
+
239
+ **A task's result travels the wire as a plain string.** When the node
240
+ returns structured output it arrives `json.dumps`'d; `hugpy-dispatch` tries
241
+ to `json.loads` and pretty-print it, and falls back to printing the raw
242
+ string when it doesn't parse.
243
+
244
+ ## Eval harness (per-model scoring)
245
+
246
+ "Which brain" is data, not vibes (design §7). `hugpy-agent eval` runs a small
247
+ suite of deterministic tasks against each model through the *real* agent loop
248
+ and emits a comparative scorecard:
249
+
250
+ ```sh
251
+ hugpy-agent eval --model Qwen~Qwen3-Coder-Next-GGUF \
252
+ --model ponpoke/flux2-klein-9b-uncensored-text-encoder
253
+ # or straight from a checkout, no install:
254
+ python evals/runner.py --model A --model B
255
+ ```
256
+
257
+ Each task has a **deterministic** checker (an artifact appeared on disk / the
258
+ final answer contains a required fact / the run finished under its step cap) —
259
+ never an LLM judging an LLM. The scorecard row per model is
260
+ `{model, ready, worker, passed, steps_avg, tokens_avg, wall_avg, tool_accuracy}`;
261
+ JSON + a readable table land in `evals/results/` (timestamped runs gitignored;
262
+ the chosen summary committed). Live cost is bounded by design: tiny prompts,
263
+ low per-task step caps, small token budgets.
264
+
265
+ **Readiness is gated on a chat token-echo**, never on HTTP 200 or an
266
+ `/api/llm/serving` mode flag — a worker that is down or still loading answers
267
+ `/v1/chat/completions` with a 200 whose *body* is an error string
268
+ (`[error: … 404 NOT FOUND …]`). A cold model's first request is handled by
269
+ polite polling (`--ready-timeout`/`--ready-poll`); a model that never becomes
270
+ servable gets a `ready=NO` row so the blocker is in the data. The engine lives
271
+ in `hugpy_agent.eval` (packaged, unit-tested); `evals/` is the operator surface
272
+ (`tasks.py` suite + `runner.py`).
273
+
274
+ ## Crash resume
275
+
276
+ Every message and every tool call is journaled to
277
+ `<workspace>/.hugpy_agent/journal.db` (SQLite WAL). Tool calls are recorded
278
+ **before** execution with an idempotency key and their result recorded after,
279
+ so resume replays completed calls instead of re-executing side effects; a
280
+ call left `pending` (killed mid-execution) is reported to the model as
281
+ "outcome unknown — verify before retrying" rather than blindly re-run
282
+ (fail-closed), unless the tool is read-only, in which case it is safely
283
+ re-executed.
284
+
285
+ **Manual kill/resume procedure** (what the automated tests simulate):
286
+
287
+ ```sh
288
+ hugpy-agent run "some multi-step task" &
289
+ sleep 10 && kill -9 %1 # SIGKILL mid-run — no cleanup possible
290
+ hugpy-agent runs # find the run_id (status: running)
291
+ hugpy-agent resume <run_id> # continues; completed side effects replayed
292
+ ```
293
+
294
+ A single Ctrl-C is gentler: the loop finishes the current step, marks the run
295
+ `interrupted`, and prints the resume command. A second Ctrl-C force-quits
296
+ (the journal is still consistent — every write is its own transaction).
297
+
298
+ ## Tests
299
+
300
+ ```sh
301
+ python -m unittest discover tests # offline, no network
302
+ HUGPY_AGENT_LIVE=1 python scripts/live_smoke.py # 3 tiny live calls to dev
303
+ # ML section (spends GPU: one embed + one small sd-turbo image) needs BOTH:
304
+ HUGPY_AGENT_LIVE=1 HUGPY_AGENT_LIVE_ML=1 python scripts/live_smoke.py
305
+ ```
306
+
307
+ ## Layout
308
+
309
+ ```
310
+ src/hugpy_agent/
311
+ config.py env > .env > agent.toml resolution
312
+ gateway.py OpenAI-compat client: SSE + fallback, retries, route probing,
313
+ max_chunks=1, client-side token estimate, cancel
314
+ adapter.py tool-calling tiers: native | prompted (Qwen/Hermes) | constrained
315
+ journal.py SQLite WAL ledger: runs, messages, tool_calls, idempotency
316
+ loop.py assess→act→observe, step cap, compaction, structured report
317
+ memory.py memory/ markdown facts + MEMORY.md index
318
+ tools/ registry + shell, fs, http, fleet (ML amenities + generation)
319
+ serve.py the daemon loop: task sources (discord-inbox | queue) + heartbeat
320
+ node.py agent node mode (P3.2): register/heartbeat/pull client of
321
+ central's /agent/* registry + the serve source that drives it
322
+ install.py systemd user unit + 0600 env file + linger (injectable runner)
323
+ eval.py per-model eval engine: tasks + deterministic checkers, token-
324
+ echo readiness gate, scorecard math + rendering (P3.4)
325
+ cli.py run | chat | resume | models | runs | serve | eval
326
+ evals/ operator surface: tasks.py (suite) + runner.py + results/
327
+ ```
328
+
329
+ Seed lineage: the wire-contract client code is lifted from the field-tested
330
+ `abstract_ide` hugpyTab/servicesTab clients (see module docstrings).
@@ -0,0 +1,309 @@
1
+ # hugpy-agent
2
+
3
+ Portable agent runtime that uses the [hugpy] self-hosted LLM fleet as its
4
+ inference brain. Phase 1 of `AGENT-SYSTEM-DESIGN.md`: gateway + tool-call
5
+ adapter, assess→act→observe loop with a crash-safe SQLite journal, a
6
+ workspace-jailed toolset, markdown memory, and a CLI. **Python ≥ 3.10,
7
+ stdlib only — zero dependencies.**
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ python3 -m venv .venv && . .venv/bin/activate
13
+ pip install -e .
14
+ hugpy-agent models # lists fleet models from the configured base
15
+ ```
16
+
17
+ ### Install as a service (one-command enrollment)
18
+
19
+ `bootstrap.sh` takes a bare box to a running systemd **user** service
20
+ (idempotent — re-run to upgrade):
21
+
22
+ ```sh
23
+ export HUGPY_API_KEY=<key> # prefer env over flags for secrets
24
+ bash bootstrap.sh --central https://dev.hugpy.ai/api \
25
+ [--session <discord-session-endpoint-url>] \
26
+ [--task-source discord-inbox|queue] [--workspace <dir>]
27
+ journalctl --user -u hugpy-agent -f # watch it heartbeat / run tasks
28
+ ```
29
+
30
+ It creates `~/hugpy-agent/venv`, pip-installs the package (from the local
31
+ checkout for now; PyPI later), then runs `python -m hugpy_agent.install`,
32
+ which writes `~/.config/systemd/user/hugpy-agent.service`
33
+ (`Restart=on-failure`, `%h`-portable, `ExecStart=… serve`), writes all
34
+ config **including the key** to the **0600** env file
35
+ `~/.config/hugpy-agent/agent.env` (never the unit file, never argv), enables
36
+ linger, and enables + starts the unit.
37
+
38
+ The `serve` daemon polls a task source every `HUGPY_POLL_INTERVAL` (10s)
39
+ and runs each task as a normal journaled run (policy, audit, escalation all
40
+ apply). Sources (`HUGPY_TASK_SOURCE`):
41
+
42
+ - `discord-inbox` — polls the operator session (`HUGPY_DISCORD_SESSION`)
43
+ for inbound messages starting with **`task:`**; the rest of the message is
44
+ the task. The outcome is replied into the channel
45
+ (`task finished (run <id>): outcome=… steps=…`). On startup the first poll
46
+ only sets the message watermark — historical `task:` messages are never
47
+ replayed (re-send a task that landed while the daemon was down).
48
+ - `queue` — a local file (`HUGPY_TASK_QUEUE`, default
49
+ `<workspace>/.hugpy_agent/tasks.queue`), one task per line; append lines
50
+ to dispatch. One task is consumed (atomically) per poll cycle.
51
+ - *(unset)* — **fail-closed idle**: the daemon heartbeats and does nothing.
52
+
53
+ SIGTERM (`systemctl --user stop hugpy-agent`) finishes the current task,
54
+ then exits cleanly.
55
+
56
+ ### Agent node mode (`serve --node`)
57
+
58
+ `serve --node` (or `HUGPY_AGENT_NODE=true`) makes the daemon a **fleet node**:
59
+ it enrolls once with central's `/agent/register`, heartbeats every ~30s, and
60
+ pulls **operator-dispatched** tasks — running each exactly like any other task
61
+ (policy, audit, escalation, journal all apply). It composes with the local
62
+ task source: `serve --node` alone runs node tasks only; `serve --node
63
+ --task-source queue` runs **both** (a queue file *and* dispatched tasks). Node
64
+ config:
65
+
66
+ - `HUGPY_AGENT_CENTRAL` (or `--central`) — base URL of central's `/agent/*`
67
+ routes; empty falls back to `HUGPY_BASE` (the `/api` dual-mount serves
68
+ `/agent` there). When central's site API-key policy is on, register needs a
69
+ console key — set `HUGPY_API_KEY` (it rides as the `Bearer` on register).
70
+ - `HUGPY_AGENT_NAME` / `HUGPY_AGENT_CAPABILITIES` — what the node advertises at
71
+ register (defaults: the box hostname / `chat,tools`).
72
+
73
+ The node's enroll token is minted **once** by register and persisted, with the
74
+ node id and pull cursor, to `<workspace>/.hugpy_agent/node_state.json` (mode
75
+ **0600**, gitignored) — never logged, never committed. If central forgets the
76
+ node (a `410`, e.g. a db reset) the daemon silently re-enrolls; a revoked node
77
+ (`403`) drops its dead token and idles until re-enrolled. An unreachable
78
+ central never crashes the daemon — it backs off (exponential, capped) and keeps
79
+ heartbeating to `journalctl`.
80
+
81
+ ## Configure
82
+
83
+ Precedence: **env > `.env` in the workspace > `agent.toml` in the workspace**.
84
+ CLI flags (`--base`, `--model`, `--workspace`, …) beat everything.
85
+
86
+ Each variable is two rows: the top row is `variable · default · values` (the
87
+ shape/enum it accepts), and the row beneath it is the full purpose.
88
+
89
+ | variable | default | values |
90
+ |---|---|---|
91
+ | `HUGPY_BASE` | `https://dev.hugpy.ai/api` | URL |
92
+ | **purpose** | fleet base URL — may end in `/api`, `/v1`, `/api/v1`, or be a bare origin; routes are normalized and probed | |
93
+ | `HUGPY_API_KEY` | *(none)* | `hp_…` token |
94
+ | **purpose** | Bearer token, sent whenever set. **Required for chat since 2026-07-14**: dev's `/v1` family now enforces keys (mint in the console under API access); the `/api/ml/*` amenities and `/api/models` catalog were still open at that time | |
95
+ | `HUGPY_MODEL` | `Qwen~Qwen3-Coder-Next-GGUF` | model id |
96
+ | **purpose** | a model id from `/v1/models` (default = the coder brain, 2026-07-17 switch: reliability over speed; for thinking-family brains see `HUGPY_NO_THINK`) | |
97
+ | `HUGPY_AGENT_BRAIN` | *(unset)* | model id |
98
+ | **purpose** | the DEDICATED agent-brain override — same effect as `HUGPY_MODEL` but named so it can't be conflated with other components' model knobs on a shared box; wins over `HUGPY_MODEL` when both are set. Central's copy of this default lives in `constants.DEFAULT_AGENT_BRAIN` | |
99
+ | `HUGPY_WORKSPACE` | cwd | path |
100
+ | **purpose** | the directory the agent works in (and is jailed to) | |
101
+ | `HUGPY_MAX_STEPS` | `25` | int |
102
+ | **purpose** | step cap per run | |
103
+ | `HUGPY_MAX_GENERATIONS` | `2` | int |
104
+ | **purpose** | per-run cap on async GPU generation jobs (`generate_image` / `generate_scene`); exceeding it returns a structured refusal to the model, not an exception | |
105
+ | `HUGPY_NO_THINK` | `false` | bool |
106
+ | **purpose** | suppress model "thinking": append ` /no_think` to the **wire copy** of the latest user turn on every chat call (never to stored history). Required for Qwen3-family brains, which otherwise spend the whole token budget inside `<think>` and never emit a tool call. Off by default since the 2026-07-17 coder-brain switch (~10% faster without the suffix); enable it when pointing at a thinking-family brain. Regardless of this knob, `<think>…</think>` spans are always stripped from output before parsing | |
107
+ | `HUGPY_TOOLS_MODE` | `prompted` | `prompted` \| `constrained` \| `auto` \| `native` |
108
+ | **purpose** | how tool-calls are delivered. The default makes **zero probe traffic**; only `auto`/`native` run the native-tools probe — one tiny live call per (base, model) per box, cached 7 days in `~/.cache/hugpy_agent/probe.json` (honors `XDG_CACHE_HOME`). The probe is expensive server-side today (the `/v1` shim drops `max_chunks` on the central→worker hop, 2026-07-14 capture), so it never fires unasked | |
109
+
110
+ `agent.toml` uses bare attribute names (`model = "..."`, optionally under
111
+ `[agent]`). Secrets belong in env or `.env` (gitignored), never in
112
+ `agent.toml` or the source.
113
+
114
+ ## Use
115
+
116
+ ```sh
117
+ hugpy-agent run "Read the files in this workspace, describe what the project \
118
+ does, and write your findings to report.md" --model Qwen2.5-3B-Instruct-GGUF
119
+ hugpy-agent chat # interactive REPL, same tools
120
+ hugpy-agent runs # journaled runs in this workspace
121
+ hugpy-agent resume <run_id> # continue an interrupted run
122
+ ```
123
+
124
+ Progress streams to stderr; the final structured report
125
+ `{outcome, steps, tool_calls, est_tokens, answer}` prints to stdout (pipeable
126
+ to `jq`).
127
+
128
+ ### Tools
129
+
130
+ | group | tools | notes |
131
+ |---|---|---|
132
+ | local | `fs_read` / `fs_write` / `fs_glob`, `shell`, `http_fetch` | workspace-jailed (symlink-safe); `shell` risk-classed destructive; fetch is GET-only, 64KB cap |
133
+ | fleet: text ML | `summarize`, `keywords`, `embed`, `similarity` | sync `POST /api/ml/*`; risk class `remote_compute` |
134
+ | fleet: file ML | `transcribe`, `classify`, `detect`, `segment`, `depth`, `vision` | multipart upload of a workspace file; image-ref results (depth maps, masks) are fetched into `artifacts/` and the path returned |
135
+ | fleet: generation | `generate_image`, `generate_scene` | async job: enqueue → poll (2s cadence, 10min ceiling, SIGINT-safe) → artifact saved as `artifacts/<job_id>.<ext>`; **capped per run** via `HUGPY_MAX_GENERATIONS`; the `job_id` is journaled the moment enqueue returns, so `resume` re-polls the same job and never enqueues a duplicate |
136
+ | meta | `models_list`, `remember`, `final_answer` | `final_answer` is the schema'd termination signal |
137
+
138
+ Every fleet ML tool takes an optional `model_key`; when omitted it is
139
+ resolved from the `/api/models` catalog by task (cached per run). An
140
+ unresolvable task, a capacity gap (`local_serving_disabled`), or any job
141
+ failure comes back to the model verbatim as structured data — never an
142
+ exception, never masked.
143
+
144
+ ### Tool calling on a fleet that ignores `tools`
145
+
146
+ The `/v1` seam silently drops the OpenAI `tools` field today, so the harness
147
+ owns tool-calling (design §3.1): tool JSON schemas are injected into the
148
+ system prompt using the Qwen/Hermes `<tool_call>{…}</tool_call>` convention,
149
+ parsed, schema-validated (with benign string→number/bool coercion), given
150
+ ONE repair round-trip on invalid output, and aborted with a structured error
151
+ if the model keeps failing. A native passthrough tier exists but is opt-in:
152
+ set `HUGPY_TOOLS_MODE=auto` (or `native`) to probe for seam support. The
153
+ probe result is cached per box — not per workspace — with a 7-day TTL, so a
154
+ machine probes each (base, model) pair at most once; under the default
155
+ `prompted` mode no probe request is ever sent.
156
+
157
+ Every chat payload carries `"max_chunks": 1` (kills the known
158
+ continuation-prompt leak) and known leak strings are scrubbed defensively.
159
+ `usage` is null at the seam today, so token accounting is a client-side
160
+ estimate (`gateway.estimate_tokens`).
161
+
162
+ ## Terminal dispatch client
163
+
164
+ `hugpy-dispatch` is a **separate, standalone tool** from the agent runtime
165
+ above: a pure-bash operator client (bash + curl + python3 for JSON — no
166
+ Python client, no venv) for dispatching a task to an already-enrolled
167
+ **agent node** (a box running `hugpy-agent serve --node`) and watching it run,
168
+ entirely from a terminal on any box — no browser, no console.
169
+
170
+ ### Install (one line, any box)
171
+
172
+ ```sh
173
+ curl -fsSL https://dev.hugpy.ai/api/agent/client.sh | bash -s install
174
+ ```
175
+
176
+ This downloads the client and installs it to `$HOME/.local/bin/hugpy-dispatch`
177
+ (pass `--prefix DIR` to install elsewhere), and creates a config template at
178
+ `~/.config/hugpy/dispatch.env` (mode `600`) if one doesn't already exist.
179
+ Re-running is safe — it reinstalls the script but never overwrites an
180
+ existing config. Add `$HOME/.local/bin` to your `PATH` if it isn't already
181
+ (the installer warns if it's missing).
182
+
183
+ ### Configure
184
+
185
+ Edit `~/.config/hugpy/dispatch.env` (keep it mode `600` — it holds a secret).
186
+ Real environment variables always win over this file:
187
+
188
+ | variable | default | purpose |
189
+ |---|---|---|
190
+ | `HUGPY_CENTRAL` | `https://dev.hugpy.ai/api` | central's API base URL |
191
+ | `HUGPY_OPERATOR_TOKEN` | *(none)* | **required** for every call — the same operator token the console uses (`X-Operator-Token`) |
192
+ | `HUGPY_DISPATCH_NODE` | *(none)* | default target node id (`agn_...`) or name, used when `-n` is omitted |
193
+
194
+ ### Use
195
+
196
+ ```sh
197
+ hugpy-dispatch "reply with the single word pong" # dispatch to the default node
198
+ hugpy-dispatch -n demo-node-vm "summarize report.md" # dispatch to a specific node, by name or agn_ id
199
+ hugpy-dispatch --timeout 600 "a longer task..." # override the 300s poll ceiling
200
+ hugpy-dispatch nodes # table of every enrolled node
201
+ ```
202
+
203
+ `hugpy-dispatch` (default action) dispatches, then polls every 2s until the
204
+ task finishes:
205
+
206
+ - **done** → result prints to stdout, exit `0`
207
+ - **error** → result prints to stderr, exit `1`
208
+ - **timeout** (default 300s, `--timeout SECS`) → the node id and task seq
209
+ print to stderr so you can poll it again later, exit `2`
210
+
211
+ `-n/--node` accepts either a node's exact id (`agn_...`) or its name; a name
212
+ is resolved via `GET /agent/nodes`, preferring a non-revoked node and, if more
213
+ than one node shares the name, the most recently seen one.
214
+
215
+ `hugpy-dispatch nodes` prints id, name, status, revoked, `last_seen` (as
216
+ "Ns/m/h/d ago"), and current_task for every enrolled node.
217
+
218
+ **A task's result travels the wire as a plain string.** When the node
219
+ returns structured output it arrives `json.dumps`'d; `hugpy-dispatch` tries
220
+ to `json.loads` and pretty-print it, and falls back to printing the raw
221
+ string when it doesn't parse.
222
+
223
+ ## Eval harness (per-model scoring)
224
+
225
+ "Which brain" is data, not vibes (design §7). `hugpy-agent eval` runs a small
226
+ suite of deterministic tasks against each model through the *real* agent loop
227
+ and emits a comparative scorecard:
228
+
229
+ ```sh
230
+ hugpy-agent eval --model Qwen~Qwen3-Coder-Next-GGUF \
231
+ --model ponpoke/flux2-klein-9b-uncensored-text-encoder
232
+ # or straight from a checkout, no install:
233
+ python evals/runner.py --model A --model B
234
+ ```
235
+
236
+ Each task has a **deterministic** checker (an artifact appeared on disk / the
237
+ final answer contains a required fact / the run finished under its step cap) —
238
+ never an LLM judging an LLM. The scorecard row per model is
239
+ `{model, ready, worker, passed, steps_avg, tokens_avg, wall_avg, tool_accuracy}`;
240
+ JSON + a readable table land in `evals/results/` (timestamped runs gitignored;
241
+ the chosen summary committed). Live cost is bounded by design: tiny prompts,
242
+ low per-task step caps, small token budgets.
243
+
244
+ **Readiness is gated on a chat token-echo**, never on HTTP 200 or an
245
+ `/api/llm/serving` mode flag — a worker that is down or still loading answers
246
+ `/v1/chat/completions` with a 200 whose *body* is an error string
247
+ (`[error: … 404 NOT FOUND …]`). A cold model's first request is handled by
248
+ polite polling (`--ready-timeout`/`--ready-poll`); a model that never becomes
249
+ servable gets a `ready=NO` row so the blocker is in the data. The engine lives
250
+ in `hugpy_agent.eval` (packaged, unit-tested); `evals/` is the operator surface
251
+ (`tasks.py` suite + `runner.py`).
252
+
253
+ ## Crash resume
254
+
255
+ Every message and every tool call is journaled to
256
+ `<workspace>/.hugpy_agent/journal.db` (SQLite WAL). Tool calls are recorded
257
+ **before** execution with an idempotency key and their result recorded after,
258
+ so resume replays completed calls instead of re-executing side effects; a
259
+ call left `pending` (killed mid-execution) is reported to the model as
260
+ "outcome unknown — verify before retrying" rather than blindly re-run
261
+ (fail-closed), unless the tool is read-only, in which case it is safely
262
+ re-executed.
263
+
264
+ **Manual kill/resume procedure** (what the automated tests simulate):
265
+
266
+ ```sh
267
+ hugpy-agent run "some multi-step task" &
268
+ sleep 10 && kill -9 %1 # SIGKILL mid-run — no cleanup possible
269
+ hugpy-agent runs # find the run_id (status: running)
270
+ hugpy-agent resume <run_id> # continues; completed side effects replayed
271
+ ```
272
+
273
+ A single Ctrl-C is gentler: the loop finishes the current step, marks the run
274
+ `interrupted`, and prints the resume command. A second Ctrl-C force-quits
275
+ (the journal is still consistent — every write is its own transaction).
276
+
277
+ ## Tests
278
+
279
+ ```sh
280
+ python -m unittest discover tests # offline, no network
281
+ HUGPY_AGENT_LIVE=1 python scripts/live_smoke.py # 3 tiny live calls to dev
282
+ # ML section (spends GPU: one embed + one small sd-turbo image) needs BOTH:
283
+ HUGPY_AGENT_LIVE=1 HUGPY_AGENT_LIVE_ML=1 python scripts/live_smoke.py
284
+ ```
285
+
286
+ ## Layout
287
+
288
+ ```
289
+ src/hugpy_agent/
290
+ config.py env > .env > agent.toml resolution
291
+ gateway.py OpenAI-compat client: SSE + fallback, retries, route probing,
292
+ max_chunks=1, client-side token estimate, cancel
293
+ adapter.py tool-calling tiers: native | prompted (Qwen/Hermes) | constrained
294
+ journal.py SQLite WAL ledger: runs, messages, tool_calls, idempotency
295
+ loop.py assess→act→observe, step cap, compaction, structured report
296
+ memory.py memory/ markdown facts + MEMORY.md index
297
+ tools/ registry + shell, fs, http, fleet (ML amenities + generation)
298
+ serve.py the daemon loop: task sources (discord-inbox | queue) + heartbeat
299
+ node.py agent node mode (P3.2): register/heartbeat/pull client of
300
+ central's /agent/* registry + the serve source that drives it
301
+ install.py systemd user unit + 0600 env file + linger (injectable runner)
302
+ eval.py per-model eval engine: tasks + deterministic checkers, token-
303
+ echo readiness gate, scorecard math + rendering (P3.4)
304
+ cli.py run | chat | resume | models | runs | serve | eval
305
+ evals/ operator surface: tasks.py (suite) + runner.py + results/
306
+ ```
307
+
308
+ Seed lineage: the wire-contract client code is lifted from the field-tested
309
+ `abstract_ide` hugpyTab/servicesTab clients (see module docstrings).