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.
- anvil/__init__.py +154 -0
- anvil/agents/__init__.py +28 -0
- anvil/agents/agent_base.py +127 -0
- anvil/agents/agent_manager.py +380 -0
- anvil/agents/builtin_agents.py +156 -0
- anvil/cli.py +507 -0
- anvil/commands/__init__.py +5 -0
- anvil/commands/command_manager.py +148 -0
- anvil/compaction/__init__.py +5 -0
- anvil/compaction/compactor.py +159 -0
- anvil/config_v2/__init__.py +5 -0
- anvil/config_v2/config_v2.py +266 -0
- anvil/core/__init__.py +19 -0
- anvil/core/commands.py +273 -0
- anvil/core/compaction.py +204 -0
- anvil/core/config.py +228 -0
- anvil/core/config_v2.py +519 -0
- anvil/core/engine.py +516 -0
- anvil/core/init_project.py +792 -0
- anvil/core/rules.py +198 -0
- anvil/core/session.py +166 -0
- anvil/core/snapshot.py +311 -0
- anvil/daemon/__init__.py +4 -0
- anvil/daemon/server.py +94 -0
- anvil/integrations/__init__.py +13 -0
- anvil/integrations/agent_swarm.py +98 -0
- anvil/integrations/cost_optimizer.py +157 -0
- anvil/integrations/error_recovery.py +188 -0
- anvil/integrations/verifyloop.py +143 -0
- anvil/mcp/__init__.py +13 -0
- anvil/mcp/mcp_manager.py +379 -0
- anvil/mcp/mcp_types.py +175 -0
- anvil/models/__init__.py +4 -0
- anvil/models/anthropic_model.py +5 -0
- anvil/models/local.py +5 -0
- anvil/models/openai_model.py +5 -0
- anvil/models/registry.py +284 -0
- anvil/permissions/__init__.py +5 -0
- anvil/permissions/permissions.py +265 -0
- anvil/rules/__init__.py +5 -0
- anvil/rules/rules_manager.py +123 -0
- anvil/sdk.py +712 -0
- anvil/snapshot/__init__.py +5 -0
- anvil/snapshot/snapshot_manager.py +148 -0
- anvil/tools/__init__.py +18 -0
- anvil/tools/executor.py +291 -0
- anvil/tools/new_tools.py +258 -0
- anvil/tui/__init__.py +17 -0
- anvil/tui/app.py +781 -0
- anvil/tui/dashboard.py +103 -0
- anvil/verify/__init__.py +4 -0
- anvil/verify/pipeline.py +266 -0
- fableforge_anvil_agent-0.1.0.dist-info/METADATA +289 -0
- fableforge_anvil_agent-0.1.0.dist-info/RECORD +58 -0
- fableforge_anvil_agent-0.1.0.dist-info/WHEEL +5 -0
- fableforge_anvil_agent-0.1.0.dist-info/entry_points.txt +7 -0
- fableforge_anvil_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
]
|
anvil/agents/__init__.py
ADDED
|
@@ -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
|