agenthandler 0.92.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.
Potentially problematic release.
This version of agenthandler might be problematic. Click here for more details.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""POML pipeline parser and builder — natural language to supervised pipelines.
|
|
2
|
+
|
|
3
|
+
Parses POML documents with ``<pipeline>`` tags into executable Pipeline objects,
|
|
4
|
+
and generates POML from natural language descriptions via an LLM.
|
|
5
|
+
|
|
6
|
+
POML pipeline format::
|
|
7
|
+
|
|
8
|
+
<!--
|
|
9
|
+
metadata:
|
|
10
|
+
name: alert-monitor
|
|
11
|
+
triggers: check alerts, monitor
|
|
12
|
+
-->
|
|
13
|
+
|
|
14
|
+
<task>
|
|
15
|
+
Check the alerts table for critical alerts.
|
|
16
|
+
If there are more than 10, escalate.
|
|
17
|
+
</task>
|
|
18
|
+
|
|
19
|
+
<pipeline>
|
|
20
|
+
<step tool="sql.query">
|
|
21
|
+
SELECT count(*) as cnt FROM alerts WHERE severity='critical'
|
|
22
|
+
</step>
|
|
23
|
+
|
|
24
|
+
<threshold key="rows.0.cnt" value="10">
|
|
25
|
+
<above tool="escalate">Notify on-call team</above>
|
|
26
|
+
<below tool="log_ok">Log all clear</below>
|
|
27
|
+
</threshold>
|
|
28
|
+
|
|
29
|
+
<condition if="'error' in output">
|
|
30
|
+
<then tool="handle_error">Fix the error</then>
|
|
31
|
+
<else tool="continue">Keep going</else>
|
|
32
|
+
</condition>
|
|
33
|
+
|
|
34
|
+
<step tool="report">Write summary</step>
|
|
35
|
+
</pipeline>
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
# Parse POML into a Pipeline
|
|
39
|
+
from agenthandler.pipeline_poml import parse_pipeline_poml, PipelineBuilder
|
|
40
|
+
|
|
41
|
+
pipeline = parse_pipeline_poml(poml_text, tool_registry, mgr, "agent-1", policy)
|
|
42
|
+
result = await pipeline.run()
|
|
43
|
+
|
|
44
|
+
# Generate POML from natural language
|
|
45
|
+
builder = PipelineBuilder(my_llm, tool_registry)
|
|
46
|
+
pipeline = await builder.create("Check alerts, escalate if > 10")
|
|
47
|
+
result = await pipeline.run()
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import ast
|
|
53
|
+
import operator
|
|
54
|
+
import re
|
|
55
|
+
import xml.etree.ElementTree as ET
|
|
56
|
+
from typing import Any, Callable, Coroutine, Dict, Optional
|
|
57
|
+
|
|
58
|
+
from .pipeline import Pipeline
|
|
59
|
+
from .session import SessionManager
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Tool registry type
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
ToolRegistry = Dict[str, Callable[..., Coroutine[Any, Any, Any]]]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# POML pipeline parser
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_pipeline_poml(
|
|
74
|
+
poml_text: str,
|
|
75
|
+
tools: ToolRegistry,
|
|
76
|
+
manager: SessionManager,
|
|
77
|
+
agent_id: str,
|
|
78
|
+
policy_dict: Optional[Dict[str, Any]] = None,
|
|
79
|
+
stateless: bool = False,
|
|
80
|
+
) -> Pipeline:
|
|
81
|
+
"""Parse a POML document with ``<pipeline>`` tags into an executable Pipeline.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
poml_text: The POML text containing a <pipeline> block.
|
|
85
|
+
tools: Registry mapping tool names to async callables.
|
|
86
|
+
manager: SessionManager for the pipeline session.
|
|
87
|
+
agent_id: Agent identifier.
|
|
88
|
+
policy_dict: Policy configuration.
|
|
89
|
+
stateless: Whether the pipeline session is stateless.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A configured Pipeline ready to .run().
|
|
93
|
+
"""
|
|
94
|
+
# Extract the <pipeline>...</pipeline> block
|
|
95
|
+
pipeline_match = re.search(r"<pipeline>(.*?)</pipeline>", poml_text, re.DOTALL)
|
|
96
|
+
if not pipeline_match:
|
|
97
|
+
raise ValueError("No <pipeline> block found in POML text")
|
|
98
|
+
|
|
99
|
+
pipeline_xml = pipeline_match.group(1).strip()
|
|
100
|
+
|
|
101
|
+
# Wrap in a root element for XML parsing
|
|
102
|
+
try:
|
|
103
|
+
root = ET.fromstring(f"<root>{pipeline_xml}</root>")
|
|
104
|
+
except ET.ParseError as e:
|
|
105
|
+
raise ValueError(f"Invalid XML in <pipeline> block: {e}") from e
|
|
106
|
+
|
|
107
|
+
pipeline = Pipeline(manager, agent_id, policy_dict=policy_dict, stateless=stateless)
|
|
108
|
+
|
|
109
|
+
for node in root:
|
|
110
|
+
if node.tag == "step":
|
|
111
|
+
_parse_step(node, tools, pipeline)
|
|
112
|
+
elif node.tag == "threshold":
|
|
113
|
+
_parse_threshold(node, tools, pipeline)
|
|
114
|
+
elif node.tag == "condition":
|
|
115
|
+
_parse_condition(node, tools, pipeline)
|
|
116
|
+
else:
|
|
117
|
+
raise ValueError(f"Unknown pipeline tag: <{node.tag}>")
|
|
118
|
+
|
|
119
|
+
return pipeline
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_step(node: ET.Element, tools: ToolRegistry, pipeline: Pipeline) -> None:
|
|
123
|
+
"""Parse a <step tool="name">sql or description</step>."""
|
|
124
|
+
tool_name = node.attrib.get("tool", "")
|
|
125
|
+
if not tool_name:
|
|
126
|
+
raise ValueError("<step> requires a 'tool' attribute")
|
|
127
|
+
if tool_name not in tools:
|
|
128
|
+
raise ValueError(f"Tool '{tool_name}' not found in registry")
|
|
129
|
+
|
|
130
|
+
content = (node.text or "").strip()
|
|
131
|
+
name = node.attrib.get("name", tool_name)
|
|
132
|
+
|
|
133
|
+
# Build kwargs from attributes (excluding tool and name)
|
|
134
|
+
kwargs: Dict[str, Any] = {}
|
|
135
|
+
for k, v in node.attrib.items():
|
|
136
|
+
if k not in ("tool", "name"):
|
|
137
|
+
kwargs[k] = v
|
|
138
|
+
|
|
139
|
+
# If content looks like SQL, pass as sql kwarg. Otherwise it's just a label.
|
|
140
|
+
if content and _looks_like_sql(content):
|
|
141
|
+
kwargs["sql"] = content
|
|
142
|
+
|
|
143
|
+
chain = node.attrib.get("chain", "true").lower() != "false"
|
|
144
|
+
pipeline.add_step(tool_name, tools[tool_name], name=name, chain_input=chain, **kwargs)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parse_threshold(node: ET.Element, tools: ToolRegistry, pipeline: Pipeline) -> None:
|
|
148
|
+
"""Parse a <threshold key="..." value="..."><above>/<below></threshold>."""
|
|
149
|
+
key = node.attrib.get("key", "")
|
|
150
|
+
value_str = node.attrib.get("value", "0")
|
|
151
|
+
if not key:
|
|
152
|
+
raise ValueError("<threshold> requires a 'key' attribute")
|
|
153
|
+
try:
|
|
154
|
+
threshold_val = float(value_str)
|
|
155
|
+
except ValueError as e:
|
|
156
|
+
raise ValueError(f"<threshold> value must be numeric: {value_str}") from e
|
|
157
|
+
|
|
158
|
+
above_step = None
|
|
159
|
+
below_step = None
|
|
160
|
+
|
|
161
|
+
for child in node:
|
|
162
|
+
tool_name = child.attrib.get("tool", "")
|
|
163
|
+
if not tool_name or tool_name not in tools:
|
|
164
|
+
raise ValueError(f"<{child.tag}> requires a valid 'tool' attribute, got '{tool_name}'")
|
|
165
|
+
if child.tag == "above":
|
|
166
|
+
above_step = (tool_name, tools[tool_name])
|
|
167
|
+
elif child.tag == "below":
|
|
168
|
+
below_step = (tool_name, tools[tool_name])
|
|
169
|
+
|
|
170
|
+
if above_step is None:
|
|
171
|
+
raise ValueError("<threshold> requires an <above> child")
|
|
172
|
+
|
|
173
|
+
pipeline.add_threshold(
|
|
174
|
+
key=key,
|
|
175
|
+
threshold=threshold_val,
|
|
176
|
+
above_step=above_step,
|
|
177
|
+
below_step=below_step,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_condition(node: ET.Element, tools: ToolRegistry, pipeline: Pipeline) -> None:
|
|
182
|
+
"""Parse a <condition if="expr"><then>/<else></condition>."""
|
|
183
|
+
expr = node.attrib.get("if", "")
|
|
184
|
+
if not expr:
|
|
185
|
+
raise ValueError("<condition> requires an 'if' attribute")
|
|
186
|
+
|
|
187
|
+
then_step = None
|
|
188
|
+
else_step = None
|
|
189
|
+
|
|
190
|
+
for child in node:
|
|
191
|
+
tool_name = child.attrib.get("tool", "")
|
|
192
|
+
if not tool_name or tool_name not in tools:
|
|
193
|
+
raise ValueError(f"<{child.tag}> requires a valid 'tool' attribute, got '{tool_name}'")
|
|
194
|
+
if child.tag == "then":
|
|
195
|
+
then_step = (tool_name, tools[tool_name])
|
|
196
|
+
elif child.tag == "else":
|
|
197
|
+
else_step = (tool_name, tools[tool_name])
|
|
198
|
+
|
|
199
|
+
if then_step is None:
|
|
200
|
+
raise ValueError("<condition> requires a <then> child")
|
|
201
|
+
|
|
202
|
+
# Build a safe predicate from the expression
|
|
203
|
+
predicate = _make_predicate(expr)
|
|
204
|
+
pipeline.add_condition(predicate=predicate, then_step=then_step, else_step=else_step)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _make_predicate(expr: str) -> Callable[[Any], bool]:
|
|
208
|
+
"""Build a predicate function from a simple expression string.
|
|
209
|
+
|
|
210
|
+
Supported expressions:
|
|
211
|
+
- "output.rows.0.cnt >= 10" — dot-notation comparison
|
|
212
|
+
- "'error' in output" — containment check
|
|
213
|
+
- "output == 'done'" — equality
|
|
214
|
+
|
|
215
|
+
Uses an AST-based evaluator — no eval().
|
|
216
|
+
"""
|
|
217
|
+
resolved_expr = expr.strip()
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
tree = ast.parse(resolved_expr, mode="eval")
|
|
221
|
+
except SyntaxError as e:
|
|
222
|
+
raise ValueError(f"Invalid condition expression: {e}") from e
|
|
223
|
+
|
|
224
|
+
_validate_ast(tree.body)
|
|
225
|
+
|
|
226
|
+
def _predicate(output: Any) -> bool:
|
|
227
|
+
resolved = _resolve_dot_notation(resolved_expr, output)
|
|
228
|
+
try:
|
|
229
|
+
parsed = ast.parse(resolved, mode="eval")
|
|
230
|
+
return bool(_safe_eval_node(parsed.body, {"output": output}))
|
|
231
|
+
except Exception:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
return _predicate
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
_SAFE_COMPARE_OPS: Dict[type, Callable[[Any, Any], Any]] = {
|
|
238
|
+
ast.Eq: operator.eq,
|
|
239
|
+
ast.NotEq: operator.ne,
|
|
240
|
+
ast.Lt: operator.lt,
|
|
241
|
+
ast.LtE: operator.le,
|
|
242
|
+
ast.Gt: operator.gt,
|
|
243
|
+
ast.GtE: operator.ge,
|
|
244
|
+
ast.In: lambda a, b: a in b,
|
|
245
|
+
ast.NotIn: lambda a, b: a not in b,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
_SAFE_BOOL_OPS = {
|
|
249
|
+
ast.And: all,
|
|
250
|
+
ast.Or: any,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_SAFE_UNARY_OPS = {
|
|
254
|
+
ast.Not: operator.not_,
|
|
255
|
+
ast.USub: operator.neg,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _validate_ast(node: ast.AST) -> None:
|
|
260
|
+
"""Reject AST nodes that aren't part of our safe subset."""
|
|
261
|
+
allowed_nodes = (
|
|
262
|
+
ast.Expression,
|
|
263
|
+
ast.Compare,
|
|
264
|
+
ast.BoolOp,
|
|
265
|
+
ast.UnaryOp,
|
|
266
|
+
ast.Constant,
|
|
267
|
+
ast.Name,
|
|
268
|
+
ast.Attribute,
|
|
269
|
+
ast.Subscript,
|
|
270
|
+
ast.Index,
|
|
271
|
+
ast.Tuple,
|
|
272
|
+
ast.List,
|
|
273
|
+
)
|
|
274
|
+
allowed_ops = (
|
|
275
|
+
ast.And,
|
|
276
|
+
ast.Or,
|
|
277
|
+
ast.Not,
|
|
278
|
+
ast.USub,
|
|
279
|
+
ast.Eq,
|
|
280
|
+
ast.NotEq,
|
|
281
|
+
ast.Lt,
|
|
282
|
+
ast.LtE,
|
|
283
|
+
ast.Gt,
|
|
284
|
+
ast.GtE,
|
|
285
|
+
ast.In,
|
|
286
|
+
ast.NotIn,
|
|
287
|
+
ast.Is,
|
|
288
|
+
ast.IsNot,
|
|
289
|
+
ast.Load,
|
|
290
|
+
)
|
|
291
|
+
if not isinstance(node, (*allowed_nodes, *allowed_ops)):
|
|
292
|
+
raise ValueError(f"Unsupported expression node: {type(node).__name__}")
|
|
293
|
+
for child in ast.iter_child_nodes(node):
|
|
294
|
+
_validate_ast(child)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _safe_eval_node(node: ast.AST, env: Dict[str, Any]) -> Any:
|
|
298
|
+
"""Evaluate an AST node against the safe subset."""
|
|
299
|
+
if isinstance(node, ast.Constant):
|
|
300
|
+
return node.value
|
|
301
|
+
|
|
302
|
+
if isinstance(node, ast.Name):
|
|
303
|
+
if node.id not in env:
|
|
304
|
+
raise ValueError(f"Unknown variable: {node.id}")
|
|
305
|
+
return env[node.id]
|
|
306
|
+
|
|
307
|
+
if isinstance(node, ast.UnaryOp):
|
|
308
|
+
op_fn = _SAFE_UNARY_OPS.get(type(node.op))
|
|
309
|
+
if op_fn is None:
|
|
310
|
+
raise ValueError(f"Unsupported unary op: {type(node.op).__name__}")
|
|
311
|
+
return op_fn(_safe_eval_node(node.operand, env))
|
|
312
|
+
|
|
313
|
+
if isinstance(node, ast.BoolOp):
|
|
314
|
+
agg = _SAFE_BOOL_OPS.get(type(node.op))
|
|
315
|
+
if agg is None:
|
|
316
|
+
raise ValueError(f"Unsupported bool op: {type(node.op).__name__}")
|
|
317
|
+
return agg(_safe_eval_node(v, env) for v in node.values)
|
|
318
|
+
|
|
319
|
+
if isinstance(node, ast.Compare):
|
|
320
|
+
left = _safe_eval_node(node.left, env)
|
|
321
|
+
for cmp_op, comparator in zip(node.ops, node.comparators):
|
|
322
|
+
cmp_fn = _SAFE_COMPARE_OPS.get(type(cmp_op))
|
|
323
|
+
if cmp_fn is None:
|
|
324
|
+
raise ValueError(f"Unsupported comparison: {type(cmp_op).__name__}")
|
|
325
|
+
right = _safe_eval_node(comparator, env)
|
|
326
|
+
if not cmp_fn(left, right):
|
|
327
|
+
return False
|
|
328
|
+
left = right
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
if isinstance(node, (ast.Tuple, ast.List)):
|
|
332
|
+
return [_safe_eval_node(elt, env) for elt in node.elts]
|
|
333
|
+
|
|
334
|
+
raise ValueError(f"Unsupported expression node: {type(node).__name__}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _resolve_dot_notation(expr: str, output: Any) -> str:
|
|
338
|
+
"""Replace output.x.y.z with the actual resolved value in the expression."""
|
|
339
|
+
|
|
340
|
+
# Find patterns like output.foo.bar.0.baz
|
|
341
|
+
def _replace(match: re.Match[str]) -> str:
|
|
342
|
+
path = match.group(0)
|
|
343
|
+
parts = path.split(".")
|
|
344
|
+
val = output
|
|
345
|
+
for part in parts[1:]: # skip 'output'
|
|
346
|
+
if isinstance(val, dict):
|
|
347
|
+
val = val.get(part)
|
|
348
|
+
elif isinstance(val, (list, tuple)):
|
|
349
|
+
try:
|
|
350
|
+
val = val[int(part)]
|
|
351
|
+
except (IndexError, ValueError):
|
|
352
|
+
return "None"
|
|
353
|
+
else:
|
|
354
|
+
return "None"
|
|
355
|
+
return repr(val)
|
|
356
|
+
|
|
357
|
+
return re.sub(r"output(?:\.\w+)+", _replace, expr)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _looks_like_sql(text: str) -> bool:
|
|
361
|
+
"""Check if text looks like a SQL statement."""
|
|
362
|
+
normalized = text.strip().upper()
|
|
363
|
+
return normalized.startswith(("SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "CREATE"))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# POML pipeline generation prompt
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
PIPELINE_GENERATION_PROMPT = """\
|
|
371
|
+
You are a pipeline builder for AI agents. Given a natural language description \
|
|
372
|
+
of what the pipeline should do, produce a POML pipeline definition.
|
|
373
|
+
|
|
374
|
+
Available tools:
|
|
375
|
+
{tools}
|
|
376
|
+
{schema_section}
|
|
377
|
+
The POML format uses XML-style tags inside a <pipeline> block:
|
|
378
|
+
|
|
379
|
+
```xml
|
|
380
|
+
<pipeline>
|
|
381
|
+
<step tool="tool_name">
|
|
382
|
+
SQL query or description of what this step does
|
|
383
|
+
</step>
|
|
384
|
+
|
|
385
|
+
<threshold key="rows.0.cnt" value="10">
|
|
386
|
+
<above tool="tool_name">What to do if above</above>
|
|
387
|
+
<below tool="tool_name">What to do if below</below>
|
|
388
|
+
</threshold>
|
|
389
|
+
|
|
390
|
+
<condition if="'error' in output">
|
|
391
|
+
<then tool="tool_name">What to do if true</then>
|
|
392
|
+
<else tool="tool_name">What to do if false</else>
|
|
393
|
+
</condition>
|
|
394
|
+
</pipeline>
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Rules:
|
|
398
|
+
- tool attributes must match one of the available tools listed above
|
|
399
|
+
- <step> content: if it's a SQL query, write correct SQL using the database schema above. \
|
|
400
|
+
Use the actual table and column names from the schema.
|
|
401
|
+
- <threshold> key uses dot notation to extract a value from the previous step's output. \
|
|
402
|
+
For SQL query results, the format is rows.0.column_name
|
|
403
|
+
- <condition> if uses simple Python expressions with 'output' as the variable
|
|
404
|
+
- Steps chain automatically — each step receives the previous step's output
|
|
405
|
+
- Add chain="false" to a step to ignore previous output
|
|
406
|
+
- Keep pipelines focused — prefer fewer steps that do more over many trivial steps
|
|
407
|
+
|
|
408
|
+
Respond with ONLY the POML text. Include a metadata comment and task description.\
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
PIPELINE_REFINEMENT_PROMPT = """\
|
|
412
|
+
You are refining an existing POML pipeline based on user feedback.
|
|
413
|
+
|
|
414
|
+
Current pipeline:
|
|
415
|
+
```
|
|
416
|
+
{current_poml}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Available tools:
|
|
420
|
+
{tools}
|
|
421
|
+
|
|
422
|
+
The user wants the following changes:
|
|
423
|
+
{feedback}
|
|
424
|
+
|
|
425
|
+
Apply the changes and return the complete updated POML. Respond with ONLY the POML text.\
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
# PipelineBuilder — natural language to POML to Pipeline
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class PipelineBuilder:
|
|
435
|
+
"""Build pipelines from natural language using any LLM.
|
|
436
|
+
|
|
437
|
+
Same bring-your-own-LLM pattern as SkillBuilder. Pass any async
|
|
438
|
+
callable that takes a prompt string and returns a string.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
llm: Async callable — your LLM function.
|
|
442
|
+
tools: Registry mapping tool names to async callables.
|
|
443
|
+
manager: SessionManager for created pipelines.
|
|
444
|
+
db_schema: Database schema dict from SqlConnector.discover_schema().
|
|
445
|
+
Included in the LLM prompt so it writes correct SQL.
|
|
446
|
+
system_prompt: Override the default generation prompt.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
def __init__(
|
|
450
|
+
self,
|
|
451
|
+
llm: Callable[[str], Coroutine[Any, Any, str]],
|
|
452
|
+
tools: ToolRegistry,
|
|
453
|
+
manager: SessionManager,
|
|
454
|
+
db_schema: Optional[Dict[str, Any]] = None,
|
|
455
|
+
system_prompt: Optional[str] = None,
|
|
456
|
+
):
|
|
457
|
+
self._llm = llm
|
|
458
|
+
self._tools = tools
|
|
459
|
+
self._manager = manager
|
|
460
|
+
self._db_schema = db_schema
|
|
461
|
+
self._system_prompt = system_prompt or PIPELINE_GENERATION_PROMPT
|
|
462
|
+
|
|
463
|
+
def _tools_description(self) -> str:
|
|
464
|
+
"""Format tool registry as a description for the LLM."""
|
|
465
|
+
lines = []
|
|
466
|
+
for name in sorted(self._tools.keys()):
|
|
467
|
+
fn = self._tools[name]
|
|
468
|
+
doc = (fn.__doc__ or "").strip().split("\n")[0]
|
|
469
|
+
lines.append(f"- {name}: {doc}" if doc else f"- {name}")
|
|
470
|
+
return "\n".join(lines)
|
|
471
|
+
|
|
472
|
+
def _schema_description(self) -> str:
|
|
473
|
+
"""Format the database schema for the LLM prompt."""
|
|
474
|
+
if self._db_schema is None:
|
|
475
|
+
return ""
|
|
476
|
+
|
|
477
|
+
# SQL schema
|
|
478
|
+
if "tables" in self._db_schema:
|
|
479
|
+
from .connectors import SqlConnector
|
|
480
|
+
|
|
481
|
+
connector = SqlConnector.__new__(SqlConnector)
|
|
482
|
+
return connector.schema_to_text(self._db_schema)
|
|
483
|
+
|
|
484
|
+
# MongoDB schema
|
|
485
|
+
if "collections" in self._db_schema:
|
|
486
|
+
lines = []
|
|
487
|
+
for coll in self._db_schema["collections"]:
|
|
488
|
+
header = f"Collection: {coll['name']}"
|
|
489
|
+
if "document_count" in coll:
|
|
490
|
+
header += f" ({coll['document_count']} documents)"
|
|
491
|
+
lines.append(header)
|
|
492
|
+
for f in coll.get("fields", []):
|
|
493
|
+
lines.append(f" - {f['name']} ({f.get('type', 'unknown')})")
|
|
494
|
+
lines.append("")
|
|
495
|
+
return "\n".join(lines)
|
|
496
|
+
|
|
497
|
+
return ""
|
|
498
|
+
|
|
499
|
+
def _build_prompt(self) -> str:
|
|
500
|
+
"""Build the full LLM prompt with tools and schema."""
|
|
501
|
+
schema_text = self._schema_description()
|
|
502
|
+
schema_section = ""
|
|
503
|
+
if schema_text:
|
|
504
|
+
schema_section = f"\nDatabase schema:\n{schema_text}\n"
|
|
505
|
+
return self._system_prompt.format(
|
|
506
|
+
tools=self._tools_description(),
|
|
507
|
+
schema_section=schema_section,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
async def create(
|
|
511
|
+
self,
|
|
512
|
+
description: str,
|
|
513
|
+
agent_id: str = "pipeline-agent",
|
|
514
|
+
policy_dict: Optional[Dict[str, Any]] = None,
|
|
515
|
+
stateless: bool = False,
|
|
516
|
+
) -> Pipeline:
|
|
517
|
+
"""Generate a pipeline from a natural language description.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
description: What the pipeline should do, in plain English.
|
|
521
|
+
agent_id: Agent identifier for the pipeline session.
|
|
522
|
+
policy_dict: Policy configuration.
|
|
523
|
+
stateless: Whether the session is stateless.
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
A configured Pipeline ready to .run().
|
|
527
|
+
"""
|
|
528
|
+
prompt = self._build_prompt()
|
|
529
|
+
prompt += f"\n\nUser's description:\n{description}"
|
|
530
|
+
|
|
531
|
+
raw = await self._llm(prompt)
|
|
532
|
+
poml = self._extract_poml(raw)
|
|
533
|
+
|
|
534
|
+
return parse_pipeline_poml(
|
|
535
|
+
poml,
|
|
536
|
+
self._tools,
|
|
537
|
+
self._manager,
|
|
538
|
+
agent_id,
|
|
539
|
+
policy_dict=policy_dict,
|
|
540
|
+
stateless=stateless,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
async def create_poml(self, description: str) -> str:
|
|
544
|
+
"""Generate POML text from a description without parsing it.
|
|
545
|
+
|
|
546
|
+
Useful for reviewing/editing the POML before running.
|
|
547
|
+
Validates that the output contains a parseable <pipeline> block.
|
|
548
|
+
"""
|
|
549
|
+
prompt = self._build_prompt()
|
|
550
|
+
prompt += f"\n\nUser's description:\n{description}"
|
|
551
|
+
|
|
552
|
+
raw = await self._llm(prompt)
|
|
553
|
+
poml = self._extract_poml(raw)
|
|
554
|
+
self._validate_poml_structure(poml)
|
|
555
|
+
return poml
|
|
556
|
+
|
|
557
|
+
async def refine(
|
|
558
|
+
self,
|
|
559
|
+
current_poml: str,
|
|
560
|
+
feedback: str,
|
|
561
|
+
) -> str:
|
|
562
|
+
"""Refine existing POML based on feedback. Returns updated POML text.
|
|
563
|
+
|
|
564
|
+
Validates that the output contains a parseable <pipeline> block.
|
|
565
|
+
"""
|
|
566
|
+
prompt = PIPELINE_REFINEMENT_PROMPT.format(
|
|
567
|
+
current_poml=current_poml,
|
|
568
|
+
tools=self._tools_description(),
|
|
569
|
+
feedback=feedback,
|
|
570
|
+
)
|
|
571
|
+
raw = await self._llm(prompt)
|
|
572
|
+
poml = self._extract_poml(raw)
|
|
573
|
+
self._validate_poml_structure(poml)
|
|
574
|
+
return poml
|
|
575
|
+
|
|
576
|
+
@staticmethod
|
|
577
|
+
def _extract_poml(text: str) -> str:
|
|
578
|
+
"""Extract POML from LLM output, stripping markdown fences."""
|
|
579
|
+
text = text.strip()
|
|
580
|
+
text = re.sub(r"^```(?:xml|poml)?\s*\n?", "", text)
|
|
581
|
+
text = re.sub(r"\n?```\s*$", "", text)
|
|
582
|
+
return text.strip()
|
|
583
|
+
|
|
584
|
+
@staticmethod
|
|
585
|
+
def _validate_poml_structure(poml: str) -> None:
|
|
586
|
+
"""Check that POML has a valid <pipeline> block with parseable XML."""
|
|
587
|
+
pipeline_match = re.search(r"<pipeline>(.*?)</pipeline>", poml, re.DOTALL)
|
|
588
|
+
if not pipeline_match:
|
|
589
|
+
raise ValueError("LLM output missing <pipeline> block")
|
|
590
|
+
try:
|
|
591
|
+
ET.fromstring(f"<root>{pipeline_match.group(1).strip()}</root>")
|
|
592
|
+
except ET.ParseError as e:
|
|
593
|
+
raise ValueError(f"LLM output has invalid XML in <pipeline>: {e}") from e
|