python-agent-harness 1.5.5.4__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.4/python_agent_harness.egg-info → python_agent_harness-1.5.5.5}/PKG-INFO +21 -2
  2. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/README.md +20 -1
  3. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/agents/reviewer.md +10 -0
  6. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts.py +62 -14
  7. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/session.py +45 -4
  8. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/commands.py +3 -0
  9. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5/python_agent_harness.egg-info}/PKG-INFO +21 -2
  10. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_custom_agents.py +223 -0
  11. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/LICENSE +0 -0
  12. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/__main__.py +0 -0
  13. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/agent.py +0 -0
  14. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/cli.py +0 -0
  15. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/client.py +0 -0
  16. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/commands.py +0 -0
  17. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/config.py +0 -0
  18. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/context_manager.py +0 -0
  19. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/diffrender.py +0 -0
  20. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/mcp/__init__.py +0 -0
  21. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/mcp/client.py +0 -0
  22. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/mcp/config.py +0 -0
  23. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/mcp/manager.py +0 -0
  24. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/models.py +0 -0
  25. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/persistence.py +0 -0
  26. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/planmode.py +0 -0
  27. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/agent.md +0 -0
  28. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/build-switch.md +0 -0
  29. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/commands/explain.md +0 -0
  30. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/compact.md +0 -0
  31. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/initialize.md +0 -0
  32. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/plan-mode.md +0 -0
  33. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/plan.md +0 -0
  34. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/review.md +0 -0
  35. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/subagent.md +0 -0
  36. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/summary.md +0 -0
  37. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  38. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/prompts/title.md +0 -0
  39. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/subagent.py +0 -0
  40. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/token_estimator.py +0 -0
  41. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tool_runner.py +0 -0
  42. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/__init__.py +0 -0
  43. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/agent_tool.py +0 -0
  44. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/base.py +0 -0
  45. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/bash.py +0 -0
  46. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/bash_win.py +0 -0
  47. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/diffapply.py +0 -0
  48. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/edit.py +0 -0
  49. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/edit_mac.py +0 -0
  50. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/edit_win.py +0 -0
  51. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/filesystem.py +0 -0
  52. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/glob.py +0 -0
  53. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/glob_mac.py +0 -0
  54. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/glob_win.py +0 -0
  55. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/grep.py +0 -0
  56. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/grep_mac.py +0 -0
  57. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/grep_win.py +0 -0
  58. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/insert.py +0 -0
  59. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/mcp.py +0 -0
  60. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/mkdir.py +0 -0
  61. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/planexit.py +0 -0
  62. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/question.py +0 -0
  63. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/read.py +0 -0
  64. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/skill.py +0 -0
  65. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/todo.py +0 -0
  66. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tools/write.py +0 -0
  67. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/__init__.py +0 -0
  68. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/core.py +0 -0
  69. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/input.py +0 -0
  70. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness/tui/render.py +0 -0
  71. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  72. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  73. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/entry_points.txt +0 -0
  74. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/requires.txt +0 -0
  75. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/python_agent_harness.egg-info/top_level.txt +0 -0
  76. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/setup.cfg +0 -0
  77. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_agents_md.py +0 -0
  78. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_cli.py +0 -0
  79. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_client.py +0 -0
  80. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_commands.py +0 -0
  81. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_concurrent_subagents.py +0 -0
  82. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_config.py +0 -0
  83. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_context_rules.py +0 -0
  84. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_diffrender.py +0 -0
  85. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_filesystem.py +0 -0
  86. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_invariants.py +0 -0
  87. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_mcp.py +0 -0
  88. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_models.py +0 -0
  89. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_persistence.py +0 -0
  90. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_planmode.py +0 -0
  91. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_prompts.py +0 -0
  92. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_scenarios.py +0 -0
  93. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_session.py +0 -0
  94. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_subagent.py +0 -0
  95. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_subagent_isolation.py +0 -0
  96. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_todos_scope.py +0 -0
  97. {python_agent_harness-1.5.5.4 → python_agent_harness-1.5.5.5}/tests/test_token_estimator.py +0 -0
  98. {python_agent_harness-1.5.5.4 → 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.4
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.4"
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.4"
8
+ __version__ = "1.5.5.5"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -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
 
@@ -201,6 +217,11 @@ class Session:
201
217
  # Original system prompt assembled at session start — used by
202
218
  # /agent default to restore the original agent.md prompt.
203
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, ...] = ()
204
225
  # Monotonic cancel identity: cancel() bumps this counter, so a
205
226
  # worker from a cancelled run can tell it was cancelled even
206
227
  # after the next run clears the shared event.
@@ -251,9 +272,26 @@ class Session:
251
272
  # tools
252
273
  # ------------------------------------------------------------------
253
274
  def tool_specs(self, exclude: tuple[str, ...] = ()) -> list:
254
- """Tool specs exposed to the model; ``exclude`` drops tools by
255
- name (e.g. one-shot/interactive tools for sub-agent runs)."""
256
- 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
+ ]
257
295
 
258
296
  def execute_tool(
259
297
  self, name: str, args: dict[str, Any], call_id: str | None = None
@@ -738,6 +776,7 @@ class Session:
738
776
  self.system_prompt = self._default_system_prompt
739
777
  self.store.system_prompt = self._default_system_prompt
740
778
  self.store.agent = None
779
+ self._agent_excluded_tools = ()
741
780
  return True, "switched to default agent"
742
781
 
743
782
  agents = discover_agents()
@@ -745,11 +784,12 @@ class Session:
745
784
  if prompt_file is None:
746
785
  available = ", ".join(sorted([RESERVED_AGENT_NAME, *agents.keys()]))
747
786
  return False, f"unknown agent: {name} (available: {available})"
748
- from .prompts import assemble_agent_prompt, load_agent_prompt
787
+ from .prompts import agent_exclude_tools, assemble_agent_prompt, load_agent_prompt
749
788
 
750
789
  agent_prompt = load_agent_prompt(prompt_file, skill_dir=self._skill_dir)
751
790
  if agent_prompt is None:
752
791
  return False, f"agent {name}: prompt file not found or empty: {prompt_file}"
792
+ new_exclusions = agent_exclude_tools(prompt_file)
753
793
  self.system_prompt = assemble_agent_prompt(
754
794
  self.project_dir,
755
795
  agent_prompt,
@@ -757,6 +797,7 @@ class Session:
757
797
  )
758
798
  self.store.system_prompt = self.system_prompt
759
799
  self.store.agent = name
800
+ self._agent_excluded_tools = new_exclusions
760
801
  return True, f"switched to {name}"
761
802
 
762
803
  # ------------------------------------------------------------------
@@ -650,6 +650,9 @@ class CommandMixin:
650
650
  if success:
651
651
  self.console.print(f"[green]{msg}[/green]")
652
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")
653
656
  self.console.print(f"[yellow]warning: {msg}[/yellow]")
654
657
  except Exception as e:
655
658
  self.console.print(f"[yellow]warning: could not restore agent: {e}[/yellow]")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.4
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
@@ -387,6 +387,229 @@ class TestMcpFailuresAsStartupWarnings(unittest.TestCase):
387
387
  os.environ[k] = v
388
388
 
389
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
+
390
613
  class TestReviewerAgentContent(unittest.TestCase):
391
614
  """Verify the bundled reviewer.md agent has expected content."""
392
615