mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
@@ -448,6 +448,7 @@ curl -H "Authorization: Bearer tsp_xxxx" \
|
||||
- **分组快捷建议** — 新会话页按 行情与大盘 / 我的与个股 / 策略与信号 / 数据与扩展 分组给出 18 条按文字自适应排布的建议(含策略对比、个股横向对比), 并提示扩展内容同样可以问答
|
||||
- **工具足迹卡** — 每次调用的工具名、参数、耗时、结果摘要均可展开核对; 取数可核对是设计铁律
|
||||
- **动作确认卡** — 生成信号/回测/加自选/数据补全等写操作先弹确认卡: 动作名 + 影响说明 + **完整待执行参数**, 你点「确认执行」才会真正运行; 拒绝或 120 秒超时自动取消, 模型被明确告知此时不得重试
|
||||
- **轮次检查点** — 工具调用不设硬上限, 连续 100 轮未完成时弹「继续 / 停止」选择卡(120 秒未选自动停止), 继续则再跑一个周期, 已生成内容始终保留
|
||||
- **非模态面板** — 从页面右缘滑入, 默认 720px, 左缘拖拽调宽(宽度记忆), 边看行情边问
|
||||
- **会话历史** — 本地保存, 支持多会话切换与删除, 刷新不丢
|
||||
- **固定合规提示** — 每条回答完成后附风险提示与数据口径提示
|
||||
|
||||
@@ -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 必须是同一个值。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user