kader 0.1.6__py3-none-any.whl → 1.0.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.
- cli/app.py +98 -30
- cli/app.tcss +20 -0
- cli/utils.py +1 -1
- cli/widgets/conversation.py +50 -4
- kader/__init__.py +2 -0
- kader/agent/agents.py +8 -0
- kader/agent/base.py +68 -5
- kader/memory/types.py +60 -0
- kader/prompts/__init__.py +9 -1
- kader/prompts/agent_prompts.py +28 -0
- kader/prompts/templates/executor_agent.j2 +70 -0
- kader/prompts/templates/kader_planner.j2 +71 -0
- kader/providers/ollama.py +2 -2
- kader/tools/__init__.py +26 -0
- kader/tools/agent.py +452 -0
- kader/tools/filesys.py +1 -1
- kader/tools/todo.py +43 -2
- kader/utils/__init__.py +10 -0
- kader/utils/checkpointer.py +371 -0
- kader/utils/context_aggregator.py +347 -0
- kader/workflows/__init__.py +13 -0
- kader/workflows/base.py +71 -0
- kader/workflows/planner_executor.py +251 -0
- {kader-0.1.6.dist-info → kader-1.0.0.dist-info}/METADATA +38 -1
- {kader-0.1.6.dist-info → kader-1.0.0.dist-info}/RECORD +27 -18
- {kader-0.1.6.dist-info → kader-1.0.0.dist-info}/WHEEL +0 -0
- {kader-0.1.6.dist-info → kader-1.0.0.dist-info}/entry_points.txt +0 -0
kader/tools/todo.py
CHANGED
|
@@ -52,7 +52,10 @@ class TodoTool(BaseTool[str]):
|
|
|
52
52
|
"Manage todo lists for planning. "
|
|
53
53
|
"Supports creating, reading, updating, and deleting todo lists. "
|
|
54
54
|
"Each list is identified by a todo_id and contains items with status "
|
|
55
|
-
"(not-started, in-progress, completed)."
|
|
55
|
+
"(not-started, in-progress, completed). "
|
|
56
|
+
"IMPORTANT: When updating, you can ONLY change the status of existing items. "
|
|
57
|
+
"You cannot add, remove, or modify task descriptions. "
|
|
58
|
+
"If you need to change tasks, delete and recreate the list."
|
|
56
59
|
),
|
|
57
60
|
category=ToolCategory.UTILITY,
|
|
58
61
|
parameters=[
|
|
@@ -185,7 +188,10 @@ class TodoTool(BaseTool[str]):
|
|
|
185
188
|
def _update_todo(
|
|
186
189
|
self, session_id: str, todo_id: str, items: list[TodoItem] | None
|
|
187
190
|
) -> str:
|
|
188
|
-
"""Update an existing todo list (
|
|
191
|
+
"""Update an existing todo list (status changes only).
|
|
192
|
+
This method enforces integrity by only allowing status updates.
|
|
193
|
+
The task descriptions must match the existing todo list exactly.
|
|
194
|
+
"""
|
|
189
195
|
path = self._get_todo_path(session_id, todo_id)
|
|
190
196
|
if not path.exists():
|
|
191
197
|
return f"Error: Todo list '{todo_id}' not found. Use 'create' to make a new list."
|
|
@@ -193,6 +199,26 @@ class TodoTool(BaseTool[str]):
|
|
|
193
199
|
if items is None:
|
|
194
200
|
return "Error: 'items' must be provided for update action."
|
|
195
201
|
|
|
202
|
+
# Read existing todo list
|
|
203
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
204
|
+
try:
|
|
205
|
+
existing_data = json.load(f)
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
return "Error: Failed to decode existing todo list JSON."
|
|
208
|
+
|
|
209
|
+
# Validate integrity: check that task descriptions match
|
|
210
|
+
existing_tasks = [item.get("task", "") for item in existing_data]
|
|
211
|
+
new_tasks = [item.task for item in items]
|
|
212
|
+
|
|
213
|
+
# Check if the number of items matches
|
|
214
|
+
if len(existing_tasks) != len(new_tasks):
|
|
215
|
+
return self._format_integrity_error(todo_id, existing_data)
|
|
216
|
+
|
|
217
|
+
# Check if all task descriptions match (order matters)
|
|
218
|
+
if existing_tasks != new_tasks:
|
|
219
|
+
return self._format_integrity_error(todo_id, existing_data)
|
|
220
|
+
|
|
221
|
+
# Validation passed - update with new statuses
|
|
196
222
|
data = [item.model_dump() for item in items]
|
|
197
223
|
|
|
198
224
|
with open(path, "w", encoding="utf-8") as f:
|
|
@@ -200,6 +226,21 @@ class TodoTool(BaseTool[str]):
|
|
|
200
226
|
|
|
201
227
|
return f"Successfully updated todo list '{todo_id}'."
|
|
202
228
|
|
|
229
|
+
def _format_integrity_error(self, todo_id: str, existing_data: list[dict]) -> str:
|
|
230
|
+
"""Format an integrity error message with the current todo list content."""
|
|
231
|
+
items_description = "\n".join(
|
|
232
|
+
f" {i + 1}. [{item.get('status', 'not-started')}] {item.get('task', '')}"
|
|
233
|
+
for i, item in enumerate(existing_data)
|
|
234
|
+
)
|
|
235
|
+
return (
|
|
236
|
+
f"Error: Update rejected - todo list integrity violation.\n"
|
|
237
|
+
f"The provided items do not match the existing todo list '{todo_id}'.\n"
|
|
238
|
+
f"You can only update the STATUS of existing items, not add, remove, or modify task descriptions.\n\n"
|
|
239
|
+
f"Current todo list '{todo_id}' content:\n{items_description}\n\n"
|
|
240
|
+
f"Please update using the exact task descriptions from the list above, "
|
|
241
|
+
f"only changing the 'status' field as needed."
|
|
242
|
+
)
|
|
243
|
+
|
|
203
244
|
def _delete_todo(self, session_id: str, todo_id: str) -> str:
|
|
204
245
|
"""Delete a todo list."""
|
|
205
246
|
path = self._get_todo_path(session_id, todo_id)
|
kader/utils/__init__.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checkpointer module for generating step-by-step summaries of agent memory.
|
|
3
|
+
|
|
4
|
+
Uses OllamaProvider to analyze conversation history and produce
|
|
5
|
+
human-readable markdown summaries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from kader.memory.types import (
|
|
12
|
+
aload_json,
|
|
13
|
+
aread_text,
|
|
14
|
+
awrite_text,
|
|
15
|
+
get_default_memory_dir,
|
|
16
|
+
load_json,
|
|
17
|
+
)
|
|
18
|
+
from kader.providers.base import Message
|
|
19
|
+
from kader.providers.ollama import OllamaProvider
|
|
20
|
+
|
|
21
|
+
CHECKPOINT_SYSTEM_PROMPT = """You are an assistant that summarizes agent conversation histories.
|
|
22
|
+
Given a conversation between a user and an AI agent, create a structured summary in markdown format.
|
|
23
|
+
|
|
24
|
+
Your summary MUST include the following sections:
|
|
25
|
+
|
|
26
|
+
## Directory Structure
|
|
27
|
+
List the directory structure of any files/folders created or modified during the conversation.
|
|
28
|
+
Use a tree-like format:
|
|
29
|
+
```
|
|
30
|
+
project/
|
|
31
|
+
├── src/
|
|
32
|
+
│ └── main.py
|
|
33
|
+
└── README.md
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Actions Performed
|
|
37
|
+
Summarize the main accomplishments and significant actions taken by the agent.
|
|
38
|
+
Focus on high-level outcomes, not individual steps. For example:
|
|
39
|
+
- "Implemented user authentication module with login/logout functionality"
|
|
40
|
+
- "Fixed database connection issues and added retry logic"
|
|
41
|
+
- "Created REST API endpoints for user management"
|
|
42
|
+
|
|
43
|
+
Do NOT list every single action (like reading files, running commands, etc.).
|
|
44
|
+
Only mention the meaningful outcomes and key decisions.
|
|
45
|
+
|
|
46
|
+
If a section has no relevant content, write "None" under that section.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Checkpointer:
|
|
51
|
+
"""
|
|
52
|
+
Generates step-by-step markdown summaries of agent memory.
|
|
53
|
+
|
|
54
|
+
Uses OllamaProvider to analyze conversation history from memory files
|
|
55
|
+
and produce human-readable checkpoint summaries.
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
checkpointer = Checkpointer()
|
|
59
|
+
md_path = checkpointer.generate_checkpoint("session-id/conversation.json")
|
|
60
|
+
print(f"Checkpoint saved to: {md_path}")
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
model: str = "gpt-oss:120b-cloud",
|
|
66
|
+
host: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Initialize the Checkpointer.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
model: Ollama model identifier (default: "gpt-oss:120b-cloud")
|
|
73
|
+
host: Optional Ollama server host
|
|
74
|
+
"""
|
|
75
|
+
self._provider = OllamaProvider(model=model, host=host)
|
|
76
|
+
|
|
77
|
+
def _load_memory(self, memory_path: Path) -> dict[str, Any]:
|
|
78
|
+
"""
|
|
79
|
+
Load memory JSON from the specified path.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
memory_path: Absolute path to the memory JSON file
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Dictionary containing the memory data
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
FileNotFoundError: If the memory file doesn't exist
|
|
89
|
+
"""
|
|
90
|
+
if not memory_path.exists():
|
|
91
|
+
raise FileNotFoundError(f"Memory file not found: {memory_path}")
|
|
92
|
+
|
|
93
|
+
return load_json(memory_path)
|
|
94
|
+
|
|
95
|
+
def _extract_messages(self, memory_data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
96
|
+
"""
|
|
97
|
+
Extract messages from memory data.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
memory_data: Dictionary containing memory data
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of message dictionaries
|
|
104
|
+
"""
|
|
105
|
+
# Handle different memory formats
|
|
106
|
+
if "messages" in memory_data:
|
|
107
|
+
# Standard conversation format
|
|
108
|
+
messages = memory_data["messages"]
|
|
109
|
+
# Extract inner message if wrapped in ConversationMessage format
|
|
110
|
+
return [
|
|
111
|
+
msg.get("message", msg) if isinstance(msg, dict) else msg
|
|
112
|
+
for msg in messages
|
|
113
|
+
]
|
|
114
|
+
elif "conversation" in memory_data:
|
|
115
|
+
# Alternative format
|
|
116
|
+
return memory_data["conversation"]
|
|
117
|
+
else:
|
|
118
|
+
# Return empty if no known format
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
def _format_conversation_for_prompt(self, messages: list[dict[str, Any]]) -> str:
|
|
122
|
+
"""
|
|
123
|
+
Format messages into a readable string for the LLM prompt.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
messages: List of message dictionaries
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Formatted string representation of the conversation
|
|
130
|
+
"""
|
|
131
|
+
lines = []
|
|
132
|
+
for i, msg in enumerate(messages, 1):
|
|
133
|
+
role = msg.get("role", "unknown").upper()
|
|
134
|
+
content = msg.get("content", "")
|
|
135
|
+
|
|
136
|
+
# Handle tool calls
|
|
137
|
+
tool_calls = msg.get("tool_calls", [])
|
|
138
|
+
if tool_calls:
|
|
139
|
+
lines.append(f"[{i}] {role}: (calling tools)")
|
|
140
|
+
for tc in tool_calls:
|
|
141
|
+
func = tc.get("function", {})
|
|
142
|
+
name = func.get("name", "unknown")
|
|
143
|
+
args = func.get("arguments", {})
|
|
144
|
+
lines.append(f" -> Tool: {name}")
|
|
145
|
+
lines.append(f" Args: {args}")
|
|
146
|
+
elif content:
|
|
147
|
+
# Truncate very long content
|
|
148
|
+
if len(content) > 1000:
|
|
149
|
+
content = content[:1000] + "... [truncated]"
|
|
150
|
+
lines.append(f"[{i}] {role}: {content}")
|
|
151
|
+
|
|
152
|
+
# Handle tool call ID (tool results)
|
|
153
|
+
tool_call_id = msg.get("tool_call_id")
|
|
154
|
+
if tool_call_id:
|
|
155
|
+
lines.append(f" (tool result for: {tool_call_id})")
|
|
156
|
+
|
|
157
|
+
return "\n".join(lines)
|
|
158
|
+
|
|
159
|
+
def _generate_summary(
|
|
160
|
+
self, conversation_text: str, existing_checkpoint: str | None = None
|
|
161
|
+
) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Generate a step-by-step summary using the LLM (synchronous).
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
conversation_text: Formatted conversation text
|
|
167
|
+
existing_checkpoint: Existing checkpoint content to update, if any
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Markdown summary of the conversation
|
|
171
|
+
"""
|
|
172
|
+
if existing_checkpoint:
|
|
173
|
+
user_prompt = f"""Here is the existing checkpoint from previous iterations:
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
{existing_checkpoint}
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
Here is the new conversation to incorporate:
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
{conversation_text}
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
Update the existing checkpoint by incorporating the new information. Merge new items into the existing sections.
|
|
186
|
+
Keep all previously documented content and add new content from this iteration."""
|
|
187
|
+
else:
|
|
188
|
+
user_prompt = f"""Please analyze this agent conversation and create a checkpoint summary:
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
{conversation_text}
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
Create a structured summary following the format specified."""
|
|
195
|
+
|
|
196
|
+
messages = [
|
|
197
|
+
Message.system(CHECKPOINT_SYSTEM_PROMPT),
|
|
198
|
+
Message.user(user_prompt),
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
response = self._provider.invoke(messages)
|
|
202
|
+
return response.content
|
|
203
|
+
|
|
204
|
+
async def _agenerate_summary(
|
|
205
|
+
self, conversation_text: str, existing_checkpoint: str | None = None
|
|
206
|
+
) -> str:
|
|
207
|
+
"""
|
|
208
|
+
Generate a step-by-step summary using the LLM (asynchronous).
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
conversation_text: Formatted conversation text
|
|
212
|
+
existing_checkpoint: Existing checkpoint content to update, if any
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Markdown summary of the conversation
|
|
216
|
+
"""
|
|
217
|
+
if existing_checkpoint:
|
|
218
|
+
user_prompt = f"""Here is the existing checkpoint from previous iterations:
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
{existing_checkpoint}
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
Here is the new conversation to incorporate:
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
{conversation_text}
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
Update the existing checkpoint by incorporating the new information. Merge new items into the existing sections.
|
|
231
|
+
Keep all previously documented content and add new content from this iteration."""
|
|
232
|
+
else:
|
|
233
|
+
user_prompt = f"""Please analyze this agent conversation and create a checkpoint summary:
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
{conversation_text}
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
Create a structured summary following the format specified."""
|
|
240
|
+
|
|
241
|
+
messages = [
|
|
242
|
+
Message.system(CHECKPOINT_SYSTEM_PROMPT),
|
|
243
|
+
Message.user(user_prompt),
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
response = await self._provider.ainvoke(messages)
|
|
247
|
+
return response.content
|
|
248
|
+
|
|
249
|
+
def _load_existing_checkpoint(self, checkpoint_path: Path) -> str | None:
|
|
250
|
+
"""
|
|
251
|
+
Load existing checkpoint content if it exists.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
checkpoint_path: Path to the checkpoint file
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Checkpoint content if exists, None otherwise
|
|
258
|
+
"""
|
|
259
|
+
if checkpoint_path.exists():
|
|
260
|
+
try:
|
|
261
|
+
return checkpoint_path.read_text(encoding="utf-8")
|
|
262
|
+
except Exception:
|
|
263
|
+
return None
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
async def _aload_existing_checkpoint(self, checkpoint_path: Path) -> str | None:
|
|
267
|
+
"""
|
|
268
|
+
Asynchronously load existing checkpoint content if it exists.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
checkpoint_path: Path to the checkpoint file
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Checkpoint content if exists, None otherwise
|
|
275
|
+
"""
|
|
276
|
+
if checkpoint_path.exists():
|
|
277
|
+
try:
|
|
278
|
+
return await aread_text(checkpoint_path)
|
|
279
|
+
except Exception:
|
|
280
|
+
return None
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def generate_checkpoint(self, memory_path: str) -> str:
|
|
284
|
+
"""
|
|
285
|
+
Generate a checkpoint markdown file from an agent's memory (synchronous).
|
|
286
|
+
|
|
287
|
+
If a checkpoint already exists, it will be updated instead of overwritten.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
memory_path: Relative path within ~/.kader/memory/sessions/
|
|
291
|
+
(e.g., "session-id/conversation.json")
|
|
292
|
+
Or absolute path to the memory JSON file.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Absolute path to the generated markdown file
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
FileNotFoundError: If the memory file doesn't exist
|
|
299
|
+
ValueError: If no messages found in memory
|
|
300
|
+
"""
|
|
301
|
+
# Resolve path
|
|
302
|
+
path = Path(memory_path)
|
|
303
|
+
if not path.is_absolute():
|
|
304
|
+
base_dir = get_default_memory_dir() / "sessions"
|
|
305
|
+
path = base_dir / memory_path
|
|
306
|
+
|
|
307
|
+
# Load and parse memory
|
|
308
|
+
memory_data = self._load_memory(path)
|
|
309
|
+
messages = self._extract_messages(memory_data)
|
|
310
|
+
|
|
311
|
+
if not messages:
|
|
312
|
+
raise ValueError(f"No messages found in memory file: {path}")
|
|
313
|
+
|
|
314
|
+
# Check for existing checkpoint
|
|
315
|
+
checkpoint_path = path.parent / "checkpoint.md"
|
|
316
|
+
existing_checkpoint = self._load_existing_checkpoint(checkpoint_path)
|
|
317
|
+
|
|
318
|
+
# Format and generate summary
|
|
319
|
+
conversation_text = self._format_conversation_for_prompt(messages)
|
|
320
|
+
summary = self._generate_summary(conversation_text, existing_checkpoint)
|
|
321
|
+
|
|
322
|
+
# Save checkpoint markdown
|
|
323
|
+
checkpoint_path.write_text(summary, encoding="utf-8")
|
|
324
|
+
|
|
325
|
+
return str(checkpoint_path)
|
|
326
|
+
|
|
327
|
+
async def agenerate_checkpoint(self, memory_path: str) -> str:
|
|
328
|
+
"""
|
|
329
|
+
Generate a checkpoint markdown file from an agent's memory (asynchronous).
|
|
330
|
+
|
|
331
|
+
If a checkpoint already exists, it will be updated instead of overwritten.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
memory_path: Relative path within ~/.kader/memory/sessions/
|
|
335
|
+
(e.g., "session-id/conversation.json")
|
|
336
|
+
Or absolute path to the memory JSON file.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Absolute path to the generated markdown file
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
FileNotFoundError: If the memory file doesn't exist
|
|
343
|
+
ValueError: If no messages found in memory
|
|
344
|
+
"""
|
|
345
|
+
# Resolve path
|
|
346
|
+
path = Path(memory_path)
|
|
347
|
+
if not path.is_absolute():
|
|
348
|
+
base_dir = get_default_memory_dir() / "sessions"
|
|
349
|
+
path = base_dir / memory_path
|
|
350
|
+
|
|
351
|
+
# Load and parse memory (async)
|
|
352
|
+
if not path.exists():
|
|
353
|
+
raise FileNotFoundError(f"Memory file not found: {path}")
|
|
354
|
+
memory_data = await aload_json(path)
|
|
355
|
+
messages = self._extract_messages(memory_data)
|
|
356
|
+
|
|
357
|
+
if not messages:
|
|
358
|
+
raise ValueError(f"No messages found in memory file: {path}")
|
|
359
|
+
|
|
360
|
+
# Check for existing checkpoint (async)
|
|
361
|
+
checkpoint_path = path.parent / "checkpoint.md"
|
|
362
|
+
existing_checkpoint = await self._aload_existing_checkpoint(checkpoint_path)
|
|
363
|
+
|
|
364
|
+
# Format and generate summary
|
|
365
|
+
conversation_text = self._format_conversation_for_prompt(messages)
|
|
366
|
+
summary = await self._agenerate_summary(conversation_text, existing_checkpoint)
|
|
367
|
+
|
|
368
|
+
# Save checkpoint markdown (async)
|
|
369
|
+
await awrite_text(checkpoint_path, summary)
|
|
370
|
+
|
|
371
|
+
return str(checkpoint_path)
|