From 47206812f54605abe1df0901d193c23fbb44c99d Mon Sep 17 00:00:00 2001
From: orhuoxu <2414234821@qq.com>
Date: Tue, 8 Sep 2026 21:37:40 +0800
Subject: [PATCH 1/5] feat(tools): add detached async execution
---
backend/app/api/external_business_tasks.py | 121 +++++++
backend/app/api/tools.py | 1 +
backend/app/config.py | 1 +
backend/app/core/capability_manifest.py | 20 +
backend/app/core/harness_agent.py | 20 +
.../app/core/harness_capability_invoker.py | 28 ++
backend/app/db/database.py | 40 ++
backend/app/db/models.py | 57 +++
backend/app/main.py | 11 +-
backend/app/tools/external_task_worker.py | 36 ++
backend/app/tools/external_tasks.py | 342 ++++++++++++++++++
backend/app/tools/tool_executor.py | 169 ++++++++-
backend/app/tools/tool_schema.py | 8 +
backend/tests/test_enterprise_auth_guards.py | 3 +
backend/tests/test_external_business_tasks.py | 242 +++++++++++++
backend/tests/test_tools_api.py | 32 +-
frontend-enterprise/src/i18n/en.json | 5 +
frontend-enterprise/src/pages/ToolsPage.tsx | 65 ++++
frontend-enterprise/src/types/index.ts | 8 +
19 files changed, 1203 insertions(+), 6 deletions(-)
create mode 100644 backend/app/api/external_business_tasks.py
create mode 100644 backend/app/tools/external_task_worker.py
create mode 100644 backend/app/tools/external_tasks.py
create mode 100644 backend/tests/test_external_business_tasks.py
diff --git a/backend/app/api/external_business_tasks.py b/backend/app/api/external_business_tasks.py
new file mode 100644
index 000000000..910ab2a56
--- /dev/null
+++ b/backend/app/api/external_business_tasks.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from fastapi import APIRouter, Depends, Header, HTTPException, Query
+from pydantic import BaseModel, Field
+from sqlmodel import Session, select
+
+from app.db import get_session
+from app.db.models import ExternalBusinessTask, ExternalBusinessTaskEvent, User
+from app.security.auth import ensure_current_user_tenant, get_current_user
+from app.tools.external_tasks import apply_task_event, verify_callback_token
+
+enterprise_router = APIRouter(
+ prefix="/api/enterprise/external-business-tasks",
+ tags=["enterprise:external-business-tasks"],
+)
+callback_router = APIRouter(
+ prefix="/api/external-business-tasks",
+ tags=["external-business-tasks"],
+)
+
+
+class ExternalTaskCallback(BaseModel):
+ event_id: str = Field(min_length=1, max_length=200)
+ event_type: str = Field(default="status", max_length=100)
+ status: Literal[
+ "accepted", "submitted", "queued", "pending", "working", "processing",
+ "completed", "succeeded", "success", "failed", "cancelled", "canceled",
+ ]
+ task_id: str | None = Field(default=None, max_length=300)
+ result: Any = None
+ error: Any = None
+ data: dict[str, Any] = Field(default_factory=dict)
+
+
+def task_read(task: ExternalBusinessTask, db: Session) -> dict[str, Any]:
+ events = db.exec(
+ select(ExternalBusinessTaskEvent)
+ .where(ExternalBusinessTaskEvent.task_id == task.id)
+ .order_by(ExternalBusinessTaskEvent.created_at)
+ ).all()
+ return {
+ "id": task.id,
+ "external_task_id": task.external_task_id,
+ "tool_id": task.tool_id,
+ "agent_id": task.agent_id,
+ "session_id": task.session_id,
+ "status": task.status,
+ "result": task.result_json or {},
+ "error": task.error_json or {},
+ "poll_attempts": task.poll_attempts,
+ "created_at": task.created_at.isoformat(),
+ "accepted_at": task.accepted_at.isoformat() if task.accepted_at else None,
+ "finished_at": task.finished_at.isoformat() if task.finished_at else None,
+ "updated_at": task.updated_at.isoformat(),
+ "events": [
+ {
+ "event_id": event.event_id,
+ "event_type": event.event_type,
+ "data": event.data_json,
+ "created_at": event.created_at.isoformat(),
+ }
+ for event in events
+ ],
+ }
+
+
+@enterprise_router.get("/{external_task_id}")
+def get_external_business_task(
+ external_task_id: str,
+ tenant_id: str = Query(...),
+ tool_id: str | None = Query(default=None),
+ db: Session = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> dict[str, Any]:
+ ensure_current_user_tenant(tenant_id, current_user)
+ statement = select(ExternalBusinessTask).where(
+ ExternalBusinessTask.tenant_id == tenant_id,
+ ExternalBusinessTask.user_id == current_user.id,
+ ExternalBusinessTask.external_task_id == external_task_id,
+ )
+ if tool_id:
+ statement = statement.where(ExternalBusinessTask.tool_id == tool_id)
+ rows = db.exec(statement).all()
+ if not rows:
+ raise HTTPException(status_code=404, detail="External business task not found")
+ if len(rows) > 1:
+ raise HTTPException(status_code=409, detail="tool_id is required for this external task id")
+ return task_read(rows[0], db)
+
+
+@callback_router.post("/{task_id}/callback")
+def external_business_task_callback(
+ task_id: str,
+ request: ExternalTaskCallback,
+ callback_token: str = Header(default="", alias="X-StaffDeck-Callback-Token"),
+ db: Session = Depends(get_session),
+) -> dict[str, Any]:
+ task = db.get(ExternalBusinessTask, task_id)
+ if task is None or not verify_callback_token(task, callback_token):
+ raise HTTPException(status_code=401, detail="Invalid callback credential")
+ if request.task_id and request.task_id != task.external_task_id:
+ raise HTTPException(
+ status_code=409,
+ detail="Provider task id does not match callback target",
+ )
+ data = dict(request.data)
+ if request.result is not None:
+ data["result"] = request.result
+ if request.error is not None:
+ data["error"] = request.error
+ created = apply_task_event(
+ db,
+ task,
+ event_id=request.event_id,
+ event_type=request.event_type,
+ status=request.status,
+ data=data,
+ )
+ return {"accepted": True, "duplicate": not created, "status": task.status}
diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py
index 75e7f669c..530fdcc02 100644
--- a/backend/app/api/tools.py
+++ b/backend/app/api/tools.py
@@ -590,6 +590,7 @@ def test_tool(
request.tenant_id,
ToolCall(name=row.name, arguments=request.arguments),
agent_id=agent_id,
+ user_id=current_user.id,
)
diff --git a/backend/app/config.py b/backend/app/config.py
index 7b7f4d51f..47bcd56b2 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -15,6 +15,7 @@ class Settings(BaseSettings):
model_thinking_mode: str = ""
model_thinking_models: str = ""
tool_timeout_seconds: float = 8.0
+ external_task_poll_seconds: float = 2.0
a2a_task_timeout_seconds: float = 600.0
a2a_poll_interval_seconds: float = 0.5
codex_a2a_enabled: bool = False
diff --git a/backend/app/core/capability_manifest.py b/backend/app/core/capability_manifest.py
index cf2131c3d..54ec47b90 100644
--- a/backend/app/core/capability_manifest.py
+++ b/backend/app/core/capability_manifest.py
@@ -45,6 +45,7 @@
"run_skill_script",
"knowledge_search",
"lark_cli",
+ "external_task_status",
}
@@ -392,6 +393,25 @@ def _lark_cli_descriptor(
def _internal_capability_descriptors() -> list[CapabilityDescriptor]:
return [
+ CapabilityDescriptor(
+ capability_id="builtin.external_task.status",
+ name="external_task_status",
+ kind="internal",
+ description=(
+ "Query a StaffDeck detached business task by task_id. Use this when the user asks "
+ "for the status of a previously submitted #taskid. Only the current user's tasks "
+ "are visible."
+ ),
+ input_schema={
+ "type": "object",
+ "properties": {
+ "task_id": {"type": "string", "minLength": 1},
+ },
+ "required": ["task_id"],
+ "additionalProperties": False,
+ },
+ metadata={"provider": "harness", "side_effect": "read"},
+ ),
CapabilityDescriptor(
capability_id="builtin.deliverables.list",
name="list_published_deliverables",
diff --git a/backend/app/core/harness_agent.py b/backend/app/core/harness_agent.py
index ae2443211..fa7040e2a 100644
--- a/backend/app/core/harness_agent.py
+++ b/backend/app/core/harness_agent.py
@@ -538,6 +538,26 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult:
if _is_non_retryable_failure(result):
non_retryable_action_signatures.add(action_signature)
bounded_result = _bounded_capability_result(tool_name, result)
+ result_data = result.get("data")
+ if (
+ result.get("success") is True
+ and isinstance(result_data, dict)
+ and result_data.get("detached") is True
+ ):
+ capability_results.append(bounded_result)
+ reply = str(result_data.get("user_reply") or "").strip()
+ return finish(TaskExecutionResult(
+ task_frame_id=requirement.task_frame_id,
+ status="completed",
+ reply_fragment=reply,
+ capability_results=capability_results,
+ action_count=iteration,
+ task_summary="异步业务任务已受理。",
+ structured_result={
+ "task_id": result_data.get("task_id"),
+ "status": result_data.get("status"),
+ },
+ ))
if _is_loaded_general_skill_result(tool_name, result):
loaded_general_skill_names.append(tool_name)
transcript.extend(
diff --git a/backend/app/core/harness_capability_invoker.py b/backend/app/core/harness_capability_invoker.py
index 98550c8f4..bcf97e7da 100644
--- a/backend/app/core/harness_capability_invoker.py
+++ b/backend/app/core/harness_capability_invoker.py
@@ -42,6 +42,7 @@
GeneralSkill,
HarnessInvocationRecord,
ModelConfig,
+ ExternalBusinessTask,
Skill,
Tool,
UIConfig,
@@ -518,6 +519,8 @@ def _invoke_internal(
return self._search_capabilities(arguments)
if name == "capability_describe":
return self._describe_capabilities(arguments)
+ if name == "external_task_status":
+ return self._external_task_status(arguments)
if name == "list_published_deliverables":
return self._list_published_deliverables(arguments)
if name == "read_published_deliverable":
@@ -540,6 +543,30 @@ def _invoke_internal(
"不支持的 Harness 内部能力。",
)
+ def _external_task_status(self, arguments: dict[str, Any]) -> dict[str, Any]:
+ task_id = str(arguments.get("task_id") or "").strip().lstrip("#")
+ if not task_id:
+ return _failure("INVALID_ARGUMENTS", "task_id 不能为空。")
+ task = self.db.exec(
+ select(ExternalBusinessTask).where(
+ ExternalBusinessTask.id == task_id,
+ ExternalBusinessTask.tenant_id == self.tenant_id,
+ ExternalBusinessTask.user_id == self.session.user_id,
+ )
+ ).first()
+ if task is None:
+ return _failure("EXTERNAL_TASK_NOT_FOUND", "未找到属于当前用户的该任务。")
+ status = str(task.status or "").lower()
+ return {
+ "success": True,
+ "data": {
+ "task_id": task.id,
+ "status": task.status,
+ "result": dict(task.result_json or {}),
+ "error": dict(task.error_json or {}),
+ },
+ }
+
def _list_published_deliverables(self, arguments: dict[str, Any]) -> dict[str, Any]:
raw_limit = arguments.get("limit", MAX_PUBLISHED_DELIVERABLES)
if isinstance(raw_limit, bool) or not isinstance(raw_limit, int):
@@ -1050,6 +1077,7 @@ def _invoke_external_tool(
agent_id=self.agent_id,
session_id=self.session.id,
invocation_id=call_id,
+ user_id=self.session.user_id,
timeout_seconds_override=self._remaining_step_seconds(),
)
payload = result.model_dump(mode="json")
diff --git a/backend/app/db/database.py b/backend/app/db/database.py
index e5c34721d..0dc872396 100644
--- a/backend/app/db/database.py
+++ b/backend/app/db/database.py
@@ -346,6 +346,46 @@ def _migrate_sqlite_skill_schema() -> None:
)
)
+ if "external_business_tasks" in tables:
+ task_columns = {
+ column["name"]
+ for column in inspector.get_columns("external_business_tasks")
+ }
+ additions = {
+ "status_config_json": (
+ "ALTER TABLE external_business_tasks ADD COLUMN status_config_json JSON"
+ ),
+ "idempotency_key": (
+ "ALTER TABLE external_business_tasks ADD COLUMN idempotency_key VARCHAR"
+ ),
+ "lease_owner": (
+ "ALTER TABLE external_business_tasks ADD COLUMN lease_owner VARCHAR"
+ ),
+ "lease_expires_at": (
+ "ALTER TABLE external_business_tasks ADD COLUMN lease_expires_at DATETIME"
+ ),
+ "expires_at": (
+ "ALTER TABLE external_business_tasks ADD COLUMN expires_at DATETIME"
+ ),
+ }
+ for column_name, ddl in additions.items():
+ if column_name not in task_columns:
+ conn.execute(text(ddl))
+ conn.execute(
+ text(
+ "UPDATE external_business_tasks SET status_config_json = '{}' "
+ "WHERE status_config_json IS NULL"
+ )
+ )
+ conn.execute(
+ text(
+ "CREATE UNIQUE INDEX IF NOT EXISTS "
+ "uq_external_business_tasks_idempotency_key "
+ "ON external_business_tasks(idempotency_key) "
+ "WHERE idempotency_key IS NOT NULL"
+ )
+ )
+
if "mcp_servers" in tables:
mcp_server_columns = {
column["name"] for column in inspector.get_columns("mcp_servers")
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index 78925cb40..e6d1af808 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -217,6 +217,63 @@ class A2ATaskEvent(SQLModel, table=True):
created_at: datetime = Field(default_factory=utc_now)
+class ExternalBusinessTask(SQLModel, table=True):
+ """Durable state for an HTTP tool whose business work finishes asynchronously."""
+
+ __tablename__ = "external_business_tasks"
+ __table_args__ = (
+ UniqueConstraint(
+ "tenant_id",
+ "user_id",
+ "tool_id",
+ "external_task_id",
+ name="uq_external_business_task",
+ ),
+ )
+
+ id: str = Field(default_factory=lambda: new_id("exttask"), primary_key=True)
+ tenant_id: str = Field(index=True)
+ user_id: str = Field(index=True)
+ agent_id: Optional[str] = Field(default=None, index=True)
+ session_id: Optional[str] = Field(default=None, index=True)
+ invocation_id: Optional[str] = Field(default=None, index=True)
+ tool_id: str = Field(index=True)
+ external_task_id: Optional[str] = Field(default=None, index=True)
+ idempotency_key: Optional[str] = Field(default=None, unique=True, index=True)
+ status: str = Field(default="submitting", index=True)
+ request_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
+ result_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
+ error_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
+ callback_token_hash: str
+ status_url: Optional[str] = None
+ status_config_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
+ poll_interval_seconds: float = 5.0
+ next_poll_at: Optional[datetime] = Field(default=None, index=True)
+ poll_attempts: int = 0
+ lease_owner: Optional[str] = Field(default=None, index=True)
+ lease_expires_at: Optional[datetime] = Field(default=None, index=True)
+ expires_at: Optional[datetime] = Field(default=None, index=True)
+ created_at: datetime = Field(default_factory=utc_now)
+ accepted_at: Optional[datetime] = None
+ finished_at: Optional[datetime] = None
+ updated_at: datetime = Field(default_factory=utc_now)
+
+
+class ExternalBusinessTaskEvent(SQLModel, table=True):
+ __tablename__ = "external_business_task_events"
+ __table_args__ = (
+ UniqueConstraint("task_id", "event_id", name="uq_external_business_task_event"),
+ )
+
+ id: str = Field(default_factory=lambda: new_id("exttaskevt"), primary_key=True)
+ tenant_id: str = Field(index=True)
+ task_id: str = Field(index=True)
+ event_id: str = Field(index=True)
+ event_type: str = Field(index=True)
+ data_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
+ created_at: datetime = Field(default_factory=utc_now)
+
+
class WebhookEndpoint(SQLModel, table=True):
__tablename__ = "webhook_endpoints"
diff --git a/backend/app/main.py b/backend/app/main.py
index 22c47a86a..b078bf97e 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -4,6 +4,8 @@
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Session
+from app.a2a import recover_codex_a2a_tasks, stop_codex_a2a_tasks
+from app.a2a import router as a2a_router
from app.api import (
agents,
app_updates,
@@ -11,6 +13,7 @@
channels,
chat,
evolution,
+ external_business_tasks,
feedback,
general_skills,
knowledge,
@@ -29,7 +32,6 @@
wechat_kf,
)
from app.async_jobs import shutdown_async_jobs, start_async_jobs
-from app.a2a import recover_codex_a2a_tasks, router as a2a_router, stop_codex_a2a_tasks
from app.channels import start_channel_services, stop_channel_services
from app.config import get_settings
from app.core.harness_recovery import (
@@ -45,8 +47,9 @@
from app.public_api.webhooks import enqueue_due_webhook_deliveries
from app.runtime_lock import acquire_runtime_instance_lock, release_runtime_instance_lock
from app.scheduled_tasks.worker import start_background_worker, stop_background_worker
-from app.tools.a2a_recovery import recover_a2a_client_tasks
from app.teams.sweeper import start_timeout_sweeper, stop_timeout_sweeper
+from app.tools.a2a_recovery import recover_a2a_client_tasks
+from app.tools.external_task_worker import start_external_task_worker, stop_external_task_worker
from app.version import app_version
settings = get_settings()
@@ -79,6 +82,7 @@ def on_startup() -> None:
recover_orphan_harness_runs(db, startup=True)
recover_codex_a2a_tasks()
recover_a2a_client_tasks()
+ start_external_task_worker()
start_background_worker()
start_channel_services()
start_timeout_sweeper()
@@ -99,6 +103,7 @@ def on_startup() -> None:
def on_shutdown() -> None:
try:
stop_codex_a2a_tasks()
+ stop_external_task_worker()
stop_public_api_maintenance()
stop_channel_services()
stop_background_worker()
@@ -140,6 +145,8 @@ def health() -> dict[str, str]:
app.include_router(teams.threads_router)
app.include_router(tools.router)
app.include_router(tools.mcp_router)
+app.include_router(external_business_tasks.enterprise_router)
+app.include_router(external_business_tasks.callback_router)
app.include_router(sessions.router)
app.include_router(traces.router)
app.include_router(mock.router)
diff --git a/backend/app/tools/external_task_worker.py b/backend/app/tools/external_task_worker.py
new file mode 100644
index 000000000..bb006a9e8
--- /dev/null
+++ b/backend/app/tools/external_task_worker.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import threading
+
+from sqlmodel import Session
+
+from app.config import get_settings
+from app.db import engine
+from app.tools.external_tasks import poll_due_external_tasks
+
+_stop_event = threading.Event()
+_thread: threading.Thread | None = None
+
+
+def run_external_task_worker() -> None:
+ while not _stop_event.is_set():
+ with Session(engine) as db:
+ poll_due_external_tasks(db)
+ _stop_event.wait(max(0.5, get_settings().external_task_poll_seconds))
+
+
+def start_external_task_worker() -> None:
+ global _thread
+ if _thread and _thread.is_alive():
+ return
+ _stop_event.clear()
+ _thread = threading.Thread(
+ target=run_external_task_worker,
+ name="staffdeck-external-task-worker",
+ daemon=True,
+ )
+ _thread.start()
+
+
+def stop_external_task_worker() -> None:
+ _stop_event.set()
diff --git a/backend/app/tools/external_tasks.py b/backend/app/tools/external_tasks.py
new file mode 100644
index 000000000..a1d022972
--- /dev/null
+++ b/backend/app/tools/external_tasks.py
@@ -0,0 +1,342 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import secrets
+from datetime import timedelta
+from typing import Any
+
+import httpx
+from sqlalchemy import or_, update
+from sqlalchemy.exc import IntegrityError
+from sqlmodel import Session, select
+
+from app.db.models import (
+ ExternalBusinessTask,
+ ExternalBusinessTaskEvent,
+ Tool,
+ new_id,
+ utc_now,
+)
+
+TERMINAL_STATUSES = {"completed", "succeeded", "success", "failed", "cancelled", "canceled"}
+SUCCESS_STATUSES = {"completed", "succeeded", "success"}
+PERSISTED_TERMINAL_STATUSES = {"completed", "failed", "cancelled", "expired"}
+
+
+def new_callback_token() -> str:
+ return secrets.token_urlsafe(32)
+
+
+def callback_token_hash(token: str) -> str:
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def verify_callback_token(task: ExternalBusinessTask, token: str) -> bool:
+ return bool(token) and hmac.compare_digest(task.callback_token_hash, callback_token_hash(token))
+
+
+def normalize_status(value: object) -> str:
+ status = str(value or "working").strip().lower()
+ if status in SUCCESS_STATUSES:
+ return "completed"
+ if status in {"cancelled", "canceled"}:
+ return "cancelled"
+ if status == "failed":
+ return "failed"
+ if status == "expired":
+ return "expired"
+ if status in {"accepted", "submitted", "queued", "pending"}:
+ return "accepted"
+ return "working"
+
+
+def apply_task_event(
+ db: Session,
+ task: ExternalBusinessTask,
+ *,
+ event_id: str,
+ event_type: str,
+ status: object,
+ data: dict[str, Any],
+) -> bool:
+ existing = db.exec(
+ select(ExternalBusinessTaskEvent).where(
+ ExternalBusinessTaskEvent.task_id == task.id,
+ ExternalBusinessTaskEvent.event_id == event_id,
+ )
+ ).first()
+ if existing is not None:
+ return False
+ event = ExternalBusinessTaskEvent(
+ tenant_id=task.tenant_id,
+ task_id=task.id,
+ event_id=event_id,
+ event_type=event_type,
+ data_json=data,
+ )
+ db.add(event)
+ try:
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ return False
+
+ now = utc_now()
+ next_status = normalize_status(status)
+ if (
+ task.status in PERSISTED_TERMINAL_STATUSES
+ and next_status not in PERSISTED_TERMINAL_STATUSES
+ ):
+ db.commit()
+ return True
+ task.status = next_status
+ result = data.get("result")
+ error = data.get("error")
+ if isinstance(result, dict):
+ task.result_json = result
+ elif result is not None:
+ task.result_json = {"value": result}
+ if isinstance(error, dict):
+ task.error_json = error
+ elif error is not None:
+ task.error_json = {"message": str(error)}
+ if task.status in PERSISTED_TERMINAL_STATUSES:
+ task.finished_at = now
+ task.next_poll_at = None
+ task.updated_at = now
+ db.add(task)
+ db.commit()
+ return True
+
+
+def poll_due_external_tasks(db: Session) -> int:
+ now = utc_now()
+ db.exec(
+ update(ExternalBusinessTask)
+ .where(
+ ExternalBusinessTask.status == "working",
+ ExternalBusinessTask.lease_expires_at.is_not(None),
+ ExternalBusinessTask.lease_expires_at <= now,
+ )
+ .values(status="queued", lease_owner=None, lease_expires_at=None, updated_at=now)
+ )
+ db.commit()
+ queued = db.exec(
+ select(ExternalBusinessTask)
+ .where(
+ ExternalBusinessTask.status == "queued",
+ ExternalBusinessTask.lease_owner.is_(None),
+ )
+ .limit(20)
+ ).all()
+ local_count = 0
+ for task in queued:
+ _execute_local_task(db, task)
+ local_count += 1
+ expired = db.exec(
+ select(ExternalBusinessTask).where(
+ ExternalBusinessTask.status.in_(["accepted", "working"]),
+ ExternalBusinessTask.expires_at.is_not(None),
+ ExternalBusinessTask.expires_at <= now,
+ )
+ ).all()
+ for task in expired:
+ apply_task_event(
+ db,
+ task,
+ event_id=f"expired-{int(now.timestamp())}",
+ event_type="expired",
+ status="expired",
+ data={
+ "error": {
+ "code": "TASK_TRACKING_EXPIRED",
+ "message": "External task tracking exceeded its configured deadline.",
+ }
+ },
+ )
+ candidates = db.exec(
+ select(ExternalBusinessTask).where(
+ ExternalBusinessTask.status.in_(["accepted", "working"]),
+ ExternalBusinessTask.status_url.is_not(None),
+ ExternalBusinessTask.next_poll_at.is_not(None),
+ ExternalBusinessTask.next_poll_at <= now,
+ or_(
+ ExternalBusinessTask.lease_owner.is_(None),
+ ExternalBusinessTask.lease_expires_at <= now,
+ ),
+ )
+ .limit(20)
+ ).all()
+ claimed = 0
+ for task in candidates:
+ owner = new_id("exttasklease")
+ result = db.exec(
+ update(ExternalBusinessTask)
+ .where(
+ ExternalBusinessTask.id == task.id,
+ ExternalBusinessTask.status.in_(["accepted", "working"]),
+ or_(
+ ExternalBusinessTask.lease_owner.is_(None),
+ ExternalBusinessTask.lease_expires_at <= now,
+ ),
+ )
+ .values(
+ lease_owner=owner,
+ lease_expires_at=now + timedelta(seconds=60),
+ updated_at=now,
+ )
+ .execution_options(synchronize_session=False)
+ )
+ if getattr(result, "rowcount", 0) != 1:
+ db.rollback()
+ continue
+ db.commit()
+ task = db.get(ExternalBusinessTask, task.id)
+ if task is None:
+ continue
+ _poll_task(db, task, owner=owner)
+ claimed += 1
+ return claimed + local_count + len(expired)
+
+
+def _execute_local_task(db: Session, task: ExternalBusinessTask) -> None:
+ owner = new_id("exttasklease")
+ now = utc_now()
+ claimed = db.exec(
+ update(ExternalBusinessTask)
+ .where(
+ ExternalBusinessTask.id == task.id,
+ ExternalBusinessTask.status == "queued",
+ ExternalBusinessTask.lease_owner.is_(None),
+ )
+ .values(
+ status="working",
+ lease_owner=owner,
+ lease_expires_at=now + timedelta(hours=1),
+ updated_at=now,
+ )
+ .execution_options(synchronize_session=False)
+ )
+ if getattr(claimed, "rowcount", 0) != 1:
+ db.rollback()
+ return
+ db.commit()
+ task = db.get(ExternalBusinessTask, task.id)
+ tool = db.get(Tool, task.tool_id) if task else None
+ try:
+ if task is None or tool is None:
+ raise ValueError("Detached tool no longer exists")
+ from app.tools.tool_executor import ToolExecutor
+
+ result = ToolExecutor(db).execute_sync_http(tool, task.request_json)
+ if result.success:
+ apply_task_event(
+ db,
+ task,
+ event_id=f"local-{task.id}",
+ event_type="completed",
+ status="completed",
+ data={"result": result.data},
+ )
+ else:
+ error = result.error.model_dump(mode="json") if result.error else {}
+ apply_task_event(
+ db,
+ task,
+ event_id=f"local-{task.id}",
+ event_type="failed",
+ status="failed",
+ data={"error": error},
+ )
+ except Exception as exc:
+ apply_task_event(
+ db,
+ task,
+ event_id=f"local-{task.id}",
+ event_type="failed",
+ status="failed",
+ data={"error": {"code": "DETACHED_EXECUTION_ERROR", "message": str(exc)}},
+ )
+ finally:
+ db.exec(
+ update(ExternalBusinessTask)
+ .where(
+ ExternalBusinessTask.id == task.id,
+ ExternalBusinessTask.lease_owner == owner,
+ )
+ .values(lease_owner=None, lease_expires_at=None)
+ .execution_options(synchronize_session=False)
+ )
+ db.commit()
+
+
+def _poll_task(db: Session, task: ExternalBusinessTask, *, owner: str) -> None:
+ tool = db.get(Tool, task.tool_id)
+ if tool is None or not task.external_task_id or not task.status_url:
+ return
+ config = dict(task.status_config_json or {})
+ status_field = str(config.get("status_field") or "status")
+ result_field = str(config.get("result_field") or "result")
+ status_mapping = dict(config.get("status_mapping") or {})
+ url = task.status_url.replace("{taskId}", task.external_task_id)
+ task.poll_attempts += 1
+ task.next_poll_at = utc_now() + timedelta(seconds=task.poll_interval_seconds)
+ try:
+ from app.tools.tool_executor import ToolExecutor
+
+ executor = ToolExecutor(db)
+ if url.startswith("/"):
+ url = f"{executor.settings.normalized_tool_base_url}{url}"
+ headers = executor._request_headers(
+ url,
+ executor._resolve_headers(tool.headers_json or {}, tool.auth_json or {}),
+ )
+ response = httpx.get(
+ url,
+ headers=headers,
+ timeout=executor._execution_policy(tool).timeout_seconds,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, dict):
+ raise ValueError("status response must be a JSON object")
+ raw_status = _json_path(payload, status_field)
+ mapped_status = status_mapping.get(str(raw_status), raw_status)
+ result_data = _json_path(payload, result_field)
+ event_data = dict(payload)
+ if result_data is not None:
+ event_data["result"] = result_data
+ apply_task_event(
+ db,
+ task,
+ event_id=f"poll-{task.poll_attempts}",
+ event_type="polled",
+ status=mapped_status,
+ data=event_data,
+ )
+ except Exception as exc:
+ task.error_json = {"code": "POLL_ERROR", "message": str(exc)}
+ task.updated_at = utc_now()
+ db.add(task)
+ db.commit()
+ finally:
+ db.exec(
+ update(ExternalBusinessTask)
+ .where(
+ ExternalBusinessTask.id == task.id,
+ ExternalBusinessTask.lease_owner == owner,
+ )
+ .values(lease_owner=None, lease_expires_at=None)
+ .execution_options(synchronize_session=False)
+ )
+ db.commit()
+
+
+def _json_path(value: Any, path: str) -> Any:
+ current = value
+ for part in (item for item in path.split(".") if item):
+ if not isinstance(current, dict):
+ return None
+ current = current.get(part)
+ return current
diff --git a/backend/app/tools/tool_executor.py b/backend/app/tools/tool_executor.py
index e5733b07e..8056a7798 100644
--- a/backend/app/tools/tool_executor.py
+++ b/backend/app/tools/tool_executor.py
@@ -13,17 +13,26 @@
from app.agents.branching import visible_tool_rows
from app.config import get_settings
-from app.db.models import MCPServer, Tool
+from app.db.models import ChatSession, ExternalBusinessTask, MCPServer, Tool, utc_now
from app.security.internal_service import INTERNAL_SERVICE_HEADER, internal_service_token
from app.tools.a2a_client import A2AClient, A2AClientError
+from app.tools.external_tasks import callback_token_hash, new_callback_token
from app.tools.http_request import prepare_get_request
from app.tools.mcp_client import MCPClientError, execute_mcp_tool, execute_mcp_tool_result
from app.tools.tool_schema import MCPAppDescriptor, ToolCall, ToolError, ToolResult
-
SECRET_PATTERN = re.compile(r"\$\{secret\.([A-Z0-9_]+)\}")
+def _json_path(value: Any, path: str) -> Any:
+ current = value
+ for part in (item for item in path.split(".") if item):
+ if not isinstance(current, dict):
+ return None
+ current = current.get(part)
+ return current
+
+
@dataclass(frozen=True)
class ToolExecutionPolicy:
timeout_seconds: float
@@ -43,6 +52,7 @@ def execute(
session_id: str | None = None,
invocation_id: str | None = None,
timeout_seconds_override: float | None = None,
+ user_id: str | None = None,
) -> ToolResult:
with self.db.no_autoflush:
tool = self.db.exec(
@@ -87,6 +97,24 @@ def execute(
tool.name, "UNSUPPORTED_TOOL_TYPE", f"不支持的工具类型:{tool.tool_type}"
)
+ execution = (
+ tool.config_json.get("execution", {}) if isinstance(tool.config_json, dict) else {}
+ )
+ if execution.get("execution_mode") == "detached":
+ if not user_id and session_id:
+ session = self.db.get(ChatSession, session_id)
+ if session and session.tenant_id == tenant_id:
+ user_id = session.user_id
+ return self._execute_detached_http(
+ tool,
+ tool_call.arguments,
+ user_id=user_id,
+ agent_id=agent_id,
+ session_id=session_id,
+ invocation_id=invocation_id,
+ timeout_seconds_override=timeout_seconds_override,
+ )
+
headers = self._request_headers(
tool.url,
self._resolve_headers(tool.headers_json or {}, tool.auth_json or {}),
@@ -128,6 +156,143 @@ def execute(
except Exception as exc:
return self._error(tool.name, "EXECUTION_ERROR", str(exc))
+ def execute_sync_http(
+ self,
+ tool: Tool,
+ arguments: dict[str, Any],
+ *,
+ timeout_seconds_override: float | None = None,
+ ) -> ToolResult:
+ """Execute the HTTP request directly for the detached worker."""
+ headers = self._request_headers(
+ tool.url,
+ self._resolve_headers(tool.headers_json or {}, tool.auth_json or {}),
+ )
+ policy = self._execution_policy(
+ tool,
+ timeout_seconds_override=timeout_seconds_override,
+ )
+ try:
+ with httpx.Client(timeout=policy.timeout_seconds) as client:
+ if tool.method.upper() == "GET":
+ request_url, request_kwargs = prepare_get_request(tool.url, arguments)
+ response = client.request(
+ tool.method.upper(), request_url, headers=headers, **request_kwargs
+ )
+ else:
+ response = client.request(
+ tool.method.upper(), tool.url, headers=headers, json=arguments
+ )
+ response.raise_for_status()
+ return ToolResult(
+ tool_name=tool.name,
+ success=True,
+ data=self._response_data(response),
+ error=None,
+ )
+ except httpx.TimeoutException:
+ return self._error(
+ tool.name,
+ "TIMEOUT",
+ f"工具调用超过 {policy.timeout_seconds:g} 秒未返回。",
+ )
+ except httpx.HTTPStatusError as exc:
+ return self._error(
+ tool.name,
+ "HTTP_ERROR",
+ f"工具返回异常状态码:{exc.response.status_code}",
+ )
+ except Exception as exc:
+ return self._error(tool.name, "EXECUTION_ERROR", str(exc))
+
+ def _execute_detached_http(
+ self,
+ tool: Tool,
+ arguments: dict[str, Any],
+ *,
+ user_id: str | None,
+ agent_id: str | None,
+ session_id: str | None,
+ invocation_id: str | None,
+ timeout_seconds_override: float | None,
+ ) -> ToolResult:
+ if not user_id:
+ return self._error(
+ tool.name,
+ "USER_CONTEXT_REQUIRED",
+ "Detached tools require an authenticated user or a user-owned session.",
+ )
+ idempotency_key = (
+ f"staffdeck:{tool.tenant_id}:{tool.id}:{invocation_id}"
+ if invocation_id
+ else None
+ )
+ if idempotency_key:
+ existing = self.db.exec(
+ select(ExternalBusinessTask).where(
+ ExternalBusinessTask.idempotency_key == idempotency_key
+ )
+ ).first()
+ if existing is not None and existing.external_task_id:
+ return self._detached_acceptance_result(tool, existing)
+ callback_token = new_callback_token()
+ task = ExternalBusinessTask(
+ tenant_id=tool.tenant_id,
+ user_id=user_id,
+ agent_id=agent_id,
+ session_id=session_id,
+ invocation_id=invocation_id,
+ idempotency_key=idempotency_key,
+ tool_id=tool.id,
+ request_json=arguments,
+ callback_token_hash=callback_token_hash(callback_token),
+ status="queued",
+ poll_interval_seconds=1,
+ )
+ self.db.add(task)
+ self.db.flush()
+ task.external_task_id = task.id
+ task.accepted_at = utc_now()
+ task.updated_at = task.accepted_at
+ self.db.add(task)
+ self.db.commit()
+ self.db.refresh(task)
+ return self._detached_acceptance_result(tool, task)
+
+ @staticmethod
+ def _detached_acceptance_result(
+ tool: Tool,
+ task: ExternalBusinessTask,
+ ) -> ToolResult:
+ return ToolResult(
+ tool_name=tool.name,
+ success=True,
+ data={
+ "accepted": True,
+ "detached": True,
+ "status": task.status,
+ "task_id": task.external_task_id,
+ "staffdeck_task_id": task.id,
+ "status_query": {
+ "method": "GET",
+ "path": (
+ f"/api/enterprise/external-business-tasks/{task.external_task_id}"
+ f"?tenant_id={tool.tenant_id}&tool_id={tool.id}"
+ ),
+ "tenant_id": tool.tenant_id,
+ "guidance": (
+ "Use a separately configured authenticated HTTP status tool or SOP "
+ "to query this task by task_id."
+ ),
+ },
+ "user_reply": (
+ f"已经帮您提交任务,任务号 #{task.external_task_id},正在后台处理。"
+ f"您随时可以对我说 “查询 #{task.external_task_id} 状态” 查看结果。"
+ ),
+ },
+ error=None,
+ )
+
def _execute_a2a_tool(
self,
tool: Tool,
diff --git a/backend/app/tools/tool_schema.py b/backend/app/tools/tool_schema.py
index 2e9030e15..6936f6baa 100644
--- a/backend/app/tools/tool_schema.py
+++ b/backend/app/tools/tool_schema.py
@@ -9,6 +9,14 @@
class ToolExecutionPolicy(BaseModel):
timeout_seconds: float = Field(ge=1, le=3600)
+ execution_mode: Literal["sync", "detached"] = "sync"
+ status_url: Optional[str] = None
+ poll_interval_seconds: float = Field(default=5, ge=1, le=3600)
+ task_id_field: str = "taskId"
+ status_field: str = "status"
+ result_field: str = "result"
+ status_mapping: dict[str, str] = Field(default_factory=dict)
+ max_tracking_seconds: int = Field(default=86400, ge=1, le=2592000)
class ToolCreateRequest(BaseModel):
diff --git a/backend/tests/test_enterprise_auth_guards.py b/backend/tests/test_enterprise_auth_guards.py
index ee70b33fa..5ded919d4 100644
--- a/backend/tests/test_enterprise_auth_guards.py
+++ b/backend/tests/test_enterprise_auth_guards.py
@@ -4,6 +4,7 @@
from app.api.agents import enterprise_router as agents_router
from app.api.agents import scope_router as agent_scope_router
from app.api.feedback import router as feedback_router
+from app.api.external_business_tasks import enterprise_router as external_tasks_router
from app.api.general_skills import router as general_skills_router
from app.api.knowledge import router as knowledge_router
from app.api.knowledge_bases import router as knowledge_bases_router
@@ -34,6 +35,7 @@ def test_enterprise_read_endpoints_require_authentication() -> None:
app.include_router(agents_router)
app.include_router(agent_scope_router)
app.include_router(feedback_router)
+ app.include_router(external_tasks_router)
app.include_router(scheduled_tasks_router)
app.include_router(sessions_router)
client = TestClient(app)
@@ -60,6 +62,7 @@ def test_enterprise_read_endpoints_require_authentication() -> None:
"/api/enterprise/feedback/summary?tenant_id=tenant_demo",
"/api/enterprise/scheduled-tasks?tenant_id=tenant_demo",
"/api/enterprise/sessions?tenant_id=tenant_demo",
+ "/api/enterprise/external-business-tasks/provider-1?tenant_id=tenant_demo",
]
for path in paths:
diff --git a/backend/tests/test_external_business_tasks.py b/backend/tests/test_external_business_tasks.py
new file mode 100644
index 000000000..9e8832802
--- /dev/null
+++ b/backend/tests/test_external_business_tasks.py
@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import ClassVar
+
+import httpx
+from fastapi import HTTPException
+from sqlalchemy import inspect, text
+from sqlalchemy.pool import StaticPool
+from sqlmodel import Session, SQLModel, create_engine, select
+
+from app.api.external_business_tasks import (
+ ExternalTaskCallback,
+ external_business_task_callback,
+ get_external_business_task,
+)
+from app.db.models import (
+ ExternalBusinessTask,
+ ExternalBusinessTaskEvent,
+ Tenant,
+ Tool,
+ User,
+)
+from app.tools.external_tasks import callback_token_hash, poll_due_external_tasks
+from app.tools.tool_executor import ToolExecutor
+from app.tools.tool_schema import ToolCall, ToolResult
+
+
+class _Client:
+ response_json: ClassVar[dict] = {"taskId": "provider-42", "status": "queued"}
+ request_headers: ClassVar[dict[str, str]] = {}
+
+ def __init__(self, **_kwargs):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return None
+
+ def request(self, method, url, **kwargs):
+ del method, url
+ type(self).request_headers = kwargs.get("headers", {})
+ return httpx.Response(
+ 202,
+ json=type(self).response_json,
+ request=httpx.Request("POST", "https://provider.test/tasks"),
+ )
+
+
+def test_detached_submission_persists_task_and_returns_query_guidance(monkeypatch) -> None:
+ monkeypatch.setattr(
+ "app.tools.tool_executor.httpx.Client",
+ lambda **_: (_ for _ in ()).throw(AssertionError("Provider must not run during submit")),
+ )
+ with _session() as db:
+ user, tool = _seed(db)
+ result = ToolExecutor(db).execute(
+ "tenant_demo",
+ ToolCall(name=tool.name, arguments={"order": "A-1"}),
+ user_id=user.id,
+ )
+
+ task = db.exec(select(ExternalBusinessTask)).one()
+ assert result.success is True
+ assert result.data["accepted"] is True
+ assert result.data["task_id"] == task.id
+ assert f"/external-business-tasks/{task.id}?" in result.data["status_query"]["path"]
+ assert "tenant_id=tenant_demo&tool_id=tool_detached" in result.data["status_query"]["path"]
+ assert task.external_task_id == task.id
+ assert task.status == "queued"
+ assert "下单任务" not in result.data["status_query"]["guidance"]
+
+
+def test_detached_submission_does_not_require_provider_task_id(monkeypatch) -> None:
+ monkeypatch.setattr(
+ "app.tools.tool_executor.httpx.Client",
+ lambda **_: (_ for _ in ()).throw(AssertionError("Provider must not run during submit")),
+ )
+ with _session() as db:
+ user, tool = _seed(db)
+ result = ToolExecutor(db).execute(
+ "tenant_demo", ToolCall(name=tool.name), user_id=user.id
+ )
+ task = db.exec(select(ExternalBusinessTask)).one()
+ assert result.success is True
+ assert result.data["task_id"] == task.id
+ assert task.status == "queued"
+
+
+def test_staffdeck_worker_executes_original_sync_tool(monkeypatch) -> None:
+ with _session() as db:
+ user, tool = _seed(db)
+ task = ExternalBusinessTask(
+ tenant_id="tenant_demo", user_id=user.id, tool_id=tool.id,
+ external_task_id="exttask-1", status="queued",
+ callback_token_hash=callback_token_hash("token"),
+ request_json={"employee_id": "12343565"},
+ )
+ db.add(task)
+ db.commit()
+ monkeypatch.setattr(
+ "app.tools.tool_executor.ToolExecutor.execute_sync_http",
+ lambda *_args, **_kwargs: ToolResult(
+ tool_name=tool.name,
+ success=True,
+ data={"remaining": 20000},
+ ),
+ )
+ assert poll_due_external_tasks(db) == 1
+ db.refresh(task)
+ assert task.status == "completed"
+ assert task.result_json == {"remaining": 20000}
+
+
+def test_callback_auth_dedupe_and_user_owned_query() -> None:
+ with _session() as db:
+ user, tool = _seed(db)
+ other = User(
+ id="user_other", tenant_id="tenant_demo", username="other", password_hash="x"
+ )
+ db.add(other)
+ token = "opaque-callback-secret"
+ task = ExternalBusinessTask(
+ id="exttask_1",
+ tenant_id="tenant_demo",
+ user_id=user.id,
+ tool_id=tool.id,
+ external_task_id="provider-42",
+ status="accepted",
+ callback_token_hash=callback_token_hash(token),
+ )
+ db.add(task)
+ db.commit()
+
+ request = ExternalTaskCallback(
+ event_id="event-1", status="completed", result={"receipt": "ok"}
+ )
+ try:
+ external_business_task_callback(task.id, request, "wrong-token", db)
+ except HTTPException as exc:
+ assert exc.status_code == 401
+ else:
+ raise AssertionError("callback must require its per-task credential")
+ first = external_business_task_callback(task.id, request, token, db)
+ duplicate = external_business_task_callback(task.id, request, token, db)
+ assert first == {"accepted": True, "duplicate": False, "status": "completed"}
+ assert duplicate["duplicate"] is True
+ assert len(db.exec(select(ExternalBusinessTaskEvent)).all()) == 1
+
+ result = get_external_business_task("provider-42", "tenant_demo", None, db, user)
+ assert result["status"] == "completed"
+ assert result["result"] == {"receipt": "ok"}
+ try:
+ get_external_business_task("provider-42", "tenant_demo", None, db, other)
+ except HTTPException as exc:
+ assert exc.status_code == 404
+ else:
+ raise AssertionError("another user must not read this task")
+
+
+def test_polling_only_updates_external_task_state(monkeypatch) -> None:
+ with _session() as db:
+ user, tool = _seed(db)
+ task = ExternalBusinessTask(
+ tenant_id="tenant_demo",
+ user_id=user.id,
+ tool_id=tool.id,
+ external_task_id="provider-42",
+ status="accepted",
+ callback_token_hash=callback_token_hash("token"),
+ status_url="https://provider.test/tasks/provider-42",
+ status_config_json={
+ "status_field": "task.state",
+ "result_field": "task.output",
+ "status_mapping": {"done": "completed"},
+ },
+ next_poll_at=tool.created_at,
+ )
+ db.add(task)
+ db.commit()
+ monkeypatch.setattr(
+ "app.tools.external_tasks.httpx.get",
+ lambda *_args, **_kwargs: SimpleNamespace(
+ raise_for_status=lambda: None,
+ json=lambda: {"task": {"state": "done", "output": {"value": 9}}},
+ ),
+ )
+
+ assert poll_due_external_tasks(db) == 1
+ db.refresh(task)
+ assert task.status == "completed"
+ assert task.result_json == {"value": 9}
+ assert task.poll_attempts == 1
+
+
+def test_create_all_migrates_existing_database_with_external_task_tables() -> None:
+ engine = create_engine("sqlite://", poolclass=StaticPool)
+ with engine.begin() as connection:
+ connection.execute(text("CREATE TABLE tenants (id VARCHAR PRIMARY KEY, name VARCHAR)"))
+
+ SQLModel.metadata.create_all(engine)
+
+ tables = set(inspect(engine).get_table_names())
+ assert {"external_business_tasks", "external_business_task_events"} <= tables
+
+
+def _seed(db: Session) -> tuple[User, Tool]:
+ db.add(Tenant(id="tenant_demo", name="Demo"))
+ user = User(
+ id="user_owner", tenant_id="tenant_demo", username="owner", password_hash="x"
+ )
+ tool = Tool(
+ id="tool_detached",
+ tenant_id="tenant_demo",
+ name="orders.submit",
+ method="POST",
+ url="https://provider.test/tasks",
+ config_json={
+ "execution": {
+ "execution_mode": "detached",
+ "timeout_seconds": 8,
+ "status_url": "https://provider.test/tasks/{taskId}",
+ "poll_interval_seconds": 5,
+ }
+ },
+ )
+ db.add(user)
+ db.add(tool)
+ db.commit()
+ return user, tool
+
+
+def _session() -> Session:
+ engine = create_engine(
+ "sqlite://",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ SQLModel.metadata.create_all(engine)
+ return Session(engine)
diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py
index 3b1ad1c82..de3abb00f 100644
--- a/backend/tests/test_tools_api.py
+++ b/backend/tests/test_tools_api.py
@@ -171,14 +171,42 @@ def test_tool_config_namespaces_execution_and_preserves_existing_policy() -> Non
existing={**created, "obsolete": True},
)
- assert created == {"tool": "sum", "execution": {"timeout_seconds": 20.0}}
+ assert created == {
+ "tool": "sum",
+ "execution": {
+ "timeout_seconds": 20.0,
+ "execution_mode": "sync",
+ "status_url": None,
+ "poll_interval_seconds": 5.0,
+ "task_id_field": "taskId",
+ "status_field": "status",
+ "result_field": "result",
+ "status_mapping": {},
+ "max_tracking_seconds": 86400,
+ },
+ }
assert updated_by_legacy_client == {
"tool": "echo",
"obsolete": False,
- "execution": {"timeout_seconds": 20.0},
+ "execution": created["execution"],
}
+def test_tool_config_round_trips_detached_execution_policy() -> None:
+ policy = ToolExecutionPolicy(
+ timeout_seconds=12,
+ execution_mode="detached",
+ status_url="https://provider.test/tasks/{taskId}",
+ poll_interval_seconds=10,
+ )
+
+ config = _tool_config({}, policy)
+
+ assert config["execution"]["execution_mode"] == "detached"
+ assert config["execution"]["status_url"].endswith("/{taskId}")
+ assert _read_execution_policy(config) == policy
+
+
def test_tool_config_rejects_untyped_execution_and_reads_invalid_legacy_safely() -> None:
config = _tool_config(
{"tool": "sum", "execution": {"timeout_seconds": 3601}},
diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json
index 8295a8c8a..4867abad9 100644
--- a/frontend-enterprise/src/i18n/en.json
+++ b/frontend-enterprise/src/i18n/en.json
@@ -69,6 +69,7 @@
"保存后可选择并测试": "After saving, can Select and Test",
"保存技能失败": "Failed to save the skill.",
"保存设置": "Save Settings",
+ "执行模式": "Execution mode",
"API 密钥": "API Keys",
"API 密钥 ·": "API Keys ·",
"API 密钥已创建,请立即复制保存": "API key created. Copy and save it now.",
@@ -1204,6 +1205,7 @@
"通过 Trace、反馈、记忆和定时任务沉淀失败原因、稳定口径和长期任务。": "Use Trace, feedback, memory, and scheduled tasks to retain failure reasons, stable standards, and long-running work.",
"通过商品知识库、比价技能和购买工具,完成推荐、价格解释、下单确认和结果反馈。": "Combine product knowledge, price-comparison skills, and purchase tools for recommendations, price explanations, order confirmation, and result feedback.",
"同步结果": "Sync Result",
+ "同步等待结果": "Wait for result",
"同步失败": "Sync Failed",
"同步完成:新增 {1},更新 {2}": "Sync Complete: Add {1}, Update {2}",
"头像读取失败": "Failed to Load Avatar",
@@ -1817,6 +1819,8 @@
"转为草稿": "Convert to Draft",
"转为草稿失败": "Failed to Convert to Draft",
"状态": "Status",
+ "状态查询 URL(可选)": "Status URL (optional)",
+ "使用 {taskId} 作为外部任务 ID 占位符;留空时由回调更新。": "Use {taskId} as the external task ID placeholder. Leave blank to rely on callbacks.",
"状态:": "Status:",
"状态筛选": "Status Filter",
"追问文案": "Follow-up Prompt",
@@ -2525,6 +2529,7 @@
"任务创建": "Task created",
"任务开始": "Task started",
"提交报告": "Report submitted",
+ "提交后异步跟踪": "Submit and track asynchronously",
"任务升级": "Task escalated",
"竞标开始": "Bidding started",
"竞标定标": "Bid awarded",
diff --git a/frontend-enterprise/src/pages/ToolsPage.tsx b/frontend-enterprise/src/pages/ToolsPage.tsx
index 1b44aeb5b..9b2943c62 100644
--- a/frontend-enterprise/src/pages/ToolsPage.tsx
+++ b/frontend-enterprise/src/pages/ToolsPage.tsx
@@ -102,6 +102,14 @@ const TOOL_FORM_INITIAL_VALUES = {
input_schema: '{}',
output_schema: '{}',
timeout_seconds: 8,
+ execution_mode: 'sync' as 'sync' | 'detached',
+ status_url: '',
+ poll_interval_seconds: 5,
+ task_id_field: 'taskId',
+ status_field: 'status',
+ result_field: 'result',
+ status_mapping: '{\n "queued": "accepted",\n "processing": "working",\n "succeeded": "completed",\n "failed": "failed"\n}',
+ max_tracking_seconds: 86400,
capability_scope: 'general' as CapabilityScope,
};
@@ -2059,6 +2067,45 @@ function ToolFormFields({
{values.tool_type === 'a2a' &&
StaffDeck 托管异步
+StaffDeck 会生成任务号,后台执行当前 HTTP 请求并保存结果。Provider 不需要返回 taskId;用户可通过“查询 #任务号”获取结果。
+提交后当前对话立即结束;任务完成只更新状态,不主动推送消息。用户后续通过任务号查询。
+StaffDeck 托管异步
-StaffDeck 会生成任务号,后台执行当前 HTTP 请求并保存结果。Provider 不需要返回 taskId;用户可通过“查询 #任务号”获取结果。
-提交后当前对话立即结束;任务完成只更新状态,不主动推送消息。用户后续通过任务号查询。
+ + {values.async_strategy === 'staffdeck_worker' ? ( +StaffDeck 托管异步
+StaffDeck 会生成任务号,在后台执行普通同步 HTTP 请求并保存最终结果;Provider 不需要返回 taskId。
+Provider 返回 taskId 后由 StaffDeck 轮询状态;配置外部回调地址后也可接收带任务密钥的回调。
+ > + )} +提交后当前对话立即结束;普通会话通过任务号查询,SOP 会在任务结束后从持久化检查点继续。