xg-cli 1.0__py3-none-any.whl

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 (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/tui/reducer.py ADDED
@@ -0,0 +1,1029 @@
1
+ """Translate Agent/Plan events to UI state.
2
+
3
+ No terminal, network, database, or Textual operation belongs in this module.
4
+ That makes event ordering and stale-event handling straightforward to test.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import replace
10
+ import re
11
+ from typing import Any
12
+
13
+ from xg.agent.plan import PlanEvent
14
+ from xg.agent.react import AgentEvent
15
+ from xg.agent.team import TeamEvent
16
+ from xg.llm.types import Usage
17
+ from xg.tui.state import (
18
+ AgentGroupState,
19
+ PlanInspectorSnapshot,
20
+ PlanTaskSnapshot,
21
+ SafetyInspectorSnapshot,
22
+ SessionInspectorSnapshot,
23
+ SmartRouterSnapshot,
24
+ TuiState,
25
+ TranscriptItem,
26
+ )
27
+
28
+
29
+ def _copy(state: TuiState) -> TuiState:
30
+ return replace(
31
+ state,
32
+ transcript=[replace(item) for item in state.transcript],
33
+ plan_tasks=dict(state.plan_tasks),
34
+ agent_groups={
35
+ key: replace(group, entries=[replace(item) for item in group.entries])
36
+ for key, group in state.agent_groups.items()
37
+ },
38
+ agent_group_order=list(state.agent_group_order),
39
+ team_input_scope=list(state.team_input_scope),
40
+ inspector=replace(
41
+ state.inspector,
42
+ session=replace(state.inspector.session),
43
+ plan=replace(state.inspector.plan),
44
+ memory=replace(state.inspector.memory),
45
+ safety=replace(state.inspector.safety),
46
+ smart_router=replace(state.inspector.smart_router),
47
+ ),
48
+ )
49
+
50
+
51
+ def set_smart_router_snapshot(state: TuiState, snapshot: SmartRouterSnapshot) -> TuiState:
52
+ """Replace the inspector SmartRouter snapshot (routing result / toggle).
53
+
54
+ 纯数据层替换(phase-02 步骤 A):不产生 transcript 项、不影响 phase。
55
+ """
56
+ out = _copy(state)
57
+ out.inspector = replace(out.inspector, smart_router=snapshot)
58
+ return out
59
+
60
+
61
+ def _append(state: TuiState, item: TranscriptItem) -> None:
62
+ state.transcript.append(item)
63
+
64
+
65
+ def _team_group_key(event: TeamEvent) -> str:
66
+ """Return a stable UI identity for one logical Team AgentRun."""
67
+ if event.agent_id:
68
+ return f"{event.team_id}:{event.agent_id}"
69
+ task_id = event.task.id if event.task is not None else "unknown"
70
+ role = event.role or ("reviewer" if event.kind.startswith("task_review") else "agent")
71
+ return f"{event.team_id}:{role}:{task_id}"
72
+
73
+
74
+ def _ensure_agent_group(
75
+ state: TuiState,
76
+ event: TeamEvent,
77
+ *,
78
+ group_id: str | None = None,
79
+ role: str | None = None,
80
+ task=None,
81
+ ) -> tuple[AgentGroupState, str]:
82
+ task = task or event.task
83
+ key = group_id or _team_group_key(event)
84
+ group = state.agent_groups.get(key)
85
+ if group is not None:
86
+ return group, key
87
+ task_id = task.id if task is not None else ""
88
+ task_title = task.title if task is not None else ""
89
+ group_role = role or event.role or "agent"
90
+ agent_id = event.agent_id or key.rsplit(":", 1)[-1]
91
+ group = AgentGroupState(
92
+ group_id=key,
93
+ team_id=event.team_id,
94
+ agent_id=agent_id,
95
+ role=group_role,
96
+ task_id=task_id,
97
+ task_title=task_title or task_id or group_role,
98
+ resource_scope_mode=getattr(task, "resource_scope_mode", "targeted"),
99
+ attempt=event.attempt,
100
+ effective_steps=event.effective_steps,
101
+ failure_category=event.failure_category,
102
+ )
103
+ state.agent_groups[key] = group
104
+ state.agent_group_order.append(key)
105
+ state.transcript.append(TranscriptItem(
106
+ id=f"agent-group-{len(state.transcript)}",
107
+ kind="agent_group",
108
+ text=group.task_title,
109
+ agent_group_id=key,
110
+ turn_id=state.active_turn_id,
111
+ trace_id=f"{state.active_turn_id}:{key}",
112
+ collapsible=True,
113
+ collapsed=True,
114
+ ))
115
+ return group, key
116
+
117
+
118
+ def _update_agent_group(state: TuiState, group_id: str, **changes) -> None:
119
+ group = state.agent_groups[group_id]
120
+ state.agent_groups[group_id] = replace(group, **changes)
121
+
122
+
123
+ def _append_group_entry(
124
+ state: TuiState,
125
+ group_id: str,
126
+ item: TranscriptItem,
127
+ *,
128
+ summary: str = "",
129
+ error: str = "",
130
+ tool: bool = False,
131
+ artifact: bool = False,
132
+ ) -> None:
133
+ group = state.agent_groups[group_id]
134
+ item.agent_group_id = group_id
135
+ group.entries.append(item)
136
+ state.agent_groups[group_id] = replace(
137
+ group,
138
+ event_count=group.event_count + 1,
139
+ tool_count=group.tool_count + (1 if tool else 0),
140
+ artifact_count=group.artifact_count + (1 if artifact else 0),
141
+ latest_summary=summary[:240] if summary else group.latest_summary,
142
+ latest_error=error[:240] if error else group.latest_error,
143
+ )
144
+
145
+
146
+ def _reduce_agent_event_in_group(
147
+ state: TuiState,
148
+ group_id: str,
149
+ event: AgentEvent,
150
+ turn_id: str,
151
+ trace_id: str,
152
+ ) -> TuiState:
153
+ """Reuse the Agent reducer against a group's private entry list."""
154
+ group = state.agent_groups[group_id]
155
+ sandbox = replace(state, transcript=[replace(item) for item in group.entries])
156
+ reduced = reduce_agent_event(sandbox, event, turn_id, trace_id)
157
+ out = _copy(state)
158
+ entries = [replace(item, agent_group_id=group_id) for item in reduced.transcript]
159
+ out.agent_groups[group_id] = replace(
160
+ out.agent_groups[group_id],
161
+ entries=entries,
162
+ event_count=out.agent_groups[group_id].event_count + 1,
163
+ tool_count=out.agent_groups[group_id].tool_count + (1 if event.kind == "tool_call" else 0),
164
+ latest_summary=(event.text or out.agent_groups[group_id].latest_summary)[:240],
165
+ latest_error=(event.text if event.kind == "error" else out.agent_groups[group_id].latest_error)[:240],
166
+ )
167
+ out.inspector = reduced.inspector
168
+ out.pending_approval = reduced.pending_approval
169
+ out.pending_confirmation = reduced.pending_confirmation
170
+ out.notification = reduced.notification
171
+ out.notification_level = reduced.notification_level
172
+ if event.kind not in {"error", "context_overflow", "budget_exceeded"}:
173
+ out.phase = "running"
174
+ return out
175
+
176
+
177
+ def _finish_agent_groups(state: TuiState, status: str) -> None:
178
+ for key, group in state.agent_groups.items():
179
+ if group.status in {"pending", "running", "reviewing", "repairing"}:
180
+ state.agent_groups[key] = replace(group, status=status)
181
+
182
+
183
+ def _remove_progress(state: TuiState, turn_id: str) -> None:
184
+ """Remove local waiting indicators without touching Agent history."""
185
+ state.transcript[:] = [
186
+ item for item in state.transcript
187
+ if not (item.kind == "progress" and item.turn_id == turn_id)
188
+ ]
189
+
190
+
191
+ def _update_progress(state: TuiState, turn_id: str, text: str) -> None:
192
+ """Update a local waiting indicator without creating a transcript item."""
193
+ if not text:
194
+ return
195
+ for item in reversed(state.transcript):
196
+ if item.kind == "progress" and item.turn_id == turn_id:
197
+ item.progress_kind = "context"
198
+ item.text = text
199
+ return
200
+
201
+
202
+ def _trace_id(turn_id: str, trace_id: str | None) -> str:
203
+ return trace_id or turn_id
204
+
205
+
206
+ def _ratio(value: int, limit: int) -> float:
207
+ return value / limit if limit > 0 else 0.0
208
+
209
+
210
+ def _valid_usage(usage: Usage | None) -> bool:
211
+ if usage is None:
212
+ return False
213
+ values = (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens)
214
+ return all(
215
+ isinstance(value, int)
216
+ and not isinstance(value, bool)
217
+ and value >= 0
218
+ for value in values
219
+ )
220
+
221
+
222
+ def _update_context_usage(out: TuiState, event: AgentEvent) -> None:
223
+ old = out.inspector.usage
224
+ estimated = max(0, event.estimated_prompt_tokens or 0)
225
+ window = event.context_window if event.context_window is not None else old.context_window
226
+ limit = event.request_token_limit if event.request_token_limit is not None else old.request_token_limit
227
+ compacted = event.compaction_before is not None and event.compaction_after is not None
228
+ usage = replace(
229
+ old,
230
+ estimated_prompt_tokens=estimated,
231
+ context_window=max(0, window or 0),
232
+ request_token_limit=max(0, limit or 0),
233
+ window_ratio=_ratio(estimated, window or 0),
234
+ budget_usage_ratio=_ratio(estimated, limit or 0),
235
+ usage_source="estimated",
236
+ compaction_count=old.compaction_count + (1 if compacted else 0),
237
+ last_compaction_before=(event.compaction_before or 0) if compacted else old.last_compaction_before,
238
+ last_compaction_after=(event.compaction_after or 0) if compacted else old.last_compaction_after,
239
+ )
240
+ out.inspector = replace(
241
+ out.inspector,
242
+ usage=usage,
243
+ context_tokens=estimated,
244
+ context_window=max(0, window or 0),
245
+ session=replace(out.inspector.session, status=_phase_status(out.phase)),
246
+ )
247
+
248
+
249
+ def _update_provider_usage(out: TuiState, usage_value: Usage | None) -> None:
250
+ if not _valid_usage(usage_value):
251
+ return
252
+ assert usage_value is not None
253
+ old = out.inspector.usage
254
+ usage = replace(
255
+ old,
256
+ last_prompt_tokens=usage_value.prompt_tokens,
257
+ last_completion_tokens=usage_value.completion_tokens,
258
+ last_total_tokens=usage_value.total_tokens,
259
+ session_prompt_tokens=old.session_prompt_tokens + usage_value.prompt_tokens,
260
+ session_completion_tokens=old.session_completion_tokens + usage_value.completion_tokens,
261
+ session_total_tokens=old.session_total_tokens + usage_value.total_tokens,
262
+ usage_source="provider",
263
+ )
264
+ out.inspector = replace(
265
+ out.inspector,
266
+ usage=usage,
267
+ session=replace(out.inspector.session, status=_phase_status(out.phase)),
268
+ )
269
+
270
+
271
+ def _phase_status(phase: str) -> str:
272
+ return {
273
+ "idle": "Idle",
274
+ "running": "Working",
275
+ "awaiting_approval": "Waiting approval",
276
+ "awaiting_plan_review": "Plan review",
277
+ "error": "Error",
278
+ }.get(phase, phase)
279
+
280
+
281
+ def _plan_snapshot(
282
+ plan,
283
+ *,
284
+ status: str | None = None,
285
+ current_round: int | None = None,
286
+ total_rounds: int | None = None,
287
+ previous: PlanInspectorSnapshot | None = None,
288
+ ) -> PlanInspectorSnapshot:
289
+ tasks = tuple(
290
+ PlanTaskSnapshot(id=task.id, title=task.title, status=task.status)
291
+ for task in (plan.tasks if plan is not None else ())
292
+ )
293
+ done = sum(task.status == "done" for task in tasks)
294
+ failures = sum(task.status == "failed" for task in tasks)
295
+ old = previous or PlanInspectorSnapshot()
296
+ return PlanInspectorSnapshot(
297
+ goal=plan.goal if plan is not None else old.goal,
298
+ status=status if status is not None else old.status,
299
+ current_round=current_round if current_round is not None else old.current_round,
300
+ total_rounds=total_rounds if total_rounds is not None else old.total_rounds,
301
+ completed_tasks=done,
302
+ total_tasks=len(tasks),
303
+ failure_count=failures,
304
+ tasks=tasks or old.tasks,
305
+ )
306
+
307
+
308
+ def _set_safety_decision(out: TuiState, event: AgentEvent) -> None:
309
+ decision = event.decision
310
+ if decision is None:
311
+ return
312
+ status = event.text or ("approved" if decision.allow else "rejected")
313
+ out.inspector = replace(
314
+ out.inspector,
315
+ safety=replace(
316
+ out.inspector.safety,
317
+ approval_status=status,
318
+ current_tool="",
319
+ current_level="",
320
+ last_decision=status,
321
+ last_reason=decision.reason,
322
+ last_rejection=(decision.reason if not decision.allow else out.inspector.safety.last_rejection),
323
+ ),
324
+ )
325
+
326
+
327
+ def _finish_streaming_text(state: TuiState, trace_id: str) -> None:
328
+ for item in reversed(state.transcript):
329
+ if item.trace_id != trace_id:
330
+ continue
331
+ if item.kind in ("assistant", "thinking") and item.streaming:
332
+ item.streaming = False
333
+ if item.kind == "thinking":
334
+ item.status = "done"
335
+ if item.user_collapsed is None:
336
+ item.collapsed = True
337
+ return
338
+
339
+
340
+ def _reclassify_intermediate_assistant(state: TuiState, turn_id: str, trace_id: str) -> None:
341
+ """Treat pre-tool plain text as thinking when no explicit event exists."""
342
+ for item in reversed(state.transcript):
343
+ if item.turn_id != turn_id or item.trace_id != trace_id:
344
+ continue
345
+ if item.kind != "assistant" or not item.text:
346
+ return
347
+ item.kind = "thinking"
348
+ item.collapsible = True
349
+ item.collapsed = True
350
+ item.user_collapsed = None
351
+ item.streaming = False
352
+ item.status = "done"
353
+ return
354
+
355
+
356
+ def _assistant(state: TuiState, turn_id: str, trace_id: str) -> TranscriptItem:
357
+ if state.transcript and state.transcript[-1].kind == "assistant" and state.transcript[-1].streaming and state.transcript[-1].trace_id == trace_id:
358
+ return state.transcript[-1]
359
+ item = TranscriptItem(
360
+ id=f"assistant-{len(state.transcript)}", kind="assistant", streaming=True,
361
+ collapsed=False, turn_id=turn_id, trace_id=trace_id, status="streaming",
362
+ )
363
+ _append(state, item)
364
+ return item
365
+
366
+
367
+ def _thinking(state: TuiState, turn_id: str, trace_id: str) -> TranscriptItem:
368
+ if state.transcript and state.transcript[-1].kind == "thinking" and state.transcript[-1].streaming and state.transcript[-1].trace_id == trace_id:
369
+ return state.transcript[-1]
370
+ item = TranscriptItem(
371
+ id=f"thinking-{len(state.transcript)}", kind="thinking", collapsible=True,
372
+ collapsed=False, streaming=True, turn_id=turn_id, trace_id=trace_id,
373
+ status="streaming",
374
+ )
375
+ _append(state, item)
376
+ return item
377
+
378
+
379
+ def _collapse_trace(state: TuiState, trace_id: str, *, status: str = "done") -> None:
380
+ for item in state.transcript:
381
+ if item.trace_id != trace_id or not item.collapsible:
382
+ continue
383
+ item.streaming = False
384
+ if item.user_collapsed is None:
385
+ item.collapsed = True
386
+ if item.status in ("streaming", "running"):
387
+ item.status = status # type: ignore[assignment]
388
+
389
+
390
+ def _collapse_turn(state: TuiState, turn_id: str, *, status: str = "done") -> None:
391
+ for item in state.transcript:
392
+ if item.trace_id == turn_id or item.trace_id.startswith(f"{turn_id}:"):
393
+ _collapse_trace(state, item.trace_id, status=status)
394
+ if item.kind == "assistant" and item.trace_id.startswith(f"{turn_id}:"):
395
+ item.collapsible = True
396
+ if item.user_collapsed is None:
397
+ item.collapsed = True
398
+
399
+
400
+ def finalize_trace(state: TuiState, turn_id: str, *, status: str = "cancelled") -> TuiState:
401
+ """Finalize a turn when its worker is cancelled outside the event stream."""
402
+ out = _copy(state)
403
+ _remove_progress(out, turn_id)
404
+ _collapse_turn(out, turn_id, status=status)
405
+ return out
406
+
407
+
408
+ def reduce_agent_event(
409
+ state: TuiState,
410
+ event: AgentEvent,
411
+ turn_id: str | None = None,
412
+ trace_id: str | None = None,
413
+ ) -> TuiState:
414
+ """Return the next state for one ReAct event."""
415
+ turn_id = turn_id or state.active_turn_id
416
+ if state.active_turn_id and turn_id and state.active_turn_id != turn_id:
417
+ return state
418
+ out = _copy(state)
419
+ trace_id = _trace_id(turn_id, trace_id)
420
+ kind = event.kind
421
+ if kind in {
422
+ "thinking", "content", "tool_call", "tool_result", "approval",
423
+ "error", "context_overflow", "budget_exceeded", "step_limit", "retrying", "done",
424
+ }:
425
+ _remove_progress(out, turn_id)
426
+ if event.estimated_prompt_tokens is not None:
427
+ _update_context_usage(out, event)
428
+ if kind == "context_usage":
429
+ return out
430
+ if kind == "usage":
431
+ _update_provider_usage(out, event.usage)
432
+ return out
433
+ if kind == "retrying":
434
+ _append(out, TranscriptItem(
435
+ id=f"retry-{len(out.transcript)}", kind="system",
436
+ text=(
437
+ f"{event.text or 'API 临时故障,正在重试'} "
438
+ f"({event.retry_attempts}/{event.retry_max_attempts}),"
439
+ f"等待 {event.retry_delay or 0:.1f}s"
440
+ ),
441
+ turn_id=turn_id, trace_id=trace_id,
442
+ ))
443
+ out.notification = event.text or "API 临时故障,正在重试"
444
+ out.notification_level = "warning"
445
+ return out
446
+ if kind == "thinking":
447
+ if not (out.transcript and out.transcript[-1].kind == "thinking" and out.transcript[-1].streaming and out.transcript[-1].trace_id == trace_id):
448
+ _finish_streaming_text(out, trace_id)
449
+ item = _thinking(out, turn_id, trace_id)
450
+ item.text += event.text
451
+ return out
452
+ if kind == "content":
453
+ if out.transcript and out.transcript[-1].kind == "thinking" and out.transcript[-1].trace_id == trace_id:
454
+ _finish_streaming_text(out, trace_id)
455
+ item = _assistant(out, turn_id, trace_id)
456
+ item.text += event.text
457
+ return out
458
+ if kind == "tool_call" and event.tool_call:
459
+ _finish_streaming_text(out, trace_id)
460
+ _reclassify_intermediate_assistant(out, turn_id, trace_id)
461
+ call = event.tool_call
462
+ _append(out, TranscriptItem(
463
+ id=call.id or f"tool-{len(out.transcript)}", kind="tool_call",
464
+ tool_name=call.name, tool_args=call.arguments, tool_call_id=call.id,
465
+ turn_id=turn_id, trace_id=trace_id, collapsible=True,
466
+ collapsed=False, status="running",
467
+ ))
468
+ out.phase = "running"
469
+ return out
470
+ if kind == "tool_result" and event.tool_result:
471
+ result = event.tool_result
472
+ for item in reversed(out.transcript):
473
+ if item.kind == "tool_call" and item.tool_call_id == result.tool_call_id:
474
+ item.tool_ok = result.ok
475
+ item.status = "success" if result.ok else "failed"
476
+ if item.user_collapsed is None:
477
+ item.collapsed = True
478
+ break
479
+ _append(out, TranscriptItem(
480
+ id=f"result-{result.tool_call_id or len(out.transcript)}", kind="tool_result",
481
+ text=result.output or result.error, tool_name=result.name,
482
+ tool_call_id=result.tool_call_id, tool_ok=result.ok, turn_id=turn_id,
483
+ trace_id=trace_id, collapsible=True, collapsed=True,
484
+ status="success" if result.ok else "failed", parent_call_id=result.tool_call_id,
485
+ ))
486
+ if not result.ok:
487
+ out.inspector = replace(
488
+ out.inspector,
489
+ safety=replace(
490
+ out.inspector.safety,
491
+ last_rejection=result.error or result.output or "tool_failed",
492
+ ),
493
+ )
494
+ return out
495
+ if kind == "approval":
496
+ if event.tool_call:
497
+ _append(out, TranscriptItem(
498
+ id=f"approval-{event.tool_call.id or len(out.transcript)}", kind="approval",
499
+ text=event.text, tool_name=event.tool_call.name,
500
+ tool_args=event.tool_call.arguments, turn_id=turn_id, trace_id=trace_id,
501
+ collapsible=True, collapsed=True, status="done",
502
+ ))
503
+ out.pending_approval = None
504
+ out.phase = "running"
505
+ _set_safety_decision(out, event)
506
+ return out
507
+ if kind in ("context_compacted", "context_warning"):
508
+ if kind == "context_compacted":
509
+ _update_progress(out, turn_id, "正在整理上下文")
510
+ _append(out, TranscriptItem(id=f"context-{len(out.transcript)}", kind="context", text=event.text, turn_id=turn_id))
511
+ out.notification = event.text
512
+ out.notification_level = "warning" if kind == "context_warning" else "info"
513
+ return out
514
+ if kind in ("error", "context_overflow", "budget_exceeded"):
515
+ text = event.text or "任务执行失败"
516
+ _append(out, TranscriptItem(id=f"error-{len(out.transcript)}", kind="error", text=text, turn_id=turn_id))
517
+ out.phase = "error"
518
+ out.notification = text
519
+ out.notification_level = "error"
520
+ out.pending_approval = None
521
+ _collapse_trace(out, trace_id, status="failed")
522
+ return out
523
+ if kind in ("step_limit", "done"):
524
+ _update_provider_usage(out, event.usage)
525
+ for item in reversed(out.transcript):
526
+ if item.kind == "assistant" and item.trace_id == trace_id:
527
+ item.streaming = False
528
+ item.status = "done"
529
+ break
530
+ _collapse_trace(out, trace_id, status="done")
531
+ out.phase = "idle"
532
+ out.pending_approval = None
533
+ if kind == "step_limit":
534
+ out.notification = "已达到本轮步骤上限"
535
+ out.notification_level = "warning"
536
+ return out
537
+ return out
538
+
539
+
540
+ def reduce_plan_event(state: TuiState, event: PlanEvent, turn_id: str | None = None) -> TuiState:
541
+ """Return the next state for one Plan event, including nested agent events."""
542
+ turn_id = turn_id or state.active_turn_id
543
+ if state.active_turn_id and turn_id and state.active_turn_id != turn_id:
544
+ return state
545
+ out = _copy(state)
546
+ kind = event.kind
547
+ if kind in {"plan_generated", "plan_done", "cancelled", "plan_failed"}:
548
+ _remove_progress(out, turn_id)
549
+ if kind == "plan_generated" and event.plan:
550
+ if event.estimated_prompt_tokens is not None:
551
+ _update_context_usage(out, AgentEvent(
552
+ kind="context_usage",
553
+ estimated_prompt_tokens=event.estimated_prompt_tokens,
554
+ request_token_limit=event.request_token_limit,
555
+ context_window=event.context_window,
556
+ compaction_before=event.compaction_before,
557
+ compaction_after=event.compaction_after,
558
+ ))
559
+ _update_provider_usage(out, event.usage)
560
+ out.pending_plan = event.plan
561
+ out.phase = "awaiting_plan_review"
562
+ out.inspector = replace(
563
+ out.inspector,
564
+ plan_status="review",
565
+ plan=_plan_snapshot(
566
+ event.plan,
567
+ status="review",
568
+ current_round=0,
569
+ total_rounds=len(event.plan.batches),
570
+ previous=out.inspector.plan,
571
+ ),
572
+ session=replace(out.inspector.session, status="Plan review"),
573
+ )
574
+ out.plan_tasks = {task.id: task.status for task in event.plan.tasks}
575
+ _append(out, TranscriptItem(
576
+ id=f"plan-{len(out.transcript)}", kind="plan", text=event.plan.goal,
577
+ plan=event.plan, plan_review=True, collapsed=True, turn_id=turn_id,
578
+ ))
579
+ return out
580
+ if kind == "review":
581
+ out.phase = "awaiting_plan_review"
582
+ return out
583
+ if kind in ("approved", "replanned"):
584
+ for item in reversed(out.transcript):
585
+ if item.kind == "plan" and item.turn_id == turn_id and (event.plan is None or item.plan is event.plan):
586
+ item.plan_review = False
587
+ break
588
+ out.phase = "running"
589
+ out.pending_plan = None
590
+ out.inspector = replace(
591
+ out.inspector,
592
+ plan_status="running",
593
+ plan=replace(out.inspector.plan, status="running"),
594
+ session=replace(out.inspector.session, status="Working"),
595
+ )
596
+ return out
597
+ if kind == "batch_started":
598
+ out.inspector = replace(
599
+ out.inspector,
600
+ batch=event.message,
601
+ plan=_plan_snapshot(
602
+ event.plan,
603
+ status="running",
604
+ current_round=(
605
+ event.plan.batches.index(event.batch) + 1
606
+ if event.plan is not None and event.batch in event.plan.batches else out.inspector.plan.current_round
607
+ ),
608
+ total_rounds=len(event.plan.batches) if event.plan is not None else out.inspector.plan.total_rounds,
609
+ previous=out.inspector.plan,
610
+ ),
611
+ )
612
+ out.phase = "running"
613
+ return out
614
+ if kind == "subtask_started" and event.task:
615
+ out.plan_tasks[event.task.id] = event.task.status
616
+ plan = event.plan
617
+ tasks = list(out.inspector.plan.tasks)
618
+ for index, task in enumerate(tasks):
619
+ if task.id == event.task.id:
620
+ tasks[index] = PlanTaskSnapshot(task.id, task.title, event.task.status)
621
+ break
622
+ out.inspector = replace(
623
+ out.inspector,
624
+ plan=replace(
625
+ out.inspector.plan,
626
+ goal=plan.goal if plan is not None else out.inspector.plan.goal,
627
+ tasks=tuple(tasks),
628
+ total_tasks=len(tasks),
629
+ completed_tasks=sum(task.status == "done" for task in tasks),
630
+ failure_count=sum(task.status == "failed" for task in tasks),
631
+ ),
632
+ )
633
+ return out
634
+ if kind == "subtask_event" and event.agent_event:
635
+ task_trace = f"{turn_id}:{event.task.id}" if event.task else turn_id
636
+ return reduce_agent_event(out, event.agent_event, turn_id, task_trace)
637
+ if kind == "planner_usage" and event.agent_event:
638
+ return reduce_agent_event(out, event.agent_event, turn_id, f"{turn_id}:planner")
639
+ if kind in ("subtask_done", "subtask_failed") and event.task:
640
+ out.plan_tasks[event.task.id] = event.task.status
641
+ task_trace = f"{turn_id}:{event.task.id}"
642
+ _collapse_trace(out, task_trace, status="failed" if kind == "subtask_failed" else "done")
643
+ for item in out.transcript:
644
+ if item.trace_id == task_trace and item.kind == "assistant":
645
+ item.collapsible = True
646
+ if item.user_collapsed is None:
647
+ item.collapsed = True
648
+ tasks = [
649
+ PlanTaskSnapshot(task.id, task.title, event.task.status)
650
+ if task.id == event.task.id else task
651
+ for task in out.inspector.plan.tasks
652
+ ]
653
+ out.inspector = replace(
654
+ out.inspector,
655
+ plan=replace(
656
+ out.inspector.plan,
657
+ tasks=tuple(tasks),
658
+ completed_tasks=sum(task.status == "done" for task in tasks),
659
+ failure_count=sum(task.status == "failed" for task in tasks),
660
+ ),
661
+ )
662
+ return out
663
+ if kind == "plan_resume_requested":
664
+ out.phase = "running"
665
+ out.pending_plan = None
666
+ out.inspector = replace(
667
+ out.inspector,
668
+ plan_status="running",
669
+ plan=_plan_snapshot(event.plan, status="running", previous=out.inspector.plan),
670
+ session=replace(out.inspector.session, status="Working"),
671
+ )
672
+ _append(out, TranscriptItem(
673
+ id=f"plan-resume-{len(out.transcript)}", kind="system",
674
+ text=event.message, turn_id=turn_id,
675
+ ))
676
+ out.notification = event.message
677
+ out.notification_level = "info"
678
+ return out
679
+ if kind in ("cancelled", "plan_done"):
680
+ _collapse_turn(out, turn_id, status="cancelled" if kind == "cancelled" else "done")
681
+ out.phase = "idle"
682
+ out.pending_plan = None
683
+ final_status = "done" if kind == "plan_done" else "cancelled"
684
+ plan = _plan_snapshot(
685
+ event.plan,
686
+ status=final_status,
687
+ previous=out.inspector.plan,
688
+ )
689
+ out.inspector = replace(
690
+ out.inspector,
691
+ plan_status=final_status,
692
+ plan=plan,
693
+ session=replace(out.inspector.session, status="Idle"),
694
+ )
695
+ out.notification = event.message
696
+ out.notification_level = "info"
697
+ return out
698
+ if kind == "plan_failed":
699
+ _collapse_turn(out, turn_id, status="failed")
700
+ out.phase = "error"
701
+ out.pending_plan = None
702
+ out.inspector = replace(
703
+ out.inspector,
704
+ plan_status="failed",
705
+ plan=_plan_snapshot(event.plan, status="failed", previous=out.inspector.plan),
706
+ session=replace(out.inspector.session, status="Error"),
707
+ )
708
+ out.notification = event.message
709
+ out.notification_level = "error"
710
+ _append(out, TranscriptItem(id=f"plan-error-{len(out.transcript)}", kind="error", text=event.message, turn_id=turn_id))
711
+ return out
712
+ return out
713
+
714
+
715
+ def reduce_team_event(state: TuiState, event: TeamEvent, turn_id: str | None = None) -> TuiState:
716
+ """将 Team 协作事件归约为现有计划/执行视图。"""
717
+ turn_id = turn_id or state.active_turn_id
718
+ if state.active_turn_id and turn_id and state.active_turn_id != turn_id:
719
+ return state
720
+ out = _copy(state)
721
+ kind = event.kind
722
+ if kind in {"team_plan_generated", "team_done", "cancelled", "team_failed"}:
723
+ _remove_progress(out, turn_id)
724
+ if kind == "team_plan_generated" and event.plan:
725
+ out.pending_plan = event.plan
726
+ out.phase = "awaiting_plan_review"
727
+ out.inspector = replace(
728
+ out.inspector,
729
+ plan_status="review",
730
+ plan=_plan_snapshot(
731
+ event.plan, status="review", current_round=0,
732
+ total_rounds=len(event.plan.batches), previous=out.inspector.plan,
733
+ ),
734
+ session=replace(out.inspector.session, status="Team review"),
735
+ )
736
+ out.plan_tasks = {task.id: task.status for task in event.plan.tasks}
737
+ _append(out, TranscriptItem(
738
+ id=f"team-plan-{len(out.transcript)}", kind="plan", text=event.plan.goal,
739
+ plan=event.plan, plan_review=True, collapsed=True, turn_id=turn_id,
740
+ ))
741
+ if event.message:
742
+ out.notification = event.message
743
+ out.notification_level = "warning"
744
+ return out
745
+ if kind == "team_review":
746
+ out.phase = "awaiting_plan_review"
747
+ return out
748
+ if kind in {"approved", "replanned"}:
749
+ for item in reversed(out.transcript):
750
+ if item.kind == "plan" and item.turn_id == turn_id:
751
+ item.plan_review = False
752
+ break
753
+ out.phase = "running"
754
+ out.pending_plan = None
755
+ out.inspector = replace(
756
+ out.inspector,
757
+ plan_status="running",
758
+ plan=replace(out.inspector.plan, status="running"),
759
+ session=replace(out.inspector.session, status="Working"),
760
+ )
761
+ return out
762
+ if kind == "batch_started":
763
+ out.phase = "running"
764
+ if event.plan:
765
+ round_number = event.plan.batches.index(event.batch) + 1 if event.batch in event.plan.batches else out.inspector.plan.current_round
766
+ out.inspector = replace(
767
+ out.inspector,
768
+ batch=event.message,
769
+ plan=_plan_snapshot(
770
+ event.plan, status="running", current_round=round_number,
771
+ total_rounds=len(event.plan.batches), previous=out.inspector.plan,
772
+ ),
773
+ )
774
+ return out
775
+ if kind == "task_retry_started" and event.task:
776
+ _append(out, TranscriptItem(
777
+ id=f"team-task-retry-{len(out.transcript)}", kind="system",
778
+ text=(
779
+ f"[{event.role or event.task.owner_role}/{event.task.id}] 恢复执行:"
780
+ f"第 {event.attempt} 次,预算 {event.retry_steps} 步;"
781
+ f"已保留 Artifact {len(event.preserved_artifacts)} 个"
782
+ ),
783
+ turn_id=turn_id, trace_id=f"{turn_id}:{event.task.id}:retry",
784
+ ))
785
+ out.notification = event.message
786
+ out.notification_level = "warning"
787
+ return out
788
+ if kind in {"task_started", "task_done", "task_failed", "task_blocked"} and event.task:
789
+ task_status = {
790
+ "task_started": "running",
791
+ "task_done": "done",
792
+ "task_failed": "failed",
793
+ "task_blocked": "blocked",
794
+ }[kind]
795
+ out.plan_tasks[event.task.id] = task_status
796
+ tasks = list(out.inspector.plan.tasks)
797
+ for index, snapshot in enumerate(tasks):
798
+ if snapshot.id == event.task.id:
799
+ tasks[index] = PlanTaskSnapshot(snapshot.id, snapshot.title, task_status)
800
+ break
801
+ out.inspector = replace(
802
+ out.inspector,
803
+ plan=replace(
804
+ out.inspector.plan,
805
+ tasks=tuple(tasks),
806
+ completed_tasks=sum(item.status == "done" for item in tasks),
807
+ failure_count=sum(item.status == "failed" for item in tasks),
808
+ ),
809
+ )
810
+ if kind == "task_started":
811
+ _append(out, TranscriptItem(
812
+ id=f"team-task-{len(out.transcript)}", kind="system",
813
+ text=f"[{event.role or event.task.owner_role}/{event.task.id}] 开始:{event.task.title}",
814
+ turn_id=turn_id, trace_id=f"{turn_id}:{event.task.id}",
815
+ ))
816
+ elif kind == "task_blocked":
817
+ _append(out, TranscriptItem(
818
+ id=f"team-task-blocked-{len(out.transcript)}", kind="system",
819
+ text=f"[{event.role or event.task.owner_role}/{event.task.id}] 阻塞:{event.message or event.task.result}",
820
+ turn_id=turn_id, trace_id=f"{turn_id}:{event.task.id}",
821
+ status="cancelled",
822
+ ))
823
+ else:
824
+ next_status = "done" if kind == "task_done" else "failed"
825
+ for group_id, group in list(out.agent_groups.items()):
826
+ if group.task_id == event.task.id and group.role not in {"reviewer", "repairer"}:
827
+ out.agent_groups[group_id] = replace(group, status=next_status)
828
+ return out
829
+ if kind == "agent_started" and event.task:
830
+ group, group_id = _ensure_agent_group(out, event)
831
+ out.agent_groups[group_id] = replace(
832
+ group,
833
+ status="repairing" if event.role == "repairer" else "running",
834
+ attempt=event.attempt or group.attempt,
835
+ effective_steps=event.effective_steps or group.effective_steps,
836
+ failure_category=event.failure_category or group.failure_category,
837
+ latest_summary="Agent 已启动",
838
+ )
839
+ return out
840
+ if kind == "subtask_event" and event.agent_event:
841
+ group, group_id = _ensure_agent_group(out, event)
842
+ trace = f"{turn_id}:{group_id}"
843
+ return _reduce_agent_event_in_group(out, group_id, event.agent_event, turn_id, trace)
844
+ if kind == "agent_done" and event.task:
845
+ group, group_id = _ensure_agent_group(out, event)
846
+ out.agent_groups[group_id] = replace(
847
+ group,
848
+ status="done",
849
+ latest_summary=event.message[:240] or "Agent 已完成",
850
+ )
851
+ return out
852
+ if kind == "agent_failed" and event.task:
853
+ group, group_id = _ensure_agent_group(out, event)
854
+ out.agent_groups[group_id] = replace(
855
+ group,
856
+ status="failed",
857
+ attempt=event.attempt or group.attempt,
858
+ effective_steps=event.effective_steps or group.effective_steps,
859
+ failure_category=event.failure_category or group.failure_category,
860
+ latest_error=event.message[:240] or "Agent 执行失败",
861
+ )
862
+ return out
863
+ if kind == "artifact_produced" and event.artifact:
864
+ group, group_id = _ensure_agent_group(out, event)
865
+ summary = event.artifact.summary.replace("\n", " ")[:240]
866
+ _append_group_entry(
867
+ out,
868
+ group_id,
869
+ TranscriptItem(
870
+ id=f"team-artifact-{group.event_count}", kind="context",
871
+ text=f"Artifact {event.artifact.kind}: {summary}",
872
+ turn_id=turn_id, trace_id=f"{turn_id}:{group_id}:artifact",
873
+ collapsible=True, collapsed=True,
874
+ ),
875
+ summary=summary,
876
+ artifact=True,
877
+ )
878
+ return out
879
+ if kind == "task_review_started" and event.task:
880
+ group, group_id = _ensure_agent_group(
881
+ out, event, group_id=f"{event.team_id}:reviewer:{event.task.id}", role="reviewer"
882
+ )
883
+ out.agent_groups[group_id] = replace(
884
+ group, status="reviewing", latest_summary="正在审查任务证据"
885
+ )
886
+ return out
887
+ if kind == "task_review_done" and event.task and event.review:
888
+ detail = ";".join(event.review.findings) or "验收通过"
889
+ group, group_id = _ensure_agent_group(
890
+ out, event, group_id=f"{event.team_id}:reviewer:{event.task.id}", role="reviewer"
891
+ )
892
+ _append_group_entry(
893
+ out,
894
+ group_id,
895
+ TranscriptItem(
896
+ id=f"team-review-result-{group.event_count}", kind="context",
897
+ text=f"{event.review.verdict}: {detail[:320]}",
898
+ turn_id=turn_id, trace_id=f"{turn_id}:{group_id}:review",
899
+ collapsible=True, collapsed=True,
900
+ status="success" if event.review.verdict == "pass" else "failed",
901
+ ),
902
+ summary=f"{event.review.verdict}: {detail}",
903
+ error=detail if event.review.verdict != "pass" else "",
904
+ )
905
+ out.agent_groups[group_id] = replace(
906
+ out.agent_groups[group_id],
907
+ status=(
908
+ "done" if event.review.verdict == "pass"
909
+ else "needs_input" if event.review.verdict == "needs_input"
910
+ else "failed"
911
+ ),
912
+ )
913
+ return out
914
+ if kind in {"review_output_invalid", "review_output_retry"} and event.task:
915
+ group, group_id = _ensure_agent_group(
916
+ out, event, group_id=f"{event.team_id}:reviewer:{event.task.id}", role="reviewer"
917
+ )
918
+ summary = event.message[:240] or "Reviewer 输出异常"
919
+ out.agent_groups[group_id] = replace(
920
+ group,
921
+ status="reviewing",
922
+ failure_category=event.failure_category or group.failure_category,
923
+ latest_summary=summary,
924
+ latest_error=summary if kind == "review_output_invalid" else "",
925
+ )
926
+ return out
927
+ if kind in {"task_needs_input", "repair_scope_required"} and event.task:
928
+ status = "needs_input"
929
+ out.plan_tasks[event.task.id] = status
930
+ tasks = [
931
+ PlanTaskSnapshot(item.id, item.title, status) if item.id == event.task.id else item
932
+ for item in out.inspector.plan.tasks
933
+ ]
934
+ out.inspector = replace(
935
+ out.inspector,
936
+ plan=replace(
937
+ out.inspector.plan,
938
+ tasks=tuple(tasks),
939
+ completed_tasks=sum(item.status == "done" for item in tasks),
940
+ failure_count=sum(item.status == "failed" for item in tasks),
941
+ ),
942
+ )
943
+ out.phase = "awaiting_team_input"
944
+ out.team_input_task_id = event.task.id
945
+ out.team_input_category = event.failure_category
946
+ out.team_input_message = event.message or event.task.result
947
+ out.team_input_scope = list(event.scope_claims)
948
+ out.notification = out.team_input_message
949
+ out.notification_level = "warning"
950
+ return out
951
+ if kind == "repair_requested" and event.task:
952
+ group, group_id = _ensure_agent_group(out, event, role="repairer")
953
+ repair_attempt = 0
954
+ match = re.search(r"-repair-(\d+)$", event.task.id)
955
+ if match:
956
+ repair_attempt = int(match.group(1))
957
+ out.agent_groups[group_id] = replace(
958
+ group,
959
+ status="repairing",
960
+ repair_attempt=repair_attempt,
961
+ latest_summary=event.message[:240] or "等待 Repairer 启动",
962
+ )
963
+ return out
964
+ if kind == "team_resume_requested":
965
+ out.phase = "running"
966
+ out.pending_plan = None
967
+ if event.plan:
968
+ out.inspector = replace(
969
+ out.inspector,
970
+ plan_status="running",
971
+ plan=_plan_snapshot(event.plan, status="running", previous=out.inspector.plan),
972
+ session=replace(out.inspector.session, status="Working"),
973
+ )
974
+ _append(out, TranscriptItem(
975
+ id=f"team-resume-{len(out.transcript)}", kind="system",
976
+ text=event.message, turn_id=turn_id,
977
+ ))
978
+ out.notification = event.message
979
+ out.notification_level = "info"
980
+ return out
981
+ if kind in {"team_done", "cancelled"}:
982
+ _collapse_turn(out, turn_id, status="cancelled" if kind == "cancelled" else "done")
983
+ _finish_agent_groups(out, "cancelled" if kind == "cancelled" else "done")
984
+ out.phase = "idle"
985
+ out.pending_plan = None
986
+ out.team_input_task_id = ""
987
+ out.team_input_category = ""
988
+ out.team_input_message = ""
989
+ out.team_input_scope = []
990
+ final_status = "done" if kind == "team_done" else "cancelled"
991
+ out.inspector = replace(
992
+ out.inspector,
993
+ plan_status=final_status,
994
+ plan=_plan_snapshot(event.plan, status=final_status, previous=out.inspector.plan),
995
+ session=replace(out.inspector.session, status="Idle"),
996
+ )
997
+ out.notification = event.message
998
+ out.notification_level = "info"
999
+ return out
1000
+ if kind == "team_failed":
1001
+ _collapse_turn(out, turn_id, status="failed")
1002
+ _finish_agent_groups(out, "failed")
1003
+ out.phase = "error"
1004
+ out.pending_plan = None
1005
+ out.team_input_task_id = ""
1006
+ out.team_input_category = ""
1007
+ out.team_input_message = ""
1008
+ out.team_input_scope = []
1009
+ out.inspector = replace(
1010
+ out.inspector,
1011
+ plan_status="failed",
1012
+ plan=_plan_snapshot(event.plan, status="failed", previous=out.inspector.plan),
1013
+ session=replace(out.inspector.session, status="Error"),
1014
+ )
1015
+ out.notification = event.message
1016
+ out.notification_level = "error"
1017
+ _append(out, TranscriptItem(
1018
+ id=f"team-error-{len(out.transcript)}", kind="error", text=event.message, turn_id=turn_id,
1019
+ ))
1020
+ return out
1021
+ return out
1022
+
1023
+
1024
+ def reduce_event(state: TuiState, event: AgentEvent | PlanEvent | TeamEvent, turn_id: str | None = None) -> TuiState:
1025
+ if isinstance(event, AgentEvent):
1026
+ return reduce_agent_event(state, event, turn_id)
1027
+ if isinstance(event, TeamEvent):
1028
+ return reduce_team_event(state, event, turn_id)
1029
+ return reduce_plan_event(state, event, turn_id)