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.
Files changed (92) hide show
  1. {python_agent_harness-1.5.4.8/python_agent_harness.egg-info → python_agent_harness-1.5.5.0}/PKG-INFO +3 -3
  2. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/README.md +2 -2
  3. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/client.py +89 -26
  6. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/config.py +9 -4
  7. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/session.py +15 -2
  8. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tool_runner.py +82 -0
  9. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/base.py +5 -0
  10. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/filesystem.py +9 -3
  11. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/glob.py +1 -0
  12. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/grep.py +1 -0
  13. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/read.py +1 -0
  14. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/skill.py +1 -0
  15. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/core.py +26 -18
  16. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/render.py +23 -8
  17. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0/python_agent_harness.egg-info}/PKG-INFO +3 -3
  18. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_client.py +129 -0
  19. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/LICENSE +0 -0
  20. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/__main__.py +0 -0
  21. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/agent.py +0 -0
  22. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/cli.py +0 -0
  23. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/commands.py +0 -0
  24. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/context_manager.py +0 -0
  25. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/diffrender.py +0 -0
  26. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/__init__.py +0 -0
  27. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/client.py +0 -0
  28. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/config.py +0 -0
  29. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/mcp/manager.py +0 -0
  30. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/models.py +0 -0
  31. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/persistence.py +0 -0
  32. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/planmode.py +0 -0
  33. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/agent.md +0 -0
  34. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/build-switch.md +0 -0
  35. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/commands/explain.md +0 -0
  36. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/compact.md +0 -0
  37. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/initialize.md +0 -0
  38. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/plan-mode.md +0 -0
  39. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/plan.md +0 -0
  40. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/review.md +0 -0
  41. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/subagent.md +0 -0
  42. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/summary.md +0 -0
  43. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  44. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts/title.md +0 -0
  45. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/prompts.py +0 -0
  46. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/subagent.py +0 -0
  47. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/token_estimator.py +0 -0
  48. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/__init__.py +0 -0
  49. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/agent_tool.py +0 -0
  50. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/bash.py +0 -0
  51. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/diffapply.py +0 -0
  52. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/edit.py +0 -0
  53. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/edit_mac.py +0 -0
  54. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/glob_mac.py +0 -0
  55. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/grep_mac.py +0 -0
  56. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/insert.py +0 -0
  57. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/mcp.py +0 -0
  58. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/mkdir.py +0 -0
  59. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/planexit.py +0 -0
  60. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/question.py +0 -0
  61. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/todo.py +0 -0
  62. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tools/write.py +0 -0
  63. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/__init__.py +0 -0
  64. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/commands.py +0 -0
  65. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness/tui/input.py +0 -0
  66. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  67. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  68. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/entry_points.txt +0 -0
  69. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/requires.txt +0 -0
  70. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/python_agent_harness.egg-info/top_level.txt +0 -0
  71. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/setup.cfg +0 -0
  72. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_agents_md.py +0 -0
  73. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_cli.py +0 -0
  74. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_commands.py +0 -0
  75. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_concurrent_subagents.py +0 -0
  76. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_config.py +0 -0
  77. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_context_rules.py +0 -0
  78. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_diffrender.py +0 -0
  79. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_filesystem.py +0 -0
  80. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_invariants.py +0 -0
  81. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_mcp.py +0 -0
  82. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_models.py +0 -0
  83. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_persistence.py +0 -0
  84. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_planmode.py +0 -0
  85. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_prompts.py +0 -0
  86. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_scenarios.py +0 -0
  87. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_session.py +0 -0
  88. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_subagent.py +0 -0
  89. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_subagent_isolation.py +0 -0
  90. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_todos_scope.py +0 -0
  91. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_token_estimator.py +0 -0
  92. {python_agent_harness-1.5.4.8 → python_agent_harness-1.5.5.0}/tests/test_tools_misc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.4.8
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 while preserving emitted order.
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 while preserving emitted order.
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.4.8"
7
+ version = "1.5.5.0"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,7 +5,7 @@ from .mcp.manager import MCPManager
5
5
  from .models import AgentMode, Message, ToolCall, ToolSpec
6
6
  from .session import Session
7
7
 
8
- __version__ = "1.5.4.8"
8
+ __version__ = "1.5.5.0"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -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 401 — the credential may have expired.
52
+ """Raised on auth-expired HTTP status codes.
52
53
 
53
- Handled specially in the retry loop: the API key is re-read from
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
- # HTTP 401: the credential (often a JWT with a short
511
- # TTL) may have expired. Re-read the API key from the
512
- # config file / environment an external token-refresh
513
- # process may have written a new one. Poll for up to
514
- # 30s waiting for the key to change; retry once if it
515
- # does. Fail immediately if already refreshed once
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 == 401:
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 == 401:
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
- pool = getattr(getattr(client, "_transport", None), "_pool", None)
853
- for conn in getattr(pool, "_connections", None) or ():
854
- try:
855
- stream = getattr(getattr(conn, "_connection", None), "_network_stream", None)
856
- sock = stream.get_extra_info("socket") if stream is not None else None
857
- except Exception: # noqa: BLE001 - best effort
858
- sock = None
859
- if sock is not None:
860
- # shutdown first (wakes the recv on Linux without touching
861
- # the fd), then close the fd (the reliable wake on
862
- # macOS/BSD, where shutdown alone may not).
863
- with contextlib.suppress(OSError):
864
- sock.shutdown(_socket.SHUT_RDWR)
865
- with contextlib.suppress(OSError):
866
- sock.close()
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]:
@@ -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
@@ -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._skill_index_cache = (key, index_skills(key))
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:
@@ -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
 
@@ -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
- _spooled_files.append(temp_file)
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
- paths = _spooled_files[:]
116
- _spooled_files.clear()
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
  ]
@@ -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'
@@ -18,6 +18,7 @@ from .filesystem import _git_root, _spool
18
18
 
19
19
  class Grep(Tool):
20
20
  name = "Grep"
21
+ is_readonly = True
21
22
  description = (
22
23
  "Search file contents with a regular expression. "
23
24
  "Use this for content search; use Glob for filename search. "
@@ -18,6 +18,7 @@ from .filesystem import READ_SIZE_LIMIT, _spool
18
18
 
19
19
  class Read(Tool):
20
20
  name = "Read"
21
+ is_readonly = True
21
22
  description = (
22
23
  "Read file contents between specified line numbers `start_line` and "
23
24
  "`end_line`, with both ends included.\n\n"
@@ -9,6 +9,7 @@ from .base import Tool, ToolContext
9
9
 
10
10
  class Skill(Tool):
11
11
  name = "Skill"
12
+ is_readonly = True
12
13
  description = (
13
14
  "Load a skill to get detailed instructions for a specific task. "
14
15
  "Use this when a task matches an available skill's description. "
@@ -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._current_tool = label
109
- self.status = f" ⏳ {label}"
110
- self._history_dirty = True
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._current_tool = name
116
- self.status = f" ⏳ {name}" if name else " ⏳ tools"
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._history_dirty = True
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.status = " compacted"
126
- self._history_dirty = True
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
- self.status = " connection lost — retrying"
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._history_dirty = True
141
+ with self.lock:
142
+ self._history_dirty = True
139
143
  elif kind == "error":
140
- self.status = " error"
144
+ with self.lock:
145
+ self.status = " error"
141
146
  elif kind == "save-error":
142
- self.status = " auto-save failed"
147
+ with self.lock:
148
+ self.status = " auto-save failed"
143
149
  else:
144
- self.status = " running"
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.status = f" {msg}"
155
+ with self.lock:
156
+ self.status = f" {msg}"
149
157
 
150
158
  # ------------------------------------------------------------------
151
159
  # main loop
@@ -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
- if self._history_dirty or self._history_cache is None:
333
- self._history_cache = self._build_history_rows()
334
- self._history_dirty = False
335
- return list(self._history_cache)
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
- if self._current_tool:
503
- t.append(f" {self._current_tool}", style="bold cyan")
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
- t.append(self._fit_status(cell_len(str(t)), self.status), style=self._status_style())
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
- s = self.status
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.4.8
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 while preserving emitted order.
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