axio-repl 0.9.2__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.
@@ -0,0 +1,14 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .pytest_cache/
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ _build/
12
+ .DS_Store
13
+ *.sqlite
14
+ *.db
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: axio-repl
3
+ Version: 0.9.2
4
+ Summary: Interactive REPL coding assistant for Axio
5
+ Project-URL: Documentation, https://docs.axio-agent.com
6
+ Project-URL: Homepage, https://github.com/mosquito/axio-agent
7
+ Project-URL: Repository, https://github.com/mosquito/axio-agent
8
+ License: MIT
9
+ Keywords: agent,ai,coding-assistant,llm,repl
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: aiohttp>=3.11
12
+ Requires-Dist: axio
13
+ Requires-Dist: axio-tools-local
14
+ Requires-Dist: axio-transport-openai
15
+ Description-Content-Type: text/markdown
16
+
17
+ # axio-repl
18
+
19
+ Interactive REPL coding assistant powered by the [axio](../axio) agent framework.
20
+ Works with any LLM backend via pluggable transports — bring your own API key.
21
+
22
+ ## Philosophy
23
+
24
+ axio-repl is an opinionated terminal agent that **actually verifies its work**.
25
+ The system prompt encodes hard-won lessons from watching models cut corners:
26
+
27
+ - **Stream everything, hide nothing.** Every piece of information shown to the
28
+ user — tool arguments, stdout/stderr, images, exit codes — must also be
29
+ faithfully presented to the model. The model should see exactly what the user
30
+ sees, so it can reason about the same reality. No summarizing, no truncating,
31
+ no dropping context between what's displayed and what's sent back.
32
+ - **Not tested — not done.** The agent must run tests, re-read edited files,
33
+ and observe actual results instead of assuming success from exit codes.
34
+ - **Iterative UI review.** When building or modifying UI, the agent captures
35
+ real screenshots at multiple viewport sizes (desktop, tablet, mobile) via
36
+ Playwright/Puppeteer, reads every screenshot through the model's vision, and
37
+ critically lists visual defects. It repeats the screenshot → fix → re-screenshot
38
+ loop until zero defects — no premature "looks good".
39
+ - **Ground everything in project context.** Read before editing. List the
40
+ directory before guessing. Never refuse a safe request.
41
+ - **Minimal edits.** Don't reformat surrounding code, don't narrate tool calls.
42
+ The user sees the full tool output in the terminal.
43
+
44
+ ## Features
45
+
46
+ - **Pluggable transports** — auto-detected from API keys via
47
+ `axio.transport` entry points. Ships with support for OpenAI, Anthropic,
48
+ Google (Gemini API & Vertex AI), Nebius, OpenRouter, and Codex.
49
+ - **Runtime model switching** — `/model <query>` to switch models mid-session
50
+ without restarting. Capabilities (vision, reasoning, image generation) are
51
+ re-evaluated and the system prompt adapts automatically.
52
+ - **Streaming tool arguments** — tool call fields appear incrementally as the
53
+ model generates them, so you see what's happening before execution starts.
54
+ - **Streaming tool output** — shell command stdout/stderr streams line-by-line
55
+ in real time instead of buffering until completion.
56
+ - **Vision** — `read_file` on images (PNG, JPG, GIF, WebP) and videos returns
57
+ multimodal content blocks. The model sees the actual pixels, not a description.
58
+ - **Image & video generation** — when the Google transport is installed,
59
+ `generate_image` and `generate_video` tools are available for Gemini Nano
60
+ Banana / Veo models.
61
+ - **AGENTS.md** — workspace-level instructions loaded into the system prompt from
62
+ an `AGENTS.md` file in the working directory.
63
+ - **Multiline paste** — pasting multi-line text into the prompt is handled
64
+ gracefully with continuation markers (`...`).
65
+ - **Graceful interruption** — Ctrl-C cancels the running agent loop, preserving
66
+ partial tool output in conversation context so the model knows what happened.
67
+ - **Readline history** — persisted across sessions in `~/.axio_repl_history`.
68
+ - **Single-prompt mode** — pass a prompt as argument for scripting and non-interactive use.
69
+
70
+ ## Install
71
+
72
+ ```bash
73
+ uv tool install axio-repl
74
+ ```
75
+
76
+ To add optional transports:
77
+
78
+ ```bash
79
+ uv tool install axio-repl --with axio-transport-anthropic
80
+ uv tool install axio-repl --with axio-transport-google
81
+ ```
82
+
83
+ Or within the monorepo workspace:
84
+
85
+ ```bash
86
+ uv run axio-repl
87
+ ```
88
+
89
+ ## Usage
90
+
91
+ ```bash
92
+ # Interactive REPL (auto-detects transport from API keys)
93
+ axio-repl
94
+
95
+ # Single prompt (non-interactive)
96
+ axio-repl "list the files in this project"
97
+
98
+ # Explicit transport and model
99
+ axio-repl --transport anthropic --model claude-sonnet-4-20250514
100
+
101
+ # Google Gemini
102
+ axio-repl --transport google --model gemini-3.1-pro-preview
103
+
104
+ # Custom temperature and iteration limit
105
+ axio-repl --temperature 0.5 --max-iterations 100
106
+ ```
107
+
108
+ ## REPL Commands
109
+
110
+ | Command | Description |
111
+ |----------------------|------------------------------------------------|
112
+ | `/help` | Show available tools and commands |
113
+ | `/model` | Show current model and list available models |
114
+ | `/model <query>` | Switch to a model matching the query |
115
+ | `/quit` `/exit` `/q` | Exit the REPL |
116
+
117
+ ## Tools
118
+
119
+ | Tool | Description |
120
+ |-----------------|----------------------------------------------------------------|
121
+ | `read_file` | Read file contents; images and videos returned as vision blocks |
122
+ | `write_file` | Create or overwrite files |
123
+ | `patch_file` | Replace line ranges in files (1-indexed, inclusive) |
124
+ | `list_files` | List directory contents |
125
+ | `search_files` | Text/regex search across files |
126
+ | `shell` | Run shell commands with streaming output and process-group cleanup |
127
+ | `generate_image` | Generate images via Gemini Nano Banana (Google transport only) |
128
+ | `generate_video` | Generate videos via Veo (Google transport only) |
129
+
130
+ ## Transports
131
+
132
+ Transports are discovered via the `axio.transport` entry point group.
133
+ The REPL picks the first transport whose required environment variable is set:
134
+
135
+ | Transport | Env Variable | Package |
136
+ |------------------|--------------------------------|----------------------------|
137
+ | `google` | `GEMINI_API_KEY` | `axio-transport-google` |
138
+ | `google-vertex` | `GOOGLE_GENAI_USE_VERTEXAI` | `axio-transport-google` |
139
+ | `anthropic` | `ANTHROPIC_API_KEY` | `axio-transport-anthropic` |
140
+ | `openai` | `OPENAI_API_KEY` | `axio-transport-openai` |
141
+ | `nebius` | `NEBIUS_API_KEY` | `axio-transport-openai` |
142
+ | `openrouter` | `OPENROUTER_API_KEY` | `axio-transport-openai` |
143
+ | `codex` | *(API key varies)* | `axio-transport-codex` |
144
+
145
+ Use `--transport <name>` to force a specific transport regardless of env vars.
146
+
147
+ ## Capability-Aware System Prompt
148
+
149
+ The system prompt adapts based on the selected model's declared capabilities:
150
+
151
+ - **Vision** — unlocks instructions to `read_file` images and do screenshot-based
152
+ UI review.
153
+ - **Reasoning** — notes that extended thinking is available.
154
+ - **Image generation** — enables inline image generation guidance.
155
+ - **Video** — enables `read_file` for video content.
156
+ - **Tool use** — gates all tool-related rules (edit workflow, testing, verification).
157
+
158
+ This means switching from a text-only model to a vision model mid-session
159
+ (via `/model`) automatically updates what the agent is instructed to do.
160
+
161
+ ## Architecture
162
+
163
+ ```
164
+ ┌─────────────┐ ┌───────────┐ ┌──────────────────┐
165
+ │ axio-repl │────▶│ axio │────▶│ transport │
166
+ │ (terminal │ │ (agent │ │ (anthropic / │
167
+ │ UI, I/O) │ │ loop) │ │ google / openai │
168
+ └─────────────┘ └───────────┘ │ / nebius / ...) │
169
+ │ └──────────────────┘
170
+ ┌─────┴─────┐
171
+ │ tools │
172
+ │ (local fs │
173
+ │ + shell) │
174
+ └───────────┘
175
+ ```
176
+
177
+ - **axio-repl** owns the terminal UI: readline, event rendering, REPL commands.
178
+ - **axio** runs the agent loop: dispatch tools, manage conversation context,
179
+ handle cancellation.
180
+ - **transports** handle LLM communication — message conversion, streaming,
181
+ model registries.
182
+ - **axio-tools-local** provides the file and shell tools.
@@ -0,0 +1,166 @@
1
+ # axio-repl
2
+
3
+ Interactive REPL coding assistant powered by the [axio](../axio) agent framework.
4
+ Works with any LLM backend via pluggable transports — bring your own API key.
5
+
6
+ ## Philosophy
7
+
8
+ axio-repl is an opinionated terminal agent that **actually verifies its work**.
9
+ The system prompt encodes hard-won lessons from watching models cut corners:
10
+
11
+ - **Stream everything, hide nothing.** Every piece of information shown to the
12
+ user — tool arguments, stdout/stderr, images, exit codes — must also be
13
+ faithfully presented to the model. The model should see exactly what the user
14
+ sees, so it can reason about the same reality. No summarizing, no truncating,
15
+ no dropping context between what's displayed and what's sent back.
16
+ - **Not tested — not done.** The agent must run tests, re-read edited files,
17
+ and observe actual results instead of assuming success from exit codes.
18
+ - **Iterative UI review.** When building or modifying UI, the agent captures
19
+ real screenshots at multiple viewport sizes (desktop, tablet, mobile) via
20
+ Playwright/Puppeteer, reads every screenshot through the model's vision, and
21
+ critically lists visual defects. It repeats the screenshot → fix → re-screenshot
22
+ loop until zero defects — no premature "looks good".
23
+ - **Ground everything in project context.** Read before editing. List the
24
+ directory before guessing. Never refuse a safe request.
25
+ - **Minimal edits.** Don't reformat surrounding code, don't narrate tool calls.
26
+ The user sees the full tool output in the terminal.
27
+
28
+ ## Features
29
+
30
+ - **Pluggable transports** — auto-detected from API keys via
31
+ `axio.transport` entry points. Ships with support for OpenAI, Anthropic,
32
+ Google (Gemini API & Vertex AI), Nebius, OpenRouter, and Codex.
33
+ - **Runtime model switching** — `/model <query>` to switch models mid-session
34
+ without restarting. Capabilities (vision, reasoning, image generation) are
35
+ re-evaluated and the system prompt adapts automatically.
36
+ - **Streaming tool arguments** — tool call fields appear incrementally as the
37
+ model generates them, so you see what's happening before execution starts.
38
+ - **Streaming tool output** — shell command stdout/stderr streams line-by-line
39
+ in real time instead of buffering until completion.
40
+ - **Vision** — `read_file` on images (PNG, JPG, GIF, WebP) and videos returns
41
+ multimodal content blocks. The model sees the actual pixels, not a description.
42
+ - **Image & video generation** — when the Google transport is installed,
43
+ `generate_image` and `generate_video` tools are available for Gemini Nano
44
+ Banana / Veo models.
45
+ - **AGENTS.md** — workspace-level instructions loaded into the system prompt from
46
+ an `AGENTS.md` file in the working directory.
47
+ - **Multiline paste** — pasting multi-line text into the prompt is handled
48
+ gracefully with continuation markers (`...`).
49
+ - **Graceful interruption** — Ctrl-C cancels the running agent loop, preserving
50
+ partial tool output in conversation context so the model knows what happened.
51
+ - **Readline history** — persisted across sessions in `~/.axio_repl_history`.
52
+ - **Single-prompt mode** — pass a prompt as argument for scripting and non-interactive use.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ uv tool install axio-repl
58
+ ```
59
+
60
+ To add optional transports:
61
+
62
+ ```bash
63
+ uv tool install axio-repl --with axio-transport-anthropic
64
+ uv tool install axio-repl --with axio-transport-google
65
+ ```
66
+
67
+ Or within the monorepo workspace:
68
+
69
+ ```bash
70
+ uv run axio-repl
71
+ ```
72
+
73
+ ## Usage
74
+
75
+ ```bash
76
+ # Interactive REPL (auto-detects transport from API keys)
77
+ axio-repl
78
+
79
+ # Single prompt (non-interactive)
80
+ axio-repl "list the files in this project"
81
+
82
+ # Explicit transport and model
83
+ axio-repl --transport anthropic --model claude-sonnet-4-20250514
84
+
85
+ # Google Gemini
86
+ axio-repl --transport google --model gemini-3.1-pro-preview
87
+
88
+ # Custom temperature and iteration limit
89
+ axio-repl --temperature 0.5 --max-iterations 100
90
+ ```
91
+
92
+ ## REPL Commands
93
+
94
+ | Command | Description |
95
+ |----------------------|------------------------------------------------|
96
+ | `/help` | Show available tools and commands |
97
+ | `/model` | Show current model and list available models |
98
+ | `/model <query>` | Switch to a model matching the query |
99
+ | `/quit` `/exit` `/q` | Exit the REPL |
100
+
101
+ ## Tools
102
+
103
+ | Tool | Description |
104
+ |-----------------|----------------------------------------------------------------|
105
+ | `read_file` | Read file contents; images and videos returned as vision blocks |
106
+ | `write_file` | Create or overwrite files |
107
+ | `patch_file` | Replace line ranges in files (1-indexed, inclusive) |
108
+ | `list_files` | List directory contents |
109
+ | `search_files` | Text/regex search across files |
110
+ | `shell` | Run shell commands with streaming output and process-group cleanup |
111
+ | `generate_image` | Generate images via Gemini Nano Banana (Google transport only) |
112
+ | `generate_video` | Generate videos via Veo (Google transport only) |
113
+
114
+ ## Transports
115
+
116
+ Transports are discovered via the `axio.transport` entry point group.
117
+ The REPL picks the first transport whose required environment variable is set:
118
+
119
+ | Transport | Env Variable | Package |
120
+ |------------------|--------------------------------|----------------------------|
121
+ | `google` | `GEMINI_API_KEY` | `axio-transport-google` |
122
+ | `google-vertex` | `GOOGLE_GENAI_USE_VERTEXAI` | `axio-transport-google` |
123
+ | `anthropic` | `ANTHROPIC_API_KEY` | `axio-transport-anthropic` |
124
+ | `openai` | `OPENAI_API_KEY` | `axio-transport-openai` |
125
+ | `nebius` | `NEBIUS_API_KEY` | `axio-transport-openai` |
126
+ | `openrouter` | `OPENROUTER_API_KEY` | `axio-transport-openai` |
127
+ | `codex` | *(API key varies)* | `axio-transport-codex` |
128
+
129
+ Use `--transport <name>` to force a specific transport regardless of env vars.
130
+
131
+ ## Capability-Aware System Prompt
132
+
133
+ The system prompt adapts based on the selected model's declared capabilities:
134
+
135
+ - **Vision** — unlocks instructions to `read_file` images and do screenshot-based
136
+ UI review.
137
+ - **Reasoning** — notes that extended thinking is available.
138
+ - **Image generation** — enables inline image generation guidance.
139
+ - **Video** — enables `read_file` for video content.
140
+ - **Tool use** — gates all tool-related rules (edit workflow, testing, verification).
141
+
142
+ This means switching from a text-only model to a vision model mid-session
143
+ (via `/model`) automatically updates what the agent is instructed to do.
144
+
145
+ ## Architecture
146
+
147
+ ```
148
+ ┌─────────────┐ ┌───────────┐ ┌──────────────────┐
149
+ │ axio-repl │────▶│ axio │────▶│ transport │
150
+ │ (terminal │ │ (agent │ │ (anthropic / │
151
+ │ UI, I/O) │ │ loop) │ │ google / openai │
152
+ └─────────────┘ └───────────┘ │ / nebius / ...) │
153
+ │ └──────────────────┘
154
+ ┌─────┴─────┐
155
+ │ tools │
156
+ │ (local fs │
157
+ │ + shell) │
158
+ └───────────┘
159
+ ```
160
+
161
+ - **axio-repl** owns the terminal UI: readline, event rendering, REPL commands.
162
+ - **axio** runs the agent loop: dispatch tools, manage conversation context,
163
+ handle cancellation.
164
+ - **transports** handle LLM communication — message conversion, streaming,
165
+ model registries.
166
+ - **axio-tools-local** provides the file and shell tools.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "axio-repl"
3
+ version = "0.9.2"
4
+ description = "Interactive REPL coding assistant for Axio"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = {text = "MIT"}
8
+ keywords = ["llm", "agent", "ai", "repl", "coding-assistant"]
9
+ dependencies = [
10
+ "axio",
11
+ "axio-tools-local",
12
+ "axio-transport-openai",
13
+ "aiohttp>=3.11",
14
+ ]
15
+
16
+ [project.scripts]
17
+ axio-repl = "axio_repl:main_sync"
18
+
19
+ [project.urls]
20
+ Documentation = "https://docs.axio-agent.com"
21
+ Homepage = "https://github.com/mosquito/axio-agent"
22
+ Repository = "https://github.com/mosquito/axio-agent"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/axio_repl"]
30
+
31
+ [tool.ruff]
32
+ line-length = 119
33
+ output-format = "concise"
34
+ target-version = "py312"
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "UP"]
38
+
39
+ [tool.mypy]
40
+ strict = true
41
+ python_version = "3.12"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=8",
46
+ "pytest-asyncio>=0.24",
47
+ "mypy>=1.14",
48
+ "ruff>=0.9",
49
+ ]
@@ -0,0 +1,800 @@
1
+ """Interactive REPL coding assistant powered by axio agent framework.
2
+
3
+ Auto-detects transport from available API keys (OPENAI_API_KEY, NEBIUS_API_KEY,
4
+ OPENROUTER_API_KEY), or use --transport to pick explicitly.
5
+
6
+ Run:
7
+ axio-repl
8
+ axio-repl "your prompt here"
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import atexit
15
+ import os
16
+ import re
17
+ import signal
18
+ import sys
19
+ from collections.abc import Callable, Iterator
20
+ from importlib.metadata import entry_points
21
+ from pathlib import Path
22
+ from typing import Any, NamedTuple
23
+
24
+ import aiohttp
25
+ from axio.agent import Agent
26
+ from axio.context import MemoryContextStore
27
+ from axio.events import (
28
+ AudioOutput,
29
+ Error,
30
+ ImageOutput,
31
+ IterationEnd,
32
+ ReasoningDelta,
33
+ SessionEndEvent,
34
+ TextDelta,
35
+ ToolFieldDelta,
36
+ ToolFieldEnd,
37
+ ToolFieldStart,
38
+ ToolInputDelta,
39
+ ToolOutputDelta,
40
+ ToolResult,
41
+ ToolUseStart,
42
+ VideoOutput,
43
+ )
44
+ from axio.field import StrictStr
45
+ from axio.models import Capability, ModelSpec
46
+ from axio.tool import Tool
47
+ from axio.tool_args import ToolArgStream
48
+ from axio_tools_local.list_files import list_files
49
+ from axio_tools_local.patch_file import patch_file
50
+ from axio_tools_local.read_file import read_file
51
+ from axio_tools_local.shell import shell
52
+ from axio_tools_local.write_file import write_file
53
+
54
+ _readline: Any
55
+ try:
56
+ import readline as _readline
57
+ except ImportError:
58
+ _readline = None
59
+
60
+ readline: Any = _readline
61
+
62
+ AGENT_NAME = "axio-repl"
63
+ AGENT_VERSION = "0.2.3"
64
+
65
+ # ── ANSI helpers ─────────────────────────────────────────────────────
66
+
67
+ DIM = "\033[2m"
68
+ BOLD = "\033[1m"
69
+ CYAN = "\033[36m"
70
+ GREEN = "\033[32m"
71
+ YELLOW = "\033[33m"
72
+ RED = "\033[31m"
73
+ RESET = "\033[0m"
74
+
75
+
76
+ # ── Custom search tool ───────────────────────────────────────────────
77
+
78
+
79
+ async def search_files(
80
+ query: StrictStr,
81
+ path: StrictStr = ".",
82
+ regex: bool = False,
83
+ max_results: int = 100,
84
+ ) -> str:
85
+ """Search for text or regex patterns in files under a directory.
86
+ Returns matching lines with file paths and line numbers."""
87
+
88
+ def _search() -> str:
89
+ base = Path(path).resolve()
90
+ if not base.exists():
91
+ return f"error: path not found: {path}"
92
+
93
+ try:
94
+ pattern = re.compile(query) if regex else None
95
+ except re.error as exc:
96
+ return f"error: invalid regex: {exc}"
97
+
98
+ skip = {".git", ".venv", "__pycache__", "node_modules"}
99
+ matches: list[str] = []
100
+
101
+ files = [base] if base.is_file() else list(_iter_files(base, skip))
102
+ for file_path in files:
103
+ if len(matches) >= max_results:
104
+ break
105
+ try:
106
+ text = file_path.read_text(encoding="utf-8", errors="replace")
107
+ except OSError:
108
+ continue
109
+ for idx, line in enumerate(text.splitlines(), start=1):
110
+ found = pattern.search(line) if pattern else (query in line)
111
+ if found:
112
+ matches.append(f"{file_path}:{idx}: {line}")
113
+ if len(matches) >= max_results:
114
+ break
115
+
116
+ if not matches:
117
+ return f"No matches for {query!r}"
118
+ return "\n".join(matches)
119
+
120
+ return await asyncio.to_thread(_search)
121
+
122
+
123
+ def _iter_files(base: Path, skip: set[str]) -> Iterator[Path]:
124
+ for current_dir, dirs, files in os.walk(base):
125
+ dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
126
+ for name in sorted(files):
127
+ if not name.startswith("."):
128
+ yield Path(current_dir) / name
129
+
130
+
131
+ # ── Tools ────────────────────────────────────────────────────────────
132
+
133
+ TOOLS: list[Tool[Any]] = [
134
+ Tool(name="read_file", handler=read_file),
135
+ Tool(name="write_file", handler=write_file),
136
+ Tool(name="patch_file", handler=patch_file),
137
+ Tool(name="list_files", handler=list_files),
138
+ Tool(name="search_files", handler=search_files),
139
+ Tool(name="shell", handler=shell),
140
+ ]
141
+
142
+
143
+ # ── Transport auto-detection ─────────────────────────────────────────
144
+
145
+
146
+ def _discover_transports() -> dict[str, Callable[..., Any]]:
147
+ result: dict[str, Callable[..., Any]] = {}
148
+ for ep in entry_points(group="axio.transport"):
149
+ try:
150
+ result[ep.name] = ep.load()
151
+ except Exception:
152
+ pass
153
+ return result
154
+
155
+
156
+ _TRANSPORT_ENV_VARS: dict[str, list[str]] = {
157
+ "google": ["GEMINI_API_KEY"],
158
+ "google-vertex": ["GOOGLE_GENAI_USE_VERTEXAI"],
159
+ "openai": ["OPENAI_API_KEY"],
160
+ "anthropic": ["ANTHROPIC_API_KEY"],
161
+ "nebius": ["NEBIUS_API_KEY"],
162
+ "openrouter": ["OPENROUTER_API_KEY"],
163
+ }
164
+
165
+
166
+ def _transport_has_credentials(name: str) -> bool:
167
+ env_vars = _TRANSPORT_ENV_VARS.get(name, [])
168
+ return any(os.environ.get(v, "") for v in env_vars)
169
+
170
+
171
+ def _select_transport(name: str | None) -> tuple[Callable[..., Any], str]:
172
+ available = _discover_transports()
173
+ if name:
174
+ if name not in available:
175
+ print(
176
+ f"Unknown transport {name!r}. Available: {', '.join(sorted(available))}",
177
+ file=sys.stderr,
178
+ )
179
+ sys.exit(1)
180
+ return available[name], ""
181
+
182
+ for transport_name, cls in available.items():
183
+ if _transport_has_credentials(transport_name):
184
+ return cls, ""
185
+
186
+ print("No API key found. Set one of:", file=sys.stderr)
187
+ for transport_name in available:
188
+ env_vars = _TRANSPORT_ENV_VARS.get(transport_name, [])
189
+ if env_vars:
190
+ print(f" {', '.join(env_vars)} ({transport_name})", file=sys.stderr)
191
+ sys.exit(1)
192
+
193
+
194
+ # ── AGENTS.md & system prompt ────────────────────────────────────────
195
+
196
+
197
+ def load_agents_instructions(root: Path) -> str:
198
+ agents_file = root / "AGENTS.md"
199
+ if not agents_file.exists():
200
+ return ""
201
+ try:
202
+ return agents_file.read_text(encoding="utf-8", errors="replace").strip()
203
+ except OSError:
204
+ return ""
205
+
206
+
207
+ def build_system_prompt(
208
+ root: Path,
209
+ model: ModelSpec,
210
+ tools: list[Tool[Any]],
211
+ agents_text: str = "",
212
+ ) -> str:
213
+ caps = model.capabilities
214
+ ctx_k = model.context_window // 1000
215
+ out_k = model.max_output_tokens // 1000
216
+ tool_names = ", ".join(t.name for t in tools)
217
+
218
+ has_tools = Capability.tool_use in caps
219
+
220
+ lines = [
221
+ f"You are {AGENT_NAME} (v{AGENT_VERSION}) — a terminal coding assistant.",
222
+ f"Model: {model.id} ({ctx_k}K context, {out_k}K max output)",
223
+ f"Current directory: {root} (perform actions here unless specified otherwise)",
224
+ ]
225
+ if has_tools:
226
+ lines.append(f"Tools: {tool_names}")
227
+ lines.append("")
228
+
229
+ # Capability-aware guidance
230
+ cap_notes: list[str] = []
231
+ if Capability.vision in caps:
232
+ cap_notes.append("You can see images via read_file (screenshots, diagrams, photos).")
233
+ if Capability.audio in caps:
234
+ cap_notes.append("You can listen to audio files via read_file (speech, music, podcasts).")
235
+ if Capability.video in caps:
236
+ cap_notes.append("You can see video files via read_file.")
237
+ if Capability.image_generation in caps:
238
+ cap_notes.append("You can generate images inline — describe what to draw in your response.")
239
+ if Capability.reasoning in caps:
240
+ cap_notes.append("Extended thinking is available for complex reasoning.")
241
+ if cap_notes:
242
+ lines += cap_notes + [""]
243
+
244
+ lines.append("Rules:")
245
+ if has_tools:
246
+ lines += [
247
+ "- Start every task by listing the current directory to understand the project.",
248
+ "- Read files before editing. Use line_numbers=True before patch_file.",
249
+ "- Keep edits minimal and targeted — don't reformat surrounding code.",
250
+ "- Ground answers on project context gathered through tools.",
251
+ ]
252
+ lines += [
253
+ "- Write idiomatic code — follow the conventions and best practices of the "
254
+ "languages and frameworks used in the project.",
255
+ "- When the user asks about a file they provided or you read, base your answer "
256
+ "strictly on the actual file contents. Do not guess, assume, or fill in details "
257
+ "from general knowledge — only state what the file actually contains.",
258
+ f"- Your max output is {out_k}K tokens. Use as many as the task requires — "
259
+ "do not stop early. If the user asks for a full transcript, detailed analysis, "
260
+ f"or comprehensive review, produce the complete output up to the {out_k}K limit.",
261
+ "- Never refuse safe requests or claim inability.",
262
+ ]
263
+ if has_tools:
264
+ lines += [
265
+ "- If a tool call fails, analyze the error and try a different approach. "
266
+ "If stuck after 3 attempts at the same sub-problem, "
267
+ "explain what you tried and ask for guidance.",
268
+ "- Do not return a final answer until all necessary work is done or you are stuck.",
269
+ "- For compound requests, build a checklist of all items and verify each is addressed before finishing.",
270
+ "- Don't narrate your tool calls — the user sees their full output.",
271
+ "- After completing work, summarize what changed briefly.",
272
+ "- Not tested — not done. Always run tests or builds to verify your changes. "
273
+ "Re-read edited files, observe actual results — don't assume success "
274
+ "from exit codes alone.",
275
+ "- After any test or build that produces images or video, you MUST read_file "
276
+ "every output file to actually see the results. Never describe visual output "
277
+ "you haven't viewed. 'Tests passed' is not the same as 'I looked at the "
278
+ "screenshots and they look correct'.",
279
+ "- To verify UI, use browser automation (Playwright, Puppeteer) to capture "
280
+ "real screenshots at multiple viewport sizes (desktop 1280×800, tablet 768×1024, "
281
+ "mobile 375×667), then read_file every screenshot.",
282
+ "- When you read a screenshot, you MUST critically analyze it. List every "
283
+ "visual defect you notice: broken layout, text overflow, misaligned elements, "
284
+ "poor contrast, missing images, clipped content, wrong spacing, responsive "
285
+ "issues. Do NOT say 'looks good' unless you can specifically confirm each "
286
+ "aspect is correct.",
287
+ "- UI review is iterative: screenshot → list issues → fix code → re-screenshot "
288
+ "→ verify fixes. Repeat until zero defects. Never declare UI done after a "
289
+ "single screenshot pass.",
290
+ "- Never use generate_image as a substitute for real UI testing.",
291
+ "- Never run destructive shell commands (rm -rf, git reset --hard) without user confirmation.",
292
+ "- For large files, read specific line ranges instead of the entire file.",
293
+ ]
294
+ lines.append("")
295
+
296
+ if agents_text:
297
+ lines += ["AGENTS.md instructions:", agents_text, ""]
298
+
299
+ return "\n".join(lines)
300
+
301
+
302
+ # ── Readline history ─────────────────────────────────────────────────
303
+
304
+
305
+ def setup_history() -> None:
306
+ if readline is None:
307
+ return
308
+ history_path = Path.home() / ".axio_repl_history"
309
+ if history_path.exists():
310
+ try:
311
+ readline.read_history_file(str(history_path))
312
+ except (OSError, RuntimeError):
313
+ pass
314
+ try:
315
+ readline.set_history_length(5000)
316
+ except (AttributeError, ValueError):
317
+ pass
318
+
319
+ def _save() -> None:
320
+ try:
321
+ readline.write_history_file(str(history_path))
322
+ except (OSError, RuntimeError):
323
+ pass
324
+
325
+ atexit.register(_save)
326
+
327
+
328
+ # ── Event rendering ──────────────────────────────────────────────────
329
+
330
+
331
+ async def run_prompt(agent: Agent, ctx: MemoryContextStore, prompt: str) -> None:
332
+ in_text = False
333
+ arg_streams: dict[str, ToolArgStream] = {}
334
+ streamed_tool_ids: set[str] = set()
335
+
336
+ async for event in agent.run_stream(prompt, ctx):
337
+ match event:
338
+ case ReasoningDelta(delta=delta):
339
+ if in_text:
340
+ print()
341
+ in_text = False
342
+ sys.stdout.write(f"{DIM}> {delta}{RESET}")
343
+ sys.stdout.flush()
344
+
345
+ case TextDelta(delta=delta):
346
+ if not in_text:
347
+ in_text = True
348
+ if "[Output truncated:" in delta:
349
+ sys.stdout.write(f"\n{RED}{delta.strip()}{RESET}\n")
350
+ in_text = False
351
+ else:
352
+ sys.stdout.write(delta)
353
+ sys.stdout.flush()
354
+
355
+ case ImageOutput(data=data, media_type=mt):
356
+ if in_text:
357
+ print()
358
+ in_text = False
359
+ path = _save_media(data, mt)
360
+ print(f"{GREEN}[image saved: {path}]{RESET}")
361
+
362
+ case AudioOutput(data=data, media_type=mt):
363
+ if in_text:
364
+ print()
365
+ in_text = False
366
+ path = _save_media(data, mt)
367
+ print(f"{GREEN}[audio saved: {path}]{RESET}")
368
+
369
+ case VideoOutput(data=data, media_type=mt):
370
+ if in_text:
371
+ print()
372
+ in_text = False
373
+ path = _save_media(data, mt)
374
+ print(f"{GREEN}[video saved: {path}]{RESET}")
375
+
376
+ case ToolUseStart(index=index, tool_use_id=tid, name=name):
377
+ if in_text:
378
+ print()
379
+ in_text = False
380
+ sys.stdout.write(f"\n{BOLD}{CYAN}\u25b6 {name}{RESET}")
381
+ sys.stdout.flush()
382
+ arg_streams[tid] = ToolArgStream(tid, index)
383
+
384
+ case ToolInputDelta(tool_use_id=tid, partial_json=pj):
385
+ stream = arg_streams.get(tid)
386
+ if stream:
387
+ for fe in stream.feed(pj):
388
+ _render_field_event(fe)
389
+ if stream.done:
390
+ sys.stdout.write("\n")
391
+ sys.stdout.flush()
392
+ del arg_streams[tid]
393
+
394
+ case ToolOutputDelta(tool_use_id=tid, key=key, delta=delta):
395
+ if tid not in streamed_tool_ids:
396
+ sys.stdout.write("\n")
397
+ streamed_tool_ids.add(tid)
398
+ color = RED if key == "stderr" else DIM
399
+ sys.stdout.write(f"{color}{delta}{RESET}")
400
+ sys.stdout.flush()
401
+
402
+ case ToolResult(tool_use_id=tid, is_error=is_error, content=content):
403
+ if is_error:
404
+ sys.stdout.write(f"{RESET}\n{RED}{content}{RESET}\n")
405
+ elif tid in streamed_tool_ids:
406
+ sys.stdout.write(f"{RESET}\n")
407
+ else:
408
+ sys.stdout.write(f"{RESET}\n{GREEN}{content}{RESET}\n")
409
+ sys.stdout.flush()
410
+
411
+ case IterationEnd():
412
+ pass
413
+
414
+ case Error(exception=exc):
415
+ print(f"\n{RED}Error: {exc}{RESET}", file=sys.stderr)
416
+
417
+ case SessionEndEvent(total_usage=usage):
418
+ if in_text:
419
+ print()
420
+ print(f"{DIM}[{usage.input_tokens}in/{usage.output_tokens}out tokens]{RESET}")
421
+
422
+
423
+ _media_counter = 0
424
+
425
+
426
+ def _save_media(data: bytes, media_type: str) -> str:
427
+ """Save media bytes to a temp file, return the path."""
428
+ import tempfile
429
+
430
+ global _media_counter
431
+ _media_counter += 1
432
+ ext = media_type.split("/")[-1].split(";")[0]
433
+ fd, path = tempfile.mkstemp(suffix=f".{ext}", prefix=f"axio_{_media_counter:03d}_")
434
+ os.write(fd, data)
435
+ os.close(fd)
436
+ return path
437
+
438
+
439
+ _field_first_delta = True
440
+
441
+
442
+ def _render_field_event(event: ToolFieldStart | ToolFieldDelta | ToolFieldEnd) -> None:
443
+ global _field_first_delta
444
+ match event:
445
+ case ToolFieldStart(key=key):
446
+ sys.stdout.write(f"\n {YELLOW}{key}{RESET}: {DIM}")
447
+ sys.stdout.flush()
448
+ _field_first_delta = True
449
+ case ToolFieldDelta(text=text):
450
+ if _field_first_delta and "\n" in text:
451
+ sys.stdout.write("\n")
452
+ _field_first_delta = False
453
+ sys.stdout.write(text)
454
+ sys.stdout.flush()
455
+ case ToolFieldEnd():
456
+ sys.stdout.write(RESET)
457
+ sys.stdout.flush()
458
+
459
+
460
+ # ── Input handling ───────────────────────────────────────────────────
461
+
462
+
463
+ def _read_input() -> str:
464
+ """Read user input, collecting extra lines from a multiline paste."""
465
+ import select
466
+
467
+ first = input("repl> ")
468
+ lines = [first]
469
+ fd = sys.stdin.fileno()
470
+ while select.select([fd], [], [], 0.05)[0]:
471
+ chunk = os.read(fd, 65536)
472
+ if not chunk:
473
+ break
474
+ extra = chunk.decode(errors="replace").splitlines()
475
+ for line in extra:
476
+ print(f" ... {line}")
477
+ lines.extend(extra)
478
+ return "\n".join(lines).strip()
479
+
480
+
481
+ # ── REPL commands ────────────────────────────────────────────────────
482
+
483
+
484
+ class Command(NamedTuple):
485
+ """A REPL command with separate show (no arg) and apply (with arg) modes."""
486
+
487
+ show: Callable[[], None]
488
+ apply: Callable[[str], None]
489
+
490
+
491
+ # CLI arg attr → slash command name (for unified init).
492
+ _CLI_TO_SLASH: dict[str, str] = {
493
+ "thinking": "/thinking",
494
+ "temperature": "/temperature",
495
+ "max_tokens": "/max-tokens",
496
+ "debug": "/debug",
497
+ }
498
+
499
+
500
+ def _apply_cli_args(args: object, commands: dict[str, Command]) -> None:
501
+ """Apply CLI arguments through the same command handlers as slash commands."""
502
+ for attr, cmd_name in _CLI_TO_SLASH.items():
503
+ val: Any = getattr(args, attr, None)
504
+ if val is None or val is False:
505
+ continue
506
+ arg = "on" if isinstance(val, bool) else val if isinstance(val, str) else str(val)
507
+ commands[cmd_name].apply(arg)
508
+
509
+
510
+ # ── model ──
511
+
512
+
513
+ def _show_model(transport: Any) -> None:
514
+ model = transport.model
515
+ caps = ", ".join(sorted(c.value for c in model.capabilities))
516
+ print(f"Current model: {BOLD}{model.id}{RESET}")
517
+ print(f"Capabilities: {caps}")
518
+ print(f"Available: {', '.join(transport.models.keys())}")
519
+
520
+
521
+ def _apply_model(
522
+ transport: Any,
523
+ agent: Agent,
524
+ tools: list[Tool[Any]],
525
+ root: Path,
526
+ agents_text: str,
527
+ arg: str,
528
+ ) -> None:
529
+ matches = transport.models.search(arg)
530
+ if len(matches) == 1:
531
+ transport.model = next(iter(matches.values()))
532
+ agent.system = build_system_prompt(root, transport.model, tools, agents_text)
533
+ print(f"Switched to {BOLD}{transport.model.id}{RESET}")
534
+ elif len(matches) == 0:
535
+ print(f"No model matching {arg!r}. Available: {', '.join(transport.models.keys())}")
536
+ else:
537
+ print(f"Ambiguous — matches: {', '.join(matches.keys())}")
538
+
539
+
540
+ # ── thinking ──
541
+
542
+
543
+ def _show_thinking(transport: Any) -> None:
544
+ level = getattr(transport, "thinking_level", None)
545
+ budget = getattr(transport, "thinking_budget", None)
546
+ get_opts = getattr(transport, "get_thinking_options", None)
547
+ valid_levels = get_opts() if get_opts else None
548
+ if level:
549
+ print(f"Thinking level: {BOLD}{level}{RESET}")
550
+ elif budget is not None:
551
+ print(f"Thinking budget: {BOLD}{budget}{RESET} tokens")
552
+ else:
553
+ print("Thinking: default")
554
+ if valid_levels is not None:
555
+ print(f"Valid levels: {', '.join(valid_levels)}")
556
+ elif get_opts is not None:
557
+ print("Usage: /thinking <budget_tokens>")
558
+
559
+
560
+ def _apply_thinking(transport: Any, arg: str) -> None:
561
+ get_opts = getattr(transport, "get_thinking_options", None)
562
+ valid_levels = get_opts() if get_opts else None
563
+ if arg.isdigit():
564
+ if valid_levels is not None:
565
+ model_id = getattr(getattr(transport, "model", None), "id", "?")
566
+ print(f"{model_id} uses thinking levels, not token budgets.")
567
+ print(f"Valid levels: {', '.join(valid_levels)}")
568
+ return
569
+ transport.thinking_budget = int(arg)
570
+ transport.thinking_level = None
571
+ print(f"Thinking budget: {BOLD}{arg}{RESET} tokens")
572
+ else:
573
+ name = arg.upper()
574
+ if valid_levels is not None and name not in valid_levels:
575
+ print(f"{name} is not valid. Valid levels: {', '.join(valid_levels)}")
576
+ return
577
+ transport.thinking_level = name
578
+ transport.thinking_budget = None
579
+ print(f"Thinking level: {BOLD}{name}{RESET}")
580
+
581
+
582
+ # ── temperature ──
583
+
584
+
585
+ def _show_temperature(transport: Any) -> None:
586
+ temp = getattr(transport, "temperature", None)
587
+ print(f"Temperature: {BOLD}{temp if temp is not None else 'default'}{RESET}")
588
+
589
+
590
+ def _apply_temperature(transport: Any, arg: str) -> None:
591
+ try:
592
+ val = float(arg)
593
+ except ValueError:
594
+ print(f"Invalid temperature: {arg!r}")
595
+ return
596
+ if hasattr(transport, "temperature"):
597
+ transport.temperature = val
598
+ print(f"Temperature: {BOLD}{val}{RESET}")
599
+ else:
600
+ print("Transport does not support temperature")
601
+
602
+
603
+ # ── iterations ──
604
+
605
+
606
+ def _show_iterations(agent: Agent) -> None:
607
+ print(f"Max iterations: {BOLD}{agent.max_iterations}{RESET}")
608
+
609
+
610
+ def _apply_iterations(agent: Agent, arg: str) -> None:
611
+ try:
612
+ val = int(arg)
613
+ except ValueError:
614
+ print(f"Invalid value: {arg!r}")
615
+ return
616
+ agent.max_iterations = val
617
+ print(f"Max iterations: {BOLD}{val}{RESET}")
618
+
619
+
620
+ # ── max-tokens ──
621
+
622
+
623
+ def _show_max_tokens(transport: Any) -> None:
624
+ cur = getattr(transport, "max_output_tokens", None)
625
+ model_default = getattr(getattr(transport, "model", None), "max_output_tokens", None)
626
+ if cur:
627
+ print(f"Max output tokens: {BOLD}{cur}{RESET} (model default: {model_default})")
628
+ else:
629
+ print(f"Max output tokens: {BOLD}{model_default}{RESET} (model default)")
630
+
631
+
632
+ def _apply_max_tokens(transport: Any, arg: str) -> None:
633
+ model_default = getattr(getattr(transport, "model", None), "max_output_tokens", None)
634
+ if arg == "default":
635
+ transport.max_output_tokens = None
636
+ print(f"Max output tokens: {BOLD}{model_default}{RESET} (model default)")
637
+ return
638
+ try:
639
+ val = int(arg)
640
+ except ValueError:
641
+ print(f"Invalid value: {arg!r}")
642
+ return
643
+ transport.max_output_tokens = val
644
+ print(f"Max output tokens: {BOLD}{val}{RESET}")
645
+
646
+
647
+ # ── debug ──
648
+
649
+
650
+ def _show_debug(transport: Any) -> None:
651
+ cur = getattr(transport, "debug", False)
652
+ print(f"Debug: {BOLD}{'on' if cur else 'off'}{RESET}")
653
+
654
+
655
+ def _apply_debug(transport: Any, arg: str) -> None:
656
+ val = arg.lower()
657
+ if val == "on":
658
+ transport.debug = True
659
+ print(f"Debug: {BOLD}on{RESET} (request/response bodies logged to stderr)")
660
+ elif val == "off":
661
+ transport.debug = False
662
+ print(f"Debug: {BOLD}off{RESET}")
663
+ else:
664
+ print("Usage: /debug on|off")
665
+
666
+
667
+ # ── Main ─────────────────────────────────────────────────────────────
668
+
669
+
670
+ async def main() -> None:
671
+ import argparse
672
+
673
+ parser = argparse.ArgumentParser(description="REPL coding assistant (axio)")
674
+ parser.add_argument("prompt", nargs="?", default=None, help="Single prompt (non-interactive)")
675
+ parser.add_argument("--transport", default=None, help="Transport name (auto-detected if omitted)")
676
+ parser.add_argument("--model", default=None, help="Model name")
677
+ parser.add_argument("--temperature", type=float, default=None)
678
+ parser.add_argument("--thinking", default=None, help="Thinking level or token budget (integer)")
679
+ parser.add_argument("--max-tokens", type=int, default=None, help="Max output tokens")
680
+ parser.add_argument("--max-iterations", type=int, default=50)
681
+ parser.add_argument("--debug", action="store_true", help="Log request/response bodies to stderr")
682
+ args = parser.parse_args()
683
+
684
+ transport_cls, _ = _select_transport(args.transport)
685
+ root = Path.cwd().resolve()
686
+ agents_text = load_agents_instructions(root)
687
+ setup_history()
688
+
689
+ async with aiohttp.ClientSession() as session:
690
+ transport = transport_cls(session=session)
691
+ await transport.fetch_models()
692
+
693
+ if args.model:
694
+ transport.model = transport.models[args.model]
695
+
696
+ # Transport-level commands (available before agent creation).
697
+ commands: dict[str, Command] = {
698
+ "/thinking": Command(lambda: _show_thinking(transport), lambda a: _apply_thinking(transport, a)),
699
+ "/temperature": Command(lambda: _show_temperature(transport), lambda a: _apply_temperature(transport, a)),
700
+ "/max-tokens": Command(lambda: _show_max_tokens(transport), lambda a: _apply_max_tokens(transport, a)),
701
+ "/debug": Command(lambda: _show_debug(transport), lambda a: _apply_debug(transport, a)),
702
+ }
703
+ _apply_cli_args(args, commands)
704
+
705
+ tools = list(TOOLS)
706
+ system = build_system_prompt(root, transport.model, tools, agents_text)
707
+ agent = Agent(
708
+ system=system,
709
+ tools=tools,
710
+ transport=transport,
711
+ max_iterations=args.max_iterations,
712
+ )
713
+ ctx = MemoryContextStore()
714
+
715
+ # Agent-dependent commands.
716
+ commands["/model"] = Command(
717
+ lambda: _show_model(transport),
718
+ lambda a: _apply_model(transport, agent, tools, root, agents_text, a),
719
+ )
720
+ commands["/iterations"] = Command(
721
+ lambda: _show_iterations(agent),
722
+ lambda a: _apply_iterations(agent, a),
723
+ )
724
+
725
+ loop = asyncio.get_event_loop()
726
+ prompt_task: asyncio.Task[None] | None = None
727
+
728
+ def _on_sigint() -> None:
729
+ nonlocal prompt_task
730
+ if prompt_task is not None and not prompt_task.done():
731
+ prompt_task.cancel()
732
+
733
+ loop.add_signal_handler(signal.SIGINT, _on_sigint)
734
+
735
+ try:
736
+ if args.prompt:
737
+ prompt_task = asyncio.create_task(run_prompt(agent, ctx, args.prompt))
738
+ try:
739
+ await prompt_task
740
+ except asyncio.CancelledError:
741
+ print(f"\n{DIM}[interrupted]{RESET}")
742
+ finally:
743
+ prompt_task = None
744
+ return
745
+
746
+ commands_list = ", ".join(["/help", *commands, "/quit"])
747
+ label = getattr(transport, "name", "unknown")
748
+ print(f"REPL ready ({label}). Commands: {commands_list}")
749
+
750
+ while True:
751
+ try:
752
+ user_input = await loop.run_in_executor(None, _read_input)
753
+ except EOFError:
754
+ print()
755
+ break
756
+
757
+ if not user_input:
758
+ continue
759
+ lowered = user_input.lower()
760
+ if lowered in {"/quit", "/exit", "/q"}:
761
+ break
762
+ if lowered == "/help":
763
+ tool_list = ", ".join(t.name for t in tools)
764
+ print(f"Type your request. Tools: {tool_list}")
765
+ print(f"Commands: {commands_list}")
766
+ continue
767
+
768
+ matched = False
769
+ for prefix, cmd in commands.items():
770
+ if lowered == prefix or lowered.startswith(prefix + " "):
771
+ arg = user_input[len(prefix) :].strip() or None
772
+ if arg is None:
773
+ cmd.show()
774
+ else:
775
+ cmd.apply(arg)
776
+ matched = True
777
+ break
778
+ if matched:
779
+ continue
780
+
781
+ prompt_task = asyncio.create_task(run_prompt(agent, ctx, user_input))
782
+ try:
783
+ await prompt_task
784
+ except asyncio.CancelledError:
785
+ print(f"\n{DIM}[interrupted]{RESET}")
786
+ finally:
787
+ prompt_task = None
788
+ finally:
789
+ loop.remove_signal_handler(signal.SIGINT)
790
+
791
+
792
+ def main_sync() -> None:
793
+ try:
794
+ asyncio.run(main())
795
+ except KeyboardInterrupt:
796
+ pass
797
+
798
+
799
+ if __name__ == "__main__":
800
+ main_sync()
@@ -0,0 +1 @@
1
+ """Shared test fixtures for axio-repl."""
@@ -0,0 +1,131 @@
1
+ """Tests for build_system_prompt: capability-aware prompt generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from axio.models import Capability, ModelSpec
9
+ from axio.tool import Tool
10
+
11
+ from axio_repl import build_system_prompt
12
+
13
+
14
+ async def _dummy_handler(x: str = "") -> str:
15
+ return ""
16
+
17
+
18
+ def _tool(name: str) -> Tool[Any]:
19
+ return Tool(name=name, description=f"{name} tool", handler=_dummy_handler)
20
+
21
+
22
+ _ROOT = Path("/tmp/test-workspace")
23
+
24
+ _CHAT_CAPS = frozenset({Capability.text, Capability.vision, Capability.tool_use})
25
+ _VISION_VIDEO_CAPS = frozenset(
26
+ {Capability.text, Capability.vision, Capability.video, Capability.tool_use, Capability.reasoning}
27
+ )
28
+ _IMAGE_GEN_CAPS = frozenset({Capability.text, Capability.vision, Capability.image_generation})
29
+ _NO_TOOLS_CAPS = frozenset({Capability.text, Capability.vision})
30
+
31
+
32
+ class TestPromptHeader:
33
+ def test_contains_model_id(self) -> None:
34
+ model = ModelSpec(id="gpt-test", capabilities=_CHAT_CAPS)
35
+ prompt = build_system_prompt(_ROOT, model, [_tool("shell")])
36
+ assert "gpt-test" in prompt
37
+
38
+ def test_contains_context_window(self) -> None:
39
+ model = ModelSpec(id="test", capabilities=_CHAT_CAPS, context_window=1_000_000)
40
+ prompt = build_system_prompt(_ROOT, model, [_tool("shell")])
41
+ assert "1000K context" in prompt
42
+
43
+ def test_contains_output_limit(self) -> None:
44
+ model = ModelSpec(id="test", capabilities=_CHAT_CAPS, max_output_tokens=65_536)
45
+ prompt = build_system_prompt(_ROOT, model, [_tool("shell")])
46
+ assert "65K max output" in prompt
47
+
48
+ def test_contains_working_directory(self) -> None:
49
+ model = ModelSpec(id="test", capabilities=_CHAT_CAPS)
50
+ prompt = build_system_prompt(_ROOT, model, [])
51
+ assert str(_ROOT) in prompt
52
+
53
+
54
+ class TestToolListing:
55
+ def test_tools_listed_when_tool_use_capable(self) -> None:
56
+ model = ModelSpec(id="test", capabilities=_CHAT_CAPS)
57
+ tools = [_tool("read_file"), _tool("shell")]
58
+ prompt = build_system_prompt(_ROOT, model, tools)
59
+ assert "Tools: read_file, shell" in prompt
60
+
61
+ def test_tools_not_listed_when_no_tool_use(self) -> None:
62
+ model = ModelSpec(id="test", capabilities=_NO_TOOLS_CAPS)
63
+ tools = [_tool("read_file"), _tool("shell")]
64
+ prompt = build_system_prompt(_ROOT, model, tools)
65
+ assert "Tools:" not in prompt
66
+
67
+
68
+ class TestCapabilityNotes:
69
+ def test_vision_note_present(self) -> None:
70
+ model = ModelSpec(id="test", capabilities=_CHAT_CAPS)
71
+ prompt = build_system_prompt(_ROOT, model, [])
72
+ assert "see images" in prompt
73
+
74
+ def test_video_note_present(self) -> None:
75
+ model = ModelSpec(id="test", capabilities=_VISION_VIDEO_CAPS)
76
+ prompt = build_system_prompt(_ROOT, model, [])
77
+ assert "video files" in prompt
78
+
79
+ def test_video_note_absent_without_capability(self) -> None:
80
+ model = ModelSpec(id="test", capabilities=_CHAT_CAPS)
81
+ prompt = build_system_prompt(_ROOT, model, [])
82
+ assert "video files" not in prompt
83
+
84
+ def test_image_generation_note(self) -> None:
85
+ model = ModelSpec(id="test", capabilities=_IMAGE_GEN_CAPS)
86
+ prompt = build_system_prompt(_ROOT, model, [])
87
+ assert "generate images inline" in prompt
88
+
89
+ def test_reasoning_note(self) -> None:
90
+ model = ModelSpec(id="test", capabilities=_VISION_VIDEO_CAPS)
91
+ prompt = build_system_prompt(_ROOT, model, [])
92
+ assert "thinking" in prompt.lower() or "reasoning" in prompt.lower()
93
+
94
+ def test_no_tool_warning_absent(self) -> None:
95
+ """Models without tool_use should NOT have a warning — tools are just omitted."""
96
+ model = ModelSpec(id="test", capabilities=_NO_TOOLS_CAPS)
97
+ prompt = build_system_prompt(_ROOT, model, [])
98
+ assert "WARNING" not in prompt
99
+ assert "cannot call tools" not in prompt
100
+
101
+
102
+ class TestToolRules:
103
+ def test_tool_rules_present_with_tool_use(self) -> None:
104
+ model = ModelSpec(id="test", capabilities=_CHAT_CAPS)
105
+ prompt = build_system_prompt(_ROOT, model, [_tool("shell")])
106
+ assert "Read files before editing" in prompt
107
+ assert "destructive shell commands" in prompt
108
+
109
+ def test_tool_rules_absent_without_tool_use(self) -> None:
110
+ model = ModelSpec(id="test", capabilities=_NO_TOOLS_CAPS)
111
+ prompt = build_system_prompt(_ROOT, model, [_tool("shell")])
112
+ assert "Read files before editing" not in prompt
113
+ assert "destructive shell commands" not in prompt
114
+
115
+ def test_base_rules_always_present(self) -> None:
116
+ model = ModelSpec(id="test", capabilities=_NO_TOOLS_CAPS)
117
+ prompt = build_system_prompt(_ROOT, model, [])
118
+ assert "Never refuse safe requests" in prompt
119
+
120
+
121
+ class TestAgentsText:
122
+ def test_agents_text_appended(self) -> None:
123
+ model = ModelSpec(id="test", capabilities=_CHAT_CAPS)
124
+ prompt = build_system_prompt(_ROOT, model, [], agents_text="Custom agent rules here")
125
+ assert "Custom agent rules here" in prompt
126
+ assert "AGENTS.md instructions:" in prompt
127
+
128
+ def test_empty_agents_text_omitted(self) -> None:
129
+ model = ModelSpec(id="test", capabilities=_CHAT_CAPS)
130
+ prompt = build_system_prompt(_ROOT, model, [])
131
+ assert "AGENTS.md" not in prompt