python-agent-harness 1.5.4.8__tar.gz → 1.5.5.0__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.4.8/python_agent_harness.egg-info → python_agent_harness-1.5.5.0}/PKG-INFO +3 -3
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/README.md +2 -2
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/pyproject.toml +1 -1
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/__init__.py +1 -1
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/client.py +89 -26
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/config.py +9 -4
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/session.py +15 -2
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tool_runner.py +82 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/base.py +5 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/filesystem.py +9 -3
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/glob.py +1 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/grep.py +1 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/read.py +1 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/skill.py +1 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/core.py +26 -18
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/render.py +23 -8
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0/python_agent_harness.egg-info}/PKG-INFO +3 -3
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_client.py +129 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/LICENSE +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/__main__.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/agent.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/cli.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/commands.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/context_manager.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/diffrender.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/__init__.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/client.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/config.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/manager.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/models.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/persistence.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/planmode.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/agent.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/build-switch.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/commands/explain.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/compact.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/initialize.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/plan-mode.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/plan.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/review.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/subagent.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/summary.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/task-completion-rules.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/title.md +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/subagent.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/token_estimator.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/__init__.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/agent_tool.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/bash.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/diffapply.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/edit.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/edit_mac.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/glob_mac.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/grep_mac.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/insert.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/mcp.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/mkdir.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/planexit.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/question.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/todo.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/write.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/__init__.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/commands.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/input.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/SOURCES.txt +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/dependency_links.txt +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/entry_points.txt +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/requires.txt +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/top_level.txt +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/setup.cfg +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_agents_md.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_cli.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_commands.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_concurrent_subagents.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_config.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_context_rules.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_diffrender.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_filesystem.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_invariants.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_mcp.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_models.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_persistence.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_planmode.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_prompts.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_scenarios.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_session.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_subagent.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_subagent_isolation.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_todos_scope.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_token_estimator.py +0 -0
- {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_tools_misc.py +0 -0
{python_agent_harness-1.5.4.8/python_agent_harness.egg-info → python_agent_harness-1.5.5.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-agent-harness
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.5.0
|
|
4
4
|
Summary: A lightweight, hackable mini-OpenCode written in Python.
|
|
5
5
|
Author: huming
|
|
6
6
|
License-Expression: MIT
|
|
@@ -91,9 +91,9 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
91
91
|
|
|
92
92
|
## Features
|
|
93
93
|
|
|
94
|
-
- **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.
|
|
94
|
+
- **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.
|
|
95
95
|
- **Context management** — CJK-aware token estimation, per-model context windows, and automatic compaction at 70% usage.
|
|
96
|
-
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, and `PlanExit`. Synchronous tools execute sequentially; asynchronous tools such as `Bash` and `Agent` can run concurrently
|
|
96
|
+
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, and `PlanExit`. Synchronous tools execute sequentially, but a round made up entirely of read-only tools (`Read`, `Glob`, `Grep`, `Skill`) 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.
|
|
97
97
|
- **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
|
|
98
98
|
- **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`.
|
|
99
99
|
- **Focused TUI** — a Rich-based interface with a pinned status bar, Todos panel, inline red/green diff rendering for `Edit` and `Write`, and a `prompt_toolkit` editor with history and completion. `Esc+Enter` submits, `Ctrl-D` quits, and `Ctrl-C` cancels without leaving the application.
|
|
@@ -68,9 +68,9 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
68
68
|
|
|
69
69
|
## Features
|
|
70
70
|
|
|
71
|
-
- **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.
|
|
71
|
+
- **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.
|
|
72
72
|
- **Context management** — CJK-aware token estimation, per-model context windows, and automatic compaction at 70% usage.
|
|
73
|
-
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, and `PlanExit`. Synchronous tools execute sequentially; asynchronous tools such as `Bash` and `Agent` can run concurrently
|
|
73
|
+
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, and `PlanExit`. Synchronous tools execute sequentially, but a round made up entirely of read-only tools (`Read`, `Glob`, `Grep`, `Skill`) 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.
|
|
74
74
|
- **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
|
|
75
75
|
- **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`.
|
|
76
76
|
- **Focused TUI** — a Rich-based interface with a pinned status bar, Todos panel, inline red/green diff rendering for `Edit` and `Write`, and a `prompt_toolkit` editor with history and completion. `Esc+Enter` submits, `Ctrl-D` quits, and `Ctrl-C` cancels without leaving the application.
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/client.py
RENAMED
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
import os
|
|
13
13
|
import random
|
|
14
14
|
import socket as _socket
|
|
15
|
+
import sys
|
|
15
16
|
import threading
|
|
16
17
|
import time
|
|
17
18
|
import uuid
|
|
@@ -48,9 +49,12 @@ class RetryableApiError(ApiError):
|
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
class AuthExpiredError(ApiError):
|
|
51
|
-
"""Raised on HTTP
|
|
52
|
+
"""Raised on auth-expired HTTP status codes.
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
By default HTTP 401, but customizable via
|
|
55
|
+
``config.AUTH_REFRESH_STATUS_CODES`` — some API gateways return 502
|
|
56
|
+
or other codes when the backend auth token has expired. Handled
|
|
57
|
+
specially in the retry loop: the API key is re-read from
|
|
54
58
|
config/environment (an external process may have refreshed the
|
|
55
59
|
token) and the request is retried once with the new key. If the
|
|
56
60
|
key hasn't changed, the error is permanent and propagated as a
|
|
@@ -59,7 +63,13 @@ class AuthExpiredError(ApiError):
|
|
|
59
63
|
|
|
60
64
|
|
|
61
65
|
def _retryable_status(status: int) -> bool:
|
|
62
|
-
"""429 and 5xx are transient; every other error is permanent.
|
|
66
|
+
"""429 and 5xx are transient; every other error is permanent.
|
|
67
|
+
|
|
68
|
+
Status codes in ``config.AUTH_REFRESH_STATUS_CODES`` are excluded —
|
|
69
|
+
they are handled as auth-expired, not retried with backoff.
|
|
70
|
+
"""
|
|
71
|
+
if status in config.AUTH_REFRESH_STATUS_CODES:
|
|
72
|
+
return False
|
|
63
73
|
return status == 429 or status >= 500
|
|
64
74
|
|
|
65
75
|
|
|
@@ -507,12 +517,14 @@ class Client:
|
|
|
507
517
|
if self._sleep_backoff(attempt, e.retry_after, cancel_check):
|
|
508
518
|
raise
|
|
509
519
|
except AuthExpiredError as e:
|
|
510
|
-
#
|
|
511
|
-
#
|
|
512
|
-
#
|
|
513
|
-
#
|
|
514
|
-
#
|
|
515
|
-
#
|
|
520
|
+
# Auth-expired status code (401, or any code in
|
|
521
|
+
# config.AUTH_REFRESH_STATUS_CODES): the credential
|
|
522
|
+
# (often a JWT with a short TTL) may have expired.
|
|
523
|
+
# Re-read the API key from the config file / environment
|
|
524
|
+
# — an external token-refresh process may have written a
|
|
525
|
+
# new one. Poll for up to 30s waiting for the key to
|
|
526
|
+
# change; retry once if it does. Fail immediately if
|
|
527
|
+
# already refreshed once
|
|
516
528
|
# (prevents infinite loops).
|
|
517
529
|
self._reset_http()
|
|
518
530
|
if auth_refreshed or not self._refresh_api_key(cancel_check):
|
|
@@ -634,7 +646,7 @@ class Client:
|
|
|
634
646
|
if resp.status_code >= 400:
|
|
635
647
|
body = resp.read().decode("utf-8", "replace")
|
|
636
648
|
message = f"API error {resp.status_code}: {body[:500]}"
|
|
637
|
-
if resp.status_code
|
|
649
|
+
if resp.status_code in config.AUTH_REFRESH_STATUS_CODES:
|
|
638
650
|
raise AuthExpiredError(message)
|
|
639
651
|
if _retryable_status(resp.status_code):
|
|
640
652
|
raise RetryableApiError(message, resp.headers.get("Retry-After"))
|
|
@@ -696,7 +708,7 @@ class Client:
|
|
|
696
708
|
resp = self._http.post(self._url(), headers=self._headers(stream=False), json=payload)
|
|
697
709
|
if resp.status_code >= 400:
|
|
698
710
|
message = f"API error {resp.status_code}: {resp.text[:500]}"
|
|
699
|
-
if resp.status_code
|
|
711
|
+
if resp.status_code in config.AUTH_REFRESH_STATUS_CODES:
|
|
700
712
|
raise AuthExpiredError(message)
|
|
701
713
|
if _retryable_status(resp.status_code):
|
|
702
714
|
raise RetryableApiError(message, resp.headers.get("Retry-After"))
|
|
@@ -822,6 +834,47 @@ def _default_api_key() -> str | None:
|
|
|
822
834
|
return os.environ.get("OPENAI_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or None
|
|
823
835
|
|
|
824
836
|
|
|
837
|
+
def _extract_socket(conn: object) -> _socket.socket | None:
|
|
838
|
+
"""Best-effort extraction of the raw socket from an httpcore connection.
|
|
839
|
+
|
|
840
|
+
For direct (non-proxy) connections the socket lives at
|
|
841
|
+
``conn._connection._network_stream``. Proxy connections are wrapped
|
|
842
|
+
(ForwardHTTPConnection/TunnelHTTPConnection -> HTTPConnection ->
|
|
843
|
+
HTTP11Connection), so we unwrap up to 4 levels looking for the
|
|
844
|
+
``_network_stream`` attribute that owns the raw socket.
|
|
845
|
+
"""
|
|
846
|
+
try:
|
|
847
|
+
obj: object = conn
|
|
848
|
+
stream = None
|
|
849
|
+
for _ in range(4):
|
|
850
|
+
stream = getattr(obj, "_network_stream", None)
|
|
851
|
+
if stream is not None:
|
|
852
|
+
break
|
|
853
|
+
obj = getattr(obj, "_connection", None)
|
|
854
|
+
if obj is None:
|
|
855
|
+
break
|
|
856
|
+
return stream.get_extra_info("socket") if stream is not None else None
|
|
857
|
+
except Exception: # noqa: BLE001 - best effort
|
|
858
|
+
return None
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _shutdown_and_close(sock: _socket.socket) -> None:
|
|
862
|
+
"""Shutdown then close a socket to wake blocked reads.
|
|
863
|
+
|
|
864
|
+
On macOS the socket is in non-blocking mode (httpcore calls
|
|
865
|
+
settimeout for the read timeout), so a blocked recv parks in
|
|
866
|
+
select(); closing the fd before that select has processed the
|
|
867
|
+
shutdown wakeup can lose the wakeup and leave the read parked
|
|
868
|
+
forever. Give the woken thread a moment to observe the EOF first.
|
|
869
|
+
"""
|
|
870
|
+
with contextlib.suppress(OSError):
|
|
871
|
+
sock.shutdown(_socket.SHUT_RDWR)
|
|
872
|
+
if sys.platform == "darwin":
|
|
873
|
+
time.sleep(0.05)
|
|
874
|
+
with contextlib.suppress(OSError):
|
|
875
|
+
sock.close()
|
|
876
|
+
|
|
877
|
+
|
|
825
878
|
def _abort_inflight_sockets(client: httpx.Client) -> None:
|
|
826
879
|
"""Wake any blocked stream reads on every live pool socket.
|
|
827
880
|
|
|
@@ -849,21 +902,31 @@ def _abort_inflight_sockets(client: httpx.Client) -> None:
|
|
|
849
902
|
``contextlib.suppress`` right after this call, so a double-close is
|
|
850
903
|
harmless. Every step is best-effort; any failure is ignored.
|
|
851
904
|
"""
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
905
|
+
pools: list[object] = []
|
|
906
|
+
base_pool = getattr(getattr(client, "_transport", None), "_pool", None)
|
|
907
|
+
if base_pool is not None:
|
|
908
|
+
pools.append(base_pool)
|
|
909
|
+
|
|
910
|
+
if sys.platform == "darwin":
|
|
911
|
+
# macOS only: with proxy env vars (HTTP_PROXY/HTTPS_PROXY) set,
|
|
912
|
+
# httpx routes requests through proxy transports registered in
|
|
913
|
+
# client._mounts (pools are httpcore.HTTPProxy), leaving the
|
|
914
|
+
# base transport pool above empty. On Linux this never happens
|
|
915
|
+
# in CI and the code path above is sufficient, so this extra
|
|
916
|
+
# walk is deliberately restricted to macOS.
|
|
917
|
+
mounts = getattr(client, "_mounts", None)
|
|
918
|
+
if isinstance(mounts, dict):
|
|
919
|
+
for transport in mounts.values():
|
|
920
|
+
if transport is not None:
|
|
921
|
+
pool = getattr(transport, "_pool", None)
|
|
922
|
+
if pool is not None:
|
|
923
|
+
pools.append(pool)
|
|
924
|
+
|
|
925
|
+
for pool in pools:
|
|
926
|
+
for conn in getattr(pool, "_connections", None) or ():
|
|
927
|
+
sock = _extract_socket(conn)
|
|
928
|
+
if sock is not None:
|
|
929
|
+
_shutdown_and_close(sock)
|
|
867
930
|
|
|
868
931
|
|
|
869
932
|
def _iter_sse(lines: Iterator[str]) -> Iterator[str]:
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/config.py
RENAMED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
"""Configuration defaults for python-agent-harness.
|
|
2
|
-
|
|
3
|
-
Mirrors the defcustom defaults of the Emacs gptel-agent-harness.
|
|
4
|
-
"""
|
|
1
|
+
"""Configuration defaults for python-agent-harness."""
|
|
5
2
|
|
|
6
3
|
from __future__ import annotations
|
|
7
4
|
|
|
@@ -131,6 +128,14 @@ API_RETRY_MAX = 3 # max attempts per request (initial + retries)
|
|
|
131
128
|
API_RETRY_BASE_DELAY = 1.0 # base backoff (seconds), doubled per attempt
|
|
132
129
|
API_RETRY_MAX_DELAY = 30.0 # per-attempt backoff cap (seconds)
|
|
133
130
|
|
|
131
|
+
# HTTP status codes that should trigger auth-refresh (key re-read from
|
|
132
|
+
# config/env) instead of being treated as transient retryable errors.
|
|
133
|
+
# 401 is always included (standard "Unauthorized"). Some API gateways
|
|
134
|
+
# return 502 or other 5xx codes when the backend auth token has expired
|
|
135
|
+
# rather than a proper 401 — add those here so the client refreshes the
|
|
136
|
+
# key instead of pointlessly retrying the same expired credential.
|
|
137
|
+
AUTH_REFRESH_STATUS_CODES: list[int] = [401, 502]
|
|
138
|
+
|
|
134
139
|
# ---- tool execution ----------------------------------------------------------
|
|
135
140
|
SUBAGENT_MAX_ROUNDS = 60
|
|
136
141
|
# Tool execution mirrors gptel's `gptel--handle-tool-use': synchronous
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/session.py
RENAMED
|
@@ -156,6 +156,11 @@ class Session:
|
|
|
156
156
|
# active clones are tracked so cancel()/close() can reach them.
|
|
157
157
|
self._subagent_clients_lock = threading.Lock()
|
|
158
158
|
self._active_subagent_clients: list[Client] = []
|
|
159
|
+
# guards _skill_index_cache: parallel readonly tool threads
|
|
160
|
+
# (e.g. multiple Skill calls in one round) can hit the lazy
|
|
161
|
+
# cache build simultaneously — double-checked locking prevents
|
|
162
|
+
# redundant filesystem scans and the data race on the cache tuple
|
|
163
|
+
self._skill_index_lock = threading.Lock()
|
|
159
164
|
self.store = SessionPersistence(
|
|
160
165
|
project_dir=project_dir,
|
|
161
166
|
model=model,
|
|
@@ -377,10 +382,18 @@ class Session:
|
|
|
377
382
|
return hit[0] if hit else None
|
|
378
383
|
|
|
379
384
|
def _skill_index(self) -> dict[str, tuple[str, str]]:
|
|
380
|
-
"""Return the cached name -> (path, description) skill index.
|
|
385
|
+
"""Return the cached name -> (path, description) skill index.
|
|
386
|
+
|
|
387
|
+
Thread-safe via double-checked locking: parallel readonly tool
|
|
388
|
+
threads (e.g. multiple Skill calls in one round) can reach the
|
|
389
|
+
lazy cache build simultaneously — the lock ensures only one
|
|
390
|
+
thread scans the filesystem and writes the cache tuple.
|
|
391
|
+
"""
|
|
381
392
|
key = self._skill_dir
|
|
382
393
|
if self._skill_index_cache is None or self._skill_index_cache[0] != key:
|
|
383
|
-
self.
|
|
394
|
+
with self._skill_index_lock:
|
|
395
|
+
if self._skill_index_cache is None or self._skill_index_cache[0] != key:
|
|
396
|
+
self._skill_index_cache = (key, index_skills(key))
|
|
384
397
|
return self._skill_index_cache[1]
|
|
385
398
|
|
|
386
399
|
def _find_skill_dir(self) -> str | None:
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tool_runner.py
RENAMED
|
@@ -6,12 +6,20 @@ state (``messages`` / ``pending`` / ``info``) through the loop
|
|
|
6
6
|
reference. Tool calls are always executed and delivered via the
|
|
7
7
|
loop's ``_execute_tool_call`` / ``_deliver_tool_result`` methods so
|
|
8
8
|
subclass or test overrides of those methods keep working.
|
|
9
|
+
|
|
10
|
+
When every call in a round is readonly (``Tool.is_readonly = True``),
|
|
11
|
+
the round is dispatched concurrently via a thread pool: readonly tools
|
|
12
|
+
only read state, so none can depend on another's side effects, and
|
|
13
|
+
running them in parallel reduces latency for read-heavy rounds (e.g.
|
|
14
|
+
the model reading several files at once). Mixed rounds (any
|
|
15
|
+
non-readonly tool) fall back to the original sequential dispatch.
|
|
9
16
|
"""
|
|
10
17
|
|
|
11
18
|
from __future__ import annotations
|
|
12
19
|
|
|
13
20
|
import json
|
|
14
21
|
import time
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
23
|
from typing import Any
|
|
16
24
|
|
|
17
25
|
from . import config
|
|
@@ -22,6 +30,13 @@ NIL_RESULT_PLACEHOLDER = (
|
|
|
22
30
|
"Error: tool produced no result (it may have been interrupted or failed to return)."
|
|
23
31
|
)
|
|
24
32
|
|
|
33
|
+
# Upper bound on threads spawned for a parallel readonly round. The
|
|
34
|
+
# round size is driven by model output (a single response can emit
|
|
35
|
+
# dozens of Read/Grep calls), so cap peak concurrency to avoid a
|
|
36
|
+
# thread explosion; the pool still drains every call, just fewer at a
|
|
37
|
+
# time.
|
|
38
|
+
MAX_PARALLEL_READONLY = 8
|
|
39
|
+
|
|
25
40
|
|
|
26
41
|
def sanitize_tool_result(result: object) -> str:
|
|
27
42
|
"""Sanitize a tool result for the model.
|
|
@@ -106,12 +121,22 @@ class ToolRunner:
|
|
|
106
121
|
afterwards, again in original call order. Delivery happens
|
|
107
122
|
later, in original tool-call order, by the caller.
|
|
108
123
|
|
|
124
|
+
When every call in the round is readonly (``Tool.is_readonly``),
|
|
125
|
+
the round is dispatched concurrently via a thread pool:
|
|
126
|
+
readonly tools only read state, so none can depend on
|
|
127
|
+
another's side effects, and running them in parallel reduces
|
|
128
|
+
latency for read-heavy rounds (e.g. the model reading several
|
|
129
|
+
files at once). Mixed rounds fall back to sequential dispatch.
|
|
130
|
+
|
|
109
131
|
A cancel landing before a call starts skips it (tools have side
|
|
110
132
|
effects); a call already running — or an async tool already
|
|
111
133
|
dispatched — cannot be stopped, but its result stays local to
|
|
112
134
|
the (dead) run.
|
|
113
135
|
"""
|
|
114
136
|
loop = self.loop
|
|
137
|
+
if calls and self._all_readonly(calls):
|
|
138
|
+
self._run_parallel(calls, results)
|
|
139
|
+
return
|
|
115
140
|
async_calls: list[tuple[ToolCall, PendingToolResult]] = []
|
|
116
141
|
for p in calls:
|
|
117
142
|
# A cancel landing while a call is still QUEUED must skip
|
|
@@ -151,6 +176,63 @@ class ToolRunner:
|
|
|
151
176
|
p.elapsed = time.monotonic() - start
|
|
152
177
|
results[p.id] = sanitize_tool_result(result)
|
|
153
178
|
|
|
179
|
+
def _all_readonly(self, calls: list[ToolCall]) -> bool:
|
|
180
|
+
"""True when every call's tool is marked ``is_readonly``."""
|
|
181
|
+
loop = self.loop
|
|
182
|
+
for p in calls:
|
|
183
|
+
tool = loop.session.registry.get(p.name)
|
|
184
|
+
if tool is None or not tool.is_readonly:
|
|
185
|
+
return False
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
def _run_parallel(self, calls: list[ToolCall], results: dict[str, str]) -> None:
|
|
189
|
+
"""Dispatch all calls concurrently via a thread pool.
|
|
190
|
+
|
|
191
|
+
Used only when every call is readonly. Each tool runs in its
|
|
192
|
+
own thread; results are collected in original call order.
|
|
193
|
+
Cancel is checked before dispatching (a call that has not
|
|
194
|
+
started yet is skipped); a call already running cannot be
|
|
195
|
+
stopped, but its result stays local to the (dead) run.
|
|
196
|
+
"""
|
|
197
|
+
loop = self.loop
|
|
198
|
+
futures: dict[str, Any] = {}
|
|
199
|
+
with ThreadPoolExecutor(max_workers=min(len(calls), MAX_PARALLEL_READONLY)) as pool:
|
|
200
|
+
for p in calls:
|
|
201
|
+
if loop._is_cancelled():
|
|
202
|
+
results[p.id] = "Error: tool call cancelled (user aborted the run)."
|
|
203
|
+
continue
|
|
204
|
+
if loop.top_level:
|
|
205
|
+
loop.session.notify("tool_running", p.name)
|
|
206
|
+
futures[p.id] = pool.submit(self._exec_one, p)
|
|
207
|
+
for p in calls:
|
|
208
|
+
fut = futures.get(p.id)
|
|
209
|
+
if fut is None:
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
result = fut.result()
|
|
213
|
+
except Exception as e: # noqa: BLE001 - containment boundary
|
|
214
|
+
results[p.id] = f"Error: tool {p.name!r} crashed during execution — {e}"
|
|
215
|
+
else:
|
|
216
|
+
if isinstance(result, PendingToolResult):
|
|
217
|
+
result = result.wait()
|
|
218
|
+
results[p.id] = sanitize_tool_result(result)
|
|
219
|
+
|
|
220
|
+
def _exec_one(self, p: ToolCall) -> str | PendingToolResult:
|
|
221
|
+
"""Execute one tool call and return its raw result.
|
|
222
|
+
|
|
223
|
+
Thin wrapper around ``loop._execute_tool_call`` that records
|
|
224
|
+
elapsed time on the call object. Used by ``_run_parallel``.
|
|
225
|
+
"""
|
|
226
|
+
loop = self.loop
|
|
227
|
+
start = time.monotonic()
|
|
228
|
+
try:
|
|
229
|
+
result = loop._execute_tool_call(p)
|
|
230
|
+
except Exception as e: # noqa: BLE001 - containment boundary
|
|
231
|
+
p.elapsed = time.monotonic() - start
|
|
232
|
+
return f"Error: tool {p.name!r} crashed during execution — {e}"
|
|
233
|
+
p.elapsed = time.monotonic() - start
|
|
234
|
+
return result
|
|
235
|
+
|
|
154
236
|
def execute_pending(self) -> None:
|
|
155
237
|
"""TOOL state: run the round's pending tool calls.
|
|
156
238
|
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/base.py
RENAMED
|
@@ -91,6 +91,11 @@ class ToolContext:
|
|
|
91
91
|
class Tool(ABC):
|
|
92
92
|
name: str = ""
|
|
93
93
|
description: str = ""
|
|
94
|
+
# True for tools that only read state (Read, Glob, Grep, Skill):
|
|
95
|
+
# when EVERY call in a round is readonly, the runner dispatches
|
|
96
|
+
# them concurrently via a thread pool instead of one-at-a-time,
|
|
97
|
+
# since none can depend on another's side effects.
|
|
98
|
+
is_readonly: bool = False
|
|
94
99
|
# NB: Tool is an ABC, not a dataclass, so this is a plain class-level
|
|
95
100
|
# default (never mutated in place — every concrete tool overrides it
|
|
96
101
|
# with its own schema). It must be a real dict: a dataclasses.field()
|
|
@@ -35,6 +35,7 @@ import os
|
|
|
35
35
|
import shutil # noqa: F401 (mock target for tests)
|
|
36
36
|
import subprocess # noqa: F401 (mock target for tests)
|
|
37
37
|
import tempfile
|
|
38
|
+
import threading
|
|
38
39
|
import time
|
|
39
40
|
from pathlib import Path
|
|
40
41
|
from typing import TypeGuard
|
|
@@ -47,6 +48,8 @@ READ_SIZE_LIMIT = 400 * 1024 # whole-file reads above this are refused
|
|
|
47
48
|
|
|
48
49
|
_spooled_files: list[str] = [] # temp files created by _spool, cleaned
|
|
49
50
|
# up by cleanup_spooled_files on session close
|
|
51
|
+
_spooled_files_lock = threading.Lock() # guards _spooled_files across
|
|
52
|
+
# parallel readonly tool threads (Glob/Grep/Read can _spool concurrently)
|
|
50
53
|
|
|
51
54
|
|
|
52
55
|
def _truncate(text: str, label: str = "output") -> str:
|
|
@@ -89,7 +92,8 @@ def _spool(text: str, label: str) -> str:
|
|
|
89
92
|
)
|
|
90
93
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
91
94
|
f.write(text)
|
|
92
|
-
|
|
95
|
+
with _spooled_files_lock:
|
|
96
|
+
_spooled_files.append(temp_file)
|
|
93
97
|
except OSError:
|
|
94
98
|
return _truncate(text, label)
|
|
95
99
|
lines = text.splitlines()
|
|
@@ -112,8 +116,9 @@ def cleanup_spooled_files() -> None:
|
|
|
112
116
|
in the temp dir. Files already removed (e.g. by a restored
|
|
113
117
|
session) are skipped.
|
|
114
118
|
"""
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
with _spooled_files_lock:
|
|
120
|
+
paths = _spooled_files[:]
|
|
121
|
+
_spooled_files.clear()
|
|
117
122
|
for path in paths:
|
|
118
123
|
try:
|
|
119
124
|
if os.path.exists(path):
|
|
@@ -179,6 +184,7 @@ __all__ = [
|
|
|
179
184
|
"_spool",
|
|
180
185
|
"_spool_dir",
|
|
181
186
|
"_spooled_files",
|
|
187
|
+
"_spooled_files_lock",
|
|
182
188
|
"_strip_diff_fence",
|
|
183
189
|
"_truncate",
|
|
184
190
|
]
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/glob.py
RENAMED
|
@@ -19,6 +19,7 @@ from .filesystem import _git_root, _natnump, _spool
|
|
|
19
19
|
|
|
20
20
|
class GlobTool(Tool):
|
|
21
21
|
name = "Glob"
|
|
22
|
+
is_readonly = True
|
|
22
23
|
description = (
|
|
23
24
|
"Recursively find files matching a provided glob pattern.\n\n"
|
|
24
25
|
'- Supports glob patterns like "*.md" or "*test*.py".\n'
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/core.py
RENAMED
|
@@ -101,29 +101,32 @@ class Tui(RenderMixin, InputMixin, CommandMixin):
|
|
|
101
101
|
# (the assistant message is committed to messages and will
|
|
102
102
|
# appear in history once delivered) and show which tools are
|
|
103
103
|
# running, so the display stays alive during long operations.
|
|
104
|
-
with self.lock:
|
|
105
|
-
self.stream_text = ""
|
|
106
104
|
names = data if isinstance(data, list) else []
|
|
107
105
|
label = ", ".join(names) if names else "tools"
|
|
108
|
-
self.
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
with self.lock:
|
|
107
|
+
self.stream_text = ""
|
|
108
|
+
self._current_tool = label
|
|
109
|
+
self.status = f" ⏳ {label}"
|
|
110
|
+
self._history_dirty = True
|
|
111
111
|
elif kind == "tool_running":
|
|
112
112
|
# Per-tool notification: update the current tool name shown
|
|
113
113
|
# beside the spinner as each sync tool starts executing.
|
|
114
114
|
name = data if isinstance(data, str) else ""
|
|
115
|
-
self.
|
|
116
|
-
|
|
115
|
+
with self.lock:
|
|
116
|
+
self._current_tool = name
|
|
117
|
+
self.status = f" ⏳ {name}" if name else " ⏳ tools"
|
|
117
118
|
elif kind == "tools":
|
|
118
|
-
self._current_tool = ""
|
|
119
|
-
self.status = " running tools"
|
|
120
119
|
# tool round finished: session.last_messages now contains the
|
|
121
120
|
# tool-call + result rows — rebuild the cached history so
|
|
122
121
|
# they show up live instead of after the run ends
|
|
123
|
-
self.
|
|
122
|
+
with self.lock:
|
|
123
|
+
self._current_tool = ""
|
|
124
|
+
self.status = " running tools"
|
|
125
|
+
self._history_dirty = True
|
|
124
126
|
elif kind == "compact":
|
|
125
|
-
self.
|
|
126
|
-
|
|
127
|
+
with self.lock:
|
|
128
|
+
self.status = " compacted"
|
|
129
|
+
self._history_dirty = True
|
|
127
130
|
elif kind == "retry":
|
|
128
131
|
# A connection error mid-stream: the client discarded the
|
|
129
132
|
# partial response and is retrying on a fresh connection —
|
|
@@ -131,21 +134,26 @@ class Tui(RenderMixin, InputMixin, CommandMixin):
|
|
|
131
134
|
# doesn't duplicate it on screen.
|
|
132
135
|
with self.lock:
|
|
133
136
|
self.stream_text = ""
|
|
134
|
-
|
|
137
|
+
self.status = " connection lost — retrying"
|
|
135
138
|
elif kind == "todos":
|
|
136
139
|
# TodoWrite updated the task list: the cached history rows
|
|
137
140
|
# (which include the Todos panel) must be rebuilt
|
|
138
|
-
self.
|
|
141
|
+
with self.lock:
|
|
142
|
+
self._history_dirty = True
|
|
139
143
|
elif kind == "error":
|
|
140
|
-
self.
|
|
144
|
+
with self.lock:
|
|
145
|
+
self.status = " error"
|
|
141
146
|
elif kind == "save-error":
|
|
142
|
-
self.
|
|
147
|
+
with self.lock:
|
|
148
|
+
self.status = " auto-save failed"
|
|
143
149
|
else:
|
|
144
|
-
self.
|
|
150
|
+
with self.lock:
|
|
151
|
+
self.status = " running"
|
|
145
152
|
self._data_event.set()
|
|
146
153
|
|
|
147
154
|
def _on_log(self, msg: str) -> None:
|
|
148
|
-
self.
|
|
155
|
+
with self.lock:
|
|
156
|
+
self.status = f" {msg}"
|
|
149
157
|
|
|
150
158
|
# ------------------------------------------------------------------
|
|
151
159
|
# main loop
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/render.py
RENAMED
|
@@ -329,10 +329,16 @@ class RenderMixin:
|
|
|
329
329
|
rebuild (and re-parse Markdown for) the whole conversation every
|
|
330
330
|
frame — that cost is what made the scroll lag behind the text.
|
|
331
331
|
"""
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
332
|
+
with self.lock:
|
|
333
|
+
dirty = self._history_dirty
|
|
334
|
+
cache = self._history_cache
|
|
335
|
+
if dirty or cache is None:
|
|
336
|
+
built = self._build_history_rows()
|
|
337
|
+
with self.lock:
|
|
338
|
+
self._history_cache = built
|
|
339
|
+
self._history_dirty = False
|
|
340
|
+
return list(built)
|
|
341
|
+
return list(cache)
|
|
336
342
|
|
|
337
343
|
def _stream_row(self) -> Text | None:
|
|
338
344
|
"""Live stream row (cheap Text, tail-capped)."""
|
|
@@ -499,11 +505,19 @@ class RenderMixin:
|
|
|
499
505
|
if self.agent_running:
|
|
500
506
|
frame = SPINNER_FRAMES[int(time.time() * 10) % len(SPINNER_FRAMES)]
|
|
501
507
|
t.append(f" {frame}", style="bold cyan")
|
|
502
|
-
|
|
503
|
-
|
|
508
|
+
with self.lock:
|
|
509
|
+
tool = self._current_tool
|
|
510
|
+
status = self.status
|
|
511
|
+
if tool:
|
|
512
|
+
t.append(f" {tool}", style="bold cyan")
|
|
504
513
|
elif self.question is not None:
|
|
505
514
|
t.append(" ❓", style="yellow")
|
|
506
|
-
|
|
515
|
+
with self.lock:
|
|
516
|
+
status = self.status
|
|
517
|
+
else:
|
|
518
|
+
with self.lock:
|
|
519
|
+
status = self.status
|
|
520
|
+
t.append(self._fit_status(cell_len(str(t)), status), style=self._status_style())
|
|
507
521
|
return t
|
|
508
522
|
|
|
509
523
|
def _fit_status(self, used: int, msg: str) -> str:
|
|
@@ -527,7 +541,8 @@ class RenderMixin:
|
|
|
527
541
|
|
|
528
542
|
def _status_style(self) -> str:
|
|
529
543
|
"""Status-bar color by state: errors red, activity cyan, idle dim."""
|
|
530
|
-
|
|
544
|
+
with self.lock:
|
|
545
|
+
s = self.status
|
|
531
546
|
if "error" in s or "failed" in s:
|
|
532
547
|
return "bold red"
|
|
533
548
|
if " ⏳" in s or " running" in s or "retrying" in s:
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0/python_agent_harness.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-agent-harness
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.5.0
|
|
4
4
|
Summary: A lightweight, hackable mini-OpenCode written in Python.
|
|
5
5
|
Author: huming
|
|
6
6
|
License-Expression: MIT
|
|
@@ -91,9 +91,9 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
91
91
|
|
|
92
92
|
## Features
|
|
93
93
|
|
|
94
|
-
- **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.
|
|
94
|
+
- **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.
|
|
95
95
|
- **Context management** — CJK-aware token estimation, per-model context windows, and automatic compaction at 70% usage.
|
|
96
|
-
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, and `PlanExit`. Synchronous tools execute sequentially; asynchronous tools such as `Bash` and `Agent` can run concurrently
|
|
96
|
+
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, and `PlanExit`. Synchronous tools execute sequentially, but a round made up entirely of read-only tools (`Read`, `Glob`, `Grep`, `Skill`) 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.
|
|
97
97
|
- **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
|
|
98
98
|
- **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`.
|
|
99
99
|
- **Focused TUI** — a Rich-based interface with a pinned status bar, Todos panel, inline red/green diff rendering for `Edit` and `Write`, and a `prompt_toolkit` editor with history and completion. `Esc+Enter` submits, `Ctrl-D` quits, and `Ctrl-C` cancels without leaving the application.
|
|
@@ -816,6 +816,7 @@ class TestAbortInflightSockets(unittest.TestCase):
|
|
|
816
816
|
|
|
817
817
|
def _fake_client_with_stream(self, stream):
|
|
818
818
|
conn = mock.Mock()
|
|
819
|
+
conn._network_stream = None
|
|
819
820
|
conn._connection._network_stream = stream
|
|
820
821
|
client = mock.Mock()
|
|
821
822
|
client._transport._pool._connections = [conn]
|
|
@@ -1707,6 +1708,134 @@ class TestAuthRefreshOn401(unittest.TestCase):
|
|
|
1707
1708
|
c.close()
|
|
1708
1709
|
|
|
1709
1710
|
|
|
1711
|
+
class TestAuthRefreshOnConfigurableStatusCodes(unittest.TestCase):
|
|
1712
|
+
"""Status codes in config.AUTH_REFRESH_STATUS_CODES (not just 401)
|
|
1713
|
+
trigger the auth-refresh path — e.g. some gateways return 502 when
|
|
1714
|
+
the backend auth token has expired."""
|
|
1715
|
+
|
|
1716
|
+
def test_502_triggers_auth_refresh_and_retries(self):
|
|
1717
|
+
"""A 502 (in AUTH_REFRESH_STATUS_CODES) triggers key re-read;
|
|
1718
|
+
if the key changed, the request is retried and can succeed."""
|
|
1719
|
+
from python_agent_harness.client import AuthExpiredError
|
|
1720
|
+
|
|
1721
|
+
c = make_offline_client(retry_max=3, retry_base_delay=0.01, retry_max_delay=0.01)
|
|
1722
|
+
calls = {"n": 0}
|
|
1723
|
+
|
|
1724
|
+
def fake_stream(payload, on_delta, on_tool_call, usage):
|
|
1725
|
+
n = calls["n"]
|
|
1726
|
+
calls["n"] += 1
|
|
1727
|
+
if n == 0:
|
|
1728
|
+
raise AuthExpiredError("API error 502: Bad Gateway")
|
|
1729
|
+
return (["ok"], [], {})
|
|
1730
|
+
|
|
1731
|
+
with (
|
|
1732
|
+
mock.patch.object(c, "_stream_response", side_effect=fake_stream),
|
|
1733
|
+
mock.patch.object(c, "_refresh_api_key", return_value=True),
|
|
1734
|
+
):
|
|
1735
|
+
msg, _ = c.chat([Message(role="user", content="hi")])
|
|
1736
|
+
|
|
1737
|
+
self.assertEqual(calls["n"], 2)
|
|
1738
|
+
self.assertEqual(msg.text(), "ok")
|
|
1739
|
+
c.close()
|
|
1740
|
+
|
|
1741
|
+
def test_502_with_unchanged_key_fails_immediately(self):
|
|
1742
|
+
"""A 502 where the key hasn't changed propagates as a permanent
|
|
1743
|
+
ApiError (no backoff retry)."""
|
|
1744
|
+
from python_agent_harness.client import ApiError, AuthExpiredError
|
|
1745
|
+
|
|
1746
|
+
c = make_offline_client(retry_max=3, retry_base_delay=0.01, retry_max_delay=0.01)
|
|
1747
|
+
calls = {"n": 0}
|
|
1748
|
+
|
|
1749
|
+
def fake_stream(payload, on_delta, on_tool_call, usage):
|
|
1750
|
+
calls["n"] += 1
|
|
1751
|
+
raise AuthExpiredError("API error 502: Bad Gateway")
|
|
1752
|
+
|
|
1753
|
+
with (
|
|
1754
|
+
mock.patch.object(c, "_stream_response", side_effect=fake_stream),
|
|
1755
|
+
mock.patch.object(c, "_refresh_api_key", return_value=False),
|
|
1756
|
+
self.assertRaises(ApiError) as ctx,
|
|
1757
|
+
):
|
|
1758
|
+
c.chat([Message(role="user", content="hi")])
|
|
1759
|
+
|
|
1760
|
+
self.assertEqual(calls["n"], 1)
|
|
1761
|
+
self.assertIn("502", str(ctx.exception))
|
|
1762
|
+
c.close()
|
|
1763
|
+
|
|
1764
|
+
def test_custom_status_code_triggers_auth_refresh(self):
|
|
1765
|
+
"""A custom status code added to AUTH_REFRESH_STATUS_CODES
|
|
1766
|
+
triggers auth refresh instead of retryable backoff."""
|
|
1767
|
+
from python_agent_harness.client import AuthExpiredError
|
|
1768
|
+
|
|
1769
|
+
c = make_offline_client(retry_max=3, retry_base_delay=0.01, retry_max_delay=0.01)
|
|
1770
|
+
calls = {"n": 0}
|
|
1771
|
+
|
|
1772
|
+
def fake_stream(payload, on_delta, on_tool_call, usage):
|
|
1773
|
+
n = calls["n"]
|
|
1774
|
+
calls["n"] += 1
|
|
1775
|
+
if n == 0:
|
|
1776
|
+
raise AuthExpiredError("API error 419: Token Expired")
|
|
1777
|
+
return (["ok"], [], {})
|
|
1778
|
+
|
|
1779
|
+
with (
|
|
1780
|
+
mock.patch("python_agent_harness.config.AUTH_REFRESH_STATUS_CODES", [401, 419]),
|
|
1781
|
+
mock.patch.object(c, "_stream_response", side_effect=fake_stream),
|
|
1782
|
+
mock.patch.object(c, "_refresh_api_key", return_value=True),
|
|
1783
|
+
):
|
|
1784
|
+
msg, _ = c.chat([Message(role="user", content="hi")])
|
|
1785
|
+
|
|
1786
|
+
self.assertEqual(calls["n"], 2)
|
|
1787
|
+
self.assertEqual(msg.text(), "ok")
|
|
1788
|
+
c.close()
|
|
1789
|
+
|
|
1790
|
+
def test_503_not_in_auth_refresh_still_retryable(self):
|
|
1791
|
+
"""A 503 (not in AUTH_REFRESH_STATUS_CODES by default) is still
|
|
1792
|
+
treated as a retryable error, not an auth-refresh error."""
|
|
1793
|
+
from python_agent_harness.client import ApiError, RetryableApiError
|
|
1794
|
+
|
|
1795
|
+
c = make_offline_client(retry_max=2, retry_base_delay=0.01, retry_max_delay=0.01)
|
|
1796
|
+
calls = {"n": 0}
|
|
1797
|
+
|
|
1798
|
+
def fake_stream(payload, on_delta, on_tool_call, usage):
|
|
1799
|
+
calls["n"] += 1
|
|
1800
|
+
raise RetryableApiError("API error 503: Service Unavailable")
|
|
1801
|
+
|
|
1802
|
+
with (
|
|
1803
|
+
mock.patch.object(c, "_stream_response", side_effect=fake_stream),
|
|
1804
|
+
mock.patch.object(c, "_refresh_api_key") as refresh_mock,
|
|
1805
|
+
self.assertRaises(ApiError),
|
|
1806
|
+
):
|
|
1807
|
+
c.chat([Message(role="user", content="hi")])
|
|
1808
|
+
|
|
1809
|
+
# retried up to retry_max, never triggered auth refresh
|
|
1810
|
+
self.assertEqual(calls["n"], 2)
|
|
1811
|
+
refresh_mock.assert_not_called()
|
|
1812
|
+
c.close()
|
|
1813
|
+
|
|
1814
|
+
def test_502_on_sync_request_also_triggers_refresh(self):
|
|
1815
|
+
"""Non-streaming requests also benefit from 502 auth refresh."""
|
|
1816
|
+
from python_agent_harness.client import AuthExpiredError
|
|
1817
|
+
|
|
1818
|
+
c = make_offline_client(retry_max=3, retry_base_delay=0.01, retry_max_delay=0.01)
|
|
1819
|
+
calls = {"n": 0}
|
|
1820
|
+
|
|
1821
|
+
def fake_sync(payload, on_delta, on_tool_call, usage):
|
|
1822
|
+
n = calls["n"]
|
|
1823
|
+
calls["n"] += 1
|
|
1824
|
+
if n == 0:
|
|
1825
|
+
raise AuthExpiredError("API error 502: Bad Gateway")
|
|
1826
|
+
return (["title"], [], {})
|
|
1827
|
+
|
|
1828
|
+
with (
|
|
1829
|
+
mock.patch.object(c, "_sync_response", side_effect=fake_sync),
|
|
1830
|
+
mock.patch.object(c, "_refresh_api_key", return_value=True),
|
|
1831
|
+
):
|
|
1832
|
+
msg, _ = c.chat_sync([Message(role="user", content="hi")])
|
|
1833
|
+
|
|
1834
|
+
self.assertEqual(calls["n"], 2)
|
|
1835
|
+
self.assertEqual(msg.text(), "title")
|
|
1836
|
+
c.close()
|
|
1837
|
+
|
|
1838
|
+
|
|
1710
1839
|
class TestContextWindow(unittest.TestCase):
|
|
1711
1840
|
"""Client.context_window: config-file overrides -> CONTEXT_WINDOWS
|
|
1712
1841
|
patterns -> DEFAULT_CONTEXT_WINDOW. Resolved on every access (no
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/commands.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/diffrender.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/client.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/config.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/manager.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/models.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/persistence.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/planmode.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/agent.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/plan.md
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/review.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/title.md
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/subagent.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/bash.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/edit.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/edit_mac.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/glob_mac.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/grep_mac.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/insert.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/mcp.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/mkdir.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/planexit.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/question.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/todo.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/write.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/commands.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/input.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_concurrent_subagents.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_subagent_isolation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|