tagword-codeagent 1.1.6__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 (173) hide show
  1. codeagent/__init__.py +10 -0
  2. codeagent/__main__.py +13 -0
  3. codeagent/cli/__init__.py +13 -0
  4. codeagent/cli/entrypoint.py +161 -0
  5. codeagent/cli/main.py +979 -0
  6. codeagent/core/__init__.py +2 -0
  7. codeagent/core/attachments.py +342 -0
  8. codeagent/core/audio_models.py +50 -0
  9. codeagent/core/bootstrap.py +178 -0
  10. codeagent/core/default_presets.py +159 -0
  11. codeagent/core/env.py +106 -0
  12. codeagent/core/folder_picker.py +151 -0
  13. codeagent/core/image_understanding.py +90 -0
  14. codeagent/core/models.py +6 -0
  15. codeagent/core/paths.py +238 -0
  16. codeagent/core/pricing.py +107 -0
  17. codeagent/core/process_ports.py +258 -0
  18. codeagent/core/seed_bridge.py +21 -0
  19. codeagent/core/settings.py +98 -0
  20. codeagent/core/speech_synth.py +330 -0
  21. codeagent/core/team_config.py +247 -0
  22. codeagent/core/team_manager.py +245 -0
  23. codeagent/core/token_counter.py +41 -0
  24. codeagent/core/tts_voices.py +103 -0
  25. codeagent/core/usage_billing.py +72 -0
  26. codeagent/core/vision_models.py +93 -0
  27. codeagent/memory/diary.py +144 -0
  28. codeagent/persona_defaults/README.md +12 -0
  29. codeagent/persona_defaults/agent.md +352 -0
  30. codeagent/persona_defaults/identity.md +29 -0
  31. codeagent/persona_defaults/memory.md +4 -0
  32. codeagent/persona_defaults/skills.md +325 -0
  33. codeagent/persona_defaults/soul.md +38 -0
  34. codeagent/persona_defaults/tools.md +41 -0
  35. codeagent/persona_defaults/user.md +18 -0
  36. codeagent/runtime/__init__.py +11 -0
  37. codeagent/runtime/agent_context.py +6 -0
  38. codeagent/runtime/agent_runtime.py +21 -0
  39. codeagent/runtime/compact_prompt.py +9 -0
  40. codeagent/runtime/compact_state.py +40 -0
  41. codeagent/runtime/llm_worker.py +132 -0
  42. codeagent/runtime/orchestrator.py +161 -0
  43. codeagent/runtime/prompt_enrichment.py +324 -0
  44. codeagent/runtime/session_manager.py +30 -0
  45. codeagent/runtime/task_split.py +38 -0
  46. codeagent/runtime/turn_loop.py +13 -0
  47. codeagent/runtime/worker.py +48 -0
  48. codeagent/server/__init__.py +505 -0
  49. codeagent/server/app_factory.py +1292 -0
  50. codeagent/server/attachment_api.py +261 -0
  51. codeagent/server/hub.py +175 -0
  52. codeagent/server/icon.png +0 -0
  53. codeagent/server/self_healing.py +143 -0
  54. codeagent/server/team_engine.py +243 -0
  55. codeagent/server/tts_api.py +51 -0
  56. codeagent/server/webui_api_app.py +3180 -0
  57. codeagent/skills/__init__.py +2 -0
  58. codeagent/skills/select.py +144 -0
  59. codeagent/tools/__init__.py +6 -0
  60. codeagent/tools/agent_tools.py +10 -0
  61. codeagent/web/__init__.py +1 -0
  62. codeagent/web/__pycache__/__init__.cpython-313.pyc +0 -0
  63. codeagent/web/__pycache__/app.cpython-313.pyc +0 -0
  64. codeagent/web/__pycache__/auth_impl.cpython-313.pyc +0 -0
  65. codeagent/web/auth_impl.py +326 -0
  66. codeagent/web/static/00-marked.min.js +6 -0
  67. codeagent/web/static/00-mermaid.min.js +2314 -0
  68. codeagent/web/static/00-purify.min.js +3 -0
  69. codeagent/web/static/00-storage.js +83 -0
  70. codeagent/web/static/00-utils.js +51 -0
  71. codeagent/web/static/01-theme.css +96 -0
  72. codeagent/web/static/01a-scroll.js +86 -0
  73. codeagent/web/static/01b-think.js +62 -0
  74. codeagent/web/static/01c-session-identity.js +81 -0
  75. codeagent/web/static/01c-session-thinking.js +61 -0
  76. codeagent/web/static/01c-session-tree-menus.js +349 -0
  77. codeagent/web/static/01c-session-tree.js +531 -0
  78. codeagent/web/static/01c-session-utilities.js +83 -0
  79. codeagent/web/static/01c-vision-model.js +132 -0
  80. codeagent/web/static/01d-image-gen-model.js +84 -0
  81. codeagent/web/static/01e-audio-model.js +97 -0
  82. codeagent/web/static/01f-compose-ui.js +88 -0
  83. codeagent/web/static/01g-tts-settings.js +128 -0
  84. codeagent/web/static/01h-music-model.js +85 -0
  85. codeagent/web/static/01i-model-stack.js +291 -0
  86. codeagent/web/static/01j-video-gen-model.js +85 -0
  87. codeagent/web/static/01k-model-stack-bootstrap.js +66 -0
  88. codeagent/web/static/01k-model-stack-state.js +209 -0
  89. codeagent/web/static/01q-inflight.js +195 -0
  90. codeagent/web/static/02-reset.css +22 -0
  91. codeagent/web/static/02-session-ui.js +201 -0
  92. codeagent/web/static/03-layout-p1.css +2 -0
  93. codeagent/web/static/03-layout-p2.css +2 -0
  94. codeagent/web/static/03a-links-stream.js +88 -0
  95. codeagent/web/static/03b-stream-text.js +119 -0
  96. codeagent/web/static/03c-render-bubble.js +520 -0
  97. codeagent/web/static/03d-agent-bubble.js +126 -0
  98. codeagent/web/static/03e-tool-trace.js +182 -0
  99. codeagent/web/static/03f-timeline.js +54 -0
  100. codeagent/web/static/03g-bubble-tts.js +292 -0
  101. codeagent/web/static/04-components-p1.css +1 -0
  102. codeagent/web/static/04-components-p2b.css +1 -0
  103. codeagent/web/static/04-components-p3.css +2 -0
  104. codeagent/web/static/04-components-p4.css +1 -0
  105. codeagent/web/static/04-components-p5.css +1 -0
  106. codeagent/web/static/04-components-p6.css +58 -0
  107. codeagent/web/static/04-components-p7.css +4 -0
  108. codeagent/web/static/04-components-p8.css +1 -0
  109. codeagent/web/static/04-components-p9.css +1 -0
  110. codeagent/web/static/04-ws-connect.js +351 -0
  111. codeagent/web/static/04-ws-helpers.js +116 -0
  112. codeagent/web/static/05-chat-p1.css +1058 -0
  113. codeagent/web/static/05-chat-p2.css +1 -0
  114. codeagent/web/static/05-chat-p3.css +295 -0
  115. codeagent/web/static/05a-session-history.js +176 -0
  116. codeagent/web/static/05b-session-list.js +114 -0
  117. codeagent/web/static/05c-dialog.js +123 -0
  118. codeagent/web/static/05d-project-create.js +172 -0
  119. codeagent/web/static/05e-project-remote.js +143 -0
  120. codeagent/web/static/06-chat.js +552 -0
  121. codeagent/web/static/06-utilities.css +181 -0
  122. codeagent/web/static/06b-attachments.js +304 -0
  123. codeagent/web/static/06c-camera.js +488 -0
  124. codeagent/web/static/07-activity-bar.css +127 -0
  125. codeagent/web/static/09-git-p1.css +1 -0
  126. codeagent/web/static/09-git-p2.css +1 -0
  127. codeagent/web/static/09a-init-bind.js +110 -0
  128. codeagent/web/static/09b-panel-toggle.js +80 -0
  129. codeagent/web/static/09c-api-refresh.js +100 -0
  130. codeagent/web/static/09d-status-log.js +80 -0
  131. codeagent/web/static/09e-commit.js +80 -0
  132. codeagent/web/static/09f-remote.js +93 -0
  133. codeagent/web/static/11a-cron-parse.js +117 -0
  134. codeagent/web/static/11b-cron-panel.js +100 -0
  135. codeagent/web/static/11c-cron-card.js +133 -0
  136. codeagent/web/static/11d-cron-form.js +105 -0
  137. codeagent/web/static/11e-env-config.js +168 -0
  138. codeagent/web/static/11f-env-llm-a-state.js +136 -0
  139. codeagent/web/static/11f-env-llm-b-form.js +409 -0
  140. codeagent/web/static/11f-env-llm-c-board.js +322 -0
  141. codeagent/web/static/11g-env-paths-git.js +973 -0
  142. codeagent/web/static/11h-env-mcp-a-state.js +161 -0
  143. codeagent/web/static/11h-env-mcp-b-form.js +229 -0
  144. codeagent/web/static/11h-env-mcp-c-board.js +565 -0
  145. codeagent/web/static/12-plugins.js +127 -0
  146. codeagent/web/static/13a-skills.js +156 -0
  147. codeagent/web/static/13a-todo-utils.js +140 -0
  148. codeagent/web/static/13b-todo-fetch.js +106 -0
  149. codeagent/web/static/13b-tools.js +127 -0
  150. codeagent/web/static/13c-todo-crud.js +92 -0
  151. codeagent/web/static/14-activity-bar.js +177 -0
  152. codeagent/web/static/15-plans.css +194 -0
  153. codeagent/web/static/15-plans.js +219 -0
  154. codeagent/web/static/16-mobile.css +612 -0
  155. codeagent/web/static/16-mobile.js +178 -0
  156. codeagent/web/static/17-preset-ux.css +287 -0
  157. codeagent/web/static/18-agent-mgr.css +616 -0
  158. codeagent/web/static/18-agent-mgr.js +204 -0
  159. codeagent/web/static/24-health-dashboard.js +68 -0
  160. codeagent/web/static/99-brand-polish.css +152 -0
  161. codeagent/web/static/body.html +824 -0
  162. codeagent/web/static/setup/setup_form.html +171 -0
  163. codeagent/web/static/setup/setup_head.html +100 -0
  164. codeagent/web/static/setup/setup_tail.html +264 -0
  165. codeagent/web/static/setup/setup_test.html +38 -0
  166. codeagent/web/web_login.html +265 -0
  167. codeagent/web/webui.html +35 -0
  168. tagword_codeagent-1.1.6.dist-info/METADATA +273 -0
  169. tagword_codeagent-1.1.6.dist-info/RECORD +173 -0
  170. tagword_codeagent-1.1.6.dist-info/WHEEL +5 -0
  171. tagword_codeagent-1.1.6.dist-info/entry_points.txt +3 -0
  172. tagword_codeagent-1.1.6.dist-info/licenses/LICENSE +21 -0
  173. tagword_codeagent-1.1.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1292 @@
1
+ """Starlette HTTP / WebSocket application factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import logging
9
+ import os
10
+ from collections import defaultdict
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ def create_app():
17
+ import logging as logging_mod
18
+
19
+ from codeagent.core import env as ca_env
20
+
21
+ if not logging_mod.getLogger().handlers:
22
+ logging_mod.basicConfig(
23
+ level=ca_env.pick_default("INFO", ca_env.LOG_LEVEL).upper(),
24
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
25
+ )
26
+
27
+ from contextlib import asynccontextmanager
28
+
29
+ from starlette.applications import Starlette
30
+ from starlette.middleware import Middleware
31
+ from starlette.requests import Request
32
+ from starlette.responses import HTMLResponse, JSONResponse, Response
33
+ from starlette.routing import Mount, Route, WebSocketRoute
34
+ from starlette.websockets import WebSocket
35
+
36
+ from codeagent.server.webui_api_app import build_webui_api_app
37
+ from codeagent.core.bootstrap import bootstrap_codeagent_runtime
38
+
39
+ bootstrap_codeagent_runtime()
40
+
41
+ try:
42
+ from seed.core.config_plane import ensure_default_config_files
43
+ from seed.core.config_plane import project_root as _pr
44
+
45
+ ensure_default_config_files(_pr())
46
+ except Exception:
47
+ logger.exception("bootstrap config failed")
48
+
49
+ from codeagent.web.auth_impl import WebUIAuthMiddleware, get_webui_token
50
+ from seed.core.config_plane import project_root as project_root_fn
51
+
52
+ from . import _memkey, get_app_html, get_setup_html
53
+
54
+ project_root = project_root_fn()
55
+
56
+ # ── WebSocket 广播基础设施 ──
57
+ ws_by_session: dict[str, set] = defaultdict(set)
58
+
59
+ async def _broadcast_session_event(aid: str, sid: str, payload: dict) -> None:
60
+ mkey = _memkey(aid, sid)
61
+ msg = dict(payload)
62
+ msg.setdefault("session_id", sid)
63
+ msg.setdefault("agent_id", aid)
64
+ for ws in list(ws_by_session.get(mkey, ())):
65
+ with contextlib.suppress(Exception):
66
+ await ws.send_json(msg)
67
+
68
+ async def homepage(request: Request) -> HTMLResponse:
69
+ from starlette.responses import RedirectResponse, Response
70
+
71
+ setup_marker = project_root / "config" / "setup.json"
72
+ if not setup_marker.is_file():
73
+ setup_marker = project_root / "config" / "codeagent.setup.json"
74
+ done = False
75
+ if setup_marker.is_file():
76
+ try:
77
+ j = json.loads(setup_marker.read_text(encoding="utf-8") or "{}")
78
+ done = bool(j.get("done"))
79
+ except Exception:
80
+ done = False
81
+ if not done:
82
+ return RedirectResponse("/setup", status_code=302)
83
+ html, etag = get_app_html()
84
+ if_none = request.headers.get("if-none-match")
85
+ if if_none and if_none == etag:
86
+ return Response(status_code=304, headers={"ETag": etag})
87
+ return HTMLResponse(
88
+ html,
89
+ headers={
90
+ "ETag": etag,
91
+ "Cache-Control": "private, max-age=0, must-revalidate",
92
+ },
93
+ )
94
+
95
+ async def setup_page(_: Request) -> HTMLResponse:
96
+ return HTMLResponse(get_setup_html())
97
+
98
+ async def api_chat(request: Request) -> JSONResponse:
99
+ from seed.core.agent_context import set_active_llm_session
100
+ from seed.core.agent_runtime import (
101
+ apply_context_usage_metadata,
102
+ build_api_projection_messages,
103
+ build_context_usage_from_run,
104
+ build_context_usage_snapshot,
105
+ maybe_compact_context_messages,
106
+ merge_llm_tail_into_full,
107
+ persist_compact_summary,
108
+ resolve_compact_trigger_tokens,
109
+ run_llm_tool_loop,
110
+ strip_ephemeral_message_fields,
111
+ )
112
+ from codeagent.runtime.prompt_enrichment import (
113
+ build_skills_suffix,
114
+ fresh_system_prompt,
115
+ get_cached_system_prompt,
116
+ record_chat_turn_diary,
117
+ )
118
+ from seed.core.llm_exec import LLMError
119
+ from seed.core.llm_presets import llm_executor_from_resolved, resolve_preset
120
+ from seed.core.llm_sess import load_or_create_chat_session, merge_fresh_system, persist_chat_session
121
+ from seed.core.mem_bridge import finalize_episodic_for_llm
122
+ from seed.core.chat_events import set_chat_cancel_checker, reset_chat_cancel_checker
123
+ from seed.integrations.session_title import maybe_llm_refresh_session_title
124
+ from datetime import datetime, timezone
125
+ import threading
126
+
127
+ from . import SESSIONS, _DEFAULT_AUTO_CONTINUE_NUDGE, _running_add, _running_discard, _running_contains, tools_for_agent, ACTIVE_CHAT_CANCELS, PENDING_INJECTIONS
128
+
129
+ try:
130
+ body = await request.json()
131
+ except Exception:
132
+ return JSONResponse({"detail": "invalid json"}, status_code=400)
133
+
134
+ session_id = str(body.get("session_id") or "web-chat").strip() or "web-chat"
135
+ from codeagent.core import env as ca_env
136
+ from seed.core.env_access import (
137
+ CHAT_AUTO_CONTINUE_MAX_SEGMENTS,
138
+ CHAT_AUTO_CONTINUE_ON_LIMIT,
139
+ MAX_TOOL_ROUNDS,
140
+ env_truthy,
141
+ pick_int,
142
+ )
143
+
144
+ _run_started = False
145
+ _run_was_cancelled = False
146
+ _run_mkey = ""
147
+ _cancel_token = None
148
+
149
+ try:
150
+ agent_id = str(body.get("agent_id") or ca_env.default_agent_id()).strip() or "default"
151
+ body["agent_id"] = agent_id
152
+ body["session_id"] = session_id
153
+ _run_mkey = _memkey(agent_id, session_id)
154
+
155
+ llm_id = str(body.get("llm_id") or "").strip()
156
+ vision_llm_id = str(body.get("vision_llm_id") or "").strip()
157
+ image_gen_llm_id = str(body.get("image_gen_llm_id") or "").strip()
158
+ audio_llm_id = str(body.get("audio_llm_id") or "").strip()
159
+ music_llm_id = str(body.get("music_llm_id") or "").strip()
160
+ video_gen_llm_id = str(body.get("video_gen_llm_id") or "").strip()
161
+
162
+ from codeagent.server.attachment_api import parse_chat_multimodal_body
163
+ from codeagent.core.attachments import content_text_for_skills
164
+ from codeagent.core.audio_models import preset_supports_audio_id
165
+ from codeagent.core.vision_models import resolve_main_llm
166
+
167
+ try:
168
+ user_msg, _att_ids, has_image, _extra = parse_chat_multimodal_body(body)
169
+ except ValueError as e:
170
+ return JSONResponse({"detail": str(e)}, status_code=400)
171
+
172
+ has_video = bool(_extra.get("has_video"))
173
+ has_audio = bool(_extra.get("has_audio"))
174
+
175
+ message = str(user_msg.get("content") or "").strip()
176
+ from codeagent.core.image_understanding import (
177
+ MCP_VISION_SENTINEL,
178
+ image_attachment_allowed,
179
+ video_attachment_allowed,
180
+ )
181
+
182
+ if has_image and not image_attachment_allowed(vision_llm_id):
183
+ return JSONResponse(
184
+ {
185
+ "detail": (
186
+ "发送图片需要 supports_vision 的多模态预设,"
187
+ "或已配置 MiniMax MCP(understand_image)"
188
+ )
189
+ },
190
+ status_code=400,
191
+ )
192
+ if has_video and not video_attachment_allowed(vision_llm_id):
193
+ return JSONResponse(
194
+ {"detail": "发送视频需要选择支持多模态的模型 (vision_llm_id)"},
195
+ status_code=400,
196
+ )
197
+ if has_audio:
198
+ if not audio_llm_id or not preset_supports_audio_id(audio_llm_id):
199
+ return JSONResponse(
200
+ {"detail": "需要选择支持音频转写的模型 (audio_llm_id)"},
201
+ status_code=400,
202
+ )
203
+
204
+ project_id = str(body.get("project_id") or "").strip()
205
+ if project_id == "__unassigned__":
206
+ project_id = ""
207
+
208
+ _forced_mcp_skill_suffix = ""
209
+ if message.startswith("/skill") or (message.startswith("/") and ":" in message.split(None, 1)[0]):
210
+ from codeagent.server.webui_api_app import _parse_mcp_skill_slash_command
211
+ from seed_tools.mcp import mcp_skill_handler
212
+
213
+ try:
214
+ _mcp_sid, _mcp_skill, _mcp_args = _parse_mcp_skill_slash_command(message)
215
+ except ValueError as e:
216
+ return JSONResponse({"detail": str(e)}, status_code=400)
217
+ _mcp_skill_text = await asyncio.to_thread(
218
+ mcp_skill_handler,
219
+ _mcp_sid,
220
+ _mcp_skill,
221
+ _mcp_args,
222
+ )
223
+ if len(_mcp_skill_text) > 30000:
224
+ _mcp_skill_text = _mcp_skill_text[:30000] + "\n\n[已截断:MCP skill 输出超过 30000 字符]"
225
+ _forced_mcp_skill_suffix = (
226
+ "\n\n## MCP Skill\n\n"
227
+ f"本轮已显式加载 MCP skill `{_mcp_sid}/{_mcp_skill}`。"
228
+ "请按该 skill 的内容处理用户请求。\n\n"
229
+ f"{_mcp_skill_text}\n"
230
+ )
231
+ message = f"请使用 MCP skill `{_mcp_sid}/{_mcp_skill}` 处理本轮请求。"
232
+ user_msg["content"] = message
233
+
234
+ # ── 同 session 并发注入 ──
235
+ # 仅当 session 确实在跑时才入队;否则清掉残留 queue 并正常发起新 run
236
+ if not _running_contains(_run_mkey):
237
+ PENDING_INJECTIONS.pop(_run_mkey, None)
238
+ existing_queue = PENDING_INJECTIONS.get(_run_mkey)
239
+ if existing_queue is not None and _running_contains(_run_mkey):
240
+ from datetime import datetime, timezone
241
+ queued = dict(user_msg)
242
+ queued.setdefault("ts", datetime.now(timezone.utc).isoformat())
243
+ existing_queue.append(queued)
244
+ with contextlib.suppress(Exception):
245
+ await _broadcast_session_event(agent_id, session_id, {
246
+ "type": "message_queued",
247
+ })
248
+ return JSONResponse(
249
+ {"queued": True, "session_id": session_id, "agent_id": agent_id},
250
+ status_code=202,
251
+ )
252
+ PENDING_INJECTIONS[_run_mkey] = []
253
+ _running_add(_run_mkey)
254
+ _run_started = True
255
+ # 停止信号必须在 _running_add 后立即注册,消除
256
+ # _running_sessions 与 ACTIVE_CHAT_CANCELS 之间的时间窗口
257
+ try:
258
+ _cancel_event = ACTIVE_CHAT_CANCELS.setdefault(_run_mkey, threading.Event())
259
+ _cancel_token = set_chat_cancel_checker(lambda: _cancel_event.is_set())
260
+ except Exception:
261
+ pass
262
+
263
+ mkey = _memkey(agent_id, session_id)
264
+ try:
265
+ from codeagent.core.paths import ensure_agent_scaffold
266
+
267
+ ensure_agent_scaffold(agent_id)
268
+ except Exception:
269
+ pass
270
+
271
+ if mkey in SESSIONS:
272
+ chat_sess = SESSIONS[mkey]
273
+ else:
274
+ chat_sess = load_or_create_chat_session(session_id, agent_id, project_id or None)
275
+ SESSIONS[mkey] = chat_sess
276
+
277
+ fresh_sys = get_cached_system_prompt(chat_sess, agent_id=agent_id)
278
+ if not chat_sess.messages:
279
+ chat_sess.messages = [{"role": "system", "content": fresh_sys}]
280
+ else:
281
+ chat_sess.messages[:] = merge_fresh_system(chat_sess.messages, fresh_sys)
282
+
283
+ if isinstance(chat_sess.metadata, dict) and chat_sess.metadata.get("cursor"):
284
+ chat_sess.metadata.pop("cursor", None)
285
+
286
+ if body.get("clear_vision_context") and isinstance(chat_sess.metadata, dict):
287
+ chat_sess.metadata.pop("vision_context", None)
288
+
289
+ chat_sess.messages.append(user_msg)
290
+
291
+ max_rounds = pick_int(24, *MAX_TOOL_ROUNDS)
292
+
293
+ llm = resolve_main_llm(llm_id or None)
294
+ _raw_thinking = body.get("enable_thinking")
295
+ if _raw_thinking is None:
296
+ chat_enable_thinking = None
297
+ else:
298
+ chat_enable_thinking = bool(_raw_thinking)
299
+ _raw_effort = str(body.get("reasoning_effort") or "").strip().lower()
300
+ chat_reasoning_effort = _raw_effort if _raw_effort else None
301
+ reg, exe = tools_for_agent(agent_id)
302
+ from seed.core.agent_context import (
303
+ clear_active_project_workspace,
304
+ set_active_agent_id,
305
+ set_active_project_episodic,
306
+ set_active_project_workspace,
307
+ set_active_vision_preset,
308
+ set_active_image_gen_preset,
309
+ set_active_audio_preset,
310
+ set_active_music_preset,
311
+ set_active_video_gen_preset,
312
+ )
313
+
314
+ set_active_llm_session(f"{agent_id}::{session_id}")
315
+ set_active_agent_id(agent_id)
316
+ _vision_preset = vision_llm_id or None
317
+ if _vision_preset == MCP_VISION_SENTINEL:
318
+ _vision_preset = None
319
+ set_active_vision_preset(_vision_preset)
320
+ set_active_image_gen_preset(image_gen_llm_id or None)
321
+ set_active_audio_preset(audio_llm_id or None)
322
+ set_active_music_preset(music_llm_id or None)
323
+ set_active_video_gen_preset(video_gen_llm_id or None)
324
+ from seed.core.proj_reg import get_project, resolve_project_path
325
+ if project_id:
326
+ set_active_project_episodic(True, project_id)
327
+ else:
328
+ set_active_project_episodic(False)
329
+ set_active_project_workspace(None)
330
+
331
+ # backward-compat: seed 1.0.0 没有 cursor 参数
332
+ import inspect as _inspect
333
+ _sig = _inspect.signature(build_api_projection_messages)
334
+ _cursor_supported = "cursor" in _sig.parameters
335
+ _kwargs = {}
336
+ if _cursor_supported:
337
+ _kwargs["cursor"] = (
338
+ chat_sess.metadata.get("cursor")
339
+ if isinstance(chat_sess.metadata, dict) else None
340
+ )
341
+ # ── 工作目录注入 ──
342
+ _work_dir_suffix = ""
343
+ if project_id:
344
+ _wd = resolve_project_path(agent_id, project_id)
345
+ if _wd:
346
+ set_active_project_workspace(_wd)
347
+ else:
348
+ set_active_project_workspace(None)
349
+ _proj = get_project(agent_id, project_id)
350
+ if _proj and _wd:
351
+ _work_dir_suffix = (
352
+ "\n\n"
353
+ "## Workspace\n\n"
354
+ f"当前工作目录:`{_wd}`\n\n"
355
+ "执行 shell 命令时,工作目录默认为此路径。"
356
+ "除非有充分理由,否则请在此目录下操作,不要 cd 到其他目录。\n"
357
+ )
358
+
359
+ _skills_suffix = build_skills_suffix(
360
+ agent_id,
361
+ user_text=content_text_for_skills(message),
362
+ workspace_suffix=_work_dir_suffix,
363
+ )
364
+ if _forced_mcp_skill_suffix:
365
+ _skills_suffix = (_skills_suffix or "").rstrip() + _forced_mcp_skill_suffix
366
+ api_msgs = build_api_projection_messages(
367
+ chat_sess.messages,
368
+ skills_suffix=_skills_suffix,
369
+ **_kwargs,
370
+ )
371
+ _api_pt = None
372
+ _prev_cu = None
373
+ if isinstance(chat_sess.metadata, dict):
374
+ _prev_cu = chat_sess.metadata.get("context_usage")
375
+ if isinstance(_prev_cu, dict):
376
+ _api_pt = resolve_compact_trigger_tokens(persisted=_prev_cu) or None
377
+ if not isinstance(chat_sess.metadata, dict):
378
+ chat_sess.metadata = {}
379
+ # episodic 在 compact 前注入,使 compact 判断与 LLM 实际输入一致
380
+ await asyncio.to_thread(
381
+ finalize_episodic_for_llm,
382
+ api_msgs,
383
+ chat_sess.metadata,
384
+ agent_id=agent_id,
385
+ session_id=session_id,
386
+ project_id=project_id or None,
387
+ compact_happened=False,
388
+ )
389
+ compact_result = await asyncio.to_thread(
390
+ maybe_compact_context_messages,
391
+ api_msgs,
392
+ llm,
393
+ api_prompt_tokens=_api_pt,
394
+ persisted_context_usage=_prev_cu if isinstance(_prev_cu, dict) else None,
395
+ )
396
+ persist_compact_summary(chat_sess.messages, compact_result)
397
+ strip_ephemeral_message_fields(api_msgs)
398
+
399
+ from codeagent.runtime.compact_state import inject_state_into_system
400
+
401
+ if compact_result:
402
+ await asyncio.to_thread(
403
+ inject_state_into_system,
404
+ api_msgs,
405
+ agent_id,
406
+ )
407
+ await asyncio.to_thread(
408
+ finalize_episodic_for_llm,
409
+ api_msgs,
410
+ chat_sess.metadata,
411
+ agent_id=agent_id,
412
+ session_id=session_id,
413
+ project_id=project_id or None,
414
+ compact_happened=True,
415
+ )
416
+
417
+ def _on_mid_compact(compact_result: dict | None) -> None:
418
+ if not compact_result:
419
+ return
420
+ persist_compact_summary(chat_sess.messages, compact_result)
421
+ inject_state_into_system(api_msgs, agent_id)
422
+
423
+ # ── WS 广播回调 ──
424
+ from seed.core.chat_events import reset_chat_event_emitter, set_chat_event_emitter
425
+
426
+ main_loop = asyncio.get_event_loop()
427
+ emitter_token = None
428
+
429
+ def _emit_progress_from_worker(evt: dict) -> None:
430
+ with contextlib.suppress(Exception):
431
+ asyncio.run_coroutine_threadsafe(
432
+ _broadcast_session_event(agent_id, session_id, evt), main_loop
433
+ )
434
+
435
+ emitter_token = set_chat_event_emitter(_emit_progress_from_worker)
436
+
437
+ with contextlib.suppress(Exception):
438
+ asyncio.run_coroutine_threadsafe(
439
+ _broadcast_session_event(agent_id, session_id, {"type": "run_started"}),
440
+ main_loop,
441
+ )
442
+
443
+ n_before = len(api_msgs)
444
+ n_before_ref = [n_before]
445
+ _last_trace_len = [0]
446
+ _stream_placeholder_created = [False]
447
+
448
+ def _on_tool_round_persist(tt: list, tu: list) -> None:
449
+ try:
450
+ new_tail = [
451
+ message
452
+ for message in api_msgs[n_before_ref[0]:]
453
+ if not (
454
+ isinstance(message, dict)
455
+ and (
456
+ message.get("_auto_continue_nudge")
457
+ or (
458
+ message.get("role") == "user"
459
+ and isinstance(message.get("content"), str)
460
+ and (
461
+ message["content"].startswith(_DEFAULT_AUTO_CONTINUE_NUDGE[:20])
462
+ or message["content"].startswith("上一段连续在")
463
+ )
464
+ )
465
+ )
466
+ )
467
+ ]
468
+ if new_tail:
469
+ rest = new_tail
470
+ if rest:
471
+ persisted_rest = []
472
+ for message in rest:
473
+ if not isinstance(message, dict):
474
+ persisted_rest.append(message)
475
+ continue
476
+ copied = dict(message)
477
+ strip_ephemeral_message_fields([copied])
478
+ if "ts" not in copied or not copied.get("ts"):
479
+ copied["ts"] = datetime.now(timezone.utc).isoformat()
480
+ persisted_rest.append(copied)
481
+ chat_sess.messages.extend(persisted_rest)
482
+ n_before_ref[0] = len(api_msgs)
483
+ _stream_placeholder_created[0] = False
484
+ if tt:
485
+ new_trace = tt[_last_trace_len[0]:]
486
+ if new_trace:
487
+ for _i in range(len(chat_sess.messages) - 1, -1, -1):
488
+ mm = chat_sess.messages[_i]
489
+ if isinstance(mm, dict) and mm.get("role") == "assistant":
490
+ existing = mm.get("tool_trace")
491
+ if not isinstance(existing, list):
492
+ existing = []
493
+ chat_sess.messages[_i] = {
494
+ **mm,
495
+ "tool_trace": existing + new_trace,
496
+ "tools_used": list(tu) if tu else mm.get("tools_used", []),
497
+ }
498
+ break
499
+ _last_trace_len[0] = len(tt)
500
+ loop = asyncio.get_running_loop()
501
+ loop.run_in_executor(None, persist_chat_session, chat_sess, agent_id)
502
+ except Exception:
503
+ pass
504
+
505
+ def _on_text_delta(full_text: str) -> None:
506
+ try:
507
+ if not _stream_placeholder_created[0]:
508
+ chat_sess.messages.append({
509
+ "role": "assistant",
510
+ "content": "",
511
+ "_streaming": True,
512
+ "ts": datetime.now(timezone.utc).isoformat(),
513
+ })
514
+ _stream_placeholder_created[0] = True
515
+ if chat_sess.messages and chat_sess.messages[-1].get("_streaming"):
516
+ chat_sess.messages[-1]["content"] = full_text
517
+ with contextlib.suppress(Exception):
518
+ asyncio.run_coroutine_threadsafe(
519
+ _broadcast_session_event(agent_id, session_id, {
520
+ "type": "text_delta",
521
+ "session_id": session_id,
522
+ "agent_id": agent_id,
523
+ "text": full_text,
524
+ }), main_loop
525
+ )
526
+ except Exception:
527
+ pass
528
+
529
+ def _on_reasoning_delta(full_reasoning: str) -> None:
530
+ try:
531
+ if (_stream_placeholder_created[0]
532
+ and chat_sess.messages
533
+ and chat_sess.messages[-1].get("_streaming")):
534
+ chat_sess.messages[-1]["reasoning_content"] = full_reasoning
535
+ except Exception:
536
+ pass
537
+
538
+ # ── 运行时消息注入:在每轮工具循环开始前捡起队列中的新消息 ──
539
+ def _drain_pending_injections() -> list:
540
+ msgs = list(PENDING_INJECTIONS.get(_run_mkey, []))
541
+ if msgs:
542
+ PENDING_INJECTIONS[_run_mkey].clear()
543
+ return msgs
544
+
545
+ try:
546
+ # ── auto_continue 配置 ──
547
+ _ac_on = env_truthy(*CHAT_AUTO_CONTINUE_ON_LIMIT, default="0")
548
+ _max_seg = pick_int(4, *CHAT_AUTO_CONTINUE_MAX_SEGMENTS)
549
+ _max_seg = max(1, min(_max_seg, 50))
550
+
551
+ if _ac_on:
552
+ from . import _auto_continue_nudge
553
+
554
+ _segment = 0
555
+ _all_trace: list[dict] = []
556
+ _all_used: list[str] = []
557
+ _final_reply = ""
558
+ _final_meta: dict = {}
559
+ _acc_usage: dict = {}
560
+ _seg_last_meta: dict = {}
561
+ _peak_across_segments = 0
562
+ from seed.core.usage_accumulator import (
563
+ begin_usage_accumulation,
564
+ end_usage_accumulation,
565
+ reset_usage_accumulation,
566
+ )
567
+
568
+ while _segment < _max_seg:
569
+ _seg_token = begin_usage_accumulation()
570
+ try:
571
+ _seg_reply, _seg_last_meta, _seg_used, _seg_trace, _seg_meta = await run_llm_tool_loop(
572
+ llm, exe,
573
+ messages=api_msgs,
574
+ registry=reg,
575
+ max_tool_rounds=max_rounds,
576
+ on_round_persist=_on_tool_round_persist,
577
+ on_text_delta=_on_text_delta,
578
+ on_reasoning_delta=_on_reasoning_delta,
579
+ on_check_pending_messages=_drain_pending_injections,
580
+ enable_thinking=chat_enable_thinking,
581
+ reasoning_effort=chat_reasoning_effort,
582
+ on_compact=_on_mid_compact,
583
+ )
584
+ except Exception:
585
+ reset_usage_accumulation(_seg_token)
586
+ raise
587
+ else:
588
+ _seg_usage = end_usage_accumulation(_seg_token)
589
+
590
+ _all_trace.extend(_seg_trace or [])
591
+ for _t in (_seg_used or []):
592
+ if _t not in _all_used:
593
+ _all_used.append(_t)
594
+ if _seg_reply:
595
+ _final_reply = _seg_reply
596
+ _final_meta = _seg_meta or {}
597
+ _peak_across_segments = max(
598
+ _peak_across_segments,
599
+ int((_seg_meta or {}).get("peak_prompt_tokens") or 0),
600
+ )
601
+ # 累加每个 segment 的 usage
602
+ if _seg_usage:
603
+ for _k in ("prompt_tokens", "completion_tokens", "total_tokens",
604
+ "prompt_cache_hit_tokens", "prompt_cache_miss_tokens"):
605
+ _v = _seg_usage.get(_k, 0)
606
+ if isinstance(_v, (int, float)):
607
+ _acc_usage[_k] = _acc_usage.get(_k, 0) + int(_v)
608
+
609
+ if _seg_meta.get("stopped_reason") != "max_tool_rounds":
610
+ if _seg_meta.get("stopped_reason") == "cancelled":
611
+ _run_was_cancelled = True
612
+ break
613
+
614
+ _segment += 1
615
+ if _segment >= _max_seg:
616
+ break
617
+
618
+ # Reset per-segment markers for next loop
619
+ _last_trace_len[0] = 0
620
+ _seg_peak = int((_seg_meta or {}).get("peak_prompt_tokens") or 0)
621
+ _seg_compact = await asyncio.to_thread(
622
+ maybe_compact_context_messages,
623
+ api_msgs,
624
+ llm,
625
+ api_prompt_tokens=_seg_peak or None,
626
+ loop_meta=_seg_meta if isinstance(_seg_meta, dict) else None,
627
+ )
628
+ persist_compact_summary(chat_sess.messages, _seg_compact)
629
+ if _seg_compact:
630
+ strip_ephemeral_message_fields(api_msgs)
631
+ await asyncio.to_thread(
632
+ inject_state_into_system,
633
+ api_msgs,
634
+ agent_id,
635
+ )
636
+ await asyncio.to_thread(
637
+ finalize_episodic_for_llm,
638
+ api_msgs,
639
+ chat_sess.metadata,
640
+ agent_id=agent_id,
641
+ session_id=session_id,
642
+ project_id=project_id or None,
643
+ compact_happened=True,
644
+ )
645
+ # Inject nudge as user message to continue
646
+ # n_before_ref deliberately NOT reset — nudge must be
647
+ # picked up by _on_tool_round_persist in the next segment
648
+ _nudge = _auto_continue_nudge(_seg_meta)
649
+ api_msgs.append(
650
+ {
651
+ "role": "user",
652
+ "content": _nudge,
653
+ "_auto_continue_nudge": True,
654
+ }
655
+ )
656
+
657
+ reply = _final_reply
658
+ tools_used = _all_used
659
+ tool_trace = _all_trace
660
+ _loop_meta = _final_meta
661
+ _last_meta = _seg_last_meta or {}
662
+ if _peak_across_segments > 0:
663
+ _loop_meta["peak_prompt_tokens"] = max(
664
+ int(_loop_meta.get("peak_prompt_tokens") or 0),
665
+ _peak_across_segments,
666
+ )
667
+ if _acc_usage:
668
+ _loop_meta["usage_summary"] = _acc_usage
669
+ else:
670
+ from seed.core.usage_accumulator import (
671
+ begin_usage_accumulation,
672
+ end_usage_accumulation,
673
+ reset_usage_accumulation,
674
+ )
675
+
676
+ _norm_token = begin_usage_accumulation()
677
+ try:
678
+ reply, _meta, tools_used, tool_trace, _loop_meta = await run_llm_tool_loop(
679
+ llm, exe,
680
+ messages=api_msgs,
681
+ registry=reg,
682
+ max_tool_rounds=max_rounds,
683
+ on_round_persist=_on_tool_round_persist,
684
+ on_text_delta=_on_text_delta,
685
+ on_reasoning_delta=_on_reasoning_delta,
686
+ on_check_pending_messages=_drain_pending_injections,
687
+ enable_thinking=chat_enable_thinking,
688
+ reasoning_effort=chat_reasoning_effort,
689
+ on_compact=_on_mid_compact,
690
+ )
691
+ _last_meta = _meta or {}
692
+ except Exception:
693
+ reset_usage_accumulation(_norm_token)
694
+ raise
695
+ else:
696
+ _norm_usage = end_usage_accumulation(_norm_token)
697
+ if _norm_usage:
698
+ _loop_meta["usage_summary"] = _norm_usage
699
+
700
+ # ── WS: 广播 text_done + reply(两分支共享) ──
701
+ trace_out_ws = [
702
+ {"tool": t.get("name", ""), "arguments": t.get("arguments", ""), "result": t.get("result", "")}
703
+ for t in (tool_trace or [])
704
+ ]
705
+ with contextlib.suppress(Exception):
706
+ asyncio.run_coroutine_threadsafe(
707
+ _broadcast_session_event(agent_id, session_id, {
708
+ "type": "text_done",
709
+ "session_id": session_id,
710
+ "agent_id": agent_id,
711
+ "text": reply or "",
712
+ "tool_trace": trace_out_ws,
713
+ "tools_used": tools_used or [],
714
+ }), main_loop
715
+ )
716
+ with contextlib.suppress(Exception):
717
+ asyncio.run_coroutine_threadsafe(
718
+ _broadcast_session_event(agent_id, session_id, {
719
+ "type": "reply",
720
+ "session_id": session_id,
721
+ "agent_id": agent_id,
722
+ "text": reply or "",
723
+ "tool_trace": trace_out_ws,
724
+ "tools_used": tools_used or [],
725
+ }), main_loop
726
+ )
727
+
728
+ tail = merge_llm_tail_into_full(chat_sess.messages, api_msgs, n_before_ref[0])
729
+ _model_name = getattr(llm, "model", "")
730
+ _ctx: dict[str, Any] = {}
731
+ # 先写入 context_usage(含 peak),避免后续异步 persist 落盘旧值
732
+ try:
733
+ _proj_ctx = build_api_projection_messages(
734
+ chat_sess.messages,
735
+ skills_suffix=_skills_suffix,
736
+ **_kwargs,
737
+ )
738
+ await asyncio.to_thread(
739
+ finalize_episodic_for_llm,
740
+ _proj_ctx,
741
+ chat_sess.metadata,
742
+ agent_id=agent_id,
743
+ session_id=session_id,
744
+ project_id=project_id or None,
745
+ compact_happened=False,
746
+ )
747
+ _ctx = build_context_usage_from_run(
748
+ _proj_ctx,
749
+ loop_meta=_loop_meta,
750
+ last_meta=_last_meta if isinstance(_last_meta, dict) else None,
751
+ model_name=_model_name,
752
+ )
753
+ except Exception:
754
+ logger.exception("build context_usage snapshot failed")
755
+ with contextlib.suppress(Exception):
756
+ _ctx = build_context_usage_from_run(
757
+ api_msgs,
758
+ loop_meta=_loop_meta,
759
+ last_meta=_last_meta if isinstance(_last_meta, dict) else None,
760
+ model_name=_model_name,
761
+ )
762
+ try:
763
+ if isinstance(_ctx, dict) and _ctx:
764
+ if not isinstance(chat_sess.metadata, dict):
765
+ chat_sess.metadata = {}
766
+ apply_context_usage_metadata(
767
+ chat_sess.metadata,
768
+ _ctx,
769
+ updated_at=chat_sess.updated_at or "",
770
+ )
771
+ persist_chat_session(chat_sess, agent_id)
772
+ except Exception:
773
+ logger.exception("persist context_usage failed")
774
+ try:
775
+ loop = asyncio.get_running_loop()
776
+ loop.run_in_executor(None, persist_chat_session, chat_sess, agent_id)
777
+ except Exception:
778
+ logger.exception("persist_chat_session failed")
779
+ try:
780
+ record_chat_turn_diary(
781
+ agent_id,
782
+ user_text=message,
783
+ reply=reply or "",
784
+ tools_used=tools_used,
785
+ project_id=project_id or None,
786
+ )
787
+ except Exception:
788
+ pass
789
+ try:
790
+ # auto-continue 注入的 nudge 消息不应参与标题生成
791
+ # (否则标题会变成"继续完成未完成事项"而非用户原始意图)
792
+ # 默认 nudge 以 "请继续完成未完成事项" 开头,
793
+ # domain playbook 以 "上一段连续在" 开头
794
+ _nudge_filtered: list[int] = []
795
+ for _i, _m in enumerate(chat_sess.messages):
796
+ if (
797
+ isinstance(_m, dict)
798
+ and _m.get("role") == "user"
799
+ and isinstance(_m.get("content"), str)
800
+ and (
801
+ _m["content"].startswith(_DEFAULT_AUTO_CONTINUE_NUDGE[:20])
802
+ or _m["content"].startswith("上一段连续在")
803
+ )
804
+ ):
805
+ _nudge_filtered.append(_i)
806
+ for _i in reversed(_nudge_filtered):
807
+ chat_sess.messages.pop(_i)
808
+ maybe_llm_refresh_session_title(llm, chat_sess)
809
+ except Exception:
810
+ logger.exception("session title refresh failed")
811
+ trace_out = [
812
+ {
813
+ # WebUI 读 row.tool;run_llm_tool_loop 写 row.name — 两边对齐
814
+ "tool": t.get("name", ""),
815
+ "name": t.get("name", ""),
816
+ "arguments": t.get("arguments", ""),
817
+ "result": t.get("result", ""),
818
+ }
819
+ for t in tool_trace
820
+ ]
821
+ # ── Token 用量精确计算(基于 API 返回的 usage) ──
822
+ _acc = {}
823
+ _cost_info = {}
824
+ _total_cost_info = {}
825
+ _api_usage = {}
826
+ try:
827
+ from codeagent.core.usage_billing import merge_accumulated_usage
828
+
829
+ _api_usage = dict(_loop_meta.get("usage_summary", {}) or {})
830
+ _model_name = getattr(llm, "model", "") or _model_name
831
+ _prev = chat_sess.metadata.get("accumulated_usage", {}) or {}
832
+ _acc, _cost_info, _total_cost_info = merge_accumulated_usage(
833
+ _prev, _model_name, _api_usage
834
+ )
835
+ chat_sess.metadata["accumulated_usage"] = _acc
836
+ from seed.core.agent_runtime import _get_compact_min_tokens as _get_cmt
837
+ _ctx_pt = int(_ctx.get("prompt_tokens") or 0)
838
+ _ws_evt = {
839
+ "type": "context_usage",
840
+ "session_id": session_id,
841
+ "agent_id": agent_id,
842
+ "prompt_tokens": _ctx_pt,
843
+ "peak_prompt_tokens": int(_ctx.get("peak_prompt_tokens") or _ctx_pt),
844
+ "context_limit": _ctx.get("context_limit", 0),
845
+ "compact_min_tokens": _get_cmt(),
846
+ "message_count": _ctx.get("message_count", 0),
847
+ "token_usage": _api_usage,
848
+ "accumulated_usage": _acc,
849
+ "cost": _cost_info,
850
+ "accumulated_cost": _total_cost_info,
851
+ "model": _model_name,
852
+ }
853
+ asyncio.run_coroutine_threadsafe(
854
+ _broadcast_session_event(agent_id, session_id, _ws_evt), main_loop
855
+ )
856
+ except Exception:
857
+ logger.exception("merge accumulated usage failed")
858
+ _api_usage = {}
859
+ _cost_info = {}
860
+ try:
861
+ persist_chat_session(chat_sess, agent_id)
862
+ except Exception:
863
+ logger.exception("persist session after billing failed")
864
+
865
+ return JSONResponse(
866
+ {
867
+ "reply": reply,
868
+ "session_id": session_id,
869
+ "tools_used": tools_used,
870
+ "tool_trace": trace_out,
871
+ "token_usage": _api_usage,
872
+ "context": _ctx,
873
+ "cost": _cost_info,
874
+ "accumulated_cost": _total_cost_info,
875
+ "accumulated_usage": _acc,
876
+ "model": _model_name,
877
+ "cancelled": bool(_run_was_cancelled),
878
+ }
879
+ )
880
+ except LLMError as e:
881
+ with contextlib.suppress(Exception):
882
+ chat_sess.messages.pop()
883
+ return JSONResponse({"detail": str(e)}, status_code=502)
884
+ finally:
885
+ set_active_llm_session(None)
886
+ set_active_project_episodic(False)
887
+ clear_active_project_workspace()
888
+ if emitter_token:
889
+ with contextlib.suppress(Exception):
890
+ reset_chat_event_emitter(emitter_token)
891
+
892
+ except asyncio.CancelledError:
893
+ _ev = ACTIVE_CHAT_CANCELS.get(_run_mkey)
894
+ if _ev is not None:
895
+ _ev.set()
896
+ raise
897
+ finally:
898
+ if _run_started:
899
+ _running_discard(_run_mkey)
900
+ PENDING_INJECTIONS.pop(_run_mkey, None)
901
+ _ev = ACTIVE_CHAT_CANCELS.get(_run_mkey)
902
+ if _ev is not None:
903
+ _ev.set()
904
+ ACTIVE_CHAT_CANCELS.pop(_run_mkey, None)
905
+ if _cancel_token is not None:
906
+ with contextlib.suppress(Exception):
907
+ reset_chat_cancel_checker(_cancel_token)
908
+ with contextlib.suppress(Exception):
909
+ await _broadcast_session_event(agent_id, session_id, {
910
+ "type": "run_finished",
911
+ "cancelled": bool(_run_was_cancelled),
912
+ })
913
+ if _run_was_cancelled:
914
+ await _broadcast_session_event(agent_id, session_id, {
915
+ "type": "chat_cancelled",
916
+ })
917
+
918
+ async def api_chat_stop(request: Request) -> JSONResponse:
919
+ try:
920
+ body = await request.json()
921
+ except Exception:
922
+ return JSONResponse({"detail": "invalid json"}, status_code=400)
923
+ from . import ACTIVE_CHAT_CANCELS, _memkey, _running_contains
924
+ session_id = (body.get("session_id") or "web-chat").strip()
925
+ agent_id = (body.get("agent_id") or "").strip() or "default"
926
+ _run_mkey = _memkey(agent_id, session_id)
927
+ # 以 _running_sessions 为唯一判断源,ACTIVE_CHAT_CANCELS 只负责取消
928
+ if not _running_contains(_run_mkey):
929
+ return JSONResponse({"cancelled": False, "reason": "not_running"})
930
+ cancel_event = ACTIVE_CHAT_CANCELS.get(_run_mkey)
931
+ if cancel_event:
932
+ cancel_event.set()
933
+ with contextlib.suppress(Exception):
934
+ await _broadcast_session_event(agent_id, session_id, {
935
+ "type": "chat_stop_requested",
936
+ })
937
+ return JSONResponse({"cancelled": True})
938
+ return JSONResponse({"cancelled": False, "reason": "not_running"})
939
+
940
+ # ---- Per-session model stack override (Session.metadata) ----
941
+ # Stored keys on Session.metadata:
942
+ # llm_id, vision_llm_id, image_gen_llm_id,
943
+ # audio_llm_id, music_llm_id, video_gen_llm_id
944
+ _MODEL_STACK_KEYS = (
945
+ "llm_id",
946
+ "vision_llm_id",
947
+ "image_gen_llm_id",
948
+ "audio_llm_id",
949
+ "music_llm_id",
950
+ "video_gen_llm_id",
951
+ )
952
+
953
+ def _read_model_stack_from_metadata(metadata):
954
+ if not isinstance(metadata, dict):
955
+ return {}
956
+ out = {}
957
+ for k in _MODEL_STACK_KEYS:
958
+ v = metadata.get(k)
959
+ if isinstance(v, str) and v.strip():
960
+ out[k] = v.strip()
961
+ return out
962
+
963
+ def _resolve_session_agent_id(body):
964
+ from codeagent.core import env as ca_env
965
+ return (
966
+ str(body.get("agent_id") or ca_env.default_agent_id()).strip() or "default"
967
+ )
968
+
969
+ async def api_session_model_stack_get(request: Request) -> JSONResponse:
970
+ """GET /api/ui/session/model-stack?session_id=...&agent_id=...
971
+
972
+ Returns the per-session model overrides stored in Session.metadata.
973
+ Empty fields mean "use the global default".
974
+ """
975
+ from seed.core.llm_sess import load_or_create_chat_session
976
+ try:
977
+ body = dict(request.query_params)
978
+ except Exception:
979
+ body = {}
980
+ session_id = str(body.get("session_id") or "").strip()
981
+ if not session_id:
982
+ return JSONResponse({"detail": "session_id required"}, status_code=400)
983
+ agent_id = _resolve_session_agent_id(body)
984
+ chat_sess = load_or_create_chat_session(session_id, agent_id)
985
+ overrides = _read_model_stack_from_metadata(chat_sess.metadata)
986
+ return JSONResponse(
987
+ {"ok": True, "session_id": session_id, "agent_id": agent_id, "overrides": overrides}
988
+ )
989
+
990
+ async def api_session_model_stack_set(request: Request) -> JSONResponse:
991
+ """POST /api/ui/session/model-stack
992
+
993
+ Body: {"session_id": "...", "agent_id": "...", "overrides": {<key>: <preset_id|"" >, ...}}
994
+
995
+ Empty string or null for a key clears that override (back to global default).
996
+ Unknown keys are ignored. Returns the persisted overrides.
997
+ """
998
+ from seed.core.llm_sess import load_or_create_chat_session, persist_chat_session
999
+ try:
1000
+ body = await request.json()
1001
+ except Exception:
1002
+ return JSONResponse({"detail": "invalid json"}, status_code=400)
1003
+ session_id = str(body.get("session_id") or "").strip()
1004
+ if not session_id:
1005
+ return JSONResponse({"detail": "session_id required"}, status_code=400)
1006
+ agent_id = _resolve_session_agent_id(body)
1007
+ overrides_in = body.get("overrides")
1008
+ if not isinstance(overrides_in, dict):
1009
+ return JSONResponse({"detail": "overrides must be an object"}, status_code=400)
1010
+ # Running sessions must be stopped first (consistency with rollback endpoint)
1011
+ from . import _running_contains, _memkey
1012
+ if _running_contains(_memkey(agent_id, session_id)):
1013
+ return JSONResponse(
1014
+ {"detail": "session is currently running, stop it first"}, status_code=409
1015
+ )
1016
+ chat_sess = load_or_create_chat_session(session_id, agent_id)
1017
+ if not isinstance(chat_sess.metadata, dict):
1018
+ chat_sess.metadata = {}
1019
+ for k in _MODEL_STACK_KEYS:
1020
+ if k not in overrides_in:
1021
+ continue
1022
+ v = overrides_in.get(k)
1023
+ if v is None or (isinstance(v, str) and not v.strip()):
1024
+ chat_sess.metadata.pop(k, None)
1025
+ else:
1026
+ chat_sess.metadata[k] = str(v).strip()
1027
+ persist_chat_session(chat_sess, agent_id)
1028
+ persisted = _read_model_stack_from_metadata(chat_sess.metadata)
1029
+ return JSONResponse(
1030
+ {"ok": True, "session_id": session_id, "agent_id": agent_id, "overrides": persisted}
1031
+ )
1032
+
1033
+ async def api_session_model_stack_clear(request: Request) -> JSONResponse:
1034
+ """POST /api/ui/session/model-stack/clear — convenience: remove all 6 overrides."""
1035
+ from seed.core.llm_sess import load_or_create_chat_session, persist_chat_session
1036
+ try:
1037
+ body = await request.json()
1038
+ except Exception:
1039
+ return JSONResponse({"detail": "invalid json"}, status_code=400)
1040
+ session_id = str(body.get("session_id") or "").strip()
1041
+ if not session_id:
1042
+ return JSONResponse({"detail": "session_id required"}, status_code=400)
1043
+ agent_id = _resolve_session_agent_id(body)
1044
+ from . import _running_contains, _memkey
1045
+ if _running_contains(_memkey(agent_id, session_id)):
1046
+ return JSONResponse(
1047
+ {"detail": "session is currently running, stop it first"}, status_code=409
1048
+ )
1049
+ chat_sess = load_or_create_chat_session(session_id, agent_id)
1050
+ if not isinstance(chat_sess.metadata, dict):
1051
+ chat_sess.metadata = {}
1052
+ for k in _MODEL_STACK_KEYS:
1053
+ chat_sess.metadata.pop(k, None)
1054
+ persist_chat_session(chat_sess, agent_id)
1055
+ return JSONResponse({"ok": True, "session_id": session_id, "agent_id": agent_id, "overrides": {}})
1056
+
1057
+ async def api_compact_config_get(request: Request) -> JSONResponse:
1058
+ """GET /api/ui/compact-config — return current compact min tokens."""
1059
+ from seed.core.agent_runtime import _get_compact_min_tokens, _compact_min_tokens_override
1060
+ return JSONResponse({
1061
+ "compact_min_tokens": _get_compact_min_tokens(),
1062
+ "runtime_override": _compact_min_tokens_override,
1063
+ })
1064
+
1065
+ async def api_compact_config_set(request: Request) -> JSONResponse:
1066
+ """POST /api/ui/compact-config — set compact min tokens at runtime."""
1067
+ from seed.core.agent_runtime import set_compact_min_tokens
1068
+ try:
1069
+ body = await request.json()
1070
+ except Exception:
1071
+ return JSONResponse({"detail": "invalid json"}, status_code=400)
1072
+ val = body.get("compact_min_tokens")
1073
+ if val is None:
1074
+ return JSONResponse({"detail": "compact_min_tokens required"}, status_code=400)
1075
+ try:
1076
+ val = int(val)
1077
+ except (ValueError, TypeError):
1078
+ return JSONResponse({"detail": "compact_min_tokens must be integer"}, status_code=400)
1079
+ set_compact_min_tokens(val)
1080
+ return JSONResponse({"ok": True, "compact_min_tokens": max(0, val)})
1081
+
1082
+ async def api_chat_rollback(request: Request) -> JSONResponse:
1083
+ """Roll back session to a given message index — subsequent turns will project from there.
1084
+
1085
+ POST body::
1086
+
1087
+ {"session_id": "...", "agent_id": "...", "message_idx": 42}
1088
+
1089
+ Response::
1090
+
1091
+ {"ok": true, "message_idx": 42, "messages_since": [<messages from idx onwards>]}
1092
+ """
1093
+ from seed.core.llm_sess import load_or_create_chat_session, persist_chat_session
1094
+ from . import _running_contains, _memkey
1095
+
1096
+ try:
1097
+ body = await request.json()
1098
+ except Exception:
1099
+ return JSONResponse({"detail": "invalid json"}, status_code=400)
1100
+
1101
+ session_id = str(body.get("session_id") or "web-chat").strip() or "web-chat"
1102
+ from codeagent.core import env as ca_env
1103
+ from seed.core.env_access import (
1104
+ CHAT_AUTO_CONTINUE_MAX_SEGMENTS,
1105
+ CHAT_AUTO_CONTINUE_ON_LIMIT,
1106
+ MAX_TOOL_ROUNDS,
1107
+ env_truthy,
1108
+ pick_int,
1109
+ )
1110
+
1111
+ agent_id = str(body.get("agent_id") or ca_env.default_agent_id()).strip() or "default"
1112
+ # 0 = system message, so minimal sensible value is 1
1113
+ message_idx = int(body.get("message_idx", 1))
1114
+
1115
+ _run_mkey = _memkey(agent_id, session_id)
1116
+ if _running_contains(_run_mkey):
1117
+ return JSONResponse({"detail": "session is currently running, stop it first"}, status_code=409)
1118
+
1119
+ chat_sess = load_or_create_chat_session(session_id, agent_id)
1120
+
1121
+ if not isinstance(chat_sess.metadata, dict):
1122
+ chat_sess.metadata = {}
1123
+
1124
+ # Clamp to valid range
1125
+ max_idx = len(chat_sess.messages) - 1
1126
+ if message_idx < 0:
1127
+ message_idx = 0
1128
+ if message_idx > max_idx:
1129
+ return JSONResponse(
1130
+ {"detail": f"message_idx {message_idx} out of range (max {max_idx})"},
1131
+ status_code=422,
1132
+ )
1133
+
1134
+ chat_sess.metadata["cursor"] = {
1135
+ "mode": "head",
1136
+ "from_idx": message_idx,
1137
+ }
1138
+ chat_sess.metadata.pop("vision_context", None)
1139
+ persist_chat_session(chat_sess, agent_id)
1140
+
1141
+ # Broadcast rollback event via websocket
1142
+ try:
1143
+ await _broadcast_session_event(agent_id, session_id, {
1144
+ "type": "rollback",
1145
+ "message_idx": message_idx,
1146
+ })
1147
+ except Exception:
1148
+ pass
1149
+
1150
+ # Return messages from rollback point for UI refresh
1151
+ messages_since = chat_sess.messages[message_idx:]
1152
+ return JSONResponse({
1153
+ "ok": True,
1154
+ "message_idx": message_idx,
1155
+ "messages_since": messages_since,
1156
+ })
1157
+
1158
+ def _resolve_site_icon_path() -> Path | None:
1159
+ """``icon.png`` may live next to the ``codeagent`` package, inside ``server/``, or at the repo root."""
1160
+ pkg_root = Path(__file__).resolve().parent.parent
1161
+ for candidate in (
1162
+ pkg_root / "server" / "icon.png", # shipped with the package
1163
+ pkg_root / "icon.png", # next to the codeagent package
1164
+ pkg_root.parent / "icon.png", # at the repo / project root
1165
+ ):
1166
+ if candidate.is_file():
1167
+ return candidate
1168
+ return None
1169
+
1170
+ async def icon_png(_: Request) -> Response:
1171
+ p = _resolve_site_icon_path()
1172
+ if p is None:
1173
+ return Response(status_code=404)
1174
+ return Response(content=p.read_bytes(), media_type="image/png")
1175
+
1176
+ async def favicon_ico(_: Request) -> Response:
1177
+ """Browsers default to /favicon.ico; serve same PNG as /icon.png."""
1178
+ p = _resolve_site_icon_path()
1179
+ if p is None:
1180
+ return Response(status_code=404)
1181
+ return Response(
1182
+ content=p.read_bytes(),
1183
+ media_type="image/png",
1184
+ headers={"Cache-Control": "public, max-age=86400"},
1185
+ )
1186
+
1187
+ async def health(_: Request) -> JSONResponse:
1188
+ return JSONResponse({"ok": True})
1189
+
1190
+ async def websocket_chat(websocket: WebSocket) -> None:
1191
+ await websocket.accept()
1192
+ sid = (websocket.query_params.get("session_id") or "").strip()
1193
+ aid = (websocket.query_params.get("agent_id") or "").strip() or "default"
1194
+ if sid:
1195
+ mkey = _memkey(aid, sid)
1196
+ ws_by_session[mkey].add(websocket)
1197
+ try:
1198
+ while True:
1199
+ await websocket.receive_text()
1200
+ except Exception:
1201
+ pass
1202
+ finally:
1203
+ if sid:
1204
+ ws_by_session[mkey].discard(websocket)
1205
+
1206
+ middleware: list[Any] = []
1207
+ tok = get_webui_token(project_root)
1208
+ if tok:
1209
+ middleware.append(Middleware(WebUIAuthMiddleware, project_root=project_root))
1210
+
1211
+ # ── Cron:Starlette 1.x 用 lifespan(on_startup 已移除);且 AsyncIOScheduler 需要已有运行中的 loop ──
1212
+ @asynccontextmanager
1213
+ async def _lifespan(_app: Starlette):
1214
+ try:
1215
+ from seed.integrations.cron_sched import start_cron_scheduler
1216
+
1217
+ start_cron_scheduler()
1218
+ except Exception as exc:
1219
+ logger.warning("cron scheduler startup failed: %s", exc)
1220
+ yield
1221
+ try:
1222
+ from seed.core._session_cache import cancel_all_active_chats
1223
+
1224
+ n = cancel_all_active_chats()
1225
+ if n:
1226
+ logger.info("shutdown: signalled %s active chat(s) to stop", n)
1227
+ except Exception:
1228
+ pass
1229
+ try:
1230
+ from seed.integrations.cron_sched import shutdown_cron_scheduler
1231
+
1232
+ shutdown_cron_scheduler()
1233
+ except Exception:
1234
+ pass
1235
+
1236
+ from codeagent.server.attachment_api import (
1237
+ api_attachment_batch,
1238
+ api_attachment_get,
1239
+ api_attachment_upload,
1240
+ api_file_serve,
1241
+ )
1242
+ from codeagent.server.tts_api import api_tts
1243
+
1244
+ routes = [
1245
+ Route("/", homepage),
1246
+ Route("/setup", setup_page),
1247
+ Route("/health", health),
1248
+ Route("/api/health", health),
1249
+ Route("/icon.png", icon_png),
1250
+ Route("/favicon.ico", favicon_ico),
1251
+ # NOTE: routes under /api/ui/session/model-stack must be registered
1252
+ # BEFORE the /api/ui Mount, otherwise Starlette will dispatch them
1253
+ # to build_webui_api_app first.
1254
+ Route(
1255
+ "/api/ui/session/model-stack",
1256
+ api_session_model_stack_get,
1257
+ methods=["GET"],
1258
+ ),
1259
+ Route(
1260
+ "/api/ui/session/model-stack",
1261
+ api_session_model_stack_set,
1262
+ methods=["POST"],
1263
+ ),
1264
+ Route(
1265
+ "/api/ui/session/model-stack/clear",
1266
+ api_session_model_stack_clear,
1267
+ methods=["POST"],
1268
+ ),
1269
+ Route(
1270
+ "/api/ui/compact-config",
1271
+ api_compact_config_get,
1272
+ methods=["GET"],
1273
+ ),
1274
+ Route(
1275
+ "/api/ui/compact-config",
1276
+ api_compact_config_set,
1277
+ methods=["POST"],
1278
+ ),
1279
+ Mount("/api/ui", app=build_webui_api_app(project_root)),
1280
+ Route("/api/attachments", api_attachment_upload, methods=["POST"]),
1281
+ Route("/api/attachments/batch", api_attachment_batch, methods=["POST"]),
1282
+ Route("/api/attachments/{attachment_id}", api_attachment_get, methods=["GET"]),
1283
+ Route("/api/file-serve", api_file_serve, methods=["GET"]),
1284
+ Route("/api/chat", api_chat, methods=["POST"]),
1285
+ Route("/api/chat/stop", api_chat_stop, methods=["POST"]),
1286
+ Route("/api/chat/rollback", api_chat_rollback, methods=["POST"]),
1287
+ Route("/api/tts", api_tts, methods=["POST"]),
1288
+ WebSocketRoute("/ws", websocket_chat),
1289
+ WebSocketRoute("/ws/{path:path}", websocket_chat),
1290
+ ]
1291
+
1292
+ return Starlette(debug=False, routes=routes, middleware=middleware, lifespan=_lifespan)