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 ADDED
@@ -0,0 +1,7 @@
1
+ """OttoAI — a local-first, extensible coding agent."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .types import Message, ToolCall, ToolResult, ChatResponse
6
+
7
+ __all__ = ["Message", "ToolCall", "ToolResult", "ChatResponse", "__version__"]
ottoai/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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)