Files
AgentLab/backend/app/engine/nodes/control.py
T
ThearchyHelios e46a3f0d62 嵌套循环的内层每轮都从头数:max_iterations 是"每进来一次"的上限,不是整次运行合计
运行 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。
2026-09-25 23:14:50 +08:00

191 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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