added providers

main
Jörn Fischer 2026-07-07 07:41:29 +02:00
parent aed7714f64
commit 757bac7f7d
3 changed files with 204 additions and 64 deletions

133
README.md
View File

@ -1,3 +1,134 @@
# jcode # jcode
Minimalistische Coding Harness in <1000 Zeilen A minimal, single-file coding agent (< 1000 lines of Python) for local LLMs with optional cloud fallback.
jcode connects to a local **LM Studio** or **Ollama** server (or, via `settings.json`, to **OpenAI**, **Anthropic**, or **Gemini**), takes a task in plain language, and works autonomously inside a sandboxed `./code` directory: it reads, writes, and edits files, runs commands, writes its own smoke tests, and keeps fixing the code until the tests pass.
```
_ _
(_) ___ ___ __| | ___
| |/ __/ _ \ / _ | / _ \
| | (_| (_) | (_| || __/
_/ |\___\___/ \____| \___|
|__/
```
## Features
- **Autonomous test-fix loop** — the agent writes a test for every change, runs it, analyzes failures, fixes the code, and repeats until green (up to `MAX_STEPS`, default 40).
- **Sandboxed** — all file operations are confined to `./code`; path-escape attempts (`../`, absolute paths) are blocked. Destructive shell commands (`rm -rf /`, `sudo`, `dd`, `mkfs`, shutdown, fork bombs, `curl | sh`, …) are refused.
- **Zero-config local backends** — auto-detects LM Studio (port 1234) first, then Ollama (port 11434).
- **Cloud providers via `settings.json`** — switch to OpenAI, Anthropic, or Gemini at runtime with `/backend <name>`; all are used through their OpenAI-compatible endpoints, so the single `openai` client dependency covers everything.
- **Project guidelines via `AGENTS.md`** — project-specific rules (coding style, per-language test conventions) are loaded from `code/AGENTS.md` and injected as binding instructions.
- **Session-wide context management** — token usage is tracked (server-reported or estimated); when the context nears its limit, older history is compacted into an LLM-written summary so long sessions keep working.
- **Robust against flaky servers** — visible retries with backoff; if the backend goes down, the session is preserved and the task can simply be resubmitted.
- **Transparent** — every request, tool call, and result is logged to `logFile.md`; file changes are shown as colored unified diffs in the terminal.
- **Tools available to the model**`list_files`, `read_file` (optionally with line numbers), `search_files` (regex grep over the sandbox, hits as `file:line: content`), `write_file`, `edit_file` (search/replace), `insert_lines`, `delete_lines`, `run_command`, `web_search` (DuckDuckGo), `fetch_url`.
## Requirements
- Python 3.10+
- `pip install openai prompt_toolkit ddgs`
- One of:
- [LM Studio](https://lmstudio.ai/) with the local server running (default `http://localhost:1234`), or
- [Ollama](https://ollama.com/) (`http://localhost:11434`), or
- an API key for OpenAI / Anthropic / Gemini (see `settings.json` below)
## Usage
```bash
# One-shot task
python jcode.py "Write fib.py with fib(n) and a smoke test that checks fib(10)==55."
# Interactive mode (multiline input; history is kept across tasks)
python jcode.py
```
All generated files land in `./code`, which is created automatically.
### Interactive commands
| Command | Effect |
|---|---|
| `/status` | Show context usage, message counts, model, sandbox |
| `/new`, `/neu`, `/reset` | Clear the session history |
| `/models` | List models available on the current backend |
| `/model <name>` | Switch model |
| `/backend <name>` | Switch to `lm-studio`, `ollama`, or a provider from `settings.json` (e.g. `openai`, `anthropic`, `gemini`) |
| `/max-turns N` | Set the step limit per task |
| `Ctrl+C` | Quit |
## Configuration
### settings.json (optional)
Placed next to `jcode.py`. Defines cloud providers and, optionally, the backend to start on. API keys are read from environment variables — don't put literal keys in the file.
```json
{
"default_provider": "",
"providers": {
"openai": {
"base_url": "https://api.openai.com/v1",
"api_key_env": "OPENAI_API_KEY",
"model": "gpt-4.1"
},
"anthropic": {
"base_url": "https://api.anthropic.com/v1/",
"api_key_env": "ANTHROPIC_API_KEY",
"model": "claude-sonnet-4-5"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
"api_key_env": "GEMINI_API_KEY",
"model": "gemini-2.5-pro"
}
}
}
```
- `default_provider`: name of a provider to activate at startup; leave `""` to use the auto-detected local backend.
- Each provider needs `base_url` (an OpenAI-compatible endpoint), `api_key_env` (environment variable holding the key), and optionally `model`.
- If the file is missing or malformed, jcode prints a warning and runs purely locally.
```bash
export ANTHROPIC_API_KEY=sk-ant-...
python jcode.py
Task> /backend anthropic
```
### AGENTS.md (optional)
If `code/AGENTS.md` exists, it is loaded at session start and treated as binding project guidelines — e.g. "simplicity first", surgical changes only, and the per-language verification conventions (how to compile/test C, C++, Java, PHP, Python, HTML, CSS). Keeping language-specific rules here rather than in the system prompt keeps every request lean and lets you adapt conventions per project without touching code.
### Constants in jcode.py
| Constant | Default | Meaning |
|---|---|---|
| `MODEL` | (auto) | Model name; auto-corrected to the first available model |
| `MAX_STEPS` | 40 | Tool-use steps per task |
| `CMD_TIMEOUT` | 60 s | Timeout per shell command |
| `CONTEXT_LIMIT` | 256 000 | Model context window (tokens) |
| `COMPACT_THRESHOLD` | 250 000 | History compaction kicks in above this |
| `REQUEST_TIMEOUT` | 600 s | Timeout per LLM request (local models are slow) |
## How it works
1. The task is appended to the session history and sent to the model together with the tool schemas (derived automatically from the Python type annotations — no schema drift).
2. The model plans, calls tools (read → edit → run test), and gets each result back.
3. Failing tests loop back into analysis and fixes; only when the tests are green does the model answer with a summary and the turn ends.
4. Near the context limit, the older history is summarized by the model itself and replaced, so the session can continue indefinitely.
## Safety notes
jcode blocks known-destructive command patterns and confines file access to the sandbox, but `run_command` still executes shell commands on your machine with your user's permissions. Review `logFile.md` when in doubt, and treat web content fetched by the agent as untrusted data.
## License
MIT License — Copyright (c) 2026 Prof. Dr. Joern Fischer
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -7,5 +7,16 @@ Write the minimum code that solves the problem—nothing speculative. No unreque
## 3. Surgical Changes ## 3. Surgical Changes
Touch only what the request requires. Don't improve, refactor, or reformat adjacent working code; match existing style. Mention unrelated dead code rather than deleting it. Remove only the orphans your own changes created. Every changed line should trace to the request. Touch only what the request requires. Don't improve, refactor, or reformat adjacent working code; match existing style. Mention unrelated dead code rather than deleting it. Remove only the orphans your own changes created. Every changed line should trace to the request.
## 4. Goal-Driven Execution ## 4. Language-Specific Verification
How to test/verify per language:
- **Python:** `test_*.py` with pytest (`python -m pytest -q`) or a smoke test script with `assert` (`python smoketest.py`).
- **C:** compile with `gcc -Wall -Wextra -o prog file.c`, then run the program with test inputs and check the output. Treat compiler warnings like errors.
- **C++:** like C, with `g++ -Wall -Wextra -std=c++17 -o prog file.cpp`.
- **Java:** `javac File.java`, then `java Class` with test cases in a main method or a separate test class.
- **PHP:** first check syntax with `php -l file.php`, then test the logic via CLI (`php test.php` with assert output).
- **HTML:** check the structure with a small Python script based on `html.parser` (well-formed, required tags present).
- **CSS:** check bracket/syntax balance with a Python script and make sure the classes/IDs referenced in the HTML exist.
## 5. Goal-Driven Execution
Turn tasks into verifiable goals (e.g. "fix the bug" → write a failing test, then make it pass). For multi-step work, state a brief plan with a verification check per step. Strong success criteria let you loop independently; weak ones ("make it work") force constant clarification. Turn tasks into verifiable goals (e.g. "fix the bug" → write a failing test, then make it pass). For multi-step work, state a brief plan with a verification check per step. Strong success criteria let you loop independently; weak ones ("make it work") force constant clarification.

122
jcode.py
View File

@ -1,40 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# MIT License # MIT License - Copyright (c) 2026 Prof. Dr. Joern Fischer (full text: see README.md)
# Copyright (c) 2026 Prof. Dr. Joern Fischer """jcode - minimal coding agent for local LLMs, sandboxed to ./code.
# Setup, usage and configuration: see README.md."""
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
jcode - minimal coding agent for a local LM Studio model.
The agent can only work inside the ./code subdirectory: read, write, and list
files, edit them via search/replace, and run commands (e.g. smoke tests /
pytest). If tests fail, it fixes the code on its own and tests again.
Requirements:
pip install openai ddgs
LM Studio with a running local server (default: http://localhost:1234)
and the model "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive" loaded.
Usage:
python jcode.py "Write fib.py with fib(n) and a smoke test that checks that fib(10)==55."
python jcode.py # interactive mode
"""
import difflib import difflib
import inspect import inspect
@ -59,7 +26,7 @@ from openai import OpenAI
# --------------------------------------------------------------- Configuration # --------------------------------------------------------------- Configuration
APP_NAME = "jcode" APP_NAME = "jcode"
APP_VERSION = "0.9.1" APP_VERSION = "0.9.3"
MODEL = "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive" MODEL = "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive"
_LM_STUDIO_URL = "http://localhost:1234/v1" _LM_STUDIO_URL = "http://localhost:1234/v1"
@ -73,6 +40,15 @@ def _fetch_models(url: str) -> list[str] | None:
except Exception: except Exception:
return None return None
SETTINGS_FILE = Path("settings.json")
def _load_settings() -> dict:
"""Reads settings.json: providers (OpenAI/Anthropic/Gemini via OpenAI-compatible endpoints) for '/backend <name>'."""
try:
return json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) if SETTINGS_FILE.is_file() else {}
except Exception as e: # noqa: BLE001
print(f"WARNING: settings.json ignored ({e})", file=sys.stderr); return {}
SETTINGS = _load_settings()
def _detect_backend() -> tuple[str, str, str]: def _detect_backend() -> tuple[str, str, str]:
"""Tries LM Studio (port 1234) first, Ollama (port 11434) as fallback.""" """Tries LM Studio (port 1234) first, Ollama (port 11434) as fallback."""
global MODEL global MODEL
@ -89,6 +65,13 @@ BASE_URL, _API_KEY, BACKEND = _detect_backend()
def _switch_backend(name: str) -> None: def _switch_backend(name: str) -> None:
global BASE_URL, _API_KEY, BACKEND, client, MODEL global BASE_URL, _API_KEY, BACKEND, client, MODEL
if (prov := SETTINGS.get("providers", {}).get(name)):
key = os.environ.get(prov.get("api_key_env", ""), "") or prov.get("api_key", "")
if not key:
print(f"{RED}\u2717 API key for '{name}' missing (set env {prov.get('api_key_env')}).{RESET}"); return
BASE_URL, _API_KEY, BACKEND, MODEL = prov["base_url"], key, name, prov.get("model", MODEL)
client = OpenAI(base_url=BASE_URL, api_key=_API_KEY, timeout=REQUEST_TIMEOUT, max_retries=0)
print(f"{DIM}Backend: {BACKEND} ({BASE_URL}) | Model: {MODEL}{RESET}"); return
url = _OLLAMA_URL if "ollama" in name.lower() else _LM_STUDIO_URL url = _OLLAMA_URL if "ollama" in name.lower() else _LM_STUDIO_URL
key, bname = ("ollama", "Ollama") if url == _OLLAMA_URL else ("lm-studio", "LM Studio") key, bname = ("ollama", "Ollama") if url == _OLLAMA_URL else ("lm-studio", "LM Studio")
models = _fetch_models(url) models = _fetch_models(url)
@ -145,33 +128,19 @@ SYSTEM_PROMPT = (
"project directory. ALWAYS give paths relative to it, e.g. 'fib.py' or " "project directory. ALWAYS give paths relative to it, e.g. 'fib.py' or "
"'tests/test_fib.py' - NEVER with a leading 'code/' and NEVER absolute. " "'tests/test_fib.py' - NEVER with a leading 'code/' and NEVER absolute. "
"Use the tools to read, write, list, and edit files and to run commands.\n\n" "Use the tools to read, write, list, and edit files and to run commands.\n\n"
"Language: Reply in the same language as the task. If the task is written " "Language: reply in the same language as the task (German task -> German answers, English task -> English).\n\n"
"in German, write your answers, summaries and explanations in German;"
"if the task is in English, reply in English. Match the user's language.\n\n"
"How to work:\n" "How to work:\n"
"1. Read a file before you edit it. For line-based changes " "1. Locate code with search_files and read a file before you edit it. "
"For line-based changes "
"(insert_lines/delete_lines) read it with numbered=true; re-read the line " "(insert_lines/delete_lines) read it with numbered=true; re-read the line "
"numbers after each change, since they shift.\n" "numbers after each change, since they shift.\n"
"2. Always write a smoke test or test for your code and run it with " "2. Always write a smoke test or test for your code and run it with "
"run_command. Depending on the language:\n" "run_command, using the standard toolchain of the language (compiler, "
" - Python: test_*.py with pytest ('python -m pytest -q') or a smoke " "interpreter, linter, test runner); treat compiler warnings as errors. "
"test script with assert ('python smoketest.py').\n" "Project-specific conventions per language come from AGENTS.md. If a "
" - C: compile with 'gcc -Wall -Wextra -o prog file.c', then run the " "compiler/interpreter is missing (command not found), report that in the "
"program with test inputs and check the output. Treat compiler warnings " "summary instead of hiding it. If a Python library is missing "
"like errors.\n" "(ModuleNotFoundError), install it via 'pip install <package>' and run the test again.\n"
" - C++: like C, with 'g++ -Wall -Wextra -std=c++17 -o prog file.cpp'.\n"
" - Java: 'javac File.java' and then 'java Class' with test cases in a "
"main method or a separate test class.\n"
" - PHP: first check syntax with 'php -l file.php', then test the logic "
"via CLI ('php test.php' with assert output).\n"
" - HTML: check the structure with a small Python script based on "
"html.parser (well-formed, required tags present).\n"
" - CSS: check bracket/syntax balance with a Python script and make "
"sure the classes/IDs referenced in the HTML exist.\n"
" If a compiler/interpreter is missing (command not found), report that "
"in the summary instead of hiding it. If a Python library is missing "
"(ModuleNotFoundError), install it with run_command via "
"'pip install <package>' and run the test again.\n"
"3. If the test fails, analyze the output, fix the code and run the test " "3. If the test fails, analyze the output, fix the code and run the test "
"again. Repeat until it passes.\n" "again. Repeat until it passes.\n"
"4. Only then, without a tool call, reply with a short summary, once the " "4. Only then, without a tool call, reply with a short summary, once the "
@ -216,6 +185,24 @@ def read_file(path: str, numbered: bool = False) -> str:
) )
return text return text
def search_files(pattern: str, glob: str = "**/*") -> str:
"""Regex search over all sandbox files; hits as 'file:line: content'."""
try:
rx = re.compile(pattern)
except re.error as e:
return f"ERROR: invalid regex: {e}"
hits = []
for p in sorted(SANDBOX.glob(glob.lstrip("/") or "**/*")):
try:
text = p.read_text(encoding="utf-8") if p.is_file() else ""
except (UnicodeDecodeError, OSError):
continue # skip binary/unreadable files
hits += [f"{p.relative_to(SANDBOX)}:{i}: {l.strip()[:200]}"
for i, l in enumerate(text.splitlines(), 1) if rx.search(l)]
if len(hits) > 100:
return "\n".join(hits[:100]) + f"\n...[{len(hits)} hits total - refine pattern or glob]"
return "\n".join(hits) or f"No hits for {pattern!r}."
# ------------------------------------------------------------- Diff display # ------------------------------------------------------------- Diff display
# ANSI colors; on Windows you may need 'colorama' or disable colors. # ANSI colors; on Windows you may need 'colorama' or disable colors.
USE_COLOR = sys.stdout.isatty() or os.environ.get("FORCE_COLOR") USE_COLOR = sys.stdout.isatty() or os.environ.get("FORCE_COLOR")
@ -490,6 +477,7 @@ def fetch_url(url: str, max_chars: int = 6000) -> str:
TOOL_FUNCS = { TOOL_FUNCS = {
"list_files": list_files, "list_files": list_files,
"read_file": read_file, "read_file": read_file,
"search_files": search_files,
"write_file": write_file, "write_file": write_file,
"edit_file": edit_file, "edit_file": edit_file,
"insert_lines": insert_lines, "insert_lines": insert_lines,
@ -517,6 +505,13 @@ _TOOL_META = {
), ),
"params": {"numbered": "Show line numbers, default false"}, "params": {"numbered": "Show line numbers, default false"},
}, },
"search_files": {
"description": ("Searches all sandbox files line by line with a Python regex; "
"hits as 'file:line: content'. Use this to locate functions, "
"classes or strings BEFORE reading whole files."),
"params": {"pattern": "Python regex",
"glob": "File filter, default '**/*' (e.g. '**/*.py')"},
},
"write_file": {"description": "Creates/overwrites a file with content."}, "write_file": {"description": "Creates/overwrites a file with content."},
"edit_file": { "edit_file": {
"description": ( "description": (
@ -635,8 +630,8 @@ def splash_screen() -> None:
_submit = "Esc, Enter" if sys.platform == "darwin" else "Alt+Enter" _submit = "Esc, Enter" if sys.platform == "darwin" else "Alt+Enter"
print(f" Describe a task (multiline, submit with {_submit}). History is kept") print(f" Describe a task (multiline, submit with {_submit}). History is kept")
print(" across tasks. '/status' shows usage, '/new' clears the session,") print(" across tasks. '/status' shows usage, '/new' clears the session,")
print(" '/model' sets model, '/models' lists models, '/backend lm-studio|ollama'") print(" '/model' sets model, '/models' lists models, '/backend <name>' switches to")
print(" switches backend, '/max-turns N' sets step limit, Ctrl+C quits.\n") print(" lm-studio, ollama or a settings.json provider, '/max-turns N' sets step limit.\n")
# Descriptive action label per tool call - WITHOUT code/file content. # Descriptive action label per tool call - WITHOUT code/file content.
def _action_label(name: str, args: dict) -> str: def _action_label(name: str, args: dict) -> str:
@ -651,6 +646,8 @@ def _action_label(name: str, args: dict) -> str:
return f"\u2796 Removing {target} (lines {args.get('start_line', '?')}-{args.get('end_line', '?')})" return f"\u2796 Removing {target} (lines {args.get('start_line', '?')}-{args.get('end_line', '?')})"
if name == "read_file": if name == "read_file":
return f"\U0001f4d6 Analyzing {target}" return f"\U0001f4d6 Analyzing {target}"
if name == "search_files":
return f"\U0001f50e Searching /{args.get('pattern', '')}/ in {args.get('glob', '**/*')}"
if name == "list_files": if name == "list_files":
return f"\U0001f4c2 Exploring {target or '.'}" return f"\U0001f4c2 Exploring {target or '.'}"
if name == "run_command": if name == "run_command":
@ -960,6 +957,7 @@ def run(task: str, messages: list) -> list:
def main() -> None: def main() -> None:
global LAST_USAGE_TOKENS, MODEL, MAX_STEPS global LAST_USAGE_TOKENS, MODEL, MAX_STEPS
SANDBOX.mkdir(exist_ok=True) SANDBOX.mkdir(exist_ok=True)
if (d := SETTINGS.get("default_provider")): _switch_backend(d)
if len(sys.argv) > 1: if len(sys.argv) > 1:
# Direct call with a task: compact header instead of full-screen splash. # Direct call with a task: compact header instead of full-screen splash.
print(f"{APP_NAME} v{APP_VERSION} | Backend: {BACKEND} | Model: {MODEL} | Sandbox: {SANDBOX}\n") print(f"{APP_NAME} v{APP_VERSION} | Backend: {BACKEND} | Model: {MODEL} | Sandbox: {SANDBOX}\n")