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.

@@ -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