codenaut 0.1.0__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 (63) hide show
  1. agent/__init__.py +1 -0
  2. agent/agent.py +236 -0
  3. agent/events.py +145 -0
  4. agent/persistence.py +123 -0
  5. agent/session.py +99 -0
  6. client/__init__.py +1 -0
  7. client/llm_client.py +266 -0
  8. client/response.py +88 -0
  9. codenaut-0.1.0.dist-info/METADATA +199 -0
  10. codenaut-0.1.0.dist-info/RECORD +63 -0
  11. codenaut-0.1.0.dist-info/WHEEL +5 -0
  12. codenaut-0.1.0.dist-info/entry_points.txt +3 -0
  13. codenaut-0.1.0.dist-info/licenses/LICENSE +21 -0
  14. codenaut-0.1.0.dist-info/top_level.txt +12 -0
  15. config/__init__.py +1 -0
  16. config/config.py +173 -0
  17. config/loader.py +173 -0
  18. context/__init__.py +1 -0
  19. context/compaction.py +96 -0
  20. context/loop_detector.py +49 -0
  21. context/manager.py +257 -0
  22. hooks/__init__.py +1 -0
  23. hooks/hook_system.py +140 -0
  24. main.py +422 -0
  25. prompts/__init__.py +1 -0
  26. prompts/system.py +380 -0
  27. safety/__init__.py +1 -0
  28. safety/approval.py +157 -0
  29. skills/__init__.py +9 -0
  30. skills/discovery.py +97 -0
  31. skills/models.py +14 -0
  32. skills/registry.py +56 -0
  33. tools/__init__.py +1 -0
  34. tools/base.py +200 -0
  35. tools/builtin/__init__.py +67 -0
  36. tools/builtin/apply_patch.py +388 -0
  37. tools/builtin/edit_file.py +211 -0
  38. tools/builtin/glob.py +80 -0
  39. tools/builtin/grep.py +117 -0
  40. tools/builtin/list_dir.py +60 -0
  41. tools/builtin/memory.py +115 -0
  42. tools/builtin/read_file.py +133 -0
  43. tools/builtin/shell.py +160 -0
  44. tools/builtin/tasks.py +336 -0
  45. tools/builtin/web_fetch.py +55 -0
  46. tools/builtin/web_search.py +60 -0
  47. tools/builtin/write_file.py +111 -0
  48. tools/discovery.py +70 -0
  49. tools/mcp/__init__.py +1 -0
  50. tools/mcp/client.py +127 -0
  51. tools/mcp/mcp_manager.py +83 -0
  52. tools/mcp/mcp_tool.py +51 -0
  53. tools/registry.py +156 -0
  54. tools/subagents.py +148 -0
  55. ui/__init__.py +12 -0
  56. ui/completer.py +160 -0
  57. ui/prompt.py +65 -0
  58. ui/tui.py +669 -0
  59. utils/__init__.py +1 -0
  60. utils/errors.py +49 -0
  61. utils/file_tags.py +83 -0
  62. utils/paths.py +40 -0
  63. utils/text.py +79 -0
agent/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core agent runtime package."""
agent/agent.py ADDED
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from typing import AsyncGenerator, Awaitable, Callable
4
+ from agent.events import AgentEvent, AgentEventType
5
+ from agent.session import Session
6
+ from client.response import StreamEventType, TokenUsage, ToolCall, ToolResultMessage
7
+ from config.config import Config
8
+ from prompts.system import create_loop_breaker_prompt
9
+ from tools.base import ToolConfirmation
10
+ from langsmith import trace
11
+
12
+ class Agent:
13
+ def __init__(
14
+ self,
15
+ config: Config,
16
+ confirmation_callback: Callable[[ToolConfirmation], bool] | None = None,
17
+ ):
18
+ self.config = config
19
+ self.session: Session | None = Session(self.config)
20
+ self.session.approval_manager.confirmation_callback = confirmation_callback
21
+
22
+ async def run(self, message: str):
23
+ with trace(
24
+ name="agent.run",
25
+ run_type="chain",
26
+ inputs={"message": message},
27
+ metadata={"model": self.config.model_name},
28
+ ) as run:
29
+ await self.session.hook_system.trigger_before_agent(message)
30
+ yield AgentEvent.agent_start(message)
31
+ self.session.context_manager.add_user_message(message)
32
+
33
+ final_response: str | None = None
34
+
35
+ async for event in self._agentic_loop(message):
36
+ yield event
37
+
38
+ if event.type == AgentEventType.TEXT_COMPLETE:
39
+ final_response = event.data.get("content")
40
+
41
+ # Patch the output onto the run before the span closes
42
+ run.end(outputs={"response": final_response})
43
+
44
+ await self.session.hook_system.trigger_after_agent(message, final_response)
45
+ yield AgentEvent.agent_end(final_response)
46
+
47
+ def _should_enable_tools(self, message: str) -> bool:
48
+ normalized = " ".join(message.lower().strip().split())
49
+ if not normalized:
50
+ return False
51
+
52
+ simple_messages = {
53
+ "hi",
54
+ "hii",
55
+ "hiii",
56
+ "hello",
57
+ "hey",
58
+ "hlo",
59
+ "yo",
60
+ "sup",
61
+ "thanks",
62
+ "thank you",
63
+ "ok",
64
+ "okay",
65
+ }
66
+
67
+ return normalized not in simple_messages
68
+
69
+ async def _agentic_loop(self, latest_user_message: str) -> AsyncGenerator[AgentEvent, None]:
70
+ max_turns = self.config.max_turns
71
+ enable_tools = self._should_enable_tools(latest_user_message)
72
+
73
+ for turn_num in range(max_turns):
74
+ self.session.increment_turn()
75
+ response_text = ""
76
+
77
+ # check for context overflow
78
+ if self.session.context_manager.needs_compression():
79
+ yield AgentEvent.compaction_start(
80
+ self.session.context_manager.message_count
81
+ )
82
+ summary, usage = await self.session.chat_compactor.compress(
83
+ self.session.context_manager
84
+ )
85
+
86
+ if summary:
87
+ self.session.context_manager.replace_with_summary(summary)
88
+ self.session.context_manager.set_latest_usage(usage)
89
+ self.session.context_manager.add_usage(usage)
90
+ yield AgentEvent.compaction_complete(
91
+ self.session.context_manager.message_count,
92
+ usage,
93
+ )
94
+ else:
95
+ yield AgentEvent.compaction_error(
96
+ self.session.chat_compactor.last_error
97
+ or "Compaction failed."
98
+ )
99
+
100
+ tool_schemas = self.session.tool_registry.get_schemas() if enable_tools else []
101
+
102
+ tool_calls: list[ToolCall] = []
103
+ usage: TokenUsage | None = None
104
+
105
+ async for event in self.session.client.chat_completion(
106
+ self.session.context_manager.get_messages(),
107
+ tools=tool_schemas if tool_schemas else None,
108
+ ):
109
+ if event.type == StreamEventType.TEXT_DELTA:
110
+ if event.text_delta:
111
+ content = event.text_delta.content
112
+ response_text += content
113
+ yield AgentEvent.text_delta(content)
114
+ elif event.type == StreamEventType.TOOL_CALL_COMPLETE:
115
+ if event.tool_call:
116
+ tool_calls.append(event.tool_call)
117
+ elif event.type == StreamEventType.ERROR:
118
+ yield AgentEvent.agent_error(
119
+ event.error or "Unknown error occurred.",
120
+ )
121
+ elif event.type == StreamEventType.MESSAGE_COMPLETE:
122
+ usage = event.usage
123
+
124
+ self.session.context_manager.add_assistant_message(
125
+ response_text or None,
126
+ (
127
+ [
128
+ {
129
+ "id": tc.call_id,
130
+ "type": "function",
131
+ "function": {
132
+ "name": tc.name,
133
+ "arguments": json.dumps(tc.arguments),
134
+ },
135
+ }
136
+ for tc in tool_calls
137
+ ]
138
+ if tool_calls
139
+ else None
140
+ ),
141
+ )
142
+ if response_text:
143
+ yield AgentEvent.text_complete(response_text)
144
+ self.session.loop_detector.record_action(
145
+ "response",
146
+ text=response_text,
147
+ )
148
+
149
+ if not tool_calls:
150
+ if usage:
151
+ self.session.context_manager.set_latest_usage(usage)
152
+ self.session.context_manager.add_usage(usage)
153
+
154
+ self.session.context_manager.prune_tool_outputs()
155
+ return
156
+
157
+ enable_tools = True
158
+
159
+ tool_call_results: list[ToolResultMessage] = []
160
+
161
+ for tool_call in tool_calls:
162
+ with trace(
163
+ name=f"tool.{tool_call.name}",
164
+ run_type="tool",
165
+ inputs={
166
+ "tool_name": tool_call.name,
167
+ "arguments": tool_call.arguments,
168
+ },
169
+ ) as tool_run:
170
+ yield AgentEvent.tool_call_start(
171
+ tool_call.call_id,
172
+ tool_call.name,
173
+ tool_call.arguments,
174
+ )
175
+
176
+ self.session.loop_detector.record_action(
177
+ "tool_call",
178
+ tool_name=tool_call.name,
179
+ args=tool_call.arguments,
180
+ )
181
+
182
+ result = await self.session.tool_registry.invoke(
183
+ tool_call.name,
184
+ tool_call.arguments,
185
+ self.config.cwd,
186
+ self.session.hook_system,
187
+ self.session.approval_manager,
188
+ )
189
+
190
+ yield AgentEvent.tool_call_complete(
191
+ tool_call.call_id,
192
+ tool_call.name,
193
+ result,
194
+ )
195
+
196
+ tool_call_results.append(
197
+ ToolResultMessage(
198
+ tool_call_id=tool_call.call_id,
199
+ content=result.to_model_output(),
200
+ is_error=not result.success,
201
+ )
202
+ )
203
+
204
+ for tool_result in tool_call_results:
205
+ self.session.context_manager.add_tool_result(
206
+ tool_result.tool_call_id,
207
+ tool_result.content,
208
+ )
209
+
210
+ loop_detection_error = self.session.loop_detector.check_for_loop()
211
+ if loop_detection_error:
212
+ loop_prompt = create_loop_breaker_prompt(loop_detection_error)
213
+ self.session.context_manager.add_user_message(loop_prompt)
214
+
215
+ if usage:
216
+ self.session.context_manager.set_latest_usage(usage)
217
+ self.session.context_manager.add_usage(usage)
218
+
219
+ self.session.context_manager.prune_tool_outputs()
220
+
221
+ yield AgentEvent.agent_error(f"Maximum turns ({max_turns}) reached")
222
+
223
+ async def __aenter__(self) -> Agent:
224
+ await self.session.initialize()
225
+ return self
226
+
227
+ async def __aexit__(
228
+ self,
229
+ exc_type,
230
+ exc_val,
231
+ exc_tb,
232
+ ) -> None:
233
+ if self.session and self.session.client and self.session.mcp_manager:
234
+ await self.session.client.close()
235
+ await self.session.mcp_manager.shutdown()
236
+ self.session = None
agent/events.py ADDED
@@ -0,0 +1,145 @@
1
+ #If the method's job is to create or configure objects for the class itself, use @classmethod. If the method's job is to work on one specific object, use an instance method.
2
+
3
+
4
+ from __future__ import annotations
5
+ from enum import Enum
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from client.response import TokenUsage
10
+ from tools.base import ToolResult
11
+
12
+
13
+ class AgentEventType(str, Enum):
14
+ # Agent lifecycle
15
+ AGENT_START = "agent_start"
16
+ AGENT_END = "agent_end"
17
+ AGENT_ERROR = "agent_error"
18
+
19
+ # Tool calls
20
+ TOOL_CALL_START = "tool_call_start"
21
+ TOOL_CALL_COMPLETE = "tool_call_complete"
22
+
23
+ # Text streaming
24
+ TEXT_DELTA = "text_delta"
25
+ TEXT_COMPLETE = "text_complete"
26
+
27
+ # Context management
28
+ COMPACTION_START = "compaction_start"
29
+ COMPACTION_COMPLETE = "compaction_complete"
30
+ COMPACTION_ERROR = "compaction_error"
31
+
32
+
33
+ @dataclass
34
+ class AgentEvent:
35
+ type: AgentEventType
36
+ data: dict[str, Any] = field(default_factory=dict)
37
+
38
+ @classmethod
39
+ def agent_start(cls, message: str) -> AgentEvent:
40
+ return cls(
41
+ type=AgentEventType.AGENT_START,
42
+ data={"message": message},
43
+ )
44
+
45
+
46
+ #__dict__ is used to convert class var into the the json object
47
+
48
+
49
+ @classmethod
50
+ def agent_end(
51
+ cls,
52
+ response: str | None = None,
53
+ usage: TokenUsage | None = None,
54
+ ) -> AgentEvent:
55
+ return cls(
56
+ type=AgentEventType.AGENT_END,
57
+ data={
58
+ "response": response,
59
+ "usage": usage.__dict__ if usage else None,
60
+ },
61
+ )
62
+
63
+ @classmethod
64
+ def agent_error(
65
+ cls,
66
+ error: str,
67
+ details: dict[str, Any] | None = None,
68
+ ) -> AgentEvent:
69
+ return cls(
70
+ type=AgentEventType.AGENT_ERROR,
71
+ data={"error": error, "details": details or {}},
72
+ )
73
+
74
+ @classmethod
75
+ def text_delta(cls, content: str) -> AgentEvent:
76
+ return cls(
77
+ type=AgentEventType.TEXT_DELTA,
78
+ data={"content": content},
79
+ )
80
+
81
+ @classmethod
82
+ def text_complete(cls, content: str) -> AgentEvent:
83
+ return cls(
84
+ type=AgentEventType.TEXT_COMPLETE,
85
+ data={"content": content},
86
+ )
87
+
88
+ @classmethod
89
+ def compaction_start(cls, message_count: int) -> AgentEvent:
90
+ return cls(
91
+ type=AgentEventType.COMPACTION_START,
92
+ data={"message_count": message_count},
93
+ )
94
+
95
+ @classmethod
96
+ def compaction_complete(
97
+ cls, message_count: int, usage: TokenUsage | None = None
98
+ ) -> AgentEvent:
99
+ return cls(
100
+ type=AgentEventType.COMPACTION_COMPLETE,
101
+ data={
102
+ "message_count": message_count,
103
+ "usage": usage.__dict__ if usage else None,
104
+ },
105
+ )
106
+
107
+ @classmethod
108
+ def compaction_error(cls, error: str) -> AgentEvent:
109
+ return cls(
110
+ type=AgentEventType.COMPACTION_ERROR,
111
+ data={"error": error},
112
+ )
113
+
114
+ @classmethod
115
+ def tool_call_start(cls, call_id: str, name: str, arguments: dict[str, Any]):
116
+ return cls(
117
+ type=AgentEventType.TOOL_CALL_START,
118
+ data={
119
+ "call_id": call_id,
120
+ "name": name,
121
+ "arguments": arguments,
122
+ },
123
+ )
124
+
125
+ @classmethod
126
+ def tool_call_complete(
127
+ cls,
128
+ call_id: str,
129
+ name: str,
130
+ result: ToolResult,
131
+ ):
132
+ return cls(
133
+ type=AgentEventType.TOOL_CALL_COMPLETE,
134
+ data={
135
+ "call_id": call_id,
136
+ "name": name,
137
+ "success": result.success,
138
+ "output": result.output,
139
+ "error": result.error,
140
+ "metadata": result.metadata,
141
+ "diff": result.diff.to_diff() if result.diff else None,
142
+ "truncated": result.truncated,
143
+ "exit_code": result.exit_code,
144
+ },
145
+ )
agent/persistence.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from typing import Any
8
+ from client.response import TokenUsage
9
+ from config.loader import get_data_dir
10
+
11
+
12
+ @dataclass
13
+ class SessionSnapshot:
14
+ session_id: str
15
+ created_at: datetime
16
+ updated_at: datetime
17
+ turn_count: int
18
+ messages: list[dict[str, Any]]
19
+ total_usage: TokenUsage
20
+
21
+ def to_dict(self) -> dict[str, Any]:
22
+ return {
23
+ "session_id": self.session_id,
24
+ "created_at": self.created_at.isoformat(),
25
+ "updated_at": self.updated_at.isoformat(),
26
+ "turn_count": self.turn_count,
27
+ "messages": self.messages,
28
+ "total_usage": self.total_usage.__dict__,
29
+ }
30
+
31
+ @classmethod
32
+ def from_dict(cls, data: dict[str, Any]) -> SessionSnapshot:
33
+ return cls(
34
+ session_id=data["session_id"],
35
+ created_at=datetime.fromisoformat(data["created_at"]),
36
+ updated_at=datetime.fromisoformat(data["updated_at"]),
37
+ turn_count=data["turn_count"],
38
+ messages=data["messages"],
39
+ total_usage=TokenUsage(**data["total_usage"]),
40
+ )
41
+
42
+
43
+ class PersistenceManager:
44
+ def __init__(self):
45
+ self.data_dir = get_data_dir()
46
+ self.sessions_dir = self.data_dir / "sessions"
47
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
48
+ self.checkpoints_dir = self.data_dir / "checkpoints"
49
+ self.checkpoints_dir.mkdir(parents=True, exist_ok=True)
50
+ os.chmod(self.sessions_dir, 0o700)
51
+ os.chmod(self.checkpoints_dir, 0o700)
52
+
53
+ def save_session(self, snapshot: SessionSnapshot) -> None:
54
+ file_path = self.sessions_dir / f"{snapshot.session_id}.json"
55
+ self._atomic_save(file_path, snapshot)
56
+
57
+ def _atomic_save(self, file_path, snapshot: SessionSnapshot) -> None:
58
+ fd, temporary_path = tempfile.mkstemp(
59
+ prefix=f".{file_path.name}.",
60
+ suffix=".tmp",
61
+ dir=file_path.parent,
62
+ text=True,
63
+ )
64
+ try:
65
+ with os.fdopen(fd, "w", encoding="utf-8") as fp:
66
+ json.dump(snapshot.to_dict(), fp, indent=2)
67
+ fp.flush()
68
+ os.fsync(fp.fileno())
69
+ os.chmod(temporary_path, 0o600)
70
+ os.replace(temporary_path, file_path)
71
+ except Exception:
72
+ try:
73
+ os.unlink(temporary_path)
74
+ except FileNotFoundError:
75
+ pass
76
+ raise
77
+
78
+ def load_session(self, session_id: str) -> SessionSnapshot | None:
79
+ file_path = self.sessions_dir / f"{session_id}.json"
80
+
81
+ if not file_path.exists():
82
+ return None
83
+
84
+ with open(file_path, "r", encoding="utf-8") as fp:
85
+ data = json.load(fp)
86
+
87
+ return SessionSnapshot.from_dict(data)
88
+
89
+ def list_sessions(self) -> list[dict[str, Any]]:
90
+ sessions = []
91
+ for file_path in self.sessions_dir.glob("*.json"):
92
+ with open(file_path, "r", encoding="utf-8") as fp:
93
+ data = json.load(fp)
94
+ sessions.append(
95
+ {
96
+ "session_id": data["session_id"],
97
+ "created_at": data["created_at"],
98
+ "updated_at": data["updated_at"],
99
+ "turn_count": data["turn_count"],
100
+ }
101
+ )
102
+
103
+ sessions.sort(key=lambda x: x["updated_at"], reverse=True)
104
+ return sessions
105
+
106
+ def save_checkpoint(self, snapshot: SessionSnapshot) -> str:
107
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
108
+ checkpoint_id = f"{snapshot.session_id}_{timestamp}"
109
+ file_path = self.checkpoints_dir / f"{checkpoint_id}.json"
110
+
111
+ self._atomic_save(file_path, snapshot)
112
+ return checkpoint_id
113
+
114
+ def load_checkpoint(self, checkpoint_id: str) -> SessionSnapshot | None:
115
+ file_path = self.checkpoints_dir / f"{checkpoint_id}.json"
116
+
117
+ if not file_path.exists():
118
+ return None
119
+
120
+ with open(file_path, "r", encoding="utf-8") as fp:
121
+ data = json.load(fp)
122
+
123
+ return SessionSnapshot.from_dict(data)
agent/session.py ADDED
@@ -0,0 +1,99 @@
1
+ from datetime import datetime
2
+ import json
3
+ from typing import Any
4
+ import uuid
5
+ from client.llm_client import LLMClient
6
+ from config.config import Config
7
+ from config.loader import get_data_dir
8
+ from context.compaction import ChatCompactor
9
+ from context.loop_detector import LoopDetector
10
+ from context.manager import ContextManager
11
+ from hooks.hook_system import HookSystem
12
+ from safety.approval import ApprovalManager
13
+ from skills import SkillDiscoveryManager, SkillRegistry
14
+ from tools.discovery import ToolDiscoveryManager
15
+ from tools.mcp.mcp_manager import MCPManager
16
+ from tools.registry import create_default_registry
17
+
18
+
19
+ class Session:
20
+ def __init__(self, config: Config):
21
+ self.config = config
22
+ self.client = LLMClient(config=config)
23
+ self.tool_registry = create_default_registry(config)
24
+ self.skill_registry = SkillRegistry(config)
25
+ self.context_manager: ContextManager | None = None
26
+ self.discovery_manager = ToolDiscoveryManager(
27
+ self.config,
28
+ self.tool_registry,
29
+ )
30
+ self.skill_discovery_manager = SkillDiscoveryManager(
31
+ self.config,
32
+ self.skill_registry,
33
+ )
34
+ self.mcp_manager = MCPManager(self.config)
35
+ self.chat_compactor = ChatCompactor(self.client)
36
+ self.approval_manager = ApprovalManager(
37
+ self.config.approval,
38
+ self.config.cwd,
39
+ )
40
+ self.loop_detector = LoopDetector()
41
+ self.hook_system = HookSystem(config)
42
+ self.session_id = str(uuid.uuid4())
43
+ self.created_at = datetime.now()
44
+ self.updated_at = datetime.now()
45
+
46
+ self.turn_count = 0
47
+
48
+ async def initialize(self) -> None:
49
+ await self.mcp_manager.initialize()
50
+ self.mcp_manager.register_tools(self.tool_registry)
51
+
52
+ self.discovery_manager.discover_all()
53
+ self.skill_discovery_manager.discover_all()
54
+ self.context_manager = ContextManager(
55
+ config=self.config,
56
+ user_memory=self._load_memory(),
57
+ tools=self.tool_registry.get_tools(),
58
+ skills=self.skill_registry.get_skills(),
59
+ )
60
+
61
+ def _load_memory(self) -> str | None:
62
+ data_dir = get_data_dir()
63
+ data_dir.mkdir(parents=True, exist_ok=True)
64
+ path = data_dir / "user_memory.json"
65
+
66
+ if not path.exists():
67
+ return None
68
+
69
+ try:
70
+ content = path.read_text(encoding="utf-8")
71
+ data = json.loads(content)
72
+ entries = data.get("entries")
73
+ if not entries:
74
+ return None
75
+
76
+ lines = ["User preferences and notes:"]
77
+ for key, value in entries.items():
78
+ lines.append(f"- {key}: {value}")
79
+
80
+ return "\n".join(lines)
81
+ except Exception:
82
+ return None
83
+
84
+ def increment_turn(self) -> int:
85
+ self.turn_count += 1
86
+ self.updated_at = datetime.now()
87
+
88
+ return self.turn_count
89
+
90
+ def get_stats(self) -> dict[str, Any]:
91
+ return {
92
+ "session_id": self.session_id,
93
+ "created_at": self.created_at.isoformat(),
94
+ "turn_count": self.turn_count,
95
+ "message_count": self.context_manager.message_count,
96
+ "token_usage": self.context_manager.total_usage,
97
+ "tools_count": len(self.tool_registry.get_tools()),
98
+ "mcp_servers": len(self.tool_registry.connected_mcp_servers),
99
+ }
client/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Provider client helpers."""