Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions amplifier_foundation/bundle/_prepared.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ def _create_system_prompt_factory(
from amplifier_foundation.mentions import ContentDeduplicator
from amplifier_foundation.mentions import format_context_block
from amplifier_foundation.mentions import load_mentions
from amplifier_foundation.mentions import load_mentions_from_file

# Capture state for the closure
captured_bundle = bundle
Expand Down Expand Up @@ -486,9 +487,7 @@ async def factory() -> str:
# Add to deduplicator and mention_to_path for unified formatting
for context_name, context_path in captured_bundle.context.items():
if context_path.exists():
content = context_path.read_text(encoding="utf-8")
# Add to deduplicator for content-based deduplication
deduplicator.add_file(context_path, content)
await load_mentions_from_file(context_path, resolver, deduplicator)
# Add to mention_to_path for attribution (context_name → path)
mention_to_path[context_name] = context_path

Expand Down
30 changes: 17 additions & 13 deletions amplifier_foundation/mentions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
"""@mention parsing and loading utilities."""

from .deduplicator import ContentDeduplicator
from .loader import expand_mentions_in_instruction
from .loader import format_context_block
from .loader import load_mentions
from .models import ContextFile
from .models import MentionResult
from .loader import (
expand_mentions_in_instruction,
format_context_block,
load_mentions,
load_mentions_from_file,
)
from .models import ContextFile, MentionResult
from .parser import parse_mentions
from .protocol import MentionResolverProtocol
from .protocol import MentionResolverProtocol, RelativeMentionResolverProtocol
from .resolver import BaseMentionResolver
from .utils import format_directory_listing

__all__ = [
"parse_mentions",
"load_mentions",
"expand_mentions_in_instruction",
"format_context_block",
"format_directory_listing",
"BaseMentionResolver",
"ContentDeduplicator",
"ContextFile",
"MentionResult",
"MentionResolverProtocol",
"BaseMentionResolver",
"MentionResult",
"RelativeMentionResolverProtocol",
"expand_mentions_in_instruction",
"format_context_block",
"format_directory_listing",
"load_mentions",
"load_mentions_from_file",
"parse_mentions",
]
85 changes: 73 additions & 12 deletions amplifier_foundation/mentions/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from .deduplicator import ContentDeduplicator
from .models import MentionResult
from .parser import parse_mentions
from .protocol import MentionResolverProtocol
from .protocol import MentionResolverProtocol, RelativeMentionResolverProtocol
from .utils import format_directory_listing


Expand Down Expand Up @@ -91,7 +91,10 @@ async def load_mentions(
text: Text containing @mentions.
resolver: Resolver to convert mentions to paths.
deduplicator: Optional deduplicator for content. If None, creates one.
relative_to: Base path for relative mentions (defaults to cwd).
relative_to: Base path for local mentions (defaults to the resolver's
base). Nested explicit ./ and ../ mentions use the referring file's
directory with RelativeMentionResolverProtocol; bare local mentions
retain the resolver's base.
max_depth: Maximum recursion depth to prevent infinite loops (default 3).

Returns:
Expand All @@ -101,6 +104,7 @@ async def load_mentions(
deduplicator = ContentDeduplicator()

results: list[MentionResult] = []
visited_paths: set[Path] = set()
mentions = parse_mentions(text)

for mention in mentions:
Expand All @@ -111,6 +115,7 @@ async def load_mentions(
relative_to=relative_to,
max_depth=max_depth,
current_depth=0,
visited_paths=visited_paths,
)
results.append(result)

Expand Down Expand Up @@ -138,7 +143,10 @@ async def expand_mentions_in_instruction(
instruction: The text to expand. May contain @mention tokens.
resolver: Resolver to convert @mentions to file paths.
deduplicator: Optional deduplicator for content. If None, creates a fresh one.
relative_to: Base path for relative mentions (defaults to cwd).
relative_to: Base path for local mentions (defaults to the resolver's
base). Nested explicit ./ and ../ mentions use the referring file's
directory with RelativeMentionResolverProtocol; bare local mentions
retain the resolver's base.

Returns:
Instruction with <context_file> blocks prepended, or the original instruction
Expand Down Expand Up @@ -172,17 +180,50 @@ async def expand_mentions_in_instruction(
return f"{block}\n\n{instruction}"


async def load_mentions_from_file(
path: Path,
resolver: MentionResolverProtocol,
deduplicator: ContentDeduplicator | None = None,
max_depth: int = 3,
) -> MentionResult:
"""Load a declared context file and its nested mentions using the same rules.

Taking a Path avoids reparsing a known filename as mention syntax (including
spaces or Windows drive letters). Explicit relative references use this
file's directory; bare references retain the resolver's workspace root.
"""
return await _load_file(
mention=str(path),
path=path,
resolver=resolver,
deduplicator=deduplicator
if deduplicator is not None
else ContentDeduplicator(),
max_depth=max_depth,
current_depth=0,
visited_paths=set(),
)


async def _resolve_mention(
mention: str,
resolver: MentionResolverProtocol,
deduplicator: ContentDeduplicator,
relative_to: Path | None,
max_depth: int,
current_depth: int,
visited_paths: set[Path],
) -> MentionResult:
"""Resolve a single mention and recursively load its mentions."""
# Resolve mention to path
path = resolver.resolve(mention)
if (
relative_to is not None
and (current_depth == 0 or mention.startswith(("@./", "@../")))
and isinstance(resolver, RelativeMentionResolverProtocol)
):
path = resolver.resolve_relative(mention, relative_to)
else:
path = resolver.resolve(mention)
if path is None:
return MentionResult(
mention=mention,
Expand All @@ -192,6 +233,22 @@ async def _resolve_mention(
failure_reason="not_found",
)

return await _load_file(
mention, path, resolver, deduplicator, max_depth, current_depth, visited_paths
)


async def _load_file(
mention: str,
path: Path,
resolver: MentionResolverProtocol,
deduplicator: ContentDeduplicator,
max_depth: int,
current_depth: int,
visited_paths: set[Path],
) -> MentionResult:
"""Shared read/recursion path for resolved mentions and declared context."""

# Handle directories: generate listing as content
if path.is_dir():
try:
Expand Down Expand Up @@ -223,6 +280,15 @@ async def _resolve_mention(
failure_reason="not_found",
)

# Path identity stops cycles, while content identity deduplicates output.
# Identical files in different directories may reference different siblings.
canonical_path = path.resolve()
if canonical_path in visited_paths:
return MentionResult(
mention=mention, resolved_path=path, content=None, error=None
)
visited_paths.add(canonical_path)

# Read file
try:
content = await read_with_retry(path)
Expand All @@ -244,13 +310,7 @@ async def _resolve_mention(
)

# Check for duplicate content
if not deduplicator.add_file(path, content):
return MentionResult(
mention=mention,
resolved_path=path,
content=None, # Already seen, don't include again
error=None,
)
new_content = deduplicator.add_file(path, content)

# Recursively load mentions from this file (if not at max depth)
if current_depth < max_depth:
Expand All @@ -263,11 +323,12 @@ async def _resolve_mention(
relative_to=path.parent,
max_depth=max_depth,
current_depth=current_depth + 1,
visited_paths=visited_paths,
)

return MentionResult(
mention=mention,
resolved_path=path,
content=content,
content=content if new_content else None,
error=None,
)
16 changes: 15 additions & 1 deletion amplifier_foundation/mentions/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from pathlib import Path
from typing import Protocol
from typing import Protocol, runtime_checkable


class MentionResolverProtocol(Protocol):
Expand All @@ -23,3 +23,17 @@ def resolve(self, mention: str) -> Path | None:
Path to the resolved file, or None if not found.
"""
...


@runtime_checkable
class RelativeMentionResolverProtocol(Protocol):
"""Optional per-call context for resolvers used by the recursive loader.

Legacy ``resolve(mention)`` implementations remain supported. Implement this
extension to anchor local paths to the referring file without changing the
resolver's workspace, namespace roots, or state between calls.
"""

def resolve_relative(self, mention: str, relative_to: Path) -> Path | None:
"""Resolve local paths relative to ``relative_to``; retain shortcut roots."""
...
13 changes: 12 additions & 1 deletion amplifier_foundation/mentions/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from copy import copy
from pathlib import Path
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -51,7 +52,7 @@ def resolve(self, mention: str) -> Path | None:
mention_body = mention[1:] # Remove @ prefix

# Pattern 1: @bundle-name:context-name
if ":" in mention_body:
if ":" in mention_body and not Path(mention_body).is_absolute():
namespace, name = mention_body.split(":", 1)
if bundle := self.bundles.get(namespace):
return bundle.resolve_context_path(name)
Expand Down Expand Up @@ -84,3 +85,13 @@ def register_bundle(self, name: str, bundle: Bundle) -> None:
bundle: Bundle instance.
"""
self.bundles[name] = bundle

def resolve_relative(self, mention: str, relative_to: Path) -> Path | None:
"""Resolve local mentions from a referring file, without shared mutation.

Use ``resolve`` on a scoped copy so subclasses retain their resolution
policy. Home paths and bundle namespaces keep their explicit roots.
"""
scoped = copy(self)
scoped.base_path = relative_to
return scoped.resolve(mention)
23 changes: 23 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,26 @@ from amplifier_foundation import load_mentions, BaseMentionResolver
resolver = BaseMentionResolver(bundles={"foundation": foundation_bundle})
results = await load_mentions("See @foundation:context/guidelines.md", resolver)
```

Local mentions in the initial text use the resolver's base directory, or the
explicit `relative_to` passed to `load_mentions`. Inside an included file, explicit
relative mentions (`@./journal.md` or `@../rules.md`) use that file's directory.
Bare local mentions such as `@AGENTS.md` retain the resolver's workspace root;
this preserves bundles that intentionally include the current project's rules.
Bundle namespaces and explicit home/absolute paths retain their own roots.
The resolver is never mutated while loading a nested file. Content is included
once, but identical instruction files in different directories still load their
own relative references. Missing files remain optional; recursion depth and
canonical-path cycle detection bound traversal.

Bundle-declared `context:` files use the same recursive loading rules.
`load_mentions_from_file(path, resolver, deduplicator)` exposes that path-based
entry point without reparsing filenames as mention syntax. This affects context
assembly, not ordinary file-tool results or attachments, whose content remains
literal.

Custom resolvers keep the existing `resolve(mention)` contract. To support
per-file relative resolution, also implement the optional
`RelativeMentionResolverProtocol.resolve_relative(mention, relative_to)` method.
App shortcuts such as `@user:` and `@project:` should retain their configured
roots. Legacy resolvers without that method continue to resolve exactly as before.
Loading
Loading