Compare commits
1 Commits
0ae0d55a8d
...
fix/tool-d
| Author | SHA1 | Date | |
|---|---|---|---|
| 455d3eef3d |
@@ -191,7 +191,7 @@ agents:
|
|||||||
| `agents.<name>.module` | yes | Importable Python module path containing a `fast` instance |
|
| `agents.<name>.module` | yes | Importable Python module path containing a `fast` instance |
|
||||||
| `agents.<name>.port` | yes | Port for this agent's StreamableHTTP MCP server |
|
| `agents.<name>.port` | yes | Port for this agent's StreamableHTTP MCP server |
|
||||||
| `agents.<name>.title` | no | Display name in registry. Default: `name.title()` |
|
| `agents.<name>.title` | no | Display name in registry. Default: `name.title()` |
|
||||||
| `agents.<name>.description` | no | Description in registry |
|
| `agents.<name>.description` | no | Description in registry. Also becomes the `send_message` tool description, overriding any `description=` on the `@fast.agent` decorator |
|
||||||
| `agents.<name>.model` | no | `provider.model-name` override for this agent. Overrides `default_model`, is applied to every agent in the module at startup, and is what the registry advertises for this entry |
|
| `agents.<name>.model` | no | `provider.model-name` override for this agent. Overrides `default_model`, is applied to every agent in the module at startup, and is what the registry advertises for this entry |
|
||||||
| `agents.<name>.model_capabilities` | no | Per-agent `{vision, context_window, max_output_tokens}` block. Overrides the top-level `model_capabilities`; the same defaults apply to omitted fields |
|
| `agents.<name>.model_capabilities` | no | Per-agent `{vision, context_window, max_output_tokens}` block. Overrides the top-level `model_capabilities`; the same defaults apply to omitted fields |
|
||||||
| `agents.<name>.depends_on` | no | List of agent names that must start and become ready before this agent |
|
| `agents.<name>.depends_on` | no | List of agent names that must start and become ready before this agent |
|
||||||
@@ -452,6 +452,16 @@ Each agent's MCP tool accepts:
|
|||||||
|
|
||||||
When `images` is provided, the message is sent as a `PromptMessageExtended` containing both `TextContent` and `ImageContent` parts — the agent's underlying model must support vision.
|
When `images` is provided, the message is sent as a `PromptMessageExtended` containing both `TextContent` and `ImageContent` parts — the agent's underlying model must support vision.
|
||||||
|
|
||||||
|
#### Tool description
|
||||||
|
|
||||||
|
The tool's description is what an MCP client shows next to the tool name, so it should say what the agent is *for*. Pallas resolves it in this order:
|
||||||
|
|
||||||
|
1. `agents.<name>.description` from `agents.yaml` — the deployment's source of truth, and the same text published in the registry
|
||||||
|
2. `description=` on the `@fast.agent` decorator
|
||||||
|
3. `Send a message to the {agent} agent` — a generic fallback
|
||||||
|
|
||||||
|
A description containing `{agent}` has the agent's name interpolated into it; other braces are left alone. Without (1), every agent whose module omits (2) falls through to the fallback, which tells a client nothing — so keep `agents.yaml` descriptions meaningful.
|
||||||
|
|
||||||
### Tool-Result Image Passthrough
|
### Tool-Result Image Passthrough
|
||||||
|
|
||||||
Images work in both directions. fast-agent's `agent.send()` returns only the final assistant text, so images produced by downstream tools during the agentic loop (playwright screenshots, rommie desktop captures) would otherwise reach the agent's own vision model but never the MCP caller. A per-request `after_tool_call` hook (`pallas.image_passthrough`) collects every `ImageContent` block from the turn's tool results; at end of turn `send_message` returns a `CallToolResult` whose content is the assistant's text block followed by the collected images. Turns that produce no images return the plain string, unchanged from previous releases.
|
Images work in both directions. fast-agent's `agent.send()` returns only the final assistant text, so images produced by downstream tools during the agentic loop (playwright screenshots, rommie desktop captures) would otherwise reach the agent's own vision model but never the MCP caller. A per-request `after_tool_call` hook (`pallas.image_passthrough`) collects every `ImageContent` block from the turn's tool results; at end of turn `send_message` returns a `CallToolResult` whose content is the assistant's text block followed by the collected images. Turns that produce no images return the plain string, unchanged from previous releases.
|
||||||
|
|||||||
@@ -283,12 +283,26 @@ async def _start_agent(name: str, agents: dict[str, dict]) -> None:
|
|||||||
if entry.get(k) is not None
|
if entry.get(k) is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The agents.yaml description is the one an operator actually wrote,
|
||||||
|
# and it is already published in the registry. Reuse it as the
|
||||||
|
# `send_message` tool description so MCP clients see something
|
||||||
|
# meaningful instead of the "Send a message to the {agent} agent"
|
||||||
|
# fallback.
|
||||||
|
#
|
||||||
|
# Precedence, per register_agent_tools: this value wins over a
|
||||||
|
# `description=` on the @fast.agent decorator, which in turn wins over
|
||||||
|
# the generic fallback. agents.yaml is the deployment's source of
|
||||||
|
# truth for agent metadata, so an operator editing it should not be
|
||||||
|
# silently overridden by a value buried in the agent module.
|
||||||
|
tool_description = entry.get("description") or None
|
||||||
|
|
||||||
server = MultimodalAgentMCPServer(
|
server = MultimodalAgentMCPServer(
|
||||||
primary_instance=primary_instance,
|
primary_instance=primary_instance,
|
||||||
create_instance=fast_instance._server_instance_factory,
|
create_instance=fast_instance._server_instance_factory,
|
||||||
dispose_instance=fast_instance._server_instance_dispose,
|
dispose_instance=fast_instance._server_instance_dispose,
|
||||||
instance_scope="request",
|
instance_scope="request",
|
||||||
server_name=f"{fast_instance.name}-MCP-Server",
|
server_name=f"{fast_instance.name}-MCP-Server",
|
||||||
|
tool_description=tool_description,
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
get_registry_version=fast_instance._get_registry_version,
|
get_registry_version=fast_instance._get_registry_version,
|
||||||
request_limits=request_limits,
|
request_limits=request_limits,
|
||||||
|
|||||||
88
tests/test_tool_description.py
Normal file
88
tests/test_tool_description.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Tests for send_message tool-description resolution.
|
||||||
|
|
||||||
|
``server._start_agent`` passes the agents.yaml ``description`` through as
|
||||||
|
``tool_description``, so MCP clients see the description an operator actually
|
||||||
|
wrote instead of the generic "Send a message to the {agent} agent" fallback.
|
||||||
|
|
||||||
|
These pin the precedence implemented in
|
||||||
|
``MultimodalAgentMCPServer.register_agent_tools``:
|
||||||
|
|
||||||
|
tool_description (agents.yaml) > @fast.agent description > fallback
|
||||||
|
|
||||||
|
Constructing a real ``MultimodalAgentMCPServer`` needs a live FastAgent
|
||||||
|
instance, so these exercise the resolution expression directly — it is the
|
||||||
|
part that carries the logic, and the part that would silently regress.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(
|
||||||
|
tool_description: str | None,
|
||||||
|
agent_description: str | None = None,
|
||||||
|
agent_name: str = "scotty",
|
||||||
|
) -> str:
|
||||||
|
"""Mirror register_agent_tools' description resolution."""
|
||||||
|
resolved = (
|
||||||
|
tool_description.format(agent=agent_name)
|
||||||
|
if tool_description and "{agent}" in tool_description
|
||||||
|
else tool_description
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
resolved
|
||||||
|
or agent_description
|
||||||
|
or f"Send a message to the {agent_name} agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Precedence ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_agents_yaml_description_is_used() -> None:
|
||||||
|
"""The agents.yaml description reaches the tool, not the fallback."""
|
||||||
|
assert _resolve("Systems administration expert", None) == (
|
||||||
|
"Systems administration expert"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agents_yaml_wins_over_decorator() -> None:
|
||||||
|
"""agents.yaml is the deployment's source of truth for agent metadata."""
|
||||||
|
assert _resolve("From agents.yaml", "From the decorator") == "From agents.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decorator_used_when_no_yaml_description() -> None:
|
||||||
|
"""An agent module's own description still beats the generic fallback."""
|
||||||
|
assert _resolve(None, "From the decorator") == "From the decorator"
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_when_nothing_configured() -> None:
|
||||||
|
"""With neither source set, the generic fallback stands."""
|
||||||
|
assert _resolve(None, None) == "Send a message to the scotty agent"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("empty", ["", None])
|
||||||
|
def test_empty_description_falls_through(empty: str | None) -> None:
|
||||||
|
"""An empty agents.yaml description must not shadow the other sources."""
|
||||||
|
assert _resolve(empty, "From the decorator") == "From the decorator"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Templating ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_placeholder_is_interpolated() -> None:
|
||||||
|
"""``{agent}`` in a description is substituted with the agent name."""
|
||||||
|
assert _resolve("Talk to {agent} about ops") == "Talk to scotty about ops"
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_braces_are_left_alone() -> None:
|
||||||
|
"""Prose containing braces is only formatted when it holds ``{agent}``.
|
||||||
|
|
||||||
|
Guards the ``"{agent}" in ...`` check — an unconditional ``.format()``
|
||||||
|
would raise KeyError on a description mentioning JSON.
|
||||||
|
"""
|
||||||
|
description = 'Handles JSON like {"a": 1} safely'
|
||||||
|
assert _resolve(description, None) == description
|
||||||
Reference in New Issue
Block a user