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.
- axio_repl-0.9.2/.gitignore +14 -0
- axio_repl-0.9.2/PKG-INFO +182 -0
- axio_repl-0.9.2/README.md +166 -0
- axio_repl-0.9.2/pyproject.toml +49 -0
- axio_repl-0.9.2/src/axio_repl/__init__.py +800 -0
- axio_repl-0.9.2/tests/conftest.py +1 -0
- axio_repl-0.9.2/tests/test_system_prompt.py +131 -0
axio_repl-0.9.2/PKG-INFO
ADDED
|
@@ -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
|