Skip to content
Open
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
69 changes: 64 additions & 5 deletions src/fast_agent/core/fastagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ def __init__(
self._agent_card_file_cache: dict[Path, tuple[int, int]] = {}
self._agent_card_name_by_path: dict[Path, str] = {}
self._agent_card_histories: dict[str, list[Path]] = {}
self._agent_card_history_mtime: dict[str, float] = {}
self._agent_card_history_len: dict[str, int] = {}
self._agent_card_tool_files: dict[Path, set[Path]] = {}
self._agent_card_last_changed: set[str] = set()
self._agent_card_last_removed: set[str] = set()
Expand Down Expand Up @@ -697,7 +699,23 @@ def _load_cards(path_entry: Path) -> list[LoadedAgentCard]:
for card_path in current_card_files:
current_tool_files.update(self._agent_card_tool_files.get(card_path, set()))

watch_files = set(current_card_files) | current_tool_files
current_history_files: set[Path] = set()
for history_files in self._agent_card_histories.values():
for history_file in history_files:
try:
if history_file.is_relative_to(root):
current_history_files.add(history_file)
Comment on lines +703 to +707
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Watch history files for single-card roots

When load_agents is called with a file path (supported by _collect_agent_card_files), root is the card file itself, so history_file.is_relative_to(root) is false for history files in the same directory. That means history files are never added to the watch set for single-card roots, so edits to those message files won’t trigger reload/refresh even though this change intends to watch them. Consider using root.parent when root is a file, or otherwise normalizing the watch root to a directory so history files are included.

Useful? React with 👍 / 👎.

except ValueError:
continue
for card in cards:
for history_file in card.message_files or []:
try:
if history_file.is_relative_to(root):
current_history_files.add(history_file)
except ValueError:
continue

watch_files = set(current_card_files) | current_tool_files | current_history_files
previous_watch_files = self._agent_card_root_watch_files.get(root, set())
removed_watch_files = previous_watch_files - watch_files

Expand Down Expand Up @@ -910,6 +928,8 @@ def _is_tool_card_path(path: Path) -> bool:
self.agents.pop(name, None)
self._agent_card_sources.pop(name, None)
self._agent_card_histories.pop(name, None)
self._agent_card_history_mtime.pop(name, None)
self._agent_card_history_len.pop(name, None)

for path_entry in removed_files:
self._agent_card_name_by_path.pop(path_entry, None)
Expand All @@ -925,6 +945,8 @@ def _is_tool_card_path(path: Path) -> bool:
self._agent_card_histories[card.name] = card.message_files
else:
self._agent_card_histories.pop(card.name, None)
self._agent_card_history_mtime.pop(card.name, None)
self._agent_card_history_len.pop(card.name, None)

if removed_names:
removed_set = set(removed_names)
Expand Down Expand Up @@ -1287,17 +1309,37 @@ async def refresh_shared_instance() -> bool:
for msg in history
]
new_agent.message_history.extend(copied_history)
existing_mtime = self._agent_card_history_mtime.get(name)
self._record_history_snapshot(
name, len(new_agent.message_history), existing_mtime
)
for name, new_agent in updated_agents.items():
if new_agent.message_history:
continue
history_files = self._agent_card_histories.get(name)
if not history_files:
continue
files_mtime = self._get_history_files_mtime(history_files)
if files_mtime is None:
continue
last_mtime = self._agent_card_history_mtime.get(name)
last_len = self._agent_card_history_len.get(name)
current_len = len(new_agent.message_history)
if last_mtime is None:
if current_len != 0:
continue
elif files_mtime <= last_mtime:
continue
elif last_len is not None and current_len != last_len:
continue
messages: list[PromptMessageExtended] = []
for history_file in history_files:
messages.extend(load_prompt(history_file))
if messages:
new_agent.message_history.extend(messages)
if not messages:
continue
new_agent.message_history.clear()
new_agent.message_history.extend(messages)
self._record_history_snapshot(
name, len(new_agent.message_history), files_mtime
)
validate_provider_keys_post_creation(updated_agents)

if global_prompt_context:
Expand Down Expand Up @@ -1675,6 +1717,21 @@ async def _apply_instruction_context(
"""Resolve late-binding placeholders for all agents in the provided instance."""
await apply_instruction_context(instance.agents.values(), context_vars)

@staticmethod
def _get_history_files_mtime(history_files: Sequence[Path]) -> float | None:
mtimes: list[float] = []
for history_file in history_files:
try:
mtimes.append(history_file.stat().st_mtime)
except OSError:
continue
return max(mtimes) if mtimes else None

def _record_history_snapshot(self, name: str, history_len: int, mtime: float | None) -> None:
self._agent_card_history_len[name] = history_len
if mtime is not None:
self._agent_card_history_mtime[name] = mtime

def _apply_agent_card_histories(self, agents: dict[str, "AgentProtocol"]) -> None:
if not self._agent_card_histories:
return
Expand All @@ -1687,6 +1744,8 @@ def _apply_agent_card_histories(self, agents: dict[str, "AgentProtocol"]) -> Non
messages.extend(load_prompt(history_file))
agent.clear(clear_prompts=True)
agent.message_history.extend(messages)
mtime = self._get_history_files_mtime(history_files)
self._record_history_snapshot(name, len(messages), mtime)

def _handle_dump_requests(self) -> None:
dump_dir = getattr(self.args, "dump_agents", None)
Expand Down
52 changes: 52 additions & 0 deletions tests/unit/fast_agent/core/test_agent_card_watch.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import json
import os
import time
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock

Expand All @@ -17,6 +20,7 @@ def _write_agent_card(
*,
name: str = "watcher",
function_tools: list[str] | None = None,
messages_file: str | None = None,
) -> None:
lines = [
"---",
Expand All @@ -26,6 +30,8 @@ def _write_agent_card(
if function_tools:
lines.append("function_tools:")
lines.extend([f" - {spec}" for spec in function_tools])
if messages_file:
lines.append(f"messages: {messages_file}")
lines.extend(
[
"---",
Expand All @@ -36,6 +42,11 @@ def _write_agent_card(
path.write_text("\n".join(lines), encoding="utf-8")


def _write_history_json(path: Path, text: str) -> None:
payload = {"messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


@pytest.mark.asyncio
async def test_reload_agents_detects_function_tool_change(tmp_path: Path) -> None:
config_path = tmp_path / "fastagent.config.yaml"
Expand Down Expand Up @@ -229,3 +240,44 @@ async def test_reload_agents_preserves_history(monkeypatch, tmp_path: Path) -> N
updated_agent = app["watcher"]
assert updated_agent.message_history
assert updated_agent.message_history[0].all_text() == "hello"


@pytest.mark.asyncio
async def test_reload_agents_updates_history_when_file_newer(monkeypatch, tmp_path: Path) -> None:
config_path = tmp_path / "fastagent.config.yaml"
config_path.write_text("", encoding="utf-8")

agents_dir = tmp_path / "agents"
agents_dir.mkdir()

history_path = agents_dir / "history.json"
_write_history_json(history_path, "first")
card_path = agents_dir / "watcher.md"
_write_agent_card(card_path, messages_file="history.json")

fast = FastAgent(
"watch-test",
config_path=str(config_path),
parse_cli_args=False,
quiet=True,
)
monkeypatch.setenv("OPENAI_API_KEY", "test")
fast.args.watch = True
fast.load_agents(agents_dir)

async with fast.run() as app:
agent = app["watcher"]
assert agent.message_history
assert agent.message_history[0].all_text() == "first"

_write_history_json(history_path, "second")
new_ts = time.time() + 2.0
os.utime(history_path, (new_ts, new_ts))

changed = await fast.reload_agents()
assert changed is True

await app.refresh_if_needed()
updated_agent = app["watcher"]
assert updated_agent.message_history
assert updated_agent.message_history[0].all_text() == "second"