From 8a74390f976d08954ff9a6ed1415a48084e0b98e Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:47:01 +0100 Subject: [PATCH] ACP Rough edges --- pyproject.toml | 2 +- src/fast_agent/acp/acp_context.py | 4 + .../commands/shared_command_intents.py | 8 +- src/fast_agent/hooks/session_history.py | 3 + src/fast_agent/llm/fastagent_llm.py | 25 +++--- src/fast_agent/session/session_manager.py | 4 + src/fast_agent/session/snapshot.py | 7 +- .../api/test_retry_error_channel.py | 16 ++++ .../commands/test_shared_command_intents.py | 8 ++ .../fast_agent/hooks/test_session_history.py | 5 ++ .../unit/fast_agent/session/test_snapshot.py | 77 +++++++++++++++++++ uv.lock | 2 +- 12 files changed, 146 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ddee6e3e1..d1718bea6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fast-agent-mcp" -version = "0.6.21" +version = "0.6.22" description = "Define, Prompt and Test MCP enabled Agents and Workflows" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/fast_agent/acp/acp_context.py b/src/fast_agent/acp/acp_context.py index 734872aa1..94ece06d0 100644 --- a/src/fast_agent/acp/acp_context.py +++ b/src/fast_agent/acp/acp_context.py @@ -412,6 +412,10 @@ def set_resolved_instructions(self, resolved_instructions: dict[str, str]) -> No """ self._resolved_instructions = resolved_instructions + def resolved_instructions_snapshot(self) -> dict[str, str]: + """Return a copy of the current session-resolved instruction cache.""" + return dict(self._resolved_instructions or {}) + # ========================================================================= # Slash Command Updates # ========================================================================= diff --git a/src/fast_agent/commands/shared_command_intents.py b/src/fast_agent/commands/shared_command_intents.py index 27e2284e0..c839e7e75 100644 --- a/src/fast_agent/commands/shared_command_intents.py +++ b/src/fast_agent/commands/shared_command_intents.py @@ -263,7 +263,7 @@ def _parse_export_argument( if token.startswith("-"): return None, None, None, None, None, False, f"Unknown export option: {token}" if target is None: - target = token + target = _normalize_export_target(token) index += 1 continue return None, None, None, None, None, False, f"Unexpected export argument: {token}" @@ -271,6 +271,12 @@ def _parse_export_argument( return target, agent_name, output_path, hf_dataset, hf_dataset_path, show_help, None +def _normalize_export_target(target: str) -> str: + if target.lower() == "latest": + return "latest" + return target + + def _split_export_tokens(argument: str) -> list[str]: tokens: list[str] = [] current: list[str] = [] diff --git a/src/fast_agent/hooks/session_history.py b/src/fast_agent/hooks/session_history.py index 2fa77232b..429a50a9d 100644 --- a/src/fast_agent/hooks/session_history.py +++ b/src/fast_agent/hooks/session_history.py @@ -52,6 +52,7 @@ async def save_session_history(ctx: "HookContext") -> None: session_cwd: Path | None = None session_store_scope: Literal["workspace", "app"] = "workspace" session_store_cwd: Path | None = None + resolved_prompts: dict[str, str] | None = None agent_context = ctx.context acp_context = agent_context.acp if agent_context else None if acp_context is not None: @@ -67,6 +68,7 @@ async def save_session_history(ctx: "HookContext") -> None: raw_session_store_cwd = acp_context.session_store_cwd if raw_session_store_cwd: session_store_cwd = Path(str(raw_session_store_cwd)).expanduser().resolve() + resolved_prompts = acp_context.resolved_instructions_snapshot() or None metadata: dict[str, object] = {"agent_name": ctx.agent_name} model_name = agent_config.model @@ -93,6 +95,7 @@ async def save_session_history(ctx: "HookContext") -> None: cast("AgentProtocol", history_agent), agent_registry=ctx.agent_registry, identity=identity, + resolved_prompts=resolved_prompts, ) except Exception as exc: logger.warning( diff --git a/src/fast_agent/llm/fastagent_llm.py b/src/fast_agent/llm/fastagent_llm.py index 8588e3781..867f4e03e 100644 --- a/src/fast_agent/llm/fastagent_llm.py +++ b/src/fast_agent/llm/fastagent_llm.py @@ -2,10 +2,12 @@ import inspect import json import os +import sys import time import traceback from abc import abstractmethod from collections.abc import Mapping +from contextlib import nullcontext from contextvars import ContextVar from typing import ( TYPE_CHECKING, @@ -28,7 +30,6 @@ from openai import NotGiven from openai.lib._parsing import type_to_response_format_param as _type_to_response_format from pydantic_core import from_json -from rich import print as rich_print from fast_agent.constants import ( CONTROL_MESSAGE_SAVE_HISTORY, @@ -78,6 +79,7 @@ from fast_agent.mcp.helpers.content_helpers import get_text from fast_agent.mcp.provider_management import ProviderManagedMCPState from fast_agent.types import PromptMessageExtended, RequestParams +from fast_agent.ui.console import error_console # Define type variables locally MessageParamT = TypeVar("MessageParamT") @@ -581,22 +583,23 @@ def _is_fatal_error(e: Exception) -> bool: print( "[webdebug] provider call failed " f"attempt={attempt + 1}/{retries + 1} " - f"error_type={type(e).__name__}" + f"error_type={type(e).__name__}", + file=sys.stderr, ) traceback.print_exception(type(e), e, e.__traceback__) - # Try to import progress_display safely try: from fast_agent.ui.progress_display import progress_display - - with progress_display.paused(): - rich_print(f"\n[yellow]▲ Provider Error: {str(e)[:300]}...[/yellow]") - rich_print( - f"[dim]⟳ Retrying in {wait_time}s... (Attempt {attempt + 1}/{retries})[/dim]" - ) except ImportError: - print(f"▲ Provider Error: {str(e)[:300]}...") - print(f"⟳ Retrying in {wait_time}s... (Attempt {attempt + 1}/{retries})") + paused_progress = nullcontext() + else: + paused_progress = progress_display.paused() + + with paused_progress: + error_console.print(f"\n[yellow]▲ Provider Error: {str(e)[:300]}...[/yellow]") + error_console.print( + f"[dim]⟳ Retrying in {wait_time}s... (Attempt {attempt + 1}/{retries})[/dim]" + ) await asyncio.sleep(wait_time) diff --git a/src/fast_agent/session/session_manager.py b/src/fast_agent/session/session_manager.py index be3f08aa7..4a3aa7d68 100644 --- a/src/fast_agent/session/session_manager.py +++ b/src/fast_agent/session/session_manager.py @@ -256,6 +256,7 @@ async def save_history( *, agent_registry: Mapping[str, AgentProtocol] | None = None, identity: "SessionSaveIdentity | None" = None, + resolved_prompts: Mapping[str, str] | None = None, ) -> str: """Save agent history to this session.""" from fast_agent.history.history_exporter import HistoryExporter @@ -318,6 +319,7 @@ async def save_history( active_agent=agent, agent_registry=agent_registry, identity=identity or self._default_save_identity(), + resolved_prompts=resolved_prompts, ) self._save_snapshot(snapshot) return result @@ -732,6 +734,7 @@ async def save_current_session( *, agent_registry: Mapping[str, AgentProtocol] | None = None, identity: "SessionSaveIdentity | None" = None, + resolved_prompts: Mapping[str, str] | None = None, ) -> str | None: """Save history to the current session.""" if identity is not None: @@ -766,6 +769,7 @@ async def save_current_session( filename, agent_registry=agent_registry, identity=identity, + resolved_prompts=resolved_prompts, ) def load_latest_session(self) -> Session | None: diff --git a/src/fast_agent/session/snapshot.py b/src/fast_agent/session/snapshot.py index 4d58b2681..596216063 100644 --- a/src/fast_agent/session/snapshot.py +++ b/src/fast_agent/session/snapshot.py @@ -290,6 +290,7 @@ def capture_session_snapshot( active_agent: "AgentProtocol", agent_registry: Mapping[str, "AgentProtocol"] | None, identity: "SessionSaveIdentity", + resolved_prompts: Mapping[str, str] | None = None, ) -> SessionSnapshot: """Capture the authoritative persisted snapshot for the current runtime state.""" snapshot = snapshot_from_session_info(session.info) @@ -312,6 +313,7 @@ def capture_session_snapshot( agent_registry=agent_registry, compatibility_snapshot=snapshot, existing_snapshot=existing_snapshot, + resolved_prompts=resolved_prompts, ) snapshot.analysis = SessionAnalysisSnapshot( usage_summary=_capture_usage_summary(active_agent), @@ -614,6 +616,7 @@ def _capture_agent_snapshots( agent_registry: Mapping[str, "AgentProtocol"] | None, compatibility_snapshot: SessionSnapshot, existing_snapshot: SessionSnapshot | None, + resolved_prompts: Mapping[str, str] | None, ) -> dict[str, SessionAgentSnapshot]: agents = dict(agent_registry or {}) agents[active_agent.name] = active_agent @@ -631,6 +634,7 @@ def _capture_agent_snapshots( agent=agent, compatibility_snapshot=compatibility_agents.get(agent_name), existing_snapshot=existing_agents.get(agent_name), + resolved_prompt=resolved_prompts.get(agent_name) if resolved_prompts is not None else None, ) return snapshots @@ -641,6 +645,7 @@ def _capture_agent_snapshot( agent: "AgentProtocol", compatibility_snapshot: SessionAgentSnapshot | None, existing_snapshot: SessionAgentSnapshot | None, + resolved_prompt: str | None, ) -> SessionAgentSnapshot: llm = agent.llm request_settings = _capture_request_settings_snapshot(agent) @@ -651,7 +656,7 @@ def _capture_agent_snapshot( compatibility_snapshot=compatibility_snapshot, existing_snapshot=existing_snapshot, ), - resolved_prompt=agent.instruction, + resolved_prompt=resolved_prompt if resolved_prompt is not None else agent.instruction, model=( llm.model_name if llm is not None and llm.model_name is not None diff --git a/tests/integration/api/test_retry_error_channel.py b/tests/integration/api/test_retry_error_channel.py index 3c7ab0de2..f3848f305 100644 --- a/tests/integration/api/test_retry_error_channel.py +++ b/tests/integration/api/test_retry_error_channel.py @@ -57,3 +57,19 @@ async def test_retry_attempts_and_backoff_are_configurable(): assert llm.attempts == 2 # initial + 1 retry assert response.stop_reason == LlmStopReason.ERROR + + +@pytest.mark.asyncio +async def test_retry_notices_are_emitted_on_stderr(capsys): + ctx = SimpleNamespace(executor=None, config=None) + llm = FailingOpenAILLM(context=ctx, name="fail-llm") + llm.retry_count = 1 + llm.retry_backoff_seconds = 0.01 + + await llm.generate([Prompt.user("hi")]) + + captured = capsys.readouterr() + assert "Provider Error" not in captured.out + assert "Retrying in" not in captured.out + assert "Provider Error" in captured.err + assert "Retrying in" in captured.err diff --git a/tests/unit/fast_agent/commands/test_shared_command_intents.py b/tests/unit/fast_agent/commands/test_shared_command_intents.py index 3d9f28d27..a2468b944 100644 --- a/tests/unit/fast_agent/commands/test_shared_command_intents.py +++ b/tests/unit/fast_agent/commands/test_shared_command_intents.py @@ -34,6 +34,14 @@ def test_parse_session_command_intent_parses_export_options() -> None: assert intent.export_error is None +def test_parse_session_command_intent_normalizes_latest_export_target() -> None: + intent = parse_session_command_intent("export LATEST") + + assert intent.action == "export" + assert intent.export_target == "latest" + assert intent.export_error is None + + def test_parse_session_command_intent_preserves_windows_export_paths() -> None: intent = parse_session_command_intent( r"export C:\tmp\session.json --output C:\tmp\trace.jsonl" diff --git a/tests/unit/fast_agent/hooks/test_session_history.py b/tests/unit/fast_agent/hooks/test_session_history.py index 432b29eb5..d2a2123ac 100644 --- a/tests/unit/fast_agent/hooks/test_session_history.py +++ b/tests/unit/fast_agent/hooks/test_session_history.py @@ -34,6 +34,7 @@ def __init__(self, label: str) -> None: self.current_session: _Session | None = None self.saved_agents: list[object] = [] self.saved_identities: list[SessionSaveIdentity | None] = [] + self.saved_resolved_prompts: list[dict[str, str] | None] = [] def get_session(self, name: str) -> object | None: del name @@ -56,10 +57,12 @@ async def save_current_session( *, agent_registry=None, identity: SessionSaveIdentity | None = None, + resolved_prompts: dict[str, str] | None = None, ) -> str: del filename, agent_registry self.saved_agents.append(agent) self.saved_identities.append(identity) + self.saved_resolved_prompts.append(resolved_prompts) return "history.json" @@ -124,6 +127,7 @@ async def fake_send_session_info_update(**kwargs: object) -> None: session_cwd=str(workspace.resolve()), session_store_scope="app", session_store_cwd=None, + resolved_instructions_snapshot=lambda: {"main": "Resolved ACP prompt"}, send_session_info_update=fake_send_session_info_update, ) history = [ @@ -155,4 +159,5 @@ async def fake_send_session_info_update(**kwargs: object) -> None: assert identity is not None assert identity.session_store_scope == "app" assert identity.session_cwd == workspace.resolve() + assert app_manager.saved_resolved_prompts == [{"main": "Resolved ACP prompt"}] assert session_info_updates == [{"updated_at": "2024-01-01T00:00:00"}] diff --git a/tests/unit/fast_agent/session/test_snapshot.py b/tests/unit/fast_agent/session/test_snapshot.py index 7ea8710da..c50c5fcd5 100644 --- a/tests/unit/fast_agent/session/test_snapshot.py +++ b/tests/unit/fast_agent/session/test_snapshot.py @@ -475,6 +475,45 @@ def test_capture_session_snapshot_preserves_existing_v2_fallback_values(tmp_path assert bar_snapshot.resolved_prompt == "persisted bar prompt" +def test_capture_session_snapshot_prefers_explicit_resolved_prompts(tmp_path: Path) -> None: + manager = SessionManager( + cwd=tmp_path, + environment_override=tmp_path / ".fast-agent", + respect_env_override=False, + ) + session = manager.create_session() + foo_agent = _Agent( + name="foo", + instruction="agent instruction foo", + config=AgentConfig("foo", instruction="template foo", model=None), + ) + bar_agent = _Agent( + name="bar", + instruction="agent instruction bar", + config=AgentConfig("bar", instruction="template bar", model=None), + ) + identity = SessionSaveIdentity( + manager=manager, + session=session, + created=False, + acp_session_id="acp-123", + session_cwd=tmp_path / "workspace", + session_store_scope="workspace", + session_store_cwd=tmp_path, + ) + + snapshot = capture_session_snapshot( + session=session, + active_agent=cast("AgentProtocol", foo_agent), + agent_registry=cast("dict[str, AgentProtocol]", {"foo": foo_agent, "bar": bar_agent}), + identity=identity, + resolved_prompts={"foo": "resolved foo from acp", "bar": "resolved bar from acp"}, + ) + + assert snapshot.continuation.agents["foo"].resolved_prompt == "resolved foo from acp" + assert snapshot.continuation.agents["bar"].resolved_prompt == "resolved bar from acp" + + @pytest.mark.asyncio async def test_save_history_writes_captured_snapshot_payload(tmp_path: Path) -> None: manager = SessionManager( @@ -530,6 +569,44 @@ async def test_save_history_writes_captured_snapshot_payload(tmp_path: Path) -> } +@pytest.mark.asyncio +async def test_save_history_persists_explicit_resolved_prompts(tmp_path: Path) -> None: + manager = SessionManager( + cwd=tmp_path, + environment_override=tmp_path / ".fast-agent", + respect_env_override=False, + ) + session = manager.create_session() + agent = _Agent( + name="main", + instruction="Template-like {{env}} prompt", + config=AgentConfig("main", instruction="Template prompt", model="passthrough"), + llm=_Llm( + model_name="passthrough", + provider_name="fast-agent", + request_params=RequestParams(maxTokens=123), + ), + message_history=[ + PromptMessageExtended( + role="user", + content=[TextContent(type="text", text="hello save path")], + ), + PromptMessageExtended( + role="assistant", + content=[TextContent(type="text", text="saved")], + ), + ], + ) + + await session.save_history( + cast("AgentProtocol", agent), + resolved_prompts={"main": "Resolved ACP prompt"}, + ) + + payload = json.loads((session.directory / "session.json").read_text(encoding="utf-8")) + assert payload["continuation"]["agents"]["main"]["resolved_prompt"] == "Resolved ACP prompt" + + @pytest.mark.asyncio async def test_save_history_tracks_most_recent_active_agent_across_known_agents( tmp_path: Path, diff --git a/uv.lock b/uv.lock index 32f71e4de..c251c1aa0 100644 --- a/uv.lock +++ b/uv.lock @@ -713,7 +713,7 @@ requires-dist = [{ name = "fast-agent-mcp", editable = "." }] [[package]] name = "fast-agent-mcp" -version = "0.6.21" +version = "0.6.22" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },