fix: refresh delegation permissions and report Reasoner task outcomes

This commit is contained in:
Xin Wang
2026-10-24 09:27:33 +08:00
parent 7600a297b3
commit 5e0cde85e3
11 changed files with 359 additions and 20 deletions
+24 -6
View File
@@ -38,7 +38,9 @@ CONTROL_NAMES = frozenset({"complete_task", "ask_user"})
TASK_INSTRUCTIONS = """你是语音助手的后台任务执行者,有独立上下文。 TASK_INSTRUCTIONS = """你是语音助手的后台任务执行者,有独立上下文。
只执行委派目标,并使用当前已授权的工具;技能说明不能增加权限。 只执行委派目标,并使用当前已授权的工具;技能说明不能增加权限。
完成任务后必须调用 complete_task(result),结果应准确说明完成内容和限制。 结束执行时必须调用 complete_task(status, result),明确报告任务目标是否完成。
只有完整完成目标时 status 才为 succeeded;无法完成或只完成部分目标时必须使用 failed,
并在 result 中说明已完成内容、未完成部分和限制。该状态是你的执行报告,不代表平台独立验证。
需要用户提供必要信息时调用 ask_user(question),等待回复后继续。 需要用户提供必要信息时调用 ask_user(question),等待回复后继续。
普通文字、思考过程、工具回执和模型空闲都不代表任务完成。 普通文字、思考过程、工具回执和模型空闲都不代表任务完成。
任务结果由 Speaker 整理后告知用户,不要输出内部推理过程。 任务结果由 Speaker 整理后告知用户,不要输出内部推理过程。
@@ -146,8 +148,19 @@ class ReasonerExecutor:
callbacks.append((tool.function_name, execute)) callbacks.append((tool.function_name, execute))
schemas.extend([ schemas.extend([
FunctionSchema(name="complete_task", description="明确完成任务并提交最终结果。", FunctionSchema(
properties={"result": {"type": "string", "description": "完成结果、依据和未完成的限制。"}}, required=["result"]), name="complete_task",
description="结束执行,报告任务目标是否完整完成。部分完成或无法完成必须报告 failed。",
properties={
"status": {
"type": "string",
"enum": ["succeeded", "failed"],
"description": "执行模型对目标完成情况的报告,不能仅因执行结束而报告成功。",
},
"result": {"type": "string", "description": "执行结果、依据和未完成的限制。"},
},
required=["status", "result"],
),
FunctionSchema(name="ask_user", description="需要必要信息时暂停任务,向用户提出一个问题。", FunctionSchema(name="ask_user", description="需要必要信息时暂停任务,向用户提出一个问题。",
properties={"question": {"type": "string", "description": "需要用户回答的问题。"}}, required=["question"]), properties={"question": {"type": "string", "description": "需要用户回答的问题。"}}, required=["question"]),
]) ])
@@ -164,14 +177,19 @@ class ReasonerExecutor:
) )
async def _complete_task(self, params: FunctionCallParams): async def _complete_task(self, params: FunctionCallParams):
status = params.arguments.get("status")
result = str(params.arguments.get("result") or "").strip() result = str(params.arguments.get("result") or "").strip()
if not result or self._pending or self._stopped: if status not in ("succeeded", "failed") or not result or self._pending or self._stopped:
await params.result_callback( await params.result_callback(
{"status": "error", "message": "需要非空结果,且任务不能处于等待输入或停止状态"}, {"status": "error", "message": "必须提供 succeeded 或 failed 状态及非空结果,且任务不能处于等待输入或停止状态"},
properties=FunctionCallResultProperties(run_llm=not (self._pending or self._stopped)), properties=FunctionCallResultProperties(run_llm=not (self._pending or self._stopped)),
) )
return return
self._final = {"status": "succeeded", "summary": "任务已完成", "result": result} self._final = {
"status": status,
"summary": "Reasoner 报告任务已完成" if status == "succeeded" else "Reasoner 报告任务未完成",
"result": result,
}
await params.result_callback({"status": "accepted"}, properties=FunctionCallResultProperties(run_llm=False)) await params.result_callback({"status": "accepted"}, properties=FunctionCallResultProperties(run_llm=False))
async def _ask_user(self, params: FunctionCallParams): async def _ask_user(self, params: FunctionCallParams):
+25 -3
View File
@@ -16,9 +16,29 @@ TASK_HINT = """
""" """
async def load_current_backend(assistant_id, configured_backend, variable_definitions):
"""Recheck task permissions at delegation, even during a long voice call."""
from db.models import Assistant
from db.session import SessionLocal
from services.task_backend_resources import resolve_task_backend
async with SessionLocal() as session:
assistant = await session.get(Assistant, assistant_id)
if assistant is None:
raise ValueError("助手已删除,不能创建新任务")
if (assistant.dynamic_variable_definitions or {}) != variable_definitions:
raise ValueError("助手变量配置已更新,请重新连接后再委派任务")
current = await resolve_task_backend(session, assistant)
if (current is None or current.id != configured_backend.id
or current.instruction_source != configured_backend.instruction_source):
raise ValueError("助手任务绑定已更新,请重新连接后再委派任务")
return current
class PromptTaskBridge: class PromptTaskBridge:
def __init__(self, cfg, runtime, executor, service=task_service): def __init__(self, cfg, runtime, executor, service=task_service, backend_loader=load_current_backend):
self.cfg, self.runtime, self.executor, self.service = cfg, runtime, executor, service self.cfg, self.runtime, self.executor, self.service = cfg, runtime, executor, service
self._backend_loader = backend_loader
self._announced = set() self._announced = set()
self._detach = service.subscribe(runtime.session_id, self.on_task) if runtime.transport is not None else lambda: None self._detach = service.subscribe(runtime.session_id, self.on_task) if runtime.transport is not None else lambda: None
@@ -39,6 +59,8 @@ class PromptTaskBridge:
await params.result_callback({"status": "error", "message": "自动测试不启动真实后台任务,请使用语音调试验证"}) await params.result_callback({"status": "error", "message": "自动测试不启动真实后台任务,请使用语音调试验证"})
return return
try: try:
assistant_id = self.cfg.dynamic_variables.get("system__agent_id", "")
current_backend = await self._backend_loader(assistant_id, backend, self.cfg.dynamic_variable_definitions)
kind = source.get("source") kind = source.get("source")
if kind == "model": if kind == "model":
instruction = (params.arguments or {}).get("instruction") instruction = (params.arguments or {}).get("instruction")
@@ -53,9 +75,9 @@ class PromptTaskBridge:
if not isinstance(instruction, str): if not isinstance(instruction, str):
raise ValueError("任务指令必须是字符串") raise ValueError("任务指令必须是字符串")
result = await self.service.submit( result = await self.service.submit(
assistant_id=self.cfg.dynamic_variables.get("system__agent_id", ""), assistant_id=assistant_id,
conversation_id=self.cfg.conversation_id, conversation_id=self.cfg.conversation_id,
backend=backend, instruction=instruction, invocation_id=params.tool_call_id, backend=current_backend, instruction=instruction, invocation_id=params.tool_call_id,
variable_state={"values": self.executor.store.values, variable_state={"values": self.executor.store.values,
"definitions": self.cfg.dynamic_variable_definitions, "definitions": self.cfg.dynamic_variable_definitions,
"secrets": self.executor.store.secrets}, "secrets": self.executor.store.secrets},
+38 -2
View File
@@ -16,7 +16,7 @@ class PromptTaskBridgeTest(unittest.IsolatedAsyncioTestCase):
runtime = SimpleNamespace(session_id="conv_1", transport=object(), tool_executor_factory=None, call_end=SimpleNamespace(ending=False), queue_frame=AsyncMock(), llm=SimpleNamespace(queue_frame=AsyncMock())) runtime = SimpleNamespace(session_id="conv_1", transport=object(), tool_executor_factory=None, call_end=SimpleNamespace(ending=False), queue_frame=AsyncMock(), llm=SimpleNamespace(queue_frame=AsyncMock()))
detach = Mock() detach = Mock()
service = SimpleNamespace(subscribe=Mock(return_value=detach), submit=AsyncMock(return_value={"id": "task_1", "status": "queued"})) service = SimpleNamespace(subscribe=Mock(return_value=detach), submit=AsyncMock(return_value={"id": "task_1", "status": "queued"}))
bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(DynamicVariableStore({})), service) bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(DynamicVariableStore({})), service, AsyncMock(return_value=cfg.task_backend))
functions = bridge.functions() functions = bridge.functions()
self.assertEqual(functions[0][0].name, "delegate_task") self.assertEqual(functions[0][0].name, "delegate_task")
callback = AsyncMock() callback = AsyncMock()
@@ -62,10 +62,46 @@ class PromptTaskBridgeTest(unittest.IsolatedAsyncioTestCase):
runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=None) runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=None)
service = SimpleNamespace(submit=AsyncMock(return_value={"id": "task_1"})) service = SimpleNamespace(submit=AsyncMock(return_value={"id": "task_1"}))
store = DynamicVariableStore({"project": "demo"}) store = DynamicVariableStore({"project": "demo"})
bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(store), service) bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(store), service, AsyncMock(return_value=cfg.task_backend))
schema, handler = bridge.functions()[0] schema, handler = bridge.functions()[0]
self.assertEqual(schema.to_default_dict()["parameters"]["properties"], {}) self.assertEqual(schema.to_default_dict()["parameters"]["properties"], {})
callback = AsyncMock() callback = AsyncMock()
await handler(SimpleNamespace(arguments={"instruction": "unauthorized override"}, tool_call_id="call_1", result_callback=callback)) await handler(SimpleNamespace(arguments={"instruction": "unauthorized override"}, tool_call_id="call_1", result_callback=callback))
self.assertEqual(service.submit.await_args.kwargs["instruction"], expected) self.assertEqual(service.submit.await_args.kwargs["instruction"], expected)
self.assertEqual(service.submit.await_args.kwargs["variable_state"]["values"], store.values) self.assertEqual(service.submit.await_args.kwargs["variable_state"]["values"], store.values)
async def test_each_delegation_uses_refreshed_backend_and_rejects_disabled_resources(self):
cfg = self.config(conversation_id="conv_1", dynamic_variables={"system__agent_id": "asst_1"})
runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=None)
service = SimpleNamespace(submit=AsyncMock(return_value={"id": "task_1"}))
updated = cfg.task_backend.model_copy(deep=True)
updated.secrets = {"password": "rotated"}
loader = AsyncMock(side_effect=[updated, ValueError("任务后端不存在或未启用")])
bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(DynamicVariableStore({})), service, loader)
handler = bridge.functions()[0][1]
callback = AsyncMock()
params = SimpleNamespace(arguments={"instruction": "检查项目"}, tool_call_id="call_1", result_callback=callback)
await handler(params)
self.assertIs(service.submit.await_args.kwargs["backend"], updated)
await handler(params)
self.assertEqual(loader.await_count, 2)
service.submit.assert_awaited_once()
self.assertEqual(callback.await_args.args[0]["status"], "error")
async def test_live_loader_rejects_binding_changes_and_revalidates_resources(self):
from unittest.mock import MagicMock, patch
from services.brains.prompt_tasks import load_current_backend
cfg = self.config()
assistant = SimpleNamespace(dynamic_variable_definitions={})
session = SimpleNamespace(get=AsyncMock(return_value=assistant))
database = MagicMock()
database.__aenter__ = AsyncMock(return_value=session)
database.__aexit__ = AsyncMock(return_value=False)
updated = cfg.task_backend.model_copy(update={"id": "different_backend"})
with patch("db.session.SessionLocal", return_value=database), patch("services.task_backend_resources.resolve_task_backend", AsyncMock(return_value=updated)) as resolve:
with self.assertRaisesRegex(ValueError, "绑定已更新"):
await load_current_backend("asst_1", cfg.task_backend, {})
resolve.assert_awaited_once_with(session, assistant)
resolve.side_effect = ValueError("模型已停用")
with self.assertRaisesRegex(ValueError, "模型已停用"):
await load_current_backend("asst_1", cfg.task_backend, {})
+34 -3
View File
@@ -134,7 +134,7 @@ class ReasonerNativeTest(unittest.IsolatedAsyncioTestCase):
await asyncio.wait_for(wait(), 5) await asyncio.wait_for(wait(), 5)
async def test_explicit_completion_uses_actual_native_backend_and_clears_context(self): async def test_explicit_completion_uses_actual_native_backend_and_clears_context(self):
await self.start_reasoner([[('complete_task', {"result": "显式模拟结果"})]]) await self.start_reasoner([[('complete_task', {"status": "succeeded", "result": "显式模拟结果"})]])
await asyncio.wait_for(self.finished.wait(), 5) await asyncio.wait_for(self.finished.wait(), 5)
self.assertEqual(self.results[0]["status"], "succeeded") self.assertEqual(self.results[0]["status"], "succeeded")
self.assertEqual(self.results[0]["result"], "显式模拟结果") self.assertEqual(self.results[0]["result"], "显式模拟结果")
@@ -150,10 +150,41 @@ class ReasonerNativeTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("complete_task", self.results[0]["result"]) self.assertIn("complete_task", self.results[0]["result"])
self.assertNotIn("private reasoning", str(self.events)) self.assertNotIn("private reasoning", str(self.events))
async def test_explicit_partial_failure_preserves_failed_business_result(self):
await self.start_reasoner([[('complete_task', {
"status": "failed", "result": "只完成查询;写入服务不可用,目标未完整完成。",
})]])
await asyncio.wait_for(self.finished.wait(), 5)
self.assertEqual(self.results[0]["status"], "failed")
self.assertIn("报告任务未完成", self.results[0]["summary"])
self.assertIn("只完成查询", self.results[0]["result"])
self.assertIsNone(self.executor._worker)
async def test_missing_completion_status_does_not_accept_success(self):
await self.start_reasoner([
[('complete_task', {"result": "执行结束,但未报告目标是否完成"})],
"simulated model stopped without correcting completion status",
])
await asyncio.wait_for(self.finished.wait(), 5)
self.assertEqual(self.results[0]["status"], "failed")
self.assertIsNone(self.executor._final)
self.assertEqual(len(self.model.contexts), 2)
self.assertIn("必须提供 succeeded 或 failed", str(self.model.contexts[1]))
async def test_invalid_completion_status_requires_model_correction(self):
await self.start_reasoner([
[('complete_task', {"status": "partial", "result": "只完成查询"})],
[('complete_task', {"status": "failed", "result": "只完成查询,目标未完成"})],
])
await asyncio.wait_for(self.finished.wait(), 5)
self.assertEqual(self.results[0]["status"], "failed")
self.assertEqual(self.results[0]["result"], "只完成查询,目标未完成")
self.assertEqual(len(self.model.contexts), 2)
async def test_question_reply_continues_same_native_context(self): async def test_question_reply_continues_same_native_context(self):
await self.start_reasoner([ await self.start_reasoner([
[('ask_user', {"question": "需要哪一天的数据?"})], [('ask_user', {"question": "需要哪一天的数据?"})],
[('complete_task', {"result": "已按用户回答完成"})], [('complete_task', {"status": "succeeded", "result": "已按用户回答完成"})],
]) ])
await self.wait_status("waiting_input") await self.wait_status("waiting_input")
self.assertFalse(self.finished.is_set()) self.assertFalse(self.finished.is_set())
@@ -186,7 +217,7 @@ class ReasonerNativeTest(unittest.IsolatedAsyncioTestCase):
# Isolate the HTTP side effect while retaining real argument/result handling. # Isolate the HTTP side effect while retaining real argument/result handling.
with patch("services.tool_executor.ToolExecutor._execute_http", AsyncMock(return_value={"status": "ok", "data": {"answer": "after"}})) as http: with patch("services.tool_executor.ToolExecutor._execute_http", AsyncMock(return_value={"status": "ok", "data": {"answer": "after"}})) as http:
await self.start_reasoner([[('lookup', {})], [('complete_task', {"result": "查询完成"})]], config) await self.start_reasoner([[('lookup', {})], [('complete_task', {"status": "succeeded", "result": "查询完成"})]], config)
await asyncio.wait_for(self.finished.wait(), 5) await asyncio.wait_for(self.finished.wait(), 5)
self.assertEqual(self.results[0]["status"], "succeeded") self.assertEqual(self.results[0]["status"], "succeeded")
self.assertEqual(http.await_args.args[1], {"customer": "local-customer"}) self.assertEqual(http.await_args.args[1], {"customer": "local-customer"})
+2 -2
View File
@@ -60,7 +60,7 @@ class ReasonerServiceTest(unittest.IsolatedAsyncioTestCase):
) )
async def test_snapshot_is_frozen_and_business_projection_has_no_secrets(self): async def test_snapshot_is_frozen_and_business_projection_has_no_secrets(self):
model = SimulatedLLM([[('complete_task', {"result": "模拟任务完成"})]]) model = SimulatedLLM([[('complete_task', {"status": "succeeded", "result": "模拟任务完成"})]])
self.backend.instructions = "为 {{customer}} 核查结果" self.backend.instructions = "为 {{customer}} 核查结果"
self.backend.config["instructions"] = "字面模板 {{customer}}" self.backend.config["instructions"] = "字面模板 {{customer}}"
state = {"values": {"customer": "before"}, "definitions": {"customer": {"type": "string"}}, "secrets": {"secret__token": "private-token"}} state = {"values": {"customer": "before"}, "definitions": {"customer": {"type": "string"}}, "secrets": {"secret__token": "private-token"}}
@@ -83,7 +83,7 @@ class ReasonerServiceTest(unittest.IsolatedAsyncioTestCase):
model = SimulatedLLM([ model = SimulatedLLM([
[('ask_user', {"question": "选择日期?"})], [('ask_user', {"question": "选择日期?"})],
[('ask_user', {"question": "选择范围?"})], [('ask_user', {"question": "选择范围?"})],
[('complete_task', {"result": "两个问题均已回答"})], [('complete_task', {"status": "succeeded", "result": "两个问题均已回答"})],
]) ])
with patch("services.background_tasks.reasoner.create_reasoner_llm", return_value=model): with patch("services.background_tasks.reasoner.create_reasoner_llm", return_value=model):
task = await self.submit() task = await self.submit()
+5 -1
View File
@@ -50,6 +50,10 @@ Speaker 使用现有 ASR → 对话 LLM → TTS 链路,负责交流、澄清
默认任务时限为 600 秒,可设置 30–7200 秒。资源配置保存时和助手绑定时都会校验模型、工具及变量引用。修改资源不会改变已创建任务的冻结配置。 默认任务时限为 600 秒,可设置 30–7200 秒。资源配置保存时和助手绑定时都会校验模型、工具及变量引用。修改资源不会改变已创建任务的冻结配置。
Speaker 每次委派前会重新读取后端、模型、工具及 MCP Server 的有效状态和凭据;助手绑定、任务要求来源或变量声明发生变化时,需要重新连接。业务变量使用提交时的快照,指令中的系统时间沿用共享渲染器,在执行时刷新。
停用资源允许保留暂时失效的引用。若后端仍被助手绑定,新会话会因配置无效而拒绝启动;先解绑或重新启用即可。已有通话继续,但新委派会重新校验并拒绝使用停用资源。
### 内置 Reasoner ### 内置 Reasoner
Reasoner 引用 `openai-llm` 或 `dashscope-llm` 的 LLM 模型资源。即使与 Speaker 选择同一资源,每个任务也会创建独立的模型服务、上下文和运行设置。 Reasoner 引用 `openai-llm` 或 `dashscope-llm` 的 LLM 模型资源。即使与 Speaker 选择同一资源,每个任务也会创建独立的模型服务、上下文和运行设置。
@@ -84,7 +88,7 @@ Pipecat 负责 Job 派发、FIFO 串行、超时和取消。`TaskService` 负责
- **并发与部署**:仅单应用进程、一个 Uvicorn worker;所有任务全局并发固定为 1。已有未结束的 OpenCode 句柄会阻止新任务提交到后端,需先核查或停止旧任务。该约束无法控制平台之外的 OpenCode 客户端。 - **并发与部署**:仅单应用进程、一个 Uvicorn worker;所有任务全局并发固定为 1。已有未结束的 OpenCode 句柄会阻止新任务提交到后端,需先核查或停止旧任务。该约束无法控制平台之外的 OpenCode 客户端。
- **截止时间**:新任务在创建时保存截止时间,覆盖排队和执行;OpenCode 重连观察或应用重启后使用剩余时间,不重新延长截止时间。迁移前没有截止时间的历史任务保留原有观察时限语义。 - **截止时间**:新任务在创建时保存截止时间,覆盖排队和执行;OpenCode 重连观察或应用重启后使用剩余时间,不重新延长截止时间。迁移前没有截止时间的历史任务保留原有观察时限语义。
- **成功判定**:OpenCode 的 204 只表示接收。需要取得关联到本次 message ID 的完成消息;Reasoner 需调用明确完成控制函数。 - **成功判定**:OpenCode 的 204 只表示接收,需要取得关联到本次 message ID 的完成消息。Reasoner 通过 `complete_task(status, result)` 报告成功或失败;仅完成部分目标时标为失败并说明限制。这是执行模型的结果报告,平台没有独立核验业务目标。
- **取消**:先取消原生执行 Job,再核查对应后端。OpenCode 需 abort 后确认状态,未确认时保留「停止待确认」。Reasoner 可停止本地执行;已发出的 HTTP/MCP 请求可能已有副作用,应核查业务系统结果。 - **取消**:先取消原生执行 Job,再核查对应后端。OpenCode 需 abort 后确认状态,未确认时保留「停止待确认」。Reasoner 可停止本地执行;已发出的 HTTP/MCP 请求可能已有副作用,应核查业务系统结果。
- **恢复**:OpenCode 使用已存 session/message 句柄重新观察,不重发未知提交。Reasoner 在应用重启后标为中断失败,保留记录,不自动重放可能有副作用的工具。 - **恢复**:OpenCode 使用已存 session/message 句柄重新观察,不重发未知提交。Reasoner 在应用重启后标为中断失败,保留记录,不自动重放可能有副作用的工具。
- **输入与目标变更**:共同支持查询、取消、回答当前待处理问题。首版不支持任意运行中 steer;修改目标时先停止,再创建新任务。 - **输入与目标变更**:共同支持查询、取消、回答当前待处理问题。首版不支持任意运行中 steer;修改目标时先停止,再创建新任务。
@@ -147,6 +147,7 @@ export function ComponentsTaskBackendsPage() {
<label htmlFor="backend-enabled" className="text-sm">启用此任务后端</label> <label htmlFor="backend-enabled" className="text-sm">启用此任务后端</label>
<Switch id="backend-enabled" checked={draft.enabled} onCheckedChange={enabled => setDraft(previous => ({ ...previous, enabled }))} /> <Switch id="backend-enabled" checked={draft.enabled} onCheckedChange={enabled => setDraft(previous => ({ ...previous, enabled }))} />
</div> </div>
{!draft.enabled && <p className="text-xs leading-5 text-muted-foreground">停用后,已绑定助手需解绑或重新启用此后端才能开启新会话。已创建的任务继续使用提交时的配置。</p>}
{draft.type === "reasoner" {draft.type === "reasoner"
? <ReasonerFields draft={draft} setDraft={setDraft} models={models} tools={tools} /> ? <ReasonerFields draft={draft} setDraft={setDraft} models={models} tools={tools} />
: <OpenCodeFields draft={draft} setDraft={setDraft} hasPassword={Boolean(editing?.secrets.password)} />} : <OpenCodeFields draft={draft} setDraft={setDraft} hasPassword={Boolean(editing?.secrets.password)} />}
+5 -3
View File
@@ -12,7 +12,7 @@
| B-007 | 推理强度 | provider_default 或 low/medium/high;非默认值要求模型资源显式声明 supportsReasoningEffort,按 reasoning_effort 参数发送,不支持时拒绝 | semantic | default | | B-007 | 推理强度 | provider_default 或 low/medium/high;非默认值要求模型资源显式声明 supportsReasoningEffort,按 reasoning_effort 参数发送,不支持时拒绝 | semantic | default |
| B-008 | 技能 | Reasoner 使用后端资源中的命名技能正文;OpenCode 使用远端原生技能/agent,平台仅保存说明,不伪装远程安装或权限隔离 | semantic | default | | B-008 | 技能 | Reasoner 使用后端资源中的命名技能正文;OpenCode 使用远端原生技能/agent,平台仅保存说明,不伪装远程安装或权限隔离 | semantic | default |
| B-009 | 外部选择 | OpenCode保留地址/目录/服务鉴权,新增可选 agent 与 modelProviderId/modelId;推理强度及沙箱由远端管理 | interface | default | | B-009 | 外部选择 | OpenCode保留地址/目录/服务鉴权,新增可选 agent 与 modelProviderId/modelId;推理强度及沙箱由远端管理 | interface | default |
| B-010 | 执行结果 | Reasoner通过明确完成/提问控制函数返回业务结果;idle、普通文本、接收回执不能直接证明成功 | semantic | default | | B-010 | 执行结果 | Reasoner通过complete_task(status,result)明确报告目标成功或失败;部分完成记为failed并说明限制。该状态是执行模型的报告,非平台独立验证;idle、普通文本、接收回执不证明成功 | semantic | sweep |
| B-011 | 生命周期与取消 | 语音断线继续;取消停止当前任务;原生取消不代表外部工具或远端副作用被回滚,需核查远端停止 | semantic | user | | B-011 | 生命周期与取消 | 语音断线继续;取消停止当前任务;原生取消不代表外部工具或远端副作用被回滚,需核查远端停止 | semantic | user |
| B-012 | 恢复 | OpenCode只观察已有句柄;内置Reasoner重启标记中断,不自动重放工具;历史任务保留旧恢复语义 | semantic | default | | B-012 | 恢复 | OpenCode只观察已有句柄;内置Reasoner重启标记中断,不自动重放工具;历史任务保留旧恢复语义 | semantic | default |
| B-013 | 时限与并发 | 新任务保存截止时间,排队和执行均计时;全局原生串行并发1,保留远端状态未确认时阻止新提交策略 | semantic | default | | B-013 | 时限与并发 | 新任务保存截止时间,排队和执行均计时;全局原生串行并发1,保留远端状态未确认时阻止新提交策略 | semantic | default |
@@ -22,6 +22,8 @@
| B-017 | F0过渡 | Walking skeleton短暂兼容旧tool提交入口以保持累计检查;迁移阶段移除该过渡入口,不保留为新产品接口 | interface | default | | B-017 | F0过渡 | Walking skeleton短暂兼容旧tool提交入口以保持累计检查;迁移阶段移除该过渡入口,不保留为新产品接口 | interface | default |
| B-018 | 回退迁移 | 0027为数据语义迁移,downgrade明确拒绝,需备份恢复;不能把新的Reasoner配置静默回退成旧task工具 | semantic | default | | B-018 | 回退迁移 | 0027为数据语义迁移,downgrade明确拒绝,需备份恢复;不能把新的Reasoner配置静默回退成旧task工具 | semantic | default |
| B-019 | 切换Realtime | 已绑定任务后端时复用现有配置移除确认交互,确认切换后解除绑定,后端资源保留 | semantic | default | | B-019 | 切换Realtime | 已绑定任务后端时复用现有配置移除确认交互,确认切换后解除绑定,后端资源保留 | semantic | default |
| B-020 | 配置有效性 | 后端配置拒绝未知字段;资源编辑验证已绑定助手,模型/工具失效时阻止保存和执行,不静默漏掉能力 | semantic | default | | B-020 | 配置有效性 | 后端配置拒绝未知字段;启用资源的编辑验证已绑定助手,模型/工具失效时阻止保存和委派;停用资源允许保留结构合法但引用失效的配置 | semantic | sweep |
| B-021 | 类型与凭据 | 后端资源可切换类型,但必须提交完整合法配置;OpenCode转Reasoner清除服务凭据,同类型空值/掩码保留原凭据 | interface | default | | B-021 | 类型与凭据 | 后端资源可切换类型,但必须提交完整合法配置;OpenCode转Reasoner清除服务凭据,同类型空值/掩码保留原凭据 | interface | default |
| B-022 | 指令模板 | 助手绑定 instructions 的 {{变量}} 使用任务接受时冻结的变量单次渲染;资源 instructions 和技能正文按字面使用,不访问密钥变量 | semantic | default | | B-022 | 指令模板 | 助手绑定 instructions 的业务变量使用接受时冻结的值单次渲染;system__time/system__time_utc沿用共享渲染器执行时刷新。资源 instructions 和技能正文按字面使用,不访问密钥变量 | semantic | sweep |
| B-023 | 通话内更新 | 每次Speaker委派前重新读取后端、模型、工具和MCP启用状态及凭据;绑定或任务参数来源改变时要求重新连接。接受后的任务继续用快照,不因配置编辑撤销已有副作用 | semantic | sweep |
| B-024 | 停用影响 | 允许停用仍被引用的后端并保留绑定;新助手会话因配置校验失败不能启动,需先解绑或启用。已有Speaker通话保留,但新委派会重新校验并拒绝停用资源 | semantic | sweep |
@@ -0,0 +1,42 @@
{
"undeclared": [
{
"site": "backend/services/brains/prompt_tasks.py:29; backend/services/brains/prompt_tasks.py:55; backend/services/background_tasks/service.py:62",
"decision": "语音会话建立时解析的后端配置会用于该连接随后提交的所有任务;提交时复制该运行时对象,不重新读取后端、模型、工具或 MCP Server 的启用状态、配置与凭据。",
"why_it_matters": "管理员在通话期间修改配置、停用能力或轮换凭据后,该通话仍可使用旧配置创建新任务。B-004 声明任务提交后的快照冻结,但没有声明提交前配置已经固定于语音连接;B-020 也未说明有效性检查只在解析时进行。",
"class": "semantic"
},
{
"site": "backend/services/task_backend_resources.py:135; backend/services/config_resolver.py:328; backend/routes/task_backends.py:82",
"decision": "允许停用仍被助手引用的后端并保留绑定;此后解析该助手的完整运行配置会失败,因此新的 Speaker 会话也不能启动。",
"why_it_matters": "停用任务后端的影响范围是整个已绑定助手的新会话,而不仅是任务委派。规格和假设记录没有声明这一影响范围。",
"class": "semantic"
},
{
"site": "backend/services/background_tasks/reasoner.py:148; backend/services/background_tasks/reasoner.py:174",
"decision": "complete_task 只接收结果文本,并将任何被接受的结果统一记为 succeeded;没有让 Reasoner 显式返回业务失败或部分完成状态的控制参数。",
"why_it_matters": "模型即使通过结果文本说明目标无法完成,调用 complete_task 后任务卡片仍显示成功。B-010 声明了显式完成控制,但未定义 succeeded 表示结束执行还是业务目标达成。",
"class": "semantic"
}
],
"stale_rows": [
{
"id": "B-020",
"site": "backend/routes/task_backends.py:37; backend/routes/task_backends.py:82",
"stated": "资源编辑验证已绑定助手,模型/工具失效时阻止保存。",
"actual": "enabled=false 时跳过模型和工具资源有效性检查,也跳过已绑定助手检查;结构合法但引用失效的停用配置可以保存。启用状态下的检查与记录一致。",
"why_it_matters": "记录没有声明停用资源的保存例外,因此描述的配置有效性保证强于实际代码。",
"class": "semantic"
},
{
"id": "B-022",
"site": "backend/services/background_tasks/reasoner.py:107; backend/services/background_tasks/opencode.py:91; backend/services/runtime_variables.py:155",
"stated": "助手绑定 instructions 的变量使用任务接受时冻结的变量单次渲染。",
"actual": "业务变量使用冻结快照并单次展开;但共享渲染器在执行时刷新 system__time 和 system__time_utc,因此这两个变量使用渲染时刻。最新 OpenCode 可选变量修复正确保留了既有可选变量语义,不改变此时钟行为。",
"why_it_matters": "任务排队后,包含系统时间的指令会得到执行时刻,而不是接受任务时刻;记录需要明确这个例外。",
"class": "semantic"
}
],
"semantic_undeclared": 3,
"verdict": "gaps"
}
@@ -0,0 +1,176 @@
{
"status": "passed_with_limitations",
"method": "Headless Google Chrome via temporary Playwright Core; built Next frontend at localhost:3217",
"mock_boundary": "Every /api request intercepted with explicit in-memory fixtures; non-local requests aborted. No backend, real model, OpenCode, database or real credentials contacted.",
"prior_attempts": [
{
"outcome": "Reasoner and OpenCode saves passed; narrow resource row edit was obstructed with existing expanded sidebar at390px. Repeated using existing sidebar collapse control; no application code changed."
}
],
"fixtures": {
"models": [
"UI Strong Model: supportsReasoningEffort=true",
"UI Basic Model: supportsReasoningEffort=false"
],
"tools": [
"UI HTTP Tool",
"UI Client Tool (must be excluded from Reasoner picker)"
]
},
"steps": [
{
"name": "Render dedicated resources page and open Reasoner form",
"status": "passed",
"details": {
"desktop_layout": {
"viewport": 1440,
"width": 751.712158203125,
"left": 344.1438903808594,
"right": 1095.8560485839844,
"scrollWidth": 768,
"clientWidth": 768,
"horizontalOverflow": false,
"outsideViewport": false
}
}
},
{
"name": "Configure Reasoner model, high effort, inline skill and server-only tool",
"status": "passed",
"details": {
"captured_payload": {
"name": "UI Reasoner Smoke",
"description": "",
"enabled": true,
"type": "reasoner",
"secrets": {},
"config": {
"instructions": "",
"timeoutSeconds": 600,
"maxConcurrency": 1,
"modelResourceId": "ui_model_strong",
"reasoningEffort": "high",
"toolBindings": [
{
"toolId": "ui_http",
"argumentBindings": {
"query": {
"source": "model"
}
},
"templateBindings": {},
"resultAssignments": {}
}
],
"skills": [
{
"name": "UI Analysis",
"content": "Analyze constraints before proposing a result."
}
]
}
}
}
},
{
"name": "Edit preserves Reasoner values; unsupported model disables nondefault effort",
"status": "passed"
},
{
"name": "Configure OpenCode connection, agent, paired model and remote skill note",
"status": "passed",
"details": {
"captured_payload": {
"name": "UI OpenCode Smoke",
"description": "",
"enabled": true,
"type": "opencode",
"secrets": {
"password": "<fake fixture credential>"
},
"config": {
"instructions": "",
"timeoutSeconds": 600,
"maxConcurrency": 1,
"url": "http://127.0.0.1:9",
"directory": "/test-fixture/repo",
"username": "opencode",
"agent": "build",
"modelProviderId": "ui-provider",
"modelId": "ui-model",
"skillsNote": "Installed remotely; fixture note only."
}
}
}
},
{
"name": "Responsive resource page and OpenCode modal fit a 390px viewport",
"status": "passed",
"details": {
"page_layout": {
"viewport": 390,
"width": 390,
"left": 0,
"right": 390,
"scrollWidth": 390,
"clientWidth": 390,
"horizontalOverflow": false,
"outsideViewport": false
},
"dialog_layout": {
"viewport": 390,
"width": 340.0999755859375,
"left": 24.950002670288086,
"right": 365.0499782562256,
"scrollWidth": 358,
"clientWidth": 358,
"horizontalOverflow": false,
"outsideViewport": false
}
}
},
{
"name": "Prompt binds backend independently and sends taskBackend without ToolBinding",
"status": "passed",
"details": {
"captured_task_backend": {
"backendId": "ui_backend_1",
"instructions": "Return clear evidence and limitations.",
"instructionSource": {
"source": "model"
}
},
"ordinary_tool_bindings": []
}
},
{
"name": "Migrated variable instruction source survives fetch/edit/save unchanged",
"status": "incomplete",
"error": "Optional legacy-variable fetch/edit/save follow-up timed out waiting for the task-instruction editor after page reload. Not diagnosed; this browser run does not claim to verify that scenario."
}
],
"console_errors": [],
"page_errors": [],
"unexpected_requests": [],
"captures": [
"/private/tmp/ai-video-browser-smoke/reasoner-desktop.png",
"/private/tmp/ai-video-browser-smoke/opencode-desktop.png",
"/private/tmp/ai-video-browser-smoke/expanded-sidebar-mobile-limitation.png",
"/private/tmp/ai-video-browser-smoke/opencode-mobile.png",
"/private/tmp/ai-video-browser-smoke/prompt-binding-desktop.png"
],
"limitations": [
"Browser smoke verifies rendered client behavior and request payloads against mocked APIs, not backend validation or genuine model/coding execution.",
"No microphone/WebRTC connection was started.",
"Responsive checks cover new resource page/dialog with the existing sidebar manually collapsed. Existing AppShell does not auto-collapse its252px sidebar on resize; the expanded sidebar obstructed row edit at390px.",
"Optional migrated variable instructionSource reload was not verified in this browser run; no backend or migration behavior is inferred from it.",
"Screenshots are diagnostic captures; mobile capture occurred during modal transition. Geometry checks, not pixel comparison, established the responsive result."
],
"completed_at": "2026-10-24T01:25:20.247Z",
"acceptance": "Core frontend browser smoke passed against explicitly mocked APIs; optional legacy variable reload remains unverified.",
"cleanup": {
"temporary_browser_closed": true,
"local_next_server_stopped": true,
"project_dependencies_changed": false
}
}
@@ -0,0 +1,7 @@
{
"review_independence": "same-family",
"acceptance_status": "provisional",
"round_limit": 2,
"rounds_completed": 1,
"first_round_semantic_undeclared": 3
}