Metadata-Version: 2.4
Name: pyagent-harness
Version: 0.13.0
Summary: A lightweight agent harness built with Textual and a configurable multi-provider backend
Author: Jacob Renn
License: MIT
Project-URL: Homepage, https://github.com/jacobrenn/pyagent
Project-URL: Repository, https://github.com/jacobrenn/pyagent
Project-URL: Bug Tracker, https://github.com/jacobrenn/pyagent/issues
Keywords: agent,tui,coding-assistant,textual,llm
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: textual
Requires-Dist: textual-serve
Requires-Dist: requests
Requires-Dist: pydantic
Requires-Dist: tabulate
Requires-Dist: pydantic-core>=2.46.4
Requires-Dist: openai>=2.14.0
Requires-Dist: uv
Provides-Extra: api
Requires-Dist: fastapi; extra == "api"
Requires-Dist: uvicorn; extra == "api"
Requires-Dist: python-multipart; extra == "api"
Dynamic: license-file

# PyAgent

PyAgent is a lightweight coding agent with a terminal UI, streaming model responses, tool use, layered project instructions, and switchable model profiles.

It is built with [Textual](https://textual.textualize.io/) and supports native Ollama chat endpoints plus both the OpenAI-compatible Chat Completions and Responses APIs.

## Quickstart

### 1. Install

```bash
pip install pyagent-harness
```

For the optional HTTP API server, install the API extra:

```bash
pip install pyagent-harness[api]
```

### 2. Configure a model

PyAgent looks for model profiles at:

```text
~/.pyagent/profiles.json
```

Create a minimal Ollama profile:

```json
{
  "default_profile": "local-qwen",
  "profiles": {
    "local-qwen": {
      "provider": "ollama",
      "base_url": "http://localhost:11434",
      "model": "qwen2.5-coder:7b"
    }
  }
}
```

Or a minimal OpenAI profile:

```json
{
  "default_profile": "openai-mini",
  "profiles": {
    "openai-mini": {
      "provider": "openai",
      "api_mode": "responses",
      "base_url": "https://api.openai.com/v1",
      "model": "gpt-4.1-mini",
      "api_key_env": "OPENAI_API_KEY"
    }
  }
}
```

Then run:

```bash
pyagent
```

You can also run one prompt and exit:

```bash
pyagent --prompt "Summarize this repository"
```

## What PyAgent does

- Provides a **streaming Textual TUI** for chat-based coding work.
- Supports **tool calling** for shell commands, file listing/search, text search, file reads/writes/appends/edits, and arithmetic.
- Supports **text-only mode** by disabling model tool calling for a session.
- Uses **Markdown rendering** for assistant and tool messages, with a plain-text fallback for fenced code blocks containing very long lines so transcript content does not get clipped.
- Loads **named model profiles** from JSON for easy switching between local and remote endpoints.
- Supports **Ollama** natively and **OpenAI-compatible** providers through the OpenAI Python SDK.
- Loads layered always-on instructions from user-global and project-local `AGENTS.md` files, with `.md` / `.skill` skills available for explicit loading.
- Supports persistent **custom tools and skills** under `~/.pyagent/`, safe from package upgrades.
- Includes optional **single-shot CLI**, **HTTP API**, **Python client**, and **browser-hosted TUI** modes.

## Installation

### Standard install

```bash
pip install pyagent-harness
```

### Install with HTTP API support

```bash
pip install pyagent-harness[api]
```

The API extra installs FastAPI and Uvicorn for `pyagent serve`.

### Developer install from a checkout

```bash
python -m pip install -e .
```

With API support:

```bash
python -m pip install -e '.[api]'
```

Non-editable local install:

```bash
python -m pip install .
```

With API support:

```bash
python -m pip install '.[api]'
```

If you only want dependencies without installing the package entry point:

```bash
pip install -r requirements.txt
```

## Model profiles

PyAgent loads named model profiles from JSON. By default it reads:

```text
~/.pyagent/profiles.json
```

Override that path with:

```bash
export PYAGENT_MODEL_PROFILES_PATH=/path/to/profiles.json
```

A sample profile file is included as [`models.example.json`](models.example.json).

### Profile file example

```json
{
  "default_profile": "local-qwen",
  "profiles": {
    "local-qwen": {
      "provider": "ollama",
      "base_url": "http://localhost:11434",
      "model": "qwen2.5-coder:7b"
    },
    "openai-gpt4": {
      "provider": "openai_compatible",
      "api_mode": "responses",
      "base_url": "https://api.openai.com/v1",
      "model": "gpt-4.1",
      "api_key_env": "OPENAI_API_KEY"
    },
    "vllm-local": {
      "provider": "vllm",
      "api_mode": "chat_completions",
      "base_url": "http://localhost:8000/v1",
      "model": "Qwen/Qwen2.5-Coder-32B-Instruct",
      "api_key_env": "VLLM_API_KEY"
    }
  }
}
```

Supported provider values:

- `ollama`
- `openai_compatible`
- `openai`
- `vllm`

`openai` and `vllm` are treated as OpenAI-compatible providers.

### OpenAI API modes

OpenAI-compatible profiles accept an `api_mode` field:

- `chat_completions` — use `/v1/chat/completions`; this is the default when the field is omitted
- `responses` — use `/v1/responses`

Both modes support streaming text and PyAgent function tools. The Responses adapter translates PyAgent's chat-shaped history and tool schemas into Responses input items, and preserves raw response output during immediate tool continuations so reasoning items are not discarded. Conversation history remains locally controlled rather than relying on `previous_response_id`. Every Responses request sets `store=false`; this is always enforced and is not profile-configurable.

Use `responses` only with an endpoint that implements the Responses API. OpenAI supports it, while support in vLLM and other OpenAI-compatible servers depends on the server and version. There is intentionally no automatic fallback to Chat Completions when a Responses request fails.

### API keys

Profiles can specify either:

- `api_key` — inline secret value
- `api_key_env` — environment variable name to read at runtime

Using `api_key_env` is recommended. Inline secrets in config files age about as well as milk.

For local OpenAI-compatible servers that do not require authentication, omit both fields.

### Extra headers and HTTP transport options

Profiles may include:

- `headers` — extra HTTP headers to send with requests
- `httpx_kwargs` — keyword arguments passed to `httpx.Client` for OpenAI-compatible providers only
- `http_kwargs` — legacy alias for `httpx_kwargs`; still accepted, but not recommended

If both `httpx_kwargs` and `http_kwargs` are present, `httpx_kwargs` wins.

Example:

```json
{
  "default_profile": "local-vllm",
  "profiles": {
    "local-vllm": {
      "provider": "vllm",
      "base_url": "https://localhost:8000/v1",
      "model": "Qwen/Qwen2.5-Coder-32B-Instruct",
      "httpx_kwargs": {
        "verify": false
      }
    }
  }
}
```

### Managing profiles from the CLI

You can now manage your model profiles directly from the CLI without editing the JSON file:

```bash
pyagent profiles list
pyagent profiles create --name my-model --provider ollama --model gemma4:latest
pyagent profiles create --name openai-responses --provider openai --model gpt-4.1-mini --api-mode responses --api-key-env OPENAI_API_KEY
```

Options for `profiles create`:
- `--base-url` / `--base_url` — endpoint base URL; defaults to the provider's standard URL
- `--api-mode` / `--api_mode` — `chat_completions` (default) or `responses`
- `--api-key-env` / `--api_key_env` — environment variable containing the API key

### Fallback profile from environment

If the profile file does not exist, PyAgent creates an implicit `default` profile from environment variables:

- `PYAGENT_PROFILE`
- `PYAGENT_PROVIDER`
- `PYAGENT_MODEL`
- `PYAGENT_BASE_URL`
- `PYAGENT_API_MODE` (`chat_completions` or `responses`)
- `PYAGENT_API_KEY`
- `PYAGENT_API_KEY_ENV`

## Running PyAgent

### Interactive TUI

```bash
pyagent
```

Or as a module:

```bash
python -m pyagent
```

Select a saved profile and optionally override its model for the current session:

```bash
pyagent --profile local-qwen
pyagent --profile openai-gpt4 --model gpt-4.1-mini
```

### Single-shot CLI

Run one prompt and exit:

```bash
pyagent --prompt "What files are in the current directory?"
```

Use a profile/model override:

```bash
pyagent --profile openai-gpt4 --prompt "Summarize README.md"
pyagent --profile openai-gpt4 --model gpt-4.1-mini --prompt "Review the current project"
```

Single-shot mode loads layered instruction context just like the TUI.

You can also load specific skills into the startup system prompt with `--skills`. Pass comma-separated scoped skill IDs (`user:<path>` or `project:<path>`). For backward compatibility, unscoped names resolve to user skills under `~/.pyagent/skills/` first:

```bash
pyagent --skills user:code-review.md,project:skills/testing.skill --prompt "Review this repository's testing strategy"
pyagent --skills code-review.md --prompt "Use my user code-review skill"
```

If any listed skill does not exist, PyAgent exits with an error. The `--skills` flag is currently supported only with `--prompt`.

### Browser-hosted TUI

PyAgent can expose the Textual app in a browser through `textual-serve`:

```bash
pyagent web
```

Optional bind and model/profile overrides:

```bash
pyagent web --host 0.0.0.0 --port 8000
pyagent web --profile local-qwen --model qwen2.5-coder:7b
```

This serves the normal `python -m pyagent` app through a small web server.

### HTTP API server

Install the API extra first:

```bash
pip install pyagent-harness[api]
```

Then start the server:

```bash
pyagent serve
```

Optional bind overrides:

```bash
pyagent serve --host 0.0.0.0 --port 8000
```

Endpoints:

- `GET /health` — basic health check
- `GET /version` — installed PyAgent package version
- `POST /run` — run a single non-streaming agent turn
- `GET /prompts` — list installed reusable system prompts
- `POST /prompts/install` — install a prompt from a multipart file upload (`file`) or JSON HTTP(S) URL (`{"url": "..."}`)
- `GET /prompts/{name}` — read an installed prompt
- `POST /prompts/{name}/use` — copy an installed prompt to the active system prompt path
- `DELETE /prompts/{name}` — remove an installed prompt
- `GET /skills?cwd=/path/to/project` — list user and project skills discoverable from a cwd
- `POST /skills/install` — install a user skill from a multipart file upload (`file`) or JSON HTTP(S) URL
- `DELETE /skills/{name}` — remove a user-managed skill
- `GET /tools` — list tool configuration, built-ins, installed tool files, discovered external tools, broken tools, disabled tools, and name collisions
- `POST /tools/install` — install a user tool from a multipart file upload (`file`) or JSON HTTP(S) URL
- `POST /tools/new` — scaffold a starter user tool (`{"name": "my_tool"}`)
- `GET /tools/{name}/path` — locate an enabled or disabled user tool script
- `POST /tools/{name}/enable` / `POST /tools/{name}/disable` — move a user tool in or out of `tools/disabled/`
- `DELETE /tools/{name}` — remove a user-managed tool
- `GET /extensions` — list enabled and disabled extensions on disk
- `POST /extensions/new` — scaffold an extension (`{"name": "demo"}`) or install one from a GitHub URL (`{"name": "demo", "url": "..."}`)
- `POST /extensions/{name}/enable` / `POST /extensions/{name}/disable` — move an extension in or out of `extensions/disabled/`
- `DELETE /extensions/{name}` — remove an extension from disk

Install endpoints intentionally accept uploaded files or HTTP(S) URLs, not arbitrary server-local source paths. `GET /tools` performs the same external-tool discovery used by PyAgent, so user tool scripts may be run with `uv run <script> describe`.

Example request:

```bash
curl -X POST http://127.0.0.1:8000/run \
  -H 'Content-Type: application/json' \
  -d '{
    "message": "Summarize README.md",
    "messages": [
      {"role": "user", "content": "We already discussed installation."}
    ],
    "profile": "local-qwen",
    "model": "qwen2.5-coder:7b",
    "cwd": ".",
    "skills": ["code-review.md"]
  }'
```

Example response:

```json
{
  "response": "...",
  "profile": "local-qwen",
  "provider": "ollama",
  "api_mode": "chat_completions",
  "model": "qwen2.5-coder:7b",
  "context_files": ["~/.pyagent/AGENTS.md", "AGENTS.md"]
}
```

The API uses the same profile selection, model override, context loading, and optional skill validation as single-shot CLI mode. Skills may be scoped IDs such as `user:code-review.md` or `project:skills/review.md`; unscoped names resolve to user skills first. You may pass prior conversation history in the optional `messages` field on `POST /run`; PyAgent preserves its own active system prompt and ignores incoming `system` messages so runtime instructions cannot be overridden by API callers.

If FastAPI or Uvicorn are missing, `pyagent serve` exits with a clear error. If an agent turn yields an internal error event, `POST /run` returns an HTTP error instead of an empty successful response.

Resource install examples:

```bash
# Upload a local prompt file
curl -X POST http://127.0.0.1:8000/prompts/install \
  -F file=@./coder.md

# Install a skill from a URL
curl -X POST http://127.0.0.1:8000/skills/install \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://example.com/review.md"}'

# Scaffold a user tool
curl -X POST http://127.0.0.1:8000/tools/new \
  -H 'Content-Type: application/json' \
  -d '{"name": "my_tool"}'
```

### Python API client

PyAgent ships with a small synchronous HTTP client for the API. It uses the Python standard library, so the client does not require the server-side `api` extra just to make requests.

```python
from pyagent.client import PyAgentClient

client = PyAgentClient("http://127.0.0.1:8000")

print(client.health())

result = client.run(
    "Summarize README.md",
    messages=[{"role": "user", "content": "We already discussed installation."}],
    profile="local-qwen",
    model="qwen2.5-coder:7b",
    cwd=".",
    skills=["code-review.md"],
)

print(result.response)
print(result.profile, result.provider, result.api_mode, result.model)
print(result.context_files)
```

Client details:

- `PyAgentClient.health()` returns the decoded `/health` JSON payload.
- `PyAgentClient.is_healthy()` returns `True` or `False` without raising on connection failures.
- `PyAgentClient.version()` returns the decoded `/version` JSON payload.
- `PyAgentClient.run(...)` returns a typed `RunResponse` object.
- Management helpers are available for prompts, skills, tools, and extensions, including `list_prompts()`, `install_prompt(url=... | file_path=...)`, `show_prompt()`, `use_prompt()`, `remove_prompt()`, `list_skills()`, `install_skill(...)`, `remove_skill()`, `list_tools()`, `install_tool(...)`, `new_tool()`, `tool_path()`, `enable_tool()`, `disable_tool()`, `remove_tool()`, `list_extensions()`, `new_extension()`, `enable_extension()`, `disable_extension()`, and `remove_extension()`.
- `PyAgentClientError` is raised for HTTP errors, invalid JSON responses, connection failures, upload-file read failures, and timeouts.
- The default base URL is `http://127.0.0.1:8000`.

## Instructions, skills, and project context

PyAgent layers always-on instruction files into the active system prompt and keeps skills discoverable for explicit loading or discovery by the agent.

Loaded first, as user-global context:

- `~/.pyagent/AGENTS.md`

Loaded next, from the current project:

- `AGENTS.md`

Available as skills, but **not loaded into the system prompt by default**:

- `~/.pyagent/skills/**/*.md`
- `~/.pyagent/skills/**/*.skill`
- `*.skill`
- `skills/**/*.md`
- `skills/**/*.skill`

Skills are plain text guidance files. Users can explicitly load skills into the system prompt with `/skills load <id-or-path>` in the TUI, `--skills` in single-shot CLI mode, or the API `skills` field.

Use `/context` in the TUI to inspect loaded instruction sources and context size. Use `/skills list` to inspect available skills. Use `/reload_context` to rescan `AGENTS.md` files and any skills explicitly loaded into the system prompt.

## Custom system prompt

PyAgent stores the base system prompt in a text file. By default:

```text
~/.pyagent/system_prompt.txt
```

On first run, PyAgent creates the file automatically if it does not already exist.

Override the location with:

```bash
export PYAGENT_SYSTEM_PROMPT_PATH="$HOME/.config/pyagent/my_prompt.txt"
pyagent
```

Or edit the default file directly:

```bash
mkdir -p ~/.pyagent
$EDITOR ~/.pyagent/system_prompt.txt
```

Manage reusable system prompts from the CLI with the plural `prompts` subcommand:

```bash
pyagent prompts install ./coder.md
pyagent prompts list
pyagent prompts show coder.md
pyagent prompts use coder.md
pyagent prompts remove coder.md
```

Installed prompt files live under `~/.pyagent/system_prompts/`. `pyagent prompts use <name>` copies the selected prompt to the active system prompt path (`~/.pyagent/system_prompt.txt` by default, or `PYAGENT_SYSTEM_PROMPT_PATH` when set) and leaves the installed copy in place.

Notes:

- `/prompt` shows the currently active system prompt in the TUI.
- The system prompt is loaded when the conversation is initialized or reset. After editing or switching it, use `/clear` to start a fresh conversation with the updated prompt.
- User and project instruction files are layered onto the base system prompt automatically.

## Tools

PyAgent has two tool layers:

1. **Built-in tools** shipped with the package.
2. **External user tools** under `~/.pyagent/tools/`.

Built-in tools include:

- `bash`
- `list_files`
- `find_files`
- `search_text`
- `read_file`
- `write_file`
- `append_file`
- `edit_file`

Tool calling is enabled by default. Disable all model tool calling for a session with:

```bash
export PYAGENT_TOOLS_ENABLED=false
```

Disable the built-in tool set while still allowing externally installed tools with:

```bash
export PYAGENT_BUILTIN_TOOLS_ENABLED=false
export PYAGENT_USER_TOOLS_ENABLED=true
```

Disable external user-tool discovery while keeping built-ins available with:

```bash
export PYAGENT_USER_TOOLS_ENABLED=false
```

Disable only the bash tool with:

```bash
export PYAGENT_BASH_ENABLED=false
```

When `PYAGENT_TOOLS_ENABLED=false`, PyAgent does not advertise tools to the model and adds a system instruction telling it not to call tools. When `PYAGENT_BUILTIN_TOOLS_ENABLED=false`, built-ins are omitted from the registry; external tools remain available if `PYAGENT_USER_TOOLS_ENABLED=true`.


### External user tools

User tools live under:

```text
~/.pyagent/tools/
```

Each user tool is a standalone Python file run through [`uv`](https://docs.astral.sh/uv/). Dependencies are declared inline using PEP 723 and installed into an isolated environment on first invocation, so adding a tool does not bloat the core PyAgent install. Miraculous, really.

Every user tool must implement two CLI subcommands:

- `<runner> run <script> describe` — print a JSON manifest with `name`, `description`, `parameters`, and optional `version`.
- `<runner> run <script> invoke --args <json>` — read JSON arguments passed inline as `<json>`, print the result to stdout, and exit non-zero with stderr on failure.

By default, `<runner>` is `uv`. Override it with `PYAGENT_TOOL_RUNNER` if needed.

Scaffold a new tool from inside the TUI:

```text
/tools new <name>
```

Or install an existing tool from the CLI:

```bash
pyagent tools install ./my_tool.py
pyagent tools install https://example.com/my_tool.py --name my_tool.py
```

Then reload tools in the TUI:

```text
/tools reload
```

### User tool skeleton

```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["click"]
# ///
import json

import click


@click.group()
def cli():
    pass


@cli.command()
def describe():
    click.echo(json.dumps({
        "name": "my_tool",
        "description": "What this tool does — sent verbatim to the model.",
        "parameters": {
            "type": "object",
            "properties": {"input": {"type": "string"}},
            "required": ["input"],
        },
        "version": "1",
    }))


@cli.command()
@click.option("--args", "args_json", required=True, help="Stringified JSON object containing the tool arguments.")
def invoke(args_json):
    args = json.loads(args_json)
    click.echo(my_logic(**args))


if __name__ == "__main__":
    cli()
```

A complete reference tool lives at [`examples/tools/search_hf_datasets.py`](examples/tools/search_hf_datasets.py). Install it with:

```bash
pyagent tools install examples/tools/search_hf_datasets.py
```

Then run `/tools reload` in PyAgent. UV installs `huggingface_hub` and `datasets` for that script on first invocation.

### External tool lifecycle

- New or changed scripts: `/tools reload` rescans the directory and rebuilds the registry.
- Schema cache: stored at `~/.pyagent/tools/.cache/manifests.json`, keyed by path, mtime, and size.
- Disable a tool: `/tools disable <name>` or `pyagent tools disable <tool_file>` moves it to `~/.pyagent/tools/disabled/`.
- Re-enable a tool: `/tools enable <name>` or `pyagent tools enable <tool_file>`.
- Locate a script: `/tools open <name>` prints its absolute path.
- Name collisions: built-ins win when built-in tools are enabled. `/tools` reports colliding external scripts so you can rename them. If `PYAGENT_BUILTIN_TOOLS_ENABLED=false`, those built-in names are no longer reserved and an external tool may register the same name.
- Broken scripts: timeout, non-zero `describe`, and malformed JSON are listed under "Broken external tools" and skipped.
- Missing `uv`: external tools are disabled at startup with a clear banner; built-ins still work unless `PYAGENT_BUILTIN_TOOLS_ENABLED=false`.

### Trust boundary

`~/.pyagent/tools/` is user-owned. PyAgent enforces wall-clock timeouts but does not otherwise sandbox these scripts. Treat any tool you install there as code you have chosen to run.

## Managing user skills, tools, and extensions from the CLI

PyAgent can install, list, and remove user-managed skills and tools under `~/.pyagent/`, and manage the on-disk lifecycle of extensions under `~/.pyagent/extensions/`.

```bash
pyagent prompts list
pyagent prompts install ./coder.md
pyagent prompts install https://example.com/coder.md --name coder.md
pyagent prompts show coder.md
pyagent prompts use coder.md
pyagent prompts remove coder.md

pyagent skills list
pyagent skills install ./review.md
pyagent skills install https://example.com/review.md --name review.md
pyagent skills remove review.md

pyagent tools list
pyagent tools install ./my_tool.py
pyagent tools install https://example.com/my_tool.py --name my_tool.py
pyagent tools disable my_tool.py
pyagent tools enable my_tool.py
pyagent tools remove my_tool.py
```

Notes:

- Prompts are installed under `~/.pyagent/system_prompts/` and must use `.txt` or `.md`.
- `pyagent prompts use <name>` copies the installed prompt to the active system prompt path; it does not remove the installed copy.
- Skills are installed under `~/.pyagent/skills/` and must use `.md` or `.skill`.
- Tools are installed under `~/.pyagent/tools/` and must use `.py`.
- `pyagent tools disable <tool_file>` moves a tool into `~/.pyagent/tools/disabled/`; `pyagent tools enable <tool_file>` moves it back. Use filenames or paths, with or without `.py`.
- CLI tool enable/disable changes disk state only; restart PyAgent or run `/tools reload` in an active TUI to apply it there.
- Use `--force` with `install` to overwrite an existing file.
- Installed tools are marked executable automatically.

### Managing extensions from the CLI

The `pyagent extensions` subcommands operate on the on-disk lifecycle of extensions — they move extension packages between `~/.pyagent/extensions/` (enabled) and `~/.pyagent/extensions/disabled/` (disabled), or delete them outright. There is no live agent session, so they do not touch the in-memory bus; use the TUI `/extension load` / `/extension unload` commands for that.

```bash
pyagent extensions list                  # show enabled and disabled extensions
pyagent extensions enable <name>         # move an extension out of disabled/ so it auto-loads on next session
pyagent extensions disable <name>        # move an extension into disabled/ so it does NOT auto-load on next session
pyagent extensions remove <name>         # permanently delete an extension (checked in both enabled and disabled/)
pyagent extensions new <name> [url]       # scaffold a new extension or install from a GitHub URL (supports subdirectories)
```

Notes:

- An extension that is **enabled** (in `~/.pyagent/extensions/<name>/`) is auto-loaded on the next session start.
- An extension that is **disabled** (in `~/.pyagent/extensions/disabled/<name>/`) is skipped at startup; its skills and tools are not discovered. Use `enable` to restore it.
- After running `enable`/`disable` from the CLI, a **running** TUI session will not see the change until you run `/extension reload` (or restart). The CLI writes to disk; it does not talk to a live session.
- `enable` only un-disables an extension that was previously `disable`d. If an extension is already enabled, `enable` reports that it was not found in the disabled directory.
- `remove` deletes the extension package or file from disk and cannot be undone. It checks both the enabled and disabled directories.

Recommended user directory layout:

```text
~/.pyagent/
├── profiles.json                    # named model profiles
├── system_prompt.txt                # active base system prompt
├── system_prompts/                  # reusable system prompts (*.txt, *.md)
├── AGENTS.md                        # optional user-global agent instructions
├── skills/                          # user-global skills (*.md, *.skill)
└── tools/                           # user tools, one UV script per tool
    ├── <my_tool>.py
    ├── disabled/                    # listed in /tools but not registered
    └── .cache/manifests.json        # automatic schema cache
```

## TUI reference

### Runtime slash commands

- `/clear` — clear the conversation
- `/help` — show command help
- `/tools` — show tool status, built-ins, external tools, and broken/disabled scripts
- `/tools on` — enable model tool calling for the current session
- `/tools off` — disable model tool calling for the current session
- `/tools reload` — rescan `~/.pyagent/tools/` and rebuild the tool registry; also available as `/reload_tools`
- `/tools new <name>` — scaffold a starter UV-script tool at `~/.pyagent/tools/<name>.py`
- `/tools enable <name>` — move a script out of `~/.pyagent/tools/disabled/`
- `/tools disable <name>` — move a script into `~/.pyagent/tools/disabled/`
- `/tools open <name>` — print the absolute path to a tool script
- `/profiles` — list saved profiles, including current/default markers and auth hints
- `/profiles reload` — reload profiles from disk
- `/reload_profiles` — reload profiles from disk
- `/profile` — show the active profile
- `/profile <name>` — switch to a saved profile
- `/profile add <name> provider=<provider> model=<model> [api_mode=chat_completions|responses] [base_url=<url>] [api_key_env=<ENV>] [api_key=<KEY>] [default=true|false] [switch=true|false] [header.<Name>=<Value>]` — create or update a profile from the TUI
- `/model` — show the active model
- `/model list` — ask the current endpoint for available models, if supported
- `/model <name>` — override the current profile's model for this session
- `/status` — show current configuration, including the agent tool-loop max-iteration setting
- `/max_iterations <n|-1>` — set the maximum tool-loop iterations for the current session; `-1` means infinite
- `/cwd` — show current working directory
- `/history` — show recent prompt history
- `/history search <text>` — search saved prompt history for matching prompts
- `/context` — show loaded instruction sources and context size
- `/skills list` — show available user and project skills plus session load state
- `/skills load <id-or-path>` — load a user or project skill into the system prompt for this session
- `/skills unload <id-or-path>` — unload a previously loaded skill from the system prompt
- `/prompt` — show the active system prompt
- `/reload_context` — reload user-global/project `AGENTS.md` files and explicitly loaded skills, reporting added/removed files
- `/extension list` (or `/extensions list`) — show extensions in `~/.pyagent/extensions/`, including disabled ones
- `/extension reload` — re-scan and reload all extensions
- `/extension new <name>` — scaffold a starter extension
- `/extension load <name>` — move (if disabled) into enabled/ and load into the bus this session
- `/extension unload <name>` — remove from the bus this session and move into `disabled/` so it stays unloaded across restarts
- `/extension enable <name>` — move an extension out of `disabled/` so it auto-loads next session (does not load into the running bus)
- `/extension disable <name>` — move an extension into `disabled/` so it does not auto-load next session (unloads it from the running bus if loaded)
- `/extension remove <name>` — permanently delete an extension from disk (checked in both enabled and disabled/)
- `/logging on|off` — enable or disable session logging under `~/.pyagent/logs/`
- `/debug` — show whether the debug pane is currently on or off
- `/debug on|off` — show or hide the debug pane

Changing tool mode at runtime resets the current conversation so the updated system prompt is applied cleanly.

Unknown slash commands may suggest a close match. For example, `/stats` may suggest `/status`.

### Profile creation from the TUI

Create or update profiles with `/profile add`. Quote values containing spaces.

```text
/profile add local-14b provider=ollama model=qwen2.5-coder:14b switch=true
/profile add openai-mini provider=openai model=gpt-4.1-mini api_mode=responses api_key_env=OPENAI_API_KEY default=true
/profile add vllm-qwen provider=vllm model="Qwen/Qwen2.5-Coder-32B-Instruct" api_mode=chat_completions base_url=http://localhost:8000/v1 api_key_env=VLLM_API_KEY header.X-Project=PyAgent
```

### Keyboard shortcuts

- `Enter` — send the current prompt
- `Shift+Enter` — insert a newline in the prompt box
- `Ctrl+P` / `Ctrl+N` — move through prompt history
- `↑` / `↓` — scroll the chat transcript
- `PgUp` / `PgDn` — page through the chat transcript
- `Home` / `End` — jump to the top or bottom of the chat transcript
- `Ctrl+L` — clear the conversation
- `Ctrl+D` — toggle the debug pane
- `Ctrl+C` — quit the app

## Configuration reference

### Core environment variables

- `PYAGENT_PROFILE` — default profile name to select
- `PYAGENT_MODEL_PROFILES_PATH` — path to the JSON profile file, overriding `~/.pyagent/profiles.json`
- `PYAGENT_SYSTEM_PROMPT_PATH` — path to the system prompt text file, overriding `~/.pyagent/system_prompt.txt`
- `PYAGENT_REQUEST_TIMEOUT` — request timeout in seconds
- `PYAGENT_MAX_ITERATIONS` — maximum tool loop iterations per user turn; `-1` means infinite
- `PYAGENT_MAX_HISTORY_MESSAGES` — number of recent non-system messages to keep
- `PYAGENT_MAX_PREVIOUS_TOOL_RESULT_CHARS` — maximum characters to send for older tool-result messages; the current trailing tool-result block is kept intact, and `0` or `-1` disables masking
- `PYAGENT_STREAM_BATCH_INTERVAL` — UI flush interval in seconds
- `PYAGENT_USER_DIR` — root for user-managed prompts, tools, skills, logs, and user-global `AGENTS.md`; default `~/.pyagent`. Model profiles use `PYAGENT_MODEL_PROFILES_PATH`.

### Tool environment variables

- `PYAGENT_TOOLS_ENABLED` — enable or disable all model tool calling for the session; default `true`
- `PYAGENT_BUILTIN_TOOLS_ENABLED` — register built-in tools (`bash`, file tools, search/edit tools); default `true`
- `PYAGENT_BASH_ENABLED` — enable or disable bash execution; default `true` (when `false`, the bash tool remains registered but returns a disabled-by-configuration error)
- `PYAGENT_BASH_READONLY_MODE` — restrict bash to read-only command prefixes
- `PYAGENT_BASH_TIMEOUT_DEFAULT` — default bash timeout in seconds
- `PYAGENT_BASH_BLOCKED_SUBSTRINGS` — comma-separated dangerous bash fragments to block
- `PYAGENT_BASH_READONLY_PREFIXES` — comma-separated allowed prefixes in read-only mode
- `PYAGENT_USER_TOOLS_ENABLED` — discover and register external tools under `~/.pyagent/tools/`; default `true`
- `PYAGENT_USER_TOOL_TIMEOUT` — wall-clock timeout in seconds for each external tool invocation; default `60`
- `PYAGENT_USER_TOOL_DESCRIBE_TIMEOUT` — wall-clock timeout for the `describe` schema fetch; default `10`
- `PYAGENT_TOOL_RUNNER` — executable used to run external tools; default `uv`

These tool environment variables apply to the TUI, single-shot CLI mode, and agents created by the HTTP API server process.

### Fallback profile environment variables

Used when no profile file exists:

- `PYAGENT_PROVIDER`
- `PYAGENT_MODEL`
- `PYAGENT_BASE_URL`
- `PYAGENT_API_MODE` — `chat_completions` (default) or `responses`
- `PYAGENT_API_KEY`
- `PYAGENT_API_KEY_ENV`

### Profile JSON fields

- `provider` — provider type: `ollama`, `openai_compatible`, `openai`, or `vllm`
- `base_url` — endpoint base URL
- `model` — model name
- `api_mode` — OpenAI API mode: `chat_completions` (default) or `responses`; only `chat_completions` is valid for native Ollama profiles
- `api_key` — inline API key, if needed
- `api_key_env` — environment variable containing the API key
- `headers` — optional object of extra HTTP headers
- `httpx_kwargs` — optional object of keyword arguments passed to `httpx.Client` for OpenAI-compatible profiles only
- `http_kwargs` — legacy alias for `httpx_kwargs`

## Extensions

Extensions are Python packages that observe and modify the agent loop through a **synchronous event bus**: they subscribe to lifecycle events, run deterministic logic (e.g. safeguards), and **inject skills into the system prompt for one turn only**. Extensions live under `~/.pyagent/extensions/` and are managed with the `/extension` command.

Each extension is a **self-contained package directory** that colocates its script, skills, and tools:

```
~/.pyagent/extensions/<name>/
├── __init__.py        # the extension script (register(bus, name))
├── skills/            # *.md skills injected via ctx.add_skill("<key>")
└── tools/             # *.py UV-script tools, discovered only while loaded
```

A bare `~/.pyagent/extensions/<name>.py` file is also accepted (backward compatible), but only a package directory can carry colocated `skills/` and `tools/`.

Extensions are intentionally minimal and one-directional: PyAgent emits events and honors skill text; extensions subscribe and decide what to do. An extension's skills and tools are **only discoverable while the extension is loaded** — unloading (or not loading) an extension hides its skills and tools again. `/extension new <name>` scaffolds a starter package with empty `skills/` and `tools/` dirs.

### What an extension can do

- **Handle lifecycle events** — observe or intercept `input`, `before_agent_start`, `turn_start`, `context`, `message_start`, `message_end`, `tool_call` (block or rewrite), `tool_result` (filter or redact), `turn_end`, `agent_end`, `model_select`, `session_start`, `session_shutdown`.
- **Inject a skill for one turn** via `ctx.add_skill(key)` — the skill's Markdown (under `~/.pyagent/extensions/<name>/skills/<key>.md`) is appended to the system prompt **for the turn in which it is declared** (when declared at `turn_start`/`before_agent_start`/`input`) or **next turn** (when declared at `turn_end`), and auto-expunged after. The skill is resolved against the *declaring* extension's `skills/` dir, so it is only reachable while that extension is loaded. Lean by default, ephemeral by design. (The legacy `~/.pyagent/skills/extensions/<key>.md` location is still read as a fallback for existing setups.)
- **Run deterministic logic** — safeguards, argument rewrites, result redaction. All logic lives in the extension file itself; no network, no state external to the turn.

### The event catalog

Events are emitted synchronously from the agent loop; handlers are called in load order. A handler returns a dict to mutate behavior. For veto fields (`blocked`, `cancel`), the first handler to set a truthy value short-circuits; for other fields the last writer wins. A handler that raises is isolated — the bus logs the fault and continues, so one broken extension never halts the loop. Handlers are **synchronous only**; an accidental `async def` return is closed and ignored.

| Event | Payload | Notable return fields |
|-------|---------|----------------------|
| `input` | `{text, source}` | `action: "continue"\|"transform"\|"handled"`, `text` |
| `before_agent_start` | `{prompt, system_prompt}` | `system_prompt` (replace; append to `event["system_prompt"]` to keep the base prompt) |
| `agent_start` | `{}` | — |
| `turn_start` | `{turn_index, timestamp}` | — |
| `context` | `{messages}` (deepcopy) | `messages` (prune/redact the request only; stored history untouched) |
| `message_start` | `{message: {role: "assistant"}}` | — |
| `message_end` | `{message}` | `message` (replace the finalized assistant message) |
| `tool_call` | `{tool_call_id, name, input}` | `blocked: bool`, `reason`, `input` (mutate arguments) |
| `tool_result` | `{tool_call_id, name, input, content, details, is_error}` | `content`, `is_error` (filter/redact) |
| `turn_end` | `{turn_index, message, message_count, tool_results}` | — |
| `agent_end` | `{messages}` | — |
| `model_select` | `{model, previous_model, source}` | — |
| `session_start` | `{reason}` | — |
| `session_shutdown` | `{reason}` | — |

The `context` event's `messages` is a deep copy (only allocated when a handler exists); mutating it affects only the LLM request, never the stored conversation.

### Writing an extension

An extension is a package (`~/.pyagent/extensions/<name>/__init__.py`) exposing a module-level `register(bus, name)`. Subscribe with `@bus.on(...)` — the scoped `bus` auto-tags your handlers so `/extension unload <name>` can remove them. Return a dict to mutate the payload, or `None` to pass through. Drop skill Markdown into `<name>/skills/` and UV-script tools into `<name>/tools/`; both are discovered only while the extension is loaded.

```python
# ~/.pyagent/extensions/safeguard/__init__.py
def register(bus, name):
    @bus.on("tool_call")
    def on_tool_call(payload, ctx):
        # Veto key: first truthy value short-circuits.
        if payload["name"] == "bash" and "rm -rf" in payload["input"].get("command", ""):
            return {"blocked": True, "reason": f"{name} blocks destructive commands"}
        return None

    @bus.on("turn_end")
    def on_turn_end(payload, ctx):
        # Inject a skill next turn once the conversation grows. The skill text
        # is read from ~/.pyagent/extensions/safeguard/skills/guide.md; it is
        # injected for one turn only and auto-expunged. Re-declare each turn
        # to persist.
        if payload.get("message_count", 0) > 20:
            ctx.add_skill("guide")
```

**Context (`ctx`) surface:** `ctx.extension` (this extension's name), `ctx.add_skill(key)` (declare a skill — this turn if called from `input`/`before_agent_start`/`turn_start`, otherwise next turn), `ctx.log` (the active `SessionLogger`, or a no-op when logging is off). That is the entire surface — there is no `ctx.agent`, `ctx.config`, `ctx.ui`, or `ctx.bus`.

### How skills are injected (and removed)

Extension skills live under `~/.pyagent/extensions/<name>/skills/<key>.md` and are **hidden** from normal skill discovery (`list_skills` never lists them). The flow:

1. During an event, a handler calls `ctx.add_skill(key)`.
2. The intent is routed based on *when* it is declared:
   - Declared at `input`, `before_agent_start`, or `turn_start` → injected into the system prompt **this turn** (the prompt is recompiled after `turn_start` fires, so the skill reaches the LLM in the same turn — this is the natural place to declare an always-on skill like a coder assistant).
   - Declared at `turn_end` (or any later event) → applied **next turn** (one-turn lag, rotated in at the top of the next turn).
3. The agent appends each declared skill's Markdown to the system prompt (total budget `MAX_EXTENSION_SKILLS_CHARS = 15_000`, truncated if exceeded; missing/unreadable files are skipped).
4. At the end of the turn the injection is **wiped** — to keep the skill every turn, the extension re-declares it each turn (e.g. at every `turn_start`).

The system prompt is split into a stable **base** (file content + tool notes + project context, plus any `before_agent_start` rewrite) and an **extension-skill suffix** recomputed from the active skill set. The suffix is re-injected after every `turn_start` without clobbering the base.

A `before_agent_start` handler that returns `system_prompt` **replaces** the base prompt (extension skills are still appended after); to *augment*, read `payload["system_prompt"]` and append.

### Managing extensions

```
/extension list                       # show extensions in ~/.pyagent/extensions/
/extension reload                     # re-scan and reload all extensions
/extension new <name>                 # scaffold a starter extension
/extension load <name>                # move (if disabled) into enabled/ and load into the bus this session
/extension unload <name>              # remove from the bus this session AND move into disabled/ (stays unloaded across restarts)
/extension enable <name>             # move an extension out of disabled/ so it auto-loads next session (does not load into the running bus)
/extension disable <name>            # move an extension into disabled/ so it does NOT auto-load next session (unloads it from the running bus if loaded)
/extension remove <name>              # permanently delete an extension from disk (checked in both enabled and disabled/)
```

`/extension` is also accepted as `/extensions`.

- `load`/`unload` operate on the in-memory bus (no manifests) and also rebuild the external-tool registry so a loaded extension's colocated `tools/` becomes discoverable (and an unloaded extension's tools disappear).
- `unload` persists: it moves the extension into `disabled/` and removes it from the bus, so it will **not** be re-loaded on the next session until you run `/extension load <name>` again. Use `unload` when you want "off now, off next time" in one step.
- `disable` is `unload` minus nothing — it moves the extension to `disabled/` and, if it was loaded in the current session, detaches it from the bus. It will not auto-load next session. Use `disable` when you only care about startup behavior, not the current bus.
- `enable` is the inverse of `disable`: it moves the extension back out of `disabled/` so it will auto-load on the next session. It does **not** load the extension into the current running bus — run `/extension load <name>` (or `/extension reload`) afterward to load it now.
- `reload` clears all handlers and re-scans the directory (idempotent: no double-subscribe). A failing extension is logged and skipped at load — it never blocks startup.

The `~/.pyagent/` layout:

```
~/.pyagent/
├── extensions/                 # one package directory per extension
│   └── safeguard/
│       ├── __init__.py         # register(bus, name)
│       ├── skills/             # *.md (hidden from list_skills; loaded-gated)
│       │   └── guide.md
│       └── tools/              # *.py UV-script tools (loaded-gated)
│           └── helper.py
├── skills/                     # user-global skills (always discoverable)
└── tools/                      # global UV-script tools (always discoverable)
```

### Reference examples

Two examples ship under [`examples/extensions/`](examples/extensions/):

- **[`examples/extensions/template/`](examples/extensions/template/)** — a copy-paste starter showing an event handler (safeguard) and a one-turn skill injection.
- **[`examples/extensions/compaction/`](examples/extensions/compaction/)** — a deterministic reference: a `turn_end` handler injects the `compaction` skill once the conversation grows past a soft threshold. The skill (`skills/compaction.md`) tells the LLM to use the `compact_context` tool (a deterministic condenser; reference script at [`examples/tools/compact_context.py`](examples/tools/compact_context.py)) to compact older context. Copy the extension package to `~/.pyagent/extensions/compaction/`, drop the tool into its `tools/` dir, then `/extension reload`.

Each example has a `__main__` self-check (`PYTHONPATH=. python examples/extensions/template/__init__.py`).

### Logging and fault isolation

Extensions log through `ctx.log` (the active `SessionLogger`, or a no-op when logging is off); do **not** call `logging.getLogger(...)`. Bus events and handler faults are logged automatically. A handler that raises is caught per-emit, logged with the event/handler/extension/traceback, and skipped — the payload is unaffected and the loop continues.

## Development

Quick smoke test:

```bash
python test_agent.py
```

For non-trivial changes, run:

```bash
python -m py_compile pyagent/*.py test_agent.py
python -m unittest -v
```
