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,29 @@
|
|
|
1
|
+
"""定义视图层统一 HTTP 错误及响应格式。"""
|
|
2
|
+
|
|
3
|
+
from starlette.exceptions import HTTPException
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import JSONResponse
|
|
6
|
+
|
|
7
|
+
from app.utils.request_logging import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RouteError(HTTPException):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def http_error(_: Request, exc: RouteError) -> JSONResponse:
|
|
17
|
+
if isinstance(exc.detail, dict):
|
|
18
|
+
payload = exc.detail
|
|
19
|
+
elif isinstance(exc.detail, str):
|
|
20
|
+
payload = {"error": exc.detail}
|
|
21
|
+
else:
|
|
22
|
+
payload = {"detail": exc.detail}
|
|
23
|
+
reason = payload.get("code") or payload.get("error", "invalid_request")
|
|
24
|
+
logger.warning(
|
|
25
|
+
"Request rejected status=%s reason=%r cause=%s",
|
|
26
|
+
exc.status_code, reason,
|
|
27
|
+
type(exc.__cause__).__name__ if exc.__cause__ else "-",
|
|
28
|
+
)
|
|
29
|
+
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,350 @@
|
|
|
1
|
+
"""处理 AG-UI 流式运行、取消及消息历史装载。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import httpx
|
|
9
|
+
from ag_ui.core import Message
|
|
10
|
+
from pydantic import TypeAdapter, ValidationError
|
|
11
|
+
from pydantic_ai.exceptions import ModelHTTPError
|
|
12
|
+
from pydantic_ai.messages import ModelMessage, ModelRequest, TextContent, UserPromptPart
|
|
13
|
+
from pydantic_ai.ui.ag_ui import AGUIAdapter
|
|
14
|
+
from starlette.requests import Request
|
|
15
|
+
from starlette.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
from app.utils.request_logging import get_logger
|
|
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 = get_logger(__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
|
+
logger.info("History loading started run_id=%r", run_input.run_id)
|
|
41
|
+
message_history = await _load_message_history(
|
|
42
|
+
state_client,
|
|
43
|
+
run_input.thread_id,
|
|
44
|
+
user_message,
|
|
45
|
+
)
|
|
46
|
+
deps = build_run_deps(runtime, user, state_client)
|
|
47
|
+
collector = RunCollector(
|
|
48
|
+
root_run_id=run_input.run_id,
|
|
49
|
+
conversation_id=run_input.thread_id,
|
|
50
|
+
)
|
|
51
|
+
collector.root.add_messages(
|
|
52
|
+
[
|
|
53
|
+
message.model_dump(by_alias=True, mode="json")
|
|
54
|
+
for message in run_input.messages
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
record = await _reserve_run(runtime, run_input, deps, collector)
|
|
58
|
+
root_agent = await _prepare_run(
|
|
59
|
+
request,
|
|
60
|
+
runtime,
|
|
61
|
+
run_input,
|
|
62
|
+
question,
|
|
63
|
+
message_history,
|
|
64
|
+
state_client,
|
|
65
|
+
deps,
|
|
66
|
+
collector,
|
|
67
|
+
record,
|
|
68
|
+
)
|
|
69
|
+
record = await _start_run(
|
|
70
|
+
request,
|
|
71
|
+
runtime,
|
|
72
|
+
run_input,
|
|
73
|
+
root_agent,
|
|
74
|
+
message_history,
|
|
75
|
+
deps,
|
|
76
|
+
collector,
|
|
77
|
+
record,
|
|
78
|
+
)
|
|
79
|
+
return runtime.executions.response(record)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _reserve_run(runtime, run_input, deps, collector):
|
|
83
|
+
try:
|
|
84
|
+
return await runtime.executions.reserve(
|
|
85
|
+
run_id=run_input.run_id,
|
|
86
|
+
user_id=deps.user_id,
|
|
87
|
+
conversation_id=run_input.thread_id,
|
|
88
|
+
deps=deps,
|
|
89
|
+
collector=collector,
|
|
90
|
+
)
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
raise RouteError(409, str(exc)) from exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _prepare_run(
|
|
96
|
+
request,
|
|
97
|
+
runtime,
|
|
98
|
+
run_input,
|
|
99
|
+
question,
|
|
100
|
+
message_history,
|
|
101
|
+
state_client,
|
|
102
|
+
deps,
|
|
103
|
+
collector,
|
|
104
|
+
record,
|
|
105
|
+
):
|
|
106
|
+
preparation_done = anyio.Event()
|
|
107
|
+
|
|
108
|
+
async def watch_disconnect() -> None:
|
|
109
|
+
while not preparation_done.is_set():
|
|
110
|
+
if await request.is_disconnected():
|
|
111
|
+
await runtime.executions.cancel(
|
|
112
|
+
run_id=run_input.run_id,
|
|
113
|
+
user_id=deps.user_id,
|
|
114
|
+
conversation_id=run_input.thread_id,
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
with anyio.move_on_after(0.1):
|
|
118
|
+
await preparation_done.wait()
|
|
119
|
+
|
|
120
|
+
logger.info("Route resolution started mode=stream run_id=%r", run_input.run_id)
|
|
121
|
+
preparation_phase = "route_resolution"
|
|
122
|
+
preparation_error: BaseException | None = None
|
|
123
|
+
try:
|
|
124
|
+
async with anyio.create_task_group() as task_group:
|
|
125
|
+
task_group.start_soon(watch_disconnect)
|
|
126
|
+
try:
|
|
127
|
+
decision = await runtime.executions.prepare(
|
|
128
|
+
record,
|
|
129
|
+
lambda: runtime.graph.router.run(
|
|
130
|
+
question,
|
|
131
|
+
deps=deps,
|
|
132
|
+
message_history=message_history,
|
|
133
|
+
conversation_id=run_input.thread_id,
|
|
134
|
+
usage_limits=runtime.graph.usage_limits,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
root_agent = runtime.graph.resolve_root(decision.output)
|
|
138
|
+
collector.root_agent_name = root_agent.name or "Agent"
|
|
139
|
+
logger.info(
|
|
140
|
+
"Agent execution starting mode=stream run_id=%r agent=%s",
|
|
141
|
+
run_input.run_id,
|
|
142
|
+
collector.root_agent_name,
|
|
143
|
+
)
|
|
144
|
+
except BaseException as exc:
|
|
145
|
+
preparation_error = exc
|
|
146
|
+
finally:
|
|
147
|
+
preparation_done.set()
|
|
148
|
+
if preparation_error is not None:
|
|
149
|
+
raise preparation_error
|
|
150
|
+
except ExecutionPreparationStopped as exc:
|
|
151
|
+
status_code = 504 if exc.error_code == "execution_timeout" else 499
|
|
152
|
+
raise RouteError(status_code, exc.error_code) from exc
|
|
153
|
+
except anyio.get_cancelled_exc_class():
|
|
154
|
+
with anyio.CancelScope(shield=True):
|
|
155
|
+
await runtime.executions.abandon(
|
|
156
|
+
record,
|
|
157
|
+
"cancelled",
|
|
158
|
+
"request_cancelled",
|
|
159
|
+
)
|
|
160
|
+
raise
|
|
161
|
+
except ModelHTTPError as exc:
|
|
162
|
+
status, code, message, model_name = model_http_error_details(exc)
|
|
163
|
+
await runtime.executions.abandon(record, "failed", code)
|
|
164
|
+
logger.error(
|
|
165
|
+
"Model request failed phase=%s status=%s model=%s code=%s",
|
|
166
|
+
preparation_phase,
|
|
167
|
+
status,
|
|
168
|
+
model_name,
|
|
169
|
+
code,
|
|
170
|
+
)
|
|
171
|
+
raise RouteError(
|
|
172
|
+
status,
|
|
173
|
+
{"error": message, "code": code, "model": model_name},
|
|
174
|
+
)
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
error_code = f"{preparation_phase}_failed"
|
|
177
|
+
await runtime.executions.abandon(record, "failed", error_code)
|
|
178
|
+
logger.error(
|
|
179
|
+
"Run preparation failed phase=%s error_type=%s",
|
|
180
|
+
preparation_phase,
|
|
181
|
+
type(exc).__name__,
|
|
182
|
+
)
|
|
183
|
+
raise RouteError(502, "route resolution failed")
|
|
184
|
+
return root_agent
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _start_run(
|
|
188
|
+
request,
|
|
189
|
+
runtime,
|
|
190
|
+
run_input,
|
|
191
|
+
root_agent,
|
|
192
|
+
message_history,
|
|
193
|
+
deps,
|
|
194
|
+
collector,
|
|
195
|
+
record,
|
|
196
|
+
):
|
|
197
|
+
try:
|
|
198
|
+
adapter = AGUIAdapter(
|
|
199
|
+
agent=root_agent,
|
|
200
|
+
run_input=run_input,
|
|
201
|
+
accept=request.headers.get("accept"),
|
|
202
|
+
allow_uploaded_files=False,
|
|
203
|
+
preserve_file_data=False,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def capture_root_messages(result) -> None:
|
|
207
|
+
collector.set_root_messages(
|
|
208
|
+
[
|
|
209
|
+
message.model_dump(by_alias=True, mode="json")
|
|
210
|
+
for message in AGUIAdapter.dump_messages(
|
|
211
|
+
without_internal_memory(result.new_messages())
|
|
212
|
+
)
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
stream = adapter.run_stream(
|
|
217
|
+
output_type=str,
|
|
218
|
+
deps=deps,
|
|
219
|
+
message_history=message_history,
|
|
220
|
+
conversation_id=run_input.thread_id,
|
|
221
|
+
run_id=run_input.run_id,
|
|
222
|
+
usage_limits=runtime.graph.usage_limits,
|
|
223
|
+
on_complete=capture_root_messages,
|
|
224
|
+
)
|
|
225
|
+
return await runtime.executions.start(
|
|
226
|
+
run_id=run_input.run_id,
|
|
227
|
+
user_id=deps.user_id,
|
|
228
|
+
conversation_id=run_input.thread_id,
|
|
229
|
+
adapter=adapter,
|
|
230
|
+
stream=stream,
|
|
231
|
+
deps=deps,
|
|
232
|
+
collector=collector,
|
|
233
|
+
record=record,
|
|
234
|
+
)
|
|
235
|
+
except anyio.get_cancelled_exc_class():
|
|
236
|
+
with anyio.CancelScope(shield=True):
|
|
237
|
+
await runtime.executions.abandon(
|
|
238
|
+
record,
|
|
239
|
+
"cancelled",
|
|
240
|
+
"request_cancelled",
|
|
241
|
+
)
|
|
242
|
+
raise
|
|
243
|
+
except ValueError as exc:
|
|
244
|
+
await runtime.executions.abandon(record, "failed", "run_start_failed")
|
|
245
|
+
raise RouteError(409, str(exc)) from exc
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
await runtime.executions.abandon(record, "failed", "run_start_failed")
|
|
248
|
+
raise RouteError(500, "run setup failed") from exc
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def without_internal_memory(messages: list[ModelMessage]) -> list[ModelMessage]:
|
|
252
|
+
"""Remove Harness memory parts while their internal metadata is still intact."""
|
|
253
|
+
filtered: list[ModelMessage] = []
|
|
254
|
+
for message in messages:
|
|
255
|
+
if not isinstance(message, ModelRequest):
|
|
256
|
+
filtered.append(message)
|
|
257
|
+
continue
|
|
258
|
+
parts = []
|
|
259
|
+
changed = False
|
|
260
|
+
for part in message.parts:
|
|
261
|
+
if not isinstance(part, UserPromptPart) or isinstance(part.content, str):
|
|
262
|
+
parts.append(part)
|
|
263
|
+
continue
|
|
264
|
+
content = [
|
|
265
|
+
item
|
|
266
|
+
for item in part.content
|
|
267
|
+
if not (
|
|
268
|
+
isinstance(item, TextContent)
|
|
269
|
+
and isinstance(item.metadata, str)
|
|
270
|
+
and (
|
|
271
|
+
item.metadata == _HARNESS_MEMORY_METADATA
|
|
272
|
+
or item.metadata.startswith(f"{_HARNESS_MEMORY_METADATA}:")
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
]
|
|
276
|
+
if len(content) == len(part.content):
|
|
277
|
+
parts.append(part)
|
|
278
|
+
else:
|
|
279
|
+
changed = True
|
|
280
|
+
if content:
|
|
281
|
+
parts.append(replace(part, content=content))
|
|
282
|
+
filtered.append(replace(message, parts=parts) if changed else message)
|
|
283
|
+
return filtered
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def cancel(request: Request):
|
|
287
|
+
runtime = request.app.state.runtime
|
|
288
|
+
user = await authenticate_user(request, runtime)
|
|
289
|
+
try:
|
|
290
|
+
payload = await request.json()
|
|
291
|
+
run_id = str(payload["runId"])
|
|
292
|
+
conversation_id = str(payload["conversationId"])
|
|
293
|
+
cancelled = await runtime.executions.cancel(
|
|
294
|
+
run_id=run_id,
|
|
295
|
+
user_id=user["user_id"],
|
|
296
|
+
conversation_id=conversation_id,
|
|
297
|
+
)
|
|
298
|
+
except (KeyError, TypeError, ValueError):
|
|
299
|
+
return JSONResponse({"error": "invalid cancel request"}, status_code=422)
|
|
300
|
+
except PermissionError:
|
|
301
|
+
return JSONResponse({"error": "forbidden"}, status_code=403)
|
|
302
|
+
return JSONResponse({"success": True, "cancelled": cancelled})
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _parse_run_input(body: bytes):
|
|
306
|
+
try:
|
|
307
|
+
run_input = AGUIAdapter.build_run_input(body)
|
|
308
|
+
except ValidationError as exc:
|
|
309
|
+
raise RouteError(
|
|
310
|
+
422,
|
|
311
|
+
exc.errors(include_context=False),
|
|
312
|
+
) from exc
|
|
313
|
+
|
|
314
|
+
if run_input.tools:
|
|
315
|
+
raise RouteError(400, "frontend tools are not supported")
|
|
316
|
+
if run_input.context:
|
|
317
|
+
raise RouteError(400, "frontend context is not supported")
|
|
318
|
+
if run_input.state:
|
|
319
|
+
raise RouteError(400, "frontend state is not supported")
|
|
320
|
+
if run_input.resume:
|
|
321
|
+
raise RouteError(400, "frontend resume is not supported")
|
|
322
|
+
return run_input
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def _load_message_history(state_client, thread_id: str, user_message):
|
|
326
|
+
try:
|
|
327
|
+
stored = await state_client.load_messages(thread_id)
|
|
328
|
+
stored_messages = _MESSAGES.validate_python(stored)
|
|
329
|
+
if stored_messages and stored_messages[-1] == user_message:
|
|
330
|
+
stored_messages.pop()
|
|
331
|
+
return AGUIAdapter.load_messages(stored_messages)
|
|
332
|
+
except (AgentStateError, httpx.HTTPError, ValidationError, TypeError, ValueError) as exc:
|
|
333
|
+
raise RouteError(503, "conversation history unavailable") from exc
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _latest_user_message(messages):
|
|
337
|
+
for message in reversed(messages):
|
|
338
|
+
body = message.model_dump(by_alias=True)
|
|
339
|
+
if body.get("role") == "user":
|
|
340
|
+
content = body.get("content")
|
|
341
|
+
if isinstance(content, str):
|
|
342
|
+
return content.strip(), message
|
|
343
|
+
if isinstance(content, list):
|
|
344
|
+
text = "\n".join(
|
|
345
|
+
str(item.get("text", ""))
|
|
346
|
+
for item in content
|
|
347
|
+
if isinstance(item, dict) and item.get("type") == "text"
|
|
348
|
+
).strip()
|
|
349
|
+
return text, message
|
|
350
|
+
return "", None
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""处理 CAS 同步运行与定时任务同步适配入口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from pydantic_ai.exceptions import ModelHTTPError, UnexpectedModelBehavior
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
from starlette.responses import JSONResponse
|
|
15
|
+
|
|
16
|
+
from app.utils.request_logging import get_logger
|
|
17
|
+
from app.agent.runtime.model_errors import (
|
|
18
|
+
model_http_error_details,
|
|
19
|
+
unexpected_model_behavior_details,
|
|
20
|
+
)
|
|
21
|
+
from app.utils.control_auth import ControlAuth
|
|
22
|
+
from app.views.auth import (
|
|
23
|
+
authenticate_scheduled_run,
|
|
24
|
+
authenticate_user,
|
|
25
|
+
reject_scheduled_on_cas_endpoint,
|
|
26
|
+
scheduled_token,
|
|
27
|
+
strict_scheduled_body,
|
|
28
|
+
)
|
|
29
|
+
from app.views.errors import RouteError
|
|
30
|
+
from app.views.run_context import build_run_deps, build_state_client
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def sync_run(request: Request):
|
|
36
|
+
runtime = request.app.state.runtime
|
|
37
|
+
reject_scheduled_on_cas_endpoint(request)
|
|
38
|
+
user = await authenticate_user(request, runtime)
|
|
39
|
+
try:
|
|
40
|
+
payload = await request.json()
|
|
41
|
+
question = str(payload["question"]).strip()
|
|
42
|
+
input_data = payload["input"]
|
|
43
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
44
|
+
return JSONResponse({"error": "invalid sync request"}, status_code=422)
|
|
45
|
+
if not question or not isinstance(input_data, dict):
|
|
46
|
+
return JSONResponse({"error": "invalid sync request"}, status_code=422)
|
|
47
|
+
return await _sync_response(
|
|
48
|
+
runtime,
|
|
49
|
+
user,
|
|
50
|
+
question,
|
|
51
|
+
input_data,
|
|
52
|
+
effective_context=_effective_time_context(request),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def scheduled_run(request: Request):
|
|
57
|
+
runtime = request.app.state.runtime
|
|
58
|
+
token = scheduled_token(request)
|
|
59
|
+
await strict_scheduled_body(request)
|
|
60
|
+
identity = await authenticate_scheduled_run(runtime, token)
|
|
61
|
+
input_data = {
|
|
62
|
+
"scheduledTaskId": identity.task_id,
|
|
63
|
+
"scheduledRunId": identity.run_id,
|
|
64
|
+
"scheduledAt": identity.scheduled_at.isoformat().replace("+00:00", "Z"),
|
|
65
|
+
"timezone": identity.timezone,
|
|
66
|
+
}
|
|
67
|
+
return await _sync_response(
|
|
68
|
+
runtime,
|
|
69
|
+
{
|
|
70
|
+
"authentication_type": "SCHEDULED",
|
|
71
|
+
"user_id": identity.user_id,
|
|
72
|
+
"org_code": identity.org_code,
|
|
73
|
+
"scheduled_run_id": identity.run_id,
|
|
74
|
+
"control_auth": ControlAuth.trusted_context(identity.trusted_context),
|
|
75
|
+
},
|
|
76
|
+
identity.prompt,
|
|
77
|
+
input_data,
|
|
78
|
+
effective_context=(input_data["scheduledAt"], identity.timezone),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _sync_response(
|
|
83
|
+
runtime,
|
|
84
|
+
user: dict[str, Any],
|
|
85
|
+
question: str,
|
|
86
|
+
input_data: dict[str, Any],
|
|
87
|
+
*,
|
|
88
|
+
effective_context: tuple[str, str] | None = None,
|
|
89
|
+
) -> JSONResponse:
|
|
90
|
+
try:
|
|
91
|
+
with anyio.fail_after(runtime.settings.max_run_seconds):
|
|
92
|
+
content = await _run_sync(
|
|
93
|
+
runtime,
|
|
94
|
+
user,
|
|
95
|
+
question,
|
|
96
|
+
input_data,
|
|
97
|
+
effective_context=effective_context,
|
|
98
|
+
)
|
|
99
|
+
except TimeoutError:
|
|
100
|
+
logger.warning(
|
|
101
|
+
"Sync execution timed out code=execution_timeout limit_seconds=%s",
|
|
102
|
+
runtime.settings.max_run_seconds,
|
|
103
|
+
)
|
|
104
|
+
return JSONResponse(
|
|
105
|
+
{"code": "execution_timeout", "error": "Agent 执行超时"},
|
|
106
|
+
status_code=504,
|
|
107
|
+
)
|
|
108
|
+
except ModelHTTPError as exc:
|
|
109
|
+
status, code, message, model_name = model_http_error_details(exc)
|
|
110
|
+
logger.error(
|
|
111
|
+
"Sync model request failed status=%s model=%s code=%s",
|
|
112
|
+
status,
|
|
113
|
+
model_name,
|
|
114
|
+
code,
|
|
115
|
+
)
|
|
116
|
+
return JSONResponse(
|
|
117
|
+
{"error": message, "code": code, "model": model_name},
|
|
118
|
+
status_code=status,
|
|
119
|
+
)
|
|
120
|
+
except UnexpectedModelBehavior as exc:
|
|
121
|
+
status, code, message = unexpected_model_behavior_details(exc)
|
|
122
|
+
logger.error(
|
|
123
|
+
"Sync Agent execution failed status=%s code=%s error_type=%s",
|
|
124
|
+
status,
|
|
125
|
+
code,
|
|
126
|
+
type(exc).__name__,
|
|
127
|
+
)
|
|
128
|
+
return JSONResponse(
|
|
129
|
+
{"error": message, "code": code},
|
|
130
|
+
status_code=status,
|
|
131
|
+
)
|
|
132
|
+
return JSONResponse({"content": content})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _run_sync(
|
|
136
|
+
runtime,
|
|
137
|
+
user: dict[str, Any],
|
|
138
|
+
question: str,
|
|
139
|
+
input_data: Any,
|
|
140
|
+
*,
|
|
141
|
+
effective_context: tuple[str, str] | None = None,
|
|
142
|
+
):
|
|
143
|
+
state_client = build_state_client(runtime, user)
|
|
144
|
+
deps = build_run_deps(runtime, user, state_client)
|
|
145
|
+
if effective_context is not None:
|
|
146
|
+
deps.effective_at, deps.effective_timezone = effective_context
|
|
147
|
+
prompt = f"{question}\n\n输入数据:{json.dumps(input_data, ensure_ascii=False)}"
|
|
148
|
+
logger.info("Route resolution started mode=sync")
|
|
149
|
+
decision = await runtime.graph.router.run(
|
|
150
|
+
prompt,
|
|
151
|
+
deps=deps,
|
|
152
|
+
usage_limits=runtime.graph.usage_limits,
|
|
153
|
+
)
|
|
154
|
+
agent = runtime.graph.resolve_root(decision.output)
|
|
155
|
+
logger.info("Agent execution started mode=sync agent=%s", agent.name)
|
|
156
|
+
result = await agent.run(
|
|
157
|
+
prompt,
|
|
158
|
+
deps=deps,
|
|
159
|
+
usage_limits=runtime.graph.usage_limits,
|
|
160
|
+
)
|
|
161
|
+
logger.info("Agent execution completed mode=sync agent=%s", agent.name)
|
|
162
|
+
output = result.output
|
|
163
|
+
return output.model_dump(by_alias=True) if isinstance(output, BaseModel) else output
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _effective_time_context(request: Request) -> tuple[str, str] | None:
|
|
167
|
+
effective_at = request.headers.get("x-agent-effective-at")
|
|
168
|
+
timezone = request.headers.get("x-agent-effective-timezone")
|
|
169
|
+
if effective_at is None and timezone is None:
|
|
170
|
+
return None
|
|
171
|
+
if not effective_at or not timezone:
|
|
172
|
+
raise RouteError(422, "invalid effective time context")
|
|
173
|
+
try:
|
|
174
|
+
parsed = datetime.fromisoformat(effective_at.replace("Z", "+00:00"))
|
|
175
|
+
if parsed.tzinfo is None:
|
|
176
|
+
raise ValueError
|
|
177
|
+
ZoneInfo(timezone)
|
|
178
|
+
except (KeyError, ValueError):
|
|
179
|
+
raise RouteError(422, "invalid effective time context")
|
|
180
|
+
return effective_at, timezone
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
APP_ENV={{APP_ENV}}
|
|
2
|
+
CONSOLE_STDOUT={{CONSOLE_STDOUT}}
|
|
3
|
+
LOG_LEVEL={{LOG_LEVEL}}
|
|
4
|
+
LOG_DIR={{LOG_DIR}}
|
|
5
|
+
# 联合运维服务
|
|
6
|
+
UNION_BASE_URL={{UNION_BASE_URL}}
|
|
7
|
+
PERMISSIONS={{PERMISSIONS}}
|
|
8
|
+
|
|
9
|
+
# 大模型地址
|
|
10
|
+
LLM_URL={{LLM_URL}}
|
|
11
|
+
LLM_KEY={{LLM_KEY}}
|
|
12
|
+
LLM_MODEL={{LLM_MODEL}}
|
|
13
|
+
LLM_CONTEXT_WINDOW={{LLM_CONTEXT_WINDOW}}
|
|
14
|
+
AGENT_MAX_RUN_SECONDS={{AGENT_MAX_RUN_SECONDS | default('900')}}
|
|
15
|
+
SUBAGENT_TIMEOUT_SECONDS={{SUBAGENT_TIMEOUT_SECONDS | default('900')}}
|
|
16
|
+
|
|
17
|
+
# 向量模型地址(OpenAI-compatible embeddings API)
|
|
18
|
+
RAG_ENABLED=true
|
|
19
|
+
RAG_KNOWLEDGE_DIR=knowledge
|
|
20
|
+
RAG_COLLECTION=ops_knowledge
|
|
21
|
+
RAG_EMBEDDING_MODEL={{RAG_EMBEDDING_MODEL}}
|
|
22
|
+
RAG_TOP_K=5
|
|
23
|
+
RAG_CHUNK_SIZE=1200
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
union-py-app:
|
|
2
|
+
test:
|
|
3
|
+
test:
|
|
4
|
+
- template: deploy/autoconf/templates/env.j2
|
|
5
|
+
dest: app/.env.test.bj12
|
|
6
|
+
prod:
|
|
7
|
+
bj11:
|
|
8
|
+
- template: deploy/autoconf/templates/env.j2
|
|
9
|
+
dest: app/.env.prod.bj11
|
|
10
|
+
sh20:
|
|
11
|
+
- template: deploy/autoconf/templates/env.j2
|
|
12
|
+
dest: app/.env.prod.sh20
|
|
13
|
+
sz31:
|
|
14
|
+
- template: deploy/autoconf/templates/env.j2
|
|
15
|
+
dest: app/.env.prod.sz31
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -u
|
|
4
|
+
|
|
5
|
+
TIMEOUT_SECONDS="${HEALTHCHECK_TIMEOUT_SECONDS:-5}"
|
|
6
|
+
|
|
7
|
+
probe_http() {
|
|
8
|
+
local url="$1"
|
|
9
|
+
curl --fail --silent --show-error --max-time "$TIMEOUT_SECONDS" "$url" >/dev/null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
probe_http "${APP_HEALTHCHECK_URL:-http://127.0.0.1:${APP_PORT:-8080}/healthcheck.html}" || exit 1
|