python-agent-harness 1.5.5.7__tar.gz → 1.5.5.9__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.5.7/python_agent_harness.egg-info → python_agent_harness-1.5.5.9}/PKG-INFO +57 -30
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/README.md +56 -29
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/pyproject.toml +10 -6
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/__init__.py +7 -4
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/__main__.py +1 -1
- python_agent_harness-1.5.5.9/python_agent_harness/core/__init__.py +1 -0
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/core}/agent.py +7 -4
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/core}/context_manager.py +3 -3
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/core}/models.py +75 -3
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/core}/planmode.py +1 -1
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/core}/subagent.py +15 -5
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/core}/token_estimator.py +34 -2
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/core}/tool_runner.py +2 -2
- python_agent_harness-1.5.5.9/python_agent_harness/entry/__init__.py +1 -0
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/entry}/cli.py +107 -21
- python_agent_harness-1.5.5.9/python_agent_harness/entry/controller.py +359 -0
- python_agent_harness-1.5.5.9/python_agent_harness/entry/headless.py +350 -0
- python_agent_harness-1.5.5.9/python_agent_harness/entry/view.py +51 -0
- python_agent_harness-1.5.5.9/python_agent_harness/io/__init__.py +1 -0
- python_agent_harness-1.5.5.9/python_agent_harness/io/attachments.py +377 -0
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/io}/diffrender.py +22 -1
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/io}/persistence.py +98 -1
- python_agent_harness-1.5.5.9/python_agent_harness/io/text_filter.py +84 -0
- python_agent_harness-1.5.5.9/python_agent_harness/llm/__init__.py +1 -0
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/llm}/client.py +103 -5
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/lsp/manager.py +1 -1
- python_agent_harness-1.5.5.9/python_agent_harness/prompts/__init__.py +41 -0
- python_agent_harness-1.5.5.9/python_agent_harness/prompts/agent.md +114 -0
- python_agent_harness-1.5.5.7/python_agent_harness/prompts.py → python_agent_harness-1.5.5.9/python_agent_harness/prompts/core.py +108 -10
- python_agent_harness-1.5.5.9/python_agent_harness/prompts/subagent.md +56 -0
- python_agent_harness-1.5.5.9/python_agent_harness/session/__init__.py +33 -0
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/session}/commands.py +3 -3
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/session}/config.py +11 -5
- {python_agent_harness-1.5.5.7/python_agent_harness → python_agent_harness-1.5.5.9/python_agent_harness/session}/session.py +141 -60
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/agent_tool.py +45 -7
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/base.py +22 -4
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/bash.py +34 -2
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/bash_win.py +2 -2
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/diffapply.py +71 -14
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/edit.py +76 -11
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/edit_mac.py +1 -1
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/edit_win.py +1 -1
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/filesystem.py +87 -1
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/glob.py +21 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/glob_mac.py +1 -0
- python_agent_harness-1.5.5.9/python_agent_harness/tools/glob_win.py +104 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/grep.py +22 -1
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/grep_mac.py +1 -1
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/grep_win.py +10 -16
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/insert.py +29 -4
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/lsp.py +23 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/mkdir.py +1 -1
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/question.py +19 -1
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/read.py +21 -1
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/skill.py +1 -0
- python_agent_harness-1.5.5.9/python_agent_harness/tools/todo.py +110 -0
- python_agent_harness-1.5.5.9/python_agent_harness/tools/write.py +91 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/__init__.py +12 -4
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/commands.py +139 -145
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/core.py +95 -83
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/input.py +91 -23
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/render.py +64 -94
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9/python_agent_harness.egg-info}/PKG-INFO +57 -30
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness.egg-info/SOURCES.txt +27 -38
- python_agent_harness-1.5.5.9/python_agent_harness.egg-info/entry_points.txt +2 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/tests/test_invariants.py +114 -49
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/tests/test_scenarios.py +26 -20
- python_agent_harness-1.5.5.7/python_agent_harness/prompts/agent.md +0 -385
- python_agent_harness-1.5.5.7/python_agent_harness/prompts/subagent.md +0 -231
- python_agent_harness-1.5.5.7/python_agent_harness/tools/glob_win.py +0 -90
- python_agent_harness-1.5.5.7/python_agent_harness/tools/todo.py +0 -60
- python_agent_harness-1.5.5.7/python_agent_harness/tools/write.py +0 -58
- python_agent_harness-1.5.5.7/python_agent_harness.egg-info/entry_points.txt +0 -2
- python_agent_harness-1.5.5.7/tests/test_agents_md.py +0 -260
- python_agent_harness-1.5.5.7/tests/test_cli.py +0 -483
- python_agent_harness-1.5.5.7/tests/test_client.py +0 -1952
- python_agent_harness-1.5.5.7/tests/test_commands.py +0 -119
- python_agent_harness-1.5.5.7/tests/test_concurrent_subagents.py +0 -384
- python_agent_harness-1.5.5.7/tests/test_config.py +0 -745
- python_agent_harness-1.5.5.7/tests/test_context_rules.py +0 -133
- python_agent_harness-1.5.5.7/tests/test_custom_agents.py +0 -629
- python_agent_harness-1.5.5.7/tests/test_diffrender.py +0 -53
- python_agent_harness-1.5.5.7/tests/test_filesystem.py +0 -2004
- python_agent_harness-1.5.5.7/tests/test_lsp.py +0 -984
- python_agent_harness-1.5.5.7/tests/test_mcp.py +0 -826
- python_agent_harness-1.5.5.7/tests/test_models.py +0 -130
- python_agent_harness-1.5.5.7/tests/test_persistence.py +0 -357
- python_agent_harness-1.5.5.7/tests/test_planmode.py +0 -243
- python_agent_harness-1.5.5.7/tests/test_prompts.py +0 -444
- python_agent_harness-1.5.5.7/tests/test_session.py +0 -1097
- python_agent_harness-1.5.5.7/tests/test_subagent.py +0 -169
- python_agent_harness-1.5.5.7/tests/test_subagent_isolation.py +0 -459
- python_agent_harness-1.5.5.7/tests/test_todos_scope.py +0 -117
- python_agent_harness-1.5.5.7/tests/test_token_estimator.py +0 -148
- python_agent_harness-1.5.5.7/tests/test_tools_misc.py +0 -563
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/LICENSE +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/lsp/__init__.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/lsp/client.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/lsp/config.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/mcp/__init__.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/mcp/client.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/mcp/config.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/mcp/manager.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/agents/reviewer.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/build-switch.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/commands/explain.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/compact.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/initialize.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/plan-mode.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/plan.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/review.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/summary.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/task-completion-rules.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/title.md +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/__init__.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/mcp.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/planexit.py +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness.egg-info/dependency_links.txt +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness.egg-info/requires.txt +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness.egg-info/top_level.txt +0 -0
- {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/setup.cfg +0 -0
{python_agent_harness-1.5.5.7/python_agent_harness.egg-info → python_agent_harness-1.5.5.9}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-agent-harness
|
|
3
|
-
Version: 1.5.5.
|
|
3
|
+
Version: 1.5.5.9
|
|
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
|
|
@@ -105,6 +105,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
105
105
|
|
|
106
106
|
- **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early, while failed tool calls are sanitized so they never strand the agent. Transient API failures (`429` / `5xx`) retry with exponential backoff and jitter. Auth-expired status codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env — some API gateways return `502` instead of `401` when the backend auth token has expired. Note: codes in this list are treated as auth-expired exclusively and will not be retried with backoff, so only include codes that are unambiguously auth-related in your environment.
|
|
107
107
|
- **Context management** — CJK-aware token estimation, per-model context windows, and automatic compaction at 70% usage.
|
|
108
|
+
- **Image & text attachments** — attach an image or a text file with `@path` in your message (e.g. `@screenshot.png`, `@README.md`): images (PNG/JPEG/GIF/WebP, up to 20 MB, magic-byte validated) become multimodal `image_url` parts, text files are inlined as text. Only images and text files are supported as input. Path supports web-url for image as well.
|
|
108
109
|
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, `LSP`, and `PlanExit`. Synchronous tools execute sequentially, but a round made up entirely of read-only tools (`Read`, `Glob`, `Grep`, `Skill`, `LSP`) is dispatched concurrently via a bounded thread pool; asynchronous tools such as `Bash` and `Agent` can run concurrently as well. Results are always delivered in the model's emitted order.
|
|
109
110
|
- **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
|
|
110
111
|
- **Persistent sessions** — sessions are automatically saved after every response to `~/.local/share/python-agent-harness/sessions/`, with LLM-generated titles and support for `/restore --latest` and `/sessions`.
|
|
@@ -145,7 +146,8 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
145
146
|
"api_key": "sk-...",
|
|
146
147
|
"model": "gpt-5-mini",
|
|
147
148
|
"reasoning_effort": null,
|
|
148
|
-
"stream": true
|
|
149
|
+
"stream": true,
|
|
150
|
+
"supports_image_input": false
|
|
149
151
|
},
|
|
150
152
|
"models": {
|
|
151
153
|
"_comment": "Named LLM profiles for /model switching. Partial settings; unset keys inherit the main llm.",
|
|
@@ -171,7 +173,8 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
171
173
|
"max_tokens": null,
|
|
172
174
|
"timeout": null,
|
|
173
175
|
"reasoning_effort": null,
|
|
174
|
-
"stream": null
|
|
176
|
+
"stream": null,
|
|
177
|
+
"supports_image_input": null
|
|
175
178
|
},
|
|
176
179
|
"default_agent": null,
|
|
177
180
|
"paths": {
|
|
@@ -210,7 +213,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
210
213
|
|
|
211
214
|
### Configuration options
|
|
212
215
|
|
|
213
|
-
- **`llm`** — main LLM configuration. Optional keys include `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, and `
|
|
216
|
+
- **`llm`** — main LLM configuration. Optional keys include `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, `stream`, and `supports_image_input`. Values such as `reasoning_effort` are passed to the API as-is when set. `run --no-stream` overrides `stream`. `supports_image_input` (default `false`) controls whether `@path` image attachments are sent to the model or stripped with a warning.
|
|
214
217
|
- **`models`** — named LLM profiles for runtime switching with `/model`. A profile is a partial settings dictionary; unset keys inherit from the main `llm`. `default` restores the main LLM configuration.
|
|
215
218
|
- **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or substrings (e.g., `deepseek-v4`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
|
|
216
219
|
- **`subagent_llm`** — LLM configuration for `Agent` tool requests. Unset values inherit from the main `llm`. Set `profile` to reuse a profile from `models`. Precedence is: profile settings > explicit `subagent_llm` settings > main `llm` > environment variables.
|
|
@@ -225,10 +228,58 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
225
228
|
## Usage
|
|
226
229
|
|
|
227
230
|
```sh
|
|
228
|
-
python-agent-harness run [project
|
|
231
|
+
python-agent-harness run [--project DIR]
|
|
229
232
|
```
|
|
230
233
|
|
|
231
|
-
Launches the interactive TUI agent. If
|
|
234
|
+
Launches the interactive TUI agent. If `--project` is omitted, the current directory is used.
|
|
235
|
+
|
|
236
|
+
### Headless mode
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
python-agent-harness headless [prompt] [--project DIR] [--restore [SPEC]] [--model NAME]
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Runs a single prompt without the TUI — for CI, scripting, and piping.
|
|
243
|
+
The assistant's answer is written to stdout once the run completes;
|
|
244
|
+
tool/status events go to stderr, so the answer stream stays clean.
|
|
245
|
+
Interactive prompts are auto-answered (`confirm` → yes, `ask` →
|
|
246
|
+
"Unanswered"), so a run never blocks.
|
|
247
|
+
|
|
248
|
+
- The prompt is a positional argument; when omitted it is read from stdin:
|
|
249
|
+
|
|
250
|
+
```sh
|
|
251
|
+
python-agent-harness headless "fix the failing test"
|
|
252
|
+
echo "fix the failing test" | python-agent-harness headless
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
- `--restore` continues a saved session (the conversation auto-saves after
|
|
256
|
+
each response). Bare `--restore` uses the most recent session; a SPEC may
|
|
257
|
+
be a file path or a title substring:
|
|
258
|
+
|
|
259
|
+
```sh
|
|
260
|
+
python-agent-harness headless "now add tests" --restore
|
|
261
|
+
python-agent-harness headless "next step" --restore parser-refactor
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- `--model NAME` selects the model: a profile from the `models` config
|
|
265
|
+
section (same as the TUI's `/model`), or a raw model name on the
|
|
266
|
+
configured endpoint when no such profile exists. An explicit `--model`
|
|
267
|
+
wins over the model restored by `--restore`.
|
|
268
|
+
|
|
269
|
+
- `--json` emits the run as JSON lines on stdout instead of plain text —
|
|
270
|
+
one `{"type": ...}` object per line: `start` (echoes the prompt and
|
|
271
|
+
submit warnings), `delta` (streamed text chunks), `notify` (tool and
|
|
272
|
+
status events, with `kind`/`data`), `log`, and a final `result` (the
|
|
273
|
+
filtered answer plus any `errors`). Diagnostics (restore/model notes,
|
|
274
|
+
a plain-text echo of error events) still go to stderr, so one pipe
|
|
275
|
+
carries the structured stream. Exit codes are unchanged.
|
|
276
|
+
|
|
277
|
+
```sh
|
|
278
|
+
python-agent-harness headless "fix it" --json | jq -c 'select(.type=="result")'
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
- Exit code is 0 on success, 1 when the prompt was empty (only failed
|
|
282
|
+
`@file` references), the run raised an agent error, or the restore failed.
|
|
232
283
|
|
|
233
284
|
### Slash commands
|
|
234
285
|
|
|
@@ -282,30 +333,6 @@ You are a personal assistant. You do NOT modify files or run shell commands.
|
|
|
282
333
|
| **Tools** | Can restrict (`allow_planexit=False`) | All tools, minus the agent's `exclude_tools` |
|
|
283
334
|
| **Ctrl-C** | Restores to default prompt | Stays on the custom agent |
|
|
284
335
|
|
|
285
|
-
## Project layout
|
|
286
|
-
|
|
287
|
-
```text
|
|
288
|
-
python_agent_harness/
|
|
289
|
-
├── agent.py # Agent FSM core: states, transitions, supervision
|
|
290
|
-
├── tool_runner.py # Tool-call execution/delivery + history salvage
|
|
291
|
-
├── context_manager.py # Context-ratio tracking + compaction
|
|
292
|
-
├── client.py # OpenAI-compatible streaming client (httpx)
|
|
293
|
-
├── models.py # Message / ToolCall / ToolSpec data classes
|
|
294
|
-
├── token_estimator.py # CJK-aware token estimation + calibration
|
|
295
|
-
├── planmode.py # Plan/build modes + plan-file lifecycle
|
|
296
|
-
├── prompts.py # Prompt loading + system-prompt assembly
|
|
297
|
-
├── persistence.py # Session persistence + titles
|
|
298
|
-
├── session.py # Session wiring hub + MCP lifecycle
|
|
299
|
-
├── subagent.py # Sub-agent runner + error containment
|
|
300
|
-
├── commands.py # Init/review/custom command definitions
|
|
301
|
-
├── cli.py # CLI entry points
|
|
302
|
-
├── tui/ # Rich + prompt_toolkit TUI (package)
|
|
303
|
-
├── diffrender.py # Unified diff generation + Rich rendering
|
|
304
|
-
├── lsp/ # Built-in LSP client
|
|
305
|
-
├── mcp/ # Optional MCP client
|
|
306
|
-
└── tools/ # Tool implementations + registry
|
|
307
|
-
```
|
|
308
|
-
|
|
309
336
|
## Development
|
|
310
337
|
|
|
311
338
|
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux, MacOS and Windows.
|
|
@@ -71,6 +71,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
71
71
|
|
|
72
72
|
- **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early, while failed tool calls are sanitized so they never strand the agent. Transient API failures (`429` / `5xx`) retry with exponential backoff and jitter. Auth-expired status codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env — some API gateways return `502` instead of `401` when the backend auth token has expired. Note: codes in this list are treated as auth-expired exclusively and will not be retried with backoff, so only include codes that are unambiguously auth-related in your environment.
|
|
73
73
|
- **Context management** — CJK-aware token estimation, per-model context windows, and automatic compaction at 70% usage.
|
|
74
|
+
- **Image & text attachments** — attach an image or a text file with `@path` in your message (e.g. `@screenshot.png`, `@README.md`): images (PNG/JPEG/GIF/WebP, up to 20 MB, magic-byte validated) become multimodal `image_url` parts, text files are inlined as text. Only images and text files are supported as input. Path supports web-url for image as well.
|
|
74
75
|
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, `LSP`, and `PlanExit`. Synchronous tools execute sequentially, but a round made up entirely of read-only tools (`Read`, `Glob`, `Grep`, `Skill`, `LSP`) is dispatched concurrently via a bounded thread pool; asynchronous tools such as `Bash` and `Agent` can run concurrently as well. Results are always delivered in the model's emitted order.
|
|
75
76
|
- **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
|
|
76
77
|
- **Persistent sessions** — sessions are automatically saved after every response to `~/.local/share/python-agent-harness/sessions/`, with LLM-generated titles and support for `/restore --latest` and `/sessions`.
|
|
@@ -111,7 +112,8 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
111
112
|
"api_key": "sk-...",
|
|
112
113
|
"model": "gpt-5-mini",
|
|
113
114
|
"reasoning_effort": null,
|
|
114
|
-
"stream": true
|
|
115
|
+
"stream": true,
|
|
116
|
+
"supports_image_input": false
|
|
115
117
|
},
|
|
116
118
|
"models": {
|
|
117
119
|
"_comment": "Named LLM profiles for /model switching. Partial settings; unset keys inherit the main llm.",
|
|
@@ -137,7 +139,8 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
137
139
|
"max_tokens": null,
|
|
138
140
|
"timeout": null,
|
|
139
141
|
"reasoning_effort": null,
|
|
140
|
-
"stream": null
|
|
142
|
+
"stream": null,
|
|
143
|
+
"supports_image_input": null
|
|
141
144
|
},
|
|
142
145
|
"default_agent": null,
|
|
143
146
|
"paths": {
|
|
@@ -176,7 +179,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
176
179
|
|
|
177
180
|
### Configuration options
|
|
178
181
|
|
|
179
|
-
- **`llm`** — main LLM configuration. Optional keys include `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, and `
|
|
182
|
+
- **`llm`** — main LLM configuration. Optional keys include `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, `stream`, and `supports_image_input`. Values such as `reasoning_effort` are passed to the API as-is when set. `run --no-stream` overrides `stream`. `supports_image_input` (default `false`) controls whether `@path` image attachments are sent to the model or stripped with a warning.
|
|
180
183
|
- **`models`** — named LLM profiles for runtime switching with `/model`. A profile is a partial settings dictionary; unset keys inherit from the main `llm`. `default` restores the main LLM configuration.
|
|
181
184
|
- **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or substrings (e.g., `deepseek-v4`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
|
|
182
185
|
- **`subagent_llm`** — LLM configuration for `Agent` tool requests. Unset values inherit from the main `llm`. Set `profile` to reuse a profile from `models`. Precedence is: profile settings > explicit `subagent_llm` settings > main `llm` > environment variables.
|
|
@@ -191,10 +194,58 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
191
194
|
## Usage
|
|
192
195
|
|
|
193
196
|
```sh
|
|
194
|
-
python-agent-harness run [project
|
|
197
|
+
python-agent-harness run [--project DIR]
|
|
195
198
|
```
|
|
196
199
|
|
|
197
|
-
Launches the interactive TUI agent. If
|
|
200
|
+
Launches the interactive TUI agent. If `--project` is omitted, the current directory is used.
|
|
201
|
+
|
|
202
|
+
### Headless mode
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
python-agent-harness headless [prompt] [--project DIR] [--restore [SPEC]] [--model NAME]
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Runs a single prompt without the TUI — for CI, scripting, and piping.
|
|
209
|
+
The assistant's answer is written to stdout once the run completes;
|
|
210
|
+
tool/status events go to stderr, so the answer stream stays clean.
|
|
211
|
+
Interactive prompts are auto-answered (`confirm` → yes, `ask` →
|
|
212
|
+
"Unanswered"), so a run never blocks.
|
|
213
|
+
|
|
214
|
+
- The prompt is a positional argument; when omitted it is read from stdin:
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
python-agent-harness headless "fix the failing test"
|
|
218
|
+
echo "fix the failing test" | python-agent-harness headless
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- `--restore` continues a saved session (the conversation auto-saves after
|
|
222
|
+
each response). Bare `--restore` uses the most recent session; a SPEC may
|
|
223
|
+
be a file path or a title substring:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
python-agent-harness headless "now add tests" --restore
|
|
227
|
+
python-agent-harness headless "next step" --restore parser-refactor
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- `--model NAME` selects the model: a profile from the `models` config
|
|
231
|
+
section (same as the TUI's `/model`), or a raw model name on the
|
|
232
|
+
configured endpoint when no such profile exists. An explicit `--model`
|
|
233
|
+
wins over the model restored by `--restore`.
|
|
234
|
+
|
|
235
|
+
- `--json` emits the run as JSON lines on stdout instead of plain text —
|
|
236
|
+
one `{"type": ...}` object per line: `start` (echoes the prompt and
|
|
237
|
+
submit warnings), `delta` (streamed text chunks), `notify` (tool and
|
|
238
|
+
status events, with `kind`/`data`), `log`, and a final `result` (the
|
|
239
|
+
filtered answer plus any `errors`). Diagnostics (restore/model notes,
|
|
240
|
+
a plain-text echo of error events) still go to stderr, so one pipe
|
|
241
|
+
carries the structured stream. Exit codes are unchanged.
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
python-agent-harness headless "fix it" --json | jq -c 'select(.type=="result")'
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
- Exit code is 0 on success, 1 when the prompt was empty (only failed
|
|
248
|
+
`@file` references), the run raised an agent error, or the restore failed.
|
|
198
249
|
|
|
199
250
|
### Slash commands
|
|
200
251
|
|
|
@@ -248,30 +299,6 @@ You are a personal assistant. You do NOT modify files or run shell commands.
|
|
|
248
299
|
| **Tools** | Can restrict (`allow_planexit=False`) | All tools, minus the agent's `exclude_tools` |
|
|
249
300
|
| **Ctrl-C** | Restores to default prompt | Stays on the custom agent |
|
|
250
301
|
|
|
251
|
-
## Project layout
|
|
252
|
-
|
|
253
|
-
```text
|
|
254
|
-
python_agent_harness/
|
|
255
|
-
├── agent.py # Agent FSM core: states, transitions, supervision
|
|
256
|
-
├── tool_runner.py # Tool-call execution/delivery + history salvage
|
|
257
|
-
├── context_manager.py # Context-ratio tracking + compaction
|
|
258
|
-
├── client.py # OpenAI-compatible streaming client (httpx)
|
|
259
|
-
├── models.py # Message / ToolCall / ToolSpec data classes
|
|
260
|
-
├── token_estimator.py # CJK-aware token estimation + calibration
|
|
261
|
-
├── planmode.py # Plan/build modes + plan-file lifecycle
|
|
262
|
-
├── prompts.py # Prompt loading + system-prompt assembly
|
|
263
|
-
├── persistence.py # Session persistence + titles
|
|
264
|
-
├── session.py # Session wiring hub + MCP lifecycle
|
|
265
|
-
├── subagent.py # Sub-agent runner + error containment
|
|
266
|
-
├── commands.py # Init/review/custom command definitions
|
|
267
|
-
├── cli.py # CLI entry points
|
|
268
|
-
├── tui/ # Rich + prompt_toolkit TUI (package)
|
|
269
|
-
├── diffrender.py # Unified diff generation + Rich rendering
|
|
270
|
-
├── lsp/ # Built-in LSP client
|
|
271
|
-
├── mcp/ # Optional MCP client
|
|
272
|
-
└── tools/ # Tool implementations + registry
|
|
273
|
-
```
|
|
274
|
-
|
|
275
302
|
## Development
|
|
276
303
|
|
|
277
304
|
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux, MacOS and Windows.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-agent-harness"
|
|
7
|
-
version = "1.5.5.
|
|
7
|
+
version = "1.5.5.9"
|
|
8
8
|
description = "A lightweight, hackable mini-OpenCode written in Python."
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Huming Chen", email = "chenhuming@gmail.com"}
|
|
@@ -57,7 +57,7 @@ dev = [
|
|
|
57
57
|
]
|
|
58
58
|
|
|
59
59
|
[project.scripts]
|
|
60
|
-
python-agent-harness = "python_agent_harness.cli:main"
|
|
60
|
+
python-agent-harness = "python_agent_harness.entry.cli:main"
|
|
61
61
|
|
|
62
62
|
[tool.setuptools.packages.find]
|
|
63
63
|
include = ["python_agent_harness*"]
|
|
@@ -90,10 +90,14 @@ source = ["python_agent_harness"]
|
|
|
90
90
|
branch = true
|
|
91
91
|
# The console/TUI entry points aren't exercised by unittest; omit the ones
|
|
92
92
|
# that would otherwise drag the number down without adding signal.
|
|
93
|
-
omit = [
|
|
93
|
+
omit = [
|
|
94
|
+
"python_agent_harness/__main__.py",
|
|
95
|
+
]
|
|
94
96
|
|
|
95
97
|
[tool.coverage.report]
|
|
96
98
|
show_missing = true
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
99
|
+
# Linux/macOS gate: 95%. Windows CI enforces 94% with tools/bash.py
|
|
100
|
+
# omitted instead (see .github/workflows/ci.yml): that module is the
|
|
101
|
+
# posix PTY/spawn backend whose body cannot execute on Windows, where
|
|
102
|
+
# tools/bash_win.py runs in its place.
|
|
103
|
+
fail_under = 95
|
{python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/__init__.py
RENAMED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"""python-agent-harness: a Python port of the gptel-agent-harness."""
|
|
2
2
|
|
|
3
|
-
from .
|
|
3
|
+
from .core.models import AgentMode, ImagePart, Message, TextPart, ToolCall, ToolSpec
|
|
4
|
+
from .entry.cli import main # noqa: F401 (public lib surface: main entry point)
|
|
5
|
+
from .llm.client import Client, LLMClient
|
|
4
6
|
from .lsp.config import LSPConfig, LSPServerConfig
|
|
5
7
|
from .mcp.config import MCPConfig, MCPServerConfig
|
|
6
8
|
from .mcp.manager import MCPManager
|
|
7
|
-
from .
|
|
8
|
-
from .session import Session
|
|
9
|
+
from .session.session import Session
|
|
9
10
|
|
|
10
|
-
__version__ = "1.5.5.
|
|
11
|
+
__version__ = "1.5.5.9"
|
|
11
12
|
|
|
12
13
|
__all__ = [
|
|
13
14
|
"Client",
|
|
@@ -20,6 +21,8 @@ __all__ = [
|
|
|
20
21
|
"MCPManager",
|
|
21
22
|
"MCPServerConfig",
|
|
22
23
|
"Message",
|
|
24
|
+
"TextPart",
|
|
25
|
+
"ImagePart",
|
|
23
26
|
"ToolCall",
|
|
24
27
|
"ToolSpec",
|
|
25
28
|
"__version__",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core engine: agent loop, tool dispatch, models, and context utilities."""
|
|
@@ -37,8 +37,9 @@ from __future__ import annotations
|
|
|
37
37
|
|
|
38
38
|
from typing import Any, Protocol
|
|
39
39
|
|
|
40
|
-
from . import
|
|
41
|
-
from
|
|
40
|
+
from ..llm.client import LLMClient
|
|
41
|
+
from ..session import config
|
|
42
|
+
from ..tools.base import PendingToolResult
|
|
42
43
|
from .context_manager import ContextManager
|
|
43
44
|
from .models import Message, ToolCall
|
|
44
45
|
from .token_estimator import context_window_for, estimate_payload_tokens
|
|
@@ -47,7 +48,6 @@ from .tool_runner import (
|
|
|
47
48
|
ToolRunner,
|
|
48
49
|
sanitize_tool_result, # noqa: F401 (tests import it from .agent)
|
|
49
50
|
)
|
|
50
|
-
from .tools.base import PendingToolResult
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
class _SupervisorSession(Protocol):
|
|
@@ -229,7 +229,7 @@ class AgentLoop:
|
|
|
229
229
|
"""Update the session's context ratio (see ContextManager)."""
|
|
230
230
|
# the estimator functions are resolved here — in the agent
|
|
231
231
|
# module namespace — so tests patching
|
|
232
|
-
# python_agent_harness.
|
|
232
|
+
# python_agent_harness.core.token_estimator.estimate_payload_tokens keep
|
|
233
233
|
# intercepting the call site
|
|
234
234
|
self._context_manager.update_context_ratio(estimate_payload_tokens, context_window_for)
|
|
235
235
|
|
|
@@ -463,12 +463,14 @@ class AgentLoop:
|
|
|
463
463
|
max_tokens = session.max_tokens
|
|
464
464
|
reasoning_effort = session.reasoning_effort
|
|
465
465
|
stream = session.stream
|
|
466
|
+
supports_image_input = session.supports_image_input
|
|
466
467
|
else:
|
|
467
468
|
client = self._client or session.subagent_client
|
|
468
469
|
temperature = session.subagent_temperature
|
|
469
470
|
max_tokens = session.subagent_max_tokens
|
|
470
471
|
reasoning_effort = session.subagent_reasoning_effort
|
|
471
472
|
stream = session.subagent_stream
|
|
473
|
+
supports_image_input = session.subagent_supports_image_input
|
|
472
474
|
assistant, usage = client.chat(
|
|
473
475
|
self.messages,
|
|
474
476
|
tools=tools if session.tools_enabled else None,
|
|
@@ -477,6 +479,7 @@ class AgentLoop:
|
|
|
477
479
|
max_tokens=max_tokens,
|
|
478
480
|
reasoning_effort=reasoning_effort,
|
|
479
481
|
stream=stream,
|
|
482
|
+
supports_image_input=supports_image_input,
|
|
480
483
|
# sub-agents must not stream into the parent's live
|
|
481
484
|
# stream row — their text is private until returned
|
|
482
485
|
on_delta=(safe_delta if self.top_level else None),
|
|
@@ -10,8 +10,8 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
-
from
|
|
14
|
-
from
|
|
13
|
+
from ..prompts import compact_summary, compacted_messages, user_prompt_texts
|
|
14
|
+
from ..session import config
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class ContextManager:
|
|
@@ -20,7 +20,7 @@ class ContextManager:
|
|
|
20
20
|
``update_context_ratio`` receives the two token-estimator functions
|
|
21
21
|
from the loop's delegate so the call site keeps resolving them
|
|
22
22
|
through the ``agent`` module namespace (tests patch
|
|
23
|
-
``python_agent_harness.
|
|
23
|
+
``python_agent_harness.core.token_estimator.estimate_payload_tokens``). The
|
|
24
24
|
context window comes from the session's client (config-file aware,
|
|
25
25
|
resolved per access); ``context_window_for`` is only the fallback
|
|
26
26
|
for clients without the property.
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
5
6
|
import enum
|
|
6
7
|
import json
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
|
-
from typing import Any
|
|
9
|
+
from typing import Any, Union
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class AgentMode(enum.Enum):
|
|
@@ -13,6 +14,60 @@ class AgentMode(enum.Enum):
|
|
|
13
14
|
PLAN = "plan"
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
@dataclass
|
|
18
|
+
class TextPart:
|
|
19
|
+
"""A plain-text content part in a multimodal message."""
|
|
20
|
+
|
|
21
|
+
text: str
|
|
22
|
+
|
|
23
|
+
def to_api(self) -> dict[str, Any]:
|
|
24
|
+
return {"type": "text", "text": self.text}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ImagePart:
|
|
29
|
+
"""A provider-neutral image attachment in a multimodal message.
|
|
30
|
+
|
|
31
|
+
The image source is either ``data`` (raw image bytes, with
|
|
32
|
+
``media_type`` as the MIME type, e.g. ``"image/png"``) or ``url``
|
|
33
|
+
(an http(s) URL the provider fetches). At least one of the two must
|
|
34
|
+
be set; when both are, ``url`` wins. The conversion to a provider-specific format (e.g.
|
|
35
|
+
OpenAI's ``image_url`` with a data URL or a plain URL) happens in
|
|
36
|
+
``to_api()``, which is only called at the API serialization boundary
|
|
37
|
+
(``Message.to_api()`` → ``Client._payload``), never in the agent core.
|
|
38
|
+
|
|
39
|
+
``path`` (when set) is the filesystem path the image was attached
|
|
40
|
+
from. It is metadata only — never sent to the API — and lets
|
|
41
|
+
session persistence record where the image came from so a restored
|
|
42
|
+
session can re-attach it. Images that did not originate from a
|
|
43
|
+
path (drag-drop, URLs) leave it None.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
data: bytes | None = None
|
|
47
|
+
media_type: str = "image/png"
|
|
48
|
+
path: str | None = None
|
|
49
|
+
url: str | None = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_url(cls, url: str) -> ImagePart:
|
|
53
|
+
return cls(url=url)
|
|
54
|
+
|
|
55
|
+
def to_api(self) -> dict[str, Any]:
|
|
56
|
+
if self.url:
|
|
57
|
+
return {"type": "image_url", "image_url": {"url": self.url}}
|
|
58
|
+
if self.data is None:
|
|
59
|
+
raise ValueError("ImagePart requires either data or url")
|
|
60
|
+
b64 = base64.b64encode(self.data).decode("ascii")
|
|
61
|
+
return {
|
|
62
|
+
"type": "image_url",
|
|
63
|
+
"image_url": {"url": f"data:{self.media_type};base64,{b64}"},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Type alias for the individual parts a Message.content list may contain.
|
|
68
|
+
ContentPart = Union[TextPart, ImagePart, str, "dict[str, Any]"]
|
|
69
|
+
|
|
70
|
+
|
|
16
71
|
@dataclass
|
|
17
72
|
class ToolCall:
|
|
18
73
|
"""A tool invocation requested by the model."""
|
|
@@ -30,14 +85,15 @@ class Message:
|
|
|
30
85
|
"""One conversation message in OpenAI-compatible format.
|
|
31
86
|
|
|
32
87
|
``role`` is one of system/user/assistant/tool.
|
|
33
|
-
``content`` may be a str
|
|
88
|
+
``content`` may be a str, a list of parts (multimodal:
|
|
89
|
+
``TextPart``, ``ImagePart``, plain strings, or dicts), or None.
|
|
34
90
|
``tool_calls`` carries requested tool invocations on assistant messages.
|
|
35
91
|
``tool_call_id`` links a tool message to its assistant tool call.
|
|
36
92
|
``reasoning`` holds reasoning content if the backend reports it.
|
|
37
93
|
"""
|
|
38
94
|
|
|
39
95
|
role: str
|
|
40
|
-
content: str | list[
|
|
96
|
+
content: str | list[ContentPart] | None = None
|
|
41
97
|
tool_calls: list[ToolCall] | None = None
|
|
42
98
|
tool_call_id: str | None = None
|
|
43
99
|
reasoning: str | None = None
|
|
@@ -79,6 +135,11 @@ class Message:
|
|
|
79
135
|
confuse the model, so it is stripped here at the API boundary.
|
|
80
136
|
The stored ``content`` is left untouched (the TUI collapses the
|
|
81
137
|
reasoning for display via its own helper).
|
|
138
|
+
|
|
139
|
+
For multimodal content (a list of parts), each part is
|
|
140
|
+
serialized to its provider-specific dict representation via
|
|
141
|
+
``to_api()`` when the part is a ``TextPart`` or ``ImagePart``;
|
|
142
|
+
strings and dicts pass through unchanged.
|
|
82
143
|
"""
|
|
83
144
|
content = self.content
|
|
84
145
|
if self.reasoning and isinstance(content, str):
|
|
@@ -87,8 +148,17 @@ class Message:
|
|
|
87
148
|
stripped = content.lstrip()
|
|
88
149
|
if stripped.startswith(self.reasoning):
|
|
89
150
|
return stripped[len(self.reasoning) :].lstrip("\n")
|
|
151
|
+
if isinstance(content, list):
|
|
152
|
+
return [self._part_to_api(p) for p in content]
|
|
90
153
|
return content
|
|
91
154
|
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _part_to_api(part: ContentPart) -> Any:
|
|
157
|
+
"""Serialize one content part to its provider-specific dict."""
|
|
158
|
+
if isinstance(part, (TextPart, ImagePart)):
|
|
159
|
+
return part.to_api()
|
|
160
|
+
return part
|
|
161
|
+
|
|
92
162
|
def text(self) -> str:
|
|
93
163
|
"""Plain text of the message; empty when no text parts exist."""
|
|
94
164
|
if isinstance(self.content, str):
|
|
@@ -98,6 +168,8 @@ class Message:
|
|
|
98
168
|
for p in self.content:
|
|
99
169
|
if isinstance(p, str):
|
|
100
170
|
parts.append(p)
|
|
171
|
+
elif isinstance(p, TextPart):
|
|
172
|
+
parts.append(p.text)
|
|
101
173
|
elif isinstance(p, dict):
|
|
102
174
|
if isinstance(p.get("text"), str):
|
|
103
175
|
parts.append(p["text"])
|
|
@@ -7,11 +7,11 @@ mode, sub-agents receive the read-only reminder.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from . import
|
|
10
|
+
from ..llm.client import LLMClient
|
|
11
|
+
from ..prompts import load_agent_prompt
|
|
12
|
+
from ..session import config
|
|
11
13
|
from .agent import run_agent_loop
|
|
12
|
-
from .client import LLMClient
|
|
13
14
|
from .models import Message
|
|
14
|
-
from .prompts import load_agent_prompt
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def _subagent_system_prompt(session: object) -> str | None:
|
|
@@ -20,12 +20,22 @@ def _subagent_system_prompt(session: object) -> str | None:
|
|
|
20
20
|
Never falls back to the parent's `system_prompt` (which carries the
|
|
21
21
|
parent's project context and task-completion rules) — a sub-agent
|
|
22
22
|
must not inherit any context from the parent. When the session has
|
|
23
|
-
no sub-agent prompt configured, the default bundled one is used
|
|
23
|
+
no sub-agent prompt configured, the default bundled one is used
|
|
24
|
+
(with tool instructions from the session's registry, filtered by
|
|
25
|
+
the sub-agent excluded tools).
|
|
24
26
|
"""
|
|
25
27
|
own = getattr(session, "subagent_system_prompt", None)
|
|
26
28
|
if own:
|
|
27
29
|
return own
|
|
28
|
-
|
|
30
|
+
registry = getattr(session, "registry", None)
|
|
31
|
+
tool_instructions = registry.tool_instructions() if registry else None
|
|
32
|
+
skill_dir = getattr(session, "_skill_dir", None)
|
|
33
|
+
return load_agent_prompt(
|
|
34
|
+
config.DEFAULT_SUBAGENT_PROMPT_FILE,
|
|
35
|
+
skill_dir=skill_dir,
|
|
36
|
+
tool_instructions=tool_instructions,
|
|
37
|
+
excluded_tools=config.SUBAGENT_EXCLUDED_TOOLS,
|
|
38
|
+
)
|
|
29
39
|
|
|
30
40
|
|
|
31
41
|
def run_subagent(
|
|
@@ -11,7 +11,7 @@ import json
|
|
|
11
11
|
import os
|
|
12
12
|
import re
|
|
13
13
|
|
|
14
|
-
from
|
|
14
|
+
from ..session import config
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def is_cjk_char(c: str) -> bool:
|
|
@@ -31,6 +31,16 @@ def is_cjk_char(c: str) -> bool:
|
|
|
31
31
|
_CJK_RE = re.compile(r"[\u3000-\u9fff\uf900-\ufaff\uff00-\uffef\U00020000-\U0002fa1f]")
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
# Flat per-image token estimate. Vision models bill images by tile /
|
|
35
|
+
# detail level, not by the size of the base64 data URL, so counting the
|
|
36
|
+
# encoded bytes as text would over-count by orders of magnitude. A flat
|
|
37
|
+
# heuristic keeps images visible to context-ratio tracking and, crucially,
|
|
38
|
+
# stops the base64 blob from poisoning the token calibrator on turns that
|
|
39
|
+
# contain an image. ~1200 tokens is a reasonable mid-range approximation
|
|
40
|
+
# for a single "high detail" image across common providers.
|
|
41
|
+
IMAGE_TOKEN_ESTIMATE = 1200
|
|
42
|
+
|
|
43
|
+
|
|
34
44
|
def estimate_tokens(text: str) -> int:
|
|
35
45
|
"""Estimate tokens in TEXT: Latin ~4 chars/token, CJK ~2 chars/token."""
|
|
36
46
|
if not text:
|
|
@@ -132,4 +142,26 @@ def payload_text(system: object, messages: list[dict], tools: list[dict]) -> str
|
|
|
132
142
|
|
|
133
143
|
|
|
134
144
|
def estimate_payload_tokens(system: object, messages: list[dict], tools: list[dict]) -> int:
|
|
135
|
-
|
|
145
|
+
text_tokens = estimate_tokens(payload_text(system, messages, tools))
|
|
146
|
+
return text_tokens + count_image_tokens(messages)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def count_image_tokens(messages: list[dict]) -> int:
|
|
150
|
+
"""Estimate the token cost of image parts across MESSAGES.
|
|
151
|
+
|
|
152
|
+
Each image content part (``{"type": "image_url", ...}``) is charged
|
|
153
|
+
a flat ``IMAGE_TOKEN_ESTIMATE``. ``payload_text`` deliberately skips
|
|
154
|
+
the base64 data URL (its length is not proportional to the model's
|
|
155
|
+
real image token cost), so this is the sole place images enter the
|
|
156
|
+
estimate — keeping them visible to context-ratio tracking without
|
|
157
|
+
letting the encoded blob distort the calibrator.
|
|
158
|
+
"""
|
|
159
|
+
total = 0
|
|
160
|
+
for msg in messages:
|
|
161
|
+
content = msg.get("content")
|
|
162
|
+
if not isinstance(content, list):
|
|
163
|
+
continue
|
|
164
|
+
for p in content:
|
|
165
|
+
if isinstance(p, dict) and p.get("type") == "image_url":
|
|
166
|
+
total += IMAGE_TOKEN_ESTIMATE
|
|
167
|
+
return total
|
|
@@ -22,9 +22,9 @@ import time
|
|
|
22
22
|
from concurrent.futures import ThreadPoolExecutor
|
|
23
23
|
from typing import Any
|
|
24
24
|
|
|
25
|
-
from
|
|
25
|
+
from ..session import config
|
|
26
|
+
from ..tools.base import PendingToolResult
|
|
26
27
|
from .models import Message, ToolCall
|
|
27
|
-
from .tools.base import PendingToolResult
|
|
28
28
|
|
|
29
29
|
NIL_RESULT_PLACEHOLDER = (
|
|
30
30
|
"Error: tool produced no result (it may have been interrupted or failed to return)."
|