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
agenthandler/pipeline.py
ADDED
|
@@ -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
|