agent-mini 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_mini/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """agent-mini: Ultra-lightweight personal AI agent."""
2
+
3
+ __version__ = "0.1.0"
agent_mini/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m agent_mini`."""
2
+
3
+ from .cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1,7 @@
1
+ """Agent sub-package — core loop, tools, memory, and context."""
2
+
3
+ from .loop import AgentLoop, ToolEvent
4
+ from .memory import Memory
5
+ from .tools import ToolExecutor
6
+
7
+ __all__ = ["AgentLoop", "Memory", "ToolExecutor", "ToolEvent"]
@@ -0,0 +1,48 @@
1
+ """System prompt builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from .memory import Memory
9
+
10
+ _SYSTEM_PROMPT_TEMPLATE = """\
11
+ You are Agent Mini, a personal AI assistant with tools for shell commands, \
12
+ file I/O, web search/fetch, and persistent memory.
13
+
14
+ Date: {date}
15
+ Workspace: {workspace}
16
+
17
+ <rules>
18
+ - ALWAYS read a file before editing it.
19
+ - Use tools to act directly — never say "I would run…" when you can just run it.
20
+ - If a tool fails, read the error and try a different approach. Retry at least twice.
21
+ - Verify changes (read back files, run tests, check output).
22
+ - Be concise. Skip preamble.
23
+ - Use memory_store/memory_recall for user preferences and project context.
24
+ </rules>
25
+ """
26
+
27
+
28
+ def build_system_prompt(config: dict, memory: Memory) -> str:
29
+ """Render the full system prompt with live context."""
30
+ workspace = Path(config.get("workspace", "~/.agent-mini/workspace")).expanduser()
31
+
32
+ # Use safe substitution to avoid format string injection from user config
33
+ prompt = _SYSTEM_PROMPT_TEMPLATE.format(
34
+ date=datetime.now().strftime("%Y-%m-%d %H:%M"),
35
+ workspace=str(workspace),
36
+ )
37
+
38
+ custom = config.get("agent", {}).get("systemPrompt", "")
39
+ if custom:
40
+ # Append directly — no .format() call on user-controlled content
41
+ prompt += f"\n## User instructions\n{custom}\n"
42
+
43
+ recent = memory.get_recent(5)
44
+ if recent:
45
+ items = "\n".join(f"- {m['key']}: {m['value']}" for m in recent)
46
+ prompt += f"\n## Recent memories\n{items}\n"
47
+
48
+ return prompt.strip()
@@ -0,0 +1,372 @@
1
+ """Core agent loop — think → act → observe → repeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import random
10
+ from dataclasses import dataclass
11
+ from typing import Awaitable, Callable
12
+
13
+ import httpx
14
+
15
+ from ..providers.base import BaseProvider
16
+ from .context import build_system_prompt
17
+ from .memory import Memory
18
+ from .token_estimator import (
19
+ classify_model_tier,
20
+ estimate_messages_tokens,
21
+ get_effective_context,
22
+ get_output_limit,
23
+ get_tier_max_iterations,
24
+ )
25
+ from .tools import ToolExecutor
26
+ from .vision import build_image_content_parts
27
+
28
+ log = logging.getLogger("agent-mini")
29
+ StreamEmitter = Callable[[str], Awaitable[None]]
30
+
31
+ # HTTP status codes considered transient (worth retrying)
32
+ _TRANSIENT_CODES = {429, 500, 502, 503, 504}
33
+
34
+
35
+ @dataclass
36
+ class ToolEvent:
37
+ """Emitted when a tool is called or produces a result."""
38
+ name: str
39
+ arguments: dict | None = None
40
+ result_preview: str | None = None
41
+ is_error: bool = False
42
+
43
+
44
+ ToolEventCallback = Callable[[ToolEvent], Awaitable[None]]
45
+
46
+
47
+ class AgentLoop:
48
+ """Runs the agentic ReAct loop.
49
+
50
+ 1. Build messages (system + history + user message).
51
+ 2. Call the LLM.
52
+ 3. If the LLM returns tool calls → execute them, append results, goto 2.
53
+ 4. If the LLM returns text → return it to the caller.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ provider: BaseProvider,
59
+ config: dict,
60
+ memory: Memory,
61
+ ):
62
+ self.provider = provider
63
+ self.config = config
64
+ self.memory = memory
65
+ self.tools = ToolExecutor(config, memory)
66
+ self._model_name: str = provider.model_name
67
+ self._model_tier: str = classify_model_tier(self._model_name)
68
+ self._effective_ctx: int = get_effective_context(self._model_name)
69
+ self._output_limit: int = get_output_limit(self._model_name)
70
+ # Max iterations: respect explicit user config, otherwise use tier default
71
+ user_iters = config.get("agent", {}).get("maxIterations")
72
+ self.max_iterations: int = (
73
+ user_iters if user_iters is not None
74
+ else get_tier_max_iterations(self._model_name)
75
+ )
76
+ self.temperature: float = config.get("agent", {}).get("temperature", 0.7)
77
+ # Token/cost tracking per session
78
+ self.session_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
79
+ self.turn_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
80
+ # Loop detection state
81
+ self._recent_calls: list[str] = []
82
+
83
+ async def close(self) -> None:
84
+ """Clean up provider and tool resources."""
85
+ await self.provider.close()
86
+ await self.tools.close()
87
+
88
+ async def run(
89
+ self,
90
+ user_message: str,
91
+ conversation: list[dict],
92
+ on_stream: StreamEmitter | None = None,
93
+ on_tool_event: ToolEventCallback | None = None,
94
+ ) -> str:
95
+ """Process *user_message* and return the assistant's final text reply.
96
+
97
+ *conversation* is mutated in-place (appended with the new user/assistant
98
+ turns) so the caller can maintain session state.
99
+ When *on_stream* is provided, partial text deltas are emitted in real time.
100
+ """
101
+ system_prompt = build_system_prompt(self.config, self.memory)
102
+ tool_defs = self.tools.get_tool_defs()
103
+
104
+ # Reset per-turn usage tracking
105
+ self.turn_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
106
+
107
+ # Build the full message list for this request
108
+ messages: list[dict] = [{"role": "system", "content": system_prompt}]
109
+ messages.extend(conversation)
110
+
111
+ # Build user message — detect image references for vision support
112
+ image_parts = build_image_content_parts(user_message)
113
+ if image_parts:
114
+ messages.append({"role": "user", "content": image_parts})
115
+ else:
116
+ messages.append({"role": "user", "content": user_message})
117
+
118
+ for iteration in range(self.max_iterations):
119
+ log.debug("iteration %d / %d", iteration + 1, self.max_iterations)
120
+
121
+ # Prune old tool results in-memory before sending to provider
122
+ pruned = self._prune_tool_results(messages)
123
+
124
+ response = await self._call_provider_with_retry(
125
+ pruned, tool_defs, on_stream
126
+ )
127
+ if isinstance(response, str):
128
+ # Error string from retry exhaustion
129
+ return response
130
+
131
+ # Accumulate token usage if available
132
+ if hasattr(response, "usage") and response.usage:
133
+ for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
134
+ val = response.usage.get(key, 0)
135
+ self.turn_usage[key] += val
136
+ self.session_usage[key] += val
137
+
138
+ # ----- text-only response → done -----
139
+ if not response.tool_calls:
140
+ final = response.content or "(empty response)"
141
+ # Persist to conversation history
142
+ conversation.append({"role": "user", "content": user_message})
143
+ conversation.append({"role": "assistant", "content": final})
144
+ # Token-aware compaction: summarize when conversation
145
+ # exceeds 75% of the model's effective context budget.
146
+ current_tokens = estimate_messages_tokens(conversation)
147
+ if current_tokens > int(self._effective_ctx * 0.75):
148
+ await self._summarize_history(conversation)
149
+ return final
150
+
151
+ # ----- tool calls → execute in parallel and continue -----
152
+ assistant_msg: dict = {
153
+ "role": "assistant",
154
+ "content": response.content or "",
155
+ "tool_calls": [
156
+ {
157
+ "id": tc.id,
158
+ "type": "function",
159
+ "function": {
160
+ "name": tc.name,
161
+ "arguments": (
162
+ json.dumps(tc.arguments)
163
+ if isinstance(tc.arguments, dict)
164
+ else tc.arguments
165
+ ),
166
+ },
167
+ }
168
+ for tc in response.tool_calls
169
+ ],
170
+ }
171
+ messages.append(assistant_msg)
172
+
173
+ # Execute all tool calls concurrently
174
+ async def _exec(tc):
175
+ log.info(
176
+ "🔧 %s(%s)",
177
+ tc.name,
178
+ json.dumps(tc.arguments, ensure_ascii=False)[:200],
179
+ )
180
+ if on_tool_event:
181
+ await on_tool_event(ToolEvent(name=tc.name, arguments=tc.arguments))
182
+ result = await self.tools.execute(tc.name, tc.arguments)
183
+ log.debug(" → %s", result[:300])
184
+ if on_tool_event:
185
+ preview = result[:200]
186
+ await on_tool_event(ToolEvent(
187
+ name=tc.name,
188
+ result_preview=preview,
189
+ is_error=result.startswith("Error:"),
190
+ ))
191
+ return tc, result
192
+
193
+ results = await asyncio.gather(
194
+ *[_exec(tc) for tc in response.tool_calls]
195
+ )
196
+
197
+ for tc, result in results:
198
+ # Compress tool output based on model tier
199
+ if len(result) > self._output_limit:
200
+ head = self._output_limit * 2 // 3
201
+ tail = self._output_limit // 3
202
+ result = (
203
+ result[:head]
204
+ + f"\n\n[... truncated {len(result) - head - tail} chars ...]\n\n"
205
+ + result[-tail:]
206
+ )
207
+ # Self-reflection on errors: nudge the LLM to reason about failures
208
+ if result.startswith("Error:"):
209
+ result = (
210
+ f"{result}\n\n"
211
+ "[The tool call failed. Analyze what went wrong "
212
+ "and try a different approach.]"
213
+ )
214
+ messages.append(
215
+ {
216
+ "role": "tool",
217
+ "tool_call_id": tc.id,
218
+ "name": tc.name,
219
+ "content": result,
220
+ }
221
+ )
222
+
223
+ # Loop detection: identical consecutive tool calls → nudge
224
+ for tc, _result in results:
225
+ sig = hashlib.md5(
226
+ json.dumps({"n": tc.name, "a": tc.arguments}, sort_keys=True).encode()
227
+ ).hexdigest()
228
+ self._recent_calls.append(sig)
229
+ # Keep a sliding window of last few signatures
230
+ self._recent_calls = self._recent_calls[-6:]
231
+ if len(self._recent_calls) >= 4:
232
+ last4 = self._recent_calls[-4:]
233
+ if last4[0] == last4[1] == last4[2] == last4[3]:
234
+ messages.append({
235
+ "role": "user",
236
+ "content": (
237
+ "You have repeated the same tool call multiple times "
238
+ "with the same result. Try a completely different approach."
239
+ ),
240
+ })
241
+
242
+ return "Reached maximum iterations. Please try a simpler request."
243
+
244
+ def _prune_tool_results(self, messages: list[dict]) -> list[dict]:
245
+ """Return a copy of *messages* with old tool results trimmed.
246
+
247
+ Protects the last 3 assistant turns and their tool results.
248
+ Older tool messages get soft-trimmed (head+tail) or replaced
249
+ with a placeholder. The original list is never mutated.
250
+ """
251
+ # Find positions of the last 3 assistant messages
252
+ asst_indices = [i for i, m in enumerate(messages) if m.get("role") == "assistant"]
253
+ cutoff = asst_indices[-3] if len(asst_indices) >= 3 else 0
254
+
255
+ pruned: list[dict] = []
256
+ for i, msg in enumerate(messages):
257
+ if i >= cutoff or msg.get("role") != "tool":
258
+ pruned.append(msg)
259
+ continue
260
+ content = msg.get("content", "")
261
+ if len(content) <= 4000:
262
+ pruned.append(msg)
263
+ elif i >= cutoff - 6:
264
+ # Soft-trim: keep head + tail
265
+ trimmed = content[:1500] + "\n...\n" + content[-1500:]
266
+ pruned.append({**msg, "content": trimmed})
267
+ else:
268
+ # Hard-clear: replace with placeholder
269
+ pruned.append({**msg, "content": "[Old tool result cleared to save context]"})
270
+ return pruned
271
+
272
+ async def _summarize_history(self, conversation: list[dict]) -> None:
273
+ """Summarize oldest messages in-place to keep context bounded."""
274
+ # Determine how many messages to summarize: enough to drop below
275
+ # 50% of effective context, but at least 6 messages.
276
+ target = int(self._effective_ctx * 0.5)
277
+ tokens_so_far = 0
278
+ cut = 0
279
+ for i, msg in enumerate(conversation):
280
+ content = msg.get("content", "")
281
+ tokens_so_far += len(content) // 4 + 4 if isinstance(content, str) else 50
282
+ if tokens_so_far > (estimate_messages_tokens(conversation) - target):
283
+ cut = max(i, 6)
284
+ break
285
+ if cut < 6:
286
+ cut = min(20, len(conversation) - 4) # fallback: oldest 20, keep last 4
287
+
288
+ to_summarize = conversation[:cut]
289
+ keep = conversation[cut:]
290
+
291
+ # Build a tight summarization request
292
+ summary_messages = [
293
+ {
294
+ "role": "system",
295
+ "content": (
296
+ "Summarize in under 200 words. Keep file paths, error "
297
+ "messages, code snippets, and key decisions verbatim. "
298
+ "State what was done and what remains."
299
+ ),
300
+ },
301
+ {
302
+ "role": "user",
303
+ "content": "\n".join(
304
+ f"[{m['role']}]: {m.get('content', '')[:500]}"
305
+ for m in to_summarize
306
+ if m.get("content")
307
+ ),
308
+ },
309
+ ]
310
+
311
+ try:
312
+ response = await self.provider.chat(
313
+ summary_messages, tools=None, temperature=0.3
314
+ )
315
+ summary_text = response.content or "Previous conversation context."
316
+ except Exception as e:
317
+ log.warning("Failed to summarize history: %s", e)
318
+ return
319
+
320
+ new_conversation = [
321
+ {
322
+ "role": "system",
323
+ "content": f"[Previous context summary]\n{summary_text}",
324
+ },
325
+ ]
326
+ new_conversation.extend(keep)
327
+ conversation.clear()
328
+ conversation.extend(new_conversation)
329
+
330
+ async def _call_provider_with_retry(self, messages, tool_defs, on_stream):
331
+ """Call the provider with retry + exponential backoff (with jitter) for transient errors."""
332
+ max_retries = 3
333
+ for attempt in range(max_retries):
334
+ try:
335
+ if on_stream:
336
+ return await self.provider.chat_stream(
337
+ messages,
338
+ on_delta=on_stream,
339
+ tools=tool_defs or None,
340
+ temperature=self.temperature,
341
+ )
342
+ else:
343
+ return await self.provider.chat(
344
+ messages,
345
+ tools=tool_defs or None,
346
+ temperature=self.temperature,
347
+ )
348
+ except httpx.HTTPStatusError as e:
349
+ if e.response.status_code in _TRANSIENT_CODES and attempt < max_retries - 1:
350
+ wait = (2 ** attempt) + random.uniform(0, 1) # jitter
351
+ log.warning(
352
+ "Transient HTTP %d, retrying in %.1fs (attempt %d/%d)",
353
+ e.response.status_code, wait, attempt + 1, max_retries,
354
+ )
355
+ await asyncio.sleep(wait)
356
+ continue
357
+ log.error("Provider error: %s", e)
358
+ return f"Error communicating with LLM: {e}"
359
+ except (httpx.ConnectError, httpx.ReadTimeout, OSError) as e:
360
+ if attempt < max_retries - 1:
361
+ wait = (2 ** attempt) + random.uniform(0, 1) # jitter
362
+ log.warning(
363
+ "Connection error, retrying in %.1fs (attempt %d/%d): %s",
364
+ wait, attempt + 1, max_retries, e,
365
+ )
366
+ await asyncio.sleep(wait)
367
+ continue
368
+ log.error("Provider error after %d attempts: %s", max_retries, e)
369
+ return f"Error communicating with LLM: {e}"
370
+ except Exception as e:
371
+ log.error("Provider error: %s", e)
372
+ return f"Error communicating with LLM: {e}"
@@ -0,0 +1,152 @@
1
+ """Persistent memory — simple JSON-backed key/value store with fuzzy search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import math
8
+ import re
9
+ from collections import Counter
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ log = logging.getLogger("agent-mini")
14
+
15
+ # ---------- Minimal Porter-style stemmer (covers common suffixes) ----------
16
+
17
+ _SUFFIX_RULES = [
18
+ ("ational", "ate"), ("tional", "tion"), ("enci", "ence"),
19
+ ("anci", "ance"), ("izer", "ize"), ("isation", "ize"),
20
+ ("ization", "ize"), ("ation", "ate"), ("ator", "ate"),
21
+ ("alism", "al"), ("iveness", "ive"), ("fulness", "ful"),
22
+ ("ousness", "ous"), ("aliti", "al"), ("iviti", "ive"),
23
+ ("biliti", "ble"), ("ling", "l"),
24
+ ("ing", ""), ("ment", ""), ("ness", ""), ("ity", ""),
25
+ ("ies", "y"), ("es", "e"), ("ed", ""), ("ly", ""), ("s", ""),
26
+ ]
27
+
28
+
29
+ def _stem(word: str) -> str:
30
+ """Very lightweight suffix stripping — handles 80 %+ of common English inflections."""
31
+ if len(word) <= 3:
32
+ return word
33
+ for suffix, replacement in _SUFFIX_RULES:
34
+ if word.endswith(suffix) and len(word) - len(suffix) + len(replacement) >= 3:
35
+ return word[: -len(suffix)] + replacement
36
+ return word
37
+
38
+
39
+ def _tokenize(text: str) -> list[str]:
40
+ """Lowercase, tokenize, and stem."""
41
+ words = re.findall(r"[a-z0-9]+", text.lower())
42
+ return [_stem(w) for w in words if len(w) > 1]
43
+
44
+
45
+ class Memory:
46
+ """Lightweight persistent memory.
47
+
48
+ Stores entries as ``{key, value, timestamp}`` dicts in a JSON file.
49
+ Recall uses simple keyword matching — no embedding model required.
50
+ """
51
+
52
+ def __init__(self, filepath: Path, max_entries: int = 1000):
53
+ self.filepath = filepath
54
+ self.max_entries = max_entries
55
+ self._data: list[dict] = []
56
+ self._load()
57
+
58
+ # ------------------------------------------------------------------
59
+ # Persistence
60
+ # ------------------------------------------------------------------
61
+
62
+ def _load(self) -> None:
63
+ if self.filepath.exists():
64
+ try:
65
+ self._data = json.loads(self.filepath.read_text())
66
+ except json.JSONDecodeError:
67
+ log.warning("Corrupted memory file %s, starting fresh", self.filepath)
68
+ self._data = []
69
+ except OSError as e:
70
+ log.error("Cannot read memory file %s: %s", self.filepath, e)
71
+ self._data = []
72
+
73
+ def _save(self) -> None:
74
+ self.filepath.parent.mkdir(parents=True, exist_ok=True)
75
+ self.filepath.write_text(json.dumps(self._data, indent=2, ensure_ascii=False))
76
+
77
+ # ------------------------------------------------------------------
78
+ # Public API (called by tools)
79
+ # ------------------------------------------------------------------
80
+
81
+ def store(self, key: str, value: str) -> str:
82
+ """Store a key/value pair. Returns confirmation string."""
83
+ entry = {
84
+ "key": key,
85
+ "value": value,
86
+ "timestamp": datetime.now().isoformat(),
87
+ }
88
+ self._data.append(entry)
89
+ if len(self._data) > self.max_entries:
90
+ self._data = self._data[-self.max_entries :]
91
+ self._save()
92
+ return f"Stored memory: {key}"
93
+
94
+ def recall(self, query: str) -> str:
95
+ """TF-IDF fuzzy search across stored memories."""
96
+ if not self._data:
97
+ return "No matching memories found."
98
+
99
+ query_tokens = _tokenize(query)
100
+ if not query_tokens:
101
+ return "No matching memories found."
102
+
103
+ # Build document token lists
104
+ docs: list[tuple[dict, list[str]]] = []
105
+ for entry in self._data:
106
+ text = f"{entry['key']} {entry['value']}"
107
+ tokens = _tokenize(text)
108
+ if tokens:
109
+ docs.append((entry, tokens))
110
+
111
+ if not docs:
112
+ return "No matching memories found."
113
+
114
+ # IDF: log(N / df) for each term in the query
115
+ n = len(docs)
116
+ df: Counter = Counter()
117
+ for _, tokens in docs:
118
+ unique = set(tokens)
119
+ for t in unique:
120
+ df[t] += 1
121
+
122
+ idf = {t: math.log((n + 1) / (df.get(t, 0) + 1)) + 1 for t in query_tokens}
123
+
124
+ # Score each document
125
+ scored: list[tuple[float, dict]] = []
126
+ for entry, tokens in docs:
127
+ tf = Counter(tokens)
128
+ doc_len = len(tokens)
129
+ score = 0.0
130
+ for qt in query_tokens:
131
+ # TF-IDF with length normalization
132
+ score += (tf.get(qt, 0) / doc_len) * idf.get(qt, 0)
133
+ # Substring fallback: partial match bonus
134
+ for token in set(tokens):
135
+ if qt in token or token in qt:
136
+ score += 0.3 * idf.get(qt, 0) / doc_len
137
+ if score > 0:
138
+ scored.append((score, entry))
139
+
140
+ scored.sort(key=lambda x: x[0], reverse=True)
141
+
142
+ if not scored:
143
+ return "No matching memories found."
144
+
145
+ lines = []
146
+ for _, entry in scored[:10]:
147
+ lines.append(f"[{entry['timestamp']}] {entry['key']}: {entry['value']}")
148
+ return "\n".join(lines)
149
+
150
+ def get_recent(self, n: int = 5) -> list[dict]:
151
+ """Return the *n* most recent entries (for system prompt context)."""
152
+ return self._data[-n:]