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,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
+ }
@@ -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
+ }