F2: delegate prompt tasks to native Reasoner or OpenCode backends

This commit is contained in:
Xin Wang
2026-10-23 23:46:29 +08:00
parent 529250173d
commit 16ebfe358c
14 changed files with 784 additions and 110 deletions
+4 -12
View File
@@ -6,8 +6,6 @@ from types import SimpleNamespace
from services.background_tasks.service import TaskService
from services.background_tasks.records import TERMINAL
from schemas import ToolUpsert
from routes.tools import _payload
class MemoryRepository:
@@ -58,7 +56,7 @@ class TaskServiceTest(unittest.IsolatedAsyncioTestCase):
SimulatedExecutor.confirms_stop = True
self.service = TaskService(MemoryRepository(), SimulatedExecutor)
await self.service.start()
self.tool = SimpleNamespace(type="task", id="tool_task", definition={"config": {"url": "https://remote.test", "directory": "/demo", "timeout_seconds": 30}}, secrets={"password": "never-public"})
self.backend = SimpleNamespace(type="opencode", id="backend_test", config={"url": "https://remote.test", "directory": "/demo", "timeout_seconds": 30}, secrets={"password": "never-public"})
async def asyncTearDown(self):
await self.service.close()
async def wait_status(self, task_id, expected):
@@ -66,7 +64,7 @@ class TaskServiceTest(unittest.IsolatedAsyncioTestCase):
while (await self.service.get(task_id)).status != expected:
await asyncio.sleep(.005)
async def submit(self, key="call_1"):
return await self.service.submit(assistant_id="asst_1", conversation_id="conv_1", tool=self.tool, instruction="执行测试", invocation_id=key)
return await self.service.submit(assistant_id="asst_1", conversation_id="conv_1", backend=self.backend, instruction="执行测试", invocation_id=key)
async def test_idempotence_public_projection_and_completion(self):
task = await self.submit()
again = await self.submit()
@@ -152,12 +150,6 @@ class TaskServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result["status"], "waiting_input")
self.assertEqual(result["inputRequest"], question)
def test_task_tool_preserves_masked_password(self):
body = ToolUpsert(name="后台编程", functionName="coding_task", definition={"type": "task", "config": {"url": "https://remote.test", "directory": "/demo"}}, secrets={"password": "****"})
data = _payload(body, {"password": "old-password"})
self.assertEqual(data["secrets"]["password"], "old-password")
class TaskApiTest(unittest.IsolatedAsyncioTestCase):
async def test_admin_auth_and_real_native_job_through_http_api(self):
import httpx
@@ -174,14 +166,14 @@ class TaskApiTest(unittest.IsolatedAsyncioTestCase):
async def database():
yield None
app.dependency_overrides[get_session] = database
cfg = SimpleNamespace(type="prompt", runtimeMode="pipeline", tool_bindings=[SimpleNamespace(tool_id="tool_task")], tools=[SimpleNamespace(type="task", id="tool_task", definition={"config": {"url": "https://remote.test", "directory": "/demo", "timeout_seconds": 30}}, secrets={})])
cfg = SimpleNamespace(type="prompt", runtimeMode="pipeline", task_backend=SimpleNamespace(type="opencode", id="backend_test", config={"url": "https://remote.test", "directory": "/demo", "timeout_seconds": 30}, secrets={}), dynamic_variables={}, dynamic_variable_definitions={}, secret_dynamic_variables={})
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://platform.test") as client:
unauthenticated = await client.get("/api/background-tasks?assistant_id=asst_1")
self.assertEqual(unauthenticated.status_code, 401)
app.dependency_overrides[require_admin] = lambda: {"username": "test"}
with patch.object(routes, "task_service", service), patch.object(routes, "resolve_runtime_config", AsyncMock(return_value=cfg)):
response = await client.post("/api/background-tasks", json={"assistantId": "asst_1", "toolId": "tool_task", "instruction": "测试请求", "requestId": "request_1"})
response = await client.post("/api/background-tasks", json={"assistantId": "asst_1", "instruction": "测试请求", "requestId": "request_1"})
self.assertEqual(response.status_code, 200)
task_id = response.json()["id"]
self.assertEqual(response.json()["assistantId"], "asst_1")
+9 -2
View File
@@ -32,7 +32,11 @@ class OpenCodeTaskTest(unittest.IsolatedAsyncioTestCase):
return httpx.Response(200, text="".join("data: " + json.dumps(e) + "\n\n" for e in events))
if request.url.path.endswith("prompt_async"):
self.assertFalse(submitted)
self.assertEqual(json.loads(request.content)["messageID"], "msg_test")
payload = json.loads(request.content)
self.assertEqual(payload["messageID"], "msg_test")
self.assertEqual(payload["agent"], "build")
self.assertEqual(payload["model"], {"providerID": "test-provider", "modelID": "test-model"})
self.assertEqual(payload["system"], "通用说明\n\n项目 demo")
submitted = True
return httpx.Response(204)
if request.url.path.endswith("/message"):
@@ -42,7 +46,10 @@ class OpenCodeTaskTest(unittest.IsolatedAsyncioTestCase):
if request.url.path == "/session/status":
return httpx.Response(200, json={})
raise AssertionError(request.url)
executor = OpenCodeExecutor({"url": "https://remote.test", "directory": "/workspace/demo"}, {}, transport=httpx.MockTransport(respond))
executor = OpenCodeExecutor({"url": "https://remote.test", "directory": "/workspace/demo", "agent": "build",
"model_provider_id": "test-provider", "model_id": "test-model",
"instructions": "通用说明", "_backend": {"instructions": "项目 {{project}}"},
"_variables": {"project": "demo"}}, {}, transport=httpx.MockTransport(respond))
async def emit(event):
updates.append(event)
result = await executor.run(handle, "修复测试", emit)
+30 -11
View File
@@ -1,22 +1,24 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from models import AssistantConfig, RuntimeTool, RuntimeToolBinding
from models import AssistantConfig, RuntimeTaskBackend
from services.brains.prompt_tasks import PromptTaskBridge
from services.tool_executor import ToolExecutor
from services.runtime_variables import DynamicVariableStore
class PromptTaskBridgeTest(unittest.IsolatedAsyncioTestCase):
def config(self, **kwargs):
return AssistantConfig(task_backend=RuntimeTaskBackend(id="backend_1", type="opencode"), **kwargs)
async def test_receipt_and_single_terminal_notification(self):
cfg = AssistantConfig(conversation_id="conv_1", dynamic_variables={"system__agent_id": "asst_1"})
cfg = self.config(conversation_id="conv_1", dynamic_variables={"system__agent_id": "asst_1"})
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)
tool = RuntimeTool(id="tool_task", name="coding", function_name="coding_task", type="task", definition={"type": "task", "config": {}})
binding = RuntimeToolBinding(toolId=tool.id, argumentBindings={"instruction": {"source": "model"}})
functions = bridge.functions(tool, binding)
functions = bridge.functions()
self.assertEqual(functions[0][0].name, "delegate_task")
callback = AsyncMock()
await functions[0][1](SimpleNamespace(arguments={"instruction": "修复测试"}, tool_call_id="call_1", result_callback=callback))
self.assertEqual(callback.await_args.args[0]["status"], "accepted")
@@ -31,22 +33,39 @@ class PromptTaskBridgeTest(unittest.IsolatedAsyncioTestCase):
async def test_test_runtime_never_submits_a_real_task(self):
runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=object())
service = SimpleNamespace(submit=AsyncMock())
bridge = PromptTaskBridge(AssistantConfig(), runtime, ToolExecutor(DynamicVariableStore({})), service)
tool = RuntimeTool(id="tool_task", name="coding", function_name="coding_task", type="task", definition={"type": "task", "config": {}})
bridge = PromptTaskBridge(self.config(), runtime, ToolExecutor(DynamicVariableStore({})), service)
callback = AsyncMock()
await bridge.functions(tool, None)[0][1](SimpleNamespace(result_callback=callback))
await bridge.functions()[0][1](SimpleNamespace(result_callback=callback))
service.submit.assert_not_awaited()
self.assertEqual(callback.await_args.args[0]["status"], "error")
async def test_test_runtime_never_controls_an_existing_real_task(self):
runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=object())
service = SimpleNamespace(get=AsyncMock(), cancel=AsyncMock(), send_input=AsyncMock())
bridge = PromptTaskBridge(AssistantConfig(), runtime, ToolExecutor(DynamicVariableStore({})), service)
tool = RuntimeTool(id="tool_task", name="coding", function_name="coding_task", type="task", definition={"type": "task", "config": {}})
for schema, handler in bridge.functions(tool, None)[1:]:
bridge = PromptTaskBridge(self.config(), runtime, ToolExecutor(DynamicVariableStore({})), service)
for schema, handler in bridge.functions()[1:]:
callback = AsyncMock()
await handler(SimpleNamespace(function_name=schema.name, arguments={"task_id": "real_task", "message": "yes"}, result_callback=callback))
self.assertEqual(callback.await_args.args[0]["status"], "error")
service.get.assert_not_awaited()
service.cancel.assert_not_awaited()
service.send_input.assert_not_awaited()
async def test_bound_instruction_sources_and_snapshot_ignore_model_overrides(self):
cases = [({"source": "literal", "value": "固定任务"}, "固定任务"),
({"source": "template", "value": "检查 {{project}}"}, "检查 demo"),
({"source": "variable", "variable_name": "project"}, "demo")]
for source, expected in cases:
with self.subTest(source=source):
cfg = self.config(conversation_id="conv_1", dynamic_variables={"system__agent_id": "asst_1"})
cfg.task_backend.instruction_source = source
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)
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)
+207
View File
@@ -0,0 +1,207 @@
"""Real native backend workers, with model output explicitly simulated.
No test in this module contacts a model provider or executes a remote task.
"""
from __future__ import annotations
import asyncio
from copy import deepcopy
import unittest
from unittest.mock import AsyncMock, patch
from models import RuntimeTaskBackend
from pipecat.frames.frames import (
FunctionCallFromLLM,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMTextFrame,
)
from pipecat.services.llm_service import LLMService
from pipecat.services.settings import LLMSettings
from services.background_tasks.native import NativeTaskRuntime
from services.background_tasks.reasoner import ReasonerExecutor, create_reasoner_llm
def backend_config(**overrides):
backend = {
"id": "backend_reasoner", "type": "reasoner", "name": "Reasoner",
"config": {"reasoning_effort": "provider_default", "skills": [{"name": "核查", "content": "先检查依据,再给结论。"}]},
"model": {"id": "model_reasoner", "capability": "LLM", "interface_type": "openai-llm",
"values": {"modelId": "simulated", "apiUrl": "https://model.example.invalid/v1"},
"secrets": {"apiKey": "simulated-key"}},
**overrides,
}
return {"_backend": backend, "_task_id": "task_reasoner"}
class SimulatedLLM(LLMService):
"""Feeds declared outputs into Pipecat's actual aggregation/tool loop."""
def __init__(self, steps):
super().__init__(settings=LLMSettings(
model="simulated", temperature=None, max_tokens=None, top_p=None, top_k=None,
frequency_penalty=None, presence_penalty=None, seed=None,
filter_incomplete_user_turns=False, user_turn_completion_config=None,
))
self.steps = iter(steps)
self.contexts = []
async def process_frame(self, frame, direction):
await super().process_frame(frame, direction)
if not isinstance(frame, LLMContextFrame):
await self.push_frame(frame, direction)
return
self.contexts.append(deepcopy(frame.context.messages))
step = next(self.steps, "simulated output without explicit completion")
await self.push_frame(LLMFullResponseStartFrame())
if isinstance(step, str):
await self.push_frame(LLMTextFrame(step))
else:
await self.run_function_calls([
FunctionCallFromLLM(function_name=name, tool_call_id=f"call_{len(self.contexts)}_{index}", arguments=arguments, context=frame.context)
for index, (name, arguments) in enumerate(step)
])
await self.push_frame(LLMFullResponseEndFrame())
class ReasonerSettingsTest(unittest.TestCase):
def test_nondefault_effort_requires_explicit_model_support(self):
config = backend_config()
config["_backend"]["config"]["reasoning_effort"] = "high"
with self.assertRaisesRegex(ValueError, "未声明"):
create_reasoner_llm(RuntimeTaskBackend.model_validate(config["_backend"]))
def test_effort_is_sent_as_real_parameter_without_changing_shared_model(self):
config = backend_config()
config["_backend"]["config"]["reasoning_effort"] = "high"
config["_backend"]["model"]["values"].update(
supportsReasoningEffort=True, extraBody={"reasoning_effort": "low", "provider_option": True},
)
backend = RuntimeTaskBackend.model_validate(config["_backend"])
service = create_reasoner_llm(backend)
params = service.build_chat_completion_params({"messages": []})
self.assertEqual(params["reasoning_effort"], "high")
self.assertEqual(params["extra_body"], {"provider_option": True})
self.assertEqual(backend.model.values["extraBody"]["reasoning_effort"], "low")
class ReasonerNativeTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.events = []
self.results = []
self.finished = asyncio.Event()
self.executor = None
async def execute(message, worker):
handle = await self.executor.prepare()
result = await self.executor.run(handle, "完成本次测试目标", self.emit)
self.results.append(result)
return result
async def cancel(message):
await self.executor.cancel(await self.executor.prepare())
async def on_result(message):
if message.source == self.runtime.executor.name:
self.finished.set()
self.runtime = NativeTaskRuntime(execute, cancel, self.ignore, on_result)
await self.runtime.start()
async def asyncTearDown(self):
await self.runtime.close()
async def ignore(self, message):
pass
async def emit(self, event):
self.events.append(event)
async def start_reasoner(self, steps, config=None):
self.model = SimulatedLLM(steps)
self.executor = ReasonerExecutor(config or backend_config(), {}, runtime=self.runtime)
patcher = patch("services.background_tasks.reasoner.create_reasoner_llm", return_value=self.model)
patcher.start()
self.addCleanup(patcher.stop)
return await self.runtime.submit("task_reasoner", 10)
async def wait_status(self, status):
async def wait():
while not any(event.get("status") == status for event in self.events):
await asyncio.sleep(0.01)
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 asyncio.wait_for(self.finished.wait(), 5)
self.assertEqual(self.results[0]["status"], "succeeded")
self.assertEqual(self.results[0]["result"], "显式模拟结果")
self.assertIn("核查", self.model.contexts[0][0]["content"])
self.assertIsNone(self.executor._worker)
self.assertTrue(self.runtime._run and not self.runtime._run.done())
self.assertFalse(any("显式模拟结果" in event.get("summary", "") for event in self.events))
async def test_plain_output_idle_does_not_claim_success_or_expose_reasoning(self):
await self.start_reasoner(["simulated private reasoning text"])
await asyncio.wait_for(self.finished.wait(), 5)
self.assertEqual(self.results[0]["status"], "failed")
self.assertIn("complete_task", self.results[0]["result"])
self.assertNotIn("private reasoning", str(self.events))
async def test_question_reply_continues_same_native_context(self):
await self.start_reasoner([
[('ask_user', {"question": "需要哪一天的数据?"})],
[('complete_task', {"result": "已按用户回答完成"})],
])
await self.wait_status("waiting_input")
self.assertFalse(self.finished.is_set())
request = next(event["input_request"] for event in self.events if event.get("status") == "waiting_input")
with self.assertRaisesRegex(ValueError, "已处理"):
await self.executor.send_input({**request, "id": "other_question"}, {"answers": [["周一"]]})
await self.executor.send_input(request, {"answers": [["周一"]]})
await asyncio.wait_for(self.finished.wait(), 5)
self.assertEqual(self.results[0]["status"], "succeeded")
self.assertEqual(len(self.model.contexts), 2)
self.assertIn("周一", str(self.model.contexts[1]))
async def test_cancellation_stops_only_the_task_pipeline(self):
job_id = await self.start_reasoner([[('ask_user', {"question": "等待测试回复"})]])
await self.wait_status("waiting_input")
await self.runtime.cancel(job_id)
await asyncio.wait_for(self.finished.wait(), 5)
self.assertIsNone(self.executor._worker)
self.assertTrue(self.runtime._run and not self.runtime._run.done())
async def test_http_tool_uses_binding_and_task_local_variable_store(self):
config = backend_config(tools=[{
"id": "tool_lookup", "name": "查询", "function_name": "lookup", "type": "http",
"definition": {"type": "http", "config": {"url": "https://tool.example.invalid", "parameters": [{"name": "customer", "required": True}]}},
}], tool_bindings=[{"tool_id": "tool_lookup", "argument_bindings": {"customer": {"source": "variable", "variable_name": "customer"}},
"result_assignments": {"answer": "answer"}}])
variables = {"customer": "local-customer", "answer": "before"}
config["_variables"] = variables
config["_variable_definitions"] = {name: {"type": "string"} for name in variables}
# 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 asyncio.wait_for(self.finished.wait(), 5)
self.assertEqual(self.results[0]["status"], "succeeded")
self.assertEqual(http.await_args.args[1], {"customer": "local-customer"})
self.assertEqual(self.executor._tools.store.values["answer"], "after")
self.assertEqual(variables["answer"], "before")
async def test_client_tools_and_control_name_collisions_are_rejected_before_model_start(self):
for kind, name in [("client", "browser_action"), ("http", "complete_task")]:
config = backend_config(tools=[{"id": "bad", "name": "bad", "type": kind, "function_name": name}])
executor = ReasonerExecutor(config, {}, runtime=self.runtime)
with patch("services.background_tasks.reasoner.create_reasoner_llm") as factory:
with self.assertRaises(ValueError):
executor._build_worker()
factory.assert_not_called()
if __name__ == "__main__":
unittest.main()
+89
View File
@@ -4,9 +4,12 @@ import json
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from models import RuntimeTaskBackend
from services.background_tasks.service import TaskService
from tests.test_background_tasks import MemoryRepository, SimulatedExecutor
from tests.test_reasoner_backend import SimulatedLLM, backend_config
class TaskBackendSpineTest(unittest.IsolatedAsyncioTestCase):
@@ -29,3 +32,89 @@ class TaskBackendSpineTest(unittest.IsolatedAsyncioTestCase):
artifact.write_text(json.dumps({"simulated_remote": True, "native_job": "completed", "backend_id": backend.id, "tool_definition_required": False}) + "\n")
finally:
await service.close()
class ReasonerServiceTest(unittest.IsolatedAsyncioTestCase):
"""Full business lifecycle with a real native worker and simulated model."""
async def asyncSetUp(self):
self.service = TaskService(MemoryRepository())
await self.service.start()
self.backend = RuntimeTaskBackend.model_validate(backend_config()["_backend"])
async def asyncTearDown(self):
await self.service.close()
async def wait_task(self, task_id, predicate):
async with asyncio.timeout(5):
while True:
record = await self.service.get(task_id)
if predicate(record):
return record
await asyncio.sleep(.005)
async def submit(self, *, variable_state=None):
return await self.service.submit(
assistant_id="asst_reasoner", conversation_id="conv_reasoner", backend=self.backend,
instruction="显式模拟任务", invocation_id="call_reasoner", variable_state=variable_state,
)
async def test_snapshot_is_frozen_and_business_projection_has_no_secrets(self):
model = SimulatedLLM([[('complete_task', {"result": "模拟任务完成"})]])
self.backend.instructions = "为 {{customer}} 核查结果"
self.backend.config["instructions"] = "字面模板 {{customer}}"
state = {"values": {"customer": "before"}, "definitions": {"customer": {"type": "string"}}, "secrets": {"secret__token": "private-token"}}
with patch("services.background_tasks.reasoner.create_reasoner_llm", return_value=model):
task = await self.submit(variable_state=state)
self.backend.config["skills"][0]["content"] = "changed after submission"
state["values"]["customer"] = "changed after submission"
record = await self.wait_task(task["id"], lambda item: item.status == "succeeded")
self.assertEqual(record.backend_type, "reasoner")
self.assertEqual(record.connection["_variables"]["customer"], "before")
self.assertEqual(record.connection["_secret_variables"]["secret__token"], "private-token")
self.assertNotIn("changed after submission", str(model.contexts))
self.assertIn("为 before 核查结果", model.contexts[0][0]["content"])
self.assertIn("字面模板 {{customer}}", model.contexts[0][0]["content"])
self.assertNotIn("private-token", str(record.public()))
self.assertNotIn("simulated-key", str(record.public()))
self.assertNotIn("_backend", record.public())
async def test_immediate_next_question_survives_reply_projection_update(self):
model = SimulatedLLM([
[('ask_user', {"question": "选择日期?"})],
[('ask_user', {"question": "选择范围?"})],
[('complete_task', {"result": "两个问题均已回答"})],
])
with patch("services.background_tasks.reasoner.create_reasoner_llm", return_value=model):
task = await self.submit()
first = await self.wait_task(task["id"], lambda item: item.status == "waiting_input")
await self.service.send_input(task["id"], {"answers": [["周一"]]})
second = await self.wait_task(task["id"], lambda item: item.status == "waiting_input" and item.input_request.get("id") != first.input_request["id"])
self.assertEqual(second.input_request["questions"][0]["question"], "选择范围?")
await self.service.send_input(task["id"], {"answers": [["本部门"]]})
finished = await self.wait_task(task["id"], lambda item: item.status == "succeeded")
self.assertEqual(finished.result, "两个问题均已回答")
self.assertFalse(finished.input_request)
async def test_shutdown_restart_does_not_replay_reasoner(self):
model = SimulatedLLM([[('ask_user', {"question": "需要补充信息"})]])
with patch("services.background_tasks.reasoner.create_reasoner_llm", return_value=model) as factory:
task = await self.submit()
await self.wait_task(task["id"], lambda item: item.status == "waiting_input")
await self.service.close()
await self.service.start()
record = await self.wait_task(task["id"], lambda item: item.status == "failed")
self.assertIn("未自动重放工具", record.summary)
self.assertEqual(factory.call_count, 1)
async def test_business_cancel_cleans_only_current_reasoner(self):
model = SimulatedLLM([[('ask_user', {"question": "需要补充信息"})]])
with patch("services.background_tasks.reasoner.create_reasoner_llm", return_value=model):
task = await self.submit()
await self.wait_task(task["id"], lambda item: item.status == "waiting_input")
executor = self.service._executors[task["id"]]
await self.service.cancel(task["id"])
record = await self.wait_task(task["id"], lambda item: item.status == "cancelled")
self.assertIsNone(executor._worker)
self.assertIn("已发出的工具请求", record.summary)
self.assertTrue(self.service.runtime._run and not self.service.runtime._run.done())