mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:11:58 +00:00
e46a3f0d62
运行 87daca70:一个计时器,外层每一拍里内层跑两个 50ms 的子拍。5 秒该推进 50 拍, 结果推进了 138 拍。循环节点走 done 出口时计数原样留着,外层下一轮再把它带进来,就从 上次停下的地方接着往上数:内层 max_iterations 是 5,第一、二拍各跑 2 个子拍,第三拍 1 个,从第四拍起一进来就撞上限直接退出,子拍一次都不跑(撞上限的 warn 136 条)。 嵌套的 foreach 更糟:外层第二轮时内层游标已经在末尾,内层一项都不再跑,也不报错, 结果只是悄悄少了一大截。 改法:走 done 时记 finished,再被带进来时从 0 数。index 仍停在最后一轮,结束后读 loops.x.index 的照旧拿到轮数;画布上内层的"第 N/上限 轮"也随之每轮重新数起。 测试:test_nested_loops 两条——嵌套 while 合计 6 次(修复前 3 次)、嵌套 foreach 走完 3×2 项(修复前只有 1a、1b)。用 87daca70 的原图在临时库里真沙箱重跑:内层每拍都是 "跑、跑、结束",撞上限 0 次。后端 473 passed ×2。
191 lines
7.1 KiB
Python
191 lines
7.1 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
from app.core.events import EventType
|
||
from app.db.base import SessionLocal
|
||
from app.engine.context import NodeContext, NodeError
|
||
from app.engine.expressions import ExpressionError, eval_condition
|
||
from app.engine.state import GraphState, message_text, template_context
|
||
from app.providers.factory import ModelSpec, get_chat_model
|
||
|
||
# 分支和循环的"决定"写在 nodes[node_id] 里,编译器的路由函数再读出来。
|
||
# 路由函数本身不能有副作用,所以判断必须发生在节点执行阶段。
|
||
DECISION_KEY = "__decision__"
|
||
|
||
|
||
async def run_branch(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||
"""条件分支。两种模式:表达式判断,或让模型做语义分类。"""
|
||
cases = ctx.cfg("cases", []) or []
|
||
if not cases:
|
||
raise NodeError(ctx.node.id, "分支节点没有配置任何条件")
|
||
|
||
mode = ctx.cfg("mode", "expression")
|
||
tctx = template_context(state)
|
||
chosen = "default"
|
||
reason = ""
|
||
|
||
if mode == "llm":
|
||
chosen, reason = await _classify_with_llm(state, ctx, cases)
|
||
else:
|
||
for case in cases:
|
||
expr = case.get("condition", "")
|
||
key = case.get("key") or case.get("label") or "case"
|
||
if not expr:
|
||
continue
|
||
try:
|
||
if eval_condition(expr, tctx):
|
||
chosen, reason = key, f"命中条件:{expr}"
|
||
break
|
||
except ExpressionError as e:
|
||
raise NodeError(ctx.node.id, f"分支条件写错了({expr}):{e}") from e
|
||
else:
|
||
reason = "所有条件都不满足,走 default"
|
||
|
||
ctx.emit(EventType.EDGE_TAKEN, branch=chosen, reason=reason, mode=mode)
|
||
return {
|
||
"nodes": {ctx.node.id: {DECISION_KEY: chosen, "branch": chosen, "reason": reason}},
|
||
}
|
||
|
||
|
||
async def _classify_with_llm(
|
||
state: GraphState, ctx: NodeContext, cases: list[dict[str, Any]]
|
||
) -> tuple[str, str]:
|
||
"""让模型在给定选项里挑一个。用结构化输出保证它只会吐出合法的 key。"""
|
||
options = [
|
||
{"key": c.get("key") or f"case{i}", "描述": c.get("label") or c.get("condition") or ""}
|
||
for i, c in enumerate(cases)
|
||
]
|
||
keys = [o["key"] for o in options] + ["default"]
|
||
subject = ctx.render_str(ctx.cfg("input", "{{ last_message }}"), state)
|
||
instruction = ctx.render_str(ctx.cfg("instruction", ""), state)
|
||
|
||
prompt = (
|
||
f"{instruction}\n\n" if instruction else ""
|
||
) + (
|
||
"请把下面的内容归入最合适的一类。\n\n"
|
||
f"可选类别:\n{json.dumps(options, ensure_ascii=False, indent=2)}\n\n"
|
||
f"内容:\n{subject}\n\n"
|
||
"只返回 JSON:{\"key\": <类别key>, \"reason\": <一句话理由>}"
|
||
)
|
||
|
||
async with SessionLocal() as session:
|
||
model, _ = await get_chat_model(
|
||
session,
|
||
ModelSpec(
|
||
provider=ctx.cfg("provider"),
|
||
model=ctx.cfg("model"),
|
||
max_tokens=512,
|
||
),
|
||
)
|
||
|
||
schema = {
|
||
"title": "classify", # langchain 靠 title 识别 JSON Schema dict
|
||
"type": "object",
|
||
"properties": {
|
||
"key": {"type": "string", "enum": keys},
|
||
"reason": {"type": "string"},
|
||
},
|
||
"required": ["key"],
|
||
}
|
||
try:
|
||
value = await model.with_structured_output(schema).ainvoke(prompt)
|
||
if not isinstance(value, dict):
|
||
value = json.loads(message_text(value))
|
||
except Exception: # noqa: BLE001 - 结构化不支持就退回自由文本再解析
|
||
raw = message_text(await model.ainvoke(prompt))
|
||
value = {}
|
||
for key in keys:
|
||
if key in raw:
|
||
value = {"key": key, "reason": raw[:200]}
|
||
break
|
||
|
||
chosen = str(value.get("key") or "default")
|
||
if chosen not in keys:
|
||
chosen = "default"
|
||
return chosen, str(value.get("reason", ""))
|
||
|
||
|
||
async def run_loop(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||
"""循环控制。
|
||
|
||
画布上的接法:loop 节点的 `body` 出口连向循环体,循环体最后一个节点再连回 loop 节点;
|
||
`done` 出口连向循环结束后要走的分支。每进一次这个节点就推进一次计数。
|
||
"""
|
||
mode = ctx.cfg("mode", "foreach")
|
||
node_id = ctx.node.id
|
||
loops = dict(state.get("loops") or {})
|
||
prev = loops.get(node_id) if isinstance(loops.get(node_id), dict) else {}
|
||
# 上次已经走过 done 出口还又进来:是外层循环的下一轮把它重新带进来,从头数。
|
||
# 以前计数原样留着接着往上加,内层的 max_iterations 于是成了整次运行合计的
|
||
# 上限——运行 87daca70 的内层每拍该跑两个子拍,第四拍起一进来就撞上限直接
|
||
# 退出;嵌套的 foreach 更糟,外层第二轮起内层一项都不跑,也不报错
|
||
cursor = 0 if prev.get("finished") else int(prev.get("index", 0))
|
||
max_iter = int(ctx.cfg("max_iterations", 10) or 10)
|
||
|
||
tctx = template_context(state)
|
||
item_var = ctx.cfg("item_var", "item")
|
||
updates_vars: dict[str, Any] = {}
|
||
keep_going = False
|
||
current_item: Any = None
|
||
total = None
|
||
|
||
if mode == "foreach":
|
||
items = ctx.render(ctx.cfg("items", ""), state)
|
||
if isinstance(items, str):
|
||
try:
|
||
items = json.loads(items)
|
||
except json.JSONDecodeError:
|
||
items = [line for line in items.splitlines() if line.strip()]
|
||
if not isinstance(items, list):
|
||
items = [items] if items not in (None, "") else []
|
||
total = len(items)
|
||
keep_going = cursor < len(items) and cursor < max_iter
|
||
if keep_going:
|
||
current_item = items[cursor]
|
||
updates_vars = {item_var: current_item, f"{item_var}_index": cursor}
|
||
else: # while
|
||
condition = ctx.cfg("condition", "")
|
||
if cursor >= max_iter:
|
||
keep_going = False
|
||
elif not condition:
|
||
keep_going = False
|
||
else:
|
||
try:
|
||
keep_going = eval_condition(condition, tctx)
|
||
except ExpressionError as e:
|
||
raise NodeError(ctx.node.id, f"循环条件写错了:{e}") from e
|
||
|
||
hit_limit = cursor >= max_iter
|
||
if hit_limit:
|
||
ctx.emit(
|
||
EventType.LOG, level="warn",
|
||
message=f"循环达到上限 {max_iter} 次,强制退出", code="loop_limit",
|
||
)
|
||
|
||
decision = "body" if keep_going else "done"
|
||
ctx.emit(
|
||
EventType.EDGE_TAKEN,
|
||
branch=decision,
|
||
iteration=cursor,
|
||
total=total,
|
||
mode=mode,
|
||
)
|
||
|
||
result = {
|
||
DECISION_KEY: decision,
|
||
"iteration": cursor,
|
||
"total": total,
|
||
"item": current_item,
|
||
"finished": not keep_going,
|
||
}
|
||
updates: dict[str, Any] = {
|
||
"nodes": {node_id: result},
|
||
# 走完了 index 留在最后一轮,读 loops.x.index 的照旧拿到轮数;finished 让下次进来重数
|
||
"loops": {node_id: {"index": cursor + 1 if keep_going else cursor, "finished": not keep_going}},
|
||
}
|
||
if updates_vars:
|
||
updates["vars"] = updates_vars
|
||
return updates
|