zu-runtime 0.2.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.
@@ -0,0 +1,66 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # uv / venv
10
+ .venv/
11
+ uv.lock.bak
12
+
13
+ # Test / type caches
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .coverage
18
+ htmlcov/
19
+
20
+ # Zu runtime artifacts
21
+ *.db
22
+ zu.db
23
+ zu.yaml.local
24
+ zu_review.jsonl
25
+ *.review.jsonl
26
+ # Per-agent cost telemetry ledger — machine-local run history, not source.
27
+ cost.jsonl
28
+ # A recorded replay path is learned per-run and machine-local — regenerated on
29
+ # every successful run, not source. The agent ships; its track does not.
30
+ track.json
31
+ # …except the flagship example ships its track on purpose, as a demo of the
32
+ # record/replay convergence (committed; re-runs show as ordinary modifications).
33
+ !examples/agents/vet-appointment/track.json
34
+
35
+ # Editor / OS
36
+ .idea/
37
+ .vscode/
38
+ .DS_Store
39
+
40
+ # Claude Code local session state
41
+ .claude/
42
+
43
+ # Secrets
44
+ .env
45
+ .env.*
46
+ !.env.example
47
+
48
+ # Microsoft Office temp/lock files
49
+ ~$*
50
+
51
+ # Internal design / strategy docs — kept local, never in the public repo
52
+ *.docx
53
+ *.pdf
54
+ # BUILD.md is the internal build-sequence / deferred-gaps ledger — kept local.
55
+ # (ARCHITECTURE.md is public: an onboarding agent needs the structural map.)
56
+ docs/BUILD.md
57
+
58
+ # Local secret — API key for live validation, never commit
59
+ zu_demo_key.md
60
+ *_key.md
61
+
62
+ # Local PyPI publish token — never commit
63
+ /pypi
64
+
65
+ # Local Discord credentials (bot token / app secrets) — never commit
66
+ /discord
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: zu-runtime
3
+ Version: 0.2.0
4
+ Summary: An opinionated, backend-agnostic runtime for agents that work in production — deterministic, auditable, injection-resistant. Import as `zu`.
5
+ Project-URL: Homepage, https://github.com/k3-mt/zu
6
+ Project-URL: Repository, https://github.com/k3-mt/zu
7
+ License-Expression: Apache-2.0
8
+ Keywords: agents,ai-agents,event-sourcing,llm,runtime,sandbox,web-scraping
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: zu-backends==0.2.0
19
+ Requires-Dist: zu-checks==0.2.0
20
+ Requires-Dist: zu-cli==0.2.0
21
+ Requires-Dist: zu-providers==0.2.0
22
+ Requires-Dist: zu-tools==0.2.0
23
+ Provides-Extra: all
24
+ Requires-Dist: zu-backends[docker]==0.2.0; extra == 'all'
25
+ Requires-Dist: zu-cli[mcp]==0.2.0; extra == 'all'
26
+ Requires-Dist: zu-cli[serve]==0.2.0; extra == 'all'
27
+ Requires-Dist: zu-cli[test]==0.2.0; extra == 'all'
28
+ Requires-Dist: zu-providers[anthropic]==0.2.0; extra == 'all'
29
+ Requires-Dist: zu-providers[openai]==0.2.0; extra == 'all'
30
+ Requires-Dist: zu-tools==0.2.0; extra == 'all'
31
+ Provides-Extra: anthropic
32
+ Requires-Dist: zu-providers[anthropic]==0.2.0; extra == 'anthropic'
33
+ Provides-Extra: demo
34
+ Requires-Dist: zu-tools==0.2.0; extra == 'demo'
35
+ Provides-Extra: docker
36
+ Requires-Dist: zu-backends[docker]==0.2.0; extra == 'docker'
37
+ Provides-Extra: mcp
38
+ Requires-Dist: zu-cli[mcp]==0.2.0; extra == 'mcp'
39
+ Provides-Extra: openai
40
+ Requires-Dist: zu-providers[openai]==0.2.0; extra == 'openai'
41
+ Provides-Extra: serve
42
+ Requires-Dist: zu-cli[serve]==0.2.0; extra == 'serve'
43
+ Provides-Extra: test
44
+ Requires-Dist: zu-cli[test]==0.2.0; extra == 'test'
45
+ Provides-Extra: web
46
+ Requires-Dist: zu-tools==0.2.0; extra == 'web'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # Zu
50
+
51
+ **An opinionated, backend-agnostic runtime for agents that work in production** —
52
+ deterministic, auditable, and injection-resistant by construction.
53
+
54
+ New here? One line:
55
+
56
+ ```bash
57
+ pip install 'zu-runtime[all]' # everything: web tools, both model SDKs, server, Docker, MCP
58
+ ```
59
+
60
+ Prefer a lean install? `pip install zu-runtime` gives you `import zu`, the `zu`
61
+ command, the **web tools** (http_fetch/html_parse/render_dom), the model-provider adapters,
62
+ detectors, validators, and a SQLite event sink. Add the heavy/situational bits as extras:
63
+
64
+ ```bash
65
+ pip install 'zu-runtime[anthropic]' # + the Anthropic SDK to call a real model (also: [openai])
66
+ pip install 'zu-runtime[serve]' # + the HTTP server (zu serve)
67
+ pip install 'zu-runtime[docker]' # + the Docker sandbox (tier-2 browser)
68
+ pip install 'zu-runtime[mcp]' # + the MCP server (zu mcp)
69
+ ```
70
+
71
+ The `zu-*` packages are also standalone on PyPI, but you rarely install them individually —
72
+ that's for plugin authors depending on just `zu-core`.
73
+
74
+ ## Embed it
75
+
76
+ ```python
77
+ import zu
78
+
79
+ result = zu.run(
80
+ {"query": "Extract the product name and price.",
81
+ "target": "https://example.com/product/123",
82
+ "output_schema": {"type": "object",
83
+ "properties": {"name": {"type": "string"}, "price": {"type": "string"}},
84
+ "required": ["name", "price"]}},
85
+ config={"provider": {"name": "anthropic", "model": "claude-sonnet-4-6",
86
+ "api_key_env": "ANTHROPIC_API_KEY"},
87
+ "plugins": {"tools": ["http_fetch", "html_parse", "render_dom"],
88
+ "detectors": ["empty", "error", "js-shell", "bot-wall"],
89
+ "validators": ["schema", "grounding"]}},
90
+ )
91
+ print(result.status, result.value)
92
+ ```
93
+
94
+ Swapping the model is a one-line edit to the `provider` block — Anthropic,
95
+ OpenRouter, OpenAI, or a local model (Ollama / vLLM) — because the runtime only
96
+ ever speaks to a `ModelProvider` port. Credentials are named by environment
97
+ variable (`api_key_env`), never passed in code or config.
98
+
99
+ ## Run it from the command line, or as a service
100
+
101
+ ```bash
102
+ zu run agent.yaml # one-shot
103
+ zu run agent.yaml --every 5m # scheduled worker
104
+ zu serve -c agent.yaml # HTTP: POST /run (needs the [serve] extra)
105
+ ```
106
+
107
+ ## What it is
108
+
109
+ A small, stable core (the loop, registry, contracts, event bus) surrounded by
110
+ six swappable ports. Every capability that can vary is a plugin behind a port,
111
+ so the production system is reached by adding adapters — never by reopening the
112
+ core. Full source, architecture, and examples:
113
+ **https://github.com/k3-mt/zu**
114
+
115
+ Apache-2.0.
@@ -0,0 +1,67 @@
1
+ # Zu
2
+
3
+ **An opinionated, backend-agnostic runtime for agents that work in production** —
4
+ deterministic, auditable, and injection-resistant by construction.
5
+
6
+ New here? One line:
7
+
8
+ ```bash
9
+ pip install 'zu-runtime[all]' # everything: web tools, both model SDKs, server, Docker, MCP
10
+ ```
11
+
12
+ Prefer a lean install? `pip install zu-runtime` gives you `import zu`, the `zu`
13
+ command, the **web tools** (http_fetch/html_parse/render_dom), the model-provider adapters,
14
+ detectors, validators, and a SQLite event sink. Add the heavy/situational bits as extras:
15
+
16
+ ```bash
17
+ pip install 'zu-runtime[anthropic]' # + the Anthropic SDK to call a real model (also: [openai])
18
+ pip install 'zu-runtime[serve]' # + the HTTP server (zu serve)
19
+ pip install 'zu-runtime[docker]' # + the Docker sandbox (tier-2 browser)
20
+ pip install 'zu-runtime[mcp]' # + the MCP server (zu mcp)
21
+ ```
22
+
23
+ The `zu-*` packages are also standalone on PyPI, but you rarely install them individually —
24
+ that's for plugin authors depending on just `zu-core`.
25
+
26
+ ## Embed it
27
+
28
+ ```python
29
+ import zu
30
+
31
+ result = zu.run(
32
+ {"query": "Extract the product name and price.",
33
+ "target": "https://example.com/product/123",
34
+ "output_schema": {"type": "object",
35
+ "properties": {"name": {"type": "string"}, "price": {"type": "string"}},
36
+ "required": ["name", "price"]}},
37
+ config={"provider": {"name": "anthropic", "model": "claude-sonnet-4-6",
38
+ "api_key_env": "ANTHROPIC_API_KEY"},
39
+ "plugins": {"tools": ["http_fetch", "html_parse", "render_dom"],
40
+ "detectors": ["empty", "error", "js-shell", "bot-wall"],
41
+ "validators": ["schema", "grounding"]}},
42
+ )
43
+ print(result.status, result.value)
44
+ ```
45
+
46
+ Swapping the model is a one-line edit to the `provider` block — Anthropic,
47
+ OpenRouter, OpenAI, or a local model (Ollama / vLLM) — because the runtime only
48
+ ever speaks to a `ModelProvider` port. Credentials are named by environment
49
+ variable (`api_key_env`), never passed in code or config.
50
+
51
+ ## Run it from the command line, or as a service
52
+
53
+ ```bash
54
+ zu run agent.yaml # one-shot
55
+ zu run agent.yaml --every 5m # scheduled worker
56
+ zu serve -c agent.yaml # HTTP: POST /run (needs the [serve] extra)
57
+ ```
58
+
59
+ ## What it is
60
+
61
+ A small, stable core (the loop, registry, contracts, event bus) surrounded by
62
+ six swappable ports. Every capability that can vary is a plugin behind a port,
63
+ so the production system is reached by adding adapters — never by reopening the
64
+ core. Full source, architecture, and examples:
65
+ **https://github.com/k3-mt/zu**
66
+
67
+ Apache-2.0.
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "zu-runtime"
3
+ version = "0.2.0"
4
+ description = "An opinionated, backend-agnostic runtime for agents that work in production — deterministic, auditable, injection-resistant. Import as `zu`."
5
+ requires-python = ">=3.11"
6
+ license = "Apache-2.0"
7
+ classifiers = [
8
+ "Development Status :: 4 - Beta",
9
+ "Intended Audience :: Developers",
10
+ "License :: OSI Approved :: Apache Software License",
11
+ "Programming Language :: Python :: 3",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
15
+ "Typing :: Typed",
16
+ ]
17
+ readme = "README.md"
18
+ keywords = ["agents", "llm", "ai-agents", "runtime", "web-scraping", "sandbox", "event-sourcing"]
19
+ # The runnable base: `import zu`, the `zu` command, the web tools
20
+ # (http_fetch/html_parse/render_dom — the common case, so they ship in the base), the
21
+ # model-provider adapters, detectors, validators, and the sqlite event sink. The
22
+ # heavy/situational bits below are opt-in extras (the real model SDKs, the HTTP server,
23
+ # the Docker sandbox, MCP); every plugin is also installable standalone (pip install
24
+ # zu-tools, …) so a plugin author can depend on just what they need (the ecosystem).
25
+ dependencies = [
26
+ "zu-cli==0.2.0",
27
+ "zu-providers==0.2.0",
28
+ "zu-checks==0.2.0",
29
+ "zu-backends==0.2.0",
30
+ "zu-tools==0.2.0", # web tools — the common case, so it ships in the base
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ # The web tools now ship in the BASE; [web]/[demo] remain as no-op back-compat
35
+ # aliases so an existing `pip install 'zu-runtime[web]'` / `[demo]` still resolves.
36
+ web = ["zu-tools==0.2.0"]
37
+ demo = ["zu-tools==0.2.0"]
38
+ # Real model SDKs (the adapters ship in the base; these add the vendor client).
39
+ anthropic = ["zu-providers[anthropic]==0.2.0"]
40
+ openai = ["zu-providers[openai]==0.2.0"]
41
+ # The HTTP server (`zu serve`) and the Docker sandbox client (tier-2 browser).
42
+ serve = ["zu-cli[serve]==0.2.0"]
43
+ docker = ["zu-backends[docker]==0.2.0"]
44
+ # The MCP server (`zu mcp`) — integrate with coding agents (Claude Code, Cursor).
45
+ mcp = ["zu-cli[mcp]==0.2.0"]
46
+ # The plugin-test gate + adversarial red team (`zu test-plugin`) — a contributor
47
+ # / CI tool for proving a plugin cooperates and withstands attack.
48
+ test = ["zu-cli[test]==0.2.0"]
49
+ # Everything: web tools, both model SDKs, the server, the Docker sandbox, MCP,
50
+ # and the test gate.
51
+ all = [
52
+ "zu-tools==0.2.0",
53
+ "zu-providers[anthropic]==0.2.0",
54
+ "zu-providers[openai]==0.2.0",
55
+ "zu-cli[serve]==0.2.0",
56
+ "zu-cli[mcp]==0.2.0",
57
+ "zu-cli[test]==0.2.0",
58
+ "zu-backends[docker]==0.2.0",
59
+ ]
60
+
61
+ [project.urls]
62
+ Homepage = "https://github.com/k3-mt/zu"
63
+ Repository = "https://github.com/k3-mt/zu"
64
+
65
+ [build-system]
66
+ requires = ["hatchling"]
67
+ build-backend = "hatchling.build"
68
+
69
+ [tool.hatch.build.targets.wheel]
70
+ packages = ["src/zu"]
@@ -0,0 +1,203 @@
1
+ """Zu — the embed facade. ``import zu`` and run an agent in one line.
2
+
3
+ This is the batteries-included entry point for *using* Zu from your own code.
4
+ It wires the same path the CLI and the HTTP server use — config in, a typed
5
+ ``Result`` out — so embedding, ``zu run``, and ``zu serve`` are one runtime, not
6
+ three.
7
+
8
+ import zu
9
+
10
+ # a self-contained agent: one agent.yaml, or a bundle dir (agent.yaml + tools/)
11
+ result = zu.run_agent("agent.yaml") # or zu.run_agent("my-agent/")
12
+
13
+ # the programmatic form — a config plus a task (config + many tasks):
14
+ result = zu.run(
15
+ {"query": "Extract the title and price.", "target": "https://example.com",
16
+ "output_schema": {"type": "object", "properties": {"title": {"type": "string"}}}},
17
+ config={"provider": {"name": "anthropic", "model": "claude-sonnet-4-6",
18
+ "api_key_env": "ANTHROPIC_API_KEY"},
19
+ "plugins": {"tools": ["http_fetch", "html_parse"], "validators": ["schema"]}},
20
+ )
21
+
22
+ print(result.status, result.value)
23
+
24
+ # a reusable, configured runner (load config once, run many tasks)
25
+ agent = zu.Zu(config="zu.yaml")
26
+ r1 = agent.run({"query": "..."})
27
+ r2, events = agent.run_with_events({"query": "..."}) # also get the event log
28
+
29
+ Credentials are never passed here: config names the *environment variable*
30
+ holding a key (``api_key_env``), resolved inside the adapter at call time.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ from typing import Any
37
+
38
+ from zu_cli.config import (
39
+ ConfigError,
40
+ RunConfig,
41
+ assemble,
42
+ coerce_config,
43
+ coerce_task,
44
+ )
45
+ from zu_core.contracts import Budget, Event, Result, Status, TaskSpec
46
+ from zu_core.loop import run_task
47
+ from zu_core.registry import (
48
+ backend,
49
+ detector,
50
+ provider,
51
+ sink,
52
+ tool,
53
+ validator,
54
+ )
55
+
56
+ from .pipeline import Pipeline, PipelineResult
57
+
58
+ __version__ = "0.1.0"
59
+
60
+ # In-process plugin registration decorators, re-exported from the core registry
61
+ # so the documented ``@zu.tool`` / ``@zu.detector`` / … surface (see
62
+ # the architecture docs and AGENTS.md) actually resolves on ``import zu``. They
63
+ # register onto the process-wide REGISTRY the loop reads, so a decorator-
64
+ # registered plugin is visible to ``zu.run`` and ``zu plugins`` alike.
65
+ __all__ = [
66
+ "Zu",
67
+ "Pipeline",
68
+ "PipelineResult",
69
+ "run_agent",
70
+ "run_agent_with_events",
71
+ "run",
72
+ "arun",
73
+ "run_with_events",
74
+ "ConfigError",
75
+ "RunConfig",
76
+ "TaskSpec",
77
+ "Result",
78
+ "Status",
79
+ "Budget",
80
+ "Event",
81
+ "create_app",
82
+ "tool",
83
+ "detector",
84
+ "validator",
85
+ "provider",
86
+ "backend",
87
+ "sink",
88
+ "__version__",
89
+ ]
90
+
91
+
92
+ # Config/task coercion is shared with the CLI surfaces (see zu_cli.config). The
93
+ # embed facade accepts a str task as a *path* (``allow_paths=True``): you're
94
+ # running in-process on your own host, so reading a task file you point at — the
95
+ # same affordance as ``zu run`` — is intended.
96
+
97
+
98
+ class Zu:
99
+ """A configured runner. Load a config once, run many tasks against it.
100
+
101
+ ``config`` is a path, a dict, a ``RunConfig``, or None (``./zu.yaml``). The
102
+ config is parsed eagerly so a bad config fails here, not on the first run.
103
+ """
104
+
105
+ def __init__(self, config: Any = None) -> None:
106
+ self.config: RunConfig = coerce_config(config)
107
+
108
+ async def arun_with_events(self, task: Any) -> tuple[Result, list[Event]]:
109
+ """Async: run one task, returning the Result and the run's event log."""
110
+ spec = coerce_task(task, self.config.budget, allow_paths=True)
111
+ provider, registry, bus, providers = assemble(self.config)
112
+ # The same observability hook the CLI uses: an embedded agent queues a
113
+ # blocked attempt to the review queue too (no console trace by default).
114
+ from zu_cli.observe import attach_observability
115
+
116
+ attach_observability(bus, self.config.observability)
117
+ try:
118
+ result = await run_task(
119
+ spec, provider, registry, bus,
120
+ providers=providers, containment=self.config.containment,
121
+ max_observation_chars=self.config.max_observation_chars,
122
+ observation_strategy=self.config.observation_strategy,
123
+ max_context_chars=self.config.max_context_chars,
124
+ )
125
+ events = await bus.query()
126
+ return result, events
127
+ finally:
128
+ # ``assemble`` builds a fresh bus (and its canonical/trace sinks) per
129
+ # run; release them here so a long-lived, reused ``Zu`` instance does
130
+ # not leak one sqlite connection per ``run()``.
131
+ await bus.aclose()
132
+
133
+ async def arun(self, task: Any) -> Result:
134
+ """Async: run one task, returning just the Result."""
135
+ result, _ = await self.arun_with_events(task)
136
+ return result
137
+
138
+ def run_with_events(self, task: Any) -> tuple[Result, list[Event]]:
139
+ """Run one task synchronously, returning the Result and the event log."""
140
+ return asyncio.run(self.arun_with_events(task))
141
+
142
+ def run(self, task: Any) -> Result:
143
+ """Run one task synchronously, returning just the Result."""
144
+ return asyncio.run(self.arun(task))
145
+
146
+
147
+ def run_agent(source: Any = None) -> Result:
148
+ """Run a self-contained agent to a Result — the embed equivalent of
149
+ ``zu run``. ``source`` is an ``agent.yaml`` path, a **bundle directory**
150
+ (agent.yaml + a tools/ package, auto-loaded), a dict, or None (``./agent.yaml``
151
+ or ``./``)."""
152
+ result, _ = run_agent_with_events(source)
153
+ return result
154
+
155
+
156
+ def run_agent_with_events(source: Any = None) -> tuple[Result, list[Event]]:
157
+ """Run a self-contained agent, returning the Result *and* its event log."""
158
+ return asyncio.run(_arun_agent(source))
159
+
160
+
161
+ async def _arun_agent(source: Any) -> tuple[Result, list[Event]]:
162
+ from zu_cli.config import load_agent
163
+ from zu_cli.observe import attach_observability
164
+
165
+ spec, cfg = load_agent(source)
166
+ provider, registry, bus, providers = assemble(cfg)
167
+ attach_observability(bus, cfg.observability)
168
+ try:
169
+ result = await run_task(
170
+ spec, provider, registry, bus,
171
+ providers=providers, containment=cfg.containment,
172
+ max_observation_chars=cfg.max_observation_chars,
173
+ observation_strategy=cfg.observation_strategy,
174
+ max_context_chars=cfg.max_context_chars,
175
+ )
176
+ return result, await bus.query()
177
+ finally:
178
+ await bus.aclose()
179
+
180
+
181
+ def run(task: Any, config: Any = None) -> Result:
182
+ """Run one task against a config — the programmatic form (config + many tasks).
183
+ ``task`` and ``config`` may each be a path, a dict, the typed object, or None.
184
+ For a single self-contained ``agent.yaml``/bundle, use :func:`run_agent`."""
185
+ return Zu(config).run(task)
186
+
187
+
188
+ async def arun(task: Any, config: Any = None) -> Result:
189
+ """Async one-shot — the coroutine behind :func:`run`."""
190
+ return await Zu(config).arun(task)
191
+
192
+
193
+ def run_with_events(task: Any, config: Any = None) -> tuple[Result, list[Event]]:
194
+ """Run one task to a Result *and* its event log (the queryable provenance)."""
195
+ return Zu(config).run_with_events(task)
196
+
197
+
198
+ def create_app(config: Any = None, **kwargs: Any) -> Any:
199
+ """The ASGI app for ``zu serve``. Re-exported here so an embedder can mount
200
+ Zu in their own ASGI stack. Needs the 'serve' extra (FastAPI)."""
201
+ from zu_cli.server import create_app as _create_app
202
+
203
+ return _create_app(config, **kwargs)
@@ -0,0 +1,92 @@
1
+ """``zu.Pipeline`` — the config-driven wrapper over the core orchestration.
2
+
3
+ The orchestration itself (shared-trace chaining, gating, resume) lives in
4
+ ``zu_core.pipeline`` — pure, SDK-free core logic over ``run_task``. This wrapper
5
+ adds the ergonomics that match ``zu.run``: a YAML/dict **config** (one provider
6
+ block, the plugins, the sink) and **dict phase specs**. It assembles the config
7
+ once and shares the resulting bus across every phase.
8
+
9
+ pipe = zu.Pipeline(config="zu.yaml")
10
+ pipe.phase("extract", {"query": "...", "output_schema": {...}})
11
+ pipe.phase("summarize", lambda prev: {"query": f"...{prev.value['name']}...", "output_schema": {...}})
12
+ result = pipe.run() # PipelineResult: status, value, phases, events, id
13
+
14
+ A phase's task is a dict, or a callable ``(prev_result) -> dict`` that consumes
15
+ the previous phase's validated value. Point the config at the ``scripted``
16
+ provider to run the whole pipeline offline (no model, no network).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+ from uuid import UUID, uuid4
25
+
26
+ from zu_cli.config import assemble, coerce_config, coerce_task
27
+ from zu_core.contracts import Result, TaskSpec
28
+ from zu_core.pipeline import Phase, PipelineResult, run_pipeline
29
+
30
+ __all__ = ["Pipeline", "PipelineResult"]
31
+
32
+ # A phase's task: a static spec dict, or a builder that consumes the prior result.
33
+ PhaseTask = dict | Callable[[Result | None], dict]
34
+
35
+
36
+ class Pipeline:
37
+ """A deterministic sequence of phases sharing one trace, log, and budget gate.
38
+
39
+ ``config`` is a path, dict, ``RunConfig``, or None (``./zu.yaml``) — the same
40
+ as ``zu.run``. ``pipeline_id`` defaults to a fresh id; pass a stable one (with
41
+ a durable ``event_sink`` in the config) to make the pipeline resumable across
42
+ process restarts.
43
+ """
44
+
45
+ def __init__(self, config: Any = None, *, pipeline_id: UUID | str | None = None) -> None:
46
+ self.config = coerce_config(config)
47
+ self._id = _as_uuid(pipeline_id) if pipeline_id is not None else uuid4()
48
+ self._phases: list[tuple[str, PhaseTask]] = []
49
+
50
+ @property
51
+ def id(self) -> UUID:
52
+ return self._id
53
+
54
+ def phase(self, name: str, task: PhaseTask) -> Pipeline:
55
+ """Append a phase. ``task`` is a spec dict or ``(prev_result) -> dict``.
56
+ Returns self, so calls chain. Names must be unique (they key resume)."""
57
+ if any(n == name for n, _ in self._phases):
58
+ raise ValueError(f"duplicate phase name: {name!r}")
59
+ self._phases.append((name, task))
60
+ return self
61
+
62
+ async def arun(self) -> PipelineResult:
63
+ cfg = self.config
64
+ provider, registry, bus, providers = assemble(cfg)
65
+ try:
66
+ phases = [Phase(name, self._build(task)) for name, task in self._phases]
67
+ return await run_pipeline(
68
+ phases, provider, registry, bus,
69
+ providers=providers, containment=cfg.containment, pipeline_id=self._id,
70
+ max_observation_chars=cfg.max_observation_chars,
71
+ observation_strategy=cfg.observation_strategy,
72
+ max_context_chars=cfg.max_context_chars,
73
+ )
74
+ finally:
75
+ # ``assemble`` built the bus + its sink(s); release them after the run.
76
+ await bus.aclose()
77
+
78
+ def run(self) -> PipelineResult:
79
+ """Run the pipeline synchronously."""
80
+ return asyncio.run(self.arun())
81
+
82
+ def _build(self, task: PhaseTask) -> Callable[[Result | None], TaskSpec]:
83
+ """Turn a dict / callable phase task into a core TaskSpec builder, coercing
84
+ the dict and inheriting the config's default budget."""
85
+ def build(prev: Result | None) -> TaskSpec:
86
+ task_dict = task(prev) if callable(task) else dict(task)
87
+ return coerce_task(task_dict, self.config.budget, allow_paths=True)
88
+ return build
89
+
90
+
91
+ def _as_uuid(value: UUID | str) -> UUID:
92
+ return value if isinstance(value, UUID) else UUID(str(value))
@@ -0,0 +1,106 @@
1
+ """The embed facade: `import zu` and run an agent in one line.
2
+
3
+ Proves the library entry point works offline (scripted provider, no key, no
4
+ network) from plain dicts and from files, returns the typed Result, exposes the
5
+ event log, and reuses one config across many runs via the Zu class.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ import pytest
13
+
14
+ import zu
15
+ from zu import ConfigError, Status
16
+
17
+
18
+ def _cfg(answer: dict) -> dict:
19
+ return {
20
+ "provider": {"name": "scripted", "script": [{"text": json.dumps(answer), "finish": "stop"}]},
21
+ "plugins": {"validators": ["schema"]},
22
+ }
23
+
24
+
25
+ _TASK = {
26
+ "query": "extract the product",
27
+ "output_schema": {
28
+ "type": "object",
29
+ "properties": {"name": {"type": "string"}, "price": {"type": "string"}},
30
+ "required": ["name", "price"],
31
+ },
32
+ }
33
+
34
+
35
+ def test_run_from_dicts_returns_typed_result():
36
+ result = zu.run(_TASK, config=_cfg({"name": "Acme", "price": "$9"}))
37
+ assert result.status is Status.SUCCESS
38
+ assert result.value == {"name": "Acme", "price": "$9"}
39
+
40
+
41
+ def test_run_with_events_exposes_the_log():
42
+ result, events = zu.run_with_events(_TASK, config=_cfg({"name": "Acme", "price": "$9"}))
43
+ assert result.status is Status.SUCCESS
44
+ assert events[-1].type == "harness.task.completed"
45
+ assert any(e.type == "harness.task.started" for e in events)
46
+
47
+
48
+ def test_zu_class_reuses_one_config_for_many_runs():
49
+ agent = zu.Zu(config=_cfg({"name": "Acme", "price": "$9"}))
50
+ r1 = agent.run(_TASK)
51
+ r2 = agent.run({**_TASK, "query": "again"})
52
+ assert r1.status is Status.SUCCESS and r2.status is Status.SUCCESS
53
+
54
+
55
+ async def test_async_entry_point():
56
+ result = await zu.arun(_TASK, config=_cfg({"name": "Acme", "price": "$9"}))
57
+ assert result.status is Status.SUCCESS
58
+
59
+
60
+ def test_run_from_files(tmp_path):
61
+ cfg = tmp_path / "zu.yaml"
62
+ cfg.write_text(
63
+ "provider:\n name: scripted\n"
64
+ ' script: [{ text: \'{"name":"Acme","price":"$9"}\', finish: stop }]\n'
65
+ "plugins:\n validators: [schema]\n",
66
+ encoding="utf-8",
67
+ )
68
+ task = tmp_path / "task.yaml"
69
+ task.write_text(
70
+ "query: extract\noutput_schema:\n type: object\n"
71
+ " properties: { name: { type: string }, price: { type: string } }\n"
72
+ " required: [name, price]\n",
73
+ encoding="utf-8",
74
+ )
75
+ result = zu.run(str(task), config=str(cfg))
76
+ assert result.status is Status.SUCCESS
77
+
78
+
79
+ def test_task_inherits_config_budget():
80
+ agent = zu.Zu(config={**_cfg({"name": "A", "price": "$1"}), "budget": {"max_steps": 3}})
81
+ assert agent.config.budget.max_steps == 3
82
+
83
+
84
+ def test_bad_config_type_is_a_clean_error():
85
+ with pytest.raises(ConfigError):
86
+ zu.run(_TASK, config=12345) # not a path/dict/RunConfig
87
+
88
+
89
+ def test_bad_task_is_a_clean_error():
90
+ with pytest.raises(ConfigError):
91
+ zu.run({"no_query": True}, config=_cfg({"x": "y"}))
92
+
93
+
94
+ def test_registration_decorators_are_exported():
95
+ """The documented ``@zu.tool`` / ``@zu.detector`` / … surface resolves on the
96
+ facade and registers onto the process-wide registry the loop reads."""
97
+ from zu_core.registry import REGISTRY
98
+
99
+ for name in ("tool", "detector", "validator", "provider", "backend", "sink"):
100
+ assert callable(getattr(zu, name)), f"zu.{name} is not exported"
101
+
102
+ @zu.tool
103
+ class _FacadeProbeTool:
104
+ name = "facade_probe_tool"
105
+
106
+ assert "facade_probe_tool" in REGISTRY.names("tools")
@@ -0,0 +1,128 @@
1
+ """Multi-phase pipelines — the event-sourced way to chain agent runs.
2
+
3
+ A pipeline lifts a single run's guarantees to the whole sequence: every phase
4
+ shares ONE trace and ONE event log, advances only on a validated success, and a
5
+ re-run resumes from the log instead of repeating finished work. All offline
6
+ (scripted model), so deterministic and keyless.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from uuid import uuid4
12
+
13
+ import zu
14
+ from zu_core.contracts import Status
15
+
16
+
17
+ def _cfg(*moves, sink: str | None = None) -> dict:
18
+ # validators off: these tests exercise pipeline ORCHESTRATION (trace, gating,
19
+ # resume) on tool-less scripted phases; schema/grounding are covered in
20
+ # zu-checks. "Validated success" still gates — a clean finalise is SUCCESS.
21
+ cfg: dict = {"provider": {"name": "scripted", "script": list(moves)},
22
+ "plugins": {"validators": []}}
23
+ if sink is not None:
24
+ cfg["event_sink"] = {"driver": "sqlite", "path": sink}
25
+ return cfg
26
+
27
+
28
+ async def test_two_phase_pipeline_passes_value_forward() -> None:
29
+ seen: dict = {}
30
+ pipe = zu.Pipeline(config=_cfg(
31
+ {"text": '{"name": "AeroPress", "price": "$39"}', "finish": "stop"},
32
+ {"text": '{"blurb": "great press"}', "finish": "stop"},
33
+ ))
34
+ pipe.phase("extract", {"query": "extract name and price"})
35
+
36
+ def blurb(prev):
37
+ seen["prev"] = prev.value # phase 2 consumes phase 1
38
+ return {"query": f"write a blurb for {prev.value['name']}"}
39
+
40
+ pipe.phase("blurb", blurb)
41
+ res = await pipe.arun()
42
+
43
+ assert res.status is Status.SUCCESS
44
+ assert res.value == {"blurb": "great press"} # final phase's value
45
+ assert seen["prev"] == {"name": "AeroPress", "price": "$39"} # data flowed forward
46
+ assert res.phases["extract"].value == {"name": "AeroPress", "price": "$39"}
47
+
48
+
49
+ async def test_pipeline_stops_on_a_failed_phase() -> None:
50
+ ran: list[str] = []
51
+ pipe = zu.Pipeline(config=_cfg(
52
+ {"text": '{"ok": true}', "finish": "stop"},
53
+ {"text": "truncated", "finish": "length"}, # phase 2 fails (terminal)
54
+ ))
55
+ pipe.phase("one", {"query": "q1"})
56
+ pipe.phase("two", {"query": "q2"})
57
+
58
+ def three(prev):
59
+ ran.append("three")
60
+ return {"query": "q3"}
61
+
62
+ pipe.phase("three", three)
63
+ res = await pipe.arun()
64
+
65
+ assert res.status is not Status.SUCCESS # the pipeline failed
66
+ assert res.failed_phase == "two"
67
+ assert "three" not in ran # phase 3 never built or ran
68
+ assert "three" not in res.phases
69
+
70
+
71
+ async def test_pipeline_is_one_replayable_trace() -> None:
72
+ pipe = zu.Pipeline(config=_cfg(
73
+ {"text": '{"a": 1}', "finish": "stop"},
74
+ {"text": '{"b": 2}', "finish": "stop"},
75
+ ))
76
+ pipe.phase("p1", {"query": "q"}).phase("p2", {"query": "q"})
77
+ res = await pipe.arun()
78
+
79
+ assert res.events # the whole pipeline log
80
+ assert all(e.trace_id == pipe.id for e in res.events) # ONE correlation id
81
+ assert len({e.task_id for e in res.events}) >= 3 # pipeline + 2 phase task_ids
82
+ types = {e.type for e in res.events}
83
+ assert "harness.pipeline.started" in types
84
+ assert "harness.pipeline.completed" in types
85
+ done = {e.payload.get("phase") for e in res.events
86
+ if e.type == "harness.pipeline.phase.completed"}
87
+ assert done == {"p1", "p2"}
88
+
89
+
90
+ async def test_pipeline_resumes_from_the_log(tmp_path) -> None:
91
+ db = str(tmp_path / "pipe.db")
92
+ pid = uuid4()
93
+
94
+ # Run 1: phase A succeeds, phase B truncates (fails) → pipeline stops; A is on
95
+ # the durable log, B is not.
96
+ p1 = zu.Pipeline(
97
+ config=_cfg({"text": '{"v": "A"}', "finish": "stop"},
98
+ {"text": "x", "finish": "length"}, sink=db),
99
+ pipeline_id=pid,
100
+ )
101
+ p1.phase("A", {"query": "qa"}).phase("B", {"query": "qb"})
102
+ r1 = await p1.arun()
103
+ assert r1.status is not Status.SUCCESS and r1.failed_phase == "B"
104
+
105
+ # Run 2 (resume): same id + same sink. A is found complete in the log and
106
+ # SKIPPED (its task builder never runs); B re-runs and now succeeds.
107
+ rebuilt: list[str] = []
108
+
109
+ def build_a(prev):
110
+ rebuilt.append("A")
111
+ return {"query": "qa"}
112
+
113
+ p2 = zu.Pipeline(
114
+ config=_cfg({"text": '{"v": "B-ok"}', "finish": "stop"}, sink=db),
115
+ pipeline_id=pid,
116
+ )
117
+ p2.phase("A", build_a).phase("B", {"query": "qb"})
118
+ r2 = await p2.arun()
119
+
120
+ assert r2.status is Status.SUCCESS
121
+ assert r2.value == {"v": "B-ok"}
122
+ assert "A" not in rebuilt # A skipped — not re-executed
123
+ assert r2.phases["A"].value == {"v": "A"} # A's value reused from the log
124
+ assert any(e.type == "harness.pipeline.phase.skipped" for e in r2.events)
125
+ # A was started exactly once across both runs (run 1), never re-run.
126
+ a_starts = [e for e in r2.events
127
+ if e.type == "harness.task.started" and e.payload.get("query") == "qa"]
128
+ assert len(a_starts) == 1