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.
- {agstack-1.18.0 → agstack-1.19.0}/PKG-INFO +1 -1
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/__init__.py +5 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/agent.py +19 -6
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/context.py +23 -1
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/flow.py +143 -17
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/agent_node.py +5 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/base.py +0 -5
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/subflow_node.py +6 -2
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/tool.py +40 -11
- agstack-1.19.0/agstack/llm/flow/trace.py +142 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/PKG-INFO +1 -1
- {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/SOURCES.txt +1 -0
- {agstack-1.18.0 → agstack-1.19.0}/pyproject.toml +1 -1
- {agstack-1.18.0 → agstack-1.19.0}/LICENSE +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/README.md +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/cache/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/cache/base.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/cache/memory.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/cache/redis.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/config/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/config/logger.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/config/manager.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/config/types.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/contexts.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/decorators.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/events.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/exceptions.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/exception.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/middleware.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/offline.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/fastapi/sse.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/infra/db/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/infra/es/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/infra/kg/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/infra/mq/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/client.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/event.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/factory.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/loader.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/records.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/registry.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/flow/state.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/prompts.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/llm/token.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/messagebus/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/messagebus/base.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/messagebus/memory.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/messagebus/redis.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/schema.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/security/__init__.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/security/casbin.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/security/crypt.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack/status.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/requires.txt +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/agstack.egg-info/top_level.txt +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/setup.cfg +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/tests/test_cache_memory.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/tests/test_cache_redis.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/tests/test_flow_io.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/tests/test_flow_switch_subflow.py +0 -0
- {agstack-1.18.0 → agstack-1.19.0}/tests/test_messagebus_memory.py +0 -0
- {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.
|
|
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 =
|
|
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
|
-
#
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
131
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"error": result.error
|
|
73
|
-
|
|
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.
|
|
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>
|
|
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
|