ottopilot 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.
- ottoai/__init__.py +7 -0
- ottoai/__main__.py +4 -0
- ottoai/agent.py +244 -0
- ottoai/cli.py +456 -0
- ottoai/config.py +142 -0
- ottoai/memory/__init__.py +5 -0
- ottoai/memory/compaction.py +60 -0
- ottoai/memory/embeddings.py +40 -0
- ottoai/memory/store.py +245 -0
- ottoai/plugins.py +66 -0
- ottoai/prompts.py +30 -0
- ottoai/providers/__init__.py +38 -0
- ottoai/providers/anthropic.py +122 -0
- ottoai/providers/base.py +60 -0
- ottoai/providers/gemini.py +125 -0
- ottoai/providers/openai_compatible.py +243 -0
- ottoai/tools/__init__.py +78 -0
- ottoai/tools/files.py +121 -0
- ottoai/tools/git.py +41 -0
- ottoai/tools/history.py +67 -0
- ottoai/tools/mcp.py +119 -0
- ottoai/tools/search.py +89 -0
- ottoai/tools/shell.py +50 -0
- ottoai/tools/web.py +53 -0
- ottoai/types.py +105 -0
- ottopilot-0.1.0.dist-info/METADATA +168 -0
- ottopilot-0.1.0.dist-info/RECORD +30 -0
- ottopilot-0.1.0.dist-info/WHEEL +4 -0
- ottopilot-0.1.0.dist-info/entry_points.txt +20 -0
- ottopilot-0.1.0.dist-info/licenses/LICENSE +21 -0
ottoai/__init__.py
ADDED
ottoai/__main__.py
ADDED
ottoai/agent.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""The agent loop: orchestrates provider + tools + memory for one user turn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from .memory import Store
|
|
8
|
+
from .memory.compaction import compact, should_compact
|
|
9
|
+
from .memory.embeddings import Embedder
|
|
10
|
+
from .prompts import build_system_prompt
|
|
11
|
+
from .providers import build_provider
|
|
12
|
+
from .tools import ToolContext, ToolRegistry
|
|
13
|
+
from .types import ChatResponse, Message, ToolCall, ToolResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Agent:
|
|
17
|
+
"""Drives a single session. Holds the active (in-context) message list and
|
|
18
|
+
persists everything to the Store."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
config: Any,
|
|
23
|
+
store: Store,
|
|
24
|
+
cwd: str,
|
|
25
|
+
provider_name: Optional[str] = None,
|
|
26
|
+
model: Optional[str] = None,
|
|
27
|
+
session_id: Optional[str] = None,
|
|
28
|
+
confirm: Optional[Callable[[str, dict], bool]] = None,
|
|
29
|
+
on_event: Optional[Callable[[str, dict], None]] = None,
|
|
30
|
+
):
|
|
31
|
+
self.config = config
|
|
32
|
+
self.store = store
|
|
33
|
+
self.cwd = cwd
|
|
34
|
+
self.provider_name = provider_name or config.get("default_provider", "ollama")
|
|
35
|
+
self.model = model or config.get("default_model", "qwen3")
|
|
36
|
+
self.confirm = confirm or (lambda name, args: True)
|
|
37
|
+
self.on_event = on_event or (lambda ev, data: None)
|
|
38
|
+
|
|
39
|
+
self.provider = build_provider(self.provider_name, config)
|
|
40
|
+
self.embedder = Embedder(config)
|
|
41
|
+
|
|
42
|
+
# Session + tool context.
|
|
43
|
+
if session_id:
|
|
44
|
+
self.session_id = session_id
|
|
45
|
+
self.messages = self.store.get_messages(session_id)
|
|
46
|
+
else:
|
|
47
|
+
self.session_id = self.store.create_session(
|
|
48
|
+
cwd=cwd, provider=self.provider_name, model=self.model
|
|
49
|
+
)
|
|
50
|
+
self.messages = []
|
|
51
|
+
|
|
52
|
+
self.context = ToolContext(
|
|
53
|
+
cwd=cwd, store=store, config=config, session_id=self.session_id
|
|
54
|
+
)
|
|
55
|
+
self.registry = ToolRegistry(self.context)
|
|
56
|
+
self.registry.load_all()
|
|
57
|
+
self._load_mcp()
|
|
58
|
+
|
|
59
|
+
if not self.messages:
|
|
60
|
+
self._ensure_system_message()
|
|
61
|
+
|
|
62
|
+
# ---------- setup helpers ----------
|
|
63
|
+
def _load_mcp(self) -> None:
|
|
64
|
+
servers = self.config.get("mcp_servers", {})
|
|
65
|
+
if not servers:
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
from .tools.mcp import register_mcp_servers
|
|
69
|
+
|
|
70
|
+
register_mcp_servers(self.registry, servers)
|
|
71
|
+
except Exception as e: # pragma: no cover
|
|
72
|
+
self.on_event("warn", {"message": f"MCP load failed: {e}"})
|
|
73
|
+
|
|
74
|
+
def _ensure_system_message(self) -> None:
|
|
75
|
+
mem = self._memory_context()
|
|
76
|
+
sys_msg = Message(role="system", content=build_system_prompt(self.cwd, self.config, mem))
|
|
77
|
+
self.messages.insert(0, sys_msg)
|
|
78
|
+
self.store.add_message(self.session_id, sys_msg)
|
|
79
|
+
|
|
80
|
+
def _memory_context(self) -> str:
|
|
81
|
+
facts = self.store.list_memory()
|
|
82
|
+
if not facts:
|
|
83
|
+
return ""
|
|
84
|
+
return "\n".join(f"- ({f['subject']}) {f['fact']}" for f in facts[:20])
|
|
85
|
+
|
|
86
|
+
# ---------- main entry ----------
|
|
87
|
+
def switch_provider(self, provider_name: str, model: Optional[str] = None) -> None:
|
|
88
|
+
self.provider_name = provider_name
|
|
89
|
+
self.provider = build_provider(provider_name, self.config)
|
|
90
|
+
if model:
|
|
91
|
+
self.model = model
|
|
92
|
+
self.store.touch_session(self.session_id, provider=provider_name, model=self.model)
|
|
93
|
+
|
|
94
|
+
# ---------- thinking mode ----------
|
|
95
|
+
COMPLEX_HINTS = (
|
|
96
|
+
"debug", "why", "fix", "bug", "error", "stack trace", "traceback", "root cause",
|
|
97
|
+
"algorithm", "optimize", "optimis", "refactor", "design", "architect", "plan",
|
|
98
|
+
"prove", "calculate", "compute", "math", "analyze", "analyse", "analysis",
|
|
99
|
+
"compare", "performance", "concurren", "race condition", "deadlock", "complexity",
|
|
100
|
+
"reason", "step by step", "edge case", "strateg", "trade-off", "tradeoff",
|
|
101
|
+
"review", "security", "vulnerab", "migrate", "implement", "architecture",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _looks_complex(self, text: str) -> bool:
|
|
105
|
+
t = text.lower().strip()
|
|
106
|
+
if len(t.split()) >= 30:
|
|
107
|
+
return True
|
|
108
|
+
if "```" in text or "\n" in t: # multi-line input / pasted code
|
|
109
|
+
return True
|
|
110
|
+
if t.count("?") >= 2 or " and then " in t:
|
|
111
|
+
return True
|
|
112
|
+
return any(h in t for h in self.COMPLEX_HINTS)
|
|
113
|
+
|
|
114
|
+
def _resolve_think(self, user_input: str) -> bool:
|
|
115
|
+
setting = self.config.get("think", True)
|
|
116
|
+
if isinstance(setting, bool):
|
|
117
|
+
return setting
|
|
118
|
+
if isinstance(setting, str):
|
|
119
|
+
s = setting.lower()
|
|
120
|
+
if s == "auto":
|
|
121
|
+
enabled = self._looks_complex(user_input)
|
|
122
|
+
self.on_event("think_mode", {"auto": True, "enabled": enabled})
|
|
123
|
+
return enabled
|
|
124
|
+
return s in ("1", "true", "on", "yes")
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
def run_turn(self, user_input: str) -> str:
|
|
128
|
+
user_msg = Message(role="user", content=user_input)
|
|
129
|
+
self.messages.append(user_msg)
|
|
130
|
+
self.store.add_message(self.session_id, user_msg)
|
|
131
|
+
self._index_embedding(user_msg)
|
|
132
|
+
|
|
133
|
+
# Resolve the thinking mode once per user turn (handles "auto").
|
|
134
|
+
self._active_think = self._resolve_think(user_input)
|
|
135
|
+
|
|
136
|
+
if self.store.get_session(self.session_id)["title"] in ("Untitled session", None):
|
|
137
|
+
self.store.touch_session(self.session_id, title=user_input[:60])
|
|
138
|
+
|
|
139
|
+
max_iters = self.config.get("max_tool_iterations", 25)
|
|
140
|
+
tool_specs = self.registry.specs()
|
|
141
|
+
|
|
142
|
+
for _ in range(max_iters):
|
|
143
|
+
self._maybe_compact()
|
|
144
|
+
self.on_event("thinking", {"model": self.model, "provider": self.provider_name})
|
|
145
|
+
resp = self._get_response(tool_specs)
|
|
146
|
+
|
|
147
|
+
assistant_msg = Message(
|
|
148
|
+
role="assistant", content=resp.content, tool_calls=resp.tool_calls
|
|
149
|
+
)
|
|
150
|
+
self.messages.append(assistant_msg)
|
|
151
|
+
self.store.add_message(self.session_id, assistant_msg)
|
|
152
|
+
if resp.content:
|
|
153
|
+
self._index_embedding(assistant_msg)
|
|
154
|
+
|
|
155
|
+
if not resp.wants_tools:
|
|
156
|
+
return resp.content
|
|
157
|
+
|
|
158
|
+
if resp.content and not self.config.get("stream", True):
|
|
159
|
+
self.on_event("assistant_text", {"text": resp.content})
|
|
160
|
+
|
|
161
|
+
for call in resp.tool_calls:
|
|
162
|
+
result = self._execute_tool(call)
|
|
163
|
+
tool_msg = Message(
|
|
164
|
+
role="tool",
|
|
165
|
+
content=result.content,
|
|
166
|
+
tool_call_id=call.id,
|
|
167
|
+
name=call.name,
|
|
168
|
+
)
|
|
169
|
+
self.messages.append(tool_msg)
|
|
170
|
+
self.store.add_message(self.session_id, tool_msg)
|
|
171
|
+
|
|
172
|
+
return "[stopped: reached max tool iterations]"
|
|
173
|
+
|
|
174
|
+
def _get_response(self, tool_specs: list) -> "ChatResponse":
|
|
175
|
+
"""Run one model turn, streaming deltas as events when enabled."""
|
|
176
|
+
think = getattr(self, "_active_think", True)
|
|
177
|
+
if not self.config.get("stream", True):
|
|
178
|
+
return self.provider.chat(
|
|
179
|
+
self.messages, tools=tool_specs or None, model=self.model, think=think
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
final = None
|
|
183
|
+
streamed = False
|
|
184
|
+
for chunk in self.provider.chat_stream(
|
|
185
|
+
self.messages, tools=tool_specs or None, model=self.model, think=think
|
|
186
|
+
):
|
|
187
|
+
if chunk.kind == "text" and chunk.text:
|
|
188
|
+
streamed = True
|
|
189
|
+
self.on_event("assistant_delta", {"text": chunk.text})
|
|
190
|
+
elif chunk.kind == "final":
|
|
191
|
+
final = chunk.response
|
|
192
|
+
if streamed:
|
|
193
|
+
self.on_event("assistant_done", {})
|
|
194
|
+
if final is None: # defensive fallback
|
|
195
|
+
final = self.provider.chat(
|
|
196
|
+
self.messages, tools=tool_specs or None, model=self.model, think=think
|
|
197
|
+
)
|
|
198
|
+
return final
|
|
199
|
+
|
|
200
|
+
# ---------- tool execution ----------
|
|
201
|
+
def _execute_tool(self, call: ToolCall) -> ToolResult:
|
|
202
|
+
tool = self.registry.get(call.name)
|
|
203
|
+
if tool is None:
|
|
204
|
+
return ToolResult(call.id, call.name, f"[error] unknown tool: {call.name}", True)
|
|
205
|
+
|
|
206
|
+
self.on_event("tool_start", {"name": call.name, "arguments": call.arguments})
|
|
207
|
+
if tool.requires_confirmation and not self.confirm(call.name, call.arguments):
|
|
208
|
+
msg = "[denied] user declined to run this tool."
|
|
209
|
+
self.on_event("tool_end", {"name": call.name, "result": msg, "denied": True})
|
|
210
|
+
return ToolResult(call.id, call.name, msg, True)
|
|
211
|
+
try:
|
|
212
|
+
output = tool.run(**call.arguments)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
output = f"[error] tool '{call.name}' raised: {e}"
|
|
215
|
+
self.on_event("tool_end", {"name": call.name, "result": output})
|
|
216
|
+
return ToolResult(call.id, call.name, output)
|
|
217
|
+
|
|
218
|
+
# ---------- memory / compaction ----------
|
|
219
|
+
def _maybe_compact(self) -> None:
|
|
220
|
+
if should_compact(self.messages, self.config):
|
|
221
|
+
self.on_event("compacting", {"count": len(self.messages)})
|
|
222
|
+
self.messages = compact(self.messages, self.provider, self.model, self.config)
|
|
223
|
+
|
|
224
|
+
def force_compact(self) -> None:
|
|
225
|
+
self.messages = compact(self.messages, self.provider, self.model, self.config)
|
|
226
|
+
|
|
227
|
+
def _index_embedding(self, msg: Message) -> None:
|
|
228
|
+
if not msg.content:
|
|
229
|
+
return
|
|
230
|
+
vec = self.embedder.embed_one(msg.content)
|
|
231
|
+
if vec:
|
|
232
|
+
import uuid
|
|
233
|
+
|
|
234
|
+
self.store.add_embedding(
|
|
235
|
+
uuid.uuid4().hex, "message", self.session_id, msg.content[:2000], vec
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def remember(self, subject: str, fact: str, scope: str = "user") -> None:
|
|
239
|
+
self.store.add_memory(subject, fact, scope, self.session_id)
|
|
240
|
+
vec = self.embedder.embed_one(fact)
|
|
241
|
+
if vec:
|
|
242
|
+
import uuid
|
|
243
|
+
|
|
244
|
+
self.store.add_embedding(uuid.uuid4().hex, "memory", self.session_id, fact, vec)
|