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.
- codeagent/__init__.py +10 -0
- codeagent/__main__.py +13 -0
- codeagent/cli/__init__.py +13 -0
- codeagent/cli/entrypoint.py +161 -0
- codeagent/cli/main.py +979 -0
- codeagent/core/__init__.py +2 -0
- codeagent/core/attachments.py +342 -0
- codeagent/core/audio_models.py +50 -0
- codeagent/core/bootstrap.py +178 -0
- codeagent/core/default_presets.py +159 -0
- codeagent/core/env.py +106 -0
- codeagent/core/folder_picker.py +151 -0
- codeagent/core/image_understanding.py +90 -0
- codeagent/core/models.py +6 -0
- codeagent/core/paths.py +238 -0
- codeagent/core/pricing.py +107 -0
- codeagent/core/process_ports.py +258 -0
- codeagent/core/seed_bridge.py +21 -0
- codeagent/core/settings.py +98 -0
- codeagent/core/speech_synth.py +330 -0
- codeagent/core/team_config.py +247 -0
- codeagent/core/team_manager.py +245 -0
- codeagent/core/token_counter.py +41 -0
- codeagent/core/tts_voices.py +103 -0
- codeagent/core/usage_billing.py +72 -0
- codeagent/core/vision_models.py +93 -0
- codeagent/memory/diary.py +144 -0
- codeagent/persona_defaults/README.md +12 -0
- codeagent/persona_defaults/agent.md +352 -0
- codeagent/persona_defaults/identity.md +29 -0
- codeagent/persona_defaults/memory.md +4 -0
- codeagent/persona_defaults/skills.md +325 -0
- codeagent/persona_defaults/soul.md +38 -0
- codeagent/persona_defaults/tools.md +41 -0
- codeagent/persona_defaults/user.md +18 -0
- codeagent/runtime/__init__.py +11 -0
- codeagent/runtime/agent_context.py +6 -0
- codeagent/runtime/agent_runtime.py +21 -0
- codeagent/runtime/compact_prompt.py +9 -0
- codeagent/runtime/compact_state.py +40 -0
- codeagent/runtime/llm_worker.py +132 -0
- codeagent/runtime/orchestrator.py +161 -0
- codeagent/runtime/prompt_enrichment.py +324 -0
- codeagent/runtime/session_manager.py +30 -0
- codeagent/runtime/task_split.py +38 -0
- codeagent/runtime/turn_loop.py +13 -0
- codeagent/runtime/worker.py +48 -0
- codeagent/server/__init__.py +505 -0
- codeagent/server/app_factory.py +1292 -0
- codeagent/server/attachment_api.py +261 -0
- codeagent/server/hub.py +175 -0
- codeagent/server/icon.png +0 -0
- codeagent/server/self_healing.py +143 -0
- codeagent/server/team_engine.py +243 -0
- codeagent/server/tts_api.py +51 -0
- codeagent/server/webui_api_app.py +3180 -0
- codeagent/skills/__init__.py +2 -0
- codeagent/skills/select.py +144 -0
- codeagent/tools/__init__.py +6 -0
- codeagent/tools/agent_tools.py +10 -0
- codeagent/web/__init__.py +1 -0
- codeagent/web/__pycache__/__init__.cpython-313.pyc +0 -0
- codeagent/web/__pycache__/app.cpython-313.pyc +0 -0
- codeagent/web/__pycache__/auth_impl.cpython-313.pyc +0 -0
- codeagent/web/auth_impl.py +326 -0
- codeagent/web/static/00-marked.min.js +6 -0
- codeagent/web/static/00-mermaid.min.js +2314 -0
- codeagent/web/static/00-purify.min.js +3 -0
- codeagent/web/static/00-storage.js +83 -0
- codeagent/web/static/00-utils.js +51 -0
- codeagent/web/static/01-theme.css +96 -0
- codeagent/web/static/01a-scroll.js +86 -0
- codeagent/web/static/01b-think.js +62 -0
- codeagent/web/static/01c-session-identity.js +81 -0
- codeagent/web/static/01c-session-thinking.js +61 -0
- codeagent/web/static/01c-session-tree-menus.js +349 -0
- codeagent/web/static/01c-session-tree.js +531 -0
- codeagent/web/static/01c-session-utilities.js +83 -0
- codeagent/web/static/01c-vision-model.js +132 -0
- codeagent/web/static/01d-image-gen-model.js +84 -0
- codeagent/web/static/01e-audio-model.js +97 -0
- codeagent/web/static/01f-compose-ui.js +88 -0
- codeagent/web/static/01g-tts-settings.js +128 -0
- codeagent/web/static/01h-music-model.js +85 -0
- codeagent/web/static/01i-model-stack.js +291 -0
- codeagent/web/static/01j-video-gen-model.js +85 -0
- codeagent/web/static/01k-model-stack-bootstrap.js +66 -0
- codeagent/web/static/01k-model-stack-state.js +209 -0
- codeagent/web/static/01q-inflight.js +195 -0
- codeagent/web/static/02-reset.css +22 -0
- codeagent/web/static/02-session-ui.js +201 -0
- codeagent/web/static/03-layout-p1.css +2 -0
- codeagent/web/static/03-layout-p2.css +2 -0
- codeagent/web/static/03a-links-stream.js +88 -0
- codeagent/web/static/03b-stream-text.js +119 -0
- codeagent/web/static/03c-render-bubble.js +520 -0
- codeagent/web/static/03d-agent-bubble.js +126 -0
- codeagent/web/static/03e-tool-trace.js +182 -0
- codeagent/web/static/03f-timeline.js +54 -0
- codeagent/web/static/03g-bubble-tts.js +292 -0
- codeagent/web/static/04-components-p1.css +1 -0
- codeagent/web/static/04-components-p2b.css +1 -0
- codeagent/web/static/04-components-p3.css +2 -0
- codeagent/web/static/04-components-p4.css +1 -0
- codeagent/web/static/04-components-p5.css +1 -0
- codeagent/web/static/04-components-p6.css +58 -0
- codeagent/web/static/04-components-p7.css +4 -0
- codeagent/web/static/04-components-p8.css +1 -0
- codeagent/web/static/04-components-p9.css +1 -0
- codeagent/web/static/04-ws-connect.js +351 -0
- codeagent/web/static/04-ws-helpers.js +116 -0
- codeagent/web/static/05-chat-p1.css +1058 -0
- codeagent/web/static/05-chat-p2.css +1 -0
- codeagent/web/static/05-chat-p3.css +295 -0
- codeagent/web/static/05a-session-history.js +176 -0
- codeagent/web/static/05b-session-list.js +114 -0
- codeagent/web/static/05c-dialog.js +123 -0
- codeagent/web/static/05d-project-create.js +172 -0
- codeagent/web/static/05e-project-remote.js +143 -0
- codeagent/web/static/06-chat.js +552 -0
- codeagent/web/static/06-utilities.css +181 -0
- codeagent/web/static/06b-attachments.js +304 -0
- codeagent/web/static/06c-camera.js +488 -0
- codeagent/web/static/07-activity-bar.css +127 -0
- codeagent/web/static/09-git-p1.css +1 -0
- codeagent/web/static/09-git-p2.css +1 -0
- codeagent/web/static/09a-init-bind.js +110 -0
- codeagent/web/static/09b-panel-toggle.js +80 -0
- codeagent/web/static/09c-api-refresh.js +100 -0
- codeagent/web/static/09d-status-log.js +80 -0
- codeagent/web/static/09e-commit.js +80 -0
- codeagent/web/static/09f-remote.js +93 -0
- codeagent/web/static/11a-cron-parse.js +117 -0
- codeagent/web/static/11b-cron-panel.js +100 -0
- codeagent/web/static/11c-cron-card.js +133 -0
- codeagent/web/static/11d-cron-form.js +105 -0
- codeagent/web/static/11e-env-config.js +168 -0
- codeagent/web/static/11f-env-llm-a-state.js +136 -0
- codeagent/web/static/11f-env-llm-b-form.js +409 -0
- codeagent/web/static/11f-env-llm-c-board.js +322 -0
- codeagent/web/static/11g-env-paths-git.js +973 -0
- codeagent/web/static/11h-env-mcp-a-state.js +161 -0
- codeagent/web/static/11h-env-mcp-b-form.js +229 -0
- codeagent/web/static/11h-env-mcp-c-board.js +565 -0
- codeagent/web/static/12-plugins.js +127 -0
- codeagent/web/static/13a-skills.js +156 -0
- codeagent/web/static/13a-todo-utils.js +140 -0
- codeagent/web/static/13b-todo-fetch.js +106 -0
- codeagent/web/static/13b-tools.js +127 -0
- codeagent/web/static/13c-todo-crud.js +92 -0
- codeagent/web/static/14-activity-bar.js +177 -0
- codeagent/web/static/15-plans.css +194 -0
- codeagent/web/static/15-plans.js +219 -0
- codeagent/web/static/16-mobile.css +612 -0
- codeagent/web/static/16-mobile.js +178 -0
- codeagent/web/static/17-preset-ux.css +287 -0
- codeagent/web/static/18-agent-mgr.css +616 -0
- codeagent/web/static/18-agent-mgr.js +204 -0
- codeagent/web/static/24-health-dashboard.js +68 -0
- codeagent/web/static/99-brand-polish.css +152 -0
- codeagent/web/static/body.html +824 -0
- codeagent/web/static/setup/setup_form.html +171 -0
- codeagent/web/static/setup/setup_head.html +100 -0
- codeagent/web/static/setup/setup_tail.html +264 -0
- codeagent/web/static/setup/setup_test.html +38 -0
- codeagent/web/web_login.html +265 -0
- codeagent/web/webui.html +35 -0
- tagword_codeagent-1.1.6.dist-info/METADATA +273 -0
- tagword_codeagent-1.1.6.dist-info/RECORD +173 -0
- tagword_codeagent-1.1.6.dist-info/WHEEL +5 -0
- tagword_codeagent-1.1.6.dist-info/entry_points.txt +3 -0
- tagword_codeagent-1.1.6.dist-info/licenses/LICENSE +21 -0
- 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)
|