Skip to content
10 changes: 10 additions & 0 deletions docs/ACP_IMPLEMENTATION_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,14 @@ fast-agent serve --transport acp \
--model haiku
```

### With AgentCards + Auto-Reload
```bash
fast-agent serve --transport acp \
--card ./agents \
--model haiku \
--watch # Auto-reload AgentCards on change
```

### With Instance Scoping
```bash
fast-agent serve --transport acp \
Expand All @@ -411,6 +419,8 @@ fast-agent serve --transport acp \
--servers filesystem,github # Expose MCP tools to agent
```

Note: `--reload` enables the `/reload` slash command in ACP sessions.

---

## Integration with Editors
Expand Down
25 changes: 19 additions & 6 deletions docs/ACP_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,27 @@ The Agent Client Protocol (ACP) support in fast-agent allows it to act as an ACP
export OPENAI_API_KEY="your-key-here"
```

2. **Instruction File**: Create a simple instruction file
2. **Agent instructions**: Use either an instruction file *or* AgentCards
```bash
# Option A: instruction file
echo "You are a helpful AI assistant." > /tmp/instruction.md
```
```bash
# Option B: AgentCards directory
mkdir -p ./agents
# Drop one or more AgentCard .md/.yaml files into ./agents
```

### Running the Server

Start the ACP server:
Start the ACP server (pick one):

```bash
fast-agent serve --transport acp --instruction /tmp/instruction.md --model haiku
fast-agent serve --transport acp --instruction /tmp/instruction.md --model haiku --watch
```

```bash
fast-agent serve --transport acp --card ./agents --model haiku --watch
```

The server will:
Expand Down Expand Up @@ -57,12 +67,12 @@ class SimpleClient:
# Add other required methods...

async def test():
# Spawn fast-agent as ACP server
# Spawn fast-agent as ACP server (pick one)
async with spawn_agent_process(
lambda agent: SimpleClient(agent),
'fast-agent', 'serve', '--transport', 'acp',
'--instruction', '/tmp/instruction.md',
'--model', 'haiku',
'--model', 'haiku', '--watch',
) as (connection, process):

# 1. Initialize
Expand Down Expand Up @@ -97,13 +107,16 @@ if __name__ == "__main__":
asyncio.run(test())
```

Note: replace `--instruction /tmp/instruction.md` with `--card ./agents` if you want to run from AgentCards.

### Testing with Manual JSON-RPC

You can also test by sending raw JSON-RPC messages:

```bash
# Terminal 1: Start server
# Terminal 1: Start server (pick one)
fast-agent serve --transport acp --instruction /tmp/instruction.md --model haiku
fast-agent serve --transport acp --card ./agents --model haiku

# Terminal 2: Send messages
# Initialize
Expand Down
80 changes: 80 additions & 0 deletions plan/acp-mcp-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ACP/MCP `--watch` + `--reload` support (spec)

## Goal
Enable AgentCard auto-reload and manual reload in server mode for:
- ACP transport (`fast-agent serve --transport acp`)
- MCP transports (`http`, `sse`, `stdio`)

`fast-agent go --watch/--reload` already works for interactive mode. This spec covers
equivalent behavior in `serve`.

## Current behavior (baseline)
- `fast-agent go --card <dir> --watch` starts the AgentCard watcher.
- `serve` does **not** expose `--watch` or `--reload`.
- `serve` uses `allow_extra_args` + `ignore_unknown_options`, so `--watch` is silently ignored.
- `FastAgent` only starts `_watch_agent_cards()` when `args.watch` is True.

## Desired behavior
1) `fast-agent serve --card <dir> --watch` automatically reloads AgentCards on change.
2) `fast-agent serve --card <dir> --reload` enables `/reload` (manual) from ACP slash command or MCP command hook.
3) Behavior mirrors `go` where possible:
- Watcher only runs when AgentCards were loaded and `--watch` is set.
- Reload is safe in both shared and per-session scopes.

## Implementation plan

### 1) CLI: add flags to `serve`
File: `src/fast_agent/cli/commands/serve.py`
- Add options:
- `reload: bool = typer.Option(False, "--reload", help="Enable manual AgentCard reloads (/reload)")`
- `watch: bool = typer.Option(False, "--watch", help="Watch AgentCard paths and reload")`
- Pass through to `run_async_agent(..., reload=reload, watch=watch)`.

### 2) CLI: pass flags through server runner
File: `src/fast_agent/cli/commands/go.py`
- `run_async_agent()` already accepts `reload` and `watch`.
- Ensure `serve` path forwards them (no other changes needed).

### 3) FastAgent: start watcher in server mode
Already implemented in `FastAgent.run()`:
- Watcher starts when `args.watch` is True and `_agent_card_roots` is non-empty.
- `args.watch` is only set by CLI or programmatic calls.
No code change required beyond setting `args.watch` via CLI.

### 4) Manual reload in ACP
Behavior:
- `/reload` is provided by ACP slash command handler.
- It calls the `load_card_callback`/`reload` hooks supplied by `FastAgent`.
Ensure the reload callback is set for server mode:
- In `FastAgent.run()`, `wrapper.set_reload_callback(...)` runs when `args.reload` or `args.watch` is True.
No change required if `args.reload` is passed through.

### 5) Manual reload in MCP
There is no built-in MCP slash command. Options:
1) Add an MCP tool, e.g., `reload_agent_cards`, that calls `AgentApp.reload_agents()`.
2) Expose reload via an MCP resource or prompt (lower priority).

Spec choice: **Add MCP tool**.
- File: `src/fast_agent/mcp/server/agent_server.py`
- Register a tool `reload_agent_cards` when `args.reload` or `args.watch` is True.
- Implementation: call `instance.app.reload_agents()` (or via `AgentApp`).
- Return a boolean (changed or not).

### 6) Documentation
Update:
- `docs/ACP_TESTING.md`: include `--watch` in ACP server examples.
- `docs/ACP_IMPLEMENTATION_OVERVIEW.md`: mention watch/reload availability in server mode.
- CLI docs: `src/fast_agent/cli/commands/README.md` to list `serve --watch/--reload`.

## Edge cases
- **No AgentCards loaded**: `--watch` should be a no-op (same as now).
- **Shared instance scope**: reloading replaces the primary instance; session maps are refreshed (already handled).
- **Connection/request scope**: each session instance should refresh safely; reload should update the instance assigned to that session.
- **Concurrent prompts**: ACP already blocks per-session overlap; MCP tool should respect request concurrency (use existing locks).

## Validation checklist
- `fast-agent serve --transport acp --card ./agents --watch` reloads on file change.
- `fast-agent serve --transport acp --card ./agents --reload` responds to `/reload`.
- `fast-agent serve --transport http --card ./agents --watch` reloads on file change.
- MCP tool `reload_agent_cards` is exposed when `--reload`/`--watch` is set.
- No regressions in `go` behavior.
55 changes: 55 additions & 0 deletions src/fast_agent/acp/server/agent_acp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ def __init__(
permissions_enabled: bool = True,
get_registry_version: Callable[[], int] | None = None,
load_card_callback: Callable[[str], Awaitable[list[str]]] | None = None,
reload_callback: Callable[[], Awaitable[bool]] | None = None,
) -> None:
"""
Initialize the ACP server.
Expand All @@ -216,6 +217,7 @@ def __init__(
skills_directory_override: Optional skills directory override (relative to session cwd)
permissions_enabled: Whether to request tool permissions from client (default: True)
load_card_callback: Optional callback to load AgentCards at runtime
reload_callback: Optional callback to reload AgentCards from disk
"""
super().__init__()

Expand All @@ -225,6 +227,7 @@ def __init__(
self._instance_scope = instance_scope
self._get_registry_version = get_registry_version
self._load_card_callback = load_card_callback
self._reload_callback = reload_callback
self._primary_registry_version = getattr(primary_instance, "registry_version", 0)
self._shared_reload_lock = asyncio.Lock()
self._stale_instances: list[AgentInstance] = []
Expand Down Expand Up @@ -616,6 +619,9 @@ def _refresh_session_state(
async def load_card(source: str) -> tuple[AgentInstance, list[str]]:
return await self._load_agent_card_for_session(session_state, source)

async def reload_cards() -> bool:
return await self._reload_agent_cards_for_session(session_state.session_id)

slash_handler = SlashCommandHandler(
session_state.session_id,
instance,
Expand All @@ -625,6 +631,7 @@ async def load_card(source: str) -> tuple[AgentInstance, list[str]]:
protocol_version=self._protocol_version,
session_instructions=resolved_for_session,
card_loader=load_card if self._load_card_callback else None,
reload_callback=reload_cards if self._reload_callback else None,
)
session_state.slash_handler = slash_handler

Expand Down Expand Up @@ -695,6 +702,50 @@ async def _load_agent_card_for_session(

return instance, loaded_names

async def _reload_agent_cards_for_session(self, session_id: str) -> bool:
if not self._reload_callback:
return False
if session_id in self._active_prompts:
current_task = asyncio.current_task()
session_task = self._session_tasks.get(session_id)
if current_task != session_task:
raise RuntimeError("Cannot reload while a prompt is active for this session.")

changed = await self._reload_callback()
if not changed:
return False

if self._instance_scope == "shared":
await self._maybe_refresh_shared_instance()
return True

async with self._session_lock:
session_state = self._session_state.get(session_id)
if not session_state:
return True

instance = await self._create_instance_task()
old_instance = session_state.instance
session_state.instance = instance
async with self._session_lock:
self.sessions[session_id] = instance
self._refresh_session_state(session_state, instance)
if old_instance != self.primary_instance:
try:
await self._dispose_instance_task(old_instance)
except Exception as exc:
logger.warning(
"Failed to dispose old session instance after reload",
name="acp_reload_dispose_error",
session_id=session_id,
error=str(exc),
)

if session_state.acp_context:
await session_state.acp_context.send_available_commands_update()

return True

async def _dispose_stale_instances_if_idle(self) -> None:
if self._active_prompts:
return
Expand Down Expand Up @@ -965,6 +1016,9 @@ async def new_session(
async def load_card(source: str) -> tuple[AgentInstance, list[str]]:
return await self._load_agent_card_for_session(session_state, source)

async def reload_cards() -> bool:
return await self._reload_agent_cards_for_session(session_id)

slash_handler = SlashCommandHandler(
session_id,
instance,
Expand All @@ -974,6 +1028,7 @@ async def load_card(source: str) -> tuple[AgentInstance, list[str]]:
protocol_version=self._protocol_version,
session_instructions=resolved_prompts,
card_loader=load_card if self._load_card_callback else None,
reload_callback=reload_cards if self._reload_callback else None,
)
session_state.slash_handler = slash_handler

Expand Down
21 changes: 21 additions & 0 deletions src/fast_agent/acp/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def __init__(
protocol_version: int | None = None,
session_instructions: dict[str, str] | None = None,
card_loader: Callable[[str], Awaitable[tuple["AgentInstance", list[str]]]] | None = None,
reload_callback: Callable[[], Awaitable[bool]] | None = None,
):
"""
Initialize the slash command handler.
Expand Down Expand Up @@ -149,6 +150,7 @@ def __init__(
self.protocol_version = protocol_version
self._session_instructions = session_instructions or {}
self._card_loader = card_loader
self._reload_callback = reload_callback

# Session-level commands (always available, operate on current agent)
self._session_commands: dict[str, AvailableCommand] = {
Expand Down Expand Up @@ -194,6 +196,12 @@ def __init__(
),
),
}
if self._reload_callback is not None:
self._session_commands["reload"] = AvailableCommand(
name="reload",
description="Reload AgentCards from disk",
input=None,
)

def get_available_commands(self) -> list[AvailableCommand]:
"""Get combined session commands and current agent's commands."""
Expand Down Expand Up @@ -348,6 +356,8 @@ async def execute_command(self, command_name: str, arguments: str) -> str:
return await self._handle_load(arguments)
if command_name == "card":
return await self._handle_card(arguments)
if command_name == "reload":
return await self._handle_reload()

# Check agent-specific commands
agent = self._get_current_agent()
Expand Down Expand Up @@ -1327,6 +1337,17 @@ async def _handle_card(self, arguments: str | None = None) -> str:
return summary
return f"{summary}\nAdded tool(s): {', '.join(added_tools)}"

async def _handle_reload(self) -> str:
if not self._reload_callback:
return "AgentCard reload is not available in this session."
try:
changed = await self._reload_callback()
except Exception as exc: # noqa: BLE001
return f"# reload\n\nFailed to reload AgentCards: {exc}"
if not changed:
return "# reload\n\nNo AgentCard changes detected."
return "# reload\n\nReloaded AgentCards."

async def _handle_clear(self, arguments: str | None = None) -> str:
"""Handle /clear and /clear last commands."""
normalized = (arguments or "").strip().lower()
Expand Down
7 changes: 6 additions & 1 deletion src/fast_agent/cli/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,14 @@ fast-agent serve [OPTIONS]
- `--npx TEXT`: NPX package and args to run as an MCP server (quoted)
- `--uvx TEXT`: UVX package and args to run as an MCP server (quoted)
- `--stdio TEXT`: Command to run as STDIO MCP server (quoted)
- `--transport [http|sse|stdio]`: Transport protocol to expose (default: http)
- `--transport [http|sse|stdio|acp]`: Transport protocol to expose (default: http)
- `--host TEXT`: Host address when using HTTP or SSE transport (default: 0.0.0.0)
- `--port INTEGER`: Port when using HTTP or SSE transport (default: 8000)
- `--shell`, `-x`: Enable a local shell runtime and expose the execute tool
- `--description`, `-d TEXT`: Description used for each send tool (supports `{agent}` placeholder)
- `--instance-scope [shared|connection|request]`: Control how MCP clients receive isolated agent instances (default: shared)
- `--reload`: Enable manual AgentCard reloads (ACP: `/reload`, MCP: `reload_agent_cards`)
- `--watch`: Watch AgentCard paths and reload

### Skills behavior

Expand All @@ -123,6 +125,9 @@ fast-agent serve --description "Interact with the {agent} workflow via MCP"
# Load AgentCards from a file or directory
fast-agent serve --card ./agents --transport=http

# Watch AgentCard directory for changes
fast-agent serve --card ./agents --watch --transport=http

# Use per-connection instances to isolate history between clients
fast-agent serve --instance-scope=connection --transport=http
```
Expand Down
4 changes: 4 additions & 0 deletions src/fast_agent/cli/commands/acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ def run_acp(
"--no-permissions",
help="Disable tool permission requests (allow all tool executions without asking)",
),
reload: bool = typer.Option(False, "--reload", help="Enable manual AgentCard reloads"),
watch: bool = typer.Option(False, "--watch", help="Watch AgentCard paths and reload"),
) -> None:
"""
Run FastAgent with ACP transport defaults.
Expand Down Expand Up @@ -135,6 +137,8 @@ def run_acp(
tool_description=description,
instance_scope=instance_scope.value,
permissions_enabled=not no_permissions,
reload=reload,
watch=watch,
)


Expand Down
4 changes: 4 additions & 0 deletions src/fast_agent/cli/commands/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ def serve(
"--no-permissions",
help="Disable tool permission requests (allow all tool executions without asking) - ACP only",
),
reload: bool = typer.Option(False, "--reload", help="Enable manual AgentCard reloads"),
watch: bool = typer.Option(False, "--watch", help="Watch AgentCard paths and reload"),
) -> None:
"""
Run FastAgent as an MCP server.
Expand Down Expand Up @@ -148,4 +150,6 @@ def serve(
tool_description=description,
instance_scope=instance_scope.value,
permissions_enabled=not no_permissions,
reload=reload,
watch=watch,
)
Loading
Loading