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
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."""
|