Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/backend/bisheng/common/services/llm_error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,33 @@ class ErrorType(str, enum.Enum):
"限流",
)

# Transient TRANSPORT failures a provider reports from ITS OWN upstream fetch,
# wrapped in an otherwise non-transient status. When an endpoint fetches something
# on our behalf — staging a data-URI image into object storage, a sandbox pulling a
# file, a RAG fetcher — its I/O error surfaces as a 4xx even though the REQUEST was
# fine; only their fetch of it failed. Retrying is exactly right, and a plain 400
# otherwise DEGRADEs, which on the main graph means the whole run dies.
#
# Measured on 180 (2026-08-09, session 649ba617): a deck screenshot handed to the
# model came back as
# 400 {"code":"DOWNLOAD_FAILED", ... "read tcp 10.0.2.60:46976->10.86.10.104:6000:
# read: connection reset by peer"}
# — both endpoints inside the vendor's own network — and killed a 21-minute run
# with zero retries.
#
# These are Go/POSIX transport phrases, not vendor error codes, so this stays
# vendor-agnostic like the other flat signature sets. DELIBERATELY transport-only:
# a fetch that failed with 404 / bad URL / unsupported format is a genuine client
# error and MUST keep degrading rather than burn three backoffs on a sure failure.
_TRANSIENT_TRANSPORT_SIGNATURES: tuple[str, ...] = (
"connection reset",
"connection refused",
"broken pipe",
"i/o timeout",
"unexpected eof",
"no route to host",
)

# Content-moderation / safety-guardrail codes across mainstream vendors:
# Aliyun DashScope (data_inspection_failed / inappropriate_content),
# Baidu Qianfan (336003/336005), Zhipu (1301), MiniMax (2013),
Expand Down Expand Up @@ -231,6 +258,16 @@ def _is_rate_limit(exc: BaseException) -> bool:
return any(sig in text for sig in _RATE_LIMIT_SIGNATURES)


def _is_transient_transport(exc: BaseException) -> bool:
"""A transport-level failure the PROVIDER hit on its own upstream fetch.

Checked only after the FAIL_FAST branches, so a quota/auth error carrying such
wording in a nested message can never be turned into a retry.
"""
text = _exc_text(exc)
return any(sig in text for sig in _TRANSIENT_TRANSPORT_SIGNATURES)


def _is_task_aborted(exc: BaseException) -> bool:
"""The task was aborted by a framework/loop guard, not a provider error.

Expand Down Expand Up @@ -300,6 +337,8 @@ def classify_behavior(exc: BaseException) -> Behavior:
return Behavior.RETRYABLE
if isinstance(exc, (TimeoutError, ConnectionError)):
return Behavior.RETRYABLE
if _is_transient_transport(exc): # provider's own upstream fetch blew up mid-flight
return Behavior.RETRYABLE

# 3) DEGRADABLE — default bucket: content filter, plain 400, anything else.
return Behavior.DEGRADABLE
Expand Down Expand Up @@ -351,6 +390,10 @@ def label_error(exc: BaseException) -> ErrorType:
status = _status_code(exc)
if isinstance(exc, openai.InternalServerError) or (status is not None and 500 <= status < 600):
return ErrorType.SERVICE_UNAVAILABLE
# Retries exhausted on the provider's own upstream fetch: "service busy" is the
# honest story for the user, not the generic unknown card.
if _is_transient_transport(exc):
return ErrorType.SERVICE_UNAVAILABLE
return ErrorType.UNKNOWN


Expand Down
14 changes: 9 additions & 5 deletions src/backend/bisheng/initdb_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,18 @@ linsight:
tool_buffer: 100000
# 单个任务最大执行步骤数(LangGraph recursion_limit),防止死循环。
# ⚠️ 这里的“步骤”是 LangGraph super-step,不是模型轮次:一轮模型调用约消耗 4 个 super-step
# (model → 工具循环熔断器 after_model → TodoList after_model → tools),
# 所以 500 ≈ 115 轮。它是跑飞时的保险丝,真正的业务上限请调 max_model_turns。
# 若本值低于 max_model_turns 所需,后端会自动抬高并打 warning 日志。
max_steps: 500
# (model → 工具循环熔断器 after_model → TodoList after_model → tools)。
# 另外「只产出 write_todos」的空转轮会退还预算(每个预算桶最多退 10 轮),
# 所以实际模型调用可能超过 max_model_turns,保险丝要留出这部分余量:
# (115 + 10) × 4 + 20 = 520,取 600 留头。
# 它是跑飞时的保险丝,真正的业务上限请调 max_model_turns。
# 若本值低于所需,后端会自动抬高并打 warning 日志。
max_steps: 600
# 主图轮次预算:一次任务运行最多允许多少次模型调用,到顶前会提示模型收尾(软着陆)。
# ask_user 打断恢复后重新计数(中间件随 agent 重建)。
max_model_turns: 115
# 子代理(researcher)自己的轮次预算,独立计数(子图跑自己的 Pregel 循环)
# 子代理(researcher)轮次预算。⚠️ 按「每次 task 调用」独立计数,不是整次任务共享:
# 主图并行委派 2 个子代理时,每个各得完整一份,互不抢额度。
max_model_turns_subagent: 30
# 距离预算耗尽还剩多少轮时开始提示模型收尾;最后 2 轮只保留写文件/导出工具,
# 归零时不再提供任何工具,模型只能输出文本,图正常结束
Expand Down
35 changes: 29 additions & 6 deletions src/backend/bisheng/linsight/domain/services/agent_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,12 @@

- ask_user(reason, questions):第 0 步澄清;整个会话最多调用一次。
- write_todos(todos):维护有编号的待办清单;只翻转 status,不改写已有文案。
__KB_TOOL_LINE__- write_file / read_file / edit_file / ls:工作区文件工具;交付物写 output/,中间产物写 scratch/。
__KB_TOOL_LINE__- write_file / read_file / edit_file / ls:工作区文件工具;交付物写 output/,中间产物写 scratch/(工具回显的 /output/x 与 output/x 是同一个文件)
- export_docx(source_path, dest_path):把 output/ 下的 markdown 转 Word(.docx),必须在对应 .md 写好之后。
- export_pdf(source_path, dest_path):把 output/ 下的 markdown 转 PDF,必须在对应 .md 写好之后。
- task(description, subagent_type="general-purpose"):把独立、可隔离、较重的调研子任务委派给子代理。description 必须自包含——子代理看不到你的对话历史与上下文,只能读到 description,因此完成该子任务所需的全部背景、目标、约束与必要标识都要写进去;不得委派最终交付物撰写,也不得委派“问用户/澄清”。

# 风格
__PATH_NAMESPACE_LINE__# 风格

- 简洁,工具调用之间不加多余解释性文字。
- 不要凭空编造事实;交付内容应基于检索到的资料/依据。
Expand Down Expand Up @@ -320,10 +320,7 @@ def _build_linsight_system_prompt(
"此时**禁止**调用 export_docx / export_pdf 从 markdown 派生同一份交付物——"
"那条路径丢掉技能规定的全部排版细节,等于没用技能;"
"只有代码执行连续失败、确实产不出成品时,才允许退回 export_docx / export_pdf 兜底,"
"并在收尾里说明退化。3a 的 markdown 规范源仍然要写(界面预览依赖它)。"
"注意:代码执行器直接写在工作区本地目录,它生成的文件**不会**出现在 ls / glob 的结果里"
"(那两个工具读的是对象存储视图)。只要执行返回 exitcode 0 且日志显示写成功,"
"就视为交付物已产出,继续下一步;不要反复 ls / glob 找它,也不要因为“找不到”而重新生成。\n"
"并在收尾里说明退化。3a 的 markdown 规范源仍然要写(界面预览依赖它)。\n"
)
else:
# No executor bound: the skill's route cannot run, so banning the
Expand All @@ -337,12 +334,38 @@ def _build_linsight_system_prompt(
else:
skill_exec_line = ""
skill_deliverable_line = ""

# The two path namespaces only need explaining when a code interpreter is bound —
# that is the ONLY tool with a cwd. Gated on ``has_code_interpreter`` ALONE:
# this used to ride along inside ``skill_deliverable_line``, so a run that bound
# the executor but selected no skill (exactly the 180 session) never saw it and
# burned three model round-trips rediscovering the mapping. Keeping the
# ``has_code_interpreter`` gate preserves the prompt↔tool lockstep this module
# enforces everywhere else (a bound-tool name must never appear otherwise).
path_namespace_line = ""
if has_code_interpreter:
path_namespace_line = (
"# 工作区路径(同一份文件的两种写法)\n\n"
"- 文件工具(ls / read_file / write_file / edit_file / export_*)用**带前导斜杠**的工作区路径:"
"/output/x.md、/scratch/x.png、/uploads/x.xlsx、/skills/<name>/SKILL.md;不带斜杠的相对写法等价。\n"
"- bisheng_code_interpreter 的**当前工作目录就是工作区根**,代码里一律用**去掉前导斜杠**的相对路径:"
'open("skills/<name>/assets/t.html")、plt.savefig("output/chart.png")。'
'写成 open("/skills/…") / open("/output/…") 会落到容器根目录——读必然 FileNotFoundError,'
"写会被丢弃、进不了交付。\n"
"- 反向同理:**不要**把代码里看到的宿主机绝对路径(形如 /root/.cache/…/<8位任务号>/output/a.png)"
"传给 read_file / edit_file,去掉前缀只传 output/a.png。\n"
"- 执行器直接写本地工作目录,它生成的文件**不会**出现在 ls / glob 的结果里"
"(那两个工具读的是对象存储视图)。只要执行返回 exitcode 0 且日志显示写成功,"
"就视为交付物已产出,继续下一步;不要反复 ls / glob 找它,也不要因为“找不到”而重新生成。\n\n"
)

return (
_LINSIGHT_SYSTEM_PROMPT_TEMPLATE_ZH.replace("__KB_EXEC_LINE__", exec_line)
.replace("__KB_TOOL_LINE__", tool_line)
.replace("__KB_DELEGATE_LINE__", delegate_line)
.replace("__SKILL_EXEC_LINE__", skill_exec_line)
.replace("__SKILL_DELIVERABLE_LINE__", skill_deliverable_line)
.replace("__PATH_NAMESPACE_LINE__", path_namespace_line)
)


Expand Down
Loading
Loading