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 ADDED
@@ -0,0 +1,3 @@
1
+ """agent7 — minimal coding agent."""
2
+
3
+ __version__ = "0.1.0"
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()