union-py-app 1.0.0
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 +387 -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 +381 -0
- package/app/agent/runtime/model.py +47 -0
- package/app/agent/runtime/model_errors.py +40 -0
- package/app/agent/runtime/session.py +156 -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 +24 -0
- package/app/asgi.py +148 -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 +365 -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 +108 -0
- package/app/utils/control_auth.py +50 -0
- package/app/utils/request_logging.py +63 -0
- package/app/utils/state_client.py +68 -0
- package/app/views/__init__.py +1 -0
- package/app/views/auth.py +208 -0
- package/app/views/errors.py +29 -0
- package/app/views/routes.py +25 -0
- package/app/views/run_context.py +33 -0
- package/app/views/streaming_runs.py +350 -0
- package/app/views/sync_runs.py +180 -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,183 @@
|
|
|
1
|
+
"""定义知识检索和运行分析 Agent 可调用的业务工具。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
from pydantic_ai import RunContext, Tool
|
|
12
|
+
from pydantic_ai.toolsets import FunctionToolset
|
|
13
|
+
|
|
14
|
+
from app.agent.runtime.deps import RunDeps
|
|
15
|
+
from app.utils.api_client import ToolError, ToolResult
|
|
16
|
+
|
|
17
|
+
QueryDate = Annotated[str, Field(pattern=r"^\d{8}$")]
|
|
18
|
+
_BIGDATA_REQUEST_TIMEOUT_SECONDS = 180
|
|
19
|
+
_BIGDATA_TIMEOUT_ATTEMPTS = 3
|
|
20
|
+
_BIGDATA_TOOL_TIMEOUT_SECONDS = 600
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def knowledge_search(
|
|
24
|
+
ctx: RunContext[RunDeps],
|
|
25
|
+
query: Annotated[str, Field(min_length=1, max_length=2000)],
|
|
26
|
+
top_k: Annotated[int | None, Field(ge=1, le=20)] = None,
|
|
27
|
+
) -> ToolResult:
|
|
28
|
+
"""检索共享知识库中的制度、SOP、机制和名词解释证据。"""
|
|
29
|
+
if ctx.deps.rag_service is None:
|
|
30
|
+
raise ToolError("知识库当前不可用。")
|
|
31
|
+
try:
|
|
32
|
+
data, status = await anyio.to_thread.run_sync(
|
|
33
|
+
ctx.deps.rag_service.knowledge_search,
|
|
34
|
+
query,
|
|
35
|
+
top_k,
|
|
36
|
+
abandon_on_cancel=True,
|
|
37
|
+
)
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
raise ToolError("知识库检索失败。") from exc
|
|
40
|
+
if status != "success":
|
|
41
|
+
raise ToolError("知识库检索失败。")
|
|
42
|
+
return ToolResult(status="success", data=data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def running_analysis_resolve_member_org(
|
|
46
|
+
ctx: RunContext[RunDeps],
|
|
47
|
+
org_name: Annotated[str, Field(min_length=1, max_length=128)],
|
|
48
|
+
) -> ToolResult:
|
|
49
|
+
"""根据机构名称、简称或别名解析可信 orgCode。"""
|
|
50
|
+
return await ctx.deps.api_client.call(
|
|
51
|
+
path="/agent/getOrgInfo",
|
|
52
|
+
payload={"orgName": org_name},
|
|
53
|
+
timeout=10,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def running_analysis_query_member_metrics(
|
|
58
|
+
ctx: RunContext[RunDeps],
|
|
59
|
+
start_date: QueryDate,
|
|
60
|
+
end_date: QueryDate,
|
|
61
|
+
org_code_list: Annotated[list[str], Field(min_length=1, max_length=100)],
|
|
62
|
+
) -> ToolResult:
|
|
63
|
+
"""查询指定成员机构在日期范围内的每日运行指标。"""
|
|
64
|
+
_validate_metric_end_date(end_date)
|
|
65
|
+
return await ctx.deps.api_client.call(
|
|
66
|
+
path="/agent/queryBigData",
|
|
67
|
+
payload={
|
|
68
|
+
"interfaceName": "runing_cnt.bank",
|
|
69
|
+
"params": {
|
|
70
|
+
"startDate": start_date,
|
|
71
|
+
"endDate": end_date,
|
|
72
|
+
"orgCodeList": org_code_list,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
timeout=_BIGDATA_REQUEST_TIMEOUT_SECONDS,
|
|
76
|
+
timeout_attempts=_BIGDATA_TIMEOUT_ATTEMPTS,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def running_analysis_query_all_member_metrics(
|
|
81
|
+
ctx: RunContext[RunDeps],
|
|
82
|
+
start_date: QueryDate,
|
|
83
|
+
end_date: QueryDate,
|
|
84
|
+
) -> ToolResult:
|
|
85
|
+
"""查询全部成员机构在日期范围内的每日运行指标。"""
|
|
86
|
+
_validate_metric_end_date(end_date)
|
|
87
|
+
return await ctx.deps.api_client.call(
|
|
88
|
+
path="/agent/queryBigData",
|
|
89
|
+
payload={
|
|
90
|
+
"interfaceName": "runing_cnt.bank",
|
|
91
|
+
"params": {"startDate": start_date, "endDate": end_date},
|
|
92
|
+
},
|
|
93
|
+
timeout=_BIGDATA_REQUEST_TIMEOUT_SECONDS,
|
|
94
|
+
timeout_attempts=_BIGDATA_TIMEOUT_ATTEMPTS,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def running_analysis_query_chain_metrics(
|
|
99
|
+
ctx: RunContext[RunDeps],
|
|
100
|
+
start_date: QueryDate,
|
|
101
|
+
end_date: QueryDate,
|
|
102
|
+
) -> ToolResult:
|
|
103
|
+
"""查询全链路在日期范围内的每日运行指标。"""
|
|
104
|
+
_validate_metric_end_date(end_date)
|
|
105
|
+
return await ctx.deps.api_client.call(
|
|
106
|
+
path="/agent/queryBigData",
|
|
107
|
+
payload={
|
|
108
|
+
"interfaceName": "runing_cnt.full_link",
|
|
109
|
+
"params": {"startDate": start_date, "endDate": end_date},
|
|
110
|
+
},
|
|
111
|
+
timeout=_BIGDATA_REQUEST_TIMEOUT_SECONDS,
|
|
112
|
+
timeout_attempts=_BIGDATA_TIMEOUT_ATTEMPTS,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def running_analysis_query_changes(
|
|
117
|
+
ctx: RunContext[RunDeps],
|
|
118
|
+
org_code: Annotated[str, Field(min_length=1, max_length=64)],
|
|
119
|
+
start_date: QueryDate,
|
|
120
|
+
end_date: QueryDate,
|
|
121
|
+
) -> ToolResult:
|
|
122
|
+
"""查询机构在日期范围内的变更通知、状态、评价和影响范围。"""
|
|
123
|
+
return await ctx.deps.api_client.call(
|
|
124
|
+
path="/agent/announceList",
|
|
125
|
+
payload={
|
|
126
|
+
"org_code": org_code,
|
|
127
|
+
"planned_start_time": start_date,
|
|
128
|
+
"planned_start_time_end": end_date,
|
|
129
|
+
},
|
|
130
|
+
timeout=10,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def running_analysis_query_faults(
|
|
135
|
+
ctx: RunContext[RunDeps],
|
|
136
|
+
org_code: Annotated[str, Field(min_length=1, max_length=64)],
|
|
137
|
+
start_date: QueryDate,
|
|
138
|
+
end_date: QueryDate,
|
|
139
|
+
) -> ToolResult:
|
|
140
|
+
"""查询机构在日期范围内的 Jira 故障、影响、原因及状态。"""
|
|
141
|
+
return await ctx.deps.api_client.call(
|
|
142
|
+
path="/agent/getJiraInfo",
|
|
143
|
+
payload={"orgCode": org_code, "startDate": start_date, "endDate": end_date},
|
|
144
|
+
timeout=10,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _validate_metric_end_date(end_date: str) -> None:
|
|
149
|
+
today = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
|
|
150
|
+
if end_date > today:
|
|
151
|
+
raise ToolError(f"运行指标结束日期不得晚于当前业务日期 {today}。")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def knowledge_toolset(timeout: float) -> FunctionToolset[RunDeps]:
|
|
155
|
+
return FunctionToolset[RunDeps](
|
|
156
|
+
[knowledge_search],
|
|
157
|
+
timeout=timeout,
|
|
158
|
+
max_retries=2,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def running_analysis_toolset(timeout: float) -> FunctionToolset[RunDeps]:
|
|
163
|
+
return FunctionToolset[RunDeps](
|
|
164
|
+
[
|
|
165
|
+
running_analysis_resolve_member_org,
|
|
166
|
+
Tool(
|
|
167
|
+
running_analysis_query_member_metrics,
|
|
168
|
+
timeout=_BIGDATA_TOOL_TIMEOUT_SECONDS,
|
|
169
|
+
),
|
|
170
|
+
Tool(
|
|
171
|
+
running_analysis_query_all_member_metrics,
|
|
172
|
+
timeout=_BIGDATA_TOOL_TIMEOUT_SECONDS,
|
|
173
|
+
),
|
|
174
|
+
Tool(
|
|
175
|
+
running_analysis_query_chain_metrics,
|
|
176
|
+
timeout=_BIGDATA_TOOL_TIMEOUT_SECONDS,
|
|
177
|
+
),
|
|
178
|
+
running_analysis_query_changes,
|
|
179
|
+
running_analysis_query_faults,
|
|
180
|
+
],
|
|
181
|
+
timeout=timeout,
|
|
182
|
+
max_retries=2,
|
|
183
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""存放不依赖 Agent 定义的通用业务 API 辅助实现。"""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""封装工具调用所需的认证 HTTP 请求、结果校验与数据边界。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from time import monotonic
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
from pydantic_ai import ModelRetry, ToolFailed
|
|
11
|
+
|
|
12
|
+
from app.utils.control_auth import ControlAuth
|
|
13
|
+
from app.utils.request_logging import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolResult(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
status: Literal["success"]
|
|
22
|
+
data: Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolError(ModelRetry):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiClient:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
http: httpx.AsyncClient,
|
|
34
|
+
base_url: str,
|
|
35
|
+
auth: ControlAuth,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._http = http
|
|
38
|
+
self._base_url = base_url.rstrip("/")
|
|
39
|
+
self._auth = auth
|
|
40
|
+
|
|
41
|
+
async def call(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
path: str,
|
|
45
|
+
payload: dict[str, Any],
|
|
46
|
+
timeout: float,
|
|
47
|
+
timeout_attempts: int = 1,
|
|
48
|
+
) -> ToolResult:
|
|
49
|
+
if timeout_attempts < 1:
|
|
50
|
+
raise ValueError("timeout_attempts must be positive")
|
|
51
|
+
started = monotonic()
|
|
52
|
+
logger.info("Tool request started path=%r", path)
|
|
53
|
+
for attempt in range(1, timeout_attempts + 1):
|
|
54
|
+
try:
|
|
55
|
+
response = await self._http.post(
|
|
56
|
+
f"{self._base_url}{path}",
|
|
57
|
+
json=payload,
|
|
58
|
+
headers=self._auth.headers(),
|
|
59
|
+
timeout=timeout,
|
|
60
|
+
)
|
|
61
|
+
break
|
|
62
|
+
except httpx.TimeoutException as exc:
|
|
63
|
+
logger.warning(
|
|
64
|
+
"Tool request timeout path=%r attempt=%s/%s timeout_seconds=%s",
|
|
65
|
+
path,
|
|
66
|
+
attempt,
|
|
67
|
+
timeout_attempts,
|
|
68
|
+
timeout,
|
|
69
|
+
)
|
|
70
|
+
if attempt == timeout_attempts:
|
|
71
|
+
raise ToolFailed(
|
|
72
|
+
f"上游查询连续 {timeout_attempts} 次超时,请稍后重试。"
|
|
73
|
+
) from exc
|
|
74
|
+
except httpx.HTTPError as exc:
|
|
75
|
+
logger.error("Tool request failed path=%r error_type=%s", path, type(exc).__name__)
|
|
76
|
+
raise
|
|
77
|
+
try:
|
|
78
|
+
result = _tool_result(response)
|
|
79
|
+
except ToolError as exc:
|
|
80
|
+
logger.warning(
|
|
81
|
+
"Tool request rejected path=%r status=%s reason=%s",
|
|
82
|
+
path,
|
|
83
|
+
response.status_code,
|
|
84
|
+
exc,
|
|
85
|
+
)
|
|
86
|
+
raise
|
|
87
|
+
logger.info(
|
|
88
|
+
"Tool request completed path=%r status=%s elapsed_ms=%.0f",
|
|
89
|
+
path,
|
|
90
|
+
response.status_code,
|
|
91
|
+
(monotonic() - started) * 1000,
|
|
92
|
+
)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _tool_result(response: httpx.Response) -> ToolResult:
|
|
97
|
+
if response.status_code >= 400:
|
|
98
|
+
raise ToolError(f"上游查询失败,HTTP {response.status_code}。")
|
|
99
|
+
try:
|
|
100
|
+
body = response.json()
|
|
101
|
+
except ValueError as exc:
|
|
102
|
+
raise ToolError("上游没有返回有效 JSON。") from exc
|
|
103
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
104
|
+
if isinstance(data, dict) and "data" in data:
|
|
105
|
+
data = data["data"]
|
|
106
|
+
if data is None:
|
|
107
|
+
raise ToolError("上游没有返回可用数据。")
|
|
108
|
+
return ToolResult(status="success", data=data)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""封装访问 Control 时互斥且不可回显的运行认证凭证。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ControlAuth:
|
|
11
|
+
"""A server-created CAS or scheduled credential, never both."""
|
|
12
|
+
|
|
13
|
+
authentication_type: Literal["CAS", "SCHEDULED"]
|
|
14
|
+
_cas_session_id: str | None = field(default=None, repr=False)
|
|
15
|
+
_scheduled_token: str | None = field(default=None, repr=False)
|
|
16
|
+
_trusted_context: str | None = field(default=None, repr=False)
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
credentials = sum(
|
|
20
|
+
bool(value)
|
|
21
|
+
for value in (
|
|
22
|
+
self._cas_session_id,
|
|
23
|
+
self._scheduled_token,
|
|
24
|
+
self._trusted_context,
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
if credentials != 1:
|
|
28
|
+
raise ValueError("exactly one Control credential is required")
|
|
29
|
+
expected = "CAS" if self._cas_session_id else "SCHEDULED"
|
|
30
|
+
if self.authentication_type != expected:
|
|
31
|
+
raise ValueError("authentication type does not match credential")
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def cas(cls, session_id: str) -> ControlAuth:
|
|
35
|
+
return cls("CAS", _cas_session_id=session_id)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def scheduled(cls, token: str) -> ControlAuth:
|
|
39
|
+
return cls("SCHEDULED", _scheduled_token=token)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def trusted_context(cls, context: str) -> ControlAuth:
|
|
43
|
+
return cls("SCHEDULED", _trusted_context=context)
|
|
44
|
+
|
|
45
|
+
def headers(self) -> dict[str, str]:
|
|
46
|
+
if self.authentication_type == "CAS":
|
|
47
|
+
return {"Cookie": f"CASSESSIONID={self._cas_session_id}"}
|
|
48
|
+
if self._trusted_context:
|
|
49
|
+
return {"X-Agent-Trusted-Context": self._trusted_context}
|
|
50
|
+
return {"Authorization": f"Scheduled {self._scheduled_token}"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""关联请求日志并记录 HTTP 生命周期,不记录请求或响应正文。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from time import monotonic
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
request_id = ContextVar("request_id", default="-")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestLogger(logging.LoggerAdapter):
|
|
12
|
+
def process(self, msg, kwargs):
|
|
13
|
+
return f"request_id={request_id.get()} {msg}", kwargs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_logger(name: str) -> RequestLogger:
|
|
17
|
+
return RequestLogger(logging.getLogger(name), {})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RequestLoggingMiddleware:
|
|
24
|
+
def __init__(self, app):
|
|
25
|
+
self.app = app
|
|
26
|
+
|
|
27
|
+
async def __call__(self, scope, receive, send):
|
|
28
|
+
if scope["type"] != "http" or scope["path"] == "/healthcheck.html":
|
|
29
|
+
return await self.app(scope, receive, send)
|
|
30
|
+
token = request_id.set(uuid4().hex)
|
|
31
|
+
started = monotonic()
|
|
32
|
+
status = None
|
|
33
|
+
outcome = "interrupted"
|
|
34
|
+
|
|
35
|
+
async def send_logged(message):
|
|
36
|
+
nonlocal status
|
|
37
|
+
if message["type"] == "http.response.start":
|
|
38
|
+
status = message["status"]
|
|
39
|
+
message = dict(message)
|
|
40
|
+
message["headers"] = [
|
|
41
|
+
*message.get("headers", []),
|
|
42
|
+
(b"x-request-id", request_id.get().encode()),
|
|
43
|
+
]
|
|
44
|
+
await send(message)
|
|
45
|
+
|
|
46
|
+
logger.info("Request received method=%s path=%r", scope["method"], scope["path"])
|
|
47
|
+
try:
|
|
48
|
+
await self.app(scope, receive, send_logged)
|
|
49
|
+
outcome = "finished"
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
outcome = "failed"
|
|
52
|
+
if status is None:
|
|
53
|
+
status = 500
|
|
54
|
+
logger.error("Request failed error_type=%s", type(exc).__name__)
|
|
55
|
+
raise
|
|
56
|
+
finally:
|
|
57
|
+
successful = outcome == "finished" and status is not None and status < 400
|
|
58
|
+
logger.log(
|
|
59
|
+
logging.INFO if successful else logging.WARNING,
|
|
60
|
+
"Request ended status=%s outcome=%s elapsed_ms=%.0f",
|
|
61
|
+
status, outcome, (monotonic() - started) * 1000,
|
|
62
|
+
)
|
|
63
|
+
request_id.reset(token)
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
from app.utils.request_logging import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentStateError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentStateClient:
|
|
20
|
+
"""访问远端 Agent 状态接口的认证客户端。"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
http: httpx.AsyncClient,
|
|
26
|
+
base_url: str,
|
|
27
|
+
auth: ControlAuth,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._http = http
|
|
30
|
+
self._base_url = base_url.rstrip("/")
|
|
31
|
+
self._auth = auth
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def headers(self) -> dict[str, str]:
|
|
35
|
+
return self._auth.headers()
|
|
36
|
+
|
|
37
|
+
async def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
try:
|
|
39
|
+
response = await self._http.post(
|
|
40
|
+
f"{self._base_url}{path}",
|
|
41
|
+
json=payload,
|
|
42
|
+
headers=self.headers,
|
|
43
|
+
timeout=15,
|
|
44
|
+
)
|
|
45
|
+
except httpx.HTTPError as exc:
|
|
46
|
+
logger.warning("State request failed path=%r error_type=%s", path, type(exc).__name__)
|
|
47
|
+
raise
|
|
48
|
+
if response.status_code >= 400:
|
|
49
|
+
logger.warning("State request rejected path=%r status=%s", path, response.status_code)
|
|
50
|
+
raise AgentStateError(f"state request failed: {response.status_code}")
|
|
51
|
+
body = response.json()
|
|
52
|
+
if not isinstance(body, dict):
|
|
53
|
+
raise AgentStateError("state service returned a non-object response")
|
|
54
|
+
return body
|
|
55
|
+
|
|
56
|
+
async def load_messages(self, conversation_id: str) -> list[dict[str, Any]]:
|
|
57
|
+
body = await self.post(
|
|
58
|
+
"/agent/getConversationMessages",
|
|
59
|
+
{"conversationId": conversation_id},
|
|
60
|
+
)
|
|
61
|
+
messages = body.get("messages", [])
|
|
62
|
+
return messages if isinstance(messages, list) else []
|
|
63
|
+
|
|
64
|
+
async def complete_run(
|
|
65
|
+
self,
|
|
66
|
+
payload: dict[str, Any],
|
|
67
|
+
) -> None:
|
|
68
|
+
await self.post("/agent/completeRun", payload)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""存放 ASGI 应用的 HTTP 路由与请求边界处理。"""
|
|
@@ -0,0 +1,208 @@
|
|
|
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.utils.request_logging import get_logger
|
|
19
|
+
|
|
20
|
+
from app.views.errors import RouteError
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
_COOKIE_VALUE = re.compile(r"^[A-Za-z0-9._:@+-]{1,256}$")
|
|
25
|
+
_SCHEDULED_TOKEN = re.compile(r"^[A-Za-z0-9_-]{43}$")
|
|
26
|
+
_FORBIDDEN_SCHEDULED_HEADERS = {
|
|
27
|
+
"x-agent-scheduled-run-id",
|
|
28
|
+
"x-agent-user-id",
|
|
29
|
+
"x-agent-org-code",
|
|
30
|
+
"x-agent-effective-at",
|
|
31
|
+
"x-agent-effective-timezone",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ScheduledIdentity(BaseModel):
|
|
36
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
37
|
+
|
|
38
|
+
authentication_type: str = Field(alias="authenticationType")
|
|
39
|
+
run_id: int = Field(alias="runId", strict=True, gt=0)
|
|
40
|
+
task_id: int = Field(alias="taskId", strict=True, gt=0)
|
|
41
|
+
user_id: str = Field(alias="userId", min_length=1, max_length=256)
|
|
42
|
+
org_code: str = Field(alias="orgCode", min_length=1, max_length=64)
|
|
43
|
+
role_id: str = Field(alias="roleId", min_length=1, max_length=64)
|
|
44
|
+
prompt: str = Field(min_length=1, max_length=65536)
|
|
45
|
+
scheduled_at: datetime = Field(alias="scheduledAt")
|
|
46
|
+
timezone: str = Field(min_length=1, max_length=128)
|
|
47
|
+
expires_at: datetime = Field(alias="expiresAt")
|
|
48
|
+
trusted_context: str = Field(alias="trustedContext", min_length=1, max_length=256)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _ScheduledEnvelope(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="forbid")
|
|
53
|
+
|
|
54
|
+
success: bool
|
|
55
|
+
data: ScheduledIdentity
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def authenticate_user(request: Request, runtime):
|
|
59
|
+
session_id = _cookies(request.headers.get("cookie", "")).get("CASSESSIONID")
|
|
60
|
+
if not session_id:
|
|
61
|
+
logger.warning("Authentication rejected reason=missing_session")
|
|
62
|
+
raise RouteError(401, "auth error")
|
|
63
|
+
try:
|
|
64
|
+
response = await runtime.http.get(
|
|
65
|
+
f"{runtime.settings.union_base_url}/common/getUserInfo",
|
|
66
|
+
headers={"Cookie": f"CASSESSIONID={session_id}"},
|
|
67
|
+
timeout=10,
|
|
68
|
+
)
|
|
69
|
+
body = response.json()
|
|
70
|
+
data = body.get("data", {}) if response.status_code < 400 else {}
|
|
71
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
72
|
+
logger.warning(
|
|
73
|
+
"Authentication failed reason=identity_lookup error_type=%s",
|
|
74
|
+
type(exc).__name__,
|
|
75
|
+
)
|
|
76
|
+
raise RouteError(401, "auth error")
|
|
77
|
+
if (
|
|
78
|
+
not isinstance(data, dict)
|
|
79
|
+
or not isinstance(data.get("userId"), str)
|
|
80
|
+
or not data["userId"]
|
|
81
|
+
):
|
|
82
|
+
logger.warning(
|
|
83
|
+
"Authentication rejected reason=invalid_identity upstream_status=%s",
|
|
84
|
+
response.status_code,
|
|
85
|
+
)
|
|
86
|
+
raise RouteError(401, "auth error")
|
|
87
|
+
permissions = data.get("permissions") or []
|
|
88
|
+
if isinstance(permissions, str):
|
|
89
|
+
permissions = [permissions]
|
|
90
|
+
required = runtime.settings.required_permission
|
|
91
|
+
if required and required not in permissions:
|
|
92
|
+
logger.warning("Authentication rejected reason=missing_permission")
|
|
93
|
+
raise RouteError(403, "auth error")
|
|
94
|
+
logger.info("Authentication succeeded type=CAS")
|
|
95
|
+
return {
|
|
96
|
+
"authentication_type": "CAS",
|
|
97
|
+
"user_id": data["userId"],
|
|
98
|
+
"org_code": data.get("orgCode", ""),
|
|
99
|
+
"permissions": tuple(permissions),
|
|
100
|
+
"control_auth": ControlAuth.cas(session_id),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def reject_scheduled_on_cas_endpoint(request: Request) -> None:
|
|
105
|
+
authorization = request.headers.get("authorization")
|
|
106
|
+
if not authorization or not authorization.lower().startswith("scheduled "):
|
|
107
|
+
return
|
|
108
|
+
if request.headers.get("cookie") is not None:
|
|
109
|
+
raise RouteError(400, "mixed_authentication")
|
|
110
|
+
raise RouteError(401, "auth error")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scheduled_token(request: Request) -> str:
|
|
114
|
+
authorizations = request.headers.getlist("authorization")
|
|
115
|
+
if len(authorizations) != 1:
|
|
116
|
+
raise RouteError(401, "scheduled_auth_error")
|
|
117
|
+
authorization = authorizations[0]
|
|
118
|
+
cookie_present = request.headers.get("cookie") is not None
|
|
119
|
+
is_scheduled = bool(
|
|
120
|
+
authorization and authorization.lower().startswith("scheduled ")
|
|
121
|
+
)
|
|
122
|
+
if cookie_present and is_scheduled:
|
|
123
|
+
raise RouteError(400, "mixed_authentication")
|
|
124
|
+
if cookie_present or not authorization:
|
|
125
|
+
raise RouteError(401, "scheduled_auth_error")
|
|
126
|
+
scheme, separator, token = authorization.partition(" ")
|
|
127
|
+
if separator != " " or scheme.lower() != "scheduled" or not _valid_token(token):
|
|
128
|
+
raise RouteError(401, "scheduled_auth_error")
|
|
129
|
+
return token
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def strict_scheduled_body(request: Request) -> None:
|
|
133
|
+
if _FORBIDDEN_SCHEDULED_HEADERS.intersection(request.headers.keys()):
|
|
134
|
+
raise RouteError(422, "invalid_scheduled_request")
|
|
135
|
+
try:
|
|
136
|
+
payload = json.loads(await request.body())
|
|
137
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
138
|
+
raise RouteError(422, "invalid_scheduled_request")
|
|
139
|
+
if not isinstance(payload, dict) or payload:
|
|
140
|
+
raise RouteError(422, "invalid_scheduled_request")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def authenticate_scheduled_run(runtime, token: str) -> ScheduledIdentity:
|
|
144
|
+
try:
|
|
145
|
+
response = await runtime.http.post(
|
|
146
|
+
f"{runtime.settings.union_base_url}/agent/scheduledTaskAuthorize",
|
|
147
|
+
headers=ControlAuth.scheduled(token).headers(),
|
|
148
|
+
json={},
|
|
149
|
+
timeout=10,
|
|
150
|
+
)
|
|
151
|
+
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
152
|
+
raise RouteError(503, "scheduled_identity_unavailable") from exc
|
|
153
|
+
if response.status_code == 401:
|
|
154
|
+
raise RouteError(401, "scheduled_auth_error")
|
|
155
|
+
if response.status_code == 403:
|
|
156
|
+
raise RouteError(403, "forbidden")
|
|
157
|
+
if response.status_code >= 500:
|
|
158
|
+
raise RouteError(503, "scheduled_identity_unavailable")
|
|
159
|
+
if response.status_code >= 400:
|
|
160
|
+
raise RouteError(502, "scheduled_identity_unavailable")
|
|
161
|
+
try:
|
|
162
|
+
envelope = _ScheduledEnvelope.model_validate(response.json())
|
|
163
|
+
identity = envelope.data
|
|
164
|
+
if not envelope.success or identity.authentication_type != "SCHEDULED":
|
|
165
|
+
raise ValueError
|
|
166
|
+
if identity.scheduled_at.tzinfo is None or identity.expires_at.tzinfo is None:
|
|
167
|
+
raise ValueError
|
|
168
|
+
if identity.expires_at <= datetime.now(timezone.utc):
|
|
169
|
+
raise ValueError
|
|
170
|
+
if identity.trusted_context != f"Scheduled {token}":
|
|
171
|
+
raise ValueError
|
|
172
|
+
ZoneInfo(identity.timezone)
|
|
173
|
+
if any(
|
|
174
|
+
not value.strip()
|
|
175
|
+
for value in (
|
|
176
|
+
identity.user_id,
|
|
177
|
+
identity.org_code,
|
|
178
|
+
identity.role_id,
|
|
179
|
+
identity.prompt,
|
|
180
|
+
)
|
|
181
|
+
):
|
|
182
|
+
raise ValueError
|
|
183
|
+
except (ValidationError, ValueError, KeyError, json.JSONDecodeError):
|
|
184
|
+
raise RouteError(502, "scheduled_identity_unavailable")
|
|
185
|
+
logger.info("Authentication succeeded type=SCHEDULED")
|
|
186
|
+
return identity
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _valid_token(token: str) -> bool:
|
|
190
|
+
if not _SCHEDULED_TOKEN.fullmatch(token):
|
|
191
|
+
return False
|
|
192
|
+
try:
|
|
193
|
+
return len(b64decode(token + "=", altchars=b"-_", validate=True)) == 32
|
|
194
|
+
except (Base64Error, ValueError):
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _cookies(header: str) -> dict[str, str]:
|
|
199
|
+
try:
|
|
200
|
+
cookie = SimpleCookie()
|
|
201
|
+
cookie.load(header)
|
|
202
|
+
except CookieError:
|
|
203
|
+
return {}
|
|
204
|
+
return {
|
|
205
|
+
name: morsel.value
|
|
206
|
+
for name, morsel in cookie.items()
|
|
207
|
+
if _COOKIE_VALUE.fullmatch(morsel.value)
|
|
208
|
+
}
|