python-agent-harness 1.5.5.1__tar.gz → 1.5.5.2__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 (94) hide show
  1. {python_agent_harness-1.5.5.1/python_agent_harness.egg-info → python_agent_harness-1.5.5.2}/PKG-INFO +13 -2
  2. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/README.md +1 -0
  3. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/pyproject.toml +18 -5
  4. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/agent.py +13 -2
  6. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/persistence.py +1 -7
  7. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/commands.py +2 -2
  8. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/core.py +4 -0
  9. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/render.py +52 -17
  10. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2/python_agent_harness.egg-info}/PKG-INFO +13 -2
  11. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_cli.py +1 -0
  12. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_concurrent_subagents.py +2 -0
  13. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_custom_agents.py +4 -0
  14. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_invariants.py +1 -0
  15. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_mcp.py +1 -0
  16. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_persistence.py +6 -9
  17. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_planmode.py +1 -0
  18. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_scenarios.py +1 -0
  19. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_session.py +1 -0
  20. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_subagent.py +1 -0
  21. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_subagent_isolation.py +57 -0
  22. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_todos_scope.py +6 -0
  23. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_tools_misc.py +4 -0
  24. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/LICENSE +0 -0
  25. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/__main__.py +0 -0
  26. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/cli.py +0 -0
  27. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/client.py +0 -0
  28. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/commands.py +0 -0
  29. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/config.py +0 -0
  30. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/context_manager.py +0 -0
  31. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/diffrender.py +0 -0
  32. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/mcp/__init__.py +0 -0
  33. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/mcp/client.py +0 -0
  34. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/mcp/config.py +0 -0
  35. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/mcp/manager.py +0 -0
  36. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/models.py +0 -0
  37. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/planmode.py +0 -0
  38. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/agent.md +0 -0
  39. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  40. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/build-switch.md +0 -0
  41. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/commands/explain.md +0 -0
  42. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/compact.md +0 -0
  43. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/initialize.md +0 -0
  44. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/plan-mode.md +0 -0
  45. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/plan.md +0 -0
  46. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/review.md +0 -0
  47. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/subagent.md +0 -0
  48. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/summary.md +0 -0
  49. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  50. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/title.md +0 -0
  51. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts.py +0 -0
  52. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/session.py +0 -0
  53. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/subagent.py +0 -0
  54. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/token_estimator.py +0 -0
  55. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tool_runner.py +0 -0
  56. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/__init__.py +0 -0
  57. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/agent_tool.py +0 -0
  58. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/base.py +0 -0
  59. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/bash.py +0 -0
  60. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/diffapply.py +0 -0
  61. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/edit.py +0 -0
  62. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/edit_mac.py +0 -0
  63. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/filesystem.py +0 -0
  64. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/glob.py +0 -0
  65. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/glob_mac.py +0 -0
  66. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/grep.py +0 -0
  67. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/grep_mac.py +0 -0
  68. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/insert.py +0 -0
  69. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/mcp.py +0 -0
  70. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/mkdir.py +0 -0
  71. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/planexit.py +0 -0
  72. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/question.py +0 -0
  73. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/read.py +0 -0
  74. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/skill.py +0 -0
  75. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/todo.py +0 -0
  76. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/write.py +0 -0
  77. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/__init__.py +0 -0
  78. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/input.py +0 -0
  79. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  80. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  81. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/entry_points.txt +0 -0
  82. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/requires.txt +0 -0
  83. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/top_level.txt +0 -0
  84. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/setup.cfg +0 -0
  85. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_agents_md.py +0 -0
  86. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_client.py +0 -0
  87. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_commands.py +0 -0
  88. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_config.py +0 -0
  89. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_context_rules.py +0 -0
  90. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_diffrender.py +0 -0
  91. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_filesystem.py +0 -0
  92. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_models.py +0 -0
  93. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_prompts.py +0 -0
  94. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.2}/tests/test_token_estimator.py +0 -0
@@ -1,9 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.1
3
+ Version: 1.5.5.2
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
- Author: huming
5
+ Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/beacoder/python-agent-harness
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
7
17
  Requires-Python: >=3.11
8
18
  Description-Content-Type: text/markdown
9
19
  License-File: LICENSE
@@ -31,6 +41,7 @@ FSM-driven execution · OpenAI-compatible · built for daily use and easy custom
31
41
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
32
42
  [![PyPI](https://img.shields.io/pypi/v/python-agent-harness.svg)](https://pypi.org/project/python-agent-harness/)
33
43
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
44
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/python-agent-harness?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/python-agent-harness)
34
45
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
35
46
 
36
47
  </div>
@@ -8,6 +8,7 @@ FSM-driven execution · OpenAI-compatible · built for daily use and easy custom
8
8
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
9
9
  [![PyPI](https://img.shields.io/pypi/v/python-agent-harness.svg)](https://pypi.org/project/python-agent-harness/)
10
10
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
11
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/python-agent-harness?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/python-agent-harness)
11
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
13
 
13
14
  </div>
@@ -4,21 +4,34 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.5.1"
7
+ version = "1.5.5.2"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
+ authors = [
10
+ {name = "Huming Chen", email = "chenhuming@gmail.com"}
11
+ ]
12
+ license = "MIT"
9
13
  readme = "README.md"
10
14
  requires-python = ">=3.11"
11
- authors = [
12
- { name = "huming" }
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14"
13
25
  ]
14
- license = 'MIT'
15
- license-files = ["LICENSE"]
16
26
  dependencies = [
17
27
  "rich>=13.0",
18
28
  "httpx>=0.27",
19
29
  "prompt_toolkit>=3.0",
20
30
  ]
21
31
 
32
+ [project.urls]
33
+ Homepage = "https://github.com/beacoder/python-agent-harness"
34
+
22
35
  [project.optional-dependencies]
23
36
  # MCP (Model Context Protocol) client support: exposes MCP server tools to
24
37
  # the agent as namespaced mcp__<server>__<tool> tools. Install with:
@@ -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.5.1"
8
+ __version__ = "1.5.5.2"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -637,14 +637,25 @@ def run_agent_loop(
637
637
 
638
638
  ``client`` (when given) overrides the session's client for this
639
639
  run — the per-invocation sub-agent clone."""
640
- return AgentLoop(
640
+ loop = AgentLoop(
641
641
  session,
642
642
  messages=messages,
643
643
  top_level=top_level,
644
644
  system=system,
645
645
  max_rounds=max_rounds,
646
646
  client=client,
647
- ).run()
647
+ )
648
+ result = loop.run()
649
+ # Notify run_done only for a successfully completed top-level run:
650
+ # - top_level: sub-agents share the parent's session (notify_fn
651
+ # included) and must not mark the parent run finished while it is
652
+ # still executing tools
653
+ # - DONE only: ERRS already notifies "error" with the detail, and
654
+ # ABRT is cancellation/supersession (TUI sets that status). DONE
655
+ # also implies not cancelled — the FSM routes cancelled to ABRT.
656
+ if loop.top_level and loop.state == AgentLoop.DONE:
657
+ session.notify("run_done")
658
+ return result
648
659
 
649
660
 
650
661
  class Supervisor:
@@ -170,18 +170,12 @@ class SessionPersistence:
170
170
  lines = [";; Local Variables:"]
171
171
  pairs = [
172
172
  ("python-agent-harness--project-dir", self.project_dir),
173
- ("gptel-model", self.model),
174
- ("gptel-system-prompt", self.system_prompt),
175
- ("gptel-temperature", self.temperature),
176
- ("gptel-max-tokens", self.max_tokens),
173
+ ("python-agent-harness--model", self.model),
177
174
  ]
178
175
  for name, value in pairs:
179
176
  if value is None:
180
177
  continue
181
178
  lines.append(f";; {name}: {value!r}")
182
- if self.tool_names:
183
- names = " ".join(f'"{n}"' for n in self.tool_names)
184
- lines.append(f";; gptel--tool-names: ({names})")
185
179
  if self.round_times:
186
180
  stamps = " ".join(repr(float(t)) for t in self.round_times)
187
181
  lines.append(f";; python-agent-harness--round-times: {stamps}")
@@ -578,7 +578,7 @@ class CommandMixin:
578
578
  continue
579
579
  meta = SessionPersistence.parse_metadata(text)
580
580
  basename = os.path.basename(f)
581
- model = meta.get("gptel-model", "?")
581
+ model = meta.get("python-agent-harness--model", "?")
582
582
  project = meta.get("python-agent-harness--project-dir", "?")
583
583
  self.console.print(f" {basename:50s} model={model:20s} project={project}")
584
584
 
@@ -645,7 +645,7 @@ class CommandMixin:
645
645
  self.session.last_messages = list(messages)
646
646
  self.session.clear_todos()
647
647
  self._history_dirty = True
648
- model = meta.get("gptel-model", "?")
648
+ model = meta.get("python-agent-harness--model", "?")
649
649
  project = meta.get("python-agent-harness--project-dir", "?")
650
650
  self.console.print(
651
651
  f"[green]restored:[/green] {os.path.basename(path)} "
@@ -153,6 +153,10 @@ class Tui(RenderMixin, InputMixin, CommandMixin):
153
153
  elif kind == "save-error":
154
154
  with self.lock:
155
155
  self.status = " auto-save failed"
156
+ elif kind == "run_done":
157
+ with self.lock:
158
+ self.status = " done"
159
+ self._history_dirty = True
156
160
  else:
157
161
  with self.lock:
158
162
  self.status = " running"
@@ -450,9 +450,9 @@ class RenderMixin:
450
450
  height = getattr(self.console, "height", None)
451
451
  if not height or height <= 0:
452
452
  return 60
453
- # reserve: status bar (1) + input prompt (1)
453
+ # reserve: status bar (up to 2 lines) + input prompt (1)
454
454
  # + the pinned Todos section when visible (its title line + rows)
455
- reserved = 2
455
+ reserved = 3
456
456
  if self.session.todos:
457
457
  reserved += min(len(self.session.todos), 8) + 1
458
458
  return max(5, height - reserved)
@@ -521,23 +521,58 @@ class RenderMixin:
521
521
  return t
522
522
 
523
523
  def _fit_status(self, used: int, msg: str) -> str:
524
- """Fit the status message on the status-bar line: newlines are
525
- flattened and the message is truncated with an ellipsis only
526
- when it would overflow the terminal width."""
524
+ """Fit the status message on the status bar: up to two lines.
525
+
526
+ Line 1 shares its row with the mode/context prefix (``used``
527
+ cells); line 2 gets the full terminal width. Newlines in the
528
+ message are flattened, words break at spaces where possible,
529
+ and anything beyond two lines is truncated with an ellipsis.
530
+ """
527
531
  msg = " ".join(msg.splitlines())
528
532
  width = self.console.width or 80
529
- avail = max(1, width - used - 1)
530
- if cell_len(msg) <= avail:
531
- return msg
532
- out = ""
533
- used_cells = 0
534
- for ch in msg:
535
- w = cell_len(ch)
536
- if used_cells + w > avail - 1:
537
- break
538
- out += ch
539
- used_cells += w
540
- return out + "…"
533
+ avail1 = max(1, width - used - 1)
534
+ avail2 = max(1, width - 2)
535
+ line1, rest = self._wrap_line(msg, avail1)
536
+ if not rest:
537
+ return line1
538
+ line2, rest2 = self._wrap_line(rest, avail2)
539
+ if rest2:
540
+ # re-wrap reserving a cell so the ellipsis fits the line
541
+ line2, _ = self._wrap_line(rest, max(1, avail2 - 1))
542
+ return f"{line1}\n{line2}…"
543
+ return f"{line1}\n{line2}"
544
+
545
+ @staticmethod
546
+ def _wrap_line(text: str, avail: int) -> tuple[str, str]:
547
+ """Split ``text`` into ``(line, remainder)`` where ``line`` is the
548
+ longest prefix fitting ``avail`` cells.
549
+
550
+ Breaks at a space so words stay whole; a single word wider than
551
+ the line (long JSON tokens, URLs) is hard-broken at the cell
552
+ boundary instead of dropped.
553
+ """
554
+ if cell_len(text) <= avail:
555
+ return text, ""
556
+ words = text.split(" ")
557
+ line = ""
558
+ for idx, word in enumerate(words):
559
+ candidate = word if not line else f"{line} {word}"
560
+ if cell_len(candidate) <= avail:
561
+ line = candidate
562
+ continue
563
+ if not line:
564
+ # one word wider than the whole line: cut mid-word
565
+ take = ""
566
+ for ch in word:
567
+ if cell_len(take) + cell_len(ch) > avail:
568
+ break
569
+ take += ch
570
+ rest_word = word[len(take) :]
571
+ tail = words[idx + 1 :]
572
+ remainder = " ".join([rest_word, *tail]) if rest_word else " ".join(tail)
573
+ return take, remainder
574
+ return line, " ".join(words[idx:])
575
+ return line, ""
541
576
 
542
577
  def _status_style(self) -> str:
543
578
  """Status-bar color by state: errors red, activity cyan, idle dim."""
@@ -1,9 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.1
3
+ Version: 1.5.5.2
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
- Author: huming
5
+ Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/beacoder/python-agent-harness
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
7
17
  Requires-Python: >=3.11
8
18
  Description-Content-Type: text/markdown
9
19
  License-File: LICENSE
@@ -31,6 +41,7 @@ FSM-driven execution · OpenAI-compatible · built for daily use and easy custom
31
41
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
32
42
  [![PyPI](https://img.shields.io/pypi/v/python-agent-harness.svg)](https://pypi.org/project/python-agent-harness/)
33
43
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
44
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/python-agent-harness?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/python-agent-harness)
34
45
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
35
46
 
36
47
  </div>
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
  sys.path.insert(0, os.path.dirname(__file__))
12
12
 
13
13
  import plan_cleanup # noqa: F401,E402 (side-effect: auto-remove /tmp plan dirs)
14
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
14
15
 
15
16
  from python_agent_harness import cli, config
16
17
 
@@ -28,6 +28,8 @@ import time
28
28
  import unittest
29
29
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
30
30
 
31
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
32
+
31
33
  from python_agent_harness.client import Client
32
34
  from python_agent_harness.session import Session
33
35
  from python_agent_harness.tools import default_registry
@@ -11,6 +11,10 @@ import unittest
11
11
  from pathlib import Path
12
12
  from unittest import mock
13
13
 
14
+ sys.path.insert(0, os.path.dirname(__file__))
15
+
16
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
17
+
14
18
  from python_agent_harness import config
15
19
  from python_agent_harness.client import Client
16
20
  from python_agent_harness.prompts import AGENTS_DIR, discover_agents
@@ -54,6 +54,7 @@ from unittest import mock
54
54
  sys.path.insert(0, os.path.dirname(__file__)) # sibling test helpers
55
55
 
56
56
  import plan_cleanup # noqa: F401,E402 (side-effect: auto-remove /tmp plan dirs)
57
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
57
58
  from agent.agent_test_utils import ParallelToolSession, RecordingSession, agent_call # noqa: E402
58
59
 
59
60
  from python_agent_harness import config # noqa: E402
@@ -21,6 +21,7 @@ from unittest import mock
21
21
 
22
22
  sys.path.insert(0, os.path.dirname(__file__))
23
23
 
24
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
24
25
  from agent.agent_test_utils import RecordingSession
25
26
 
26
27
  from python_agent_harness import config
@@ -37,8 +37,7 @@ class TestSession(unittest.TestCase):
37
37
  text = "conversation...\n\n" + meta + "\n"
38
38
  parsed = SessionPersistence.parse_metadata(text)
39
39
  self.assertEqual(parsed["python-agent-harness--project-dir"], "/tmp/proj")
40
- self.assertEqual(parsed["gptel-model"], "deepseek-v4")
41
- self.assertIn("glob", parsed["gptel--tool-names"])
40
+ self.assertEqual(parsed["python-agent-harness--model"], "deepseek-v4")
42
41
  stripped = SessionPersistence.strip_metadata(text)
43
42
  self.assertEqual(stripped.strip(), "conversation...")
44
43
 
@@ -252,22 +251,20 @@ class TestSession(unittest.TestCase):
252
251
  ";; Local Variables:\n"
253
252
  "plain line without marker\n"
254
253
  ";; no-colon-here\n"
255
- ";; gptel-model: 'm'\n"
256
- ";; gptel--tool-names: ['a', 'b']\n"
257
- ";; gptel-system-prompt: unquoted-value\n"
254
+ ";; python-agent-harness--project-dir: '/tmp/proj'\n"
255
+ ";; python-agent-harness--model: 'deepseek-v4'\n"
258
256
  ";; End:\n"
259
257
  )
260
258
  parsed = SessionPersistence.parse_metadata(text)
261
- self.assertEqual(parsed["gptel-model"], "m")
262
- self.assertEqual(parsed["gptel--tool-names"], "a b")
263
- self.assertEqual(parsed["gptel-system-prompt"], "unquoted-value")
259
+ self.assertEqual(parsed["python-agent-harness--project-dir"], "/tmp/proj")
260
+ self.assertEqual(parsed["python-agent-harness--model"], "deepseek-v4")
264
261
  self.assertNotIn("plain line without marker", parsed)
265
262
 
266
263
  def test_strip_metadata_without_block(self):
267
264
  self.assertEqual(SessionPersistence.strip_metadata("plain"), "plain")
268
265
 
269
266
  def test_strip_metadata_without_end_marker(self):
270
- text = "conversation\n;; Local Variables:\n;; gptel-model: 'm'\n"
267
+ text = "conversation\n;; Local Variables:\n;; python-agent-harness--model: 'm'\n"
271
268
  self.assertEqual(SessionPersistence.strip_metadata(text), "conversation")
272
269
 
273
270
  def test_latest_session_missing_dir(self):
@@ -8,6 +8,7 @@ from unittest import mock
8
8
  sys.path.insert(0, os.path.dirname(__file__))
9
9
 
10
10
  import plan_cleanup # noqa: F401,E402 (side-effect: auto-remove /tmp plan dirs)
11
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
11
12
 
12
13
  from python_agent_harness.models import AgentMode
13
14
  from python_agent_harness.planmode import PlanMode, _plan_temp_dir
@@ -23,6 +23,7 @@ from unittest import mock
23
23
  sys.path.insert(0, os.path.dirname(__file__))
24
24
 
25
25
  import plan_cleanup # noqa: F401,E402 (side-effect: auto-remove /tmp plan dirs)
26
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
26
27
 
27
28
  from python_agent_harness import config
28
29
  from python_agent_harness.agent import AgentLoop
@@ -10,6 +10,7 @@ from unittest import mock
10
10
  sys.path.insert(0, os.path.dirname(__file__))
11
11
 
12
12
  import plan_cleanup # noqa: F401,E402 (side-effect: auto-remove /tmp plan dirs)
13
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
13
14
  from agent.agent_test_utils import FakeClient, RecordingSession
14
15
 
15
16
  from python_agent_harness import config
@@ -10,6 +10,7 @@ import unittest
10
10
  sys.path.insert(0, os.path.dirname(__file__))
11
11
 
12
12
  import plan_cleanup # noqa: F401,E402 (side-effect: auto-remove /tmp plan dirs)
13
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
13
14
 
14
15
  from python_agent_harness.models import Message, Usage
15
16
  from python_agent_harness.persistence import SessionPersistence
@@ -4,8 +4,14 @@ session, and must never clobber the parent's history.
4
4
  """
5
5
 
6
6
  import json
7
+ import os
8
+ import sys
7
9
  import unittest
8
10
 
11
+ sys.path.insert(0, os.path.dirname(__file__))
12
+
13
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
14
+
9
15
  from python_agent_harness import config
10
16
  from python_agent_harness.agent import run_agent_loop
11
17
  from python_agent_harness.models import Message, ToolCall, Usage
@@ -127,6 +133,57 @@ class TestSubagentIsolation(unittest.TestCase):
127
133
  self.assertIn(1, client.streamed) # parent's Agent-call turn
128
134
  self.assertIn(4, client.streamed) # parent's reply turn
129
135
 
136
+ def test_run_done_notified_once_for_parent_not_subagent(self):
137
+ """'run_done' fires exactly once for the top-level run reaching
138
+ DONE — the inner sub-agent loop (which shares the parent's
139
+ session) must not fire it mid-parent-run, and the sub-agent's
140
+ own DONE must not double-fire after the parent already notified."""
141
+ client = RecClient(
142
+ [
143
+ ("", [ToolCall(id="p1", name="Agent", arguments=AGENT_CALL)]),
144
+ ("", [ToolCall(id="s1", name="Read", arguments="{}")]),
145
+ "sub done", # sub-agent reaches DONE
146
+ "parent done", # parent reaches DONE
147
+ ]
148
+ )
149
+ s = make_session(client)
150
+ kinds = []
151
+ s.notify_fn = lambda kind, data=None: kinds.append(kind)
152
+ run_agent_loop(s, messages=[Message(role="user", content="delegate")], top_level=True)
153
+ self.assertEqual(kinds.count("run_done"), 1)
154
+
155
+ def test_run_done_not_sent_on_error_or_cancel(self):
156
+ """ERRS (API error) and ABRT (Ctrl-C) must not notify run_done —
157
+ those states have their own status paths ('error', cancel)."""
158
+
159
+ # ERRS: the client raises mid-request
160
+ class BoomClient(RecClient):
161
+ def chat(self, *a, **k):
162
+ raise RuntimeError("boom")
163
+
164
+ s = make_session(BoomClient([]))
165
+ kinds = []
166
+ s.notify_fn = lambda kind, data=None: kinds.append(kind)
167
+ run_agent_loop(s, messages=[Message(role="user", content="go")], top_level=True)
168
+ self.assertNotIn("run_done", kinds)
169
+
170
+ # ABRT: cancel before the run; the loop routes to ABRT
171
+ s2 = make_session(RecClient(["done"]))
172
+ kinds2 = []
173
+ s2.notify_fn = lambda kind, data=None: kinds2.append(kind)
174
+ s2.cancel()
175
+ run_agent_loop(s2, messages=[Message(role="user", content="go")], top_level=True)
176
+ self.assertNotIn("run_done", kinds2)
177
+
178
+ def test_run_done_without_notify_fn_is_noop(self):
179
+ """A session without a TUI (notify_fn None) must not crash when
180
+ the run completes (Session.notify no-ops)."""
181
+ client = RecClient(["done"])
182
+ s = make_session(client)
183
+ run_agent_loop(
184
+ s, messages=[Message(role="user", content="go")], top_level=True
185
+ ) # must not raise
186
+
130
187
  def test_subagent_uses_separate_client_when_configured(self):
131
188
  """A session with a dedicated sub-agent client (subagent_llm in
132
189
  the config) routes sub-agent requests through it with its own
@@ -4,8 +4,14 @@ todo list.
4
4
  """
5
5
 
6
6
  import json
7
+ import os
8
+ import sys
7
9
  import unittest
8
10
 
11
+ sys.path.insert(0, os.path.dirname(__file__))
12
+
13
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
14
+
9
15
  from python_agent_harness.agent import AgentLoop
10
16
  from python_agent_harness.models import Message, ToolCall, Usage
11
17
  from python_agent_harness.session import Session
@@ -18,6 +18,10 @@ import time
18
18
  import unittest
19
19
  from unittest import mock
20
20
 
21
+ sys.path.insert(0, os.path.dirname(__file__))
22
+
23
+ import session_sandbox # noqa: F401,E402 (side-effect: redirect SESSION_DIR)
24
+
21
25
  from python_agent_harness.tools.agent_tool import AgentTool
22
26
  from python_agent_harness.tools.base import PendingToolResult, Registry, ToolContext
23
27
  from python_agent_harness.tools.planexit import PlanExit