agent7 0.1.0__tar.gz
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-0.1.0/.gitignore +9 -0
- agent7-0.1.0/LICENSE +21 -0
- agent7-0.1.0/PKG-INFO +127 -0
- agent7-0.1.0/README.md +99 -0
- agent7-0.1.0/agent7/__init__.py +3 -0
- agent7-0.1.0/agent7/__main__.py +357 -0
- agent7-0.1.0/agent7/agent.py +398 -0
- agent7-0.1.0/agent7/commands.py +236 -0
- agent7-0.1.0/agent7/providers.py +161 -0
- agent7-0.1.0/agent7/session.py +101 -0
- agent7-0.1.0/pyproject.toml +60 -0
agent7-0.1.0/.gitignore
ADDED
agent7-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 r1cardohj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
agent7-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent7
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The simplest coding agent — four tools, one loop, ~1200 lines of Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/r1cardohj/agent7
|
|
6
|
+
Project-URL: Repository, https://github.com/r1cardohj/agent7
|
|
7
|
+
Project-URL: Issues, https://github.com/r1cardohj/agent7/issues
|
|
8
|
+
Author-email: r1cardohj <2413302357@qq.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,cli,coding-agent,deepseek,kimi,llm,openai-compatible
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: openai>=1.40.0
|
|
25
|
+
Requires-Dist: prompt-toolkit>=3.0.0
|
|
26
|
+
Requires-Dist: rich>=13.0.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# agent7
|
|
30
|
+
|
|
31
|
+
**The simplest coding agent. ~1200 lines of Python, four tools, one loop.**
|
|
32
|
+
|
|
33
|
+
No orchestration: no plan mode, no subagents, no parallel tasks, no background jobs.
|
|
34
|
+
You give it one sentence; it reads files, edits files, runs commands, then stops.
|
|
35
|
+
Built for people who work on one thing at a time — you hold one task in your head, and so does it.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
agent7/ 1252 lines
|
|
39
|
+
__main__.py 355 CLI + REPL + @path completion
|
|
40
|
+
agent.py 398 tools + agent loop + compaction
|
|
41
|
+
commands.py 236 slash commands + model switching
|
|
42
|
+
providers.py 161 API key auth
|
|
43
|
+
session.py 101 session state
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install agent7
|
|
50
|
+
# or: uv tool install agent7
|
|
51
|
+
# or from source: uv sync && uv run agent7
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Auth
|
|
55
|
+
|
|
56
|
+
Paste an API key; there is no OAuth. Keys are stored in `~/.config/agent7/auth.json`.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export DEEPSEEK_API_KEY=sk-...
|
|
60
|
+
# export KIMI_API_KEY=...
|
|
61
|
+
# export MOONSHOT_API_KEY=sk-...
|
|
62
|
+
# export AGENT7_MODEL=deepseek-flash
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
You can also log in from inside the REPL:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
/login deepseek
|
|
69
|
+
/login kimi # optional: /login kimi global
|
|
70
|
+
/login moonshot
|
|
71
|
+
/model # list models for every logged-in provider
|
|
72
|
+
/model deepseek-flash
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv run agent7 "create hello.py that prints hi" # one-shot
|
|
79
|
+
uv run agent7 # REPL
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
REPL commands: `/help` `/model` `/status` `/login` `/logout` `/compact`.
|
|
83
|
+
Exit with an empty line or Ctrl-D.
|
|
84
|
+
|
|
85
|
+
Type `@` in the REPL to recursively complete files and directories from the cwd:
|
|
86
|
+
`fix @agent7/agent.py`.
|
|
87
|
+
Tab accepts, directories keep going (`@agent7/` → `@agent7/session.py`), and spaces
|
|
88
|
+
are escaped (`@my\ file.txt`), so the path the model sees is the path it can read.
|
|
89
|
+
|
|
90
|
+
`--cwd` sets the working directory, `--max-turns` caps the loop (default 30 turns;
|
|
91
|
+
when it runs out you get `# (stopped after N turns)`). A run that ends with no visible
|
|
92
|
+
text never ends silently: you get either `# (stopped after N turns)`,
|
|
93
|
+
`# no text in the reply …` or `# … hit the output token limit …`.
|
|
94
|
+
|
|
95
|
+
## Only four tools
|
|
96
|
+
|
|
97
|
+
| Tool | What it does | Output budget |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `read` | Read a file with line numbers | 200 lines per call by default; page with `offset=` |
|
|
100
|
+
| `write` | Write a file; with `old_string=` replace one unique span | `path +N -M` diff stats |
|
|
101
|
+
| `bash` | Run a shell command in the cwd | ~12k chars |
|
|
102
|
+
| `grep` | rg/grep search | 100 lines / ~12k chars |
|
|
103
|
+
|
|
104
|
+
That's it. No `edit`, no `list_dir`, no `todo`, no `task`.
|
|
105
|
+
Need more capability? Let the agent use `bash`.
|
|
106
|
+
|
|
107
|
+
Tools run **serially**, one after another — so the log always reads in order and
|
|
108
|
+
you never get three outputs interleaved. Edits are applied without a confirmation
|
|
109
|
+
prompt; you just get a one-line diff stat.
|
|
110
|
+
|
|
111
|
+
## Context
|
|
112
|
+
|
|
113
|
+
At 75% context usage, old tool/assistant text is compacted automatically: the last
|
|
114
|
+
3 user turns are kept, or for one-shot tasks the last 4 tool results. `/compact`
|
|
115
|
+
triggers the same compaction manually.
|
|
116
|
+
|
|
117
|
+
`AGENT7_CONTEXT_LIMIT` overrides the context limit.
|
|
118
|
+
|
|
119
|
+
## What it is not
|
|
120
|
+
|
|
121
|
+
- Not an orchestrator: no branching, no subagents
|
|
122
|
+
- Not a parallel framework: single-threaded, one thing at a time
|
|
123
|
+
- Not a background service: a foreground process, stopped with Ctrl-C
|
|
124
|
+
- No sandbox: tools run as you, and `bash` can do anything your account can do
|
|
125
|
+
|
|
126
|
+
If that kind of "small" is exactly what you want, this is enough. If it's missing
|
|
127
|
+
the abstraction you had in mind, that omission is probably the point — not an oversight.
|
agent7-0.1.0/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# agent7
|
|
2
|
+
|
|
3
|
+
**The simplest coding agent. ~1200 lines of Python, four tools, one loop.**
|
|
4
|
+
|
|
5
|
+
No orchestration: no plan mode, no subagents, no parallel tasks, no background jobs.
|
|
6
|
+
You give it one sentence; it reads files, edits files, runs commands, then stops.
|
|
7
|
+
Built for people who work on one thing at a time — you hold one task in your head, and so does it.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
agent7/ 1252 lines
|
|
11
|
+
__main__.py 355 CLI + REPL + @path completion
|
|
12
|
+
agent.py 398 tools + agent loop + compaction
|
|
13
|
+
commands.py 236 slash commands + model switching
|
|
14
|
+
providers.py 161 API key auth
|
|
15
|
+
session.py 101 session state
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install agent7
|
|
22
|
+
# or: uv tool install agent7
|
|
23
|
+
# or from source: uv sync && uv run agent7
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Auth
|
|
27
|
+
|
|
28
|
+
Paste an API key; there is no OAuth. Keys are stored in `~/.config/agent7/auth.json`.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export DEEPSEEK_API_KEY=sk-...
|
|
32
|
+
# export KIMI_API_KEY=...
|
|
33
|
+
# export MOONSHOT_API_KEY=sk-...
|
|
34
|
+
# export AGENT7_MODEL=deepseek-flash
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
You can also log in from inside the REPL:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
/login deepseek
|
|
41
|
+
/login kimi # optional: /login kimi global
|
|
42
|
+
/login moonshot
|
|
43
|
+
/model # list models for every logged-in provider
|
|
44
|
+
/model deepseek-flash
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv run agent7 "create hello.py that prints hi" # one-shot
|
|
51
|
+
uv run agent7 # REPL
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
REPL commands: `/help` `/model` `/status` `/login` `/logout` `/compact`.
|
|
55
|
+
Exit with an empty line or Ctrl-D.
|
|
56
|
+
|
|
57
|
+
Type `@` in the REPL to recursively complete files and directories from the cwd:
|
|
58
|
+
`fix @agent7/agent.py`.
|
|
59
|
+
Tab accepts, directories keep going (`@agent7/` → `@agent7/session.py`), and spaces
|
|
60
|
+
are escaped (`@my\ file.txt`), so the path the model sees is the path it can read.
|
|
61
|
+
|
|
62
|
+
`--cwd` sets the working directory, `--max-turns` caps the loop (default 30 turns;
|
|
63
|
+
when it runs out you get `# (stopped after N turns)`). A run that ends with no visible
|
|
64
|
+
text never ends silently: you get either `# (stopped after N turns)`,
|
|
65
|
+
`# no text in the reply …` or `# … hit the output token limit …`.
|
|
66
|
+
|
|
67
|
+
## Only four tools
|
|
68
|
+
|
|
69
|
+
| Tool | What it does | Output budget |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `read` | Read a file with line numbers | 200 lines per call by default; page with `offset=` |
|
|
72
|
+
| `write` | Write a file; with `old_string=` replace one unique span | `path +N -M` diff stats |
|
|
73
|
+
| `bash` | Run a shell command in the cwd | ~12k chars |
|
|
74
|
+
| `grep` | rg/grep search | 100 lines / ~12k chars |
|
|
75
|
+
|
|
76
|
+
That's it. No `edit`, no `list_dir`, no `todo`, no `task`.
|
|
77
|
+
Need more capability? Let the agent use `bash`.
|
|
78
|
+
|
|
79
|
+
Tools run **serially**, one after another — so the log always reads in order and
|
|
80
|
+
you never get three outputs interleaved. Edits are applied without a confirmation
|
|
81
|
+
prompt; you just get a one-line diff stat.
|
|
82
|
+
|
|
83
|
+
## Context
|
|
84
|
+
|
|
85
|
+
At 75% context usage, old tool/assistant text is compacted automatically: the last
|
|
86
|
+
3 user turns are kept, or for one-shot tasks the last 4 tool results. `/compact`
|
|
87
|
+
triggers the same compaction manually.
|
|
88
|
+
|
|
89
|
+
`AGENT7_CONTEXT_LIMIT` overrides the context limit.
|
|
90
|
+
|
|
91
|
+
## What it is not
|
|
92
|
+
|
|
93
|
+
- Not an orchestrator: no branching, no subagents
|
|
94
|
+
- Not a parallel framework: single-threaded, one thing at a time
|
|
95
|
+
- Not a background service: a foreground process, stopped with Ctrl-C
|
|
96
|
+
- No sandbox: tools run as you, and `bash` can do anything your account can do
|
|
97
|
+
|
|
98
|
+
If that kind of "small" is exactly what you want, this is enough. If it's missing
|
|
99
|
+
the abstraction you had in mind, that omission is probably the point — not an oversight.
|
|
@@ -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()
|