Compare commits

...
18 changed files with 741 additions and 392 deletions
+15
View File
@@ -1,5 +1,7 @@
"""Agent modules for Stirling AI reasoning flows."""
from collections.abc import Iterable
from .document_classifier import DocumentClassifierAgent
from .execution import ExecutionPlanningAgent
from .orchestrator import OrchestratorAgent
@@ -7,9 +9,20 @@ from .pdf_create import PdfCreateAgent
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from .pdf_questions import PdfQuestionAgent
from .pdf_review import PdfReviewAgent
from .registry import AgentDescriptor, RegisterableAgent
from .user_spec import UserSpecAgent
def build_descriptors(agents: Iterable[RegisterableAgent]) -> list[AgentDescriptor]:
"""The canonical descriptor list driving both orchestrator routing and the MCP
manifest. Pass the live agent singletons. Adding an agent means implementing
``describe`` and including its instance in the caller's list.
"""
return [agent.describe() for agent in agents]
__all__ = [
"AgentDescriptor",
"DocumentClassifierAgent",
"ExecutionPlanningAgent",
"OrchestratorAgent",
@@ -19,5 +32,7 @@ __all__ = [
"PdfEditPlanSelection",
"PdfQuestionAgent",
"PdfReviewAgent",
"RegisterableAgent",
"UserSpecAgent",
"build_descriptors",
]
@@ -6,6 +6,7 @@ from pydantic import Field
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents.registry import AgentDescriptor, McpCapability, RegisterableAgent
from stirling.contracts import (
ClassifyDocumentRequest,
ClassifyDocumentResponse,
@@ -98,7 +99,7 @@ def validate_labels(output: _ClassifierOutput, allowed: list[LabelOption]) -> Do
return DocumentClassificationResponse(labels=kept)
class DocumentClassifierAgent:
class DocumentClassifierAgent(RegisterableAgent):
"""Assigns labels to a document from an allowed vocabulary.
Reads the bounded page window supplied on the request (first/last
@@ -115,6 +116,22 @@ class DocumentClassifierAgent:
model_settings=runtime.fast_model_settings,
)
def describe(self) -> AgentDescriptor:
# MCP-only: a standalone classification capability invoked directly via its
# route; never a top-level orchestrator delegate.
return AgentDescriptor(
mcp=(
McpCapability(
id="document-classify",
description="Assign document-type labels to a document from a fixed allowed vocabulary.",
input_model=ClassifyDocumentRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/documents/classify",
),
),
)
async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
# The caller (the backend) always supplies the allowed vocabulary — its
# fixed built-in label set — so the engine holds no vocabulary of its own.
+20 -1
View File
@@ -1,13 +1,32 @@
from __future__ import annotations
from stirling.agents.registry import AgentDescriptor, McpCapability, RegisterableAgent
from stirling.contracts import AgentExecutionRequest, CannotContinueExecutionAction, NextExecutionAction
from stirling.services import AppRuntime
class ExecutionPlanningAgent:
class ExecutionPlanningAgent(RegisterableAgent):
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
def describe(self) -> AgentDescriptor:
# MCP-only: an internal sub-agent the orchestrator never delegates to.
return AgentDescriptor(
mcp=(
McpCapability(
id="agent-next-action",
description=(
"Decide the next execution step for an in-progress agent workflow. Returns a"
" ToolCall, Completed, or CannotContinue action."
),
input_model=AgentExecutionRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/agents/next-action",
),
),
)
async def next_action(self, request: AgentExecutionRequest) -> NextExecutionAction:
return CannotContinueExecutionAction(
reason=f"Execution planning is not implemented yet for step {request.current_step_index}."
+33 -1
View File
@@ -29,6 +29,7 @@ from pydantic import BaseModel, Field
from pydantic_ai import Agent
from pydantic_ai.exceptions import AgentRunError
from stirling.agents.registry import AgentDescriptor, McpCapability, RegisterableAgent
from stirling.contracts.ledger import (
Discrepancy,
DiscrepancyKind,
@@ -113,7 +114,7 @@ class StatementsResult(BaseModel):
# ---------------------------------------------------------------------------
class MathAuditorAgent:
class MathAuditorAgent(RegisterableAgent):
"""
Encapsulates the Ledger Auditor pipeline.
@@ -121,6 +122,37 @@ class MathAuditorAgent:
pre-built Model objects and ModelSettings.
"""
def describe(self) -> AgentDescriptor:
# MCP-only: the orchestrator reaches the math auditor indirectly (an agent
# emits a plan with the MATH_AUDITOR_AGENT tool, which Java runs via the
# examine/deliberate routes), so there is no orchestrator delegate here.
return AgentDescriptor(
mcp=(
McpCapability(
id="math-audit-examine",
description=(
"Examine a folio manifest of financial / numeric documents and surface the"
" evidence that needs to be checked for arithmetic consistency."
),
input_model=FolioManifest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/math-auditor-agent/examine",
),
McpCapability(
id="math-audit-deliberate",
description=(
"Render a deliberated verdict on a single piece of evidence the examine step"
" surfaced (does the arithmetic check out, with what caveats)."
),
input_model=Evidence,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/math-auditor-agent/deliberate",
),
),
)
def __init__(self, runtime: AppRuntime) -> None:
fast_model = runtime.fast_model
model_settings = runtime.fast_model_settings
+52 -207
View File
@@ -1,156 +1,74 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Literal, assert_never
from pydantic import ConfigDict, Field
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput, ToolOutput
from pydantic_ai.tools import RunContext
from pydantic_ai.output import NativeOutput
from stirling.agents.output_mode import output_retries, uses_tool_output
from stirling.agents.pdf_create import PdfCreateAgent
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.agents.pdf_review import PdfReviewAgent
from stirling.agents.user_spec import UserSpecAgent
from stirling.agents.output_mode import output_retries
from stirling.agents.registry import AgentDescriptor, OrchestratorRoute
from stirling.contracts import (
AgentDraftWorkflowResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
PdfEditResponse,
PdfQuestionOrchestrateResponse,
PdfReviewOrchestrateResponse,
SupportedCapability,
UnsupportedCapabilityResponse,
format_conversation_history,
format_file_names,
)
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class OrchestratorDeps:
runtime: AppRuntime
request: OrchestratorRequest
# Enum routing for Ollama/custom local models: they pass the user message as args to the
# zero-arg tool delegates below, which reject it, so pick a capability by name and dispatch in Python.
_RouteCapability = Literal["pdf_edit", "pdf_question", "user_spec", "pdf_review", "pdf_create", "unsupported"]
class _RouteDecision(ApiModel):
# Local models add stray tool args and send null for optional fields; tolerate both.
model_config = ConfigDict(extra="ignore")
capability: _RouteCapability
capability: SupportedCapability
message: str | None = Field(
default=None,
description="Only for capability='unsupported': a short, helpful message to show the user.",
description="Only when no capability fits (capability=orchestrate): a short, helpful message for the user.",
)
_ROUTER_SYSTEM_PROMPT = (
"You are the top-level router. Choose exactly one capability that best handles the request:\n"
"- pdf_edit: modify or convert one or more attached PDFs.\n"
"- pdf_question: answer questions about the contents of the attached PDFs.\n"
"- user_spec: create or define an agent spec.\n"
"- pdf_review: return the PDF with review comments/annotations attached.\n"
"- pdf_create: generate a NEW document from scratch (invoice, report, letter) - no input file.\n"
"- unsupported: none of the above fit, or the user asks about the assistant itself; put a "
"helpful message in 'message'.\n"
"Respond with the capability and (only for unsupported) a message."
)
def _build_router_prompt(routes: list[OrchestratorRoute]) -> str:
"""Router prompt derived from the registry: one bullet per routable capability plus the
``orchestrate`` escape hatch. The routable set is never hand-maintained here."""
options = "\n".join(f"- {route.capability.value}: {route.description}" for route in routes)
return (
"You are the top-level router. Choose exactly one capability that best handles the request:\n"
f"{options}\n"
f"- {SupportedCapability.ORCHESTRATE.value}: none of the above fit, or the user asks about the "
"assistant itself; put a helpful message in 'message'.\n"
"Respond with the capability and (only for orchestrate) a message."
)
class OrchestratorAgent:
def __init__(self, runtime: AppRuntime) -> None:
"""Classifies each request to one registered capability, then dispatches to its delegate.
A single provider-agnostic classifier replaces per-provider routing: ``structured_output``
delivers the decision via a tool call on local models and native json-schema elsewhere
(see ``agents.output_mode``). Both the option list and the prompt are derived from the
registry, so adding a routable agent is a ``describe()`` change only.
"""
def __init__(self, runtime: AppRuntime, descriptors: list[AgentDescriptor]) -> None:
self.runtime = runtime
self.agent = Agent(
routes = [d.orchestrator for d in descriptors if d.orchestrator is not None]
self._delegates_by_capability: dict[SupportedCapability, OrchestratorRoute] = {
route.capability: route for route in routes
}
self._router = Agent(
model=runtime.fast_model,
output_type=[
ToolOutput(
self.delegate_pdf_edit,
name="delegate_pdf_edit",
description="Delegate requests to modify or convert PDFs and return the PDF edit result.",
),
ToolOutput(
self.delegate_pdf_question,
name="delegate_pdf_question",
description="Delegate questions about PDF contents and return the PDF question result.",
),
ToolOutput(
self.delegate_user_spec,
name="delegate_user_spec",
description="Delegate requests to create or revise a user agent spec and return the draft result.",
),
ToolOutput(
self.delegate_pdf_review,
name="delegate_pdf_review",
description=(
"Delegate requests to review a PDF and leave review comments, notes, or"
" sticky-note annotations on the document itself. Use this when the user"
" wants the PDF returned with comments attached (e.g. 'review this',"
" 'add review comments', 'flag unclear sentences', 'annotate with"
" feedback')."
),
),
ToolOutput(
self.delegate_pdf_create,
name="delegate_pdf_create",
description=(
"Delegate requests to create a new PDF document from scratch based on a"
" description. Use this when the user wants to generate a new document"
" (e.g. 'create an invoice', 'write a report', 'make a contract',"
" 'draft a letter'). No input file is required."
),
),
ToolOutput(
self.unsupported_capability,
name="unsupported_capability",
description="Return this when none of the delegate outputs fit the request.",
),
],
# Local models pick a delegate less reliably; extra retries. No-op for real providers.
output_type=NativeOutput([_RouteDecision]),
# Local models can still need extra output-validation retries. No-op for real providers.
retries=output_retries(runtime.settings.chat_provider),
deps_type=OrchestratorDeps,
system_prompt=(
"You are the top-level orchestrator. "
"Choose exactly one output function that best handles the request. "
"Use delegate_pdf_edit for any request to modify or convert one or more PDFs. "
"Use delegate_pdf_question for questions about the contents of the attached PDFs. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use delegate_pdf_review when the user wants the PDF returned with review"
" comments attached — anything like 'review this', 'annotate with comments',"
" 'leave feedback on the PDF'. "
"Use delegate_pdf_create when the user wants to generate a new document from"
" scratch with no input file — invoices, reports, letters, contracts, etc. "
"Use unsupported_capability when the user asks about the assistant itself "
"or when none of the other outputs fit; supply a helpful message."
),
system_prompt=_build_router_prompt(routes),
model_settings=runtime.fast_model_settings,
)
# Local models can't drive the zero-arg tool delegates; route by name instead (#6163: unify these paths).
self._route_via_enum = uses_tool_output(runtime.settings.chat_provider)
# The router has no tools, so NativeOutput works on Ollama here; a lone output tool
# would tempt a local model to answer in plain text and never call it.
self._router = (
Agent(
model=runtime.fast_model,
output_type=NativeOutput([_RouteDecision]),
retries=output_retries(runtime.settings.chat_provider),
system_prompt=_ROUTER_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
if self._route_via_enum
else None
)
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
logger.info(
@@ -162,105 +80,32 @@ class OrchestratorAgent:
)
if request.resume_with is not None:
return await self._resume(request, request.resume_with)
if self._router is not None:
return await self._route_and_dispatch(request)
result = await self.agent.run(
self._build_prompt(request),
deps=OrchestratorDeps(runtime=self.runtime, request=request),
)
logger.info("[orchestrator] routed -> %s", type(result.output).__name__)
return result.output
async def _route_and_dispatch(self, request: OrchestratorRequest) -> OrchestratorResponse:
"""Local-model routing: pick a capability by name, then dispatch in Python."""
assert self._router is not None
result = await self._router.run(self._build_prompt(request))
decision = result.output
logger.info("[orchestrator] enum-routed -> %s", decision.capability)
match decision.capability:
case "pdf_edit":
return await self._run_pdf_edit(request)
case "pdf_question":
return await self._run_pdf_question(request)
case "user_spec":
return await self._run_agent_draft(request)
case "pdf_review":
return await self._run_pdf_review(request)
case "pdf_create":
return await self._run_pdf_create(request)
case "unsupported":
return UnsupportedCapabilityResponse(
capability="orchestrate",
message=decision.message or "I can't help with that request.",
)
case _ as unreachable:
assert_never(unreachable)
return await self._dispatch(result.output, request)
async def _dispatch(self, decision: _RouteDecision, request: OrchestratorRequest) -> OrchestratorResponse:
route = self._delegates_by_capability.get(decision.capability)
if route is None:
# ``orchestrate`` (or any non-routable pick) means "nothing fits" — the escape hatch.
logger.info("[orchestrator] routed -> unsupported (%s)", decision.capability)
return UnsupportedCapabilityResponse(
capability=SupportedCapability.ORCHESTRATE.value,
message=decision.message or "I can't help with that request.",
)
logger.info("[orchestrator] routed -> %s", decision.capability)
return await route.orchestrate(request)
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
"""Fast-path to get back to the correct endpoint without having to call AI.
"""Fast-path back to the right delegate without consulting the LLM.
Also the entry point for the *multi-turn* flow where a delegate emits a plan with
``resume_with`` set — Java runs the plan, captures any tool reports as artifacts, and
re-enters via this method so the delegate can digest the reports.
re-enters here so the delegate can digest the reports.
"""
match capability:
case SupportedCapability.PDF_QUESTION:
return await self._run_pdf_question(request)
case SupportedCapability.PDF_REVIEW:
return await self._run_pdf_review(request)
case SupportedCapability.PDF_EDIT:
return await self._run_pdf_edit(request)
case SupportedCapability.AGENT_DRAFT:
return await self._run_agent_draft(request)
case SupportedCapability.PDF_CREATE:
return await self._run_pdf_create(request)
case (
SupportedCapability.ORCHESTRATE
| SupportedCapability.AGENT_REVISE
| SupportedCapability.AGENT_NEXT_ACTION
| SupportedCapability.MATH_AUDITOR_AGENT
):
raise ValueError(f"Cannot resume orchestrator with capability: {capability}")
case _ as unreachable:
assert_never(unreachable)
async def delegate_pdf_edit(self, ctx: RunContext[OrchestratorDeps]) -> PdfEditResponse:
return await self._run_pdf_edit(ctx.deps.request)
async def _run_pdf_edit(self, request: OrchestratorRequest) -> PdfEditResponse:
return await PdfEditAgent(self.runtime).orchestrate(request)
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionOrchestrateResponse:
return await self._run_pdf_question(ctx.deps.request)
async def _run_pdf_question(self, request: OrchestratorRequest) -> PdfQuestionOrchestrateResponse:
return await PdfQuestionAgent(self.runtime).orchestrate(request)
async def delegate_user_spec(self, ctx: RunContext[OrchestratorDeps]) -> AgentDraftWorkflowResponse:
return await self._run_agent_draft(ctx.deps.request)
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).orchestrate(request)
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> PdfReviewOrchestrateResponse:
return await self._run_pdf_review(ctx.deps.request)
async def _run_pdf_review(self, request: OrchestratorRequest) -> PdfReviewOrchestrateResponse:
return await PdfReviewAgent(self.runtime).orchestrate(request)
async def delegate_pdf_create(self, ctx: RunContext[OrchestratorDeps]) -> PdfCreateOrchestrateResponse:
return await self._run_pdf_create(ctx.deps.request)
async def _run_pdf_create(self, request: OrchestratorRequest) -> PdfCreateOrchestrateResponse:
return await PdfCreateAgent(self.runtime).orchestrate(request)
async def unsupported_capability(
self,
ctx: RunContext[OrchestratorDeps],
capability: str,
message: str,
) -> UnsupportedCapabilityResponse:
return UnsupportedCapabilityResponse(capability=capability, message=message)
route = self._delegates_by_capability.get(capability)
if route is None:
raise ValueError(f"Cannot resume orchestrator with capability: {capability}")
return await route.orchestrate(request)
def _build_prompt(self, request: OrchestratorRequest) -> str:
artifact_summary = self._describe_artifacts(request)
@@ -21,6 +21,7 @@ from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.pdf_comment.prompts import COMMENT_AGENT_SYSTEM_PROMPT
from stirling.agents.registry import AgentDescriptor, McpCapability, RegisterableAgent
from stirling.contracts.pdf_comments import (
MAX_COMMENT_TEXT_LENGTH,
PdfCommentInstruction,
@@ -66,7 +67,7 @@ class LlmCommentOutput(ApiModel):
rationale: str = Field(max_length=1_000)
class PdfCommentAgent:
class PdfCommentAgent(RegisterableAgent):
"""Encapsulates the single-shot PDF comment generation pipeline.
Instantiated once at app startup with an :class:`AppRuntime`, which
@@ -82,6 +83,22 @@ class PdfCommentAgent:
model_settings=runtime.fast_model_settings,
)
def describe(self) -> AgentDescriptor:
# MCP-only: invoked as a tool inside the PDF review plan, never a
# top-level orchestrator delegate.
return AgentDescriptor(
mcp=(
McpCapability(
id="pdf-comment-generate",
description="Generate inline review comments for a PDF document.",
input_model=PdfCommentRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/pdf-comment/generate",
),
),
)
async def generate(self, request: PdfCommentRequest) -> PdfCommentResponse:
"""Run the agent against a ``PdfCommentRequest`` and return comments.
+16 -1
View File
@@ -26,10 +26,12 @@ from dataclasses import dataclass
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents.registry import AgentDescriptor, OrchestratorRoute, RegisterableAgent
from stirling.contracts import (
EditCannotDoResponse,
EditPlanResponse,
OrchestratorRequest,
SupportedCapability,
ToolOperationStep,
format_conversation_history,
)
@@ -307,7 +309,7 @@ def _safe_filename(title: str) -> str:
# ── Agent ─────────────────────────────────────────────────────────────────────────────────────────
class PdfCreateAgent:
class PdfCreateAgent(RegisterableAgent):
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
@@ -332,6 +334,19 @@ class PdfCreateAgent:
model_settings={**runtime.smart_model_settings, "temperature": 0.3},
)
def describe(self) -> AgentDescriptor:
return AgentDescriptor(
orchestrator=OrchestratorRoute(
capability=SupportedCapability.PDF_CREATE,
description=(
"Generate a new document from scratch based on a description. Use this when the"
" user wants to create a new document (e.g. 'create an invoice', 'write a report',"
" 'make a contract', 'draft a letter'). No input file is required."
),
orchestrate=self.orchestrate,
),
)
async def orchestrate(self, request: OrchestratorRequest) -> PdfCreateOrchestrateResponse:
history = format_conversation_history(request.conversation_history)
+25 -1
View File
@@ -9,6 +9,7 @@ from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents._page_text import format_page_text, get_extracted_text_artifact, has_page_text
from stirling.agents.registry import AgentDescriptor, McpCapability, OrchestratorRoute, RegisterableAgent
from stirling.contracts import (
EditCannotDoResponse,
EditClarificationRequest,
@@ -171,11 +172,34 @@ class PdfEditParameterSelector:
)
class PdfEditAgent:
class PdfEditAgent(RegisterableAgent):
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self.parameter_selector = PdfEditParameterSelector(runtime)
def describe(self) -> AgentDescriptor:
return AgentDescriptor(
orchestrator=OrchestratorRoute(
capability=SupportedCapability.PDF_EDIT,
description="Modify or convert one or more attached PDFs; returns the edit result.",
orchestrate=self.orchestrate,
),
mcp=(
McpCapability(
id="pdf-edit-plan",
description=(
"Produce an edit plan (a structured sequence of PDF operations) from a"
" natural-language edit request. The plan is executed by Java through the job"
" pipeline; this capability does not modify files itself."
),
input_model=PdfEditRequest,
mode="async",
required_scope="mcp.tools.write",
route="/api/v1/pdf-edit",
),
),
)
async def orchestrate(self, request: OrchestratorRequest) -> PdfEditResponse:
"""Entry point for the orchestrator delegate — adapts the orchestrator's
request shape into a :class:`PdfEditRequest` and runs the standard
+21 -1
View File
@@ -7,6 +7,7 @@ from pydantic_ai import Agent
from stirling.agents.contradiction import ContradictionCapability, ContradictionDetector
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.agents.output_mode import output_retries, structured_output
from stirling.agents.registry import AgentDescriptor, McpCapability, OrchestratorRoute, RegisterableAgent
from stirling.agents.shared import ChunkedReasoner, WholeDocReaderCapability
from stirling.contracts import (
AiFile,
@@ -85,7 +86,7 @@ _MATH_SYNTH_SYSTEM_PROMPT = (
)
class PdfQuestionAgent:
class PdfQuestionAgent(RegisterableAgent):
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._math_synth_agent: Agent[None, str] = Agent(
@@ -103,6 +104,25 @@ class PdfQuestionAgent:
# (mirrors the chunked-reasoner pattern).
self._contradiction_detector = ContradictionDetector(runtime)
def describe(self) -> AgentDescriptor:
return AgentDescriptor(
orchestrator=OrchestratorRoute(
capability=SupportedCapability.PDF_QUESTION,
description="Answer questions about the contents of the attached PDFs; returns the answer.",
orchestrate=self.orchestrate,
),
mcp=(
McpCapability(
id="pdf-question-answer",
description="Answer a natural-language question about a PDF document.",
input_model=PdfQuestionRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/pdf-question",
),
),
)
async def handle(self, request: PdfQuestionRequest) -> PdfQuestionResponse:
logger.info(
"[pdf-question] handle: files=%s question=%r",
+16 -1
View File
@@ -35,6 +35,7 @@ from stirling.agents.contradiction import ContradictionDetector, ContradictionIn
from stirling.agents.contradiction.detector import _escape_for_tag
from stirling.agents.contradiction.prompts import REVIEW_LOCALISER_PROMPT
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.agents.registry import AgentDescriptor, OrchestratorRoute, RegisterableAgent
from stirling.contracts import (
AiFile,
CommentSpec,
@@ -104,7 +105,7 @@ class _LocalisedContradictionReport(ApiModel):
comments: list[_PairedLocalisedContradiction] = Field(default_factory=list)
class PdfReviewAgent:
class PdfReviewAgent(RegisterableAgent):
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._localiser_agent: Agent[None, _LocalisedVerdict] = Agent(
@@ -128,6 +129,20 @@ class PdfReviewAgent:
# request's stages.
self._contradiction_detector = ContradictionDetector(runtime)
def describe(self) -> AgentDescriptor:
return AgentDescriptor(
orchestrator=OrchestratorRoute(
capability=SupportedCapability.PDF_REVIEW,
description=(
"Review a PDF and leave review comments, notes, or sticky-note annotations on the"
" document itself. Use this when the user wants the PDF returned with comments"
" attached (e.g. 'review this', 'add review comments', 'flag unclear sentences',"
" 'annotate with feedback')."
),
orchestrate=self.orchestrate,
),
)
async def orchestrate(self, request: OrchestratorRequest) -> PdfReviewOrchestrateResponse:
"""Entry point for the orchestrator delegate.
+76
View File
@@ -0,0 +1,76 @@
"""Single source of truth for how each agent is exposed.
An agent declares one :class:`AgentDescriptor` via :meth:`RegisterableAgent.describe`.
Two projections are derived from the collected descriptors, so neither has to be
hand-maintained:
* the **orchestrator** builds its capability classifier and ``resume`` dispatch
from descriptors whose ``orchestrator`` route is set;
* the **MCP capabilities manifest** is built from descriptors' ``mcp`` rows.
Adding an agent therefore means implementing ``describe`` and adding the instance
to ``build_descriptors`` — the orchestrator and the manifest both update for free.
Note: this ``AgentDescriptor`` registry (how an agent is *published*) is unrelated
to the runtime "capability" toolsets like ``ContradictionCapability`` /
``RagCapability`` (tools *injected into* an agent run).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Literal
from pydantic import BaseModel
from stirling.contracts import OrchestratorRequest, OrchestratorResponse, SupportedCapability
OrchestrateFn = Callable[[OrchestratorRequest], Awaitable[OrchestratorResponse]]
@dataclass(frozen=True)
class OrchestratorRoute:
"""How an agent is exposed to the top-level orchestrator.
``capability`` both identifies the option the router picks and keys the resume
dispatch: the orchestrator re-enters this delegate when a ``resume_with`` of the
same value arrives. ``description`` is the one-line summary the router sees.
"""
capability: SupportedCapability
description: str
orchestrate: OrchestrateFn
@dataclass(frozen=True)
class McpCapability:
"""One row in the MCP capabilities manifest the Java MCP server publishes."""
id: str
description: str
input_model: type[BaseModel]
mode: Literal["sync", "async"]
required_scope: str
route: str
@dataclass(frozen=True)
class AgentDescriptor:
"""How one agent is published. ``orchestrator`` set => routable by the
top-level orchestrator; ``mcp`` non-empty => exposed in the MCP manifest.
The two are independent: an agent may be one, the other, or both."""
orchestrator: OrchestratorRoute | None = None
mcp: tuple[McpCapability, ...] = ()
class RegisterableAgent(ABC):
"""Base for any agent that publishes itself to the orchestrator and/or MCP.
Enforces a uniform ``describe`` entry point that startup wiring collects via
``build_descriptors``."""
@abstractmethod
def describe(self) -> AgentDescriptor: ...
+35 -1
View File
@@ -4,6 +4,7 @@ from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.registry import AgentDescriptor, McpCapability, OrchestratorRoute, RegisterableAgent
from stirling.contracts import (
AgentDraft,
AgentDraftRequest,
@@ -18,6 +19,7 @@ from stirling.contracts import (
OrchestratorRequest,
PdfEditRequest,
PdfEditTerminalResponse,
SupportedCapability,
format_conversation_history,
)
from stirling.models import ApiModel
@@ -30,7 +32,7 @@ class UserSpecMetadata(ApiModel):
objective: str
class UserSpecAgent:
class UserSpecAgent(RegisterableAgent):
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self.pdf_edit_agent = PdfEditAgent(runtime)
@@ -45,6 +47,38 @@ class UserSpecAgent:
model_settings=runtime.smart_model_settings,
)
def describe(self) -> AgentDescriptor:
return AgentDescriptor(
orchestrator=OrchestratorRoute(
capability=SupportedCapability.AGENT_DRAFT,
description="Create or define an agent spec; returns the draft result.",
orchestrate=self.orchestrate,
),
mcp=(
McpCapability(
id="agent-draft",
description=(
"Draft a structured agent specification from a free-text description"
" of the task the user wants automated."
),
input_model=AgentDraftRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/agents/draft",
),
McpCapability(
id="agent-revise",
description=(
"Revise an existing draft agent specification based on user feedback or constraint changes."
),
input_model=AgentRevisionRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/agents/revise",
),
),
)
async def orchestrate(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
"""Entry point for the orchestrator delegate — adapts the orchestrator's
request shape into an :class:`AgentDraftRequest` and runs the standard
+43 -138
View File
@@ -1,160 +1,65 @@
"""
Curated registry of agent capabilities the MCP server (Java side) is allowed to publish.
"""Serialize the MCP capabilities manifest the Java MCP server pulls at boot.
Internal sub-agents (currently only ``ExecutionPlanningAgent`` - it lives behind the orchestrator
and has no end-user-facing API surface) are intentionally absent. The handoff spec calls for
"user-facing" capabilities only; revisit this list when adding a new agent and ask whether MCP
clients should be able to invoke it directly.
The manifest is *derived* from the agent registry: every agent declares its
exposed capabilities in ``describe()`` (see ``stirling.agents.registry``), and
this module flattens the ``mcp`` rows of the startup descriptor list into the
wire shape Java consumes. There is no separately maintained capability list to
keep in sync — adding an MCP capability means adding an ``McpCapability`` to the
owning agent's descriptor.
The Java side pulls ``/api/v1/agents/capabilities`` once at boot and again every few minutes; the
manifest is the authoritative source for the ``stirling_ai`` MCP tool's operation enum.
Curation note: exposure is opt-in. An agent is published to MCP only if its
descriptor carries one or more ``McpCapability`` rows; registering an agent with
the orchestrator does not auto-expose it over the (OAuth-scoped) MCP surface.
The Java side pulls ``/api/v1/agents/capabilities`` once at boot and again every
few minutes; the manifest is the authoritative source for the ``stirling_ai`` MCP
tool's operation enum.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from collections.abc import Iterable
from typing import Any, Literal
from pydantic import BaseModel
from stirling.contracts import (
AgentDraftRequest,
AgentExecutionRequest,
AgentRevisionRequest,
Evidence,
FolioManifest,
PdfCommentRequest,
PdfEditRequest,
PdfQuestionRequest,
)
from stirling.agents import AgentDescriptor
# The manifest is a deliberately snake_case wire contract Java already consumes, so these
# use plain BaseModel rather than the camelCasing ``ApiModel``. ``input_schema`` is a JSON
# Schema, which is inherently a dynamic dict - the one accepted ``Any`` on this boundary.
@dataclass(frozen=True)
class AgentCapability:
"""One row in the curated manifest.
Attributes:
id: stable capability identifier (used as the operation enum value in
``stirling_ai``). Avoid renaming - clients persist these.
description: one-line human-friendly summary shown inside MCP tool descriptions.
input_model: Pydantic class whose JSON Schema becomes the capability's
``input_schema``. Auto-derived; do not hand-write schemas.
mode: ``"sync"`` if the capability returns content inline, ``"async"`` if it returns a
plan that Java executes via the job pipeline.
required_scope: coarse OAuth scope. ``mcp.tools.read`` for pure-read capabilities
(Q&A, audits) and ``mcp.tools.write`` for anything that yields a plan / file.
route: HTTP path Java POSTs to when invoking this capability. When a capability does
not have a stable per-agent route yet, use the generic invoke fallback at
``/api/v1/agents/invoke/{id}``.
"""
class ManifestCapability(BaseModel):
id: str
description: str
input_model: type[BaseModel]
mode: str
input_schema: dict[str, Any]
mode: Literal["sync", "async"]
required_scope: str
route: str
EXPOSED_CAPABILITIES: list[AgentCapability] = [
AgentCapability(
id="pdf-question-answer",
description="Answer a natural-language question about a PDF document.",
input_model=PdfQuestionRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/pdf-question",
),
AgentCapability(
id="pdf-edit-plan",
description=(
"Produce an edit plan (a structured sequence of PDF operations) from a"
" natural-language edit request. The plan is executed by Java through the job"
" pipeline; this capability does not modify files itself."
),
input_model=PdfEditRequest,
mode="async",
required_scope="mcp.tools.write",
route="/api/v1/pdf-edit",
),
AgentCapability(
id="agent-draft",
description=(
"Draft a structured agent specification from a free-text description of the task the user wants automated."
),
input_model=AgentDraftRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/agents/draft",
),
AgentCapability(
id="agent-revise",
description=("Revise an existing draft agent specification based on user feedback or constraint changes."),
input_model=AgentRevisionRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/agents/revise",
),
AgentCapability(
id="math-audit-examine",
description=(
"Examine a folio manifest of financial / numeric documents and surface the"
" evidence that needs to be checked for arithmetic consistency."
),
input_model=FolioManifest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/math-auditor-agent/examine",
),
AgentCapability(
id="math-audit-deliberate",
description=(
"Render a deliberated verdict on a single piece of evidence the examine step"
" surfaced (does the arithmetic check out, with what caveats)."
),
input_model=Evidence,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/math-auditor-agent/deliberate",
),
AgentCapability(
id="pdf-comment-generate",
description="Generate inline review comments for a PDF document.",
input_model=PdfCommentRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/pdf-comment/generate",
),
AgentCapability(
id="agent-next-action",
description=(
"Decide the next execution step for an in-progress agent workflow. Returns a"
" ToolCall, Completed, or CannotContinue action."
),
input_model=AgentExecutionRequest,
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/agents/next-action",
),
]
class CapabilitiesManifest(BaseModel):
version: int = 1
capabilities: list[ManifestCapability]
def manifest_payload() -> dict[str, Any]:
"""Serialize the curated registry to the wire shape consumed by Java.
def manifest_payload(descriptors: Iterable[AgentDescriptor]) -> CapabilitiesManifest:
"""Flatten the ``mcp`` rows of the descriptor list to the wire shape.
Schema is derived from ``input_model.model_json_schema()`` so we never hand-write JSON
Schema - the Pydantic model is the single source of truth.
Schema is derived from ``input_model.model_json_schema()`` so we never
hand-write JSON Schema - the Pydantic model is the single source of truth.
"""
items: list[dict[str, Any]] = []
for cap in EXPOSED_CAPABILITIES:
items.append(
{
"id": cap.id,
"description": cap.description,
"input_schema": cap.input_model.model_json_schema(),
"mode": cap.mode,
"required_scope": cap.required_scope,
"route": cap.route,
}
capabilities = [
ManifestCapability(
id=cap.id,
description=cap.description,
input_schema=cap.input_model.model_json_schema(),
mode=cap.mode,
required_scope=cap.required_scope,
route=cap.route,
)
return {"version": 1, "capabilities": items}
for descriptor in descriptors
for cap in descriptor.mcp
]
return CapabilitiesManifest(capabilities=capabilities)
+37 -8
View File
@@ -8,12 +8,16 @@ from typing import Any
from pydantic_ai.models import Model
from stirling.agents import (
AgentDescriptor,
DocumentClassifierAgent,
ExecutionPlanningAgent,
OrchestratorAgent,
PdfCreateAgent,
PdfEditAgent,
PdfQuestionAgent,
PdfReviewAgent,
UserSpecAgent,
build_descriptors,
)
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
@@ -35,6 +39,8 @@ class AppState:
math_auditor_agent: MathAuditorAgent
pdf_comment_agent: PdfCommentAgent
document_classifier_agent: DocumentClassifierAgent
# One descriptor list drives both orchestrator routing and the MCP manifest.
agent_descriptors: list[AgentDescriptor]
def build_app_state(
@@ -53,16 +59,39 @@ def build_app_state(
smart_model=smart_model,
embedder=embedder,
)
pdf_edit_agent = PdfEditAgent(runtime)
pdf_question_agent = PdfQuestionAgent(runtime)
user_spec_agent = UserSpecAgent(runtime)
pdf_review_agent = PdfReviewAgent(runtime)
pdf_create_agent = PdfCreateAgent(runtime)
execution_planning_agent = ExecutionPlanningAgent(runtime)
math_auditor_agent = MathAuditorAgent(runtime)
pdf_comment_agent = PdfCommentAgent(runtime)
document_classifier_agent = DocumentClassifierAgent(runtime)
agent_descriptors = build_descriptors(
[
pdf_edit_agent,
pdf_question_agent,
user_spec_agent,
pdf_review_agent,
pdf_create_agent,
pdf_comment_agent,
math_auditor_agent,
execution_planning_agent,
document_classifier_agent,
]
)
return AppState(
runtime=runtime,
orchestrator_agent=OrchestratorAgent(runtime),
pdf_edit_agent=PdfEditAgent(runtime),
pdf_question_agent=PdfQuestionAgent(runtime),
user_spec_agent=UserSpecAgent(runtime),
execution_planning_agent=ExecutionPlanningAgent(runtime),
math_auditor_agent=MathAuditorAgent(runtime),
pdf_comment_agent=PdfCommentAgent(runtime),
document_classifier_agent=DocumentClassifierAgent(runtime),
orchestrator_agent=OrchestratorAgent(runtime, agent_descriptors),
pdf_edit_agent=pdf_edit_agent,
pdf_question_agent=pdf_question_agent,
user_spec_agent=user_spec_agent,
execution_planning_agent=execution_planning_agent,
math_auditor_agent=math_auditor_agent,
pdf_comment_agent=pdf_comment_agent,
document_classifier_agent=document_classifier_agent,
agent_descriptors=agent_descriptors,
)
@@ -2,21 +2,19 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request
from fastapi import APIRouter
from stirling.api.agent_capabilities import manifest_payload
from stirling.api.agent_capabilities import CapabilitiesManifest, manifest_payload
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
@router.get("/capabilities")
def get_capabilities() -> dict[str, Any]:
"""Return the curated agent capabilities manifest.
def get_capabilities(request: Request) -> CapabilitiesManifest:
"""Return the agent capabilities manifest, derived from the startup registry.
Gated by ``EngineSharedSecretMiddleware`` when the ``STIRLING_ENGINE_SHARED_SECRET`` env var
is configured. In dev/local mode (no secret set), the endpoint is open - the engine binds to
localhost only by default, so this is acceptable while iterating.
"""
return manifest_payload()
return manifest_payload(request.app.state.agent_descriptors)
@@ -1,11 +1,10 @@
"""
Orchestrator ``delegate_pdf_review`` contract test.
PDF-review delegate contract test.
The real orchestrator delegates PDF-review requests via a pydantic-ai tool
output. Exercising the full ``agent.run(...)`` call would hit the LLM and
requires building a real ``RunContext`` — so instead this test invokes
``delegate_pdf_review`` directly with a minimal ``deps`` stand-in. That's
enough to verify the wire contract the orchestrator produces:
The orchestrator routes PDF-review requests to ``PdfReviewAgent.orchestrate``
(the orchestrator merely selects the delegate; the review logic lives on the
agent). Exercising the agent directly avoids the LLM routing call and verifies
the wire contract the delegate produces for a plain prose-review request:
* it returns an ``EditPlanResponse``;
* with exactly one step;
@@ -16,13 +15,11 @@ enough to verify the wire contract the orchestrator produces:
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from stirling.agents import OrchestratorAgent
from stirling.agents import PdfReviewAgent
from stirling.contracts import AiFile, OrchestratorRequest
from stirling.contracts.pdf_edit import EditPlanResponse
from stirling.models import FileId
@@ -30,27 +27,28 @@ from stirling.models.agent_tool_models import AgentToolId, PdfCommentAgentParams
from stirling.services.runtime import AppRuntime
@dataclass(frozen=True)
class _FakeDeps:
request: OrchestratorRequest
@pytest.mark.anyio
async def test_delegate_pdf_review_wires_prompt_to_tool_step(runtime: AppRuntime) -> None:
orchestrator = OrchestratorAgent(runtime)
async def test_pdf_review_wires_prompt_to_tool_step(runtime: AppRuntime) -> None:
review_agent = PdfReviewAgent(runtime)
request = OrchestratorRequest(
user_message="please add review comments flagging ambiguous dates",
files=[AiFile(id=FileId("contract-id"), name="contract.pdf")],
)
ctx = SimpleNamespace(deps=_FakeDeps(request=request))
# PdfReviewAgent now classifies math intent locally via a tiny LLM. Stub it
# to false so this test stays focused on the prose-review wire contract.
with patch(
"stirling.agents.pdf_review.MathIntentClassifier.classify",
new=AsyncMock(return_value=False),
# PdfReviewAgent classifies math and contradiction intent locally via tiny
# LLMs. Stub both to false so this test stays focused on the prose-review
# wire contract.
with (
patch(
"stirling.agents.pdf_review.MathIntentClassifier.classify",
new=AsyncMock(return_value=False),
),
patch(
"stirling.agents.pdf_review.ContradictionIntentClassifier.classify",
new=AsyncMock(return_value=False),
),
):
response = await orchestrator.delegate_pdf_review(ctx) # type: ignore[arg-type]
response = await review_agent.orchestrate(request)
assert isinstance(response, EditPlanResponse)
assert len(response.steps) == 1
@@ -0,0 +1,128 @@
"""Behavioural lock: the router's capability decision reaches the right delegate.
No real LLM — a :class:`TestModel` feeds the orchestrator's classifier a scripted
``_RouteDecision`` as JSON, and we assert which delegate handled it. Built on the
real descriptor list (via ``build_descriptors``) with each agent's ``orchestrate``
swapped for a recording spy, so the test stays honest to whatever agents are
actually registered.
"""
from __future__ import annotations
import json
import pytest
from pydantic_ai.models.test import TestModel
from pydantic_ai.profiles import ModelProfile
from stirling.agents import OrchestratorAgent, build_descriptors
from stirling.agents.registry import AgentDescriptor, OrchestratorRoute, RegisterableAgent
from stirling.contracts import (
EditCannotDoResponse,
EditPlanResponse,
OrchestratorRequest,
OrchestratorResponse,
PdfQuestionNotFoundResponse,
SupportedCapability,
UnsupportedCapabilityResponse,
)
from stirling.services.runtime import AppRuntime
_NATIVE_PROFILE = ModelProfile(supports_json_schema_output=True)
_REACHED: list[SupportedCapability] = []
class _SpyAgent(RegisterableAgent):
"""Registers a real delegate route but records the reach and returns a fixed
sentinel instead of doing work."""
def __init__(self, capability: SupportedCapability, response: OrchestratorResponse) -> None:
self._capability = capability
self._response = response
def describe(self) -> AgentDescriptor:
return AgentDescriptor(
orchestrator=OrchestratorRoute(
capability=self._capability,
description=f"spy for {self._capability.value}",
orchestrate=self._orchestrate,
),
)
async def _orchestrate(self, _request: OrchestratorRequest) -> OrchestratorResponse:
_REACHED.append(self._capability)
return self._response
def _spies() -> list[RegisterableAgent]:
return [
_SpyAgent(SupportedCapability.PDF_EDIT, EditCannotDoResponse(reason="spy")),
_SpyAgent(SupportedCapability.PDF_QUESTION, PdfQuestionNotFoundResponse(reason="spy")),
_SpyAgent(SupportedCapability.PDF_REVIEW, EditPlanResponse(summary="", steps=[])),
_SpyAgent(SupportedCapability.PDF_CREATE, EditPlanResponse(summary="", steps=[])),
]
async def _route(runtime: AppRuntime, decision: dict[str, str]) -> OrchestratorResponse:
_REACHED.clear()
orchestrator = OrchestratorAgent(runtime, build_descriptors(_spies()))
scripted = TestModel(profile=_NATIVE_PROFILE, custom_output_text=json.dumps(decision))
with orchestrator._router.override(model=scripted):
return await orchestrator.handle(OrchestratorRequest(user_message="x"))
@pytest.mark.anyio
async def test_router_reaches_edit_delegate(runtime: AppRuntime) -> None:
response = await _route(runtime, {"capability": "pdf_edit"})
assert _REACHED == [SupportedCapability.PDF_EDIT]
assert isinstance(response, EditCannotDoResponse)
@pytest.mark.anyio
async def test_router_reaches_question_delegate(runtime: AppRuntime) -> None:
response = await _route(runtime, {"capability": "pdf_question"})
assert _REACHED == [SupportedCapability.PDF_QUESTION]
assert isinstance(response, PdfQuestionNotFoundResponse)
@pytest.mark.anyio
async def test_router_reaches_review_delegate(runtime: AppRuntime) -> None:
response = await _route(runtime, {"capability": "pdf_review"})
assert _REACHED == [SupportedCapability.PDF_REVIEW]
assert isinstance(response, EditPlanResponse)
@pytest.mark.anyio
async def test_router_reaches_create_delegate(runtime: AppRuntime) -> None:
response = await _route(runtime, {"capability": "pdf_create"})
assert _REACHED == [SupportedCapability.PDF_CREATE]
assert isinstance(response, EditPlanResponse)
@pytest.mark.anyio
async def test_router_orchestrate_pick_is_unsupported(runtime: AppRuntime) -> None:
# 'orchestrate' is the escape hatch: it has no delegate, so it surfaces as unsupported.
response = await _route(runtime, {"capability": "orchestrate", "message": "no fit"})
assert _REACHED == []
assert isinstance(response, UnsupportedCapabilityResponse)
assert response.message == "no fit"
@pytest.mark.anyio
async def test_resume_dispatches_to_matching_delegate(runtime: AppRuntime) -> None:
_REACHED.clear()
orchestrator = OrchestratorAgent(runtime, build_descriptors(_spies()))
await orchestrator.handle(OrchestratorRequest(user_message="x", resume_with=SupportedCapability.PDF_REVIEW))
assert _REACHED == [SupportedCapability.PDF_REVIEW]
@pytest.mark.anyio
async def test_resume_with_unroutable_capability_raises(runtime: AppRuntime) -> None:
# MATH_AUDITOR_AGENT is MCP-only — not an orchestrator delegate, so it has no
# route and resuming into it must raise.
orchestrator = OrchestratorAgent(runtime, build_descriptors(_spies()))
with pytest.raises(ValueError, match="Cannot resume"):
await orchestrator.handle(
OrchestratorRequest(user_message="x", resume_with=SupportedCapability.MATH_AUDITOR_AGENT)
)
+162
View File
@@ -0,0 +1,162 @@
"""Lock the MCP capabilities manifest wire shape.
Exercises the real ``GET /api/v1/agents/capabilities`` endpoint (so it covers the
actual startup wiring of ``app.state.agent_descriptors``) and pins every
capability's id, metadata, and Pydantic-derived input schema. The manifest is
built by flattening each agent's ``describe()`` rows; this suite is the guard
that the derived manifest never drifts from the published contract.
"""
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
import pytest
from conftest import build_app_settings
from fastapi.testclient import TestClient
from pydantic import BaseModel
from stirling.api import app
from stirling.config import load_settings
from stirling.contracts import (
AgentDraftRequest,
AgentExecutionRequest,
AgentRevisionRequest,
ClassifyDocumentRequest,
Evidence,
FolioManifest,
PdfCommentRequest,
PdfEditRequest,
PdfQuestionRequest,
)
@pytest.fixture
def manifest() -> Iterator[dict[str, Any]]:
# Force test settings for the lifespan (other suites pop this override, so we
# can't rely on a module-level set), then enter the client as a context manager
# so the lifespan runs and populates ``app.state.agent_descriptors`` — the
# manifest is built from that real startup registration, not a duplicated list.
app.dependency_overrides[load_settings] = build_app_settings
try:
with TestClient(app) as client:
response = client.get("/api/v1/agents/capabilities")
finally:
app.dependency_overrides.pop(load_settings, None)
assert response.status_code == 200
yield response.json()
@dataclass(frozen=True)
class _Expected:
description: str
mode: str
required_scope: str
route: str
input_model: type[BaseModel]
# Expected per-capability metadata, keyed by id. Order-independent on purpose —
# Java consumes the manifest as a keyed operation registry, not a sequence.
_EXPECTED: dict[str, _Expected] = {
"pdf-question-answer": _Expected(
description="Answer a natural-language question about a PDF document.",
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/pdf-question",
input_model=PdfQuestionRequest,
),
"pdf-edit-plan": _Expected(
description=(
"Produce an edit plan (a structured sequence of PDF operations) from a"
" natural-language edit request. The plan is executed by Java through the job"
" pipeline; this capability does not modify files itself."
),
mode="async",
required_scope="mcp.tools.write",
route="/api/v1/pdf-edit",
input_model=PdfEditRequest,
),
"agent-draft": _Expected(
description=(
"Draft a structured agent specification from a free-text description of the task the user wants automated."
),
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/agents/draft",
input_model=AgentDraftRequest,
),
"agent-revise": _Expected(
description="Revise an existing draft agent specification based on user feedback or constraint changes.",
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/agents/revise",
input_model=AgentRevisionRequest,
),
"math-audit-examine": _Expected(
description=(
"Examine a folio manifest of financial / numeric documents and surface the"
" evidence that needs to be checked for arithmetic consistency."
),
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/math-auditor-agent/examine",
input_model=FolioManifest,
),
"math-audit-deliberate": _Expected(
description=(
"Render a deliberated verdict on a single piece of evidence the examine step"
" surfaced (does the arithmetic check out, with what caveats)."
),
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/ai/math-auditor-agent/deliberate",
input_model=Evidence,
),
"pdf-comment-generate": _Expected(
description="Generate inline review comments for a PDF document.",
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/pdf-comment/generate",
input_model=PdfCommentRequest,
),
"agent-next-action": _Expected(
description=(
"Decide the next execution step for an in-progress agent workflow. Returns a"
" ToolCall, Completed, or CannotContinue action."
),
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/agents/next-action",
input_model=AgentExecutionRequest,
),
"document-classify": _Expected(
description="Assign document-type labels to a document from a fixed allowed vocabulary.",
mode="sync",
required_scope="mcp.tools.read",
route="/api/v1/documents/classify",
input_model=ClassifyDocumentRequest,
),
}
def test_manifest_version(manifest: dict[str, Any]) -> None:
assert manifest["version"] == 1
def test_manifest_exposes_exactly_the_expected_capabilities(manifest: dict[str, Any]) -> None:
ids = {c["id"] for c in manifest["capabilities"]}
assert ids == set(_EXPECTED)
def test_manifest_capability_metadata_and_schema(manifest: dict[str, Any]) -> None:
by_id = {c["id"]: c for c in manifest["capabilities"]}
for cap_id, expected in _EXPECTED.items():
entry = by_id[cap_id]
assert entry["description"] == expected.description, cap_id
assert entry["mode"] == expected.mode, cap_id
assert entry["required_scope"] == expected.required_scope, cap_id
assert entry["route"] == expected.route, cap_id
assert entry["input_schema"] == expected.input_model.model_json_schema(), cap_id