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
31 changes: 22 additions & 9 deletions src/backend/core/evolution/agent_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,13 @@
# out-of-range value hides the fact that something proposed it.
MIN_BUDGET_MULTIPLIER = 0.25
MAX_BUDGET_MULTIPLIER = 3.0
# 0 is the default and means "no turn cap" — the main agent is unbounded unless
# a published profile deliberately fences it. Any other value must land in the
# band below; the band starts at 3 because a 1-2 turn main agent cannot finish a
# single tool round-trip.
MIN_REACT_TURNS = 3
MAX_REACT_TURNS = 80
UNBOUNDED_REACT_TURNS = 0
MIN_MEMORY_BUDGET_MS = 100
MAX_MEMORY_BUDGET_MS = 5000
MIN_LOOP_ATTEMPTS = 2
Expand Down Expand Up @@ -178,7 +183,10 @@ class AgentProfile:
subagent_routes: List[SubagentRoute] = field(default_factory=list)

memory_budget_ms: int = 600
max_react_turns: int = 50
# 0 = 不限(默认)。See UNBOUNDED_REACT_TURNS: the main agent's round ceiling
# was removed, so a turn budget here is an opt-in narrowing a published
# profile performs deliberately, not a floor every profile inherits.
max_react_turns: int = UNBOUNDED_REACT_TURNS
budget_multiplier: float = 1.0
reviewer_checkpoints: List[str] = field(default_factory=list)
intervention_rules: List[InterventionRule] = field(default_factory=list)
Expand Down Expand Up @@ -225,7 +233,10 @@ def from_dict(cls, raw: Dict[str, Any]) -> "AgentProfile":
SubagentRoute.from_dict(r) for r in (raw.get("subagent_routes") or [])
],
memory_budget_ms=int(raw.get("memory_budget_ms") or 600),
max_react_turns=int(raw.get("max_react_turns") or 20),
# Missing key → unbounded, not an invented ceiling: a profile
# published for unrelated reasons (tool allowlist, memory budget)
# must not silently re-fence the main loop.
max_react_turns=int(raw.get("max_react_turns") or UNBOUNDED_REACT_TURNS),
budget_multiplier=float(raw.get("budget_multiplier") or 1.0),
reviewer_checkpoints=[str(c) for c in (raw.get("reviewer_checkpoints") or [])],
intervention_rules=[
Expand Down Expand Up @@ -253,11 +264,10 @@ def builtin_profile() -> AgentProfile:
version="v0",
tool_allowlist=None,
memory_budget_ms=600,
# The main agent's existing cap, reproduced exactly. Installing profile
# resolution must change nothing until a profile is published; a
# "sensible default" here would be a behaviour change disguised as a
# refactor.
max_react_turns=50,
# The main agent's runtime default, reproduced exactly — which is now
# "no cap". This field only bounds the loop when a published profile
# sets it on purpose.
max_react_turns=UNBOUNDED_REACT_TURNS,
budget_multiplier=1.0,
loop_max_attempts_per_requirement=6,
loop_strategy_change_after=2,
Expand Down Expand Up @@ -323,9 +333,12 @@ def validate_profile(
f"预算倍数 {profile.budget_multiplier} 超出 "
f"[{MIN_BUDGET_MULTIPLIER}, {MAX_BUDGET_MULTIPLIER}]"
)
if not (MIN_REACT_TURNS <= profile.max_react_turns <= MAX_REACT_TURNS):
if profile.max_react_turns != UNBOUNDED_REACT_TURNS and not (
MIN_REACT_TURNS <= profile.max_react_turns <= MAX_REACT_TURNS
):
problems.append(
f"最大轮数 {profile.max_react_turns} 超出 [{MIN_REACT_TURNS}, {MAX_REACT_TURNS}]"
f"最大轮数 {profile.max_react_turns} 超出 "
f"[{MIN_REACT_TURNS}, {MAX_REACT_TURNS}]({UNBOUNDED_REACT_TURNS} 表示不限)"
)
if not (MIN_MEMORY_BUDGET_MS <= profile.memory_budget_ms <= MAX_MEMORY_BUDGET_MS):
problems.append(
Expand Down
75 changes: 65 additions & 10 deletions src/backend/core/llm/agent_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"确认后系统会自动逐条执行并把结果实时推送给用户,无需你重复调用。\n"
"若请求确实只针对单一对象/单一概念,再走普通回答即可。\n"
)

from orchestration.registry import AgentSpec

load_dotenv()
Expand All @@ -92,6 +93,32 @@
_HTTP_MCP_FAIL_COOLDOWN_S = 60.0


def _render_turn_budget_hint(max_iters: int) -> str:
"""Tell an agent that *does* have a turn budget how to spend it.

Stating the budget up front is cheaper than shouting about it at the end:
the wrap-up reminder (:class:`IterBudgetReminderMiddleware`) can only ask a
loop that already burned its rounds one tool at a time to salvage
something, while this changes how the rounds get spent in the first place.
Parallel fan-out within one round is the lever — a round is one reasoning
step, not one tool call, so the budget binds serial round-trips, never the
total number of tool calls.

Only rendered where a bound actually exists (sub-agents, turbo, custom
agents, an explicit operator cap). The main agent is unbounded and must not
be told otherwise — a false scarcity claim would make it cut work short.
"""
return (
"\n\n## 轮次预算\n"
f"本次运行最多 {max_iters} 轮「推理 → 工具调用」。一轮里可以同时发起任意多个"
"工具调用,所以受限的是串行往返次数,不是工具调用总数。\n"
"- 先想清楚需要哪些信息,再**在同一轮里并行发起**全部彼此独立的调用"
"(一次读多个文件、一次检索多个关键词),不要一轮只调一个。\n"
"- 只有后一步的参数确实要等前一步的结果时,才分成下一轮。\n"
"- 同一个操作连续失败两次就换思路或如实报告,不要用剩余轮次反复重试。\n"
)


def _effective_mcp_server_keys(
cfg,
agent_spec: Optional[AgentSpec],
Expand Down Expand Up @@ -2053,29 +2080,50 @@ def _build_toolkit() -> Toolkit:
# toolkit.get_agent_skill_prompt(), so no separate hook is needed.

# ── Resolve agent name and max_iters ──
_DEFAULT_MAIN_ITERS = 50
#
# **The main agent has no turn cap.** A fixed round ceiling bounds the wrong
# axis: what a long task actually exhausts is context, not rounds, and any
# number picked here is simultaneously too low for report-scale work and too
# high to catch a genuine runaway. Neither of the two things a cap was
# supposed to buy needs it:
# - runaway protection lives in the chat-run watchdog, which can see
# wall-clock and output (CHAT_RUN_INACTIVITY_TIMEOUT_SEC /
# CHAT_RUN_MAX_AGE_SEC / CHAT_RUN_HARD_MAX_AGE_SEC reap silent and
# immortal runs regardless of how many rounds they took);
# - context exhaustion is handled by compaction.
# ``_UNBOUNDED_ITERS`` is a loop backstop, not a budget — AgentScope's
# ReActConfig needs an int, and this one sits far above any real turn.
#
# Bounded budgets survive only where the bound is a deliberate contract:
# sub-agents (a delegated task that must come back), turbo's quick-lookup
# cap, a custom agent's own ``max_iters``, a published profile's turn
# budget, and the CHAT_MAIN_MAX_ITERS opt-in for operators who do want the
# main agent fenced.
_UNBOUNDED_ITERS = 100_000
_DEFAULT_SUBAGENT_ITERS = 10
_agent_name = "hugagent_agent"
# The active profile's turn budget governs the main agent. The built-in
# profile carries the same 50 this used, so resolution is a no-op until a
# profile is published.
_max_iters = profile.max_react_turns if user_agent is None else _DEFAULT_MAIN_ITERS
_max_iters = _UNBOUNDED_ITERS
if max_iters is not None:
_max_iters = max_iters
elif user_agent is not None:
_agent_name = (
f"subagent_{user_agent.agent_id}" if isolated else f"agent_{user_agent.agent_id}"
)
_max_iters = user_agent.max_iters or (
_DEFAULT_SUBAGENT_ITERS if isolated else _DEFAULT_MAIN_ITERS
_DEFAULT_SUBAGENT_ITERS if isolated else _UNBOUNDED_ITERS
)
elif isolated:
_max_iters = _DEFAULT_SUBAGENT_ITERS
else:
# Main-agent env override for long-running tasks. Wins over the profile
# value: the profile validation range tops out at 80 turns, far below
# what e.g. a multi-hour report-generation run needs (container restart
# required to change, like all env config).
# Both main-agent caps are opt-in and absent by default: the built-in
# profile now carries 0 ("no cap"), so profile resolution only bounds the
# loop when someone publishes a profile that deliberately sets a turn
# budget. The env override still wins over it — the profile validation
# range tops out at 80 turns, far below what e.g. a multi-hour
# report-generation run needs (container restart required to change,
# like all env config).
if profile.max_react_turns:
_max_iters = profile.max_react_turns
from core.config.settings import _env, _int

_env_iters = _int(_env("CHAT_MAIN_MAX_ITERS"), 0)
Expand All @@ -2096,6 +2144,13 @@ def _build_toolkit() -> Toolkit:
# else: 开了代码执行位且模式没配上限 → 不套极速的检索档硬顶(默认 4 轮
# 会把跑代码的任务掐死),按 profile/env 的常规上限走。

# Budget spending policy, injected only where a budget exists. Appended last
# so it sees the final number after every narrowing above (turbo included);
# skipped without tools, where "spend your rounds on parallel calls" has
# nothing to describe.
if _max_iters < _UNBOUNDED_ITERS and not disable_tools:
system_prompt += _render_turn_budget_hint(_max_iters)

# ── Create the Agent (AgentScope 2.0) ──
# Note: long_term_memory is not passed — mem0 is fully stripped from the SSE
# main path (manual non-blocking pipeline).
Expand Down
9 changes: 7 additions & 2 deletions src/backend/tests/evolution/test_policies_and_memory_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ def test_builtin_profile_reproduces_todays_constants():
policy = PL.policy_from_profile(AP.builtin_profile())
assert policy.max_attempts_per_requirement == 6
assert policy.strategy_change_after == 2
# The main ReAct agent's existing cap, unchanged.
assert AP.builtin_profile().max_react_turns == 50
# The main ReAct agent has no turn cap; the built-in profile must not
# reintroduce one behind the runtime's back.
assert AP.builtin_profile().max_react_turns == AP.UNBOUNDED_REACT_TURNS
# A profile published without an explicit turn budget stays unbounded too —
# a missing key must not become an invented ceiling.
unspecified = AP.AgentProfile.from_dict({"profile_id": "p"})
assert unspecified.max_react_turns == AP.UNBOUNDED_REACT_TURNS


def test_longer_stall_escalates_rather_than_repeating_the_mild_response():
Expand Down
36 changes: 36 additions & 0 deletions src/backend/tests/test_iter_budget_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,39 @@ def test_force_text_kill_switch(monkeypatch):
agent = _fake_agent(max_iters=10, cur_iter=9)
out = _force(mw, agent, {"tool_choice": None})
assert out.get("tool_choice") is None


# ── Turn-budget hint (static policy, injected only where a budget exists) ──


def test_turn_budget_hint_states_the_actual_number():
from core.llm.agent_factory import _render_turn_budget_hint

hint = _render_turn_budget_hint(12)
assert "12 轮" in hint
# The point of the hint is the parallel fan-out strategy, not the scary
# number — one that only announces a limit makes the model cautious
# rather than efficient.
assert "并行" in hint


def test_turn_budget_hint_is_byte_stable():
"""Prefix-cache safety: same budget → same bytes, every request."""
from core.llm.agent_factory import _render_turn_budget_hint

assert _render_turn_budget_hint(30) == _render_turn_budget_hint(30)


def test_unbounded_profile_is_valid_and_bad_bounds_are_not():
from core.evolution import agent_profile as AP

unbounded = AP.builtin_profile()
assert unbounded.max_react_turns == AP.UNBOUNDED_REACT_TURNS
ok, problems = AP.validate_profile(unbounded)
assert ok, problems

fenced = AP.builtin_profile()
fenced.max_react_turns = 1 # below MIN_REACT_TURNS, and not the 0 sentinel
ok, problems = AP.validate_profile(fenced)
assert ok is False
assert any("最大轮数" in p for p in problems)
27 changes: 23 additions & 4 deletions src/frontend/src/styles/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -598,13 +598,32 @@
color:transparent;
animation:jx-kf-shimmer 1.6s linear infinite;
}
/* 深色下把流光方向掰回来:上面那条渐变是「slate-400 打底 + slate-600 高光」,
靠"把字压暗"做高光——浅色底成立,暗底上就完全反了,高光处 (#475569) 对比度不到 2,
于是「深度拥抱中…」会朝着看不见的方向脉动。深色档改成暗→亮,高光才是最亮的一点。 */
:root[data-theme="dark"] .jx-turnStatus-label{
background-image:linear-gradient(90deg,
color-mix(in srgb, var(--color-text) 52%, transparent) 0%,
color-mix(in srgb, var(--color-text) 96%, transparent) 50%,
color-mix(in srgb, var(--color-text) 52%, transparent) 100%);
background-size:200% 100%;
/* ⚠️ 必须用 background-image 而不是 background 简写:简写会把 background-clip 一并
重置回 border-box,这行字正是靠 clip:text 把渐变裁进字形的,重置后整行会变成一块
实心灰方块、文字消失(已踩)。这两行是保险,即便将来改回简写也不至于塌掉。 */
-webkit-background-clip:text;
background-clip:text;
}
.jx-turnStatus-clock{
margin-left:2px;
font-size:12px;
font-weight:400;
font-variant-numeric:tabular-nums;
color:var(--color-text-placeholder);
}
/* 与折叠摘要同理:placeholder 令牌在深色档只有 2.65 对比度,计时数字看不清 */
:root[data-theme="dark"] .jx-turnStatus-clock{
color:var(--color-text-tertiary);
}


/* ── 上传文件预览卡片(输入区 & 用户消息) ──────── */
Expand Down Expand Up @@ -4162,8 +4181,8 @@ textarea.jx-composer{
.jx-deploySwitcherMenu {
position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%);
min-width: 288px; z-index: 50; padding: 6px;
background: var(--color-bg-white, #fff);
border: 1px solid var(--color-border, #e5e6eb);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,.14);
}
.jx-deploySwitcherGroup {
Expand All @@ -4187,7 +4206,7 @@ textarea.jx-composer{

/* Local permissions modal (ticket #06) */
.jx-localPermOverlay { position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; }
.jx-localPermModal { width: 520px; max-width: 92vw; max-height: 82vh; overflow: auto; background: var(--color-bg-white, #fff); color: var(--color-text, #1d2129); border-radius: 14px; box-shadow: 0 16px 48px rgba(0,0,0,.24); }
.jx-localPermModal { width: 520px; max-width: 92vw; max-height: 82vh; overflow: auto; background: var(--color-bg-elevated); color: var(--color-text); border-radius: 14px; box-shadow: 0 16px 48px rgba(0,0,0,.24); }
.jx-localPermHead { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px; font-size: 16px; font-weight: 600; border-bottom: 1px solid var(--color-border, #e5e6eb); }
.jx-localPermClose { border: 0; background: transparent; font-size: 22px; line-height: 1; cursor: pointer; color: var(--color-text-tertiary, #86909c); }
.jx-localPermSection { padding: 14px 18px; border-bottom: 1px solid var(--color-border, var(--color-border)); }
Expand All @@ -4201,7 +4220,7 @@ textarea.jx-composer{
.jx-localPermAdd { margin-top: 8px; border: 1px dashed var(--color-border, #c9cdd4); background: transparent; color: var(--color-primary, #165dff); border-radius: 8px; padding: 7px 12px; cursor: pointer; font-size: 13px; }
.jx-localPermPolicyRow { display: flex; align-items: center; justify-content: space-between; padding: 7px 0; }
.jx-localPermPolicyLabel { font-size: 13px; }
.jx-localPermSelect { font-size: 13px; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--color-border, #c9cdd4); background: var(--color-bg-white, #fff); color: var(--color-text, #1d2129); }
.jx-localPermSelect { font-size: 13px; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--color-border); background: var(--color-bg-container); color: var(--color-text); }
/* 项目框旁的本机操作权限档胶囊(桌面壳本机模式) */
.jx-approvalPillBtn .jx-approvalPillIcon { font-size: 13px; line-height: 1; flex-shrink: 0; }
.jx-approvalMenu .ant-dropdown-menu { min-width: 248px; }
Expand Down
Loading
Loading