debug-agent-py 0.2.1__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.
@@ -0,0 +1,132 @@
1
+ """Runtime inspector: GC, memory, threads, process info."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gc
6
+ import os
7
+ import sys
8
+ import threading
9
+ import time
10
+ import tracemalloc
11
+ from typing import Any
12
+
13
+ from debug_agent.tool_registry import debug_tool, ToolParam
14
+
15
+
16
+ @debug_tool("get_gc_stats", "Get garbage collector statistics: collection counts per generation")
17
+ def get_gc_stats() -> dict:
18
+ counts = gc.get_count() # (gen0, gen1, gen2) counts
19
+ return {
20
+ "generation_counts": {"gen0": counts[0], "gen1": counts[1], "gen2": counts[2]},
21
+ "garbage_count": len(gc.garbage),
22
+ "total_objects": len(gc.get_objects()),
23
+ }
24
+
25
+
26
+ @debug_tool("get_memory_summary", "Get process memory usage: RSS, VMS, and Python heap info")
27
+ def get_memory_summary() -> dict:
28
+ try:
29
+ import resource
30
+ rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
31
+ # macOS reports in bytes, Linux in KB
32
+ if sys.platform == "darwin":
33
+ rss_mb = rss / 1024 / 1024
34
+ else:
35
+ rss_mb = rss / 1024
36
+ except ImportError:
37
+ rss_mb = 0
38
+
39
+ import ctypes
40
+ info = {"rss_mb": round(rss_mb, 2)}
41
+
42
+ # Python object counts
43
+ counts = {}
44
+ for obj in gc.get_objects():
45
+ t = type(obj).__name__
46
+ counts[t] = counts.get(t, 0) + 1
47
+ top_types = sorted(counts.items(), key=lambda x: -x[1])[:15]
48
+ info["top_object_types"] = {t: c for t, c in top_types}
49
+ info["total_objects"] = sum(counts.values())
50
+
51
+ return info
52
+
53
+
54
+ @debug_tool("trigger_gc", "Trigger garbage collection and show before/after comparison")
55
+ def trigger_gc() -> dict:
56
+ before = len(gc.get_objects())
57
+ collected = gc.collect()
58
+ after = len(gc.get_objects())
59
+ return {
60
+ "objects_before": before,
61
+ "objects_collected": collected,
62
+ "objects_after": after,
63
+ "freed": before - after,
64
+ }
65
+
66
+
67
+ @debug_tool("get_thread_summary", "Get thread state overview: count, names, daemon status")
68
+ def get_thread_summary() -> dict:
69
+ threads = threading.enumerate()
70
+ return {
71
+ "total_threads": len(threads),
72
+ "threads": [
73
+ {"name": t.name, "ident": t.ident, "daemon": t.daemon, "alive": t.is_alive()}
74
+ for t in threads
75
+ ],
76
+ }
77
+
78
+
79
+ @debug_tool("get_thread_dump", "Get stack traces for all threads")
80
+ def get_thread_dump() -> dict:
81
+ frames = sys._current_frames()
82
+ result = {}
83
+ for tid, frame in frames.items():
84
+ stack = []
85
+ f = frame
86
+ while f:
87
+ stack.append({"file": f.f_code.co_filename, "line": f.f_lineno, "function": f.f_code.co_name})
88
+ f = f.f_back
89
+ # Find thread name
90
+ tname = "unknown"
91
+ for t in threading.enumerate():
92
+ if t.ident == tid:
93
+ tname = t.name
94
+ break
95
+ result[tname] = stack[:20]
96
+ return result
97
+
98
+
99
+ @debug_tool("get_runtime_info", "Get Python runtime info: version, platform, uptime")
100
+ def get_runtime_info() -> dict:
101
+ return {
102
+ "python_version": sys.version,
103
+ "platform": sys.platform,
104
+ "executable": sys.executable,
105
+ "pid": os.getpid(),
106
+ "argv": sys.argv,
107
+ "path_count": len(sys.path),
108
+ }
109
+
110
+
111
+ @debug_tool("get_memory_allocations", "Get top memory allocations using tracemalloc (if enabled)")
112
+ def get_memory_allocations() -> dict:
113
+ if not tracemalloc.is_tracing():
114
+ return {"error": "tracemalloc is not enabled. Call tracemalloc.start() to enable."}
115
+ snapshot = tracemalloc.take_snapshot()
116
+ top = snapshot.statistics("lineno")
117
+ return {
118
+ "top_allocations": [
119
+ {"file": str(s.traceback), "size_kb": round(s.size / 1024, 2), "count": s.count}
120
+ for s in top[:20]
121
+ ],
122
+ }
123
+
124
+
125
+ @debug_tool("get_tracemalloc_status", "Check if tracemalloc is enabled and its configuration")
126
+ def get_tracemalloc_status() -> dict:
127
+ return {
128
+ "enabled": tracemalloc.is_tracing(),
129
+ "traced_memory_mb": round(sum((c.size for c in tracemalloc.get_traced_memory())) / 1024 / 1024, 2)
130
+ if tracemalloc.is_tracing()
131
+ else 0,
132
+ }
@@ -0,0 +1,79 @@
1
+ """System inspector: process info, CPU, disk, network."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import socket
8
+ import sys
9
+ import time
10
+
11
+ from debug_agent.tool_registry import debug_tool, ToolParam
12
+
13
+
14
+ @debug_tool("get_process_info", "Get process info: PID, CPU usage, memory limits, container detection")
15
+ def get_process_info() -> dict:
16
+ try:
17
+ import resource
18
+ cpu_time = resource.getrusage(resource.RUSAGE_SELF)
19
+ cpu_user = cpu_time.ru_utime
20
+ cpu_sys = cpu_time.ru_stime
21
+ except ImportError:
22
+ cpu_user = cpu_sys = 0
23
+
24
+ # Container detection
25
+ in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
26
+ try:
27
+ with open("/proc/1/cgroup", "t") as f:
28
+ if "docker" in f.read() or "kubepods" in f.read():
29
+ in_container = True
30
+ except (FileNotFoundError, IOError):
31
+ pass
32
+
33
+ return {
34
+ "pid": os.getpid(),
35
+ "ppid": os.getppid(),
36
+ "cpu_time": {"user_seconds": round(cpu_user, 2), "system_seconds": round(cpu_sys, 2)},
37
+ "platform": platform.platform(),
38
+ "hostname": socket.gethostname(),
39
+ "in_container": in_container,
40
+ }
41
+
42
+
43
+ @debug_tool("get_system_info", "Get system information: OS, CPU cores, load average")
44
+ def get_system_info() -> dict:
45
+ return {
46
+ "os": platform.platform(),
47
+ "python_version": sys.version.split()[0],
48
+ "cpu_count": os.cpu_count(),
49
+ "load_average": _get_loadavg(),
50
+ }
51
+
52
+
53
+ def _get_loadavg() -> dict | None:
54
+ try:
55
+ avg = os.getloadavg()
56
+ return {"1min": avg[0], "5min": avg[1], "15min": avg[2]}
57
+ except (AttributeError, OSError):
58
+ return None
59
+
60
+
61
+ @debug_tool("get_disk_usage", "Get disk usage for current working directory")
62
+ def get_disk_usage() -> dict:
63
+ stat = os.statvfs(".")
64
+ total = stat.f_blocks * stat.f_frsize
65
+ free = stat.f_bavail * stat.f_frsize
66
+ return {
67
+ "total_gb": round(total / 1024**3, 2),
68
+ "free_gb": round(free / 1024**3, 2),
69
+ "used_pct": round((1 - free / total) * 100, 1),
70
+ }
71
+
72
+
73
+ @debug_tool("get_python_path", "Get Python module search paths")
74
+ def get_python_path() -> dict:
75
+ return {
76
+ "paths": sys.path,
77
+ "count": len(sys.path),
78
+ "site_packages": [p for p in sys.path if "site-packages" in p],
79
+ }
@@ -0,0 +1,97 @@
1
+ """Thread inspector: live thread info, counts, and per-thread tracebacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+ from typing import Any
8
+
9
+ from debug_agent.tool_registry import debug_tool, ToolParam
10
+
11
+
12
+ @debug_tool(
13
+ "get_thread_info",
14
+ "List all threads with name, daemon status, and alive status",
15
+ )
16
+ def get_thread_info() -> dict:
17
+ threads = threading.enumerate()
18
+ return {
19
+ "total_threads": len(threads),
20
+ "active_count": threading.active_count(),
21
+ "current_thread": threading.current_thread().name,
22
+ "threads": [
23
+ {
24
+ "name": t.name,
25
+ "ident": t.ident,
26
+ "daemon": t.daemon,
27
+ "alive": t.is_alive(),
28
+ "native_id": getattr(t, "native_id", None),
29
+ }
30
+ for t in threads
31
+ ],
32
+ }
33
+
34
+
35
+ @debug_tool(
36
+ "get_thread_count",
37
+ "Get the number of active threads in the process",
38
+ )
39
+ def get_thread_count() -> dict:
40
+ return {
41
+ "active_count": threading.active_count(),
42
+ "enumerated_count": len(threading.enumerate()),
43
+ "main_thread_alive": threading.main_thread().is_alive(),
44
+ }
45
+
46
+
47
+ @debug_tool(
48
+ "get_thread_traceback",
49
+ "Get the Python traceback (stack frames) for a specific thread by name or ID",
50
+ )
51
+ def get_thread_traceback(
52
+ thread_name: str = ToolParam("Thread name to look up (use 'MainThread' for main thread)", required=False),
53
+ thread_id: int = ToolParam("Thread identifier (OS-level TID) to look up", required=False),
54
+ ) -> dict:
55
+ frames = sys._current_frames()
56
+ thread_map = {t.ident: t for t in threading.enumerate()}
57
+
58
+ target_tid = None
59
+ matched_name = None
60
+
61
+ if thread_id is not None:
62
+ target_tid = thread_id
63
+ t = thread_map.get(thread_id)
64
+ matched_name = t.name if t else f"tid-{thread_id}"
65
+ elif thread_name:
66
+ for t in threading.enumerate():
67
+ if t.name == thread_name:
68
+ target_tid = t.ident
69
+ matched_name = t.name
70
+ break
71
+ if target_tid is None:
72
+ return {"error": f"No thread found with name '{thread_name}'"}
73
+ else:
74
+ # Default to current thread
75
+ target_tid = threading.current_thread().ident
76
+ matched_name = threading.current_thread().name
77
+
78
+ frame = frames.get(target_tid)
79
+ if not frame:
80
+ return {"error": f"No frame found for thread '{matched_name}' (tid={target_tid})"}
81
+
82
+ stack = []
83
+ f = frame
84
+ while f:
85
+ stack.append({
86
+ "file": f.f_code.co_filename,
87
+ "line": f.f_lineno,
88
+ "function": f.f_code.co_name,
89
+ })
90
+ f = f.f_back
91
+
92
+ return {
93
+ "thread_name": matched_name,
94
+ "thread_id": target_tid,
95
+ "stack_depth": len(stack),
96
+ "frames": stack[:30],
97
+ }
@@ -0,0 +1,195 @@
1
+ """OpenAI-compatible LLM client with real streaming, retry, and token usage tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import random
8
+ import time
9
+ from typing import Any, Callable
10
+
11
+ import httpx
12
+
13
+ from debug_agent.config import LLMConfig
14
+
15
+ logger = logging.getLogger("debug_agent")
16
+
17
+
18
+ class StreamHandler:
19
+ """Low-level stream callback interface (engine implements this)."""
20
+
21
+ def on_content(self, chunk: str): ...
22
+ def on_complete(self, tool_calls: list[dict], finish_reason: str | None, usage: dict | None): ...
23
+ def on_error(self, error: Exception): ...
24
+
25
+
26
+ class LLMClient:
27
+ """OpenAI-compatible chat client with real streaming and retry."""
28
+
29
+ def __init__(self, config: LLMConfig):
30
+ self.cfg = config
31
+ self.client = httpx.Client(timeout=config.timeout_seconds)
32
+
33
+ # ==================== Non-Streaming ====================
34
+
35
+ def chat(self, messages: list[dict], tools: list[dict] | None = None) -> dict:
36
+ """Non-streaming chat completion with retry."""
37
+ body: dict = {
38
+ "model": self.cfg.model,
39
+ "messages": messages,
40
+ "temperature": 0,
41
+ "max_tokens": 1024,
42
+ }
43
+ if tools:
44
+ body["tools"] = tools
45
+
46
+ return self._post_with_retry("/chat/completions", body)
47
+
48
+ # ==================== Streaming ====================
49
+
50
+ def chat_stream_raw(self, messages: list[dict], tools: list[dict] | None, tool_choice: Any, handler: StreamHandler):
51
+ """Streaming chat completion with retry. Calls handler callbacks."""
52
+ body: dict = {
53
+ "model": self.cfg.model,
54
+ "messages": messages,
55
+ "temperature": self.cfg.temperature,
56
+ "max_tokens": self.cfg.max_tokens,
57
+ "stream": True,
58
+ "stream_options": {"include_usage": True},
59
+ }
60
+ if tools:
61
+ body["tools"] = tools
62
+ if tool_choice:
63
+ body["tool_choice"] = tool_choice
64
+
65
+ max_retries = self.cfg.max_retries
66
+ last_error = None
67
+
68
+ for attempt in range(max_retries + 1):
69
+ try:
70
+ self._stream_request("/chat/completions", body, handler)
71
+ return
72
+ except Exception as e:
73
+ last_error = e
74
+ if self._is_retriable(e) and attempt < max_retries:
75
+ delay = self._calculate_delay(attempt)
76
+ time.sleep(delay / 1000.0)
77
+ continue
78
+ handler.on_error(e)
79
+ return
80
+
81
+ handler.on_error(Exception(f"Exhausted retries after {max_retries} attempts: {last_error}"))
82
+
83
+ # ==================== Stream Processing ====================
84
+
85
+ def _stream_request(self, path: str, body: dict, handler: StreamHandler):
86
+ url = f"{self.cfg.base_url}{path}"
87
+
88
+ with self.client.stream("POST", url, headers=self._headers(), json=body) as resp:
89
+ if resp.status_code >= 400:
90
+ error_body = resp.read().decode()
91
+ raise RetriableError(resp.status_code, f"HTTP {resp.status_code}: {error_body}")
92
+
93
+ tool_call_map: dict[int, dict] = {}
94
+ finish_reason = None
95
+ usage = None
96
+
97
+ for line in resp.iter_lines():
98
+ if not line or not line.startswith("data: "):
99
+ continue
100
+ data_str = line[6:]
101
+ if data_str.strip() == "[DONE]":
102
+ continue
103
+
104
+ try:
105
+ chunk = json.loads(data_str)
106
+ except json.JSONDecodeError:
107
+ continue
108
+
109
+ if chunk.get("usage") and chunk["usage"].get("prompt_tokens"):
110
+ usage = chunk["usage"]
111
+
112
+ choices = chunk.get("choices", [])
113
+ if not choices:
114
+ continue
115
+
116
+ choice = choices[0]
117
+ delta = choice.get("delta", {})
118
+
119
+ if delta.get("content"):
120
+ handler.on_content(delta["content"])
121
+
122
+ if delta.get("tool_calls"):
123
+ for tc in delta["tool_calls"]:
124
+ idx = tc.get("index", 0)
125
+ if idx not in tool_call_map:
126
+ tool_call_map[idx] = {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
127
+ entry = tool_call_map[idx]
128
+ if tc.get("id"):
129
+ entry["id"] = tc["id"]
130
+ if tc.get("type"):
131
+ entry["type"] = tc["type"]
132
+ fn = tc.get("function", {})
133
+ if fn.get("name"):
134
+ entry["function"]["name"] += fn["name"]
135
+ if fn.get("arguments") is not None:
136
+ entry["function"]["arguments"] += fn["arguments"]
137
+
138
+ if choice.get("finish_reason"):
139
+ finish_reason = choice["finish_reason"]
140
+
141
+ tool_calls = [tool_call_map[k] for k in sorted(tool_call_map.keys()) if tool_call_map[k]["function"]["name"]]
142
+ handler.on_complete(tool_calls, finish_reason, usage)
143
+
144
+ # ==================== Non-Streaming POST with retry ====================
145
+
146
+ def _post_with_retry(self, path: str, body: dict) -> dict:
147
+ max_retries = self.cfg.max_retries
148
+ last_error = None
149
+
150
+ for attempt in range(max_retries + 1):
151
+ try:
152
+ url = f"{self.cfg.base_url}{path}"
153
+ resp = self.client.post(url, headers=self._headers(), json=body)
154
+ if resp.status_code >= 400:
155
+ raise RetriableError(resp.status_code, f"HTTP {resp.status_code}: {resp.text}")
156
+ return resp.json()
157
+ except Exception as e:
158
+ last_error = e
159
+ if self._is_retriable(e) and attempt < max_retries:
160
+ delay = self._calculate_delay(attempt)
161
+ time.sleep(delay / 1000.0)
162
+ continue
163
+ raise
164
+
165
+ raise last_error
166
+
167
+ # ==================== Helpers ====================
168
+
169
+ def _headers(self) -> dict:
170
+ return {"Authorization": f"Bearer {self.cfg.api_key}", "Content-Type": "application/json"}
171
+
172
+ def _is_retriable(self, error: Exception) -> bool:
173
+ if isinstance(error, RetriableError):
174
+ return error.is_retriable()
175
+ return True # Network errors
176
+
177
+ def _calculate_delay(self, attempt: int) -> int:
178
+ base = self.cfg.retry_base_delay_ms * (2 ** attempt)
179
+ jitter = random.randint(0, base // 2)
180
+ delay = base + jitter
181
+ return min(delay, self.cfg.retry_max_delay_ms)
182
+
183
+ def close(self):
184
+ self.client.close()
185
+
186
+
187
+ class RetriableError(Exception):
188
+ """HTTP error that can be retried."""
189
+
190
+ def __init__(self, status_code: int, message: str):
191
+ super().__init__(message)
192
+ self.status_code = status_code
193
+
194
+ def is_retriable(self) -> bool:
195
+ return self.status_code in (429, 500, 502, 503, 504)
@@ -0,0 +1,199 @@
1
+ """Framework integration: FastAPI, Starlette, Flask middleware.
2
+
3
+ SSE events (Spring-aligned):
4
+ content, tool_start, tool_result, done, error, context_compressed
5
+
6
+ Endpoints:
7
+ GET /agent — Chat UI
8
+ POST /agent/api/chat — SSE streaming chat
9
+ POST /agent/api/clear — Clear conversation
10
+ GET /agent/api/health — Health check
11
+ GET /agent/api/tools — List available tools
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ from debug_agent.config import AgentConfig
20
+ from debug_agent.engine import DebugEngine, ChatCallback
21
+ from debug_agent.web.chat_page import render as render_chat_page
22
+
23
+
24
+ # ==================== SSE Callback ====================
25
+
26
+ class SseCallback(ChatCallback):
27
+ """Bridges engine callbacks to SSE event lines."""
28
+
29
+ def __init__(self):
30
+ self.events: list[tuple[str, str]] = []
31
+
32
+ def on_content(self, chunk: str):
33
+ self.events.append(("content", json.dumps(chunk)))
34
+
35
+ def on_tool_start(self, tool_name: str, args: str):
36
+ self.events.append(("tool_start", tool_name))
37
+
38
+ def on_tool_result(self, tool_name: str, result: str):
39
+ self.events.append(("tool_result", f"{tool_name}: {result}"))
40
+
41
+ def on_complete(self):
42
+ self.events.append(("done", ""))
43
+
44
+ def on_error(self, message: str):
45
+ self.events.append(("error", message))
46
+
47
+ def on_context_compressed(self, original_tokens: int, compressed_tokens: int, removed_rounds: int):
48
+ info = json.dumps({"originalTokens": original_tokens, "compressedTokens": compressed_tokens, "removedRounds": removed_rounds})
49
+ self.events.append(("context_compressed", info))
50
+
51
+
52
+ # ==================== FastAPI ====================
53
+
54
+ def create_fastapi_router(config: AgentConfig | None = None):
55
+ """Create a FastAPI APIRouter with all debug agent endpoints."""
56
+ from fastapi import APIRouter, Request
57
+ from fastapi.responses import HTMLResponse, StreamingResponse
58
+ from debug_agent import inspectors # noqa: F401
59
+
60
+ cfg = config or AgentConfig.from_env()
61
+ engine = DebugEngine(cfg)
62
+ router = APIRouter(prefix=cfg.base_path, tags=["Debug Agent"])
63
+
64
+ @router.get("", response_class=HTMLResponse)
65
+ async def chat_page():
66
+ return render_chat_page(cfg.base_path)
67
+
68
+ @router.get("/")
69
+ async def chat_page_slash():
70
+ return render_chat_page(cfg.base_path)
71
+
72
+ @router.post("/api/chat")
73
+ async def chat(request: Request):
74
+ body = await request.json()
75
+ message = body.get("message", "")
76
+ session_id = body.get("sessionId", f"session-{id(request)}")
77
+
78
+ def event_stream():
79
+ cb = SseCallback()
80
+ engine.chat(message, session_id, cb)
81
+ for event_type, data in cb.events:
82
+ yield f"event: {event_type}\ndata: {data}\n\n"
83
+
84
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
85
+
86
+ @router.post("/api/clear")
87
+ async def clear_conversation(request: Request):
88
+ body = await request.json()
89
+ session_id = body.get("sessionId", "")
90
+ if session_id:
91
+ engine.clear_session(session_id)
92
+ return {"status": "cleared"}
93
+
94
+ @router.get("/api/health")
95
+ async def health():
96
+ return {"status": "ok", "agent": "python-debug-agent"}
97
+
98
+ @router.get("/api/tools")
99
+ async def list_tools():
100
+ return {"tools": engine.tools.all_schemas()}
101
+
102
+ return router
103
+
104
+
105
+ # ==================== Starlette ====================
106
+
107
+ def create_starlette_app(config: AgentConfig | None = None):
108
+ from starlette.applications import Starlette
109
+ from starlette.responses import HTMLResponse, StreamingResponse, JSONResponse
110
+ from starlette.routing import Route
111
+ from debug_agent import inspectors # noqa: F401
112
+
113
+ cfg = config or AgentConfig.from_env()
114
+ engine = DebugEngine(cfg)
115
+
116
+ async def chat_page(request):
117
+ return HTMLResponse(render_chat_page(cfg.base_path))
118
+
119
+ async def chat(request):
120
+ body = await request.json()
121
+ message = body.get("message", "")
122
+ session_id = body.get("sessionId", f"session-{id(request)}")
123
+
124
+ def event_stream():
125
+ cb = SseCallback()
126
+ engine.chat(message, session_id, cb)
127
+ for event_type, data in cb.events:
128
+ yield f"event: {event_type}\ndata: {data}\n\n"
129
+
130
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
131
+
132
+ async def clear(request):
133
+ body = await request.json()
134
+ session_id = body.get("sessionId", "")
135
+ if session_id:
136
+ engine.clear_session(session_id)
137
+ return JSONResponse({"status": "cleared"})
138
+
139
+ async def health(request):
140
+ return JSONResponse({"status": "ok", "agent": "python-debug-agent"})
141
+
142
+ async def tools(request):
143
+ return JSONResponse({"tools": engine.tools.all_schemas()})
144
+
145
+ routes = [
146
+ Route("/", chat_page),
147
+ Route("/api/chat", chat, methods=["POST"]),
148
+ Route("/api/clear", clear, methods=["POST"]),
149
+ Route("/api/health", health),
150
+ Route("/api/tools", tools),
151
+ ]
152
+ return Starlette(routes=routes)
153
+
154
+
155
+ # ==================== Flask ====================
156
+
157
+ def create_flask_blueprint(config: AgentConfig | None = None):
158
+ from flask import Blueprint, Response, request, jsonify
159
+ from debug_agent import inspectors # noqa: F401
160
+
161
+ cfg = config or AgentConfig.from_env()
162
+ engine = DebugEngine(cfg)
163
+
164
+ bp = Blueprint("debug_agent", __name__, url_prefix=cfg.base_path)
165
+
166
+ @bp.route("", methods=["GET"])
167
+ @bp.route("/", methods=["GET"])
168
+ def chat_page():
169
+ return render_chat_page(cfg.base_path)
170
+
171
+ @bp.route("/api/chat", methods=["POST"])
172
+ def chat():
173
+ message = request.json.get("message", "")
174
+ session_id = request.json.get("sessionId", f"session-{id(request)}")
175
+
176
+ def generate():
177
+ cb = SseCallback()
178
+ engine.chat(message, session_id, cb)
179
+ for event_type, data in cb.events:
180
+ yield f"event: {event_type}\ndata: {data}\n\n"
181
+
182
+ return Response(generate(), mimetype="text/event-stream")
183
+
184
+ @bp.route("/api/clear", methods=["POST"])
185
+ def clear():
186
+ session_id = request.json.get("sessionId", "")
187
+ if session_id:
188
+ engine.clear_session(session_id)
189
+ return jsonify({"status": "cleared"})
190
+
191
+ @bp.route("/api/health", methods=["GET"])
192
+ def health():
193
+ return jsonify({"status": "ok", "agent": "python-debug-agent"})
194
+
195
+ @bp.route("/api/tools", methods=["GET"])
196
+ def tools():
197
+ return jsonify({"tools": engine.tools.all_schemas()})
198
+
199
+ return bp