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,261 @@
|
|
|
1
|
+
"""HTTP handlers for chat attachments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import FileResponse, JSONResponse, Response
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_body_json(body: bytes) -> dict[str, Any]:
|
|
17
|
+
try:
|
|
18
|
+
j = json.loads(body.decode("utf-8") or "{}")
|
|
19
|
+
return j if isinstance(j, dict) else {}
|
|
20
|
+
except Exception:
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def api_attachment_upload(request: Request) -> JSONResponse:
|
|
25
|
+
from codeagent.core import env as ca_env
|
|
26
|
+
from codeagent.core.attachments import save_attachment, _max_count
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
body = await request.json()
|
|
30
|
+
except Exception:
|
|
31
|
+
return JSONResponse({"detail": "invalid json"}, status_code=400)
|
|
32
|
+
|
|
33
|
+
agent_id = str(body.get("agent_id") or ca_env.default_agent_id()).strip() or "default"
|
|
34
|
+
session_id = str(body.get("session_id") or "web-chat").strip() or "web-chat"
|
|
35
|
+
filename = str(body.get("filename") or "upload.bin").strip() or "upload.bin"
|
|
36
|
+
data_b64 = str(body.get("data_base64") or "").strip()
|
|
37
|
+
if not data_b64:
|
|
38
|
+
return JSONResponse({"detail": "data_base64 required"}, status_code=400)
|
|
39
|
+
try:
|
|
40
|
+
raw = base64.standard_b64decode(data_b64)
|
|
41
|
+
except Exception:
|
|
42
|
+
return JSONResponse({"detail": "invalid base64"}, status_code=400)
|
|
43
|
+
mime = str(body.get("mime") or "").strip()
|
|
44
|
+
try:
|
|
45
|
+
meta = save_attachment(
|
|
46
|
+
agent_id=agent_id,
|
|
47
|
+
session_id=session_id,
|
|
48
|
+
raw_bytes=raw,
|
|
49
|
+
filename=filename,
|
|
50
|
+
mime=mime,
|
|
51
|
+
)
|
|
52
|
+
except ValueError as e:
|
|
53
|
+
return JSONResponse({"detail": str(e)}, status_code=400)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.exception("attachment upload failed")
|
|
56
|
+
return JSONResponse({"detail": str(e)}, status_code=500)
|
|
57
|
+
|
|
58
|
+
return JSONResponse(
|
|
59
|
+
{
|
|
60
|
+
"id": meta.id,
|
|
61
|
+
"url": f"/api/attachments/{meta.id}?session_id={session_id}&agent_id={agent_id}",
|
|
62
|
+
"meta": meta.to_dict(),
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def api_attachment_batch(request: Request) -> JSONResponse:
|
|
68
|
+
from codeagent.core import env as ca_env
|
|
69
|
+
from codeagent.core.attachments import save_attachment, _max_count, _dir_max_files
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
body = await request.json()
|
|
73
|
+
except Exception:
|
|
74
|
+
return JSONResponse({"detail": "invalid json"}, status_code=400)
|
|
75
|
+
|
|
76
|
+
agent_id = str(body.get("agent_id") or ca_env.default_agent_id()).strip() or "default"
|
|
77
|
+
session_id = str(body.get("session_id") or "web-chat").strip() or "web-chat"
|
|
78
|
+
files = body.get("files")
|
|
79
|
+
if not isinstance(files, list) or not files:
|
|
80
|
+
return JSONResponse({"detail": "files array required"}, status_code=400)
|
|
81
|
+
|
|
82
|
+
limit = min(_dir_max_files(), _max_count() * 4)
|
|
83
|
+
attachment_ids: list[str] = []
|
|
84
|
+
errors: list[str] = []
|
|
85
|
+
truncated = False
|
|
86
|
+
for item in files[: limit + 1]:
|
|
87
|
+
if len(attachment_ids) >= limit:
|
|
88
|
+
truncated = True
|
|
89
|
+
break
|
|
90
|
+
if not isinstance(item, dict):
|
|
91
|
+
continue
|
|
92
|
+
filename = str(item.get("filename") or "file").strip()
|
|
93
|
+
data_b64 = str(item.get("data_base64") or "").strip()
|
|
94
|
+
if not data_b64:
|
|
95
|
+
errors.append(f"missing data for {filename}")
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
raw = base64.standard_b64decode(data_b64)
|
|
99
|
+
meta = save_attachment(
|
|
100
|
+
agent_id=agent_id,
|
|
101
|
+
session_id=session_id,
|
|
102
|
+
raw_bytes=raw,
|
|
103
|
+
filename=filename,
|
|
104
|
+
mime=str(item.get("mime") or "").strip(),
|
|
105
|
+
)
|
|
106
|
+
attachment_ids.append(meta.id)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
errors.append(f"{filename}: {e}")
|
|
109
|
+
|
|
110
|
+
return JSONResponse(
|
|
111
|
+
{
|
|
112
|
+
"attachment_ids": attachment_ids,
|
|
113
|
+
"dir_label": str(body.get("dir_label") or "").strip() or None,
|
|
114
|
+
"truncated": truncated,
|
|
115
|
+
"errors": errors or None,
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def api_attachment_get(request: Request) -> Response:
|
|
121
|
+
from codeagent.core import env as ca_env
|
|
122
|
+
from codeagent.core.attachments import resolve_attachment_path
|
|
123
|
+
import mimetypes
|
|
124
|
+
|
|
125
|
+
attachment_id = request.path_params.get("attachment_id") or ""
|
|
126
|
+
agent_id = str(request.query_params.get("agent_id") or ca_env.default_agent_id()).strip() or "default"
|
|
127
|
+
session_id = str(request.query_params.get("session_id") or "").strip()
|
|
128
|
+
if not session_id:
|
|
129
|
+
return JSONResponse({"detail": "session_id required"}, status_code=400)
|
|
130
|
+
|
|
131
|
+
path = resolve_attachment_path(agent_id, session_id, attachment_id)
|
|
132
|
+
if not path or not path.is_file():
|
|
133
|
+
return JSONResponse({"detail": "not found"}, status_code=404)
|
|
134
|
+
|
|
135
|
+
mime, _ = mimetypes.guess_type(str(path))
|
|
136
|
+
return FileResponse(
|
|
137
|
+
str(path),
|
|
138
|
+
media_type=mime or "application/octet-stream",
|
|
139
|
+
filename=path.name,
|
|
140
|
+
headers={"Cache-Control": "private, max-age=3600"},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def api_file_serve(request: Request) -> Response:
|
|
145
|
+
"""Serve local files from the server filesystem.
|
|
146
|
+
|
|
147
|
+
Security: only serves files under /home/u2/ (agent workspace).
|
|
148
|
+
Rejects '..' path traversal.
|
|
149
|
+
"""
|
|
150
|
+
from pathlib import Path as _Path
|
|
151
|
+
import mimetypes as _mime
|
|
152
|
+
|
|
153
|
+
raw = request.query_params.get("path", "").strip()
|
|
154
|
+
if not raw:
|
|
155
|
+
return JSONResponse({"detail": "path query parameter required"}, status_code=400)
|
|
156
|
+
|
|
157
|
+
# Path traversal protection
|
|
158
|
+
if ".." in raw.split("/") or raw.startswith("~"):
|
|
159
|
+
return JSONResponse({"detail": "invalid path"}, status_code=403)
|
|
160
|
+
|
|
161
|
+
p = _Path(raw)
|
|
162
|
+
if not p.is_absolute():
|
|
163
|
+
return JSONResponse({"detail": "absolute path required"}, status_code=400)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
resolved = p.resolve(strict=False)
|
|
167
|
+
except Exception:
|
|
168
|
+
return JSONResponse({"detail": "invalid path"}, status_code=400)
|
|
169
|
+
|
|
170
|
+
# Only allow files under /home/u2/
|
|
171
|
+
allowed_prefix = "/home/u2/"
|
|
172
|
+
if not str(resolved).startswith(allowed_prefix):
|
|
173
|
+
return JSONResponse({"detail": "access denied"}, status_code=403)
|
|
174
|
+
|
|
175
|
+
if not resolved.is_file():
|
|
176
|
+
return JSONResponse({"detail": "not found"}, status_code=404)
|
|
177
|
+
|
|
178
|
+
mt, _ = _mime.guess_type(str(resolved))
|
|
179
|
+
return FileResponse(
|
|
180
|
+
str(resolved),
|
|
181
|
+
media_type=mt or "application/octet-stream",
|
|
182
|
+
filename=resolved.name,
|
|
183
|
+
headers={"Cache-Control": "private, max-age=300"},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_chat_multimodal_body(body: dict[str, Any]) -> tuple[str, list[str], bool, dict[str, Any]]:
|
|
188
|
+
"""Return (message_text, attachment_ids, has_image, extra)."""
|
|
189
|
+
from codeagent.core.attachments import (
|
|
190
|
+
ATTACHMENT_TAG_RE,
|
|
191
|
+
build_user_message,
|
|
192
|
+
message_has_audio_attachments,
|
|
193
|
+
message_has_image_attachments,
|
|
194
|
+
message_has_video_attachments,
|
|
195
|
+
save_attachment,
|
|
196
|
+
parse_image_dir_tags,
|
|
197
|
+
)
|
|
198
|
+
from codeagent.core import env as ca_env
|
|
199
|
+
|
|
200
|
+
agent_id = str(body.get("agent_id") or ca_env.default_agent_id()).strip() or "default"
|
|
201
|
+
session_id = str(body.get("session_id") or "web-chat").strip() or "web-chat"
|
|
202
|
+
message = str(body.get("message") or "").strip()
|
|
203
|
+
clear_vision = bool(body.get("clear_vision_context"))
|
|
204
|
+
|
|
205
|
+
attachment_ids: list[str] = []
|
|
206
|
+
raw_ids = body.get("attachment_ids")
|
|
207
|
+
if isinstance(raw_ids, list):
|
|
208
|
+
attachment_ids.extend(str(x).strip() for x in raw_ids if str(x).strip())
|
|
209
|
+
|
|
210
|
+
metas = []
|
|
211
|
+
inline = body.get("attachments")
|
|
212
|
+
if isinstance(inline, list):
|
|
213
|
+
for item in inline:
|
|
214
|
+
if not isinstance(item, dict):
|
|
215
|
+
continue
|
|
216
|
+
data_b64 = str(item.get("data_base64") or "").strip()
|
|
217
|
+
if not data_b64:
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
raw = base64.standard_b64decode(data_b64)
|
|
221
|
+
meta = save_attachment(
|
|
222
|
+
agent_id=agent_id,
|
|
223
|
+
session_id=session_id,
|
|
224
|
+
raw_bytes=raw,
|
|
225
|
+
filename=str(item.get("filename") or "upload"),
|
|
226
|
+
mime=str(item.get("mime") or "").strip(),
|
|
227
|
+
)
|
|
228
|
+
metas.append(meta)
|
|
229
|
+
attachment_ids.append(meta.id)
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
for aid in attachment_ids:
|
|
234
|
+
if not any(m.id == aid for m in metas):
|
|
235
|
+
from codeagent.core.attachments import load_attachment_meta
|
|
236
|
+
|
|
237
|
+
m = load_attachment_meta(agent_id, session_id, aid)
|
|
238
|
+
if m:
|
|
239
|
+
metas.append(m)
|
|
240
|
+
|
|
241
|
+
dir_tags = parse_image_dir_tags(message)
|
|
242
|
+
image_dir_tag = ""
|
|
243
|
+
if dir_tags:
|
|
244
|
+
rel, mx = dir_tags[0]
|
|
245
|
+
image_dir_tag = f"[image_dir:{rel} max={mx}]"
|
|
246
|
+
|
|
247
|
+
if not message and not metas and not dir_tags:
|
|
248
|
+
raise ValueError("message or attachments required")
|
|
249
|
+
|
|
250
|
+
user_msg = build_user_message(message, metas, image_dir_tag=image_dir_tag)
|
|
251
|
+
has_image = message_has_image_attachments(user_msg) or bool(dir_tags)
|
|
252
|
+
has_video = message_has_video_attachments(user_msg)
|
|
253
|
+
has_audio = message_has_audio_attachments(user_msg)
|
|
254
|
+
|
|
255
|
+
return user_msg, attachment_ids, has_image, {
|
|
256
|
+
"clear_vision_context": clear_vision,
|
|
257
|
+
"agent_id": agent_id,
|
|
258
|
+
"session_id": session_id,
|
|
259
|
+
"has_video": has_video,
|
|
260
|
+
"has_audio": has_audio,
|
|
261
|
+
}
|
codeagent/server/hub.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Hub — multi-agent message bus with SSE streaming.
|
|
2
|
+
|
|
3
|
+
Messages flow: Agent A → POST /api/hub/send → persist → SSE stream → Agent B
|
|
4
|
+
|
|
5
|
+
Architecture:
|
|
6
|
+
- Incoming messages are persisted to ~/.codeagent/hub/ as JSON files
|
|
7
|
+
- SSE subscribers get real-time delivery via an in-memory event queue
|
|
8
|
+
- `last-event-id` support for reconnection (replays missed events)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, AsyncGenerator
|
|
22
|
+
|
|
23
|
+
from codeagent.core.paths import codeagent_home
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# ── In-memory SSE event queue ─────────────────────────────────
|
|
28
|
+
# {subscriber_id: asyncio.Queue}
|
|
29
|
+
_sse_subscribers: dict[str, asyncio.Queue] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hub_dir() -> Path:
|
|
33
|
+
p = codeagent_home() / "hub"
|
|
34
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return p
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _hub_msgs_path() -> Path:
|
|
39
|
+
"""Single JSON lines file for all hub messages (append-only)."""
|
|
40
|
+
return _hub_dir() / "messages.jl"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _persist_message(msg: dict) -> None:
|
|
44
|
+
"""Append a message to the messages.jl file."""
|
|
45
|
+
path = _hub_msgs_path()
|
|
46
|
+
try:
|
|
47
|
+
with open(str(path), "a", encoding="utf-8") as f:
|
|
48
|
+
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
|
49
|
+
except OSError as e:
|
|
50
|
+
logger.error("hub persist failed: %s", e)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_messages(
|
|
54
|
+
since_id: str | None = None,
|
|
55
|
+
limit: int = 100,
|
|
56
|
+
filter_agent: str | None = None,
|
|
57
|
+
) -> list[dict]:
|
|
58
|
+
"""Load messages from the persisted file, newest first."""
|
|
59
|
+
path = _hub_msgs_path()
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
return []
|
|
62
|
+
msgs: list[dict] = []
|
|
63
|
+
try:
|
|
64
|
+
with open(str(path), "r", encoding="utf-8") as f:
|
|
65
|
+
for line in f:
|
|
66
|
+
line = line.strip()
|
|
67
|
+
if not line:
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
msg = json.loads(line)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
continue
|
|
73
|
+
msgs.append(msg)
|
|
74
|
+
except OSError:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
# Filter by since_id
|
|
78
|
+
if since_id:
|
|
79
|
+
idx = -1
|
|
80
|
+
for i, m in enumerate(msgs):
|
|
81
|
+
if m.get("id") == since_id:
|
|
82
|
+
idx = i
|
|
83
|
+
break
|
|
84
|
+
if idx >= 0:
|
|
85
|
+
msgs = msgs[idx + 1:]
|
|
86
|
+
|
|
87
|
+
# Filter by agent
|
|
88
|
+
if filter_agent:
|
|
89
|
+
msgs = [m for m in msgs if m.get("frm") == filter_agent or m.get("to") == filter_agent]
|
|
90
|
+
|
|
91
|
+
# Return newest first, limited
|
|
92
|
+
msgs.reverse()
|
|
93
|
+
return msgs[:limit]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def send_message(frm: str, to: str, content: str, msg_type: str = "text") -> dict:
|
|
97
|
+
"""Create, persist, and broadcast a hub message."""
|
|
98
|
+
msg = {
|
|
99
|
+
"id": uuid.uuid4().hex[:12],
|
|
100
|
+
"ts": time.time(),
|
|
101
|
+
"frm": frm,
|
|
102
|
+
"to": to,
|
|
103
|
+
"type": msg_type,
|
|
104
|
+
"content": content,
|
|
105
|
+
}
|
|
106
|
+
_persist_message(msg)
|
|
107
|
+
_broadcast(msg)
|
|
108
|
+
return msg
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _broadcast(msg: dict) -> None:
|
|
112
|
+
"""Push a message to all active SSE subscribers."""
|
|
113
|
+
dead: list[str] = []
|
|
114
|
+
for sid, q in _sse_subscribers.items():
|
|
115
|
+
try:
|
|
116
|
+
q.put_nowait(msg)
|
|
117
|
+
except asyncio.QueueFull:
|
|
118
|
+
dead.append(sid)
|
|
119
|
+
except Exception:
|
|
120
|
+
dead.append(sid)
|
|
121
|
+
for sid in dead:
|
|
122
|
+
_sse_subscribers.pop(sid, None)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def subscribe() -> tuple[str, asyncio.Queue]:
|
|
126
|
+
"""Register a new SSE subscriber. Returns (subscriber_id, queue)."""
|
|
127
|
+
sid = uuid.uuid4().hex[:8]
|
|
128
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=256)
|
|
129
|
+
_sse_subscribers[sid] = q
|
|
130
|
+
return sid, q
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def unsubscribe(sid: str) -> None:
|
|
134
|
+
"""Remove an SSE subscriber."""
|
|
135
|
+
_sse_subscribers.pop(sid, None)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def sse_generator(
|
|
139
|
+
subscriber_id: str,
|
|
140
|
+
queue: asyncio.Queue,
|
|
141
|
+
last_event_id: str | None = None,
|
|
142
|
+
) -> AsyncGenerator[bytes, None]:
|
|
143
|
+
"""Async generator for SSE streaming.
|
|
144
|
+
|
|
145
|
+
Yields SSE-format bytes. Supports last-event-id for reconnection.
|
|
146
|
+
"""
|
|
147
|
+
# Replay missed messages if last_event_id provided
|
|
148
|
+
if last_event_id:
|
|
149
|
+
missed = load_messages(since_id=last_event_id)
|
|
150
|
+
for msg in reversed(missed):
|
|
151
|
+
yield _format_sse(msg)
|
|
152
|
+
try:
|
|
153
|
+
while True:
|
|
154
|
+
try:
|
|
155
|
+
msg = await asyncio.wait_for(queue.get(), timeout=30)
|
|
156
|
+
yield _format_sse(msg)
|
|
157
|
+
except asyncio.TimeoutError:
|
|
158
|
+
# Send keepalive comment
|
|
159
|
+
yield b": keepalive\n\n"
|
|
160
|
+
except asyncio.CancelledError:
|
|
161
|
+
pass
|
|
162
|
+
finally:
|
|
163
|
+
unsubscribe(subscriber_id)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _format_sse(msg: dict) -> bytes:
|
|
167
|
+
"""Format a message dict as SSE bytes."""
|
|
168
|
+
data = json.dumps(msg, ensure_ascii=False)
|
|
169
|
+
lines = [
|
|
170
|
+
f"id: {msg.get('id', '')}",
|
|
171
|
+
f"event: message",
|
|
172
|
+
f"data: {data}",
|
|
173
|
+
"",
|
|
174
|
+
]
|
|
175
|
+
return "\n".join(lines).encode("utf-8") + b"\n"
|
|
Binary file
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Self-healing engine — integrates Watchdog + Supervisor for autonomous recovery.
|
|
2
|
+
|
|
3
|
+
Architecture:
|
|
4
|
+
Heartbeat (every 5s) → Watchdog (scans every 10s) → SelfHealer (decides action)
|
|
5
|
+
→ Supervisor (diagnose + fix) → Recovery (restart agents / skip tasks / notify)
|
|
6
|
+
|
|
7
|
+
Config can be toggled via environment variable CODEAGENT_SELF_HEALING_ENABLED (default: 1).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from codeagent.core.paths import codeagent_home
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_DEFAULT_SELF_HEALING_ENABLED = True
|
|
23
|
+
_DEFAULT_HEARTBEAT_TIMEOUT = 180
|
|
24
|
+
_DEFAULT_WATCHDOG_INTERVAL = 10
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
28
|
+
raw = os.environ.get(name)
|
|
29
|
+
if raw is None:
|
|
30
|
+
return default
|
|
31
|
+
v = str(raw).strip().lower()
|
|
32
|
+
if v in ("1", "true", "yes", "on"):
|
|
33
|
+
return True
|
|
34
|
+
if v in ("0", "false", "no", "off"):
|
|
35
|
+
return False
|
|
36
|
+
return default
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _env_int(name: str, default: int, *, min_value: int, max_value: int) -> int:
|
|
40
|
+
raw = os.environ.get(name)
|
|
41
|
+
try:
|
|
42
|
+
value = int(str(raw).strip()) if raw is not None else default
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
value = default
|
|
45
|
+
return max(min_value, min(value, max_value))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _heartbeat_timeout() -> int:
|
|
49
|
+
return _env_int(
|
|
50
|
+
"CODEAGENT_HEARTBEAT_TIMEOUT",
|
|
51
|
+
_DEFAULT_HEARTBEAT_TIMEOUT,
|
|
52
|
+
min_value=10,
|
|
53
|
+
max_value=3600,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def watchdog_interval() -> int:
|
|
58
|
+
return _env_int(
|
|
59
|
+
"CODEAGENT_WATCHDOG_INTERVAL",
|
|
60
|
+
_DEFAULT_WATCHDOG_INTERVAL,
|
|
61
|
+
min_value=1,
|
|
62
|
+
max_value=600,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_enabled() -> bool:
|
|
67
|
+
return _env_bool("CODEAGENT_SELF_HEALING_ENABLED", _DEFAULT_SELF_HEALING_ENABLED)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_heartbeat_path() -> Path:
|
|
71
|
+
return codeagent_home() / "heartbeat"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def heartbeat_age() -> float:
|
|
75
|
+
"""Return seconds since last heartbeat, or -1 if never."""
|
|
76
|
+
hb = get_heartbeat_path()
|
|
77
|
+
if not hb.is_file():
|
|
78
|
+
return -1
|
|
79
|
+
try:
|
|
80
|
+
last = float(hb.read_text(encoding="utf-8").strip())
|
|
81
|
+
except (ValueError, OSError):
|
|
82
|
+
return -1
|
|
83
|
+
return time.time() - last
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_health() -> dict:
|
|
87
|
+
"""Return health status dict."""
|
|
88
|
+
timeout = _heartbeat_timeout()
|
|
89
|
+
age = heartbeat_age()
|
|
90
|
+
status = "alive" if 0 < age < timeout else ("stuck" if age >= timeout else "unknown")
|
|
91
|
+
return {
|
|
92
|
+
"status": status,
|
|
93
|
+
"heartbeat_age": round(age, 1) if age >= 0 else None,
|
|
94
|
+
"timeout": timeout,
|
|
95
|
+
"healing_enabled": is_enabled(),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def diagnose() -> dict:
|
|
100
|
+
"""Run diagnostic checks and return findings."""
|
|
101
|
+
health = check_health()
|
|
102
|
+
findings = []
|
|
103
|
+
findings.append(f"heartbeat: {health['heartbeat_age']}s ago (timeout={health['timeout']}s)")
|
|
104
|
+
|
|
105
|
+
if health["status"] == "stuck":
|
|
106
|
+
findings.append("WARNING: agent appears stuck")
|
|
107
|
+
# Check process
|
|
108
|
+
try:
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
["ps", "-p", str(os.getpid()), "-o", "pid,stat,%cpu,%mem,etime", "--no-headers"],
|
|
111
|
+
capture_output=True, text=True, timeout=5,
|
|
112
|
+
)
|
|
113
|
+
findings.append(f"process: {result.stdout.strip()}")
|
|
114
|
+
except Exception as e:
|
|
115
|
+
findings.append(f"process check failed: {e}")
|
|
116
|
+
else:
|
|
117
|
+
findings.append("OK: agent is alive")
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
"healthy": health["status"] == "alive",
|
|
121
|
+
"status": health["status"],
|
|
122
|
+
"findings": findings,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def heal() -> dict:
|
|
127
|
+
"""Attempt to heal the agent."""
|
|
128
|
+
health = check_health()
|
|
129
|
+
actions = []
|
|
130
|
+
|
|
131
|
+
if health["status"] == "stuck":
|
|
132
|
+
actions.append("heartbeat too old, forcing heartbeat reset")
|
|
133
|
+
get_heartbeat_path().write_text(str(time.time()), encoding="utf-8")
|
|
134
|
+
actions.append("heartbeat reset applied")
|
|
135
|
+
logger.warning("self-healing: forced heartbeat reset")
|
|
136
|
+
else:
|
|
137
|
+
actions.append("no action needed")
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"healed": health["status"] == "stuck",
|
|
141
|
+
"status": health["status"],
|
|
142
|
+
"actions": actions,
|
|
143
|
+
}
|