fix: refresh delegation permissions and report Reasoner task outcomes
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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, {})
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)} />}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user