diff --git a/docs/docs.json b/docs/docs.json
index 482b852dd..594bce9b1 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -93,7 +93,8 @@
{
"group": "Developer Tools",
"pages": [
- "guides/code-index"
+ "guides/code-index",
+ "guides/project-map"
]
},
{
diff --git a/docs/guides/code-index.mdx b/docs/guides/code-index.mdx
index b360c5b9e..5212d7c6d 100644
--- a/docs/guides/code-index.mdx
+++ b/docs/guides/code-index.mdx
@@ -66,6 +66,12 @@ The [`coding` skill](https://github.com/amd/gaia/blob/main/hub/skills/coding/SKI
| `get_index_status` | Report current index state |
| `clear_code_index` | Remove the cached index |
+You usually will not have to wait for that first `index_codebase` call. The
+[project map](/guides/project-map) starts one in the background at task start
+whenever the agent is in a repository that has no index, so by the time a
+semantic search matters the index is often already there. Set
+`GAIA_PROJECT_MAP_AUTO_INDEX=0` to keep indexing entirely manual.
+
### Example interaction
```
diff --git a/docs/guides/project-map.mdx b/docs/guides/project-map.mdx
new file mode 100644
index 000000000..4abc9047a
--- /dev/null
+++ b/docs/guides/project-map.mdx
@@ -0,0 +1,153 @@
+---
+title: "Project Map"
+description: "The orientation pass the agent runs at task start, so it stops guessing paths and commands."
+icon: "map"
+---
+
+
+ **Source Code:** [`src/gaia/agents/base/project_map.py`](https://github.com/amd/gaia/blob/main/src/gaia/agents/base/project_map.py)
+
+
+Without a project map the agent opens every task blind. It guesses directory
+names, guesses which programs are installed, and guesses which shell it is
+talking to — and every wrong guess costs a full round trip to learn something
+one orientation pass establishes once.
+
+The [GAIA agent](/guides/gaia) now runs that pass at the start of every task and
+puts the result in its system prompt.
+
+## What it contains
+
+```text
+==== PROJECT MAP ====
+Root: C:\Users\me\Work\gaia
+Code repository: yes (pyproject.toml, setup.py, package.json)
+
+Platform (these three change the commands you write):
+- Path separator: \
+- Paths with spaces: wrap in double quotes: "C:\Program Files\app"
+- Shell dialect: cmd.exe
+
+Directories:
+- docs/ (assets, deployment, guides, integrations)
+- hub/ (agents, components, skills)
+- src/ (gaia, vscode)
+- tests/ (electron, fixtures, integration, mcp, unit)
+
+Entry points: Makefile, src/cli.py, npm run build
+
+NOT installed, do not invoke: cargo, gradle, java, mvn, rustc
+run_shell_command accepts: cat, date, diff, find, git, grep, head, ls, ...
+Installed but run_shell_command refuses them — use a tool, not the shell:
+cmake, curl, docker, go, node, npm, pip, python, uv
+
+Code index: not built — call index_codebase to enable semantic code search
+```
+
+| Section | Where it comes from |
+|---------|---------------------|
+| Root, repository status | The `is_code_repository` predicate below |
+| Platform | `detect_platform_quirks()` — exactly three fields, see [Platform quirks](#platform-quirks) |
+| Directories | Two levels deep, build output and vendored dependencies skipped |
+| Entry points | A closed list of well-known files, plus `npm run` targets from `package.json` |
+| Commands | `probe_binaries()` — the same PATH probe that backs [day-0 memory](/guides/memory), crossed with the shell tool's own allowlist |
+| Code index | [`CodeIndexSDK.is_indexed()`](/guides/code-index) — a presence check, so it is cheap enough to run on every render |
+
+## The token budget
+
+**600 tokens.** That is 1.8% of the NPU profile's 32,768-token window
+(`NPU_CTX_SIZE`) and 0.9% of the GPU profile's 65,536. It is sized against the
+smaller window on purpose — a budget that only holds on 64K is not a budget.
+
+Measured with GAIA's shared estimator (`count_tokens` — cl100k, or a character
+ratio when tiktoken is absent), so it bounds the map to within that estimator's
+error of the model's own count rather than to the exact token.
+
+`render_project_map()` enforces it on every render. Sections are emitted in
+priority order and the directory listing carries its own sub-cap, so on a
+2,000-directory monorepo the platform quirks and the command list still survive
+while the tree is what gets trimmed.
+
+## Which directories count as a code repository
+
+The predicate is testable, not a judgement call. **`is_code_repository(path)` is
+true when a version-control directory or a recognised manifest sits at the
+root** — non-recursive, so a home directory full of repositories is not itself
+one.
+
+
+
+ `.git`, `.hg`, `.svn`
+
+
+ `pyproject.toml`, `setup.py`, `setup.cfg`, `requirements.txt`,
+ `package.json`, `Cargo.toml`, `go.mod`, `pom.xml`, `build.gradle`,
+ `build.gradle.kts`, `CMakeLists.txt`, `Makefile`, `Gemfile`,
+ `composer.json`
+
+
+
+## Platform quirks
+
+Three, and only three — the differences that change the *text* of a command the
+model emits. A contributor can check the list off:
+
+1. **Path separator** — `\` on Windows, `/` elsewhere.
+2. **Path quoting for spaces** — double quotes on Windows, single quotes on POSIX.
+3. **Shell dialect** — `cmd.exe` or PowerShell on Windows; `$SHELL`'s basename otherwise.
+
+## Automatic code indexing
+
+When the root is a code repository and no [code index](/guides/code-index)
+exists, the map calls `index_codebase` in a background thread and says so in the
+prompt, so the model knows to grep until it lands. It fires at most once per
+session.
+
+Turn it off on a monorepo where a full embedding pass is not worth it:
+
+```bash
+export GAIA_PROJECT_MAP_AUTO_INDEX=0
+```
+
+## Choosing the root
+
+Resolution order:
+
+1. `project_root` on `GaiaAgentConfig`
+2. the `GAIA_PROJECT_ROOT` environment variable
+3. the working directory, or the nearest repository up to four levels above it
+
+A working directory that resolves to **GAIA's own source tree** is rejected — in
+dev mode the agent sidecar is launched from the GAIA checkout, and mapping (and
+auto-indexing) its own source is never what the user asked for. An explicitly
+configured root is exempt: pointing GAIA at GAIA is fine when you mean it.
+
+If none of those is a repository the agent gets **no map at all**. That is the
+right answer, not a degraded one — an agent answering questions from a home
+directory is not in a project, and a map of `~` would spend tokens describing
+nothing.
+
+```bash
+export GAIA_PROJECT_ROOT=/home/me/Work/my-service
+```
+
+A `GAIA_PROJECT_ROOT` that does not exist raises at startup rather than being
+quietly ignored.
+
+## Caching
+
+The map is built once per root and reused. It is rebuilt when a fingerprint over
+four things changes: the top-level directory listing, every manifest's size and
+mtime, the VCS head, and `PATH`. Between changes, repeated queries reuse one
+walk.
+
+## Related
+
+
+
+ The semantic index the map triggers.
+
+
+ Day-0 system facts, from the same binary probe.
+
+
diff --git a/hub/agents/gaia/npm/CHANGELOG.md b/hub/agents/gaia/npm/CHANGELOG.md
index a1c23bfb1..cef79adc7 100644
--- a/hub/agents/gaia/npm/CHANGELOG.md
+++ b/hub/agents/gaia/npm/CHANGELOG.md
@@ -14,6 +14,14 @@ the terminal UI meant building it from source.
### Added
+- **A project map at task start.** In a code repository the agent now opens
+ every task knowing the directory shape, the likely entry points, which
+ commands are installed, and the three platform differences that change
+ command syntax — instead of discovering each one through a failed tool call.
+ Capped at 600 prompt tokens. If the repository has no code index the map
+ starts one in the background; `GAIA_PROJECT_MAP_AUTO_INDEX=0` turns that off,
+ and `GAIA_PROJECT_ROOT` picks the project when the working directory is not
+ it. See SKILL §11.
- **`503` from `/query` at session capacity.** When every retained session
slot is busy and none is idle enough to evict, starting a new session
returns `503` with the reason in `detail` — retryable, distinct from a
diff --git a/hub/agents/gaia/npm/SKILL.md b/hub/agents/gaia/npm/SKILL.md
index 472b1e2c6..2780ac497 100644
--- a/hub/agents/gaia/npm/SKILL.md
+++ b/hub/agents/gaia/npm/SKILL.md
@@ -413,7 +413,32 @@ packaged sidecar (its CLI accepts only `--host` and `--port`), and an undeclared
name raises naming the valid sets rather than falling back to a default. Beyond
`gaia-voice`, do not design around a skill being on by default.
-## 11. Ports
+## 11. The project map — two things it costs you
+
+When the agent's working directory is a code repository, every task starts with
+a **project map** in the system prompt: the root, the directory shape, the
+likely entry points, which commands are installed, and the three platform
+differences that change command syntax. It exists so the agent stops burning
+round trips on "no such file" and "command not found".
+
+Two consequences an integrator needs to plan for:
+
+- **Up to 600 prompt tokens, every turn.** That is the enforced ceiling
+ (1.8% of the NPU profile's 32K window), not a typical value — budget it
+ alongside `gaia-voice`'s 676.
+- **A background embedding pass on first contact with a new repository.** If
+ the repo has no [code index](https://amd-gaia.ai/docs/guides/code-index), the
+ map starts one in a background thread so semantic search is ready when it is
+ needed. On a large monorepo that is minutes of local embedding.
+ `GAIA_PROJECT_MAP_AUTO_INDEX=0` turns it off.
+
+The sidecar's CLI accepts only `--host` and `--port`, so pointing the map at a
+specific project means `GAIA_PROJECT_ROOT=/path/to/repo` in its environment, or
+`GaiaAgentConfig(project_root=...)` when embedding. A directory that is neither
+a VCS checkout nor holds a recognised manifest gets **no map** — that is the
+designed answer, not a failure.
+
+## 12. Ports
| Service | Port |
|---|---|
@@ -425,7 +450,7 @@ Port **4001 is reserved repo-wide**: `spawnSidecar` throws a `RangeError` and
speaks for the user's documents and memory and has no business on a LAN
interface.
-## 12. Running in a server or long-lived app
+## 13. Running in a server or long-lived app
- **`fetchAll` / `fetchBinary` are a build step**, not per request — network plus
a full SHA-256 hash of a large artifact. Run once at install time.
diff --git a/hub/agents/gaia/npm/SPEC.md b/hub/agents/gaia/npm/SPEC.md
index c10f2fe22..97c858808 100644
--- a/hub/agents/gaia/npm/SPEC.md
+++ b/hub/agents/gaia/npm/SPEC.md
@@ -289,6 +289,15 @@ collapses the rest to a one-line menu entry (re-activated by calling
disables it for the session and every body renders. Omitting it is a valid, explicit one-shot: nothing
persists past that single turn, and the agent is not told otherwise.
+A retained session also carries a **project map** — up to 600 prompt tokens of
+directory shape, entry points, installed commands and platform quirks, present
+whenever the agent's working directory resolves to a code repository (a VCS
+directory or a recognised manifest at its root). `GAIA_PROJECT_ROOT=`
+picks the project when the working directory is not it. If that repository has
+no code index, the first turn starts one in a background thread;
+`GAIA_PROJECT_MAP_AUTO_INDEX=0` disables that. Neither affects the wire
+contract — they change what the agent knows and what the first turn costs.
+
A second `/query` for a `session_id` that already has a turn in flight gets
`409 Conflict` — cancel the running turn or wait for it, then retry. A
`/query` that needs a **new** session while every retained slot is busy and
diff --git a/hub/agents/gaia/python/gaia_agent/agent.py b/hub/agents/gaia/python/gaia_agent/agent.py
index 111d8bdd4..21c21b7c6 100644
--- a/hub/agents/gaia/python/gaia_agent/agent.py
+++ b/hub/agents/gaia/python/gaia_agent/agent.py
@@ -49,6 +49,7 @@
from gaia_agent_chat.agent import ChatAgent, ChatAgentConfig
+from gaia.agents.base.project_map import ProjectMapMixin
from gaia.agents.base.skill_discovery import (
DISCOVERY_THRESHOLD_ENV,
SkillDiscovery,
@@ -204,11 +205,24 @@ class GaiaAgentConfig(ChatAgentConfig):
default_factory=lambda: [str(Path.home())]
)
+ # The project this task is about (#3379). ``None`` resolves through
+ # ``GAIA_PROJECT_ROOT``, then the working directory or the nearest
+ # repository above it — and stays ``None`` when neither is a repository,
+ # which is the common case for a sidecar launched from its package dir.
+ project_root: Optional[str] = None
-# Base agent first, tool mixins after — the repo's MRO convention for every
-# hub agent. Neither mixin overrides anything today; this order keeps a future
-# mixin method from silently winning over ChatAgent's.
-class GaiaAgent(ChatAgent, SkillLibraryToolsMixin, CodeIndexToolsMixin):
+ # Build the semantic code index at task start when the project is a
+ # repository and has none. Off-switch: ``GAIA_PROJECT_MAP_AUTO_INDEX=0``,
+ # for a monorepo where a full embed pass is not worth it.
+ auto_index: bool = True
+
+
+# ``ProjectMapMixin`` is the one exception to "base agent first": it overrides
+# ``_on_task_start`` and calls ``super()``, and ``Agent``'s no-op default sits
+# ahead of every trailing mixin in the MRO — listed after ChatAgent it would
+# never run. Tool mixins keep their usual place at the back, where neither
+# overrides anything and a future method cannot silently win over ChatAgent's.
+class GaiaAgent(ProjectMapMixin, ChatAgent, SkillLibraryToolsMixin, CodeIndexToolsMixin):
"""The flagship GAIA agent — conversation, documents, data, web, and skills."""
SKILL_DIRS: ClassVar[List[str]] = _bundled_skill_roots()
@@ -264,12 +278,16 @@ def _register_tools(self) -> None:
self.skill_loader = self._maybe_build_skill_loader()
self._skill_discovery = self._maybe_build_skill_discovery()
self.register_skill_library_tools()
- # Same scope as allowed_paths, for the same reason that field rejects
- # cwd: the daemon launches this sidecar with cwd = the package
- # directory, so cwd would sandbox code search to the agent's own
- # source tree — and index it by default.
+ # The project map's root when there is one, so "is the index built?"
+ # and "index it" both mean the repository the task is about. Falling
+ # back to allowed_paths for the same reason that field rejects cwd: the
+ # daemon launches this sidecar with cwd = the package directory, so cwd
+ # would sandbox code search to the agent's own source tree.
allowed = getattr(self.config, "allowed_paths", None) or [str(Path.home())]
- self._init_code_index_state(repo_path=allowed[0])
+ # Through the mixin, so both read the one cached resolution and can
+ # never end up describing two different trees.
+ index_root = self._project_map_root() or allowed[0]
+ self._init_code_index_state(repo_path=index_root)
self.register_code_index_tools()
super()._register_tools()
diff --git a/src/gaia/agents/base/agent.py b/src/gaia/agents/base/agent.py
index 70d717087..30a9cd1e4 100644
--- a/src/gaia/agents/base/agent.py
+++ b/src/gaia/agents/base/agent.py
@@ -612,6 +612,9 @@ class Agent(abc.ABC):
"get_memory_system_prompt", # changes on any remember()/forget()
"get_skills_system_prompt", # per-turn body selection (#2848)
"get_recalled_skills_system_prompt", # per-turn procedural recall
+ # Mostly static, but the index line flips as a background index
+ # lands and the shape line changes if the project does (#3379).
+ "get_project_map_system_prompt",
}
)
@@ -1248,6 +1251,17 @@ def _select_tools_for_turn( # pylint: disable=unused-argument
"""
return None
+ def _on_task_start( # pylint: disable=unused-argument
+ self, user_input: str
+ ) -> None:
+ """Hook run at the top of every turn, before the prompt is composed.
+
+ Default: no-op. Mixins that orient the agent in its environment — the
+ project map (#3379) is the first — override this and must call
+ ``super()._on_task_start(user_input)``. Anything expensive belongs
+ behind a once-per-session guard inside the override, not here.
+ """
+
def _on_tool_invoked(self, tool_name: str) -> None:
"""Hook called when a tool is about to execute (after registry lookup).
@@ -4486,6 +4500,10 @@ def _process_query_impl(
self._current_query = user_input
self._single_tool_done = False
+ # Orientation. Runs before the prompt is composed so anything it
+ # establishes is in the prompt on the turn that established it.
+ self._on_task_start(user_input)
+
# Proactive skill discovery: a skill the user never named can become
# loaded here, registering its tools — so it must run BEFORE the tool
# filter, or those tools are invisible on the very turn that loaded the
diff --git a/src/gaia/agents/base/project_map.py b/src/gaia/agents/base/project_map.py
new file mode 100644
index 000000000..541389f47
--- /dev/null
+++ b/src/gaia/agents/base/project_map.py
@@ -0,0 +1,797 @@
+# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
+# SPDX-License-Identifier: MIT
+"""Task-start orientation: a project map injected into the system prompt.
+
+The agent otherwise opens every task blind — it guesses directory names, guesses
+which commands exist, and guesses which shell it is talking to. Each wrong guess
+costs a full round trip to learn something one orientation pass establishes once.
+
+What the map carries, and why each part is there:
+
+* **Directory shape and likely entry points** — the genuinely new half. Nothing
+ else in GAIA states them without the model choosing to call ``tree``.
+* **Which binaries are present** — from ``system_context.probe_binaries``, the
+ same probe that backs day-0 memory, widened to the developer toolchain and to
+ the shell tool's own allowlist. Absent commands are named too: "do not run
+ ``cargo``" saves the round trip that "command not found" would have cost.
+* **Three platform quirks** — path separator, quoting for spaces, shell dialect.
+ Exactly three, enumerated in :class:`PlatformQuirks`.
+
+Budget: :data:`PROJECT_MAP_TOKEN_BUDGET` tokens, enforced by
+:func:`render_project_map` on every render. The figure is sized against the
+smaller of the two device profiles (``NPU_CTX_SIZE`` = 32,768), not the 64K one.
+
+Caching: :func:`build_project_map` is memoised per root and invalidated by a
+filesystem fingerprint (see :func:`_fingerprint`), so it is rebuilt when the
+project changes rather than on every query.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import platform
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+from gaia.agents.base.system_context import DEV_TOOL_PROBES, probe_binaries
+from gaia.agents.base.turn_metrics import count_tokens
+from gaia.logger import get_logger
+
+logger = get_logger(__name__)
+
+# ── budget ────────────────────────────────────────────────────────────────
+
+#: Ceiling on the rendered map, measured with the shared estimator in
+#: :mod:`gaia.agents.base.turn_metrics` (cl100k, or a char ratio when tiktoken
+#: is absent) — so it bounds the map to within that estimator's error of the
+#: model's own count, not to the exact token.
+#:
+#: 600 tokens is 1.8% of the NPU profile's 32,768-token window (``NPU_CTX_SIZE``)
+#: and 0.9% of the GPU profile's 65,536. Sized against the NPU deliberately: the
+#: smaller window is the one that has to survive the addition, and a budget that
+#: only holds on 64K is not a budget.
+PROJECT_MAP_TOKEN_BUDGET = 600
+
+#: Per-section sub-caps, as a fraction of the total budget. Without these the
+#: directory listing — the one unbounded section — eats the whole allowance and
+#: the platform quirks fall off the end.
+_DIR_SHAPE_SHARE = 0.40
+_COMMANDS_SHARE = 0.30
+
+# ── the "code repository" predicate ───────────────────────────────────────
+
+#: A version-control directory at the root. First half of the predicate.
+VCS_DIRS: Tuple[str, ...] = (".git", ".hg", ".svn")
+
+#: A recognised build/package manifest at the root. Second half of the predicate.
+PROJECT_MANIFESTS: Tuple[str, ...] = (
+ "pyproject.toml",
+ "setup.py",
+ "setup.cfg",
+ "requirements.txt",
+ "package.json",
+ "Cargo.toml",
+ "go.mod",
+ "pom.xml",
+ "build.gradle",
+ "build.gradle.kts",
+ "CMakeLists.txt",
+ "Makefile",
+ "Gemfile",
+ "composer.json",
+)
+
+
+def is_code_repository(root: os.PathLike | str) -> bool:
+ """Is *root* a code repository?
+
+ The predicate, stated so it can be checked rather than judged: **a directory
+ from** :data:`VCS_DIRS` **is present at the root, or a file from**
+ :data:`PROJECT_MANIFESTS` **is present at the root.** Non-recursive, so a
+ home directory that happens to contain repositories is not itself one.
+ """
+ path = Path(root)
+ try:
+ if not path.is_dir():
+ return False
+ return any((path / d).is_dir() for d in VCS_DIRS) or any(
+ (path / f).is_file() for f in PROJECT_MANIFESTS
+ )
+ except OSError as e:
+ logger.debug("is_code_repository(%s) could not stat the path: %s", root, e)
+ return False
+
+
+# ── platform quirks: exactly three, enumerated ────────────────────────────
+
+
+@dataclass(frozen=True)
+class PlatformQuirks:
+ """The closed set of platform differences that change command syntax.
+
+ Three, and only three. Each is something the model gets wrong by default on
+ the other platform, and each changes the text of a command it emits.
+ """
+
+ #: 1. Separator between path components — ``\\`` or ``/``.
+ path_separator: str
+ #: 2. How to quote a path containing spaces.
+ path_quoting: str
+ #: 3. Which shell interprets the command line.
+ shell_dialect: str
+
+
+def detect_platform_quirks() -> PlatformQuirks:
+ """Resolve the three quirks for the running host."""
+ if platform.system() == "Windows":
+ comspec = os.environ.get("COMSPEC", "")
+ dialect = "PowerShell" if "powershell" in comspec.lower() else "cmd.exe"
+ return PlatformQuirks(
+ path_separator="\\",
+ path_quoting='wrap in double quotes: "C:\\Program Files\\app"',
+ shell_dialect=dialect,
+ )
+ shell = os.environ.get("SHELL", "")
+ return PlatformQuirks(
+ path_separator="/",
+ path_quoting="wrap in single quotes: '/home/me/My Docs'",
+ shell_dialect=Path(shell).name if shell else "sh",
+ )
+
+
+# ── directory shape and entry points ──────────────────────────────────────
+
+#: Directories never worth a slot in the map — build output, caches, vendored
+#: dependencies. Closed list.
+IGNORED_DIRS: frozenset = frozenset(
+ {
+ ".git",
+ ".hg",
+ ".svn",
+ ".idea",
+ ".vscode",
+ ".mypy_cache",
+ ".pytest_cache",
+ ".ruff_cache",
+ ".tox",
+ ".venv",
+ ".next",
+ ".gaia",
+ "__pycache__",
+ "node_modules",
+ "venv",
+ "env",
+ "dist",
+ "build",
+ "target",
+ "coverage",
+ "htmlcov",
+ "site-packages",
+ "vendor",
+ }
+)
+
+#: Files that are, when present, a likely way to start or drive the project.
+#: Closed list — checked as literal relative paths, no globbing.
+ENTRY_POINT_CANDIDATES: Tuple[str, ...] = (
+ "main.py",
+ "__main__.py",
+ "app.py",
+ "cli.py",
+ "run.py",
+ "manage.py",
+ "src/main.py",
+ "src/cli.py",
+ "src/app.py",
+ "index.js",
+ "index.ts",
+ "server.js",
+ "src/index.js",
+ "src/index.ts",
+ "main.go",
+ "cmd/main.go",
+ "src/main.rs",
+ "Makefile",
+ "Dockerfile",
+ "docker-compose.yml",
+)
+
+_MAX_TOP_LEVEL_DIRS = 24
+_MAX_SUBDIRS_PER_DIR = 8
+_MAX_EXPANDED_DIRS = 6
+
+#: Only a few directories can afford a second level within the budget. These
+#: names get it first — alphabetical order would spend the whole allowance on
+#: ``cpp/``, ``data/``, ``docs/`` and never reach ``src/``.
+_EXPAND_FIRST: Tuple[str, ...] = (
+ "src",
+ "lib",
+ "app",
+ "apps",
+ "packages",
+ "pkg",
+ "cmd",
+ "internal",
+ "hub",
+ "tests",
+ "test",
+)
+
+
+@dataclass
+class ProjectMap:
+ """One project's shape, as of the fingerprint it was built at."""
+
+ root: str
+ is_repository: bool
+ vcs: Optional[str]
+ manifests: List[str] = field(default_factory=list)
+ top_level_dirs: List[str] = field(default_factory=list)
+ subdirs: Dict[str, List[str]] = field(default_factory=dict)
+ entry_points: List[str] = field(default_factory=list)
+ #: Toolchain binaries found on PATH, and those looked for and not found.
+ tools_present: List[str] = field(default_factory=list)
+ tools_absent: List[str] = field(default_factory=list)
+ #: The subset of the above that ``run_shell_command`` will actually accept.
+ shell_commands: List[str] = field(default_factory=list)
+ quirks: PlatformQuirks = field(default_factory=detect_platform_quirks)
+ fingerprint: str = ""
+
+
+def _shell_allowlisted_commands() -> Tuple[str, ...]:
+ """Commands ``run_shell_command`` will accept, as a sorted tuple.
+
+ Imported lazily to keep ``agents/base`` free of an import-time dependency
+ on ``agents/tools``.
+ """
+ from gaia.agents.tools.shell_tools import ALLOWED_COMMANDS
+
+ return tuple(sorted(ALLOWED_COMMANDS))
+
+
+def _fingerprint(root: Path) -> str:
+ """Cheap change detector for *root*.
+
+ Covers the three things that make a map stale: the top-level listing (a new
+ directory appears), any manifest's content (dependencies or scripts change),
+ and the VCS head (a branch switch rewrites the tree). Reading the whole tree
+ to detect a change would cost as much as rebuilding the map.
+ """
+ parts: List[str] = [",".join(sorted(e.name for e in os.scandir(root)))]
+
+ for name in PROJECT_MANIFESTS:
+ p = root / name
+ try:
+ st = p.stat()
+ parts.append(f"{name}:{st.st_mtime_ns}:{st.st_size}")
+ except OSError:
+ continue
+
+ for vcs in VCS_DIRS:
+ head = root / vcs / "HEAD"
+ try:
+ parts.append(f"{vcs}:{head.stat().st_mtime_ns}")
+ except OSError:
+ continue
+
+ parts.append(os.environ.get("PATH", ""))
+ return "|".join(parts)
+
+
+#: ``absolute root -> (fingerprint, map)``. Module-level so several agents over
+#: the same project in one process pay for the walk once.
+_MAP_CACHE: Dict[str, Tuple[str, ProjectMap]] = {}
+
+
+def clear_project_map_cache() -> None:
+ """Drop every cached map. For tests and for an explicit re-orientation."""
+ _MAP_CACHE.clear()
+
+
+def build_project_map(root: os.PathLike | str) -> ProjectMap:
+ """Build (or return the cached) map for *root*.
+
+ Cached per absolute root and invalidated when :func:`_fingerprint` changes,
+ so repeated queries against an unchanged project reuse one walk.
+ """
+ path = Path(root).expanduser().resolve()
+ key = str(path)
+ fp = _fingerprint(path)
+
+ cached = _MAP_CACHE.get(key)
+ if cached is not None and cached[0] == fp:
+ return cached[1]
+
+ pm = _collect(path, fp)
+ _MAP_CACHE[key] = (fp, pm)
+ logger.debug(
+ "[project-map] built for %s (%d top-level dirs, %d entry points)",
+ key,
+ len(pm.top_level_dirs),
+ len(pm.entry_points),
+ )
+ return pm
+
+
+def _collect(path: Path, fingerprint: str) -> ProjectMap:
+ """Walk *path* two levels deep and probe the toolchain."""
+ vcs = next((d for d in VCS_DIRS if (path / d).is_dir()), None)
+ manifests = [f for f in PROJECT_MANIFESTS if (path / f).is_file()]
+
+ top_dirs: List[str] = []
+ for entry in sorted(os.scandir(path), key=lambda e: e.name):
+ if len(top_dirs)>= _MAX_TOP_LEVEL_DIRS:
+ break
+ if not entry.is_dir(follow_symlinks=False):
+ continue
+ if entry.name in IGNORED_DIRS or entry.name.startswith("."):
+ continue
+ top_dirs.append(entry.name)
+
+ expand_order = sorted(
+ top_dirs,
+ key=lambda n: (
+ _EXPAND_FIRST.index(n) if n in _EXPAND_FIRST else len(_EXPAND_FIRST),
+ n,
+ ),
+ )
+ subdirs: Dict[str, List[str]] = {}
+ for name in expand_order[:_MAX_EXPANDED_DIRS]:
+ children: List[str] = []
+ try:
+ for entry in sorted(os.scandir(path / name), key=lambda e: e.name):
+ if len(children)>= _MAX_SUBDIRS_PER_DIR:
+ break
+ if not entry.is_dir(follow_symlinks=False):
+ continue
+ if entry.name in IGNORED_DIRS or entry.name.startswith("."):
+ continue
+ children.append(entry.name)
+ except OSError as e:
+ # One unreadable subdirectory renders with no children rather than
+ # sinking the whole map.
+ logger.debug("[project-map] cannot list %s: %s", path / name, e)
+ continue
+ if children:
+ subdirs[name] = children
+
+ entry_points = [c for c in ENTRY_POINT_CANDIDATES if (path / c).is_file()]
+ entry_points.extend(_declared_npm_scripts(path))
+
+ allowlist = _shell_allowlisted_commands()
+ probed = probe_binaries(set(DEV_TOOL_PROBES) | set(allowlist))
+ tools_present = sorted(n for n in DEV_TOOL_PROBES if probed.get(n))
+ # Only toolchain absences are reported: that list is closed and bounded,
+ # whereas "every allowlisted command not installed" is mostly platform noise
+ # (a Windows box is never going to have ``lspci``).
+ tools_absent = sorted(n for n in DEV_TOOL_PROBES if not probed.get(n))
+ # Installed AND allowlisted. Kept separate from ``tools_present`` because
+ # most of the toolchain is not allowlisted — ``uv`` and ``npm`` are on this
+ # machine and ``run_shell_command`` refuses both.
+ shell_commands = sorted(n for n in allowlist if probed.get(n))
+
+ return ProjectMap(
+ root=str(path),
+ is_repository=bool(vcs) or bool(manifests),
+ vcs=vcs,
+ manifests=manifests,
+ top_level_dirs=top_dirs,
+ subdirs=subdirs,
+ entry_points=entry_points,
+ tools_present=tools_present,
+ tools_absent=tools_absent,
+ shell_commands=shell_commands,
+ quirks=detect_platform_quirks(),
+ fingerprint=fingerprint,
+ )
+
+
+def _declared_npm_scripts(path: Path) -> List[str]:
+ """``npm run `` targets declared in ``package.json``, capped at 8."""
+ manifest = path / "package.json"
+ if not manifest.is_file():
+ return []
+ try:
+ data = json.loads(manifest.read_text(encoding="utf-8", errors="replace"))
+ except (OSError, ValueError) as e:
+ logger.warning("[project-map] %s is not readable JSON: %s", manifest, e)
+ return []
+ scripts = data.get("scripts")
+ if not isinstance(scripts, dict):
+ return []
+ return [f"npm run {name}" for name in sorted(scripts)[:8]]
+
+
+# ── project-root resolution ───────────────────────────────────────────────
+
+#: Overrides the resolved root. Set by a host that knows the workspace.
+PROJECT_ROOT_ENV = "GAIA_PROJECT_ROOT"
+
+#: How far up from the working directory to look for a repository root.
+_MAX_ASCEND = 4
+
+
+def is_agent_own_source(root: os.PathLike | str) -> bool:
+ """Does *root* contain the ``gaia`` package this process is running from?
+
+ The daemon launches the agent sidecar with its working directory set to the
+ GAIA checkout in dev mode, so a working-directory-derived root there is the
+ agent's own source tree, not the user's project — and auto-indexing it would
+ embed thousands of files nobody asked about. An explicitly configured root
+ is never subject to this check: pointing GAIA at GAIA is legitimate when you
+ mean it.
+ """
+ import gaia
+
+ package = Path(gaia.__file__).resolve().parent
+ path = Path(root).resolve()
+ return path == package or path in package.parents
+
+
+def resolve_project_root(explicit: Optional[str] = None) -> Optional[str]:
+ """The project this task is about, or ``None`` when there isn't one.
+
+ Order: *explicit* argument, then ``GAIA_PROJECT_ROOT``, then the working
+ directory or the nearest repository above it (at most :data:`_MAX_ASCEND`
+ levels, never the home directory and never :func:`is_agent_own_source`).
+
+ ``None`` is a real answer, not a degraded one — an agent answering questions
+ from a home directory is not in a project, and inventing a map of ``~``
+ would cost tokens to describe nothing.
+ """
+ for candidate in (explicit, os.environ.get(PROJECT_ROOT_ENV)):
+ if not candidate:
+ continue
+ path = Path(candidate).expanduser()
+ if not path.is_dir():
+ raise ValueError(
+ f"Project root {candidate!r} is not a directory. "
+ f"Point {PROJECT_ROOT_ENV} (or the agent's project_root config) "
+ f"at an existing directory, or unset it to use the working "
+ f"directory."
+ )
+ return str(path.resolve())
+
+ cwd = Path.cwd().resolve()
+ home = Path.home().resolve()
+ for ancestor in [cwd, *list(cwd.parents)[: _MAX_ASCEND - 1]]:
+ if ancestor == home or ancestor == ancestor.parent:
+ break
+ if not is_code_repository(ancestor):
+ continue
+ if is_agent_own_source(ancestor):
+ logger.info(
+ "[project-map] %s is GAIA's own source tree — no map. Set %s to "
+ "the project you want mapped.",
+ ancestor,
+ PROJECT_ROOT_ENV,
+ )
+ return None
+ return str(ancestor)
+ return None
+
+
+# ── rendering, under budget ───────────────────────────────────────────────
+
+
+def _fit(text: str, token_cap: int) -> str:
+ """Trim *text* to *token_cap* estimated tokens, whole lines first."""
+ if count_tokens(text) <= token_cap: + return text + lines = text.splitlines() + while lines and count_tokens("\n".join(lines))> token_cap:
+ lines.pop()
+ return "\n".join(lines)
+
+
+def render_project_map(
+ pm: ProjectMap,
+ index_status: Optional[str] = None,
+ token_budget: int = PROJECT_MAP_TOKEN_BUDGET,
+ has_shell_tool: bool = True,
+) -> str:
+ """Render *pm* as a system-prompt block of at most *token_budget* tokens.
+
+ Sections are emitted in priority order and a section that would overflow
+ stops the render, so what survives truncation is always the highest-value
+ text rather than whatever happened to come first.
+
+ Pass ``has_shell_tool=False`` for an agent without ``run_shell_command`` —
+ naming a tool it does not have buys a guaranteed failed call.
+ """
+ q = pm.quirks
+ header = [
+ "==== PROJECT MAP ====",
+ f"Root: {pm.root}",
+ ]
+ if pm.vcs or pm.manifests:
+ detail = ", ".join(filter(None, [pm.vcs, ", ".join(pm.manifests[:4])]))
+ header.append(f"Code repository: yes ({detail})")
+ else:
+ header.append("Code repository: no (no VCS directory, no known manifest)")
+
+ quirks = [
+ "Platform (these three change the commands you write):",
+ f"- Path separator: {q.path_separator}",
+ f"- Paths with spaces: {q.path_quoting}",
+ f"- Shell dialect: {q.shell_dialect}",
+ ]
+
+ shape: List[str] = []
+ if pm.top_level_dirs:
+ shape.append("Directories:")
+ for name in pm.top_level_dirs:
+ children = pm.subdirs.get(name)
+ suffix = f" ({', '.join(children)})" if children else ""
+ shape.append(f"- {name}/{suffix}")
+
+ entries: List[str] = []
+ if pm.entry_points:
+ entries.append(f"Entry points: {', '.join(pm.entry_points)}")
+
+ # Absences first: they are the line that prevents a wasted round trip, so
+ # they must survive the sub-cap even when the longer lists do not.
+ commands: List[str] = []
+ if pm.tools_absent:
+ commands.append(f"NOT installed, do not invoke: {', '.join(pm.tools_absent)}")
+ if has_shell_tool:
+ allowed = set(pm.shell_commands)
+ if pm.shell_commands:
+ commands.append(
+ f"run_shell_command accepts: {', '.join(pm.shell_commands)}"
+ )
+ off_limits = [t for t in pm.tools_present if t not in allowed]
+ if off_limits:
+ commands.append(
+ "Installed but run_shell_command refuses them — use a tool, not "
+ f"the shell: {', '.join(off_limits)}"
+ )
+ elif pm.tools_present:
+ commands.append(f"Installed: {', '.join(pm.tools_present)}")
+
+ index: List[str] = [f"Code index: {index_status}"] if index_status else []
+
+ # The header always ships — a map that says nothing but "you are in
+ # /x/y, it is a git repo" is still worth more than an empty block — so it
+ # is clamped to the budget rather than dropped by it.
+ head = _fit("\n".join(header), token_budget)
+ out: List[str] = [head]
+ used = count_tokens(head)
+
+ optional: Sequence[str] = (
+ "\n".join(quirks),
+ _fit("\n".join(shape), int(token_budget * _DIR_SHAPE_SHARE)),
+ "\n".join(entries),
+ _fit("\n".join(commands), int(token_budget * _COMMANDS_SHARE)),
+ "\n".join(index),
+ )
+ for text in optional:
+ if not text:
+ continue
+ cost = count_tokens(text) + 1 # +1 for the blank-line join
+ if used + cost> token_budget:
+ logger.debug(
+ "[project-map] budget of %d tokens reached, dropping the tail",
+ token_budget,
+ )
+ break
+ out.append(text)
+ used += cost
+
+ return "\n\n".join(out)
+
+
+# ── agent mixin ───────────────────────────────────────────────────────────
+
+#: Truthy/falsy override for the task-start ``index_codebase`` trigger.
+AUTO_INDEX_ENV = "GAIA_PROJECT_MAP_AUTO_INDEX"
+
+
+def auto_index_env_override() -> Optional[bool]:
+ """``GAIA_PROJECT_MAP_AUTO_INDEX`` as a bool, or ``None`` when unset."""
+ raw = os.environ.get(AUTO_INDEX_ENV)
+ if raw is None or not raw.strip():
+ return None
+ return raw.strip().lower() in ("1", "true", "yes", "on")
+
+
+#: States of the task-start index trigger. All three post-``idle`` states are
+#: terminal for the session: the trigger fires at most once, and ``failed``
+#: exists so an index that died does not leave the prompt saying "building".
+_IDLE, _RUNNING, _DONE, _FAILED = "idle", "running", "done", "failed"
+
+
+class ProjectMapMixin:
+ """Injects a project map into the system prompt and triggers indexing.
+
+ Consumer responsibilities:
+
+ * List this mixin **before** the base agent in the bases — ``Agent``'s no-op
+ ``_on_task_start`` otherwise wins the MRO and the trigger never fires.
+ ``__init_subclass__`` raises if you get it wrong.
+ * Compose :class:`CodeIndexToolsMixin` too if the ``index_codebase`` trigger
+ is wanted; without it the map still renders, minus the index line.
+ * Optionally give the config a ``project_root`` field; otherwise the root
+ comes from ``GAIA_PROJECT_ROOT`` or the working directory.
+ """
+
+ def __init_subclass__(cls, **kwargs) -> None:
+ """Fail at class definition when the MRO would silence the hook.
+
+ The prompt fragment is found by ``dir()`` and would still render, so a
+ wrong base order otherwise produces a map with no index trigger and no
+ symptom at all.
+ """
+ super().__init_subclass__(**kwargs)
+ from gaia.agents.base.agent import Agent
+
+ mro = cls.__mro__
+ if Agent in mro and mro.index(ProjectMapMixin)> mro.index(Agent):
+ raise TypeError(
+ f"{cls.__name__} lists ProjectMapMixin after Agent, so "
+ f"Agent._on_task_start shadows it and the project map never "
+ f"triggers indexing. Put ProjectMapMixin first in the bases: "
+ f"class {cls.__name__}(ProjectMapMixin, ...)."
+ )
+
+ # ── the map ───────────────────────────────────────────────────────────
+
+ def _project_map_root(self) -> Optional[str]:
+ """This session's project root, resolved once.
+
+ Resolved once rather than per turn so the map and the code index can
+ never end up describing two different trees after a ``chdir``.
+ """
+ if not hasattr(self, "_project_map_root_cache"):
+ explicit = getattr(getattr(self, "config", None), "project_root", None)
+ self._project_map_root_cache = resolve_project_root(explicit)
+ return self._project_map_root_cache
+
+ def materialize_project_map(self) -> Optional[ProjectMap]:
+ """This task's map, or ``None`` when the task is not in a project."""
+ root = self._project_map_root()
+ return build_project_map(root) if root else None
+
+ def get_project_map_system_prompt(self) -> str:
+ """Auto-discovered by ``Agent._get_mixin_prompts``."""
+ pm = self.materialize_project_map()
+ if pm is None:
+ return ""
+ return render_project_map(
+ pm,
+ index_status=self._code_index_status(pm),
+ has_shell_tool="run_shell_command" in (self._tool_names()),
+ )
+
+ def _tool_names(self) -> Dict[str, Any]:
+ return getattr(self, "_tools_registry", {}) or {}
+
+ # ── code index ────────────────────────────────────────────────────────
+
+ def _code_index_status(self, pm: ProjectMap) -> Optional[str]:
+ """One line on the semantic index, or ``None`` when it does not apply."""
+ if not pm.is_repository:
+ return None
+ indexed = self._code_index_is_built()
+ if indexed is None:
+ return None
+ if indexed:
+ return "built — use search_code_index before grepping"
+ state = getattr(self, "_project_map_index_state", _IDLE)
+ if state == _RUNNING:
+ return "building now in the background; grep until it lands"
+ if state == _FAILED:
+ return "build FAILED — grep instead, or call index_codebase to see why"
+ return "not built — call index_codebase to enable semantic code search"
+
+ def _code_index_is_built(self) -> Optional[bool]:
+ """``True``/``False``, or ``None`` when this agent has no code index.
+
+ Reads the index at *the agent's* configured repo path, which the trigger
+ below also indexes, so the two can never disagree about which tree they
+ mean. ``GaiaAgent`` points both at :func:`resolve_project_root`.
+ """
+ getter = getattr(self, "_get_code_index_sdk", None)
+ if getter is None:
+ return None
+ sdk = getter()
+ if sdk is None:
+ return None
+ # Presence check, not get_status(): this runs on every prompt
+ # composition and get_status parses every indexed chunk.
+ return bool(sdk.is_indexed())
+
+ def _auto_index_enabled(self) -> bool:
+ override = auto_index_env_override()
+ if override is not None:
+ return override
+ return bool(getattr(getattr(self, "config", None), "auto_index", True))
+
+ def _on_task_start(self, user_input: str) -> None:
+ """Materialize the map and, if warranted, kick off ``index_codebase``."""
+ super()._on_task_start(user_input)
+ pm = self.materialize_project_map()
+ if pm is not None:
+ self._maybe_start_background_index(pm)
+
+ def _maybe_start_background_index(self, pm: ProjectMap) -> None:
+ """Start ``index_codebase`` in a background thread, at most once."""
+ if getattr(self, "_project_map_index_state", _IDLE) != _IDLE:
+ return
+ if not pm.is_repository or not self._auto_index_enabled():
+ return
+ if self._code_index_is_built() is not False:
+ return
+ index_tool = self._tool_names().get("index_codebase")
+ if index_tool is None:
+ return
+
+ self._project_map_index_state = _RUNNING
+ import threading
+
+ def _fail(detail: str) -> None:
+ # Background work has no caller to raise into, and a swallowed
+ # failure surfaces only as an empty search_code_index later.
+ self._project_map_index_state = _FAILED
+ logger.error(
+ "[project-map] background index of %s failed: %s. "
+ "Call index_codebase directly to see the full error.",
+ pm.root,
+ detail,
+ )
+
+ def _run() -> None:
+ logger.info("[project-map] indexing %s in the background", pm.root)
+ try:
+ # No repo_path: the tool defaults to the agent's configured
+ # code-index root, the one the status above was read from.
+ raw = index_tool["function"]()
+ except Exception as e:
+ _fail(str(e))
+ return
+ # The tool reports refusals and internal errors as JSON rather than
+ # by raising, so "no exception" is not "it worked".
+ error = _tool_error(raw)
+ if error:
+ _fail(error)
+ else:
+ self._project_map_index_state = _DONE
+ logger.info("[project-map] background index of %s done", pm.root)
+
+ threading.Thread(
+ target=_run, name="gaia-project-map-index", daemon=True
+ ).start()
+
+
+def _tool_error(raw: Any) -> Optional[str]:
+ """The ``error`` a code-index tool reported as JSON, if any."""
+ if not isinstance(raw, str):
+ return None
+ try:
+ parsed = json.loads(raw)
+ except ValueError:
+ return None
+ return parsed.get("error") if isinstance(parsed, dict) else None
+
+
+__all__ = [
+ "AUTO_INDEX_ENV",
+ "ENTRY_POINT_CANDIDATES",
+ "IGNORED_DIRS",
+ "PROJECT_MANIFESTS",
+ "PROJECT_MAP_TOKEN_BUDGET",
+ "PROJECT_ROOT_ENV",
+ "PlatformQuirks",
+ "ProjectMap",
+ "ProjectMapMixin",
+ "VCS_DIRS",
+ "auto_index_env_override",
+ "build_project_map",
+ "clear_project_map_cache",
+ "detect_platform_quirks",
+ "is_code_repository",
+ "render_project_map",
+ "resolve_project_root",
+]
diff --git a/src/gaia/agents/base/system_context.py b/src/gaia/agents/base/system_context.py
index bcf2933a4..1364e7464 100644
--- a/src/gaia/agents/base/system_context.py
+++ b/src/gaia/agents/base/system_context.py
@@ -23,7 +23,76 @@
import time
from datetime import datetime, timezone
from pathlib import Path
-from typing import Dict, List
+from typing import Dict, Iterable, List, Tuple
+
+#: CLI tools whose presence also implies a desktop application is installed.
+#: Labels match the per-platform probes in ``collect_system_info`` so the two
+#: never emit the same app twice.
+CLI_TOOL_PROBES: Dict[str, str] = {
+ "git": "git",
+ "code": "VS Code",
+ "cursor": "Cursor",
+ "node": "Node.js",
+ "docker": "Docker",
+ "brew": "Homebrew",
+ "npm": "npm",
+}
+
+#: Developer toolchain binaries, probed by the project map. The extension over
+#: ``CLI_TOOL_PROBES``, which covers only the seven commands that double as
+#: desktop-app markers. Closed list; adding a name is a one-line change.
+DEV_TOOL_PROBES: Tuple[str, ...] = (
+ "bash",
+ "cargo",
+ "cmake",
+ "curl",
+ "docker",
+ "dotnet",
+ "gcc",
+ "gh",
+ "git",
+ "go",
+ "gradle",
+ "java",
+ "make",
+ "mvn",
+ "node",
+ "npm",
+ "npx",
+ "pip",
+ "pnpm",
+ "poetry",
+ "powershell",
+ "python",
+ "python3",
+ "rg",
+ "ruby",
+ "rustc",
+ "tsc",
+ "uv",
+ "yarn",
+)
+
+#: ``(PATH value, binary name) -> present``. Keyed on PATH so a shell that
+#: prepends a venv invalidates the answer rather than reusing a stale one.
+_BINARY_CACHE: Dict[Tuple[str, str], bool] = {}
+
+
+def probe_binaries(names: Iterable[str]) -> Dict[str, bool]:
+ """Presence on PATH of each binary in *names*.
+
+ The single binary probe in the codebase — ``collect_system_info`` and the
+ project map both go through it, so "is X installed" has one answer and one
+ cache rather than drifting per caller.
+ """
+ path_env = os.environ.get("PATH", "")
+ result: Dict[str, bool] = {}
+ for name in names:
+ key = (path_env, name)
+ if key not in _BINARY_CACHE:
+ _BINARY_CACHE[key] = shutil.which(name) is not None
+ result[name] = _BINARY_CACHE[key]
+ return result
def collect_system_info() -> List[Dict[str, str]]:
@@ -342,23 +411,12 @@ def collect_system_info() -> List[Dict[str, str]]:
except Exception:
pass
- # Cross-platform: shutil.which() for CLI tools.
+ # Cross-platform: PATH probe for CLI tools.
# Use the same display names as the platform checks to avoid duplicates.
- _cli_tools: dict = {
- "git": "git",
- "code": "VS Code",
- "cursor": "Cursor",
- "node": "Node.js",
- "docker": "Docker",
- "brew": "Homebrew",
- "npm": "npm",
- }
- for cmd, label in _cli_tools.items():
- try:
- if shutil.which(cmd) and label not in found_apps:
- found_apps.append(label)
- except Exception:
- pass
+ present = probe_binaries(CLI_TOOL_PROBES)
+ for cmd, label in CLI_TOOL_PROBES.items():
+ if present.get(cmd) and label not in found_apps:
+ found_apps.append(label)
if found_apps:
facts.append(
diff --git a/src/gaia/agents/base/turn_metrics.py b/src/gaia/agents/base/turn_metrics.py
index b60ba1974..cf86bf6e8 100644
--- a/src/gaia/agents/base/turn_metrics.py
+++ b/src/gaia/agents/base/turn_metrics.py
@@ -102,6 +102,11 @@ def _tokenizer() -> _Tokenizer:
return _TOKENIZER
+def count_tokens(text: str) -> int:
+ """Estimated token count for *text*, via this module's shared estimator."""
+ return _tokenizer().count(text)
+
+
def _common_prefix_len(a: str, b: str) -> int:
"""Length of the longest shared leading substring of *a* and *b*.
diff --git a/src/gaia/code_index/sdk.py b/src/gaia/code_index/sdk.py
index 9238b3799..14a989a5e 100644
--- a/src/gaia/code_index/sdk.py
+++ b/src/gaia/code_index/sdk.py
@@ -499,6 +499,18 @@ def search(
return results
+ def is_indexed(self) -> bool:
+ """Is a usable index on disk for this repository?
+
+ Presence only — it does not read the metadata, and so does not detect a
+ cache written by an older ``_CACHE_VERSION`` (which ``index_repository``
+ rebuilds anyway). Callers that need the version or the chunk counts want
+ :meth:`get_status`, which parses the whole metadata file: on a large
+ repo that is tens of megabytes of JSON, far too much to spend on
+ "is there an index?".
+ """
+ return self._meta_path.exists() and self._index_path.exists()
+
def get_status(self) -> Dict[str, Any]:
"""Return index statistics."""
meta = self._load_metadata()
diff --git a/tests/unit/test_project_map.py b/tests/unit/test_project_map.py
new file mode 100644
index 000000000..3b034b7ba
--- /dev/null
+++ b/tests/unit/test_project_map.py
@@ -0,0 +1,554 @@
+# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
+# SPDX-License-Identifier: MIT
+"""Task-start project map (#3379).
+
+Pins the four things the feature is only useful if it guarantees: the
+"code repository" predicate, the token budget on the 32K NPU profile, cache
+invalidation on change, and the once-per-session ``index_codebase`` trigger.
+"""
+
+from __future__ import annotations
+
+import ast
+import json
+import os
+import pathlib
+import re
+import threading
+
+import pytest
+
+from gaia.agents.base.project_map import (
+ PROJECT_MANIFESTS,
+ PROJECT_MAP_TOKEN_BUDGET,
+ PROJECT_ROOT_ENV,
+ PlatformQuirks,
+ ProjectMapMixin,
+ build_project_map,
+ clear_project_map_cache,
+ detect_platform_quirks,
+ is_agent_own_source,
+ is_code_repository,
+ render_project_map,
+ resolve_project_root,
+)
+from gaia.agents.base.system_context import (
+ CLI_TOOL_PROBES,
+ DEV_TOOL_PROBES,
+ probe_binaries,
+)
+from gaia.agents.base.turn_metrics import count_tokens
+from gaia.llm.lemonade_client import NPU_CTX_SIZE
+
+
+@pytest.fixture(autouse=True)
+def _clean_cache():
+ clear_project_map_cache()
+ yield
+ clear_project_map_cache()
+
+
+@pytest.fixture
+def repo(tmp_path):
+ """A minimal but realistic project: VCS dir, manifest, source tree."""
+ (tmp_path / ".git").mkdir()
+ (tmp_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
+ (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n")
+ (tmp_path / "src").mkdir()
+ (tmp_path / "src" / "demo").mkdir()
+ (tmp_path / "src" / "cli.py").write_text("print('hi')\n")
+ (tmp_path / "tests").mkdir()
+ (tmp_path / "node_modules").mkdir()
+ (tmp_path / "Makefile").write_text("all:\n\techo hi\n")
+ return tmp_path
+
+
+# ── the predicate ─────────────────────────────────────────────────────────
+
+
+def test_predicate_true_for_vcs_directory(tmp_path):
+ (tmp_path / ".git").mkdir()
+ assert is_code_repository(tmp_path)
+
+
+@pytest.mark.parametrize("manifest", PROJECT_MANIFESTS)
+def test_predicate_true_for_every_declared_manifest(tmp_path, manifest):
+ (tmp_path / manifest).write_text("")
+ assert is_code_repository(tmp_path)
+
+
+def test_predicate_false_without_vcs_or_manifest(tmp_path):
+ (tmp_path / "notes.txt").write_text("hello")
+ (tmp_path / "photos").mkdir()
+ assert not is_code_repository(tmp_path)
+
+
+def test_predicate_is_not_recursive(tmp_path):
+ """A directory of repositories is not itself one."""
+ (tmp_path / "proj" / ".git").mkdir(parents=True)
+ assert not is_code_repository(tmp_path)
+
+
+def test_predicate_false_for_a_file(tmp_path):
+ f = tmp_path / "pyproject.toml"
+ f.write_text("")
+ assert not is_code_repository(f)
+
+
+# ── platform quirks: exactly three, all populated ─────────────────────────
+
+
+def test_platform_quirks_are_a_closed_set_of_three():
+ fields = PlatformQuirks.__dataclass_fields__
+ assert set(fields) == {"path_separator", "path_quoting", "shell_dialect"}
+
+
+def test_platform_quirks_are_all_populated():
+ q = detect_platform_quirks()
+ assert q.path_separator == os.sep
+ assert q.path_quoting and q.shell_dialect
+
+
+def test_rendered_map_names_all_three_quirks(repo):
+ text = render_project_map(build_project_map(repo))
+ for label in ("Path separator", "Paths with spaces", "Shell dialect"):
+ assert label in text
+
+
+# ── shape and entry points ────────────────────────────────────────────────
+
+
+def test_map_records_directory_shape_and_skips_noise(repo):
+ pm = build_project_map(repo)
+ assert "src" in pm.top_level_dirs
+ assert "tests" in pm.top_level_dirs
+ assert "node_modules" not in pm.top_level_dirs
+ assert ".git" not in pm.top_level_dirs
+ assert pm.subdirs["src"] == ["demo"]
+
+
+def test_map_records_entry_points_from_the_closed_list(repo):
+ pm = build_project_map(repo)
+ assert "src/cli.py" in pm.entry_points
+ assert "Makefile" in pm.entry_points
+
+
+def test_npm_scripts_become_entry_points(tmp_path):
+ (tmp_path / "package.json").write_text(
+ json.dumps({"scripts": {"build": "vite build", "dev": "vite"}})
+ )
+ pm = build_project_map(tmp_path)
+ assert "npm run build" in pm.entry_points
+ assert "npm run dev" in pm.entry_points
+
+
+def test_malformed_package_json_is_reported_not_swallowed(tmp_path, caplog):
+ (tmp_path / "package.json").write_text("{ not json")
+ with caplog.at_level("WARNING"):
+ pm = build_project_map(tmp_path)
+ assert pm.entry_points == []
+ assert any("not readable JSON" in r.message for r in caplog.records)
+
+
+# ── binaries: one probe, extended ─────────────────────────────────────────
+
+
+def test_dev_probe_extends_rather_than_duplicates_the_app_probe():
+ """The extension is a superset in kind, not a second mechanism."""
+ assert set(CLI_TOOL_PROBES) - set(DEV_TOOL_PROBES) <= {"code", "cursor", "brew"} + assert len(DEV_TOOL_PROBES)> len(CLI_TOOL_PROBES)
+
+
+def test_probe_binaries_agrees_with_which():
+ import shutil
+
+ probed = probe_binaries(["python", "definitely-not-a-real-binary-xyz"])
+ assert probed["python"] == (shutil.which("python") is not None)
+ assert probed["definitely-not-a-real-binary-xyz"] is False
+
+
+def test_map_names_absent_commands_so_the_agent_does_not_try_them(repo, monkeypatch):
+ monkeypatch.setenv("PATH", "")
+ clear_project_map_cache()
+ pm = build_project_map(repo)
+ assert pm.tools_present == []
+ assert pm.shell_commands == []
+ assert set(pm.tools_absent) == set(DEV_TOOL_PROBES)
+ assert "NOT installed" in render_project_map(pm)
+
+
+def test_shell_allowlist_is_not_conflated_with_what_is_installed(repo):
+ """Claiming run_shell_command accepts ``uv`` causes the very refusal
+ this map exists to prevent."""
+ from gaia.agents.tools.shell_tools import ALLOWED_COMMANDS
+
+ pm = build_project_map(repo)
+ assert set(pm.shell_commands) <= ALLOWED_COMMANDS + off_limits = set(pm.tools_present) - ALLOWED_COMMANDS + accepts = next( + (ln for ln in render_project_map(pm).splitlines() if "accepts:" in ln), "" + ) + named = set(re.findall(r"[\w.-]+", accepts.partition(":")[2])) + assert not (named & off_limits) + + +# ── the budget, on the 32K profile ──────────────────────────────────────── + + +def test_budget_is_a_small_fraction_of_the_npu_window(): + assert PROJECT_MAP_TOKEN_BUDGET == 600 + assert PROJECT_MAP_TOKEN_BUDGET < NPU_CTX_SIZE * 0.02 + + +def test_render_stays_within_budget_on_a_realistic_repo(repo): + assert count_tokens(render_project_map(build_project_map(repo))) <= ( + PROJECT_MAP_TOKEN_BUDGET + ) + + +def test_render_stays_within_budget_on_a_pathological_repo(tmp_path): + """200 top-level directories, each with 20 children, must still fit.""" + (tmp_path / ".git").mkdir() + for i in range(200): + d = tmp_path / f"package_with_a_long_name_{i:03d}" + d.mkdir() + for j in range(20): + (d / f"submodule_{j:02d}").mkdir() + pm = build_project_map(tmp_path) + assert count_tokens(render_project_map(pm)) <= PROJECT_MAP_TOKEN_BUDGET + + +def test_high_priority_sections_survive_truncation(tmp_path): + """Quirks outrank the directory listing when the budget bites.""" + (tmp_path / ".git").mkdir() + for i in range(200): + (tmp_path / f"dir_{i:03d}").mkdir() + text = render_project_map(build_project_map(tmp_path)) + assert "Shell dialect" in text + assert "PROJECT MAP" in text + + +def test_budget_is_honoured_when_the_caller_lowers_it(repo): + text = render_project_map(build_project_map(repo), token_budget=40) + assert count_tokens(text) <= 40 + assert "PROJECT MAP" in text + + +# ── caching ─────────────────────────────────────────────────────────────── + + +def test_map_is_cached_between_calls(repo): + assert build_project_map(repo) is build_project_map(repo) + + +def test_cache_invalidates_when_a_directory_appears(repo): + first = build_project_map(repo) + (repo / "docs").mkdir() + second = build_project_map(repo) + assert second is not first + assert "docs" in second.top_level_dirs + + +def test_cache_invalidates_when_a_manifest_changes(repo): + first = build_project_map(repo) + (repo / "pyproject.toml").write_text("[project]\nname='demo'\nversion='2'\n") + assert build_project_map(repo) is not first + + +def test_cache_invalidates_when_path_changes(repo, monkeypatch): + first = build_project_map(repo) + monkeypatch.setenv("PATH", "/some/other/place") + assert build_project_map(repo) is not first + + +# ── root resolution ─────────────────────────────────────────────────────── + + +def test_explicit_root_wins(repo, monkeypatch): + monkeypatch.setenv(PROJECT_ROOT_ENV, str(repo.parent)) + assert resolve_project_root(str(repo)) == str(repo.resolve()) + + +def test_env_root_is_used_when_no_explicit_root(repo, monkeypatch): + monkeypatch.setenv(PROJECT_ROOT_ENV, str(repo)) + assert resolve_project_root() == str(repo.resolve()) + + +def test_a_nonexistent_root_fails_loudly(tmp_path): + missing = tmp_path / "nope" + with pytest.raises(ValueError, match="not a directory"): + resolve_project_root(str(missing)) + + +def test_cwd_resolves_when_it_is_a_repository(repo, monkeypatch): + monkeypatch.delenv(PROJECT_ROOT_ENV, raising=False) + monkeypatch.chdir(repo) + assert resolve_project_root() == str(repo.resolve()) + + +def test_a_subdirectory_resolves_to_the_repository_above_it(repo, monkeypatch): + monkeypatch.delenv(PROJECT_ROOT_ENV, raising=False) + monkeypatch.chdir(repo / "src" / "demo") + assert resolve_project_root() == str(repo.resolve()) + + +def test_no_root_outside_a_repository(tmp_path, monkeypatch): + monkeypatch.delenv(PROJECT_ROOT_ENV, raising=False) + plain = tmp_path / "just" / "files" + plain.mkdir(parents=True) + monkeypatch.chdir(plain) + assert resolve_project_root() is None + + +# ── the mixin: prompt injection and the index trigger ───────────────────── + + +class _FakeSDK: + def __init__(self, indexed: bool): + self._indexed = indexed + self.status_calls = 0 + + def is_indexed(self): + return self._indexed + + def get_status(self): + self.status_calls += 1 + return {"indexed": self._indexed} + + +class _Base: + """Stands in for ``Agent``: a no-op ``_on_task_start`` that ends the chain.""" + + def _on_task_start(self, user_input: str) -> None:
+ pass
+
+
+class _FakeAgent(ProjectMapMixin, _Base):
+ def __init__(self, root, indexed=False, auto_index=True):
+ self.config = type(
+ "C", (), {"project_root": str(root), "auto_index": auto_index}
+ )()
+ self.sdk = _FakeSDK(indexed)
+ self.index_calls = []
+ self._tools_registry = {
+ "index_codebase": {"function": lambda **kw: self.index_calls.append(kw)},
+ "run_shell_command": {"function": lambda **kw: None},
+ }
+
+ def _get_code_index_sdk(self):
+ return self.sdk
+
+
+def _join(agent):
+ """Run the background index thread to completion."""
+ for t in threading.enumerate():
+ if t.name == "gaia-project-map-index":
+ t.join(timeout=10)
+ return agent.index_calls
+
+
+def test_prompt_fragment_is_the_rendered_map(repo):
+ text = _FakeAgent(repo).get_project_map_system_prompt()
+ assert text.startswith("==== PROJECT MAP ====")
+ assert str(repo.resolve()) in text
+ assert count_tokens(text) <= PROJECT_MAP_TOKEN_BUDGET + + +def test_prompt_fragment_is_empty_outside_a_project(tmp_path, monkeypatch): + monkeypatch.delenv(PROJECT_ROOT_ENV, raising=False) + plain = tmp_path / "docs-only" + plain.mkdir() + monkeypatch.chdir(plain) + agent = _FakeAgent(plain) + agent.config.project_root = None + assert agent.get_project_map_system_prompt() == "" + + +def test_index_trigger_fires_for_an_unindexed_repository(repo): + agent = _FakeAgent(repo, indexed=False) + agent._on_task_start("do a thing") + assert _join(agent) == [{}] + + +def test_prompt_says_building_while_the_index_is_running(repo): + gate = threading.Event() + agent = _FakeAgent(repo, indexed=False) + agent._tools_registry["index_codebase"]["function"] = lambda **kw: gate.wait(10) + agent._on_task_start("do a thing") + try: + text = agent.get_project_map_system_prompt() + finally: + gate.set() + _join(agent) + assert "building now in the background" in text + + +def test_prompt_reports_a_failed_index_instead_of_waiting_forever(repo): + """A dead background index must not read as "still building" all session.""" + + def _boom(**_kw): + raise RuntimeError("faiss exploded") + + agent = _FakeAgent(repo, indexed=False) + agent._tools_registry["index_codebase"]["function"] = _boom + agent._on_task_start("do a thing") + _join(agent) + assert "build FAILED" in agent.get_project_map_system_prompt() + + +def test_a_json_error_from_the_tool_counts_as_a_failure(repo, caplog): + """``index_codebase`` reports refusals as JSON, not by raising.""" + agent = _FakeAgent(repo, indexed=False) + agent._tools_registry["index_codebase"]["function"] = lambda **kw: json.dumps( + {"error": "refused: home directory"} + ) + with caplog.at_level("ERROR"): + agent._on_task_start("do a thing") + _join(agent) + assert "build FAILED" in agent.get_project_map_system_prompt() + assert any("refused: home directory" in r.getMessage() for r in caplog.records) + + +def test_index_status_is_a_presence_check_not_a_metadata_parse(repo): + """``get_status`` parses every indexed chunk; the prompt renders every turn.""" + agent = _FakeAgent(repo, indexed=True) + for _ in range(3): + agent.get_project_map_system_prompt() + assert agent.sdk.status_calls == 0 + + +def test_index_trigger_is_skipped_when_already_indexed(repo): + agent = _FakeAgent(repo, indexed=True) + agent._on_task_start("do a thing") + assert _join(agent) == [] + assert "search_code_index" in agent.get_project_map_system_prompt() + + +def test_index_trigger_fires_at_most_once(repo): + agent = _FakeAgent(repo, indexed=False) + for _ in range(3): + agent._on_task_start("again") + assert len(_join(agent)) == 1 + + +def test_index_trigger_respects_the_config_off_switch(repo): + agent = _FakeAgent(repo, indexed=False, auto_index=False) + agent._on_task_start("do a thing") + assert _join(agent) == [] + + +def test_index_trigger_respects_the_env_off_switch(repo, monkeypatch): + monkeypatch.setenv("GAIA_PROJECT_MAP_AUTO_INDEX", "0") + agent = _FakeAgent(repo, indexed=False) + agent._on_task_start("do a thing") + assert _join(agent) == [] + + +def test_index_trigger_skipped_for_a_non_repository(tmp_path): + plain = tmp_path / "plain" + plain.mkdir() + agent = _FakeAgent(plain, indexed=False) + agent._on_task_start("do a thing") + assert _join(agent) == [] + + +def test_background_index_failure_is_logged_not_swallowed(repo, caplog): + def _boom(**_kw): + raise RuntimeError("faiss exploded") + + agent = _FakeAgent(repo, indexed=False) + agent._tools_registry["index_codebase"]["function"] = _boom + with caplog.at_level("ERROR"): + agent._on_task_start("do a thing") + _join(agent) + assert any("faiss exploded" in r.getMessage() for r in caplog.records) + + +# ── wiring, checked against the source ──────────────────────────────────── +# +# ``gaia_agent`` resolves through an editable install that can point at a +# different checkout, so importing ``GaiaAgent`` here would assert against the +# wrong tree. These parse this repository's files instead. + +_REPO = pathlib.Path(__file__).resolve().parents[2] + + +def _bases_of(path: pathlib.Path, class_name: str) -> list:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
+ return [b.id for b in node.bases if isinstance(b, ast.Name)]
+ raise AssertionError(f"class {class_name!r} not found in {path}")
+
+
+def test_project_map_mixin_precedes_the_base_agent_in_gaia_agent():
+ """Listed after ChatAgent, ``Agent``'s no-op hook would shadow the override."""
+ bases = _bases_of(
+ _REPO / "hub" / "agents" / "gaia" / "python" / "gaia_agent" / "agent.py",
+ "GaiaAgent",
+ )
+ assert bases[0] == "ProjectMapMixin"
+ assert "ChatAgent" in bases[1:]
+
+
+def test_base_agent_calls_the_task_start_hook_before_composing_the_prompt():
+ src = (_REPO / "src" / "gaia" / "agents" / "base" / "agent.py").read_text(
+ encoding="utf-8"
+ )
+ tree = ast.parse(src)
+ impl = next(
+ n
+ for n in ast.walk(tree)
+ if isinstance(n, ast.FunctionDef) and n.name == "_process_query_impl"
+ )
+ called = [
+ n.func.attr
+ for n in ast.walk(impl)
+ if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
+ ]
+ assert "_on_task_start" in called
+ assert called.index("_on_task_start") < called.index("_refresh_active_tool_filter")
+
+
+def test_base_agent_hook_is_a_no_op_so_agents_without_the_mixin_are_unaffected():
+ from gaia.agents.base.agent import Agent
+
+ assert Agent._on_task_start(object(), "anything") is None
+
+
+def test_the_agents_own_source_tree_is_never_the_project(monkeypatch):
+ """The dev-mode sidecar's cwd is the GAIA checkout — not the user's work."""
+ import gaia
+
+ gaia_repo = pathlib.Path(gaia.__file__).resolve().parents[2]
+ assert is_agent_own_source(gaia_repo)
+ assert is_code_repository(gaia_repo), "precondition: it looks like a project"
+
+ monkeypatch.delenv(PROJECT_ROOT_ENV, raising=False)
+ monkeypatch.chdir(gaia_repo)
+ assert resolve_project_root() is None
+
+
+def test_an_explicit_root_may_point_at_gaia_itself(monkeypatch):
+ """Working on GAIA is legitimate — it just has to be asked for."""
+ import gaia
+
+ gaia_repo = pathlib.Path(gaia.__file__).resolve().parents[2]
+ monkeypatch.delenv(PROJECT_ROOT_ENV, raising=False)
+ assert resolve_project_root(str(gaia_repo)) == str(gaia_repo)
+
+
+def test_shell_commands_are_omitted_for_an_agent_without_the_shell_tool(repo):
+ text = render_project_map(build_project_map(repo), has_shell_tool=False)
+ assert "run_shell_command" not in text
+ assert "NOT installed" in text
+
+
+def test_a_wrong_base_order_fails_at_class_definition():
+ """Silent otherwise: the prompt fragment renders either way."""
+ from gaia.agents.base.agent import Agent
+
+ with pytest.raises(TypeError, match="ProjectMapMixin after Agent"):
+
+ class _Wrong(Agent, ProjectMapMixin):
+ pass