hubzoid 0.2.2__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.
- hubzoid/__init__.py +10 -0
- hubzoid/__main__.py +12 -0
- hubzoid/_fs.py +56 -0
- hubzoid/cli.py +482 -0
- hubzoid/factory.py +135 -0
- hubzoid/factory_claude.py +292 -0
- hubzoid/frontmatter.py +63 -0
- hubzoid/loaders/__init__.py +0 -0
- hubzoid/loaders/agents.py +129 -0
- hubzoid/loaders/knowledge.py +50 -0
- hubzoid/loaders/mcp.py +107 -0
- hubzoid/loaders/skills.py +77 -0
- hubzoid/loaders/tools_local.py +57 -0
- hubzoid/memory.py +21 -0
- hubzoid/model.py +60 -0
- hubzoid/runtime.py +104 -0
- hubzoid/server.py +182 -0
- hubzoid/settings.py +69 -0
- hubzoid/templates/starter/.gitignore +6 -0
- hubzoid/templates/starter/AGENTS.md +95 -0
- hubzoid/templates/starter/agents/builder/AGENTS.md +97 -0
- hubzoid/templates/starter/connectors/.mcp.json +3 -0
- hubzoid/templates/starter/knowledge/agents-md-format.md +59 -0
- hubzoid/templates/starter/knowledge/hub-folder-layout.md +65 -0
- hubzoid/templates/starter/knowledge/mcp-and-connectors.md +71 -0
- hubzoid/templates/starter/knowledge/three-agent-types.md +63 -0
- hubzoid/templates/starter/knowledge/welcome.md +42 -0
- hubzoid/templates/starter/knowledge/what-is-hubzoid.md +66 -0
- hubzoid/templates/starter/skills/build-first-agent/SKILL.md +74 -0
- hubzoid/templates/starter/skills/explain-skills/SKILL.md +73 -0
- hubzoid/templates/starter/skills/find-the-docs/SKILL.md +59 -0
- hubzoid/templates/starter/skills/inspect-this-hub/SKILL.md +70 -0
- hubzoid/templates/starter/tools_local/current_time.py +35 -0
- hubzoid/tools/__init__.py +21 -0
- hubzoid/tools/files.py +74 -0
- hubzoid/tools/knowledge.py +47 -0
- hubzoid/tools/memory.py +125 -0
- hubzoid/tools/render.py +38 -0
- hubzoid/tools/skills_tool.py +48 -0
- hubzoid/tools/web_http.py +91 -0
- hubzoid/webui.py +69 -0
- hubzoid-0.2.2.dist-info/METADATA +352 -0
- hubzoid-0.2.2.dist-info/RECORD +47 -0
- hubzoid-0.2.2.dist-info/WHEEL +5 -0
- hubzoid-0.2.2.dist-info/entry_points.txt +2 -0
- hubzoid-0.2.2.dist-info/licenses/LICENSE +21 -0
- hubzoid-0.2.2.dist-info/top_level.txt +1 -0
hubzoid/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""HubZoid — markdown-driven AI agent platform.
|
|
2
|
+
|
|
3
|
+
Drop AGENTS.md + skills/ + knowledge/ into a folder, get a working chat agent
|
|
4
|
+
with a polished web UI. See README.md.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.2"
|
|
9
|
+
|
|
10
|
+
from .factory import build_agent # noqa: E402,F401 (public re-export)
|
hubzoid/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Enables `python -m hubzoid …` as an alias for the installed `hubzoid` CLI.
|
|
2
|
+
|
|
3
|
+
Used by the no-install clone path:
|
|
4
|
+
git clone hubzoid && cd hubzoid && pip install -r requirements.txt
|
|
5
|
+
python -m hubzoid run demo-hub
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .cli import app
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
app()
|
hubzoid/_fs.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Case- and plural-insensitive folder resolution within a hub directory.
|
|
2
|
+
|
|
3
|
+
Hub authors may type `Skills/`, `skills/`, `skill/`, `Skill/` — all should
|
|
4
|
+
resolve to the same logical bucket. We pick the first match found on disk
|
|
5
|
+
(alphabetical by actual name) and warn if multiple are present.
|
|
6
|
+
|
|
7
|
+
The canonical names used internally are: agents, skills, knowledge, tools_local,
|
|
8
|
+
connectors, output. The mapping accepts the singular and any case variant.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Map a canonical bucket → set of acceptable directory names (lowercase).
|
|
18
|
+
# Singular and plural both accepted. Case-insensitive at match time.
|
|
19
|
+
_ALIASES: dict[str, tuple[str, ...]] = {
|
|
20
|
+
"agents": ("agents", "agent"),
|
|
21
|
+
"skills": ("skills", "skill"),
|
|
22
|
+
"knowledge": ("knowledge",),
|
|
23
|
+
"tools_local": ("tools_local", "tool_local", "tools", "local_tools"),
|
|
24
|
+
"connectors": ("connectors", "connector"),
|
|
25
|
+
"output": ("output", "outputs"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_bucket(hub_dir: Path, bucket: str) -> Path | None:
|
|
30
|
+
"""Return the actual on-disk path for the given canonical bucket, or None.
|
|
31
|
+
|
|
32
|
+
`bucket` must be one of the keys in `_ALIASES`. Folder names are matched
|
|
33
|
+
case-insensitively; singular/plural variants are accepted. If two valid
|
|
34
|
+
variants exist (e.g. both `Skills/` and `skill/`), the alphabetically-first
|
|
35
|
+
match is returned and a warning is logged.
|
|
36
|
+
"""
|
|
37
|
+
if bucket not in _ALIASES:
|
|
38
|
+
raise ValueError(f"unknown bucket: {bucket!r}")
|
|
39
|
+
accepted = {a.lower() for a in _ALIASES[bucket]}
|
|
40
|
+
|
|
41
|
+
matches: list[Path] = []
|
|
42
|
+
if hub_dir.is_dir():
|
|
43
|
+
for child in sorted(hub_dir.iterdir(), key=lambda p: p.name.lower()):
|
|
44
|
+
if child.is_dir() and child.name.lower() in accepted:
|
|
45
|
+
matches.append(child)
|
|
46
|
+
|
|
47
|
+
if not matches:
|
|
48
|
+
return None
|
|
49
|
+
if len(matches) > 1:
|
|
50
|
+
names = ", ".join(m.name for m in matches)
|
|
51
|
+
log.warning(
|
|
52
|
+
"multiple folders match the %r bucket (%s) — using %r. "
|
|
53
|
+
"Consolidate to one to avoid surprises.",
|
|
54
|
+
bucket, names, matches[0].name,
|
|
55
|
+
)
|
|
56
|
+
return matches[0]
|
hubzoid/cli.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""hubzoid CLI — typer app.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
hubzoid init [PATH] Scaffold a hub from the bundled template.
|
|
5
|
+
hubzoid run [PATH] Start FastAPI bridge + Open WebUI for a hub.
|
|
6
|
+
hubzoid doctor [PATH] Validate hub config and report issues.
|
|
7
|
+
hubzoid test [PATH] Send a hello prompt and assert non-empty response.
|
|
8
|
+
hubzoid version Print version.
|
|
9
|
+
|
|
10
|
+
Path defaults to `.` (the current directory) everywhere.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.resources as resources
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from . import settings as settingslib
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
name="hubzoid",
|
|
31
|
+
add_completion=False,
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
help="Drop a folder of markdown files, get a chat agent with a polished web UI.",
|
|
34
|
+
)
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# init
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
@app.command()
|
|
42
|
+
def init(
|
|
43
|
+
name: Path = typer.Argument(
|
|
44
|
+
Path("demo-hub"),
|
|
45
|
+
help="Name of the new hub folder. Created under the current directory. Default: demo-hub.",
|
|
46
|
+
),
|
|
47
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files in the hub folder."),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Scaffold a new hub. Also drops agents-repo wrapper files if the parent looks fresh.
|
|
50
|
+
|
|
51
|
+
First run in an empty directory:
|
|
52
|
+
$ hubzoid init devops-agent
|
|
53
|
+
→ writes ./requirements.txt, ./.gitignore, ./README.md, ./devops-agent/...
|
|
54
|
+
|
|
55
|
+
Second run in the same directory:
|
|
56
|
+
$ hubzoid init irs-agent
|
|
57
|
+
→ writes ./irs-agent/... only. Parent files are left alone.
|
|
58
|
+
|
|
59
|
+
The result is a Samarth-style multi-hub agents repo built one hub at a time.
|
|
60
|
+
"""
|
|
61
|
+
# Resolve. If `name` is just a folder name, drop it under cwd. If it is
|
|
62
|
+
# `.`, init in cwd itself (legacy / "I am already in my hub dir" case).
|
|
63
|
+
if str(name) == ".":
|
|
64
|
+
hub_dir = Path.cwd().resolve()
|
|
65
|
+
is_in_place = True
|
|
66
|
+
else:
|
|
67
|
+
hub_dir = (Path.cwd() / name).resolve() if not name.is_absolute() else name.resolve()
|
|
68
|
+
is_in_place = False
|
|
69
|
+
|
|
70
|
+
template_root = _template_root()
|
|
71
|
+
if template_root is None:
|
|
72
|
+
console.print("[red]Bundled template not found in the installed package.[/red]")
|
|
73
|
+
raise typer.Exit(2)
|
|
74
|
+
|
|
75
|
+
parent = hub_dir.parent
|
|
76
|
+
# Check parent freshness BEFORE creating the hub folder, so the hub we are
|
|
77
|
+
# about to create does not itself disqualify the parent.
|
|
78
|
+
parent_is_fresh = (not is_in_place) and _parent_looks_fresh(parent, ignore=hub_dir.name)
|
|
79
|
+
|
|
80
|
+
hub_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
# 1. Scaffold the hub folder from the bundled template.
|
|
83
|
+
written: list[Path] = []
|
|
84
|
+
skipped: list[Path] = []
|
|
85
|
+
for src in template_root.rglob("*"):
|
|
86
|
+
if src.is_dir():
|
|
87
|
+
continue
|
|
88
|
+
rel = src.relative_to(template_root)
|
|
89
|
+
dst = hub_dir / rel
|
|
90
|
+
if dst.exists() and not force:
|
|
91
|
+
skipped.append(dst)
|
|
92
|
+
continue
|
|
93
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
shutil.copy2(src, dst)
|
|
95
|
+
written.append(dst)
|
|
96
|
+
|
|
97
|
+
# 2. Write .env from a Python constant (not part of the template tree
|
|
98
|
+
# because .env is gitignored). Same skip rules as template files.
|
|
99
|
+
env_dst = hub_dir / ".env"
|
|
100
|
+
if env_dst.exists() and not force:
|
|
101
|
+
skipped.append(env_dst)
|
|
102
|
+
else:
|
|
103
|
+
env_dst.write_text(_STARTER_ENV)
|
|
104
|
+
written.append(env_dst)
|
|
105
|
+
|
|
106
|
+
# 3. If the parent looks fresh and we are scaffolding a sub-folder, drop
|
|
107
|
+
# the agents-repo wrapper files. Never overwrite existing ones, with or
|
|
108
|
+
# without --force (parent files are not the hub's concern).
|
|
109
|
+
parent_written: list[Path] = []
|
|
110
|
+
if parent_is_fresh:
|
|
111
|
+
version_str = _installed_version()
|
|
112
|
+
wrapper = _wrapper_files(parent, hub_dir.name, version_str)
|
|
113
|
+
for dst, content in wrapper.items():
|
|
114
|
+
if dst.exists():
|
|
115
|
+
continue
|
|
116
|
+
dst.write_text(content)
|
|
117
|
+
parent_written.append(dst)
|
|
118
|
+
|
|
119
|
+
# 3. Report.
|
|
120
|
+
console.print(f"[green]Initialized hub at[/green] {hub_dir}")
|
|
121
|
+
if written:
|
|
122
|
+
console.print(f" wrote {len(written)} hub files")
|
|
123
|
+
if skipped:
|
|
124
|
+
console.print(f" skipped {len(skipped)} existing files (use --force to overwrite)")
|
|
125
|
+
if parent_written:
|
|
126
|
+
console.print(f"\n[green]Bootstrapped agents-repo wrapper at[/green] {parent}")
|
|
127
|
+
for p in parent_written:
|
|
128
|
+
console.print(f" + {p.name}")
|
|
129
|
+
|
|
130
|
+
console.print("\nNext:")
|
|
131
|
+
console.print(f" 1. edit {hub_dir.name}/.env if you do not have `claude` CLI logged in")
|
|
132
|
+
console.print(f" 2. hubzoid run {hub_dir.name}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# run
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
@app.command()
|
|
139
|
+
def run(
|
|
140
|
+
hub: Path = typer.Argument(Path("."), help="Hub directory. Default: current dir."),
|
|
141
|
+
port: int = typer.Option(None, "--port", help="Open WebUI port. Default: 3080 (or PORT env)."),
|
|
142
|
+
bridge_port: int = typer.Option(None, "--bridge-port", help="FastAPI bridge port. Default: 8000 (or BRIDGE_PORT env)."),
|
|
143
|
+
no_ui: bool = typer.Option(False, "--no-ui", help="Skip Open WebUI; bridge only."),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Start the bridge (+ Open WebUI) for a hub."""
|
|
146
|
+
hub = hub.resolve()
|
|
147
|
+
if not hub.is_dir():
|
|
148
|
+
console.print(f"[red]Hub directory not found:[/red] {hub}")
|
|
149
|
+
# If the user is currently inside a hub folder, point that out.
|
|
150
|
+
cwd = Path.cwd()
|
|
151
|
+
if (cwd / "AGENTS.md").is_file():
|
|
152
|
+
console.print(
|
|
153
|
+
f"[yellow]Your current directory ({cwd}) looks like a hub. "
|
|
154
|
+
f"Try:[/yellow]\n python -m hubzoid run . (or just: hubzoid run .)"
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
console.print(
|
|
158
|
+
"[yellow]Tip:[/yellow] paths are resolved against the current "
|
|
159
|
+
f"directory ({cwd}). Run from the repo root, or pass `.` from inside the hub folder."
|
|
160
|
+
)
|
|
161
|
+
raise typer.Exit(2)
|
|
162
|
+
if not (hub / "AGENTS.md").is_file():
|
|
163
|
+
console.print(f"[red]No AGENTS.md in {hub}. Run `hubzoid init` first.[/red]")
|
|
164
|
+
raise typer.Exit(2)
|
|
165
|
+
|
|
166
|
+
settings = settingslib.load(hub)
|
|
167
|
+
ui_port = port or settings.ui_port
|
|
168
|
+
br_port = bridge_port or settings.bridge_port
|
|
169
|
+
|
|
170
|
+
# 1. Start the bridge in a subprocess. We pass HUBZOID_HUB_DIR via env so
|
|
171
|
+
# `hubzoid.server.build_app` knows what to load.
|
|
172
|
+
bridge_env = os.environ.copy()
|
|
173
|
+
bridge_env["HUBZOID_HUB_DIR"] = str(hub)
|
|
174
|
+
bridge_cmd = [
|
|
175
|
+
sys.executable, "-m", "uvicorn",
|
|
176
|
+
"hubzoid.server:build_app", "--factory",
|
|
177
|
+
"--host", "127.0.0.1", "--port", str(br_port),
|
|
178
|
+
"--log-level", settings.log_level,
|
|
179
|
+
]
|
|
180
|
+
console.print(f"[cyan]→ bridge[/cyan] http://127.0.0.1:{br_port} (hub: {hub.name})")
|
|
181
|
+
bridge_proc = subprocess.Popen(bridge_cmd, env=bridge_env)
|
|
182
|
+
|
|
183
|
+
# 2. Wait for the bridge to come up before starting Open WebUI.
|
|
184
|
+
if not _wait_for(f"http://127.0.0.1:{br_port}/healthz", timeout=60):
|
|
185
|
+
console.print("[red]bridge failed to come up[/red]")
|
|
186
|
+
bridge_proc.terminate()
|
|
187
|
+
raise typer.Exit(1)
|
|
188
|
+
console.print("[green]→ bridge[/green] ready")
|
|
189
|
+
|
|
190
|
+
ui_proc = None
|
|
191
|
+
if not no_ui:
|
|
192
|
+
try:
|
|
193
|
+
from . import webui
|
|
194
|
+
ui_proc = webui.start(
|
|
195
|
+
hub_dir=hub,
|
|
196
|
+
bridge_port=br_port,
|
|
197
|
+
ui_port=ui_port,
|
|
198
|
+
api_key=settings.first_api_key,
|
|
199
|
+
model_label=settings.model_label or _read_main_agent_name(hub),
|
|
200
|
+
webui_name=settings.webui_name,
|
|
201
|
+
)
|
|
202
|
+
log_path = getattr(ui_proc, "_log_path", None)
|
|
203
|
+
console.print(f"[cyan]→ webui [/cyan] http://127.0.0.1:{ui_port} (booting, first start takes 1-2 min while it downloads its embedding model)")
|
|
204
|
+
if log_path:
|
|
205
|
+
console.print(f" log: {log_path}")
|
|
206
|
+
except FileNotFoundError as exc:
|
|
207
|
+
console.print(f"[yellow]{exc}[/yellow]")
|
|
208
|
+
console.print("Bridge only. Curl http://127.0.0.1:" + str(br_port) + "/v1/chat/completions to chat.")
|
|
209
|
+
|
|
210
|
+
def _shutdown(signum, frame): # noqa: ARG001
|
|
211
|
+
console.print("\n[cyan]shutting down...[/cyan]")
|
|
212
|
+
for p in (ui_proc, bridge_proc):
|
|
213
|
+
if p is not None and p.poll() is None:
|
|
214
|
+
p.terminate()
|
|
215
|
+
sys.exit(0)
|
|
216
|
+
|
|
217
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
218
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
219
|
+
# Block on the bridge process; its exit ends the CLI.
|
|
220
|
+
try:
|
|
221
|
+
bridge_proc.wait()
|
|
222
|
+
finally:
|
|
223
|
+
if ui_proc is not None and ui_proc.poll() is None:
|
|
224
|
+
ui_proc.terminate()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# doctor
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
@app.command()
|
|
231
|
+
def doctor(
|
|
232
|
+
hub: Path = typer.Argument(Path("."), help="Hub directory. Default: current dir."),
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Validate a hub: AGENTS.md, sub-agents, skills, knowledge, tools, .env."""
|
|
235
|
+
hub = hub.resolve()
|
|
236
|
+
problems: list[str] = []
|
|
237
|
+
notes: list[str] = []
|
|
238
|
+
|
|
239
|
+
if not hub.is_dir():
|
|
240
|
+
console.print(f"[red]Hub directory not found:[/red] {hub}")
|
|
241
|
+
raise typer.Exit(2)
|
|
242
|
+
|
|
243
|
+
if not (hub / "AGENTS.md").is_file():
|
|
244
|
+
problems.append("missing AGENTS.md at hub root")
|
|
245
|
+
|
|
246
|
+
env_path = hub / ".env"
|
|
247
|
+
if not env_path.is_file():
|
|
248
|
+
notes.append(f"no .env at {env_path} (run `hubzoid init` to scaffold one, or create it by hand)")
|
|
249
|
+
|
|
250
|
+
# Try to actually build the runtime — this is the most thorough check.
|
|
251
|
+
# Picks the backend based on MODEL (openai-agents by default,
|
|
252
|
+
# claude-local when MODEL=claude-local).
|
|
253
|
+
try:
|
|
254
|
+
from . import runtime as runtime_lib
|
|
255
|
+
rt = runtime_lib.build(hub)
|
|
256
|
+
notes.append(f"runtime built: {rt.name!r} via {type(rt).__name__}")
|
|
257
|
+
except Exception as exc: # noqa: BLE001
|
|
258
|
+
problems.append(f"runtime build failed: {type(exc).__name__}: {exc}")
|
|
259
|
+
|
|
260
|
+
for n in notes:
|
|
261
|
+
console.print(f"[green]✓[/green] {n}")
|
|
262
|
+
for p in problems:
|
|
263
|
+
console.print(f"[red]✗[/red] {p}")
|
|
264
|
+
if problems:
|
|
265
|
+
raise typer.Exit(1)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# test
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
@app.command("test")
|
|
272
|
+
def test_hub(
|
|
273
|
+
hub: Path = typer.Argument(Path("."), help="Hub directory. Default: current dir."),
|
|
274
|
+
prompt: str = typer.Option("Reply with the single word: pong", "--prompt", help="Test prompt to send."),
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Send one prompt to the hub's agent and print the response.
|
|
277
|
+
|
|
278
|
+
Runs in-process (no bridge / no UI). Backend is picked from MODEL in .env:
|
|
279
|
+
`claude-local` -> Claude Agent SDK; anything else -> OpenAI Agents SDK.
|
|
280
|
+
"""
|
|
281
|
+
import asyncio
|
|
282
|
+
|
|
283
|
+
hub = hub.resolve()
|
|
284
|
+
settings = settingslib.load(hub)
|
|
285
|
+
if not settings.model:
|
|
286
|
+
console.print("[red]MODEL is not set in .env. Cannot run test.[/red]")
|
|
287
|
+
raise typer.Exit(2)
|
|
288
|
+
|
|
289
|
+
from . import runtime as runtime_lib
|
|
290
|
+
|
|
291
|
+
rt = runtime_lib.build(hub)
|
|
292
|
+
console.print(f"[cyan]→[/cyan] {prompt}")
|
|
293
|
+
text = asyncio.run(rt.run(prompt))
|
|
294
|
+
console.print(f"[green]←[/green] {text}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# version
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
@app.command()
|
|
301
|
+
def version() -> None:
|
|
302
|
+
"""Print the installed hubzoid version."""
|
|
303
|
+
console.print(__version__)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# helpers
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
_STARTER_ENV = """\
|
|
310
|
+
# demo-hub configuration. This file is git-ignored.
|
|
311
|
+
#
|
|
312
|
+
# The default below uses your installed `claude` CLI and Pro/Max subscription
|
|
313
|
+
# for inference. No API key needed. Requires `claude login` already done.
|
|
314
|
+
#
|
|
315
|
+
# To use a hosted provider instead, comment out MODEL=claude-local and
|
|
316
|
+
# uncomment one of the alternative stanzas. Set the matching API key.
|
|
317
|
+
|
|
318
|
+
MODEL=claude-local
|
|
319
|
+
# MODEL=claude-local/sonnet # pin Sonnet
|
|
320
|
+
# MODEL=claude-local/opus # pin Opus
|
|
321
|
+
# MODEL=claude-local/haiku # pin Haiku
|
|
322
|
+
|
|
323
|
+
# --- OpenRouter (one key, many models) -------------------------------------
|
|
324
|
+
# OPENROUTER_API_KEY=
|
|
325
|
+
# MODEL=openrouter/anthropic/claude-haiku-4.5
|
|
326
|
+
|
|
327
|
+
# --- OpenAI -----------------------------------------------------------------
|
|
328
|
+
# OPENAI_API_KEY=
|
|
329
|
+
# MODEL=openai/gpt-4o-mini
|
|
330
|
+
|
|
331
|
+
# --- Anthropic --------------------------------------------------------------
|
|
332
|
+
# ANTHROPIC_API_KEY=
|
|
333
|
+
# MODEL=anthropic/claude-haiku-4-5
|
|
334
|
+
|
|
335
|
+
# --- Bridge / UI knobs (all optional) --------------------------------------
|
|
336
|
+
WEBUI_NAME=Hubzoid Guide
|
|
337
|
+
# BRIDGE_API_KEYS=dev # comma-separated; first one is what Open WebUI sees
|
|
338
|
+
# MODEL_LABEL= # what /v1/models reports; blank = derived from AGENTS.md name
|
|
339
|
+
# PORT=3080 # Open WebUI port
|
|
340
|
+
# BRIDGE_PORT=8000 # FastAPI bridge port
|
|
341
|
+
# HTTP_ALLOWLIST= # comma-separated hostnames the http_get tool may visit
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _installed_version() -> str:
|
|
346
|
+
"""Return the installed hubzoid version, or the source-tree version as a fallback."""
|
|
347
|
+
try:
|
|
348
|
+
from importlib.metadata import PackageNotFoundError, version as _ver
|
|
349
|
+
try:
|
|
350
|
+
return _ver("hubzoid")
|
|
351
|
+
except PackageNotFoundError:
|
|
352
|
+
pass
|
|
353
|
+
except ImportError:
|
|
354
|
+
pass
|
|
355
|
+
return __version__
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _parent_looks_fresh(parent: Path, *, ignore: str) -> bool:
|
|
359
|
+
"""Heuristic: parent is empty enough to be a fresh agents-repo wrapper.
|
|
360
|
+
|
|
361
|
+
Empty parent → fresh. Parent that contains only dotfiles, a README, a
|
|
362
|
+
requirements.txt, a LICENSE, a `.venv`, or the hub folder we are about
|
|
363
|
+
to create → also fresh. Anything else (sibling hub folders, src/, etc.)
|
|
364
|
+
means this is an existing project; do not write parent files.
|
|
365
|
+
"""
|
|
366
|
+
if not parent.exists():
|
|
367
|
+
return True
|
|
368
|
+
allowed = {"README.md", "requirements.txt", "LICENSE", "LICENSE.md", ".env"}
|
|
369
|
+
for entry in parent.iterdir():
|
|
370
|
+
if entry.name == ignore:
|
|
371
|
+
continue
|
|
372
|
+
if entry.name.startswith("."):
|
|
373
|
+
continue
|
|
374
|
+
if entry.name in allowed:
|
|
375
|
+
continue
|
|
376
|
+
return False
|
|
377
|
+
return True
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _wrapper_files(parent: Path, hub_name: str, version_str: str) -> dict[Path, str]:
|
|
381
|
+
"""The agents-repo wrapper files to drop at the parent level on first init."""
|
|
382
|
+
requirements_txt = (
|
|
383
|
+
"# Hubzoid agents repo. One hub per sibling folder.\n"
|
|
384
|
+
"# Replace the pin below with your version. For private mirrors, swap to:\n"
|
|
385
|
+
"# git+ssh://git@github.com/<org>/<your-mirror>@v<version>#egg=hubzoid\n"
|
|
386
|
+
f"hubzoid=={version_str}\n"
|
|
387
|
+
)
|
|
388
|
+
gitignore = (
|
|
389
|
+
"# Hubzoid\n"
|
|
390
|
+
".env\n"
|
|
391
|
+
"output/\n"
|
|
392
|
+
".openwebui-data/\n"
|
|
393
|
+
"\n"
|
|
394
|
+
"# Python\n"
|
|
395
|
+
"__pycache__/\n"
|
|
396
|
+
"*.pyc\n"
|
|
397
|
+
".venv/\n"
|
|
398
|
+
".pytest_cache/\n"
|
|
399
|
+
"\n"
|
|
400
|
+
"# OS\n"
|
|
401
|
+
".DS_Store\n"
|
|
402
|
+
)
|
|
403
|
+
readme = (
|
|
404
|
+
f"# {parent.name}\n"
|
|
405
|
+
"\n"
|
|
406
|
+
"Hubzoid agents repo. Each subfolder is a hub.\n"
|
|
407
|
+
"\n"
|
|
408
|
+
"## Run a hub\n"
|
|
409
|
+
"\n"
|
|
410
|
+
"```bash\n"
|
|
411
|
+
"python -m venv .venv && source .venv/bin/activate\n"
|
|
412
|
+
"pip install -r requirements.txt\n"
|
|
413
|
+
f"hubzoid run {hub_name}\n"
|
|
414
|
+
"```\n"
|
|
415
|
+
"\n"
|
|
416
|
+
"## Add another hub\n"
|
|
417
|
+
"\n"
|
|
418
|
+
"```bash\n"
|
|
419
|
+
"hubzoid init <hub-name>\n"
|
|
420
|
+
"```\n"
|
|
421
|
+
"\n"
|
|
422
|
+
"Each hub gets its own `.env`, its own port, and its own user database.\n"
|
|
423
|
+
"Agents are independent products.\n"
|
|
424
|
+
"\n"
|
|
425
|
+
"## Where the framework lives\n"
|
|
426
|
+
"\n"
|
|
427
|
+
"Installed from PyPI via `requirements.txt`. Framework source is at\n"
|
|
428
|
+
"[github.com/hubzoid/hubzoid](https://github.com/hubzoid/hubzoid).\n"
|
|
429
|
+
)
|
|
430
|
+
return {
|
|
431
|
+
parent / "requirements.txt": requirements_txt,
|
|
432
|
+
parent / ".gitignore": gitignore,
|
|
433
|
+
parent / "README.md": readme,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _template_root() -> Path | None:
|
|
438
|
+
"""Return the on-disk path of the bundled starter template, or None."""
|
|
439
|
+
try:
|
|
440
|
+
root = resources.files("hubzoid") / "templates" / "starter"
|
|
441
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
442
|
+
return None
|
|
443
|
+
# `resources.files` returns a Traversable; we need a real Path. For files
|
|
444
|
+
# installed normally (not zipped), this just works.
|
|
445
|
+
p = Path(str(root))
|
|
446
|
+
return p if p.exists() else None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _wait_for(url: str, timeout: float = 60.0) -> bool:
|
|
450
|
+
import httpx
|
|
451
|
+
deadline = time.time() + timeout
|
|
452
|
+
while time.time() < deadline:
|
|
453
|
+
try:
|
|
454
|
+
r = httpx.get(url, timeout=2.0)
|
|
455
|
+
if r.status_code < 500:
|
|
456
|
+
return True
|
|
457
|
+
except httpx.HTTPError:
|
|
458
|
+
pass
|
|
459
|
+
time.sleep(0.5)
|
|
460
|
+
return False
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _read_main_agent_name(hub: Path) -> str:
|
|
464
|
+
"""Pull the `name:` from AGENTS.md frontmatter to use as the model label."""
|
|
465
|
+
from . import frontmatter as fm
|
|
466
|
+
try:
|
|
467
|
+
data, _ = fm.read(hub / "AGENTS.md")
|
|
468
|
+
name = data.get("name", "agent")
|
|
469
|
+
return _slugify(name)
|
|
470
|
+
except Exception: # noqa: BLE001
|
|
471
|
+
return "agent"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _slugify(text: str) -> str:
|
|
475
|
+
out = "".join(c if c.isalnum() else "-" for c in str(text).strip().lower())
|
|
476
|
+
while "--" in out:
|
|
477
|
+
out = out.replace("--", "-")
|
|
478
|
+
return out.strip("-") or "agent"
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
if __name__ == "__main__":
|
|
482
|
+
app()
|
hubzoid/factory.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Top-level: build_agent(hub_dir) -> Agent.
|
|
2
|
+
|
|
3
|
+
Walks a hub folder, loads everything, and assembles an OpenAI Agents SDK
|
|
4
|
+
Agent with sub-agents (as handoffs), pre-shipped tools, hub-local tools,
|
|
5
|
+
skills/knowledge tools, and MCP servers.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from agents import Agent
|
|
14
|
+
from agents.tool import FunctionTool
|
|
15
|
+
|
|
16
|
+
from . import memory as memlib
|
|
17
|
+
from . import model as modellib
|
|
18
|
+
from . import settings as settingslib
|
|
19
|
+
from .loaders import agents as agents_loader
|
|
20
|
+
from .loaders import knowledge as knowledge_loader
|
|
21
|
+
from .loaders import mcp as mcp_loader
|
|
22
|
+
from .loaders import skills as skills_loader
|
|
23
|
+
from .loaders import tools_local as tools_local_loader
|
|
24
|
+
from .tools import make_all as make_builtin_tools
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("hubzoid")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HubContext:
|
|
31
|
+
hub_dir: Path
|
|
32
|
+
output_dir: Path
|
|
33
|
+
session_id: str
|
|
34
|
+
settings: "settingslib.Settings"
|
|
35
|
+
skills: list = field(default_factory=list)
|
|
36
|
+
knowledge: list = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_agent(hub_dir: Path) -> Agent:
|
|
40
|
+
"""Build and return the main Agent for the hub at `hub_dir`.
|
|
41
|
+
|
|
42
|
+
Sub-agents are wired as handoffs. Tools are scoped per sub-agent based on
|
|
43
|
+
each one's `tools:` frontmatter whitelist. Missing tool names raise with a
|
|
44
|
+
list of valid names.
|
|
45
|
+
"""
|
|
46
|
+
hub_dir = Path(hub_dir).resolve()
|
|
47
|
+
if not hub_dir.is_dir():
|
|
48
|
+
raise FileNotFoundError(f"hub directory not found: {hub_dir}")
|
|
49
|
+
|
|
50
|
+
settings = settingslib.load(hub_dir)
|
|
51
|
+
session_id = memlib.make_session_id()
|
|
52
|
+
output_dir = memlib.session_output_dir(hub_dir, session_id)
|
|
53
|
+
|
|
54
|
+
skills = skills_loader.load_all(hub_dir)
|
|
55
|
+
knowledge = knowledge_loader.load_all(hub_dir)
|
|
56
|
+
log.info(
|
|
57
|
+
"hub %s: %d skill(s), %d knowledge doc(s)",
|
|
58
|
+
hub_dir.name, len(skills), len(knowledge),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
ctx = HubContext(
|
|
62
|
+
hub_dir=hub_dir,
|
|
63
|
+
output_dir=output_dir,
|
|
64
|
+
session_id=session_id,
|
|
65
|
+
settings=settings,
|
|
66
|
+
skills=skills,
|
|
67
|
+
knowledge=knowledge,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Tool registry: pre-shipped (with closures over ctx) + hub-local.
|
|
71
|
+
builtin: dict[str, FunctionTool] = make_builtin_tools(ctx) # name -> tool
|
|
72
|
+
local: dict[str, FunctionTool] = tools_local_loader.load_all(hub_dir)
|
|
73
|
+
overlap = set(builtin) & set(local)
|
|
74
|
+
if overlap:
|
|
75
|
+
log.info("hub-local tools override built-ins: %s", sorted(overlap))
|
|
76
|
+
registry: dict[str, FunctionTool] = {**builtin, **local}
|
|
77
|
+
|
|
78
|
+
mcp_servers = mcp_loader.load_all(hub_dir)
|
|
79
|
+
|
|
80
|
+
# Sub-agents first; the main agent needs them as `handoffs=[...]`.
|
|
81
|
+
sub_specs = agents_loader.load_subagents(hub_dir)
|
|
82
|
+
handoffs: list[Agent] = [
|
|
83
|
+
_build_one(spec, registry=registry, default_model=settings.model)
|
|
84
|
+
for spec in sub_specs
|
|
85
|
+
]
|
|
86
|
+
log.info("hub %s: %d sub-agent(s)", hub_dir.name, len(handoffs))
|
|
87
|
+
|
|
88
|
+
main_spec = agents_loader.load_main(hub_dir)
|
|
89
|
+
main_model_id = main_spec.spec.model or settings.model
|
|
90
|
+
if not main_model_id:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"no model configured. Set MODEL in <hub>/.env or `model:` in AGENTS.md frontmatter."
|
|
93
|
+
)
|
|
94
|
+
main_model = modellib.build(main_model_id)
|
|
95
|
+
|
|
96
|
+
# The main agent gets ALL tools (whitelist on the main agent is treated as full access).
|
|
97
|
+
main_tools = list(registry.values())
|
|
98
|
+
|
|
99
|
+
main = Agent(
|
|
100
|
+
name=main_spec.spec.name,
|
|
101
|
+
instructions=main_spec.instructions,
|
|
102
|
+
model=main_model,
|
|
103
|
+
tools=main_tools,
|
|
104
|
+
handoffs=handoffs,
|
|
105
|
+
mcp_servers=mcp_servers,
|
|
106
|
+
)
|
|
107
|
+
return main
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _build_one(loaded: agents_loader.LoadedAgent, *, registry: dict[str, FunctionTool], default_model: str | None) -> Agent:
|
|
111
|
+
model_id = loaded.spec.model or default_model
|
|
112
|
+
if not model_id:
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
f"{loaded.source_path}: no model. Set `model:` in frontmatter or MODEL in <hub>/.env."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
tools: list[FunctionTool] = []
|
|
118
|
+
if loaded.spec.tools:
|
|
119
|
+
unknown = [t for t in loaded.spec.tools if t not in registry]
|
|
120
|
+
if unknown:
|
|
121
|
+
available = ", ".join(sorted(registry)) or "(none)"
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"{loaded.source_path}: tools reference unknown names: {unknown}. "
|
|
124
|
+
f"Available: {available}"
|
|
125
|
+
)
|
|
126
|
+
tools = [registry[t] for t in loaded.spec.tools]
|
|
127
|
+
# If no tools specified, sub-agent gets none (explicit-default-deny).
|
|
128
|
+
|
|
129
|
+
return Agent(
|
|
130
|
+
name=loaded.spec.name,
|
|
131
|
+
handoff_description=loaded.spec.description,
|
|
132
|
+
instructions=loaded.instructions,
|
|
133
|
+
model=modellib.build(model_id),
|
|
134
|
+
tools=tools,
|
|
135
|
+
)
|