From 3b5fa266a6d819c54b7e35f2f2ab20c68f6a8e5c Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Tue, 5 May 2026 22:31:29 +0800
Subject: [PATCH 01/11] feat(agent): add AGENTS_DIR constant and agent data
model
---
src/constants/manual_aid.py | 3 +++
src/models/agent.py | 30 +++++++++++++++++++++++++++
tests/models/test_agent.py | 41 +++++++++++++++++++++++++++++++++++++
3 files changed, 74 insertions(+)
create mode 100644 src/models/agent.py
create mode 100644 tests/models/test_agent.py
diff --git a/src/constants/manual_aid.py b/src/constants/manual_aid.py
index f22ee04..6c88e60 100644
--- a/src/constants/manual_aid.py
+++ b/src/constants/manual_aid.py
@@ -24,3 +24,6 @@
# 用户配置覆盖文件名
CONFIG_FILE = "config.json"
+
+# Agent 配置目录
+AGENTS_DIR = "agents"
diff --git a/src/models/agent.py b/src/models/agent.py
new file mode 100644
index 0000000..8d7874c
--- /dev/null
+++ b/src/models/agent.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+
+@dataclass
+class ToolPermissions:
+ whitelist: list[str] = field(default_factory=list)
+ blacklist: list[str] = field(default_factory=list)
+
+ def is_tool_allowed(self, tool_name: str) -> bool:
+ """判定工具是否应注入到 /ws 输出.
+
+ Priority: blacklist first, then whitelist.
+ Empty whitelist = allow all.
+ """
+ if tool_name in self.blacklist:
+ return False
+ if self.whitelist and tool_name not in self.whitelist:
+ return False
+ return True
+
+
+@dataclass
+class AgentConfig:
+ name: str
+ description: str
+ tool_permissions: ToolPermissions
+ body_role: str = ""
+ body_workflow: str = ""
diff --git a/tests/models/test_agent.py b/tests/models/test_agent.py
new file mode 100644
index 0000000..bebea07
--- /dev/null
+++ b/tests/models/test_agent.py
@@ -0,0 +1,41 @@
+"""Tests for agent data models."""
+
+from src.models.agent import ToolPermissions
+
+
+def test_empty_permissions_allow_all():
+ p = ToolPermissions()
+ assert p.is_tool_allowed("read")
+ assert p.is_tool_allowed("write")
+ assert p.is_tool_allowed("git")
+
+
+def test_whitelist_restricts_tools():
+ p = ToolPermissions(whitelist=["read", "glob", "ls"])
+ assert p.is_tool_allowed("read")
+ assert p.is_tool_allowed("glob")
+ assert not p.is_tool_allowed("write")
+ assert not p.is_tool_allowed("git")
+
+
+def test_blacklist_blocks_tools():
+ p = ToolPermissions(blacklist=["git"])
+ assert not p.is_tool_allowed("git")
+ assert p.is_tool_allowed("read")
+
+
+def test_blacklist_overrides_whitelist():
+ p = ToolPermissions(whitelist=["read", "git"], blacklist=["git"])
+ assert p.is_tool_allowed("read")
+ assert not p.is_tool_allowed("git")
+
+
+def test_whitelist_empty_is_no_restriction():
+ """Empty whitelist means 'no whitelist restriction' — all tools pass."""
+ p = ToolPermissions(whitelist=[], blacklist=[])
+ assert p.is_tool_allowed("anything")
+
+
+def test_whitelist_and_blacklist_both_empty():
+ p = ToolPermissions()
+ assert p.is_tool_allowed("any_tool")
From 972ca517e46475f368a2084481ab38b5aabdfee4 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Tue, 5 May 2026 22:36:39 +0800
Subject: [PATCH 02/11] test(agent): add AgentConfig smoke tests, deduplicate
permission test
---
tests/models/test_agent.py | 38 ++++++++++++++++++++++++++++----------
1 file changed, 28 insertions(+), 10 deletions(-)
diff --git a/tests/models/test_agent.py b/tests/models/test_agent.py
index bebea07..8ace23f 100644
--- a/tests/models/test_agent.py
+++ b/tests/models/test_agent.py
@@ -1,6 +1,6 @@
"""Tests for agent data models."""
-from src.models.agent import ToolPermissions
+from src.models.agent import AgentConfig, ToolPermissions
def test_empty_permissions_allow_all():
@@ -8,6 +8,9 @@ def test_empty_permissions_allow_all():
assert p.is_tool_allowed("read")
assert p.is_tool_allowed("write")
assert p.is_tool_allowed("git")
+ # Explicit empty lists are equivalent to no arguments
+ p2 = ToolPermissions(whitelist=[], blacklist=[])
+ assert p2.is_tool_allowed("anything")
def test_whitelist_restricts_tools():
@@ -30,12 +33,27 @@ def test_blacklist_overrides_whitelist():
assert not p.is_tool_allowed("git")
-def test_whitelist_empty_is_no_restriction():
- """Empty whitelist means 'no whitelist restriction' — all tools pass."""
- p = ToolPermissions(whitelist=[], blacklist=[])
- assert p.is_tool_allowed("anything")
-
-
-def test_whitelist_and_blacklist_both_empty():
- p = ToolPermissions()
- assert p.is_tool_allowed("any_tool")
+def test_agent_config_creation():
+ """Verify AgentConfig stores fields correctly."""
+ perms = ToolPermissions(whitelist=["read", "glob"])
+ agent = AgentConfig(
+ name="test-agent",
+ description="A test agent",
+ tool_permissions=perms,
+ body_role="## Role\nYou are a test agent.",
+ body_workflow="## Workflow\n1. Do work.",
+ )
+ assert agent.name == "test-agent"
+ assert agent.description == "A test agent"
+ assert agent.tool_permissions is perms
+ assert "You are a test agent." in agent.body_role
+ assert "Do work." in agent.body_workflow
+
+
+def test_agent_config_defaults():
+ """Verify AgentConfig defaults are empty strings."""
+ agent = AgentConfig(name="minimal", description="Minimal", tool_permissions=ToolPermissions())
+ assert agent.body_role == ""
+ assert agent.body_workflow == ""
+ assert agent.tool_permissions.whitelist == []
+ assert agent.tool_permissions.blacklist == []
From f0d2feb6c8d7e578e93405041d987ea3220bd1c0 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Tue, 5 May 2026 22:40:26 +0800
Subject: [PATCH 03/11] feat(agent): add AgentManager singleton with
frontmatter parser
---
src/core/agent_manager.py | 278 +++++++++++++++++++++++++++++++
tests/core/test_agent_manager.py | 114 +++++++++++++
2 files changed, 392 insertions(+)
create mode 100644 src/core/agent_manager.py
create mode 100644 tests/core/test_agent_manager.py
diff --git a/src/core/agent_manager.py b/src/core/agent_manager.py
new file mode 100644
index 0000000..0a73c6c
--- /dev/null
+++ b/src/core/agent_manager.py
@@ -0,0 +1,278 @@
+"""Agent manager — singleton, loads and manages agent configurations."""
+
+from __future__ import annotations
+
+import threading
+import warnings
+from pathlib import Path
+
+from src.constants.manual_aid import AGENTS_DIR, MANUALAID_DIR
+from src.models.agent import AgentConfig, ToolPermissions
+
+
+# ---------------------------------------------------------------------------
+# Frontmatter parser (YAML subset: key:value, nested keys, dash lists)
+# ---------------------------------------------------------------------------
+
+def _parse_frontmatter(content: str) -> tuple[dict, str]:
+ """Parse YAML frontmatter delimited by --- markers.
+
+ Returns (metadata_dict, body_string). Only supports:
+ - key: value pairs (strings)
+ - nested keys via indentation (2-space)
+ - dash list items under nested keys
+
+ Non-goal: not a full YAML parser; only the subset needed for agent configs.
+ """
+ lines = content.split("\n")
+ if not lines or lines[0].strip() != "---":
+ return {}, content
+
+ end_idx = -1
+ for i in range(1, len(lines)):
+ if lines[i].strip() == "---":
+ end_idx = i
+ break
+
+ if end_idx == -1:
+ return {}, content
+
+ frontmatter_lines = lines[1:end_idx]
+ body = "\n".join(lines[end_idx + 1:]).strip()
+
+ metadata: dict = {}
+ current_section: str | None = None
+ current_subsection: str | None = None
+
+ for line in frontmatter_lines:
+ stripped = line.rstrip()
+ if not stripped.strip():
+ continue
+
+ indent = len(line) - len(line.lstrip())
+
+ if stripped.lstrip().startswith("- ") and current_subsection:
+ item = stripped.lstrip()[2:].strip()
+ if item:
+ metadata.setdefault(current_subsection, []).append(item)
+ elif ":" in stripped:
+ parts = stripped.split(":", 1)
+ key = parts[0].strip()
+ value = parts[1].strip() if len(parts) > 1 and parts[1].strip() else ""
+
+ if indent == 0:
+ current_section = key
+ current_subsection = None
+ if value:
+ metadata[key] = value
+ elif indent > 0 and current_section:
+ qualified = f"{current_section}.{key}"
+ if value and value != "[]":
+ metadata[qualified] = value
+ current_subsection = None
+ else:
+ current_subsection = qualified if value != "[]" else None
+
+ return metadata, body
+
+
+def _parse_agent_file(file_path: Path) -> AgentConfig | None:
+ """Parse a single agent .md file into an AgentConfig."""
+ try:
+ content = file_path.read_text(encoding="utf-8")
+ except Exception as e:
+ warnings.warn(f"Failed to read agent file {file_path}: {e}", stacklevel=2)
+ return None
+
+ metadata, body = _parse_frontmatter(content)
+
+ name = metadata.get("name", file_path.stem)
+ description = metadata.get("description", "")
+
+ # Parse tool permissions — handle both list and empty-list cases
+ raw_whitelist = metadata.get("tool_permissions.whitelist", [])
+ raw_blacklist = metadata.get("tool_permissions.blacklist", [])
+ if isinstance(raw_whitelist, str):
+ raw_whitelist = [raw_whitelist] if raw_whitelist else []
+ if isinstance(raw_blacklist, str):
+ raw_blacklist = [raw_blacklist] if raw_blacklist else []
+
+ tool_permissions = ToolPermissions(
+ whitelist=raw_whitelist,
+ blacklist=raw_blacklist,
+ )
+
+ # Parse ## Role and ## Workflow sections from body
+ body_role = ""
+ body_workflow = ""
+ current_heading: str | None = None
+ section_lines: list[str] = []
+
+ for line in body.split("\n"):
+ if line.startswith("## "):
+ # Save previous section
+ heading_text = line[3:].strip().lower()
+ joined = "\n".join(section_lines).strip()
+ if current_heading == "role":
+ body_role = joined
+ elif current_heading == "workflow":
+ body_workflow = joined
+
+ current_heading = heading_text if heading_text in ("role", "workflow") else None
+ section_lines = [line]
+ elif current_heading:
+ section_lines.append(line)
+ else:
+ section_lines = []
+
+ # Save last section
+ joined = "\n".join(section_lines).strip()
+ if current_heading == "role":
+ body_role = joined
+ elif current_heading == "workflow":
+ body_workflow = joined
+
+ return AgentConfig(
+ name=name,
+ description=description,
+ tool_permissions=tool_permissions,
+ body_role=body_role,
+ body_workflow=body_workflow,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Agent Manager (singleton)
+# ---------------------------------------------------------------------------
+
+class AgentManager:
+ """Singleton — loads and caches agent configurations from .ManualAid/agents/.
+
+ Usage:
+ mgr = AgentManager()
+ mgr.initialize(workspace_root)
+ mgr.write_default(workspace_root)
+ agent = mgr.get_current()
+ """
+
+ _instance: AgentManager | None = None
+ _instance_lock: threading.Lock = threading.Lock()
+
+ def __new__(cls) -> AgentManager:
+ with cls._instance_lock:
+ if cls._instance is None:
+ instance = super().__new__(cls)
+ instance._initialized = False
+ cls._instance = instance
+ return cls._instance
+
+ def __init__(self) -> None:
+ if self._initialized:
+ return
+ self._agents: dict[str, AgentConfig] = {}
+ self._agents_dir: Path | None = None
+ self._loaded = False
+ self._current_agent_name: str = "default"
+ self._initialized = True
+
+ @property
+ def current_agent_name(self) -> str:
+ return self._current_agent_name
+
+ @current_agent_name.setter
+ def current_agent_name(self, value: str) -> None:
+ self._current_agent_name = value
+
+ def initialize(self, root_path: str | Path) -> None:
+ """Set the workspace root. Agents are loaded lazily on first access."""
+ self._agents_dir = Path(root_path) / MANUALAID_DIR / AGENTS_DIR
+ self._loaded = False
+ self._agents = {}
+
+ def _ensure_loaded(self) -> None:
+ if self._loaded:
+ return
+ self._agents = {}
+ if self._agents_dir and self._agents_dir.is_dir():
+ for fpath in sorted(self._agents_dir.glob("*.md")):
+ agent = _parse_agent_file(fpath)
+ if agent is not None:
+ self._agents[agent.name] = agent
+ self._loaded = True
+
+ def get(self, name: str) -> AgentConfig | None:
+ self._ensure_loaded()
+ return self._agents.get(name)
+
+ def get_default(self) -> AgentConfig:
+ self._ensure_loaded()
+ default = self._agents.get("default")
+ if default is None:
+ return AgentConfig(
+ name="default",
+ description="Default agent",
+ tool_permissions=ToolPermissions(),
+ )
+ return default
+
+ def get_current(self) -> AgentConfig:
+ agent = self.get(self._current_agent_name)
+ return agent if agent is not None else self.get_default()
+
+ def switch_agent(self, name: str) -> bool:
+ self._ensure_loaded()
+ if name in self._agents:
+ self._current_agent_name = name
+ return True
+ return False
+
+ def list_agents(self) -> list[AgentConfig]:
+ self._ensure_loaded()
+ return list(self._agents.values())
+
+ def agent_names(self) -> list[str]:
+ self._ensure_loaded()
+ return sorted(self._agents.keys())
+
+ def write_default(self, root_path: str | Path) -> None:
+ """Write the default.md agent file if it does not exist.
+
+ Content mirrors the prompts.py SYSTEM_IDENTITY and WORKFLOW_GUIDELINES
+ so that users can edit language/behavior by modifying this file.
+ """
+ agents_dir = Path(root_path) / MANUALAID_DIR / AGENTS_DIR
+ agents_dir.mkdir(parents=True, exist_ok=True)
+ default_path = agents_dir / "default.md"
+ if default_path.exists():
+ return
+
+ content = r"""---
+name: default
+description: Default ManualAid agent
+tool_permissions:
+ whitelist: []
+ blacklist: []
+---
+
+## Role
+
+你是一个与 ManualAid 工作区集成的、依赖工具进行文件探索和编辑的助手。
+你的能力来源于工作区提供的工具——如果没有调用正确的工具,你无法独立行动。
+
+
+ 你是一个依赖工具的助手。你不能独立行动;必须调用工具来完成任务
+ 严格使用指定的 XML 格式来调用工具(见 <tool_rules>)
+ 调用工具后,始终停止并等待用户的工具输出
+ 绝不虚构工具返回值。绝不臆测结果继续
+ 如果工具调用失败或返回空,向用户请求澄清
+
+
+## Workflow
+
+1. 在采取行动前充分理解用户的请求。如有疑问,先提问再行动。
+2. 将复杂或多步骤任务分解为较小的顺序子任务;一次一个步骤。
+3. 为每个步骤选择最合适的工具。如果没有合适的工具,解释并请求替代方案。
+4. 等待每个工具的结果后再继续下一步。
+5. 构建响应时,先使用工具收集信息,然后形成最终答案。
+"""
+ default_path.write_text(content, encoding="utf-8")
diff --git a/tests/core/test_agent_manager.py b/tests/core/test_agent_manager.py
new file mode 100644
index 0000000..fd1523a
--- /dev/null
+++ b/tests/core/test_agent_manager.py
@@ -0,0 +1,114 @@
+"""Tests for AgentManager and frontmatter parser."""
+
+from pathlib import Path
+
+from src.core.agent_manager import _parse_frontmatter, _parse_agent_file
+from src.models.agent import ToolPermissions
+
+
+class TestParseFrontmatter:
+ def test_no_frontmatter(self):
+ content = "## Role\nhello"
+ meta, body = _parse_frontmatter(content)
+ assert meta == {}
+ assert "hello" in body
+
+ def test_basic_frontmatter(self):
+ content = """---
+name: test-agent
+description: A test agent
+---
+## Role
+Hello World
+"""
+ meta, body = _parse_frontmatter(content)
+ assert meta["name"] == "test-agent"
+ assert meta["description"] == "A test agent"
+ assert "Hello World" in body
+
+ def test_permissions_frontmatter(self):
+ content = """---
+name: restricted
+description: Restricted agent
+tool_permissions:
+ whitelist:
+ - read
+ - glob
+ blacklist:
+ - git
+---
+## Role
+test
+"""
+ meta, body = _parse_frontmatter(content)
+ assert meta["tool_permissions.whitelist"] == ["read", "glob"]
+ assert meta["tool_permissions.blacklist"] == ["git"]
+
+ def test_empty_permissions(self):
+ content = """---
+name: empty
+description: Empty perms
+tool_permissions:
+ whitelist: []
+ blacklist: []
+---
+## Role
+test
+"""
+ meta, body = _parse_frontmatter(content)
+ whitelist = meta.get("tool_permissions.whitelist", [])
+ blacklist = meta.get("tool_permissions.blacklist", [])
+ assert whitelist == [] or whitelist == [""]
+ assert blacklist == [] or blacklist == [""]
+
+
+class TestParseAgentFile:
+ def test_full_agent_file(self, tmp_path: Path):
+ md_file = tmp_path / "test-agent.md"
+ md_file.write_text("""---
+name: test-agent
+description: My test agent
+tool_permissions:
+ whitelist:
+ - read
+ - glob
+ blacklist:
+ - git
+---
+
+## Role
+
+You are a test agent.
+
+## Workflow
+
+1. Test things.
+2. Verify results.
+""", encoding="utf-8")
+ agent = _parse_agent_file(md_file)
+ assert agent is not None
+ assert agent.name == "test-agent"
+ assert agent.description == "My test agent"
+ assert agent.tool_permissions.whitelist == ["read", "glob"]
+ assert agent.tool_permissions.blacklist == ["git"]
+ assert "You are a test agent." in agent.body_role
+ assert "Test things." in agent.body_workflow
+
+ def test_invalid_file_returns_none(self, tmp_path: Path):
+ md_file = tmp_path / "invalid.md"
+ md_file.write_bytes(b"\x80\x81\x82") # invalid UTF-8
+ agent = _parse_agent_file(md_file)
+ assert agent is None
+
+ def test_no_role_section(self, tmp_path: Path):
+ md_file = tmp_path / "no-role.md"
+ md_file.write_text("""---
+name: no-role
+description: No role
+---
+Some content without sections.
+""", encoding="utf-8")
+ agent = _parse_agent_file(md_file)
+ assert agent is not None
+ assert agent.body_role == ""
+ assert agent.body_workflow == ""
From b413b0c98a91e63c30c3a017f414b708e3d3fcca Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Tue, 5 May 2026 22:45:05 +0800
Subject: [PATCH 04/11] fix(agent): add AgentManager tests, fix
empty-permissions assertion, thread-safe loading
---
src/core/agent_manager.py | 18 ++++++++-----
tests/core/test_agent_manager.py | 46 +++++++++++++++++++++++++++++---
2 files changed, 54 insertions(+), 10 deletions(-)
diff --git a/src/core/agent_manager.py b/src/core/agent_manager.py
index 0a73c6c..9174a22 100644
--- a/src/core/agent_manager.py
+++ b/src/core/agent_manager.py
@@ -172,6 +172,7 @@ def __init__(self) -> None:
self._agents: dict[str, AgentConfig] = {}
self._agents_dir: Path | None = None
self._loaded = False
+ self._load_lock: threading.Lock = threading.Lock()
self._current_agent_name: str = "default"
self._initialized = True
@@ -192,13 +193,16 @@ def initialize(self, root_path: str | Path) -> None:
def _ensure_loaded(self) -> None:
if self._loaded:
return
- self._agents = {}
- if self._agents_dir and self._agents_dir.is_dir():
- for fpath in sorted(self._agents_dir.glob("*.md")):
- agent = _parse_agent_file(fpath)
- if agent is not None:
- self._agents[agent.name] = agent
- self._loaded = True
+ with self._load_lock:
+ if self._loaded:
+ return
+ self._agents = {}
+ if self._agents_dir and self._agents_dir.is_dir():
+ for fpath in sorted(self._agents_dir.glob("*.md")):
+ agent = _parse_agent_file(fpath)
+ if agent is not None:
+ self._agents[agent.name] = agent
+ self._loaded = True
def get(self, name: str) -> AgentConfig | None:
self._ensure_loaded()
diff --git a/tests/core/test_agent_manager.py b/tests/core/test_agent_manager.py
index fd1523a..c0557e1 100644
--- a/tests/core/test_agent_manager.py
+++ b/tests/core/test_agent_manager.py
@@ -2,7 +2,7 @@
from pathlib import Path
-from src.core.agent_manager import _parse_frontmatter, _parse_agent_file
+from src.core.agent_manager import AgentManager, _parse_frontmatter, _parse_agent_file
from src.models.agent import ToolPermissions
@@ -58,8 +58,8 @@ def test_empty_permissions(self):
meta, body = _parse_frontmatter(content)
whitelist = meta.get("tool_permissions.whitelist", [])
blacklist = meta.get("tool_permissions.blacklist", [])
- assert whitelist == [] or whitelist == [""]
- assert blacklist == [] or blacklist == [""]
+ assert whitelist == [], f"Expected [], got {whitelist!r}"
+ assert blacklist == [], f"Expected [], got {blacklist!r}"
class TestParseAgentFile:
@@ -112,3 +112,43 @@ def test_no_role_section(self, tmp_path: Path):
assert agent is not None
assert agent.body_role == ""
assert agent.body_workflow == ""
+
+
+class TestAgentManager:
+ def test_get_default_fallback(self, tmp_path):
+ """No agents dir -> get_default() returns a fallback AgentConfig."""
+ mgr = AgentManager()
+ mgr.initialize(tmp_path)
+ default = mgr.get_default()
+ assert default.name == "default"
+ assert default.tool_permissions.whitelist == []
+
+ def test_switch_unknown_returns_false(self, tmp_path):
+ mgr = AgentManager()
+ mgr.initialize(tmp_path)
+ assert mgr.switch_agent("nonexistent") is False
+
+ def test_write_default_creates_file(self, tmp_path):
+ mgr = AgentManager()
+ mgr.initialize(tmp_path)
+ mgr.write_default(tmp_path)
+ agents_dir = tmp_path / ".ManualAid" / "agents"
+ assert (agents_dir / "default.md").exists()
+
+ def test_write_default_is_idempotent(self, tmp_path):
+ mgr = AgentManager()
+ mgr.initialize(tmp_path)
+ mgr.write_default(tmp_path)
+ content_first = (tmp_path / ".ManualAid" / "agents" / "default.md").read_text(encoding="utf-8")
+ mgr.write_default(tmp_path) # second call should be no-op
+ content_second = (tmp_path / ".ManualAid" / "agents" / "default.md").read_text(encoding="utf-8")
+ assert content_first == content_second
+
+ def test_agent_names_sorted(self, tmp_path):
+ agents_dir = tmp_path / ".ManualAid" / "agents"
+ agents_dir.mkdir(parents=True)
+ (agents_dir / "z-agent.md").write_text("---\nname: z-agent\ndescription: Z\n---\n## Role\nx")
+ (agents_dir / "a-agent.md").write_text("---\nname: a-agent\ndescription: A\n---\n## Role\nx")
+ mgr = AgentManager()
+ mgr.initialize(tmp_path)
+ assert mgr.agent_names() == ["a-agent", "z-agent"]
From 0aaf6aeac4b0746a97ddc7becebb57c67892263e Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Tue, 5 May 2026 22:50:24 +0800
Subject: [PATCH 05/11] feat(agent): add /agent command with list, switch, copy
---
src/console/commands/command_registry.py | 2 +
src/console/commands/workspaces/agent_cmd.py | 147 +++++++++++++++++++
2 files changed, 149 insertions(+)
create mode 100644 src/console/commands/workspaces/agent_cmd.py
diff --git a/src/console/commands/command_registry.py b/src/console/commands/command_registry.py
index fbb266a..0372832 100644
--- a/src/console/commands/command_registry.py
+++ b/src/console/commands/command_registry.py
@@ -48,6 +48,7 @@ def create_default(cls) -> CommandRegistry:
from src.console.commands.systems.quit_cmd import QuitCommand
from src.console.commands.systems.tool_detail_cmd import ToolDetailCommand
from src.console.commands.systems.tools_cmd import ToolsCommand
+ from src.console.commands.workspaces.agent_cmd import AgentCommand
from src.console.commands.workspaces.workspace_cmd import WorkspaceCommand
registry = cls()
@@ -59,6 +60,7 @@ def create_default(cls) -> CommandRegistry:
CopyCommand(),
HistoryCommand(),
WorkspaceCommand(),
+ AgentCommand(),
NewWindowCommand(),
HelpCommand(),
ClsCommand(),
diff --git a/src/console/commands/workspaces/agent_cmd.py b/src/console/commands/workspaces/agent_cmd.py
new file mode 100644
index 0000000..cb6c7d8
--- /dev/null
+++ b/src/console/commands/workspaces/agent_cmd.py
@@ -0,0 +1,147 @@
+"""Agent management command (/agent)."""
+
+from __future__ import annotations
+
+import pyperclip
+
+from src.core.agent_manager import AgentManager
+from src.models.commands import Command, CommandContext, CommandResult
+
+
+class AgentCommand(Command):
+ """Manage Agent configuration"""
+
+ def __init__(self):
+ super().__init__()
+ self.name = "agent"
+ self.aliases = ["/agent"]
+ self.description = "Manage agent configuration (list, switch, copy)"
+ self.usage = (
+ "/agent — show current agent\n"
+ "/agent list — list all agents\n"
+ "/agent — switch to agent by name or unique prefix\n"
+ "/agent default — switch to default agent\n"
+ "/agent copy — copy current agent's role+workflow to clipboard\n"
+ "/agent copy — copy specified agent's role+workflow to clipboard"
+ )
+
+ def execute(self, context: CommandContext) -> CommandResult:
+ mgr = AgentManager()
+ args = context.parsed_input.args.strip()
+
+ if not args:
+ return self._show_current(mgr, context)
+ if args == "list":
+ return self._list_all(mgr, context)
+ if args.startswith("copy"):
+ rest = args[4:].strip()
+ return self._copy_agent(mgr, context, rest or None)
+ if args == "default":
+ return self._switch(mgr, "default", context)
+
+ # Treat as agent name (supports unique prefix matching)
+ return self._switch(mgr, args, context)
+
+ def _show_current(self, mgr: AgentManager, context: CommandContext) -> CommandResult:
+ agent = mgr.get_current()
+ context.console.print(
+ f"[bold]Current Agent:[/bold] {agent.name}\n"
+ f"[dim]{agent.description}[/dim]\n"
+ f"Whitelist: {agent.tool_permissions.whitelist or '(all)'}\n"
+ f"Blacklist: {agent.tool_permissions.blacklist or '(none)'}"
+ )
+ return CommandResult(success=True)
+
+ def _list_all(self, mgr: AgentManager, context: CommandContext) -> CommandResult:
+ agents = mgr.list_agents()
+ if not agents:
+ context.console.print("[yellow]No agents found in .ManualAid/agents/[/yellow]")
+ return CommandResult(success=True)
+
+ lines = ["[bold]Available Agents:[/bold]"]
+ for a in agents:
+ marker = ">" if a.name == mgr.current_agent_name else " "
+ lines.append(f" {marker} {a.name} — {a.description}")
+ context.console.print("\n".join(lines))
+ return CommandResult(success=True)
+
+ def _switch(self, mgr: AgentManager, name: str, context: CommandContext) -> CommandResult:
+ # Try exact match first
+ if mgr.switch_agent(name):
+ agent = mgr.get_current()
+ context.console.print(f"[green]Switched to agent:[/green] {agent.name}")
+ # Update TUI dropdown if available
+ self._sync_tui(context, mgr.current_agent_name)
+ return CommandResult(success=True)
+
+ # Try unique prefix match
+ matches = [n for n in mgr.agent_names() if n.startswith(name)]
+ if len(matches) == 1:
+ mgr.switch_agent(matches[0])
+ context.console.print(f"[green]Switched to agent:[/green] {matches[0]}")
+ self._sync_tui(context, mgr.current_agent_name)
+ return CommandResult(success=True)
+
+ if len(matches) > 1:
+ context.console.print(
+ f"[red]Ambiguous prefix '{name}' matches: {', '.join(matches)}[/red]"
+ )
+ else:
+ context.console.print(f"[red]Agent '{name}' not found.[/red]")
+ context.console.print("Use [bold]/agent list[/bold] to see available agents.")
+ return CommandResult(success=True)
+
+ def _copy_agent(self, mgr: AgentManager, context: CommandContext, name: str | None) -> CommandResult:
+ if name:
+ # Resolve name (exact or unique prefix)
+ agent = mgr.get(name)
+ if agent is None:
+ matches = [n for n in mgr.agent_names() if n.startswith(name)]
+ if len(matches) == 1:
+ agent = mgr.get(matches[0])
+ elif len(matches) > 1:
+ context.console.print(
+ f"[red]Ambiguous prefix '{name}' matches: {', '.join(matches)}[/red]"
+ )
+ return CommandResult(success=False)
+ else:
+ context.console.print(f"[red]Agent '{name}' not found.[/red]")
+ return CommandResult(success=False)
+ else:
+ agent = mgr.get_current()
+
+ text = self._format_agent_copy(agent)
+ try:
+ pyperclip.copy(text)
+ context.console.print(f"[green]Agent '{agent.name}' settings copied to clipboard.[/green]")
+ except Exception:
+ context.console.print(text)
+ context.console.print("[yellow](pyperclip unavailable — printed above instead)[/yellow]")
+ return CommandResult(success=True)
+
+ @staticmethod
+ def _format_agent_copy(agent: "AgentConfig") -> str:
+ """Format agent body (role + workflow) for external pasting."""
+ parts = [f"--- Agent: {agent.name} ---", ""]
+ if agent.body_role:
+ parts.append(agent.body_role)
+ parts.append("")
+ if agent.body_workflow:
+ parts.append(agent.body_workflow)
+ parts.append("")
+ return "\n".join(parts).strip()
+
+ @staticmethod
+ def _sync_tui(context: CommandContext, agent_name: str) -> None:
+ """Update TUI dropdown and title bar after agent switch."""
+ app = context.app
+ if app is None:
+ return
+ try:
+ from textual.widgets import Select
+
+ select = app.query_one("#agent-select", Select)
+ if select:
+ select.value = agent_name
+ except Exception:
+ pass
From 0116d5efb4780a7f820354a6708d5eb21ca89662 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Tue, 5 May 2026 22:59:59 +0800
Subject: [PATCH 06/11] fix(agent): use parsed_input.source for args and
copy_to_clipboard wrapper
---
src/console/commands/workspaces/agent_cmd.py | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/console/commands/workspaces/agent_cmd.py b/src/console/commands/workspaces/agent_cmd.py
index cb6c7d8..b620e97 100644
--- a/src/console/commands/workspaces/agent_cmd.py
+++ b/src/console/commands/workspaces/agent_cmd.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-import pyperclip
+from src.core.copy2clip import copy_to_clipboard
from src.core.agent_manager import AgentManager
from src.models.commands import Command, CommandContext, CommandResult
@@ -27,7 +27,9 @@ def __init__(self):
def execute(self, context: CommandContext) -> CommandResult:
mgr = AgentManager()
- args = context.parsed_input.args.strip()
+ # Parse args from source: "/agent list" -> "list"
+ parts = context.parsed_input.source.split()
+ args = " ".join(parts[1:]) if len(parts) > 1 else ""
if not args:
return self._show_current(mgr, context)
@@ -93,7 +95,6 @@ def _switch(self, mgr: AgentManager, name: str, context: CommandContext) -> Comm
def _copy_agent(self, mgr: AgentManager, context: CommandContext, name: str | None) -> CommandResult:
if name:
- # Resolve name (exact or unique prefix)
agent = mgr.get(name)
if agent is None:
matches = [n for n in mgr.agent_names() if n.startswith(name)]
@@ -111,12 +112,11 @@ def _copy_agent(self, mgr: AgentManager, context: CommandContext, name: str | No
agent = mgr.get_current()
text = self._format_agent_copy(agent)
- try:
- pyperclip.copy(text)
+ if copy_to_clipboard(text):
context.console.print(f"[green]Agent '{agent.name}' settings copied to clipboard.[/green]")
- except Exception:
+ else:
context.console.print(text)
- context.console.print("[yellow](pyperclip unavailable — printed above instead)[/yellow]")
+ context.console.print("[yellow](Clipboard unavailable — printed above instead)[/yellow]")
return CommandResult(success=True)
@staticmethod
From a21f347e85959bd487049589840ea565f1b59d69 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Tue, 5 May 2026 23:05:15 +0800
Subject: [PATCH 07/11] feat(agent): /ws injects agent directive and filters
tool definitions
---
.../commands/workspaces/workspace_cmd.py | 47 ++++++++++++++++---
1 file changed, 40 insertions(+), 7 deletions(-)
diff --git a/src/console/commands/workspaces/workspace_cmd.py b/src/console/commands/workspaces/workspace_cmd.py
index 53d9bdd..a06bdd7 100644
--- a/src/console/commands/workspaces/workspace_cmd.py
+++ b/src/console/commands/workspaces/workspace_cmd.py
@@ -9,6 +9,7 @@
WORKFLOW_GUIDELINES,
generate_extensions_section,
)
+from src.core.agent_manager import AgentManager
from src.models.commands import Command, CommandContext, CommandResult
INSTRUCTION: list[str] = ["AGENTS.md", "CLAUDE.md"]
@@ -17,13 +18,20 @@
def _generate_tool_definitions_section(context: CommandContext) -> str:
- """Generate XML block with doc for each registered tool."""
+ """Generate XML block with doc for each registered tool,
+ filtered by the current agent's tool permissions."""
+ mgr = AgentManager()
+ agent = mgr.get_current()
tools = context.tool_registry.list_tools()
+
if not tools.get("sync"):
return ""
docs: list[str] = [""]
for name in tools["sync"]:
+ # Filter by agent permissions
+ if not agent.tool_permissions.is_tool_allowed(name):
+ continue
info = context.tool_registry.get_tool_info(name)
if info:
doc_xml = info.to_doc()
@@ -82,6 +90,24 @@ def _load_agents_md(context: CommandContext) -> str:
return ""
+def _generate_agent_directive_section(context: CommandContext) -> str:
+ """Generate XML block from the current agent."""
+ mgr = AgentManager()
+ agent = mgr.get_current()
+ if not agent.body_role and not agent.body_workflow:
+ return ""
+
+ parts = [f' ', ""]
+ if agent.body_role:
+ parts.append(agent.body_role)
+ parts.append("")
+ if agent.body_workflow:
+ parts.append(agent.body_workflow)
+ parts.append("")
+ parts.append(" ")
+ return "\n".join(parts)
+
+
def _assemble_full_prompt(context: CommandContext) -> str:
"""Assemble the complete system prompt from ordered XML sections."""
sections = [
@@ -91,14 +117,21 @@ def _assemble_full_prompt(context: CommandContext) -> str:
"",
TOOL_RULES,
"",
- _generate_tool_definitions_section(context),
- "",
- WORKFLOW_GUIDELINES,
- "",
- _generate_workspace_metadata(context),
- "",
]
+ # Agent directive (if any)
+ agent_directive = _generate_agent_directive_section(context)
+ if agent_directive:
+ sections.append(agent_directive)
+ sections.append("")
+
+ sections.append(_generate_tool_definitions_section(context))
+ sections.append("")
+ sections.append(WORKFLOW_GUIDELINES)
+ sections.append("")
+ sections.append(_generate_workspace_metadata(context))
+ sections.append("")
+
augmentations = _load_agents_md(context)
if augmentations:
sections.append(augmentations)
From 1139197f9c254f64276c6fbc05c4365f4fbda9d4 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Tue, 5 May 2026 23:08:26 +0800
Subject: [PATCH 08/11] feat(agent): add agent selector dropdown to TUI and
init default agent on startup
---
src/console/main.py | 7 ++++++
src/console/ui/repl.py | 48 ++++++++++++++++++++++++++++++++++++------
2 files changed, 48 insertions(+), 7 deletions(-)
diff --git a/src/console/main.py b/src/console/main.py
index 0d1c65c..65a8d15 100644
--- a/src/console/main.py
+++ b/src/console/main.py
@@ -80,6 +80,13 @@ def init_workspace(start_path: str | None = None) -> Workspace | None:
workspace: Workspace = Workspace(str(folder_path))
tool_registry.register(workspace)
+ # Initialize AgentManager and write default agent config
+ from src.core.agent_manager import AgentManager
+
+ agent_manager = AgentManager()
+ agent_manager.initialize(workspace.root_path)
+ agent_manager.write_default(workspace.root_path)
+
# 在创建新会话之前清理孤立的会话
_cleanup_orphaned_sessions(workspace.db)
diff --git a/src/console/ui/repl.py b/src/console/ui/repl.py
index 47f2f86..a58b4ab 100644
--- a/src/console/ui/repl.py
+++ b/src/console/ui/repl.py
@@ -6,7 +6,7 @@
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
-from textual.widgets import Button, Footer, Label, TextArea
+from textual.widgets import Button, Footer, Label, Select, TextArea
from src.console.handlers.command_handler import CommandHandler
from src.console.handlers.tool_handler import ToolHandler
@@ -15,6 +15,7 @@
from src.core.paste_cache import PasteReference
from src.core.paste_window import show_paste_window
from src.utils.generate_help_text import generate_help_text
+from src.core.agent_manager import AgentManager
from src.utils.string_snapshot import truncate_for_display
if TYPE_CHECKING:
@@ -48,7 +49,7 @@ class REPL(App):
}
#title-left {
- width: 20%;
+ width: auto;
content-align: right middle;
text-style: bold;
color: $success;
@@ -56,7 +57,7 @@ class REPL(App):
}
#title-version {
- width: 20%;
+ width: auto;
content-align: left middle;
color: $text-muted;
text-style: italic;
@@ -64,8 +65,14 @@ class REPL(App):
margin-left: 1;
}
+ #agent-select {
+ width: auto;
+ max-width: 40;
+ margin: 0 1;
+ }
+
#title-right {
- width: 60%;
+ width: 1fr;
content-align: center middle;
color: $text-muted;
}
@@ -154,12 +161,23 @@ def __init__(
def compose(self) -> ComposeResult:
"""构建控件树"""
- # 标题栏:左侧名称 + 中间工作区路径 + 右侧版本号
+ # 标题栏:左侧名称 + 版本号 + Agent选择 + 工作区路径
with Horizontal(id="title-bar"), Horizontal():
yield Label(self.CONSOLE_TITLE, id="title-left")
from src.constants import __version__
yield Label(f"v{__version__}", id="title-version")
+
+ # Agent selector dropdown
+ mgr = AgentManager()
+ options = [(a, a) for a in mgr.agent_names()]
+ yield Select(
+ options,
+ id="agent-select",
+ prompt="Agent",
+ value=mgr.current_agent_name,
+ )
+
yield Label("工作区", id="title-right")
# 输出区域: 使用新的 TuiConsole 组件
@@ -191,9 +209,12 @@ def on_mount(self) -> None:
self.tui_console = tui_console
self.result_manager.console = tui_console
- # 更新标题栏右侧显示实际工作区路径
+ # 更新标题栏右侧显示实际工作区路径和当前 Agent
title_right = self.query_one("#title-right", Label)
- title_right.update(f"工作区: {self.workspace.root_path}")
+ mgr = AgentManager()
+ title_right.update(
+ f"Agent: {mgr.current_agent_name} | 工作区: {self.workspace.root_path}"
+ )
# 创建审核提交模块并注入审核标签页
from src.core.audit_committer import AuditCommitter
@@ -228,6 +249,19 @@ def on_mount(self) -> None:
# 自动聚焦输入框
self.query_one("#input-field", TextArea).focus()
+ def on_select_changed(self, event: Select.Changed) -> None:
+ """Handle agent selection change from dropdown."""
+ if event.select.id == "agent-select":
+ mgr = AgentManager()
+ if mgr.switch_agent(str(event.value)):
+ title_right = self.query_one("#title-right", Label)
+ title_right.update(
+ f"Agent: {mgr.current_agent_name} | 工作区: {self.workspace.root_path}"
+ )
+ self.tui_console.print(
+ f"[dim]Switched to agent: {mgr.current_agent_name}[/dim]"
+ )
+
# -- 输入处理 -----------------------------------------------------------
def on_button_pressed(self, event: Button.Pressed) -> None:
From c159042a65ce516c64e17922213b51f21131a241 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Tue, 5 May 2026 23:24:08 +0800
Subject: [PATCH 09/11] =?UTF-8?q?refactor(core):=20=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=A3=8E=E6=A0=BC=E5=B9=B6=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=9D=83=E9=99=90=E9=80=BB=E8=BE=91=20-=20?=
=?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96:=20=E8=B0=83=E6=95=B4?=
=?UTF-8?q?=E5=AF=BC=E5=85=A5=E9=A1=BA=E5=BA=8F=E4=B8=8E=E7=BC=A9=E8=BF=9B?=
=?UTF-8?q?=E6=A0=BC=E5=BC=8F=20=20=20*=20=E4=BF=AE=E6=AD=A3=20`src/consol?=
=?UTF-8?q?e/commands/workspaces/agent=5Fcmd.py`=20=E4=B8=AD=20`AgentManag?=
=?UTF-8?q?er`=E3=80=81`copy=5Fto=5Fclipboard`=20=E5=8F=8A=20`AgentConfig`?=
=?UTF-8?q?=20=E7=9A=84=E5=AF=BC=E5=85=A5=E9=A1=BA=E5=BA=8F=EF=BC=8C?=
=?UTF-8?q?=E7=AC=A6=E5=90=88=E9=A1=B9=E7=9B=AE=E8=A7=84=E8=8C=83=E3=80=82?=
=?UTF-8?q?=20=20=20*=20=E4=BC=98=E5=8C=96=20`src/core/agent=5Fmanager.py`?=
=?UTF-8?q?=20=E4=B8=AD=20`=5Fparse=5Ffrontmatter`=20=E5=87=BD=E6=95=B0?=
=?UTF-8?q?=E7=9A=84=E5=88=87=E7=89=87=E8=AF=AD=E6=B3=95=E4=B8=BA=20`lines?=
=?UTF-8?q?[end=5Fidx=20+=201=20:]`=E3=80=82=20=20=20*=20=E7=AE=80?=
=?UTF-8?q?=E5=8C=96=20`src/console/ui/repl.py`=20=E4=B8=AD=20`title=5Frig?=
=?UTF-8?q?ht.update`=20=E5=92=8C=20`tui=5Fconsole.print`=20=E7=9A=84?=
=?UTF-8?q?=E5=A4=9A=E8=A1=8C=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=8B=BC=E6=8E=A5?=
=?UTF-8?q?=EF=BC=8C=E6=94=B9=E4=B8=BA=E5=8D=95=E8=A1=8C=E5=BD=A2=E5=BC=8F?=
=?UTF-8?q?=E3=80=82=20-=20=E4=BF=AE=E5=A4=8D=E9=97=AE=E9=A2=98:=20?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=AD=E6=96=87=E6=A0=87=E7=82=B9=E7=AC=A6?=
=?UTF-8?q?=E5=8F=B7=E4=BD=BF=E7=94=A8=20=20=20*=20=E5=9C=A8=20`src/core/a?=
=?UTF-8?q?gent=5Fmanager.py`=20=E7=9A=84=E9=BB=98=E8=AE=A4=20Agent=20?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=86=85=E5=AE=B9=E4=B8=AD=EF=BC=8C=E5=B0=86?=
=?UTF-8?q?=E5=85=A8=E8=A7=92=E9=80=97=E5=8F=B7=20`,`=20=E6=9B=BF=E6=8D=A2?=
=?UTF-8?q?=E4=B8=BA=E8=8B=B1=E6=96=87=E5=8D=8A=E8=A7=92=E7=82=B9=E5=8F=B7?=
=?UTF-8?q?=20`.`=EF=BC=8C=E7=A1=AE=E4=BF=9D=E8=BE=93=E5=87=BA=E6=A0=BC?=
=?UTF-8?q?=E5=BC=8F=E4=B8=80=E8=87=B4=E6=80=A7=E3=80=82=20-=20=E9=87=8D?=
=?UTF-8?q?=E6=9E=84=E4=BC=98=E5=8C=96:=20=E7=B2=BE=E7=AE=80=E5=B7=A5?=
=?UTF-8?q?=E5=85=B7=E6=9D=83=E9=99=90=E5=88=A4=E6=96=AD=E9=80=BB=E8=BE=91?=
=?UTF-8?q?=20=20=20*=20=E9=87=8D=E6=9E=84=20`src/models/agent.py`=20?=
=?UTF-8?q?=E4=B8=AD=20`ToolPermissions.can=5Fuse=5Ftool`=20=E6=96=B9?=
=?UTF-8?q?=E6=B3=95=EF=BC=8C=E5=B0=86=E5=8E=9F=E6=9C=89=E7=9A=84=E5=A4=9A?=
=?UTF-8?q?=E5=B1=82=20`if-else`=20=E9=80=BB=E8=BE=91=E7=AE=80=E5=8C=96?=
=?UTF-8?q?=E4=B8=BA=20`return=20not=20self.whitelist=20or=20tool=5Fname?=
=?UTF-8?q?=20in=20self.whitelist`=EF=BC=8C=E6=8F=90=E5=8D=87=E5=8F=AF?=
=?UTF-8?q?=E8=AF=BB=E6=80=A7=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/console/commands/workspaces/agent_cmd.py | 14 ++++-------
src/console/ui/repl.py | 14 ++++-------
src/core/agent_manager.py | 25 ++++++++++----------
src/models/agent.py | 4 +---
tests/core/test_agent_manager.py | 21 +++++++++-------
5 files changed, 36 insertions(+), 42 deletions(-)
diff --git a/src/console/commands/workspaces/agent_cmd.py b/src/console/commands/workspaces/agent_cmd.py
index b620e97..2bdef9a 100644
--- a/src/console/commands/workspaces/agent_cmd.py
+++ b/src/console/commands/workspaces/agent_cmd.py
@@ -2,9 +2,9 @@
from __future__ import annotations
-from src.core.copy2clip import copy_to_clipboard
-
from src.core.agent_manager import AgentManager
+from src.core.copy2clip import copy_to_clipboard
+from src.models.agent import AgentConfig
from src.models.commands import Command, CommandContext, CommandResult
@@ -85,9 +85,7 @@ def _switch(self, mgr: AgentManager, name: str, context: CommandContext) -> Comm
return CommandResult(success=True)
if len(matches) > 1:
- context.console.print(
- f"[red]Ambiguous prefix '{name}' matches: {', '.join(matches)}[/red]"
- )
+ context.console.print(f"[red]Ambiguous prefix '{name}' matches: {', '.join(matches)}[/red]")
else:
context.console.print(f"[red]Agent '{name}' not found.[/red]")
context.console.print("Use [bold]/agent list[/bold] to see available agents.")
@@ -101,9 +99,7 @@ def _copy_agent(self, mgr: AgentManager, context: CommandContext, name: str | No
if len(matches) == 1:
agent = mgr.get(matches[0])
elif len(matches) > 1:
- context.console.print(
- f"[red]Ambiguous prefix '{name}' matches: {', '.join(matches)}[/red]"
- )
+ context.console.print(f"[red]Ambiguous prefix '{name}' matches: {', '.join(matches)}[/red]")
return CommandResult(success=False)
else:
context.console.print(f"[red]Agent '{name}' not found.[/red]")
@@ -120,7 +116,7 @@ def _copy_agent(self, mgr: AgentManager, context: CommandContext, name: str | No
return CommandResult(success=True)
@staticmethod
- def _format_agent_copy(agent: "AgentConfig") -> str:
+ def _format_agent_copy(agent: AgentConfig) -> str:
"""Format agent body (role + workflow) for external pasting."""
parts = [f"--- Agent: {agent.name} ---", ""]
if agent.body_role:
diff --git a/src/console/ui/repl.py b/src/console/ui/repl.py
index a58b4ab..326fc45 100644
--- a/src/console/ui/repl.py
+++ b/src/console/ui/repl.py
@@ -11,11 +11,11 @@
from src.console.handlers.command_handler import CommandHandler
from src.console.handlers.tool_handler import ToolHandler
from src.console.ui.tui_console import TuiConsole
+from src.core.agent_manager import AgentManager
from src.core.input_parser import parse_input
from src.core.paste_cache import PasteReference
from src.core.paste_window import show_paste_window
from src.utils.generate_help_text import generate_help_text
-from src.core.agent_manager import AgentManager
from src.utils.string_snapshot import truncate_for_display
if TYPE_CHECKING:
@@ -212,9 +212,7 @@ def on_mount(self) -> None:
# 更新标题栏右侧显示实际工作区路径和当前 Agent
title_right = self.query_one("#title-right", Label)
mgr = AgentManager()
- title_right.update(
- f"Agent: {mgr.current_agent_name} | 工作区: {self.workspace.root_path}"
- )
+ title_right.update(f"Agent: {mgr.current_agent_name} | 工作区: {self.workspace.root_path}")
# 创建审核提交模块并注入审核标签页
from src.core.audit_committer import AuditCommitter
@@ -255,12 +253,8 @@ def on_select_changed(self, event: Select.Changed) -> None:
mgr = AgentManager()
if mgr.switch_agent(str(event.value)):
title_right = self.query_one("#title-right", Label)
- title_right.update(
- f"Agent: {mgr.current_agent_name} | 工作区: {self.workspace.root_path}"
- )
- self.tui_console.print(
- f"[dim]Switched to agent: {mgr.current_agent_name}[/dim]"
- )
+ title_right.update(f"Agent: {mgr.current_agent_name} | 工作区: {self.workspace.root_path}")
+ self.tui_console.print(f"[dim]Switched to agent: {mgr.current_agent_name}[/dim]")
# -- 输入处理 -----------------------------------------------------------
diff --git a/src/core/agent_manager.py b/src/core/agent_manager.py
index 9174a22..8e286e6 100644
--- a/src/core/agent_manager.py
+++ b/src/core/agent_manager.py
@@ -9,11 +9,11 @@
from src.constants.manual_aid import AGENTS_DIR, MANUALAID_DIR
from src.models.agent import AgentConfig, ToolPermissions
-
# ---------------------------------------------------------------------------
# Frontmatter parser (YAML subset: key:value, nested keys, dash lists)
# ---------------------------------------------------------------------------
+
def _parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter delimited by --- markers.
@@ -38,7 +38,7 @@ def _parse_frontmatter(content: str) -> tuple[dict, str]:
return {}, content
frontmatter_lines = lines[1:end_idx]
- body = "\n".join(lines[end_idx + 1:]).strip()
+ body = "\n".join(lines[end_idx + 1 :]).strip()
metadata: dict = {}
current_section: str | None = None
@@ -145,6 +145,7 @@ def _parse_agent_file(file_path: Path) -> AgentConfig | None:
# Agent Manager (singleton)
# ---------------------------------------------------------------------------
+
class AgentManager:
"""Singleton — loads and caches agent configurations from .ManualAid/agents/.
@@ -260,23 +261,23 @@ def write_default(self, root_path: str | Path) -> None:
## Role
-你是一个与 ManualAid 工作区集成的、依赖工具进行文件探索和编辑的助手。
-你的能力来源于工作区提供的工具——如果没有调用正确的工具,你无法独立行动。
+你是一个与 ManualAid 工作区集成的、依赖工具进行文件探索和编辑的助手.
+你的能力来源于工作区提供的工具——如果没有调用正确的工具,你无法独立行动.
- 你是一个依赖工具的助手。你不能独立行动;必须调用工具来完成任务
- 严格使用指定的 XML 格式来调用工具(见 <tool_rules>)
+ 你是一个依赖工具的助手.你不能独立行动;必须调用工具来完成任务
+ 严格使用指定的 XML 格式来调用工具(见 <tool_rules>)
调用工具后,始终停止并等待用户的工具输出
- 绝不虚构工具返回值。绝不臆测结果继续
+ 绝不虚构工具返回值.绝不臆测结果继续
如果工具调用失败或返回空,向用户请求澄清
## Workflow
-1. 在采取行动前充分理解用户的请求。如有疑问,先提问再行动。
-2. 将复杂或多步骤任务分解为较小的顺序子任务;一次一个步骤。
-3. 为每个步骤选择最合适的工具。如果没有合适的工具,解释并请求替代方案。
-4. 等待每个工具的结果后再继续下一步。
-5. 构建响应时,先使用工具收集信息,然后形成最终答案。
+1. 在采取行动前充分理解用户的请求.如有疑问,先提问再行动.
+2. 将复杂或多步骤任务分解为较小的顺序子任务;一次一个步骤.
+3. 为每个步骤选择最合适的工具.如果没有合适的工具,解释并请求替代方案.
+4. 等待每个工具的结果后再继续下一步.
+5. 构建响应时,先使用工具收集信息,然后形成最终答案.
"""
default_path.write_text(content, encoding="utf-8")
diff --git a/src/models/agent.py b/src/models/agent.py
index 8d7874c..9f064ca 100644
--- a/src/models/agent.py
+++ b/src/models/agent.py
@@ -16,9 +16,7 @@ def is_tool_allowed(self, tool_name: str) -> bool:
"""
if tool_name in self.blacklist:
return False
- if self.whitelist and tool_name not in self.whitelist:
- return False
- return True
+ return not self.whitelist or tool_name in self.whitelist
@dataclass
diff --git a/tests/core/test_agent_manager.py b/tests/core/test_agent_manager.py
index c0557e1..bb4d794 100644
--- a/tests/core/test_agent_manager.py
+++ b/tests/core/test_agent_manager.py
@@ -2,8 +2,7 @@
from pathlib import Path
-from src.core.agent_manager import AgentManager, _parse_frontmatter, _parse_agent_file
-from src.models.agent import ToolPermissions
+from src.core.agent_manager import AgentManager, _parse_agent_file, _parse_frontmatter
class TestParseFrontmatter:
@@ -40,7 +39,7 @@ def test_permissions_frontmatter(self):
## Role
test
"""
- meta, body = _parse_frontmatter(content)
+ meta, _body = _parse_frontmatter(content)
assert meta["tool_permissions.whitelist"] == ["read", "glob"]
assert meta["tool_permissions.blacklist"] == ["git"]
@@ -55,7 +54,7 @@ def test_empty_permissions(self):
## Role
test
"""
- meta, body = _parse_frontmatter(content)
+ meta, _body = _parse_frontmatter(content)
whitelist = meta.get("tool_permissions.whitelist", [])
blacklist = meta.get("tool_permissions.blacklist", [])
assert whitelist == [], f"Expected [], got {whitelist!r}"
@@ -65,7 +64,8 @@ def test_empty_permissions(self):
class TestParseAgentFile:
def test_full_agent_file(self, tmp_path: Path):
md_file = tmp_path / "test-agent.md"
- md_file.write_text("""---
+ md_file.write_text(
+ """---
name: test-agent
description: My test agent
tool_permissions:
@@ -84,7 +84,9 @@ def test_full_agent_file(self, tmp_path: Path):
1. Test things.
2. Verify results.
-""", encoding="utf-8")
+""",
+ encoding="utf-8",
+ )
agent = _parse_agent_file(md_file)
assert agent is not None
assert agent.name == "test-agent"
@@ -102,12 +104,15 @@ def test_invalid_file_returns_none(self, tmp_path: Path):
def test_no_role_section(self, tmp_path: Path):
md_file = tmp_path / "no-role.md"
- md_file.write_text("""---
+ md_file.write_text(
+ """---
name: no-role
description: No role
---
Some content without sections.
-""", encoding="utf-8")
+""",
+ encoding="utf-8",
+ )
agent = _parse_agent_file(md_file)
assert agent is not None
assert agent.body_role == ""
From 2c8c07a688dc9d6a75367c7d535923ead277be20 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Wed, 6 May 2026 11:37:43 +0800
Subject: [PATCH 10/11] =?UTF-8?q?feat(workspaces):=20=E9=87=8D=E6=9E=84?=
=?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=8F=90=E7=A4=BA=E8=AF=8D=E7=BB=84=E8=A3=85?=
=?UTF-8?q?=E9=80=BB=E8=BE=91=E4=B8=8E=E9=BB=98=E8=AE=A4=20Agent=20?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=20-=20=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD:?=
=?UTF-8?q?=20=E4=BC=98=E5=8C=96=E7=B3=BB=E7=BB=9F=E6=8F=90=E7=A4=BA?=
=?UTF-8?q?=E8=AF=8D=E7=94=9F=E6=88=90=E6=B5=81=E7=A8=8B=EF=BC=8C=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=E5=9F=BA=E4=BA=8E=20Agent=20=E9=85=8D=E7=BD=AE?=
=?UTF-8?q?=E7=9A=84=E5=8A=A8=E6=80=81=E8=A6=86=E7=9B=96=20=20=20*=20?=
=?UTF-8?q?=E4=BF=AE=E6=94=B9=20`=5Fgenerate=5Ftool=5Fdefinitions=5Fsectio?=
=?UTF-8?q?n`=20=E7=AD=BE=E5=90=8D=EF=BC=8C=E6=98=BE=E5=BC=8F=E6=8E=A5?=
=?UTF-8?q?=E6=94=B6=20`AgentConfig`=20=E5=8F=82=E6=95=B0=E6=9B=BF?=
=?UTF-8?q?=E4=BB=A3=E5=86=85=E9=83=A8=E8=B0=83=E7=94=A8=20`AgentManager()?=
=?UTF-8?q?.get=5Fcurrent()`=20=20=20*=20=E4=BF=AE=E6=94=B9=20`=5Fgenerate?=
=?UTF-8?q?=5Fagent=5Fdirective=5Fsection`=20=E7=AD=BE=E5=90=8D=EF=BC=8C?=
=?UTF-8?q?=E7=A7=BB=E9=99=A4=20`CommandContext`=20=E4=BE=9D=E8=B5=96?=
=?UTF-8?q?=EF=BC=8C=E7=9B=B4=E6=8E=A5=E5=A4=84=E7=90=86=20`agent`=20?=
=?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=B9=B6=E5=A2=9E=E5=8A=A0=20`precedence=3D"?=
=?UTF-8?q?OVERRIDES=5FBASE"`=20=E5=B1=9E=E6=80=A7=20=20=20*=20=E9=87=8D?=
=?UTF-8?q?=E6=9E=84=20`=5Fassemble=5Ffull=5Fprompt`=20=E5=87=BD=E6=95=B0?=
=?UTF-8?q?=EF=BC=8C=E6=98=8E=E7=A1=AE=E5=AE=9A=E4=B9=89=E6=8F=90=E7=A4=BA?=
=?UTF-8?q?=E8=AF=8D=E7=BB=84=E8=A3=85=E9=A1=BA=E5=BA=8F=EF=BC=9ARole=20?=
=?UTF-8?q?=E2=86=92=20Constraints=20=E2=86=92=20Agent=20Directive=20?=
=?UTF-8?q?=E2=86=92=20Tool=20Rules=20=E2=86=92=20Tool=20Definitions=20?=
=?UTF-8?q?=E2=86=92=20Workflow=20=E2=86=92=20Workspace=20Context=20?=
=?UTF-8?q?=E2=86=92=20Augmentation=20=E2=86=92=20Extensions=20=20=20*=20?=
=?UTF-8?q?=E5=BC=95=E5=85=A5=E6=9D=A1=E4=BB=B6=E9=80=BB=E8=BE=91=EF=BC=8C?=
=?UTF-8?q?=E5=BD=93=20Agent=20=E8=87=AA=E8=BA=AB=E5=AE=9A=E4=B9=89?=
=?UTF-8?q?=E4=BA=86=20`body=5Frole`=20=E6=88=96=20`body=5Fworkflow`=20?=
=?UTF-8?q?=E6=97=B6=EF=BC=8C=E8=B7=B3=E8=BF=87=E5=85=A8=E5=B1=80=E7=9A=84?=
=?UTF-8?q?=20`SYSTEM=5FROLE`=20=E5=92=8C=20`WORKFLOW=5FGUIDELINES`=20?=
=?UTF-8?q?=E6=B3=A8=E5=85=A5=20-=20=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96:?=
=?UTF-8?q?=20=E5=88=86=E7=A6=BB=E5=B8=B8=E9=87=8F=E5=AE=9A=E4=B9=89?=
=?UTF-8?q?=E4=B8=8E=E6=A8=A1=E6=9D=BF=E5=86=85=E5=AE=B9=EF=BC=8C=E6=8F=90?=
=?UTF-8?q?=E5=8D=87=E4=BB=A3=E7=A0=81=E5=8F=AF=E7=BB=B4=E6=8A=A4=E6=80=A7?=
=?UTF-8?q?=20=20=20*=20=E5=B0=86=20`src/constants/prompts.py`=20=E4=B8=AD?=
=?UTF-8?q?=E7=9A=84=20`SYSTEM=5FIDENTITY`=20=E6=8B=86=E5=88=86=E4=B8=BA?=
=?UTF-8?q?=20`SYSTEM=5FROLE`=20(=E4=BB=85=E5=8C=85=E5=90=AB=E8=BA=AB?=
=?UTF-8?q?=E4=BB=BD=E6=8F=8F=E8=BF=B0)=20=E5=92=8C=20`SYSTEM=5FCONSTRAINT?=
=?UTF-8?q?S`=20(=E7=8B=AC=E7=AB=8B=E7=BA=A6=E6=9D=9F=E5=9D=97)=20=20=20*?=
=?UTF-8?q?=20=E6=9B=B4=E6=96=B0=20`TOOL=5FRULES`=20=E4=B8=AD=E7=9A=84=20`?=
=?UTF-8?q?`=20=E6=A0=87=E7=AD=BE=EF=BC=8C=E5=A2=9E?=
=?UTF-8?q?=E5=8A=A0=20`precedence=3D"ABSOLUTE"`=20=E5=B1=9E=E6=80=A7?=
=?UTF-8?q?=E4=BB=A5=E5=BC=BA=E5=8C=96=E4=BC=98=E5=85=88=E7=BA=A7=20=20=20?=
=?UTF-8?q?*=20=E8=B0=83=E6=95=B4=20`WORKFLOW=5FGUIDELINES`=20=E6=AD=A5?=
=?UTF-8?q?=E9=AA=A4=EF=BC=8C=E5=9C=A8=E4=BB=BB=E5=8A=A1=E5=88=86=E8=A7=A3?=
=?UTF-8?q?=E5=89=8D=E5=A2=9E=E5=8A=A0=E2=80=9C=E5=85=88=E9=80=9A=E8=BF=87?=
=?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=92=8C=E8=AF=BB=E5=8F=96=E4=BA=86=E8=A7=A3?=
=?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=BB=93=E6=9E=84=E2=80=9D=E7=9A=84=E6=AD=A5?=
=?UTF-8?q?=E9=AA=A4=20=20=20*=20=E6=9B=B4=E6=96=B0=20`src/core/agent=5Fma?=
=?UTF-8?q?nager.py`=20=E4=B8=AD=20`write=5Fdefault`=20=E6=96=B9=E6=B3=95?=
=?UTF-8?q?=EF=BC=8C=E4=BD=BF=E7=94=A8=20f-string=20=E5=8A=A8=E6=80=81?=
=?UTF-8?q?=E6=B3=A8=E5=85=A5=20`SYSTEM=5FROLE`=20=E5=92=8C=20`WORKFLOW=5F?=
=?UTF-8?q?GUIDELINES`=20=E5=B8=B8=E9=87=8F=E8=87=B3=E9=BB=98=E8=AE=A4=20`?=
=?UTF-8?q?default.md`=20=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../commands/workspaces/workspace_cmd.py | 55 +++++++++++++------
src/constants/prompts.py | 28 +++++-----
src/core/agent_manager.py | 24 ++------
3 files changed, 57 insertions(+), 50 deletions(-)
diff --git a/src/console/commands/workspaces/workspace_cmd.py b/src/console/commands/workspaces/workspace_cmd.py
index a06bdd7..a5ae3e3 100644
--- a/src/console/commands/workspaces/workspace_cmd.py
+++ b/src/console/commands/workspaces/workspace_cmd.py
@@ -4,12 +4,14 @@
from src.constants.prompts import (
AUGMENTATION_WRAPPER,
- SYSTEM_IDENTITY,
+ SYSTEM_CONSTRAINTS,
+ SYSTEM_ROLE,
TOOL_RULES,
WORKFLOW_GUIDELINES,
generate_extensions_section,
)
from src.core.agent_manager import AgentManager
+from src.models.agent import AgentConfig
from src.models.commands import Command, CommandContext, CommandResult
INSTRUCTION: list[str] = ["AGENTS.md", "CLAUDE.md"]
@@ -17,11 +19,9 @@
AGENTS_MD_FENCE_END = ""
-def _generate_tool_definitions_section(context: CommandContext) -> str:
+def _generate_tool_definitions_section(context: CommandContext, agent: AgentConfig) -> str:
"""Generate XML block with doc for each registered tool,
filtered by the current agent's tool permissions."""
- mgr = AgentManager()
- agent = mgr.get_current()
tools = context.tool_registry.list_tools()
if not tools.get("sync"):
@@ -90,14 +90,12 @@ def _load_agents_md(context: CommandContext) -> str:
return ""
-def _generate_agent_directive_section(context: CommandContext) -> str:
+def _generate_agent_directive_section(agent: AgentConfig) -> str:
"""Generate XML block from the current agent."""
- mgr = AgentManager()
- agent = mgr.get_current()
if not agent.body_role and not agent.body_workflow:
return ""
- parts = [f' ', ""]
+ parts = [f' ', ""]
if agent.body_role:
parts.append(agent.body_role)
parts.append("")
@@ -109,34 +107,57 @@ def _generate_agent_directive_section(context: CommandContext) -> str:
def _assemble_full_prompt(context: CommandContext) -> str:
- """Assemble the complete system prompt from ordered XML sections."""
+ """Assemble the complete system prompt from ordered XML sections.
+
+ Order: role → constraints → agent_directive → tool_rules → tool_definitions
+ → workflow → workspace_context → augmentation → extensions
+ """
+ agent = AgentManager().get_current()
+
sections = [
"",
"",
- SYSTEM_IDENTITY,
- "",
- TOOL_RULES,
- "",
]
- # Agent directive (if any)
- agent_directive = _generate_agent_directive_section(context)
+ # ① System role — skip if agent provides its own role
+ if not agent.body_role:
+ sections.append(SYSTEM_ROLE)
+ sections.append("")
+
+ # ② System constraints — always injected (anti-hallucination handled by tool_rules)
+ sections.append(SYSTEM_CONSTRAINTS)
+ sections.append("")
+
+ # ③ Agent directive (role + workflow from agent .md file, if any)
+ agent_directive = _generate_agent_directive_section(agent)
if agent_directive:
sections.append(agent_directive)
sections.append("")
- sections.append(_generate_tool_definitions_section(context))
+ # ④ Tool call format rules
+ sections.append(TOOL_RULES)
sections.append("")
- sections.append(WORKFLOW_GUIDELINES)
+
+ # ⑤ Tool definitions
+ sections.append(_generate_tool_definitions_section(context, agent))
sections.append("")
+
+ # ⑥ Workflow guidelines — skip if agent provides its own workflow
+ if not agent.body_workflow:
+ sections.append(WORKFLOW_GUIDELINES)
+ sections.append("")
+
+ # ⑦ Workspace metadata
sections.append(_generate_workspace_metadata(context))
sections.append("")
+ # ⑧ Augmentations from AGENTS.md / CLAUDE.md
augmentations = _load_agents_md(context)
if augmentations:
sections.append(augmentations)
sections.append("")
+ # ⑨ Extensions (Skills / MCP hooks)
sections.append(generate_extensions_section())
sections.append("")
sections.append("")
diff --git a/src/constants/prompts.py b/src/constants/prompts.py
index a4ef5db..4a8c4f0 100644
--- a/src/constants/prompts.py
+++ b/src/constants/prompts.py
@@ -2,18 +2,15 @@
from collections.abc import Callable
-SYSTEM_IDENTITY: str = """
-你是一个与 ManualAid 工作区集成的、依赖工具进行文件探索和编辑的助手
+SYSTEM_ROLE: str = """
+你是一个与 ManualAid 工作区集成的、依赖工具完成任务的助手
你的能力来源于工作区提供的工具——如果没有调用正确的工具,你无法独立行动
+"""
-
- 你是一个依赖工具的助手. 你不能独立行动;必须调用工具来完成任务
+SYSTEM_CONSTRAINTS: str = """
+ 你是一个依赖工具的助手.你不能独立行动;必须调用工具来完成任务
严格使用指定的 XML 格式来调用工具(见 <tool_rules>)
- 调用工具后,始终停止并等待用户的工具输出
- 绝不虚构工具返回值. 绝不臆测结果继续
- 如果工具调用失败或返回空,向用户请求澄清
-
-"""
+"""
TOOL_RULES: str = """
@@ -42,9 +39,9 @@
-
+
绝不虚构工具返回的数据
- 在调用工具后停止——不要代表工具生成结果
+ 在调用工具后停止并等待用户的工具输出——不要代表工具生成结果,也不要臆测结果继续
如果工具返回错误或空结果,向用户请求澄清
当参数值包含XML标签字符(`<`或`>`)时, 必须将其转换为HTML实体转义符(`<`和`>`).
例如: 如果参数值是, 必须写为<func_call>
@@ -53,10 +50,11 @@
WORKFLOW_GUIDELINES: str = """
1. 在采取行动前充分理解用户的请求. 如有疑问,先提问再行动
-2. 将复杂或多步骤任务分解为较小的顺序子任务;一次一个步骤
-3. 为每个步骤选择最合适的工具. 如果没有合适的工具,解释并请求替代方案
-4. 等待每个工具的结果后再继续下一步
-5. 构建响应时,先使用工具收集信息,然后形成最终答案
+2. 先通过搜索和读取了解项目结构与上下文,再制定具体方案
+3. 将复杂或多步骤任务分解为较小的顺序子任务;一次一个步骤
+4. 为每个步骤选择最合适的工具. 如果没有合适的工具,解释并请求替代方案
+5. 等待每个工具的结果后再继续下一步
+6. 构建响应时,先使用工具收集信息,然后形成最终答案
"""
AUGMENTATION_WRAPPER: str = """
diff --git a/src/core/agent_manager.py b/src/core/agent_manager.py
index 8e286e6..b55d4c5 100644
--- a/src/core/agent_manager.py
+++ b/src/core/agent_manager.py
@@ -7,6 +7,7 @@
from pathlib import Path
from src.constants.manual_aid import AGENTS_DIR, MANUALAID_DIR
+from src.constants.prompts import SYSTEM_ROLE, WORKFLOW_GUIDELINES
from src.models.agent import AgentConfig, ToolPermissions
# ---------------------------------------------------------------------------
@@ -119,7 +120,7 @@ def _parse_agent_file(file_path: Path) -> AgentConfig | None:
body_workflow = joined
current_heading = heading_text if heading_text in ("role", "workflow") else None
- section_lines = [line]
+ section_lines = []
elif current_heading:
section_lines.append(line)
else:
@@ -242,7 +243,7 @@ def agent_names(self) -> list[str]:
def write_default(self, root_path: str | Path) -> None:
"""Write the default.md agent file if it does not exist.
- Content mirrors the prompts.py SYSTEM_IDENTITY and WORKFLOW_GUIDELINES
+ Content mirrors the prompts.py SYSTEM_ROLE and WORKFLOW_GUIDELINES
so that users can edit language/behavior by modifying this file.
"""
agents_dir = Path(root_path) / MANUALAID_DIR / AGENTS_DIR
@@ -251,7 +252,7 @@ def write_default(self, root_path: str | Path) -> None:
if default_path.exists():
return
- content = r"""---
+ content = f"""---
name: default
description: Default ManualAid agent
tool_permissions:
@@ -261,23 +262,10 @@ def write_default(self, root_path: str | Path) -> None:
## Role
-你是一个与 ManualAid 工作区集成的、依赖工具进行文件探索和编辑的助手.
-你的能力来源于工作区提供的工具——如果没有调用正确的工具,你无法独立行动.
-
-
- 你是一个依赖工具的助手.你不能独立行动;必须调用工具来完成任务
- 严格使用指定的 XML 格式来调用工具(见 <tool_rules>)
- 调用工具后,始终停止并等待用户的工具输出
- 绝不虚构工具返回值.绝不臆测结果继续
- 如果工具调用失败或返回空,向用户请求澄清
-
+{SYSTEM_ROLE}
## Workflow
-1. 在采取行动前充分理解用户的请求.如有疑问,先提问再行动.
-2. 将复杂或多步骤任务分解为较小的顺序子任务;一次一个步骤.
-3. 为每个步骤选择最合适的工具.如果没有合适的工具,解释并请求替代方案.
-4. 等待每个工具的结果后再继续下一步.
-5. 构建响应时,先使用工具收集信息,然后形成最终答案.
+{WORKFLOW_GUIDELINES}
"""
default_path.write_text(content, encoding="utf-8")
From 6a1e2f1681f3485ebb82711e9c8cf3800ced98c7 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Wed, 6 May 2026 12:32:45 +0800
Subject: [PATCH 11/11] =?UTF-8?q?feat(console):=20=E6=96=B0=E5=A2=9E=20Age?=
=?UTF-8?q?nt=20=E9=87=8D=E7=BD=AE=E5=8A=9F=E8=83=BD=E5=B9=B6=E9=87=8D?=
=?UTF-8?q?=E6=9E=84=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE=E5=86=99=E5=85=A5?=
=?UTF-8?q?=E9=80=BB=E8=BE=91=20-=20=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD:?=
=?UTF-8?q?=20=E6=B7=BB=E5=8A=A0=20`/agent=20reset`=20=E5=AD=90=E5=91=BD?=
=?UTF-8?q?=E4=BB=A4=E7=94=A8=E4=BA=8E=E9=87=8D=E5=86=99=20default.md=20?=
=?UTF-8?q?=20=20*=20=E5=9C=A8=20`AgentCommand`=20=E4=B8=AD=E9=9B=86?=
=?UTF-8?q?=E6=88=90=20`=5Freset=5Fdefault`=20=E5=87=BD=E6=95=B0=EF=BC=8C?=
=?UTF-8?q?=E8=B0=83=E7=94=A8=20`AgentManager.reset=5Fdefault()`=20=20=20*?=
=?UTF-8?q?=20=E6=96=B0=E5=A2=9E=20`ArgumentParser`=20=E8=A7=A3=E6=9E=90?=
=?UTF-8?q?=E5=99=A8=E4=BB=A5=E6=94=AF=E6=8C=81=E5=AD=90=E5=91=BD=E4=BB=A4?=
=?UTF-8?q?=E5=8F=82=E6=95=B0=E5=8F=8A=E5=B8=AE=E5=8A=A9=E4=BF=A1=E6=81=AF?=
=?UTF-8?q?=E5=B1=95=E7=A4=BA=20(`-h`,=20`--help`)=20=20=20*=20=E5=B0=86?=
=?UTF-8?q?=E5=8E=9F=E6=9C=89=E7=9A=84=E7=A7=81=E6=9C=89=E6=96=B9=E6=B3=95?=
=?UTF-8?q?=20`=5Fshow=5Fcurrent`=20=E5=92=8C=20`=5Flist=5Fall`=20?=
=?UTF-8?q?=E6=8F=90=E5=8D=87=E4=B8=BA=E6=A8=A1=E5=9D=97=E7=BA=A7=E5=87=BD?=
=?UTF-8?q?=E6=95=B0=E4=BE=9B=E5=A4=96=E9=83=A8=E8=B0=83=E7=94=A8=20-=20?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=97=AE=E9=A2=98:=20=E8=A7=A3=E5=86=B3?=
=?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E8=B7=AF=E5=BE=84=E6=9C=AA=E4=BF=9D?=
=?UTF-8?q?=E5=AD=98=E5=AF=BC=E8=87=B4=E9=87=8D=E7=BD=AE=E5=A4=B1=E8=B4=A5?=
=?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=20=20=20*=20=E5=9C=A8=20`AgentManag?=
=?UTF-8?q?er.initialize`=20=E4=B8=AD=E6=96=B0=E5=A2=9E=20`=5Froot=5Fpath`?=
=?UTF-8?q?=20=E5=B1=9E=E6=80=A7=E5=AD=98=E5=82=A8=E5=B7=A5=E4=BD=9C?=
=?UTF-8?q?=E5=8C=BA=E6=A0=B9=E8=B7=AF=E5=BE=84=20=20=20*=20=E4=BF=AE?=
=?UTF-8?q?=E6=94=B9=20`write=5Fdefault`=20=E6=96=B9=E6=B3=95=E7=AD=BE?=
=?UTF-8?q?=E5=90=8D=EF=BC=8C=E5=A2=9E=E5=8A=A0=20`force`=20=E5=B8=83?=
=?UTF-8?q?=E5=B0=94=E5=8F=82=E6=95=B0=E6=8E=A7=E5=88=B6=E6=98=AF=E5=90=A6?=
=?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=B0=E6=9C=89=E6=96=87=E4=BB=B6=20=20=20?=
=?UTF-8?q?*=20=E5=AE=9E=E7=8E=B0=20`reset=5Fdefault`=20=E6=96=B9=E6=B3=95?=
=?UTF-8?q?=EF=BC=8C=E5=BC=BA=E5=88=B6=E9=87=8D=E5=86=99=20`default.md`=20?=
=?UTF-8?q?=E5=B9=B6=E6=B8=85=E7=A9=BA=E5=86=85=E9=83=A8=E7=BC=93=E5=AD=98?=
=?UTF-8?q?=20(`=5Floaded`,=20`=5Fagents`)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/console/commands/workspaces/agent_cmd.py | 98 +++++++++++++-------
src/core/agent_manager.py | 25 ++++-
2 files changed, 88 insertions(+), 35 deletions(-)
diff --git a/src/console/commands/workspaces/agent_cmd.py b/src/console/commands/workspaces/agent_cmd.py
index 2bdef9a..6e695b3 100644
--- a/src/console/commands/workspaces/agent_cmd.py
+++ b/src/console/commands/workspaces/agent_cmd.py
@@ -2,12 +2,47 @@
from __future__ import annotations
+from argparse import ArgumentParser
+
from src.core.agent_manager import AgentManager
from src.core.copy2clip import copy_to_clipboard
from src.models.agent import AgentConfig
from src.models.commands import Command, CommandContext, CommandResult
+def _reset_default(mgr: AgentManager, context: CommandContext) -> CommandResult:
+ if mgr.reset_default():
+ context.console.print("[green]default.md 已根据内置Default Agent重写完成[/green]")
+ else:
+ context.console.print("[red]重置失败: 工作区根路径未初始化[/red]")
+ return CommandResult(success=True)
+
+
+def _show_current(mgr: AgentManager, context: CommandContext) -> CommandResult:
+ agent = mgr.get_current()
+ context.console.print(
+ f"[bold]Current Agent:[/bold] {agent.name}\n"
+ f"[dim]{agent.description}[/dim]\n"
+ f"Whitelist: {agent.tool_permissions.whitelist or '(all)'}\n"
+ f"Blacklist: {agent.tool_permissions.blacklist or '(none)'}"
+ )
+ return CommandResult(success=True)
+
+
+def _list_all(mgr: AgentManager, context: CommandContext) -> CommandResult:
+ agents = mgr.list_agents()
+ if not agents:
+ context.console.print("[yellow]No agents found in .ManualAid/agents/[/yellow]")
+ return CommandResult(success=True)
+
+ lines = ["[bold]Available Agents:[/bold]"]
+ for a in agents:
+ marker = ">" if a.name == mgr.current_agent_name else " "
+ lines.append(f" {marker} {a.name} — {a.description}")
+ context.console.print("\n".join(lines))
+ return CommandResult(success=True)
+
+
class AgentCommand(Command):
"""Manage Agent configuration"""
@@ -15,58 +50,57 @@ def __init__(self):
super().__init__()
self.name = "agent"
self.aliases = ["/agent"]
- self.description = "Manage agent configuration (list, switch, copy)"
+ self.description = "管理 Agent 配置 (列表、切换、复制、重置)"
self.usage = (
- "/agent — show current agent\n"
- "/agent list — list all agents\n"
- "/agent — switch to agent by name or unique prefix\n"
- "/agent default — switch to default agent\n"
- "/agent copy — copy current agent's role+workflow to clipboard\n"
- "/agent copy — copy specified agent's role+workflow to clipboard"
+ "/agent — 显示当前 Agent\n"
+ "/agent list — 列出所有 Agent\n"
+ "/agent — 按名称或唯一前缀切换 Agent\n"
+ "/agent default — 切换到默认 Agent\n"
+ "/agent copy — 复制当前 Agent 的角色+工作流到剪贴板\n"
+ "/agent copy — 复制指定 Agent 的角色+工作流到剪贴板\n"
+ "/agent reset — 根据 prompts.py 重写 default.md"
+ )
+ self.argparse = ArgumentParser("agent")
+ self.argparse.add_argument(
+ "subcommand",
+ nargs="?",
+ default=None,
+ help="子命令: list, default, copy, reset, 或 Agent 名称",
)
+ for usage in self.usage.split("\n"):
+ self.argparse.add_argument(
+ "Usage",
+ nargs="?",
+ default=None,
+ help=usage,
+ )
def execute(self, context: CommandContext) -> CommandResult:
+ # Show help on -h / --help
+ if "-h" in context.parsed_input.source or "--help" in context.parsed_input.source:
+ context.console.print(self.argparse.format_help())
+ return CommandResult(success=True)
+
mgr = AgentManager()
# Parse args from source: "/agent list" -> "list"
parts = context.parsed_input.source.split()
args = " ".join(parts[1:]) if len(parts) > 1 else ""
if not args:
- return self._show_current(mgr, context)
+ return _show_current(mgr, context)
if args == "list":
- return self._list_all(mgr, context)
+ return _list_all(mgr, context)
if args.startswith("copy"):
rest = args[4:].strip()
return self._copy_agent(mgr, context, rest or None)
if args == "default":
return self._switch(mgr, "default", context)
+ if args == "reset":
+ return _reset_default(mgr, context)
# Treat as agent name (supports unique prefix matching)
return self._switch(mgr, args, context)
- def _show_current(self, mgr: AgentManager, context: CommandContext) -> CommandResult:
- agent = mgr.get_current()
- context.console.print(
- f"[bold]Current Agent:[/bold] {agent.name}\n"
- f"[dim]{agent.description}[/dim]\n"
- f"Whitelist: {agent.tool_permissions.whitelist or '(all)'}\n"
- f"Blacklist: {agent.tool_permissions.blacklist or '(none)'}"
- )
- return CommandResult(success=True)
-
- def _list_all(self, mgr: AgentManager, context: CommandContext) -> CommandResult:
- agents = mgr.list_agents()
- if not agents:
- context.console.print("[yellow]No agents found in .ManualAid/agents/[/yellow]")
- return CommandResult(success=True)
-
- lines = ["[bold]Available Agents:[/bold]"]
- for a in agents:
- marker = ">" if a.name == mgr.current_agent_name else " "
- lines.append(f" {marker} {a.name} — {a.description}")
- context.console.print("\n".join(lines))
- return CommandResult(success=True)
-
def _switch(self, mgr: AgentManager, name: str, context: CommandContext) -> CommandResult:
# Try exact match first
if mgr.switch_agent(name):
diff --git a/src/core/agent_manager.py b/src/core/agent_manager.py
index b55d4c5..4987fe2 100644
--- a/src/core/agent_manager.py
+++ b/src/core/agent_manager.py
@@ -173,6 +173,7 @@ def __init__(self) -> None:
return
self._agents: dict[str, AgentConfig] = {}
self._agents_dir: Path | None = None
+ self._root_path: Path | None = None
self._loaded = False
self._load_lock: threading.Lock = threading.Lock()
self._current_agent_name: str = "default"
@@ -188,7 +189,8 @@ def current_agent_name(self, value: str) -> None:
def initialize(self, root_path: str | Path) -> None:
"""Set the workspace root. Agents are loaded lazily on first access."""
- self._agents_dir = Path(root_path) / MANUALAID_DIR / AGENTS_DIR
+ self._root_path = Path(root_path)
+ self._agents_dir = self._root_path / MANUALAID_DIR / AGENTS_DIR
self._loaded = False
self._agents = {}
@@ -240,16 +242,20 @@ def agent_names(self) -> list[str]:
self._ensure_loaded()
return sorted(self._agents.keys())
- def write_default(self, root_path: str | Path) -> None:
+ def write_default(self, root_path: str | Path, *, force: bool = False) -> None:
"""Write the default.md agent file if it does not exist.
Content mirrors the prompts.py SYSTEM_ROLE and WORKFLOW_GUIDELINES
so that users can edit language/behavior by modifying this file.
+
+ Args:
+ root_path: Workspace root directory.
+ force: If True, overwrite existing default.md.
"""
agents_dir = Path(root_path) / MANUALAID_DIR / AGENTS_DIR
agents_dir.mkdir(parents=True, exist_ok=True)
default_path = agents_dir / "default.md"
- if default_path.exists():
+ if default_path.exists() and not force:
return
content = f"""---
@@ -269,3 +275,16 @@ def write_default(self, root_path: str | Path) -> None:
{WORKFLOW_GUIDELINES}
"""
default_path.write_text(content, encoding="utf-8")
+
+ def reset_default(self) -> bool:
+ """Rewrite default.md from current prompts.py constants.
+
+ Returns True on success, False if root_path was never set.
+ """
+ if self._root_path is None:
+ return False
+ self.write_default(self._root_path, force=True)
+ # Invalidate cache so next access re-reads the file
+ self._loaded = False
+ self._agents = {}
+ return True