mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c021d5be0 | ||
|
|
f10c8b3730 | ||
|
|
909c70842a | ||
|
|
d257956d07 | ||
|
|
419395c60b |
@@ -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.
|
||||
|
||||
@@ -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}."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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: ...
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user