opendot 0.0.1__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.
opendot/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """opendot — an interactive terminal AI agent you can fully undo.
2
+
3
+ Public SDK surface (so CLI, and future clients, are thin layers over this):
4
+
5
+ from opendot import Agent, AgentConfig
6
+
7
+ agent = Agent(AgentConfig(model="gpt-4o"))
8
+ async for event in agent.run("list the python files and summarize them"):
9
+ ...
10
+ """
11
+
12
+ from opendot.agent.loop import Agent
13
+ from opendot.agent.config import AgentConfig
14
+ from opendot.agent.events import Event
15
+
16
+ __version__ = "0.0.1"
17
+
18
+ __all__ = ["Agent", "AgentConfig", "Event", "__version__"]
@@ -0,0 +1 @@
1
+ """The opendot agent: the model-agnostic ReAct loop, config, events, prompt."""
@@ -0,0 +1,27 @@
1
+ """Agent configuration.
2
+
3
+ Model IDs are whatever LiteLLM accepts (``gpt-4o``, ``claude-sonnet-4-5``,
4
+ ``ollama/qwen2.5``, ``huggingface/...``, etc.) — opendot is model-agnostic and
5
+ does not maintain its own provider list. API keys are read from the environment
6
+ by LiteLLM in the usual way (OPENAI_API_KEY, ANTHROPIC_API_KEY, ...).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass, field
13
+
14
+ # A sensible default that works if the user has an OpenAI key; over/ridden via
15
+ # --model, the OPENDOT_MODEL env var, or config.
16
+ DEFAULT_MODEL = os.environ.get("OPENDOT_MODEL", "gpt-4o")
17
+
18
+
19
+ @dataclass
20
+ class AgentConfig:
21
+ """Everything the agent loop needs. Kept minimal for v1."""
22
+
23
+ model: str = DEFAULT_MODEL
24
+ workdir: str = field(default_factory=os.getcwd)
25
+ max_steps: int = 40 # hard bound on tool-calling turns per user message
26
+ temperature: float | None = None
27
+ system_prompt: str | None = None # None => use the built-in default
@@ -0,0 +1,34 @@
1
+ """Events streamed by the agent loop.
2
+
3
+ The loop is an async generator of these. The CLI renders them; a future SDK
4
+ consumer or remote client can consume them just as easily. Keeping this a small,
5
+ stable vocabulary is what lets other surfaces (TUI, web, Slack) be thin adapters.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Literal
12
+
13
+ EventType = Literal[
14
+ "thinking", # a streamed chunk of the model's reasoning (reasoning models)
15
+ "text", # a streamed chunk of the assistant's answer
16
+ "tool_start", # the agent is about to run a tool
17
+ "tool_end", # a tool finished (result attached)
18
+ "final", # the assistant's turn is complete
19
+ "error", # something went wrong
20
+ # parallel read-only explorers
21
+ "explorer_start", # a subagent lane started (text=task, lane=index)
22
+ "explorer_step", # a subagent did something (text=summary, lane=index)
23
+ "explorer_done", # a subagent finished (text=finding summary, lane=index)
24
+ ]
25
+
26
+
27
+ @dataclass
28
+ class Event:
29
+ type: EventType
30
+ text: str = "" # for "text" / "error" / explorer_*
31
+ tool: str = "" # for tool_start / tool_end
32
+ args: dict[str, Any] = field(default_factory=dict) # tool_start
33
+ result: str = "" # tool_end
34
+ lane: int = -1 # explorer lane index (-1 = main agent)
@@ -0,0 +1,93 @@
1
+ """Parallel read-only explorer subagents.
2
+
3
+ The main agent can call `spawn_explorers([task, task, ...])` to fan out several
4
+ independent *read-only* investigations at once — each a full reasoning agent
5
+ restricted to grep/glob/read/list (NO write, edit, or shell). They run
6
+ concurrently and each returns a short findings summary, which is fed back to the
7
+ main agent.
8
+
9
+ Why read-only: opendot's guarantee is a clean, sequential, undoable action
10
+ ledger. Parallel *writers* would race the filesystem and make "undo the last
11
+ action" ambiguous. Explorers never mutate anything, so they parallelize the safe
12
+ part (understanding the project) without touching that guarantee.
13
+
14
+ Events are lane-tagged (lane = subagent index) so the TUI can show parallel
15
+ "Explore Task" lanes like opencode.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ from typing import Any, AsyncIterator
22
+
23
+ from opendot.agent.events import Event
24
+
25
+ MAX_EXPLORERS = 6
26
+
27
+ _EXPLORER_SYSTEM = """\
28
+ You are a read-only explorer subagent. Your ONLY job is to investigate and \
29
+ report — you cannot change anything. You have grep, glob, read_file, and \
30
+ list_files. Use them to answer the task, then give a concise findings summary \
31
+ (a few bullet points with the key files/lines). Do not attempt to write or run \
32
+ anything; those tools are not available to you.
33
+ """
34
+
35
+
36
+ async def run_explorers(
37
+ tasks: list[str], *, model: str, workdir: str
38
+ ) -> AsyncIterator[Event]:
39
+ """Run each task as a concurrent read-only subagent, yielding lane-tagged
40
+ events, and finally a merged findings summary the caller can use."""
41
+ from opendot.agent.config import AgentConfig
42
+ from opendot.agent.loop import Agent
43
+
44
+ tasks = [t for t in tasks if t.strip()][:MAX_EXPLORERS]
45
+ if not tasks:
46
+ yield Event("error", text="spawn_explorers: no tasks given")
47
+ return
48
+
49
+ # A queue lets concurrent lanes interleave their events into one stream.
50
+ q: asyncio.Queue = asyncio.Queue()
51
+ findings: dict[int, str] = {}
52
+
53
+ async def one(lane: int, task: str) -> None:
54
+ await q.put(Event("explorer_start", text=task, lane=lane))
55
+ agent = Agent(AgentConfig(
56
+ model=model, workdir=workdir, system_prompt=_EXPLORER_SYSTEM,
57
+ ))
58
+ # Force a read-only toolbox regardless of defaults.
59
+ from opendot.tools.local import Toolbox
60
+ agent.toolbox = Toolbox(workdir, reversibility=None, read_only=True)
61
+ answer_parts: list[str] = []
62
+ try:
63
+ async for ev in agent.run(task):
64
+ if ev.type == "tool_start":
65
+ await q.put(Event("explorer_step", text=f"{ev.tool}", lane=lane))
66
+ elif ev.type == "text":
67
+ answer_parts.append(ev.text)
68
+ elif ev.type == "error":
69
+ await q.put(Event("explorer_step", text=f"error: {ev.text}", lane=lane))
70
+ except Exception as exc: # noqa: BLE001
71
+ answer_parts.append(f"(explorer failed: {exc})")
72
+ summary = "".join(answer_parts).strip() or "(no findings)"
73
+ findings[lane] = summary
74
+ await q.put(Event("explorer_done", text=summary, lane=lane))
75
+
76
+ async def run_all() -> None:
77
+ await asyncio.gather(*(one(i, t) for i, t in enumerate(tasks)))
78
+ await q.put(None) # sentinel
79
+
80
+ runner = asyncio.create_task(run_all())
81
+ while True:
82
+ ev = await q.get()
83
+ if ev is None:
84
+ break
85
+ yield ev
86
+ await runner
87
+
88
+ # Attach the merged findings as the tool's textual result via a final event.
89
+ merged = "\n\n".join(
90
+ f"[explorer {i+1}] {tasks[i]}\n{findings.get(i, '(no findings)')}"
91
+ for i in range(len(tasks))
92
+ )
93
+ yield Event("tool_end", tool="spawn_explorers", result=merged)
opendot/agent/loop.py ADDED
@@ -0,0 +1,263 @@
1
+ """The agent loop — a model-agnostic ReAct loop over LiteLLM.
2
+
3
+ `Agent.run(user_message)` is an async generator of `Event`s. It keeps a running
4
+ conversation (`self.messages`) across turns so the CLI REPL is just repeated
5
+ `run(...)` calls. Tool calls are executed locally via the `Toolbox` and fed back
6
+ to the model until it produces a final answer (no more tool calls).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from typing import Any, AsyncIterator
14
+
15
+ from opendot.agent.config import AgentConfig
16
+ from opendot.agent.events import Event
17
+ from opendot.agent.prompt import DEFAULT_SYSTEM_PROMPT
18
+ from opendot.tools.local import Toolbox
19
+
20
+
21
+ _SPAWN_EXPLORERS_SPEC = {
22
+ "type": "function",
23
+ "function": {
24
+ "name": "spawn_explorers",
25
+ "description": (
26
+ "Investigate several INDEPENDENT questions about the project in "
27
+ "parallel. Each task runs as a read-only subagent (grep/glob/read "
28
+ "only — it cannot change anything) and returns findings. Use this to "
29
+ "understand a codebase fast by splitting distinct questions across "
30
+ "lanes. For anything that changes files, do it yourself."
31
+ ),
32
+ "parameters": {
33
+ "type": "object",
34
+ "properties": {
35
+ "tasks": {
36
+ "type": "array", "items": {"type": "string"},
37
+ "description": "2-6 independent, self-contained investigation tasks.",
38
+ }
39
+ },
40
+ "required": ["tasks"],
41
+ },
42
+ },
43
+ }
44
+
45
+
46
+ class _StreamUnsupported(Exception):
47
+ """Raised when a provider's stream can't yield structured tool calls."""
48
+
49
+
50
+ @dataclass
51
+ class _Assembled:
52
+ """Sentinel yielded at the end of a turn carrying the assembled result."""
53
+
54
+ assistant_msg: dict[str, Any]
55
+ tool_calls: list[dict[str, Any]]
56
+
57
+
58
+ def _assistant_msg(content: str, calls: list[dict[str, Any]]) -> dict[str, Any]:
59
+ msg: dict[str, Any] = {"role": "assistant", "content": content or None}
60
+ if calls:
61
+ msg["tool_calls"] = [
62
+ {"id": c["id"], "type": "function",
63
+ "function": {"name": c["name"], "arguments": c["args"] or "{}"}}
64
+ for c in calls
65
+ ]
66
+ return msg
67
+
68
+
69
+ class Agent:
70
+ def __init__(self, config: AgentConfig | None = None, confirm=None,
71
+ mcp_manager=None) -> None:
72
+ self.config = config or AgentConfig()
73
+ self.mcp = mcp_manager
74
+
75
+ # The reversibility engine snapshots + logs before every mutating action.
76
+ # Snapshot include/exclude rules come from OPENDOT.md (or defaults).
77
+ from opendot.reversibility.engine import Reversibility
78
+ from opendot.reversibility.rules import load_rules
79
+
80
+ self.reversibility = Reversibility(
81
+ workdir=self.config.workdir, rules=load_rules(self.config.workdir)
82
+ )
83
+ self.toolbox = Toolbox(
84
+ self.config.workdir,
85
+ reversibility=self.reversibility,
86
+ confirm=confirm,
87
+ mcp_manager=mcp_manager,
88
+ )
89
+ system = self.config.system_prompt or DEFAULT_SYSTEM_PROMPT
90
+ self.messages: list[dict[str, Any]] = [{"role": "system", "content": system}]
91
+
92
+ from opendot.agent.usage import Usage
93
+
94
+ self.usage = Usage() # running token/cost totals for the session
95
+
96
+ # The main agent can fan out read-only explorer subagents; explorers
97
+ # themselves cannot (no recursive spawning). Read-only toolboxes = explorer.
98
+ self.explorers_enabled = not getattr(self.toolbox, "read_only", False)
99
+
100
+ def reset(self) -> None:
101
+ """Clear the conversation (the /clear context-reset barrier), keeping system."""
102
+ self.messages = self.messages[:1]
103
+
104
+ def compact(self, keep_recent: int = 6) -> int:
105
+ """Trim old turns to fight context bloat, keeping the system prompt and
106
+ the most recent messages. Returns how many messages were dropped.
107
+
108
+ v1 is a simple truncation (honest and predictable). A summarizing
109
+ compaction can replace this later without changing the interface.
110
+ """
111
+ if len(self.messages) <= keep_recent + 1:
112
+ return 0
113
+ system, tail = self.messages[:1], self.messages[-keep_recent:]
114
+ dropped = len(self.messages) - len(system) - len(tail)
115
+ self.messages = system + tail
116
+ return dropped
117
+
118
+ async def run(self, user_message: str) -> AsyncIterator[Event]:
119
+ """Run one user turn to completion, yielding events."""
120
+ import litellm
121
+
122
+ # Keep litellm/provider chatter out of the user's terminal.
123
+ litellm.suppress_debug_info = True
124
+ litellm.set_verbose = False
125
+
126
+ self.messages.append({"role": "user", "content": user_message})
127
+ tools = self.toolbox.specs()
128
+ if self.explorers_enabled:
129
+ tools = tools + [_SPAWN_EXPLORERS_SPEC]
130
+
131
+ for _ in range(self.config.max_steps):
132
+ # Stream the turn: reasoning ("thinking") and answer ("text") arrive
133
+ # live, and tool-call deltas are reassembled. Streaming is what makes
134
+ # the UX feel alive. If streaming fails for a provider, fall back to
135
+ # a single non-streaming call (some local models emit tool calls as
136
+ # text in streaming mode).
137
+ try:
138
+ gen = None
139
+ async for ev in self._stream_turn(litellm, tools):
140
+ if isinstance(ev, _Assembled):
141
+ gen = ev
142
+ else:
143
+ yield ev
144
+ except _StreamUnsupported:
145
+ gen = None
146
+ async for ev in self._nonstream_turn(litellm, tools):
147
+ if isinstance(ev, _Assembled):
148
+ gen = ev
149
+ else:
150
+ yield ev
151
+ except Exception as exc: # noqa: BLE001
152
+ yield Event("error", text=f"model call failed: {exc}")
153
+ return
154
+
155
+ if gen is None: # an error event was already yielded
156
+ return
157
+
158
+ self.messages.append(gen.assistant_msg)
159
+
160
+ if not gen.tool_calls:
161
+ yield Event("final")
162
+ return
163
+
164
+ for i, c in enumerate(gen.tool_calls):
165
+ name, call_id, raw_args = c["name"], c["id"], c["args"]
166
+ try:
167
+ args = json.loads(raw_args or "{}")
168
+ except json.JSONDecodeError:
169
+ args = {}
170
+
171
+ # spawn_explorers is handled by the runtime (parallel read-only
172
+ # subagents), not the toolbox — it streams lane-tagged events.
173
+ if name == "spawn_explorers" and self.explorers_enabled:
174
+ from opendot.agent.explorers import run_explorers
175
+
176
+ result = "(no findings)"
177
+ async for ev in run_explorers(
178
+ args.get("tasks", []), model=self.config.model,
179
+ workdir=self.config.workdir,
180
+ ):
181
+ if ev.type == "tool_end" and ev.tool == "spawn_explorers":
182
+ result = ev.result # merged findings
183
+ else:
184
+ yield ev
185
+ self.messages.append(
186
+ {"role": "tool", "tool_call_id": call_id, "content": result}
187
+ )
188
+ continue
189
+
190
+ yield Event("tool_start", tool=name, args=args)
191
+ # Run the (synchronous) tool off the event loop. This is what lets
192
+ # a confirm-callback safely block for a UI prompt (e.g. the TUI
193
+ # modal) without freezing the loop.
194
+ import asyncio
195
+ result = await asyncio.to_thread(self.toolbox.call, name, args)
196
+ yield Event("tool_end", tool=name, result=result)
197
+ self.messages.append(
198
+ {"role": "tool", "tool_call_id": call_id, "content": result}
199
+ )
200
+
201
+ yield Event("error", text=f"stopped: hit max_steps ({self.config.max_steps})")
202
+
203
+ async def _stream_turn(self, litellm, tools):
204
+ """Stream one model turn. Yields Events, then a final _Assembled sentinel.
205
+
206
+ Raises _StreamUnsupported if the stream looks like a provider that emits
207
+ tool calls as plain text (so the caller can fall back to non-streaming).
208
+ """
209
+ stream = await litellm.acompletion(
210
+ model=self.config.model, messages=self.messages, tools=tools,
211
+ temperature=self.config.temperature, stream=True,
212
+ stream_options={"include_usage": True},
213
+ )
214
+ content_parts: list[str] = []
215
+ tool_calls: dict[int, dict[str, Any]] = {}
216
+
217
+ async for chunk in stream:
218
+ # The final usage chunk (include_usage) has no choices.
219
+ if getattr(chunk, "usage", None) and not chunk.choices:
220
+ self.usage.add_response(chunk, litellm)
221
+ continue
222
+ if not chunk.choices:
223
+ continue
224
+ delta = chunk.choices[0].delta
225
+ # reasoning models expose reasoning separately
226
+ reasoning = getattr(delta, "reasoning_content", None)
227
+ if reasoning:
228
+ yield Event("thinking", text=reasoning)
229
+ if getattr(delta, "content", None):
230
+ content_parts.append(delta.content)
231
+ yield Event("text", text=delta.content)
232
+ for tc in getattr(delta, "tool_calls", None) or []:
233
+ slot = tool_calls.setdefault(tc.index, {"id": "", "name": "", "args": ""})
234
+ if tc.id:
235
+ slot["id"] = tc.id
236
+ if tc.function and tc.function.name:
237
+ slot["name"] = tc.function.name
238
+ if tc.function and tc.function.arguments:
239
+ slot["args"] += tc.function.arguments
240
+
241
+ calls = [
242
+ {"id": c["id"] or f"call_{i}", "name": c["name"], "args": c["args"]}
243
+ for i, c in sorted(tool_calls.items()) if c["name"]
244
+ ]
245
+ yield _Assembled(_assistant_msg("".join(content_parts), calls), calls)
246
+
247
+ async def _nonstream_turn(self, litellm, tools):
248
+ """Non-streaming fallback (reliable tool calls; no live tokens)."""
249
+ resp = await litellm.acompletion(
250
+ model=self.config.model, messages=self.messages, tools=tools,
251
+ temperature=self.config.temperature, stream=False,
252
+ )
253
+ self.usage.add_response(resp, litellm)
254
+ msg = resp.choices[0].message
255
+ raw = getattr(msg, "tool_calls", None) or []
256
+ calls = [
257
+ {"id": tc.id or f"call_{i}", "name": tc.function.name,
258
+ "args": tc.function.arguments or "{}"}
259
+ for i, tc in enumerate(raw)
260
+ ]
261
+ if msg.content:
262
+ yield Event("text", text=msg.content)
263
+ yield _Assembled(_assistant_msg(msg.content or "", calls), calls)
@@ -0,0 +1,30 @@
1
+ """The default system prompt. Lean by design — a big product-framing prompt is
2
+ what bloats other agents; opendot's keeps it to role, tools, and safety posture.
3
+ An OPENDOT.md in the working dir (loaded by the caller) is appended for
4
+ project-specific guidance.
5
+ """
6
+
7
+ DEFAULT_SYSTEM_PROMPT = """\
8
+ You are opendot, an AI agent operating in the user's terminal. You work directly \
9
+ on their real files and shell in the current working directory.
10
+
11
+ Tools:
12
+ - list_files, read_file — inspect the project
13
+ - grep — search file contents by regex
14
+ - glob — find files by pattern (e.g. **/*.py)
15
+ - edit — make a targeted find-and-replace in a file (PREFER THIS for changes)
16
+ - write_file — create a new file or fully rewrite one
17
+ - run_shell — anything else (npm, git, build, mv, cp, tests, …)
18
+
19
+ How to work:
20
+ - Narrate briefly BEFORE each action: say what you're about to do and why, in one \
21
+ line, so the user can follow your reasoning. Then take the action.
22
+ - Explore before you change: read/grep/glob to understand the code first.
23
+ - Prefer `edit` (surgical find-replace) over `write_file` (full rewrite) so changes \
24
+ are small and reviewable. Only write_file for new files or genuine rewrites.
25
+ - Keep shell commands scoped to the working directory.
26
+
27
+ Every change you make is snapshotted and can be undone, so work confidently — but \
28
+ if a request is destructive or reaches outside the workspace (deleting outside \
29
+ files, network, git push), call it out first. When done, give a short summary.
30
+ """
opendot/agent/usage.py ADDED
@@ -0,0 +1,36 @@
1
+ """Token + cost accounting for a session.
2
+
3
+ LiteLLM computes per-response cost via ``litellm.completion_cost`` and reports
4
+ token counts in the response usage. We accumulate both across a session so the
5
+ TUI sidebar can show "N tokens · $X spent" like the tools people expect.
6
+
7
+ Best-effort: if a provider/model has no cost data, cost stays 0 but tokens still
8
+ accumulate. Never raises.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass
17
+ class Usage:
18
+ prompt_tokens: int = 0
19
+ completion_tokens: int = 0
20
+ total_tokens: int = 0
21
+ cost_usd: float = 0.0
22
+
23
+ def add_response(self, resp, litellm) -> None:
24
+ """Fold one LiteLLM response's usage + cost into the running totals."""
25
+ try:
26
+ u = getattr(resp, "usage", None)
27
+ if u:
28
+ self.prompt_tokens += int(getattr(u, "prompt_tokens", 0) or 0)
29
+ self.completion_tokens += int(getattr(u, "completion_tokens", 0) or 0)
30
+ self.total_tokens += int(getattr(u, "total_tokens", 0) or 0)
31
+ except Exception: # noqa: BLE001
32
+ pass
33
+ try:
34
+ self.cost_usd += float(litellm.completion_cost(completion_response=resp) or 0.0)
35
+ except Exception: # noqa: BLE001
36
+ pass