folpipe 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.
- folpipe-0.1.0.dist-info/METADATA +235 -0
- folpipe-0.1.0.dist-info/RECORD +18 -0
- folpipe-0.1.0.dist-info/WHEEL +4 -0
- folpipe-0.1.0.dist-info/entry_points.txt +2 -0
- folpipe-0.1.0.dist-info/licenses/LICENSE +21 -0
- pipelinex/__init__.py +0 -0
- pipelinex/cli.py +219 -0
- pipelinex/context_mgr.py +78 -0
- pipelinex/loader.py +101 -0
- pipelinex/logger.py +73 -0
- pipelinex/model.py +99 -0
- pipelinex/runner.py +493 -0
- pipelinex/scaffold.py +141 -0
- pipelinex/state.py +65 -0
- pipelinex/tools/__init__.py +0 -0
- pipelinex/tools/builtin.py +318 -0
- pipelinex/tools/executor.py +61 -0
- pipelinex/tools/resolver.py +73 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: folpipe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Folder-based agentic AI pipeline framework
|
|
5
|
+
Project-URL: Homepage, https://github.com/kroczynskikrzysztof/pipelinex
|
|
6
|
+
Project-URL: Documentation, https://github.com/kroczynskikrzysztof/pipelinex/tree/main/docs
|
|
7
|
+
Project-URL: Issues, https://github.com/kroczynskikrzysztof/pipelinex/issues
|
|
8
|
+
Author-email: Krzysztof Roczynski <kroczynskikrzysztof.info@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,ai,automation,llm,pipeline
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: click>=8.0
|
|
22
|
+
Requires-Dist: httpx>=0.24
|
|
23
|
+
Requires-Dist: jinja2>=3.0
|
|
24
|
+
Requires-Dist: litellm>=1.40
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0
|
|
26
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# pipelinex
|
|
30
|
+
|
|
31
|
+
Folder-based agentic AI pipeline framework. No programming required to use.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install folpipe
|
|
35
|
+
folpipe run ./my-pipeline --watch
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## The idea
|
|
41
|
+
|
|
42
|
+
A pipeline is a folder. Instructions live in plain-English markdown files. Tools are drop-in folders. The model is swappable with one line.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
my-pipeline/
|
|
46
|
+
├── pipeline.yaml ← steps, model, routing
|
|
47
|
+
├── SKILL.md ← global instructions (plain English)
|
|
48
|
+
├── .env ← API keys
|
|
49
|
+
├── input/ ← drop files here
|
|
50
|
+
├── output/ ← results land here
|
|
51
|
+
├── tools/ ← drop-in custom tools
|
|
52
|
+
├── step-01-ingest/
|
|
53
|
+
│ └── SKILL.md ← what this step does
|
|
54
|
+
└── step-02-process/
|
|
55
|
+
├── SKILL.md
|
|
56
|
+
└── sub-01-chunk/ ← sub-steps, recursively
|
|
57
|
+
└── SKILL.md
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Zip the folder, share it, version it in git. No server, no database, no cloud account.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
**1. Install**
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install pipelinex
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**2. Create a pipeline**
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pipelinex new my-pipeline
|
|
76
|
+
cd my-pipeline
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**3. Configure the model** — edit `.env`:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
DEEPSEEK_API_KEY=sk-...
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**4. Write your step** — edit `step-01-start/SKILL.md`:
|
|
86
|
+
|
|
87
|
+
```markdown
|
|
88
|
+
# Summarise input
|
|
89
|
+
|
|
90
|
+
Read the file from input/ and write a 3-paragraph summary to output/summary.md.
|
|
91
|
+
|
|
92
|
+
## Task
|
|
93
|
+
|
|
94
|
+
Use read_file to read the input file.
|
|
95
|
+
Use write_file to write output/summary.md.
|
|
96
|
+
Leave a handoff note when done.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**5. Run it**
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
cp myfile.txt input/
|
|
103
|
+
pipelinex run . --watch
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## What it can do
|
|
109
|
+
|
|
110
|
+
**Linear pipelines** — steps run in order, each hands off to the next via shared state.
|
|
111
|
+
|
|
112
|
+
**Agent branching** — the model decides which step comes next from a declared whitelist:
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
- id: step-03-validate
|
|
116
|
+
can_goto: [step-04-output, step-05-partial-output]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Parallel dispatch** — the model fires multiple `dispatch_task` calls in one response; the runner detects the batch and executes them concurrently:
|
|
120
|
+
|
|
121
|
+
```markdown
|
|
122
|
+
## Task
|
|
123
|
+
Process all documents at once — send them all for chunking
|
|
124
|
+
in the same breath rather than one at a time.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Sub-steps** — steps contain named sub-step folders with their own SKILL.md and tools:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
dispatch_task(task="chunk intro-to-ml.txt", substep="sub-01-chunk", context={...})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Human-in-the-loop** — steps can pause for console input, file review, or a custom tool (Slack, email, webhook):
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
- id: step-03-review
|
|
137
|
+
human_input:
|
|
138
|
+
mode: console
|
|
139
|
+
prompt: "Approve this output? (yes/no):"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Model-driven context** — each step's `## Context` section tells the model what state to pay attention to and what to ignore. Skip signals are respected; the model never sees irrelevant data.
|
|
143
|
+
|
|
144
|
+
**Any model** — DeepSeek, Anthropic, OpenAI, Ollama, Groq, or any OpenAI-compatible endpoint. Change one line in `pipeline.yaml`. Optional per-step overrides for cost optimisation.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Custom tools
|
|
149
|
+
|
|
150
|
+
Drop a folder into `tools/`:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
tools/
|
|
154
|
+
└── send_slack/
|
|
155
|
+
├── tool.json ← MCP-compatible schema + deps declaration
|
|
156
|
+
└── run.py ← stdin JSON in, stdout JSON out
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"name": "send_slack",
|
|
162
|
+
"description": "Post a message to a Slack channel. Use for approvals and failures that need human eyes.",
|
|
163
|
+
"inputSchema": { ... },
|
|
164
|
+
"deps": ["slack-sdk"]
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Dependencies declared in `deps` are installed automatically before the first run. No manual `pip install`.
|
|
169
|
+
|
|
170
|
+
Any language works — `run.py`, `run.sh`, or `run.js`.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Example pipelines
|
|
175
|
+
|
|
176
|
+
### doc-pipeline
|
|
177
|
+
|
|
178
|
+
Ingests documents → chunks → embeds → indexed output.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pipelinex run ./doc-pipeline --watch
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Demonstrates: parallel dispatch, sub-steps, agent branching, partial-failure routing.
|
|
185
|
+
|
|
186
|
+
### cv-pipeline
|
|
187
|
+
|
|
188
|
+
Job URL → developer profile → tailored CV + styled PDF.
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
pipelinex run ./cv-pipeline --input "https://example.com/jobs/engineer" --watch
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Demonstrates: custom tools (`fetch_page`, `render_pdf`), linear pipeline, HTML→PDF via Edge headless.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## CLI
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
pipelinex run ./my-pipeline # run
|
|
202
|
+
pipelinex run ./my-pipeline --watch # run with live output
|
|
203
|
+
pipelinex run ./my-pipeline --from step-03 # resume after failure
|
|
204
|
+
pipelinex run ./my-pipeline --dry-run # validate config without running
|
|
205
|
+
pipelinex new my-pipeline # scaffold new pipeline
|
|
206
|
+
pipelinex new step step-05-review --in ./my-pipeline
|
|
207
|
+
pipelinex new tool send_email --in ./my-pipeline/tools
|
|
208
|
+
pipelinex validate ./my-pipeline # check config
|
|
209
|
+
pipelinex tools list --pipeline ./my-pipeline
|
|
210
|
+
pipelinex tools install ./my-pipeline # pre-install tool deps
|
|
211
|
+
pipelinex log ./my-pipeline # show run log
|
|
212
|
+
pipelinex log ./my-pipeline --errors # show last error report
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Documentation
|
|
218
|
+
|
|
219
|
+
- [Getting Started](docs/getting-started.md)
|
|
220
|
+
- [Pipeline Structure](docs/pipeline-structure.md) — `pipeline.yaml` and `SKILL.md` reference
|
|
221
|
+
- [State & Handoffs](docs/state.md)
|
|
222
|
+
- [Tools](docs/tools.md) — built-ins, custom tools, resolution order
|
|
223
|
+
- [Execution Patterns](docs/execution-patterns.md) — linear, branching, loops, dispatch, sub-steps
|
|
224
|
+
- [Context Management](docs/context-management.md)
|
|
225
|
+
- [Human Input](docs/human-input.md)
|
|
226
|
+
- [CLI Reference](docs/cli.md)
|
|
227
|
+
- [Examples](docs/examples.md)
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Requirements
|
|
232
|
+
|
|
233
|
+
- Python 3.10+
|
|
234
|
+
- A model with tool calling support (DeepSeek, Claude, GPT-4, Llama 3.1+)
|
|
235
|
+
- For PDF rendering in cv-pipeline: Edge or Chrome installed
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
pipelinex/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pipelinex/cli.py,sha256=VwuCsCffJbuhX5ydlEMjp3CgJwBW3Kuma9EohMA05rE,7416
|
|
3
|
+
pipelinex/context_mgr.py,sha256=K9Z6lfB2g6xClSCxz_pPYxjdLlAIGo-8BaITmgvQgBo,2483
|
|
4
|
+
pipelinex/loader.py,sha256=44VR7_nynmmXui3nY4yk4NDBhzUx_8SHXvskannPExI,3056
|
|
5
|
+
pipelinex/logger.py,sha256=uhryTI-Puo4KOcaMi_TCD8vtRQLyeivkkCqL8ncRVr0,2835
|
|
6
|
+
pipelinex/model.py,sha256=_usWH5_fLgfa5yd-AHRONI0PZNsIpnrT0bnr85Uc17E,2828
|
|
7
|
+
pipelinex/runner.py,sha256=sQKdnI4vRUvmVfRzMtmcBbMdcEbB-hPKGL9XCCLeLHU,20816
|
|
8
|
+
pipelinex/scaffold.py,sha256=46Ho6bLF-B2cwLo3ujA5tjlWdB0V7_f2CvsemCougNM,3485
|
|
9
|
+
pipelinex/state.py,sha256=Dqw56Niaj7aU2JnbO-rZxZoJbXMeb3Qq7l3mHGxcxjA,2331
|
|
10
|
+
pipelinex/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
pipelinex/tools/builtin.py,sha256=31G0UVD77WzsNnw_v1Z2v8KSErU_Gpih_y4yrCvwbJM,12344
|
|
12
|
+
pipelinex/tools/executor.py,sha256=AryHZ76JRhrDGF9daMO9OZHMtLT5kzUzJa34wIf0G3k,1770
|
|
13
|
+
pipelinex/tools/resolver.py,sha256=FlU-WvNEsmjp1ns4b1n60qCZdulXNdaRrJNuNOXC5dk,2136
|
|
14
|
+
folpipe-0.1.0.dist-info/METADATA,sha256=T-gvXCEI0Uahxjeg1ppLMMsbLkuNqbDsrb5gkJq_GBk,6636
|
|
15
|
+
folpipe-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
16
|
+
folpipe-0.1.0.dist-info/entry_points.txt,sha256=BtJ2UJH9QDnQt6ybIATZlAmRVD-_yJIEbepW6UKIjC0,47
|
|
17
|
+
folpipe-0.1.0.dist-info/licenses/LICENSE,sha256=OCwD3ACF1Br_JQbOE9Uy6W81ykoODdABXEmIGWsxZfA,1106
|
|
18
|
+
folpipe-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kroczyński Krzysztof s26192
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pipelinex/__init__.py
ADDED
|
File without changes
|
pipelinex/cli.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
# Force UTF-8 stdout/stderr on Windows (avoids charmap errors with model output)
|
|
7
|
+
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
|
8
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
9
|
+
if sys.stderr.encoding and sys.stderr.encoding.lower() not in ("utf-8", "utf8"):
|
|
10
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
11
|
+
|
|
12
|
+
from .loader import load_pipeline, validate_pipeline
|
|
13
|
+
from .runner import PipelineRunner
|
|
14
|
+
from .scaffold import scaffold_pipeline, scaffold_step, scaffold_tool
|
|
15
|
+
from .tools.builtin import BUILTIN_SCHEMAS
|
|
16
|
+
from .tools.resolver import GLOBAL_TOOLS_PATH, install_tool_deps, resolve_tools
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def main():
|
|
21
|
+
"""pipelinex — folder-based agentic AI pipeline framework"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@main.command()
|
|
25
|
+
@click.argument("pipeline_path", type=click.Path(exists=True))
|
|
26
|
+
@click.option("--input", "input_data", default=None, help="Input text or path to input file")
|
|
27
|
+
@click.option("--from", "from_step", default=None, metavar="STEP_ID", help="Resume from this step")
|
|
28
|
+
@click.option("--dry-run", is_flag=True, help="Validate config and env, don't execute")
|
|
29
|
+
@click.option("--watch", is_flag=True, help="Show live step/tool progress")
|
|
30
|
+
@click.option("--model", "model_override", default=None, metavar="PROVIDER/NAME", help="Override model")
|
|
31
|
+
def run(pipeline_path, input_data, from_step, dry_run, watch, model_override):
|
|
32
|
+
"""Run a pipeline."""
|
|
33
|
+
path = Path(pipeline_path)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
config = load_pipeline(path)
|
|
37
|
+
except (FileNotFoundError, SystemExit) as e:
|
|
38
|
+
click.echo(f"ERROR: {e}", err=True)
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
|
|
41
|
+
errors = validate_pipeline(config)
|
|
42
|
+
if errors:
|
|
43
|
+
click.echo("Validation errors:", err=True)
|
|
44
|
+
for e in errors:
|
|
45
|
+
click.echo(f" - {e}", err=True)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
if dry_run:
|
|
49
|
+
click.echo("Pipeline config valid.")
|
|
50
|
+
click.echo(f" Name: {config.get('name')}")
|
|
51
|
+
click.echo(f" Version: {config.get('version')}")
|
|
52
|
+
click.echo(f" Model: {config['model']['provider']}/{config['model']['name']}")
|
|
53
|
+
click.echo(f" Steps: {[s['id'] for s in config.get('steps', [])]}")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if input_data:
|
|
57
|
+
p = Path(input_data)
|
|
58
|
+
if p.exists():
|
|
59
|
+
input_data = p.read_text(encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
runner = PipelineRunner(
|
|
62
|
+
pipeline_path=path,
|
|
63
|
+
watch=watch,
|
|
64
|
+
from_step=from_step,
|
|
65
|
+
model_override=model_override,
|
|
66
|
+
input_data=input_data,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
runner.run()
|
|
71
|
+
except SystemExit as e:
|
|
72
|
+
click.echo(str(e), err=True)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
except KeyboardInterrupt:
|
|
75
|
+
click.echo("\nInterrupted.", err=True)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
click.echo(f"ERROR: {e}", err=True)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@main.command()
|
|
83
|
+
@click.argument("args", nargs=-1)
|
|
84
|
+
@click.option("--in", "in_dir", default=None, help="Target directory")
|
|
85
|
+
def new(args, in_dir):
|
|
86
|
+
"""Scaffold a pipeline, step, or tool.
|
|
87
|
+
|
|
88
|
+
\b
|
|
89
|
+
pipelinex new my-pipeline
|
|
90
|
+
pipelinex new step step-05-review --in ./my-pipeline
|
|
91
|
+
pipelinex new tool send_email --in ./my-pipeline/tools
|
|
92
|
+
"""
|
|
93
|
+
if not args:
|
|
94
|
+
click.echo("Usage:")
|
|
95
|
+
click.echo(" pipelinex new <pipeline-name>")
|
|
96
|
+
click.echo(" pipelinex new step <step-id> --in <pipeline-dir>")
|
|
97
|
+
click.echo(" pipelinex new tool <tool-name> --in <tools-dir>")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
if args[0] == "step":
|
|
101
|
+
if len(args) < 2:
|
|
102
|
+
click.echo("ERROR: step name required. Usage: pipelinex new step <step-id> --in <pipeline-dir>", err=True)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
scaffold_step(args[1], Path(in_dir or "."))
|
|
105
|
+
|
|
106
|
+
elif args[0] == "tool":
|
|
107
|
+
if len(args) < 2:
|
|
108
|
+
click.echo("ERROR: tool name required. Usage: pipelinex new tool <name> --in <tools-dir>", err=True)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
scaffold_tool(args[1], Path(in_dir or "tools"))
|
|
111
|
+
|
|
112
|
+
else:
|
|
113
|
+
scaffold_pipeline(args[0], Path(in_dir or "."))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@main.command()
|
|
117
|
+
@click.argument("pipeline_path", type=click.Path(exists=True))
|
|
118
|
+
def validate(pipeline_path):
|
|
119
|
+
"""Validate pipeline config without running."""
|
|
120
|
+
path = Path(pipeline_path)
|
|
121
|
+
try:
|
|
122
|
+
config = load_pipeline(path)
|
|
123
|
+
except (FileNotFoundError, SystemExit) as e:
|
|
124
|
+
click.echo(f"ERROR: {e}", err=True)
|
|
125
|
+
sys.exit(1)
|
|
126
|
+
|
|
127
|
+
errors = validate_pipeline(config)
|
|
128
|
+
if errors:
|
|
129
|
+
click.echo("Validation failed:")
|
|
130
|
+
for e in errors:
|
|
131
|
+
click.echo(f" x {e}")
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
click.echo("OK Pipeline is valid")
|
|
135
|
+
click.echo(f" Steps: {[s['id'] for s in config.get('steps', [])]}")
|
|
136
|
+
click.echo(f" Model: {config['model']['provider']}/{config['model']['name']}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@main.group()
|
|
140
|
+
def tools():
|
|
141
|
+
"""Manage tools."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@tools.command("list")
|
|
145
|
+
@click.option("--pipeline", "pipeline_path", default=None, help="Include pipeline-level tools")
|
|
146
|
+
def tools_list(pipeline_path):
|
|
147
|
+
"""List available tools."""
|
|
148
|
+
click.echo("Built-in tools:")
|
|
149
|
+
for t in BUILTIN_SCHEMAS:
|
|
150
|
+
click.echo(f" {t['name']:<20} {t['description'][:60]}")
|
|
151
|
+
|
|
152
|
+
if pipeline_path:
|
|
153
|
+
path = Path(pipeline_path)
|
|
154
|
+
custom = [t for t in resolve_tools(path) if t["name"] not in {s["name"] for s in BUILTIN_SCHEMAS}]
|
|
155
|
+
if custom:
|
|
156
|
+
click.echo(f"\nPipeline tools ({path.name}):")
|
|
157
|
+
for t in custom:
|
|
158
|
+
click.echo(f" {t['name']}")
|
|
159
|
+
|
|
160
|
+
if GLOBAL_TOOLS_PATH.exists():
|
|
161
|
+
global_tools = list(GLOBAL_TOOLS_PATH.iterdir())
|
|
162
|
+
if global_tools:
|
|
163
|
+
click.echo(f"\nGlobal tools (~/.pipelinex/tools/):")
|
|
164
|
+
for d in global_tools:
|
|
165
|
+
if d.is_dir():
|
|
166
|
+
click.echo(f" {d.name}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@tools.command("install")
|
|
170
|
+
@click.argument("pipeline_path", type=click.Path(exists=True))
|
|
171
|
+
def tools_install(pipeline_path):
|
|
172
|
+
"""Install tool deps for a pipeline without running."""
|
|
173
|
+
path = Path(pipeline_path)
|
|
174
|
+
cache = Path.home() / ".pipelinex"
|
|
175
|
+
count = 0
|
|
176
|
+
|
|
177
|
+
for tools_dir in _all_tool_dirs(path):
|
|
178
|
+
if not tools_dir.exists():
|
|
179
|
+
continue
|
|
180
|
+
for tool_dir in tools_dir.iterdir():
|
|
181
|
+
if tool_dir.is_dir() and (tool_dir / "tool.json").exists():
|
|
182
|
+
try:
|
|
183
|
+
install_tool_deps(tool_dir, cache)
|
|
184
|
+
count += 1
|
|
185
|
+
except Exception as e:
|
|
186
|
+
click.echo(f" Failed {tool_dir.name}: {e}", err=True)
|
|
187
|
+
|
|
188
|
+
click.echo(f"Installed deps for {count} tool(s).")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _all_tool_dirs(pipeline_path: Path):
|
|
192
|
+
yield pipeline_path / "tools"
|
|
193
|
+
try:
|
|
194
|
+
config = load_pipeline(pipeline_path)
|
|
195
|
+
for step in config.get("steps", []):
|
|
196
|
+
yield pipeline_path / step["id"] / "tools"
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@main.command()
|
|
202
|
+
@click.argument("pipeline_path", type=click.Path(exists=True))
|
|
203
|
+
@click.option("--errors", is_flag=True, help="Show error report from last failed run")
|
|
204
|
+
def log(pipeline_path, errors):
|
|
205
|
+
"""Show run log or error report."""
|
|
206
|
+
path = Path(pipeline_path)
|
|
207
|
+
|
|
208
|
+
if errors:
|
|
209
|
+
report = path / "output" / "errors" / "report.md"
|
|
210
|
+
if report.exists():
|
|
211
|
+
click.echo(report.read_text(encoding="utf-8"))
|
|
212
|
+
else:
|
|
213
|
+
click.echo("No error report found.")
|
|
214
|
+
else:
|
|
215
|
+
log_file = path / "output" / "run.log"
|
|
216
|
+
if log_file.exists():
|
|
217
|
+
click.echo(log_file.read_text(encoding="utf-8"))
|
|
218
|
+
else:
|
|
219
|
+
click.echo("No run log found.")
|
pipelinex/context_mgr.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _extract_context_section(skill_md: str) -> str:
|
|
7
|
+
m = re.search(r'##\s+Context\s*\n(.*?)(?=\n##|\Z)', skill_md, re.DOTALL | re.IGNORECASE)
|
|
8
|
+
return m.group(1).strip() if m else ""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _classify_tiers(context_section: str, keys: list[str], model_cfg: dict) -> dict[str, str]:
|
|
12
|
+
from .model import call_llm, extract_text
|
|
13
|
+
|
|
14
|
+
prompt = (
|
|
15
|
+
f"A pipeline step has this context guidance:\n\n{context_section}\n\n"
|
|
16
|
+
f"Available state keys: {keys}\n\n"
|
|
17
|
+
"Classify each key as:\n"
|
|
18
|
+
"- 'essential': explicitly needed in full\n"
|
|
19
|
+
"- 'skip': explicitly not needed\n"
|
|
20
|
+
"- 'include': everything else\n\n"
|
|
21
|
+
"Reply with JSON only: {\"key\": \"essential|include|skip\", ...}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
resp = call_llm(model_cfg, [{"role": "user", "content": prompt}], max_tokens=256)
|
|
26
|
+
text = extract_text(resp)
|
|
27
|
+
m = re.search(r'\{[^{}]+\}', text, re.DOTALL)
|
|
28
|
+
if m:
|
|
29
|
+
result = json.loads(m.group())
|
|
30
|
+
valid = {"essential", "include", "skip"}
|
|
31
|
+
return {k: (v if v in valid else "include") for k, v in result.items()}
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
return {k: "include" for k in keys}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fmt(value: Any) -> str:
|
|
39
|
+
if isinstance(value, str):
|
|
40
|
+
return value
|
|
41
|
+
return f"```json\n{json.dumps(value, indent=2, default=str)}\n```"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_context_prompt(
|
|
45
|
+
state: dict,
|
|
46
|
+
skill_md: str,
|
|
47
|
+
handoff: str | None,
|
|
48
|
+
model_cfg: dict | None = None,
|
|
49
|
+
) -> str:
|
|
50
|
+
context_section = _extract_context_section(skill_md)
|
|
51
|
+
parts = []
|
|
52
|
+
|
|
53
|
+
if handoff:
|
|
54
|
+
parts.append(f"## Handoff from previous step\n\n{handoff}")
|
|
55
|
+
|
|
56
|
+
meta_keys = {"_meta", "handoff", "_input_consumed"}
|
|
57
|
+
state_data = {k: v for k, v in state.items() if k not in meta_keys}
|
|
58
|
+
|
|
59
|
+
if state_data:
|
|
60
|
+
keys = list(state_data.keys())
|
|
61
|
+
|
|
62
|
+
if context_section and model_cfg and keys:
|
|
63
|
+
tiers = _classify_tiers(context_section, keys, model_cfg)
|
|
64
|
+
else:
|
|
65
|
+
tiers = {k: "include" for k in keys}
|
|
66
|
+
|
|
67
|
+
state_parts = []
|
|
68
|
+
for key, value in state_data.items():
|
|
69
|
+
tier = tiers.get(key, "include")
|
|
70
|
+
if tier == "skip":
|
|
71
|
+
continue
|
|
72
|
+
tag = " [ESSENTIAL]" if tier == "essential" else ""
|
|
73
|
+
state_parts.append(f"### {key}{tag}\n{_fmt(value)}")
|
|
74
|
+
|
|
75
|
+
if state_parts:
|
|
76
|
+
parts.append("## Pipeline State\n\n" + "\n\n".join(state_parts))
|
|
77
|
+
|
|
78
|
+
return "\n\n".join(parts)
|
pipelinex/loader.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from dotenv import dotenv_values
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_env(pipeline_path: Path) -> dict:
|
|
10
|
+
env = dict(os.environ)
|
|
11
|
+
env_file = pipeline_path / ".env"
|
|
12
|
+
if env_file.exists():
|
|
13
|
+
env.update(dotenv_values(env_file))
|
|
14
|
+
return env
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _substitute(obj, env: dict):
|
|
18
|
+
if isinstance(obj, str):
|
|
19
|
+
def replace(m):
|
|
20
|
+
var = m.group(1)
|
|
21
|
+
if var not in env:
|
|
22
|
+
raise EnvironmentError(
|
|
23
|
+
f"Missing environment variable: {var}\n"
|
|
24
|
+
f" Add it to .env or export it in your shell."
|
|
25
|
+
)
|
|
26
|
+
return env[var]
|
|
27
|
+
return re.sub(r'\$\{([^}]+)\}', replace, obj)
|
|
28
|
+
elif isinstance(obj, dict):
|
|
29
|
+
return {k: _substitute(v, env) for k, v in obj.items()}
|
|
30
|
+
elif isinstance(obj, list):
|
|
31
|
+
return [_substitute(i, env) for i in obj]
|
|
32
|
+
return obj
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_pipeline(pipeline_path: Path) -> dict:
|
|
36
|
+
pipeline_path = Path(pipeline_path)
|
|
37
|
+
yaml_file = pipeline_path / "pipeline.yaml"
|
|
38
|
+
if not yaml_file.exists():
|
|
39
|
+
raise FileNotFoundError(f"pipeline.yaml not found in {pipeline_path}")
|
|
40
|
+
|
|
41
|
+
env = load_env(pipeline_path)
|
|
42
|
+
|
|
43
|
+
with open(yaml_file) as f:
|
|
44
|
+
raw = yaml.safe_load(f)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
config = _substitute(raw, env)
|
|
48
|
+
except EnvironmentError as e:
|
|
49
|
+
raise SystemExit(f"ERROR: {e}")
|
|
50
|
+
|
|
51
|
+
config["_path"] = pipeline_path
|
|
52
|
+
config["_env"] = env
|
|
53
|
+
return config
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_skill_md(pipeline_path: Path, step_id: str | None = None, substep_id: str | None = None) -> str:
|
|
57
|
+
parts = []
|
|
58
|
+
|
|
59
|
+
global_skill = pipeline_path / "SKILL.md"
|
|
60
|
+
if global_skill.exists():
|
|
61
|
+
parts.append(global_skill.read_text(encoding="utf-8"))
|
|
62
|
+
|
|
63
|
+
if step_id:
|
|
64
|
+
step_skill = pipeline_path / step_id / "SKILL.md"
|
|
65
|
+
if step_skill.exists():
|
|
66
|
+
parts.append(step_skill.read_text(encoding="utf-8"))
|
|
67
|
+
|
|
68
|
+
if substep_id:
|
|
69
|
+
substep_skill = pipeline_path / step_id / substep_id / "SKILL.md"
|
|
70
|
+
if substep_skill.exists():
|
|
71
|
+
parts.append(substep_skill.read_text(encoding="utf-8"))
|
|
72
|
+
|
|
73
|
+
return "\n\n---\n\n".join(parts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_pipeline(config: dict) -> list[str]:
|
|
77
|
+
errors = []
|
|
78
|
+
|
|
79
|
+
if "model" not in config:
|
|
80
|
+
errors.append("Missing 'model' section")
|
|
81
|
+
else:
|
|
82
|
+
if "provider" not in config["model"]:
|
|
83
|
+
errors.append("model.provider is required")
|
|
84
|
+
if "name" not in config["model"]:
|
|
85
|
+
errors.append("model.name is required")
|
|
86
|
+
|
|
87
|
+
if not config.get("steps"):
|
|
88
|
+
errors.append("No steps defined")
|
|
89
|
+
else:
|
|
90
|
+
step_ids = {s["id"] for s in config["steps"] if "id" in s}
|
|
91
|
+
for step in config["steps"]:
|
|
92
|
+
if "id" not in step:
|
|
93
|
+
errors.append(f"Step missing 'id': {step}")
|
|
94
|
+
continue
|
|
95
|
+
for target in step.get("can_goto", []):
|
|
96
|
+
if target not in step_ids:
|
|
97
|
+
errors.append(
|
|
98
|
+
f"Step '{step['id']}' can_goto '{target}' which doesn't exist"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return errors
|