agent7 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.
- agent7/__init__.py +3 -0
- agent7/__main__.py +357 -0
- agent7/agent.py +398 -0
- agent7/commands.py +236 -0
- agent7/providers.py +161 -0
- agent7/session.py +101 -0
- agent7-0.1.0.dist-info/METADATA +127 -0
- agent7-0.1.0.dist-info/RECORD +11 -0
- agent7-0.1.0.dist-info/WHEEL +4 -0
- agent7-0.1.0.dist-info/entry_points.txt +2 -0
- agent7-0.1.0.dist-info/licenses/LICENSE +21 -0
agent7/__init__.py
ADDED
agent7/__main__.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""CLI: python -m agent7 [task] [--cwd DIR]"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from openai import AsyncOpenAI
|
|
14
|
+
from prompt_toolkit import PromptSession
|
|
15
|
+
from prompt_toolkit.application.current import get_app
|
|
16
|
+
from prompt_toolkit.completion import Completer, Completion, CompleteEvent
|
|
17
|
+
from prompt_toolkit.document import Document
|
|
18
|
+
from prompt_toolkit.enums import EditingMode
|
|
19
|
+
from prompt_toolkit.formatted_text import HTML
|
|
20
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
21
|
+
from prompt_toolkit.key_binding.vi_state import InputMode
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.markdown import Markdown
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
|
|
26
|
+
from .agent import SYSTEM, Interrupted, run_agent, tool_summary
|
|
27
|
+
from .commands import dispatch, fetch_models, known_names, switch_model
|
|
28
|
+
from .providers import get_provider, resolve_all, resolve_default
|
|
29
|
+
from .session import Session, format_context_prompt
|
|
30
|
+
|
|
31
|
+
console, err = Console(stderr=False), Console(stderr=True)
|
|
32
|
+
_THINK_FRAMES = ("thinking ", "thinking. ", "thinking.. ", "thinking...")
|
|
33
|
+
|
|
34
|
+
def _load_dotenv(path: Path = Path(".env")) -> None:
|
|
35
|
+
if not path.is_file():
|
|
36
|
+
return
|
|
37
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
38
|
+
line = line.strip()
|
|
39
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
40
|
+
continue
|
|
41
|
+
key, _, val = line.partition("=")
|
|
42
|
+
os.environ.setdefault(key.strip(), val.strip().strip("'").strip('"'))
|
|
43
|
+
|
|
44
|
+
def _force_utf8_stdio() -> None:
|
|
45
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
46
|
+
try:
|
|
47
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
51
|
+
|
|
52
|
+
class ReplyPrinter:
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self.buf: list[str] = []
|
|
55
|
+
self._chars = 0
|
|
56
|
+
self._thinking_open = False
|
|
57
|
+
self._thinking_t0 = 0.0
|
|
58
|
+
self._counting = False
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> ReplyPrinter:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *exc: object) -> None:
|
|
64
|
+
self._close_thinking()
|
|
65
|
+
self._clear_counter()
|
|
66
|
+
text = "".join(self.buf)
|
|
67
|
+
self.buf.clear()
|
|
68
|
+
self._chars = 0
|
|
69
|
+
if text.strip():
|
|
70
|
+
console.print()
|
|
71
|
+
console.print(Markdown(text))
|
|
72
|
+
console.print()
|
|
73
|
+
|
|
74
|
+
def _clear_status_line(self) -> None:
|
|
75
|
+
if err.is_terminal:
|
|
76
|
+
sys.stderr.write("\r\033[K")
|
|
77
|
+
sys.stderr.flush()
|
|
78
|
+
|
|
79
|
+
def _close_thinking(self) -> None:
|
|
80
|
+
if self._thinking_open:
|
|
81
|
+
self._clear_status_line()
|
|
82
|
+
self._thinking_open = False
|
|
83
|
+
|
|
84
|
+
def _clear_counter(self) -> None:
|
|
85
|
+
if self._counting:
|
|
86
|
+
self._clear_status_line()
|
|
87
|
+
self._counting = False
|
|
88
|
+
|
|
89
|
+
def _show_counter(self) -> None:
|
|
90
|
+
if not err.is_terminal:
|
|
91
|
+
return
|
|
92
|
+
self._close_thinking()
|
|
93
|
+
self._counting = True
|
|
94
|
+
sys.stderr.write(f"\r\033[K … {self._chars} chars")
|
|
95
|
+
sys.stderr.flush()
|
|
96
|
+
|
|
97
|
+
def on_thinking(self, text: str) -> None:
|
|
98
|
+
if not text:
|
|
99
|
+
return
|
|
100
|
+
self._clear_counter()
|
|
101
|
+
if not self._thinking_open:
|
|
102
|
+
self._thinking_open, self._thinking_t0 = True, time.monotonic()
|
|
103
|
+
if err.is_terminal:
|
|
104
|
+
frame = int((time.monotonic() - self._thinking_t0) / 0.4) % len(_THINK_FRAMES)
|
|
105
|
+
sys.stderr.write(f"\r\033[K {_THINK_FRAMES[frame]}")
|
|
106
|
+
sys.stderr.flush()
|
|
107
|
+
|
|
108
|
+
def on_answer_to_thinking(self) -> None:
|
|
109
|
+
if not self.buf:
|
|
110
|
+
return
|
|
111
|
+
self.buf.clear()
|
|
112
|
+
self._chars = 0
|
|
113
|
+
self._clear_counter()
|
|
114
|
+
self.on_thinking(".")
|
|
115
|
+
|
|
116
|
+
def on_delta(self, text: str) -> None:
|
|
117
|
+
self.buf.append(text)
|
|
118
|
+
self._chars += len(text)
|
|
119
|
+
self._show_counter()
|
|
120
|
+
|
|
121
|
+
def on_tool(self, turn: int, name: str, args: dict, result: str) -> None:
|
|
122
|
+
self._close_thinking()
|
|
123
|
+
self._clear_counter()
|
|
124
|
+
self.buf.clear()
|
|
125
|
+
self._chars = 0
|
|
126
|
+
ok = not result.startswith("error:")
|
|
127
|
+
mark = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
128
|
+
meta = ""
|
|
129
|
+
if name == "bash" and result.startswith("exit="):
|
|
130
|
+
code = result.split("\n", 1)[0].removeprefix("exit=")
|
|
131
|
+
color = "green" if code == "0" else "yellow"
|
|
132
|
+
meta = f" [{color}]exit={code}[/{color}]"
|
|
133
|
+
err.print(f" {mark} [bold]{name:<5}[/bold] [dim]{tool_summary(name, args)}[/dim]{meta}")
|
|
134
|
+
|
|
135
|
+
def on_preview(self, stats: str) -> None:
|
|
136
|
+
self._close_thinking()
|
|
137
|
+
self._clear_counter()
|
|
138
|
+
line = (stats.splitlines() or [""])[0]
|
|
139
|
+
if not line:
|
|
140
|
+
return
|
|
141
|
+
path, _, rest = line.rpartition(" ")
|
|
142
|
+
head = Text("# write ", style="dim")
|
|
143
|
+
head.append(path or line, style="dim")
|
|
144
|
+
if path and rest.startswith("+"):
|
|
145
|
+
head.append(" ")
|
|
146
|
+
for part in rest.split():
|
|
147
|
+
head.append(part + " ", style="green bold" if part[0] == "+" else "red bold")
|
|
148
|
+
head.right_crop(1)
|
|
149
|
+
err.print(head)
|
|
150
|
+
|
|
151
|
+
_AT_PATH = re.compile(r"(?<![A-Za-z0-9_.\-/@])@((?:\\ |[^\s@])*)$")
|
|
152
|
+
|
|
153
|
+
def at_path_prefix(text: str) -> str | None:
|
|
154
|
+
"""The path fragment after an '@' that starts the current word ('@' -> '')."""
|
|
155
|
+
m = _AT_PATH.search(text)
|
|
156
|
+
return m.group(1) if m else None
|
|
157
|
+
|
|
158
|
+
def path_completions(cwd: Path, text: str) -> list[Completion]:
|
|
159
|
+
"""Complete files/dirs as `@path` for the word being typed at the end of text."""
|
|
160
|
+
fragment = at_path_prefix(text)
|
|
161
|
+
if fragment is None:
|
|
162
|
+
return []
|
|
163
|
+
raw = os.path.expanduser(fragment.replace("\\ ", " "))
|
|
164
|
+
head, _, prefix = raw.rpartition("/")
|
|
165
|
+
target = Path(head) if head else Path()
|
|
166
|
+
if not target.is_absolute():
|
|
167
|
+
target = cwd / target
|
|
168
|
+
entries: list[tuple[Path, str]] = []
|
|
169
|
+
show_hidden = prefix.startswith(".")
|
|
170
|
+
for root, dirs, files in os.walk(target):
|
|
171
|
+
if not show_hidden:
|
|
172
|
+
dirs[:] = [name for name in dirs if not name.startswith(".")]
|
|
173
|
+
for name in dirs + files:
|
|
174
|
+
if (name.startswith(".") and not show_hidden) or not name.startswith(prefix):
|
|
175
|
+
continue
|
|
176
|
+
p = Path(root) / name
|
|
177
|
+
entries.append((p, p.relative_to(target).as_posix()))
|
|
178
|
+
entries.sort(key=lambda entry: (not entry[0].is_dir(), entry[1].lower()))
|
|
179
|
+
out = []
|
|
180
|
+
for p, path in entries:
|
|
181
|
+
is_dir = p.is_dir()
|
|
182
|
+
rel = f"{head}/{path}" if head else path
|
|
183
|
+
out.append(Completion(
|
|
184
|
+
(rel + "/" if is_dir else rel).replace(" ", "\\ "),
|
|
185
|
+
start_position=-len(fragment),
|
|
186
|
+
display=path + ("/" if is_dir else ""),
|
|
187
|
+
))
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
class ReplCompleter(Completer):
|
|
191
|
+
"""Slash commands, plus `@path` file completion from the cwd."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, session: Session) -> None:
|
|
194
|
+
self.session = session
|
|
195
|
+
|
|
196
|
+
def get_completions(self, document: Document, complete_event: CompleteEvent):
|
|
197
|
+
text = document.text_before_cursor
|
|
198
|
+
if not text.startswith("/"):
|
|
199
|
+
yield from path_completions(self.session.cwd, text)
|
|
200
|
+
return
|
|
201
|
+
parts = text[1:].split(" ", 1)
|
|
202
|
+
cmd = parts[0].lower()
|
|
203
|
+
if len(parts) == 1 and " " not in text:
|
|
204
|
+
for name in known_names():
|
|
205
|
+
if name.startswith(cmd):
|
|
206
|
+
yield Completion(name, start_position=-len(cmd), display=f"/{name}")
|
|
207
|
+
return
|
|
208
|
+
if len(parts) == 2:
|
|
209
|
+
prefix = parts[1]
|
|
210
|
+
if cmd == "model":
|
|
211
|
+
for mid in self.session.models_cache or []:
|
|
212
|
+
bare = mid.partition("/")[2]
|
|
213
|
+
if mid.startswith(prefix) or bare.startswith(prefix) or prefix.lower() in mid.lower():
|
|
214
|
+
yield Completion(mid, start_position=-len(prefix), display=mid)
|
|
215
|
+
elif cmd in ("login", "logout"):
|
|
216
|
+
for mid in ("deepseek", "kimi", "moonshot", "global"):
|
|
217
|
+
if mid.startswith(prefix.lower()):
|
|
218
|
+
yield Completion(mid, start_position=-len(prefix), display=mid)
|
|
219
|
+
|
|
220
|
+
NO_TEXT_NOTICE = "no text in the reply — the model stopped without content (retry, or /model)"
|
|
221
|
+
TRUNCATED_NOTICE = "no text in the reply — the model hit the output token limit (retry with a smaller step)"
|
|
222
|
+
STOPPED_PREFIX = "(stopped after "
|
|
223
|
+
|
|
224
|
+
def reply_notice(answer: str, finish_reason: str | None = None) -> str | None:
|
|
225
|
+
"""Dim note for a run that produced nothing visible on screen."""
|
|
226
|
+
if answer.startswith(STOPPED_PREFIX): # "ran out of turns" is itself the message
|
|
227
|
+
return answer
|
|
228
|
+
if answer.strip():
|
|
229
|
+
return None
|
|
230
|
+
return TRUNCATED_NOTICE if finish_reason == "length" else NO_TEXT_NOTICE
|
|
231
|
+
|
|
232
|
+
async def _run_once(session: Session, task: str) -> str:
|
|
233
|
+
finish: list[str] = []
|
|
234
|
+
with ReplyPrinter() as rp:
|
|
235
|
+
answer = await run_agent(
|
|
236
|
+
task, cwd=session.cwd, client=session.client, model=session.model,
|
|
237
|
+
max_turns=session.max_turns, messages=session.messages,
|
|
238
|
+
on_delta=rp.on_delta, on_thinking=rp.on_thinking,
|
|
239
|
+
on_answer_to_thinking=rp.on_answer_to_thinking, on_tool=rp.on_tool,
|
|
240
|
+
on_preview=rp.on_preview, on_usage=session.update_usage,
|
|
241
|
+
context_limit=session.context_limit, prompt_tokens=session.prompt_tokens,
|
|
242
|
+
on_compact=lambda n: err.print(f"[dim]# compacted {n} message(s)[/dim]"),
|
|
243
|
+
on_finish=finish.append,
|
|
244
|
+
)
|
|
245
|
+
# never leave a tool call as the last thing on screen
|
|
246
|
+
if notice := reply_notice(answer, finish[-1] if finish else None):
|
|
247
|
+
err.print(f"[dim]# {notice}[/dim]")
|
|
248
|
+
return answer
|
|
249
|
+
|
|
250
|
+
async def async_main(argv: list[str] | None = None) -> None:
|
|
251
|
+
_force_utf8_stdio()
|
|
252
|
+
_load_dotenv()
|
|
253
|
+
p = argparse.ArgumentParser(description="agent7 — minimal coding agent")
|
|
254
|
+
p.add_argument("task", nargs="*", help="task; omit for REPL")
|
|
255
|
+
p.add_argument("--cwd", type=Path, default=None)
|
|
256
|
+
p.add_argument("--max-turns", type=int, default=30)
|
|
257
|
+
args = p.parse_args(argv)
|
|
258
|
+
task = " ".join(args.task).strip() or None
|
|
259
|
+
cwd = (args.cwd or Path.cwd()).resolve()
|
|
260
|
+
model = os.environ.get("AGENT7_MODEL") or ""
|
|
261
|
+
|
|
262
|
+
all_creds = resolve_all()
|
|
263
|
+
picked = resolve_default()
|
|
264
|
+
if picked is None and all_creds:
|
|
265
|
+
pid = next(iter(all_creds))
|
|
266
|
+
prov = get_provider(pid)
|
|
267
|
+
picked = (prov, all_creds[pid]) if prov else None
|
|
268
|
+
if picked:
|
|
269
|
+
prov, cred = picked
|
|
270
|
+
client = prov.make_client(cred)
|
|
271
|
+
else:
|
|
272
|
+
client = AsyncOpenAI(api_key="missing", base_url="https://api.deepseek.com")
|
|
273
|
+
if task:
|
|
274
|
+
print("No credentials. Set DEEPSEEK_API_KEY / KIMI_API_KEY / MOONSHOT_API_KEY,\n"
|
|
275
|
+
"or run `uv run agent7` and `/login`.", file=sys.stderr)
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
prov, cred = None, None
|
|
278
|
+
state = Session(
|
|
279
|
+
model=model or "pending", cwd=cwd, client=client, max_turns=args.max_turns,
|
|
280
|
+
messages=[{"role": "system", "content": SYSTEM}],
|
|
281
|
+
provider_id=prov.id if prov else None, cred_source=cred.source if cred else None,
|
|
282
|
+
credential=cred, credentials=dict(all_creds),
|
|
283
|
+
)
|
|
284
|
+
try:
|
|
285
|
+
ids: list[str] = []
|
|
286
|
+
if all_creds:
|
|
287
|
+
try:
|
|
288
|
+
ids = await fetch_models(state)
|
|
289
|
+
except Exception as e: # noqa: BLE001
|
|
290
|
+
err.print(f"[dim]# models fetch failed: {e}[/dim]")
|
|
291
|
+
if model:
|
|
292
|
+
result = await switch_model(state, model)
|
|
293
|
+
if result.message.startswith(("unknown", "ambiguous")):
|
|
294
|
+
err.print(f"[dim]# {result.message}[/dim]")
|
|
295
|
+
if ids:
|
|
296
|
+
await switch_model(state, ids[0])
|
|
297
|
+
else:
|
|
298
|
+
state.set_model(model)
|
|
299
|
+
elif state.model in ("", "pending"):
|
|
300
|
+
if ids:
|
|
301
|
+
await switch_model(state, ids[0])
|
|
302
|
+
else:
|
|
303
|
+
state.set_model("deepseek-chat")
|
|
304
|
+
err.print(f"[dim]# agent7 model={state.model_ref or state.model} "
|
|
305
|
+
f"providers=[{','.join(state.credentials) or 'none'}] cwd={state.cwd}[/dim]")
|
|
306
|
+
if task:
|
|
307
|
+
try:
|
|
308
|
+
await _run_once(state, task)
|
|
309
|
+
except Interrupted:
|
|
310
|
+
err.print("\n[dim]# interrupted[/dim]")
|
|
311
|
+
return
|
|
312
|
+
err.print("[dim]# REPL /help · /login · /model · @file · empty line / Ctrl-D to quit[/dim]")
|
|
313
|
+
if not all_creds:
|
|
314
|
+
err.print("[dim]# no credentials — /login deepseek | /login kimi[/dim]")
|
|
315
|
+
|
|
316
|
+
def _vi_rprompt() -> HTML:
|
|
317
|
+
mode = get_app().vi_state.input_mode
|
|
318
|
+
label, color = ("INSERT", "ansigreen") if mode in (InputMode.INSERT, InputMode.INSERT_MULTIPLE) else (
|
|
319
|
+
("NORMAL", "ansiyellow") if mode == InputMode.NAVIGATION else ("REPLACE", "ansired")
|
|
320
|
+
)
|
|
321
|
+
return HTML(f'<style fg="{color}">-- {label} --</style>')
|
|
322
|
+
|
|
323
|
+
prompt = PromptSession(
|
|
324
|
+
history=InMemoryHistory(),
|
|
325
|
+
completer=ReplCompleter(state),
|
|
326
|
+
complete_while_typing=True,
|
|
327
|
+
editing_mode=EditingMode.VI,
|
|
328
|
+
rprompt=_vi_rprompt,
|
|
329
|
+
)
|
|
330
|
+
while True:
|
|
331
|
+
try:
|
|
332
|
+
line = (await prompt.prompt_async(lambda: f"{format_context_prompt(state)} > ")).strip()
|
|
333
|
+
except (EOFError, KeyboardInterrupt):
|
|
334
|
+
print(file=sys.stderr)
|
|
335
|
+
break
|
|
336
|
+
if not line:
|
|
337
|
+
break
|
|
338
|
+
result = await dispatch(state, line)
|
|
339
|
+
if result is not None:
|
|
340
|
+
for row in result.message.splitlines():
|
|
341
|
+
err.print(f"[dim]{row}[/dim]")
|
|
342
|
+
continue
|
|
343
|
+
if state.credential is None:
|
|
344
|
+
err.print("[yellow]# login first: /login deepseek | /login kimi[/yellow]")
|
|
345
|
+
continue
|
|
346
|
+
try:
|
|
347
|
+
await _run_once(state, line)
|
|
348
|
+
except (Interrupted, asyncio.CancelledError, KeyboardInterrupt):
|
|
349
|
+
err.print("\n[dim]# interrupted[/dim]")
|
|
350
|
+
finally:
|
|
351
|
+
await state.client.close()
|
|
352
|
+
|
|
353
|
+
def main(argv: list[str] | None = None) -> None:
|
|
354
|
+
asyncio.run(async_main(argv))
|
|
355
|
+
|
|
356
|
+
if __name__ == "__main__":
|
|
357
|
+
main()
|