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.
- agent/__init__.py +1 -0
- agent/agent.py +236 -0
- agent/events.py +145 -0
- agent/persistence.py +123 -0
- agent/session.py +99 -0
- client/__init__.py +1 -0
- client/llm_client.py +266 -0
- client/response.py +88 -0
- codenaut-0.1.0.dist-info/METADATA +199 -0
- codenaut-0.1.0.dist-info/RECORD +63 -0
- codenaut-0.1.0.dist-info/WHEEL +5 -0
- codenaut-0.1.0.dist-info/entry_points.txt +3 -0
- codenaut-0.1.0.dist-info/licenses/LICENSE +21 -0
- codenaut-0.1.0.dist-info/top_level.txt +12 -0
- config/__init__.py +1 -0
- config/config.py +173 -0
- config/loader.py +173 -0
- context/__init__.py +1 -0
- context/compaction.py +96 -0
- context/loop_detector.py +49 -0
- context/manager.py +257 -0
- hooks/__init__.py +1 -0
- hooks/hook_system.py +140 -0
- main.py +422 -0
- prompts/__init__.py +1 -0
- prompts/system.py +380 -0
- safety/__init__.py +1 -0
- safety/approval.py +157 -0
- skills/__init__.py +9 -0
- skills/discovery.py +97 -0
- skills/models.py +14 -0
- skills/registry.py +56 -0
- tools/__init__.py +1 -0
- tools/base.py +200 -0
- tools/builtin/__init__.py +67 -0
- tools/builtin/apply_patch.py +388 -0
- tools/builtin/edit_file.py +211 -0
- tools/builtin/glob.py +80 -0
- tools/builtin/grep.py +117 -0
- tools/builtin/list_dir.py +60 -0
- tools/builtin/memory.py +115 -0
- tools/builtin/read_file.py +133 -0
- tools/builtin/shell.py +160 -0
- tools/builtin/tasks.py +336 -0
- tools/builtin/web_fetch.py +55 -0
- tools/builtin/web_search.py +60 -0
- tools/builtin/write_file.py +111 -0
- tools/discovery.py +70 -0
- tools/mcp/__init__.py +1 -0
- tools/mcp/client.py +127 -0
- tools/mcp/mcp_manager.py +83 -0
- tools/mcp/mcp_tool.py +51 -0
- tools/registry.py +156 -0
- tools/subagents.py +148 -0
- ui/__init__.py +12 -0
- ui/completer.py +160 -0
- ui/prompt.py +65 -0
- ui/tui.py +669 -0
- utils/__init__.py +1 -0
- utils/errors.py +49 -0
- utils/file_tags.py +83 -0
- utils/paths.py +40 -0
- utils/text.py +79 -0
context/manager.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
from client.response import TokenUsage
|
|
5
|
+
from config.config import Config
|
|
6
|
+
from prompts.system import get_system_prompt
|
|
7
|
+
from skills.models import SkillDefinition
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from tools.base import Tool
|
|
11
|
+
from utils.text import count_tokens
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MessageItem:
|
|
16
|
+
role: str
|
|
17
|
+
content: str
|
|
18
|
+
tool_call_id: str | None = None
|
|
19
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
20
|
+
token_count: int | None = None
|
|
21
|
+
pruned_at: datetime | None = None
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
result: dict[str, Any] = {"role": self.role}
|
|
25
|
+
|
|
26
|
+
if self.tool_call_id:
|
|
27
|
+
result["tool_call_id"] = self.tool_call_id
|
|
28
|
+
|
|
29
|
+
if self.tool_calls:
|
|
30
|
+
result["tool_calls"] = self.tool_calls
|
|
31
|
+
|
|
32
|
+
# `content` is required by the API for tool messages and for assistant
|
|
33
|
+
# messages that carry no tool_calls. A quiet shell command returns "",
|
|
34
|
+
# and dropping the field here produces a message the API rejects, which
|
|
35
|
+
# then poisons every subsequent request in the session.
|
|
36
|
+
requires_content = self.role == "tool" or (
|
|
37
|
+
self.role == "assistant" and not self.tool_calls
|
|
38
|
+
)
|
|
39
|
+
if self.content or requires_content:
|
|
40
|
+
result["content"] = self.content
|
|
41
|
+
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ContextManager:
|
|
46
|
+
PRUNE_PROTECT_TOKENS = 40_000
|
|
47
|
+
PRUNE_MINIMUM_TOKENS = 20_000
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
config: Config,
|
|
52
|
+
user_memory: str | None,
|
|
53
|
+
tools: list[Tool] | None,
|
|
54
|
+
skills: list[SkillDefinition] | None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.config = config
|
|
57
|
+
self._user_memory = user_memory
|
|
58
|
+
self._tools = tools
|
|
59
|
+
self._skills = skills or []
|
|
60
|
+
self._model_name = self.config.model_name
|
|
61
|
+
self._messages: list[MessageItem] = []
|
|
62
|
+
self._latest_usage = TokenUsage()
|
|
63
|
+
self.total_usage = TokenUsage()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def message_count(self) -> int:
|
|
67
|
+
return len(self._messages)
|
|
68
|
+
|
|
69
|
+
def add_user_message(self, content: str) -> None:
|
|
70
|
+
item = MessageItem(
|
|
71
|
+
role="user",
|
|
72
|
+
content=content,
|
|
73
|
+
token_count=count_tokens(
|
|
74
|
+
content,
|
|
75
|
+
self._model_name,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
self._messages.append(item)
|
|
80
|
+
|
|
81
|
+
def add_assistant_message(
|
|
82
|
+
self,
|
|
83
|
+
content: str,
|
|
84
|
+
tool_calls: list[dict[str, any]] | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
item = MessageItem(
|
|
87
|
+
role="assistant",
|
|
88
|
+
content=content or "",
|
|
89
|
+
token_count=count_tokens(
|
|
90
|
+
content or "",
|
|
91
|
+
self._model_name,
|
|
92
|
+
),
|
|
93
|
+
tool_calls=tool_calls or [],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self._messages.append(item)
|
|
97
|
+
|
|
98
|
+
def add_tool_result(self, tool_call_id: str, content: str) -> None:
|
|
99
|
+
item = MessageItem(
|
|
100
|
+
role="tool",
|
|
101
|
+
content=content,
|
|
102
|
+
tool_call_id=tool_call_id,
|
|
103
|
+
token_count=count_tokens(content, self._model_name),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self._messages.append(item)
|
|
107
|
+
|
|
108
|
+
def get_messages(self) -> list[dict[str, Any]]:
|
|
109
|
+
messages = []
|
|
110
|
+
|
|
111
|
+
system_prompt = self._build_system_prompt()
|
|
112
|
+
if system_prompt:
|
|
113
|
+
messages.append(
|
|
114
|
+
{
|
|
115
|
+
"role": "system",
|
|
116
|
+
"content": system_prompt,
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
for item in self._messages:
|
|
121
|
+
messages.append(item.to_dict())
|
|
122
|
+
|
|
123
|
+
return messages
|
|
124
|
+
|
|
125
|
+
def _build_system_prompt(self) -> str:
|
|
126
|
+
active_skills = self._get_active_skills()
|
|
127
|
+
return get_system_prompt(
|
|
128
|
+
self.config,
|
|
129
|
+
self._user_memory,
|
|
130
|
+
self._tools,
|
|
131
|
+
available_skills=self._skills,
|
|
132
|
+
active_skills=active_skills,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _get_active_skills(self) -> list[SkillDefinition]:
|
|
136
|
+
if not self._skills or not self.config.skills_enabled:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
allowed = {name.casefold() for name in self.config.allowed_skills or []}
|
|
140
|
+
always_loaded = {name.casefold() for name in self.config.always_loaded_skills}
|
|
141
|
+
query_parts = [
|
|
142
|
+
message.content
|
|
143
|
+
for message in self._messages
|
|
144
|
+
if message.role == "user" and message.content
|
|
145
|
+
]
|
|
146
|
+
normalized_query = " ".join(" ".join(query_parts).casefold().split())
|
|
147
|
+
active_skills: list[SkillDefinition] = []
|
|
148
|
+
|
|
149
|
+
for skill in self._skills:
|
|
150
|
+
skill_name = skill.name.casefold()
|
|
151
|
+
if allowed and skill_name not in allowed:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
trigger_match = any(
|
|
155
|
+
trigger.casefold() in normalized_query for trigger in skill.triggers
|
|
156
|
+
)
|
|
157
|
+
name_match = skill_name in normalized_query if normalized_query else False
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
skill.always_include
|
|
161
|
+
or skill_name in always_loaded
|
|
162
|
+
or trigger_match
|
|
163
|
+
or name_match
|
|
164
|
+
):
|
|
165
|
+
active_skills.append(skill)
|
|
166
|
+
|
|
167
|
+
return active_skills
|
|
168
|
+
|
|
169
|
+
def needs_compression(self) -> bool:
|
|
170
|
+
context_limit = self.config.model.context_window
|
|
171
|
+
current_tokens = self.estimate_context_tokens()
|
|
172
|
+
|
|
173
|
+
return current_tokens > (context_limit * 0.8)
|
|
174
|
+
|
|
175
|
+
def estimate_context_tokens(self) -> int:
|
|
176
|
+
total_tokens = 0
|
|
177
|
+
|
|
178
|
+
system_prompt = self._build_system_prompt()
|
|
179
|
+
if system_prompt:
|
|
180
|
+
total_tokens += count_tokens(system_prompt, self._model_name)
|
|
181
|
+
|
|
182
|
+
for item in self._messages:
|
|
183
|
+
total_tokens += self._estimate_message_tokens(item)
|
|
184
|
+
|
|
185
|
+
return total_tokens
|
|
186
|
+
|
|
187
|
+
def _estimate_message_tokens(self, item: MessageItem) -> int:
|
|
188
|
+
payload = item.to_dict()
|
|
189
|
+
serialized = json.dumps(payload, ensure_ascii=False)
|
|
190
|
+
return count_tokens(serialized, self._model_name)
|
|
191
|
+
|
|
192
|
+
def set_latest_usage(self, usage: TokenUsage):
|
|
193
|
+
self._latest_usage = usage
|
|
194
|
+
|
|
195
|
+
def add_usage(self, usage: TokenUsage):
|
|
196
|
+
self.total_usage += usage
|
|
197
|
+
|
|
198
|
+
def replace_with_summary(self, summary: str) -> None:
|
|
199
|
+
continuation_content = f"""# Context Restoration (Previous Session Compacted)
|
|
200
|
+
|
|
201
|
+
The previous conversation was compacted due to context length limits. Below is a detailed summary of the work done so far.
|
|
202
|
+
|
|
203
|
+
Actions listed under "COMPLETED ACTIONS" are already done. Do not repeat them.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
{summary}
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
Resume work from where we left off. Focus only on the remaining tasks and treat this summary as authoritative context."""
|
|
212
|
+
|
|
213
|
+
summary_item = MessageItem(
|
|
214
|
+
role="user",
|
|
215
|
+
content=continuation_content,
|
|
216
|
+
token_count=count_tokens(continuation_content, self._model_name),
|
|
217
|
+
)
|
|
218
|
+
# Commit only after the replacement message is fully built.
|
|
219
|
+
self._messages = [summary_item]
|
|
220
|
+
|
|
221
|
+
def prune_tool_outputs(self) -> int:
|
|
222
|
+
user_message_count = sum(1 for msg in self._messages if msg.role == "user")
|
|
223
|
+
|
|
224
|
+
if user_message_count < 2:
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
total_tokens = 0
|
|
228
|
+
pruned_tokens = 0
|
|
229
|
+
to_prune: list[MessageItem] = []
|
|
230
|
+
|
|
231
|
+
for msg in reversed(self._messages):
|
|
232
|
+
if msg.role == "tool" and msg.tool_call_id:
|
|
233
|
+
if msg.pruned_at:
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
tokens = msg.token_count or count_tokens(msg.content, self._model_name)
|
|
237
|
+
total_tokens += tokens
|
|
238
|
+
|
|
239
|
+
if total_tokens > self.PRUNE_PROTECT_TOKENS:
|
|
240
|
+
pruned_tokens += tokens
|
|
241
|
+
to_prune.append(msg)
|
|
242
|
+
|
|
243
|
+
if pruned_tokens < self.PRUNE_MINIMUM_TOKENS:
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
pruned_count = 0
|
|
247
|
+
|
|
248
|
+
for msg in to_prune:
|
|
249
|
+
msg.content = "[Old tool result content cleared]"
|
|
250
|
+
msg.token_count = count_tokens(msg.content, self._model_name)
|
|
251
|
+
msg.pruned_at = datetime.now()
|
|
252
|
+
pruned_count += 1
|
|
253
|
+
|
|
254
|
+
return pruned_count
|
|
255
|
+
|
|
256
|
+
def clear(self) -> None:
|
|
257
|
+
self._messages = []
|
hooks/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lifecycle hook support."""
|
hooks/hook_system.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from typing import Any
|
|
8
|
+
from config.config import Config, HookConfig, HookTrigger
|
|
9
|
+
from tools.base import ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HookSystem:
|
|
13
|
+
def __init__(self, config: Config):
|
|
14
|
+
self.config = config
|
|
15
|
+
self.hooks: list[HookConfig] = []
|
|
16
|
+
if self.config.hooks_enabled:
|
|
17
|
+
self.hooks = [hook for hook in self.config.hooks if hook.enabled]
|
|
18
|
+
|
|
19
|
+
async def _run_hook(self, hook: HookConfig, env: dict[str, str]) -> None:
|
|
20
|
+
try:
|
|
21
|
+
if hook.command:
|
|
22
|
+
await self._run_command(hook.command, hook.timeout_sec, env)
|
|
23
|
+
else:
|
|
24
|
+
with tempfile.NamedTemporaryFile(
|
|
25
|
+
mode="w", suffix=".sh", delete=False
|
|
26
|
+
) as f:
|
|
27
|
+
f.write("#!/bin/bash\n")
|
|
28
|
+
f.write(hook.script)
|
|
29
|
+
script_path = f.name
|
|
30
|
+
try:
|
|
31
|
+
os.chmod(script_path, 0o755)
|
|
32
|
+
await self._run_command(script_path, hook.timeout_sec, env)
|
|
33
|
+
finally:
|
|
34
|
+
os.unlink(script_path)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(e)
|
|
37
|
+
|
|
38
|
+
async def _run_command(
|
|
39
|
+
self,
|
|
40
|
+
command: str,
|
|
41
|
+
timeout: float,
|
|
42
|
+
env: dict[str, str],
|
|
43
|
+
) -> None:
|
|
44
|
+
process = await asyncio.create_subprocess_shell(
|
|
45
|
+
command,
|
|
46
|
+
stdout=asyncio.subprocess.PIPE,
|
|
47
|
+
stderr=asyncio.subprocess.PIPE,
|
|
48
|
+
cwd=self.config.cwd,
|
|
49
|
+
env=env,
|
|
50
|
+
start_new_session=True,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
await asyncio.wait_for(process.communicate(), timeout=timeout)
|
|
55
|
+
except asyncio.TimeoutError:
|
|
56
|
+
if sys.platform != "win32":
|
|
57
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
58
|
+
else:
|
|
59
|
+
process.kill()
|
|
60
|
+
await process.wait()
|
|
61
|
+
|
|
62
|
+
def _build_env(
|
|
63
|
+
self,
|
|
64
|
+
trigger: HookTrigger,
|
|
65
|
+
tool_name: str | None = None,
|
|
66
|
+
user_message: str | None = None,
|
|
67
|
+
error: Exception | None = None,
|
|
68
|
+
) -> dict[str, str]:
|
|
69
|
+
env = os.environ.copy()
|
|
70
|
+
env["AI_AGENT_TRIGGER"] = trigger.value
|
|
71
|
+
env["AI_AGENT_CWD"] = str(self.config.cwd)
|
|
72
|
+
|
|
73
|
+
if tool_name:
|
|
74
|
+
env["AI_AGENT_TOOL_NAME"] = tool_name
|
|
75
|
+
|
|
76
|
+
if user_message:
|
|
77
|
+
env["AI_AGENT_USER_MESSAGE"] = user_message
|
|
78
|
+
|
|
79
|
+
if error:
|
|
80
|
+
env["AI_AGENT_ERROR"] = str(error)
|
|
81
|
+
|
|
82
|
+
return env
|
|
83
|
+
|
|
84
|
+
async def trigger_before_agent(self, user_message: str) -> None:
|
|
85
|
+
env = self._build_env(
|
|
86
|
+
HookTrigger.BEFORE_AGENT,
|
|
87
|
+
user_message=user_message,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
for hook in self.hooks:
|
|
91
|
+
if hook.trigger == HookTrigger.BEFORE_AGENT:
|
|
92
|
+
await self._run_hook(hook, env)
|
|
93
|
+
|
|
94
|
+
async def trigger_after_agent(
|
|
95
|
+
self,
|
|
96
|
+
user_message: str,
|
|
97
|
+
agent_response: str,
|
|
98
|
+
) -> None:
|
|
99
|
+
env = self._build_env(
|
|
100
|
+
HookTrigger.AFTER_AGENT,
|
|
101
|
+
user_message=user_message,
|
|
102
|
+
)
|
|
103
|
+
env["AI_AGENT_RESPONSE"] = agent_response
|
|
104
|
+
|
|
105
|
+
for hook in self.hooks:
|
|
106
|
+
if hook.trigger == HookTrigger.AFTER_AGENT:
|
|
107
|
+
await self._run_hook(hook, env)
|
|
108
|
+
|
|
109
|
+
async def trigger_before_tool(
|
|
110
|
+
self,
|
|
111
|
+
tool_name: str,
|
|
112
|
+
tool_params: dict[str, Any],
|
|
113
|
+
) -> None:
|
|
114
|
+
env = self._build_env(HookTrigger.BEFORE_TOOL, tool_name=tool_name)
|
|
115
|
+
env["AI_AGENT_TOOL_PARAMS"] = json.dumps(tool_params)
|
|
116
|
+
|
|
117
|
+
for hook in self.hooks:
|
|
118
|
+
if hook.trigger == HookTrigger.BEFORE_TOOL:
|
|
119
|
+
await self._run_hook(hook, env)
|
|
120
|
+
|
|
121
|
+
async def trigger_after_tool(
|
|
122
|
+
self,
|
|
123
|
+
tool_name: str,
|
|
124
|
+
tool_params: dict[str, Any],
|
|
125
|
+
tool_result: ToolResult,
|
|
126
|
+
) -> None:
|
|
127
|
+
env = self._build_env(HookTrigger.AFTER_TOOL, tool_name=tool_name)
|
|
128
|
+
env["AI_AGENT_TOOL_PARAMS"] = json.dumps(tool_params)
|
|
129
|
+
env["AI_AGENT_TOOL_RESULT"] = tool_result.to_model_output()
|
|
130
|
+
|
|
131
|
+
for hook in self.hooks:
|
|
132
|
+
if hook.trigger == HookTrigger.AFTER_TOOL:
|
|
133
|
+
await self._run_hook(hook, env)
|
|
134
|
+
|
|
135
|
+
async def trigger_on_error(self, error: Exception) -> None:
|
|
136
|
+
env = self._build_env(HookTrigger.ON_ERROR, error=error)
|
|
137
|
+
|
|
138
|
+
for hook in self.hooks:
|
|
139
|
+
if hook.trigger == HookTrigger.ON_ERROR:
|
|
140
|
+
await self._run_hook(hook, env)
|