python-agent-harness 1.5.6.6__tar.gz → 1.5.6.8__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.
- {python_agent_harness-1.5.6.6/python_agent_harness.egg-info → python_agent_harness-1.5.6.8}/PKG-INFO +22 -3
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/README.md +21 -2
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/pyproject.toml +1 -1
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/__init__.py +1 -1
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/core/models.py +17 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/core/token_estimator.py +4 -2
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/core/tool_runner.py +13 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/entry/cli.py +18 -5
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/entry/controller.py +109 -2
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/entry/headless.py +1 -41
- python_agent_harness-1.5.6.8/python_agent_harness/hooks.py +633 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/io/text_filter.py +15 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/llm/client.py +15 -8
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/core.py +7 -3
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/session/config.py +46 -1
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/session/session.py +28 -1
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tui/commands.py +15 -6
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tui/core.py +90 -11
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tui/input.py +32 -2
- python_agent_harness-1.5.6.8/python_agent_harness/tui/mouse.py +406 -0
- python_agent_harness-1.5.6.8/python_agent_harness/tui/render.py +1215 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8/python_agent_harness.egg-info}/PKG-INFO +22 -3
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness.egg-info/SOURCES.txt +2 -0
- python_agent_harness-1.5.6.6/python_agent_harness/tui/render.py +0 -658
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/LICENSE +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/__main__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/core/__init__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/core/agent.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/core/context_manager.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/core/planmode.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/core/subagent.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/entry/__init__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/entry/restore.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/entry/server.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/entry/view.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/io/__init__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/io/attachments.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/io/diffrender.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/io/persistence.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/llm/__init__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/mcp/__init__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/mcp/client.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/mcp/config.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/mcp/manager.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/__init__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/agent.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/agents/reviewer.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/build-switch.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/commands/explain.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/compact.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/initialize.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/plan-mode.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/plan.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/review.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/subagent.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/summary.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/task-completion-rules.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/prompts/title.md +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/session/__init__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/session/commands.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/__init__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/agent_tool.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/base.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/bash.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/diffapply.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/edit.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/edit_mac.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/filesystem.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/glob.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/glob_mac.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/grep.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/grep_mac.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/mcp.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/planexit.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/question.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/read.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/skill.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/todo.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tools/write.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/tui/__init__.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness.egg-info/dependency_links.txt +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness.egg-info/entry_points.txt +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness.egg-info/requires.txt +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness.egg-info/top_level.txt +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/setup.cfg +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/tests/test_invariants.py +0 -0
- {python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/tests/test_scenarios.py +0 -0
{python_agent_harness-1.5.6.6/python_agent_harness.egg-info → python_agent_harness-1.5.6.8}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-agent-harness
|
|
3
|
-
Version: 1.5.6.
|
|
3
|
+
Version: 1.5.6.8
|
|
4
4
|
Summary: A lightweight, hackable mini-OpenCode written in Python.
|
|
5
5
|
Author-email: Huming Chen <chenhuming@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -72,7 +72,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
72
72
|
## Features
|
|
73
73
|
|
|
74
74
|
- **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early; failed tool calls are sanitized. Transient API failures (`429` / `5xx`) retry with exponential backoff. Auth-expired codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env.
|
|
75
|
-
- **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at
|
|
75
|
+
- **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 80% usage.
|
|
76
76
|
- **Image & text attachments** — attach with `@path` (e.g. `@screenshot.png`, `@README.md`): images (PNG/JPEG/GIF/WebP, up to 20 MB) become multimodal `image_url` parts; text files are inlined. Web URLs supported for images.
|
|
77
77
|
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Edit` (incl. unified diffs), `Write`, `Bash`, `Skill`, `Question`, `PlanExit`. Read-only tool rounds (`Read`, `Glob`, `Grep`, `Skill`) dispatch concurrently; async tools (`Bash`, `Agent`) can also run concurrently. Results always delivered in emitted order.
|
|
78
78
|
- **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
|
|
@@ -81,6 +81,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
81
81
|
- **MCP support** — optional via `[mcp]` extra. MCP tools become ordinary agent tools (`mcp__<server>__<tool>`). Supports `stdio`, `streamable-http`, and `sse` transports.
|
|
82
82
|
- **Slash commands** — built-in `/init`, `/review`, `/explain`, etc., plus custom commands from `prompts/commands/*.md`.
|
|
83
83
|
- **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent files live in `prompts/agents/*.md` and support `name:` and `exclude_tools:` frontmatter.
|
|
84
|
+
- **Hooks** — shell commands on `tool.before` / `tool.after` / `agent.start` / `agent.end`: block risky tool calls, feed linter output back to the model, add per-turn context, notify on completion. See [Hooks](#hooks).
|
|
84
85
|
- **Embeddable runtime boundaries** — `headless --json` for one-shot CI/scripting; `serve` for hosting apps (resident process, bidirectional protocol: multi-turn memory, mid-run Q&A, protocol-level cancel).
|
|
85
86
|
|
|
86
87
|
## Usage
|
|
@@ -140,7 +141,7 @@ Agent files are markdown (`.md`) in `prompts/agents/`. Each becomes a switchable
|
|
|
140
141
|
- `name:` — override the agent name (defaults to file stem)
|
|
141
142
|
- `exclude_tools:` — list of tool names to hide. Matches by exact name, glob (`mcp__git__*`), or `__`-delimited prefix (`mcp__git` hides `mcp__git__list_repos` but `Write` does NOT hide `TodoWrite`).
|
|
142
143
|
|
|
143
|
-
**Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`.
|
|
144
|
+
**Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`. The active agent is always shown in the status bar (`[BUILD] @reviewer ...`, `@default` for the built-in).
|
|
144
145
|
|
|
145
146
|
## Configuration
|
|
146
147
|
|
|
@@ -193,10 +194,28 @@ All LLM settings live in `~/.config/python-agent-harness/config.json`. Environme
|
|
|
193
194
|
- **`paths.context_path` / `paths.skill_path`** — locations for context files and skills. Default: project-local `contexts/` and `skills/`.
|
|
194
195
|
- **`mcp.servers`** — MCP server config (requires `[mcp]` extra). Each supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, `enabled`.
|
|
195
196
|
- **`headless` / `serve`** — optional budget limits: `max_rounds` (LLM rounds) and `timeout` (wall-clock seconds). `--max-rounds` / `--timeout` override per call; `<= 0` or `null` disables. Unset = unlimited.
|
|
197
|
+
- **`hooks`** — shell-command hooks; see [Hooks](#hooks).
|
|
196
198
|
- **Precedence** — code defaults < config file < `OPENAI_*` env vars. Sub-agents also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
|
|
197
199
|
- **Custom config** — `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG` env var.
|
|
198
200
|
- **LLM logging** — request/response bodies logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change. Path printed at startup.
|
|
199
201
|
|
|
202
|
+
### Hooks
|
|
203
|
+
|
|
204
|
+
Shell commands run at four points of a run, configured under `hooks` (`config --init` writes inactive examples under `hooks._examples`; `config` lists the active ones):
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
"hooks": {
|
|
208
|
+
"tool.before": [{ "matcher": "Bash", "command": "~/bin/guard.sh", "fail_closed": true }],
|
|
209
|
+
"tool.after": [{ "matcher": "Edit|Write", "command": "ruff check -q . >&2 || exit 2" }],
|
|
210
|
+
"agent.start": [{ "command": "git status --short" }],
|
|
211
|
+
"agent.end": [{ "command": "notify-send 'run finished'" }]
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- **Behavior** — the event arrives as JSON on stdin. Exit `2` from `tool.before` blocks the call (stderr is the reason); from `tool.after` it appends stderr to the result as feedback. `agent.start` stdout becomes turn context; `agent.end` is notify-only and gets `status` + `answer`; `tool.after` skips results discarded by `Ctrl-C`.
|
|
216
|
+
- **Keys** — `command` (required), `matcher` (regex on the full tool name), `timeout` (default 30 s), `fail_closed` (block if the hook itself fails), `env_passthrough` (secret env vars the hook may see; others are stripped as for Bash).
|
|
217
|
+
- **Safety** — failures are logged, not fed to the model (except a `fail_closed` reason), and can't crash the agent; `Ctrl-C` stops running hooks; output is capped. The payload is model-written: never `eval` it, and never print secrets (hook output reaches the model).
|
|
218
|
+
|
|
200
219
|
## Inspired by opencode
|
|
201
220
|
|
|
202
221
|
Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core behaviors have been ported. The goal: retain its practical coding-agent workflow while keeping the implementation small and easy to customize.
|
|
@@ -38,7 +38,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
38
38
|
## Features
|
|
39
39
|
|
|
40
40
|
- **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early; failed tool calls are sanitized. Transient API failures (`429` / `5xx`) retry with exponential backoff. Auth-expired codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env.
|
|
41
|
-
- **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at
|
|
41
|
+
- **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 80% usage.
|
|
42
42
|
- **Image & text attachments** — attach with `@path` (e.g. `@screenshot.png`, `@README.md`): images (PNG/JPEG/GIF/WebP, up to 20 MB) become multimodal `image_url` parts; text files are inlined. Web URLs supported for images.
|
|
43
43
|
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Edit` (incl. unified diffs), `Write`, `Bash`, `Skill`, `Question`, `PlanExit`. Read-only tool rounds (`Read`, `Glob`, `Grep`, `Skill`) dispatch concurrently; async tools (`Bash`, `Agent`) can also run concurrently. Results always delivered in emitted order.
|
|
44
44
|
- **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
|
|
@@ -47,6 +47,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
47
47
|
- **MCP support** — optional via `[mcp]` extra. MCP tools become ordinary agent tools (`mcp__<server>__<tool>`). Supports `stdio`, `streamable-http`, and `sse` transports.
|
|
48
48
|
- **Slash commands** — built-in `/init`, `/review`, `/explain`, etc., plus custom commands from `prompts/commands/*.md`.
|
|
49
49
|
- **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent files live in `prompts/agents/*.md` and support `name:` and `exclude_tools:` frontmatter.
|
|
50
|
+
- **Hooks** — shell commands on `tool.before` / `tool.after` / `agent.start` / `agent.end`: block risky tool calls, feed linter output back to the model, add per-turn context, notify on completion. See [Hooks](#hooks).
|
|
50
51
|
- **Embeddable runtime boundaries** — `headless --json` for one-shot CI/scripting; `serve` for hosting apps (resident process, bidirectional protocol: multi-turn memory, mid-run Q&A, protocol-level cancel).
|
|
51
52
|
|
|
52
53
|
## Usage
|
|
@@ -106,7 +107,7 @@ Agent files are markdown (`.md`) in `prompts/agents/`. Each becomes a switchable
|
|
|
106
107
|
- `name:` — override the agent name (defaults to file stem)
|
|
107
108
|
- `exclude_tools:` — list of tool names to hide. Matches by exact name, glob (`mcp__git__*`), or `__`-delimited prefix (`mcp__git` hides `mcp__git__list_repos` but `Write` does NOT hide `TodoWrite`).
|
|
108
109
|
|
|
109
|
-
**Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`.
|
|
110
|
+
**Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`. The active agent is always shown in the status bar (`[BUILD] @reviewer ...`, `@default` for the built-in).
|
|
110
111
|
|
|
111
112
|
## Configuration
|
|
112
113
|
|
|
@@ -159,10 +160,28 @@ All LLM settings live in `~/.config/python-agent-harness/config.json`. Environme
|
|
|
159
160
|
- **`paths.context_path` / `paths.skill_path`** — locations for context files and skills. Default: project-local `contexts/` and `skills/`.
|
|
160
161
|
- **`mcp.servers`** — MCP server config (requires `[mcp]` extra). Each supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, `enabled`.
|
|
161
162
|
- **`headless` / `serve`** — optional budget limits: `max_rounds` (LLM rounds) and `timeout` (wall-clock seconds). `--max-rounds` / `--timeout` override per call; `<= 0` or `null` disables. Unset = unlimited.
|
|
163
|
+
- **`hooks`** — shell-command hooks; see [Hooks](#hooks).
|
|
162
164
|
- **Precedence** — code defaults < config file < `OPENAI_*` env vars. Sub-agents also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
|
|
163
165
|
- **Custom config** — `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG` env var.
|
|
164
166
|
- **LLM logging** — request/response bodies logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change. Path printed at startup.
|
|
165
167
|
|
|
168
|
+
### Hooks
|
|
169
|
+
|
|
170
|
+
Shell commands run at four points of a run, configured under `hooks` (`config --init` writes inactive examples under `hooks._examples`; `config` lists the active ones):
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
"hooks": {
|
|
174
|
+
"tool.before": [{ "matcher": "Bash", "command": "~/bin/guard.sh", "fail_closed": true }],
|
|
175
|
+
"tool.after": [{ "matcher": "Edit|Write", "command": "ruff check -q . >&2 || exit 2" }],
|
|
176
|
+
"agent.start": [{ "command": "git status --short" }],
|
|
177
|
+
"agent.end": [{ "command": "notify-send 'run finished'" }]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- **Behavior** — the event arrives as JSON on stdin. Exit `2` from `tool.before` blocks the call (stderr is the reason); from `tool.after` it appends stderr to the result as feedback. `agent.start` stdout becomes turn context; `agent.end` is notify-only and gets `status` + `answer`; `tool.after` skips results discarded by `Ctrl-C`.
|
|
182
|
+
- **Keys** — `command` (required), `matcher` (regex on the full tool name), `timeout` (default 30 s), `fail_closed` (block if the hook itself fails), `env_passthrough` (secret env vars the hook may see; others are stripped as for Bash).
|
|
183
|
+
- **Safety** — failures are logged, not fed to the model (except a `fail_closed` reason), and can't crash the agent; `Ctrl-C` stops running hooks; output is capped. The payload is model-written: never `eval` it, and never print secrets (hook output reaches the model).
|
|
184
|
+
|
|
166
185
|
## Inspired by opencode
|
|
167
186
|
|
|
168
187
|
Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core behaviors have been ported. The goal: retain its practical coding-agent workflow while keeping the implementation small and easy to customize.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-agent-harness"
|
|
7
|
-
version = "1.5.6.
|
|
7
|
+
version = "1.5.6.8"
|
|
8
8
|
description = "A lightweight, hackable mini-OpenCode written in Python."
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Huming Chen", email = "chenhuming@gmail.com"}
|
{python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/core/models.py
RENAMED
|
@@ -35,6 +35,23 @@ class AgentMode(enum.Enum):
|
|
|
35
35
|
PLAN = "plan"
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def extract_reasoning(obj: dict[str, Any]) -> str:
|
|
39
|
+
"""Reasoning text from a backend response object, or "".
|
|
40
|
+
|
|
41
|
+
Backends disagree on the field name: most (DeepSeek, GLM, ...) use
|
|
42
|
+
``reasoning_content``; Qwen uses ``reasoning``. This is the single
|
|
43
|
+
place that knows the variants — callers (the LLM client's streaming
|
|
44
|
+
and non-streaming parsers, token estimation) must not open-code the
|
|
45
|
+
keys. Non-string values (some backends send a list of parts) are
|
|
46
|
+
dropped, not coerced.
|
|
47
|
+
"""
|
|
48
|
+
for key in ("reasoning_content", "reasoning"):
|
|
49
|
+
value = obj.get(key)
|
|
50
|
+
if isinstance(value, str) and value:
|
|
51
|
+
return value
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
38
55
|
@dataclass
|
|
39
56
|
class TextPart:
|
|
40
57
|
"""A plain-text content part in a multimodal message."""
|
|
@@ -12,6 +12,7 @@ import os
|
|
|
12
12
|
import re
|
|
13
13
|
|
|
14
14
|
from ..session import config
|
|
15
|
+
from .models import extract_reasoning
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def is_cjk_char(c: str) -> bool:
|
|
@@ -128,8 +129,9 @@ def payload_text(system: object, messages: list[dict], tools: list[dict]) -> str
|
|
|
128
129
|
if isinstance(v, str):
|
|
129
130
|
buf.append(v)
|
|
130
131
|
break
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
# currently unreachable: Message.to_api() never emits reasoning (and strips it from content)
|
|
133
|
+
reasoning = extract_reasoning(msg)
|
|
134
|
+
if reasoning:
|
|
133
135
|
buf.append(reasoning)
|
|
134
136
|
for tc in msg.get("tool_calls") or []:
|
|
135
137
|
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
|
|
@@ -97,11 +97,24 @@ class ToolRunner:
|
|
|
97
97
|
missing = [k for k in required if k not in args]
|
|
98
98
|
if missing:
|
|
99
99
|
return f"Error: {call.name} is missing required argument(s): {', '.join(missing)}"
|
|
100
|
+
hooks = getattr(loop.session, "hooks", None) # test doubles may lack it
|
|
101
|
+
if hooks is not None:
|
|
102
|
+
blocked = hooks.before_tool(call.name, args, call.id, not loop.top_level)
|
|
103
|
+
if isinstance(blocked, str): # not just "not None": hooks may be replaced
|
|
104
|
+
return blocked
|
|
100
105
|
return loop.session.execute_tool(call.name, args, call_id=call.id)
|
|
101
106
|
|
|
102
107
|
def deliver_tool_result(self, p: ToolCall, result: str) -> None:
|
|
103
108
|
"""Append one tool result message for call P (parent thread only)."""
|
|
104
109
|
loop = self.loop
|
|
110
|
+
# tool.after: the result is final here for sync AND async tools,
|
|
111
|
+
# and delivery is sequential in call order on the loop's thread.
|
|
112
|
+
# HookRunner never raises (its own boundary): no handling here.
|
|
113
|
+
hooks = getattr(loop.session, "hooks", None) # test doubles may lack it
|
|
114
|
+
if hooks is not None:
|
|
115
|
+
final = hooks.after_tool(p.name, p.arguments, p.id, not loop.top_level, result)
|
|
116
|
+
if isinstance(final, str): # a replaced hooks object may return anything
|
|
117
|
+
result = final
|
|
105
118
|
p.result = result
|
|
106
119
|
if hasattr(loop.session, "take_diff"):
|
|
107
120
|
p.diff = loop.session.take_diff(p.id)
|
{python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/entry/cli.py
RENAMED
|
@@ -45,6 +45,9 @@ def make_session(
|
|
|
45
45
|
settings = config.load_llm_config(config_path)
|
|
46
46
|
paths = config.load_paths_config(config_path)
|
|
47
47
|
mcp_config = config.load_mcp_config(config_path)
|
|
48
|
+
# validated before anything is built: an invalid hooks section stops
|
|
49
|
+
# startup (a configured guard must never be silently dropped)
|
|
50
|
+
hook_specs = config.load_hooks(config_path)
|
|
48
51
|
model = model or settings["model"]
|
|
49
52
|
# resolve sub-agent overrides against the EFFECTIVE main settings
|
|
50
53
|
# (so a CLI/caller model override is inherited too when the
|
|
@@ -139,13 +142,10 @@ def make_session(
|
|
|
139
142
|
llm_settings=llm_settings,
|
|
140
143
|
config_path=config_path,
|
|
141
144
|
)
|
|
145
|
+
session.install_hooks(hook_specs)
|
|
142
146
|
if default_agent and default_agent != RESERVED_AGENT_NAME:
|
|
143
147
|
ok, msg = session.switch_agent(default_agent)
|
|
144
|
-
if ok:
|
|
145
|
-
# record the applied agent so saved sessions restore it;
|
|
146
|
-
# switch_agent("default") clears it again
|
|
147
|
-
session.store.agent = default_agent
|
|
148
|
-
else:
|
|
148
|
+
if not ok:
|
|
149
149
|
# A typo'd/missing default_agent must not silently start the
|
|
150
150
|
# session with the built-in prompt — record the config error
|
|
151
151
|
# so the TUI can surface it (see Tui.run).
|
|
@@ -319,6 +319,19 @@ def cmd_config(args: argparse.Namespace) -> int:
|
|
|
319
319
|
)
|
|
320
320
|
else:
|
|
321
321
|
print('mcp: (none configured — add an "mcp" section to the config file)')
|
|
322
|
+
try:
|
|
323
|
+
hooks = config.load_hooks(args.path)
|
|
324
|
+
except ValueError as e:
|
|
325
|
+
print(f"hooks: INVALID — {e}")
|
|
326
|
+
else:
|
|
327
|
+
if hooks:
|
|
328
|
+
print("hooks:")
|
|
329
|
+
for h in hooks:
|
|
330
|
+
matcher = f" [{h.matcher.pattern}]" if h.matcher else ""
|
|
331
|
+
flags = ", fail_closed" if h.fail_closed else ""
|
|
332
|
+
print(f" {h.event}{matcher}: {h.command} (timeout={h.timeout:g}s{flags})")
|
|
333
|
+
else:
|
|
334
|
+
print('hooks: (none configured — add a "hooks" section to the config file)')
|
|
322
335
|
print(
|
|
323
336
|
"subagent_llm: (inherits main)"
|
|
324
337
|
if subagent_settings == settings
|
|
@@ -23,8 +23,11 @@ from dataclasses import dataclass
|
|
|
23
23
|
from typing import Any
|
|
24
24
|
|
|
25
25
|
from ..core.agent import run_agent_loop
|
|
26
|
-
from ..core.models import ImagePart, Message, TextPart
|
|
26
|
+
from ..core.models import ImagePart, Message, TextPart, api_safe
|
|
27
|
+
from ..hooks import RunStatus
|
|
27
28
|
from ..io.attachments import AttachmentError, parse_at_references
|
|
29
|
+
from ..io.text_filter import strip_final_check
|
|
30
|
+
from ..session import config
|
|
28
31
|
from ..session.session import Session
|
|
29
32
|
from .view import View
|
|
30
33
|
|
|
@@ -51,6 +54,45 @@ class RunHandle:
|
|
|
51
54
|
warnings: list[str]
|
|
52
55
|
|
|
53
56
|
|
|
57
|
+
def final_answer_text(session: Any) -> str:
|
|
58
|
+
"""The run's final assistant answer, TUI-filtered.
|
|
59
|
+
|
|
60
|
+
Scans the session history backwards for the last assistant message
|
|
61
|
+
whose text survives the filters (reasoning preamble and the
|
|
62
|
+
trailing [FINAL CHECK] block). Models following the
|
|
63
|
+
task-completion rules often end with a SEPARATE assistant message
|
|
64
|
+
holding only the [FINAL CHECK] block, so a check-only message
|
|
65
|
+
strips to empty and must be skipped in favor of the real answer
|
|
66
|
+
before it. Empty when no assistant message has visible text
|
|
67
|
+
(e.g. the run errored out).
|
|
68
|
+
|
|
69
|
+
The scan stops at THIS run's prompt -- the newest user message the
|
|
70
|
+
harness did not inject itself (nudges, plan reminders and mid-run
|
|
71
|
+
prompts are ``injected``; compaction re-adds the prompts as plain
|
|
72
|
+
user messages, so the run's own prompt is still the newest one).
|
|
73
|
+
History persists across runs (``serve``) and restored sessions
|
|
74
|
+
(``--restore``), so without the boundary a run that was cancelled
|
|
75
|
+
or died before answering reported the PREVIOUS run's answer as its
|
|
76
|
+
own -- and defeated serve's "run produced no answer" check.
|
|
77
|
+
"""
|
|
78
|
+
for msg in reversed(session.last_messages):
|
|
79
|
+
role = getattr(msg, "role", None)
|
|
80
|
+
if role == "user" and not getattr(msg, "injected", False):
|
|
81
|
+
break
|
|
82
|
+
if role != "assistant":
|
|
83
|
+
continue
|
|
84
|
+
text = strip_final_check(msg.text_without_reasoning()).strip()
|
|
85
|
+
if text:
|
|
86
|
+
# api_safe: the answer is written to stdout (headless prints it
|
|
87
|
+
# verbatim; --json and serve JSON-encode it), and a lone
|
|
88
|
+
# surrogate from a restored non-UTF-8 session raised
|
|
89
|
+
# UnicodeEncodeError there — headless died before writing the
|
|
90
|
+
# result line, and serve's _write_line SWALLOWED it (it is a
|
|
91
|
+
# ValueError), reporting success with an empty stream.
|
|
92
|
+
return api_safe(text)
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
54
96
|
class Controller:
|
|
55
97
|
"""Mediator between a ``Session`` (model) and a ``View``.
|
|
56
98
|
|
|
@@ -367,9 +409,23 @@ class Controller:
|
|
|
367
409
|
staleness guard; the cancel event no longer blocks the
|
|
368
410
|
adoption).
|
|
369
411
|
"""
|
|
412
|
+
hooks = getattr(self.session, "hooks", None) # test doubles may lack it
|
|
413
|
+
answer: str | None = None
|
|
414
|
+
crashed = False
|
|
370
415
|
try:
|
|
416
|
+
context = hooks.agent_start(user_msg.text()) if hooks is not None else None
|
|
417
|
+
if isinstance(context, str) and context: # hooks may be a replaced object
|
|
418
|
+
# its own injected message BEFORE the user's: the request
|
|
419
|
+
# stays the last thing the model reads (where queued
|
|
420
|
+
# prompts expect it), and title generation / compaction
|
|
421
|
+
# skip it like other harness-injected messages
|
|
422
|
+
self.conversation_history.append(
|
|
423
|
+
Message(
|
|
424
|
+
role="user", content=config.HOOK_CONTEXT_HEADER + context, injected=True
|
|
425
|
+
)
|
|
426
|
+
)
|
|
371
427
|
self.conversation_history.append(user_msg)
|
|
372
|
-
run_agent_loop(
|
|
428
|
+
answer = run_agent_loop(
|
|
373
429
|
self.session,
|
|
374
430
|
messages=list(self.conversation_history),
|
|
375
431
|
top_level=True,
|
|
@@ -381,9 +437,17 @@ class Controller:
|
|
|
381
437
|
if seq == self.session.run_generation and self.session.last_messages:
|
|
382
438
|
self.conversation_history = list(self.session.last_messages)
|
|
383
439
|
except Exception as e: # noqa: BLE001
|
|
440
|
+
crashed = True
|
|
384
441
|
if seq == self.session.run_generation:
|
|
385
442
|
self.session.log(f"agent error: {e}")
|
|
386
443
|
finally:
|
|
444
|
+
# every run that started gets its end event (a superseded
|
|
445
|
+
# one too -- the payload carries its own text). agent_end
|
|
446
|
+
# never raises and returns at once (its hooks run on their
|
|
447
|
+
# own thread); answer/status are computed inside its
|
|
448
|
+
# boundary, so run_finished/restore below always run
|
|
449
|
+
if hooks is not None:
|
|
450
|
+
hooks.agent_end(user_msg.text(), lambda: self._end_outcome(seq, answer, crashed))
|
|
387
451
|
if seq == self.session.run_generation:
|
|
388
452
|
# the run is done: the final assistant message is now
|
|
389
453
|
# part of the conversation history, so the view can drop
|
|
@@ -392,3 +456,46 @@ class Controller:
|
|
|
392
456
|
self.session.notify("run_finished")
|
|
393
457
|
if restore is not None:
|
|
394
458
|
restore()
|
|
459
|
+
|
|
460
|
+
def _end_outcome(
|
|
461
|
+
self, seq: int, raw: str | None, crashed: bool
|
|
462
|
+
) -> tuple[str | None, RunStatus]:
|
|
463
|
+
"""``(answer, status)`` for the agent.end hook."""
|
|
464
|
+
status = self._run_status(seq, raw, crashed)
|
|
465
|
+
return self._end_answer(seq, raw, status), status
|
|
466
|
+
|
|
467
|
+
def _end_answer(self, seq: int, raw: str | None, status: RunStatus) -> str | None:
|
|
468
|
+
"""The ``answer`` for agent.end: what the user was shown.
|
|
469
|
+
|
|
470
|
+
The loop's raw result can carry the reasoning preamble, or be
|
|
471
|
+
only the [FINAL CHECK] block of a supervised run, so the current
|
|
472
|
+
run's answer is extracted like headless/serve do. An ``error``
|
|
473
|
+
run reports its error text instead; a superseded run's history
|
|
474
|
+
already belongs to the next run, so only its raw result (with
|
|
475
|
+
the check block dropped) is available.
|
|
476
|
+
"""
|
|
477
|
+
if status == "error":
|
|
478
|
+
return raw
|
|
479
|
+
if status == "superseded":
|
|
480
|
+
if raw is None:
|
|
481
|
+
return None
|
|
482
|
+
return strip_final_check(raw).strip() or None
|
|
483
|
+
return final_answer_text(self.session) or None
|
|
484
|
+
|
|
485
|
+
def _run_status(self, seq: int, answer: str | None, crashed: bool) -> RunStatus:
|
|
486
|
+
"""How a run ended, for the agent.end hook (see ``hooks.RunStatus``).
|
|
487
|
+
|
|
488
|
+
Derived here rather than from the loop's FSM state, which
|
|
489
|
+
``run_agent_loop`` does not expose: superseded beats cancelled
|
|
490
|
+
(a newer run clears the cancel flag); an ``Error: ...`` result
|
|
491
|
+
is how the loop reports a failed LLM request (and how the TUI
|
|
492
|
+
recognizes failures).
|
|
493
|
+
"""
|
|
494
|
+
if seq != self.session.run_generation:
|
|
495
|
+
return "superseded"
|
|
496
|
+
cancel_event = getattr(self.session, "cancel_event", None)
|
|
497
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
498
|
+
return "cancelled"
|
|
499
|
+
if crashed or (answer is not None and answer.startswith("Error:")):
|
|
500
|
+
return "error"
|
|
501
|
+
return "done"
|
{python_agent_harness-1.5.6.6 → python_agent_harness-1.5.6.8}/python_agent_harness/entry/headless.py
RENAMED
|
@@ -23,9 +23,8 @@ import threading
|
|
|
23
23
|
from typing import Any, TextIO
|
|
24
24
|
|
|
25
25
|
from ..core.models import api_safe
|
|
26
|
-
from ..io.text_filter import strip_final_check
|
|
27
26
|
from ..session.session import Session
|
|
28
|
-
from .controller import Controller
|
|
27
|
+
from .controller import Controller, final_answer_text # noqa: F401 (re-exported)
|
|
29
28
|
from .view import FilteredDeltaStream
|
|
30
29
|
|
|
31
30
|
# Exec-stream protocol version: rides on every ``start`` and ``result``
|
|
@@ -156,45 +155,6 @@ def usage_snapshot(session: Any) -> dict[str, Any] | None:
|
|
|
156
155
|
return usage
|
|
157
156
|
|
|
158
157
|
|
|
159
|
-
def final_answer_text(session: Session) -> str:
|
|
160
|
-
"""The run's final assistant answer, TUI-filtered.
|
|
161
|
-
|
|
162
|
-
Scans the session history backwards for the last assistant message
|
|
163
|
-
whose text survives the filters (reasoning preamble and the
|
|
164
|
-
trailing [FINAL CHECK] block). Models following the
|
|
165
|
-
task-completion rules often end with a SEPARATE assistant message
|
|
166
|
-
holding only the [FINAL CHECK] block, so a check-only message
|
|
167
|
-
strips to empty and must be skipped in favor of the real answer
|
|
168
|
-
before it. Empty when no assistant message has visible text
|
|
169
|
-
(e.g. the run errored out).
|
|
170
|
-
|
|
171
|
-
The scan stops at THIS run's prompt -- the newest user message the
|
|
172
|
-
harness did not inject itself (nudges, plan reminders and mid-run
|
|
173
|
-
prompts are ``injected``; compaction re-adds the prompts as plain
|
|
174
|
-
user messages, so the run's own prompt is still the newest one).
|
|
175
|
-
History persists across runs (``serve``) and restored sessions
|
|
176
|
-
(``--restore``), so without the boundary a run that was cancelled
|
|
177
|
-
or died before answering reported the PREVIOUS run's answer as its
|
|
178
|
-
own -- and defeated serve's "run produced no answer" check.
|
|
179
|
-
"""
|
|
180
|
-
for msg in reversed(session.last_messages):
|
|
181
|
-
role = getattr(msg, "role", None)
|
|
182
|
-
if role == "user" and not getattr(msg, "injected", False):
|
|
183
|
-
break
|
|
184
|
-
if role != "assistant":
|
|
185
|
-
continue
|
|
186
|
-
text = strip_final_check(msg.text_without_reasoning()).strip()
|
|
187
|
-
if text:
|
|
188
|
-
# api_safe: the answer is written to stdout (headless prints it
|
|
189
|
-
# verbatim; --json and serve JSON-encode it), and a lone
|
|
190
|
-
# surrogate from a restored non-UTF-8 session raised
|
|
191
|
-
# UnicodeEncodeError there — headless died before writing the
|
|
192
|
-
# result line, and serve's _write_line SWALLOWED it (it is a
|
|
193
|
-
# ValueError), reporting success with an empty stream.
|
|
194
|
-
return api_safe(text)
|
|
195
|
-
return ""
|
|
196
|
-
|
|
197
|
-
|
|
198
158
|
def _restorable_handler(previous: Any) -> Any:
|
|
199
159
|
"""A handler value ``signal.signal`` will accept back.
|
|
200
160
|
|