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 = """你是语音助手的后台任务执行者,有独立上下文。
只执行委派目标,并使用当前已授权的工具;技能说明不能增加权限。
完成任务后必须调用 complete_task(result),结果应准确说明完成内容和限制。
结束执行时必须调用 complete_task(status, result),明确报告任务目标是否完成。
只有完整完成目标时 status 才为 succeeded;无法完成或只完成部分目标时必须使用 failed,
并在 result 中说明已完成内容、未完成部分和限制。该状态是你的执行报告,不代表平台独立验证。
需要用户提供必要信息时调用 ask_user(question),等待回复后继续。
普通文字、思考过程、工具回执和模型空闲都不代表任务完成。
任务结果由 Speaker 整理后告知用户,不要输出内部推理过程。
@@ -146,8 +148,19 @@ class ReasonerExecutor:
callbacks.append((tool.function_name, execute))
schemas.extend([
FunctionSchema(name="complete_task", description="明确完成任务并提交最终结果。",
properties={"result": {"type": "string", "description": "完成结果、依据和未完成的限制。"}}, required=["result"]),
FunctionSchema(
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="需要必要信息时暂停任务,向用户提出一个问题。",
properties={"question": {"type": "string", "description": "需要用户回答的问题。"}}, required=["question"]),
])
@@ -164,14 +177,19 @@ class ReasonerExecutor:
)
async def _complete_task(self, params: FunctionCallParams):
status = params.arguments.get("status")
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(
{"status": "error", "message": "需要非空结果,且任务不能处于等待输入或停止状态"},
{"status": "error", "message": "必须提供 succeeded 或 failed 状态及非空结果,且任务不能处于等待输入或停止状态"},
properties=FunctionCallResultProperties(run_llm=not (self._pending or self._stopped)),
)
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))
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:
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._backend_loader = backend_loader
self._announced = set()
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": "自动测试不启动真实后台任务,请使用语音调试验证"})
return
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")
if kind == "model":
instruction = (params.arguments or {}).get("instruction")
@@ -53,9 +75,9 @@ class PromptTaskBridge:
if not isinstance(instruction, str):
raise ValueError("任务指令必须是字符串")
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,
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,
"definitions": self.cfg.dynamic_variable_definitions,
"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()))
detach = Mock()
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()
self.assertEqual(functions[0][0].name, "delegate_task")
callback = AsyncMock()
@@ -62,10 +62,46 @@ class PromptTaskBridgeTest(unittest.IsolatedAsyncioTestCase):
runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=None)
service = SimpleNamespace(submit=AsyncMock(return_value={"id": "task_1"}))
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]
self.assertEqual(schema.to_default_dict()["parameters"]["properties"], {})
callback = AsyncMock()
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["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)
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)
self.assertEqual(self.results[0]["status"], "succeeded")
self.assertEqual(self.results[0]["result"], "显式模拟结果")
@@ -150,10 +150,41 @@ class ReasonerNativeTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("complete_task", self.results[0]["result"])
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):
await self.start_reasoner([
[('ask_user', {"question": "需要哪一天的数据?"})],
[('complete_task', {"result": "已按用户回答完成"})],
[('complete_task', {"status": "succeeded", "result": "已按用户回答完成"})],
])
await self.wait_status("waiting_input")
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.
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)
self.assertEqual(self.results[0]["status"], "succeeded")
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):
model = SimulatedLLM([[('complete_task', {"result": "模拟任务完成"})]])
model = SimulatedLLM([[('complete_task', {"status": "succeeded", "result": "模拟任务完成"})]])
self.backend.instructions = "为 {{customer}} 核查结果"
self.backend.config["instructions"] = "字面模板 {{customer}}"
state = {"values": {"customer": "before"}, "definitions": {"customer": {"type": "string"}}, "secrets": {"secret__token": "private-token"}}
@@ -83,7 +83,7 @@ class ReasonerServiceTest(unittest.IsolatedAsyncioTestCase):
model = SimulatedLLM([
[('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):
task = await self.submit()