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.
Files changed (66) hide show
  1. package/app/__init__.py +1 -0
  2. package/app/agent/__init__.py +1 -0
  3. package/app/agent/capabilities.py +388 -0
  4. package/app/agent/coordinator/__init__.py +1 -0
  5. package/app/agent/coordinator/definition.py +50 -0
  6. package/app/agent/coordinator/output_guard.py +29 -0
  7. package/app/agent/graph.py +95 -0
  8. package/app/agent/guardrails.py +30 -0
  9. package/app/agent/routing.py +81 -0
  10. package/app/agent/runtime/__init__.py +1 -0
  11. package/app/agent/runtime/activity.py +393 -0
  12. package/app/agent/runtime/delegation.py +80 -0
  13. package/app/agent/runtime/deps.py +34 -0
  14. package/app/agent/runtime/execution.py +368 -0
  15. package/app/agent/runtime/model.py +47 -0
  16. package/app/agent/runtime/model_errors.py +24 -0
  17. package/app/agent/runtime/session.py +154 -0
  18. package/app/agent/specialists/__init__.py +1 -0
  19. package/app/agent/specialists/behavior_risk/__init__.py +1 -0
  20. package/app/agent/specialists/behavior_risk/definition.py +54 -0
  21. package/app/agent/specialists/build.py +94 -0
  22. package/app/agent/specialists/knowledge/__init__.py +1 -0
  23. package/app/agent/specialists/knowledge/definition.py +38 -0
  24. package/app/agent/specialists/personal_memory/__init__.py +1 -0
  25. package/app/agent/specialists/personal_memory/definition.py +35 -0
  26. package/app/agent/specialists/personal_memory/output_guard.py +55 -0
  27. package/app/agent/specialists/running_analysis/__init__.py +1 -0
  28. package/app/agent/specialists/running_analysis/definition.py +46 -0
  29. package/app/agent/specialists/running_analysis/output_guard.py +38 -0
  30. package/app/agent/specialists/scheduled_task_draft/__init__.py +8 -0
  31. package/app/agent/specialists/scheduled_task_draft/definition.py +142 -0
  32. package/app/agent/specialists/scheduled_task_draft/output_guard.py +81 -0
  33. package/app/asgi.py +139 -0
  34. package/app/config/__init__.py +1 -0
  35. package/app/config/settings.py +67 -0
  36. package/app/memory/__init__.py +1 -0
  37. package/app/memory/store.py +154 -0
  38. package/app/service/rag_service.py +364 -0
  39. package/app/skills/full-chain-quality-analysis/SKILL.md +22 -0
  40. package/app/tools/__init__.py +1 -0
  41. package/app/tools/business.py +183 -0
  42. package/app/utils/__init__.py +1 -0
  43. package/app/utils/api_client.py +76 -0
  44. package/app/utils/control_auth.py +35 -0
  45. package/app/utils/state_client.py +60 -0
  46. package/app/views/__init__.py +1 -0
  47. package/app/views/auth.py +189 -0
  48. package/app/views/errors.py +19 -0
  49. package/app/views/routes.py +25 -0
  50. package/app/views/run_context.py +33 -0
  51. package/app/views/streaming_runs.py +340 -0
  52. package/app/views/sync_runs.py +152 -0
  53. package/deploy/autoconf/templates/env.j2 +23 -0
  54. package/deploy/autoconf.yml +15 -0
  55. package/deploy/scripts/healthcheck.sh +12 -0
  56. package/deploy/scripts/start.sh +80 -0
  57. package/deploy/scripts/stop.sh +35 -0
  58. package/knowledge/000036-scenario-offline-function-call-mock-v1.md +134 -0
  59. package/package.json +21 -0
  60. package/requirements.txt +10 -0
  61. package/scripts/healthcheck.sh +4 -0
  62. package/scripts/start-BJ11.sh +1 -0
  63. package/scripts/start-BJ12.sh +1 -0
  64. package/scripts/start-SH20.sh +1 -0
  65. package/scripts/start-SZ31.sh +1 -0
  66. 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