Compare commits
3 Commits
54d639c13a
...
fix/regist
| Author | SHA1 | Date | |
|---|---|---|---|
| 73f6afdbf8 | |||
| 5af198911b | |||
| 1b05504207 |
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -192,9 +192,12 @@ agents:
|
|||||||
| `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 |
|
||||||
|
| `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,14 @@ 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-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 +619,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 +677,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 +698,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` |
|
||||||
|
|
||||||
|
|||||||
@@ -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
146
pallas/image_passthrough.py
Normal 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
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,6 +278,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "pallas-mcp"
|
name = "pallas-mcp"
|
||||||
version = "0.6.0"
|
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 = [
|
||||||
|
|||||||
138
tests/test_image_passthrough.py
Normal file
138
tests/test_image_passthrough.py
Normal 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
|
||||||
202
tests/test_registry.py
Normal file
202
tests/test_registry.py
Normal 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
|
||||||
Reference in New Issue
Block a user