9 Commits

Author SHA1 Message Date
b5a3aa214b 🐾 fix(mantle): survive Mantle's 20k output ceiling on tool-heavy turns
Two new Mantle shims, auto-installed alongside the existing pair:

- Opt out of the fine-grained-tool-streaming beta. Under it, an
  output-token cutoff mid-tool_use ends the stream without
  content_block_stop; fast-agent raises "Streaming completed but tool
  call never finished" and burns its retry ladder against the same
  wall (the observed ~700s Alan revise_workspace_file failures on
  Taurus).

- Clamp default maxTokens to Mantle's observed 20 000-token server
  ceiling, so the model stops gracefully (proper block close +
  stop_reason=max_tokens) instead of being cut off by the gateway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:49:21 -04:00
d528192bee Merge pull request '🐾 fix: use the agents.yaml description as the send_message tool description' (#7) from fix/tool-description-from-agents-yaml into main
Reviewed-on: #7
2026-08-03 17:53:48 +00:00
455d3eef3d 🐾 fix: use the agents.yaml description as the send_message tool description
Every agent's MCP tool advertised the generic "Send a message to the {agent}
agent" fallback, because register_agent_tools only had the @fast.agent
decorator's description to fall back on — and no agent in the estate sets one
(0 of 34 agent modules across kottos, iolaus, mentor and dodona).

Meanwhile the description an operator actually wrote already sits in
agents.yaml and is published in the registry; _start_agent had it in scope
and simply never passed it through. Wire it to tool_description.

Fixes every agent in every deployment at once, with no per-repo edits:
scotty's tool description becomes "Systems administration expert —
infrastructure diagnostics, security hardening, and keeping everything
running" instead of "Send a message to the scotty agent".

Adds tests/test_tool_description.py pinning the resolution order
(agents.yaml > decorator > fallback) and the {agent} templating, including
that prose containing other braces is not passed through .format().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:14:45 -04:00
0ae0d55a8d Merge pull request '🐾 fix: resolve registry capabilities per agent, not once globally' (#6) from fix/registry-per-agent-capabilities into main
Reviewed-on: #6
2026-08-03 16:29:44 +00:00
73f6afdbf8 🐾 fix: resolve registry capabilities per agent, not once globally
_build_registry built one capabilities dict from the global default_model and
attached it to every entry, so any agent with an `agents.<name>.model` or
`model_capabilities` override was advertised under the wrong model — even
though server.py applies those overrides at startup. Resolve capabilities per
agent, mirroring _register_unknown_models.

Also stop emitting null context_window / max_output_tokens when
model_capabilities is absent: _register_one_model registers the model with
131072 / 16384, so the registry now advertises those effective values. The
defaults are hoisted to module constants in server.py so the two cannot drift.

Verified against mentor's config: capabilities go from
{"context_window": null, "max_output_tokens": null} to {131072, 16384}, model
name unchanged. No checked-in deployment uses per-agent overrides today, so no
currently-published model changes.

Adds tests/test_registry.py (9 tests, first coverage for registry.py) and
documents agents.<name>.model / model_capabilities, which the agents.yaml
field table omitted entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:22:30 -04:00
5af198911b Merge pull request 'feat: forward tool-result images to the MCP caller in send_message results' (#5) from feat/tool-result-image-passthrough into main
Reviewed-on: #5
2026-08-02 13:14:57 +00:00
1b05504207 feat: forward tool-result images to the MCP caller in send_message results
fast-agent's agent.send() returns only the final assistant text, so
ImageContent produced by downstream tools during the agentic loop
(playwright screenshots, rommie desktop captures) reached the agent's own
vision model but never crossed the MCP boundary — Daedalus and lead agents
saw text-only results.

A per-request after_tool_call hook (pallas.image_passthrough, same
composition pattern as assistant_stream / loop_guard) collects every
ImageContent block from the turn's tool results; send_message then returns
a FastMCP ToolResult of [final text, *images]. Turns with no images return
the plain string — wire shape unchanged (the str-only output schema is
dropped so the union return passes through cleanly; no consumer read
structuredContent). Images cascade hop-by-hop up delegation chains with no
extra wiring: verified live playwright → dolores → harper → MCP client,
image intact at each hop.

New per-agent agents.yaml knob max_result_images (default 8, keeps most
recent, 0 disables) and pallas_result_images_total counter. Version 0.7.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 22:47:25 -04:00
54d639c13a Merge pull request 'feat: upgrade fast-agent-mcp to 0.7.22, add Fable 5 / Opus 4.8 Mantle wire names' (#4) from feat/fast-agent-0.7.22 into main
Reviewed-on: #4
2026-07-25 10:46:59 +00:00
b38d4b1c69 feat: upgrade fast-agent-mcp to 0.7.22, add Fable 5 / Opus 4.8 Mantle wire names
Bumps the runtime from 0.7.15 to 0.7.22 (last 0.7.x release). All five
monkey-patched surfaces (MCPAgentClientSession.send_request/call_tool,
MCPAggregator._execute_on_server/_create_session_factory/call_tool),
ToolRunnerHooks, and both AnthropicConverter private methods patched by
mantle_shims were verified unchanged in signature at 0.7.22.

Adds claude-opus-4-8 and claude-fable-5 to MANTLE_WIRE_NAMES; both are
natively known to fast-agent's ModelDatabase as of 0.7.17+ (1M context).

58/58 tests pass; server/patch modules import cleanly on 0.7.22.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 06:34:12 -04:00
14 changed files with 930 additions and 54 deletions

View File

@@ -218,7 +218,7 @@ anthropic:
That's the whole configuration. Pallas auto-detects the That's the whole configuration. Pallas auto-detects the
`bedrock-mantle` hostname in `anthropic.base_url` at startup and installs `bedrock-mantle` hostname in `anthropic.base_url` at startup and installs
two compatibility shims so fast-agent's default request shape matches four compatibility shims so fast-agent's default request shape matches
what Mantle expects (see `pallas/mantle_shims.py`): what Mantle expects (see `pallas/mantle_shims.py`):
1. **Wire-name prefix** — re-adds the `anthropic.` prefix that fast-agent's 1. **Wire-name prefix** — re-adds the `anthropic.` prefix that fast-agent's
@@ -233,6 +233,25 @@ what Mantle expects (see `pallas/mantle_shims.py`):
Input should be a valid dictionary or object"`, which would otherwise Input should be a valid dictionary or object"`, which would otherwise
break the MCP tool-use loop on the second turn. break the MCP tool-use loop on the second turn.
3. **Fine-grained tool streaming opt-out** — stops fast-agent sending the
`fine-grained-tool-streaming-2025-05-14` beta. Under that beta an
output-token cutoff mid-`tool_use` block ends the stream without
`content_block_stop`, which fast-agent surfaces as
`Streaming completed but tool call never finished` and then retries
into the same wall (~700 s agent failures on large tool bodies).
Without the beta a cutoff closes blocks properly and lands in
fast-agent's graceful `stop_reason=max_tokens` handling.
4. **`max_tokens` clamp** — Mantle enforces a server-side output ceiling
of 20 000 tokens per response regardless of the requested
`max_tokens` (fast-agent asks for the model's full 128 000). The shim
clamps default `maxTokens` to `MANTLE_MAX_OUTPUT_TOKENS` (20 000) so
the model stops gracefully at the limit instead of the gateway
cutting the stream. A single agent turn — thinking, prose, and tool
input combined — cannot exceed this on Mantle; agents that must emit
more output in one turn need to split the work (e.g. patch-style
edits instead of full-document rewrites).
The Anthropic SDK appends `/v1/messages` to `base_url` automatically. The Anthropic SDK appends `/v1/messages` to `base_url` automatically.
**Feature support.** Mantle accepts the same Messages API request shape **Feature support.** Mantle accepts the same Messages API request shape

View File

@@ -111,7 +111,7 @@ server.py main()
|---|---| |---|---|
| `pallas.server` | CLI entry point, configuration loading, agent lifecycle orchestration, model registration | | `pallas.server` | CLI entry point, configuration loading, agent lifecycle orchestration, model registration |
| `pallas.registry` | Starlette app serving `GET /.well-known/mcp/server.json` — builds the agent catalogue from `agents.yaml` + `fastagent.config.yaml` | | `pallas.registry` | Starlette app serving `GET /.well-known/mcp/server.json` — builds the agent catalogue from `agents.yaml` + `fastagent.config.yaml` |
| `pallas.multimodal_server` | `MultimodalAgentMCPServer``AgentMCPServer` subclass adding image attachment support and conversation history prompts | | `pallas.multimodal_server` | `MultimodalAgentMCPServer``AgentMCPServer` subclass adding image attachment support, tool-result image passthrough, and conversation history prompts |
| `pallas.health` | Two-layer health: startup LLM preflight validation + runtime `get_health` MCP tool with downstream server probing | | `pallas.health` | Two-layer health: startup LLM preflight validation + runtime `get_health` MCP tool with downstream server probing |
--- ---
@@ -191,10 +191,13 @@ 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_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 |
| `agents.<name>.max_iterations` | no | Hard cap on agentic-loop turns per `send_message`. Default: `15`. fast-agent returns a partial answer once exceeded | | `agents.<name>.max_iterations` | no | Hard cap on agentic-loop turns per `send_message`. Default: `15`. fast-agent returns a partial answer once exceeded |
| `agents.<name>.loop_repeat_threshold` | no | Halt the loop after this many consecutive identical `(tool, args) → result` rounds. Default: `3`. `0` disables the guard | | `agents.<name>.loop_repeat_threshold` | no | Halt the loop after this many consecutive identical `(tool, args) → result` rounds. Default: `3`. `0` disables the guard |
| `agents.<name>.max_result_images` | no | Cap on tool-result images forwarded in the final `send_message` result (most recent kept). Default: `8`. `0` disables image passthrough |
### `fastagent.config.yaml` Extensions ### `fastagent.config.yaml` Extensions
@@ -428,7 +431,9 @@ Built dynamically from `agents.yaml` + `fastagent.config.yaml`:
### Capabilities ### Capabilities
If `model_capabilities` is defined in `fastagent.config.yaml`, each registry entry includes a `capabilities` object with model name, vision support, context window, and max output tokens. This allows clients to make informed decisions about what an agent can handle. Each registry entry includes a `capabilities` object model name, vision support, context window, and max output tokens — whenever a model is known for that agent, i.e. `agents.<name>.model` is set or `default_model` is defined in `fastagent.config.yaml`.
Capabilities are resolved **per agent**: the agent's own `model` and `model_capabilities` take precedence over the global values, and omitted fields fall back to the same defaults Pallas uses to register the model (`vision: false`, `context_window: 131072`, `max_output_tokens: 16384`). The published values therefore match what was actually registered with fast-agent's `ModelDatabase`, rather than being null whenever `model_capabilities` was left out. Clients use them to make informed decisions about what an agent can handle.
--- ---
@@ -447,6 +452,24 @@ 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
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.
Only the most recent `max_result_images` (default `8`) are forwarded — screenshots are usually taken to show final state, and an unbounded loop of full-desktop captures would balloon the HTTP response. Forwarded images are counted in `pallas_result_images_total`; drops are logged.
Because a lead agent receives a sub-agent's images as ordinary tool-result content, the same hook on the lead's own turn forwards them again — images cascade hop-by-hop up the delegation chain (playwright → dolores → lead → Daedalus) with no extra wiring.
### Conversation History Prompt ### Conversation History Prompt
For agents with `instance_scope != "request"`, a `{agent}_history` prompt is registered that returns the full conversation history as FastMCP `Message` objects. This allows clients to retrieve the stored context. For agents with `instance_scope != "request"`, a `{agent}_history` prompt is registered that returns the full conversation history as FastMCP `Message` objects. This allows clients to retrieve the stored context.
@@ -606,6 +629,7 @@ scrape_configs:
| `pallas_llm_provider_up` | gauge | `provider` | `1` when the active LLM provider passed its last preflight or runtime re-probe | | `pallas_llm_provider_up` | gauge | `provider` | `1` when the active LLM provider passed its last preflight or runtime re-probe |
| `pallas_agent_health_status` | gauge | `agent` | Aggregate from the last `get_health`: `1`=ok, `0.5`=degraded, `0`=error | | `pallas_agent_health_status` | gauge | `agent` | Aggregate from the last `get_health`: `1`=ok, `0.5`=degraded, `0`=error |
| `pallas_agent_loop_aborted_total` | counter | `agent`, `reason` | Agentic loops force-stopped by a runtime guard. `reason``repeat` (identical-tool-call loop detected) | | `pallas_agent_loop_aborted_total` | counter | `agent`, `reason` | Agentic loops force-stopped by a runtime guard. `reason``repeat` (identical-tool-call loop detected) |
| `pallas_result_images_total` | counter | `agent` | Tool-result images forwarded to the MCP caller in `send_message` results |
Standard process metrics (RSS, CPU, GC, open FDs) are emitted by `prometheus-client`'s default collectors on the same endpoint. Standard process metrics (RSS, CPU, GC, open FDs) are emitted by `prometheus-client`'s default collectors on the same endpoint.
@@ -663,12 +687,13 @@ Pallas registers models not in fast-agent's built-in `ModelDatabase` at startup,
The process: The process:
1. Read `default_model` and `model_capabilities` from config 1. Read `default_model` and `model_capabilities` from config
2. Extract the model name (portion after the provider prefix dot) 2. Also read every `agents.<name>.model` from `agents.yaml`, using that agent's own `model_capabilities` when it declares one
3. Check if `ModelDatabase` already knows this model — if so, skip 3. Extract the model name (portion after the provider prefix dot)
4. Register with `ModelDatabase.register_runtime_model_params()`: 4. Check if `ModelDatabase` already knows this model — if so, skip
5. Register with `ModelDatabase.register_runtime_model_params()`:
- `vision: true` → multimodal tokenization (`QWEN_MULTIMODAL`) - `vision: true` → multimodal tokenization (`QWEN_MULTIMODAL`)
- `vision: false` → text-only tokenization (`TEXT_ONLY`) - `vision: false` → text-only tokenization (`TEXT_ONLY`)
- `context_window` and `max_output_tokens` from config (with sensible defaults) - `context_window` and `max_output_tokens` from config, defaulting to `131072` / `16384`the same values the registry advertises
This avoids the brittle pattern of inferring capabilities from model name substrings, which breaks for custom or fine-tuned models with non-standard names. This avoids the brittle pattern of inferring capabilities from model name substrings, which breaks for custom or fine-tuned models with non-standard names.
@@ -683,6 +708,7 @@ This avoids the brittle pattern of inferring capabilities from model name substr
| `pallas.multimodal_server` | `multimodal_server.py` | `MultimodalAgentMCPServer` — extends `AgentMCPServer` with image support, conversation history prompts, bearer token propagation | | `pallas.multimodal_server` | `multimodal_server.py` | `MultimodalAgentMCPServer` — extends `AgentMCPServer` with image support, conversation history prompts, bearer token propagation |
| `pallas.health` | `health.py` | LLM provider preflight validation, downstream MCP server probing, `get_health` tool registration | | `pallas.health` | `health.py` | LLM provider preflight validation, downstream MCP server probing, `get_health` tool registration |
| `pallas.loop_guard` | `loop_guard.py` | Per-request `ToolRunnerHooks` that halt the agentic loop on repeated-identical tool calls | | `pallas.loop_guard` | `loop_guard.py` | Per-request `ToolRunnerHooks` that halt the agentic loop on repeated-identical tool calls |
| `pallas.image_passthrough` | `image_passthrough.py` | Per-request `ToolRunnerHooks` that collect tool-result images so `send_message` can return them in the final `CallToolResult` |
| `pallas.log` | `log.py` | JSON log configuration, third-party traceback capture, Rich-TUI-safe handler attachment | | `pallas.log` | `log.py` | JSON log configuration, third-party traceback capture, Rich-TUI-safe handler attachment |
| `pallas._fastagent_patch` | `_fastagent_patch.py` | Monkey-patches fast-agent at import time: per-request bearer forwarding via `httpx.Auth`, diagnostic trace-capture wrappers around `send_request` / `session.call_tool` / `_execute_on_server` | | `pallas._fastagent_patch` | `_fastagent_patch.py` | Monkey-patches fast-agent at import time: per-request bearer forwarding via `httpx.Auth`, diagnostic trace-capture wrappers around `send_request` / `session.call_tool` / `_execute_on_server` |

View File

@@ -120,7 +120,7 @@ No authentication. No query parameters.
| `servers[].server.version` | string | no | Semver version string. | | `servers[].server.version` | string | no | Semver version string. |
| `servers[].server.icons` | array | no | Array of `{ src, sizes }`. Daedalus uses the first entry. | | `servers[].server.icons` | array | no | Array of `{ src, sizes }`. Daedalus uses the first entry. |
| `servers[].server.remotes` | array | yes | Connection endpoints. Daedalus looks for `type: "streamable-http"` and uses its `url`. | | `servers[].server.remotes` | array | yes | Connection endpoints. Daedalus looks for `type: "streamable-http"` and uses its `url`. |
| `servers[].server.capabilities` | object | no | Model capabilities. Contains `model` (string), `vision` (bool), `context_window` (int), `max_output_tokens` (int). Published when `model_capabilities` is configured in `fastagent.config.yaml`. | | `servers[].server.capabilities` | object | no | Model capabilities. Contains `model` (string), `vision` (bool), `context_window` (int), `max_output_tokens` (int). Published whenever a model is known for the agent (`agents.<name>.model` or `default_model`); resolved per agent, with omitted fields falling back to the defaults Pallas registered the model with. |
| `servers[]._meta` | object | no | Registry metadata. Informational only — Daedalus does not act on it. | | `servers[]._meta` | object | no | Registry metadata. Informational only — Daedalus does not act on it. |
#### Behaviour #### Behaviour

146
pallas/image_passthrough.py Normal file
View File

@@ -0,0 +1,146 @@
"""Tool-result image passthrough for ``send_message``.
fast-agent's ``agent.send()`` returns only the final assistant text, so
``ImageContent`` produced by downstream tools during the agentic loop —
playwright screenshots, rommie desktop captures — reaches the agent's own
vision model but never crosses the MCP boundary. The caller (Daedalus, or a
lead agent driving a sub-agent) sees a text-only ``CallToolResult`` and the
live stream reduces result images to a ``"+N images"`` preview.
This module fixes that by installing a per-request ``after_tool_call`` hook
(same composition pattern as ``assistant_stream`` / ``loop_guard``) that
collects every ``ImageContent`` block from the turn's tool results. At end of
turn ``multimodal_server.send_message`` appends the collected images to the
final ``CallToolResult`` via FastMCP's ``ToolResult`` passthrough, after the
assistant's text block. Daedalus already renders image blocks in final
results; a lead agent calling a sub-agent receives them as ordinary
tool-result content, so images cascade hop-by-hop up the delegation chain.
Only the most recent ``max_images`` are forwarded — a long loop that grabs a
screenshot per iteration would otherwise balloon the HTTP response — and the
final state is what the screenshots are usually for. Dropped images are
logged; forwarded ones are counted in ``pallas_result_images_total``.
"""
from __future__ import annotations
import logging
from typing import Any
from fast_agent.agents.tool_runner import ToolRunnerHooks
from fast_agent.types import PromptMessageExtended
from fastmcp.tools import ToolResult
from mcp.types import ImageContent, TextContent
from pallas.assistant_stream import _merge_hooks
logger = logging.getLogger("pallas.image_passthrough")
DEFAULT_MAX_IMAGES = 8
class ImageCollector:
"""Per-request accumulator of tool-result ``ImageContent`` blocks."""
def __init__(
self,
*,
agent_name: str,
conversation_id: str | None,
max_images: int = DEFAULT_MAX_IMAGES,
) -> None:
self._agent_name = agent_name
self._conversation_id = conversation_id
self._max_images = max_images
self._images: list[ImageContent] = []
def as_after_tool_call_hook(self):
async def _hook(_runner: Any, message: PromptMessageExtended) -> None:
try:
for result in (message.tool_results or {}).values():
for block in getattr(result, "content", None) or []:
if isinstance(block, ImageContent):
self._images.append(block)
except Exception: # never let collection break a live turn
logger.warning(
"image_passthrough collection failed",
exc_info=True,
extra={
"agent": self._agent_name,
"conversation_id": self._conversation_id,
},
)
return _hook
def collected(self) -> list[ImageContent]:
"""Return the images to forward — the most recent ``max_images``.
When the loop produced more than the cap, the oldest are dropped:
screenshots are usually taken to show the *final* state, and the
intermediate ones were already summarized on the live stream.
"""
if len(self._images) <= self._max_images:
return list(self._images)
dropped = len(self._images) - self._max_images
logger.warning(
"image_passthrough dropping oldest tool-result images",
extra={
"agent": self._agent_name,
"conversation_id": self._conversation_id,
"collected": len(self._images),
"forwarded": self._max_images,
"dropped": dropped,
},
)
return self._images[-self._max_images :]
def build_result(text: str, images: list[ImageContent]) -> str | ToolResult:
"""Assemble ``send_message``'s return value.
With no images the plain string is returned so the wire shape is
byte-identical to previous releases. With images, an explicit FastMCP
``ToolResult`` carries ``[text, *images]`` content blocks — FastMCP
passes it through verbatim, bypassing return-annotation conversion.
"""
if not images:
return text
content: list[TextContent | ImageContent] = []
if text:
content.append(TextContent(type="text", text=text))
content.extend(images)
return ToolResult(content=content)
def install_for_request(
agent: Any,
*,
agent_name: str,
conversation_id: str | None,
max_images: int = DEFAULT_MAX_IMAGES,
):
"""Install the image collector on a request-scoped agent instance.
Returns ``(collector, restore)``. Call ``restore`` in a ``finally`` like
the other per-request hooks. A non-positive ``max_images`` disables
passthrough entirely (returns ``(None, no-op)``).
"""
if max_images is None or max_images < 1:
return None, lambda: None
collector = ImageCollector(
agent_name=agent_name,
conversation_id=conversation_id,
max_images=max_images,
)
extra = ToolRunnerHooks(after_tool_call=collector.as_after_tool_call_hook())
previous = getattr(agent, "tool_runner_hooks", None)
agent.tool_runner_hooks = _merge_hooks(previous, extra)
def restore() -> None:
agent.tool_runner_hooks = previous
return collector, restore

View File

@@ -24,7 +24,23 @@ before the wire traffic is valid:
Upstream SDK tracker: https://github.com/anthropics/anthropic-sdk-python/issues/1454 Upstream SDK tracker: https://github.com/anthropics/anthropic-sdk-python/issues/1454
Both shims are idempotent and may be installed at process startup before any 3. **Fine-grained tool streaming truncation.** Fast-agent unconditionally
sends the ``fine-grained-tool-streaming-2025-05-14`` beta on tool-bearing
requests. Under that beta, an output-token cutoff mid-``tool_use`` block
ends the stream *without* ``content_block_stop``, which fast-agent's
stream accounting surfaces as ``Streaming completed but tool call never
finished`` — followed by a full retry ladder against the same wall
(observed as ~700 s agent failures on large ``revise_workspace_file``
bodies). We disable that one beta so a cutoff closes blocks properly and
lands in fast-agent's graceful ``stop_reason=max_tokens`` handling.
4. **Output-token ceiling.** Mantle clamps ``max_tokens`` to 20 000
server-side (streams complete at exactly 20 000 output tokens regardless
of the requested 128 000). We clamp the request to that ceiling so the
*model* stops gracefully at the limit — emitting proper block-stop events
and ``stop_reason`` — instead of being cut off by the gateway's clamp.
All shims are idempotent and may be installed at process startup before any
fast-agent ``FastAgent`` instance is constructed. fast-agent ``FastAgent`` instance is constructed.
""" """
from __future__ import annotations from __future__ import annotations
@@ -42,6 +58,8 @@ logger = logging.getLogger(__name__)
MANTLE_WIRE_NAMES: dict[str, str] = { MANTLE_WIRE_NAMES: dict[str, str] = {
"claude-haiku-4-5": "anthropic.claude-haiku-4-5", "claude-haiku-4-5": "anthropic.claude-haiku-4-5",
"claude-opus-4-7": "anthropic.claude-opus-4-7", "claude-opus-4-7": "anthropic.claude-opus-4-7",
"claude-opus-4-8": "anthropic.claude-opus-4-8",
"claude-fable-5": "anthropic.claude-fable-5",
} }
@@ -127,12 +145,90 @@ def install_tool_use_caller_strip() -> None:
logger.info("Mantle tool_use.caller strip shim installed") logger.info("Mantle tool_use.caller strip shim installed")
# ── Shim 3: disable fine-grained tool streaming ──────────────────────────────
_beta_opt_out_installed = False
def install_fine_grained_tool_streaming_opt_out() -> None:
"""Stop fast-agent requesting the fine-grained tool streaming beta.
Under that beta a ``max_tokens`` cutoff mid-``tool_use`` ends the stream
without ``content_block_stop``; fast-agent then raises
``Streaming completed but tool call never finished`` and burns its whole
retry ladder against the same ceiling. Without the beta the cutoff closes
blocks properly and fast-agent's ``stop_reason=max_tokens`` handling
applies. Safe to call more than once.
"""
global _beta_opt_out_installed
if _beta_opt_out_installed:
return
from fast_agent.llm.provider.anthropic.llm_anthropic import AnthropicLLM
original_supports = AnthropicLLM.supports_direct_anthropic_beta
def patched_supports(self: Any, feature: str) -> bool:
if feature == "fine_grained_tool_streaming":
return False
return original_supports(self, feature)
AnthropicLLM.supports_direct_anthropic_beta = patched_supports # type: ignore[method-assign]
_beta_opt_out_installed = True
logger.info("Mantle fine-grained tool streaming opt-out shim installed")
# ── Shim 4: clamp max_tokens to Mantle's output ceiling ──────────────────────
# Observed server-side clamp: Mantle streams stop at exactly 20 000 output
# tokens however large the requested max_tokens. Requesting the ceiling
# explicitly makes the model stop gracefully (proper block close + stop_reason)
# instead of the gateway cutting the stream at its own limit.
MANTLE_MAX_OUTPUT_TOKENS = 20_000
_max_tokens_clamp_installed = False
def install_max_tokens_clamp() -> None:
"""Clamp default ``maxTokens`` to Mantle's output ceiling.
Wraps ``AnthropicLLM._initialize_default_params`` so every agent's
default request params carry an explicit ``maxTokens`` no higher than the
ceiling. Per-request ``RequestParams`` overrides bypass this — none of
our agent modules set one. Safe to call more than once.
"""
global _max_tokens_clamp_installed
if _max_tokens_clamp_installed:
return
from fast_agent.llm.provider.anthropic.llm_anthropic import AnthropicLLM
original_init = AnthropicLLM._initialize_default_params # noqa: SLF001
def patched_init(self: Any, kwargs: dict) -> Any:
params = original_init(self, kwargs)
if params.maxTokens is None or params.maxTokens > MANTLE_MAX_OUTPUT_TOKENS:
params.maxTokens = MANTLE_MAX_OUTPUT_TOKENS
return params
AnthropicLLM._initialize_default_params = patched_init # noqa: SLF001
_max_tokens_clamp_installed = True
logger.info(
"Mantle max_tokens clamp shim installed (ceiling %d)",
MANTLE_MAX_OUTPUT_TOKENS,
)
# ── Orchestrator ───────────────────────────────────────────────────────────── # ── Orchestrator ─────────────────────────────────────────────────────────────
def install_all() -> None: def install_all() -> None:
"""Install all Mantle shims. Call once at process startup.""" """Install all Mantle shims. Call once at process startup."""
install_wire_name_prefix() install_wire_name_prefix()
install_tool_use_caller_strip() install_tool_use_caller_strip()
install_fine_grained_tool_streaming_opt_out()
install_max_tokens_clamp()
def maybe_install(anthropic_base_url: str | None) -> bool: def maybe_install(anthropic_base_url: str | None) -> bool:

View File

@@ -138,6 +138,13 @@ agent_loop_aborted_total = Counter(
registry=REGISTRY, registry=REGISTRY,
) )
result_images_total = Counter(
"pallas_result_images_total",
"Tool-result images forwarded to the MCP caller in send_message results",
labelnames=["agent"],
registry=REGISTRY,
)
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
@@ -147,6 +154,11 @@ def record_loop_abort(agent: str, reason: str) -> None:
agent_loop_aborted_total.labels(agent=agent, reason=reason).inc() agent_loop_aborted_total.labels(agent=agent, reason=reason).inc()
def record_result_images(agent: str, count: int) -> None:
"""Record tool-result images forwarded in a send_message result."""
result_images_total.labels(agent=agent).inc(count)
def set_agent_info(agents: dict[str, dict]) -> None: def set_agent_info(agents: dict[str, dict]) -> None:
"""Record the deployment's configured agents (called once at startup).""" """Record the deployment's configured agents (called once at startup)."""
for name, agent in agents.items(): for name, agent in agents.items():

View File

@@ -9,7 +9,10 @@ Overrides register_agent_tools to:
so callers own conversation state and seed it on every turn, so callers own conversation state and seed it on every turn,
* accept an optional ``conversation_id`` string that is recorded in * accept an optional ``conversation_id`` string that is recorded in
structured logs and progress notification metadata for end-to-end structured logs and progress notification metadata for end-to-end
trace correlation. trace correlation,
* append images produced by downstream tools during the turn (playwright
screenshots, rommie desktop captures) to the final ``CallToolResult``
so they reach the MCP caller — see ``pallas.image_passthrough``.
Drop-in replacement for AgentMCPServer. When combined with Drop-in replacement for AgentMCPServer. When combined with
``instance_scope="request"`` (the Pallas default), this gives a fully ``instance_scope="request"`` (the Pallas default), this gives a fully
@@ -29,11 +32,17 @@ from fast_agent.mcp.server import AgentMCPServer
from fast_agent.types import PromptMessageExtended, RequestParams from fast_agent.types import PromptMessageExtended, RequestParams
from pallas.assistant_stream import install_for_request as _install_assistant_stream from pallas.assistant_stream import install_for_request as _install_assistant_stream
from pallas.image_passthrough import (
DEFAULT_MAX_IMAGES,
build_result as _build_image_result,
install_for_request as _install_image_passthrough,
)
from pallas.loop_guard import install_for_request as _install_loop_guard from pallas.loop_guard import install_for_request as _install_loop_guard
from pallas.progress import EnrichedMCPToolProgressManager from pallas.progress import EnrichedMCPToolProgressManager
from pallas import metrics as _pallas_metrics from pallas import metrics as _pallas_metrics
from fastmcp import Context as MCPContext from fastmcp import Context as MCPContext
from fastmcp.prompts import Message from fastmcp.prompts import Message
from fastmcp.tools import ToolResult
from mcp.types import ImageContent, TextContent from mcp.types import ImageContent, TextContent
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
@@ -188,7 +197,7 @@ class MultimodalAgentMCPServer(AgentMCPServer):
images: list[dict] | None = None, images: list[dict] | None = None,
history: list[dict] | None = None, history: list[dict] | None = None,
conversation_id: str | None = None, conversation_id: str | None = None,
) -> str: ) -> str | ToolResult:
"""Send a single turn to the agent. """Send a single turn to the agent.
Parameters Parameters
@@ -209,6 +218,12 @@ class MultimodalAgentMCPServer(AgentMCPServer):
conversation_id: conversation_id:
Optional opaque identifier, logged for trace correlation. Optional opaque identifier, logged for trace correlation.
Pallas does not interpret it. Pallas does not interpret it.
Returns the assistant's final text. When downstream tools
produced images this turn (screenshots etc.), returns a
``ToolResult`` whose content is the text block followed by the
most recent ``max_result_images`` image blocks, so they reach
the MCP caller instead of dying inside ``message_history``.
""" """
report_progress = self._build_progress_reporter(ctx) report_progress = self._build_progress_reporter(ctx)
request_params = RequestParams( request_params = RequestParams(
@@ -245,8 +260,20 @@ class MultimodalAgentMCPServer(AgentMCPServer):
conversation_id=conversation_id, conversation_id=conversation_id,
threshold=self._request_limits.get("loop_repeat_threshold", 3), threshold=self._request_limits.get("loop_repeat_threshold", 3),
) )
# Collect tool-result images (screenshots) so the final
# CallToolResult can carry them to the caller — fast-agent's
# send() return value is text-only.
image_collector, restore_images = _install_image_passthrough(
agent,
agent_name=agent_name,
conversation_id=conversation_id,
max_images=self._request_limits.get(
"max_result_images", DEFAULT_MAX_IMAGES
),
)
def restore_hooks() -> None: def restore_hooks() -> None:
restore_images()
restore_guard() restore_guard()
restore_stream() restore_stream()
try: try:
@@ -314,7 +341,25 @@ class MultimodalAgentMCPServer(AgentMCPServer):
return await execute_send() return await execute_send()
try: try:
return await asyncio.wait_for(_dispatch(), timeout=turn_timeout) response = await asyncio.wait_for(
_dispatch(), timeout=turn_timeout
)
images = (
image_collector.collected() if image_collector else []
)
if images:
_pallas_metrics.record_result_images(
agent_name, len(images)
)
logger.debug(
f"Forwarding {len(images)} tool-result image(s) "
f"from agent '{agent_name}'",
name="result_images_forwarded",
agent=agent_name,
image_count=len(images),
conversation_id=conversation_id,
)
return _build_image_result(response, images)
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning( logger.warning(
f"Agent '{agent_name}' turn exceeded {turn_timeout}s wall-clock limit", f"Agent '{agent_name}' turn exceeded {turn_timeout}s wall-clock limit",

View File

@@ -22,11 +22,13 @@ import yaml
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from starlette.applications import Starlette from starlette.applications import Starlette
from pallas.metrics import REGISTRY as _metrics_registry, set_agent_info
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, Response from starlette.responses import JSONResponse, PlainTextResponse, Response
from starlette.routing import Route from starlette.routing import Route
from pallas.metrics import REGISTRY as _metrics_registry, set_agent_info
from pallas.server import DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_OUTPUT_TOKENS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,37 +47,53 @@ def _load_deployment_config() -> dict:
return yaml.safe_load(f) or {} return yaml.safe_load(f) or {}
def _load_model_capabilities() -> dict: def _load_fastagent_defaults() -> tuple[str, dict]:
"""Read model info and capabilities from the active fastagent.config.yaml.""" """Read ``(default_model, model_capabilities)`` from fastagent.config.yaml."""
config_path = _config_root() / "fastagent.config.yaml" config_path = _config_root() / "fastagent.config.yaml"
if not config_path.exists(): if not config_path.exists():
return {} return "", {}
with open(config_path) as f: with open(config_path) as f:
config = yaml.safe_load(f) or {} config = yaml.safe_load(f) or {}
default_model = config.get("default_model", "") return (
capabilities = config.get("model_capabilities", {}) config.get("default_model", "") or "",
config.get("model_capabilities", {}) or {},
if not default_model and not capabilities:
return {}
model_name = (
default_model.split(".", 1)[-1] if "." in default_model else default_model
) )
def _resolve_capabilities(
agent: dict, default_model: str, default_capabilities: dict
) -> dict | None:
"""Resolve the capabilities Pallas actually registered for one agent.
Mirrors ``server._register_unknown_models``: an agent's own ``model``
in agents.yaml wins over the global ``default_model``, its own
``model_capabilities`` block wins over the global one, and the same
defaults apply — so the registry advertises the effective values rather
than nulls. Returns ``None`` when no model is known for this agent.
"""
model_spec = agent.get("model") or default_model
if not model_spec:
return None
capabilities = agent.get("model_capabilities") or default_capabilities
model_name = model_spec.split(".", 1)[-1] if "." in model_spec else model_spec
return { return {
"model": model_name or None, "model": model_name,
"vision": capabilities.get("vision", False), "vision": capabilities.get("vision", False),
"context_window": capabilities.get("context_window", None), "context_window": capabilities.get("context_window", DEFAULT_CONTEXT_WINDOW),
"max_output_tokens": capabilities.get("max_output_tokens", None), "max_output_tokens": capabilities.get(
"max_output_tokens", DEFAULT_MAX_OUTPUT_TOKENS
),
} }
def _build_registry(config: dict) -> dict: def _build_registry(config: dict) -> dict:
"""Build the registry JSON from agents.yaml + fastagent.config.yaml.""" """Build the registry JSON from agents.yaml + fastagent.config.yaml."""
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
model_caps = _load_model_capabilities() default_model, default_capabilities = _load_fastagent_defaults()
host = config.get("host", "localhost") host = config.get("host", "localhost")
namespace = config.get("namespace", "") namespace = config.get("namespace", "")
@@ -101,8 +119,9 @@ def _build_registry(config: dict) -> dict:
} }
], ],
} }
if model_caps: capabilities = _resolve_capabilities(agent, default_model, default_capabilities)
server_entry["capabilities"] = model_caps if capabilities:
server_entry["capabilities"] = capabilities
entries.append( entries.append(
{ {

View File

@@ -24,6 +24,12 @@ from pallas.multimodal_server import MultimodalAgentMCPServer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Effective model capability defaults, applied when `model_capabilities` omits
# them. registry.py mirrors these so the published registry advertises what
# was actually registered with fast-agent's ModelDatabase.
DEFAULT_CONTEXT_WINDOW = 131072
DEFAULT_MAX_OUTPUT_TOKENS = 16384
def _config_root() -> Path: def _config_root() -> Path:
"""Return the working directory where agents.yaml and fastagent configs live.""" """Return the working directory where agents.yaml and fastagent configs live."""
@@ -67,6 +73,7 @@ def _build_agents_table(config: dict) -> dict[str, dict]:
"streaming_timeout": agent.get("streaming_timeout"), "streaming_timeout": agent.get("streaming_timeout"),
"turn_timeout": agent.get("turn_timeout"), "turn_timeout": agent.get("turn_timeout"),
"loop_repeat_threshold": agent.get("loop_repeat_threshold"), "loop_repeat_threshold": agent.get("loop_repeat_threshold"),
"max_result_images": agent.get("max_result_images"),
} }
for name, agent in config["agents"].items() for name, agent in config["agents"].items()
} }
@@ -142,8 +149,8 @@ def _register_one_model(model_spec: str, capabilities: dict) -> None:
return return
is_vision = capabilities.get("vision", False) is_vision = capabilities.get("vision", False)
context_window = capabilities.get("context_window", 131072) context_window = capabilities.get("context_window", DEFAULT_CONTEXT_WINDOW)
max_output_tokens = capabilities.get("max_output_tokens", 16384) max_output_tokens = capabilities.get("max_output_tokens", DEFAULT_MAX_OUTPUT_TOKENS)
if is_vision: if is_vision:
tokenizes = list(ModelDatabase.QWEN_MULTIMODAL) tokenizes = list(ModelDatabase.QWEN_MULTIMODAL)
@@ -271,16 +278,31 @@ async def _start_agent(name: str, agents: dict[str, dict]) -> None:
"streaming_timeout", "streaming_timeout",
"turn_timeout", "turn_timeout",
"loop_repeat_threshold", "loop_repeat_threshold",
"max_result_images",
) )
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,

View File

@@ -1,10 +1,10 @@
[project] [project]
name = "pallas-mcp" name = "pallas-mcp"
version = "0.5.2" version = "0.7.0"
description = "FastAgent MCP Bridge — generic runtime for serving FastAgent agents over StreamableHTTP" description = "FastAgent MCP Bridge — generic runtime for serving FastAgent agents over StreamableHTTP"
requires-python = ">=3.13.5" requires-python = ">=3.13.5"
dependencies = [ dependencies = [
"fast-agent-mcp==0.7.15", "fast-agent-mcp==0.7.22",
"httpx", "httpx",
"prometheus-client", "prometheus-client",
"pyyaml", "pyyaml",

View File

@@ -0,0 +1,138 @@
"""Tests for ``pallas.image_passthrough``.
Drives the ``after_tool_call`` hook with handcrafted
``PromptMessageExtended`` objects and asserts the collection, cap, and
result-assembly behaviour. No fast-agent runtime is involved — the hook is
a pure async function. Uses ``asyncio.run`` directly to match the
convention in the other test modules (pallas has no pytest-asyncio
dependency).
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from fast_agent.types import PromptMessageExtended
from fastmcp.tools import ToolResult
from mcp.types import CallToolResult, ImageContent, TextContent
from pallas.image_passthrough import (
ImageCollector,
build_result,
install_for_request,
)
def _run(coro):
return asyncio.run(coro)
def _image(tag: str) -> ImageContent:
return ImageContent(type="image", data=f"b64-{tag}", mimeType="image/png")
def _result_message(*blocks, call_id: str = "toolu_1") -> PromptMessageExtended:
return PromptMessageExtended(
role="user",
content=[],
tool_results={call_id: CallToolResult(content=list(blocks))},
)
def _collector(max_images: int = 8) -> ImageCollector:
return ImageCollector(
agent_name="dolores", conversation_id="c1", max_images=max_images
)
def test_collects_images_and_ignores_text():
collector = _collector()
hook = collector.as_after_tool_call_hook()
async def go():
await hook(None, _result_message(TextContent(type="text", text="snapshot")))
await hook(None, _result_message(_image("a")))
await hook(
None,
_result_message(TextContent(type="text", text="took it"), _image("b")),
)
_run(go())
assert [i.data for i in collector.collected()] == ["b64-a", "b64-b"]
def test_collects_across_multiple_results_in_one_message():
collector = _collector()
hook = collector.as_after_tool_call_hook()
message = PromptMessageExtended(
role="user",
content=[],
tool_results={
"toolu_1": CallToolResult(content=[_image("a")]),
"toolu_2": CallToolResult(content=[_image("b")]),
},
)
_run(hook(None, message))
assert len(collector.collected()) == 2
def test_cap_keeps_most_recent():
collector = _collector(max_images=2)
hook = collector.as_after_tool_call_hook()
async def go():
for tag in ("a", "b", "c", "d"):
await hook(None, _result_message(_image(tag)))
_run(go())
assert [i.data for i in collector.collected()] == ["b64-c", "b64-d"]
def test_empty_tool_results_are_harmless():
collector = _collector()
hook = collector.as_after_tool_call_hook()
message = PromptMessageExtended(role="user", content=[], tool_results=None)
_run(hook(None, message))
assert collector.collected() == []
def test_build_result_plain_text_when_no_images():
assert build_result("answer", []) == "answer"
def test_build_result_wraps_text_and_images():
result = build_result("answer", [_image("a"), _image("b")])
assert isinstance(result, ToolResult)
assert [b.type for b in result.content] == ["text", "image", "image"]
assert result.content[0].text == "answer"
assert result.content[1].data == "b64-a"
def test_build_result_omits_empty_text_block():
result = build_result("", [_image("a")])
assert isinstance(result, ToolResult)
assert [b.type for b in result.content] == ["image"]
def test_install_disabled_with_nonpositive_max():
agent = SimpleNamespace(tool_runner_hooks="sentinel")
collector, restore = install_for_request(
agent, agent_name="a", conversation_id=None, max_images=0
)
assert collector is None
assert agent.tool_runner_hooks == "sentinel" # untouched
restore() # no-op, must not raise
def test_install_merges_and_restores():
agent = SimpleNamespace(tool_runner_hooks=None)
collector, restore = install_for_request(
agent, agent_name="a", conversation_id=None
)
assert collector is not None
assert agent.tool_runner_hooks is not None
assert agent.tool_runner_hooks.after_tool_call is not None
restore()
assert agent.tool_runner_hooks is None

View File

@@ -114,34 +114,97 @@ def test_install_tool_use_caller_strip_is_idempotent() -> None:
mantle_shims.install_tool_use_caller_strip() # must not raise or re-wrap mantle_shims.install_tool_use_caller_strip() # must not raise or re-wrap
# ── install_fine_grained_tool_streaming_opt_out ──────────────────────────────
def test_fine_grained_beta_opt_out() -> None:
from fast_agent.llm.provider.anthropic.llm_anthropic import AnthropicLLM
mantle_shims.install_fine_grained_tool_streaming_opt_out()
# The patched method never touches self, so a bare object suffices.
stub = object()
assert AnthropicLLM.supports_direct_anthropic_beta(stub, "fine_grained_tool_streaming") is False
# Every other beta keeps the base-class answer (True).
assert AnthropicLLM.supports_direct_anthropic_beta(stub, "interleaved_thinking") is True
assert AnthropicLLM.supports_direct_anthropic_beta(stub, "long_context") is True
def test_fine_grained_beta_opt_out_is_idempotent() -> None:
mantle_shims.install_fine_grained_tool_streaming_opt_out()
mantle_shims.install_fine_grained_tool_streaming_opt_out() # must not re-wrap
from fast_agent.llm.provider.anthropic.llm_anthropic import AnthropicLLM
assert (
AnthropicLLM.supports_direct_anthropic_beta(object(), "fine_grained_tool_streaming")
is False
)
# ── install_max_tokens_clamp ─────────────────────────────────────────────────
@pytest.mark.parametrize(
"initial,expected",
[
(128000, mantle_shims.MANTLE_MAX_OUTPUT_TOKENS), # over the ceiling → clamped
(None, mantle_shims.MANTLE_MAX_OUTPUT_TOKENS), # unset → pinned to ceiling
(4096, 4096), # under the ceiling → untouched
],
)
def test_max_tokens_clamp(
monkeypatch: pytest.MonkeyPatch, initial: int | None, expected: int
) -> None:
from fast_agent.llm.provider.anthropic.llm_anthropic import AnthropicLLM
from fast_agent.types import RequestParams
# Stub the underlying initializer, then force a fresh wrap around it.
monkeypatch.setattr(
AnthropicLLM,
"_initialize_default_params",
lambda self, kwargs: RequestParams(maxTokens=initial),
)
monkeypatch.setattr(mantle_shims, "_max_tokens_clamp_installed", False)
mantle_shims.install_max_tokens_clamp()
params = AnthropicLLM._initialize_default_params(object(), {})
assert params.maxTokens == expected
def test_max_tokens_clamp_is_idempotent() -> None:
mantle_shims.install_max_tokens_clamp()
mantle_shims.install_max_tokens_clamp() # must not raise or re-wrap
# ── maybe_install ──────────────────────────────────────────────────────────── # ── maybe_install ────────────────────────────────────────────────────────────
def test_maybe_install_installs_when_mantle(monkeypatch: pytest.MonkeyPatch) -> None: _INSTALLER_NAMES = [
("install_wire_name_prefix", "wire"),
("install_tool_use_caller_strip", "tool_use"),
("install_fine_grained_tool_streaming_opt_out", "beta_opt_out"),
("install_max_tokens_clamp", "max_tokens"),
]
def _patch_installers(monkeypatch: pytest.MonkeyPatch) -> list[str]:
calls: list[str] = [] calls: list[str] = []
monkeypatch.setattr( for attr, label in _INSTALLER_NAMES:
mantle_shims, "install_wire_name_prefix", monkeypatch.setattr(
lambda: calls.append("wire"), mantle_shims, attr,
) lambda label=label: calls.append(label),
monkeypatch.setattr( )
mantle_shims, "install_tool_use_caller_strip", return calls
lambda: calls.append("tool_use"),
)
def test_maybe_install_installs_when_mantle(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _patch_installers(monkeypatch)
installed = mantle_shims.maybe_install("https://bedrock-mantle.us-east-1.api.aws/anthropic") installed = mantle_shims.maybe_install("https://bedrock-mantle.us-east-1.api.aws/anthropic")
assert installed is True assert installed is True
assert calls == ["wire", "tool_use"] assert calls == ["wire", "tool_use", "beta_opt_out", "max_tokens"]
def test_maybe_install_noop_for_non_mantle(monkeypatch: pytest.MonkeyPatch) -> None: def test_maybe_install_noop_for_non_mantle(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = [] calls = _patch_installers(monkeypatch)
monkeypatch.setattr(
mantle_shims, "install_wire_name_prefix",
lambda: calls.append("wire"),
)
monkeypatch.setattr(
mantle_shims, "install_tool_use_caller_strip",
lambda: calls.append("tool_use"),
)
assert mantle_shims.maybe_install("https://api.anthropic.com") is False assert mantle_shims.maybe_install("https://api.anthropic.com") is False
assert mantle_shims.maybe_install(None) is False assert mantle_shims.maybe_install(None) is False

202
tests/test_registry.py Normal file
View File

@@ -0,0 +1,202 @@
"""Tests for pallas.registry — per-agent model capability resolution.
The registry advertises the capabilities Pallas actually registered with
fast-agent, resolved *per agent*: an agent's own ``model`` /
``model_capabilities`` in agents.yaml override the global ``default_model`` /
``model_capabilities`` in fastagent.config.yaml, and the same effective
defaults as ``server._register_one_model`` apply when a field is omitted.
Regression cover for two bugs: a single capabilities dict was previously
built from ``default_model`` and attached to *every* agent entry (so an
agent with a ``model:`` override was advertised under the wrong model), and
``context_window`` / ``max_output_tokens`` were published as ``null`` when
``model_capabilities`` was absent even though the model had been registered
with 131072 / 16384.
``_build_registry`` takes the deployment config as an argument but reads
fastagent.config.yaml from the working directory on every call, so each test
chdirs into a clean temp workspace and writes only the config it needs.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from pallas import registry
from pallas.server import DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_OUTPUT_TOKENS
# ── Helpers ──────────────────────────────────────────────────────────────────
@pytest.fixture
def workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Chdir into a clean temp workspace — _build_registry reads cwd."""
monkeypatch.chdir(tmp_path)
return tmp_path
def _write_fastagent(workspace: Path, **config) -> None:
(workspace / "fastagent.config.yaml").write_text(yaml.safe_dump(config))
def _deployment(**agents) -> dict:
"""Minimal agents.yaml-shaped config; each agent needs at least a port."""
return {
"name": "test-project",
"namespace": "ca.helu.test",
"host": "test-host",
"agents": {
name: {"module": f"agents.{name}", "port": 9000 + i, **overrides}
for i, (name, overrides) in enumerate(agents.items())
},
}
def _entries(config: dict) -> dict[str, dict]:
"""Build the registry and return ``{agent slug: server entry}``."""
servers = registry._build_registry(config)["servers"]
return {e["server"]["name"].rsplit("/", 1)[-1]: e["server"] for e in servers}
def _capabilities(config: dict, agent: str = "solo") -> dict | None:
return _entries(config)[agent].get("capabilities")
# ── Model resolution ─────────────────────────────────────────────────────────
def test_agent_model_overrides_default_model(workspace: Path) -> None:
"""An agents.yaml ``model:`` wins over the global default_model."""
_write_fastagent(workspace, default_model="openai.global-model")
caps = _capabilities(_deployment(solo={"model": "anthropic.agent-model"}))
assert caps is not None
assert caps["model"] == "agent-model"
def test_falls_back_to_default_model(workspace: Path) -> None:
"""With no per-agent model, the global default_model is advertised."""
_write_fastagent(workspace, default_model="anthropic.claude-opus-4-7")
caps = _capabilities(_deployment(solo={}))
assert caps is not None
# Provider prefix is stripped — clients get the bare model name.
assert caps["model"] == "claude-opus-4-7"
def test_model_without_provider_prefix_passes_through(workspace: Path) -> None:
"""A default_model with no ``provider.`` prefix is emitted verbatim."""
_write_fastagent(workspace, default_model="bare-model")
assert _capabilities(_deployment(solo={}))["model"] == "bare-model"
# ── Capability resolution ────────────────────────────────────────────────────
def test_agent_capabilities_override_global(workspace: Path) -> None:
"""A per-agent model_capabilities block replaces the global one."""
_write_fastagent(
workspace,
default_model="openai.global-model",
model_capabilities={"vision": False, "context_window": 200000},
)
caps = _capabilities(
_deployment(
solo={
"model": "anthropic.agent-model",
"model_capabilities": {"vision": True, "context_window": 400000},
}
)
)
assert caps["vision"] is True
assert caps["context_window"] == 400000
def test_effective_defaults_when_capabilities_absent(workspace: Path) -> None:
"""Absent model_capabilities publishes the values actually registered.
Regression: these were previously advertised as ``null`` while
``server._register_one_model`` registered 131072 / 16384.
"""
_write_fastagent(workspace, default_model="openai.some-model")
caps = _capabilities(_deployment(solo={}))
assert caps["context_window"] == DEFAULT_CONTEXT_WINDOW
assert caps["max_output_tokens"] == DEFAULT_MAX_OUTPUT_TOKENS
assert caps["vision"] is False
def test_global_capabilities_are_published(workspace: Path) -> None:
"""The ordinary case: one global model + capabilities for every agent."""
_write_fastagent(
workspace,
default_model="anthropic.claude-opus-4-7",
model_capabilities={
"vision": True,
"context_window": 200000,
"max_output_tokens": 50000,
},
)
caps = _capabilities(_deployment(solo={}))
assert caps == {
"model": "claude-opus-4-7",
"vision": True,
"context_window": 200000,
"max_output_tokens": 50000,
}
# ── Per-agent independence ───────────────────────────────────────────────────
def test_agents_resolve_independently(workspace: Path) -> None:
"""Each entry gets its own capabilities — the original shared-dict bug."""
_write_fastagent(
workspace,
default_model="openai.global-model",
model_capabilities={"vision": False, "context_window": 128000},
)
entries = _entries(
_deployment(
inherits={},
overrides={
"model": "anthropic.special-model",
"model_capabilities": {"vision": True, "context_window": 500000},
},
)
)
assert entries["inherits"]["capabilities"]["model"] == "global-model"
assert entries["inherits"]["capabilities"]["context_window"] == 128000
assert entries["overrides"]["capabilities"]["model"] == "special-model"
assert entries["overrides"]["capabilities"]["context_window"] == 500000
# ── Omission ─────────────────────────────────────────────────────────────────
def test_capabilities_omitted_without_any_model(workspace: Path) -> None:
"""No fastagent.config.yaml and no per-agent model → no capabilities key."""
entry = _entries(_deployment(solo={}))["solo"]
assert "capabilities" not in entry
def test_agent_model_published_without_fastagent_config(workspace: Path) -> None:
"""An agent's own model is advertised even with no fastagent.config.yaml."""
caps = _capabilities(_deployment(solo={"model": "anthropic.agent-model"}))
assert caps["model"] == "agent-model"
assert caps["context_window"] == DEFAULT_CONTEXT_WINDOW

View 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