"""EnvironmentContext: surface the working environment and project docs to the model.
Before each user turn an internal ``ContextInjector`` re-surfaces a small block: the platform and
current date (the date is why this is injected rather than baked into the system prompt — a value
that changes each turn would bust the cached system-prompt prefix, whereas an ephemeral injection
lands after it and leaves the cache warm), the working directory, the contents of the project's
``AGENTS.md``, and links to other ``AGENTS.md`` / ``README.md`` files found a couple of levels down
(links, not contents, so the block stays small — the agent reads them on demand).
Everything is read through the agent's ``sandbox`` seam (the same one the file tools use), so it works
against a local, Docker, or SSH sandbox. Discovery (the directory walk and cwd) is done once and
memoized per agent; only the date is recomputed each turn, so the per-turn cost is negligible.
"""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING, Any
from strands.plugins import Plugin
from strands.vended_plugins.context_injector import ContextInjector, InjectionContext
if TYPE_CHECKING:
from strands import Agent
_DEFAULT_NAME = "strands:environment"
# How many directory levels below the working directory to scan for nearby AGENTS.md / README.md.
_DISCOVERY_DEPTH = 2
# AGENTS.md is injected in full up to this size; longer files are truncated with a marker so a large
# doc can't dominate every turn's context (the agent can still ``read`` the whole file).
_AGENTS_MD_CAP = 16_000
# Directories skipped during discovery: dependency/build/VCS trees that hold no project AGENTS.md and
# would make the walk slow and the link list noisy. Hidden directories (``.`` prefix) are skipped too.
_SKIP_DIRS = frozenset({"node_modules", "dist", "build", "target", "__pycache__", "venv", "site-packages", "vendor"})
# Filenames collected during discovery and surfaced as nearby links.
_DISCOVERED_FILES = ("AGENTS.md", "README.md")
class EnvironmentContext(Plugin):
"""Injects working-environment and project-doc context before each user turn.
Reads through the agent's ``sandbox``, so it honors whatever sandbox the agent runs against.
Sharing one instance across agents is safe: the captured agent and memoized discovery are scoped
per ``init_agent`` call, not stored on the plugin.
Args:
name: Plugin name, for logging and duplicate detection. Defaults to ``"strands:environment"``.
"""
def __init__(self, *, name: str = _DEFAULT_NAME) -> None:
self._name = name
super().__init__()
@property
def name(self) -> str:
return self._name
def init_agent(self, agent: Agent) -> None:
"""Register the environment-context injector, scoped to this agent."""
memo: dict[str, Any] = {}
async def render(_context: InjectionContext) -> str | None:
return await _render(agent, memo)
ContextInjector(render, name=f"{self._name}:injector", trigger="userTurn").init_agent(agent)
async def _render(agent: Agent, memo: dict[str, Any]) -> str | None:
if "gathered" not in memo:
memo["gathered"] = await _gather(agent)
platform, cwd, agents_md, other_agents, readmes = memo["gathered"]
env = []
if platform:
env.append(f"Platform: {platform}")
env.append(f"Date: {date.today().isoformat()}")
if cwd:
env.append(f"Working directory: {cwd}")
sections = ["\n" + "\n".join(env) + "\n"]
if agents_md:
sections.append(f"\n{agents_md}\n")
if other_agents:
sections.append("Other AGENTS.md files nearby (read as needed): " + ", ".join(other_agents))
if readmes:
sections.append("README files nearby (read as needed): " + ", ".join(readmes))
return "\n" + "\n\n".join(sections) + "\n"
async def _gather(agent: Agent) -> tuple[str | None, str | None, str | None, list[str], list[str]]:
"""The one-time, memoized part: platform, working directory, cwd ``AGENTS.md``, and nearby links.
Platform and cwd are read from the sandbox (via ``uname``/``pwd``), not the host process, so they
describe where the agent actually runs — a Docker or SSH sandbox, not the machine hosting it.
"""
try:
sandbox = agent.sandbox
except Exception:
# Defensive: normally a default local sandbox is present, but if the getter has none to fall
# back to, still surface the date block without probing the environment.
return None, None, None, [], []
platform = await _probe(sandbox, "uname -s")
cwd = await _probe(sandbox, "pwd")
agents_md = await _read_text(sandbox, "AGENTS.md")
found = await _discover(sandbox)
# The working-directory AGENTS.md is shown in full above; keep only the nested ones as links.
other_agents = [p for p in found["AGENTS.md"] if p != "AGENTS.md"]
return platform, cwd, agents_md, other_agents, found["README.md"]
async def _probe(sandbox: Any, command: str) -> str | None:
"""Run a one-line probe command in the sandbox; return its first stdout line, or ``None``."""
try:
result = await sandbox.execute(command)
except Exception:
return None
if result.exit_code == 0 and result.stdout.strip():
return result.stdout.strip().splitlines()[0]
return None
async def _read_text(sandbox: Any, path: str) -> str | None:
try:
text = await sandbox.read_text(path)
except Exception:
return None
if len(text) > _AGENTS_MD_CAP:
return text[:_AGENTS_MD_CAP] + "\n… (truncated — read the full file if you need the rest)"
return text
async def _discover(sandbox: Any) -> dict[str, list[str]]:
"""Walk up to ``_DISCOVERY_DEPTH`` levels from the working directory, collecting the paths of each
``_DISCOVERED_FILES`` name (skipping dependency/build/hidden directories)."""
found: dict[str, list[str]] = {name: [] for name in _DISCOVERED_FILES}
async def visit(rel: str, depth: int) -> None:
try:
entries = await sandbox.list_files(rel or ".")
except Exception:
return
for entry in entries:
path = f"{rel}/{entry.name}" if rel else entry.name
if entry.is_dir:
if depth < _DISCOVERY_DEPTH and entry.name not in _SKIP_DIRS and not entry.name.startswith("."):
await visit(path, depth + 1)
elif entry.name in found:
found[entry.name].append(path)
await visit("", 0)
return found