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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
4 changes: 4 additions & 0 deletions src/fast_agent/acp/acp_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =========================================================================
Expand Down
8 changes: 7 additions & 1 deletion src/fast_agent/commands/shared_command_intents.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,14 +263,20 @@ 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}"

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] = []
Expand Down
3 changes: 3 additions & 0 deletions src/fast_agent/hooks/session_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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(
Expand Down
25 changes: 14 additions & 11 deletions src/fast_agent/llm/fastagent_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions src/fast_agent/session/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion src/fast_agent/session/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/integration/api/test_retry_error_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions tests/unit/fast_agent/commands/test_shared_command_intents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/fast_agent/hooks/test_session_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"


Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"}]
77 changes: 77 additions & 0 deletions tests/unit/fast_agent/session/test_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading