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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- 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)
|