kidwit 0.1.0rc1__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.
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kidwit
|
|
3
|
+
Version: 0.1.0rc1
|
|
4
|
+
Summary: A minimal coding agent in one file of standard-library Python. It works inside one directory and asks before it changes anything.
|
|
5
|
+
Author-email: SM Sabbir Amin <sabbiramin.cse11ruet@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://kidwit.sabbiramin.dev
|
|
8
|
+
Project-URL: Source, https://github.com/sabbiramin113008/kidwit
|
|
9
|
+
Project-URL: Issues, https://github.com/sabbiramin113008/kidwit/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/sabbiramin113008/kidwit/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: coding-agent,ai-agent,llm,cli,anthropic,openai,deepseek,ollama
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# kidwit
|
|
30
|
+
|
|
31
|
+
A complete coding-agent engine in one file of standard-library Python, around 500 lines of code. It works inside one directory and asks before it changes anything.
|
|
32
|
+
|
|
33
|
+
kidwit reads your code, edits files, runs commands and reports back, using native tool calling with Anthropic or any OpenAI-compatible API. It is a from-scratch build and a case study in how little a coding agent needs. It is not a replacement for mature agents.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
You need Python 3.9 or newer. kidwit has no dependencies. From a clone of this repository:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pipx install . # or: pip install .
|
|
41
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
42
|
+
kidwit "fix the failing test"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The whole engine is one file, so you can also run it with no install at all:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
python3 src/kidwit.py "fix the failing test"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Without a task, kidwit starts an interactive session and, on a color terminal, shows a small banner. The banner never appears with `--json`, with a task on the command line, or when output is piped. `/clear` resets the conversation, `/verbose` toggles detailed output, and `/exit` (or `/quit`, or end of input) leaves.
|
|
52
|
+
|
|
53
|
+
## Status
|
|
54
|
+
|
|
55
|
+
kidwit is early software. This is what has and has not been tested:
|
|
56
|
+
|
|
57
|
+
1. **Tested live:** the OpenAI-compatible adapter with DeepSeek (`deepseek-chat`) on macOS with Python 3.14. The 8 tasks in `evals/` all pass end to end.
|
|
58
|
+
2. **Not yet run against the real service:** the Anthropic adapter. It is covered by the offline tests, but nobody has run it against the live Anthropic API yet.
|
|
59
|
+
3. **Untested:** Windows. The command runner uses Python's default shell there (`cmd.exe`), and killing a timed-out command's child processes is only verified on macOS and Linux-style systems. Linux and Python 3.9 to 3.13 are also untested for now.
|
|
60
|
+
|
|
61
|
+
If you try it somewhere on this list, please open an issue with what happened.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
Environment variables:
|
|
66
|
+
|
|
67
|
+
| Variable | Meaning |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `ANTHROPIC_API_KEY` | Key for the Anthropic Messages API. If set, Anthropic is the default provider. |
|
|
70
|
+
| `OPENAI_API_KEY` | Key for an OpenAI-compatible API. Optional for local servers. |
|
|
71
|
+
| `AGENT_PROVIDER` | `anthropic` or `openai`. |
|
|
72
|
+
| `AGENT_MODEL` | Model name. Defaults to `claude-sonnet-5-5` for Anthropic. Required for `openai`. |
|
|
73
|
+
| `AGENT_BASE_URL` | API base URL, for OpenRouter, Ollama, LM Studio, vLLM or a proxy. |
|
|
74
|
+
|
|
75
|
+
Provider choice follows this order: `--provider`, then `AGENT_PROVIDER`, then Anthropic if `ANTHROPIC_API_KEY` is set, then OpenAI-compatible.
|
|
76
|
+
|
|
77
|
+
Local model example:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
AGENT_BASE_URL=http://localhost:11434/v1 AGENT_MODEL=qwen2.5-coder kidwit "add a docstring to main()"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Flags:
|
|
84
|
+
|
|
85
|
+
| Flag | Meaning |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `--root DIR` | Project directory. Default: the current directory. |
|
|
88
|
+
| `--provider anthropic\|openai` | Choose the API. |
|
|
89
|
+
| `--model M`, `--base-url URL` | Override `AGENT_MODEL` and `AGENT_BASE_URL`. |
|
|
90
|
+
| `--yes` | Never ask for approval. |
|
|
91
|
+
| `--read-only` | Offer only the read tools. Writes and commands are not available. |
|
|
92
|
+
| `--json` | Print JSON-lines events instead of text. |
|
|
93
|
+
| `--verbose` | Show live command output and full tool arguments. By default kidwit prints one short dim line per tool call and then the answer. |
|
|
94
|
+
| `--no-log` | Do not write `.agent/audit.jsonl`. |
|
|
95
|
+
| `--version` | Print the version. |
|
|
96
|
+
|
|
97
|
+
A task given on the command line runs once. The exit code is 0 on success and 1 on error, so it works in scripts and benchmarks.
|
|
98
|
+
|
|
99
|
+
## Reading the terminal
|
|
100
|
+
|
|
101
|
+
On a color terminal each kind of text looks different. Your prompt is a bold green `>>`. The model's replies have a magenta `▌` gutter. Tool calls are one dim line with the tool name in cyan. Permission requests start with a yellow `┌ permission needed` line, followed by the diff or command and a yellow `└ allow?` prompt. Errors are red. With `NO_COLOR`, piped output or `--json`, none of this styling is printed.
|
|
102
|
+
|
|
103
|
+
## Tools
|
|
104
|
+
|
|
105
|
+
The model gets six tools: `list_files`, `search`, `read_file`, `write_file`, `edit_file` and `run`. Inside a git repository, `list_files` and `search` honour `.gitignore`, so build output and similar files stay out of the way. Edits are exact-string replacement: a match that differs only in trailing whitespace is accepted, and when nothing matches the error names the closest line so the model can correct itself. `old_text` must match exactly once unless `replace_all` is set, and files with Windows line endings keep them.
|
|
106
|
+
|
|
107
|
+
If the project root has an `AGENTS.md` (or else a `CLAUDE.md`), its first 20,000 characters are added to the system prompt.
|
|
108
|
+
|
|
109
|
+
## Approval modes
|
|
110
|
+
|
|
111
|
+
1. `ask` (default). Before every `write_file`, `edit_file` and `run`, kidwit shows the diff or command and waits for `y`, `a`, `n`, or any other text. Other text is sent to the model as the reason for the denial. `a` (always) approves that kind of action for the rest of the session: file changes, or commands, but not both.
|
|
112
|
+
2. `auto` (`--yes`). No prompts, except for dangerous commands.
|
|
113
|
+
3. `read-only` (`--read-only`). The write tools are not offered to the model, and are refused if called anyway.
|
|
114
|
+
|
|
115
|
+
Some things are handled for you in every mode:
|
|
116
|
+
|
|
117
|
+
1. In `ask` mode, harmless commands run without a prompt: `ls`, `pwd`, `git status`, `git diff`, `git log`, `git show`, `git branch`, and test runners such as `python3 -m unittest`, `pytest`, `npm test`, `go test` and `cargo test`. They must use plain relative arguments with no shell operators. `cat`, `grep` and `head` are not on the list on purpose: the path jail does not cover `run`, so they could read files outside the project.
|
|
118
|
+
2. Dangerous commands always ask, even with `--yes` or after `a`: `rm -rf`, `sudo`, `curl ... | sh`, `git reset --hard`, `git clean`, `git push`, `chmod -R` and similar. With no one to answer (for example `--json` with closed stdin) they are denied.
|
|
119
|
+
3. `/undo` in an interactive session reverts the last file write or edit. It does not undo commands.
|
|
120
|
+
4. If the model's reply is cut off at the token limit, the half-written tool call is not run. The model is told to do less per call.
|
|
121
|
+
|
|
122
|
+
Every write, edit and command is appended to `<root>/.agent/audit.jsonl`. kidwit creates `.agent/.gitignore` so the log stays out of git.
|
|
123
|
+
|
|
124
|
+
## Limits you should know about
|
|
125
|
+
|
|
126
|
+
The path jail covers the file tools only. `read_file`, `write_file` and `edit_file` refuse paths outside the project root, including `../` and symlinks that lead out, and `list_files` and `search` skip such symlinks. `run` executes real shell commands, and those can reach anything your user account can reach. The protections in v1 are the approval prompt and `--read-only`. There is no OS-level sandbox, so review each command before you approve it, and avoid `--yes` on a machine or directory you care about.
|
|
127
|
+
|
|
128
|
+
Long sessions are kept within the model's context in two steps. First, old tool output is dropped. If the history is still too large (over about 200,000 characters), kidwit asks the model to summarize the older half of the conversation and continues from that summary.
|
|
129
|
+
|
|
130
|
+
Other deliberate omissions: no streaming of model responses (command output does stream), no sub-agents, no MCP, no saved sessions, and no UI beyond plain terminal text.
|
|
131
|
+
|
|
132
|
+
## Event protocol
|
|
133
|
+
|
|
134
|
+
With `--json`, kidwit writes one JSON object per line on stdout. Tasks and approval answers are read from stdin, one line each. End of input during an approval counts as "no". A UI can drive the engine through this protocol.
|
|
135
|
+
|
|
136
|
+
| `type` | Fields |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `info` | `message` |
|
|
139
|
+
| `text` | `text` |
|
|
140
|
+
| `tool` | `id`, `name`, `input` |
|
|
141
|
+
| `output` | `line` (live command output) |
|
|
142
|
+
| `approval` | `action` (`write`, `edit` or `run`), `target`, `detail` (diff or command) |
|
|
143
|
+
| `result` | `id`, `output`, `is_error` |
|
|
144
|
+
| `done` | `turns`, `input_tokens`, `output_tokens` |
|
|
145
|
+
| `error` | `message` |
|
|
146
|
+
|
|
147
|
+
## Tests
|
|
148
|
+
|
|
149
|
+
The tests use only `unittest` and need no API key. A fake local HTTP server stands in for both APIs.
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
scripts/check.sh # everything below in one go
|
|
153
|
+
python3 -m unittest discover -s tests -t . # just the tests
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`scripts/check.sh` checks the Python version, compiles `src/kidwit.py`, confirms it imports only the standard library, runs the tests, and checks the size (`grep -vcE '^\s*(#.*)?$' src/kidwit.py`). Over 500 lines prints a warning, and over 700 fails.
|
|
157
|
+
|
|
158
|
+
## Evals
|
|
159
|
+
|
|
160
|
+
`evals/` holds 8 small tasks, each a tiny repo with a failing check: an off-by-one, a name error, implementing a function, a root cause in a second file, a rename across files, a CRLF file, a missing config default, and a new CLI flag. They show whether a change to kidwit makes it better or worse.
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
python3 evals/run_evals.py --selftest # checks the tasks themselves, no model needed
|
|
164
|
+
python3 evals/run_evals.py # runs every task against your configured model
|
|
165
|
+
python3 evals/run_evals.py --task crlf-file -- --provider openai --model deepseek-chat
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The runner prints pass or fail, turns, tokens and time per task, and saves the results to `evals/results/`. Be aware that it runs the agent with `--yes`, so the model's commands run as you with no sandbox. Dangerous commands still need approval, and because the runner closes stdin they are denied. Run it only where that is acceptable.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
kidwit.py,sha256=sCAd3Gb4Vha4966IM3Ti6MupFp7zCJZEwCsPiR-l7rE,32991
|
|
2
|
+
kidwit-0.1.0rc1.dist-info/licenses/LICENSE,sha256=denLz2gy8k9sJNHcUmaAWza01zaNnyV-8ZRvJ-F95SE,1071
|
|
3
|
+
kidwit-0.1.0rc1.dist-info/METADATA,sha256=KKe7IE9wEQnyP3VHWCB2cYkQhS498PezMnIKPjtbYX0,10480
|
|
4
|
+
kidwit-0.1.0rc1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
kidwit-0.1.0rc1.dist-info/entry_points.txt,sha256=ha0h53KwshW7ODje9kvDu4uv936vupjAZ1GhnY1fQ5A,39
|
|
6
|
+
kidwit-0.1.0rc1.dist-info/top_level.txt,sha256=YLig_nM4SUI3yhhG2uqcp1BfSX7AOQiNSU0ssZEkW4I,7
|
|
7
|
+
kidwit-0.1.0rc1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SM Sabbir Amin
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kidwit
|
kidwit.py
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""kidwit: a minimal coding-agent engine. Standard library only.
|
|
3
|
+
|
|
4
|
+
Usage: kidwit "fix the failing test" (no task: REPL; --json: JSON-lines events)
|
|
5
|
+
Also runs as a single file: python3 kidwit.py "fix the failing test"
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import difflib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import re
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
VERSION = __version__ = "0.1.0rc1"
|
|
23
|
+
MAX_TURNS = 50
|
|
24
|
+
MAX_TOKENS = 8192
|
|
25
|
+
MAX_TOOL_OUTPUT = 12000
|
|
26
|
+
CONTEXT_BUDGET = 200000
|
|
27
|
+
DEFAULT_TIMEOUT = 120
|
|
28
|
+
DEFAULT_MODEL = "claude-sonnet-5-5"
|
|
29
|
+
BACKOFF = 2
|
|
30
|
+
RETRY_CODES = {408, 429, 500, 502, 503, 504, 529}
|
|
31
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
|
|
32
|
+
".agent", ".next", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".cache"}
|
|
33
|
+
OLD_OUTPUT = "[old output removed to save context]"
|
|
34
|
+
SUMMARY_SYSTEM = ("You summarize a coding agent's earlier work so it can continue after the old messages are dropped. "
|
|
35
|
+
"Write compact plain text: the user's task and later instructions, files read or changed and why, "
|
|
36
|
+
"commands run and their results, decisions made, and what is still left to do. Keep exact file "
|
|
37
|
+
"paths, names and error messages. No preamble.")
|
|
38
|
+
# Commands that run without asking in ask mode: read-only inspection and test runners. Deliberately
|
|
39
|
+
# no cat/grep/head: the path jail covers the file tools, not `run`, so those could read outside the root.
|
|
40
|
+
CUT_OFF = {"_error": "your reply hit the output token limit and this call was cut off, so it did not run; "
|
|
41
|
+
"do less per call, for example edit_file instead of rewriting a whole file"}
|
|
42
|
+
SAFE_CMD = re.compile(r"(ls|pwd|git (status|diff|log|show|branch)|python3? -m (unittest|pytest)|pytest"
|
|
43
|
+
r"|npm test|go test|cargo test)( [\w:./-]+)*")
|
|
44
|
+
# Commands that always ask, even with --yes or after "always".
|
|
45
|
+
DANGER = re.compile(r"\brm\s+-\w*[rf]|\bsudo\b|\bmkfs|\bdd\s+if=|\bch(mod|own)\s+-\w*R|\|\s*(ba|z)?sh\b"
|
|
46
|
+
r"|>\s*/dev/|\bgit\s+(reset\s+--hard|clean|push)\b")
|
|
47
|
+
BANNER = " █▄▀ █ █▀▄ █ █ █ █ ▀█▀\n █ █ █ █▄▀ ▀▄▀▄▀ █ █ "
|
|
48
|
+
|
|
49
|
+
SYSTEM = """You are kidwit, a coding agent working inside one project directory.
|
|
50
|
+
Project root: {root} OS: {os} Shell: {shell}
|
|
51
|
+
|
|
52
|
+
Explore first: use list_files and search to find the right files, and read_file before you change anything.
|
|
53
|
+
Make the smallest change that solves the task. Prefer edit_file, copying old_text exactly with enough context to match once.
|
|
54
|
+
Never guess file contents or paths. If a tool call fails, read the error and fix the call instead of repeating it.
|
|
55
|
+
After changing code, run the tests or a relevant command to check your work, and fix anything you broke.
|
|
56
|
+
Paths are relative to the project root. If the user denies an action, adapt to their feedback and do not retry it.
|
|
57
|
+
If the request is ambiguous or risky, ask a short question instead of guessing.
|
|
58
|
+
Be concise. When done, say what you changed and the result."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AgentError(Exception):
|
|
62
|
+
"""Fatal problem for the current task (API failure, bad configuration)."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ToolError(Exception):
|
|
66
|
+
"""A tool failure the model should read and recover from."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def clip(text, limit=MAX_TOOL_OUTPUT):
|
|
70
|
+
if len(text) <= limit:
|
|
71
|
+
return text
|
|
72
|
+
head = limit * 2 // 3
|
|
73
|
+
return f"{text[:head]}\n...[{len(text) - limit} chars clipped]...\n{text[-(limit - head):]}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _tool(name, desc, props, required=()):
|
|
77
|
+
schema = {k: {"type": t} for k, t in props.items()}
|
|
78
|
+
return {"name": name, "description": desc,
|
|
79
|
+
"input_schema": {"type": "object", "properties": schema, "required": list(required)}}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
GLOB = "pattern is an optional glob such as '*.py' or 'src/**/*.py'."
|
|
83
|
+
TOOLS = [
|
|
84
|
+
_tool("list_files", f"List project files. {GLOB}", {"pattern": "string"}),
|
|
85
|
+
_tool("search", f"Search file contents with a Python regex; returns path:line: text. {GLOB}",
|
|
86
|
+
{"regex": "string", "pattern": "string"}, ["regex"]),
|
|
87
|
+
_tool("read_file", "Read a file with line numbers. offset is the 1-based first line; "
|
|
88
|
+
"limit defaults to 2000.", {"path": "string", "offset": "integer", "limit": "integer"}, ["path"]),
|
|
89
|
+
_tool("write_file", "Create or overwrite a file.",
|
|
90
|
+
{"path": "string", "content": "string"}, ["path", "content"]),
|
|
91
|
+
_tool("edit_file", "Replace exact text in a file. old_text must match exactly once "
|
|
92
|
+
"unless replace_all is true.",
|
|
93
|
+
{"path": "string", "old_text": "string", "new_text": "string", "replace_all": "boolean"},
|
|
94
|
+
["path", "old_text", "new_text"]),
|
|
95
|
+
_tool("run", "Run a shell command in the project root. timeout is in seconds (default 120).",
|
|
96
|
+
{"command": "string", "timeout": "integer"}, ["command"]),
|
|
97
|
+
]
|
|
98
|
+
WRITE_TOOLS = {"write_file", "edit_file", "run"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def post_json(url, headers, body):
|
|
102
|
+
"""POST JSON and return the parsed reply. Retries transient failures with backoff."""
|
|
103
|
+
data, err = json.dumps(body).encode(), "unknown error"
|
|
104
|
+
for attempt in range(4):
|
|
105
|
+
req = urllib.request.Request(url, data, {"Content-Type": "application/json", **headers})
|
|
106
|
+
try:
|
|
107
|
+
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
108
|
+
return json.loads(resp.read())
|
|
109
|
+
except urllib.error.HTTPError as e:
|
|
110
|
+
with e:
|
|
111
|
+
detail = e.read().decode("utf-8", errors="replace")[:1500]
|
|
112
|
+
if e.code not in RETRY_CODES:
|
|
113
|
+
raise AgentError(f"HTTP {e.code}: {detail}")
|
|
114
|
+
err = f"HTTP {e.code}: {detail}"
|
|
115
|
+
except ValueError:
|
|
116
|
+
raise AgentError("the API returned a reply that is not valid JSON")
|
|
117
|
+
except OSError as e:
|
|
118
|
+
err = str(e)
|
|
119
|
+
if attempt < 3:
|
|
120
|
+
time.sleep(BACKOFF * 2 ** attempt)
|
|
121
|
+
raise AgentError(f"request failed after 4 attempts: {err}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Provider:
|
|
125
|
+
"""chat(system, history, tools) -> (text, calls, (tokens_in, tokens_out)); calls are {id, name, input} dicts."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, key, model, base=None):
|
|
128
|
+
self.key, self.model, self.base = key, model, (base or self.default_base).rstrip("/")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AnthropicProvider(Provider):
|
|
132
|
+
default_base = "https://api.anthropic.com"
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def convert(history):
|
|
136
|
+
"""Neutral history -> API messages, merging consecutive same-role messages."""
|
|
137
|
+
msgs = []
|
|
138
|
+
for m in history:
|
|
139
|
+
if m["role"] == "user":
|
|
140
|
+
role, blocks = "user", [{"type": "text", "text": m["text"]}]
|
|
141
|
+
elif m["role"] == "assistant":
|
|
142
|
+
role = "assistant"
|
|
143
|
+
blocks = [{"type": "text", "text": m["text"]}] if m["text"] else []
|
|
144
|
+
blocks += [{"type": "tool_use", **c} for c in m.get("calls", [])]
|
|
145
|
+
else:
|
|
146
|
+
role = "user"
|
|
147
|
+
blocks = [{"type": "tool_result", "tool_use_id": r["id"], "content": r["output"],
|
|
148
|
+
"is_error": r["is_error"]} for r in m["results"]]
|
|
149
|
+
if msgs and msgs[-1]["role"] == role:
|
|
150
|
+
msgs[-1]["content"] += blocks
|
|
151
|
+
else:
|
|
152
|
+
msgs.append({"role": role, "content": blocks})
|
|
153
|
+
return msgs
|
|
154
|
+
|
|
155
|
+
def chat(self, system, history, tools):
|
|
156
|
+
cache = {"type": "ephemeral"}
|
|
157
|
+
msgs = self.convert(history)
|
|
158
|
+
if msgs:
|
|
159
|
+
msgs[-1]["content"][-1]["cache_control"] = cache
|
|
160
|
+
body = {"model": self.model, "max_tokens": MAX_TOKENS, "messages": msgs,
|
|
161
|
+
"system": [{"type": "text", "text": system, "cache_control": cache}]}
|
|
162
|
+
if tools:
|
|
163
|
+
body["tools"] = tools
|
|
164
|
+
headers = {"x-api-key": self.key, "anthropic-version": "2023-06-01"}
|
|
165
|
+
reply = post_json(f"{self.base}/v1/messages", headers, body)
|
|
166
|
+
blocks = reply.get("content") or []
|
|
167
|
+
text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
|
168
|
+
calls = [{"id": b["id"], "name": b["name"], "input": b.get("input") or {}}
|
|
169
|
+
for b in blocks if b.get("type") == "tool_use"]
|
|
170
|
+
if reply.get("stop_reason") == "max_tokens":
|
|
171
|
+
calls = [{**c, "input": CUT_OFF} for c in calls]
|
|
172
|
+
u = reply.get("usage") or {}
|
|
173
|
+
tokens_in = sum(u.get(k, 0) for k in ("input_tokens", "cache_read_input_tokens",
|
|
174
|
+
"cache_creation_input_tokens"))
|
|
175
|
+
return text, calls, (tokens_in, u.get("output_tokens", 0))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class OpenAIProvider(Provider):
|
|
179
|
+
"""OpenAI-compatible Chat Completions (OpenAI, OpenRouter, Ollama, LM Studio, vLLM)."""
|
|
180
|
+
default_base = "https://api.openai.com/v1"
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def convert(system, history):
|
|
184
|
+
msgs = [{"role": "system", "content": system}]
|
|
185
|
+
for m in history:
|
|
186
|
+
if m["role"] == "user":
|
|
187
|
+
msgs.append({"role": "user", "content": m["text"]})
|
|
188
|
+
elif m["role"] == "assistant":
|
|
189
|
+
calls = m.get("calls") or []
|
|
190
|
+
msg = {"role": "assistant", "content": m["text"] or (None if calls else "")}
|
|
191
|
+
if calls:
|
|
192
|
+
msg["tool_calls"] = [{"id": c["id"], "type": "function", "function": {
|
|
193
|
+
"name": c["name"], "arguments": json.dumps(c["input"])}} for c in calls]
|
|
194
|
+
msgs.append(msg)
|
|
195
|
+
else:
|
|
196
|
+
msgs += [{"role": "tool", "tool_call_id": r["id"], "content": r["output"]}
|
|
197
|
+
for r in m["results"]]
|
|
198
|
+
return msgs
|
|
199
|
+
|
|
200
|
+
def chat(self, system, history, tools):
|
|
201
|
+
body = {"model": self.model, "messages": self.convert(system, history)}
|
|
202
|
+
if tools:
|
|
203
|
+
body["tools"] = [{"type": "function", "function": {"name": t["name"], "description": t["description"],
|
|
204
|
+
"parameters": t["input_schema"]}} for t in tools]
|
|
205
|
+
headers = {"Authorization": f"Bearer {self.key}"} if self.key else {}
|
|
206
|
+
reply = post_json(f"{self.base}/chat/completions", headers, body)
|
|
207
|
+
try:
|
|
208
|
+
choice = reply["choices"][0]
|
|
209
|
+
msg = choice["message"]
|
|
210
|
+
except (KeyError, IndexError, TypeError):
|
|
211
|
+
raise AgentError(f"unexpected API reply: {str(reply)[:500]}")
|
|
212
|
+
calls = []
|
|
213
|
+
for c in msg.get("tool_calls") or []:
|
|
214
|
+
raw = c["function"].get("arguments") or "{}"
|
|
215
|
+
try:
|
|
216
|
+
args = json.loads(raw)
|
|
217
|
+
except ValueError:
|
|
218
|
+
args = None
|
|
219
|
+
calls.append({"id": c["id"], "name": c["function"]["name"],
|
|
220
|
+
"input": args if isinstance(args, dict) else {"_invalid_json": raw}})
|
|
221
|
+
if choice.get("finish_reason") == "length":
|
|
222
|
+
calls = [{**c, "input": CUT_OFF} for c in calls]
|
|
223
|
+
u = reply.get("usage") or {}
|
|
224
|
+
return msg.get("content") or "", calls, (u.get("prompt_tokens", 0), u.get("completion_tokens", 0))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def glob_re(pat):
|
|
228
|
+
"""Glob to regex: '*' and '?' stay inside a directory; '**/' also matches zero directories."""
|
|
229
|
+
rx = re.escape(pat).replace(r"\*\*/", "(?:.*/)?").replace(r"\*\*", ".*")
|
|
230
|
+
return re.compile(rx.replace(r"\*", "[^/]*").replace(r"\?", "[^/]") + r"\Z")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def kill_tree(proc):
|
|
234
|
+
"""Kill a command and its children (a new session makes the process group killable)."""
|
|
235
|
+
try:
|
|
236
|
+
os.killpg(proc.pid, signal.SIGKILL) if os.name == "posix" else proc.kill()
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def loose_matches(text, old):
|
|
242
|
+
"""Slices of text equal to old once trailing whitespace is ignored on every line."""
|
|
243
|
+
lines, want = text.splitlines(True), [line.rstrip() for line in old.splitlines()]
|
|
244
|
+
spans = ["".join(lines[i:i + len(want)]) for i in range(len(lines) - len(want) + 1)
|
|
245
|
+
if [line.rstrip() for line in lines[i:i + len(want)]] == want]
|
|
246
|
+
return spans if old.endswith("\n") else [span.rstrip("\r\n") for span in spans]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def diff(old, new, name):
|
|
250
|
+
return "".join(difflib.unified_diff(old.splitlines(True), new.splitlines(True),
|
|
251
|
+
f"a/{name}", f"b/{name}"))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class Tools:
|
|
255
|
+
def __init__(self, root, ui, mode="ask", log=True):
|
|
256
|
+
self.root, self.ui, self.mode, self.log = Path(root).resolve(), ui, mode, log
|
|
257
|
+
self.always, self.undo = set(), [] # kinds approved with "always"; (path, old bytes) per change
|
|
258
|
+
|
|
259
|
+
def available(self):
|
|
260
|
+
return [t for t in TOOLS if self.mode != "read-only" or t["name"] not in WRITE_TOOLS]
|
|
261
|
+
|
|
262
|
+
def call(self, name, args):
|
|
263
|
+
"""Run a tool. Never raises: every failure becomes (message, True)."""
|
|
264
|
+
if self.mode == "read-only" and name in WRITE_TOOLS:
|
|
265
|
+
return f"{name} is disabled in read-only mode", True
|
|
266
|
+
fn = getattr(self, "t_" + name, None)
|
|
267
|
+
if fn is None:
|
|
268
|
+
return f"unknown tool: {name}", True
|
|
269
|
+
try:
|
|
270
|
+
if not isinstance(args, dict):
|
|
271
|
+
raise TypeError("arguments must be an object")
|
|
272
|
+
if "_invalid_json" in args:
|
|
273
|
+
raise ToolError("bad arguments: not valid JSON: " + str(args["_invalid_json"])[:200])
|
|
274
|
+
if "_error" in args:
|
|
275
|
+
raise ToolError(args["_error"])
|
|
276
|
+
return clip(fn(**args)), False
|
|
277
|
+
except ToolError as e:
|
|
278
|
+
return clip(str(e)), True
|
|
279
|
+
except TypeError as e:
|
|
280
|
+
return f"bad arguments for {name}: {e}", True
|
|
281
|
+
except Exception as e:
|
|
282
|
+
return f"{type(e).__name__}: {e}", True
|
|
283
|
+
|
|
284
|
+
def path(self, p):
|
|
285
|
+
"""Resolve p inside the project root. Symlinks are followed, then checked."""
|
|
286
|
+
full = (self.root / p).resolve()
|
|
287
|
+
if full != self.root and self.root not in full.parents:
|
|
288
|
+
raise ToolError(f"path is outside the project root: {p}")
|
|
289
|
+
return full
|
|
290
|
+
|
|
291
|
+
def rel(self, p):
|
|
292
|
+
return p.relative_to(self.root).as_posix()
|
|
293
|
+
|
|
294
|
+
def listing(self):
|
|
295
|
+
"""Project files: git's list (honours .gitignore) inside a repo, else a directory walk."""
|
|
296
|
+
try:
|
|
297
|
+
raw = subprocess.run(["git", "ls-files", "-co", "--exclude-standard", "-z"], cwd=self.root,
|
|
298
|
+
capture_output=True, check=True, timeout=10).stdout.decode("utf-8", "replace")
|
|
299
|
+
if raw:
|
|
300
|
+
return sorted(self.root / name for name in raw.split("\0") if name)
|
|
301
|
+
except (OSError, subprocess.SubprocessError):
|
|
302
|
+
pass
|
|
303
|
+
paths = []
|
|
304
|
+
for dirpath, dirs, files in os.walk(self.root):
|
|
305
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
|
306
|
+
paths += [Path(dirpath) / name for name in sorted(files)]
|
|
307
|
+
return paths
|
|
308
|
+
|
|
309
|
+
def walk(self, pattern=None):
|
|
310
|
+
"""Yield (path, relpath) for project files, skipping SKIP_DIRS and outside symlinks."""
|
|
311
|
+
rx = glob_re(pattern) if pattern else None
|
|
312
|
+
for p in self.listing():
|
|
313
|
+
rel = self.rel(p)
|
|
314
|
+
outside = p.is_symlink() and self.root not in p.resolve().parents
|
|
315
|
+
if p.is_file() and not outside and not SKIP_DIRS.intersection(rel.split("/")[:-1]) \
|
|
316
|
+
and (rx is None or rx.match(rel if "/" in pattern else p.name)):
|
|
317
|
+
yield p, rel
|
|
318
|
+
|
|
319
|
+
def load(self, p):
|
|
320
|
+
data = p.read_bytes()
|
|
321
|
+
if b"\0" in data[:4096]:
|
|
322
|
+
raise ToolError(f"binary file: {self.rel(p)}")
|
|
323
|
+
return data.decode("utf-8", errors="replace")
|
|
324
|
+
|
|
325
|
+
def t_list_files(self, pattern=None):
|
|
326
|
+
rels = [rel for _, rel in self.walk(pattern)]
|
|
327
|
+
more = f"\n[truncated: {len(rels) - 1000} more files]" if len(rels) > 1000 else ""
|
|
328
|
+
return "\n".join(rels[:1000]) + more or "(no files)"
|
|
329
|
+
|
|
330
|
+
def t_search(self, regex, pattern=None):
|
|
331
|
+
try:
|
|
332
|
+
rx = re.compile(regex)
|
|
333
|
+
except re.error as e:
|
|
334
|
+
raise ToolError(f"invalid regex: {e}")
|
|
335
|
+
hits = []
|
|
336
|
+
for p, rel in self.walk(pattern):
|
|
337
|
+
try:
|
|
338
|
+
text = self.load(p) if p.stat().st_size <= 2_000_000 else ""
|
|
339
|
+
except (OSError, ToolError):
|
|
340
|
+
continue
|
|
341
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
342
|
+
if rx.search(line):
|
|
343
|
+
hits.append(f"{rel}:{n}: {line[:300]}")
|
|
344
|
+
if len(hits) >= 200:
|
|
345
|
+
return "\n".join(hits) + "\n[stopped at 200 matches]"
|
|
346
|
+
return "\n".join(hits) or "no matches"
|
|
347
|
+
|
|
348
|
+
def t_read_file(self, path, offset=1, limit=2000):
|
|
349
|
+
lines = self.load(self.path(path)).splitlines()
|
|
350
|
+
offset, limit = max(1, int(offset)), int(limit)
|
|
351
|
+
chunk = lines[offset - 1:offset - 1 + limit]
|
|
352
|
+
out = "\n".join(f"{i:>6}\t{line}" for i, line in enumerate(chunk, offset))
|
|
353
|
+
left = len(lines) - (offset - 1 + len(chunk))
|
|
354
|
+
if left > 0:
|
|
355
|
+
out += f"\n[{left} more lines; use offset={offset + len(chunk)}]"
|
|
356
|
+
return out or "(empty file, or offset is past the end)"
|
|
357
|
+
|
|
358
|
+
def confirm(self, action, target, detail, force=False):
|
|
359
|
+
"""Ask before a write, edit or command. Raises ToolError if denied. force asks even in auto mode."""
|
|
360
|
+
kind = "run" if action == "run" else "files" # "always" covers file changes or commands, not both
|
|
361
|
+
if not force and (self.mode == "auto" or kind in self.always):
|
|
362
|
+
return
|
|
363
|
+
self.ui.emit("approval", action=action, target=target, detail=detail)
|
|
364
|
+
prompt = "└ DANGEROUS, allow?" if force else "└ allow?"
|
|
365
|
+
answer = (self.ui.ask(f"{prompt} [y]es / [a]lways / [n]o / or type feedback › ", "1;33") or "").strip()
|
|
366
|
+
low = answer.lower()
|
|
367
|
+
if low in ("a", "always"):
|
|
368
|
+
if not force:
|
|
369
|
+
self.always.add(kind)
|
|
370
|
+
elif low not in ("y", "yes"):
|
|
371
|
+
raise ToolError("denied by user" + (f": {answer}" if low not in ("", "n", "no") else ""))
|
|
372
|
+
|
|
373
|
+
def audit(self, action, target):
|
|
374
|
+
"""Append a JSON line to .agent/audit.jsonl. Failures never break the agent."""
|
|
375
|
+
if not self.log:
|
|
376
|
+
return
|
|
377
|
+
try:
|
|
378
|
+
folder = self.root / ".agent"
|
|
379
|
+
folder.mkdir(exist_ok=True)
|
|
380
|
+
(folder / ".gitignore").write_text("*\n")
|
|
381
|
+
entry = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "action": action, "target": target}
|
|
382
|
+
with (folder / "audit.jsonl").open("a", encoding="utf-8") as f:
|
|
383
|
+
f.write(json.dumps(entry) + "\n")
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
def save(self, p, text):
|
|
388
|
+
self.undo.append((p, p.read_bytes() if p.exists() else None))
|
|
389
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
p.write_bytes(text.encode("utf-8")) # bytes keep line endings exactly
|
|
391
|
+
|
|
392
|
+
def revert(self):
|
|
393
|
+
"""Undo the most recent write or edit. Commands cannot be undone."""
|
|
394
|
+
if not self.undo:
|
|
395
|
+
return "nothing to undo"
|
|
396
|
+
p, old = self.undo.pop()
|
|
397
|
+
p.unlink() if old is None else p.write_bytes(old)
|
|
398
|
+
return f"reverted {self.rel(p)}"
|
|
399
|
+
|
|
400
|
+
def t_write_file(self, path, content):
|
|
401
|
+
p = self.path(path)
|
|
402
|
+
rel = self.rel(p)
|
|
403
|
+
old = self.load(p) if p.exists() else None
|
|
404
|
+
detail = diff(old, content, rel) if old is not None else content[:2000]
|
|
405
|
+
self.confirm("write", rel, detail)
|
|
406
|
+
self.audit("write", rel)
|
|
407
|
+
self.save(p, content)
|
|
408
|
+
return f"wrote {len(content)} chars to {rel}"
|
|
409
|
+
|
|
410
|
+
def t_edit_file(self, path, old_text, new_text, replace_all=False):
|
|
411
|
+
p = self.path(path)
|
|
412
|
+
rel, text = self.rel(p), self.load(p)
|
|
413
|
+
if not old_text:
|
|
414
|
+
raise ToolError("old_text must not be empty")
|
|
415
|
+
if old_text not in text and "\r\n" in text:
|
|
416
|
+
old_text = old_text.replace("\r\n", "\n").replace("\n", "\r\n")
|
|
417
|
+
new_text = new_text.replace("\r\n", "\n").replace("\n", "\r\n")
|
|
418
|
+
count = text.count(old_text)
|
|
419
|
+
if count == 0 and len(spans := loose_matches(text, old_text)) == 1:
|
|
420
|
+
old_text, count = spans[0], 1
|
|
421
|
+
if count == 0:
|
|
422
|
+
lines = [line.strip() for line in text.splitlines()]
|
|
423
|
+
near = difflib.get_close_matches(old_text.strip().split("\n")[0].strip(), lines, 1, 0.6)
|
|
424
|
+
hint = f"; the closest line is {lines.index(near[0]) + 1}: {near[0]!r}" if near else ""
|
|
425
|
+
raise ToolError(f"old_text not found in {rel}{hint}. Re-read the file and copy it exactly")
|
|
426
|
+
if count > 1 and not replace_all:
|
|
427
|
+
raise ToolError(f"old_text matches {count} places in {rel}; add surrounding "
|
|
428
|
+
"context to make it unique, or set replace_all")
|
|
429
|
+
new = text.replace(old_text, new_text, -1 if replace_all else 1)
|
|
430
|
+
self.confirm("edit", rel, diff(text, new, rel))
|
|
431
|
+
self.audit("edit", rel)
|
|
432
|
+
self.save(p, new)
|
|
433
|
+
return f"edited {rel} ({count if replace_all else 1} replacement(s))"
|
|
434
|
+
|
|
435
|
+
def t_run(self, command, timeout=DEFAULT_TIMEOUT):
|
|
436
|
+
timeout = float(timeout)
|
|
437
|
+
danger = bool(DANGER.search(command))
|
|
438
|
+
if danger or not (SAFE_CMD.fullmatch(command) and ".." not in command and " /" not in command):
|
|
439
|
+
self.confirm("run", command, command, force=danger)
|
|
440
|
+
self.audit("run", command)
|
|
441
|
+
proc = subprocess.Popen(command, shell=True, cwd=self.root, stdin=subprocess.DEVNULL,
|
|
442
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8",
|
|
443
|
+
errors="replace", start_new_session=os.name == "posix",
|
|
444
|
+
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) # no stale .pyc after fast edits
|
|
445
|
+
expired = []
|
|
446
|
+
timer = threading.Timer(timeout, lambda: (expired.append(True), kill_tree(proc)))
|
|
447
|
+
timer.start()
|
|
448
|
+
lines = []
|
|
449
|
+
try:
|
|
450
|
+
for line in proc.stdout:
|
|
451
|
+
lines.append(line)
|
|
452
|
+
self.ui.emit("output", line=line.rstrip("\n"))
|
|
453
|
+
proc.wait()
|
|
454
|
+
except BaseException:
|
|
455
|
+
kill_tree(proc)
|
|
456
|
+
raise
|
|
457
|
+
finally:
|
|
458
|
+
timer.cancel()
|
|
459
|
+
proc.stdout.close()
|
|
460
|
+
status = f"[timed out after {timeout:g}s and was killed]" if expired else f"[exit code {proc.returncode}]"
|
|
461
|
+
return status + "\n" + "".join(lines)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class UI:
|
|
465
|
+
"""Terminal output, or JSON-lines events with --json (see the event table in the README)."""
|
|
466
|
+
|
|
467
|
+
def __init__(self, json_mode=False, verbose=False):
|
|
468
|
+
self.json, self.verbose = json_mode, verbose
|
|
469
|
+
self.color = not json_mode and sys.stdout.isatty() and "NO_COLOR" not in os.environ
|
|
470
|
+
|
|
471
|
+
def paint(self, code, text):
|
|
472
|
+
return f"\033[{code}m{text}\033[0m" if self.color else text
|
|
473
|
+
|
|
474
|
+
def paint_diff(self, text):
|
|
475
|
+
codes = {"+": "32", "-": "31", "@": "36"}
|
|
476
|
+
return "\n".join(self.paint(codes.get(line[:1], "0"), line) for line in text.splitlines())
|
|
477
|
+
|
|
478
|
+
def banner(self):
|
|
479
|
+
if self.color:
|
|
480
|
+
print("\n".join(self.paint(f"1;38;5;{c}", row) for c, row in zip((205, 141), BANNER.split("\n"))))
|
|
481
|
+
|
|
482
|
+
def emit(self, kind, **d):
|
|
483
|
+
if self.json:
|
|
484
|
+
print(json.dumps({"type": kind, **d}), flush=True)
|
|
485
|
+
elif kind == "text":
|
|
486
|
+
print("\n" + "\n".join(self.paint("35", "▌ ") + line for line in d["text"].splitlines())
|
|
487
|
+
if self.color else "\n" + d["text"])
|
|
488
|
+
elif kind == "info":
|
|
489
|
+
print(self.paint("2", d["message"]))
|
|
490
|
+
elif kind == "tool":
|
|
491
|
+
args = json.dumps(d["input"])[:120 if self.verbose else 60]
|
|
492
|
+
print(self.paint("2", "- ") + self.paint("36", d["name"]) + self.paint("2", " " + args))
|
|
493
|
+
elif kind == "output" and self.verbose:
|
|
494
|
+
print(self.paint("2", " " + d["line"]), flush=True)
|
|
495
|
+
elif kind == "approval":
|
|
496
|
+
print(self.paint("1;33", f"┌ permission needed: {d['action']} {d['target']}"))
|
|
497
|
+
print(" " + d["detail"] if d["action"] == "run" else self.paint_diff(d["detail"]))
|
|
498
|
+
elif kind == "result" and d["is_error"]:
|
|
499
|
+
print(self.paint("31", "x " + d["output"].split("\n")[0][:200]))
|
|
500
|
+
elif kind == "done":
|
|
501
|
+
print(self.paint("2", "done: {turns} turns, {input_tokens} in / {output_tokens} out tokens".format(**d)))
|
|
502
|
+
elif kind == "error":
|
|
503
|
+
print(self.paint("31", "error: " + d["message"]), file=sys.stderr)
|
|
504
|
+
|
|
505
|
+
def ask(self, prompt, code="0"):
|
|
506
|
+
"""Read one line from the user. Returns None on EOF."""
|
|
507
|
+
if self.json:
|
|
508
|
+
line = sys.stdin.readline()
|
|
509
|
+
return line.rstrip("\n") if line else None
|
|
510
|
+
if self.color and sys.stdin.isatty(): # \001..\002 tell readline the escape codes take no width
|
|
511
|
+
prompt = f"\001\033[{code}m\002{prompt}\001\033[0m\002"
|
|
512
|
+
try:
|
|
513
|
+
return input(prompt)
|
|
514
|
+
except EOFError:
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def transcript(messages):
|
|
519
|
+
"""Plain-text rendering of history, with long text and outputs cut, for the summarizer."""
|
|
520
|
+
lines = []
|
|
521
|
+
for m in messages:
|
|
522
|
+
if m["role"] == "user":
|
|
523
|
+
lines.append("USER: " + m["text"][:4000])
|
|
524
|
+
elif m["role"] == "assistant":
|
|
525
|
+
calls = "".join(f"\n CALL {c['name']} {json.dumps(c['input'])[:200]}" for c in m.get("calls", []))
|
|
526
|
+
lines.append("ASSISTANT: " + m["text"][:1500] + calls)
|
|
527
|
+
else:
|
|
528
|
+
lines += [f" RESULT{' (error)' if r['is_error'] else ''}: {r['output'][:300]}" for r in m["results"]]
|
|
529
|
+
return "\n".join(lines)[:60000]
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class Agent:
|
|
533
|
+
def __init__(self, provider, tools, ui, system):
|
|
534
|
+
self.provider, self.tools, self.ui, self.system = provider, tools, ui, system
|
|
535
|
+
self.history = []
|
|
536
|
+
|
|
537
|
+
def run(self, task):
|
|
538
|
+
"""Run one task to completion. Returns the final text, or None on error/interrupt."""
|
|
539
|
+
self.history.append({"role": "user", "text": task})
|
|
540
|
+
tokens_in = tokens_out = 0
|
|
541
|
+
try:
|
|
542
|
+
for turn in range(1, MAX_TURNS + 1):
|
|
543
|
+
if self.trim() > CONTEXT_BUDGET:
|
|
544
|
+
self.compact()
|
|
545
|
+
text, calls, (n_in, n_out) = self.provider.chat(
|
|
546
|
+
self.system, self.history, self.tools.available())
|
|
547
|
+
tokens_in, tokens_out = tokens_in + n_in, tokens_out + n_out
|
|
548
|
+
self.history.append({"role": "assistant", "calls": calls,
|
|
549
|
+
"text": text or ("" if calls else "(empty response)")})
|
|
550
|
+
if text:
|
|
551
|
+
self.ui.emit("text", text=text)
|
|
552
|
+
if not calls:
|
|
553
|
+
self.ui.emit("done", turns=turn, input_tokens=tokens_in, output_tokens=tokens_out)
|
|
554
|
+
return text
|
|
555
|
+
results = []
|
|
556
|
+
for c in calls:
|
|
557
|
+
self.ui.emit("tool", id=c["id"], name=c["name"], input=c["input"])
|
|
558
|
+
output, is_error = self.tools.call(c["name"], c["input"])
|
|
559
|
+
self.ui.emit("result", id=c["id"], output=output, is_error=is_error)
|
|
560
|
+
results.append({"id": c["id"], "output": output, "is_error": is_error})
|
|
561
|
+
self.history.append({"role": "tool", "results": results})
|
|
562
|
+
self.ui.emit("error", message=f"stopped after {MAX_TURNS} turns without finishing")
|
|
563
|
+
except (KeyboardInterrupt, AgentError) as e:
|
|
564
|
+
self.repair()
|
|
565
|
+
self.ui.emit("error", message=str(e) or "interrupted")
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
def repair(self):
|
|
569
|
+
"""Give every tool call a result, or the next API request is rejected."""
|
|
570
|
+
last = self.history[-1] if self.history else {}
|
|
571
|
+
if last.get("role") == "assistant" and last.get("calls"):
|
|
572
|
+
self.history.append({"role": "tool", "results": [
|
|
573
|
+
{"id": c["id"], "output": "interrupted before this tool ran", "is_error": True}
|
|
574
|
+
for c in last["calls"]]})
|
|
575
|
+
|
|
576
|
+
def trim(self):
|
|
577
|
+
"""Drop old tool output when the history outgrows CONTEXT_BUDGET. Last two messages stay. Returns the size."""
|
|
578
|
+
size = len(json.dumps(self.history))
|
|
579
|
+
for m in self.history[:-2]:
|
|
580
|
+
for r in m.get("results", []):
|
|
581
|
+
if size <= CONTEXT_BUDGET:
|
|
582
|
+
return size
|
|
583
|
+
if len(r["output"]) > 200:
|
|
584
|
+
size -= len(r["output"]) - len(OLD_OUTPUT)
|
|
585
|
+
r["output"] = OLD_OUTPUT
|
|
586
|
+
return size
|
|
587
|
+
|
|
588
|
+
def compact(self):
|
|
589
|
+
"""Still over budget after trim(): replace the older half of the history with a model-written summary."""
|
|
590
|
+
cut = next((i for i in range(max(1, len(self.history) // 2), len(self.history) - 2)
|
|
591
|
+
if self.history[i]["role"] == "assistant"), None)
|
|
592
|
+
if cut is None:
|
|
593
|
+
return
|
|
594
|
+
try:
|
|
595
|
+
summary = self.provider.chat(SUMMARY_SYSTEM, [{"role": "user", "text": transcript(self.history[:cut])}], [])[0]
|
|
596
|
+
except AgentError:
|
|
597
|
+
return # keep going with the trimmed history
|
|
598
|
+
if summary.strip():
|
|
599
|
+
self.history[:cut] = [{"role": "user", "text": "Summary of the earlier work in this session:\n" + summary}]
|
|
600
|
+
self.ui.emit("info", message="summarized the earlier conversation to save context")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def build_system(root):
|
|
604
|
+
"""System prompt plus the project's AGENTS.md or CLAUDE.md, if present."""
|
|
605
|
+
system = SYSTEM.format(root=root, os=f"{platform.system()} {platform.release()}",
|
|
606
|
+
shell=os.environ.get("SHELL") or os.environ.get("COMSPEC") or "sh")
|
|
607
|
+
for name in ("AGENTS.md", "CLAUDE.md"):
|
|
608
|
+
if (root / name).is_file():
|
|
609
|
+
text = (root / name).read_text(encoding="utf-8", errors="replace")
|
|
610
|
+
return f"{system}\n\nProject instructions from {name}:\n{text[:20000]}"
|
|
611
|
+
return system
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def make_provider(args):
|
|
615
|
+
"""Pick a provider: --provider, then AGENT_PROVIDER, then Anthropic if its key is set."""
|
|
616
|
+
env = os.environ
|
|
617
|
+
name = args.provider or env.get("AGENT_PROVIDER") or ("anthropic" if env.get("ANTHROPIC_API_KEY") else "openai")
|
|
618
|
+
model, base = args.model or env.get("AGENT_MODEL"), args.base_url or env.get("AGENT_BASE_URL")
|
|
619
|
+
if name == "anthropic":
|
|
620
|
+
if not env.get("ANTHROPIC_API_KEY"):
|
|
621
|
+
raise AgentError("ANTHROPIC_API_KEY is not set")
|
|
622
|
+
return AnthropicProvider(env["ANTHROPIC_API_KEY"], model or DEFAULT_MODEL, base)
|
|
623
|
+
if name != "openai":
|
|
624
|
+
raise AgentError(f"unknown provider: {name} (use anthropic or openai)")
|
|
625
|
+
if not env.get("OPENAI_API_KEY") and not base:
|
|
626
|
+
raise AgentError("no provider configured: set ANTHROPIC_API_KEY, or OPENAI_API_KEY, "
|
|
627
|
+
"or AGENT_BASE_URL for a local server")
|
|
628
|
+
if not model:
|
|
629
|
+
raise AgentError("set AGENT_MODEL or --model for the OpenAI-compatible provider")
|
|
630
|
+
return OpenAIProvider(env.get("OPENAI_API_KEY"), model, base)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def main(argv=None):
|
|
634
|
+
ap = argparse.ArgumentParser(prog="kidwit", description="A minimal coding agent.")
|
|
635
|
+
ap.add_argument("task", nargs="*", help="task to run once; omit for an interactive session")
|
|
636
|
+
ap.add_argument("--root", default=".", help="project directory (default: current)")
|
|
637
|
+
ap.add_argument("--provider", choices=["anthropic", "openai"])
|
|
638
|
+
ap.add_argument("--model")
|
|
639
|
+
ap.add_argument("--base-url")
|
|
640
|
+
mode = ap.add_mutually_exclusive_group()
|
|
641
|
+
mode.add_argument("--yes", action="store_true", help="never ask for approval")
|
|
642
|
+
mode.add_argument("--read-only", action="store_true", help="no writes or commands")
|
|
643
|
+
ap.add_argument("--json", action="store_true", help="emit JSON-lines events")
|
|
644
|
+
ap.add_argument("--verbose", action="store_true", help="show live command output and full tool calls")
|
|
645
|
+
ap.add_argument("--no-log", action="store_true", help="disable .agent/audit.jsonl")
|
|
646
|
+
ap.add_argument("--version", action="version", version=f"kidwit {VERSION}")
|
|
647
|
+
args = ap.parse_args(argv)
|
|
648
|
+
ui, root = UI(args.json, args.verbose), Path(args.root).resolve()
|
|
649
|
+
try:
|
|
650
|
+
if not root.is_dir():
|
|
651
|
+
raise AgentError(f"not a directory: {root}")
|
|
652
|
+
provider = make_provider(args)
|
|
653
|
+
except AgentError as e:
|
|
654
|
+
ui.emit("error", message=str(e))
|
|
655
|
+
return 1
|
|
656
|
+
tools = Tools(root, ui, "auto" if args.yes else "read-only" if args.read_only else "ask", not args.no_log)
|
|
657
|
+
bot = Agent(provider, tools, ui, build_system(root))
|
|
658
|
+
if args.task:
|
|
659
|
+
return 0 if bot.run(" ".join(args.task)) is not None else 1
|
|
660
|
+
ui.banner()
|
|
661
|
+
ui.emit("info", message=f"kidwit {VERSION} in {root}. /clear resets, /undo reverts the last edit, /verbose toggles detail, /exit quits.")
|
|
662
|
+
while True:
|
|
663
|
+
try:
|
|
664
|
+
line = ui.ask(">> ", "1;32")
|
|
665
|
+
except KeyboardInterrupt:
|
|
666
|
+
return 0
|
|
667
|
+
if line is None or line.strip() in ("/exit", "/quit"):
|
|
668
|
+
return 0
|
|
669
|
+
if line.strip() == "/clear":
|
|
670
|
+
bot.history.clear()
|
|
671
|
+
ui.emit("info", message="history cleared")
|
|
672
|
+
elif line.strip() == "/undo":
|
|
673
|
+
note = tools.revert()
|
|
674
|
+
bot.history.append({"role": "user", "text": f"[the user ran /undo: {note}]"})
|
|
675
|
+
ui.emit("info", message=note)
|
|
676
|
+
elif line.strip() == "/verbose":
|
|
677
|
+
ui.verbose = not ui.verbose
|
|
678
|
+
ui.emit("info", message="verbose " + ("on" if ui.verbose else "off"))
|
|
679
|
+
elif line.strip():
|
|
680
|
+
bot.run(line)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
if __name__ == "__main__":
|
|
684
|
+
sys.exit(main())
|