Files
Stirling-PDF/engine/tests/test_pdf_edit_agent.py
T
e5767ed58b Change AI engine to execute tools in Java instead of on frontend (#6116)
# Description of Changes
Redesign AI engine so that it autogenerates the `tool_models.py` file
from the OpenAPI spec so the Python has access to the Java API
parameters and the full list of Java tools that it can run. CI ensures
that whenever someone modifies a tool endpoint that the AI enigne tool
models get updated as well (the dev gets told to run `task
engine:tool-models`).

There's loads of advantages to having the Java be the one that actually
executes the tools, rather than the frontend as it was previously set up
to theoretically use:
- The AI gets much better descriptions of the params from the API docs
- It'll be usable headless in the future so a Java daemon could run to
execute ops on files in a folder without the need for the UI to run
- The Java already has all the logic it needs to execute the tools 
- We don't need to parse the TypeScript to find the API (which is hard
because the TS wasn't designed to be computer-read to extract the API)

I've also hooked up the prototype frontend to ensure it's working
properly, and have built it in a way that all the tool names can be
translated properly, which was always an issue with previous prototypes
of this.

---------

Co-authored-by: Anthony Stirling <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
2026-04-20 15:57:11 +01:00

156 lines
5.2 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import pytest
from stirling.agents import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from stirling.contracts import (
EditCannotDoResponse,
EditClarificationRequest,
EditPlanResponse,
PdfEditRequest,
ToolOperationStep,
)
from stirling.models.tool_models import Angle, FlattenParams, RotatePdfParams, ToolEndpoint
from stirling.services.runtime import AppRuntime
@dataclass(frozen=True)
class ParameterSelectorCall:
request: PdfEditRequest
operation_plan: list[ToolEndpoint]
operation_index: int
generated_steps: list[ToolOperationStep]
class RecordingParameterSelector:
def __init__(self) -> None:
self.calls: list[ParameterSelectorCall] = []
async def select(
self,
request: PdfEditRequest,
operation_plan: list[ToolEndpoint],
operation_index: int,
generated_steps: list[ToolOperationStep],
) -> RotatePdfParams | FlattenParams:
self.calls.append(
ParameterSelectorCall(
request=request,
operation_plan=operation_plan,
operation_index=operation_index,
generated_steps=list(generated_steps),
)
)
if operation_index == 0:
return RotatePdfParams(angle=Angle(90))
return FlattenParams(flatten_only_forms=False, render_dpi=None)
class StubPdfEditAgent(PdfEditAgent):
def __init__(
self,
runtime: AppRuntime,
selection: PdfEditPlanSelection | EditClarificationRequest | EditCannotDoResponse,
parameter_selector: RecordingParameterSelector | PdfEditParameterSelector | None = None,
) -> None:
super().__init__(runtime)
self.selection = selection
if parameter_selector is not None:
self.parameter_selector = parameter_selector
async def _select_plan(
self,
request: PdfEditRequest,
) -> PdfEditPlanSelection | EditClarificationRequest | EditCannotDoResponse:
return self.selection
@pytest.mark.anyio
async def test_pdf_edit_agent_builds_multi_step_plan(runtime: AppRuntime) -> None:
parameter_selector = RecordingParameterSelector()
agent = StubPdfEditAgent(
runtime,
PdfEditPlanSelection(
operations=[ToolEndpoint.ROTATE_PDF, ToolEndpoint.FLATTEN],
summary="Rotate the PDF, then compress it.",
rationale="The pages need reorientation before reducing file size.",
),
parameter_selector=parameter_selector,
)
response = await agent.handle(
PdfEditRequest(
user_message="Rotate the PDF clockwise and then compress it.",
file_names=["scan.pdf"],
)
)
assert isinstance(response, EditPlanResponse)
assert response.summary == "Rotate the PDF, then compress it."
assert response.rationale == "The pages need reorientation before reducing file size."
assert [step.tool for step in response.steps] == [ToolEndpoint.ROTATE_PDF, ToolEndpoint.FLATTEN]
assert isinstance(response.steps[0].parameters, RotatePdfParams)
assert isinstance(response.steps[1].parameters, FlattenParams)
@pytest.mark.anyio
async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector(runtime: AppRuntime) -> None:
parameter_selector = RecordingParameterSelector()
agent = StubPdfEditAgent(
runtime,
PdfEditPlanSelection(
operations=[ToolEndpoint.ROTATE_PDF, ToolEndpoint.FLATTEN],
summary="Rotate the PDF, then compress it.",
),
parameter_selector=parameter_selector,
)
request = PdfEditRequest(
user_message="Rotate the PDF clockwise and then compress it.",
file_names=["scan.pdf"],
)
response = await agent.handle(request)
assert isinstance(response, EditPlanResponse)
assert len(parameter_selector.calls) == 2
assert parameter_selector.calls[0].operation_index == 0
assert parameter_selector.calls[0].generated_steps == []
assert parameter_selector.calls[1].operation_index == 1
assert parameter_selector.calls[1].generated_steps == [
ToolOperationStep(
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
]
@pytest.mark.anyio
async def test_pdf_edit_agent_returns_clarification_without_partial_plan(runtime: AppRuntime) -> None:
agent = StubPdfEditAgent(
runtime,
EditClarificationRequest(
question="Which pages should be rotated?",
reason="The request does not say which pages to change.",
),
)
response = await agent.handle(PdfEditRequest(user_message="Rotate some pages."))
assert isinstance(response, EditClarificationRequest)
@pytest.mark.anyio
async def test_pdf_edit_agent_returns_cannot_do_without_partial_plan(runtime: AppRuntime) -> None:
agent = StubPdfEditAgent(
runtime,
EditCannotDoResponse(
reason="This request requires OCR, which is not part of PDF edit planning.",
),
)
response = await agent.handle(PdfEditRequest(user_message="Read this scan and summarize it."))
assert isinstance(response, EditCannotDoResponse)