F2: delegate prompt tasks to native Reasoner or OpenCode backends
This commit is contained in:
@@ -76,9 +76,21 @@ class OpenCodeExecutor:
|
||||
async with client.stream("GET", "event") as stream:
|
||||
stream.raise_for_status()
|
||||
if not resume:
|
||||
payload = {"messageID": handle["message_id"], "parts": [{"type": "text", "text": instruction}]}
|
||||
if self.config.get("agent"):
|
||||
payload["agent"] = self.config["agent"]
|
||||
if self.config.get("model_provider_id") and self.config.get("model_id"):
|
||||
payload["model"] = {"providerID": self.config["model_provider_id"], "modelID": self.config["model_id"]}
|
||||
backend = self.config.get("_backend") or {}
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
store = DynamicVariableStore(self.config.get("_variables") or {})
|
||||
assistant_instructions = store.render(str(backend.get("instructions") or ""))
|
||||
instructions = "\n\n".join(value for value in (self.config.get("instructions"), assistant_instructions) if value)
|
||||
if instructions:
|
||||
payload["system"] = instructions
|
||||
response = await client.post(
|
||||
f"session/{handle['session_id']}/prompt_async",
|
||||
json={"messageID": handle["message_id"], "parts": [{"type": "text", "text": instruction}]},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
await emit({"status": "submitted", "summary": "远端已接收,等待执行"})
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Task-local Reasoner built on Pipecat's public backend worker contract.
|
||||
|
||||
Pipecat owns the context, model/tool loop and Jobs. This adapter only turns
|
||||
explicit task controls into the same persisted events as a remote backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import deepcopy
|
||||
from uuid import uuid4
|
||||
|
||||
from models import AssistantConfig, RuntimeModelResource, RuntimeTaskBackend
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.pipeline.job_context import JobEvent, JobParams
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.services.llm_service import FunctionCallParams, FunctionCallResultProperties
|
||||
from pipecat.services.settings import LLMSettings
|
||||
from pipecat.workers.llm.backend_llm_worker import (
|
||||
ATTACHED_UPDATE_TYPE,
|
||||
ATTACH_JOB_NAME,
|
||||
ERROR_UPDATE_TYPE,
|
||||
IDLE_UPDATE_TYPE,
|
||||
MESSAGE_JOB_NAME,
|
||||
TOOL_CALL_UPDATE_TYPE,
|
||||
BackendLLMWorker,
|
||||
)
|
||||
|
||||
from services.background_tasks.native import NativeTaskRuntime
|
||||
from services.pipecat.service_factory import config_with_resource, create_llm
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.system_tools import RESERVED_TOOL_NAMES
|
||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||
|
||||
Emit = Callable[[dict], Awaitable[None]]
|
||||
CONTROL_NAMES = frozenset({"complete_task", "ask_user"})
|
||||
|
||||
TASK_INSTRUCTIONS = """你是语音助手的后台任务执行者,有独立上下文。
|
||||
只执行委派目标,并使用当前已授权的工具;技能说明不能增加权限。
|
||||
完成任务后必须调用 complete_task(result),结果应准确说明完成内容和限制。
|
||||
需要用户提供必要信息时调用 ask_user(question),等待回复后继续。
|
||||
普通文字、思考过程、工具回执和模型空闲都不代表任务完成。
|
||||
任务结果由 Speaker 整理后告知用户,不要输出内部推理过程。
|
||||
"""
|
||||
|
||||
|
||||
def create_reasoner_llm(backend: RuntimeTaskBackend):
|
||||
"""Use the shared model factory, with independent settings for this task."""
|
||||
resource = backend.model
|
||||
if resource is None or resource.capability != "LLM":
|
||||
raise ValueError("内置 Reasoner 需要配置独立的 LLM 模型资源")
|
||||
effort = backend.config.get("reasoning_effort", "provider_default")
|
||||
if effort not in {"provider_default", "low", "medium", "high"}:
|
||||
raise ValueError("不支持的推理强度")
|
||||
if effort != "provider_default" and resource.values.get("supportsReasoningEffort") is not True:
|
||||
raise ValueError("该模型未声明支持 reasoning_effort,不能设置推理强度")
|
||||
|
||||
# Do not mutate the reusable model resource or its Speaker settings.
|
||||
resource = RuntimeModelResource.model_validate(resource.model_dump(mode="json"))
|
||||
if effort != "provider_default":
|
||||
extra_body = resource.values.get("extraBody")
|
||||
if isinstance(extra_body, dict):
|
||||
# The explicit task setting wins if a model preset contains it too.
|
||||
resource.values["extraBody"] = {key: value for key, value in extra_body.items() if key != "reasoning_effort"}
|
||||
llm = create_llm(config_with_resource(AssistantConfig(), resource))
|
||||
if effort != "provider_default":
|
||||
llm.settings.apply_update(LLMSettings(extra={**llm.settings.extra, "reasoning_effort": effort}))
|
||||
return llm
|
||||
|
||||
|
||||
class _Finished(Exception):
|
||||
"""Leave the long-lived attach Job through its native cancellation path."""
|
||||
|
||||
def __init__(self, result: dict):
|
||||
super().__init__("task finished")
|
||||
self.result = result
|
||||
|
||||
|
||||
class ReasonerExecutor:
|
||||
def __init__(self, config: dict, secrets: dict, *, runtime: NativeTaskRuntime):
|
||||
self.config = config
|
||||
self.runtime = runtime
|
||||
self.backend = RuntimeTaskBackend.model_validate(config["_backend"])
|
||||
self._worker: BackendLLMWorker | None = None
|
||||
self._emit: Emit | None = None
|
||||
self._pending: dict = {}
|
||||
self._final: dict | None = None
|
||||
self._confirmed: dict | None = None
|
||||
self._registered: list[str] = []
|
||||
self._stopped = False
|
||||
self._sending_input = False
|
||||
self._cleanup_lock = asyncio.Lock()
|
||||
self._tools = ToolExecutor(DynamicVariableStore.from_config(AssistantConfig(
|
||||
dynamic_variables=deepcopy(config.get("_variables") or {}),
|
||||
dynamic_variable_definitions=deepcopy(config.get("_variable_definitions") or {}),
|
||||
secret_dynamic_variables=deepcopy(config.get("_secret_variables") or {}),
|
||||
)))
|
||||
|
||||
async def prepare(self) -> dict:
|
||||
return {"kind": "reasoner", "task_id": self.config["_task_id"]}
|
||||
|
||||
def _system_prompt(self) -> str:
|
||||
parts = [
|
||||
TASK_INSTRUCTIONS,
|
||||
str(self.backend.config.get("instructions") or "").strip(),
|
||||
self._tools.store.render(self.backend.instructions).strip(),
|
||||
]
|
||||
for skill in self.backend.config.get("skills") or []:
|
||||
parts.append(f"技能:{skill['name']}\n{skill['content']}")
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
def _build_worker(self) -> BackendLLMWorker:
|
||||
bindings = {binding.tool_id: binding for binding in self.backend.tool_bindings}
|
||||
names = set(CONTROL_NAMES | RESERVED_TOOL_NAMES | {"delegate_task"})
|
||||
schemas = []
|
||||
callbacks = []
|
||||
for tool in self.backend.tools:
|
||||
if tool.type not in {"http", "mcp"}:
|
||||
raise ValueError("Reasoner 仅允许 HTTP/MCP 服务端工具")
|
||||
if tool.function_name in names:
|
||||
raise ValueError("Reasoner 工具函数名重复或与任务控制函数冲突")
|
||||
names.add(tool.function_name)
|
||||
binding = bindings.get(tool.id)
|
||||
if binding is None:
|
||||
raise ValueError("Reasoner 工具必须显式绑定才可执行")
|
||||
properties, required = self._tools.schema_parts(tool, binding)
|
||||
schemas.append(FunctionSchema(name=tool.function_name, description=tool.description or tool.name,
|
||||
properties=properties, required=required))
|
||||
|
||||
async def execute(params, current_tool=tool, current_binding=binding):
|
||||
if self._pending or self._final or self._stopped:
|
||||
await params.result_callback({"status": "error", "message": "任务已暂停或结束,不执行新工具"},
|
||||
properties=FunctionCallResultProperties(run_llm=False))
|
||||
return
|
||||
try:
|
||||
result = await self._tools.execute(current_tool, dict(params.arguments or {}), binding=current_binding)
|
||||
except (ToolExecutionError, ValueError):
|
||||
# Tool errors can carry URLs or sensitive payloads. Let the
|
||||
# model report a useful failure without exposing those.
|
||||
result = {"status": "error", "message": "服务端工具执行失败,请检查工具配置或稍后重试"}
|
||||
await params.result_callback(result, properties=FunctionCallResultProperties(
|
||||
run_llm=not (self._pending or self._final or self._stopped),
|
||||
))
|
||||
|
||||
callbacks.append((tool.function_name, execute))
|
||||
|
||||
schemas.extend([
|
||||
FunctionSchema(name="complete_task", description="明确完成任务并提交最终结果。",
|
||||
properties={"result": {"type": "string", "description": "完成结果、依据和未完成的限制。"}}, required=["result"]),
|
||||
FunctionSchema(name="ask_user", description="需要必要信息时暂停任务,向用户提出一个问题。",
|
||||
properties={"question": {"type": "string", "description": "需要用户回答的问题。"}}, required=["question"]),
|
||||
])
|
||||
llm = create_reasoner_llm(self.backend)
|
||||
for name, callback in callbacks + [("complete_task", self._complete_task), ("ask_user", self._ask_user)]:
|
||||
# Synchronous native tools drive BackendLLMWorker's existing loop.
|
||||
llm.register_function(name, callback, cancel_on_interruption=True)
|
||||
self._registered.append(name)
|
||||
return BackendLLMWorker(
|
||||
name="reasoner-" + uuid4().hex,
|
||||
llm=llm,
|
||||
context=LLMContext([{"role": "system", "content": self._system_prompt()}], tools=schemas),
|
||||
pairing_instruction="任务由 Speaker 发送给你。使用任务控制函数提交结果或提问;普通输出不作为给用户的通知。",
|
||||
)
|
||||
|
||||
async def _complete_task(self, params: FunctionCallParams):
|
||||
result = str(params.arguments.get("result") or "").strip()
|
||||
if not result or self._pending or self._stopped:
|
||||
await params.result_callback(
|
||||
{"status": "error", "message": "需要非空结果,且任务不能处于等待输入或停止状态"},
|
||||
properties=FunctionCallResultProperties(run_llm=not (self._pending or self._stopped)),
|
||||
)
|
||||
return
|
||||
self._final = {"status": "succeeded", "summary": "任务已完成", "result": result}
|
||||
await params.result_callback({"status": "accepted"}, properties=FunctionCallResultProperties(run_llm=False))
|
||||
|
||||
async def _ask_user(self, params: FunctionCallParams):
|
||||
question = str(params.arguments.get("question") or "").strip()
|
||||
if not question or self._pending or self._final or self._stopped:
|
||||
await params.result_callback(
|
||||
{"status": "error", "message": "请在任务运行时提出一个非空问题"},
|
||||
properties=FunctionCallResultProperties(run_llm=not (self._pending or self._final or self._stopped)),
|
||||
)
|
||||
return
|
||||
self._pending = {"id": "question_" + uuid4().hex, "kind": "question",
|
||||
"questions": [{"header": "补充信息", "question": question, "options": [], "custom": True}]}
|
||||
await self._emit({"status": "waiting_input", "summary": "任务需要补充信息", "input_request": deepcopy(self._pending)})
|
||||
await params.result_callback({"status": "waiting_input"}, properties=FunctionCallResultProperties(run_llm=False))
|
||||
|
||||
async def _message(self, text: str):
|
||||
if self._worker is None or self._stopped:
|
||||
raise ValueError("内置 Reasoner 已停止,不能继续当前任务")
|
||||
async with self.runtime.coordinator.job(self._worker.name, params=JobParams(
|
||||
name=MESSAGE_JOB_NAME, payload={"request": text}, timeout=15,
|
||||
)):
|
||||
pass
|
||||
|
||||
async def run(self, handle: dict, instruction: str, emit: Emit, *, resume=False) -> dict:
|
||||
if resume:
|
||||
return {"status": "failed", "summary": "内置任务执行曾中断,不会自动重放工具", "result": "请核查已有操作后重新创建任务。"}
|
||||
if self.runtime.runner is None:
|
||||
raise ValueError("后台任务运行器尚未启动")
|
||||
self._emit = emit
|
||||
self._worker = self._build_worker()
|
||||
try:
|
||||
await self.runtime.runner.add_workers(self._worker)
|
||||
async with self.runtime.coordinator.job(self._worker.name, params=JobParams(name=ATTACH_JOB_NAME)) as attached:
|
||||
async for event in attached:
|
||||
if event.type != JobEvent.UPDATE:
|
||||
continue
|
||||
data = event.data or {}
|
||||
kind = data.get("type")
|
||||
if kind == ATTACHED_UPDATE_TYPE:
|
||||
await emit({"status": "running", "summary": "内置 Reasoner 正在执行任务"})
|
||||
await self._message(instruction)
|
||||
elif kind == TOOL_CALL_UPDATE_TYPE and data.get("function_name") not in CONTROL_NAMES:
|
||||
if not self._pending:
|
||||
await emit({"status": "running", "summary": f"服务端工具 · {data.get('function_name', '')}"[:300]})
|
||||
elif kind == ERROR_UPDATE_TYPE:
|
||||
raise _Finished({"status": "failed", "summary": "内置推理服务执行失败", "result": "请检查模型连接和工具配置。"})
|
||||
elif kind == IDLE_UPDATE_TYPE:
|
||||
# An old idle update can already be on the bus when a
|
||||
# user reply starts the next native message Job.
|
||||
if self._sending_input or self._worker.working:
|
||||
continue
|
||||
if self._final:
|
||||
raise _Finished(self._final)
|
||||
if not self._pending:
|
||||
raise _Finished({"status": "failed", "summary": "模型没有明确完成任务", "result": "模型已停止生成,但没有调用 complete_task 或 ask_user;请调整任务指令或模型后重试。"})
|
||||
return {"status": "failed", "summary": "内置任务连接已中断", "result": "不会自动重放工具调用。"}
|
||||
except _Finished as finished:
|
||||
self._confirmed = finished.result
|
||||
return finished.result
|
||||
finally:
|
||||
await self._cleanup()
|
||||
|
||||
async def _cleanup(self):
|
||||
async with self._cleanup_lock:
|
||||
worker = self._worker
|
||||
self._stopped = True
|
||||
if worker is None:
|
||||
return
|
||||
# This is PipelineWorker.cancel(), which cancels only this pipeline.
|
||||
# BaseWorker.cancel()/runner.cancel() would stop unrelated tasks too.
|
||||
await worker.cancel(reason="task finished or cancelled")
|
||||
await asyncio.wait_for(worker.wait(), 10)
|
||||
# WorkerRunner retains ended worker registrations. Release transcript
|
||||
# and handler captures through public APIs instead of editing Pipecat.
|
||||
worker.context.set_messages([])
|
||||
worker.context.set_tools([])
|
||||
for name in self._registered:
|
||||
worker.llm.unregister_function(name)
|
||||
self._registered.clear()
|
||||
self._worker = None
|
||||
|
||||
async def cancel(self, handle: dict) -> bool:
|
||||
if self._worker is not None:
|
||||
await self._cleanup()
|
||||
return True
|
||||
|
||||
async def snapshot(self, handle: dict) -> dict:
|
||||
if self._confirmed and self._worker is None:
|
||||
return dict(self._confirmed)
|
||||
if self._pending:
|
||||
return {"status": "waiting_input", "summary": "任务需要补充信息", "input_request": deepcopy(self._pending)}
|
||||
if self._stopped:
|
||||
return {"status": "failed", "summary": "内置任务已中断", "result": "不会自动重放工具调用。"}
|
||||
return {"status": "running", "summary": "内置 Reasoner 正在执行任务"}
|
||||
|
||||
async def send_input(self, request: dict, value: dict):
|
||||
if not self._pending or request.get("id") != self._pending["id"]:
|
||||
raise ValueError("该问题已处理或不属于当前任务")
|
||||
answers = value.get("answers")
|
||||
if not isinstance(answers, list) or len(answers) != 1 or not isinstance(answers[0], list):
|
||||
raise ValueError("请回答当前任务的问题")
|
||||
text = "\n".join(str(answer).strip() for answer in answers[0]).strip()
|
||||
if not text:
|
||||
raise ValueError("回答不能为空")
|
||||
previous = self._pending
|
||||
self._pending = {}
|
||||
self._sending_input = True
|
||||
try:
|
||||
await self._message("用户补充信息:\n" + text)
|
||||
except Exception:
|
||||
if not self._pending and not self._stopped:
|
||||
self._pending = previous
|
||||
raise
|
||||
finally:
|
||||
self._sending_input = False
|
||||
@@ -23,6 +23,9 @@ class TaskRecord:
|
||||
origin_key: str
|
||||
connection: dict
|
||||
secrets: dict
|
||||
backend_id: str = ""
|
||||
backend_type: str = "opencode"
|
||||
deadline_at: str | None = None
|
||||
status: str = "queued"
|
||||
summary: str = "任务已排队"
|
||||
result: str = ""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Business entry point backed by native Pipecat Jobs."""
|
||||
"""Task records and backend selection around application-owned Pipecat Jobs."""
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
@@ -12,18 +13,19 @@ from services.background_tasks.records import TaskRecord, TaskRepository, TERMIN
|
||||
|
||||
|
||||
class TaskService:
|
||||
def __init__(self, repository=None, executor_factory=OpenCodeExecutor):
|
||||
def __init__(self, repository=None, executor_factory=None):
|
||||
self.repository = repository or TaskRepository()
|
||||
self.executor_factory = executor_factory
|
||||
self.runtime = None
|
||||
self._listeners = defaultdict(set)
|
||||
self._jobs = {}
|
||||
self._executors = {}
|
||||
self._submit_lock = asyncio.Lock()
|
||||
self._closing = False
|
||||
|
||||
async def start(self):
|
||||
self._closing = False
|
||||
self.runtime = NativeTaskRuntime(self._execute, self._cancel_remote, self._on_update, self._on_result)
|
||||
self.runtime = NativeTaskRuntime(self._execute, self._cancel_backend, self._on_update, self._on_result)
|
||||
await self.runtime.start()
|
||||
try:
|
||||
records = await self.repository.list(active=True)
|
||||
@@ -31,10 +33,12 @@ class TaskService:
|
||||
await self.close()
|
||||
raise
|
||||
for record in records:
|
||||
if record.remote_handle:
|
||||
if record.backend_type == "reasoner":
|
||||
await self.repository.patch(record.id, status="failed", summary="服务重启,内置任务已中断;未自动重放工具,请核查已执行操作")
|
||||
elif record.remote_handle:
|
||||
await self._dispatch(record)
|
||||
else:
|
||||
await self.repository.patch(record.id, status="unknown", summary="服务曾中断,未自动重新提交;请核查远端后再创建任务")
|
||||
await self.repository.patch(record.id, status="unknown", summary="服务曾中断,未自动重新提交;请核查执行后端后再创建任务")
|
||||
|
||||
async def close(self):
|
||||
self._closing = True
|
||||
@@ -43,8 +47,9 @@ class TaskService:
|
||||
self.runtime = None
|
||||
self._listeners.clear()
|
||||
self._jobs.clear()
|
||||
self._executors.clear()
|
||||
|
||||
async def submit(self, *, assistant_id, conversation_id, instruction, invocation_id, backend=None, tool=None):
|
||||
async def submit(self, *, assistant_id, conversation_id, backend, instruction, invocation_id, variable_state=None):
|
||||
if not self.runtime:
|
||||
raise ValueError("后台任务服务尚未启动")
|
||||
if not assistant_id or not conversation_id:
|
||||
@@ -52,32 +57,42 @@ class TaskService:
|
||||
text = instruction.strip()
|
||||
if not text or len(text) > 12000:
|
||||
raise ValueError("任务指令必须为 1-12000 个字符")
|
||||
if backend is not None:
|
||||
if backend.type != "opencode":
|
||||
raise ValueError("该任务后端尚未接通")
|
||||
backend_id = backend.id
|
||||
connection = deepcopy(backend.config)
|
||||
connection["_backend_type"] = backend.type
|
||||
secrets = deepcopy(backend.secrets)
|
||||
else:
|
||||
# F0 transition: retired when saved Tool bindings migrate in F1.
|
||||
if tool is None or tool.type != "task":
|
||||
raise ValueError("请选择任务后端")
|
||||
backend_id, connection, secrets = tool.id, deepcopy(tool.definition["config"]), deepcopy(tool.secrets)
|
||||
if backend.type not in {"reasoner", "opencode"}:
|
||||
raise ValueError("不支持该任务后端")
|
||||
snapshot = backend.model_dump(mode="json") if hasattr(backend, "model_dump") else {
|
||||
"id": backend.id, "name": getattr(backend, "name", ""), "type": backend.type,
|
||||
"config": deepcopy(backend.config), "secrets": deepcopy(backend.secrets),
|
||||
}
|
||||
connection = deepcopy(backend.config)
|
||||
connection["_backend_type"] = backend.type
|
||||
connection["_backend"] = snapshot
|
||||
state = variable_state or {}
|
||||
connection["_variables"] = deepcopy(state.get("values", {}))
|
||||
connection["_variable_definitions"] = deepcopy(state.get("definitions", {}))
|
||||
connection["_secret_variables"] = deepcopy(state.get("secrets", {}))
|
||||
timeout = int(connection.get("timeout_seconds", 600))
|
||||
deadline = (datetime.now(UTC) + timedelta(seconds=timeout)).isoformat()
|
||||
async with self._submit_lock:
|
||||
record = await self.repository.create(TaskRecord(
|
||||
id="task_" + uuid4().hex,
|
||||
assistant_id=assistant_id, conversation_id=conversation_id,
|
||||
tool_id=backend_id, instruction=text,
|
||||
origin_key=f"{conversation_id}:{invocation_id}"[:256],
|
||||
connection=connection, secrets=secrets,
|
||||
assistant_id=assistant_id, conversation_id=conversation_id, tool_id="",
|
||||
backend_id=backend.id, backend_type=backend.type, deadline_at=deadline,
|
||||
instruction=text, origin_key=f"{conversation_id}:{invocation_id}"[:256],
|
||||
connection=connection, secrets=deepcopy(backend.secrets),
|
||||
))
|
||||
if not record.job_id and record.status == "queued":
|
||||
await self._dispatch(record)
|
||||
return record.public()
|
||||
return (await self.get(record.id)).public()
|
||||
|
||||
async def _dispatch(self, record):
|
||||
job_id = await self.runtime.submit(record.id, record.connection.get("timeout_seconds", 600))
|
||||
timeout = float(record.connection.get("timeout_seconds", 600))
|
||||
if record.deadline_at:
|
||||
timeout = (datetime.fromisoformat(record.deadline_at) - datetime.now(UTC)).total_seconds()
|
||||
if timeout <= 0:
|
||||
await self._save(record.id, status="cancel_requested", summary="任务已超过等待时限,正在核查停止状态")
|
||||
await self._stop_record(await self.get(record.id))
|
||||
return
|
||||
job_id = await self.runtime.submit(record.id, timeout)
|
||||
self._jobs[job_id] = record.id
|
||||
await self.repository.patch(record.id, job_id=job_id)
|
||||
|
||||
@@ -107,23 +122,36 @@ class TaskService:
|
||||
await self._publish(record)
|
||||
return record
|
||||
|
||||
def _executor(self, record):
|
||||
if record.id in self._executors:
|
||||
return self._executors[record.id]
|
||||
config = {**record.connection, "_task_id": record.id}
|
||||
if self.executor_factory:
|
||||
return self.executor_factory(config, record.secrets)
|
||||
if record.backend_type == "reasoner":
|
||||
from services.background_tasks.reasoner import ReasonerExecutor
|
||||
return ReasonerExecutor(config, record.secrets, runtime=self.runtime)
|
||||
return OpenCodeExecutor(config, record.secrets)
|
||||
|
||||
async def _execute(self, message, worker):
|
||||
task_id = message.payload["task_id"]
|
||||
self._jobs[message.job_id] = task_id
|
||||
record = await self.get(task_id)
|
||||
if record.status in TERMINAL:
|
||||
return {"task_id": task_id}
|
||||
if record.status == "cancel_requested":
|
||||
await self._stop_record(record)
|
||||
return {"task_id": task_id}
|
||||
executor = self.executor_factory(record.connection, record.secrets)
|
||||
executor = self._executor(record)
|
||||
self._executors[task_id] = executor
|
||||
resume = bool(record.remote_handle)
|
||||
try:
|
||||
if not resume:
|
||||
outstanding = await self.repository.list(active=True)
|
||||
if any(other.id != task_id and other.remote_handle for other in outstanding):
|
||||
await self.repository.patch(task_id, status="failed", summary="另一远端任务尚未结束,请先核查或停止该任务;本任务未提交到远端")
|
||||
if any(other.id != task_id and other.backend_type == "opencode" and other.remote_handle for other in outstanding):
|
||||
await self.repository.patch(task_id, status="failed", summary="另一远端任务尚未结束,请先核查或停止该任务;本任务未提交到后端")
|
||||
return {"task_id": task_id}
|
||||
handle = await executor.prepare()
|
||||
record = await self.repository.patch(task_id, remote_handle=handle)
|
||||
record = await self.repository.patch(task_id, remote_handle=await executor.prepare())
|
||||
last_event = None
|
||||
async def emit(event):
|
||||
nonlocal last_event
|
||||
@@ -132,8 +160,9 @@ class TaskService:
|
||||
last_event = deepcopy(event)
|
||||
await self.repository.patch(task_id, **event)
|
||||
await worker.send_job_update(message.job_id, {"task_id": task_id})
|
||||
# Reconnect only observations; never repeat a mutating prompt.
|
||||
for attempt in range(5):
|
||||
# Only OpenCode can reconnect observations without replaying work.
|
||||
attempts = 1 if record.backend_type == "reasoner" else 5
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
result = await executor.run(record.remote_handle, record.instruction, emit, resume=resume)
|
||||
await self.repository.patch(task_id, **result, input_request={})
|
||||
@@ -141,32 +170,35 @@ class TaskService:
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
resume = True
|
||||
await emit({"summary": "远端连接中断,正在核查任务状态"})
|
||||
if attempt == 4:
|
||||
raise
|
||||
await asyncio.sleep(min(2 ** attempt, 10))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
await self.repository.patch(task_id, status="unknown", summary="无法确认远端执行状态,请检查远端服务;不会自动重复提交")
|
||||
if record.backend_type == "reasoner":
|
||||
await self.repository.patch(task_id, status="failed", summary="内置推理任务中断;未自动重复执行,请核查任务配置与已执行操作")
|
||||
else:
|
||||
await self.repository.patch(task_id, status="unknown", summary="无法确认远端执行状态,请检查远端服务;不会自动重复提交")
|
||||
return {"task_id": task_id}
|
||||
|
||||
async def _on_update(self, message):
|
||||
task_id = (message.update or {}).get("task_id")
|
||||
if task_id:
|
||||
record = await self.get(task_id)
|
||||
await self._publish(record)
|
||||
await self._publish(await self.get(task_id))
|
||||
|
||||
async def _on_result(self, message):
|
||||
if self._closing:
|
||||
return
|
||||
task_id = self._jobs.pop(message.job_id, None)
|
||||
if not task_id:
|
||||
return
|
||||
self._executors.pop(task_id, None)
|
||||
if self._closing:
|
||||
return
|
||||
record = await self.get(task_id)
|
||||
if record.status not in TERMINAL and str(message.status) in {"error", "failed"}:
|
||||
await self._save(task_id, status="unknown", summary="任务运行中断,请核查远端状态")
|
||||
await self._save(task_id, status="unknown", summary="任务运行中断,请核查执行后端状态")
|
||||
else:
|
||||
await self._publish(record)
|
||||
|
||||
@@ -174,13 +206,13 @@ class TaskService:
|
||||
record = await self.get(task_id, assistant_id)
|
||||
if record.status in TERMINAL:
|
||||
return record.public()
|
||||
await self._save(task_id, status="cancel_requested", summary="正在请求远端停止")
|
||||
await self._save(task_id, status="cancel_requested", summary="正在请求停止任务")
|
||||
accepted = bool(self.runtime and record.job_id and await self.runtime.cancel(record.job_id))
|
||||
if not accepted:
|
||||
await self._stop_record(record)
|
||||
return (await self.get(task_id)).public()
|
||||
|
||||
async def _cancel_remote(self, message):
|
||||
async def _cancel_backend(self, message):
|
||||
task_id = self._jobs.get(message.job_id)
|
||||
if not task_id or self._closing:
|
||||
return
|
||||
@@ -190,38 +222,41 @@ class TaskService:
|
||||
if record.status in TERMINAL:
|
||||
return
|
||||
if not record.remote_handle:
|
||||
await self._save(record.id, status="cancelled", summary="已取消,尚未向远端提交指令")
|
||||
await self._save(record.id, status="cancelled", summary="已取消,尚未向任务后端提交指令")
|
||||
return
|
||||
try:
|
||||
stopped = await self.executor_factory(record.connection, record.secrets).cancel(record.remote_handle)
|
||||
stopped = await self._executor(record).cancel(record.remote_handle)
|
||||
except Exception:
|
||||
stopped = False
|
||||
await self._save(record.id, status="cancelled" if stopped else "cancel_requested", summary="远端已停止" if stopped else "停止尚未确认,请检查远端服务")
|
||||
summary = "已停止内置推理;已发出的工具请求结果请核查" if record.backend_type == "reasoner" else "远端已停止"
|
||||
await self._save(record.id, status="cancelled" if stopped else "cancel_requested", summary=summary if stopped else "停止尚未确认,请检查执行后端")
|
||||
|
||||
async def reconcile(self, task_id):
|
||||
record = await self.get(task_id)
|
||||
if not record.remote_handle or record.status in TERMINAL:
|
||||
return record.public()
|
||||
executor = self.executor_factory(record.connection, record.secrets)
|
||||
if record.status == "cancel_requested":
|
||||
await self._stop_record(record)
|
||||
else:
|
||||
snapshot = await executor.snapshot(record.remote_handle)
|
||||
# Session status does not describe pending questions/permissions.
|
||||
# Keep the saved request visible until a reply or terminal result.
|
||||
snapshot = await self._executor(record).snapshot(record.remote_handle)
|
||||
if snapshot["status"] == "running" and record.input_request:
|
||||
snapshot.update(status="waiting_input", summary="远端等待确认或补充信息")
|
||||
snapshot.update(status="waiting_input", summary="任务后端等待补充信息")
|
||||
await self._save(task_id, **snapshot)
|
||||
if snapshot["status"] not in TERMINAL and self.runtime and record.job_id not in self.runtime.coordinator.job_groups:
|
||||
await self._dispatch(await self.get(task_id))
|
||||
if record.backend_type == "reasoner":
|
||||
await self._save(task_id, status="failed", summary="内置任务已中断,不自动重放工具")
|
||||
else:
|
||||
await self._dispatch(await self.get(task_id))
|
||||
return (await self.get(task_id)).public()
|
||||
|
||||
async def send_input(self, task_id, value, assistant_id=None):
|
||||
record = await self.get(task_id, assistant_id)
|
||||
if record.status != "waiting_input" or not record.input_request:
|
||||
raise ValueError("任务当前没有待处理的确认或问题")
|
||||
await self.executor_factory(record.connection, record.secrets).send_input(record.input_request, value)
|
||||
await self._save(task_id, status="running", summary="已发送回复,等待远端继续", input_request={})
|
||||
await self._executor(record).send_input(record.input_request, value)
|
||||
latest = await self.get(task_id)
|
||||
if latest.input_request.get("id") == record.input_request.get("id"):
|
||||
await self._save(task_id, status="running", summary="已发送回复,等待任务后端继续", input_request={})
|
||||
return (await self.get(task_id)).public()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user