feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence, limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs. Default nearby searches to 200 meters and five results. Integrate investigation messaging/presentation, health/readiness and typed history; remove duplicate web search and fix online simulation and compressed baseline writes. Include project migrations 023-025; keep local credentials and demo seeding excluded. Validation: 319 focused pytest checks; real PostGIS temporary-table regression; read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
This commit is contained in:
@@ -13,6 +13,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -35,6 +36,7 @@ class WorkerSettings:
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
token_refresh_seconds: float = 300
|
||||
task_created_from: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "WorkerSettings":
|
||||
@@ -43,6 +45,9 @@ class WorkerSettings:
|
||||
interval = float(os.environ.get("OPERATIONAL_WORKER_RECONCILE_SECONDS", "30"))
|
||||
if interval < 1:
|
||||
raise ValueError("worker interval must be at least one second")
|
||||
token_url = os.environ.get("OPERATIONAL_WORKER_TOKEN_URL", "")
|
||||
if os.environ.get("ENVIRONMENT") == "development":
|
||||
token_url = os.environ.get("OPERATIONAL_WORKER_DEV_TOKEN_URL") or token_url
|
||||
settings = cls(
|
||||
server_url=os.environ.get("OPERATIONAL_WORKER_SERVER_URL", "http://127.0.0.1:8000"),
|
||||
agent_url=os.environ.get("OPERATIONAL_WORKER_AGENT_URL", "http://127.0.0.1:8787"),
|
||||
@@ -50,11 +55,16 @@ class WorkerSettings:
|
||||
interval_seconds=interval,
|
||||
max_concurrency=int(os.environ.get("OPERATIONAL_WORKER_MAX_CONCURRENCY", "4")),
|
||||
bearer=os.environ.get("OPERATIONAL_WORKER_BEARER", ""),
|
||||
token_url=os.environ.get("OPERATIONAL_WORKER_TOKEN_URL", ""),
|
||||
token_url=token_url,
|
||||
client_id=os.environ.get("OPERATIONAL_WORKER_CLIENT_ID", ""),
|
||||
client_secret=os.environ.get("OPERATIONAL_WORKER_CLIENT_SECRET", ""),
|
||||
token_refresh_seconds=float(os.environ.get("OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS", "300")),
|
||||
task_created_from=os.environ.get("OPERATIONAL_WORKER_TASK_CREATED_FROM") or None,
|
||||
)
|
||||
if settings.task_created_from:
|
||||
boundary = datetime.fromisoformat(settings.task_created_from.replace("Z", "+00:00"))
|
||||
if boundary.tzinfo is None:
|
||||
raise ValueError("worker task creation boundary must include a timezone")
|
||||
if not 30 <= settings.token_refresh_seconds <= 600:
|
||||
raise ValueError("service token refresh interval must be between 30 and 600 seconds")
|
||||
if not 1 <= settings.max_concurrency <= 32:
|
||||
@@ -97,28 +107,52 @@ class OperationalWorker:
|
||||
self.settings = settings
|
||||
self.client = client
|
||||
self.credentials = CredentialProvider(settings)
|
||||
self._agent_sessions: dict[str, str] = {}
|
||||
self._message_cursor: str | None = None
|
||||
|
||||
async def tick(self) -> int:
|
||||
headers = await self.credentials.headers(self.client)
|
||||
server = self.settings.server_url.rstrip("/")
|
||||
scope = {"task_type": "event_investigation"}
|
||||
if self.settings.task_created_from:
|
||||
scope["task_created_from"] = self.settings.task_created_from
|
||||
recovery = await self.client.post(
|
||||
f"{server}/api/v1/operational-tasks/leases/recover", headers=headers, json={"limit": 100},
|
||||
f"{server}/api/v1/operational-tasks/leases/recover", headers=headers,
|
||||
json={"limit": 100, **scope},
|
||||
)
|
||||
recovery.raise_for_status()
|
||||
active_run_ids: set[str] = set()
|
||||
for status_value in ("claimed", "running"):
|
||||
response = await self.client.get(
|
||||
f"{server}/api/v1/operational-task-runs", headers=headers,
|
||||
params={"status": status_value, "limit": self.settings.max_concurrency},
|
||||
params={"status": status_value, "limit": self.settings.max_concurrency, **scope},
|
||||
)
|
||||
response.raise_for_status()
|
||||
active_run_ids.update(str(item["run_id"]) for item in response.json()["items"])
|
||||
for item in response.json()["items"]:
|
||||
run_id = str(item["run_id"])
|
||||
active_run_ids.add(run_id)
|
||||
session_id = item.get("agent_session_id")
|
||||
if isinstance(session_id, str) and session_id:
|
||||
self._agent_sessions[run_id] = session_id
|
||||
for run_id, session_id in list(self._agent_sessions.items()):
|
||||
if run_id not in active_run_ids:
|
||||
self._agent_sessions.pop(run_id, None)
|
||||
continue
|
||||
# Refresh the Agent vault with the worker's existing OAuth provider.
|
||||
# Background runs never depend on an interactive browser refresh.
|
||||
renewed = await self.client.post(
|
||||
f"{self.settings.agent_url.rstrip('/')}/api/v1/session/{session_id}/credentials",
|
||||
headers=headers, json={},
|
||||
)
|
||||
if renewed.status_code not in (202, 404, 409):
|
||||
renewed.raise_for_status()
|
||||
available = max(0, self.settings.max_concurrency - len(active_run_ids))
|
||||
if available == 0:
|
||||
await self._reconcile_messages(headers)
|
||||
return 0
|
||||
response = await self.client.get(
|
||||
f"{server}/api/v1/operational-task-runs", headers=headers,
|
||||
params={"status": "queued", "limit": available},
|
||||
params={"status": "queued", "limit": available, **scope},
|
||||
)
|
||||
response.raise_for_status()
|
||||
queued = response.json()["items"]
|
||||
@@ -131,7 +165,7 @@ class OperationalWorker:
|
||||
request_id = str(UUID(run["execution_request_id"]))
|
||||
try:
|
||||
result = await self.client.post(
|
||||
f"{self.settings.agent_url.rstrip('/')}/api/v1/agent/chat/task-executions",
|
||||
f"{self.settings.agent_url.rstrip('/')}/api/v1/task-executions",
|
||||
headers=headers,
|
||||
json={
|
||||
"task_id": task_id,
|
||||
@@ -143,6 +177,9 @@ class OperationalWorker:
|
||||
payload = result.json()
|
||||
if payload.get("task_id") != task_id or payload.get("run_id") != run_id:
|
||||
raise ValueError("Agent returned a different task run")
|
||||
session_id = payload.get("session_id")
|
||||
if isinstance(session_id, str) and session_id:
|
||||
self._agent_sessions[run_id] = session_id
|
||||
return True
|
||||
await self._record_dispatch_failure(
|
||||
headers, run, retryable=result.status_code >= 500,
|
||||
@@ -157,8 +194,57 @@ class OperationalWorker:
|
||||
return False
|
||||
|
||||
results = await asyncio.gather(*(dispatch(run) for run in queued))
|
||||
await self._reconcile_messages(headers)
|
||||
return sum(results)
|
||||
|
||||
async def _reconcile_messages(self, headers: dict[str, str]) -> None:
|
||||
# Reconcile already bound runs even when task dispatch is at capacity.
|
||||
# Agent and Server share immutable message/native identities for retries.
|
||||
semaphore = asyncio.Semaphore(self.settings.max_concurrency)
|
||||
|
||||
async def dispatch(message: dict) -> None:
|
||||
async with semaphore:
|
||||
message_id = str(UUID(message["message_id"]))
|
||||
try:
|
||||
delivered = await self.client.post(
|
||||
f"{self.settings.agent_url.rstrip('/')}/api/v1/task-messages/deliver",
|
||||
headers=headers, json={"message_id": message_id},
|
||||
)
|
||||
if delivered.status_code in {400, 403, 404, 410, 422}:
|
||||
failed = await self.client.post(
|
||||
f"{self.settings.server_url.rstrip('/')}/api/v1/agent-messages/{message_id}/delivery",
|
||||
headers=headers, json={
|
||||
"status": "failed",
|
||||
"failure_detail": f"Agent rejected message delivery (HTTP {delivered.status_code})",
|
||||
},
|
||||
)
|
||||
if failed.status_code != 409:
|
||||
failed.raise_for_status()
|
||||
elif not delivered.is_success:
|
||||
delivered.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
# Avoid message content and credentials in logs. Durable
|
||||
# nonterminal messages are available at the next tick.
|
||||
logger.warning("Agent message delivery remains pending: %s", message_id)
|
||||
|
||||
# Bounded pages and a cursor retained between ticks prevent idle chats
|
||||
# from occupying the first page forever. Completing a sweep starts the
|
||||
# next tick from the beginning so older transient failures are retried.
|
||||
for _ in range(4):
|
||||
params = {"limit": 50}
|
||||
if self._message_cursor:
|
||||
params["cursor"] = self._message_cursor
|
||||
response = await self.client.get(
|
||||
f"{self.settings.server_url.rstrip('/')}/api/v1/agent-messages/pending",
|
||||
headers=headers, params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
await asyncio.gather(*(dispatch(message) for message in payload["items"]))
|
||||
self._message_cursor = payload.get("next_cursor")
|
||||
if not self._message_cursor:
|
||||
break
|
||||
|
||||
async def _record_dispatch_failure(
|
||||
self, headers: dict[str, str], run: dict, *, retryable: bool,
|
||||
code: str, detail: str = "Agent rejected task run dispatch",
|
||||
@@ -221,6 +307,10 @@ class OperationalWorker:
|
||||
|
||||
async def run(once: bool) -> None:
|
||||
settings = WorkerSettings.from_environment()
|
||||
logger.info(
|
||||
"Investigation worker scope: project=%s task_created_from=%s concurrency=%d",
|
||||
settings.project_id, settings.task_created_from or "all", settings.max_concurrency,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
|
||||
worker = OperationalWorker(settings, client)
|
||||
wake = asyncio.Event()
|
||||
|
||||
@@ -122,7 +122,9 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
existing[task["source"].get("external_id")] = task
|
||||
elif task["task_type"] == "event_investigation":
|
||||
event_tasks[task.get("source", {}).get("external_id")] = task
|
||||
if task["status"] in {"queued", "waiting_human", "running"}:
|
||||
if task["status"] in {"queued", "waiting_human", "running"} or (
|
||||
work_order_ids is not None and task["status"] == "completed"
|
||||
):
|
||||
investigations[task["task_id"]] = task
|
||||
if page.get("next_offset") is None:
|
||||
break
|
||||
@@ -188,23 +190,62 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
result.raise_for_status()
|
||||
task = result.json()
|
||||
existing[external_id] = task
|
||||
changed = (task["source"].get("external_revision") or 0) < order["revision"]
|
||||
if work_order_ids is None and changed and order["revision"] > 1:
|
||||
# Only changed sources need completed-parent receipt discovery.
|
||||
# Never fetch every historical completed investigation each tick.
|
||||
offset = 0
|
||||
while True:
|
||||
related = await client.get(tasks_url, headers=headers, params={
|
||||
"limit": 100, "offset": offset, "peripheral_work_order_id": order["work_order_id"],
|
||||
})
|
||||
related.raise_for_status()
|
||||
related_page = related.json()
|
||||
for parent in related_page["items"]:
|
||||
if parent["task_type"] != "event_investigation" or parent.get("status") != "completed":
|
||||
continue
|
||||
detail = await client.get(f"{tasks_url}/{parent['task_id']}", headers=headers)
|
||||
detail.raise_for_status()
|
||||
for action in detail.json().get("actions", []):
|
||||
receipt = action.get("receipt")
|
||||
if (action["kind"] == "create_work_order" and receipt
|
||||
and receipt["adapter"] == "peripheral_sample"
|
||||
and receipt["receipt_id"] == order["work_order_id"]):
|
||||
linked.setdefault(order["work_order_id"], []).append(parent["task_id"])
|
||||
if related_page.get("next_offset") is None:
|
||||
break
|
||||
offset = related_page["next_offset"]
|
||||
event_id = f"{external_id}:{order['revision']}"
|
||||
notes = [activity["detail"] for activity in order.get("activity", [])
|
||||
if activity.get("kind") == "feedback" and activity.get("detail")]
|
||||
evidence_summary = order["summary"]
|
||||
if notes:
|
||||
evidence_summary += "\n现场反馈:\n" + "\n".join(notes[-10:])
|
||||
feedback = {
|
||||
"request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id,
|
||||
"source_revision": order["revision"], "status": STATUS[order["status"]],
|
||||
"occurred_at": order["updated_at"],
|
||||
"detail": f"合成外围工单 {order['work_order_id']}:{order['status']}",
|
||||
"evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": order["summary"],
|
||||
"evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": evidence_summary,
|
||||
"source_label": "外围样板合成工单", "source_ref": external_id,
|
||||
"observed_at": order["updated_at"]},
|
||||
}
|
||||
targets = set(linked.get(order["work_order_id"], []))
|
||||
# A child order's creation is already represented by its dispatch
|
||||
# receipt. Only a subsequent source revision is new field input.
|
||||
targets = set(linked.get(order["work_order_id"], [])) if order["revision"] > 1 else set()
|
||||
if work_order_ids is None and not changed:
|
||||
targets.clear()
|
||||
event_task = event_tasks.get(order["work_order_id"])
|
||||
if event_task and order["revision"] > (event_task["source"].get("external_revision") or 0):
|
||||
if (event_task and event_task.get("status") != "cancelled"
|
||||
and order["revision"] > (event_task["source"].get("external_revision") or 0)):
|
||||
targets.add(event_task["task_id"])
|
||||
if (task["source"].get("external_revision") or 0) < order["revision"]:
|
||||
targets.add(task["task_id"])
|
||||
for target in targets:
|
||||
# The external projection is the reconciliation cursor. Advance it
|
||||
# only after all investigation feedback was accepted; retry remains
|
||||
# safe if a response is lost or a later target fails.
|
||||
ordered_targets = sorted(targets)
|
||||
if changed:
|
||||
ordered_targets.append(task["task_id"])
|
||||
for target in ordered_targets:
|
||||
payload = dict(feedback)
|
||||
if target == task["task_id"]:
|
||||
payload["work_order_snapshot"] = work_order_snapshot(order)
|
||||
|
||||
Reference in New Issue
Block a user