zu-cli 0.1.0__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.
- zu_cli/__init__.py +0 -0
- zu_cli/build.py +111 -0
- zu_cli/config.py +738 -0
- zu_cli/construct.py +318 -0
- zu_cli/construct_sandbox.py +139 -0
- zu_cli/contribute.py +104 -0
- zu_cli/demo.py +373 -0
- zu_cli/deploy.py +207 -0
- zu_cli/explore.py +93 -0
- zu_cli/guardrails.py +102 -0
- zu_cli/harden.py +221 -0
- zu_cli/main.py +1126 -0
- zu_cli/mcp_server.py +444 -0
- zu_cli/observe.py +69 -0
- zu_cli/offline.py +335 -0
- zu_cli/sandbox.py +276 -0
- zu_cli/scaffold.py +116 -0
- zu_cli/server.py +363 -0
- zu_cli/trace.py +111 -0
- zu_cli-0.1.0.dist-info/METADATA +26 -0
- zu_cli-0.1.0.dist-info/RECORD +23 -0
- zu_cli-0.1.0.dist-info/WHEEL +4 -0
- zu_cli-0.1.0.dist-info/entry_points.txt +4 -0
zu_cli/demo.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""The demos behind ``zu demo`` — shipped in the package so they run after a
|
|
2
|
+
pip install, no repo clone needed.
|
|
3
|
+
|
|
4
|
+
The demos exist to prove **runnability**: that a freshly installed Zu actually
|
|
5
|
+
runs an agent against a **real model** and produces a validated result. So a real
|
|
6
|
+
run needs an API key — the default. (``--offline`` replays a scripted, fixtured
|
|
7
|
+
run instead; that proves the *wiring* for CI/self-test, not a real run, and is
|
|
8
|
+
labelled as such.)
|
|
9
|
+
|
|
10
|
+
Demo types (``--type``), ordered by what they require to run:
|
|
11
|
+
|
|
12
|
+
* **minimal** — a model answers a question as JSON, schema-validated. No tools,
|
|
13
|
+
no network. Requires: an **API key**.
|
|
14
|
+
* **web** (default) — a real ``http_fetch`` of a real page + the model extracts a
|
|
15
|
+
field + schema/grounding validation. This is **tier 1**: requires an **API key
|
|
16
|
+
+ network**, and the ``[demo]`` extra. **No Docker.**
|
|
17
|
+
* **escalation** — the full fetch → fail-on-JS → escalate-to-browser arc. Tier 2
|
|
18
|
+
(a real browser) needs **Docker**, and the headless-Chromium image is not yet
|
|
19
|
+
published — so the *real* path isn't available out of the box. Use
|
|
20
|
+
``--offline`` to watch the escalation logic with a fixtured browser.
|
|
21
|
+
|
|
22
|
+
The requirement ladder: Python → +network (tier 1) → +Docker (tier 2), plus an
|
|
23
|
+
API key for any real model. We never ship or require a key.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from zu_core.bus import EventBus
|
|
31
|
+
from zu_core.contracts import Result, Status, TaskSpec
|
|
32
|
+
from zu_core.loop import run_task
|
|
33
|
+
from zu_core.ports import ModelProvider, ToolCall
|
|
34
|
+
from zu_core.registry import Registry
|
|
35
|
+
|
|
36
|
+
# The web tools (zu-tools) are an opt-in extra; this module must still import on
|
|
37
|
+
# the lean base, so plugin packages are imported lazily inside the builders.
|
|
38
|
+
_WEB_HINT = "this demo needs the web tools — install them with: pip install 'zu-runtime[demo]'"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_web_tools() -> None:
|
|
42
|
+
"""Raise a clear, actionable error if the web tools aren't installed."""
|
|
43
|
+
try:
|
|
44
|
+
import zu_tools # noqa: F401
|
|
45
|
+
except ModuleNotFoundError as exc:
|
|
46
|
+
raise RuntimeError(_WEB_HINT) from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- tasks -------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
_MINIMAL_TASK = TaskSpec(
|
|
52
|
+
query="Reply with the capital of France as a JSON object: {\"capital\": ...}.",
|
|
53
|
+
max_tier=1,
|
|
54
|
+
output_schema={
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {"capital": {"type": "string"}},
|
|
57
|
+
"required": ["capital"],
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
_WEB_URL = "https://example.com"
|
|
62
|
+
_WEB_TASK = TaskSpec(
|
|
63
|
+
query=(
|
|
64
|
+
"Fetch the page and extract its main heading. "
|
|
65
|
+
"Return a JSON object: {\"title\": ...}."
|
|
66
|
+
),
|
|
67
|
+
target=_WEB_URL,
|
|
68
|
+
max_tier=1,
|
|
69
|
+
output_schema={
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {"title": {"type": "string"}},
|
|
72
|
+
"required": ["title"],
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# A stand-in for example.com used only by the offline self-test (no network).
|
|
77
|
+
_EXAMPLE_HTML = (
|
|
78
|
+
"<html><head><title>Example Domain</title></head><body><main>"
|
|
79
|
+
"<h1>Example Domain</h1>"
|
|
80
|
+
"<p>This domain is for use in illustrative examples in documents.</p>"
|
|
81
|
+
"</main></body></html>"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# --- the escalation arc (offline self-test; real tier-2 needs Docker + image) -
|
|
85
|
+
|
|
86
|
+
_SHELL_URL = "http://shop.test/product/123"
|
|
87
|
+
_SHELL = '<html><body><div id="root"></div><script src="/app.js"></script></body></html>'
|
|
88
|
+
_RENDERED = (
|
|
89
|
+
"<html><body><main><h1>Acme Widget</h1><span class='price'>$9.00</span></main></body></html>"
|
|
90
|
+
)
|
|
91
|
+
_ESCALATION_TASK = TaskSpec(
|
|
92
|
+
query="Extract the product name and price.",
|
|
93
|
+
target=_SHELL_URL,
|
|
94
|
+
max_tier=2,
|
|
95
|
+
output_schema={
|
|
96
|
+
"type": "object",
|
|
97
|
+
"properties": {"name": {"type": "string"}, "price": {"type": "string"}},
|
|
98
|
+
"required": ["name", "price"],
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _FixtureBrowser:
|
|
104
|
+
"""A stand-in SandboxBackend for the offline escalation self-test: no Docker,
|
|
105
|
+
no real browser — returns the saved rendered page."""
|
|
106
|
+
|
|
107
|
+
name = "fixture-browser"
|
|
108
|
+
|
|
109
|
+
def __init__(self, rendered: str) -> None:
|
|
110
|
+
self._rendered = rendered
|
|
111
|
+
self.launched: list[dict] = []
|
|
112
|
+
self.destroyed = 0
|
|
113
|
+
|
|
114
|
+
async def launch(self, spec: dict) -> dict:
|
|
115
|
+
self.launched.append(spec)
|
|
116
|
+
return {"id": "sbx-1", "spec": spec}
|
|
117
|
+
|
|
118
|
+
async def exec(self, sandbox: dict, call: ToolCall) -> dict:
|
|
119
|
+
return {"status": 200, "html": self._rendered, "url": call.args["url"]}
|
|
120
|
+
|
|
121
|
+
async def destroy(self, sandbox: dict) -> None:
|
|
122
|
+
self.destroyed += 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- registries (built per type; ``offline`` fixtures the network/browser) ----
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _minimal_registry(offline: bool) -> tuple[Registry, None]:
|
|
129
|
+
from zu_checks.validators.schema import SchemaValidator
|
|
130
|
+
|
|
131
|
+
reg = Registry()
|
|
132
|
+
reg.register("validators", "schema", SchemaValidator())
|
|
133
|
+
return reg, None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _web_registry(offline: bool) -> tuple[Registry, None]:
|
|
137
|
+
ensure_web_tools()
|
|
138
|
+
from zu_checks.validators.grounding import GroundingValidator
|
|
139
|
+
from zu_checks.validators.schema import SchemaValidator
|
|
140
|
+
from zu_tools.fetch import HttpFetch
|
|
141
|
+
from zu_tools.parse import HtmlParse
|
|
142
|
+
|
|
143
|
+
if offline:
|
|
144
|
+
import httpx
|
|
145
|
+
|
|
146
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
147
|
+
return httpx.Response(200, text=_EXAMPLE_HTML)
|
|
148
|
+
|
|
149
|
+
fetch = HttpFetch(allow_private=True, transport=httpx.MockTransport(handler))
|
|
150
|
+
else:
|
|
151
|
+
fetch = HttpFetch() # real network
|
|
152
|
+
|
|
153
|
+
reg = Registry()
|
|
154
|
+
reg.register("tools", "http_fetch", fetch)
|
|
155
|
+
reg.register("tools", "html_parse", HtmlParse())
|
|
156
|
+
reg.register("validators", "schema", SchemaValidator())
|
|
157
|
+
reg.register("validators", "grounding", GroundingValidator())
|
|
158
|
+
return reg, None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _escalation_registry(offline: bool) -> tuple[Registry, Any]:
|
|
162
|
+
if not offline:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
"the real escalation demo needs Docker and a published tier-2 browser image "
|
|
165
|
+
"(not available yet); run it with --offline to see the escalation logic."
|
|
166
|
+
)
|
|
167
|
+
ensure_web_tools()
|
|
168
|
+
import httpx
|
|
169
|
+
|
|
170
|
+
from zu_checks.detectors.bot_wall import BotWallDetector
|
|
171
|
+
from zu_checks.detectors.empty import EmptyDetector
|
|
172
|
+
from zu_checks.detectors.error import ErrorDetector
|
|
173
|
+
from zu_checks.detectors.js_shell import JsShellDetector
|
|
174
|
+
from zu_checks.validators.grounding import GroundingValidator
|
|
175
|
+
from zu_checks.validators.schema import SchemaValidator
|
|
176
|
+
from zu_tools.fetch import HttpFetch
|
|
177
|
+
from zu_tools.parse import HtmlParse
|
|
178
|
+
from zu_tools.render import RenderDom
|
|
179
|
+
|
|
180
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
181
|
+
return httpx.Response(200, text=_SHELL)
|
|
182
|
+
|
|
183
|
+
backend = _FixtureBrowser(_RENDERED)
|
|
184
|
+
reg = Registry()
|
|
185
|
+
reg.register("tools", "http_fetch", HttpFetch(allow_private=True, transport=httpx.MockTransport(handler)))
|
|
186
|
+
reg.register("tools", "html_parse", HtmlParse())
|
|
187
|
+
# allow_private: the offline demo renders a non-resolvable fixture host
|
|
188
|
+
# through a fake browser backend (no real egress), so skip the SSRF DNS check.
|
|
189
|
+
reg.register("tools", "render_dom", RenderDom(backend=backend, allow_private=True))
|
|
190
|
+
for name, det in [
|
|
191
|
+
("empty", EmptyDetector()),
|
|
192
|
+
("error", ErrorDetector()),
|
|
193
|
+
("js-shell", JsShellDetector()),
|
|
194
|
+
("bot-wall", BotWallDetector()),
|
|
195
|
+
]:
|
|
196
|
+
reg.register("detectors", name, det)
|
|
197
|
+
reg.register("validators", "schema", SchemaValidator())
|
|
198
|
+
reg.register("validators", "grounding", GroundingValidator())
|
|
199
|
+
return reg, backend
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# --- scripted providers (used ONLY by --offline self-test) -------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _minimal_scripted() -> Any:
|
|
206
|
+
from zu_providers.scripted import ScriptedProvider
|
|
207
|
+
|
|
208
|
+
return ScriptedProvider.from_moves([{"text": '{"capital": "Paris"}', "finish": "stop"}])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _web_scripted() -> Any:
|
|
212
|
+
from zu_providers.scripted import ScriptedProvider
|
|
213
|
+
|
|
214
|
+
return ScriptedProvider.from_moves(
|
|
215
|
+
[
|
|
216
|
+
{"tool": "http_fetch", "args": {"url": _WEB_URL}},
|
|
217
|
+
{"text": '{"title": "Example Domain"}', "finish": "stop"},
|
|
218
|
+
]
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _escalation_scripted() -> Any:
|
|
223
|
+
from zu_providers.scripted import ScriptedProvider
|
|
224
|
+
|
|
225
|
+
return ScriptedProvider.from_moves(
|
|
226
|
+
[
|
|
227
|
+
{"tool": "http_fetch", "args": {"url": _SHELL_URL}},
|
|
228
|
+
{"tool": "render_dom", "args": {"url": _SHELL_URL}},
|
|
229
|
+
{"text": '{"name": "Acme Widget", "price": "$9.00"}', "finish": "stop"},
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
DEMOS: dict[str, dict[str, Any]] = {
|
|
235
|
+
"minimal": {
|
|
236
|
+
"task": _MINIMAL_TASK,
|
|
237
|
+
"registry": _minimal_registry,
|
|
238
|
+
"scripted": _minimal_scripted,
|
|
239
|
+
"needs_web": False,
|
|
240
|
+
"requires": "an API key (a real model)",
|
|
241
|
+
"title": "minimal — a model answers, schema-validated (no tools, no network)",
|
|
242
|
+
},
|
|
243
|
+
"web": {
|
|
244
|
+
"task": _WEB_TASK,
|
|
245
|
+
"registry": _web_registry,
|
|
246
|
+
"scripted": _web_scripted,
|
|
247
|
+
"needs_web": True,
|
|
248
|
+
"requires": "an API key + network · tier 1, no Docker",
|
|
249
|
+
"title": "web — fetch a real page, extract a field, validate (tier 1)",
|
|
250
|
+
},
|
|
251
|
+
"escalation": {
|
|
252
|
+
"task": _ESCALATION_TASK,
|
|
253
|
+
"registry": _escalation_registry,
|
|
254
|
+
"scripted": _escalation_scripted,
|
|
255
|
+
"needs_web": True,
|
|
256
|
+
"requires": "an API key + Docker (tier-2 browser) — real path not yet available; use --offline",
|
|
257
|
+
"title": "escalation — fetch, fail on JS, escalate to a browser, validate (tier 2)",
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
DEMO_TYPES = tuple(DEMOS)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --- running ------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def run_arc(
|
|
268
|
+
provider: ModelProvider, kind: str = "web", offline: bool = False, bus: EventBus | None = None
|
|
269
|
+
) -> tuple[Result, EventBus, Any]:
|
|
270
|
+
"""Drive the chosen demo; return the result, the event bus, and the fixture
|
|
271
|
+
browser (escalation) or None. Pass a ``bus`` with a subscriber attached to
|
|
272
|
+
watch the run live."""
|
|
273
|
+
registry, backend = DEMOS[kind]["registry"](offline)
|
|
274
|
+
bus = bus or EventBus()
|
|
275
|
+
result = await run_task(DEMOS[kind]["task"], provider, registry, bus)
|
|
276
|
+
return result, bus, backend
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def run_demo(
|
|
280
|
+
provider: ModelProvider,
|
|
281
|
+
provider_label: str = "scripted",
|
|
282
|
+
kind: str = "web",
|
|
283
|
+
offline: bool = False,
|
|
284
|
+
) -> int:
|
|
285
|
+
"""Run the chosen demo and print the narrated timeline + result. Returns a
|
|
286
|
+
process exit code (0 success, 1 otherwise)."""
|
|
287
|
+
meta = DEMOS[kind]
|
|
288
|
+
task = meta["task"]
|
|
289
|
+
# Only a real model id, never the provider name standing in for one — a
|
|
290
|
+
# scripted/offline provider has no model, and "model=scripted" conflates the
|
|
291
|
+
# two. None here means the provider line below shows the provider alone.
|
|
292
|
+
model = getattr(provider, "model", None)
|
|
293
|
+
|
|
294
|
+
print("=" * 72)
|
|
295
|
+
print(f"Zu · demo: {meta['title']}")
|
|
296
|
+
print("=" * 72)
|
|
297
|
+
print(f"type : {kind}{' (offline self-test — wiring only, not a real run)' if offline else ''}")
|
|
298
|
+
print(f"requires : {meta['requires']}")
|
|
299
|
+
print(f"task : {task.query}")
|
|
300
|
+
if task.target:
|
|
301
|
+
print(f"target : {task.target}")
|
|
302
|
+
print(f"provider : {provider_label}" + (f" model: {model}" if model else ""))
|
|
303
|
+
print("-" * 72)
|
|
304
|
+
|
|
305
|
+
# Watch the run live — the train of thought, tools, and escalations stream
|
|
306
|
+
# to the console as the loop runs (the same trace `zu run` shows).
|
|
307
|
+
from .trace import live_printer
|
|
308
|
+
|
|
309
|
+
bus = EventBus()
|
|
310
|
+
bus.subscribe(live_printer())
|
|
311
|
+
try:
|
|
312
|
+
result, bus, backend = await run_arc(provider, kind, offline, bus=bus)
|
|
313
|
+
except Exception as exc: # noqa: BLE001 - a clean message beats a traceback
|
|
314
|
+
print(f"\nrun failed: {type(exc).__name__}: {exc}")
|
|
315
|
+
return 1
|
|
316
|
+
|
|
317
|
+
print("-" * 72)
|
|
318
|
+
print(f"RESULT : {result.status.value}")
|
|
319
|
+
if result.value is not None:
|
|
320
|
+
print(f"value : {result.value}")
|
|
321
|
+
if result.reason is not None:
|
|
322
|
+
print(f"reason : {result.reason}")
|
|
323
|
+
if backend is not None:
|
|
324
|
+
print(
|
|
325
|
+
f"provenance: {await bus.count()} events recorded · "
|
|
326
|
+
f"tier-2 browser leased {len(backend.launched)}×, torn down {backend.destroyed}×"
|
|
327
|
+
)
|
|
328
|
+
else:
|
|
329
|
+
print(f"provenance: {await bus.count()} events recorded")
|
|
330
|
+
|
|
331
|
+
if result.status is Status.SUCCESS:
|
|
332
|
+
proof = "wiring verified (offline)" if offline else "a real model ran end to end"
|
|
333
|
+
print(f"\n{proof}: validated result + a queryable event log. See the quickstart.")
|
|
334
|
+
return 0
|
|
335
|
+
print("\nThe run did not succeed — inspect the timeline above and the event log.")
|
|
336
|
+
return 1
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def build_provider(
|
|
340
|
+
provider: str | None,
|
|
341
|
+
model: str | None,
|
|
342
|
+
api_key: str | None,
|
|
343
|
+
api_key_env: str | None,
|
|
344
|
+
base_url_env: str | None,
|
|
345
|
+
kind: str = "web",
|
|
346
|
+
offline: bool = False,
|
|
347
|
+
) -> tuple[ModelProvider, str]:
|
|
348
|
+
"""Pick the demo's model. ``--offline`` uses the scripted self-test provider;
|
|
349
|
+
otherwise a real provider is built the same way ``zu run`` does (proving one
|
|
350
|
+
config surface), defaulting to Anthropic when only a model is given."""
|
|
351
|
+
if offline:
|
|
352
|
+
return DEMOS[kind]["scripted"](), "scripted"
|
|
353
|
+
from .config import ConfigError, ProviderConfig
|
|
354
|
+
from .config import build_provider as cfg_build_provider
|
|
355
|
+
|
|
356
|
+
# No hard-coded provider default: an agent must say which provider it runs on.
|
|
357
|
+
# If a real run names none, fail clearly rather than silently assuming one.
|
|
358
|
+
if not provider:
|
|
359
|
+
raise ConfigError(
|
|
360
|
+
"a real run needs a provider — pass --provider (e.g. --provider "
|
|
361
|
+
"openai-compatible --model openai/gpt-4o-mini, or --provider anthropic "
|
|
362
|
+
"--model claude-opus-4-8), or use --offline for the scripted self-test."
|
|
363
|
+
)
|
|
364
|
+
prov = cfg_build_provider(
|
|
365
|
+
ProviderConfig(
|
|
366
|
+
name=provider,
|
|
367
|
+
model=model,
|
|
368
|
+
api_key=api_key,
|
|
369
|
+
api_key_env=api_key_env,
|
|
370
|
+
base_url_env=base_url_env,
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
return prov, provider
|
zu_cli/deploy.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Deployment — turn a config + task into something running, from the CLI.
|
|
2
|
+
|
|
3
|
+
Two paths, both honest about credentials (secrets are never baked into an image
|
|
4
|
+
or a manifest — they are referenced as environment variables the platform
|
|
5
|
+
injects at deploy time):
|
|
6
|
+
|
|
7
|
+
* **local** — generate a project Dockerfile (pip-installs ``zu-runtime``, copies
|
|
8
|
+
the config), build it, and run ``zu serve`` in a container, passing the
|
|
9
|
+
provider's key env through. One command to a running, streamable agent.
|
|
10
|
+
* **manifests** — emit a Dockerfile / docker-compose / Fly / Render config you
|
|
11
|
+
apply with your platform's own tooling (we don't hold your cloud creds).
|
|
12
|
+
|
|
13
|
+
The generated artifacts are deterministic text, so the manifest path needs no
|
|
14
|
+
Docker and is fully testable; only ``local`` touches the daemon.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
TARGETS = ("local", "dockerfile", "compose", "fly", "render")
|
|
22
|
+
|
|
23
|
+
# Env vars worth passing through to a container / referencing in a manifest.
|
|
24
|
+
# Provider key/base-url envs plus ZU_SERVE_TOKEN (the bearer token `zu serve`
|
|
25
|
+
# requires when bound to a non-localhost host — see server.py). A config that
|
|
26
|
+
# names a *different* key env (a custom provider) has it added on top by
|
|
27
|
+
# ``key_envs_for_config`` below, so the passthrough isn't limited to these.
|
|
28
|
+
_KEY_ENVS = (
|
|
29
|
+
"ANTHROPIC_API_KEY",
|
|
30
|
+
"OPENAI_API_KEY",
|
|
31
|
+
"OPENAI_BASE_URL",
|
|
32
|
+
"OPENROUTER_API_KEY",
|
|
33
|
+
"OPENROUTER_BASE_URL",
|
|
34
|
+
"ZU_SERVE_TOKEN",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def key_envs_for_config(cfg: object | None) -> tuple[str, ...]:
|
|
39
|
+
"""The env vars to pass through for ``cfg``: the defaults above plus any
|
|
40
|
+
``api_key_env`` / ``base_url_env`` the config's provider(s) actually name —
|
|
41
|
+
so a custom provider reading e.g. ``GROQ_API_KEY`` is credentialled in the
|
|
42
|
+
deployed container instead of silently starting without a key. Order is
|
|
43
|
+
stable (defaults first, then config-declared extras) and de-duplicated."""
|
|
44
|
+
envs: list[str] = list(_KEY_ENVS)
|
|
45
|
+
provider = getattr(cfg, "provider", None)
|
|
46
|
+
providers = list(getattr(cfg, "providers", {}).values()) if cfg is not None else []
|
|
47
|
+
for p in [provider, *providers]:
|
|
48
|
+
for attr in ("api_key_env", "base_url_env"):
|
|
49
|
+
val = getattr(p, attr, None)
|
|
50
|
+
if isinstance(val, str) and val and val not in envs:
|
|
51
|
+
envs.append(val)
|
|
52
|
+
return tuple(envs)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def dockerfile_text(
|
|
56
|
+
config: str, *, extras: str = "all", port: int = 8000,
|
|
57
|
+
python_version: str = "3.11", version: str | None = None,
|
|
58
|
+
) -> str:
|
|
59
|
+
# Pin the runtime version when known, so a rebuilt image is reproducible
|
|
60
|
+
# rather than silently pulling a newer (possibly breaking) release. The
|
|
61
|
+
# Python base is configurable for the same reason — a pinned, deliberate base.
|
|
62
|
+
spec = f"zu-runtime[{extras}]" + (f"=={version}" if version else "")
|
|
63
|
+
return (
|
|
64
|
+
"# Generated by `zu deploy`. Runs your agent as an HTTP service.\n"
|
|
65
|
+
"# Secrets are NOT baked in — pass them at run time (-e ANTHROPIC_API_KEY=...).\n"
|
|
66
|
+
f"FROM python:{python_version}-slim\n"
|
|
67
|
+
"ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1\n"
|
|
68
|
+
f'RUN pip install "{spec}"\n'
|
|
69
|
+
"WORKDIR /app\n"
|
|
70
|
+
f"COPY {config} ./{config}\n"
|
|
71
|
+
f"EXPOSE {port}\n"
|
|
72
|
+
f'CMD ["zu", "serve", "-c", "{config}", "--host", "0.0.0.0", "--port", "{port}"]\n'
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def pack_dockerfile_text(base: str = "zu:latest", *, agent: str = "agent.yaml") -> str:
|
|
77
|
+
"""A Dockerfile that BAKES a bundle (its ``agent.yaml`` + ``tools/`` + a
|
|
78
|
+
``requirements.txt``) into a standalone image, FROM a Zu runtime ``base``.
|
|
79
|
+
|
|
80
|
+
Unlike ``--sandboxed`` (which mounts the bundle and so is limited to the base
|
|
81
|
+
image's packages), pack installs the bundle's own pip deps at build time — so
|
|
82
|
+
a bundle whose tools need extra libraries runs anywhere. Secrets are NOT
|
|
83
|
+
baked: pass them at run time (``-e ANTHROPIC_API_KEY=...``). The bundle is on
|
|
84
|
+
``PYTHONPATH`` so its ``tools.x:Class`` import-refs resolve; the image runs as
|
|
85
|
+
the base's non-root user."""
|
|
86
|
+
return (
|
|
87
|
+
"# Generated by `zu pack`. Bakes this bundle into a standalone image.\n"
|
|
88
|
+
"# Secrets are NOT baked — pass them at run time (-e ANTHROPIC_API_KEY=...).\n"
|
|
89
|
+
f"FROM {base}\n"
|
|
90
|
+
"USER root\n"
|
|
91
|
+
"WORKDIR /agent\n"
|
|
92
|
+
"COPY . /agent\n"
|
|
93
|
+
"RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi\n"
|
|
94
|
+
"ENV PYTHONPATH=/agent\n"
|
|
95
|
+
"RUN chown -R 10001:10001 /agent 2>/dev/null || true\n"
|
|
96
|
+
"USER 10001\n"
|
|
97
|
+
f'CMD ["zu", "run", "/agent/{agent}"]\n'
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def pack_build_command(tag: str, bundle: str) -> list[str]:
|
|
102
|
+
"""The ``docker build`` argv for ``zu pack``. The Dockerfile is fed on stdin
|
|
103
|
+
(``-f -``) with the bundle as the build context, so packing never writes a
|
|
104
|
+
file into the user's bundle directory."""
|
|
105
|
+
return ["docker", "build", "-t", tag, "-f", "-", bundle]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def compose_text(name: str, config: str, *, port: int = 8000, envs: tuple[str, ...] = _KEY_ENVS) -> str:
|
|
109
|
+
env_lines = "\n".join(f" - {k}" for k in envs)
|
|
110
|
+
return (
|
|
111
|
+
"# Generated by `zu deploy compose`. Secrets pass through from your shell\n"
|
|
112
|
+
"# (export them, or use an env_file). `docker compose up --build`.\n"
|
|
113
|
+
"services:\n"
|
|
114
|
+
f" {name}:\n"
|
|
115
|
+
" build: .\n"
|
|
116
|
+
f' ports: ["{port}:{port}"]\n'
|
|
117
|
+
" environment:\n"
|
|
118
|
+
f"{env_lines}\n"
|
|
119
|
+
" restart: unless-stopped\n"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fly_text(name: str, *, port: int = 8000) -> str:
|
|
124
|
+
return (
|
|
125
|
+
"# Generated by `zu deploy fly`. Then: fly launch --no-deploy (once),\n"
|
|
126
|
+
"# fly secrets set ANTHROPIC_API_KEY=... , fly deploy.\n"
|
|
127
|
+
f'app = "{name}"\n\n'
|
|
128
|
+
"[build]\n\n"
|
|
129
|
+
"[http_service]\n"
|
|
130
|
+
f" internal_port = {port}\n"
|
|
131
|
+
" force_https = true\n"
|
|
132
|
+
" auto_stop_machines = true\n"
|
|
133
|
+
" min_machines_running = 0\n\n"
|
|
134
|
+
"[[vm]]\n"
|
|
135
|
+
' memory = "512mb"\n'
|
|
136
|
+
" cpus = 1\n"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render_text(name: str, *, port: int = 8000, envs: tuple[str, ...] = _KEY_ENVS) -> str:
|
|
141
|
+
env_lines = "\n".join(f" - key: {k}\n sync: false" for k in envs)
|
|
142
|
+
return (
|
|
143
|
+
"# Generated by `zu deploy render`. Commit, then create a Blueprint on Render.\n"
|
|
144
|
+
"services:\n"
|
|
145
|
+
" - type: web\n"
|
|
146
|
+
f" name: {name}\n"
|
|
147
|
+
" runtime: docker\n"
|
|
148
|
+
" dockerfilePath: ./Dockerfile\n"
|
|
149
|
+
" envVars:\n"
|
|
150
|
+
f"{env_lines}\n"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def write_dockerfile(
|
|
155
|
+
directory: str, config: str, *, extras: str, port: int, force: bool,
|
|
156
|
+
python_version: str = "3.11", version: str | None = None,
|
|
157
|
+
) -> str:
|
|
158
|
+
path = os.path.join(directory, "Dockerfile")
|
|
159
|
+
if os.path.exists(path) and not force:
|
|
160
|
+
return path # respect an existing Dockerfile (e.g. a hand-tuned one)
|
|
161
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
162
|
+
fh.write(dockerfile_text(
|
|
163
|
+
config, extras=extras, port=port, python_version=python_version, version=version,
|
|
164
|
+
))
|
|
165
|
+
return path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def generate(
|
|
169
|
+
target: str, directory: str, *, name: str, config: str, extras: str, port: int, force: bool,
|
|
170
|
+
python_version: str = "3.11", version: str | None = None,
|
|
171
|
+
envs: tuple[str, ...] = _KEY_ENVS,
|
|
172
|
+
) -> list[str]:
|
|
173
|
+
"""Write the manifest(s) for a non-local target. Returns the paths written."""
|
|
174
|
+
written: list[str] = [write_dockerfile(
|
|
175
|
+
directory, config, extras=extras, port=port, force=True,
|
|
176
|
+
python_version=python_version, version=version,
|
|
177
|
+
)]
|
|
178
|
+
files: dict[str, str] = {}
|
|
179
|
+
if target == "dockerfile":
|
|
180
|
+
pass # the Dockerfile above is the whole artifact
|
|
181
|
+
elif target == "compose":
|
|
182
|
+
files["docker-compose.yml"] = compose_text(name, config, port=port, envs=envs)
|
|
183
|
+
elif target == "fly":
|
|
184
|
+
files["fly.toml"] = fly_text(name, port=port)
|
|
185
|
+
elif target == "render":
|
|
186
|
+
files["render.yaml"] = render_text(name, port=port, envs=envs)
|
|
187
|
+
for fname, content in files.items():
|
|
188
|
+
path = os.path.join(directory, fname)
|
|
189
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
190
|
+
fh.write(content)
|
|
191
|
+
written.append(path)
|
|
192
|
+
return written
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def local_commands(
|
|
196
|
+
name: str, config: str, *, port: int, envs: tuple[str, ...] = _KEY_ENVS
|
|
197
|
+
) -> tuple[list[str], list[str]]:
|
|
198
|
+
"""The (build, run) docker command lines for a local deploy. The run command
|
|
199
|
+
passes through whichever of ``envs`` are actually set, so no secret is ever
|
|
200
|
+
written to a file or the image."""
|
|
201
|
+
build = ["docker", "build", "-t", name, "."]
|
|
202
|
+
run = ["docker", "run", "-d", "--name", name, "-p", f"{port}:{port}"]
|
|
203
|
+
for key in envs:
|
|
204
|
+
if os.environ.get(key):
|
|
205
|
+
run += ["-e", key]
|
|
206
|
+
run.append(name)
|
|
207
|
+
return build, run
|
zu_cli/explore.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Harness-driven pathfinding — turn a coding agent's exploration into a track.
|
|
2
|
+
|
|
3
|
+
A developer's OWN harness model (Claude Code, Codex, Cursor — any MCP client) drives zu's
|
|
4
|
+
off-box tools step by step over ``zu mcp``: fetch a page, open the browser, act, read, react
|
|
5
|
+
to what it sees, and find the path. Each step + its observation is recorded; when the path
|
|
6
|
+
reaches the data, the session is projected into a ``fixtures/`` bundle — so the discovery the
|
|
7
|
+
developer would do anyway BECOMES the agent's replayable path. The frontier reasoning is
|
|
8
|
+
spent once, in the harness they already pay for; everything downstream replays it free
|
|
9
|
+
(``zu run --offline``), with the model returning only on divergence at run time.
|
|
10
|
+
|
|
11
|
+
This module is the SESSION core, with the tools injected — the real off-box tools in
|
|
12
|
+
production (a live browser via the docker backend), fakes in tests — so the orchestration is
|
|
13
|
+
verified at $0. The ``zu mcp`` server wraps one session per process as ``zu_explore`` /
|
|
14
|
+
``zu_explore_save``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .offline import Bundle
|
|
24
|
+
|
|
25
|
+
# The off-box tools a harness drives while pathfinding. Pure tools (html_parse, recall) need
|
|
26
|
+
# no exploration — they run unchanged offline — so they are not part of an explore session.
|
|
27
|
+
EXPLORABLE = ("http_fetch", "render_dom", "browser")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ExplorationSession:
|
|
32
|
+
"""One live pathfinding session: the tools being driven (the persistent ``browser`` keeps
|
|
33
|
+
its session across steps), a read-only ``ctx``, and the ordered (tool, args, observation)
|
|
34
|
+
trail the harness builds. Project it into a :class:`~zu_cli.offline.Bundle` when the path
|
|
35
|
+
reaches the data."""
|
|
36
|
+
|
|
37
|
+
tools: dict[str, Any]
|
|
38
|
+
ctx: Any
|
|
39
|
+
steps: list[dict] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
async def step(self, tool: str, args: dict) -> dict:
|
|
42
|
+
"""Drive one tool call, record the (tool, args, observation), and return the
|
|
43
|
+
observation so the harness model can decide the next step. An unknown/non-explorable
|
|
44
|
+
tool is a loud error, not a silent no-op."""
|
|
45
|
+
if tool not in self.tools:
|
|
46
|
+
raise KeyError(
|
|
47
|
+
f"{tool!r} is not an explorable tool; choose one of {sorted(self.tools)}")
|
|
48
|
+
obs = await self.tools[tool](self.ctx, **args)
|
|
49
|
+
self.steps.append({"tool": tool, "args": dict(args), "observation": obs})
|
|
50
|
+
return obs
|
|
51
|
+
|
|
52
|
+
def to_bundle(self, *, task: str, answer: Any, model: str | None = None) -> Bundle:
|
|
53
|
+
"""Project the recorded trail into a replayable Bundle — the SAME shape
|
|
54
|
+
:func:`zu_cli.offline.project_capture` produces: one move per step in order, then a
|
|
55
|
+
final text move carrying ``answer`` (so offline replay reproduces both the navigation
|
|
56
|
+
and the extraction); observations grouped by tool. A browser ``op=close`` has no
|
|
57
|
+
replayable observation (the tool returns without a session read), so it is dropped to
|
|
58
|
+
keep each tool's observation sequence aligned with its calls."""
|
|
59
|
+
moves: list[dict] = [{"tool": s["tool"], "args": s["args"]} for s in self.steps]
|
|
60
|
+
moves.append({"text": json.dumps(answer), "finish": "stop"})
|
|
61
|
+
observations: dict[str, list[dict]] = {}
|
|
62
|
+
for s in self.steps:
|
|
63
|
+
if s["tool"] == "browser" and s["args"].get("op") == "close":
|
|
64
|
+
continue
|
|
65
|
+
if isinstance(s["observation"], dict):
|
|
66
|
+
observations.setdefault(s["tool"], []).append(dict(s["observation"]))
|
|
67
|
+
return Bundle(task=task, moves=moves, observations=observations, model=model)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def default_tools(*, allow_private: bool = False) -> dict[str, Any]:
|
|
71
|
+
"""The real off-box tools a LIVE exploration drives: a one-shot ``http_fetch`` /
|
|
72
|
+
``render_dom`` and a persistent ``browser`` (a real headless browser via the docker
|
|
73
|
+
backend). ``allow_private`` stays False so the SSRF guard holds against a real site."""
|
|
74
|
+
from zu_tools.browser import Browser
|
|
75
|
+
from zu_tools.fetch import HttpFetch
|
|
76
|
+
from zu_tools.render import RenderDom
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"http_fetch": HttpFetch(allow_private=allow_private),
|
|
80
|
+
"render_dom": RenderDom(allow_private=allow_private),
|
|
81
|
+
"browser": Browser(allow_private=allow_private),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def new_session(*, tools: dict[str, Any] | None = None, allow_private: bool = False
|
|
86
|
+
) -> ExplorationSession:
|
|
87
|
+
"""A fresh exploration session — real off-box tools unless ``tools`` is injected (tests)."""
|
|
88
|
+
from zu_core.ports import RunContext
|
|
89
|
+
|
|
90
|
+
return ExplorationSession(
|
|
91
|
+
tools=tools if tools is not None else default_tools(allow_private=allow_private),
|
|
92
|
+
ctx=RunContext(spec=None),
|
|
93
|
+
)
|