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,23 @@
1
+ """Debug Agent — AI-powered runtime debugging for Python applications."""
2
+
3
+ from debug_agent.config import AgentConfig
4
+ from debug_agent.tool_registry import debug_tool, ToolParam, registry
5
+ from debug_agent.engine import DebugEngine, ChatCallback
6
+ from debug_agent.chat_session import ChatSession
7
+ from debug_agent.system_prompt_builder import SystemPromptBuilder
8
+ from debug_agent.context_compressor import ContextCompressor, CompressionResult
9
+ from debug_agent.llm_client import LLMClient, StreamHandler
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = [
13
+ "AgentConfig", "debug_tool", "ToolParam", "registry", "DebugEngine", "ChatCallback",
14
+ "ChatSession", "SystemPromptBuilder", "ContextCompressor", "CompressionResult",
15
+ "LLMClient", "StreamHandler", "setup",
16
+ ]
17
+
18
+
19
+ def setup(config: AgentConfig | None = None) -> DebugEngine:
20
+ """Initialize the debug agent and return the engine instance."""
21
+ from debug_agent import inspectors # noqa: F401 — triggers registration
22
+ cfg = config or AgentConfig.from_env()
23
+ return DebugEngine(cfg)
@@ -0,0 +1,45 @@
1
+ """Chat session management with token tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+
9
+ class ChatSession:
10
+ """Manages conversation history and cumulative token usage."""
11
+
12
+ def __init__(self, session_id: str):
13
+ self.session_id = session_id
14
+ self.created_at = time.time()
15
+ self.messages: list[dict[str, Any]] = []
16
+ self.last_active_at = self.created_at
17
+
18
+ self.last_token_usage: dict | None = None
19
+ self.cumulative_prompt_tokens: int = 0
20
+ self.cumulative_completion_tokens: int = 0
21
+
22
+ def add_message(self, message: dict[str, Any]):
23
+ self.messages.append(message)
24
+ self.last_active_at = time.time()
25
+
26
+ def replace_messages(self, new_messages: list[dict[str, Any]]):
27
+ self.messages = new_messages
28
+ self.last_active_at = time.time()
29
+
30
+ def record_token_usage(self, usage: dict | None):
31
+ if not usage:
32
+ return
33
+ self.last_token_usage = usage
34
+ self.cumulative_prompt_tokens = usage.get("prompt_tokens", 0)
35
+ self.cumulative_completion_tokens += usage.get("completion_tokens", 0)
36
+
37
+ def get_current_context_tokens(self) -> int:
38
+ return self.cumulative_prompt_tokens
39
+
40
+ def clear(self):
41
+ self.messages = []
42
+ self.last_token_usage = None
43
+ self.cumulative_prompt_tokens = 0
44
+ self.cumulative_completion_tokens = 0
45
+ self.last_active_at = time.time()
debug_agent/config.py ADDED
@@ -0,0 +1,48 @@
1
+ """Configuration for Debug Agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class LLMConfig:
11
+ base_url: str = "https://open.bigmodel.cn/api/coding/paas/v4"
12
+ api_key: str = ""
13
+ model: str = "glm-5.2"
14
+ temperature: float = 0.3
15
+ max_tokens: int = 4096
16
+ max_tool_rounds: int = 25
17
+ timeout_seconds: int = 120
18
+ max_retries: int = 3
19
+ retry_base_delay_ms: int = 1000
20
+ retry_max_delay_ms: int = 30000
21
+ context_window_tokens: int = 100000
22
+
23
+
24
+ @dataclass
25
+ class AgentConfig:
26
+ enabled: bool = True
27
+ base_path: str = "/agent"
28
+ llm: LLMConfig = field(default_factory=LLMConfig)
29
+
30
+ @classmethod
31
+ def from_env(cls) -> AgentConfig:
32
+ return cls(
33
+ enabled=os.getenv("DEBUG_AGENT_ENABLED", "true").lower() == "true",
34
+ base_path=os.getenv("DEBUG_AGENT_BASE_PATH", "/agent"),
35
+ llm=LLMConfig(
36
+ base_url=os.getenv("LLM_BASE_URL", "https://open.bigmodel.cn/api/coding/paas/v4"),
37
+ api_key=os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY", ""),
38
+ model=os.getenv("LLM_MODEL", "glm-5.2"),
39
+ temperature=float(os.getenv("LLM_TEMPERATURE", "0.3")),
40
+ max_tokens=int(os.getenv("LLM_MAX_TOKENS", "4096")),
41
+ max_tool_rounds=int(os.getenv("LLM_MAX_TOOL_ROUNDS", "25")),
42
+ timeout_seconds=int(os.getenv("LLM_TIMEOUT_SECONDS", "120")),
43
+ max_retries=int(os.getenv("LLM_MAX_RETRIES", "3")),
44
+ retry_base_delay_ms=int(os.getenv("LLM_RETRY_BASE_DELAY_MS", "1000")),
45
+ retry_max_delay_ms=int(os.getenv("LLM_RETRY_MAX_DELAY_MS", "30000")),
46
+ context_window_tokens=int(os.getenv("LLM_CONTEXT_WINDOW_TOKENS", "100000")),
47
+ ),
48
+ )
@@ -0,0 +1,157 @@
1
+ """Context compressor — summarizes older conversation rounds via LLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ from debug_agent.chat_session import ChatSession
10
+ from debug_agent.llm_client import LLMClient
11
+
12
+ logger = logging.getLogger("debug_agent")
13
+
14
+
15
+ class CompressionResult:
16
+ def __init__(self, original_tokens: int, compressed_tokens: int, removed_rounds: int, strategy: str):
17
+ self.original_tokens = original_tokens
18
+ self.compressed_tokens = compressed_tokens
19
+ self.removed_rounds = removed_rounds
20
+ self.strategy = strategy
21
+
22
+
23
+ class ContextCompressor:
24
+ def __init__(self, llm: LLMClient, model: str, temperature: float, max_context_tokens: int, recent_rounds_to_keep: int = 3):
25
+ self.llm = llm
26
+ self.model = model
27
+ self.temperature = temperature
28
+ self.max_context_tokens = max_context_tokens
29
+ self.recent_rounds_to_keep = recent_rounds_to_keep
30
+
31
+ def needs_compression(self, current_tokens: int) -> bool:
32
+ return current_tokens > self.max_context_tokens * 0.75
33
+
34
+ def compress(self, session: ChatSession) -> CompressionResult | None:
35
+ original_tokens = session.get_current_context_tokens()
36
+ if not self.needs_compression(original_tokens):
37
+ return None
38
+
39
+ rounds = self._identify_rounds(session.messages)
40
+
41
+ keep_count = min(self.recent_rounds_to_keep, len(rounds) - 1)
42
+ if keep_count < 1:
43
+ return None
44
+
45
+ summarize_count = len(rounds) - keep_count
46
+
47
+ to_summarize = []
48
+ for i in range(summarize_count):
49
+ to_summarize.extend(rounds[i])
50
+
51
+ to_keep = []
52
+ for i in range(summarize_count, len(rounds)):
53
+ to_keep.extend(rounds[i])
54
+
55
+ try:
56
+ summary = self._summarize_with_llm(to_summarize)
57
+ except Exception as e:
58
+ logger.warning(f"LLM summarization failed: {e}")
59
+ summary = self._fallback_truncate(to_summarize)
60
+
61
+ compressed = [
62
+ {"role": "system", "content": f"[Previous conversation summary — {summarize_count} rounds compressed]\n\n{summary}"},
63
+ *to_keep,
64
+ ]
65
+ compressed_tokens = self._estimate_tokens(compressed)
66
+ session.replace_messages(compressed)
67
+
68
+ return CompressionResult(original_tokens, compressed_tokens, summarize_count, f"LLM summarized {summarize_count} rounds")
69
+
70
+ def _summarize_with_llm(self, old_messages: list[dict]) -> str:
71
+ conversation_text = ""
72
+ for msg in old_messages:
73
+ role = msg.get("role", "")
74
+ content = msg.get("content", "")
75
+ if role == "user":
76
+ conversation_text += f"[User] {content}\n\n"
77
+ elif role == "assistant":
78
+ if content:
79
+ conversation_text += f"[Assistant] {content}\n\n"
80
+ for tc in msg.get("tool_calls", []):
81
+ fn = tc.get("function", {})
82
+ conversation_text += f"[Tool Call] {fn.get('name', '')}({fn.get('arguments', '')})\n\n"
83
+ elif role == "tool":
84
+ if len(content) > 2000:
85
+ content = content[:2000] + "...[truncated]"
86
+ conversation_text += f"[Tool Result] {content}\n\n"
87
+
88
+ prompt = """You are a conversation summarizer for a Python debugging assistant.
89
+ Summarize the KEY diagnostic findings from the conversation below concisely.
90
+
91
+ Focus on preserving:
92
+ - Problems investigated and their root causes (if found)
93
+ - Key tool results: actual numbers, statuses, error messages, configuration values
94
+ - Recommendations or fixes already suggested
95
+ - Any unresolved issues or follow-up actions pending
96
+
97
+ Rules:
98
+ - Be concise but preserve ALL important data points
99
+ - Use bullet points
100
+ - Do NOT include full JSON dumps
101
+ - Keep it under 600 words"""
102
+
103
+ response = self.llm.chat(
104
+ [
105
+ {"role": "system", "content": prompt},
106
+ {"role": "user", "content": f"Conversation to summarize:\n\n{conversation_text}"},
107
+ ],
108
+ tools=None,
109
+ )
110
+ return response["choices"][0]["message"]["content"]
111
+
112
+ def _fallback_truncate(self, messages: list[dict]) -> str:
113
+ sb = "Previous conversation summary (fallback):\n\n"
114
+ for msg in messages:
115
+ if msg.get("role") == "user" and msg.get("content"):
116
+ q = msg["content"][:100] + "..." if len(msg["content"]) > 100 else msg["content"]
117
+ sb += f"- User asked: {q}\n"
118
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
119
+ for tc in msg["tool_calls"]:
120
+ sb += f"- Called tool: {tc.get('function', {}).get('name', '')}\n"
121
+ return sb
122
+
123
+ def _identify_rounds(self, messages: list[dict]) -> list[list[dict]]:
124
+ rounds = []
125
+ current = []
126
+ has_assistant = False
127
+
128
+ for msg in messages:
129
+ role = msg.get("role", "")
130
+ if role == "user":
131
+ if current:
132
+ rounds.append(current)
133
+ current = []
134
+ has_assistant = False
135
+ current.append(msg)
136
+ elif role == "assistant":
137
+ if has_assistant:
138
+ rounds.append(current)
139
+ current = []
140
+ has_assistant = False
141
+ current.append(msg)
142
+ has_assistant = True
143
+ else:
144
+ current.append(msg)
145
+
146
+ if current:
147
+ rounds.append(current)
148
+ return rounds
149
+
150
+ def _estimate_tokens(self, messages: list[dict]) -> int:
151
+ chars = 0
152
+ for msg in messages:
153
+ chars += len(msg.get("content", "") or "")
154
+ for tc in msg.get("tool_calls", []):
155
+ fn = tc.get("function", {})
156
+ chars += len(fn.get("name", "")) + len(fn.get("arguments", ""))
157
+ return chars // 4
debug_agent/engine.py ADDED
@@ -0,0 +1,180 @@
1
+ """Debug engine — the reasoning loop that connects LLM to tools.
2
+
3
+ Spring-aligned: dynamic system prompt, ChatSession, ContextCompressor, real streaming.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ from typing import Any
11
+
12
+ from debug_agent.chat_session import ChatSession
13
+ from debug_agent.config import AgentConfig
14
+ from debug_agent.context_compressor import ContextCompressor
15
+ from debug_agent.llm_client import LLMClient, StreamHandler
16
+ from debug_agent.system_prompt_builder import SystemPromptBuilder
17
+ from debug_agent.tool_registry import registry
18
+
19
+ logger = logging.getLogger("debug_agent")
20
+
21
+
22
+ class ChatCallback:
23
+ """Engine to UI streaming callback."""
24
+
25
+ def on_content(self, chunk: str): ...
26
+ def on_tool_start(self, tool_name: str, args: str): ...
27
+ def on_tool_result(self, tool_name: str, result: str): ...
28
+ def on_complete(self): ...
29
+ def on_error(self, message: str): ...
30
+ def on_context_compressed(self, original_tokens: int, compressed_tokens: int, removed_rounds: int): ...
31
+
32
+
33
+ class _EngineStreamHandler(StreamHandler):
34
+ """StreamHandler implementation used internally by the engine."""
35
+
36
+ def __init__(self, cb: ChatCallback):
37
+ self._cb = cb
38
+ self.tool_calls: list[dict] = []
39
+ self.usage: dict | None = None
40
+ self.had_error = False
41
+ self.content = ""
42
+
43
+ def on_content(self, chunk: str):
44
+ self.content += chunk
45
+ self._cb.on_content(chunk)
46
+
47
+ def on_complete(self, tool_calls: list[dict], finish_reason: str | None, usage: dict | None):
48
+ self.tool_calls = tool_calls
49
+ self.usage = usage
50
+
51
+ def on_error(self, error: Exception):
52
+ self.had_error = True
53
+ self._cb.on_error(f"LLM API error: {error}")
54
+
55
+
56
+ class DebugEngine:
57
+ """Orchestrates the LLM Tool reasoning loop."""
58
+
59
+ def __init__(self, config: AgentConfig):
60
+ self.config = config
61
+ self.llm = LLMClient(config.llm)
62
+ self.tools = registry
63
+ self._sessions: dict[str, ChatSession] = {}
64
+
65
+ self.prompt_builder = SystemPromptBuilder(registry)
66
+ self.system_prompt = self.prompt_builder.build()
67
+ self.context_compressor = ContextCompressor(
68
+ self.llm, config.llm.model, config.llm.temperature, config.llm.context_window_tokens,
69
+ )
70
+
71
+ def chat(self, message: str, session_id: str = "default", callback: ChatCallback | None = None):
72
+ """Process a user message with streaming via callback."""
73
+ if callback is None:
74
+ callback = ChatCallback()
75
+
76
+ session = self._get_or_create_session(session_id)
77
+ session.add_message({"role": "user", "content": message})
78
+ self._run_tool_loop(session, callback)
79
+
80
+ def clear_session(self, session_id: str = "default"):
81
+ session = self._sessions.get(session_id)
82
+ if session:
83
+ session.clear()
84
+
85
+ def _get_or_create_session(self, session_id: str) -> ChatSession:
86
+ if session_id not in self._sessions:
87
+ self._sessions[session_id] = ChatSession(session_id)
88
+ return self._sessions[session_id]
89
+
90
+ # ==================== Core Tool-Calling Loop ====================
91
+
92
+ def _run_tool_loop(self, session: ChatSession, cb: ChatCallback):
93
+ max_rounds = self.config.llm.max_tool_rounds
94
+
95
+ for round_num in range(max_rounds):
96
+ # Context compression check
97
+ if round_num > 0 and self.context_compressor.needs_compression(session.get_current_context_tokens()):
98
+ result = self.context_compressor.compress(session)
99
+ if result:
100
+ cb.on_content(
101
+ f"\n\n> [Context auto-compressed: {result.original_tokens}"
102
+ f" -> ~{result.compressed_tokens} tokens ({result.strategy})]\n\n"
103
+ )
104
+ cb.on_context_compressed(result.original_tokens, result.compressed_tokens, result.removed_rounds)
105
+
106
+ messages = [{"role": "system", "content": self.system_prompt}] + session.messages
107
+ tool_schemas = self.tools.all_schemas()
108
+
109
+ handler = _EngineStreamHandler(cb)
110
+ self.llm.chat_stream_raw(messages, tool_schemas, "auto", handler)
111
+
112
+ if handler.had_error:
113
+ return
114
+
115
+ if handler.usage:
116
+ session.record_token_usage(handler.usage)
117
+
118
+ if not handler.tool_calls:
119
+ # If empty content after tool calls, prompt LLM to summarize
120
+ if not handler.content.strip() and round_num > 0:
121
+ session.add_message({
122
+ "role": "user",
123
+ "content": (
124
+ "Based on all the diagnostic data you've gathered from the tools above, "
125
+ "please provide a comprehensive analysis of the findings and "
126
+ "actionable recommendations."
127
+ ),
128
+ })
129
+ messages = [{"role": "system", "content": self.system_prompt}] + session.messages
130
+ summarize_handler = _EngineStreamHandler(cb)
131
+ self.llm.chat_stream_raw(messages, [], "none", summarize_handler)
132
+ session.add_message({"role": "assistant", "content": summarize_handler.content})
133
+ else:
134
+ session.add_message({"role": "assistant", "content": handler.content})
135
+ cb.on_complete()
136
+ return
137
+
138
+ # Execute tool calls
139
+ session.add_message({
140
+ "role": "assistant",
141
+ "content": handler.content,
142
+ "tool_calls": handler.tool_calls,
143
+ })
144
+
145
+ for tc in handler.tool_calls:
146
+ tool_name = tc["function"]["name"]
147
+ try:
148
+ args = json.loads(tc["function"]["arguments"] or "{}")
149
+ except json.JSONDecodeError:
150
+ args = {}
151
+
152
+ cb.on_tool_start(tool_name, tc["function"]["arguments"])
153
+
154
+ result = self.tools.execute(tool_name, args)
155
+ result_str = json.dumps(result, default=str, ensure_ascii=False)
156
+ if len(result_str) > 12000:
157
+ result_str = result_str[:12000]
158
+
159
+ cb.on_tool_result(tool_name, result_str)
160
+ session.add_message({
161
+ "role": "tool",
162
+ "tool_call_id": tc["id"],
163
+ "content": result_str,
164
+ })
165
+
166
+ # Max rounds - force final summary
167
+ final_messages = [{"role": "system", "content": self.system_prompt}] + session.messages
168
+ final_messages.append({
169
+ "role": "system",
170
+ "content": (
171
+ "You have reached the maximum number of tool-calling rounds. "
172
+ "Based on all the diagnostic data you have gathered so far, "
173
+ "provide a comprehensive analysis and actionable recommendations NOW. "
174
+ "Do not attempt to call more tools."
175
+ ),
176
+ })
177
+
178
+ handler = _EngineStreamHandler(cb)
179
+ self.llm.chat_stream_raw(final_messages, [], "none", handler)
180
+ cb.on_complete()
@@ -0,0 +1,13 @@
1
+ """All built-in inspectors are auto-registered when this package is imported."""
2
+
3
+ from debug_agent.inspectors import ( # noqa: F401
4
+ async_tasks,
5
+ database,
6
+ framework,
7
+ http_tracker,
8
+ memory,
9
+ modules,
10
+ runtime,
11
+ system,
12
+ threads,
13
+ )
@@ -0,0 +1,108 @@
1
+ """Async task inspector: pending asyncio tasks and event loop details."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from typing import Any
8
+
9
+ from debug_agent.tool_registry import debug_tool, ToolParam
10
+
11
+
12
+ def _get_running_loop() -> asyncio.AbstractEventLoop | None:
13
+ """Try to get the currently running event loop."""
14
+ try:
15
+ return asyncio.get_running_loop()
16
+ except RuntimeError:
17
+ # No running loop in this thread
18
+ pass
19
+ try:
20
+ return asyncio.get_event_loop()
21
+ except RuntimeError:
22
+ return None
23
+
24
+
25
+ @debug_tool(
26
+ "get_async_tasks",
27
+ "List pending asyncio tasks if running in async mode",
28
+ )
29
+ def get_async_tasks() -> dict:
30
+ loop = _get_running_loop()
31
+ if loop is None:
32
+ return {
33
+ "message": "No running asyncio event loop found. The app may be using synchronous mode.",
34
+ "has_running_loop": False,
35
+ }
36
+
37
+ try:
38
+ all_tasks = asyncio.all_tasks(loop=loop)
39
+ except RuntimeError:
40
+ return {"error": "Could not enumerate asyncio tasks"}
41
+
42
+ tasks_info = []
43
+ for task in all_tasks:
44
+ coro = task.get_coro() if hasattr(task, "get_coro") else None
45
+ tasks_info.append({
46
+ "name": task.get_name() if hasattr(task, "get_name") else str(task),
47
+ "done": task.done(),
48
+ "cancelled": task.cancelled(),
49
+ "coroutine": str(coro) if coro else None,
50
+ "stack_repr": repr(task),
51
+ })
52
+
53
+ return {
54
+ "has_running_loop": True,
55
+ "total_tasks": len(all_tasks),
56
+ "pending_tasks": sum(1 for t in all_tasks if not t.done()),
57
+ "done_tasks": sum(1 for t in all_tasks if t.done()),
58
+ "tasks": tasks_info[:50],
59
+ }
60
+
61
+
62
+ @debug_tool(
63
+ "get_event_loop_info",
64
+ "Get asyncio event loop details: type, running state, debug mode",
65
+ )
66
+ def get_event_loop_info() -> dict:
67
+ loop = _get_running_loop()
68
+ if loop is None:
69
+ return {
70
+ "message": "No running asyncio event loop found",
71
+ "has_running_loop": False,
72
+ }
73
+
74
+ info = {
75
+ "has_running_loop": True,
76
+ "loop_type": type(loop).__name__,
77
+ "is_running": loop.is_running(),
78
+ "is_closed": loop.is_closed(),
79
+ "debug_mode": loop.get_debug(),
80
+ }
81
+
82
+ # Try to get additional details
83
+ try:
84
+ info["time"] = loop.time()
85
+ except Exception:
86
+ pass
87
+
88
+ # Async generator count
89
+ try:
90
+ info["async_generator_count"] = len(loop._asyncgens) if hasattr(loop, "_asyncgens") else 0
91
+ except Exception:
92
+ pass
93
+
94
+ # Check for uvloop
95
+ try:
96
+ import uvloop
97
+ info["using_uvloop"] = isinstance(loop, uvloop.Loop)
98
+ except ImportError:
99
+ info["using_uvloop"] = False
100
+
101
+ # Task count
102
+ try:
103
+ all_tasks = asyncio.all_tasks(loop=loop)
104
+ info["active_task_count"] = len(all_tasks)
105
+ except RuntimeError:
106
+ info["active_task_count"] = 0
107
+
108
+ return info