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.
Files changed (79) hide show
  1. {agstack-1.22.0 → agstack-1.23.0}/PKG-INFO +1 -1
  2. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/event.py +4 -4
  3. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/flow.py +55 -15
  4. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/__init__.py +2 -0
  5. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/agent_node.py +4 -2
  6. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/base.py +4 -2
  7. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/echo_node.py +3 -2
  8. agstack-1.23.0/agstack/llm/flow/nodes/iterator_node.py +172 -0
  9. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/llm_chat_node.py +6 -4
  10. {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/PKG-INFO +1 -1
  11. {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/SOURCES.txt +2 -0
  12. {agstack-1.22.0 → agstack-1.23.0}/pyproject.toml +1 -1
  13. agstack-1.23.0/tests/test_flow_iterator.py +501 -0
  14. {agstack-1.22.0 → agstack-1.23.0}/LICENSE +0 -0
  15. {agstack-1.22.0 → agstack-1.23.0}/README.md +0 -0
  16. {agstack-1.22.0 → agstack-1.23.0}/agstack/__init__.py +0 -0
  17. {agstack-1.22.0 → agstack-1.23.0}/agstack/cache/__init__.py +0 -0
  18. {agstack-1.22.0 → agstack-1.23.0}/agstack/cache/base.py +0 -0
  19. {agstack-1.22.0 → agstack-1.23.0}/agstack/cache/memory.py +0 -0
  20. {agstack-1.22.0 → agstack-1.23.0}/agstack/cache/redis.py +0 -0
  21. {agstack-1.22.0 → agstack-1.23.0}/agstack/config/__init__.py +0 -0
  22. {agstack-1.22.0 → agstack-1.23.0}/agstack/config/logger.py +0 -0
  23. {agstack-1.22.0 → agstack-1.23.0}/agstack/config/manager.py +0 -0
  24. {agstack-1.22.0 → agstack-1.23.0}/agstack/config/types.py +0 -0
  25. {agstack-1.22.0 → agstack-1.23.0}/agstack/contexts.py +0 -0
  26. {agstack-1.22.0 → agstack-1.23.0}/agstack/decorators.py +0 -0
  27. {agstack-1.22.0 → agstack-1.23.0}/agstack/events.py +0 -0
  28. {agstack-1.22.0 → agstack-1.23.0}/agstack/exceptions.py +0 -0
  29. {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/__init__.py +0 -0
  30. {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/exception.py +0 -0
  31. {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/middleware.py +0 -0
  32. {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/offline.py +0 -0
  33. {agstack-1.22.0 → agstack-1.23.0}/agstack/fastapi/sse.py +0 -0
  34. {agstack-1.22.0 → agstack-1.23.0}/agstack/infra/db/__init__.py +0 -0
  35. {agstack-1.22.0 → agstack-1.23.0}/agstack/infra/es/__init__.py +0 -0
  36. {agstack-1.22.0 → agstack-1.23.0}/agstack/infra/kg/__init__.py +0 -0
  37. {agstack-1.22.0 → agstack-1.23.0}/agstack/infra/mq/__init__.py +0 -0
  38. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/__init__.py +0 -0
  39. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/client.py +0 -0
  40. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/__init__.py +0 -0
  41. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/agent.py +0 -0
  42. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/context.py +0 -0
  43. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/exceptions.py +0 -0
  44. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/factory.py +0 -0
  45. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/loader.py +0 -0
  46. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
  47. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
  48. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
  49. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/python_node.py +0 -0
  50. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
  51. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
  52. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
  53. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/records.py +0 -0
  54. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/registry.py +0 -0
  55. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/sandbox.py +0 -0
  56. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/state.py +0 -0
  57. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/tool.py +0 -0
  58. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/flow/trace.py +0 -0
  59. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/prompts.py +0 -0
  60. {agstack-1.22.0 → agstack-1.23.0}/agstack/llm/token.py +0 -0
  61. {agstack-1.22.0 → agstack-1.23.0}/agstack/messagebus/__init__.py +0 -0
  62. {agstack-1.22.0 → agstack-1.23.0}/agstack/messagebus/base.py +0 -0
  63. {agstack-1.22.0 → agstack-1.23.0}/agstack/messagebus/memory.py +0 -0
  64. {agstack-1.22.0 → agstack-1.23.0}/agstack/messagebus/redis.py +0 -0
  65. {agstack-1.22.0 → agstack-1.23.0}/agstack/schema.py +0 -0
  66. {agstack-1.22.0 → agstack-1.23.0}/agstack/security/__init__.py +0 -0
  67. {agstack-1.22.0 → agstack-1.23.0}/agstack/security/casbin.py +0 -0
  68. {agstack-1.22.0 → agstack-1.23.0}/agstack/security/crypt.py +0 -0
  69. {agstack-1.22.0 → agstack-1.23.0}/agstack/status.py +0 -0
  70. {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/dependency_links.txt +0 -0
  71. {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/requires.txt +0 -0
  72. {agstack-1.22.0 → agstack-1.23.0}/agstack.egg-info/top_level.txt +0 -0
  73. {agstack-1.22.0 → agstack-1.23.0}/setup.cfg +0 -0
  74. {agstack-1.22.0 → agstack-1.23.0}/tests/test_cache_memory.py +0 -0
  75. {agstack-1.22.0 → agstack-1.23.0}/tests/test_cache_redis.py +0 -0
  76. {agstack-1.22.0 → agstack-1.23.0}/tests/test_flow_io.py +0 -0
  77. {agstack-1.22.0 → agstack-1.23.0}/tests/test_flow_switch_subflow.py +0 -0
  78. {agstack-1.22.0 → agstack-1.23.0}/tests/test_messagebus_memory.py +0 -0
  79. {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.22.0
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
- result = await handler.execute(node, context)
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
- yield event.step_started(step_name=f"flow:{self.name}")
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
- step_evt = event.step_started(step_name=f"message:{current_node_id}")
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
- step_evt = event.step_started(step_name=f"parallel:{current_node_id}")
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
- parallel_qid = context.trace._qualify_id(current_node_id)
504
+ _pqid: str = context.trace._qualify_id(current_node_id)
479
505
 
480
- async def _exec_branch(branch_id: str, _parent_qid: str = parallel_qid) -> None:
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=_parent_qid,
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
- step_evt = event.step_started(step_name=f"iteration:{current_node_id}")
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
- async for evt in self._execute_node_with_retry(node, context, current_node_id):
600
- yield evt
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
- yield event.step_started(step_name=step_name)
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
- yield event.step_started(step_name=step_name)
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
- yield event.step_started(step_name=step_name)
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.22.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agstack"
3
- version = "1.22.0"
3
+ version = "1.23.0"
4
4
  description = "Production-ready toolkit for building FastAPI and LLM applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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