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:
2026-10-19 18:46:58 +08:00
parent 2b6fc5aa52
commit be6317eb48
117 changed files with 12703 additions and 638 deletions
+96 -6
View File
@@ -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()