feat(assistant): 工具轮次 6 轮硬限改为 100 轮检查点(继续/停止选择卡)

- 到达 _TOOL_ROUND_CHECKPOINT(100) 时复用动作确认闸门(PendingRegistry+决策端点)
  弹 rounds_confirm 事件: 点「继续」重置计数再跑一个周期, 点「停止」或 120 秒
  未选则优雅收尾(notice + done, 已生成内容保留, 不再是 error)
- 前端 rounds_confirm 以合成足迹记录承载选择卡(继续/停止按钮), 复用 decideAction;
  流结束时仍悬挂的 awaiting 确认统一收敛为已取消
- 旧 test_chat_stream_stops_at_rounds_limit 改写为新契约; 新增 3 项检查点测试
  (批准继续/拒绝停止/超时停止)
This commit is contained in:
shy3130
2026-09-26 18:04:37 +08:00
parent 24c1d10f7f
commit c6c4fd2819
7 changed files with 158 additions and 19 deletions
+1
View File
@@ -448,6 +448,7 @@ curl -H "Authorization: Bearer tsp_xxxx" \
- **分组快捷建议** — 新会话页按 行情与大盘 / 我的与个股 / 策略与信号 / 数据与扩展 分组给出 18 条按文字自适应排布的建议(含策略对比、个股横向对比), 并提示扩展内容同样可以问答
- **工具足迹卡** — 每次调用的工具名、参数、耗时、结果摘要均可展开核对; 取数可核对是设计铁律
- **动作确认卡** — 生成信号/回测/加自选/数据补全等写操作先弹确认卡: 动作名 + 影响说明 + **完整待执行参数**, 你点「确认执行」才会真正运行; 拒绝或 120 秒超时自动取消, 模型被明确告知此时不得重试
- **轮次检查点** — 工具调用不设硬上限, 连续 100 轮未完成时弹「继续 / 停止」选择卡(120 秒未选自动停止), 继续则再跑一个周期, 已生成内容始终保留
- **非模态面板** — 从页面右缘滑入, 默认 720px, 左缘拖拽调宽(宽度记忆), 边看行情边问
- **会话历史** — 本地保存, 支持多会话切换与删除, 刷新不丢
- **固定合规提示** — 每条回答完成后附风险提示与数据口径提示
+27 -8
View File
@@ -32,8 +32,9 @@ from app.services.ai_provider import (
# 单轮最多保留的用户/助手消息条数(约 8 轮对话), 更早历史截断以控 token。
_MAX_HISTORY_MESSAGES = 16
# 工具轮次上限(与策略迭代器的预算同量级, 防失控展开)。
_MAX_TOOL_ROUNDS = 6
# 工具轮次检查点(非硬限): 到达后弹「是否继续」卡(与动作确认卡同机制),
# 继续则重置计数再跑一个周期, 停止或超时优雅收尾 — 防失控展开的同时不掐断复杂编排。
_TOOL_ROUND_CHECKPOINT = 100
_SETTINGS_HINT = "/settings?tab=ai"
@@ -249,12 +250,30 @@ async def chat_stream(
return
tool_rounds += 1
if tool_rounds >= _MAX_TOOL_ROUNDS:
await queue.put(_error_event(
"rounds",
f"本轮工具调用达到 {_MAX_TOOL_ROUNDS} 轮上限仍未产出回答, 请缩小问题范围后重试。",
))
return
if tool_rounds >= _TOOL_ROUND_CHECKPOINT:
# 轮次检查点: 复用动作确认闸门(PendingRegistry + 决策端点)。
# 批准 → 重置计数继续; 拒绝/超时 → 保留已生成内容, 优雅收尾。
checkpoint_id = assistant_actions.new_call_id()
action = await assistant_actions.registry.register(
checkpoint_id, "rounds_continue", {"reached": tool_rounds},
)
await queue.put({
"type": "rounds_confirm",
"call_id": checkpoint_id,
"reached": tool_rounds,
"expires_in": int(assistant_actions.registry.timeout_s),
})
decision = await assistant_actions.registry.await_decision(action)
if decision != "approved":
await queue.put({
"type": "notice",
"message": (
f"已在 {tool_rounds} 轮工具调用的检查点停止本轮对话, "
"已生成的内容保留。可缩小问题范围后重新提问。"
),
})
return
tool_rounds = 0
# 部分兼容网关的流式 tool_calls 不带 id: 补一次并写回 call, 助手消息的
# tool_calls[].id 与下方 role:tool 回填的 tool_call_id 必须是同一个值。
+75
View File
@@ -444,3 +444,78 @@ def test_sync_status_and_summaries(tmp_path: Path) -> None:
"check_data_coverage", {"ok": True, "result": {"issues": []}})
assert "已启动" in assistant_tools.summarize_tool_result(
"sync_data", {"ok": True, "result": {"kind": "pipeline", "job_id": "j1", "reused": False}})
# ── 轮次检查点: 到达 _TOOL_ROUND_CHECKPOINT 时询问「继续/停止」 ─────
async def _run_rounds_chat(
monkeypatch: pytest.MonkeyPatch,
n_rounds: int,
on_rounds_confirm,
checkpoint: int = 2,
) -> list[dict[str, Any]]:
"""每轮都请求一个查询工具, 第 n_rounds+1 轮产出纯文本收尾。"""
monkeypatch.setattr(chat_service, "ai_configured", lambda: True)
monkeypatch.setattr(chat_service, "is_codex_cli_provider", lambda provider=None: False)
monkeypatch.setattr(chat_service, "_TOOL_ROUND_CHECKPOINT", checkpoint)
script = [
{"tool_calls": [{"id": f"c{i}", "name": "get_indices", "arguments": "{}"}]}
for i in range(n_rounds)
]
script.append({"text_pieces": ["完成"]})
monkeypatch.setattr(chat_service, "stream_openai_round", _script_round(script))
async def fake_execute(name: str, args: dict[str, Any], ctx: Any) -> dict[str, Any]:
return {"ok": True, "result": {"rows": []}}
monkeypatch.setattr(assistant_tools, "execute_assistant_tool", fake_execute)
events: list[dict[str, Any]] = []
async for line in chat_service.chat_stream(history=[{"role": "user", "content": "跑"}]):
event = json.loads(line)
events.append(event)
if event.get("type") == "rounds_confirm":
await on_rounds_confirm(event)
return events
async def test_rounds_checkpoint_continue_resets_and_finishes(monkeypatch: pytest.MonkeyPatch) -> None:
events = await _run_rounds_chat(
monkeypatch, 2,
lambda ev: assistant_actions.registry.resolve(ev["call_id"], True),
)
confirms = [e for e in events if e["type"] == "rounds_confirm"]
assert len(confirms) == 1 and confirms[0]["reached"] == 2 and confirms[0]["expires_in"] > 0
# 继续后: 两轮工具都执行(tool_call 的 call_id 由后端重新生成), 最终正常收尾
results = [e for e in events if e["type"] == "tool_result"]
assert len(results) == 2 and all(r["ok"] for r in results)
assert any(e["type"] == "delta" for e in events)
assert events[-1]["type"] == "done"
async def test_rounds_checkpoint_stop_ends_with_notice(monkeypatch: pytest.MonkeyPatch) -> None:
events = await _run_rounds_chat(
monkeypatch, 2,
lambda ev: assistant_actions.registry.resolve(ev["call_id"], False),
)
assert [e for e in events if e["type"] == "rounds_confirm"]
# 停止: 优雅收尾(notice + done), 只执行了检查点前的第一轮, 不产错误事件
notices = [e for e in events if e["type"] == "notice"]
assert notices and "停止" in notices[-1]["message"]
results = [e for e in events if e["type"] == "tool_result"]
assert len(results) == 1
assert not any(e["type"] == "error" for e in events)
assert events[-1]["type"] == "done"
async def test_rounds_checkpoint_timeout_stops(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
assistant_actions, "registry", assistant_actions.PendingRegistry(timeout_s=0.05),
)
async def no_decision(event: dict[str, Any]) -> None:
return None
events = await _run_rounds_chat(monkeypatch, 2, no_decision)
assert [e for e in events if e["type"] == "rounds_confirm"]
assert any(e["type"] == "notice" and "停止" in e["message"] for e in events)
assert not any(e["type"] == "error" for e in events)
+16 -7
View File
@@ -14,6 +14,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.custom.assistant import actions as assistant_actions
from app.custom.assistant import chat_service
from app.custom.assistant import tools as assistant_tools
from app.extensions.loader import configure_backend_extensions
@@ -166,14 +167,16 @@ async def test_chat_stream_emits_tool_events_in_order(monkeypatch: pytest.Monkey
assert json.loads(second_round[-1]["content"])["ok"] is True
async def test_chat_stream_stops_at_rounds_limit(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_chat_stream_rounds_checkpoint_prompts_and_stops(monkeypatch: pytest.MonkeyPatch) -> None:
"""轮次检查点(取代旧 6 轮硬限): 到点弹 rounds_confirm, 拒绝则优雅收尾。"""
script = [
{"tool_calls": [{"id": f"c{i}", "name": "list_factors", "arguments": "{}"}]}
for i in range(99)
for i in range(3)
]
monkeypatch.setattr(chat_service, "ai_configured", lambda: True)
monkeypatch.setattr(chat_service, "is_codex_cli_provider", lambda provider=None: False)
monkeypatch.setattr(chat_service, "stream_openai_round", _stream_script(script))
monkeypatch.setattr(chat_service, "_TOOL_ROUND_CHECKPOINT", 2)
# list_factors 走核心目录且无需引擎; 简化起见用本地工具拦截执行。
monkeypatch.setattr(
assistant_tools,
@@ -181,12 +184,18 @@ async def test_chat_stream_stops_at_rounds_limit(monkeypatch: pytest.MonkeyPatch
_fake_execute_tool,
)
events = await _collect(await _drain(chat_service.chat_stream(
history=[{"role": "user", "content": "hi"}],
)))
events: list[dict[str, Any]] = []
async for line in chat_service.chat_stream(history=[{"role": "user", "content": "hi"}]):
event = json.loads(line)
events.append(event)
if event.get("type") == "rounds_confirm":
await assistant_actions.registry.resolve(event["call_id"], False)
error = next(e for e in events if e["type"] == "error")
assert error["kind"] == "rounds"
confirm = next(e for e in events if e["type"] == "rounds_confirm")
assert confirm["reached"] == 2 and confirm["expires_in"] > 0
notices = [e for e in events if e["type"] == "notice"]
assert notices and "停止" in notices[-1]["message"]
assert not any(e["type"] == "error" for e in events)
assert events[-1]["type"] == "done"
+1
View File
@@ -46,6 +46,7 @@ export type AssistantEvent =
| { type: 'tool_call'; call_id: string; name: string; args: Record<string, unknown> }
| { type: 'tool_result'; call_id: string; name: string; ok: boolean; summary: string; elapsed_ms: number; charts?: AssistantChart[] }
| { type: 'action_confirm'; call_id: string; name: string; label: string; risk: string; expires_in: number }
| { type: 'rounds_confirm'; call_id: string; reached: number; expires_in: number }
| { type: 'delta'; content: string }
| { type: 'error'; kind: string; message: string; hint?: string }
| { type: 'done' }
+30
View File
@@ -240,6 +240,27 @@ function applyEvent(event: AssistantEvent, footprintId: string, assistantId: str
: m),
}))
break
case 'rounds_confirm':
// 轮次检查点: 以合成足迹记录承载「继续/停止」卡, 决策走同一端点。
patchActiveSession(session => ({
...session,
messages: session.messages.map(m => m.id === footprintId && m.role === 'footprint'
? {
...m,
calls: [...m.calls, {
callId: event.call_id,
name: '__rounds',
args: { reached: event.reached },
status: 'awaiting' as ToolCallStatus,
confirm: {
label: `已连续 ${event.reached} 轮工具调用仍未完成`,
risk: '继续将再运行一个周期(至多再 100 轮), 消耗更多时间与 token; 停止则保留已生成内容。',
},
}],
}
: m),
}))
break
case 'delta':
// 逐字流式高频到达: 只更新内存态, 由生成结束时的 setState({sending:false}) 统一落盘。
patchActiveSession(session => ({
@@ -255,6 +276,15 @@ function applyEvent(event: AssistantEvent, footprintId: string, assistantId: str
case 'notice':
appendMessage({ id: uid(), role: 'notice', content: event.message, ts: Date.now() })
break
case 'done':
// 流结束: 仍处于 awaiting 的确认(后端超时未执行)收敛为已取消, 卡片不再悬挂。
patchActiveSession(session => ({
...session,
messages: session.messages.map(m => m.role === 'footprint'
? { ...m, calls: m.calls.map(c => (c.status === 'awaiting' ? { ...c, status: 'denied' as ToolCallStatus } : c)) }
: m),
}))
break
default:
break
}
@@ -107,6 +107,7 @@ const TOOL_LABELS: Record<string, string> = {
create_signal_strategy: '创建自定义信号',
add_to_watchlist: '加入自选股',
check_data_coverage: '检查数据完整性',
__rounds: '轮次检查点',
list_ext_tables: '检索扩展数据表',
query_ext_table: '读取扩展数据表',
get_sync_status: '查询同步进度',
@@ -229,11 +230,12 @@ function ToolCallRow({ call }: { call: ToolCallRecord }) {
*/
function ConfirmCard({ call }: { call: ToolCallRecord }) {
const confirm = call.confirm!
const isRounds = call.name === '__rounds'
return (
<div className="mt-1 ml-5 rounded-card border border-border bg-elevated/60 p-2">
<div className="flex items-center gap-1.5 text-foreground">
<ShieldAlert className="h-3.5 w-3.5 shrink-0 text-danger" />
<span className="font-medium">{confirm.label} · 需要你的确认</span>
<span className="font-medium">{confirm.label}{isRounds ? ' · 需要你的选择' : ' · 需要你的确认'}</span>
</div>
<div className="mt-1 leading-relaxed text-secondary">{confirm.risk}</div>
{Object.keys(call.args ?? {}).length > 0 && (
@@ -247,16 +249,18 @@ function ConfirmCard({ call }: { call: ToolCallRecord }) {
onClick={() => { void decideAction(call.callId, true) }}
className="cursor-pointer rounded-btn bg-accent/15 px-2.5 py-1 font-medium text-accent transition-colors duration-150 ease-smooth hover:bg-accent/25"
>
确认执行
{isRounds ? '继续' : '确认执行'}
</button>
<button
type="button"
onClick={() => { void decideAction(call.callId, false) }}
className="cursor-pointer rounded-btn px-2.5 py-1 text-secondary transition-colors duration-150 ease-smooth hover:bg-elevated hover:text-foreground"
>
取消
{isRounds ? '停止' : '取消'}
</button>
<span className="text-[10px] text-muted">120 秒内未确认将自动取消</span>
<span className="text-[10px] text-muted">
{isRounds ? '120 秒内未选择将自动停止' : '120 秒内未确认将自动取消'}
</span>
</div>
</div>
)