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 +18 -0
- opendot/agent/__init__.py +1 -0
- opendot/agent/config.py +27 -0
- opendot/agent/events.py +34 -0
- opendot/agent/explorers.py +93 -0
- opendot/agent/loop.py +263 -0
- opendot/agent/prompt.py +30 -0
- opendot/agent/usage.py +36 -0
- opendot/cli.py +364 -0
- opendot/mcp/__init__.py +14 -0
- opendot/mcp/manager.py +202 -0
- opendot/reversibility/__init__.py +2 -0
- opendot/reversibility/classifier.py +106 -0
- opendot/reversibility/engine.py +82 -0
- opendot/reversibility/ledger.py +62 -0
- opendot/reversibility/rules.py +64 -0
- opendot/reversibility/snapshots.py +220 -0
- opendot/tools/__init__.py +1 -0
- opendot/tools/local.py +332 -0
- opendot/tools/office.py +132 -0
- opendot/tui.py +409 -0
- opendot-0.0.1.dist-info/METADATA +169 -0
- opendot-0.0.1.dist-info/RECORD +26 -0
- opendot-0.0.1.dist-info/WHEEL +4 -0
- opendot-0.0.1.dist-info/entry_points.txt +2 -0
- opendot-0.0.1.dist-info/licenses/LICENSE +21 -0
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."""
|
opendot/agent/config.py
ADDED
|
@@ -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
|
opendot/agent/events.py
ADDED
|
@@ -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)
|
opendot/agent/prompt.py
ADDED
|
@@ -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
|