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