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,532 @@
1
+ """Pipeline orchestration — supervised sequential and conditional tool chains.
2
+
3
+ Level 1: Sequential steps with output chaining.
4
+ Level 2: Conditional branching — if/else/threshold routing.
5
+
6
+ Usage:
7
+ from agenthandler import Pipeline, SessionManager, MemoryStore
8
+
9
+ store = MemoryStore()
10
+ mgr = SessionManager(store)
11
+
12
+ # Sequential
13
+ pipeline = Pipeline(mgr, "my-agent", policy_dict={...})
14
+ pipeline.add_step("search", search_fn, query="quantum computing")
15
+ pipeline.add_step("summarize", summarize_fn)
16
+ results = await pipeline.run()
17
+
18
+ # Conditional
19
+ pipeline = Pipeline(mgr, "analyst", policy_dict={...})
20
+ pipeline.add_step("check_metric", sql.query, sql="SELECT count(*) as cnt FROM alerts")
21
+ pipeline.add_condition(
22
+ lambda output: output["rows"][0]["cnt"] > 10,
23
+ then_step=("escalate", escalate_fn),
24
+ else_step=("log_ok", log_fn),
25
+ )
26
+ results = await pipeline.run()
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass, field
32
+ from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple, Union
33
+
34
+ from .session import SessionManager
35
+ from .supervisor import SupervisedResult
36
+
37
+
38
+ @dataclass
39
+ class Step:
40
+ """A single pipeline step."""
41
+
42
+ name: str
43
+ tool_name: str
44
+ fn: Callable[..., Coroutine[Any, Any, Any]]
45
+ kwargs: Dict[str, Any] = field(default_factory=dict)
46
+ chain_input: bool = True
47
+ input_key: str = "input"
48
+
49
+
50
+ @dataclass
51
+ class Condition:
52
+ """A conditional branch in the pipeline."""
53
+
54
+ name: str
55
+ predicate: Callable[[Any], bool]
56
+ then_step: Step
57
+ else_step: Optional[Step] = None # None = skip (do nothing)
58
+
59
+
60
+ # A pipeline node is either a Step or a Condition
61
+ PipelineNode = Union[Step, Condition]
62
+
63
+
64
+ @dataclass
65
+ class StepResult:
66
+ """Result of a single pipeline step."""
67
+
68
+ name: str
69
+ tool_name: str
70
+ result: SupervisedResult
71
+ step_index: int = 0
72
+ branch: str = "" # "then", "else", or "" for non-conditional
73
+
74
+
75
+ @dataclass
76
+ class PipelineResult:
77
+ """Result of a full pipeline run."""
78
+
79
+ session_id: str
80
+ steps: List[StepResult] = field(default_factory=list)
81
+ completed: bool = False
82
+ stopped_at: Optional[str] = None
83
+ error: Optional[str] = None
84
+
85
+ @property
86
+ def final_output(self) -> Any:
87
+ """The output of the last successful step."""
88
+ for step in reversed(self.steps):
89
+ if step.result.succeeded:
90
+ return step.result.output
91
+ return None
92
+
93
+ @property
94
+ def total_duration_ms(self) -> int:
95
+ return sum(s.result.duration_ms for s in self.steps)
96
+
97
+
98
+ class Pipeline:
99
+ """Supervised pipeline with sequential steps and conditional branching.
100
+
101
+ Every step and branch runs through the Supervisor with full policy enforcement
102
+ (timeouts, budgets, circuit breakers, PII redaction, approvals).
103
+
104
+ Args:
105
+ manager: SessionManager to create the session with.
106
+ agent_id: Agent identifier for the session.
107
+ policy_dict: Policy configuration for the session.
108
+ payload: Optional initial payload.
109
+ stateless: If True, session is in-memory only.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ manager: SessionManager,
115
+ agent_id: str,
116
+ policy_dict: Optional[Dict[str, Any]] = None,
117
+ payload: Optional[Dict[str, Any]] = None,
118
+ stateless: bool = False,
119
+ ):
120
+ self._manager = manager
121
+ self._agent_id = agent_id
122
+ self._policy_dict = policy_dict
123
+ self._payload = payload
124
+ self._stateless = stateless
125
+ self._nodes: List[PipelineNode] = []
126
+ self._reuse_session_id: Optional[str] = None
127
+
128
+ def with_context(self, session_id: str) -> "Pipeline":
129
+ """Attach this pipeline to an existing session.
130
+
131
+ Instead of starting a new session on run(), the pipeline reuses
132
+ the given session. The previous payload is loaded as context and
133
+ the new payload is merged into it after each run.
134
+
135
+ Use this for stateful agents that accumulate history — e.g.,
136
+ a streaming agent that sees the last N messages plus the current one.
137
+
138
+ Args:
139
+ session_id: Existing session to reuse.
140
+
141
+ Returns:
142
+ self, for chaining.
143
+ """
144
+ self._reuse_session_id = session_id
145
+ return self
146
+
147
+ def add_step(
148
+ self,
149
+ tool_name: str,
150
+ fn: Callable[..., Coroutine[Any, Any, Any]],
151
+ name: Optional[str] = None,
152
+ chain_input: bool = True,
153
+ input_key: str = "input",
154
+ **kwargs: Any,
155
+ ) -> "Pipeline":
156
+ """Add a sequential step to the pipeline.
157
+
158
+ Args:
159
+ tool_name: Name of the tool (for supervision and audit).
160
+ fn: Async callable to execute.
161
+ name: Human-readable step name (defaults to tool_name).
162
+ chain_input: If True, pass previous step's output as a kwarg.
163
+ input_key: Name of the kwarg for the chained input.
164
+ **kwargs: Additional arguments passed to fn.
165
+
166
+ Returns:
167
+ self, for chaining.
168
+ """
169
+ self._nodes.append(
170
+ Step(
171
+ name=name or f"{len(self._nodes) + 1}_{tool_name}",
172
+ tool_name=tool_name,
173
+ fn=fn,
174
+ kwargs=kwargs,
175
+ chain_input=chain_input,
176
+ input_key=input_key,
177
+ )
178
+ )
179
+ return self
180
+
181
+ def add_condition(
182
+ self,
183
+ predicate: Callable[[Any], bool],
184
+ then_step: Tuple[str, Callable[..., Coroutine[Any, Any, Any]]],
185
+ else_step: Optional[Tuple[str, Callable[..., Coroutine[Any, Any, Any]]]] = None,
186
+ name: Optional[str] = None,
187
+ then_kwargs: Optional[Dict[str, Any]] = None,
188
+ else_kwargs: Optional[Dict[str, Any]] = None,
189
+ ) -> "Pipeline":
190
+ """Add a conditional branch.
191
+
192
+ Evaluates ``predicate(previous_output)`` and routes to the
193
+ appropriate step. Both branches are fully supervised.
194
+
195
+ Args:
196
+ predicate: Function that takes the previous step's output and
197
+ returns True (take then_step) or False (take else_step).
198
+ then_step: Tuple of (tool_name, fn) to execute if predicate is True.
199
+ else_step: Tuple of (tool_name, fn) if predicate is False.
200
+ If None, the condition is skipped when False.
201
+ name: Human-readable name for the condition.
202
+ then_kwargs: Extra kwargs for the then_step.
203
+ else_kwargs: Extra kwargs for the else_step.
204
+
205
+ Returns:
206
+ self, for chaining.
207
+ """
208
+ then = Step(
209
+ name=f"{then_step[0]}_then",
210
+ tool_name=then_step[0],
211
+ fn=then_step[1],
212
+ kwargs=then_kwargs or {},
213
+ chain_input=True,
214
+ )
215
+ else_s = None
216
+ if else_step is not None:
217
+ else_s = Step(
218
+ name=f"{else_step[0]}_else",
219
+ tool_name=else_step[0],
220
+ fn=else_step[1],
221
+ kwargs=else_kwargs or {},
222
+ chain_input=True,
223
+ )
224
+ self._nodes.append(
225
+ Condition(
226
+ name=name or f"{len(self._nodes) + 1}_condition",
227
+ predicate=predicate,
228
+ then_step=then,
229
+ else_step=else_s,
230
+ )
231
+ )
232
+ return self
233
+
234
+ def add_threshold(
235
+ self,
236
+ key: str,
237
+ threshold: float,
238
+ above_step: Tuple[str, Callable[..., Coroutine[Any, Any, Any]]],
239
+ below_step: Optional[Tuple[str, Callable[..., Coroutine[Any, Any, Any]]]] = None,
240
+ name: Optional[str] = None,
241
+ above_kwargs: Optional[Dict[str, Any]] = None,
242
+ below_kwargs: Optional[Dict[str, Any]] = None,
243
+ ) -> "Pipeline":
244
+ """Add a threshold-based branch — sugar for add_condition.
245
+
246
+ Extracts a numeric value from the previous step's output and
247
+ routes based on whether it's above or below the threshold.
248
+
249
+ Args:
250
+ key: Dot-notation key to extract from previous output.
251
+ e.g., "rows.0.cnt" extracts output["rows"][0]["cnt"]
252
+ threshold: The threshold value.
253
+ above_step: (tool_name, fn) if value >= threshold.
254
+ below_step: (tool_name, fn) if value < threshold. None = skip.
255
+ name: Name for the condition.
256
+ above_kwargs: Extra kwargs for above_step.
257
+ below_kwargs: Extra kwargs for below_step.
258
+
259
+ Returns:
260
+ self, for chaining.
261
+ """
262
+
263
+ def _extract(output: Any) -> bool:
264
+ val = output
265
+ for part in key.split("."):
266
+ if isinstance(val, dict):
267
+ val = val.get(part)
268
+ elif isinstance(val, (list, tuple)):
269
+ try:
270
+ val = val[int(part)]
271
+ except (IndexError, ValueError):
272
+ return False
273
+ else:
274
+ return False
275
+ try:
276
+ return float(val) >= threshold
277
+ except (TypeError, ValueError):
278
+ return False
279
+
280
+ return self.add_condition(
281
+ predicate=_extract,
282
+ then_step=above_step,
283
+ else_step=below_step,
284
+ name=name or f"threshold_{key}_{threshold}",
285
+ then_kwargs=above_kwargs,
286
+ else_kwargs=below_kwargs,
287
+ )
288
+
289
+ def add_llm_decision(
290
+ self,
291
+ llm: Callable[[str], Coroutine[Any, Any, str]],
292
+ prompt_template: str,
293
+ name: str = "llm_decision",
294
+ output_schema: Optional[Dict[str, Any]] = None,
295
+ include_context: bool = False,
296
+ ) -> "Pipeline":
297
+ """Add an LLM-backed decision step.
298
+
299
+ The LLM reads the previous step's output (and optionally the
300
+ session payload for context) and returns a structured decision
301
+ as JSON. The pipeline then routes on the decision.
302
+
303
+ Example:
304
+ pipeline.add_step("fetch", fetch_fn)
305
+ pipeline.add_llm_decision(
306
+ my_llm,
307
+ prompt_template=\"\"\"
308
+ You are a fraud detector. Given this transaction:
309
+ {input}
310
+
311
+ Historical context: {context}
312
+
313
+ Return JSON: {{"action": "block|approve|escalate", "reason": "..."}}
314
+ \"\"\",
315
+ include_context=True,
316
+ )
317
+ pipeline.add_condition(
318
+ lambda out: out["action"] == "block",
319
+ then_step=("block", block_fn),
320
+ else_step=("approve", approve_fn),
321
+ )
322
+
323
+ Args:
324
+ llm: Async callable — your LLM (same pattern as SkillBuilder).
325
+ prompt_template: String with {input} and optionally {context} placeholders.
326
+ name: Name for this step.
327
+ output_schema: Optional JSON schema hint for the LLM.
328
+ include_context: If True, pass the session payload as {context}.
329
+
330
+ Returns:
331
+ self, for chaining.
332
+ """
333
+
334
+ async def llm_step(input: Any = None, _context: Any = None) -> Dict[str, Any]:
335
+ """Call the LLM with the templated prompt and parse JSON output."""
336
+ import json as _json
337
+
338
+ input_str = _json.dumps(input, default=str) if input is not None else ""
339
+ context_str = _json.dumps(_context, default=str) if _context is not None else "{}"
340
+
341
+ prompt = prompt_template.replace("{input}", input_str).replace("{context}", context_str)
342
+ if output_schema:
343
+ schema_json = _json.dumps(output_schema)
344
+ prompt += f"\n\nRespond with ONLY JSON matching: {schema_json}"
345
+
346
+ raw = await llm(prompt)
347
+
348
+ # Strip markdown fences if present
349
+ text = raw.strip()
350
+ if text.startswith("```"):
351
+ text = text.split("\n", 1)[1] if "\n" in text else text[3:]
352
+ if text.endswith("```"):
353
+ text = text[:-3]
354
+ text = text.strip()
355
+ if text.startswith("json\n"):
356
+ text = text[5:]
357
+
358
+ try:
359
+ result: Dict[str, Any] = _json.loads(text)
360
+ return result
361
+ except (ValueError, _json.JSONDecodeError):
362
+ return {"raw": raw, "error": "Failed to parse JSON"}
363
+
364
+ # Mark the step so Pipeline.run() knows to inject context
365
+ step = Step(
366
+ name=name,
367
+ tool_name=name,
368
+ fn=llm_step,
369
+ kwargs={},
370
+ chain_input=True,
371
+ input_key="input",
372
+ )
373
+ step.__dict__["_include_context"] = include_context
374
+ self._nodes.append(step)
375
+ return self
376
+
377
+ async def run(
378
+ self,
379
+ initial_input: Optional[Any] = None,
380
+ ) -> PipelineResult:
381
+ """Execute the pipeline — steps and conditions in sequence.
382
+
383
+ Returns:
384
+ PipelineResult with all step results and the final output.
385
+ """
386
+ # Reuse an existing session if with_context() was called
387
+ if self._reuse_session_id:
388
+ sid = self._reuse_session_id
389
+ sv = self._manager.get_supervisor(sid)
390
+ if sv is None:
391
+ # Session not in memory — try to resume it
392
+ try:
393
+ sv = self._manager.resume(sid)
394
+ except Exception as e:
395
+ return PipelineResult(
396
+ session_id=sid, error=f"Failed to resume context session: {e}"
397
+ )
398
+ else:
399
+ sid = self._manager.start(
400
+ self._agent_id,
401
+ self._policy_dict,
402
+ self._payload,
403
+ stateless=self._stateless,
404
+ )
405
+ sv = self._manager.get_supervisor(sid)
406
+ if sv is None:
407
+ return PipelineResult(session_id=sid, error="Failed to create supervisor")
408
+
409
+ pipeline_result = PipelineResult(session_id=sid)
410
+ # Initial input for the first step (from stream payload or explicit param)
411
+ previous_output: Any = initial_input
412
+
413
+ # Load session context if any step needs it
414
+ session_context = None
415
+ needs_context = any(
416
+ isinstance(n, Step) and n.__dict__.get("_include_context", False) for n in self._nodes
417
+ )
418
+ if needs_context:
419
+ cp = self._manager.status(sid)
420
+ session_context = cp.payload if cp else {}
421
+
422
+ for i, node in enumerate(self._nodes):
423
+ sv.record_iteration()
424
+
425
+ if isinstance(node, Step):
426
+ # Inject context for LLM decision steps
427
+ if node.__dict__.get("_include_context", False):
428
+ node.kwargs = dict(node.kwargs)
429
+ node.kwargs["_context"] = session_context
430
+
431
+ result, step_result = await self._run_step(sv, node, previous_output, i)
432
+ pipeline_result.steps.append(step_result)
433
+
434
+ if not result.succeeded:
435
+ pipeline_result.stopped_at = node.name
436
+ error_kind = result.error.kind if result.error else "unknown"
437
+ if error_kind == "approval_pending":
438
+ err = result.error
439
+ aid = err.details.get("approval_id", "") if err else ""
440
+ pipeline_result.error = f"Step '{node.name}' awaiting approval: {aid}"
441
+ else:
442
+ pipeline_result.error = f"Step '{node.name}' failed: {error_kind}"
443
+ self._manager.stop(sid)
444
+ return pipeline_result
445
+
446
+ previous_output = result.output
447
+
448
+ elif isinstance(node, Condition):
449
+ # Evaluate the predicate
450
+ try:
451
+ take_then = node.predicate(previous_output)
452
+ except Exception as e:
453
+ pipeline_result.stopped_at = node.name
454
+ pipeline_result.error = f"Condition '{node.name}' predicate failed: {e}"
455
+ self._manager.stop(sid)
456
+ return pipeline_result
457
+
458
+ chosen = node.then_step if take_then else node.else_step
459
+ branch = "then" if take_then else "else"
460
+
461
+ if chosen is None:
462
+ # else_step is None — skip, keep previous output
463
+ continue
464
+
465
+ result, step_result = await self._run_step(
466
+ sv, chosen, previous_output, i, branch=branch
467
+ )
468
+ pipeline_result.steps.append(step_result)
469
+
470
+ if not result.succeeded:
471
+ pipeline_result.stopped_at = chosen.name
472
+ error_kind = result.error.kind if result.error else "unknown"
473
+ pipeline_result.error = f"Step '{chosen.name}' ({branch}) failed: {error_kind}"
474
+ self._manager.stop(sid)
475
+ return pipeline_result
476
+
477
+ previous_output = result.output
478
+
479
+ # Update payload with progress (preserve existing payload)
480
+ existing_cp = self._manager.status(sid)
481
+ existing_payload = existing_cp.payload if existing_cp else {}
482
+ merged = dict(existing_payload)
483
+ merged["pipeline_step"] = i
484
+ merged["pipeline_total_steps"] = len(self._nodes)
485
+ self._manager.update_payload(sid, merged)
486
+
487
+ pipeline_result.completed = True
488
+ # If running with context, keep the session alive for future runs
489
+ if not self._reuse_session_id:
490
+ self._manager.stop(sid)
491
+ else:
492
+ # Accumulate the output into the session payload for next run
493
+ cp = self._manager.status(sid)
494
+ if cp:
495
+ merged_payload = dict(cp.payload)
496
+ history = merged_payload.get("history", [])
497
+ if not isinstance(history, list):
498
+ history = []
499
+ history.append(
500
+ {
501
+ "input": initial_input,
502
+ "output": pipeline_result.final_output,
503
+ }
504
+ )
505
+ # Keep only last 20 runs to bound payload size
506
+ merged_payload["history"] = history[-20:]
507
+ self._manager.update_payload(sid, merged_payload)
508
+ return pipeline_result
509
+
510
+ async def _run_step(
511
+ self,
512
+ sv: Any,
513
+ step: Step,
514
+ previous_output: Any,
515
+ index: int,
516
+ branch: str = "",
517
+ ) -> Tuple[SupervisedResult, StepResult]:
518
+ """Execute a single step through the supervisor."""
519
+ call_kwargs = dict(step.kwargs)
520
+ if step.chain_input and previous_output is not None:
521
+ call_kwargs[step.input_key] = previous_output
522
+
523
+ result = await sv.call(step.tool_name, step.fn, **call_kwargs)
524
+
525
+ step_result = StepResult(
526
+ name=step.name,
527
+ tool_name=step.tool_name,
528
+ result=result,
529
+ step_index=index,
530
+ branch=branch,
531
+ )
532
+ return result, step_result