fableforge-anvil-agent 0.1.0__py3-none-any.whl

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 (58) hide show
  1. anvil/__init__.py +154 -0
  2. anvil/agents/__init__.py +28 -0
  3. anvil/agents/agent_base.py +127 -0
  4. anvil/agents/agent_manager.py +380 -0
  5. anvil/agents/builtin_agents.py +156 -0
  6. anvil/cli.py +507 -0
  7. anvil/commands/__init__.py +5 -0
  8. anvil/commands/command_manager.py +148 -0
  9. anvil/compaction/__init__.py +5 -0
  10. anvil/compaction/compactor.py +159 -0
  11. anvil/config_v2/__init__.py +5 -0
  12. anvil/config_v2/config_v2.py +266 -0
  13. anvil/core/__init__.py +19 -0
  14. anvil/core/commands.py +273 -0
  15. anvil/core/compaction.py +204 -0
  16. anvil/core/config.py +228 -0
  17. anvil/core/config_v2.py +519 -0
  18. anvil/core/engine.py +516 -0
  19. anvil/core/init_project.py +792 -0
  20. anvil/core/rules.py +198 -0
  21. anvil/core/session.py +166 -0
  22. anvil/core/snapshot.py +311 -0
  23. anvil/daemon/__init__.py +4 -0
  24. anvil/daemon/server.py +94 -0
  25. anvil/integrations/__init__.py +13 -0
  26. anvil/integrations/agent_swarm.py +98 -0
  27. anvil/integrations/cost_optimizer.py +157 -0
  28. anvil/integrations/error_recovery.py +188 -0
  29. anvil/integrations/verifyloop.py +143 -0
  30. anvil/mcp/__init__.py +13 -0
  31. anvil/mcp/mcp_manager.py +379 -0
  32. anvil/mcp/mcp_types.py +175 -0
  33. anvil/models/__init__.py +4 -0
  34. anvil/models/anthropic_model.py +5 -0
  35. anvil/models/local.py +5 -0
  36. anvil/models/openai_model.py +5 -0
  37. anvil/models/registry.py +284 -0
  38. anvil/permissions/__init__.py +5 -0
  39. anvil/permissions/permissions.py +265 -0
  40. anvil/rules/__init__.py +5 -0
  41. anvil/rules/rules_manager.py +123 -0
  42. anvil/sdk.py +712 -0
  43. anvil/snapshot/__init__.py +5 -0
  44. anvil/snapshot/snapshot_manager.py +148 -0
  45. anvil/tools/__init__.py +18 -0
  46. anvil/tools/executor.py +291 -0
  47. anvil/tools/new_tools.py +258 -0
  48. anvil/tui/__init__.py +17 -0
  49. anvil/tui/app.py +781 -0
  50. anvil/tui/dashboard.py +103 -0
  51. anvil/verify/__init__.py +4 -0
  52. anvil/verify/pipeline.py +266 -0
  53. fableforge_anvil_agent-0.1.0.dist-info/METADATA +289 -0
  54. fableforge_anvil_agent-0.1.0.dist-info/RECORD +58 -0
  55. fableforge_anvil_agent-0.1.0.dist-info/WHEEL +5 -0
  56. fableforge_anvil_agent-0.1.0.dist-info/entry_points.txt +7 -0
  57. fableforge_anvil_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  58. fableforge_anvil_agent-0.1.0.dist-info/top_level.txt +1 -0
anvil/__init__.py ADDED
@@ -0,0 +1,154 @@
1
+ """Anvil — The open-source, self-verified coding agent.
2
+
3
+ Generate → Execute → Verify → Recover.
4
+
5
+ Every other open agent generates and hopes. Anvil generates, runs,
6
+ checks, and fixes — because it was trained on 210,000 examples of
7
+ real agents doing exactly that.
8
+
9
+ The core loop:
10
+ 1. PLAN — Decompose task into atomic steps
11
+ 2. EXECUTE — Run each step with real tools
12
+ 3. VERIFY — Check the output actually works (syntax, tests, lint, type)
13
+ 4. RECOVER — If verification fails, diagnose and fix automatically
14
+
15
+ This isn't prompt engineering. This is behavior engineering.
16
+
17
+ v2 adds multi-agent support (switching, @mention, custom agents)
18
+ and a fine-grained permissions system.
19
+
20
+ Python SDK usage::
21
+
22
+ import anvil
23
+
24
+ result = anvil.run("fix the failing tests")
25
+ print(result.output)
26
+
27
+ session = anvil.Session()
28
+ session.ask("explain this code")
29
+ session.ask("now refactor it")
30
+
31
+ for chunk in anvil.stream("write a function"):
32
+ print(chunk, end="")
33
+
34
+ report = anvil.verify(["src/app.py"])
35
+
36
+ anvil.create_agent(
37
+ name="reviewer", mode="subagent",
38
+ permission={"edit": "deny", "bash": "deny"},
39
+ )
40
+
41
+ agents = anvil.list_agents()
42
+ anvil.switch_agent("plan")
43
+ """
44
+
45
+ from anvil.core.engine import AnvilEngine, EngineResult
46
+ from anvil.core.config import AnvilConfig
47
+ from anvil.core.session import Session
48
+ from anvil.verify.pipeline import VerifyPipeline
49
+ from anvil.models.registry import ModelRegistry
50
+ from anvil.agents.agent_base import BaseAgent, AgentMode
51
+ from anvil.agents.agent_manager import AgentManager
52
+ from anvil.agents.builtin_agents import BUILTIN_AGENTS
53
+ from anvil.permissions.permissions import PermissionAction, PermissionConfig, PermissionManager
54
+ from anvil.sdk import (
55
+ run,
56
+ stream,
57
+ arun,
58
+ astream,
59
+ verify,
60
+ Session as SDKSession,
61
+ configure,
62
+ SDKResult,
63
+ SDKVerifyResult,
64
+ SDKAgentManager,
65
+ _AgentsProxy,
66
+ )
67
+
68
+ __version__ = "0.1.0"
69
+
70
+ # ── Agent management via SDK ──────────────────────────────────────────────
71
+ # anvil._agents is the SDK proxy for agent operations.
72
+ # It's accessed via anvil.list_agents(), anvil.create_agent(), etc.
73
+ # This avoids conflicting with the anvil.agents subpackage.
74
+ _agents_proxy = _AgentsProxy()
75
+
76
+
77
+ def list_agents() -> list:
78
+ """List all available agents."""
79
+ return _agents_proxy.list()
80
+
81
+
82
+ def switch_agent(name: str) -> dict:
83
+ """Switch to a different agent."""
84
+ return _agents_proxy.switch(name)
85
+
86
+
87
+ def create_agent(
88
+ name: str,
89
+ description: str = "",
90
+ mode: str = "subagent",
91
+ model: str = "local",
92
+ temperature: float = 0.2,
93
+ max_steps: int = 20,
94
+ tools_whitelist: list = None,
95
+ tools_blacklist: list = None,
96
+ permission: dict = None,
97
+ prompt_template: str = "",
98
+ hidden: bool = False,
99
+ color: str = "white",
100
+ ) -> dict:
101
+ """Create a custom agent."""
102
+ return _agents_proxy.create(
103
+ name=name,
104
+ description=description,
105
+ mode=mode,
106
+ model=model,
107
+ temperature=temperature,
108
+ max_steps=max_steps,
109
+ tools_whitelist=tools_whitelist or [],
110
+ tools_blacklist=tools_blacklist or [],
111
+ permission=permission or {},
112
+ prompt_template=prompt_template,
113
+ hidden=hidden,
114
+ color=color,
115
+ )
116
+
117
+
118
+ def invoke_agent(name: str, task: str, **kwargs) -> dict:
119
+ """Invoke a subagent directly."""
120
+ return _agents_proxy.invoke(name, task, **kwargs)
121
+
122
+
123
+ __all__ = [
124
+ # Core
125
+ "AnvilEngine",
126
+ "AnvilConfig",
127
+ "EngineResult",
128
+ "Session",
129
+ "VerifyPipeline",
130
+ "ModelRegistry",
131
+ "BaseAgent",
132
+ "AgentMode",
133
+ "AgentManager",
134
+ "BUILTIN_AGENTS",
135
+ "PermissionAction",
136
+ "PermissionConfig",
137
+ "PermissionManager",
138
+ # SDK
139
+ "run",
140
+ "stream",
141
+ "arun",
142
+ "astream",
143
+ "verify",
144
+ "SDKSession",
145
+ "configure",
146
+ "SDKResult",
147
+ "SDKVerifyResult",
148
+ "SDKAgentManager",
149
+ # Agent management
150
+ "list_agents",
151
+ "switch_agent",
152
+ "create_agent",
153
+ "invoke_agent",
154
+ ]
@@ -0,0 +1,28 @@
1
+ """Anvil Agents — multi-agent orchestration for the self-verified coding agent."""
2
+
3
+ from anvil.agents.agent_base import BaseAgent, AgentMode
4
+ from anvil.agents.builtin_agents import (
5
+ BuildAgent,
6
+ PlanAgent,
7
+ ExploreAgent,
8
+ GeneralAgent,
9
+ ScoutAgent,
10
+ CompactionAgent,
11
+ TitleAgent,
12
+ BUILTIN_AGENTS,
13
+ )
14
+ from anvil.agents.agent_manager import AgentManager
15
+
16
+ __all__ = [
17
+ "BaseAgent",
18
+ "AgentMode",
19
+ "BuildAgent",
20
+ "PlanAgent",
21
+ "ExploreAgent",
22
+ "GeneralAgent",
23
+ "ScoutAgent",
24
+ "CompactionAgent",
25
+ "TitleAgent",
26
+ "BUILTIN_AGENTS",
27
+ "AgentManager",
28
+ ]
@@ -0,0 +1,127 @@
1
+ """Base agent definition — the contract every Anvil agent satisfies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, Any
8
+
9
+ from anvil.permissions.permissions import PermissionConfig
10
+
11
+
12
+ class AgentMode(str, Enum):
13
+ """Whether this agent runs as the primary loop or is invoked as a subagent."""
14
+ PRIMARY = "primary"
15
+ SUBAGENT = "subagent"
16
+
17
+
18
+ @dataclass
19
+ class BaseAgent:
20
+ """A fully-specified agent persona that the engine can switch to.
21
+
22
+ Attributes
23
+ ----------
24
+ name : str
25
+ Human-readable identifier, used with ``--agent build`` or ``@explore``.
26
+ description : str
27
+ One-line summary shown in ``anvil agents list``.
28
+ mode : AgentMode
29
+ PRIMARY agents own the main loop; SUBAGENT agents are invoked on-demand.
30
+ model : str
31
+ Model identifier forwarded to the model registry.
32
+ temperature : float
33
+ Sampling temperature for this agent.
34
+ top_p : float
35
+ Nucleus sampling parameter.
36
+ max_steps : int
37
+ Hard cap on tool-call iterations this agent may take per task.
38
+ tools_whitelist : list[str]
39
+ If non-empty, **only** these tools are available to the agent.
40
+ tools_blacklist : list[str]
41
+ Tools explicitly excluded even if they appear in the whitelist.
42
+ permission : PermissionConfig
43
+ Fine-grained per-tool permission overrides.
44
+ prompt_template : str
45
+ System-prompt template. May contain ``{tools}`` placeholder.
46
+ hidden : bool
47
+ Hidden agents don't appear in ``anvil agents list`` output
48
+ (but are still functional).
49
+ color : str
50
+ Rich colour name used in the TUI to distinguish this agent's output.
51
+ extra : dict
52
+ Additional model-level kwargs forwarded to the backend.
53
+ """
54
+
55
+ name: str = "build"
56
+ description: str = "Primary coding agent with full tool access"
57
+ mode: AgentMode = AgentMode.PRIMARY
58
+ model: str = "local"
59
+ temperature: float = 0.2
60
+ top_p: float = 1.0
61
+ max_steps: int = 20
62
+ tools_whitelist: list[str] = field(default_factory=list)
63
+ tools_blacklist: list[str] = field(default_factory=list)
64
+ permission: PermissionConfig = field(default_factory=PermissionConfig.permissive)
65
+ prompt_template: str = ""
66
+ hidden: bool = False
67
+ color: str = "cyan"
68
+ extra: dict = field(default_factory=dict)
69
+
70
+ # ── derived helpers ────────────────────────────────────────────────
71
+
72
+ @property
73
+ def is_primary(self) -> bool:
74
+ return self.mode == AgentMode.PRIMARY
75
+
76
+ @property
77
+ def is_subagent(self) -> bool:
78
+ return self.mode == AgentMode.SUBAGENT
79
+
80
+ def available_tools(self, all_tools: list[str]) -> list[str]:
81
+ """Return the tools this agent is allowed to see.
82
+
83
+ Whitelist wins over blacklist. If no whitelist is given, all
84
+ tools except blacklisted ones are available.
85
+ """
86
+ if self.tools_whitelist:
87
+ allowed = [t for t in all_tools if t in self.tools_whitelist]
88
+ else:
89
+ allowed = list(all_tools)
90
+ return [t for t in allowed if t not in self.tools_blacklist]
91
+
92
+ def get_system_prompt(self, tools: list[str]) -> str:
93
+ """Render the system-prompt template with available tools."""
94
+ tool_str = ", ".join(tools)
95
+ if self.prompt_template:
96
+ return self.prompt_template.format(tools=tool_str)
97
+ return f"You are the {self.name} agent. Available tools: {tool_str}"
98
+
99
+ # ── serialisation ──────────────────────────────────────────────────
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ return {
103
+ "name": self.name,
104
+ "description": self.description,
105
+ "mode": self.mode.value,
106
+ "model": self.model,
107
+ "temperature": self.temperature,
108
+ "top_p": self.top_p,
109
+ "max_steps": self.max_steps,
110
+ "tools_whitelist": self.tools_whitelist,
111
+ "tools_blacklist": self.tools_blacklist,
112
+ "permission": self.permission.to_dict(),
113
+ "prompt_template": self.prompt_template,
114
+ "hidden": self.hidden,
115
+ "color": self.color,
116
+ "extra": self.extra,
117
+ }
118
+
119
+ @classmethod
120
+ def from_dict(cls, data: dict[str, Any]) -> "BaseAgent":
121
+ perm_data = data.pop("permission", {})
122
+ mode_val = data.pop("mode", "primary")
123
+ return cls(
124
+ mode=AgentMode(mode_val),
125
+ permission=PermissionConfig.from_dict(perm_data) if perm_data else PermissionConfig.permissive(),
126
+ **{k: v for k, v in data.items() if k in cls.__dataclass_fields__},
127
+ )
@@ -0,0 +1,380 @@
1
+ """Agent manager — registration, switching, @mention, and lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import json
7
+ from pathlib import Path
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional, Any
10
+
11
+ from anvil.agents.agent_base import BaseAgent, AgentMode
12
+ from anvil.agents.builtin_agents import BUILTIN_AGENTS
13
+ from anvil.models.registry import ModelRegistry, Message, ModelResponse
14
+
15
+
16
+ @dataclass
17
+ class AgentInvocation:
18
+ """Record of a single subagent invocation and its result."""
19
+ agent_name: str
20
+ task: str
21
+ response: str
22
+ success: bool
23
+ tool_calls: list[dict] = field(default_factory=list)
24
+ duration_ms: float = 0.0
25
+
26
+
27
+ class AgentManager:
28
+ """Central registry and lifecycle manager for Anvil agents.
29
+
30
+ Responsibilities
31
+ ----------------
32
+ * Register and look up agents (builtins + user-defined)
33
+ * Switch the active primary agent (Tab-key style)
34
+ * Dispatch subagent invocations via ``@mention`` syntax
35
+ * Load custom agents from ``~/.config/anvil/agents/`` and ``.anvil/agents/``
36
+ * Create agents from JSON dicts or markdown front-matter
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ config_dir: Optional[Path] = None,
42
+ project_dir: Optional[Path] = None,
43
+ ):
44
+ self._agents: dict[str, BaseAgent] = {}
45
+ self._active_agent: str = "build"
46
+ self._invocation_history: list[AgentInvocation] = []
47
+ self._config_dir = config_dir or Path.home() / ".config" / "anvil"
48
+ self._project_dir = project_dir or Path.cwd()
49
+
50
+ # Register builtins (they may be factory functions or BaseAgent instances)
51
+ for name, agent in BUILTIN_AGENTS.items():
52
+ self._agents[name] = agent() if callable(agent) and not isinstance(agent, BaseAgent) else agent
53
+
54
+ # Load user-defined agents
55
+ self._load_from_directory(self._config_dir / "agents")
56
+ self._load_from_directory(self._project_dir / ".anvil" / "agents")
57
+
58
+ # ── registration ───────────────────────────────────────────────────
59
+
60
+ def register(self, agent: BaseAgent) -> None:
61
+ """Register (or replace) an agent by name."""
62
+ self._agents[agent.name] = agent
63
+
64
+ def get(self, name: str) -> Optional[BaseAgent]:
65
+ """Look up an agent by name."""
66
+ return self._agents.get(name)
67
+
68
+ def list_agents(self, include_hidden: bool = False) -> list[BaseAgent]:
69
+ """Return all registered agents, optionally including hidden ones."""
70
+ return [
71
+ a for a in self._agents.values()
72
+ if include_hidden or not a.hidden
73
+ ]
74
+
75
+ @property
76
+ def active_agent(self) -> BaseAgent:
77
+ """The agent currently owning the primary loop."""
78
+ return self._agents[self._active_agent]
79
+
80
+ def switch(self, name: str) -> BaseAgent:
81
+ """Switch the active primary agent.
82
+
83
+ Raises ``KeyError`` if *name* is not registered or the agent
84
+ is not a PRIMARY agent.
85
+ """
86
+ agent = self._agents.get(name)
87
+ if agent is None:
88
+ raise KeyError(f"Unknown agent: {name!r}. Available: {list(self._agents.keys())}")
89
+ if not agent.is_primary:
90
+ raise ValueError(f"Agent {name!r} is a subagent — use @mention, not switch")
91
+ self._active_agent = name
92
+ return agent
93
+
94
+ # ── @mention dispatch ──────────────────────────────────────────────
95
+
96
+ _MENTION_RE = re.compile(r"@(\w+)\s+(.*)", re.DOTALL)
97
+
98
+ def parse_mention(self, text: str) -> Optional[tuple[str, str]]:
99
+ """Extract ``@agent task`` from *text*.
100
+
101
+ Returns ``(agent_name, task)`` or ``None`` if no @mention found.
102
+ """
103
+ m = self._MENTION_RE.search(text.strip())
104
+ if m:
105
+ return m.group(1).lower(), m.group(2).strip()
106
+ return None
107
+
108
+ def invoke_subagent(
109
+ self,
110
+ name: str,
111
+ task: str,
112
+ model: Any = None,
113
+ working_dir: str = ".",
114
+ ) -> AgentInvocation:
115
+ """Invoke a subagent and capture its response.
116
+
117
+ Parameters
118
+ ----------
119
+ name : str
120
+ The subagent's registered name.
121
+ task : str
122
+ Description of what the subagent should do.
123
+ model : BaseModel, optional
124
+ An already-constructed model backend. If ``None``, one is
125
+ created from the agent's model setting.
126
+ working_dir : str
127
+ Working directory for tool execution.
128
+
129
+ Returns
130
+ -------
131
+ AgentInvocation
132
+ Summary of what the subagent did.
133
+ """
134
+ import time
135
+
136
+ agent = self._agents.get(name)
137
+ if agent is None:
138
+ return AgentInvocation(
139
+ agent_name=name, task=task,
140
+ response=f"Error: unknown agent '{name}'",
141
+ success=False,
142
+ )
143
+ if not agent.is_subagent:
144
+ return AgentInvocation(
145
+ agent_name=name, task=task,
146
+ response=f"Error: '{name}' is a primary agent, not a subagent",
147
+ success=False,
148
+ )
149
+
150
+ start = time.time()
151
+
152
+ # Resolve or create the model backend.
153
+ if model is None:
154
+ from anvil.core.config import AnvilConfig
155
+ cfg = AnvilConfig()
156
+ cfg.model.model = agent.model
157
+ model = ModelRegistry.create(
158
+ agent.model,
159
+ api_key=cfg.model.api_key,
160
+ api_base=cfg.model.api_base,
161
+ )
162
+
163
+ # Build the tool list available to this agent.
164
+ from anvil.tools.executor import ToolExecutor
165
+ all_tool_names = [t["name"] for t in _all_tool_definitions()]
166
+ available = agent.available_tools(all_tool_names)
167
+ system_prompt = agent.get_system_prompt(available)
168
+
169
+ messages = [
170
+ Message(role="system", content=system_prompt),
171
+ Message(role="user", content=task),
172
+ ]
173
+
174
+ response: ModelResponse = model.complete(
175
+ messages,
176
+ temperature=agent.temperature,
177
+ max_tokens=agent.extra.get("max_tokens", 4096),
178
+ )
179
+
180
+ duration_ms = (time.time() - start) * 1000
181
+
182
+ invocation = AgentInvocation(
183
+ agent_name=name,
184
+ task=task,
185
+ response=response.content,
186
+ success=True,
187
+ tool_calls=response.tool_calls,
188
+ duration_ms=duration_ms,
189
+ )
190
+ self._invocation_history.append(invocation)
191
+ return invocation
192
+
193
+ # ── custom agent creation ───────────────────────────────────────────
194
+
195
+ def create_agent_from_dict(self, name: str, spec: dict[str, Any]) -> BaseAgent:
196
+ """Create and register a custom agent from a plain dict.
197
+
198
+ Example ``spec``::
199
+
200
+ {
201
+ "description": "Reviews code for quality",
202
+ "mode": "subagent",
203
+ "model": "claude-3.5-sonnet",
204
+ "permission": {"edit": "deny", "bash": "ask"},
205
+ "max_steps": 10
206
+ }
207
+
208
+ Missing keys default to sensible values.
209
+ """
210
+ spec_copy = dict(spec)
211
+ spec_copy.setdefault("description", f"Custom agent: {name}")
212
+ spec_copy.setdefault("mode", "subagent")
213
+ spec_copy.setdefault("model", "local")
214
+ spec_copy.setdefault("temperature", 0.2)
215
+ spec_copy.setdefault("top_p", 1.0)
216
+ spec_copy.setdefault("max_steps", 20)
217
+ spec_copy.setdefault("tools_whitelist", [])
218
+ spec_copy.setdefault("tools_blacklist", [])
219
+ spec_copy.setdefault("hidden", False)
220
+ spec_copy.setdefault("color", "white")
221
+ spec_copy.setdefault("prompt_template", "")
222
+ spec_copy.setdefault("extra", {})
223
+
224
+ agent = BaseAgent(name=name, **{
225
+ k: v for k, v in spec_copy.items()
226
+ if k in BaseAgent.__dataclass_fields__
227
+ })
228
+ self.register(agent)
229
+ return agent
230
+
231
+ def create_agent_from_markdown(self, text: str, name: Optional[str] = None) -> BaseAgent:
232
+ """Parse an agent from markdown with YAML-like front matter.
233
+
234
+ Format::
235
+
236
+ ---
237
+ name: code-reviewer
238
+ description: Reviews code for quality
239
+ mode: subagent
240
+ model: anthropic/claude-sonnet-4
241
+ permission:
242
+ edit: deny
243
+ bash: ask
244
+ ---
245
+
246
+ You are a code reviewer...
247
+ (body becomes the prompt_template)
248
+
249
+ """
250
+ front_matter: dict[str, Any] = {}
251
+ body = ""
252
+
253
+ if text.strip().startswith("---"):
254
+ parts = text.strip().split("---", 2)
255
+ if len(parts) >= 3:
256
+ fm_text = parts[1].strip()
257
+ body = parts[2].strip()
258
+ for line in fm_text.split("\n"):
259
+ line = line.strip()
260
+ if not line or line.startswith("#"):
261
+ continue
262
+ if ":" in line:
263
+ key, _, value = line.partition(":")
264
+ key = key.strip()
265
+ value = value.strip()
266
+ # Parse nested YAML (simple 2-space indent).
267
+ if value == "" and key == "permission":
268
+ # Will be handled in the next indented block.
269
+ continue
270
+ front_matter[key] = _parse_yaml_value(value)
271
+ else:
272
+ body = text
273
+ else:
274
+ body = text
275
+
276
+ # Handle indented permission block inside front matter.
277
+ permission_block: dict[str, str] = {}
278
+ if "permission" not in front_matter:
279
+ # Try to parse from raw front matter.
280
+ fm_lines = text.strip().split("---")[1].strip().split("\n") if text.strip().startswith("---") else []
281
+ in_permission = False
282
+ for line in fm_lines:
283
+ stripped = line.strip()
284
+ if stripped.startswith("permission:"):
285
+ in_permission = True
286
+ continue
287
+ if in_permission:
288
+ if line.startswith(" ") or line.startswith("\t"):
289
+ k, _, v = stripped.partition(":")
290
+ permission_block[k.strip()] = v.strip()
291
+ else:
292
+ in_permission = False
293
+ if permission_block:
294
+ front_matter["permission"] = permission_block
295
+
296
+ if body and "prompt_template" not in front_matter:
297
+ front_matter["prompt_template"] = body + "\n\nAvailable tools: {tools}"
298
+
299
+ agent_name = name or front_matter.pop("name", "custom")
300
+ front_matter.pop("name", None)
301
+ return self.create_agent_from_dict(agent_name, front_matter)
302
+
303
+ # ── directory loading ───────────────────────────────────────────────
304
+
305
+ def _load_from_directory(self, directory: Path) -> None:
306
+ """Load custom agents from *.json* and *.md* files in *directory*.
307
+
308
+ JSON files should map agent-name → spec.
309
+ Markdown files should use front-matter format.
310
+ """
311
+ if not directory.exists():
312
+ return
313
+
314
+ for filepath in sorted(directory.iterdir()):
315
+ if filepath.suffix == ".json":
316
+ try:
317
+ data = json.loads(filepath.read_text(encoding="utf-8"))
318
+ for agent_name, spec in data.items():
319
+ if isinstance(spec, dict):
320
+ self.create_agent_from_dict(agent_name, spec)
321
+ except (json.JSONDecodeError, TypeError, KeyError) as exc:
322
+ # Silently skip malformed files — they'll be logged elsewhere.
323
+ pass
324
+
325
+ elif filepath.suffix == ".md":
326
+ try:
327
+ text = filepath.read_text(encoding="utf-8")
328
+ agent_name = filepath.stem
329
+ self.create_agent_from_markdown(text, name=agent_name)
330
+ except Exception:
331
+ pass
332
+
333
+ # ── lifecycle helpers ───────────────────────────────────────────────
334
+
335
+ def agent_names(self) -> list[str]:
336
+ """Return names of all non-hidden agents."""
337
+ return [a.name for a in self.list_agents(include_hidden=False)]
338
+
339
+ def all_agent_names(self) -> list[str]:
340
+ """Return names of all agents including hidden."""
341
+ return list(self._agents.keys())
342
+
343
+ def invocation_history(self) -> list[AgentInvocation]:
344
+ """Return the history of all subagent invocations."""
345
+ return list(self._invocation_history)
346
+
347
+
348
+ def _all_tool_definitions() -> list[dict]:
349
+ """Return the master list of built-in tool definitions."""
350
+ return [
351
+ {"name": "bash", "description": "Run a shell command", "args": ["command"]},
352
+ {"name": "read", "description": "Read a file", "args": ["path", "offset", "limit"]},
353
+ {"name": "write", "description": "Write a file", "args": ["path", "content"]},
354
+ {"name": "edit", "description": "Edit a file by replacing text", "args": ["path", "old_string", "new_string"]},
355
+ {"name": "grep", "description": "Search file contents", "args": ["pattern", "path", "include"]},
356
+ {"name": "glob", "description": "Find files by pattern", "args": ["pattern", "path"]},
357
+ {"name": "ls", "description": "List directory contents", "args": ["path"]},
358
+ ]
359
+
360
+
361
+ def _parse_yaml_value(value: str) -> Any:
362
+ """Minimal YAML value parser for front-matter scalars."""
363
+ value = value.strip()
364
+ if value.lower() in ("true", "yes"):
365
+ return True
366
+ if value.lower() in ("false", "no"):
367
+ return False
368
+ try:
369
+ return int(value)
370
+ except ValueError:
371
+ pass
372
+ try:
373
+ return float(value)
374
+ except ValueError:
375
+ pass
376
+ if value.startswith('"') and value.endswith('"'):
377
+ return value[1:-1]
378
+ if value.startswith("'") and value.endswith("'"):
379
+ return value[1:-1]
380
+ return value