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.
Files changed (121) hide show
  1. {python_agent_harness-1.5.5.7/python_agent_harness.egg-info → python_agent_harness-1.5.5.9}/PKG-INFO +57 -30
  2. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/README.md +56 -29
  3. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/pyproject.toml +10 -6
  4. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/__init__.py +7 -4
  5. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/__main__.py +1 -1
  6. python_agent_harness-1.5.5.9/python_agent_harness/core/__init__.py +1 -0
  7. {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
  8. {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
  9. {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
  10. {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
  11. {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
  12. {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
  13. {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
  14. python_agent_harness-1.5.5.9/python_agent_harness/entry/__init__.py +1 -0
  15. {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
  16. python_agent_harness-1.5.5.9/python_agent_harness/entry/controller.py +359 -0
  17. python_agent_harness-1.5.5.9/python_agent_harness/entry/headless.py +350 -0
  18. python_agent_harness-1.5.5.9/python_agent_harness/entry/view.py +51 -0
  19. python_agent_harness-1.5.5.9/python_agent_harness/io/__init__.py +1 -0
  20. python_agent_harness-1.5.5.9/python_agent_harness/io/attachments.py +377 -0
  21. {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
  22. {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
  23. python_agent_harness-1.5.5.9/python_agent_harness/io/text_filter.py +84 -0
  24. python_agent_harness-1.5.5.9/python_agent_harness/llm/__init__.py +1 -0
  25. {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
  26. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/lsp/manager.py +1 -1
  27. python_agent_harness-1.5.5.9/python_agent_harness/prompts/__init__.py +41 -0
  28. python_agent_harness-1.5.5.9/python_agent_harness/prompts/agent.md +114 -0
  29. 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
  30. python_agent_harness-1.5.5.9/python_agent_harness/prompts/subagent.md +56 -0
  31. python_agent_harness-1.5.5.9/python_agent_harness/session/__init__.py +33 -0
  32. {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
  33. {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
  34. {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
  35. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/agent_tool.py +45 -7
  36. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/base.py +22 -4
  37. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/bash.py +34 -2
  38. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/bash_win.py +2 -2
  39. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/diffapply.py +71 -14
  40. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/edit.py +76 -11
  41. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/edit_mac.py +1 -1
  42. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/edit_win.py +1 -1
  43. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/filesystem.py +87 -1
  44. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/glob.py +21 -0
  45. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/glob_mac.py +1 -0
  46. python_agent_harness-1.5.5.9/python_agent_harness/tools/glob_win.py +104 -0
  47. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/grep.py +22 -1
  48. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/grep_mac.py +1 -1
  49. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/grep_win.py +10 -16
  50. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/insert.py +29 -4
  51. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/lsp.py +23 -0
  52. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/mkdir.py +1 -1
  53. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/question.py +19 -1
  54. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/read.py +21 -1
  55. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/skill.py +1 -0
  56. python_agent_harness-1.5.5.9/python_agent_harness/tools/todo.py +110 -0
  57. python_agent_harness-1.5.5.9/python_agent_harness/tools/write.py +91 -0
  58. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/__init__.py +12 -4
  59. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/commands.py +139 -145
  60. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/core.py +95 -83
  61. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/input.py +91 -23
  62. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tui/render.py +64 -94
  63. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9/python_agent_harness.egg-info}/PKG-INFO +57 -30
  64. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness.egg-info/SOURCES.txt +27 -38
  65. python_agent_harness-1.5.5.9/python_agent_harness.egg-info/entry_points.txt +2 -0
  66. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/tests/test_invariants.py +114 -49
  67. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/tests/test_scenarios.py +26 -20
  68. python_agent_harness-1.5.5.7/python_agent_harness/prompts/agent.md +0 -385
  69. python_agent_harness-1.5.5.7/python_agent_harness/prompts/subagent.md +0 -231
  70. python_agent_harness-1.5.5.7/python_agent_harness/tools/glob_win.py +0 -90
  71. python_agent_harness-1.5.5.7/python_agent_harness/tools/todo.py +0 -60
  72. python_agent_harness-1.5.5.7/python_agent_harness/tools/write.py +0 -58
  73. python_agent_harness-1.5.5.7/python_agent_harness.egg-info/entry_points.txt +0 -2
  74. python_agent_harness-1.5.5.7/tests/test_agents_md.py +0 -260
  75. python_agent_harness-1.5.5.7/tests/test_cli.py +0 -483
  76. python_agent_harness-1.5.5.7/tests/test_client.py +0 -1952
  77. python_agent_harness-1.5.5.7/tests/test_commands.py +0 -119
  78. python_agent_harness-1.5.5.7/tests/test_concurrent_subagents.py +0 -384
  79. python_agent_harness-1.5.5.7/tests/test_config.py +0 -745
  80. python_agent_harness-1.5.5.7/tests/test_context_rules.py +0 -133
  81. python_agent_harness-1.5.5.7/tests/test_custom_agents.py +0 -629
  82. python_agent_harness-1.5.5.7/tests/test_diffrender.py +0 -53
  83. python_agent_harness-1.5.5.7/tests/test_filesystem.py +0 -2004
  84. python_agent_harness-1.5.5.7/tests/test_lsp.py +0 -984
  85. python_agent_harness-1.5.5.7/tests/test_mcp.py +0 -826
  86. python_agent_harness-1.5.5.7/tests/test_models.py +0 -130
  87. python_agent_harness-1.5.5.7/tests/test_persistence.py +0 -357
  88. python_agent_harness-1.5.5.7/tests/test_planmode.py +0 -243
  89. python_agent_harness-1.5.5.7/tests/test_prompts.py +0 -444
  90. python_agent_harness-1.5.5.7/tests/test_session.py +0 -1097
  91. python_agent_harness-1.5.5.7/tests/test_subagent.py +0 -169
  92. python_agent_harness-1.5.5.7/tests/test_subagent_isolation.py +0 -459
  93. python_agent_harness-1.5.5.7/tests/test_todos_scope.py +0 -117
  94. python_agent_harness-1.5.5.7/tests/test_token_estimator.py +0 -148
  95. python_agent_harness-1.5.5.7/tests/test_tools_misc.py +0 -563
  96. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/LICENSE +0 -0
  97. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/lsp/__init__.py +0 -0
  98. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/lsp/client.py +0 -0
  99. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/lsp/config.py +0 -0
  100. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/mcp/__init__.py +0 -0
  101. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/mcp/client.py +0 -0
  102. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/mcp/config.py +0 -0
  103. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/mcp/manager.py +0 -0
  104. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  105. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/build-switch.md +0 -0
  106. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/commands/explain.md +0 -0
  107. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/compact.md +0 -0
  108. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/initialize.md +0 -0
  109. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/plan-mode.md +0 -0
  110. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/plan.md +0 -0
  111. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/review.md +0 -0
  112. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/summary.md +0 -0
  113. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  114. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/prompts/title.md +0 -0
  115. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/__init__.py +0 -0
  116. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/mcp.py +0 -0
  117. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness/tools/planexit.py +0 -0
  118. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  119. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness.egg-info/requires.txt +0 -0
  120. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/python_agent_harness.egg-info/top_level.txt +0 -0
  121. {python_agent_harness-1.5.5.7 → python_agent_harness-1.5.5.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.7
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 `stream`. Values such as `reasoning_effort` are passed to the API as-is when set. `run --no-stream` overrides `stream`.
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-dir]
231
+ python-agent-harness run [--project DIR]
229
232
  ```
230
233
 
231
- Launches the interactive TUI agent. If `project-dir` is omitted, the current directory is used.
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 `stream`. Values such as `reasoning_effort` are passed to the API as-is when set. `run --no-stream` overrides `stream`.
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-dir]
197
+ python-agent-harness run [--project DIR]
195
198
  ```
196
199
 
197
- Launches the interactive TUI agent. If `project-dir` is omitted, the current directory is used.
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"
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 = ["python_agent_harness/__main__.py"]
93
+ omit = [
94
+ "python_agent_harness/__main__.py",
95
+ ]
94
96
 
95
97
  [tool.coverage.report]
96
98
  show_missing = true
97
- # Current line + branch coverage is ~98%. Gate at 90 to leave headroom for
98
- # small changes while still catching real regressions. Raise as coverage grows.
99
- fail_under = 90
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
@@ -1,13 +1,14 @@
1
1
  """python-agent-harness: a Python port of the gptel-agent-harness."""
2
2
 
3
- from .client import Client, LLMClient
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 .models import AgentMode, Message, ToolCall, ToolSpec
8
- from .session import Session
9
+ from .session.session import Session
9
10
 
10
- __version__ = "1.5.5.7"
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__",
@@ -1,5 +1,5 @@
1
1
  import sys
2
2
 
3
- from .cli import main
3
+ from .entry.cli import main
4
4
 
5
5
  sys.exit(main())
@@ -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 config
41
- from .client import LLMClient
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.agent.estimate_payload_tokens keep
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 . import config
14
- from .prompts import compact_summary, compacted_messages, user_prompt_texts
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.agent.estimate_payload_tokens``). The
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 or a list of parts (multimodal).
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[Any] | None = None
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"])
@@ -21,7 +21,7 @@ import string
21
21
  import tempfile
22
22
  from pathlib import Path
23
23
 
24
- from . import config
24
+ from ..session import config
25
25
  from .models import AgentMode
26
26
 
27
27
 
@@ -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 config
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
- return load_agent_prompt(config.DEFAULT_SUBAGENT_PROMPT_FILE)
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 . import config
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
- return estimate_tokens(payload_text(system, messages, tools))
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 . import config
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)."