7 Commits

Author SHA1 Message Date
84e026c2e7 Merge pull request '🐾 fix(mantle): stop installing the unproven 20k max_tokens clamp' (#9) from fix/drop-unproven-max-tokens-clamp into main
Reviewed-on: #9
2026-08-06 02:00:15 +00:00
d8703a1ad6 🐾 fix(mantle): stop installing the unproven 20k max_tokens clamp
The fine-grained-tool-streaming opt-out is what actually fixes the
"Streaming completed but tool call never finished" crash loop: under
that beta an output cutoff mid-tool_use ends the stream without
content_block_stop, fast-agent's tool tracker leaves the block open,
and _raise_for_incomplete_anthropic_tools raises a RuntimeError that
bypasses the graceful stop_reason=max_tokens path and burns the retry
ladder. That shim costs no output length and is kept.

The companion max_tokens clamp is not. Its 20 000 ceiling was an
empirical observation, never a documented Mantle limit, and
re-investigation could not establish what enforces it: fast-agent
carries no 20 000 default anywhere (the matching TASK_BUDGET_MIN_TOKENS
is a validation floor for a different, unconfigured feature), no model
overlay is configured, and ModelDatabase reports
max_output_tokens=128000 for opus-4-8. Installing it would cement a
ceiling we cannot prove and silently truncate turns that might
otherwise complete.

install_max_tokens_clamp() is left in place, unwired, so it can be
re-enabled if the limit is ever confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:55:10 -04:00
537f3c7963 Merge pull request '🐾 fix(mantle): survive Mantle's 20k output ceiling on tool-heavy turns' (#8) from feature/mantle-max-tokens-shim into main
Reviewed-on: #8
2026-08-04 20:54:12 +00:00
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
6 changed files with 327 additions and 23 deletions

View File

@@ -218,7 +218,7 @@ anthropic:
That's the whole configuration. Pallas auto-detects the
`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`):
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
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.
**Feature support.** Mantle accepts the same Messages API request shape

View File

@@ -191,7 +191,7 @@ agents:
| `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>.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 |
@@ -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.
#### 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.

View File

@@ -24,7 +24,23 @@ before the wire traffic is valid:
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.
"""
from __future__ import annotations
@@ -129,12 +145,104 @@ def install_tool_use_caller_strip() -> None:
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 ─────────────────────────────────────────────────────────────
def install_all() -> None:
"""Install all Mantle shims. Call once at process startup."""
"""Install all Mantle shims. Call once at process startup.
``install_max_tokens_clamp`` is deliberately NOT installed. Its 20 000
ceiling was an empirical observation, never a documented Mantle limit,
and re-investigation could not establish what enforces it: fast-agent
carries no 20 000 default anywhere, no model overlay is configured, and
``ModelDatabase`` reports ``max_output_tokens=128000`` for opus-4-8.
Hardcoding the constant would cement a ceiling we cannot prove and would
silently truncate turns that might otherwise complete. The shim is kept
below so it can be re-enabled if the limit is ever confirmed.
The fine-grained-tool-streaming opt-out is what actually fixes the
``Streaming completed but tool call never finished`` crash loop: it costs
no output length, it only lets a cutoff close its blocks properly and
land in fast-agent's graceful ``stop_reason=max_tokens`` handling.
"""
install_wire_name_prefix()
install_tool_use_caller_strip()
install_fine_grained_tool_streaming_opt_out()
def maybe_install(anthropic_base_url: str | None) -> bool:

View File

@@ -283,12 +283,26 @@ async def _start_agent(name: str, agents: dict[str, dict]) -> 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(
primary_instance=primary_instance,
create_instance=fast_instance._server_instance_factory,
dispose_instance=fast_instance._server_instance_dispose,
instance_scope="request",
server_name=f"{fast_instance.name}-MCP-Server",
tool_description=tool_description,
host="0.0.0.0",
get_registry_version=fast_instance._get_registry_version,
request_limits=request_limits,

View File

@@ -114,34 +114,99 @@ def test_install_tool_use_caller_strip_is_idempotent() -> None:
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 ────────────────────────────────────────────────────────────
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] = []
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"),
)
for attr, label in _INSTALLER_NAMES:
monkeypatch.setattr(
mantle_shims, attr,
lambda label=label: calls.append(label),
)
return calls
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")
assert installed is True
assert calls == ["wire", "tool_use"]
# "max_tokens" is intentionally absent: the 20 000 clamp is not installed
# by default because the ceiling was never confirmed. See install_all().
assert calls == ["wire", "tool_use", "beta_opt_out"]
def test_maybe_install_noop_for_non_mantle(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = []
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"),
)
calls = _patch_installers(monkeypatch)
assert mantle_shims.maybe_install("https://api.anthropic.com") is False
assert mantle_shims.maybe_install(None) is False

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