python-agent-harness 1.5.5.3__tar.gz → 1.5.5.5__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 (98) hide show
  1. {python_agent_harness-1.5.5.3/python_agent_harness.egg-info → python_agent_harness-1.5.5.5}/PKG-INFO +21 -2
  2. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/README.md +20 -1
  3. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/cli.py +5 -1
  6. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/persistence.py +3 -0
  7. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/agents/reviewer.md +10 -0
  8. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts.py +62 -14
  9. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/session.py +53 -4
  10. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/diffapply.py +2 -2
  11. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/grep_win.py +12 -7
  12. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/commands.py +74 -15
  13. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5/python_agent_harness.egg-info}/PKG-INFO +21 -2
  14. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_custom_agents.py +266 -0
  15. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_persistence.py +16 -0
  16. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/LICENSE +0 -0
  17. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/__main__.py +0 -0
  18. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/agent.py +0 -0
  19. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/client.py +0 -0
  20. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/commands.py +0 -0
  21. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/config.py +0 -0
  22. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/context_manager.py +0 -0
  23. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/diffrender.py +0 -0
  24. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/mcp/__init__.py +0 -0
  25. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/mcp/client.py +0 -0
  26. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/mcp/config.py +0 -0
  27. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/mcp/manager.py +0 -0
  28. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/models.py +0 -0
  29. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/planmode.py +0 -0
  30. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/agent.md +0 -0
  31. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/build-switch.md +0 -0
  32. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/commands/explain.md +0 -0
  33. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/compact.md +0 -0
  34. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/initialize.md +0 -0
  35. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/plan-mode.md +0 -0
  36. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/plan.md +0 -0
  37. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/review.md +0 -0
  38. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/subagent.md +0 -0
  39. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/summary.md +0 -0
  40. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  41. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/title.md +0 -0
  42. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/subagent.py +0 -0
  43. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/token_estimator.py +0 -0
  44. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tool_runner.py +0 -0
  45. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/__init__.py +0 -0
  46. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/agent_tool.py +0 -0
  47. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/base.py +0 -0
  48. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/bash.py +0 -0
  49. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/bash_win.py +0 -0
  50. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/edit.py +0 -0
  51. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/edit_mac.py +0 -0
  52. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/edit_win.py +0 -0
  53. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/filesystem.py +0 -0
  54. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/glob.py +0 -0
  55. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/glob_mac.py +0 -0
  56. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/glob_win.py +0 -0
  57. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/grep.py +0 -0
  58. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/grep_mac.py +0 -0
  59. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/insert.py +0 -0
  60. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/mcp.py +0 -0
  61. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/mkdir.py +0 -0
  62. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/planexit.py +0 -0
  63. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/question.py +0 -0
  64. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/read.py +0 -0
  65. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/skill.py +0 -0
  66. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/todo.py +0 -0
  67. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/write.py +0 -0
  68. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/__init__.py +0 -0
  69. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/core.py +0 -0
  70. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/input.py +0 -0
  71. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/render.py +0 -0
  72. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  73. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  74. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/entry_points.txt +0 -0
  75. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/requires.txt +0 -0
  76. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/top_level.txt +0 -0
  77. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/setup.cfg +0 -0
  78. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_agents_md.py +0 -0
  79. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_cli.py +0 -0
  80. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_client.py +0 -0
  81. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_commands.py +0 -0
  82. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_concurrent_subagents.py +0 -0
  83. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_config.py +0 -0
  84. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_context_rules.py +0 -0
  85. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_diffrender.py +0 -0
  86. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_filesystem.py +0 -0
  87. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_invariants.py +0 -0
  88. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_mcp.py +0 -0
  89. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_models.py +0 -0
  90. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_planmode.py +0 -0
  91. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_prompts.py +0 -0
  92. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_scenarios.py +0 -0
  93. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_session.py +0 -0
  94. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_subagent.py +0 -0
  95. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_subagent_isolation.py +0 -0
  96. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_todos_scope.py +0 -0
  97. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/tests/test_token_estimator.py +0 -0
  98. {python_agent_harness-1.5.5.3 → python_agent_harness-1.5.5.5}/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.5.3
3
+ Version: 1.5.5.5
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -243,13 +243,32 @@ Custom commands from `prompts/commands/*.md` are registered as slash commands as
243
243
 
244
244
  Agent prompt files are markdown files (`.md`) placed in the `prompts/agents/` directory. Each file becomes a switchable agent profile available via the `/agent` TUI command.
245
245
 
246
+ An agent file may carry YAML frontmatter with two optional keys:
247
+
248
+ - `name:` — override the agent name (defaults to the file stem)
249
+ - `exclude_tools:` — a list of tool names the agent must not see. An entry matches a tool by exact name, glob pattern (`mcp__git__*`), or `__`-delimited prefix (`mcp__git` hides `mcp__git__list_repos` but `Write` does NOT hide `TodoWrite`). The built-in `default` agent always sees all tools.
250
+
251
+ ```markdown
252
+ ---
253
+ name: assistant
254
+ exclude_tools:
255
+ - Bash
256
+ - Edit
257
+ - Write
258
+ - mcp__git__*
259
+ ---
260
+
261
+ # Role and Behavior
262
+ You are a personal assistant. You do NOT modify files or run shell commands.
263
+ ```
264
+
246
265
  #### Commands vs Agents
247
266
 
248
267
  | | Commands (`/review`) | Agents (`/agent reviewer`) |
249
268
  |---|---|---|
250
269
  | **Scope** | One-shot (prompt resets after run) | Persistent (stays until next `/agent`) |
251
270
  | **Kickoff** | Hardcoded kickoff message | User types their own prompt |
252
- | **Tools** | Can restrict (`allow_planexit=False`) | All tools |
271
+ | **Tools** | Can restrict (`allow_planexit=False`) | All tools, minus the agent's `exclude_tools` |
253
272
  | **Ctrl-C** | Restores to default prompt | Stays on the custom agent |
254
273
 
255
274
  ## Project layout
@@ -209,13 +209,32 @@ Custom commands from `prompts/commands/*.md` are registered as slash commands as
209
209
 
210
210
  Agent prompt files are markdown files (`.md`) placed in the `prompts/agents/` directory. Each file becomes a switchable agent profile available via the `/agent` TUI command.
211
211
 
212
+ An agent file may carry YAML frontmatter with two optional keys:
213
+
214
+ - `name:` — override the agent name (defaults to the file stem)
215
+ - `exclude_tools:` — a list of tool names the agent must not see. An entry matches a tool by exact name, glob pattern (`mcp__git__*`), or `__`-delimited prefix (`mcp__git` hides `mcp__git__list_repos` but `Write` does NOT hide `TodoWrite`). The built-in `default` agent always sees all tools.
216
+
217
+ ```markdown
218
+ ---
219
+ name: assistant
220
+ exclude_tools:
221
+ - Bash
222
+ - Edit
223
+ - Write
224
+ - mcp__git__*
225
+ ---
226
+
227
+ # Role and Behavior
228
+ You are a personal assistant. You do NOT modify files or run shell commands.
229
+ ```
230
+
212
231
  #### Commands vs Agents
213
232
 
214
233
  | | Commands (`/review`) | Agents (`/agent reviewer`) |
215
234
  |---|---|---|
216
235
  | **Scope** | One-shot (prompt resets after run) | Persistent (stays until next `/agent`) |
217
236
  | **Kickoff** | Hardcoded kickoff message | User types their own prompt |
218
- | **Tools** | Can restrict (`allow_planexit=False`) | All tools |
237
+ | **Tools** | Can restrict (`allow_planexit=False`) | All tools, minus the agent's `exclude_tools` |
219
238
  | **Ctrl-C** | Restores to default prompt | Stays on the custom agent |
220
239
 
221
240
  ## Project layout
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.5.3"
7
+ version = "1.5.5.5"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -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.3"
8
+ __version__ = "1.5.5.5"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -125,7 +125,11 @@ def make_session(
125
125
  )
126
126
  if default_agent and default_agent != RESERVED_AGENT_NAME:
127
127
  ok, msg = session.switch_agent(default_agent)
128
- if not ok:
128
+ if ok:
129
+ # record the applied agent so saved sessions restore it;
130
+ # switch_agent("default") clears it again
131
+ session.store.agent = default_agent
132
+ else:
129
133
  # A typo'd/missing default_agent must not silently start the
130
134
  # session with the built-in prompt — record the config error
131
135
  # so the TUI can surface it (see Tui.run).
@@ -112,9 +112,11 @@ class SessionPersistence:
112
112
  max_tokens: int | None = None,
113
113
  tool_names: list[str] | None = None,
114
114
  round_times: list[float] | None = None,
115
+ agent: str | None = None,
115
116
  ) -> None:
116
117
  self.project_dir = project_dir
117
118
  self.model = model
119
+ self.agent = agent
118
120
  self.system_prompt = system_prompt
119
121
  self.temperature = temperature
120
122
  self.max_tokens = max_tokens
@@ -171,6 +173,7 @@ class SessionPersistence:
171
173
  pairs = [
172
174
  ("python-agent-harness--project-dir", self.project_dir),
173
175
  ("python-agent-harness--model", self.model),
176
+ ("python-agent-harness--agent", self.agent),
174
177
  ]
175
178
  for name, value in pairs:
176
179
  if value is None:
@@ -1,3 +1,13 @@
1
+ ---
2
+ name: reviewer
3
+ exclude_tools:
4
+ - Bash
5
+ - Edit
6
+ - Insert
7
+ - Write
8
+ - Mkdir
9
+ ---
10
+
1
11
  # Role and Behavior
2
12
  You are a code reviewer agent. Your job is to review code changes
3
13
  thoroughly and provide constructive feedback.
@@ -35,7 +35,7 @@ def read_prompt_file(name: str) -> str:
35
35
  return path.read_text(encoding="utf-8")
36
36
 
37
37
 
38
- _FRONTMATTER_RE = re.compile(r"\A---\n.*?\n---\n?", re.DOTALL)
38
+ _FRONTMATTER_RE = re.compile(r"\A---\r?\n.*?\r?\n---\r?\n?", re.DOTALL)
39
39
  _SKILLS_PLACEHOLDER_RE = re.compile(r"\{\{\s*SKILLS\s*\}\}")
40
40
  _SKILLS_FALLBACK = (
41
41
  "Invoke with a skill name and optional args; the tool reports an "
@@ -509,30 +509,78 @@ def compacted_messages(summary: str, prompts: list[str]) -> list[Message]:
509
509
  # ``prompts/agents/``. Each file becomes a switchable agent profile
510
510
  # available via the ``/agent`` TUI command. The file's stem (e.g.
511
511
  # ``reviewer.md`` → ``reviewer``) is the agent name; an optional YAML
512
- # frontmatter ``name:`` can override it. The built-in ``default``
513
- # agent (``prompts/agent.md``) is always available and cannot be
514
- # overridden by a file in this directory.
512
+ # frontmatter ``name:`` can override it, and an optional
513
+ # ``exclude_tools:`` list restricts which tools the agent sees (an
514
+ # entry matches by exact name, glob pattern, or ``__``-delimited
515
+ # prefix, e.g. ``mcp__git`` or ``mcp__git__*``). The built-in
516
+ # ``default`` agent (``prompts/agent.md``) is always available and
517
+ # cannot be overridden by a file in this directory.
515
518
 
516
519
 
517
- def _agent_name_from_file(path: Path) -> str:
518
- """Derive agent name from a file: frontmatter ``name:`` if present,
519
- otherwise the file stem (lowercased, non-alphanumerics → ``-``)."""
520
+ def _agent_frontmatter(path: Path) -> dict[str, Any]:
521
+ """Parse the YAML frontmatter of an agent prompt file.
522
+
523
+ Supports ``name:`` (string) and ``exclude_tools:`` (a YAML list —
524
+ either block form with ``- item`` lines or inline ``[a, b]``).
525
+ Unknown keys are ignored; a missing or unreadable frontmatter
526
+ yields an empty dict.
527
+ """
520
528
  try:
521
529
  text = path.read_text(encoding="utf-8", errors="replace")
522
530
  except OSError:
523
- return ""
531
+ return {}
524
532
  m = _FRONTMATTER_RE.match(text)
525
- if m:
526
- for line in m.group(0).splitlines():
527
- if line.startswith("name:"):
528
- name = line[len("name:") :].strip()
529
- if name:
530
- return name
533
+ if not m:
534
+ return {}
535
+ data: dict[str, Any] = {}
536
+ lines = m.group(0).splitlines()
537
+ i = 0
538
+ while i < len(lines):
539
+ line = lines[i]
540
+ if line.startswith("name:"):
541
+ data["name"] = line[len("name:") :].strip()
542
+ elif line.startswith("exclude_tools:"):
543
+ rest = line[len("exclude_tools:") :].strip()
544
+ tools: list[str] = []
545
+ if rest.startswith("[") and rest.endswith("]"):
546
+ tools = [
547
+ item.strip().strip("'\"") for item in rest[1:-1].split(",") if item.strip()
548
+ ]
549
+ else:
550
+ j = i + 1
551
+ while j < len(lines) and lines[j].lstrip().startswith("- "):
552
+ tools.append(lines[j].lstrip()[2:].strip().strip("'\""))
553
+ j += 1
554
+ i = j - 1
555
+ data["exclude_tools"] = tools
556
+ i += 1
557
+ return data
558
+
559
+
560
+ def _agent_name_from_file(path: Path) -> str:
561
+ """Derive agent name from a file: frontmatter ``name:`` if present,
562
+ otherwise the file stem (lowercased, non-alphanumerics → ``-``)."""
563
+ name = _agent_frontmatter(path).get("name", "")
564
+ if name:
565
+ return name
531
566
  base = path.stem.lower()
532
567
  base = re.sub(r"[^a-z0-9]+", "-", base)
533
568
  return base.strip("-")
534
569
 
535
570
 
571
+ def agent_exclude_tools(path: Path | str) -> tuple[str, ...]:
572
+ """Tool name substrings/patterns the agent profile excludes.
573
+
574
+ Reads the ``exclude_tools:`` frontmatter list of an agent prompt
575
+ file. An entry matches a tool by exact name, glob pattern
576
+ (``mcp__git__*``), or ``__``-delimited prefix (``mcp__git`` hides
577
+ ``mcp__git__list_repos``); matching happens in
578
+ ``Session.tool_specs``. Returns an empty tuple when the key is
579
+ absent.
580
+ """
581
+ return tuple(_agent_frontmatter(Path(path)).get("exclude_tools", ()))
582
+
583
+
536
584
  def discover_agents() -> dict[str, str]:
537
585
  """Discover all custom agents in the ``prompts/agents/`` directory.
538
586
 
@@ -9,6 +9,7 @@ provide interactive confirmations.
9
9
  from __future__ import annotations
10
10
 
11
11
  import contextlib
12
+ import fnmatch
12
13
  import os
13
14
  import threading
14
15
  import time
@@ -31,6 +32,21 @@ from .tools.filesystem import cleanup_spooled_files
31
32
  from .tools.mcp import mcp_tools_from_manager
32
33
 
33
34
 
35
+ def _tool_excluded(pattern: str, name: str) -> bool:
36
+ """True when *pattern* excludes tool *name*.
37
+
38
+ A pattern matches when it is the exact tool name, a glob pattern
39
+ (``mcp__git__*``), or a prefix delimited by ``__`` (``mcp__git``
40
+ hides ``mcp__git__list_repos`` but ``Write`` does NOT hide
41
+ ``TodoWrite``).
42
+ """
43
+ if "*" in pattern:
44
+ return fnmatch.fnmatchcase(name, pattern)
45
+ if pattern == name:
46
+ return True
47
+ return name.startswith(pattern + "__")
48
+
49
+
34
50
  def find_skill_dir(project_dir: str, configured: str | None = None) -> str | None:
35
51
  """Locate the skill directory.
36
52
 
@@ -162,6 +178,12 @@ class Session:
162
178
  # cache build simultaneously — double-checked locking prevents
163
179
  # redundant filesystem scans and the data race on the cache tuple
164
180
  self._skill_index_lock = threading.Lock()
181
+ # NOTE: the store does NOT record ``default_agent`` here: the
182
+ # store's agent must reflect the agent actually applied, and
183
+ # only a successful switch_agent() can guarantee that (a typo'd
184
+ # default_agent is reported as a startup warning and the session
185
+ # keeps the built-in prompt). cli.make_session applies the
186
+ # default_agent right after construction, which sets it.
165
187
  self.store = SessionPersistence(
166
188
  project_dir=project_dir,
167
189
  model=model,
@@ -195,6 +217,11 @@ class Session:
195
217
  # Original system prompt assembled at session start — used by
196
218
  # /agent default to restore the original agent.md prompt.
197
219
  self._default_system_prompt = system_prompt
220
+ # Tool exclusions from the active agent profile's
221
+ # ``exclude_tools`` frontmatter (substrings or glob patterns).
222
+ # The built-in default agent excludes nothing; switch_agent
223
+ # replaces this tuple when a custom agent is activated.
224
+ self._agent_excluded_tools: tuple[str, ...] = ()
198
225
  # Monotonic cancel identity: cancel() bumps this counter, so a
199
226
  # worker from a cancelled run can tell it was cancelled even
200
227
  # after the next run clears the shared event.
@@ -245,9 +272,26 @@ class Session:
245
272
  # tools
246
273
  # ------------------------------------------------------------------
247
274
  def tool_specs(self, exclude: tuple[str, ...] = ()) -> list:
248
- """Tool specs exposed to the model; ``exclude`` drops tools by
249
- name (e.g. one-shot/interactive tools for sub-agent runs)."""
250
- return [spec for spec in self.registry.specs() if spec.name not in exclude]
275
+ """Tool specs exposed to the model.
276
+
277
+ ``exclude`` drops tools by name (e.g. one-shot/interactive tools
278
+ for sub-agent runs). The active agent profile's own
279
+ ``exclude_tools`` frontmatter is applied on top: an entry
280
+ matches a tool by exact name, glob pattern (``mcp__git__*``),
281
+ or ``__``-delimited prefix (``mcp__git`` hides
282
+ ``mcp__git__list_repos`` but ``Write`` does NOT hide
283
+ ``TodoWrite``).
284
+ """
285
+ excluded = set(exclude)
286
+ specs = self.registry.specs()
287
+ patterns = self._agent_excluded_tools
288
+ if not patterns:
289
+ return [spec for spec in specs if spec.name not in excluded]
290
+ return [
291
+ spec
292
+ for spec in specs
293
+ if spec.name not in excluded and not any(_tool_excluded(p, spec.name) for p in patterns)
294
+ ]
251
295
 
252
296
  def execute_tool(
253
297
  self, name: str, args: dict[str, Any], call_id: str | None = None
@@ -731,6 +775,8 @@ class Session:
731
775
  if name == RESERVED_AGENT_NAME:
732
776
  self.system_prompt = self._default_system_prompt
733
777
  self.store.system_prompt = self._default_system_prompt
778
+ self.store.agent = None
779
+ self._agent_excluded_tools = ()
734
780
  return True, "switched to default agent"
735
781
 
736
782
  agents = discover_agents()
@@ -738,17 +784,20 @@ class Session:
738
784
  if prompt_file is None:
739
785
  available = ", ".join(sorted([RESERVED_AGENT_NAME, *agents.keys()]))
740
786
  return False, f"unknown agent: {name} (available: {available})"
741
- from .prompts import assemble_agent_prompt, load_agent_prompt
787
+ from .prompts import agent_exclude_tools, assemble_agent_prompt, load_agent_prompt
742
788
 
743
789
  agent_prompt = load_agent_prompt(prompt_file, skill_dir=self._skill_dir)
744
790
  if agent_prompt is None:
745
791
  return False, f"agent {name}: prompt file not found or empty: {prompt_file}"
792
+ new_exclusions = agent_exclude_tools(prompt_file)
746
793
  self.system_prompt = assemble_agent_prompt(
747
794
  self.project_dir,
748
795
  agent_prompt,
749
796
  context_path=self._configured_context_path,
750
797
  )
751
798
  self.store.system_prompt = self.system_prompt
799
+ self.store.agent = name
800
+ self._agent_excluded_tools = new_exclusions
752
801
  return True, f"switched to {name}"
753
802
 
754
803
  # ------------------------------------------------------------------
@@ -48,7 +48,7 @@ class _Hunk:
48
48
  """(kind, content-without-newline) for the lines that must exist
49
49
  in the old file (' ' context and '-' removed lines)."""
50
50
  return [
51
- (kind, content.rstrip("\n")) for kind, content, _ in self.body if kind in (" ", "-")
51
+ (kind, content.rstrip("\r\n")) for kind, content, _ in self.body if kind in (" ", "-")
52
52
  ]
53
53
 
54
54
 
@@ -142,7 +142,7 @@ def _match_hunk(hunk: _Hunk, file_lines: list[str]) -> int | None:
142
142
  mismatches = 0
143
143
  ok = True
144
144
  for i, (kind, content) in enumerate(old_seq):
145
- file_content = file_lines[p + i].rstrip("\n")
145
+ file_content = file_lines[p + i].rstrip("\r\n")
146
146
  if kind == "-":
147
147
  if file_content != content:
148
148
  ok = False
@@ -98,12 +98,17 @@ class GrepWindows(Grep):
98
98
  files = [Path(path)]
99
99
  else:
100
100
  root = Path(path)
101
- files = (
102
- p
103
- for p in root.rglob("*")
104
- if p.is_file()
105
- and not any(part.startswith(".") for part in p.relative_to(root).parts[:-1])
106
- )
101
+ collected: list[Path] = []
102
+ try:
103
+ for p in root.rglob("*"):
104
+ if not p.is_file():
105
+ continue
106
+ if any(part.startswith(".") for part in p.relative_to(root).parts[:-1]):
107
+ continue
108
+ collected.append(p)
109
+ except OSError:
110
+ pass
111
+ files = collected
107
112
 
108
113
  for file_path in files:
109
114
  if match_count >= max_matches:
@@ -124,7 +129,7 @@ class GrepWindows(Grep):
124
129
  start = max(0, i - 1 - context)
125
130
  end = min(len(lines), i + context)
126
131
  for j in range(start, end):
127
- marker = ":" if j + 1 != i else ":"
132
+ marker = "-" if j + 1 != i else ":"
128
133
  results.append(f"{file_path}:{j + 1}{marker}{lines[j].rstrip()}")
129
134
  results.append("") # blank line between matches with context
130
135
  else:
@@ -119,7 +119,7 @@ class CommandMixin:
119
119
  "/review [project] [commit|branch|PR] review code changes\n"
120
120
  "/explain [project] [target] explain code\n"
121
121
  "/sessions list saved sessions\n"
122
- "/restore [path | title | --latest | latest] restore a saved session\n"
122
+ "/restore [path | title | --latest | latest] restore a saved session (conversation + agent + model)\n"
123
123
  "/model [name] switch LLM model profile\n"
124
124
  "/agent [name] switch agent system prompt\n"
125
125
  "Ctrl-C cancels the current execution (app stays open); "
@@ -642,6 +642,62 @@ class CommandMixin:
642
642
  title = title_from_filename(path)
643
643
  if title:
644
644
  self.session.store.title = title
645
+ # Restore agent if saved
646
+ agent = meta.get("python-agent-harness--agent")
647
+ if agent:
648
+ try:
649
+ success, msg = self.session.switch_agent(agent)
650
+ if success:
651
+ self.console.print(f"[green]{msg}[/green]")
652
+ else:
653
+ # Agent no longer exists: reset to default to avoid
654
+ # stale exclusions from the previously active agent
655
+ self.session.switch_agent("default")
656
+ self.console.print(f"[yellow]warning: {msg}[/yellow]")
657
+ except Exception as e:
658
+ self.console.print(f"[yellow]warning: could not restore agent: {e}[/yellow]")
659
+ # Restore model if saved: /model's profile switching is the
660
+ # canonical path — it also fixes base_url/api_key/temperature
661
+ # and other settings that a bare model-name change would leave
662
+ # pointing at the wrong endpoint. The metadata stores the raw
663
+ # model name, so first try it as a profile name, then fall back
664
+ # to a profile whose model matches; without a matching profile
665
+ # the current model stays active (profile names are not saved).
666
+ model = meta.get("python-agent-harness--model")
667
+ if model:
668
+ try:
669
+ if model == self.session.model:
670
+ # Already the active model: nothing to do, no noise.
671
+ pass
672
+ elif model == self.session.llm_settings.get("model"):
673
+ # The saved model is the session-start default: the
674
+ # "default" pseudo-profile restores exactly those
675
+ # original llm settings (profile names were not saved,
676
+ # so there is no better match).
677
+ success, msg = self.session.switch_model("default")
678
+ self.console.print(f"[green]{msg}[/green]")
679
+ else:
680
+ success, msg = self.session.switch_model(model)
681
+ if not success:
682
+ profile = next(
683
+ (
684
+ name
685
+ for name, p in self.session.model_profiles.items()
686
+ if p.get("model") == model
687
+ ),
688
+ None,
689
+ )
690
+ if profile:
691
+ success, msg = self.session.switch_model(profile)
692
+ if success:
693
+ self.console.print(f"[green]{msg}[/green]")
694
+ else:
695
+ self.console.print(
696
+ f"[yellow]warning: model {model!r} has no matching profile, "
697
+ f"keeping current model[/yellow]"
698
+ )
699
+ except Exception as e:
700
+ self.console.print(f"[yellow]warning: could not restore model: {e}[/yellow]")
645
701
  # Replace conversation history: a new generation. Invalidate any
646
702
  # worker still winding down from a cancelled run so its salvaged
647
703
  # history can't clobber the restored session.
@@ -650,11 +706,11 @@ class CommandMixin:
650
706
  self.session.last_messages = list(messages)
651
707
  self.session.clear_todos()
652
708
  self._history_dirty = True
653
- model = meta.get("python-agent-harness--model", "?")
654
709
  project = meta.get("python-agent-harness--project-dir", "?")
710
+ agent_display = meta.get("python-agent-harness--agent", "default")
655
711
  self.console.print(
656
712
  f"[green]restored:[/green] {os.path.basename(path)} "
657
- f"(model={model}, project={project}, {len(messages)} messages)"
713
+ f"(model={model}, agent={agent_display}, project={project}, {len(messages)} messages)"
658
714
  )
659
715
 
660
716
  @staticmethod
@@ -664,13 +720,15 @@ class CommandMixin:
664
720
  The save format is markdown with **role**: content blocks
665
721
  separated by blank lines.
666
722
 
667
- ``tool`` blocks are dropped: the saved markdown does not keep
668
- ``tool_call_id``/``name`` (assistant tool calls are flattened to
669
- plain text), so a restored ``role="tool"`` message would form an
670
- API-invalid payload (a tool message with no preceding assistant
671
- ``tool_calls``). The following assistant reply already
672
- summarizes the results, so dropping them loses no essential
673
- context.
723
+ ``tool`` and ``system`` blocks are dropped: the saved markdown
724
+ does not keep ``tool_call_id``/``name`` (assistant tool calls are
725
+ flattened to plain text), so a restored ``role="tool"`` message
726
+ would form an API-invalid payload (a tool message with no
727
+ preceding assistant ``tool_calls``). A restored ``system``
728
+ message would duplicate the live system prompt the client
729
+ prepends on every request. The following assistant reply
730
+ already summarizes the results, so dropping them loses no
731
+ essential context.
674
732
 
675
733
  Body lines that merely look like a block header are escaped by
676
734
  the renderer (see `escape_role_headers`) and unescaped here, so
@@ -687,9 +745,10 @@ class CommandMixin:
687
745
  header = split_role_header(line)
688
746
  if header is not None:
689
747
  role, rest = header
690
- # Save the previous block (tool blocks are dropped:
691
- # their tool_call_id/name were not persisted)
692
- if current_role is not None and current_role != "tool":
748
+ # Save the previous block (tool blocks lose their
749
+ # tool_call_id/name; system blocks would duplicate the
750
+ # live system prompt the client prepends per request)
751
+ if current_role is not None and current_role not in ("tool", "system"):
693
752
  content = "\n".join(current_lines).strip()
694
753
  if content:
695
754
  messages.append(Message(role=current_role, content=content))
@@ -698,8 +757,8 @@ class CommandMixin:
698
757
  continue
699
758
  current_lines.append(unescape_role_header(line))
700
759
 
701
- # Don't forget the last block (tool blocks are dropped)
702
- if current_role is not None and current_role != "tool":
760
+ # Don't forget the last block (tool/system blocks dropped, see above)
761
+ if current_role is not None and current_role not in ("tool", "system"):
703
762
  content = "\n".join(current_lines).strip()
704
763
  if content:
705
764
  messages.append(Message(role=current_role, content=content))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.3
3
+ Version: 1.5.5.5
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -243,13 +243,32 @@ Custom commands from `prompts/commands/*.md` are registered as slash commands as
243
243
 
244
244
  Agent prompt files are markdown files (`.md`) placed in the `prompts/agents/` directory. Each file becomes a switchable agent profile available via the `/agent` TUI command.
245
245
 
246
+ An agent file may carry YAML frontmatter with two optional keys:
247
+
248
+ - `name:` — override the agent name (defaults to the file stem)
249
+ - `exclude_tools:` — a list of tool names the agent must not see. An entry matches a tool by exact name, glob pattern (`mcp__git__*`), or `__`-delimited prefix (`mcp__git` hides `mcp__git__list_repos` but `Write` does NOT hide `TodoWrite`). The built-in `default` agent always sees all tools.
250
+
251
+ ```markdown
252
+ ---
253
+ name: assistant
254
+ exclude_tools:
255
+ - Bash
256
+ - Edit
257
+ - Write
258
+ - mcp__git__*
259
+ ---
260
+
261
+ # Role and Behavior
262
+ You are a personal assistant. You do NOT modify files or run shell commands.
263
+ ```
264
+
246
265
  #### Commands vs Agents
247
266
 
248
267
  | | Commands (`/review`) | Agents (`/agent reviewer`) |
249
268
  |---|---|---|
250
269
  | **Scope** | One-shot (prompt resets after run) | Persistent (stays until next `/agent`) |
251
270
  | **Kickoff** | Hardcoded kickoff message | User types their own prompt |
252
- | **Tools** | Can restrict (`allow_planexit=False`) | All tools |
271
+ | **Tools** | Can restrict (`allow_planexit=False`) | All tools, minus the agent's `exclude_tools` |
253
272
  | **Ctrl-C** | Restores to default prompt | Stays on the custom agent |
254
273
 
255
274
  ## Project layout
@@ -163,6 +163,25 @@ class TestSwitchAgent(unittest.TestCase):
163
163
  session.switch_agent("default")
164
164
  self.assertEqual(session.store.system_prompt, "original prompt")
165
165
 
166
+ def test_switch_updates_store_agent(self):
167
+ """switch_agent must keep store.agent in sync so every auto-save
168
+ records the currently active agent in the metadata block."""
169
+ session = _make_session(system_prompt="original prompt")
170
+ self.assertIsNone(session.store.agent) # no custom agent yet
171
+ session.switch_agent("reviewer")
172
+ self.assertEqual(session.store.agent, "reviewer")
173
+ session.switch_agent("default")
174
+ self.assertIsNone(session.store.agent) # default = no custom agent
175
+
176
+ def test_failed_switch_leaves_store_agent_untouched(self):
177
+ """A failed switch (unknown name) must not change store.agent:
178
+ the metadata must never record an agent that is not active."""
179
+ session = _make_session(system_prompt="original prompt")
180
+ session.switch_agent("reviewer")
181
+ ok, _ = session.switch_agent("nonexistent")
182
+ self.assertFalse(ok)
183
+ self.assertEqual(session.store.agent, "reviewer")
184
+
166
185
 
167
186
  class TestDefaultAgentConfig(unittest.TestCase):
168
187
  """config.load_default_agent: reading the default_agent setting."""
@@ -286,6 +305,30 @@ class TestMakeSessionWithDefaultAgent(unittest.TestCase):
286
305
  p.write_text(json.dumps({"default_agent": "reviewer"}), encoding="utf-8")
287
306
  session = make_session("/tmp", config_path=str(p))
288
307
  self.assertEqual(session.startup_warnings, [])
308
+ session.close()
309
+
310
+ def test_valid_default_agent_recorded_in_store(self):
311
+ """A successfully applied default_agent is recorded in store.agent,
312
+ so saved sessions restore it. A typo'd one must NOT be recorded:
313
+ the metadata must never name an agent that is not active."""
314
+ from python_agent_harness.cli import make_session
315
+
316
+ with tempfile.TemporaryDirectory() as d:
317
+ p = Path(d) / "config.json"
318
+ p.write_text(json.dumps({"default_agent": "reviewer"}), encoding="utf-8")
319
+ session = make_session("/tmp", config_path=str(p))
320
+ try:
321
+ self.assertEqual(session.store.agent, "reviewer")
322
+ finally:
323
+ session.close()
324
+ with tempfile.TemporaryDirectory() as d:
325
+ p = Path(d) / "config.json"
326
+ p.write_text(json.dumps({"default_agent": "no-such-agent"}), encoding="utf-8")
327
+ session = make_session("/tmp", config_path=str(p))
328
+ try:
329
+ self.assertIsNone(session.store.agent)
330
+ finally:
331
+ session.close()
289
332
 
290
333
 
291
334
  class TestMcpFailuresAsStartupWarnings(unittest.TestCase):
@@ -344,6 +387,229 @@ class TestMcpFailuresAsStartupWarnings(unittest.TestCase):
344
387
  os.environ[k] = v
345
388
 
346
389
 
390
+ class TestAgentExcludeTools(unittest.TestCase):
391
+ """Agent profile ``exclude_tools`` frontmatter: parsing and effect
392
+ on the tool specs exposed to the model."""
393
+
394
+ def _write_agent(
395
+ self, name: str, frontmatter: str, body: str = "You are a test agent."
396
+ ) -> Path:
397
+ p = AGENTS_DIR / f"{name}.md"
398
+ p.write_text(f"---\n{frontmatter}---\n{body}", encoding="utf-8")
399
+ return p
400
+
401
+ def test_parse_exclude_tools(self):
402
+ """agent_exclude_tools reads the frontmatter list."""
403
+ from python_agent_harness.prompts import agent_exclude_tools
404
+
405
+ p = self._write_agent(
406
+ "test-exclude-parse",
407
+ "name: test-exclude-parse\nexclude_tools:\n - Bash\n - Edit\n - mcp__git__*\n",
408
+ )
409
+ try:
410
+ self.assertEqual(
411
+ agent_exclude_tools(p),
412
+ ("Bash", "Edit", "mcp__git__*"),
413
+ )
414
+ finally:
415
+ p.unlink(missing_ok=True)
416
+
417
+ def test_parse_exclude_tools_inline(self):
418
+ """agent_exclude_tools reads an inline ``[a, b]`` list too."""
419
+ from python_agent_harness.prompts import agent_exclude_tools
420
+
421
+ p = self._write_agent(
422
+ "test-exclude-inline",
423
+ "name: test-exclude-inline\nexclude_tools: [Bash, mcp__git__*]\n",
424
+ )
425
+ try:
426
+ self.assertEqual(
427
+ agent_exclude_tools(p),
428
+ ("Bash", "mcp__git__*"),
429
+ )
430
+ finally:
431
+ p.unlink(missing_ok=True)
432
+
433
+ def test_no_exclude_tools_returns_empty(self):
434
+ """An agent without the key excludes nothing."""
435
+ from python_agent_harness.prompts import agent_exclude_tools
436
+
437
+ p = self._write_agent("test-exclude-none", "name: test-exclude-none\n")
438
+ try:
439
+ self.assertEqual(agent_exclude_tools(p), ())
440
+ finally:
441
+ p.unlink(missing_ok=True)
442
+
443
+ def test_switch_agent_applies_exclusions(self):
444
+ """switch_agent activates the profile's excluded tools."""
445
+ p = self._write_agent(
446
+ "test-exclude-switch",
447
+ "name: test-exclude-switch\nexclude_tools:\n - Bash\n - Edit\n",
448
+ )
449
+ try:
450
+ session = _make_session()
451
+ ok, msg = session.switch_agent("test-exclude-switch")
452
+ self.assertTrue(ok, msg)
453
+ names = {s.name for s in session.tool_specs()}
454
+ self.assertNotIn("Bash", names)
455
+ self.assertNotIn("Edit", names)
456
+ self.assertIn("Read", names)
457
+ finally:
458
+ p.unlink(missing_ok=True)
459
+
460
+ def test_switch_to_default_clears_exclusions(self):
461
+ """/agent default restores the full tool set."""
462
+ p = self._write_agent(
463
+ "test-exclude-default",
464
+ "name: test-exclude-default\nexclude_tools:\n - Bash\n",
465
+ )
466
+ try:
467
+ session = _make_session()
468
+ session.switch_agent("test-exclude-default")
469
+ self.assertNotIn("Bash", {s.name for s in session.tool_specs()})
470
+ ok, msg = session.switch_agent("default")
471
+ self.assertTrue(ok, msg)
472
+ self.assertIn("Bash", {s.name for s in session.tool_specs()})
473
+ finally:
474
+ p.unlink(missing_ok=True)
475
+
476
+ def test_glob_pattern_excludes_mcp_tools(self):
477
+ """mcp__<server>__* patterns hide every tool of one server."""
478
+ from python_agent_harness.tools.base import Tool
479
+
480
+ class FakeMcpTool(Tool):
481
+ name = "mcp__git__list_repos"
482
+ description = "fake"
483
+
484
+ def run(self, args, ctx):
485
+ return ""
486
+
487
+ p = self._write_agent(
488
+ "test-exclude-glob",
489
+ "name: test-exclude-glob\nexclude_tools:\n - mcp__git__*\n",
490
+ )
491
+ try:
492
+ session = _make_session()
493
+ session.registry.register(FakeMcpTool())
494
+ self.assertIn("mcp__git__list_repos", {s.name for s in session.tool_specs()})
495
+ session.switch_agent("test-exclude-glob")
496
+ names = {s.name for s in session.tool_specs()}
497
+ self.assertNotIn("mcp__git__list_repos", names)
498
+ finally:
499
+ p.unlink(missing_ok=True)
500
+
501
+ def test_substring_pattern_excludes_mcp_tools(self):
502
+ """A ``__``-delimited prefix (mcp__git) hides every tool of one
503
+ server, but does NOT accidentally match unrelated tools."""
504
+ from python_agent_harness.tools.base import Tool
505
+
506
+ class FakeMcpTool(Tool):
507
+ name = "mcp__git__list_repos"
508
+ description = "fake"
509
+
510
+ def run(self, args, ctx):
511
+ return ""
512
+
513
+ p = self._write_agent(
514
+ "test-exclude-substr",
515
+ "name: test-exclude-substr\nexclude_tools:\n - mcp__git\n",
516
+ )
517
+ try:
518
+ session = _make_session()
519
+ session.registry.register(FakeMcpTool())
520
+ self.assertIn("mcp__git__list_repos", {s.name for s in session.tool_specs()})
521
+ session.switch_agent("test-exclude-substr")
522
+ names = {s.name for s in session.tool_specs()}
523
+ self.assertNotIn("mcp__git__list_repos", names)
524
+ finally:
525
+ p.unlink(missing_ok=True)
526
+
527
+ def test_write_does_not_exclude_todowrite(self):
528
+ """``Write`` must not accidentally hide ``TodoWrite``: matching
529
+ is by exact name or ``__``-delimited prefix, not raw substring."""
530
+ p = self._write_agent(
531
+ "test-exclude-no-false-match",
532
+ "name: test-exclude-no-false-match\nexclude_tools:\n - Write\n",
533
+ )
534
+ try:
535
+ session = _make_session()
536
+ session.switch_agent("test-exclude-no-false-match")
537
+ names = {s.name for s in session.tool_specs()}
538
+ self.assertNotIn("Write", names)
539
+ self.assertIn("TodoWrite", names)
540
+ finally:
541
+ p.unlink(missing_ok=True)
542
+
543
+ def test_explicit_exclude_still_works(self):
544
+ """The caller's exclude argument composes with the profile's."""
545
+ p = self._write_agent(
546
+ "test-exclude-compose",
547
+ "name: test-exclude-compose\nexclude_tools:\n - Bash\n",
548
+ )
549
+ try:
550
+ session = _make_session()
551
+ session.switch_agent("test-exclude-compose")
552
+ names = {s.name for s in session.tool_specs(exclude=("Edit",))}
553
+ self.assertNotIn("Bash", names)
554
+ self.assertNotIn("Edit", names)
555
+ finally:
556
+ p.unlink(missing_ok=True)
557
+
558
+ def test_failed_switch_leaves_exclusions_untouched(self):
559
+ """A failed switch (unknown name) must not change the active
560
+ exclusions: the caller's tool set must never silently shift."""
561
+ p = self._write_agent(
562
+ "test-exclude-fail",
563
+ "name: test-exclude-fail\nexclude_tools:\n - Bash\n",
564
+ )
565
+ try:
566
+ session = _make_session()
567
+ session.switch_agent("test-exclude-fail")
568
+ self.assertNotIn("Bash", {s.name for s in session.tool_specs()})
569
+ ok, _ = session.switch_agent("nonexistent")
570
+ self.assertFalse(ok)
571
+ self.assertNotIn("Bash", {s.name for s in session.tool_specs()})
572
+ finally:
573
+ p.unlink(missing_ok=True)
574
+
575
+ def test_quoted_items_stripped(self):
576
+ """Quoted frontmatter items lose their quotes during parsing."""
577
+ from python_agent_harness.prompts import agent_exclude_tools
578
+
579
+ p = self._write_agent(
580
+ "test-exclude-quoted",
581
+ "name: test-exclude-quoted\nexclude_tools:\n - \"Bash\"\n - 'Edit'\n",
582
+ )
583
+ try:
584
+ self.assertEqual(agent_exclude_tools(p), ("Bash", "Edit"))
585
+ finally:
586
+ p.unlink(missing_ok=True)
587
+
588
+ def test_no_frontmatter_at_all(self):
589
+ """A file with no frontmatter block excludes nothing."""
590
+ from python_agent_harness.prompts import agent_exclude_tools
591
+
592
+ p = self._write_agent("test-no-frontmatter", "")
593
+ p.write_text("You are a simple agent with no frontmatter.", encoding="utf-8")
594
+ try:
595
+ self.assertEqual(agent_exclude_tools(p), ())
596
+ finally:
597
+ p.unlink(missing_ok=True)
598
+
599
+ def test_crlf_line_endings(self):
600
+ """CRLF line endings must not break frontmatter parsing."""
601
+ from python_agent_harness.prompts import agent_exclude_tools
602
+
603
+ p = AGENTS_DIR / "test-crlf-agent.md"
604
+ p.write_bytes(
605
+ b"---\r\nname: test-crlf-agent\r\nexclude_tools:\r\n - Bash\r\n - Edit\r\n---\r\nYou are a CRLF agent.\r\n"
606
+ )
607
+ try:
608
+ self.assertEqual(agent_exclude_tools(p), ("Bash", "Edit"))
609
+ finally:
610
+ p.unlink(missing_ok=True)
611
+
612
+
347
613
  class TestReviewerAgentContent(unittest.TestCase):
348
614
  """Verify the bundled reviewer.md agent has expected content."""
349
615
 
@@ -59,6 +59,22 @@ class TestSession(unittest.TestCase):
59
59
  store = SessionPersistence(project_dir="/tmp/proj", model="m")
60
60
  self.assertNotIn("round-times", store.metadata_block())
61
61
 
62
+ def test_agent_persisted_in_metadata(self):
63
+ """A session with an active custom agent records it in the
64
+ metadata block so /restore can reapply it."""
65
+ store = SessionPersistence(project_dir="/tmp/proj", model="m", agent="reviewer")
66
+ meta = store.metadata_block()
67
+ self.assertIn("python-agent-harness--agent", meta)
68
+ text = "conversation...\n\n" + meta + "\n"
69
+ parsed = SessionPersistence.parse_metadata(text)
70
+ self.assertEqual(parsed["python-agent-harness--agent"], "reviewer")
71
+
72
+ def test_agent_absent_when_none(self):
73
+ """No custom agent -> no agent line in the metadata block, so
74
+ old sessions and default-agent sessions parse identically."""
75
+ store = SessionPersistence(project_dir="/tmp/proj", model="m", agent=None)
76
+ self.assertNotIn("python-agent-harness--agent", store.metadata_block())
77
+
62
78
  def test_save_and_restore_flow(self):
63
79
  import os
64
80
  import tempfile