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,393 @@
1
+ """收集根执行和子执行消息,并生成标准 AG-UI Activity 快照。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from ag_ui.core import (
10
+ ActivitySnapshotEvent,
11
+ EventType,
12
+ ReasoningMessageContentEvent,
13
+ ReasoningMessageStartEvent,
14
+ TextMessageContentEvent,
15
+ TextMessageStartEvent,
16
+ ToolCallArgsEvent,
17
+ ToolCallResultEvent,
18
+ ToolCallStartEvent,
19
+ )
20
+
21
+ from app.agent.runtime.delegation import Delegation, PlanReferenceError
22
+
23
+
24
+ class MessageReducer:
25
+ """Reduce standard AG-UI message events by message/tool-call identity."""
26
+
27
+ def __init__(self, on_new_message=None) -> None:
28
+ self.messages: list[dict[str, Any]] = []
29
+ self._by_id: dict[str, dict[str, Any]] = {}
30
+ self._tool_calls: dict[str, dict[str, Any]] = {}
31
+ self._on_new_message = on_new_message
32
+
33
+ def add_messages(self, messages: list[dict[str, Any]]) -> None:
34
+ for raw in messages:
35
+ message = dict(raw)
36
+ message_id = str(message.get("id") or "")
37
+ if not message_id:
38
+ continue
39
+ existing = self._by_id.get(message_id)
40
+ if existing is None:
41
+ self._append(message)
42
+ else:
43
+ existing.clear()
44
+ existing.update(message)
45
+ self._index_tool_calls(self._by_id[message_id])
46
+
47
+ def apply(self, event: Any) -> None:
48
+ if isinstance(event, TextMessageStartEvent):
49
+ message = {"id": event.message_id, "role": event.role, "content": ""}
50
+ if event.name:
51
+ message["name"] = event.name
52
+ self._ensure(message)
53
+ elif isinstance(event, TextMessageContentEvent):
54
+ self._content(event.message_id, event.delta, "assistant")
55
+ elif isinstance(event, ReasoningMessageStartEvent):
56
+ self._ensure({"id": event.message_id, "role": "reasoning", "content": ""})
57
+ elif isinstance(event, ReasoningMessageContentEvent):
58
+ self._content(event.message_id, event.delta, "reasoning")
59
+ elif isinstance(event, ToolCallStartEvent):
60
+ message_id = event.parent_message_id or f"tool-parent:{event.tool_call_id}"
61
+ message = self._ensure(
62
+ {"id": message_id, "role": "assistant", "content": "", "toolCalls": []}
63
+ )
64
+ calls = message.setdefault("toolCalls", [])
65
+ call = {
66
+ "id": event.tool_call_id,
67
+ "type": "function",
68
+ "function": {"name": event.tool_call_name, "arguments": ""},
69
+ }
70
+ calls.append(call)
71
+ self._tool_calls[event.tool_call_id] = call
72
+ elif isinstance(event, ToolCallArgsEvent):
73
+ call = self._tool_calls.get(event.tool_call_id)
74
+ if call is not None:
75
+ call["function"]["arguments"] += event.delta
76
+ elif isinstance(event, ToolCallResultEvent):
77
+ message = {
78
+ "id": event.message_id,
79
+ "role": "tool",
80
+ "toolCallId": event.tool_call_id,
81
+ "content": event.content,
82
+ }
83
+ self._ensure(message)
84
+
85
+ def _content(self, message_id: str, delta: str, role: str) -> None:
86
+ message = self._ensure({"id": message_id, "role": role, "content": ""})
87
+ message["content"] = str(message.get("content") or "") + delta
88
+
89
+ def _ensure(self, message: dict[str, Any]) -> dict[str, Any]:
90
+ existing = self._by_id.get(str(message["id"]))
91
+ if existing is not None:
92
+ return existing
93
+ self._append(message)
94
+ return message
95
+
96
+ def _append(self, message: dict[str, Any]) -> None:
97
+ message_id = str(message["id"])
98
+ self.messages.append(message)
99
+ self._by_id[message_id] = message
100
+ if self._on_new_message is not None:
101
+ self._on_new_message(message)
102
+ self._index_tool_calls(message)
103
+
104
+ def _index_tool_calls(self, message: dict[str, Any]) -> None:
105
+ for call in message.get("toolCalls") or []:
106
+ call_id = call.get("id")
107
+ if call_id:
108
+ self._tool_calls[str(call_id)] = call
109
+
110
+
111
+ @dataclass
112
+ class ChildExecution:
113
+ delegation: Delegation
114
+ status: str = "running"
115
+ error_code: str | None = None
116
+ reducer: MessageReducer | None = None
117
+ transformer: Any | None = None
118
+
119
+
120
+ @dataclass
121
+ class RunCollector:
122
+ root_run_id: str
123
+ conversation_id: str
124
+ root_agent_name: str = "UnionCoordinatorAgent"
125
+ root_status: str = "running"
126
+ root_error_code: str | None = None
127
+ children: dict[str, ChildExecution] = field(default_factory=dict)
128
+ ordered_messages: list[tuple[str, dict[str, Any]]] = field(default_factory=list)
129
+ canonical_root_messages: list[dict[str, Any]] | None = None
130
+ plans: dict[str, dict[str, Any]] = field(default_factory=dict)
131
+
132
+ def __post_init__(self) -> None:
133
+ self.root = MessageReducer(
134
+ lambda message: self.ordered_messages.append((self.root_run_id, message))
135
+ )
136
+
137
+ def start_child(self, delegation: Delegation) -> ChildExecution:
138
+ child = ChildExecution(delegation=delegation)
139
+ child.reducer = MessageReducer(
140
+ lambda message: self.ordered_messages.append((delegation.run_id, message))
141
+ )
142
+ self.children[delegation.run_id] = child
143
+ return child
144
+
145
+ def finish_child(
146
+ self, run_id: str, status: str, error_code: str | None = None
147
+ ) -> ChildExecution:
148
+ child = self.children[run_id]
149
+ child.status = status
150
+ child.error_code = error_code
151
+ return child
152
+
153
+ def validate_plan_step(self, index: int) -> None:
154
+ plan = self.plans.get(self.root_run_id)
155
+ if plan is None or index < 1 or index > len(plan["items"]):
156
+ raise PlanReferenceError("plan step does not exist")
157
+ if any(
158
+ child.status == "running" and child.delegation.plan_index == index
159
+ for child in self.children.values()
160
+ ):
161
+ raise PlanReferenceError("plan step is already running")
162
+
163
+ def set_root_messages(self, messages: list[dict[str, Any]]) -> None:
164
+ """Combine trusted ingress users with the Agent's official message dump."""
165
+ ingress_users = [
166
+ deepcopy(message)
167
+ for message in self.root.messages
168
+ if message.get("role") == "user"
169
+ ]
170
+ seen_ids = {
171
+ str(message["id"])
172
+ for message in ingress_users
173
+ if message.get("id") is not None
174
+ }
175
+ self.canonical_root_messages = ingress_users
176
+ for message in messages:
177
+ message_id = message.get("id")
178
+ if message_id is not None and str(message_id) in seen_ids:
179
+ continue
180
+ self.canonical_root_messages.append(deepcopy(message))
181
+ if message_id is not None:
182
+ seen_ids.add(str(message_id))
183
+
184
+ def snapshot(self, run_id: str) -> ActivitySnapshotEvent:
185
+ child = self.children[run_id]
186
+ metadata = child.delegation
187
+ return ActivitySnapshotEvent(
188
+ message_id=f"subagent:{run_id}",
189
+ activity_type="subagent_execution",
190
+ replace=True,
191
+ content={
192
+ "runId": run_id,
193
+ "parentRunId": metadata.parent_run_id,
194
+ "agentName": metadata.agent_name,
195
+ "delegationToolCallId": metadata.delegation_tool_call_id,
196
+ "task": metadata.task,
197
+ "status": child.status,
198
+ "messages": child.reducer.messages if child.reducer else [],
199
+ "errorCode": child.error_code,
200
+ },
201
+ )
202
+
203
+ def update_plan(
204
+ self,
205
+ *,
206
+ run_id: str,
207
+ parent_run_id: str | None,
208
+ agent_name: str,
209
+ skill_ids: list[str],
210
+ items: list[dict[str, str]],
211
+ ) -> ActivitySnapshotEvent:
212
+ """Project one complete plan as a replaceable AG-UI Activity."""
213
+ if run_id == self.root_run_id and run_id in self.plans:
214
+ existing_items = self.plans[run_id]["items"]
215
+ for child in self.children.values():
216
+ index = child.delegation.plan_index
217
+ if index is None:
218
+ continue
219
+ if (
220
+ index > len(items)
221
+ or index > len(existing_items)
222
+ or items[index - 1]["content"]
223
+ != existing_items[index - 1]["content"]
224
+ ):
225
+ raise PlanReferenceError("bound plan step changed")
226
+ current = next(
227
+ (index for index, item in enumerate(items, 1) if item["status"] == "in_progress"),
228
+ None,
229
+ )
230
+ if current is None:
231
+ current = next(
232
+ (index for index, item in enumerate(items, 1) if item["status"] == "pending"),
233
+ len(items) or None,
234
+ )
235
+ status = (
236
+ "running"
237
+ if any(item["status"] in {"pending", "in_progress"} for item in items)
238
+ else "completed"
239
+ )
240
+ content = {
241
+ "runId": run_id,
242
+ "parentRunId": parent_run_id,
243
+ "agentName": agent_name,
244
+ "skillIds": skill_ids,
245
+ "currentIndex": current,
246
+ "total": len(items),
247
+ "status": status,
248
+ "items": [dict(item) for item in items],
249
+ }
250
+ self.plans[run_id] = content
251
+ event = self.plan_snapshot(run_id)
252
+ assert event is not None
253
+ return event
254
+
255
+ def plan_snapshot(self, run_id: str) -> ActivitySnapshotEvent | None:
256
+ content = self.plans.get(run_id)
257
+ if content is None:
258
+ return None
259
+ projected = deepcopy(content)
260
+ if run_id == self.root_run_id:
261
+ for child in self.children.values():
262
+ index = child.delegation.plan_index
263
+ if index is None or index > len(projected["items"]):
264
+ continue
265
+ projected["items"][index - 1]["status"] = (
266
+ "in_progress" if child.status == "running" else child.status
267
+ )
268
+ current = next(
269
+ (
270
+ index
271
+ for index, item in enumerate(projected["items"], 1)
272
+ if item["status"] == "in_progress"
273
+ ),
274
+ None,
275
+ )
276
+ if current is None:
277
+ current = next(
278
+ (
279
+ index
280
+ for index, item in enumerate(projected["items"], 1)
281
+ if item["status"] == "pending"
282
+ ),
283
+ len(projected["items"]) or None,
284
+ )
285
+ statuses = {item["status"] for item in projected["items"]}
286
+ projected["currentIndex"] = current
287
+ projected["status"] = (
288
+ "running"
289
+ if statuses & {"pending", "in_progress"}
290
+ else "failed"
291
+ if "failed" in statuses
292
+ else "cancelled"
293
+ if "cancelled" in statuses
294
+ else "completed"
295
+ )
296
+ return ActivitySnapshotEvent(
297
+ message_id=f"task-plan:{run_id}",
298
+ activity_type="task_plan",
299
+ replace=True,
300
+ content=projected,
301
+ )
302
+
303
+ def finish_plan(
304
+ self, run_id: str, status: str
305
+ ) -> ActivitySnapshotEvent | None:
306
+ content = self.plans.get(run_id)
307
+ if content is None or content["status"] == status:
308
+ return None
309
+ content = {**content, "status": status}
310
+ self.plans[run_id] = content
311
+ return ActivitySnapshotEvent(
312
+ message_id=f"task-plan:{run_id}",
313
+ activity_type="task_plan",
314
+ replace=True,
315
+ content=content,
316
+ )
317
+
318
+ def apply_root_event(self, event: Any) -> None:
319
+ self.root.apply(event)
320
+ if getattr(event, "type", None) == EventType.RUN_ERROR:
321
+ self.root_status = "failed"
322
+ self.root_error_code = getattr(event, "code", None) or "run_error"
323
+ elif getattr(event, "type", None) == EventType.RUN_FINISHED:
324
+ self.root_status = "completed"
325
+
326
+ def completion_payload(self) -> dict[str, Any]:
327
+ executions = [
328
+ {
329
+ "runId": self.root_run_id,
330
+ "parentRunId": None,
331
+ "agentName": self.root_agent_name,
332
+ "delegationToolCallId": None,
333
+ "task": None,
334
+ "status": self.root_status,
335
+ "errorCode": self.root_error_code,
336
+ }
337
+ ]
338
+ executions.extend(
339
+ {
340
+ "runId": child.delegation.run_id,
341
+ "parentRunId": child.delegation.parent_run_id,
342
+ "agentName": child.delegation.agent_name,
343
+ "delegationToolCallId": child.delegation.delegation_tool_call_id,
344
+ "task": child.delegation.task,
345
+ "status": child.status,
346
+ "errorCode": child.error_code,
347
+ }
348
+ for child in self.children.values()
349
+ )
350
+ root_messages = (
351
+ self.canonical_root_messages
352
+ if self.canonical_root_messages is not None
353
+ else self.root.messages
354
+ )
355
+ messages = [
356
+ {"runId": self.root_run_id, "message": persisted}
357
+ for message in root_messages
358
+ if (
359
+ persisted := self._persisted_message(
360
+ self.root_run_id,
361
+ message,
362
+ )
363
+ )
364
+ is not None
365
+ ]
366
+ messages.extend(
367
+ {"runId": run_id, "message": persisted}
368
+ for run_id, message in self.ordered_messages
369
+ if run_id != self.root_run_id
370
+ and (persisted := self._persisted_message(run_id, message)) is not None
371
+ )
372
+ return {
373
+ "conversationId": self.conversation_id,
374
+ "runId": self.root_run_id,
375
+ "status": self.root_status,
376
+ "errorCode": self.root_error_code,
377
+ "executions": executions,
378
+ "messages": messages,
379
+ }
380
+
381
+ def _persisted_message(
382
+ self, run_id: str, message: dict[str, Any]
383
+ ) -> dict[str, Any] | None:
384
+ value = deepcopy(message)
385
+ if (
386
+ run_id == self.root_run_id
387
+ and self.root_status != "completed"
388
+ and value.get("role") == "assistant"
389
+ ):
390
+ if not value.get("toolCalls"):
391
+ return None
392
+ value["content"] = ""
393
+ return value
@@ -0,0 +1,80 @@
1
+ """定义 delegation 子执行的元数据、观察器和任务局部生命周期。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+ from contextlib import asynccontextmanager
8
+ from contextvars import ContextVar
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+
13
+ class PlanReferenceError(ValueError):
14
+ """Reject an invalid live binding between one root-plan step and a child."""
15
+
16
+
17
+ @dataclass
18
+ class Delegation:
19
+ run_id: str
20
+ parent_run_id: str
21
+ agent_name: str
22
+ delegation_tool_call_id: str
23
+ task: str
24
+ plan_index: int | None = None
25
+ terminal_status: str | None = None
26
+ terminal_error_code: str | None = None
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class DelegationObserver:
31
+ on_start: Callable[[Delegation], Awaitable[None]]
32
+ on_event: Callable[[Delegation, Any], Awaitable[None]]
33
+ on_finish: Callable[[Delegation, str, str | None], Awaitable[None]]
34
+
35
+
36
+ current_delegation: ContextVar[Delegation | None] = ContextVar(
37
+ "current_delegation",
38
+ default=None,
39
+ )
40
+
41
+
42
+ @asynccontextmanager
43
+ async def delegation_scope(ctx, delegation: Delegation):
44
+ """Notify one observer exactly once around a delegate_task call."""
45
+ observer = ctx.deps.delegation_observer
46
+ token = current_delegation.set(delegation)
47
+ started = False
48
+ try:
49
+ if observer is not None:
50
+ await observer.on_start(delegation)
51
+ started = True
52
+ yield
53
+ except asyncio.CancelledError:
54
+ if ctx.deps.cancelled.is_set():
55
+ delegation.terminal_status = "cancelled"
56
+ delegation.terminal_error_code = "cancelled"
57
+ if observer is not None and started:
58
+ await observer.on_finish(
59
+ delegation,
60
+ delegation.terminal_status or "failed",
61
+ delegation.terminal_error_code or "subagent_timeout",
62
+ )
63
+ raise
64
+ except Exception as exc:
65
+ if observer is not None and started:
66
+ await observer.on_finish(
67
+ delegation,
68
+ delegation.terminal_status or "failed",
69
+ delegation.terminal_error_code or type(exc).__name__,
70
+ )
71
+ raise
72
+ else:
73
+ if observer is not None and started:
74
+ await observer.on_finish(
75
+ delegation,
76
+ delegation.terminal_status or "completed",
77
+ delegation.terminal_error_code,
78
+ )
79
+ finally:
80
+ current_delegation.reset(token)
@@ -0,0 +1,34 @@
1
+ """定义服务端为一次 Agent 运行注入的可信依赖。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Awaitable, Callable
10
+
11
+ from app.agent.runtime.delegation import DelegationObserver
12
+ from app.utils.api_client import ApiClient
13
+
14
+
15
+ @dataclass
16
+ class RunDeps:
17
+ """Trusted, server-created dependencies for one Agent run."""
18
+
19
+ user_id: str
20
+ permissions: tuple[str, ...]
21
+ state_client: Any
22
+ api_client: ApiClient
23
+ cancelled: Any
24
+ authentication_type: str = "CAS"
25
+ org_code: str = ""
26
+ scheduled_run_id: int | None = None
27
+ rag_service: Any | None = None
28
+ effective_at: str | None = None
29
+ effective_timezone: str | None = None
30
+ planned_run_ids: set[str] = field(default_factory=set)
31
+ delegated_agents_by_run: dict[str, set[str]] = field(default_factory=dict)
32
+ delegation_observer: DelegationObserver | None = None
33
+ plan_observer: Callable[[Any, list[dict[str, str]]], Awaitable[None]] | None = None
34
+ delegation_plan_validator: Callable[[int], None] | None = None