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.
- zu_runtime-0.2.0/.gitignore +66 -0
- zu_runtime-0.2.0/PKG-INFO +115 -0
- zu_runtime-0.2.0/README.md +67 -0
- zu_runtime-0.2.0/pyproject.toml +70 -0
- zu_runtime-0.2.0/src/zu/__init__.py +203 -0
- zu_runtime-0.2.0/src/zu/pipeline.py +92 -0
- zu_runtime-0.2.0/tests/test_facade.py +106 -0
- zu_runtime-0.2.0/tests/test_pipeline.py +128 -0
|
@@ -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
|