agstack 1.18.0__tar.gz → 1.19.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 (77) hide show
  1. {agstack-1.18.0 → agstack-1.19.0}/PKG-INFO +1 -1
  2. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/__init__.py +5 -0
  3. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/agent.py +19 -6
  4. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/context.py +23 -1
  5. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/flow.py +143 -17
  6. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/agent_node.py +5 -0
  7. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/base.py +0 -5
  8. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/subflow_node.py +6 -2
  9. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/tool.py +40 -11
  10. agstack-1.19.0/agstack/llm/flow/trace.py +142 -0
  11. {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/PKG-INFO +1 -1
  12. {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/SOURCES.txt +1 -0
  13. {agstack-1.18.0 → agstack-1.19.0}/pyproject.toml +1 -1
  14. {agstack-1.18.0 → agstack-1.19.0}/LICENSE +0 -0
  15. {agstack-1.18.0 → agstack-1.19.0}/README.md +0 -0
  16. {agstack-1.18.0 → agstack-1.19.0}/agstack/__init__.py +0 -0
  17. {agstack-1.18.0 → agstack-1.19.0}/agstack/cache/__init__.py +0 -0
  18. {agstack-1.18.0 → agstack-1.19.0}/agstack/cache/base.py +0 -0
  19. {agstack-1.18.0 → agstack-1.19.0}/agstack/cache/memory.py +0 -0
  20. {agstack-1.18.0 → agstack-1.19.0}/agstack/cache/redis.py +0 -0
  21. {agstack-1.18.0 → agstack-1.19.0}/agstack/config/__init__.py +0 -0
  22. {agstack-1.18.0 → agstack-1.19.0}/agstack/config/logger.py +0 -0
  23. {agstack-1.18.0 → agstack-1.19.0}/agstack/config/manager.py +0 -0
  24. {agstack-1.18.0 → agstack-1.19.0}/agstack/config/types.py +0 -0
  25. {agstack-1.18.0 → agstack-1.19.0}/agstack/contexts.py +0 -0
  26. {agstack-1.18.0 → agstack-1.19.0}/agstack/decorators.py +0 -0
  27. {agstack-1.18.0 → agstack-1.19.0}/agstack/events.py +0 -0
  28. {agstack-1.18.0 → agstack-1.19.0}/agstack/exceptions.py +0 -0
  29. {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/__init__.py +0 -0
  30. {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/exception.py +0 -0
  31. {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/middleware.py +0 -0
  32. {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/offline.py +0 -0
  33. {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/sse.py +0 -0
  34. {agstack-1.18.0 → agstack-1.19.0}/agstack/infra/db/__init__.py +0 -0
  35. {agstack-1.18.0 → agstack-1.19.0}/agstack/infra/es/__init__.py +0 -0
  36. {agstack-1.18.0 → agstack-1.19.0}/agstack/infra/kg/__init__.py +0 -0
  37. {agstack-1.18.0 → agstack-1.19.0}/agstack/infra/mq/__init__.py +0 -0
  38. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/__init__.py +0 -0
  39. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/client.py +0 -0
  40. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/event.py +0 -0
  41. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/exceptions.py +0 -0
  42. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/factory.py +0 -0
  43. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/loader.py +0 -0
  44. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/__init__.py +0 -0
  45. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
  46. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
  47. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
  48. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
  49. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
  50. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/python_node.py +0 -0
  51. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
  52. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
  53. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/records.py +0 -0
  54. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/registry.py +0 -0
  55. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/sandbox.py +0 -0
  56. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/state.py +0 -0
  57. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/prompts.py +0 -0
  58. {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/token.py +0 -0
  59. {agstack-1.18.0 → agstack-1.19.0}/agstack/messagebus/__init__.py +0 -0
  60. {agstack-1.18.0 → agstack-1.19.0}/agstack/messagebus/base.py +0 -0
  61. {agstack-1.18.0 → agstack-1.19.0}/agstack/messagebus/memory.py +0 -0
  62. {agstack-1.18.0 → agstack-1.19.0}/agstack/messagebus/redis.py +0 -0
  63. {agstack-1.18.0 → agstack-1.19.0}/agstack/schema.py +0 -0
  64. {agstack-1.18.0 → agstack-1.19.0}/agstack/security/__init__.py +0 -0
  65. {agstack-1.18.0 → agstack-1.19.0}/agstack/security/casbin.py +0 -0
  66. {agstack-1.18.0 → agstack-1.19.0}/agstack/security/crypt.py +0 -0
  67. {agstack-1.18.0 → agstack-1.19.0}/agstack/status.py +0 -0
  68. {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/dependency_links.txt +0 -0
  69. {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/requires.txt +0 -0
  70. {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/top_level.txt +0 -0
  71. {agstack-1.18.0 → agstack-1.19.0}/setup.cfg +0 -0
  72. {agstack-1.18.0 → agstack-1.19.0}/tests/test_cache_memory.py +0 -0
  73. {agstack-1.18.0 → agstack-1.19.0}/tests/test_cache_redis.py +0 -0
  74. {agstack-1.18.0 → agstack-1.19.0}/tests/test_flow_io.py +0 -0
  75. {agstack-1.18.0 → agstack-1.19.0}/tests/test_flow_switch_subflow.py +0 -0
  76. {agstack-1.18.0 → agstack-1.19.0}/tests/test_messagebus_memory.py +0 -0
  77. {agstack-1.18.0 → agstack-1.19.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.18.0
3
+ Version: 1.19.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>
@@ -23,6 +23,7 @@ from .records import Record, Status
23
23
  from .registry import registry
24
24
  from .state import FlowState
25
25
  from .tool import Tool, ToolResult
26
+ from .trace import EdgeTrace, FlowTrace, NodeTrace
26
27
 
27
28
 
28
29
  register_node_handler = registry.register_node_handler
@@ -35,6 +36,10 @@ __all__ = [
35
36
  "Flow",
36
37
  "FlowContext",
37
38
  "Usage",
39
+ # 结构化执行轨迹
40
+ "FlowTrace",
41
+ "NodeTrace",
42
+ "EdgeTrace",
38
43
  # 节点处理器
39
44
  "NodeHandler",
40
45
  "register_node_handler",
@@ -264,17 +264,30 @@ class Agent:
264
264
  # 执行工具(传入 LLM 解析的参数作为 inputs)
265
265
  result = await tool.execute_async(context, tool_args)
266
266
 
267
- # 保存工具结果
268
- result_content = json.dumps(result.result) if result.success else json.dumps({"error": result.error})
267
+ # 使用 result.content 作为 LLM 上下文(Tool 已计算好)
268
+ result_content = result.content or (
269
+ json.dumps(result.result) if result.success else json.dumps({"error": result.error})
270
+ )
269
271
  context.add_message(self.name, "tool", content=result_content, tool_call_id=tool_call["id"])
270
272
 
271
273
  # AG-UI: TOOL_CALL_RESULT
272
274
  yield event.tool_call_result(tool_call_id=tool_call["id"], content=result_content)
273
275
 
274
- # 可观测性:yield tool 执行记录
275
- for record in context.pop_execution_records():
276
- record["tool_call_id"] = tool_call["id"]
277
- yield event.custom(name="tool_execution", value=record)
276
+ # 实时用户进度 — 有 summary 时告知前端
277
+ if result.summary:
278
+ yield event.custom(
279
+ name="tool_progress",
280
+ value={
281
+ "tool_call_id": tool_call["id"],
282
+ "tool_name": result.name,
283
+ "success": result.success,
284
+ "summary": result.summary,
285
+ },
286
+ )
287
+
288
+ # 业务自定义事件 — flush pending
289
+ for pending_evt in context.pop_pending_custom_events():
290
+ yield pending_evt
278
291
 
279
292
  # 更新消息列表,继续下一轮
280
293
  messages = [self.get_system_message()] + context.history + context.get_messages(self.name)
@@ -5,10 +5,14 @@
5
5
  import uuid
6
6
  from dataclasses import dataclass, field
7
7
  from datetime import datetime
8
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
9
9
  from uuid import UUID
10
10
 
11
11
 
12
+ if TYPE_CHECKING:
13
+ from .trace import FlowTrace
14
+
15
+
12
16
  @dataclass
13
17
  class Usage:
14
18
  """Token 使用统计"""
@@ -56,6 +60,18 @@ class FlowContext:
56
60
  # 执行记录(可选)
57
61
  execution_records: list[dict[str, Any]] = field(default_factory=list)
58
62
 
63
+ # 结构化执行轨迹
64
+ trace: "FlowTrace" = field(default=None, repr=False) # type: ignore[assignment]
65
+
66
+ # 业务自定义事件缓冲(tool 内部通过 context 注入,agent 循环中 flush)
67
+ pending_custom_events: list[dict[str, Any]] = field(default_factory=list)
68
+
69
+ def __post_init__(self) -> None:
70
+ if self.trace is None:
71
+ from .trace import FlowTrace
72
+
73
+ self.trace = FlowTrace()
74
+
59
75
  def get_variable(self, key: str, default: Any = None) -> Any:
60
76
  """获取变量值"""
61
77
  return self.variables.get(key, default)
@@ -157,3 +173,9 @@ class FlowContext:
157
173
  records = self.execution_records
158
174
  self.execution_records = []
159
175
  return records
176
+
177
+ def pop_pending_custom_events(self) -> list[dict[str, Any]]:
178
+ """取出并清空待发射的业务自定义事件"""
179
+ events = self.pending_custom_events
180
+ self.pending_custom_events = []
181
+ return events
@@ -3,6 +3,7 @@
3
3
  """Flow 定义和执行"""
4
4
 
5
5
  import asyncio
6
+ import time
6
7
  from dataclasses import dataclass, field
7
8
  from typing import TYPE_CHECKING, Any, AsyncIterator
8
9
  from uuid import uuid4
@@ -127,8 +128,27 @@ class Flow:
127
128
  cond = edge.get("condition")
128
129
  if cond is None:
129
130
  fallback_target = edge.get("target")
130
- elif not force_fallback and self._eval_condition(cond, context):
131
- return edge.get("target")
131
+ elif not force_fallback:
132
+ satisfied = self._eval_condition(cond, context)
133
+ context.trace.record_edge(
134
+ current_id,
135
+ edge.get("target"),
136
+ condition=cond,
137
+ condition_value=satisfied,
138
+ satisfied=satisfied,
139
+ )
140
+ if satisfied:
141
+ return edge.get("target")
142
+
143
+ # 记录最终选中的 fallback 边
144
+ if fallback_target is not None:
145
+ context.trace.record_edge(
146
+ current_id,
147
+ fallback_target,
148
+ condition=None,
149
+ condition_value=None,
150
+ satisfied=True,
151
+ )
132
152
  return fallback_target
133
153
 
134
154
  # ── message 节点 ──
@@ -349,16 +369,22 @@ class Flow:
349
369
 
350
370
  async def stream(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
351
371
  """流式执行 Flow(输出 AG-UI 标准事件)"""
372
+ context.trace.started_at = time.time()
352
373
  yield event.step_started(step_name=f"flow:{self.name}")
353
374
 
354
- if not self.edges:
355
- # 向后兼容:无 edges 时按 nodes 列表顺序执行(原有逻辑)
356
- async for evt in self._stream_sequential(context):
357
- yield evt
358
- else:
359
- # edge 驱动执行
360
- async for evt in self._stream_edge_driven(context):
361
- yield evt
375
+ try:
376
+ if not self.edges:
377
+ async for evt in self._stream_sequential(context):
378
+ yield evt
379
+ else:
380
+ async for evt in self._stream_edge_driven(context):
381
+ yield evt
382
+ except Exception as e:
383
+ context.trace.error = str(e)
384
+ raise
385
+ finally:
386
+ context.trace.finished_at = time.time()
387
+ context.trace.total_usage = context.usage
362
388
 
363
389
  yield event.step_finished(step_name=f"flow:{self.name}")
364
390
 
@@ -408,32 +434,73 @@ class Flow:
408
434
 
409
435
  if node_type == "message":
410
436
  msg_config = node.get("config", {})
437
+ context.trace.record_node_start(current_node_id, "message", inputs=msg_config)
438
+
439
+ # message 节点增加 STEP 事件
440
+ step_evt = event.step_started(step_name=f"message:{current_node_id}")
441
+ step_evt["_node_id"] = current_node_id
442
+ step_evt["_label"] = msg_config.get("label")
443
+ step_evt["_echo"] = msg_config.get("echo", True)
444
+ yield step_evt
445
+
411
446
  async for evt in self._emit_message(node, context):
412
447
  evt["_node_id"] = current_node_id
413
448
  evt["_label"] = msg_config.get("label")
414
449
  evt["_echo"] = msg_config.get("echo", True)
415
450
  yield evt
451
+
452
+ # 存储 message 输出
453
+ template = msg_config.get("content", "")
454
+ text = template.format_map(_SafeFormatDict(context.variables))
455
+ context.set_output(current_node_id, {"result": text})
456
+
457
+ fin_evt = event.step_finished(step_name=f"message:{current_node_id}")
458
+ fin_evt["_node_id"] = current_node_id
459
+ fin_evt["_label"] = msg_config.get("label")
460
+ fin_evt["_echo"] = msg_config.get("echo", True)
461
+ yield fin_evt
462
+
463
+ context.trace.record_node_end(current_node_id, outputs={"result": text})
416
464
  current_node_id = self._resolve_next_node(current_node_id, context)
417
465
 
418
466
  elif node_type == "parallel":
419
467
  config = node.get("config", {})
420
468
  branches = config.get("branches", [])
469
+
470
+ context.trace.record_node_start(current_node_id, "parallel", inputs=config)
471
+
421
472
  step_evt = event.step_started(step_name=f"parallel:{current_node_id}")
422
473
  step_evt["_node_id"] = current_node_id
423
474
  step_evt["_label"] = None
424
475
  step_evt["_echo"] = False
425
476
  yield step_evt
426
477
 
427
- async def _exec_branch(branch_id: str) -> None:
478
+ parallel_qid = context.trace._qualify_id(current_node_id)
479
+
480
+ async def _exec_branch(branch_id: str, _parent_qid: str = parallel_qid) -> None:
428
481
  branch_node = self.get_node_config(branch_id)
429
482
  if not branch_node:
430
483
  return
431
- context.current_node = branch_id
432
484
  branch_type = branch_node.get("type", "")
485
+ branch_config = branch_node.get("config", {})
433
486
  branch_handler = self._node_handlers.get(branch_type)
434
- if branch_handler:
487
+ if not branch_handler:
488
+ return
489
+
490
+ context.trace.record_node_start(
491
+ branch_id,
492
+ branch_type,
493
+ inputs=branch_config.get("inputs", {}),
494
+ parent_id=_parent_qid,
495
+ )
496
+ context.current_node = branch_id
497
+ try:
435
498
  result = await branch_handler.execute(branch_node, context)
436
499
  context.set_output(branch_id, result)
500
+ context.trace.record_node_end(branch_id, outputs=result)
501
+ except Exception as e:
502
+ context.trace.record_node_end(branch_id, error=str(e))
503
+ raise
437
504
 
438
505
  await asyncio.gather(*[_exec_branch(bid) for bid in branches])
439
506
  merged: dict[str, Any] = {}
@@ -442,11 +509,14 @@ class Flow:
442
509
  if isinstance(branch_result, dict):
443
510
  merged.update(branch_result)
444
511
  context.set_output(current_node_id, merged)
512
+
445
513
  fin_evt = event.step_finished(step_name=f"parallel:{current_node_id}")
446
514
  fin_evt["_node_id"] = current_node_id
447
515
  fin_evt["_label"] = None
448
516
  fin_evt["_echo"] = False
449
517
  yield fin_evt
518
+
519
+ context.trace.record_node_end(current_node_id, outputs=merged)
450
520
  current_node_id = self._resolve_next_node(current_node_id, context)
451
521
 
452
522
  elif node_type == "iteration":
@@ -461,11 +531,14 @@ class Flow:
461
531
  body_nodes: list[str] = config.get("body", [])
462
532
  results: list[Any] = []
463
533
 
534
+ context.trace.record_node_start(current_node_id, "iteration", inputs=config)
535
+
464
536
  step_evt = event.step_started(step_name=f"iteration:{current_node_id}")
465
537
  step_evt["_node_id"] = current_node_id
466
538
  step_evt["_label"] = None
467
539
  step_evt["_echo"] = False
468
540
  yield step_evt
541
+
469
542
  for idx, item in enumerate(items):
470
543
  context.set_variable(item_var, item)
471
544
  context.set_variable(index_var, idx)
@@ -474,24 +547,77 @@ class Flow:
474
547
  if not body_node:
475
548
  continue
476
549
  body_type = body_node.get("type", "")
550
+ body_config = body_node.get("config", {})
477
551
  body_handler = self._node_handlers.get(body_type)
478
- if body_handler:
479
- body_result = await body_handler.execute(body_node, context)
480
- context.set_output(body_node_id, body_result)
552
+ if not body_handler:
553
+ continue
554
+
555
+ context.trace.record_node_start(
556
+ body_node_id,
557
+ body_type,
558
+ inputs=body_config.get("inputs", {}),
559
+ parent_id=context.trace._qualify_id(current_node_id),
560
+ iteration_index=idx,
561
+ )
562
+ body_result = await body_handler.execute(body_node, context)
563
+ context.set_output(body_node_id, body_result)
564
+ # 收集 body 节点产生的 execution_records
565
+ body_tool_calls = context.pop_execution_records()
566
+ context.trace.record_node_end(
567
+ body_node_id,
568
+ outputs=body_result,
569
+ tool_calls=body_tool_calls if body_tool_calls else None,
570
+ )
481
571
  if body_nodes:
482
572
  results.append(context.outputs.get(body_nodes[-1]))
483
573
 
484
- context.set_output(current_node_id, {"results": results})
574
+ iteration_output = {"results": results}
575
+ context.set_output(current_node_id, iteration_output)
576
+
485
577
  fin_evt = event.step_finished(step_name=f"iteration:{current_node_id}")
486
578
  fin_evt["_node_id"] = current_node_id
487
579
  fin_evt["_label"] = None
488
580
  fin_evt["_echo"] = False
489
581
  yield fin_evt
582
+
583
+ context.trace.record_node_end(current_node_id, outputs=iteration_output)
490
584
  current_node_id = self._resolve_next_node(current_node_id, context)
491
585
 
492
586
  elif node_type in self._node_handlers:
587
+ # 获取 resolved inputs 用于 trace
588
+ config = node.get("config", {})
589
+ handler = self._node_handlers[node_type]
590
+ resolved_inputs = handler.resolve_inputs(config, context)
591
+
592
+ context.trace.record_node_start(
593
+ current_node_id,
594
+ node_type,
595
+ inputs=resolved_inputs,
596
+ label=config.get("label"),
597
+ )
598
+
493
599
  async for evt in self._execute_node_with_retry(node, context, current_node_id):
494
600
  yield evt
601
+
602
+ # 收集 agent 节点存放的 tool_calls 或通用 execution_records
603
+ tool_calls = context.get_variable("_last_node_tool_calls")
604
+ if tool_calls is None:
605
+ tool_calls = context.pop_execution_records() or None
606
+ else:
607
+ context.set_variable("_last_node_tool_calls", None)
608
+
609
+ # 获取可选的 messages(agent 节点)
610
+ messages = None
611
+ if node_type == "agent" and context.get_variable("_capture_messages"):
612
+ agent_name = config.get("agent_name", "")
613
+ messages = context.get_messages(agent_name) if agent_name else None
614
+
615
+ context.trace.record_node_end(
616
+ current_node_id,
617
+ outputs=context.outputs.get(current_node_id),
618
+ tool_calls=tool_calls if tool_calls else None,
619
+ messages=messages,
620
+ )
495
621
  current_node_id = self._resolve_next_node(current_node_id, context)
496
622
 
497
623
  else:
@@ -58,4 +58,9 @@ class AgentNodeHandler(NodeHandler):
58
58
  else:
59
59
  result = context.get_last_output(ag.name) or ""
60
60
  context.set_output(node_id, {"result": result})
61
+
62
+ # 一次性收集累积的 tool 执行记录,供 flow 引擎存入 NodeTrace
63
+ tool_calls = context.pop_execution_records()
64
+ context.set_variable("_last_node_tool_calls", tool_calls)
65
+
61
66
  yield event.step_finished(step_name=step_name)
@@ -48,9 +48,4 @@ class NodeHandler:
48
48
  yield event.step_started(step_name=step_name)
49
49
  result = await self.execute(node, context)
50
50
  context.set_output(node_id, result)
51
-
52
- # 可观测性:drain tool 执行记录
53
- for record in context.pop_execution_records():
54
- yield event.custom(name="tool_execution", value=record)
55
-
56
51
  yield event.step_finished(step_name=step_name)
@@ -67,8 +67,12 @@ class SubflowNodeHandler(NodeHandler):
67
67
  self._resolve_and_apply_inputs(config, context)
68
68
  sub_flow = self._load_subflow(config)
69
69
 
70
- async for evt in sub_flow.stream(context):
71
- yield evt
70
+ context.trace.push_namespace(node_id)
71
+ try:
72
+ async for evt in sub_flow.stream(context):
73
+ yield evt
74
+ finally:
75
+ context.trace.pop_namespace()
72
76
 
73
77
  result = self._get_last_node_output(sub_flow, context)
74
78
  context.set_output(node_id, result)
@@ -24,6 +24,8 @@ class ToolResult:
24
24
  result: dict[str, Any]
25
25
  success: bool
26
26
  error: str | None = None
27
+ content: str | None = None
28
+ summary: str | None = None
27
29
 
28
30
 
29
31
  class Tool:
@@ -38,6 +40,9 @@ class Tool:
38
40
  *,
39
41
  label: str | None = None,
40
42
  echo: bool = False,
43
+ category: str | None = None,
44
+ summary_fn: Callable[["ToolResult"], str | None] | None = None,
45
+ result_formatter: Callable[["ToolResult"], str] | None = None,
41
46
  ):
42
47
  """初始化工具
43
48
 
@@ -47,6 +52,9 @@ class Tool:
47
52
  :param parameters: JSON Schema 参数定义(用于 LLM 调用)
48
53
  :param label: 面向用户的展示名称(控制 STEP/TOOL_CALL 进度事件可见性)
49
54
  :param echo: 是否转发 TEXT_MESSAGE 给用户
55
+ :param category: 工具分类(retrieval / analysis / action / utility)
56
+ :param summary_fn: 生成面向用户摘要的函数 (ToolResult) -> str | None
57
+ :param result_formatter: 自定义 LLM 内容格式化函数 (ToolResult) -> str
50
58
  """
51
59
  self.name = name
52
60
  self.description = description
@@ -54,24 +62,45 @@ class Tool:
54
62
  self.parameters = parameters or {"type": "object", "properties": {}, "required": []}
55
63
  self.label = label
56
64
  self.echo = echo
65
+ self.category = category
66
+ self.summary_fn = summary_fn
67
+ self.result_formatter = result_formatter
57
68
 
58
69
  async def execute_async(self, context: "FlowContext", inputs: dict[str, Any] | None = None) -> ToolResult:
59
- """异步执行工具(包含计时和可观测性记录)"""
70
+ """异步执行工具(包含计时、摘要生成、结果格式化、可观测性记录)"""
60
71
  args = inputs or {}
61
72
  _t0 = time.perf_counter()
62
73
  result = await self._execute(context, args)
63
74
  _duration_ms = int((time.perf_counter() - _t0) * 1000)
64
75
 
65
- result_content = json.dumps(result.result) if result.success else json.dumps({"error": result.error})
66
- context.execution_records.append({
67
- "agent_call_id": context.get_variable("_agent_call_id"),
68
- "tool_name": self.name,
69
- "tool_args": args,
70
- "success": result.success,
71
- "result": result_content,
72
- "error": result.error,
73
- "duration_ms": _duration_ms,
74
- })
76
+ # 计算 LLM 消费内容
77
+ if self.result_formatter:
78
+ try:
79
+ result.content = self.result_formatter(result)
80
+ except Exception:
81
+ result.content = json.dumps(result.result) if result.success else json.dumps({"error": result.error})
82
+ else:
83
+ result.content = json.dumps(result.result) if result.success else json.dumps({"error": result.error})
84
+
85
+ # 生成面向用户的摘要
86
+ if self.summary_fn:
87
+ try:
88
+ result.summary = self.summary_fn(result)
89
+ except Exception:
90
+ result.summary = None
91
+
92
+ context.execution_records.append(
93
+ {
94
+ "agent_call_id": context.get_variable("_agent_call_id"),
95
+ "tool_name": self.name,
96
+ "tool_args": args,
97
+ "success": result.success,
98
+ "result": result.content,
99
+ "error": result.error,
100
+ "duration_ms": _duration_ms,
101
+ "summary": result.summary,
102
+ }
103
+ )
75
104
 
76
105
  return result
77
106
 
@@ -0,0 +1,142 @@
1
+ # Copyright (c) 2020-2026 XtraVisions, All rights reserved.
2
+
3
+ """结构化执行轨迹 — 与事件流独立,flow 执行完成后一次性可用"""
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from .context import Usage
10
+
11
+
12
+ @dataclass
13
+ class NodeTrace:
14
+ """单次节点执行记录"""
15
+
16
+ node_id: str
17
+ node_type: str
18
+ label: str | None = None
19
+
20
+ inputs: dict[str, Any] = field(default_factory=dict)
21
+ outputs: Any = None
22
+ error: str | None = None
23
+
24
+ started_at: float = field(default_factory=time.time)
25
+ finished_at: float | None = None
26
+ duration_ms: int | None = None
27
+
28
+ usage: Usage | None = None
29
+
30
+ parent_id: str | None = None
31
+ iteration_index: int | None = None
32
+ execution_index: int = 0
33
+
34
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
35
+ messages: list[dict[str, Any]] | None = None
36
+
37
+
38
+ @dataclass
39
+ class EdgeTrace:
40
+ """实际遍历的边"""
41
+
42
+ source: str
43
+ target: str | None = None
44
+ condition: str | None = None
45
+ condition_value: Any = None
46
+ satisfied: bool = True
47
+ timestamp: float = field(default_factory=time.time)
48
+
49
+
50
+ @dataclass
51
+ class FlowTrace:
52
+ """完整执行轨迹"""
53
+
54
+ nodes: list[NodeTrace] = field(default_factory=list)
55
+ edges: list[EdgeTrace] = field(default_factory=list)
56
+ total_usage: Usage = field(default_factory=Usage)
57
+ started_at: float | None = None
58
+ finished_at: float | None = None
59
+ error: str | None = None
60
+
61
+ _namespace_stack: list[str] = field(default_factory=list, repr=False)
62
+ _active_nodes: dict[str, NodeTrace] = field(default_factory=dict, repr=False)
63
+ _execution_counter: int = field(default=0, repr=False)
64
+
65
+ def push_namespace(self, parent_id: str) -> None:
66
+ self._namespace_stack.append(parent_id)
67
+
68
+ def pop_namespace(self) -> str:
69
+ return self._namespace_stack.pop()
70
+
71
+ def _qualify_id(self, node_id: str) -> str:
72
+ if self._namespace_stack:
73
+ return "::".join(self._namespace_stack) + "::" + node_id
74
+ return node_id
75
+
76
+ def record_node_start(
77
+ self,
78
+ node_id: str,
79
+ node_type: str,
80
+ inputs: dict[str, Any] | None = None,
81
+ *,
82
+ label: str | None = None,
83
+ parent_id: str | None = None,
84
+ iteration_index: int | None = None,
85
+ ) -> NodeTrace:
86
+ qid = self._qualify_id(node_id)
87
+ self._execution_counter += 1
88
+ trace = NodeTrace(
89
+ node_id=qid,
90
+ node_type=node_type,
91
+ label=label,
92
+ inputs=inputs or {},
93
+ parent_id=parent_id or (self._namespace_stack[-1] if self._namespace_stack else None),
94
+ iteration_index=iteration_index,
95
+ execution_index=self._execution_counter,
96
+ )
97
+ self.nodes.append(trace)
98
+ self._active_nodes[qid] = trace
99
+ return trace
100
+
101
+ def record_node_end(
102
+ self,
103
+ node_id: str,
104
+ outputs: Any = None,
105
+ *,
106
+ error: str | None = None,
107
+ usage: Usage | None = None,
108
+ tool_calls: list[dict[str, Any]] | None = None,
109
+ messages: list[dict[str, Any]] | None = None,
110
+ ) -> None:
111
+ qid = self._qualify_id(node_id)
112
+ trace = self._active_nodes.pop(qid, None)
113
+ if trace is None:
114
+ return
115
+ trace.outputs = outputs
116
+ trace.error = error
117
+ trace.finished_at = time.time()
118
+ trace.duration_ms = int((trace.finished_at - trace.started_at) * 1000)
119
+ trace.usage = usage
120
+ if tool_calls:
121
+ trace.tool_calls = tool_calls
122
+ if messages is not None:
123
+ trace.messages = messages
124
+
125
+ def record_edge(
126
+ self,
127
+ source: str,
128
+ target: str | None,
129
+ *,
130
+ condition: str | None = None,
131
+ condition_value: Any = None,
132
+ satisfied: bool = True,
133
+ ) -> None:
134
+ self.edges.append(
135
+ EdgeTrace(
136
+ source=self._qualify_id(source),
137
+ target=self._qualify_id(target) if target else None,
138
+ condition=condition,
139
+ condition_value=condition_value,
140
+ satisfied=satisfied,
141
+ )
142
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.18.0
3
+ Version: 1.19.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>
@@ -47,6 +47,7 @@ agstack/llm/flow/registry.py
47
47
  agstack/llm/flow/sandbox.py
48
48
  agstack/llm/flow/state.py
49
49
  agstack/llm/flow/tool.py
50
+ agstack/llm/flow/trace.py
50
51
  agstack/llm/flow/nodes/__init__.py
51
52
  agstack/llm/flow/nodes/agent_node.py
52
53
  agstack/llm/flow/nodes/base.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agstack"
3
- version = "1.18.0"
3
+ version = "1.19.0"
4
4
  description = "Production-ready toolkit for building FastAPI and LLM applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
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