F2: delegate prompt tasks to native Reasoner or OpenCode backends
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user