agstack 1.22.0__tar.gz → 1.23.0__tar.gz
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.
- {agstack-1.22.0 → agstack-1.23.0}/PKG-INFO +1 -1
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/event.py +4 -4
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/flow.py +55 -15
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/__init__.py +2 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/agent_node.py +4 -2
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/base.py +4 -2
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/echo_node.py +3 -2
- agstack-1.23.0/agstack/llm/flow/nodes/iterator_node.py +172 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/llm_chat_node.py +6 -4
- {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/PKG-INFO +1 -1
- {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/SOURCES.txt +2 -0
- {agstack-1.22.0 → agstack-1.23.0}/pyproject.toml +1 -1
- agstack-1.23.0/tests/test_flow_iterator.py +501 -0
- {agstack-1.22.0 → agstack-1.23.0}/LICENSE +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/README.md +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/cache/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/cache/base.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/cache/memory.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/cache/redis.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/config/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/config/logger.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/config/manager.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/config/types.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/contexts.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/decorators.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/events.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/exceptions.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/exception.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/middleware.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/offline.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/sse.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/infra/db/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/infra/es/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/infra/kg/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/infra/mq/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/client.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/agent.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/context.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/factory.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/loader.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/records.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/registry.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/state.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/tool.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/trace.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/prompts.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/token.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/messagebus/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/messagebus/base.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/messagebus/memory.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/messagebus/redis.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/schema.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/security/__init__.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/security/casbin.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/security/crypt.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack/status.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/requires.txt +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/top_level.txt +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/setup.cfg +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/tests/test_cache_memory.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/tests/test_cache_redis.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/tests/test_flow_io.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/tests/test_flow_switch_subflow.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/tests/test_messagebus_memory.py +0 -0
- {agstack-1.22.0 → agstack-1.23.0}/tests/test_messagebus_redis.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agstack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.0
|
|
4
4
|
Summary: Production-ready toolkit for building FastAPI and LLM applications
|
|
5
5
|
Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
6
6
|
Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
@@ -97,12 +97,12 @@ def run_error(*, message: str, code: str | None = None) -> dict[str, Any]:
|
|
|
97
97
|
# ── Step ──
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
def step_started(*, step_name: str) -> dict[str, Any]:
|
|
101
|
-
return _ev(EventType.STEP_STARTED, step_name=step_name)
|
|
100
|
+
def step_started(*, step_name: str, step_id: str | None = None) -> dict[str, Any]:
|
|
101
|
+
return _ev(EventType.STEP_STARTED, step_name=step_name, step_id=step_id or str(uuid4()))
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
def step_finished(*, step_name: str) -> dict[str, Any]:
|
|
105
|
-
return _ev(EventType.STEP_FINISHED, step_name=step_name)
|
|
104
|
+
def step_finished(*, step_name: str, step_id: str) -> dict[str, Any]:
|
|
105
|
+
return _ev(EventType.STEP_FINISHED, step_name=step_name, step_id=step_id)
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
# ── State ──
|
|
@@ -358,8 +358,19 @@ class Flow:
|
|
|
358
358
|
|
|
359
359
|
elif node_type in self._node_handlers:
|
|
360
360
|
handler = self._node_handlers[node_type]
|
|
361
|
-
|
|
361
|
+
try:
|
|
362
|
+
result = await handler.execute(node, context)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
iter_target = self._find_iterator_fallback(current_node_id)
|
|
365
|
+
if iter_target:
|
|
366
|
+
context.set_variable(f"_iter_{iter_target}_error", str(e))
|
|
367
|
+
context.set_output(current_node_id, {"error": str(e)})
|
|
368
|
+
context.set_variable("_prev_node_id", current_node_id)
|
|
369
|
+
current_node_id = iter_target
|
|
370
|
+
continue
|
|
371
|
+
raise
|
|
362
372
|
context.set_output(current_node_id, result)
|
|
373
|
+
context.set_variable("_prev_node_id", current_node_id)
|
|
363
374
|
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
364
375
|
|
|
365
376
|
else:
|
|
@@ -370,7 +381,8 @@ class Flow:
|
|
|
370
381
|
async def stream(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
|
|
371
382
|
"""流式执行 Flow(输出 AG-UI 标准事件)"""
|
|
372
383
|
context.trace.started_at = time.time()
|
|
373
|
-
|
|
384
|
+
flow_sid = str(uuid4())
|
|
385
|
+
yield event.step_started(step_name=f"flow:{self.name}", step_id=flow_sid)
|
|
374
386
|
|
|
375
387
|
try:
|
|
376
388
|
if not self.edges:
|
|
@@ -386,7 +398,7 @@ class Flow:
|
|
|
386
398
|
context.trace.finished_at = time.time()
|
|
387
399
|
context.trace.total_usage = context.usage
|
|
388
400
|
|
|
389
|
-
yield event.step_finished(step_name=f"flow:{self.name}")
|
|
401
|
+
yield event.step_finished(step_name=f"flow:{self.name}", step_id=flow_sid)
|
|
390
402
|
|
|
391
403
|
async def _stream_sequential(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
|
|
392
404
|
"""顺序流式执行"""
|
|
@@ -408,6 +420,18 @@ class Flow:
|
|
|
408
420
|
)
|
|
409
421
|
raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
|
|
410
422
|
|
|
423
|
+
def _find_iterator_fallback(self, source_id: str) -> str | None:
|
|
424
|
+
"""查找从 source_id 出发指向 iterator 节点的 edge target"""
|
|
425
|
+
for edge in self.edges:
|
|
426
|
+
if edge.get("source") == source_id:
|
|
427
|
+
target_id: str | None = edge.get("target")
|
|
428
|
+
if not target_id:
|
|
429
|
+
continue
|
|
430
|
+
target_node = self.get_node_config(target_id)
|
|
431
|
+
if target_node and target_node.get("type") == "iterator":
|
|
432
|
+
return target_id
|
|
433
|
+
return None
|
|
434
|
+
|
|
411
435
|
async def _stream_edge_driven(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
|
|
412
436
|
"""边驱动流式执行"""
|
|
413
437
|
current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
|
|
@@ -437,7 +461,8 @@ class Flow:
|
|
|
437
461
|
context.trace.record_node_start(current_node_id, "message", inputs=msg_config)
|
|
438
462
|
|
|
439
463
|
# message 节点增加 STEP 事件
|
|
440
|
-
|
|
464
|
+
msg_sid = str(uuid4())
|
|
465
|
+
step_evt = event.step_started(step_name=f"message:{current_node_id}", step_id=msg_sid)
|
|
441
466
|
step_evt["_node_id"] = current_node_id
|
|
442
467
|
step_evt["_label"] = msg_config.get("label")
|
|
443
468
|
step_evt["_echo"] = msg_config.get("echo", True)
|
|
@@ -454,7 +479,7 @@ class Flow:
|
|
|
454
479
|
text = template.format_map(_SafeFormatDict(context.variables))
|
|
455
480
|
context.set_output(current_node_id, {"result": text})
|
|
456
481
|
|
|
457
|
-
fin_evt = event.step_finished(step_name=f"message:{current_node_id}")
|
|
482
|
+
fin_evt = event.step_finished(step_name=f"message:{current_node_id}", step_id=msg_sid)
|
|
458
483
|
fin_evt["_node_id"] = current_node_id
|
|
459
484
|
fin_evt["_label"] = msg_config.get("label")
|
|
460
485
|
fin_evt["_echo"] = msg_config.get("echo", True)
|
|
@@ -469,15 +494,16 @@ class Flow:
|
|
|
469
494
|
|
|
470
495
|
context.trace.record_node_start(current_node_id, "parallel", inputs=config)
|
|
471
496
|
|
|
472
|
-
|
|
497
|
+
parallel_sid = str(uuid4())
|
|
498
|
+
step_evt = event.step_started(step_name=f"parallel:{current_node_id}", step_id=parallel_sid)
|
|
473
499
|
step_evt["_node_id"] = current_node_id
|
|
474
500
|
step_evt["_label"] = None
|
|
475
501
|
step_evt["_echo"] = False
|
|
476
502
|
yield step_evt
|
|
477
503
|
|
|
478
|
-
|
|
504
|
+
_pqid: str = context.trace._qualify_id(current_node_id)
|
|
479
505
|
|
|
480
|
-
async def _exec_branch(branch_id: str,
|
|
506
|
+
async def _exec_branch(branch_id: str, parent_qid: str) -> None:
|
|
481
507
|
branch_node = self.get_node_config(branch_id)
|
|
482
508
|
if not branch_node:
|
|
483
509
|
return
|
|
@@ -491,7 +517,7 @@ class Flow:
|
|
|
491
517
|
branch_id,
|
|
492
518
|
branch_type,
|
|
493
519
|
inputs=branch_config.get("inputs", {}),
|
|
494
|
-
parent_id=
|
|
520
|
+
parent_id=parent_qid,
|
|
495
521
|
)
|
|
496
522
|
context.current_node = branch_id
|
|
497
523
|
try:
|
|
@@ -502,7 +528,7 @@ class Flow:
|
|
|
502
528
|
context.trace.record_node_end(branch_id, error=str(e))
|
|
503
529
|
raise
|
|
504
530
|
|
|
505
|
-
await asyncio.gather(*[_exec_branch(bid) for bid in branches])
|
|
531
|
+
await asyncio.gather(*[_exec_branch(bid, _pqid) for bid in branches])
|
|
506
532
|
merged: dict[str, Any] = {}
|
|
507
533
|
for bid in branches:
|
|
508
534
|
branch_result = context.outputs.get(bid, {})
|
|
@@ -510,7 +536,7 @@ class Flow:
|
|
|
510
536
|
merged.update(branch_result)
|
|
511
537
|
context.set_output(current_node_id, merged)
|
|
512
538
|
|
|
513
|
-
fin_evt = event.step_finished(step_name=f"parallel:{current_node_id}")
|
|
539
|
+
fin_evt = event.step_finished(step_name=f"parallel:{current_node_id}", step_id=parallel_sid)
|
|
514
540
|
fin_evt["_node_id"] = current_node_id
|
|
515
541
|
fin_evt["_label"] = None
|
|
516
542
|
fin_evt["_echo"] = False
|
|
@@ -533,7 +559,8 @@ class Flow:
|
|
|
533
559
|
|
|
534
560
|
context.trace.record_node_start(current_node_id, "iteration", inputs=config)
|
|
535
561
|
|
|
536
|
-
|
|
562
|
+
iter_sid = str(uuid4())
|
|
563
|
+
step_evt = event.step_started(step_name=f"iteration:{current_node_id}", step_id=iter_sid)
|
|
537
564
|
step_evt["_node_id"] = current_node_id
|
|
538
565
|
step_evt["_label"] = None
|
|
539
566
|
step_evt["_echo"] = False
|
|
@@ -574,7 +601,7 @@ class Flow:
|
|
|
574
601
|
iteration_output = {"results": results}
|
|
575
602
|
context.set_output(current_node_id, iteration_output)
|
|
576
603
|
|
|
577
|
-
fin_evt = event.step_finished(step_name=f"iteration:{current_node_id}")
|
|
604
|
+
fin_evt = event.step_finished(step_name=f"iteration:{current_node_id}", step_id=iter_sid)
|
|
578
605
|
fin_evt["_node_id"] = current_node_id
|
|
579
606
|
fin_evt["_label"] = None
|
|
580
607
|
fin_evt["_echo"] = False
|
|
@@ -596,8 +623,20 @@ class Flow:
|
|
|
596
623
|
label=config.get("label"),
|
|
597
624
|
)
|
|
598
625
|
|
|
599
|
-
|
|
600
|
-
|
|
626
|
+
try:
|
|
627
|
+
async for evt in self._execute_node_with_retry(node, context, current_node_id):
|
|
628
|
+
yield evt
|
|
629
|
+
except (NodeExecutionError, Exception) as e:
|
|
630
|
+
# 迭代体错误容忍:如果该节点有 edge 指向 iterator,路由回去而非中断 flow
|
|
631
|
+
iter_target = self._find_iterator_fallback(current_node_id)
|
|
632
|
+
if iter_target:
|
|
633
|
+
context.set_variable(f"_iter_{iter_target}_error", str(e))
|
|
634
|
+
context.set_output(current_node_id, {"error": str(e)})
|
|
635
|
+
context.trace.record_node_end(current_node_id, error=str(e))
|
|
636
|
+
context.set_variable("_prev_node_id", current_node_id)
|
|
637
|
+
current_node_id = iter_target
|
|
638
|
+
continue
|
|
639
|
+
raise
|
|
601
640
|
|
|
602
641
|
# 收集 agent 节点存放的 tool_calls 或通用 execution_records
|
|
603
642
|
tool_calls = context.get_variable("_last_node_tool_calls")
|
|
@@ -618,6 +657,7 @@ class Flow:
|
|
|
618
657
|
tool_calls=tool_calls if tool_calls else None,
|
|
619
658
|
messages=messages,
|
|
620
659
|
)
|
|
660
|
+
context.set_variable("_prev_node_id", current_node_id)
|
|
621
661
|
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
622
662
|
|
|
623
663
|
else:
|
|
@@ -6,6 +6,7 @@ from .agent_node import AgentNodeHandler
|
|
|
6
6
|
from .base import NodeHandler
|
|
7
7
|
from .detect_node import DetectNodeHandler
|
|
8
8
|
from .echo_node import EchoNodeHandler
|
|
9
|
+
from .iterator_node import IteratorNodeHandler
|
|
9
10
|
from .llm_chat_node import LLMChatNodeHandler
|
|
10
11
|
from .llm_embed_node import LLMEmbedNodeHandler
|
|
11
12
|
from .llm_rerank_node import LLMRerankNodeHandler
|
|
@@ -27,6 +28,7 @@ builtin_handlers: list[NodeHandler] = [
|
|
|
27
28
|
EchoNodeHandler(),
|
|
28
29
|
SwitchNodeHandler(),
|
|
29
30
|
SubflowNodeHandler(),
|
|
31
|
+
IteratorNodeHandler(),
|
|
30
32
|
]
|
|
31
33
|
|
|
32
34
|
__all__ = [
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"""Agent 节点处理器 — 从 flow.py 提取"""
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any, AsyncIterator
|
|
6
|
+
from uuid import uuid4
|
|
6
7
|
|
|
7
8
|
from .. import event
|
|
8
9
|
from ..exceptions import FlowError
|
|
@@ -46,8 +47,9 @@ class AgentNodeHandler(NodeHandler):
|
|
|
46
47
|
async def stream(self, node: dict, context: "FlowContext", node_id: str) -> AsyncIterator[dict[str, Any]]:
|
|
47
48
|
config = node.get("config", {})
|
|
48
49
|
step_name = self.get_step_name(node, node_id)
|
|
50
|
+
sid = str(uuid4())
|
|
49
51
|
|
|
50
|
-
yield event.step_started(step_name=step_name)
|
|
52
|
+
yield event.step_started(step_name=step_name, step_id=sid)
|
|
51
53
|
resolved = self.resolve_inputs(config, context)
|
|
52
54
|
ag = self._create_agent(config, context)
|
|
53
55
|
async for evt in ag.stream(context, inputs=resolved):
|
|
@@ -63,4 +65,4 @@ class AgentNodeHandler(NodeHandler):
|
|
|
63
65
|
tool_calls = context.pop_execution_records()
|
|
64
66
|
context.set_variable("_last_node_tool_calls", tool_calls)
|
|
65
67
|
|
|
66
|
-
yield event.step_finished(step_name=step_name)
|
|
68
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"""NodeHandler 基类 — 所有执行类节点的公共接口"""
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any, AsyncIterator
|
|
6
|
+
from uuid import uuid4
|
|
6
7
|
|
|
7
8
|
from .. import event
|
|
8
9
|
|
|
@@ -45,7 +46,8 @@ class NodeHandler:
|
|
|
45
46
|
需要流式输出的节点(如 agent, llm_chat)应覆盖此方法。
|
|
46
47
|
"""
|
|
47
48
|
step_name = self.get_step_name(node, node_id)
|
|
48
|
-
|
|
49
|
+
sid = str(uuid4())
|
|
50
|
+
yield event.step_started(step_name=step_name, step_id=sid)
|
|
49
51
|
result = await self.execute(node, context)
|
|
50
52
|
context.set_output(node_id, result)
|
|
51
|
-
yield event.step_finished(step_name=step_name)
|
|
53
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
@@ -32,8 +32,9 @@ class EchoNodeHandler(NodeHandler):
|
|
|
32
32
|
async def stream(self, node: dict, context: "FlowContext", node_id: str) -> AsyncIterator[dict[str, Any]]:
|
|
33
33
|
config = node.get("config", {})
|
|
34
34
|
step_name = self.get_step_name(node, node_id)
|
|
35
|
+
sid = str(uuid4())
|
|
35
36
|
|
|
36
|
-
yield event.step_started(step_name=step_name)
|
|
37
|
+
yield event.step_started(step_name=step_name, step_id=sid)
|
|
37
38
|
|
|
38
39
|
resolved = self.resolve_inputs(config, context)
|
|
39
40
|
content = resolved.get("content", "")
|
|
@@ -51,4 +52,4 @@ class EchoNodeHandler(NodeHandler):
|
|
|
51
52
|
yield event.text_message_end(message_id=msg_id)
|
|
52
53
|
|
|
53
54
|
context.set_output(node_id, {"result": content})
|
|
54
|
-
yield event.step_finished(step_name=step_name)
|
|
55
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Iterator 节点处理器 — 通用迭代原语,管理遍历状态并通过 edge 组合实现路由"""
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, AsyncIterator
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from .. import event
|
|
9
|
+
from .base import NodeHandler
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..context import FlowContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IteratorNodeHandler(NodeHandler):
|
|
17
|
+
"""Iterator 节点:管理数组遍历状态,暴露当前元素供下游引用,收集每轮结果。
|
|
18
|
+
|
|
19
|
+
通过 edge-driven 执行被多次访问:
|
|
20
|
+
- 首次访问:初始化迭代,暴露第一个元素,走迭代体 edge
|
|
21
|
+
- Cycle-back:收集上一轮结果,推进 index,继续或走 completion edge
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
node_type = "iterator"
|
|
25
|
+
|
|
26
|
+
def _resolve_items(self, config: dict, context: "FlowContext") -> list:
|
|
27
|
+
items_ref = config.get("items", "")
|
|
28
|
+
items = context.resolve_reference(items_ref) if isinstance(items_ref, str) else items_ref
|
|
29
|
+
if items is None:
|
|
30
|
+
return []
|
|
31
|
+
if not isinstance(items, list):
|
|
32
|
+
return [items]
|
|
33
|
+
return items
|
|
34
|
+
|
|
35
|
+
def _set_item_output(self, node_id: str, state: dict, config: dict, context: "FlowContext") -> None:
|
|
36
|
+
collect_to = config.get("collect_to", "results")
|
|
37
|
+
idx = state["index"]
|
|
38
|
+
context.set_output(
|
|
39
|
+
node_id,
|
|
40
|
+
{
|
|
41
|
+
"current_item": state["items"][idx],
|
|
42
|
+
"index": idx,
|
|
43
|
+
"count": len(state["items"]),
|
|
44
|
+
"done": False,
|
|
45
|
+
collect_to: list(state["collected"]),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _set_done_output(self, node_id: str, state: dict, config: dict, context: "FlowContext") -> None:
|
|
50
|
+
collect_to = config.get("collect_to", "results")
|
|
51
|
+
context.set_output(
|
|
52
|
+
node_id,
|
|
53
|
+
{
|
|
54
|
+
"done": True,
|
|
55
|
+
"count": len(state["items"]),
|
|
56
|
+
collect_to: list(state["collected"]),
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _build_event(
|
|
61
|
+
self, config: dict, event_key: str, state: dict, *, error: str | None = None
|
|
62
|
+
) -> dict[str, Any] | None:
|
|
63
|
+
events_config = config.get("events")
|
|
64
|
+
if not events_config:
|
|
65
|
+
return None
|
|
66
|
+
evt_cfg = events_config.get(event_key)
|
|
67
|
+
if not evt_cfg:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
name = evt_cfg.get("name", event_key)
|
|
71
|
+
value_template = evt_cfg.get("value", {})
|
|
72
|
+
value = self._interpolate_event_value(value_template, state, error=error)
|
|
73
|
+
return event.custom(name=name, value=value)
|
|
74
|
+
|
|
75
|
+
def _interpolate_event_value(self, template: Any, state: dict, *, error: str | None = None) -> Any:
|
|
76
|
+
if isinstance(template, str):
|
|
77
|
+
if template == "$index":
|
|
78
|
+
return state["index"]
|
|
79
|
+
if template == "$item":
|
|
80
|
+
return state["items"][state["index"]] if state["index"] < len(state["items"]) else None
|
|
81
|
+
if template == "$count":
|
|
82
|
+
return len(state["items"])
|
|
83
|
+
if template == "$error":
|
|
84
|
+
return error
|
|
85
|
+
return template
|
|
86
|
+
if isinstance(template, dict):
|
|
87
|
+
return {k: self._interpolate_event_value(v, state, error=error) for k, v in template.items()}
|
|
88
|
+
if isinstance(template, list):
|
|
89
|
+
return [self._interpolate_event_value(v, state, error=error) for v in template]
|
|
90
|
+
return template
|
|
91
|
+
|
|
92
|
+
async def execute(self, node: dict, context: "FlowContext") -> Any:
|
|
93
|
+
config = node.get("config", {})
|
|
94
|
+
items = self._resolve_items(config, context)
|
|
95
|
+
collect_to = config.get("collect_to", "results")
|
|
96
|
+
return {"done": True, "count": len(items), collect_to: [], "current_item": None, "index": 0}
|
|
97
|
+
|
|
98
|
+
async def stream(self, node: dict, context: "FlowContext", node_id: str) -> AsyncIterator[dict[str, Any]]:
|
|
99
|
+
config = node.get("config", {})
|
|
100
|
+
state_key = f"_iter_{node_id}"
|
|
101
|
+
step_name = self.get_step_name(node, node_id)
|
|
102
|
+
|
|
103
|
+
state = context.get_variable(state_key)
|
|
104
|
+
|
|
105
|
+
if state is None:
|
|
106
|
+
# ═══ 首次访问:初始化 ═══
|
|
107
|
+
items = self._resolve_items(config, context)
|
|
108
|
+
state = {"items": items, "index": 0, "collected": []}
|
|
109
|
+
context.set_variable(state_key, state)
|
|
110
|
+
|
|
111
|
+
sid = str(uuid4())
|
|
112
|
+
yield event.step_started(step_name=step_name, step_id=sid)
|
|
113
|
+
|
|
114
|
+
evt = self._build_event(config, "on_start", state)
|
|
115
|
+
if evt:
|
|
116
|
+
yield evt
|
|
117
|
+
|
|
118
|
+
if not items:
|
|
119
|
+
self._set_done_output(node_id, state, config, context)
|
|
120
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
self._set_item_output(node_id, state, config, context)
|
|
124
|
+
evt = self._build_event(config, "on_item_start", state)
|
|
125
|
+
if evt:
|
|
126
|
+
yield evt
|
|
127
|
+
|
|
128
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
129
|
+
|
|
130
|
+
else:
|
|
131
|
+
# ═══ Cycle-back:收集 + 推进 ═══
|
|
132
|
+
sid = str(uuid4())
|
|
133
|
+
yield event.step_started(step_name=step_name, step_id=sid)
|
|
134
|
+
|
|
135
|
+
prev_node_id = context.get_variable("_prev_node_id")
|
|
136
|
+
error = context.get_variable(f"_iter_{node_id}_error")
|
|
137
|
+
|
|
138
|
+
if error:
|
|
139
|
+
state["collected"].append(
|
|
140
|
+
{
|
|
141
|
+
"error": error,
|
|
142
|
+
"item": state["items"][state["index"]] if state["index"] < len(state["items"]) else None,
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
evt = self._build_event(config, "on_item_error", state, error=error)
|
|
146
|
+
if evt:
|
|
147
|
+
yield evt
|
|
148
|
+
context.set_variable(f"_iter_{node_id}_error", None)
|
|
149
|
+
else:
|
|
150
|
+
prev_output = context.outputs.get(prev_node_id) if prev_node_id else None
|
|
151
|
+
state["collected"].append(prev_output)
|
|
152
|
+
evt = self._build_event(config, "on_item_end", state)
|
|
153
|
+
if evt:
|
|
154
|
+
yield evt
|
|
155
|
+
|
|
156
|
+
state["index"] += 1
|
|
157
|
+
max_iter = config.get("max_iterations")
|
|
158
|
+
|
|
159
|
+
if state["index"] >= len(state["items"]) or (max_iter and state["index"] >= max_iter):
|
|
160
|
+
self._set_done_output(node_id, state, config, context)
|
|
161
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
self._set_item_output(node_id, state, config, context)
|
|
165
|
+
evt = self._build_event(config, "on_item_start", state)
|
|
166
|
+
if evt:
|
|
167
|
+
yield evt
|
|
168
|
+
|
|
169
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
170
|
+
|
|
171
|
+
def get_step_name(self, node: dict, node_id: str) -> str:
|
|
172
|
+
return f"iterator:{node_id}"
|
|
@@ -92,15 +92,17 @@ class LLMChatNodeHandler(NodeHandler):
|
|
|
92
92
|
if not use_stream:
|
|
93
93
|
# 非流式:走默认 execute 路径
|
|
94
94
|
step_name = self.get_step_name(node, node_id)
|
|
95
|
-
|
|
95
|
+
sid = str(uuid4())
|
|
96
|
+
yield event.step_started(step_name=step_name, step_id=sid)
|
|
96
97
|
result = await self.execute(node, context)
|
|
97
98
|
context.set_output(node_id, result)
|
|
98
|
-
yield event.step_finished(step_name=step_name)
|
|
99
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
99
100
|
return
|
|
100
101
|
|
|
101
102
|
# 流式输出
|
|
102
103
|
step_name = self.get_step_name(node, node_id)
|
|
103
|
-
|
|
104
|
+
sid = str(uuid4())
|
|
105
|
+
yield event.step_started(step_name=step_name, step_id=sid)
|
|
104
106
|
|
|
105
107
|
resolved_inputs = self.resolve_inputs(config, context)
|
|
106
108
|
prompt_text = self._build_prompt(config.get("prompt", ""), resolved_inputs)
|
|
@@ -153,4 +155,4 @@ class LLMChatNodeHandler(NodeHandler):
|
|
|
153
155
|
result_text = "".join(content_parts)
|
|
154
156
|
context.set_output(node_id, {"result": result_text})
|
|
155
157
|
|
|
156
|
-
yield event.step_finished(step_name=step_name)
|
|
158
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agstack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.0
|
|
4
4
|
Summary: Production-ready toolkit for building FastAPI and LLM applications
|
|
5
5
|
Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
6
6
|
Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
@@ -53,6 +53,7 @@ agstack/llm/flow/nodes/agent_node.py
|
|
|
53
53
|
agstack/llm/flow/nodes/base.py
|
|
54
54
|
agstack/llm/flow/nodes/detect_node.py
|
|
55
55
|
agstack/llm/flow/nodes/echo_node.py
|
|
56
|
+
agstack/llm/flow/nodes/iterator_node.py
|
|
56
57
|
agstack/llm/flow/nodes/llm_chat_node.py
|
|
57
58
|
agstack/llm/flow/nodes/llm_embed_node.py
|
|
58
59
|
agstack/llm/flow/nodes/llm_rerank_node.py
|
|
@@ -70,6 +71,7 @@ agstack/security/crypt.py
|
|
|
70
71
|
tests/test_cache_memory.py
|
|
71
72
|
tests/test_cache_redis.py
|
|
72
73
|
tests/test_flow_io.py
|
|
74
|
+
tests/test_flow_iterator.py
|
|
73
75
|
tests/test_flow_switch_subflow.py
|
|
74
76
|
tests/test_messagebus_memory.py
|
|
75
77
|
tests/test_messagebus_redis.py
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Iterator 节点及 Agent instructions 注入测试"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from agstack.llm.flow.context import FlowContext
|
|
8
|
+
from agstack.llm.flow.flow import Flow
|
|
9
|
+
from agstack.llm.flow.nodes.iterator_node import IteratorNodeHandler
|
|
10
|
+
from agstack.llm.flow.registry import registry
|
|
11
|
+
from agstack.llm.flow.tool import Tool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(coro):
|
|
15
|
+
return asyncio.get_event_loop().run_until_complete(coro)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def collect_events(flow: Flow, context: FlowContext) -> list[dict]:
|
|
19
|
+
events = []
|
|
20
|
+
async for evt in flow.stream(context):
|
|
21
|
+
events.append(evt)
|
|
22
|
+
return events
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── 辅助 mock tool ──
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _echo_tool_fn(context, inputs):
|
|
29
|
+
return {"echo": inputs}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _failing_tool_fn(context, inputs):
|
|
33
|
+
raise RuntimeError("tool_failed")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def setup_module():
|
|
37
|
+
"""注册测试用 mock tools"""
|
|
38
|
+
registry.register_tool(
|
|
39
|
+
"test_echo",
|
|
40
|
+
Tool(name="test_echo", description="Echo inputs", function=_echo_tool_fn),
|
|
41
|
+
)
|
|
42
|
+
registry.register_tool(
|
|
43
|
+
"test_fail",
|
|
44
|
+
Tool(name="test_fail", description="Always fails", function=_failing_tool_fn),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── IteratorNodeHandler 单元测试 ──
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestIteratorNodeHandler:
|
|
52
|
+
"""iterator 节点处理器单元测试"""
|
|
53
|
+
|
|
54
|
+
def setup_method(self):
|
|
55
|
+
self.handler = IteratorNodeHandler()
|
|
56
|
+
|
|
57
|
+
def test_resolve_items_from_variable(self):
|
|
58
|
+
ctx = FlowContext(variables={"tasks": [{"type": "a"}, {"type": "b"}]})
|
|
59
|
+
config = {"items": "$v.tasks"}
|
|
60
|
+
items = self.handler._resolve_items(config, ctx)
|
|
61
|
+
assert items == [{"type": "a"}, {"type": "b"}]
|
|
62
|
+
|
|
63
|
+
def test_resolve_items_from_output(self):
|
|
64
|
+
ctx = FlowContext()
|
|
65
|
+
ctx.set_output("planner", {"tasks": [1, 2, 3]})
|
|
66
|
+
config = {"items": "$o.planner.tasks"}
|
|
67
|
+
items = self.handler._resolve_items(config, ctx)
|
|
68
|
+
assert items == [1, 2, 3]
|
|
69
|
+
|
|
70
|
+
def test_resolve_items_none_returns_empty(self):
|
|
71
|
+
ctx = FlowContext()
|
|
72
|
+
config = {"items": "$v.missing"}
|
|
73
|
+
items = self.handler._resolve_items(config, ctx)
|
|
74
|
+
assert items == []
|
|
75
|
+
|
|
76
|
+
def test_resolve_items_non_list_wraps(self):
|
|
77
|
+
ctx = FlowContext(variables={"single": "value"})
|
|
78
|
+
config = {"items": "$v.single"}
|
|
79
|
+
items = self.handler._resolve_items(config, ctx)
|
|
80
|
+
assert items == ["value"]
|
|
81
|
+
|
|
82
|
+
def test_set_item_output(self):
|
|
83
|
+
ctx = FlowContext()
|
|
84
|
+
state = {"items": ["a", "b", "c"], "index": 1, "collected": ["prev"]}
|
|
85
|
+
self.handler._set_item_output("loop", state, {"collect_to": "results"}, ctx)
|
|
86
|
+
output = ctx.outputs["loop"]
|
|
87
|
+
assert output["current_item"] == "b"
|
|
88
|
+
assert output["index"] == 1
|
|
89
|
+
assert output["count"] == 3
|
|
90
|
+
assert output["done"] is False
|
|
91
|
+
assert output["results"] == ["prev"]
|
|
92
|
+
|
|
93
|
+
def test_set_done_output(self):
|
|
94
|
+
ctx = FlowContext()
|
|
95
|
+
state = {"items": ["a", "b"], "index": 2, "collected": ["r1", "r2"]}
|
|
96
|
+
self.handler._set_done_output("loop", state, {"collect_to": "results"}, ctx)
|
|
97
|
+
output = ctx.outputs["loop"]
|
|
98
|
+
assert output["done"] is True
|
|
99
|
+
assert output["count"] == 2
|
|
100
|
+
assert output["results"] == ["r1", "r2"]
|
|
101
|
+
|
|
102
|
+
def test_build_event_with_interpolation(self):
|
|
103
|
+
config = {
|
|
104
|
+
"events": {
|
|
105
|
+
"on_start": {
|
|
106
|
+
"name": "iteration_started",
|
|
107
|
+
"value": {"total": "$count", "first": "$item"},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
state = {"items": ["x", "y", "z"], "index": 0, "collected": []}
|
|
112
|
+
evt = self.handler._build_event(config, "on_start", state)
|
|
113
|
+
assert evt is not None
|
|
114
|
+
assert evt["type"] == "CUSTOM"
|
|
115
|
+
assert evt["name"] == "iteration_started"
|
|
116
|
+
assert evt["value"]["total"] == 3
|
|
117
|
+
assert evt["value"]["first"] == "x"
|
|
118
|
+
|
|
119
|
+
def test_build_event_missing_config_returns_none(self):
|
|
120
|
+
evt = self.handler._build_event({}, "on_start", {"items": [], "index": 0, "collected": []})
|
|
121
|
+
assert evt is None
|
|
122
|
+
|
|
123
|
+
def test_execute_returns_done(self):
|
|
124
|
+
"""execute() 返回 done 状态(用于非 edge-driven 场景)"""
|
|
125
|
+
ctx = FlowContext(variables={"items": [1, 2, 3]})
|
|
126
|
+
node = {"id": "loop", "type": "iterator", "config": {"items": "$v.items"}}
|
|
127
|
+
result = run(self.handler.execute(node, ctx))
|
|
128
|
+
assert result["done"] is True
|
|
129
|
+
assert result["count"] == 3
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Flow 集成测试:基本迭代 ──
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestIteratorFlowIntegration:
|
|
136
|
+
"""iterator 节点 + edge-driven flow 集成测试"""
|
|
137
|
+
|
|
138
|
+
def test_basic_iteration(self):
|
|
139
|
+
"""遍历 3 个 items,每轮调用 echo tool,收集结果"""
|
|
140
|
+
flow = Flow(
|
|
141
|
+
flow_id="test",
|
|
142
|
+
name="iter_test",
|
|
143
|
+
nodes=[
|
|
144
|
+
{
|
|
145
|
+
"id": "loop",
|
|
146
|
+
"type": "iterator",
|
|
147
|
+
"config": {
|
|
148
|
+
"items": "$v.tasks",
|
|
149
|
+
"collect_to": "results",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "do_work",
|
|
154
|
+
"type": "tool",
|
|
155
|
+
"config": {
|
|
156
|
+
"tool_name": "test_echo",
|
|
157
|
+
"inputs": {"query": "$o.loop.current_item"},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"id": "done_node",
|
|
162
|
+
"type": "python",
|
|
163
|
+
"config": {
|
|
164
|
+
"code": "def main():\n return {'status': 'done'}",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
edges=[
|
|
169
|
+
{"source": "loop", "target": "do_work"},
|
|
170
|
+
{"source": "do_work", "target": "loop"},
|
|
171
|
+
{"source": "loop", "target": "done_node", "condition": "$o.loop.done"},
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
ctx = FlowContext(variables={"tasks": ["task_a", "task_b", "task_c"]})
|
|
175
|
+
run(collect_events(flow, ctx))
|
|
176
|
+
|
|
177
|
+
# 验证迭代完成
|
|
178
|
+
loop_output = ctx.outputs["loop"]
|
|
179
|
+
assert loop_output["done"] is True
|
|
180
|
+
assert loop_output["count"] == 3
|
|
181
|
+
assert len(loop_output["results"]) == 3
|
|
182
|
+
|
|
183
|
+
# 验证每轮结果是 echo tool 的输出
|
|
184
|
+
for i, result in enumerate(loop_output["results"]):
|
|
185
|
+
assert result == {"echo": {"query": f"task_{chr(ord('a') + i)}"}}
|
|
186
|
+
|
|
187
|
+
def test_empty_items_goes_to_completion(self):
|
|
188
|
+
"""空数组直接走 completion edge"""
|
|
189
|
+
flow = Flow(
|
|
190
|
+
flow_id="test",
|
|
191
|
+
name="iter_empty",
|
|
192
|
+
nodes=[
|
|
193
|
+
{
|
|
194
|
+
"id": "loop",
|
|
195
|
+
"type": "iterator",
|
|
196
|
+
"config": {"items": "$v.tasks", "collect_to": "results"},
|
|
197
|
+
},
|
|
198
|
+
{"id": "body", "type": "tool", "config": {"tool_name": "test_echo", "inputs": {}}},
|
|
199
|
+
{
|
|
200
|
+
"id": "end",
|
|
201
|
+
"type": "python",
|
|
202
|
+
"config": {"code": "def main():\n return {'status': 'done'}"},
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
edges=[
|
|
206
|
+
{"source": "loop", "target": "body"},
|
|
207
|
+
{"source": "body", "target": "loop"},
|
|
208
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
209
|
+
],
|
|
210
|
+
)
|
|
211
|
+
ctx = FlowContext(variables={"tasks": []})
|
|
212
|
+
run(collect_events(flow, ctx))
|
|
213
|
+
|
|
214
|
+
loop_output = ctx.outputs["loop"]
|
|
215
|
+
assert loop_output["done"] is True
|
|
216
|
+
assert loop_output["results"] == []
|
|
217
|
+
# end 节点应该被执行
|
|
218
|
+
assert "end" in ctx.outputs
|
|
219
|
+
|
|
220
|
+
def test_max_iterations_truncates(self):
|
|
221
|
+
"""max_iterations 截断循环"""
|
|
222
|
+
flow = Flow(
|
|
223
|
+
flow_id="test",
|
|
224
|
+
name="iter_max",
|
|
225
|
+
nodes=[
|
|
226
|
+
{
|
|
227
|
+
"id": "loop",
|
|
228
|
+
"type": "iterator",
|
|
229
|
+
"config": {
|
|
230
|
+
"items": "$v.tasks",
|
|
231
|
+
"collect_to": "results",
|
|
232
|
+
"max_iterations": 2,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"id": "body",
|
|
237
|
+
"type": "tool",
|
|
238
|
+
"config": {"tool_name": "test_echo", "inputs": {"x": "$o.loop.current_item"}},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"id": "end",
|
|
242
|
+
"type": "python",
|
|
243
|
+
"config": {"code": "def main():\n return {'status': 'truncated'}"},
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
edges=[
|
|
247
|
+
{"source": "loop", "target": "body"},
|
|
248
|
+
{"source": "body", "target": "loop"},
|
|
249
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
250
|
+
],
|
|
251
|
+
)
|
|
252
|
+
ctx = FlowContext(variables={"tasks": ["a", "b", "c", "d", "e"]})
|
|
253
|
+
run(collect_events(flow, ctx))
|
|
254
|
+
|
|
255
|
+
loop_output = ctx.outputs["loop"]
|
|
256
|
+
assert loop_output["done"] is True
|
|
257
|
+
assert len(loop_output["results"]) == 2
|
|
258
|
+
|
|
259
|
+
def test_error_tolerance(self):
|
|
260
|
+
"""单项失败不中断循环,记录 error 后继续"""
|
|
261
|
+
flow = Flow(
|
|
262
|
+
flow_id="test",
|
|
263
|
+
name="iter_error",
|
|
264
|
+
nodes=[
|
|
265
|
+
{
|
|
266
|
+
"id": "loop",
|
|
267
|
+
"type": "iterator",
|
|
268
|
+
"config": {"items": "$v.tasks", "collect_to": "results"},
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"id": "body",
|
|
272
|
+
"type": "tool",
|
|
273
|
+
"config": {"tool_name": "test_fail", "inputs": {}},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"id": "end",
|
|
277
|
+
"type": "python",
|
|
278
|
+
"config": {"code": "def main():\n return {'status': 'done'}"},
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
edges=[
|
|
282
|
+
{"source": "loop", "target": "body"},
|
|
283
|
+
{"source": "body", "target": "loop"},
|
|
284
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
285
|
+
],
|
|
286
|
+
)
|
|
287
|
+
ctx = FlowContext(variables={"tasks": ["a", "b"]})
|
|
288
|
+
run(collect_events(flow, ctx))
|
|
289
|
+
|
|
290
|
+
loop_output = ctx.outputs["loop"]
|
|
291
|
+
assert loop_output["done"] is True
|
|
292
|
+
assert len(loop_output["results"]) == 2
|
|
293
|
+
# 每个结果都应该包含 error
|
|
294
|
+
for result in loop_output["results"]:
|
|
295
|
+
assert "error" in result
|
|
296
|
+
|
|
297
|
+
def test_custom_events_emitted(self):
|
|
298
|
+
"""验证 CUSTOM 事件发射"""
|
|
299
|
+
flow = Flow(
|
|
300
|
+
flow_id="test",
|
|
301
|
+
name="iter_events",
|
|
302
|
+
nodes=[
|
|
303
|
+
{
|
|
304
|
+
"id": "loop",
|
|
305
|
+
"type": "iterator",
|
|
306
|
+
"config": {
|
|
307
|
+
"items": "$v.tasks",
|
|
308
|
+
"collect_to": "results",
|
|
309
|
+
"events": {
|
|
310
|
+
"on_start": {"name": "loop_started", "value": {"total": "$count"}},
|
|
311
|
+
"on_item_start": {"name": "item_begin", "value": {"idx": "$index"}},
|
|
312
|
+
"on_item_end": {"name": "item_done", "value": {"idx": "$index"}},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"id": "body",
|
|
318
|
+
"type": "tool",
|
|
319
|
+
"config": {"tool_name": "test_echo", "inputs": {}},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"id": "end",
|
|
323
|
+
"type": "python",
|
|
324
|
+
"config": {"code": "def main():\n return {'status': 'ok'}"},
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
edges=[
|
|
328
|
+
{"source": "loop", "target": "body"},
|
|
329
|
+
{"source": "body", "target": "loop"},
|
|
330
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
331
|
+
],
|
|
332
|
+
)
|
|
333
|
+
ctx = FlowContext(variables={"tasks": ["x", "y"]})
|
|
334
|
+
events = run(collect_events(flow, ctx))
|
|
335
|
+
|
|
336
|
+
custom_events = [e for e in events if e.get("type") == "CUSTOM"]
|
|
337
|
+
custom_names = [e.get("name") for e in custom_events]
|
|
338
|
+
|
|
339
|
+
assert "loop_started" in custom_names
|
|
340
|
+
assert "item_begin" in custom_names
|
|
341
|
+
assert "item_done" in custom_names
|
|
342
|
+
|
|
343
|
+
def test_with_switch_routing(self):
|
|
344
|
+
"""与 switch 节点组合实现按类型分发"""
|
|
345
|
+
|
|
346
|
+
# 注册两个不同的 tool
|
|
347
|
+
def tool_upper(context, inputs):
|
|
348
|
+
return {"result": inputs.get("text", "").upper()}
|
|
349
|
+
|
|
350
|
+
def tool_lower(context, inputs):
|
|
351
|
+
return {"result": inputs.get("text", "").lower()}
|
|
352
|
+
|
|
353
|
+
registry.register_tool(
|
|
354
|
+
"test_upper",
|
|
355
|
+
Tool(name="test_upper", description="Uppercase", function=tool_upper),
|
|
356
|
+
)
|
|
357
|
+
registry.register_tool(
|
|
358
|
+
"test_lower",
|
|
359
|
+
Tool(name="test_lower", description="Lowercase", function=tool_lower),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
flow = Flow(
|
|
363
|
+
flow_id="test",
|
|
364
|
+
name="iter_switch",
|
|
365
|
+
nodes=[
|
|
366
|
+
{
|
|
367
|
+
"id": "loop",
|
|
368
|
+
"type": "iterator",
|
|
369
|
+
"config": {"items": "$v.tasks", "collect_to": "results"},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
"id": "route",
|
|
373
|
+
"type": "switch",
|
|
374
|
+
"config": {
|
|
375
|
+
"variable": "$o.loop.current_item.action",
|
|
376
|
+
"cases": {"upper": "upper", "lower": "lower"},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
"id": "do_upper",
|
|
381
|
+
"type": "tool",
|
|
382
|
+
"config": {
|
|
383
|
+
"tool_name": "test_upper",
|
|
384
|
+
"inputs": {"text": "$o.loop.current_item.text"},
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"id": "do_lower",
|
|
389
|
+
"type": "tool",
|
|
390
|
+
"config": {
|
|
391
|
+
"tool_name": "test_lower",
|
|
392
|
+
"inputs": {"text": "$o.loop.current_item.text"},
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
"id": "end",
|
|
397
|
+
"type": "python",
|
|
398
|
+
"config": {"code": "def main():\n return {'status': 'done'}"},
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
edges=[
|
|
402
|
+
{"source": "loop", "target": "route"},
|
|
403
|
+
{"source": "route", "target": "do_upper", "condition": "$o.route.choice == upper"},
|
|
404
|
+
{"source": "route", "target": "do_lower", "condition": "$o.route.choice == lower"},
|
|
405
|
+
{"source": "do_upper", "target": "loop"},
|
|
406
|
+
{"source": "do_lower", "target": "loop"},
|
|
407
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
408
|
+
],
|
|
409
|
+
)
|
|
410
|
+
ctx = FlowContext(
|
|
411
|
+
variables={
|
|
412
|
+
"tasks": [
|
|
413
|
+
{"action": "upper", "text": "hello"},
|
|
414
|
+
{"action": "lower", "text": "WORLD"},
|
|
415
|
+
{"action": "upper", "text": "foo"},
|
|
416
|
+
]
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
run(collect_events(flow, ctx))
|
|
420
|
+
|
|
421
|
+
loop_output = ctx.outputs["loop"]
|
|
422
|
+
assert loop_output["done"] is True
|
|
423
|
+
assert loop_output["results"] == [
|
|
424
|
+
{"result": "HELLO"},
|
|
425
|
+
{"result": "world"},
|
|
426
|
+
{"result": "FOO"},
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ── Agent instructions 注入测试 ──
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class TestAgentInstructionsInjection:
|
|
434
|
+
"""验证 Agent 节点 config.instructions 传递"""
|
|
435
|
+
|
|
436
|
+
def test_instructions_passthrough_via_kwargs(self):
|
|
437
|
+
"""instructions 字段通过 _create_agent kwargs 机制传递到 Agent"""
|
|
438
|
+
from agstack.llm.flow.agent import Agent
|
|
439
|
+
from agstack.llm.flow.nodes.agent_node import AgentNodeHandler
|
|
440
|
+
|
|
441
|
+
# 注册一个测试 agent
|
|
442
|
+
class TestAgent(Agent):
|
|
443
|
+
def __init__(self, **kwargs):
|
|
444
|
+
super().__init__(name="test_agent", **kwargs)
|
|
445
|
+
|
|
446
|
+
registry.register_agent("test_agent", TestAgent)
|
|
447
|
+
|
|
448
|
+
handler = AgentNodeHandler()
|
|
449
|
+
ctx = FlowContext(variables={"custom_prompt": "You are a custom assistant."})
|
|
450
|
+
config = {
|
|
451
|
+
"agent_name": "test_agent",
|
|
452
|
+
"instructions": "$v.custom_prompt",
|
|
453
|
+
"inputs": {"input": "hello"},
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
agent = handler._create_agent(config, ctx)
|
|
457
|
+
assert agent.instructions == "You are a custom assistant."
|
|
458
|
+
|
|
459
|
+
def test_instructions_literal_string(self):
|
|
460
|
+
"""instructions 字段为字面量字符串"""
|
|
461
|
+
from agstack.llm.flow.agent import Agent
|
|
462
|
+
from agstack.llm.flow.nodes.agent_node import AgentNodeHandler
|
|
463
|
+
|
|
464
|
+
class LiteralAgent(Agent):
|
|
465
|
+
def __init__(self, **kwargs):
|
|
466
|
+
super().__init__(name="literal_agent", **kwargs)
|
|
467
|
+
|
|
468
|
+
registry.register_agent("literal_agent", LiteralAgent)
|
|
469
|
+
|
|
470
|
+
handler = AgentNodeHandler()
|
|
471
|
+
ctx = FlowContext()
|
|
472
|
+
config = {
|
|
473
|
+
"agent_name": "literal_agent",
|
|
474
|
+
"instructions": "Be concise and direct.",
|
|
475
|
+
"inputs": {},
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
agent = handler._create_agent(config, ctx)
|
|
479
|
+
assert agent.instructions == "Be concise and direct."
|
|
480
|
+
|
|
481
|
+
def test_no_instructions_uses_default(self):
|
|
482
|
+
"""不提供 instructions 时使用 Agent 默认值"""
|
|
483
|
+
from agstack.llm.flow.agent import Agent
|
|
484
|
+
from agstack.llm.flow.nodes.agent_node import AgentNodeHandler
|
|
485
|
+
|
|
486
|
+
class DefaultAgent(Agent):
|
|
487
|
+
def __init__(self, **kwargs):
|
|
488
|
+
kwargs.setdefault("instructions", "I am the default.")
|
|
489
|
+
super().__init__(name="default_agent", **kwargs)
|
|
490
|
+
|
|
491
|
+
registry.register_agent("default_agent", DefaultAgent)
|
|
492
|
+
|
|
493
|
+
handler = AgentNodeHandler()
|
|
494
|
+
ctx = FlowContext()
|
|
495
|
+
config = {
|
|
496
|
+
"agent_name": "default_agent",
|
|
497
|
+
"inputs": {},
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
agent = handler._create_agent(config, ctx)
|
|
501
|
+
assert agent.instructions == "I am the default."
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|