Skip to content

Commit 47110ca

Browse files
evalstateclaude
andauthored
Review MCP Server connection in agents ACP (#563)
* feat(mcp-agent): add rebuild_instruction_templates for post-connect prompt refresh Add mechanism to rebuild system prompts after MCP servers connect lazily. This solves the issue where /connect doesn't update {{serverInstructions}} because the template was already resolved during initialization. Changes: - McpAgent: Store original template in _instruction_template - McpAgent: Add rebuild_instruction_templates() method - ACPContext: Add invalidate_instruction_cache() to update session caches - ACPContext: Add set_resolved_instructions() to share cache reference - AgentACPServer: Wire up resolved_instructions cache to ACPContext - HuggingFaceAgent: Call rebuild_instruction_templates() after /connect * refactor(acp): add public API for updating session instructions Add SlashCommandHandler.update_session_instruction() public method to avoid ACPContext accessing private _session_instructions directly. This improves encapsulation and reduces coupling between classes. * feat(core): add InstructionBuilder for template-based system prompts Introduce InstructionBuilder class that cleanly separates template storage from source resolution. The builder produces instruction strings on demand, supporting both static values and dynamic async resolvers. Features: - Static placeholders: {{name}} with .set("name", "value") - Dynamic resolvers: .set_resolver("name", async_fn) called on each build() - URL patterns: {{url:https://...}} fetched at build time - File patterns: {{file:path}} and {{file_silent:path}} relative to workspaceRoot - Fluent API: builder.set(...).set_resolver(...).build() - Utility methods: get_placeholders(), get_unresolved_placeholders() Integration: - McpAgent now uses InstructionBuilder internally - Resolvers registered for serverInstructions and agentSkills - rebuild_instruction_templates() simply calls builder.build() - Pattern: construct-on-change-and-replace (stateless builder, agent owns result) Includes 20 unit tests covering all functionality. * fix(acp): preserve session context when rebuilding instructions When rebuild_instruction_templates() is called (e.g., after /connect), the InstructionBuilder was starting from the original template which still contained {{env}}, {{workspaceRoot}}, etc. These session-level variables weren't being resolved because the builder only had resolvers for serverInstructions and agentSkills. Fix: - Add McpAgent.set_instruction_context() to set session variables on builder - Call set_instruction_context() from ACP server during session setup - Session context (env, workspaceRoot, agentSkills, etc.) is now available when rebuilding instructions after /connect * make instructions visible * fix(mcp): avoid implicit connect for server instructions * make server instructions visible (toad xml rendering) --------- Co-authored-by: Claude <[email protected]>
1 parent f4017dd commit 47110ca

File tree

10 files changed

+843
-72
lines changed

10 files changed

+843
-72
lines changed

publish/hf-inference-acp/src/hf_inference_acp/agents.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,12 @@ async def _send_connect_update(
497497
self._hf_mcp_connected = True
498498
await _send_connect_update(title="Connected", status="in_progress")
499499

500+
# Rebuild system prompt to include fresh server instructions
501+
await _send_connect_update(
502+
title="Rebuilding system prompt…", status="in_progress"
503+
)
504+
await self.rebuild_instruction_templates()
505+
500506
# Get available tools
501507
await _send_connect_update(title="Fetching available tools…", status="in_progress")
502508
tools_result = await self._aggregator.list_tools()

src/fast_agent/acp/acp_context.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ def __init__(
147147
self._progress_manager: "ACPToolProgressManager | None" = None
148148
self._slash_handler: "SlashCommandHandler | None" = None
149149

150+
# Reference to session-level resolved instructions cache
151+
# (shared with ACPSessionState.resolved_instructions)
152+
self._resolved_instructions: dict[str, str] | None = None
153+
150154
# Lock for async operations
151155
self._lock = asyncio.Lock()
152156

@@ -357,6 +361,15 @@ def set_slash_handler(self, handler: "SlashCommandHandler") -> None:
357361
"""Set the slash command handler (called by server)."""
358362
self._slash_handler = handler
359363

364+
def set_resolved_instructions(self, resolved_instructions: dict[str, str]) -> None:
365+
"""
366+
Set the reference to resolved instructions cache (called by server).
367+
368+
This should point to the same dict as ACPSessionState.resolved_instructions
369+
so that updates are reflected in both places.
370+
"""
371+
self._resolved_instructions = resolved_instructions
372+
360373
# =========================================================================
361374
# Slash Command Updates
362375
# =========================================================================
@@ -416,6 +429,41 @@ async def send_session_update(self, update: Any) -> None:
416429
update=update,
417430
)
418431

432+
async def invalidate_instruction_cache(
433+
self, agent_name: str, new_instruction: str | None
434+
) -> None:
435+
"""
436+
Invalidate the session instruction cache for an agent.
437+
438+
Call this when an agent's system prompt has been rebuilt (e.g., after
439+
connecting new MCP servers) to ensure the ACP session uses the fresh
440+
instruction on subsequent prompts.
441+
442+
Args:
443+
agent_name: Name of the agent whose instruction was updated
444+
new_instruction: The new resolved instruction (or None to remove)
445+
"""
446+
async with self._lock:
447+
# Update the session-level resolved instructions cache
448+
# (used by _build_session_request_params in AgentACPServer)
449+
if self._resolved_instructions is not None:
450+
if new_instruction:
451+
self._resolved_instructions[agent_name] = new_instruction
452+
elif agent_name in self._resolved_instructions:
453+
del self._resolved_instructions[agent_name]
454+
455+
# Update the SlashCommandHandler's session instructions cache
456+
# (used by /system command)
457+
if self._slash_handler:
458+
self._slash_handler.update_session_instruction(agent_name, new_instruction)
459+
460+
logger.info(
461+
"Invalidated instruction cache for agent",
462+
name="acp_instruction_cache_invalidated",
463+
session_id=self._session_id,
464+
agent_name=agent_name,
465+
)
466+
419467
# =========================================================================
420468
# Cleanup
421469
# =========================================================================

src/fast_agent/acp/server/agent_acp_server.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,17 @@ async def new_session(
748748
if resolved_for_session:
749749
session_state.resolved_instructions = resolved_for_session
750750

751+
# Set session context on agents that have InstructionBuilder
752+
# This ensures {{env}}, {{workspaceRoot}}, etc. are available when rebuilding
753+
for agent_name, agent in instance.agents.items():
754+
if hasattr(agent, "set_instruction_context"):
755+
try:
756+
agent.set_instruction_context(session_context)
757+
except Exception as e:
758+
logger.warning(
759+
f"Failed to set instruction context on agent {agent_name}: {e}"
760+
)
761+
751762
# Create slash command handler for this session
752763
resolved_prompts = session_state.resolved_instructions
753764

@@ -784,6 +795,9 @@ async def new_session(
784795

785796
acp_context.set_slash_handler(slash_handler)
786797

798+
# Share the resolved instructions cache so agents can invalidate it
799+
acp_context.set_resolved_instructions(session_state.resolved_instructions)
800+
787801
# Store ACPContext
788802
session_state.acp_context = acp_context
789803

src/fast_agent/acp/slash_commands.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,24 @@ def set_current_agent(self, agent_name: str) -> None:
174174
"""
175175
self.current_agent_name = agent_name
176176

177+
def update_session_instruction(
178+
self, agent_name: str, instruction: str | None
179+
) -> None:
180+
"""
181+
Update the cached session instruction for an agent.
182+
183+
Call this when an agent's system prompt has been rebuilt (e.g., after
184+
connecting new MCP servers) to keep the /system command output current.
185+
186+
Args:
187+
agent_name: Name of the agent whose instruction was updated
188+
instruction: The new instruction (or None to remove from cache)
189+
"""
190+
if instruction:
191+
self._session_instructions[agent_name] = instruction
192+
elif agent_name in self._session_instructions:
193+
del self._session_instructions[agent_name]
194+
177195
def _get_current_agent(self) -> AgentProtocol | None:
178196
"""Return the current agent or None if it does not exist."""
179197
return self.instance.agents.get(self.current_agent_name)

src/fast_agent/agents/mcp_agent.py

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from fast_agent.agents.tool_agent import ToolAgent
3939
from fast_agent.constants import HUMAN_INPUT_TOOL_NAME
4040
from fast_agent.core.exceptions import PromptExitError
41+
from fast_agent.core.instruction import InstructionBuilder
4142
from fast_agent.core.logging.logger import get_logger
4243
from fast_agent.interfaces import FastAgentLLMProtocol
4344
from fast_agent.mcp.common import (
@@ -102,7 +103,9 @@ def __init__(
102103
**kwargs,
103104
)
104105

105-
self.instruction = self.config.instruction
106+
# Store the original template - resolved instruction set after build()
107+
self._instruction_template = self.config.instruction
108+
self.instruction = self.config.instruction # Will be replaced by builder output
106109
self.executor = context.executor if context else None
107110
self.logger = get_logger(f"{__name__}.{self._name}")
108111
manifests: list[SkillManifest] = list(getattr(self.config, "skill_manifests", []) or [])
@@ -169,6 +172,13 @@ def __init__(
169172
if self._shell_runtime_enabled:
170173
self._shell_runtime.announce()
171174

175+
# Create instruction builder with dynamic resolvers
176+
self._instruction_builder = InstructionBuilder(self._instruction_template or "")
177+
self._instruction_builder.set_resolver(
178+
"serverInstructions", self._resolve_server_instructions
179+
)
180+
self._instruction_builder.set_resolver("agentSkills", self._resolve_agent_skills)
181+
172182
# Allow external runtime injection (e.g., for ACP terminal support)
173183
self._external_runtime = None
174184

@@ -268,30 +278,18 @@ async def _apply_instruction_templates(self) -> None:
268278
Apply template substitution to the instruction, including server instructions.
269279
This is called during initialization after servers are connected.
270280
"""
271-
if not self.instruction:
281+
if not self._instruction_builder.template:
272282
return
273283

274-
# Gather server instructions if the template includes {{serverInstructions}}
275-
if "{{serverInstructions}}" in self.instruction:
276-
try:
277-
instructions_data = await self._aggregator.get_server_instructions()
278-
server_instructions = self._format_server_instructions(instructions_data)
279-
except Exception as e:
280-
self.logger.warning(f"Failed to get server instructions: {e}")
281-
server_instructions = ""
282-
283-
# Replace the template variable
284-
self.instruction = self.instruction.replace(
285-
"{{serverInstructions}}", server_instructions
286-
)
287-
288-
skills_placeholder_present = "{{agentSkills}}" in self.instruction
284+
# Build the instruction using the InstructionBuilder
285+
self.instruction = await self._instruction_builder.build()
289286

290-
if skills_placeholder_present:
291-
agent_skills = format_skills_for_prompt(self._skill_manifests)
292-
self.instruction = self.instruction.replace("{{agentSkills}}", agent_skills)
293-
self._agent_skills_warning_shown = True
294-
elif self._skill_manifests and not self._agent_skills_warning_shown:
287+
# Warn if skills configured but placeholder missing
288+
if (
289+
self._skill_manifests
290+
and not self._agent_skills_warning_shown
291+
and "{{agentSkills}}" not in self._instruction_builder.template
292+
):
295293
warning_message = (
296294
"Agent skills are configured but the system prompt does not include {{agentSkills}}. "
297295
"Skill descriptions will not be added to the system prompt."
@@ -309,6 +307,38 @@ async def _apply_instruction_templates(self) -> None:
309307

310308
self.logger.debug(f"Applied instruction templates for agent {self._name}")
311309

310+
# ─────────────────────────────────────────────────────────────────────────
311+
# Instruction Resolvers (for InstructionBuilder)
312+
# ─────────────────────────────────────────────────────────────────────────
313+
314+
async def _resolve_server_instructions(self) -> str:
315+
"""Resolver for {{serverInstructions}} placeholder."""
316+
try:
317+
instructions_data = await self._aggregator.get_server_instructions()
318+
return self._format_server_instructions(instructions_data)
319+
except Exception as e:
320+
self.logger.warning(f"Failed to get server instructions: {e}")
321+
return ""
322+
323+
async def _resolve_agent_skills(self) -> str:
324+
"""Resolver for {{agentSkills}} placeholder."""
325+
self._agent_skills_warning_shown = True
326+
return format_skills_for_prompt(self._skill_manifests)
327+
328+
def set_instruction_context(self, context: dict[str, str]) -> None:
329+
"""
330+
Set session-level context variables on the instruction builder.
331+
332+
This should be called when an ACP session is established to provide
333+
variables like {{env}}, {{workspaceRoot}}, {{agentSkills}} etc. that
334+
are resolved per-session.
335+
336+
Args:
337+
context: Dict mapping placeholder names to values (e.g., {"env": "...", "workspaceRoot": "/path"})
338+
"""
339+
self._instruction_builder.set_many(context)
340+
self.logger.debug(f"Set instruction context for agent {self._name}: {list(context.keys())}")
341+
312342
def _format_server_instructions(
313343
self, instructions_data: dict[str, tuple[str | None, list[str]]]
314344
) -> str:
@@ -335,16 +365,48 @@ def _format_server_instructions(
335365
tools_list = ", ".join(prefixed_tools) if prefixed_tools else "No tools available"
336366

337367
formatted_parts.append(
338-
f'<mcp-server name="{server_name}">\n'
368+
f'<fastagent:mcp-server name="{server_name}">\n'
339369
f"<tools>{tools_list}</tools>\n"
340370
f"<instructions>\n{instructions}\n</instructions>\n"
341-
f"</mcp-server>"
371+
f"</fastagent:mcp-server>"
342372
)
343373

344374
if formatted_parts:
345375
return "\n\n".join(formatted_parts)
346376
return ""
347377

378+
async def rebuild_instruction_templates(self) -> None:
379+
"""
380+
Rebuild instruction from template with fresh source values.
381+
382+
Call this method after connecting new MCP servers (e.g., via /connect command)
383+
to update the system prompt with fresh {{serverInstructions}}.
384+
385+
The InstructionBuilder re-resolves all dynamic sources (serverInstructions,
386+
agentSkills, etc.) each time build() is called.
387+
"""
388+
if not self._instruction_builder.template:
389+
return
390+
391+
# Rebuild using the instruction builder (resolvers are called fresh)
392+
self.instruction = await self._instruction_builder.build()
393+
394+
# Update default request params to match
395+
if self._default_request_params:
396+
self._default_request_params.systemPrompt = self.instruction
397+
398+
# Invalidate ACP session caches if running in ACP mode
399+
if self.context and hasattr(self.context, "acp") and self.context.acp:
400+
try:
401+
await self.context.acp.invalidate_instruction_cache(
402+
agent_name=self._name,
403+
new_instruction=self.instruction,
404+
)
405+
except Exception as e:
406+
self.logger.warning(f"Failed to invalidate ACP instruction cache: {e}")
407+
408+
self.logger.info(f"Rebuilt instruction templates for agent {self._name}")
409+
348410
async def __call__(
349411
self,
350412
message: Union[
@@ -430,7 +492,9 @@ def _filter_server_tools(self, tools: list[Tool] | None, namespace: str) -> list
430492
if namespace not in filters:
431493
return list(tools)
432494

433-
filtered = self._filter_server_collections({namespace: tools}, filters, lambda tool: tool.name)
495+
filtered = self._filter_server_collections(
496+
{namespace: tools}, filters, lambda tool: tool.name
497+
)
434498
return filtered.get(namespace, [])
435499

436500
async def _get_filtered_mcp_tools(self) -> list[Tool]:
@@ -517,7 +581,9 @@ async def call_tool(
517581
if name == "read_text_file":
518582
return await self._filesystem_runtime.read_text_file(arguments, tool_use_id)
519583
elif name == "write_text_file":
520-
return await self._filesystem_runtime.write_text_file(arguments, tool_use_id)
584+
return await self._filesystem_runtime.write_text_file(
585+
arguments, tool_use_id
586+
)
521587

522588
# Fall back to shell runtime
523589
if self._shell_runtime.tool and name == self._shell_runtime.tool.name:
@@ -905,7 +971,7 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend
905971
# Store timing and transport channel info
906972
tool_timings[correlation_id] = {
907973
"timing_ms": duration_ms,
908-
"transport_channel": getattr(result, "transport_channel", None)
974+
"transport_channel": getattr(result, "transport_channel", None),
909975
}
910976

911977
# Show tool result (like ToolAgent does)
@@ -937,7 +1003,9 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend
9371003
# Show error result too (no need for skybridge config on errors)
9381004
self.display.show_tool_result(name=self._name, result=error_result)
9391005

940-
return self._finalize_tool_results(tool_results, tool_timings=tool_timings, tool_loop_error=tool_loop_error)
1006+
return self._finalize_tool_results(
1007+
tool_results, tool_timings=tool_timings, tool_loop_error=tool_loop_error
1008+
)
9411009

9421010
def _prepare_tool_display(
9431011
self,

0 commit comments

Comments
 (0)