union-app-chat-stream 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/__init__.py +1 -0
- package/app/agent/__init__.py +1 -0
- package/app/agent/capabilities.py +388 -0
- package/app/agent/coordinator/__init__.py +1 -0
- package/app/agent/coordinator/definition.py +50 -0
- package/app/agent/coordinator/output_guard.py +29 -0
- package/app/agent/graph.py +95 -0
- package/app/agent/guardrails.py +30 -0
- package/app/agent/routing.py +81 -0
- package/app/agent/runtime/__init__.py +1 -0
- package/app/agent/runtime/activity.py +393 -0
- package/app/agent/runtime/delegation.py +80 -0
- package/app/agent/runtime/deps.py +34 -0
- package/app/agent/runtime/execution.py +368 -0
- package/app/agent/runtime/model.py +47 -0
- package/app/agent/runtime/model_errors.py +24 -0
- package/app/agent/runtime/session.py +154 -0
- package/app/agent/specialists/__init__.py +1 -0
- package/app/agent/specialists/behavior_risk/__init__.py +1 -0
- package/app/agent/specialists/behavior_risk/definition.py +54 -0
- package/app/agent/specialists/build.py +94 -0
- package/app/agent/specialists/knowledge/__init__.py +1 -0
- package/app/agent/specialists/knowledge/definition.py +38 -0
- package/app/agent/specialists/personal_memory/__init__.py +1 -0
- package/app/agent/specialists/personal_memory/definition.py +35 -0
- package/app/agent/specialists/personal_memory/output_guard.py +55 -0
- package/app/agent/specialists/running_analysis/__init__.py +1 -0
- package/app/agent/specialists/running_analysis/definition.py +46 -0
- package/app/agent/specialists/running_analysis/output_guard.py +38 -0
- package/app/agent/specialists/scheduled_task_draft/__init__.py +8 -0
- package/app/agent/specialists/scheduled_task_draft/definition.py +142 -0
- package/app/agent/specialists/scheduled_task_draft/output_guard.py +81 -0
- package/app/asgi.py +139 -0
- package/app/config/__init__.py +1 -0
- package/app/config/settings.py +67 -0
- package/app/memory/__init__.py +1 -0
- package/app/memory/store.py +154 -0
- package/app/service/rag_service.py +364 -0
- package/app/skills/full-chain-quality-analysis/SKILL.md +22 -0
- package/app/tools/__init__.py +1 -0
- package/app/tools/business.py +183 -0
- package/app/utils/__init__.py +1 -0
- package/app/utils/api_client.py +76 -0
- package/app/utils/control_auth.py +35 -0
- package/app/utils/state_client.py +60 -0
- package/app/views/__init__.py +1 -0
- package/app/views/auth.py +189 -0
- package/app/views/errors.py +19 -0
- package/app/views/routes.py +25 -0
- package/app/views/run_context.py +33 -0
- package/app/views/streaming_runs.py +340 -0
- package/app/views/sync_runs.py +152 -0
- package/deploy/autoconf/templates/env.j2 +23 -0
- package/deploy/autoconf.yml +15 -0
- package/deploy/scripts/healthcheck.sh +12 -0
- package/deploy/scripts/start.sh +80 -0
- package/deploy/scripts/stop.sh +35 -0
- package/knowledge/000036-scenario-offline-function-call-mock-v1.md +134 -0
- package/package.json +21 -0
- package/requirements.txt +10 -0
- package/scripts/healthcheck.sh +4 -0
- package/scripts/start-BJ11.sh +1 -0
- package/scripts/start-BJ12.sh +1 -0
- package/scripts/start-SH20.sh +1 -0
- package/scripts/start-SZ31.sh +1 -0
- package/scripts/stop.sh +4 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""封装 Agent 会话、运行结果与记忆状态的远端操作。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from app.utils.control_auth import ControlAuth
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgentStateError(RuntimeError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AgentStateClient:
|
|
17
|
+
"""访问远端 Agent 状态接口的认证客户端。"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
http: httpx.AsyncClient,
|
|
23
|
+
base_url: str,
|
|
24
|
+
auth: ControlAuth,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._http = http
|
|
27
|
+
self._base_url = base_url.rstrip("/")
|
|
28
|
+
self._auth = auth
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def headers(self) -> dict[str, str]:
|
|
32
|
+
return self._auth.headers()
|
|
33
|
+
|
|
34
|
+
async def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
35
|
+
response = await self._http.post(
|
|
36
|
+
f"{self._base_url}{path}",
|
|
37
|
+
json=payload,
|
|
38
|
+
headers=self.headers,
|
|
39
|
+
timeout=15,
|
|
40
|
+
)
|
|
41
|
+
if response.status_code >= 400:
|
|
42
|
+
raise AgentStateError(f"state request failed: {response.status_code}")
|
|
43
|
+
body = response.json()
|
|
44
|
+
if not isinstance(body, dict):
|
|
45
|
+
raise AgentStateError("state service returned a non-object response")
|
|
46
|
+
return body
|
|
47
|
+
|
|
48
|
+
async def load_messages(self, conversation_id: str) -> list[dict[str, Any]]:
|
|
49
|
+
body = await self.post(
|
|
50
|
+
"/agent/getConversationMessages",
|
|
51
|
+
{"conversationId": conversation_id},
|
|
52
|
+
)
|
|
53
|
+
messages = body.get("messages", [])
|
|
54
|
+
return messages if isinstance(messages, list) else []
|
|
55
|
+
|
|
56
|
+
async def complete_run(
|
|
57
|
+
self,
|
|
58
|
+
payload: dict[str, Any],
|
|
59
|
+
) -> None:
|
|
60
|
+
await self.post("/agent/completeRun", payload)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""存放 ASGI 应用的 HTTP 路由与请求边界处理。"""
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""校验 CAS 与定时任务身份并构造可信运行身份。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from base64 import b64decode
|
|
8
|
+
from binascii import Error as Base64Error
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from http.cookies import CookieError, SimpleCookie
|
|
11
|
+
from zoneinfo import ZoneInfo
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
15
|
+
from starlette.requests import Request
|
|
16
|
+
|
|
17
|
+
from app.utils.control_auth import ControlAuth
|
|
18
|
+
from app.views.errors import RouteError
|
|
19
|
+
|
|
20
|
+
_COOKIE_VALUE = re.compile(r"^[A-Za-z0-9._:@+-]{1,256}$")
|
|
21
|
+
_SCHEDULED_TOKEN = re.compile(r"^[A-Za-z0-9_-]{43}$")
|
|
22
|
+
_FORBIDDEN_SCHEDULED_HEADERS = {
|
|
23
|
+
"x-agent-scheduled-run-id",
|
|
24
|
+
"x-agent-user-id",
|
|
25
|
+
"x-agent-org-code",
|
|
26
|
+
"x-agent-effective-at",
|
|
27
|
+
"x-agent-effective-timezone",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ScheduledIdentity(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
33
|
+
|
|
34
|
+
authentication_type: str = Field(alias="authenticationType")
|
|
35
|
+
run_id: int = Field(alias="runId", strict=True, gt=0)
|
|
36
|
+
task_id: int = Field(alias="taskId", strict=True, gt=0)
|
|
37
|
+
user_id: str = Field(alias="userId", min_length=1, max_length=256)
|
|
38
|
+
org_code: str = Field(alias="orgCode", min_length=1, max_length=64)
|
|
39
|
+
role_id: str = Field(alias="roleId", min_length=1, max_length=64)
|
|
40
|
+
prompt: str = Field(min_length=1, max_length=65536)
|
|
41
|
+
scheduled_at: datetime = Field(alias="scheduledAt")
|
|
42
|
+
timezone: str = Field(min_length=1, max_length=128)
|
|
43
|
+
expires_at: datetime = Field(alias="expiresAt")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _ScheduledEnvelope(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
success: bool
|
|
50
|
+
data: ScheduledIdentity
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def authenticate_user(request: Request, runtime):
|
|
54
|
+
session_id = _cookies(request.headers.get("cookie", "")).get("CASSESSIONID")
|
|
55
|
+
if not session_id:
|
|
56
|
+
raise RouteError(401, "auth error")
|
|
57
|
+
try:
|
|
58
|
+
response = await runtime.http.get(
|
|
59
|
+
f"{runtime.settings.union_base_url}/common/getUserInfo",
|
|
60
|
+
headers={"Cookie": f"CASSESSIONID={session_id}"},
|
|
61
|
+
timeout=10,
|
|
62
|
+
)
|
|
63
|
+
body = response.json()
|
|
64
|
+
data = body.get("data", {}) if response.status_code < 400 else {}
|
|
65
|
+
except (httpx.HTTPError, ValueError):
|
|
66
|
+
raise RouteError(401, "auth error")
|
|
67
|
+
if (
|
|
68
|
+
not isinstance(data, dict)
|
|
69
|
+
or not isinstance(data.get("userId"), str)
|
|
70
|
+
or not data["userId"]
|
|
71
|
+
):
|
|
72
|
+
raise RouteError(401, "auth error")
|
|
73
|
+
permissions = data.get("permissions") or []
|
|
74
|
+
if isinstance(permissions, str):
|
|
75
|
+
permissions = [permissions]
|
|
76
|
+
required = runtime.settings.required_permission
|
|
77
|
+
if required and required not in permissions:
|
|
78
|
+
raise RouteError(403, "auth error")
|
|
79
|
+
return {
|
|
80
|
+
"authentication_type": "CAS",
|
|
81
|
+
"user_id": data["userId"],
|
|
82
|
+
"org_code": data.get("orgCode", ""),
|
|
83
|
+
"permissions": tuple(permissions),
|
|
84
|
+
"control_auth": ControlAuth.cas(session_id),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def reject_scheduled_on_cas_endpoint(request: Request) -> None:
|
|
89
|
+
authorization = request.headers.get("authorization")
|
|
90
|
+
if not authorization or not authorization.lower().startswith("scheduled "):
|
|
91
|
+
return
|
|
92
|
+
if request.headers.get("cookie") is not None:
|
|
93
|
+
raise RouteError(400, "mixed_authentication")
|
|
94
|
+
raise RouteError(401, "auth error")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def scheduled_token(request: Request) -> str:
|
|
98
|
+
authorizations = request.headers.getlist("authorization")
|
|
99
|
+
if len(authorizations) != 1:
|
|
100
|
+
raise RouteError(401, "scheduled_auth_error")
|
|
101
|
+
authorization = authorizations[0]
|
|
102
|
+
cookie_present = request.headers.get("cookie") is not None
|
|
103
|
+
is_scheduled = bool(
|
|
104
|
+
authorization and authorization.lower().startswith("scheduled ")
|
|
105
|
+
)
|
|
106
|
+
if cookie_present and is_scheduled:
|
|
107
|
+
raise RouteError(400, "mixed_authentication")
|
|
108
|
+
if cookie_present or not authorization:
|
|
109
|
+
raise RouteError(401, "scheduled_auth_error")
|
|
110
|
+
scheme, separator, token = authorization.partition(" ")
|
|
111
|
+
if separator != " " or scheme.lower() != "scheduled" or not _valid_token(token):
|
|
112
|
+
raise RouteError(401, "scheduled_auth_error")
|
|
113
|
+
return token
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def strict_scheduled_body(request: Request) -> None:
|
|
117
|
+
if _FORBIDDEN_SCHEDULED_HEADERS.intersection(request.headers.keys()):
|
|
118
|
+
raise RouteError(422, "invalid_scheduled_request")
|
|
119
|
+
try:
|
|
120
|
+
payload = json.loads(await request.body())
|
|
121
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
122
|
+
raise RouteError(422, "invalid_scheduled_request")
|
|
123
|
+
if not isinstance(payload, dict) or payload:
|
|
124
|
+
raise RouteError(422, "invalid_scheduled_request")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def authenticate_scheduled_run(runtime, token: str) -> ScheduledIdentity:
|
|
128
|
+
try:
|
|
129
|
+
response = await runtime.http.post(
|
|
130
|
+
f"{runtime.settings.union_base_url}/agent/scheduledExecutionIdentity",
|
|
131
|
+
headers=ControlAuth.scheduled(token).headers(),
|
|
132
|
+
json={},
|
|
133
|
+
timeout=10,
|
|
134
|
+
)
|
|
135
|
+
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
136
|
+
raise RouteError(503, "scheduled_identity_unavailable") from exc
|
|
137
|
+
if response.status_code == 401:
|
|
138
|
+
raise RouteError(401, "scheduled_auth_error")
|
|
139
|
+
if response.status_code == 403:
|
|
140
|
+
raise RouteError(403, "forbidden")
|
|
141
|
+
if response.status_code >= 500:
|
|
142
|
+
raise RouteError(503, "scheduled_identity_unavailable")
|
|
143
|
+
if response.status_code >= 400:
|
|
144
|
+
raise RouteError(502, "scheduled_identity_unavailable")
|
|
145
|
+
try:
|
|
146
|
+
envelope = _ScheduledEnvelope.model_validate(response.json())
|
|
147
|
+
identity = envelope.data
|
|
148
|
+
if not envelope.success or identity.authentication_type != "SCHEDULED":
|
|
149
|
+
raise ValueError
|
|
150
|
+
if identity.scheduled_at.tzinfo is None or identity.expires_at.tzinfo is None:
|
|
151
|
+
raise ValueError
|
|
152
|
+
if identity.expires_at <= datetime.now(timezone.utc):
|
|
153
|
+
raise ValueError
|
|
154
|
+
ZoneInfo(identity.timezone)
|
|
155
|
+
if any(
|
|
156
|
+
not value.strip()
|
|
157
|
+
for value in (
|
|
158
|
+
identity.user_id,
|
|
159
|
+
identity.org_code,
|
|
160
|
+
identity.role_id,
|
|
161
|
+
identity.prompt,
|
|
162
|
+
)
|
|
163
|
+
):
|
|
164
|
+
raise ValueError
|
|
165
|
+
except (ValidationError, ValueError, KeyError, json.JSONDecodeError):
|
|
166
|
+
raise RouteError(502, "scheduled_identity_unavailable")
|
|
167
|
+
return identity
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _valid_token(token: str) -> bool:
|
|
171
|
+
if not _SCHEDULED_TOKEN.fullmatch(token):
|
|
172
|
+
return False
|
|
173
|
+
try:
|
|
174
|
+
return len(b64decode(token + "=", altchars=b"-_", validate=True)) == 32
|
|
175
|
+
except (Base64Error, ValueError):
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _cookies(header: str) -> dict[str, str]:
|
|
180
|
+
try:
|
|
181
|
+
cookie = SimpleCookie()
|
|
182
|
+
cookie.load(header)
|
|
183
|
+
except CookieError:
|
|
184
|
+
return {}
|
|
185
|
+
return {
|
|
186
|
+
name: morsel.value
|
|
187
|
+
for name, morsel in cookie.items()
|
|
188
|
+
if _COOKIE_VALUE.fullmatch(morsel.value)
|
|
189
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""定义视图层统一 HTTP 错误及响应格式。"""
|
|
2
|
+
|
|
3
|
+
from starlette.exceptions import HTTPException
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import JSONResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RouteError(HTTPException):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def http_error(_: Request, exc: RouteError) -> JSONResponse:
|
|
13
|
+
if isinstance(exc.detail, dict):
|
|
14
|
+
payload = exc.detail
|
|
15
|
+
elif isinstance(exc.detail, str):
|
|
16
|
+
payload = {"error": exc.detail}
|
|
17
|
+
else:
|
|
18
|
+
payload = {"detail": exc.detail}
|
|
19
|
+
return JSONResponse(payload, status_code=exc.status_code, headers=exc.headers)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""注册应用对外公开的 HTTP 路由。"""
|
|
2
|
+
|
|
3
|
+
from starlette.requests import Request
|
|
4
|
+
from starlette.responses import PlainTextResponse
|
|
5
|
+
from starlette.routing import Route
|
|
6
|
+
|
|
7
|
+
from app.views.errors import RouteError, http_error
|
|
8
|
+
from app.views.streaming_runs import cancel, runs
|
|
9
|
+
from app.views.sync_runs import scheduled_run, sync_run
|
|
10
|
+
|
|
11
|
+
__all__ = ["RouteError", "build_routes", "http_error"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_routes() -> list[Route]:
|
|
15
|
+
return [
|
|
16
|
+
Route("/healthcheck.html", healthcheck, methods=["GET"]),
|
|
17
|
+
Route("/agent/v1/runs", runs, methods=["POST"]),
|
|
18
|
+
Route("/agent/v1/runs/cancel", cancel, methods=["POST"]),
|
|
19
|
+
Route("/agent/v1/runs/sync", sync_run, methods=["POST"]),
|
|
20
|
+
Route("/agent/v1/runs/scheduled", scheduled_run, methods=["POST"]),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def healthcheck(_: Request):
|
|
25
|
+
return PlainTextResponse("success")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""为各类运行构造一致的依赖与状态客户端。"""
|
|
2
|
+
|
|
3
|
+
import anyio
|
|
4
|
+
|
|
5
|
+
from app.agent.runtime.deps import RunDeps
|
|
6
|
+
from app.utils.api_client import ApiClient
|
|
7
|
+
from app.utils.state_client import AgentStateClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_state_client(runtime, user: dict) -> AgentStateClient:
|
|
11
|
+
return AgentStateClient(
|
|
12
|
+
http=runtime.http,
|
|
13
|
+
base_url=runtime.settings.union_base_url,
|
|
14
|
+
auth=user["control_auth"],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_run_deps(runtime, user: dict, state_client: AgentStateClient) -> RunDeps:
|
|
19
|
+
return RunDeps(
|
|
20
|
+
user_id=user["user_id"],
|
|
21
|
+
permissions=tuple(user.get("permissions") or ()),
|
|
22
|
+
authentication_type=user.get("authentication_type", "CAS"),
|
|
23
|
+
org_code=user.get("org_code", ""),
|
|
24
|
+
scheduled_run_id=user.get("scheduled_run_id"),
|
|
25
|
+
state_client=state_client,
|
|
26
|
+
api_client=ApiClient(
|
|
27
|
+
http=runtime.http,
|
|
28
|
+
base_url=runtime.settings.union_base_url,
|
|
29
|
+
auth=user["control_auth"],
|
|
30
|
+
),
|
|
31
|
+
cancelled=anyio.Event(),
|
|
32
|
+
rag_service=runtime.rag_service,
|
|
33
|
+
)
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""处理 AG-UI 流式运行、取消及消息历史装载。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
import httpx
|
|
10
|
+
from ag_ui.core import Message
|
|
11
|
+
from pydantic import TypeAdapter, ValidationError
|
|
12
|
+
from pydantic_ai.exceptions import ModelHTTPError
|
|
13
|
+
from pydantic_ai.messages import ModelMessage, ModelRequest, TextContent, UserPromptPart
|
|
14
|
+
from pydantic_ai.ui.ag_ui import AGUIAdapter
|
|
15
|
+
from starlette.requests import Request
|
|
16
|
+
from starlette.responses import JSONResponse
|
|
17
|
+
|
|
18
|
+
from app.agent.runtime.activity import RunCollector
|
|
19
|
+
from app.agent.runtime.execution import ExecutionPreparationStopped
|
|
20
|
+
from app.agent.runtime.model_errors import model_http_error_details
|
|
21
|
+
from app.utils.state_client import AgentStateError
|
|
22
|
+
from app.views.auth import authenticate_user
|
|
23
|
+
from app.views.errors import RouteError
|
|
24
|
+
from app.views.run_context import build_run_deps, build_state_client
|
|
25
|
+
|
|
26
|
+
_MESSAGES = TypeAdapter(list[Message])
|
|
27
|
+
_HARNESS_MEMORY_METADATA = "pydantic-ai-harness.memory.v1"
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def runs(request: Request):
|
|
32
|
+
runtime = request.app.state.runtime
|
|
33
|
+
user = await authenticate_user(request, runtime)
|
|
34
|
+
run_input = _parse_run_input(await request.body())
|
|
35
|
+
question, user_message = _latest_user_message(run_input.messages)
|
|
36
|
+
if not question:
|
|
37
|
+
raise RouteError(422, "a latest user message is required")
|
|
38
|
+
run_input = run_input.model_copy(update={"messages": [user_message]})
|
|
39
|
+
state_client = build_state_client(runtime, user)
|
|
40
|
+
message_history = await _load_message_history(
|
|
41
|
+
state_client,
|
|
42
|
+
run_input.thread_id,
|
|
43
|
+
user_message,
|
|
44
|
+
)
|
|
45
|
+
deps = build_run_deps(runtime, user, state_client)
|
|
46
|
+
collector = RunCollector(
|
|
47
|
+
root_run_id=run_input.run_id,
|
|
48
|
+
conversation_id=run_input.thread_id,
|
|
49
|
+
)
|
|
50
|
+
collector.root.add_messages(
|
|
51
|
+
[
|
|
52
|
+
message.model_dump(by_alias=True, mode="json")
|
|
53
|
+
for message in run_input.messages
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
record = await _reserve_run(runtime, run_input, deps, collector)
|
|
57
|
+
root_agent = await _prepare_run(
|
|
58
|
+
request,
|
|
59
|
+
runtime,
|
|
60
|
+
run_input,
|
|
61
|
+
question,
|
|
62
|
+
message_history,
|
|
63
|
+
state_client,
|
|
64
|
+
deps,
|
|
65
|
+
collector,
|
|
66
|
+
record,
|
|
67
|
+
)
|
|
68
|
+
record = await _start_run(
|
|
69
|
+
request,
|
|
70
|
+
runtime,
|
|
71
|
+
run_input,
|
|
72
|
+
root_agent,
|
|
73
|
+
message_history,
|
|
74
|
+
deps,
|
|
75
|
+
collector,
|
|
76
|
+
record,
|
|
77
|
+
)
|
|
78
|
+
return runtime.executions.response(record)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _reserve_run(runtime, run_input, deps, collector):
|
|
82
|
+
try:
|
|
83
|
+
return await runtime.executions.reserve(
|
|
84
|
+
run_id=run_input.run_id,
|
|
85
|
+
user_id=deps.user_id,
|
|
86
|
+
conversation_id=run_input.thread_id,
|
|
87
|
+
deps=deps,
|
|
88
|
+
collector=collector,
|
|
89
|
+
)
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
raise RouteError(409, str(exc)) from exc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _prepare_run(
|
|
95
|
+
request,
|
|
96
|
+
runtime,
|
|
97
|
+
run_input,
|
|
98
|
+
question,
|
|
99
|
+
message_history,
|
|
100
|
+
state_client,
|
|
101
|
+
deps,
|
|
102
|
+
collector,
|
|
103
|
+
record,
|
|
104
|
+
):
|
|
105
|
+
preparation_done = anyio.Event()
|
|
106
|
+
|
|
107
|
+
async def watch_disconnect() -> None:
|
|
108
|
+
while not preparation_done.is_set():
|
|
109
|
+
if await request.is_disconnected():
|
|
110
|
+
await runtime.executions.cancel(
|
|
111
|
+
run_id=run_input.run_id,
|
|
112
|
+
user_id=deps.user_id,
|
|
113
|
+
conversation_id=run_input.thread_id,
|
|
114
|
+
)
|
|
115
|
+
return
|
|
116
|
+
with anyio.move_on_after(0.1):
|
|
117
|
+
await preparation_done.wait()
|
|
118
|
+
|
|
119
|
+
preparation_phase = "route_resolution"
|
|
120
|
+
preparation_error: BaseException | None = None
|
|
121
|
+
try:
|
|
122
|
+
async with anyio.create_task_group() as task_group:
|
|
123
|
+
task_group.start_soon(watch_disconnect)
|
|
124
|
+
try:
|
|
125
|
+
decision = await runtime.executions.prepare(
|
|
126
|
+
record,
|
|
127
|
+
lambda: runtime.graph.router.run(
|
|
128
|
+
question,
|
|
129
|
+
deps=deps,
|
|
130
|
+
message_history=message_history,
|
|
131
|
+
conversation_id=run_input.thread_id,
|
|
132
|
+
usage_limits=runtime.graph.usage_limits,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
root_agent = runtime.graph.resolve_root(decision.output)
|
|
136
|
+
collector.root_agent_name = root_agent.name or "Agent"
|
|
137
|
+
except BaseException as exc:
|
|
138
|
+
preparation_error = exc
|
|
139
|
+
finally:
|
|
140
|
+
preparation_done.set()
|
|
141
|
+
if preparation_error is not None:
|
|
142
|
+
raise preparation_error
|
|
143
|
+
except ExecutionPreparationStopped as exc:
|
|
144
|
+
status_code = 504 if exc.error_code == "execution_timeout" else 499
|
|
145
|
+
raise RouteError(status_code, exc.error_code) from exc
|
|
146
|
+
except anyio.get_cancelled_exc_class():
|
|
147
|
+
with anyio.CancelScope(shield=True):
|
|
148
|
+
await runtime.executions.abandon(
|
|
149
|
+
record,
|
|
150
|
+
"cancelled",
|
|
151
|
+
"request_cancelled",
|
|
152
|
+
)
|
|
153
|
+
raise
|
|
154
|
+
except ModelHTTPError as exc:
|
|
155
|
+
status, code, message, model_name = model_http_error_details(exc)
|
|
156
|
+
await runtime.executions.abandon(record, "failed", code)
|
|
157
|
+
logger.error(
|
|
158
|
+
"Model request failed phase=%s status=%s model=%s code=%s message=%s",
|
|
159
|
+
preparation_phase,
|
|
160
|
+
status,
|
|
161
|
+
model_name,
|
|
162
|
+
code,
|
|
163
|
+
message,
|
|
164
|
+
)
|
|
165
|
+
raise RouteError(
|
|
166
|
+
status,
|
|
167
|
+
{"error": message, "code": code, "model": model_name},
|
|
168
|
+
)
|
|
169
|
+
except Exception:
|
|
170
|
+
error_code = f"{preparation_phase}_failed"
|
|
171
|
+
await runtime.executions.abandon(record, "failed", error_code)
|
|
172
|
+
logger.exception("Run preparation failed phase=%s", preparation_phase)
|
|
173
|
+
raise RouteError(502, "route resolution failed")
|
|
174
|
+
return root_agent
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _start_run(
|
|
178
|
+
request,
|
|
179
|
+
runtime,
|
|
180
|
+
run_input,
|
|
181
|
+
root_agent,
|
|
182
|
+
message_history,
|
|
183
|
+
deps,
|
|
184
|
+
collector,
|
|
185
|
+
record,
|
|
186
|
+
):
|
|
187
|
+
try:
|
|
188
|
+
adapter = AGUIAdapter(
|
|
189
|
+
agent=root_agent,
|
|
190
|
+
run_input=run_input,
|
|
191
|
+
accept=request.headers.get("accept"),
|
|
192
|
+
allow_uploaded_files=False,
|
|
193
|
+
preserve_file_data=False,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def capture_root_messages(result) -> None:
|
|
197
|
+
collector.set_root_messages(
|
|
198
|
+
[
|
|
199
|
+
message.model_dump(by_alias=True, mode="json")
|
|
200
|
+
for message in AGUIAdapter.dump_messages(
|
|
201
|
+
without_internal_memory(result.new_messages())
|
|
202
|
+
)
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
stream = adapter.run_stream(
|
|
207
|
+
output_type=str,
|
|
208
|
+
deps=deps,
|
|
209
|
+
message_history=message_history,
|
|
210
|
+
conversation_id=run_input.thread_id,
|
|
211
|
+
run_id=run_input.run_id,
|
|
212
|
+
usage_limits=runtime.graph.usage_limits,
|
|
213
|
+
on_complete=capture_root_messages,
|
|
214
|
+
)
|
|
215
|
+
return await runtime.executions.start(
|
|
216
|
+
run_id=run_input.run_id,
|
|
217
|
+
user_id=deps.user_id,
|
|
218
|
+
conversation_id=run_input.thread_id,
|
|
219
|
+
adapter=adapter,
|
|
220
|
+
stream=stream,
|
|
221
|
+
deps=deps,
|
|
222
|
+
collector=collector,
|
|
223
|
+
record=record,
|
|
224
|
+
)
|
|
225
|
+
except anyio.get_cancelled_exc_class():
|
|
226
|
+
with anyio.CancelScope(shield=True):
|
|
227
|
+
await runtime.executions.abandon(
|
|
228
|
+
record,
|
|
229
|
+
"cancelled",
|
|
230
|
+
"request_cancelled",
|
|
231
|
+
)
|
|
232
|
+
raise
|
|
233
|
+
except ValueError as exc:
|
|
234
|
+
await runtime.executions.abandon(record, "failed", "run_start_failed")
|
|
235
|
+
raise RouteError(409, str(exc)) from exc
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
await runtime.executions.abandon(record, "failed", "run_start_failed")
|
|
238
|
+
raise RouteError(500, "run setup failed") from exc
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def without_internal_memory(messages: list[ModelMessage]) -> list[ModelMessage]:
|
|
242
|
+
"""Remove Harness memory parts while their internal metadata is still intact."""
|
|
243
|
+
filtered: list[ModelMessage] = []
|
|
244
|
+
for message in messages:
|
|
245
|
+
if not isinstance(message, ModelRequest):
|
|
246
|
+
filtered.append(message)
|
|
247
|
+
continue
|
|
248
|
+
parts = []
|
|
249
|
+
changed = False
|
|
250
|
+
for part in message.parts:
|
|
251
|
+
if not isinstance(part, UserPromptPart) or isinstance(part.content, str):
|
|
252
|
+
parts.append(part)
|
|
253
|
+
continue
|
|
254
|
+
content = [
|
|
255
|
+
item
|
|
256
|
+
for item in part.content
|
|
257
|
+
if not (
|
|
258
|
+
isinstance(item, TextContent)
|
|
259
|
+
and isinstance(item.metadata, str)
|
|
260
|
+
and (
|
|
261
|
+
item.metadata == _HARNESS_MEMORY_METADATA
|
|
262
|
+
or item.metadata.startswith(f"{_HARNESS_MEMORY_METADATA}:")
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
]
|
|
266
|
+
if len(content) == len(part.content):
|
|
267
|
+
parts.append(part)
|
|
268
|
+
else:
|
|
269
|
+
changed = True
|
|
270
|
+
if content:
|
|
271
|
+
parts.append(replace(part, content=content))
|
|
272
|
+
filtered.append(replace(message, parts=parts) if changed else message)
|
|
273
|
+
return filtered
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def cancel(request: Request):
|
|
277
|
+
runtime = request.app.state.runtime
|
|
278
|
+
user = await authenticate_user(request, runtime)
|
|
279
|
+
try:
|
|
280
|
+
payload = await request.json()
|
|
281
|
+
run_id = str(payload["runId"])
|
|
282
|
+
conversation_id = str(payload["conversationId"])
|
|
283
|
+
cancelled = await runtime.executions.cancel(
|
|
284
|
+
run_id=run_id,
|
|
285
|
+
user_id=user["user_id"],
|
|
286
|
+
conversation_id=conversation_id,
|
|
287
|
+
)
|
|
288
|
+
except (KeyError, TypeError, ValueError):
|
|
289
|
+
return JSONResponse({"error": "invalid cancel request"}, status_code=422)
|
|
290
|
+
except PermissionError:
|
|
291
|
+
return JSONResponse({"error": "forbidden"}, status_code=403)
|
|
292
|
+
return JSONResponse({"success": True, "cancelled": cancelled})
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _parse_run_input(body: bytes):
|
|
296
|
+
try:
|
|
297
|
+
run_input = AGUIAdapter.build_run_input(body)
|
|
298
|
+
except ValidationError as exc:
|
|
299
|
+
raise RouteError(
|
|
300
|
+
422,
|
|
301
|
+
exc.errors(include_context=False),
|
|
302
|
+
) from exc
|
|
303
|
+
|
|
304
|
+
if run_input.tools:
|
|
305
|
+
raise RouteError(400, "frontend tools are not supported")
|
|
306
|
+
if run_input.context:
|
|
307
|
+
raise RouteError(400, "frontend context is not supported")
|
|
308
|
+
if run_input.state:
|
|
309
|
+
raise RouteError(400, "frontend state is not supported")
|
|
310
|
+
if run_input.resume:
|
|
311
|
+
raise RouteError(400, "frontend resume is not supported")
|
|
312
|
+
return run_input
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
async def _load_message_history(state_client, thread_id: str, user_message):
|
|
316
|
+
try:
|
|
317
|
+
stored = await state_client.load_messages(thread_id)
|
|
318
|
+
stored_messages = _MESSAGES.validate_python(stored)
|
|
319
|
+
if stored_messages and stored_messages[-1] == user_message:
|
|
320
|
+
stored_messages.pop()
|
|
321
|
+
return AGUIAdapter.load_messages(stored_messages)
|
|
322
|
+
except (AgentStateError, httpx.HTTPError, ValidationError, TypeError, ValueError) as exc:
|
|
323
|
+
raise RouteError(503, "conversation history unavailable") from exc
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _latest_user_message(messages):
|
|
327
|
+
for message in reversed(messages):
|
|
328
|
+
body = message.model_dump(by_alias=True)
|
|
329
|
+
if body.get("role") == "user":
|
|
330
|
+
content = body.get("content")
|
|
331
|
+
if isinstance(content, str):
|
|
332
|
+
return content.strip(), message
|
|
333
|
+
if isinstance(content, list):
|
|
334
|
+
text = "\n".join(
|
|
335
|
+
str(item.get("text", ""))
|
|
336
|
+
for item in content
|
|
337
|
+
if isinstance(item, dict) and item.get("type") == "text"
|
|
338
|
+
).strip()
|
|
339
|
+
return text, message
|
|
340
|
+
return "", None
|