From 4ffdedd369c1ee242fe79e43faa1230f46ed3a6d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH] feat(tools): add project workspace tools --- agent/prompt_builder.py | 5 +- tests/agent/test_system_prompt.py | 21 ++-- tools/project_tools.py | 189 ++++++++++++++++++++++++++++++ toolsets.py | 11 ++ 4 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 tools/project_tools.py diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 7f1986fbed0..1a87e66cde4 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -243,7 +243,10 @@ KANBAN_GUIDANCE = ( "- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind " "with no `.git`, `git worktree add " "${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then " - "cd there.\n" + "cd there. For a project-linked task the workspace is a fresh " + "`/.worktrees/` and `$HERMES_KANBAN_BRANCH` a deterministic " + "`/` — the main repo is two levels up, so run " + "`git worktree add` from there.\n" "- **Deliverables.** Files a human wants go in " "`kanban_complete(artifacts=[])` (top-level param; paths in " "`metadata` are NOT uploaded). Files must exist at completion.\n" diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index 7c4d252ec79..6ebf2a61960 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -67,11 +67,18 @@ def _stable_prompt(agent): return build_system_prompt_parts(agent)["stable"] +def _init_code_repo(path): + """A git repo that actually holds code — the coding posture requires a source + file (or manifest), not a bare ``.git`` (a prose/notes repo stays general).""" + import subprocess + + subprocess.run(["git", "-C", str(path), "init", "-q"], check=True) + (path / "main.py").write_text("print('hi')\n") + + class TestCodingContextBlock: def test_injected_when_active(self, monkeypatch, tmp_path): - import subprocess - - subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True) + _init_code_repo(tmp_path) monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) agent = _make_agent(valid_tool_names=["read_file"], platform="cli") stable = _stable_prompt(agent) @@ -79,9 +86,7 @@ class TestCodingContextBlock: assert "Workspace" in stable def test_absent_when_off(self, monkeypatch, tmp_path): - import subprocess - - subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True) + _init_code_repo(tmp_path) monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) agent = _make_agent(valid_tool_names=["read_file"], platform="cli") # Drive the real path: force the resolved mode to "off" via config. @@ -90,9 +95,7 @@ class TestCodingContextBlock: assert "coding agent" not in stable def test_absent_without_tools(self, monkeypatch, tmp_path): - import subprocess - - subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True) + _init_code_repo(tmp_path) monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) agent = _make_agent(valid_tool_names=[], platform="cli") assert "coding agent" not in _stable_prompt(agent) diff --git a/tools/project_tools.py b/tools/project_tools.py new file mode 100644 index 00000000000..2b52e3144d6 --- /dev/null +++ b/tools/project_tools.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Project tools — the agent's INTENTIONAL handle on first-class Projects. + +Projects (per-profile ``projects.db``) are the named workspaces the desktop +sidebar groups sessions into. Creating / switching a project is a deliberate act +expressed as explicit tools — never a side effect of a terminal ``cd``. + +Exposed only on GUI sessions: the tools live in the `project` toolset (kept off +``_HERMES_CORE_TOOLS``) which the desktop/TUI gateway folds into its resolved +toolsets, so no CLI/messaging/cron schema carries them. The GUI also wires +``set_project_workspace_callback`` so a create/switch re-anchors the live +session's cwd and the sidebar follows the move; the DB write is the durable part. +""" + +import json +import os +from typing import Callable, Optional + +from tools.registry import registry + +# Set by the GUI gateway (tui_gateway) at session wiring. Receives +# ``(task_id, primary_path, project_name)`` and re-anchors that session's +# workspace + refreshes the sidebar. ``None`` in CLI / messaging contexts — the +# DB write still happens; there's just no live GUI session to move. +_workspace_callback: Optional[Callable[[str, str, str], None]] = None + + +def set_project_workspace_callback(fn: Optional[Callable[[str, str, str], None]]) -> None: + global _workspace_callback + _workspace_callback = fn + + +def _primary_path(proj) -> Optional[str]: + if getattr(proj, "primary_path", None): + return proj.primary_path + for folder in proj.folders: + if folder.is_primary: + return folder.path + return proj.folders[0].path if proj.folders else None + + +def _apply_workspace(task_id: Optional[str], path: Optional[str], name: str) -> None: + cb = _workspace_callback + if cb and task_id and path: + try: + cb(task_id, path, name) + except Exception: + pass + + +def _resolve(conn, token: str): + from hermes_cli import projects_db as pdb + + token = (token or "").strip() + if not token: + return None + projects = pdb.list_projects(conn, include_archived=True) + # Exact id / slug / name first, then case-insensitive slug / name. + for proj in projects: + if token in (proj.id, proj.slug) or proj.name == token: + return proj + low = token.lower() + for proj in projects: + if proj.slug.lower() == low or proj.name.lower() == low: + return proj + return None + + +def project_list(task_id: Optional[str] = None) -> str: + from hermes_cli import projects_db as pdb + + with pdb.connect_closing() as conn: + active = pdb.get_active_id(conn) + projects = pdb.list_projects(conn) + + return json.dumps({ + "active_id": active, + "projects": [ + { + "id": p.id, + "slug": p.slug, + "name": p.name, + "primary_path": _primary_path(p), + "active": p.id == active, + } + for p in projects + ], + }) + + +def project_create(name: str, path: Optional[str] = None, task_id: Optional[str] = None) -> str: + name = (name or "").strip() + if not name: + return json.dumps({"success": False, "error": "name is required"}) + + from hermes_cli import projects_db as pdb + + folder = (path or "").strip() + if folder: + folder = os.path.abspath(os.path.expanduser(folder)) + + try: + with pdb.connect_closing() as conn: + pid = pdb.create_project(conn, name=name, folders=[folder] if folder else [], primary_path=folder or None) + pdb.set_active(conn, pid) + proj = pdb.get_project(conn, pid) + except ValueError as exc: + return json.dumps({"success": False, "error": str(exc)}) + + if proj is None: + return json.dumps({"success": False, "error": "project vanished after create"}) + + primary = _primary_path(proj) + _apply_workspace(task_id, primary, proj.name) + + return json.dumps({"success": True, "id": proj.id, "slug": proj.slug, "name": proj.name, "primary_path": primary}) + + +def project_switch(project: str, task_id: Optional[str] = None) -> str: + from hermes_cli import projects_db as pdb + + with pdb.connect_closing() as conn: + proj = _resolve(conn, project) + if proj is None: + return json.dumps({"success": False, "error": f"no project matching '{project}'"}) + pdb.set_active(conn, proj.id) + + primary = _primary_path(proj) + _apply_workspace(task_id, primary, proj.name) + + return json.dumps({"success": True, "id": proj.id, "slug": proj.slug, "name": proj.name, "primary_path": primary}) + + +registry.register( + name="project_list", + toolset="project", + schema={ + "name": "project_list", + "description": "List the desktop Projects (named workspaces) and which one is active.", + "parameters": {"type": "object", "properties": {}}, + }, + handler=lambda args, **kw: project_list(task_id=kw.get("task_id")), +) + +registry.register( + name="project_create", + toolset="project", + schema={ + "name": "project_create", + "description": ( + "Create a desktop Project (a named workspace) and switch this chat into it. " + "Pass `path` to anchor it to a repo/folder — this chat's workspace moves there " + "and the sidebar follows. Use when starting work in a new repo/folder; this is " + "the intentional way to move the session, not `cd`." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Human name, e.g. 'Aurora Demo'"}, + "path": {"type": "string", "description": "Primary repo/folder to anchor the project to"}, + }, + "required": ["name"], + }, + }, + handler=lambda args, **kw: project_create( + name=args.get("name", ""), path=args.get("path"), task_id=kw.get("task_id") + ), +) + +registry.register( + name="project_switch", + toolset="project", + schema={ + "name": "project_switch", + "description": ( + "Switch this chat into an existing desktop Project (by name, slug, or id). " + "Moves the session's workspace to the project's primary folder and the sidebar " + "follows. The intentional way to move between projects, not `cd`." + ), + "parameters": { + "type": "object", + "properties": { + "project": {"type": "string", "description": "Project name, slug, or id"}, + }, + "required": ["project"], + }, + }, + handler=lambda args, **kw: project_switch(project=args.get("project", ""), task_id=kw.get("task_id")), +) diff --git a/toolsets.py b/toolsets.py index 9efb32d8cee..ef7c41e9166 100644 --- a/toolsets.py +++ b/toolsets.py @@ -51,6 +51,11 @@ _HERMES_CORE_TOOLS = [ "text_to_speech", # Planning & memory "todo", "memory", + # NOTE: the desktop Project tools (project_list/create/switch) are + # deliberately NOT here. They only make sense where a GUI can follow the + # move, so they live in the `project` toolset and are enabled solely by the + # GUI gateway (tui_gateway/server.py::_load_enabled_toolsets) — keeping them + # off every CLI/messaging/cron schema (narrow waist). # Session history search "session_search", # Clarifying questions @@ -216,6 +221,12 @@ TOOLSETS = { "tools": ["session_search"], "includes": [] }, + + "project": { + "description": "Desktop Projects — create/switch named workspaces (GUI sessions only)", + "tools": ["project_list", "project_create", "project_switch"], + "includes": [] + }, "clarify": { "description": "Ask the user clarifying questions (multiple-choice or open-ended)",