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.
Files changed (67) hide show
  1. package/app/__init__.py +1 -0
  2. package/app/agent/__init__.py +1 -0
  3. package/app/agent/capabilities.py +387 -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 +381 -0
  15. package/app/agent/runtime/model.py +47 -0
  16. package/app/agent/runtime/model_errors.py +40 -0
  17. package/app/agent/runtime/session.py +156 -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 +24 -0
  33. package/app/asgi.py +148 -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 +365 -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 +108 -0
  44. package/app/utils/control_auth.py +50 -0
  45. package/app/utils/request_logging.py +63 -0
  46. package/app/utils/state_client.py +68 -0
  47. package/app/views/__init__.py +1 -0
  48. package/app/views/auth.py +208 -0
  49. package/app/views/errors.py +29 -0
  50. package/app/views/routes.py +25 -0
  51. package/app/views/run_context.py +33 -0
  52. package/app/views/streaming_runs.py +350 -0
  53. package/app/views/sync_runs.py +180 -0
  54. package/deploy/autoconf/templates/env.j2 +23 -0
  55. package/deploy/autoconf.yml +15 -0
  56. package/deploy/scripts/healthcheck.sh +12 -0
  57. package/deploy/scripts/start.sh +80 -0
  58. package/deploy/scripts/stop.sh +35 -0
  59. package/knowledge/000036-scenario-offline-function-call-mock-v1.md +134 -0
  60. package/package.json +21 -0
  61. package/requirements.txt +10 -0
  62. package/scripts/healthcheck.sh +4 -0
  63. package/scripts/start-BJ11.sh +1 -0
  64. package/scripts/start-BJ12.sh +1 -0
  65. package/scripts/start-SH20.sh +1 -0
  66. package/scripts/start-SZ31.sh +1 -0
  67. 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