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]>
This commit is contained in:
James Brunton
2026-04-20 15:57:11 +01:00
committed by GitHub
co-authored by Anthony Stirling EthanHealy01
parent cc9650e7a3
commit e5767ed58b
45 changed files with 3565 additions and 1285 deletions
+1
View File
@@ -15,6 +15,7 @@ max_line_length = 100
[*.py]
indent_size = 4
max_line_length = 120
[*.gradle]
indent_size = 4
+58 -33
View File
@@ -20,54 +20,79 @@ jobs:
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 9.3.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Run fixers
# Ignore errors here because we're going to add comments for them in the following steps before actually failing
run: task engine:fix || true
- name: Regenerate tool models
run: task engine:tool-models
- name: Check for fixer changes
id: fixer_changes
- name: Verify tool models are up to date
run: |
if git diff --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
if ! git diff --exit-code engine/src/stirling/models/tool_models.py; then
echo "tool_models.py is out of date."
echo "Run 'task engine:tool-models' locally and commit the updated file."
exit 1
fi
- name: Post fixer suggestions
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
uses: reviewdog/action-suggester@v1
continue-on-error: true
with:
tool_name: engine-make-fix
github_token: ${{ secrets.GITHUB_TOKEN }}
filter_mode: file
fail_level: any
level: info
- name: Run fixers
run: task engine:fix
- name: Comment on fixer suggestions
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
- name: Verify fixes are committed
id: fixer_changes
run: |
if ! git diff --quiet; then
git --no-pager diff --stat
echo "::error::There are issues with your Python code that will need to be fixed before they can be merged in. Run 'task engine:fix' to auto-fix what can be fixed automatically, then run 'task engine:check' to see what still needs fixing manually."
exit 1
fi
- name: Comment on fixer failures
if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
const marker = '<!-- engine-check -->';
const body = [
marker,
'### Engine Check Failed',
'',
'There are issues with your Python code that will need to be fixed before they can be merged in.',
'',
'Run `task engine:fix` to auto-fix what can be fixed automatically, then run `task engine:check` to see what still needs fixing manually.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: "The Python code in your PR has formatting/linting issues. Consider running `task engine:fix` locally or setting up your editor's Ruff integration to auto-format and lint your files as you go, or commit the suggested changes on this PR.",
});
- name: Verify fixer changes are committed
if: steps.fixer_changes.outputs.changed == 'true'
run: |
if ! git diff --exit-code; then
echo "Fixes are out of date."
echo "Apply the reviewdog suggestions or run 'task engine:fix' from the repo root and commit the updated files."
git --no-pager diff --stat
exit 1
fi
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Run linting
run: task engine:lint
+8 -2
View File
@@ -66,10 +66,16 @@ tasks:
swagger:
desc: "Generate OpenAPI docs"
cmds:
- cmd: cmd /c gradlew.bat :stirling-pdf:generateOpenApiDocs
- cmd: cmd /c gradlew.bat :stirling-pdf:copySwaggerDoc
platforms: [windows]
- cmd: ./gradlew :stirling-pdf:generateOpenApiDocs
- cmd: ./gradlew :stirling-pdf:copySwaggerDoc
platforms: [linux, darwin]
sources:
- app/core/src/main/java/**/*.java
- app/proprietary/src/main/java/**/*.java
- app/common/src/main/java/**/*.java
generates:
- SwaggerDoc.json
check:
desc: "Backend quality gate"
+12 -8
View File
@@ -13,7 +13,7 @@ tasks:
status:
- test -d .venv
prep:
prepare:
desc: "Set up engine .env from template"
deps: [install]
cmds:
@@ -26,7 +26,7 @@ tasks:
run:
desc: "Run engine server"
deps: [prep]
deps: [prepare]
ignore_error: true
dir: src
env:
@@ -36,7 +36,7 @@ tasks:
dev:
desc: "Start engine dev server with hot reload"
deps: [prep]
deps: [prepare]
ignore_error: true
dir: src
env:
@@ -76,7 +76,7 @@ tasks:
test:
desc: "Run tests"
deps: [prep]
deps: [prepare]
cmds:
- uv run pytest tests
@@ -95,11 +95,15 @@ tasks:
- task: test
tool-models:
desc: "Generate tool_models.py from frontend TypeScript defs"
deps: [install]
desc: "Generate tool_models.py from Java OpenAPI spec (SwaggerDoc.json)"
deps: [install, ":backend:swagger"]
cmds:
- uv run python scripts/generate_tool_models.py --output src/stirling/models/tool_models.py
- task: fix
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py
sources:
- ../SwaggerDoc.json
- scripts/generate_tool_models.py
generates:
- src/stirling/models/tool_models.py
clean:
desc: "Clean build artifacts"
+4 -2
View File
@@ -29,6 +29,8 @@ This project uses [Task](https://taskfile.dev/) as a unified command runner. All
- **Code formatting**: `task format` (or `task backend:format` for Java only)
- **Full quality gate**: `task check` (runs lint + typecheck + test across all components)
After modifying any files in the project, you must run the relevant `task check` command that covers that area of the code. For example, when editing frontend files run `task frontend:check`; for Python engine files run `task engine:check`; for Java backend files run `task backend:check`.
### Docker Development
- **Build standard**: `task docker:build` (or `docker build -t stirling-pdf -f docker/embedded/Dockerfile .`)
- **Build fat version**: `task docker:build:fat`
@@ -68,8 +70,8 @@ Development for the AI engine happens in the `engine/` folder. The frontend call
- `frontend/config/.env.saas.example` — SaaS-only vars
- `frontend/config/.env.desktop.example` — desktop (Tauri)-only vars
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the example files
- `task frontend:prep` / `prep:saas` / `prep:desktop` auto-create the env files from examples on first run, and error if any required keys are missing
- Prep runs automatically as a dependency of all `dev*`, `build*`, and `desktop*` tasks
- `task frontend:prepare` / `prepare:saas` / `prepare:desktop` auto-create the env files from examples on first run, and error if any required keys are missing
- Prepare runs automatically as a dependency of all `dev*`, `build*`, and `desktop*` tasks
- See `frontend/README.md#environment-variables` for full documentation
#### Import Paths - CRITICAL
+1 -1
View File
@@ -44,7 +44,7 @@ tasks:
desc: "Start backend + frontend + engine concurrently"
deps:
- backend:dev
- frontend:dev
- frontend:dev:prototypes
- engine:dev
# ============================================================
@@ -0,0 +1,184 @@
package stirling.software.common.service;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.RestTemplate;
import jakarta.servlet.ServletContext;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Dispatches HTTP POST requests to internal Stirling API endpoints via loopback. Used by
* PipelineProcessor and AiWorkflowService to execute tool operations programmatically without
* leaving the JVM network stack.
*/
@Service
@Slf4j
public class InternalApiClient {
// Allowlist for internal dispatch. Matches a fixed namespace prefix,
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
// character that could alter the resolved endpoint on the local Spring server.
private static final Pattern ALLOWED_ENDPOINT_PATH =
Pattern.compile("^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$");
private final ServletContext servletContext;
private final UserServiceInterface userService;
private final TempFileManager tempFileManager;
private final Environment environment;
public InternalApiClient(
ServletContext servletContext,
@Autowired(required = false) UserServiceInterface userService,
TempFileManager tempFileManager,
Environment environment) {
this.servletContext = servletContext;
this.userService = userService;
this.tempFileManager = tempFileManager;
this.environment = environment;
}
/**
* POST to an internal API endpoint. The endpointPath must start with one of the allowed
* prefixes (e.g. {@code /api/v1/misc/compress-pdf}).
*
* @param endpointPath API path (e.g. {@code /api/v1/general/rotate-pdf})
* @param body multipart form body (fileInput + parameters)
* @return response with the result file as a {@link TempFileResource} body
*/
public ResponseEntity<Resource> post(String endpointPath, MultiValueMap<String, Object> body) {
validateUrl(endpointPath);
String url = getBaseUrl() + endpointPath;
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
String apiKey = getApiKeyForUser();
if (apiKey != null && !apiKey.isEmpty()) {
headers.add("X-API-KEY", apiKey);
}
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
return restTemplate.execute(
url,
HttpMethod.POST,
requestCallback,
response -> {
try {
TempFile tempFile = tempFileManager.createManagedTempFile("internal-api");
Files.copy(
response.getBody(),
tempFile.getPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
String filename = extractFilename(response.getHeaders());
TempFileResource resource = new TempFileResource(tempFile, filename);
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.body(resource);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
/**
* Extract the filename from a response's {@code Content-Disposition} header. Returns {@code
* null} if the header is missing or has no filename.
*/
private static String extractFilename(HttpHeaders headers) {
String contentDisposition = headers.getFirst(HttpHeaders.CONTENT_DISPOSITION);
if (contentDisposition == null || contentDisposition.isBlank()) {
return null;
}
for (String part : contentDisposition.split(";")) {
String trimmed = part.trim();
if (trimmed.startsWith("filename")) {
String[] kv = trimmed.split("=", 2);
if (kv.length != 2) {
continue;
}
String value = kv[1].trim().replace("\"", "");
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
}
return null;
}
private String getBaseUrl() {
// Resolve the port lazily so desktop mode (server.port=0, OS-assigned) dispatches to the
// actual bound port. Spring publishes local.server.port once the web server is up; fall
// back to the configured server.port for early calls (tests, non-web contexts).
String port = environment.getProperty("local.server.port");
if (port == null) {
port = environment.getProperty("server.port", "8080");
}
return "http://localhost:" + port + servletContext.getContextPath();
}
private String getApiKeyForUser() {
if (userService == null) return "";
String username = userService.getCurrentUsername();
if (username != null && !username.equals("anonymousUser")) {
return userService.getApiKeyForUser(username);
}
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
}
private void validateUrl(String endpointPath) {
if (endpointPath == null || !ALLOWED_ENDPOINT_PATH.matcher(endpointPath).matches()) {
log.warn("Blocked internal API request to disallowed path: {}", endpointPath);
throw new SecurityException(
"Internal API dispatch not permitted for endpoint: " + endpointPath);
}
}
/**
* A {@link FileSystemResource} that holds a reference to its backing {@link TempFile}.
*
* <p>If a display filename is supplied (typically parsed from the upstream response's {@code
* Content-Disposition} header), it is returned from {@link #getFilename()} instead of the
* underlying temp file's path-based name.
*/
public static class TempFileResource extends FileSystemResource {
private final TempFile tempFile;
private final String displayFilename;
public TempFileResource(TempFile tempFile) {
this(tempFile, null);
}
public TempFileResource(TempFile tempFile, String displayFilename) {
super(tempFile.getFile());
this.tempFile = tempFile;
this.displayFilename = displayFilename;
}
public TempFile getTempFile() {
return tempFile;
}
@Override
public String getFilename() {
return displayFilename != null ? displayFilename : super.getFilename();
}
}
}
@@ -0,0 +1,18 @@
package stirling.software.common.service;
/** Provides metadata about tool endpoints for internal dispatch. */
public interface ToolMetadataService {
/** Returns true if the given operation path accepts multiple input files. */
boolean isMultiInput(String operationPath);
/**
* Returns true when the endpoint's ZIP response is a transport for multiple typed results and
* should be unpacked: multi-output endpoints (Type:SIMO / Type:MIMO) and wrapper declarations
* such as {@code Output:ZIP-PDF} or {@code Output:IMAGE/ZIP}.
*
* <p>Returns false for a bare {@code Output:ZIP} (e.g. {@code get-attachments}), where the
* archive itself is the deliverable and should be kept packed.
*/
boolean shouldUnpackZipResponse(String operationPath);
}
@@ -538,9 +538,9 @@ public final class RegexPatternUtils {
getPattern("[^a-zA-Z0-9 ]"); // Input sanitization
getPattern("[^a-zA-Z0-9]"); // Filename sanitization
// API doc patterns
getPattern("Output:(\\w+)"); // precompiled single-escaped for runtime regex \w
getPattern("Input:(\\w+)");
getPattern("Type:(\\w+)");
getPattern("Output:\\s*(\\w+)");
getPattern("Input:\\s*(\\w+)");
getPattern("Type:\\s*(\\w+)");
log.debug("Pre-compiled {} common regex patterns", patternCache.size());
}
@@ -552,19 +552,19 @@ public final class RegexPatternUtils {
/* Pattern for matching Output:<TYPE> in API descriptions */
public Pattern getApiDocOutputTypePattern() {
return getPattern("Output:(\\w+)");
return getPattern("Output:\\s*(\\w+)");
}
/* Pattern for matching Input:<TYPE> in API descriptions */
public Pattern getApiDocInputTypePattern() {
return getPattern("Input:(\\w+)");
return getPattern("Input:\\s*(\\w+)");
}
/**
* Pattern for matching Type:<CODE> in API descriptions
*/
public Pattern getApiDocTypePattern() {
return getPattern("Type:(\\w+)");
return getPattern("Type:\\s*(\\w+)");
}
/* Pattern for validating file extensions (2-4 alphanumeric, case-insensitive) */
@@ -0,0 +1,142 @@
package stirling.software.common.util;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import io.github.pixee.security.ZipSecurity;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
/**
* Helpers for detecting and extracting ZIP-formatted responses returned from Stirling API
* endpoints. Shared between {@code PipelineProcessor} and {@code AiWorkflowService} so both callers
* unpack ZIPs consistently (hardened against zip-slip, depth-limited, backed by managed temp
* files).
*/
@Slf4j
@UtilityClass
public class ZipExtractionUtils {
private static final int MAX_UNZIP_DEPTH = 10;
private static final byte[] ZIP_MAGIC = {0x50, 0x4B, 0x03, 0x04};
/**
* Returns true if the resource starts with the standard ZIP magic bytes. CBZ files are
* explicitly treated as non-ZIP.
*/
public static boolean isZip(Resource data) throws IOException {
return isZip(data, null);
}
/**
* Returns true if the resource starts with the standard ZIP magic bytes. Files named with the
* {@code .cbz} extension are excluded (handled separately by the comic viewer).
*/
public static boolean isZip(Resource data, String filename) throws IOException {
if (data == null || data.contentLength() < ZIP_MAGIC.length) {
return false;
}
if (filename != null && filename.toLowerCase().endsWith(".cbz")) {
return false;
}
try (InputStream is = data.getInputStream()) {
byte[] header = new byte[ZIP_MAGIC.length];
if (is.read(header) < ZIP_MAGIC.length) {
return false;
}
for (int i = 0; i < ZIP_MAGIC.length; i++) {
if (header[i] != ZIP_MAGIC[i]) {
return false;
}
}
return true;
}
}
/**
* Extract a ZIP resource into a flat list of resources, one per file entry. Nested ZIPs are
* recursively extracted up to {@link #MAX_UNZIP_DEPTH}. Each entry is materialized as a
* hardened-extracted managed temp file so downstream consumers can stream the bytes.
*/
public static List<Resource> extractZip(Resource zip, TempFileManager tempFileManager)
throws IOException {
return extractZip(zip, tempFileManager, null);
}
/**
* Extract a ZIP resource into a flat list of resources. Each created {@link TempFile} is also
* passed to {@code tempFileConsumer} when non-null, giving callers the option to register the
* temp files with an auxiliary lifecycle (e.g. {@code PipelineResult}).
*/
public static List<Resource> extractZip(
Resource zip, TempFileManager tempFileManager, Consumer<TempFile> tempFileConsumer)
throws IOException {
return extractZipInternal(zip, tempFileManager, tempFileConsumer, 0);
}
private static List<Resource> extractZipInternal(
Resource zip,
TempFileManager tempFileManager,
Consumer<TempFile> tempFileConsumer,
int depth)
throws IOException {
if (depth > MAX_UNZIP_DEPTH) {
log.warn(
"ZIP nesting depth {} exceeds limit {}, treating as file",
depth,
MAX_UNZIP_DEPTH);
return List.of(zip);
}
log.debug("Unzipping data of length: {}", zip.contentLength());
List<Resource> extracted = new ArrayList<>();
try (InputStream bais = zip.getInputStream();
ZipInputStream zis = ZipSecurity.createHardenedInputStream(bais)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
TempFile tempFile = tempFileManager.createManagedTempFile("unzip");
if (tempFileConsumer != null) {
tempFileConsumer.accept(tempFile);
}
try (OutputStream os = Files.newOutputStream(tempFile.getPath())) {
byte[] buffer = new byte[4096];
int count;
while ((count = zis.read(buffer)) != -1) {
os.write(buffer, 0, count);
}
}
final String filename = entry.getName();
Resource fileResource =
new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return filename;
}
};
if (isZip(fileResource, filename)) {
log.debug("Nested ZIP entry {} — recursing", filename);
extracted.addAll(
extractZipInternal(
fileResource, tempFileManager, tempFileConsumer, depth + 1));
} else {
extracted.add(fileResource);
}
}
}
log.debug("Unzipping completed. {} files extracted.", extracted.size());
return extracted;
}
}
@@ -0,0 +1,159 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.ResponseExtractor;
import org.springframework.web.client.RestTemplate;
import jakarta.servlet.ServletContext;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class InternalApiClientTest {
@Mock ServletContext servletContext;
@Mock UserServiceInterface userService;
@Mock TempFileManager tempFileManager;
InternalApiClient client;
@BeforeEach
void setUp() {
lenient().when(servletContext.getContextPath()).thenReturn("");
MockEnvironment environment = new MockEnvironment().withProperty("server.port", "8080");
client = new InternalApiClient(servletContext, userService, tempFileManager, environment);
}
@Test
void postDoesNotForceContentType() throws Exception {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", namedResource("input.pdf", "data"));
Path tempPath = Files.createTempFile("internal-api-test", ".tmp");
TempFile tempFile = mock(TempFile.class);
when(tempFile.getPath()).thenReturn(tempPath);
when(tempFile.getFile()).thenReturn(tempPath.toFile());
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
HttpHeaders[] captured = {null};
try (var ignored =
mockConstruction(
RestTemplate.class,
(rt, ctx) -> {
when(rt.httpEntityCallback(any(), eq(Resource.class)))
.thenAnswer(
inv -> {
HttpEntity<?> entity = inv.getArgument(0);
captured[0] = entity.getHeaders();
return (RequestCallback) req -> {};
});
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
})) {
ResponseEntity<Resource> response = client.post("/api/v1/general/merge-pdfs", body);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertNull(captured[0].getContentType(), "Content-Type should not be forced");
} finally {
Files.deleteIfExists(tempPath);
}
}
@Test
void postRejectsDisallowedPath() {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
assertThrows(SecurityException.class, () -> client.post("/api/v1/admin/settings", body));
}
@Test
void postRejectsPathTraversal() {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
assertThrows(
SecurityException.class,
() -> client.post("/api/v1/misc/../../actuator/env", body));
}
@Test
void postRejectsUrlEncodedCharacters() {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
assertThrows(
SecurityException.class, () -> client.post("/api/v1/misc/%2e%2e/actuator", body));
}
@Test
void postRejectsQueryString() {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
assertThrows(
SecurityException.class,
() -> client.post("/api/v1/misc/compress-pdf?redirect=evil", body));
}
@Test
void postRejectsEmptySegment() {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
assertThrows(SecurityException.class, () -> client.post("/api/v1/misc//foo", body));
}
@Test
void postRejectsTrailingSlash() {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
assertThrows(SecurityException.class, () -> client.post("/api/v1/misc/foo/", body));
}
@Test
void postRejectsNullPath() {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
assertThrows(SecurityException.class, () -> client.post(null, body));
}
/** Create a ByteArrayResource with a filename (required for multipart). */
private static Resource namedResource(String filename, String content) {
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) {
@Override
public String getFilename() {
return filename;
}
};
}
/** Simulate a successful HTTP response through a RestTemplate ResponseExtractor. */
@SuppressWarnings("unchecked")
private static ResponseEntity<Resource> fakeOkResponse(Object extractorArg) throws Exception {
var extractor = (ResponseExtractor<ResponseEntity<Resource>>) extractorArg;
ClientHttpResponse response = mock(ClientHttpResponse.class);
when(response.getBody())
.thenReturn(new ByteArrayInputStream("ok".getBytes(StandardCharsets.UTF_8)));
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"out.pdf\"");
when(response.getHeaders()).thenReturn(headers);
lenient().when(response.getStatusCode()).thenReturn(HttpStatus.OK);
return extractor.extractData(response);
}
}
@@ -124,7 +124,9 @@ public class SplitPdfByChaptersController {
@MultiFileResponse
@Operation(
summary = "Split PDFs by Chapters",
description = "Splits a PDF into chapters and returns a ZIP file.")
description =
"Splits a PDF into chapters and returns a ZIP file. Input:PDF Output:ZIP-PDF"
+ " Type:SISO")
public ResponseEntity<StreamingResponseBody> splitPdf(
@ModelAttribute SplitPdfByChaptersRequest request) throws Exception {
MultipartFile file = request.getFileInput();
@@ -11,36 +11,26 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.github.pixee.security.ZipSecurity;
import jakarta.servlet.ServletContext;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.SPDFApplication;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFile;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.ZipExtractionUtils;
@Service
@Slf4j
@@ -48,20 +38,16 @@ public class PipelineProcessor {
private final ApiDocService apiDocService;
private final UserServiceInterface userService;
private final ServletContext servletContext;
private final InternalApiClient internalApiClient;
private final TempFileManager tempFileManager;
public PipelineProcessor(
ApiDocService apiDocService,
@Autowired(required = false) UserServiceInterface userService,
ServletContext servletContext,
InternalApiClient internalApiClient,
TempFileManager tempFileManager) {
this.apiDocService = apiDocService;
this.userService = userService;
this.servletContext = servletContext;
this.internalApiClient = internalApiClient;
this.tempFileManager = tempFileManager;
}
@@ -84,48 +70,6 @@ public class PipelineProcessor {
return name.substring(0, underscoreIndex) + extension;
}
// Allowlist of URL path prefixes permitted through the pipeline.
private static final List<String> ALLOWED_PIPELINE_PATH_PREFIXES =
List.of(
"/api/v1/general/",
"/api/v1/misc/",
"/api/v1/security/",
"/api/v1/convert/",
"/api/v1/filter/");
private void validatePipelineUrl(String url) {
// Strip scheme+host to get the path portion for comparison
String path = url;
int schemeEnd = url.indexOf("://");
if (schemeEnd != -1) {
int pathStart = url.indexOf('/', schemeEnd + 3);
path = pathStart != -1 ? url.substring(pathStart) : "/";
}
final String pathToCheck = path;
boolean allowed = ALLOWED_PIPELINE_PATH_PREFIXES.stream().anyMatch(pathToCheck::contains);
if (!allowed) {
log.warn("Blocked pipeline request to disallowed URL: {}", url);
throw new SecurityException(
"Pipeline operation not permitted for endpoint: " + pathToCheck);
}
}
private String getApiKeyForUser() {
if (userService == null) return "";
String username = userService.getCurrentUsername();
if (username != null && !username.equals("anonymousUser")) {
return userService.getApiKeyForUser(username);
}
// Scheduled/internal context — no user in security context
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
}
private String getBaseUrl() {
String contextPath = servletContext.getContextPath();
String port = SPDFApplication.getStaticPort();
return "http://localhost:" + port + contextPath + "/";
}
PipelineResult runPipelineAgainstFiles(List<Resource> outputFiles, PipelineConfig config)
throws Exception {
PipelineResult result = new PipelineResult();
@@ -153,7 +97,6 @@ public class PipelineProcessor {
"Invalid operation: " + operation + " with parameters: " + parameters);
}
String url = getBaseUrl() + operation;
List<Resource> newOutputFiles = new ArrayList<>();
if (!isMultiInputOperation) {
for (Resource file : outputFiles) {
@@ -175,12 +118,15 @@ public class PipelineProcessor {
body.add(entry.getKey(), entry.getValue());
}
}
ResponseEntity<Resource> response = sendWebRequest(url, body);
ResponseEntity<Resource> response =
internalApiClient.post(operation, body);
// If the operation is filter and the response body is null or empty,
// skip
// this
// file
if (response.getBody() instanceof TempFileResource tempFileResource) {
if (response.getBody()
instanceof
InternalApiClient.TempFileResource tempFileResource) {
result.addTempFile(tempFileResource.getTempFile());
}
@@ -257,8 +203,9 @@ public class PipelineProcessor {
body.add(entry.getKey(), entry.getValue());
}
}
ResponseEntity<Resource> response = sendWebRequest(url, body);
if (response.getBody() instanceof TempFileResource tempFileResource) {
ResponseEntity<Resource> response = internalApiClient.post(operation, body);
if (response.getBody()
instanceof InternalApiClient.TempFileResource tempFileResource) {
result.addTempFile(tempFileResource.getTempFile());
}
// Handle the response
@@ -312,43 +259,6 @@ public class PipelineProcessor {
return result;
}
/* package */ ResponseEntity<Resource> sendWebRequest(
String url, MultiValueMap<String, Object> body) {
validatePipelineUrl(url);
RestTemplate restTemplate = new RestTemplate();
// Set up headers, including API key
HttpHeaders headers = new HttpHeaders();
String apiKey = getApiKeyForUser();
if (apiKey != null && !apiKey.isEmpty()) {
headers.add("X-API-KEY", apiKey);
}
// Let the message converter set the multipart boundary/content type
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback =
restTemplate.httpEntityCallback(entity, Resource.class /* response type hint */);
return restTemplate.execute(
url,
HttpMethod.POST,
requestCallback,
response -> {
try {
TempFile tempFile = tempFileManager.createManagedTempFile("pipeline");
Files.copy(
response.getBody(),
tempFile.getPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
TempFileResource resource = new TempFileResource(tempFile);
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.body(resource);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
private List<Resource> processOutputFiles(
String operation,
ResponseEntity<Resource> response,
@@ -367,13 +277,15 @@ public class PipelineProcessor {
newFilename = removeTrailingNaming(extractFilename(response));
}
// Check if the response body is a zip file
if (isZip(response.getBody(), newFilename)) {
if (ZipExtractionUtils.isZip(response.getBody(), newFilename)) {
// Unzip the file and add all the files to the new output files
newOutputFiles.addAll(unzip(response.getBody(), result));
newOutputFiles.addAll(
ZipExtractionUtils.extractZip(
response.getBody(), tempFileManager, result::addTempFile));
} else {
final Resource tempResource = response.getBody();
if (tempResource instanceof TempFileResource) {
result.addTempFile(((TempFileResource) tempResource).getTempFile());
if (tempResource instanceof InternalApiClient.TempFileResource tfr) {
result.addTempFile(tfr.getTempFile());
}
Resource outputResource =
new FileSystemResource(tempResource.getFile()) {
@@ -456,97 +368,4 @@ public class PipelineProcessor {
log.info("Files successfully loaded. Starting processing...");
return outputFiles;
}
private boolean isZip(Resource data, String filename) throws IOException {
if (data == null || data.contentLength() < 4) {
return false;
}
if (filename != null) {
String lower = filename.toLowerCase();
if (lower.endsWith(".cbz")) {
// Treat CBZ as non-zip for our unzipping purposes
return false;
}
}
// Check the first four bytes of the data against the standard zip magic number
try (InputStream is = data.getInputStream()) {
byte[] header = new byte[4];
if (is.read(header) < 4) {
return false;
}
return header[0] == 0x50 && header[1] == 0x4B && header[2] == 0x03 && header[3] == 0x04;
}
}
private boolean isZip(Resource data) throws IOException {
return isZip(data, null);
}
private static final int MAX_UNZIP_DEPTH = 10;
private List<Resource> unzip(Resource data, PipelineResult result) throws IOException {
return unzip(data, result, 0);
}
private List<Resource> unzip(Resource data, PipelineResult result, int depth)
throws IOException {
if (depth > MAX_UNZIP_DEPTH) {
log.warn(
"ZIP nesting depth {} exceeds limit {}, treating as file",
depth,
MAX_UNZIP_DEPTH);
return List.of(data);
}
log.info("Unzipping data of length: {}", data.contentLength());
List<Resource> unzippedFiles = new ArrayList<>();
try (InputStream bais = data.getInputStream();
ZipInputStream zis = ZipSecurity.createHardenedInputStream(bais)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
TempFile tempFile = tempFileManager.createManagedTempFile("unzip");
result.addTempFile(tempFile);
try (OutputStream os = Files.newOutputStream(tempFile.getPath())) {
byte[] buffer = new byte[4096];
int count;
while ((count = zis.read(buffer)) != -1) {
os.write(buffer, 0, count);
}
}
final String filename = entry.getName();
Resource fileResource =
new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return filename;
}
};
// If the unzipped file is a zip file, unzip it
if (isZip(fileResource, filename)) {
log.info("File {} is a zip file. Unzipping...", filename);
unzippedFiles.addAll(unzip(fileResource, result, depth + 1));
} else {
unzippedFiles.add(fileResource);
}
}
}
log.info("Unzipping completed. {} files were unzipped.", unzippedFiles.size());
return unzippedFiles;
}
private static class TempFileResource extends FileSystemResource {
private final TempFile tempFile;
public TempFileResource(TempFile tempFile) {
super(tempFile.getFile());
this.tempFile = tempFile;
}
public TempFile getTempFile() {
return tempFile;
}
}
}
@@ -13,7 +13,8 @@ public class RotatePDFRequest extends PDFFile {
@Schema(
description =
"The angle by which to rotate the PDF file. This should be a multiple of 90.",
"The clockwise angle by which to rotate the PDF file. Must be a multiple of"
+ " 90.",
type = "integer",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"0", "90", "180", "270"})
@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
@@ -30,7 +31,14 @@ import tools.jackson.databind.ObjectMapper;
@Service
@Slf4j
public class ApiDocService {
public class ApiDocService implements stirling.software.common.service.ToolMetadataService {
// Matches a bare "Output:ZIP" declaration (i.e. ZIP is not followed by "-" or "/").
// Bare ZIP means the archive itself is the deliverable (e.g. get-attachments), so it
// should not be auto-unpacked. Wrapper forms like Output:ZIP-PDF or Output:IMAGE/ZIP
// use ZIP as transport for multiple typed results and are safe to unpack.
private static final Pattern BARE_ZIP_OUTPUT =
Pattern.compile("Output\\s*:\\s*ZIP(?![-/])", Pattern.CASE_INSENSITIVE);
private final Map<String, ApiEndpoint> apiDocumentation = new HashMap<>();
@@ -149,6 +157,7 @@ public class ApiDocService {
return endpoint.areParametersValid(parameters);
}
@Override
public boolean isMultiInput(String operationName) {
if (apiDocsJsonRootNode == null || apiDocumentation.isEmpty()) {
loadApiDocumentation();
@@ -166,5 +175,36 @@ public class ApiDocService {
}
return false;
}
@Override
public boolean shouldUnpackZipResponse(String operationName) {
if (apiDocsJsonRootNode == null || apiDocumentation.isEmpty()) {
loadApiDocumentation();
}
if (!apiDocumentation.containsKey(operationName)) {
return false;
}
ApiEndpoint endpoint = apiDocumentation.get(operationName);
String description = endpoint.getDescription();
Matcher typeMatcher =
RegexPatternUtils.getInstance().getApiDocTypePattern().matcher(description);
if (typeMatcher.find()) {
String type = typeMatcher.group(1);
// Multi-output endpoints (SIMO/MIMO) return a ZIP of their outputs.
if (type.endsWith("MO")) {
return true;
}
}
Matcher outputMatcher =
RegexPatternUtils.getInstance().getApiDocOutputTypePattern().matcher(description);
if (outputMatcher.find()) {
String output = outputMatcher.group(1).toUpperCase(Locale.ROOT);
if (output.startsWith("ZIP")) {
// Bare "Output:ZIP" is a single-archive deliverable, not a transport.
return !BARE_ZIP_OUTPUT.matcher(description).find();
}
}
return false;
}
}
// Model class for API Endpoint
@@ -4,8 +4,6 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -15,24 +13,18 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedConstruction;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import jakarta.servlet.ServletContext;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@@ -40,9 +32,7 @@ class PipelineProcessorTest {
@Mock ApiDocService apiDocService;
@Mock UserServiceInterface userService;
@Mock ServletContext servletContext;
@Mock InternalApiClient internalApiClient;
@Mock TempFileManager tempFileManager;
@@ -51,9 +41,7 @@ class PipelineProcessorTest {
@BeforeEach
void setUp() throws Exception {
pipelineProcessor =
spy(
new PipelineProcessor(
apiDocService, userService, servletContext, tempFileManager));
new PipelineProcessor(apiDocService, internalApiClient, tempFileManager);
}
@Test
@@ -65,7 +53,6 @@ class PipelineProcessorTest {
config.setOperations(List.of(op));
Resource file = new MyFileByteArrayResource();
List<Resource> files = List.of(file);
when(apiDocService.isMultiInput("/api/v1/filter/filter-page-count")).thenReturn(false);
@@ -74,13 +61,11 @@ class PipelineProcessorTest {
when(apiDocService.isValidOperation(eq("/api/v1/filter/filter-page-count"), anyMap()))
.thenReturn(true);
// Use a FileSystemResource backed by a temp file to avoid FileNotFoundException
Path emptyTemp = Files.createTempFile("empty", ".tmp");
Resource emptyResource = new FileSystemResource(emptyTemp.toFile());
doReturn(new ResponseEntity<>(emptyResource, HttpStatus.OK))
.when(pipelineProcessor)
.sendWebRequest(anyString(), any());
when(internalApiClient.post(anyString(), any()))
.thenReturn(new ResponseEntity<>(emptyResource, HttpStatus.OK));
PipelineResult result = pipelineProcessor.runPipelineAgainstFiles(files, config);
@@ -118,105 +103,18 @@ class PipelineProcessorTest {
when(apiDocService.getExtensionTypes(anyBoolean(), anyString())).thenReturn(List.of("pdf"));
when(apiDocService.isValidOperation(anyString(), anyMap())).thenReturn(true);
doReturn(new ResponseEntity<>(outputResource, HttpStatus.OK))
.when(pipelineProcessor)
.sendWebRequest(anyString(), any());
when(internalApiClient.post(anyString(), any()))
.thenReturn(new ResponseEntity<>(outputResource, HttpStatus.OK));
PipelineResult result = pipelineProcessor.runPipelineAgainstFiles(files, config);
verify(pipelineProcessor).sendWebRequest(anyString(), any());
verify(internalApiClient).post(anyString(), any());
assertFalse(result.isHasErrors());
// Clean up
Files.deleteIfExists(tempPath);
}
@Test
void sendWebRequestDoesNotForceContentType() throws Exception {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add(
"fileInput",
new ByteArrayResource("data".getBytes(StandardCharsets.UTF_8)) {
@Override
public String getFilename() {
return "input.pdf";
}
});
Path tempPath = Files.createTempFile("pipeline-test", ".tmp");
var tempFile = mock(stirling.software.common.util.TempFile.class);
when(tempFile.getPath()).thenReturn(tempPath);
when(tempFile.getFile()).thenReturn(tempPath.toFile());
when(tempFileManager.createManagedTempFile("pipeline")).thenReturn(tempFile);
var capturedHeaders = new org.springframework.http.HttpHeaders[1];
try (MockedConstruction<org.springframework.web.client.RestTemplate> ignored =
mockConstruction(
org.springframework.web.client.RestTemplate.class,
(mock, context) -> {
when(mock.httpEntityCallback(any(), eq(Resource.class)))
.thenAnswer(
invocation -> {
var entity = invocation.getArgument(0);
capturedHeaders[0] =
((org.springframework.http.HttpEntity<?>)
entity)
.getHeaders();
return (org.springframework.web.client
.RequestCallback)
request -> {};
});
when(mock.execute(
anyString(),
eq(org.springframework.http.HttpMethod.POST),
any(),
any()))
.thenAnswer(
invocation -> {
@SuppressWarnings("unchecked")
var extractor =
(org.springframework.web.client
.ResponseExtractor<
ResponseEntity<Resource>>)
invocation.getArgument(3);
ClientHttpResponse response =
mock(ClientHttpResponse.class);
when(response.getBody())
.thenReturn(
new ByteArrayInputStream(
"ok"
.getBytes(
StandardCharsets
.UTF_8)));
var headers =
new org.springframework.http.HttpHeaders();
headers.add(
org.springframework.http.HttpHeaders
.CONTENT_DISPOSITION,
"attachment; filename=\"out.pdf\"");
when(response.getHeaders()).thenReturn(headers);
lenient()
.when(response.getStatusCode())
.thenReturn(HttpStatus.OK);
return extractor.extractData(response);
});
})) {
ResponseEntity<Resource> response =
pipelineProcessor.sendWebRequest(
"http://localhost/api/v1/general/merge-pdfs", body);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertNull(capturedHeaders[0].getContentType());
} finally {
Files.deleteIfExists(tempPath);
}
}
private static class MyFileByteArrayResource extends ByteArrayResource {
public MyFileByteArrayResource() {
super("data".getBytes());
@@ -9,6 +9,8 @@ import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -348,6 +350,123 @@ class ApiDocServiceTest {
assertTrue(apiDocService.isMultiInput("/miso"));
}
@Test
void shouldUnpackZipResponseDetectsMultiOutputType() throws Exception {
String json = "{\"description\": \"Output:PDF Type:SIMO\"}";
JsonNode postNode = mapper.readTree(json);
ApiEndpoint endpoint = new ApiEndpoint("/split", postNode);
setApiDocumentation(Map.of("/split", endpoint));
setApiDocsJsonRootNode();
assertTrue(apiDocService.shouldUnpackZipResponse("/split"));
}
@Test
void shouldUnpackZipResponseDetectsMimoType() throws Exception {
String json = "{\"description\": \"Output:PDF Type:MIMO\"}";
JsonNode postNode = mapper.readTree(json);
ApiEndpoint endpoint = new ApiEndpoint("/overlay", postNode);
setApiDocumentation(Map.of("/overlay", endpoint));
setApiDocsJsonRootNode();
assertTrue(apiDocService.shouldUnpackZipResponse("/overlay"));
}
@Test
void shouldUnpackZipResponseDetectsZipOutputDeclaration() throws Exception {
String json = "{\"description\": \"Output:ZIP-PDF Type:SISO\"}";
JsonNode postNode = mapper.readTree(json);
ApiEndpoint endpoint = new ApiEndpoint("/split-by-sections", postNode);
setApiDocumentation(Map.of("/split-by-sections", endpoint));
setApiDocsJsonRootNode();
assertTrue(apiDocService.shouldUnpackZipResponse("/split-by-sections"));
}
@Test
void shouldUnpackZipResponseReturnsFalseForSisoPdf() throws Exception {
String json = "{\"description\": \"Input:PDF Output:PDF Type:SISO\"}";
JsonNode postNode = mapper.readTree(json);
ApiEndpoint endpoint = new ApiEndpoint("/rotate", postNode);
setApiDocumentation(Map.of("/rotate", endpoint));
setApiDocsJsonRootNode();
assertFalse(apiDocService.shouldUnpackZipResponse("/rotate"));
}
@Test
void shouldUnpackZipResponseReturnsFalseForUnknownOperation() throws Exception {
setApiDocumentation(Map.of());
assertFalse(apiDocService.shouldUnpackZipResponse("/unknown"));
}
/**
* Coverage test: every Stirling endpoint whose ZIP response is a transport for multiple typed
* results (SIMO/MIMO or Output:ZIP-PDF / Output:IMAGE/ZIP etc.) must be classified as {@code
* shouldUnpackZipResponse = true}. Descriptions below are the real
* {@code @Operation(description=...)} strings from each controller, so if a controller is
* renamed, tweaked or introduced without a {@code Type:} / {@code Output:ZIP-*} tag, this test
* breaks, surfacing the bug before {@code AiWorkflowService} silently registers a multi-result
* ZIP as a single file.
*
* <p>Add a new row here whenever a new unpack-eligible endpoint is introduced. Descriptions can
* be trimmed to the part containing the relevant tags.
*/
@ParameterizedTest(name = "{0} → shouldUnpackZipResponse")
@CsvSource(
textBlock =
"""
/api/v1/general/split-pages, 'Split pages. Input:PDF Output:PDF Type:SIMO'
/api/v1/general/split-pdf-by-sections, 'Split. Input:PDF Output:ZIP-PDF Type:SISO'
/api/v1/general/split-by-size-or-count, 'Split by size. Input:PDF Output:ZIP-PDF Type:SISO'
/api/v1/general/split-pdf-by-chapters, 'Split by chapters. Input:PDF Output:ZIP-PDF Type:SISO'
/api/v1/general/split-for-poster-print, 'Poster split. Input: PDF Output: ZIP-PDF Type: SISO'
/api/v1/general/overlay-pdfs, 'Overlay PDFs. Input:PDF Output:PDF Type:MIMO'
/api/v1/misc/auto-split-pdf, 'Auto split. Input:PDF Output:ZIP-PDF Type:SISO'
/api/v1/misc/extract-images, 'Extract images. Output:IMAGE/ZIP Type:SIMO'
/api/v1/misc/extract-image-scans, 'Extract image scans. Input:PDF Output:IMAGE/ZIP Type:SIMO'
""")
void shouldUnpackZipResponseClassifiesKnownUnpackableEndpoints(
String endpoint, String description) throws Exception {
String json = mapper.writeValueAsString(Map.of("description", description));
JsonNode postNode = mapper.readTree(json);
setApiDocumentation(Map.of(endpoint, new ApiEndpoint(endpoint, postNode)));
setApiDocsJsonRootNode();
assertTrue(
apiDocService.shouldUnpackZipResponse(endpoint),
() ->
"Expected shouldUnpackZipResponse=true for "
+ endpoint
+ " with description: "
+ description);
}
/**
* Inverse coverage: endpoints whose ZIP response is the deliverable itself (or that return
* single non-ZIP files) must not be flagged for unpacking. Catches regressions where a change
* to the classifier accidentally widens the positive match.
*/
@ParameterizedTest(name = "{0} → !shouldUnpackZipResponse")
@CsvSource(
textBlock =
"""
/api/v1/general/rotate-pdf, 'Rotate. Input:PDF Output:PDF Type:SISO'
/api/v1/general/merge-pdfs, 'Merge. Input:PDF Output:PDF Type:MISO'
/api/v1/misc/compress-pdf, 'Compress. Input:PDF Output:PDF Type:SISO'
/api/v1/misc/flatten, 'Flatten forms. Input:PDF Output:PDF Type:SISO'
/api/v1/security/get-attachments, 'Extract attachments. Input:PDF Output:ZIP Type:SISO'
""")
void shouldUnpackZipResponseRejectsNonUnpackableEndpoints(String endpoint, String description)
throws Exception {
String json = mapper.writeValueAsString(Map.of("description", description));
JsonNode postNode = mapper.readTree(json);
setApiDocumentation(Map.of(endpoint, new ApiEndpoint(endpoint, postNode)));
setApiDocsJsonRootNode();
assertFalse(
apiDocService.shouldUnpackZipResponse(endpoint),
() ->
"Expected shouldUnpackZipResponse=false for "
+ endpoint
+ " with description: "
+ description);
}
@Test
void constructorAcceptsNullUserService() {
ApiDocService service = new ApiDocService(mapper, servletContext, null);
@@ -10,6 +10,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.core.task.support.TaskExecutorAdapter;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
@Configuration
@EnableAsync
@@ -49,11 +50,18 @@ public class AsyncConfig {
return adapter;
}
/**
* AI orchestration runs on a background executor, so the incoming request's {@code
* SecurityContext} must be propagated for downstream calls to see the authenticated user.
* Without this, {@code JobOwnershipService} scopes job keys without a user prefix and
* authenticated downloads fail with 403; {@code InternalApiClient} also falls back to the
* internal-API-user key instead of the caller's.
*/
@Bean(name = "aiStreamExecutor")
public Executor aiStreamExecutor() {
TaskExecutorAdapter adapter =
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
adapter.setTaskDecorator(new MDCContextTaskDecorator());
return adapter;
return new DelegatingSecurityContextExecutor(adapter);
}
}
@@ -1,10 +1,12 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -25,8 +27,12 @@ import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.TaskManager;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiWorkflowService;
@@ -45,16 +51,30 @@ public class AiEngineController {
private final AiWorkflowService aiWorkflowService;
private final ObjectMapper objectMapper;
private final Executor aiStreamExecutor;
private final TaskManager taskManager;
private final JobOwnershipService jobOwnershipService;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
*/
@Value("${stirling.ai.streamTimeoutMs:1800000}")
private long streamTimeoutMs;
public AiEngineController(
AiEngineClient aiEngineClient,
AiWorkflowService aiWorkflowService,
ObjectMapper objectMapper,
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor) {
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
TaskManager taskManager,
JobOwnershipService jobOwnershipService) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
this.objectMapper = objectMapper;
this.aiStreamExecutor = aiStreamExecutor;
this.taskManager = taskManager;
this.jobOwnershipService = jobOwnershipService;
}
@GetMapping("/health")
@@ -70,10 +90,14 @@ public class AiEngineController {
@Operation(
summary = "Run an AI workflow against a PDF",
description =
"Accepts a PDF upload and a user message and returns an AI workflow result")
public ResponseEntity<AiWorkflowResponse> orchestrate(
@Valid @ModelAttribute AiWorkflowRequest request) throws IOException {
return ResponseEntity.ok(aiWorkflowService.orchestrate(request));
"Accepts PDF uploads and a user message and returns an AI workflow result."
+ " When the workflow produces files, they are registered with the job"
+ " system and downloadable via GET /api/v1/general/files/{fileId}.")
public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request)
throws IOException {
AiWorkflowResponse result = aiWorkflowService.orchestrate(request);
registerFileResultAsJob(result);
return result;
}
@PostMapping(value = "/orchestrate/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -83,11 +107,23 @@ public class AiEngineController {
"Accepts a PDF upload and a user message, returns SSE events with progress"
+ " updates followed by the final AI workflow result")
public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) {
SseEmitter emitter = new SseEmitter(180_000L);
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
emitter.onTimeout(
() -> {
log.warn("SSE emitter timed out for AI orchestration stream");
// Emit an explicit error frame so the frontend reports a timeout rather than
// silently seeing the stream end without a result.
log.warn(
"SSE emitter timed out for AI orchestration stream after {} ms",
streamTimeoutMs);
sendEvent(
emitter,
"error",
Map.of(
"message",
"AI workflow timed out after "
+ (streamTimeoutMs / 1000)
+ " seconds"));
emitter.complete();
});
emitter.onError(e -> log.warn("SSE emitter error for AI orchestration stream", e));
@@ -102,15 +138,50 @@ public class AiEngineController {
AiWorkflowResponse result =
aiWorkflowService.orchestrate(
request, progress -> sendEvent(emitter, "progress", progress));
registerFileResultAsJob(result);
sendEvent(emitter, "result", result);
emitter.complete();
} catch (Exception e) {
log.error("AI orchestration stream failed", e);
// Emit an error frame for the frontend and then complete normally. Using
// completeWithError here as well would double-complete the emitter - the error
// frame already conveys the failure to the client.
sendEvent(emitter, "error", Map.of("message", e.getMessage()));
emitter.completeWithError(e);
emitter.complete();
}
}
/**
* Register any file results produced by the workflow with {@link TaskManager} so they are
* downloadable via {@code GET /api/v1/general/files/{fileId}}. Uses {@code
* setMultipleFileResults} so the fileIds we registered earlier are not mangled by TaskManager's
* ZIP auto-extract path.
*/
private void registerFileResultAsJob(AiWorkflowResponse result) {
List<AiWorkflowResultFile> files = result.getResultFiles();
if (files == null || files.isEmpty()) {
return;
}
// Scope the job key to the current user so the download endpoint's ownership check
// passes when security is enabled. NoOpJobOwnershipService returns the UUID unchanged
// when security is off.
String jobKey =
jobOwnershipService.createScopedJobKey(java.util.UUID.randomUUID().toString());
taskManager.createTask(jobKey);
List<ResultFile> jobFiles =
files.stream()
.map(
f ->
ResultFile.builder()
.fileId(f.getFileId())
.fileName(f.getFileName())
.contentType(f.getContentType())
.build())
.toList();
taskManager.setMultipleFileResults(jobKey, jobFiles);
taskManager.setComplete(jobKey);
}
private void sendEvent(SseEmitter emitter, String name, Object data) {
try {
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
@@ -15,6 +15,7 @@ public enum AiWorkflowOutcome {
PLAN("plan"),
NEED_CLARIFICATION("need_clarification"),
CANNOT_DO("cannot_do"),
DRAFT("draft"),
TOOL_CALL("tool_call"),
COMPLETED("completed"),
UNSUPPORTED_CAPABILITY("unsupported_capability"),
@@ -8,6 +8,7 @@ public enum AiWorkflowPhase {
ANALYZING("analyzing"),
CALLING_ENGINE("calling_engine"),
EXTRACTING_CONTENT("extracting_content"),
EXECUTING_TOOL("executing_tool"),
PROCESSING("processing");
private final String value;
@@ -1,15 +1,38 @@
package stirling.software.proprietary.model.api.ai;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AiWorkflowProgressEvent {
private AiWorkflowPhase phase;
private long timestamp;
/** The tool endpoint path being executed, for {@link AiWorkflowPhase#EXECUTING_TOOL} events. */
private String tool;
/**
* 1-based index of the current plan step, for {@link AiWorkflowPhase#EXECUTING_TOOL} events.
*/
private Integer stepIndex;
/** Total number of plan steps, for {@link AiWorkflowPhase#EXECUTING_TOOL} events. */
private Integer stepCount;
public static AiWorkflowProgressEvent of(AiWorkflowPhase phase) {
return new AiWorkflowProgressEvent(phase, System.currentTimeMillis());
return new AiWorkflowProgressEvent(phase, System.currentTimeMillis(), null, null, null);
}
public static AiWorkflowProgressEvent executingTool(String tool, int stepIndex, int stepCount) {
return new AiWorkflowProgressEvent(
AiWorkflowPhase.EXECUTING_TOOL,
System.currentTimeMillis(),
tool,
stepIndex,
stepCount);
}
}
@@ -44,6 +44,30 @@ public class AiWorkflowResponse {
@Schema(description = "Structured tool steps when the workflow returns a plan")
private List<Map<String, Object>> steps = new ArrayList<>();
@Schema(
description =
"Tool endpoint path for tool_call outcomes (e.g. /api/v1/misc/compress-pdf)")
private String tool;
@Schema(description = "Tool parameters for tool_call outcomes")
private Map<String, Object> parameters;
@Schema(description = "Result file ID after tool execution completes (single-file result)")
private String fileId;
@Schema(description = "Result filename after tool execution completes (single-file result)")
private String fileName;
@Schema(description = "Result MIME type after tool execution completes (single-file result)")
private String contentType;
@Schema(
description =
"Result files produced by the workflow. Always populated on completed outcomes"
+ " with at least one entry; for single-file results this mirrors"
+ " fileId/fileName/contentType.")
private List<AiWorkflowResultFile> resultFiles = new ArrayList<>();
@Schema(description = "Per-file text extraction requests from the AI engine")
private List<AiWorkflowFileRequest> files = new ArrayList<>();
@@ -0,0 +1,24 @@
package stirling.software.proprietary.model.api.ai;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/** A single file produced by a completed AI workflow. */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Descriptor for a file produced by an AI workflow")
public class AiWorkflowResultFile {
@Schema(description = "Stirling file ID — download with GET /api/v1/general/files/{fileId}")
private String fileId;
@Schema(description = "Original filename for the file")
private String fileName;
@Schema(description = "MIME type of the file", example = "application/pdf")
private String contentType;
}
@@ -7,16 +7,33 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.ZipExtractionUtils;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
@@ -24,6 +41,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowPhase;
import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
@@ -39,6 +57,10 @@ public class AiWorkflowService {
private final AiEngineClient aiEngineClient;
private final PdfContentExtractor pdfContentExtractor;
private final ObjectMapper objectMapper;
private final InternalApiClient internalApiClient;
private final FileStorage fileStorage;
private final ToolMetadataService toolMetadataService;
private final TempFileManager tempFileManager;
@FunctionalInterface
public interface ProgressListener {
@@ -89,12 +111,13 @@ public class AiWorkflowService {
AiWorkflowResponse response = invokeOrchestrator(request);
return switch (response.getOutcome()) {
case NEED_CONTENT -> onNeedContent(response, filesByName, request, listener);
case TOOL_CALL -> onToolCall(response, filesByName, listener);
case PLAN -> onPlan(response, filesByName, listener);
case ANSWER,
NOT_FOUND,
PLAN,
NEED_CLARIFICATION,
CANNOT_DO,
TOOL_CALL,
DRAFT,
COMPLETED,
UNSUPPORTED_CAPABILITY,
CANNOT_CONTINUE ->
@@ -174,6 +197,199 @@ public class AiWorkflowService {
}
}
@SuppressWarnings("unchecked")
private WorkflowState onToolCall(
AiWorkflowResponse response,
Map<String, MultipartFile> filesByName,
ProgressListener listener) {
String endpointPath = response.getTool();
Map<String, Object> parameters = response.getParameters();
if (endpointPath == null || endpointPath.isBlank()) {
return new WorkflowState.Terminal(
cannotContinue("AI engine returned tool_call without a tool endpoint."));
}
if (parameters == null) {
parameters = Map.of();
}
try {
List<Resource> inputFiles = toResources(filesByName);
listener.onProgress(AiWorkflowProgressEvent.executingTool(endpointPath, 1, 1));
List<Resource> results = executeStep(endpointPath, parameters, inputFiles);
return new WorkflowState.Terminal(
buildCompletedResponse(
response.getRationale(),
results,
new ArrayList<>(filesByName.keySet())));
} catch (Exception e) {
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
return new WorkflowState.Terminal(
cannotContinue("Tool execution failed: " + e.getMessage()));
}
}
@SuppressWarnings("unchecked")
private WorkflowState onPlan(
AiWorkflowResponse response,
Map<String, MultipartFile> filesByName,
ProgressListener listener) {
List<Map<String, Object>> steps = response.getSteps();
if (steps == null || steps.isEmpty()) {
return new WorkflowState.Terminal(
cannotContinue("AI engine returned a plan with no steps."));
}
try {
List<Resource> currentFiles = toResources(filesByName);
for (int i = 0; i < steps.size(); i++) {
Map<String, Object> step = steps.get(i);
String endpointPath = (String) step.get("tool");
Map<String, Object> parameters =
step.containsKey("parameters")
? (Map<String, Object>) step.get("parameters")
: Map.of();
if (endpointPath == null || endpointPath.isBlank()) {
return new WorkflowState.Terminal(
cannotContinue("Plan step " + (i + 1) + " has no tool endpoint."));
}
listener.onProgress(
AiWorkflowProgressEvent.executingTool(endpointPath, i + 1, steps.size()));
currentFiles = executeStep(endpointPath, parameters, currentFiles);
}
return new WorkflowState.Terminal(
buildCompletedResponse(
response.getSummary(),
currentFiles,
new ArrayList<>(filesByName.keySet())));
} catch (Exception e) {
log.error("Failed to execute plan: {}", e.getMessage(), e);
return new WorkflowState.Terminal(
cannotContinue("Plan execution failed: " + e.getMessage()));
}
}
/**
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
*/
private List<Resource> executeStep(
String endpointPath, Map<String, Object> parameters, List<Resource> inputFiles)
throws IOException {
List<Resource> results = new ArrayList<>();
if (toolMetadataService.isMultiInput(endpointPath)) {
results.addAll(callEndpoint(endpointPath, parameters, inputFiles));
} else {
for (Resource file : inputFiles) {
results.addAll(callEndpoint(endpointPath, parameters, List.of(file)));
}
}
return results;
}
/**
* Call an endpoint and return the response body. Endpoints that are declared as ZIP-returning
* in the API spec (multi-output, or {@code Output:ZIP-*}) are unpacked into their individual
* entries so callers always see a flat list of result files.
*/
private List<Resource> callEndpoint(
String endpointPath, Map<String, Object> parameters, List<Resource> files)
throws IOException {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
for (Resource file : files) {
body.add("fileInput", file);
}
for (Map.Entry<String, Object> entry : parameters.entrySet()) {
if (entry.getValue() instanceof List<?> list) {
for (Object item : list) {
body.add(entry.getKey(), item);
}
} else {
body.add(entry.getKey(), entry.getValue());
}
}
ResponseEntity<Resource> response = internalApiClient.post(endpointPath, body);
if (!HttpStatus.OK.equals(response.getStatusCode()) || response.getBody() == null) {
throw new IOException(
"Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath);
}
Resource resource = response.getBody();
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
return ZipExtractionUtils.extractZip(resource, tempFileManager);
}
return List.of(resource);
}
private List<Resource> toResources(Map<String, MultipartFile> filesByName) throws IOException {
List<Resource> resources = new ArrayList<>();
for (MultipartFile file : filesByName.values()) {
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
file.transferTo(tempFile.getPath());
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
resources.add(
new FileSystemResource(tempFile.getFile()) {
@Override
public String getFilename() {
return originalName;
}
});
}
return resources;
}
private AiWorkflowResponse buildCompletedResponse(
String summary, List<Resource> resultFiles, List<String> inputFileNames)
throws IOException {
// Store every output file individually so each gets its own Stirling file ID and the
// frontend can add them as independent variants without going through a zip.
boolean preserveInputNames = inputFileNames.size() == resultFiles.size();
List<AiWorkflowResultFile> descriptors = new ArrayList<>();
for (int i = 0; i < resultFiles.size(); i++) {
Resource resource = resultFiles.get(i);
String responseName = resource.getFilename();
String inputName = preserveInputNames ? inputFileNames.get(i) : null;
// Prefer the input name only for 1:1 operations where the output keeps the same
// extension (rotate, compress, etc.). For converters and other extension-changing
// tools, the response filename from Content-Disposition is authoritative.
String name;
if (inputName != null
&& FilenameUtils.getExtension(inputName)
.equalsIgnoreCase(FilenameUtils.getExtension(responseName))) {
name = inputName;
} else if (responseName != null) {
name = responseName;
} else {
name = "result-" + (i + 1);
}
String contentType =
MediaTypeFactory.getMediaType(name)
.orElse(MediaType.APPLICATION_OCTET_STREAM)
.toString();
String fileId;
try (java.io.InputStream is = resource.getInputStream()) {
fileId = fileStorage.storeInputStream(is, name).fileId();
}
descriptors.add(new AiWorkflowResultFile(fileId, name, contentType));
}
AiWorkflowResponse completed = new AiWorkflowResponse();
completed.setOutcome(AiWorkflowOutcome.COMPLETED);
completed.setSummary(summary);
completed.setResultFiles(descriptors);
// Mirror the first file into the legacy single-file fields so existing clients still work.
if (!descriptors.isEmpty()) {
AiWorkflowResultFile first = descriptors.getFirst();
completed.setFileId(first.getFileId());
completed.setFileName(first.getFileName());
completed.setContentType(first.getContentType());
}
return completed;
}
private void validateRequest(AiWorkflowRequest request) {
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
if (fileInput.getFileInput().isEmpty()) {
@@ -0,0 +1,335 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.util.MultiValueMap;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.FileStorage.StoredFile;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput;
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Smoke tests for {@link AiWorkflowService}. Covers the TOOL_CALL and PLAN execution paths,
* ZIP-response unpacking (split endpoints), multi-input dispatch (merge endpoints), and the 1:1
* input-to-output filename preservation rule.
*
* <p>External collaborators (engine client, internal API client, tool metadata, file storage) are
* mocked. {@link TempFileManager} is constructed with a real in-test registry so the service's
* temp-file handling exercises real code.
*/
@ExtendWith(MockitoExtension.class)
class AiWorkflowServiceTest {
private static final String ROTATE_ENDPOINT = "/api/v1/general/rotate-pdf";
private static final String SPLIT_ENDPOINT = "/api/v1/general/split-pages";
private static final String MERGE_ENDPOINT = "/api/v1/general/merge-pdfs";
private static final String COMPRESS_ENDPOINT = "/api/v1/misc/compress-pdf";
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private AiEngineClient aiEngineClient;
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private InternalApiClient internalApiClient;
@Mock private FileStorage fileStorage;
@Mock private ToolMetadataService toolMetadataService;
@TempDir Path tempDir;
private TempFileManager tempFileManager;
private ObjectMapper objectMapper;
private AiWorkflowService service;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("ai-test-");
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
objectMapper = JsonMapper.builder().build();
service =
new AiWorkflowService(
pdfDocumentFactory,
aiEngineClient,
pdfContentExtractor,
objectMapper,
internalApiClient,
fileStorage,
toolMetadataService,
tempFileManager);
}
@Test
void toolCallSingleFilePreservesInputFilename() throws IOException {
MockMultipartFile input = pdf("input.pdf", "original-pdf-bytes");
stubOrchestrator(
"""
{"outcome":"tool_call","tool":"%s","parameters":{"angle":90},"rationale":"Rotating"}
"""
.formatted(ROTATE_ENDPOINT));
when(toolMetadataService.isMultiInput(ROTATE_ENDPOINT)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(ROTATE_ENDPOINT)).thenReturn(false);
stubEndpoint(ROTATE_ENDPOINT, pdfResource("rotated-bytes", "rotated.pdf"));
AtomicInteger ids = stubFileStorage();
AiWorkflowResponse result = service.orchestrate(requestFor(input, "rotate 90"));
assertEquals(AiWorkflowOutcome.COMPLETED, result.getOutcome());
assertEquals(1, result.getResultFiles().size());
// 1:1 mapping — the single output should inherit the single input's filename.
assertEquals("input.pdf", result.getResultFiles().get(0).getFileName());
assertEquals("file-1", result.getResultFiles().get(0).getFileId());
assertEquals(1, ids.get());
verify(internalApiClient, times(1)).post(eq(ROTATE_ENDPOINT), any());
}
@Test
void toolCallZipResponseUnpacksIntoMultipleResults() throws IOException {
MockMultipartFile input = pdf("doc.pdf", "original");
stubOrchestrator(
"""
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Splitting"}
"""
.formatted(SPLIT_ENDPOINT));
when(toolMetadataService.isMultiInput(SPLIT_ENDPOINT)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(SPLIT_ENDPOINT)).thenReturn(true);
stubEndpoint(
SPLIT_ENDPOINT,
zipResource(
"doc.zip",
List.of(
new ZipEntryBytes("page-1.pdf", "page-one"),
new ZipEntryBytes("page-2.pdf", "page-two"),
new ZipEntryBytes("page-3.pdf", "page-three"))));
stubFileStorage();
AiWorkflowResponse result = service.orchestrate(requestFor(input, "split"));
assertEquals(AiWorkflowOutcome.COMPLETED, result.getOutcome());
assertEquals(3, result.getResultFiles().size());
// Input count (1) != output count (3) so the per-entry filename is kept.
assertEquals("page-1.pdf", result.getResultFiles().get(0).getFileName());
assertEquals("page-2.pdf", result.getResultFiles().get(1).getFileName());
assertEquals("page-3.pdf", result.getResultFiles().get(2).getFileName());
}
@Test
void multiInputEndpointIsCalledOnceWithAllFiles() throws IOException {
MockMultipartFile a = pdf("a.pdf", "a-bytes");
MockMultipartFile b = pdf("b.pdf", "b-bytes");
stubOrchestrator(
"""
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Merging"}
"""
.formatted(MERGE_ENDPOINT));
when(toolMetadataService.isMultiInput(MERGE_ENDPOINT)).thenReturn(true);
when(toolMetadataService.shouldUnpackZipResponse(MERGE_ENDPOINT)).thenReturn(false);
stubEndpoint(MERGE_ENDPOINT, pdfResource("merged-bytes", "merged.pdf"));
stubFileStorage();
AiWorkflowResponse result =
service.orchestrate(requestFor(new MockMultipartFile[] {a, b}, "merge these"));
assertEquals(AiWorkflowOutcome.COMPLETED, result.getOutcome());
assertEquals(1, result.getResultFiles().size());
// Two inputs but only one output → filename is not preserved from either input.
assertEquals("merged.pdf", result.getResultFiles().get(0).getFileName());
verify(internalApiClient, times(1)).post(eq(MERGE_ENDPOINT), any());
}
@Test
void singleInputEndpointIsCalledOncePerFile() throws IOException {
MockMultipartFile a = pdf("a.pdf", "a-bytes");
MockMultipartFile b = pdf("b.pdf", "b-bytes");
stubOrchestrator(
"""
{"outcome":"tool_call","tool":"%s","parameters":{"angle":90},"rationale":"Rotating"}
"""
.formatted(ROTATE_ENDPOINT));
when(toolMetadataService.isMultiInput(ROTATE_ENDPOINT)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(ROTATE_ENDPOINT)).thenReturn(false);
stubEndpoint(ROTATE_ENDPOINT, pdfResource("rotated", "rotated.pdf"));
stubFileStorage();
AiWorkflowResponse result =
service.orchestrate(requestFor(new MockMultipartFile[] {a, b}, "rotate both"));
assertEquals(AiWorkflowOutcome.COMPLETED, result.getOutcome());
assertEquals(2, result.getResultFiles().size());
// Per-file loop dispatches one call per input file.
verify(internalApiClient, times(2)).post(eq(ROTATE_ENDPOINT), any());
// 1:1 mapping preserves each input's filename.
assertEquals("a.pdf", result.getResultFiles().get(0).getFileName());
assertEquals("b.pdf", result.getResultFiles().get(1).getFileName());
}
@Test
void planExecutesStepsSequentially() throws IOException {
MockMultipartFile input = pdf("input.pdf", "bytes");
stubOrchestrator(
"""
{
"outcome":"plan",
"summary":"Rotate then compress",
"steps":[
{"tool":"%s","parameters":{"angle":90}},
{"tool":"%s","parameters":{}}
]
}
"""
.formatted(ROTATE_ENDPOINT, COMPRESS_ENDPOINT));
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
stubEndpoint(ROTATE_ENDPOINT, pdfResource("rotated", "rotated.pdf"));
stubEndpoint(COMPRESS_ENDPOINT, pdfResource("compressed", "compressed.pdf"));
stubFileStorage();
AiWorkflowResponse result = service.orchestrate(requestFor(input, "rotate and compress"));
assertEquals(AiWorkflowOutcome.COMPLETED, result.getOutcome());
assertEquals(1, result.getResultFiles().size());
// 1:1 input → output mapping at the plan level preserves the input's filename.
assertEquals("input.pdf", result.getResultFiles().get(0).getFileName());
verify(internalApiClient, times(1)).post(eq(ROTATE_ENDPOINT), any());
verify(internalApiClient, times(1)).post(eq(COMPRESS_ENDPOINT), any());
}
@Test
void toolCallWithoutEndpointFallsBackToCannotContinue() throws IOException {
MockMultipartFile input = pdf("input.pdf", "bytes");
stubOrchestrator("{\"outcome\":\"tool_call\",\"parameters\":{}}");
AiWorkflowResponse result = service.orchestrate(requestFor(input, "do something"));
assertEquals(AiWorkflowOutcome.CANNOT_CONTINUE, result.getOutcome());
assertNotNull(result.getReason());
verify(internalApiClient, never()).post(anyString(), any());
}
// --- helpers ---
private void stubOrchestrator(String responseJson) throws IOException {
when(aiEngineClient.post(eq("/api/v1/orchestrator"), anyString())).thenReturn(responseJson);
}
private void stubEndpoint(String endpoint, Resource body) {
when(internalApiClient.post(eq(endpoint), any(MultiValueMap.class)))
.thenReturn(ResponseEntity.ok(body));
}
/**
* Stub {@link FileStorage#storeInputStream} with sequential file IDs and an accurate byte
* count. Returns the counter so tests can assert how many stores happened.
*/
private AtomicInteger stubFileStorage() throws IOException {
AtomicInteger counter = new AtomicInteger();
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
.thenAnswer(
inv -> {
InputStream is = inv.getArgument(0);
long size = is.readAllBytes().length;
return new StoredFile("file-" + counter.incrementAndGet(), size);
});
return counter;
}
private static MockMultipartFile pdf(String filename, String content) {
return new MockMultipartFile("fileInput", filename, "application/pdf", content.getBytes());
}
private static AiWorkflowRequest requestFor(MockMultipartFile file, String message) {
return requestFor(new MockMultipartFile[] {file}, message);
}
private static AiWorkflowRequest requestFor(MockMultipartFile[] files, String message) {
AiWorkflowRequest request = new AiWorkflowRequest();
List<AiWorkflowFileInput> inputs = new ArrayList<>();
for (MockMultipartFile file : files) {
AiWorkflowFileInput fileInput = new AiWorkflowFileInput();
fileInput.setFileInput(file);
inputs.add(fileInput);
}
request.setFileInputs(inputs);
request.setUserMessage(message);
return request;
}
private static ByteArrayResource pdfResource(String content, String filename) {
return new ByteArrayResource(content.getBytes()) {
@Override
public String getFilename() {
return filename;
}
};
}
private static ByteArrayResource zipResource(String filename, List<ZipEntryBytes> entries)
throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (ZipEntryBytes entry : entries) {
zos.putNextEntry(new ZipEntry(entry.name()));
zos.write(entry.bytes());
zos.closeEntry();
}
}
byte[] zipBytes = baos.toByteArray();
return new ByteArrayResource(zipBytes) {
@Override
public String getFilename() {
return filename;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(zipBytes);
}
};
}
private record ZipEntryBytes(String name, byte[] bytes) {
ZipEntryBytes(String name, String content) {
this(name, content.getBytes());
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ All engine commands can be run from the repository root using Task:
- `task engine:lint` — run ruff linting
- `task engine:typecheck` — run pyright
- `task engine:format` — format code with ruff
- `task engine:tool-models` — generate tool_models.py from frontend TypeScript defs
- `task engine:tool-models` — generate tool_models.py from Java OpenAPI spec
## Code Style
+2
View File
@@ -16,8 +16,10 @@ dependencies = [
[dependency-groups]
dev = [
"datamodel-code-generator[ruff]>=0.26.0",
"pytest>=8.0.0",
"pyright>=1.1.408",
"referencing>=0.35.0",
"ruff>=0.14.10",
]
+184 -471
View File
@@ -1,509 +1,222 @@
#!/usr/bin/env python3
"""Generate Python tool models from the Java backend's OpenAPI spec (SwaggerDoc.json).
Uses datamodel-code-generator to convert OpenAPI request schemas to Pydantic models.
Run via:
task engine:tool-models
"""
from __future__ import annotations
import argparse
import json
import keyword
import re
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
TOOL_MODELS_HEADER = """# AUTO-GENERATED FILE. DO NOT EDIT.
# Generated by scripts/generate_tool_models.py from frontend TypeScript sources.
# ruff: noqa: N815
"""
from datamodel_code_generator import InputFileType, PythonVersion, generate
from datamodel_code_generator.enums import DataModelType
from datamodel_code_generator.format import Formatter
from referencing import Registry, Resource
from referencing.jsonschema import DRAFT202012
# Fields inherited from PDFFile base class — not tool parameters.
BASE_CLASS_FIELDS = frozenset({"fileInput", "fileId"})
OPERATION_TYPE_RE = re.compile(r"operationType\s*:\s*['\"]([A-Za-z0-9_]+)['\"]")
DEFAULT_REF_RE = re.compile(r"defaultParameters\s*:\s*([A-Za-z0-9_]+)")
DEFAULT_SHORTHAND_RE = re.compile(r"\bdefaultParameters\b")
IMPORT_RE = re.compile(r"import\s*\{([^}]+)\}\s*from\s*['\"]([^'\"]+)['\"]")
VAR_OBJ_RE_TEMPLATE = r"(?:export\s+)?const\s+{name}\b[^=]*=\s*\{{"
_ENGINE_ROOT = Path(__file__).resolve().parents[1]
_FILE_HEADER = (
"# AUTO-GENERATED FILE. DO NOT EDIT.\n"
"# Generated by scripts/generate_tool_models.py from Java OpenAPI spec (SwaggerDoc.json).\n"
"# ruff: noqa: E501"
)
@dataclass
class ToolModelSpec:
tool_id: str
params: dict[str, Any]
param_types: dict[str, Any]
class ToolSpec:
path: str
enum_name: str
class_name: str
class ParseError(Exception):
pass
@dataclass
class DiscoveryResult:
tools: list[ToolSpec]
combined_schema: dict[str, Any]
def _find_matching(text: str, start: int, open_char: str, close_char: str) -> int:
depth = 0
i = start
in_str: str | None = None
while i < len(text):
ch = text[i]
if in_str:
if ch == "\\":
i += 2
continue
if ch == in_str:
in_str = None
i += 1
continue
if ch in {"'", '"'}:
in_str = ch
elif ch == open_char:
depth += 1
elif ch == close_char:
depth -= 1
if depth == 0:
return i
i += 1
raise ParseError(f"Unmatched {open_char}{close_char} block")
class ToolDiscovery:
"""Discovers tool endpoints from an OpenAPI spec and builds a combined JSON Schema."""
def _extract_block(text: str, pattern: str) -> str | None:
match = re.search(pattern, text)
if not match:
return None
brace_start = text.find("{", match.end() - 1)
if brace_start == -1:
return None
brace_end = _find_matching(text, brace_start, "{", "}")
return text[brace_start : brace_end + 1]
def _split_top_level_items(obj_body: str) -> list[str]:
items: list[str] = []
depth_obj = depth_arr = 0
in_str: str | None = None
token_start = 0
i = 0
while i < len(obj_body):
ch = obj_body[i]
if in_str:
if ch == "\\":
i += 2
continue
if ch == in_str:
in_str = None
i += 1
continue
if ch in {"'", '"'}:
in_str = ch
elif ch == "{":
depth_obj += 1
elif ch == "}":
depth_obj -= 1
elif ch == "[":
depth_arr += 1
elif ch == "]":
depth_arr -= 1
elif ch == "," and depth_obj == 0 and depth_arr == 0:
piece = obj_body[token_start:i].strip()
if piece:
items.append(piece)
token_start = i + 1
i += 1
tail = obj_body[token_start:].strip()
if tail:
items.append(tail)
return items
def _resolve_import_path(repo_root: Path, current_file: Path, module_path: str) -> Path | None:
candidates: list[Path] = []
if module_path.startswith("@app/"):
rel = module_path[len("@app/") :]
candidates.extend(
[
repo_root / "frontend/src/core" / f"{rel}.ts",
repo_root / "frontend/src/core" / f"{rel}.tsx",
repo_root / "frontend/src/saas" / f"{rel}.ts",
repo_root / "frontend/src/saas" / f"{rel}.tsx",
repo_root / "frontend/src" / f"{rel}.ts",
repo_root / "frontend/src" / f"{rel}.tsx",
]
)
elif module_path.startswith("."):
base = (current_file.parent / module_path).resolve()
candidates.extend([Path(f"{base}.ts"), Path(f"{base}.tsx")])
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _parse_literal_value(value: str, resolver: Callable[[str], dict[str, Any] | None]) -> Any:
value = value.strip()
if not value:
return None
if value.startswith("{") and value.endswith("}"):
return _parse_object_literal(value, resolver)
if value.startswith("[") and value.endswith("]"):
inner = value[1:-1].strip()
if not inner:
return []
return [_parse_literal_value(item, resolver) for item in _split_top_level_items(inner)]
if value.startswith(("'", '"')) and value.endswith(("'", '"')):
return value[1:-1]
if value in {"true", "false"}:
return value == "true"
if value == "null":
return None
if re.fullmatch(r"-?\d+", value):
return int(value)
if re.fullmatch(r"-?\d+\.\d+", value):
return float(value)
resolved = resolver(value)
if resolved is not None:
return resolved
return None
def _parse_object_literal(obj_text: str, resolver: Callable[[str], dict[str, Any] | None]) -> dict[str, Any]:
body = obj_text.strip()[1:-1]
result: dict[str, Any] = {}
for item in _split_top_level_items(body):
if item.startswith("..."):
spread_name = item[3:].strip()
spread = resolver(spread_name)
if isinstance(spread, dict):
result.update(spread)
continue
if ":" not in item:
continue
key, raw_value = item.split(":", 1)
key = key.strip().strip("'\"")
result[key] = _parse_literal_value(raw_value.strip(), resolver)
return result
def _extract_imports(source: str) -> dict[str, str]:
imports: dict[str, str] = {}
for names, module_path in IMPORT_RE.findall(source):
for part in names.split(","):
segment = part.strip()
if not segment:
continue
if " as " in segment:
original, alias = [x.strip() for x in segment.split(" as ", 1)]
imports[alias] = module_path
imports[original] = module_path
else:
imports[segment] = module_path
return imports
def _resolve_object_identifier(repo_root: Path, file_path: Path, source: str, identifier: str) -> dict[str, Any] | None:
var_pattern = VAR_OBJ_RE_TEMPLATE.format(name=re.escape(identifier))
block = _extract_block(source, var_pattern)
imports = _extract_imports(source)
def resolver(name: str) -> dict[str, Any] | None:
local_block = _extract_block(source, VAR_OBJ_RE_TEMPLATE.format(name=re.escape(name)))
if local_block:
return _parse_object_literal(local_block, resolver)
import_path = imports.get(name)
if not import_path:
return None
resolved_file = _resolve_import_path(repo_root, file_path, import_path)
if not resolved_file:
return None
imported_source = resolved_file.read_text(encoding="utf-8")
return _resolve_object_identifier(repo_root, resolved_file, imported_source, name)
if block:
return _parse_object_literal(block, resolver)
import_path = imports.get(identifier)
if not import_path:
return None
resolved_file = _resolve_import_path(repo_root, file_path, import_path)
if not resolved_file:
return None
imported_source = resolved_file.read_text(encoding="utf-8")
return _resolve_object_identifier(repo_root, resolved_file, imported_source, identifier)
def _infer_py_type(value: Any) -> str:
if isinstance(value, bool):
return "bool"
if isinstance(value, int):
return "int"
if isinstance(value, float):
return "float"
if isinstance(value, str):
return "str"
if isinstance(value, list):
return "list[Any]"
if isinstance(value, dict):
return "dict[str, Any]"
return "Any"
def _spec_is_none(spec: dict[str, Any]) -> bool:
return spec.get("kind") == "null"
def _py_type_from_spec(spec: dict[str, Any]) -> str:
kind = spec.get("kind")
if kind == "string":
return "str"
if kind == "number":
return "float"
if kind == "boolean":
return "bool"
if kind == "date":
return "str"
if kind == "enum":
values = spec.get("values")
if isinstance(values, list) and values:
literal_values = ", ".join(_py_repr(v) for v in values)
return f"Literal[{literal_values}]"
if kind == "ref":
ref_name = spec.get("name")
if isinstance(ref_name, str) and ref_name.endswith("Parameters"):
return f"{ref_name[:-10]}Params"
if kind == "array":
element = spec.get("element")
inner = _py_type_from_spec(element) if isinstance(element, dict) else "Any"
return f"list[{inner}]"
if kind == "object":
dict_value = spec.get("dictValue")
if isinstance(dict_value, dict):
inner = _py_type_from_spec(dict_value)
return f"dict[str, {inner}]"
properties = spec.get("properties")
if isinstance(properties, dict) and properties:
property_types = {_py_type_from_spec(p) for p in properties.values() if isinstance(p, dict)}
if len(property_types) == 1:
inner = next(iter(property_types))
return f"dict[str, {inner}]"
return "dict[str, Any]"
if kind in {"null"}:
return "Any"
return "Any"
def _to_class_name(tool_id: str) -> str:
cleaned = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", tool_id)
cleaned = re.sub(r"[^A-Za-z0-9]+", " ", cleaned)
parts = [part.capitalize() for part in cleaned.split() if part]
return "".join(parts) + "Params"
def _to_snake_case(name: str) -> str:
snake = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
snake = re.sub(r"[^A-Za-z0-9]+", "_", snake).strip("_").lower()
if not snake:
snake = "param"
if snake[0].isdigit():
snake = f"param_{snake}"
if keyword.iskeyword(snake):
snake = f"{snake}_"
return snake
def _build_field_name_map(params: dict[str, Any]) -> dict[str, str]:
field_map: dict[str, str] = {}
used: set[str] = set()
for original_key in sorted(params):
base_name = _to_snake_case(original_key)
candidate = base_name
suffix = 2
while candidate in used:
candidate = f"{base_name}_{suffix}"
suffix += 1
used.add(candidate)
field_map[original_key] = candidate
return field_map
def _to_enum_member_name(tool_id: str) -> str:
return _to_snake_case(tool_id).upper()
def _build_enum_member_map(specs: list[ToolModelSpec]) -> dict[str, str]:
member_map: dict[str, str] = {}
used: set[str] = set()
for spec in specs:
base_name = _to_enum_member_name(spec.tool_id)
candidate = base_name
suffix = 2
while candidate in used:
candidate = f"{base_name}_{suffix}"
suffix += 1
used.add(candidate)
member_map[spec.tool_id] = candidate
return member_map
def _py_repr(value: Any) -> str:
return (
json.dumps(value, ensure_ascii=True).replace("true", "True").replace("false", "False").replace("null", "None")
# Namespaces exposed to the LLM as callable tools. Largely matches ``InternalApiClient.java``.
# Note: ``/api/v1/filter/`` is intentionally excluded because those APIs are for pipeline processing,
# not tool execution.
ALLOWED_PATH_PREFIXES = (
"/api/v1/general/",
"/api/v1/misc/",
"/api/v1/security/",
"/api/v1/convert/",
)
def __init__(self, spec: dict[str, Any]):
resource = Resource.from_contents(spec, default_specification=DRAFT202012)
self.resolver = Registry().with_resource("", resource).resolver()
self.spec = spec
def discover_tool_specs(repo_root: Path) -> list[ToolModelSpec]:
frontend_dir = repo_root / "frontend"
extractor = frontend_dir / "scripts/export-tool-specs.ts"
command = ["node", "--import", "tsx", str(extractor)]
result = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
cwd=str(frontend_dir),
def discover(self) -> DiscoveryResult:
tools: list[ToolSpec] = []
defs: dict[str, Any] = {}
used_enum: set[str] = set()
used_class: set[str] = set()
for path, path_item in sorted(self.spec.get("paths", {}).items()):
if "{" in path or not any(path.startswith(p) for p in self.ALLOWED_PATH_PREFIXES):
continue
properties = self._get_request_properties(path_item)
if not properties:
continue
clean_props = self._filter_properties(properties)
if not clean_props:
continue
enum_name = _deduplicate(_path_to_enum_name(path), used_enum)
class_name = _deduplicate(_path_to_class_name(path), used_class)
defs[class_name] = {"type": "object", "properties": clean_props}
tools.append(ToolSpec(path, enum_name, class_name))
combined_schema: dict[str, Any] = {
"$defs": defs,
"anyOf": [{"$ref": f"#/$defs/{t.class_name}"} for t in tools],
}
return DiscoveryResult(tools=tools, combined_schema=combined_schema)
def _resolve_ref(self, schema: dict[str, Any]) -> dict[str, Any]:
if "$ref" in schema:
return self.resolver.lookup(schema["$ref"]).contents
return schema
def _get_request_properties(self, path_item: dict[str, Any]) -> dict[str, Any] | None:
post = path_item.get("post")
if not post:
return None
content = post.get("requestBody", {}).get("content", {})
for media_type in ("multipart/form-data", "application/json"):
if media_type in content:
schema = content[media_type].get("schema")
if schema:
return self._resolve_ref(schema).get("properties")
return None
def _filter_properties(self, properties: dict[str, Any]) -> dict[str, Any]:
"""Remove base-class fields and binary upload fields, resolving any $refs."""
clean: dict[str, Any] = {}
for name, prop in properties.items():
if name in BASE_CLASS_FIELDS:
continue
prop = self._resolve_ref(prop)
if prop.get("type") == "string" and prop.get("format") == "binary":
continue
clean[name] = prop
return clean
def _tool_name_segments(path: str) -> str:
"""Extract a descriptive name from the endpoint path.
Converters use two segments (e.g. /api/v1/convert/cbr/pdf cbr-to-pdf).
Other tools use the last segment (e.g. /api/v1/misc/compress-pdf compress-pdf).
"""
parts = path.rstrip("/").split("/")
if "/api/v1/convert/" in path and len(parts) >= 6:
return f"{parts[-2]}-to-{parts[-1]}"
return parts[-1]
def _path_to_enum_name(path: str) -> str:
return _tool_name_segments(path).replace("-", "_").upper()
def _path_to_class_name(path: str) -> str:
return "".join(p.capitalize() for p in _tool_name_segments(path).split("-")) + "Params"
def _deduplicate(name: str, used: set[str]) -> str:
"""Return name, appending 2, 3, ... if already in used. Adds result to used."""
candidate = name
n = 2
while candidate in used:
candidate = f"{name}{n}"
n += 1
used.add(candidate)
return candidate
def generate_models_code(combined_schema: dict[str, Any]) -> str:
"""Run datamodel-code-generator once on the combined schema."""
code = generate(
input_=json.dumps(combined_schema, sort_keys=True),
input_file_type=InputFileType.JsonSchema,
output_model_type=DataModelType.PydanticV2BaseModel,
target_python_version=PythonVersion.PY_313,
snake_case_field=True,
base_class="stirling.models.base.ApiModel",
field_constraints=True,
no_alias=True,
set_default_enum_member=True,
additional_imports=["enum.StrEnum"],
enable_version_header=False,
custom_file_header=_FILE_HEADER,
formatters=[Formatter.RUFF_FORMAT, Formatter.RUFF_CHECK],
settings_path=_ENGINE_ROOT / "pyproject.toml",
)
raw = json.loads(result.stdout)
specs: list[ToolModelSpec] = []
for item in raw:
tool_id = item.get("tool_id")
if not isinstance(tool_id, str) or not tool_id:
continue
params = item.get("params")
param_types = item.get("param_types")
specs.append(
ToolModelSpec(
tool_id=tool_id,
params=params if isinstance(params, dict) else {},
param_types=param_types if isinstance(param_types, dict) else {},
)
)
return sorted(specs, key=lambda spec: spec.tool_id)
return str(code or "")
def write_models_module(out_path: Path, specs: list[ToolModelSpec]) -> None:
lines: list[str] = [
TOOL_MODELS_HEADER,
"from __future__ import annotations\n\n",
"from enum import StrEnum\n",
"from typing import Any, Literal\n\n",
"from stirling.models.base import ApiModel\n",
def write_output(out_path: Path, tools: list[ToolSpec], models_code: str) -> None:
union_lines = ["type ParamToolModel = ("]
for i, tool in enumerate(tools):
prefix = " | " if i > 0 else " "
union_lines.append(f"{prefix}{tool.class_name}")
union_lines.append(")")
union_lines.append("type ParamToolModelType = type[ParamToolModel]")
enum_lines = [
"class ToolEndpoint(StrEnum):",
*(f' {t.enum_name} = "{t.path}"' for t in tools),
]
class_names: dict[str, str] = {spec.tool_id: _to_class_name(spec.tool_id) for spec in specs}
class_name_to_tool_id = {name: tool_id for tool_id, name in class_names.items()}
ops_lines = [
"OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {",
*(f" ToolEndpoint.{t.enum_name}: {t.class_name}," for t in tools),
"}",
]
def extract_class_dependencies(spec: ToolModelSpec) -> set[str]:
deps: set[str] = set()
if not isinstance(spec.param_types, dict):
return deps
for entry in spec.param_types.values():
if not isinstance(entry, dict):
continue
type_spec = entry
if "type" in entry and isinstance(entry.get("type"), dict):
type_spec = entry["type"]
if not isinstance(type_spec, dict):
continue
if type_spec.get("kind") != "ref":
continue
ref_name = type_spec.get("name")
if isinstance(ref_name, str) and ref_name.endswith("Parameters"):
ref_class = f"{ref_name[:-10]}Params"
if ref_class in class_name_to_tool_id:
deps.add(ref_class)
return deps
dependencies_by_class: dict[str, set[str]] = {}
for spec in specs:
class_name = class_names[spec.tool_id]
dependencies_by_class[class_name] = extract_class_dependencies(spec)
remaining = set(class_names.values())
ordered_class_names: list[str] = []
while remaining:
progress = False
for class_name in sorted(remaining):
deps = dependencies_by_class.get(class_name, set())
if deps.issubset(set(ordered_class_names)):
ordered_class_names.append(class_name)
remaining.remove(class_name)
progress = True
break
if not progress:
ordered_class_names.extend(sorted(remaining))
break
ordered_specs = [next(spec for spec in specs if class_names[spec.tool_id] == name) for name in ordered_class_names]
for spec in ordered_specs:
class_name = class_names[spec.tool_id]
lines.append(f"class {class_name}(ApiModel):\n")
all_param_keys = set(spec.params)
if isinstance(spec.param_types, dict):
all_param_keys.update(spec.param_types.keys())
if not all_param_keys:
lines.append(" pass\n\n\n")
continue
field_name_map = _build_field_name_map({key: True for key in all_param_keys})
for key in sorted(all_param_keys):
field_name = field_name_map[key]
value = spec.params.get(key)
type_spec = spec.param_types.get(key) if isinstance(spec.param_types, dict) else None
if isinstance(type_spec, dict):
py_type = _py_type_from_spec(type_spec)
else:
py_type = _infer_py_type(value)
if value is None and (isinstance(type_spec, dict) and _spec_is_none(type_spec)):
if py_type != "Any" and "| None" not in py_type:
py_type = f"{py_type} | None"
lines.append(f" {field_name}: {py_type} = None\n")
elif value is None:
lines.append(f" {field_name}: {py_type} | None = None\n")
else:
if isinstance(type_spec, dict) and type_spec.get("kind") == "ref" and isinstance(value, dict):
lines.append(f" {field_name}: {py_type} = {py_type}.model_validate({_py_repr(value)})\n")
continue
lines.append(f" {field_name}: {py_type} = {_py_repr(value)}\n")
lines.append("\n\n")
if class_names:
union_members = " | ".join(class_names[tool_id] for tool_id in sorted(class_names))
lines.append(f"type ParamToolModel = {union_members}\n")
lines.append("type ParamToolModelType = type[ParamToolModel]\n\n")
else:
lines.append("type ParamToolModel = ApiModel\n")
lines.append("type ParamToolModelType = type[ParamToolModel]\n\n")
enum_member_map = _build_enum_member_map(specs)
lines.append("class OperationId(StrEnum):\n")
for spec in specs:
lines.append(f" {enum_member_map[spec.tool_id]} = {spec.tool_id!r}\n")
lines.extend(
[
"\n\n",
"OPERATIONS: dict[OperationId, ParamToolModelType] = {\n",
]
)
for spec in specs:
model_name = _to_class_name(spec.tool_id)
lines.append(f" OperationId.{enum_member_map[spec.tool_id]}: {model_name},\n")
lines.append("}\n")
out_path.write_text("".join(lines), encoding="utf-8")
parts = [models_code, "\n", *union_lines, "\n", *enum_lines, "\n", *ops_lines, ""]
out_path.write_text("\n".join(parts), encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="Generate tool models from frontend TypeScript tool definitions")
parser.add_argument("--spec", help="Deprecated (ignored)", default="")
parser.add_argument("--output", default="", help="Path to tool_models.py")
parser.add_argument("--ai-output", default="", help="Deprecated (ignored)")
parser = argparse.ArgumentParser(description="Generate Python tool models from Java OpenAPI spec")
parser.add_argument("--spec", required=True, help="Path to SwaggerDoc.json")
parser.add_argument("--output", required=True, help="Path to output tool_models.py")
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[3]
specs = discover_tool_specs(repo_root)
spec_path = Path(args.spec)
if not spec_path.exists():
raise SystemExit(f"OpenAPI spec not found at {spec_path}\nRun 'task engine:tool-models' to generate it.")
output_path = Path(args.output)
output_path = Path(args.output) if args.output else (repo_root / "src/stirling/models/tool_models.py")
with open(spec_path) as f:
spec = json.load(f)
write_models_module(output_path, specs)
print(f"Wrote {len(specs)} tool model specs")
result = ToolDiscovery(spec).discover()
models_code = generate_models_code(result.combined_schema)
write_output(output_path, result.tools, models_code)
print(f"Generated {len(result.tools)} tool models from {spec_path.name}")
for tool in result.tools:
print(f" {tool.enum_name}: {tool.path}{tool.class_name}")
if __name__ == "__main__":
+4 -2
View File
@@ -74,7 +74,7 @@ class OrchestratorAgent:
system_prompt=(
"You are the top-level orchestrator. "
"Choose exactly one output function that best handles the request. "
"Use delegate_pdf_edit for requested PDF modifications. "
"Use delegate_pdf_edit for requested modifications of single or multiple PDFs. "
"Use delegate_pdf_question for questions about PDF contents. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use math_auditor_agent for requests to check arithmetic, validate "
@@ -116,7 +116,9 @@ class OrchestratorAgent:
return await self._run_pdf_edit(ctx.deps.request)
async def _run_pdf_edit(self, request: OrchestratorRequest) -> PdfEditResponse:
return await PdfEditAgent(self.runtime).handle(PdfEditRequest(user_message=request.user_message))
return await PdfEditAgent(self.runtime).handle(
PdfEditRequest(user_message=request.user_message, file_names=request.file_names)
)
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionResponse:
return await self._run_pdf_question(ctx.deps.request)
+8 -8
View File
@@ -14,13 +14,13 @@ from stirling.contracts import (
PdfEditResponse,
ToolOperationStep,
)
from stirling.models import OPERATIONS, ApiModel, OperationId, ParamToolModel
from stirling.models import OPERATIONS, ApiModel, ParamToolModel, ToolEndpoint
from stirling.services import AppRuntime
class PdfEditPlanSelection(ApiModel):
outcome: Literal["plan"] = "plan"
operations: list[OperationId] = Field(min_length=1)
operations: list[ToolEndpoint] = Field(min_length=1)
summary: str
rationale: str | None = None
@@ -41,7 +41,7 @@ class PdfEditParameterSelector:
async def select(
self,
request: PdfEditRequest,
operation_plan: list[OperationId],
operation_plan: list[ToolEndpoint],
operation_index: int,
generated_steps: list[ToolOperationStep],
) -> ParamToolModel:
@@ -51,7 +51,7 @@ class PdfEditParameterSelector:
self._build_parameter_prompt(request, operation_plan, operation_index, generated_steps),
output_type=NativeOutput(parameter_model),
instructions=(
f"Generate only the parameters for the PDF operation `{operation_id.value}`. "
f"Generate only the parameters for the PDF operation `{operation_id.name}`. "
"Do not include fields from any other operation."
),
)
@@ -60,12 +60,12 @@ class PdfEditParameterSelector:
def _build_parameter_prompt(
self,
request: PdfEditRequest,
operation_plan: list[OperationId],
operation_plan: list[ToolEndpoint],
operation_index: int,
generated_steps: list[ToolOperationStep],
) -> str:
operation_id = operation_plan[operation_index]
operation_list = ", ".join(operation.value for operation in operation_plan)
operation_list = ", ".join(operation.name for operation in operation_plan)
file_names = ", ".join(request.file_names) if request.file_names else "No file names were provided."
generated_steps_text = (
"\n".join(
@@ -79,7 +79,7 @@ class PdfEditParameterSelector:
f"Files: {file_names}\n"
f"Operation plan: {operation_list}\n"
f"Selected operation index: {operation_index + 1} of {len(operation_plan)}\n"
f"Selected operation: {operation_id.value}\n"
f"Selected operation: {operation_id.name}\n"
f"Already generated steps:\n{generated_steps_text}\n"
"Return only the parameter object for the selected operation."
)
@@ -153,4 +153,4 @@ class PdfEditAgent:
)
def _supported_operations_prompt(self) -> str:
return ", ".join(operation_id.value for operation_id in self.supported_operations)
return ", ".join(f"{op.name} ({op.value})" for op in self.supported_operations)
+2 -2
View File
@@ -4,7 +4,7 @@ from typing import Annotated, Literal
from pydantic import Field
from stirling.models import ApiModel, OperationId
from stirling.models import ApiModel, ToolEndpoint
from .common import StepKind, ToolOperationStep
@@ -13,7 +13,7 @@ class AiToolAgentStep(ApiModel):
kind: Literal[StepKind.AI_TOOL] = StepKind.AI_TOOL
title: str
description: str
tool: OperationId
tool: ToolEndpoint
instruction: str
+2 -2
View File
@@ -5,7 +5,7 @@ from typing import Literal, assert_never
from pydantic import Field, model_validator
from stirling.models import OPERATIONS, ApiModel, OperationId
from stirling.models import OPERATIONS, ApiModel, ToolEndpoint
from stirling.models.agent_tool_models import AGENT_OPERATIONS, AgentToolId, AnyParamModel, AnyToolId
@@ -110,7 +110,7 @@ class ToolOperationStep(ApiModel):
def validate_tool_parameter_pairing(self) -> ToolOperationStep:
if isinstance(self.tool, AgentToolId):
expected_type = AGENT_OPERATIONS[self.tool]
elif isinstance(self.tool, OperationId):
elif isinstance(self.tool, ToolEndpoint):
expected_type = OPERATIONS[self.tool]
else:
assert_never(self.tool)
+3 -3
View File
@@ -4,7 +4,7 @@ from typing import Annotated, Any, Literal
from pydantic import Field
from stirling.models import ApiModel, OperationId, ParamToolModel
from stirling.models import ApiModel, ParamToolModel, ToolEndpoint
from .agent_specs import AgentSpec
from .common import WorkflowOutcome
@@ -12,7 +12,7 @@ from .common import WorkflowOutcome
class ExecutionStepResult(ApiModel):
step_index: int
tool: OperationId | None = None
tool: ToolEndpoint | None = None
success: bool
output_summary: str | None = None
output_data: dict[str, Any] = Field(default_factory=dict)
@@ -33,7 +33,7 @@ class AgentExecutionRequest(ApiModel):
class ToolCallExecutionAction(ApiModel):
outcome: Literal[WorkflowOutcome.TOOL_CALL] = WorkflowOutcome.TOOL_CALL
tool: OperationId
tool: ToolEndpoint
parameters: ParamToolModel
rationale: str | None = None
+2 -2
View File
@@ -1,11 +1,11 @@
from . import tool_models
from .base import ApiModel
from .tool_models import OPERATIONS, OperationId, ParamToolModel
from .tool_models import OPERATIONS, ParamToolModel, ToolEndpoint
__all__ = [
"ApiModel",
"OPERATIONS",
"OperationId",
"ParamToolModel",
"ToolEndpoint",
"tool_models",
]
@@ -1,6 +1,6 @@
"""Agent tool IDs, parameter models, and registry.
tool_models.py is auto-generated from the frontend. This file is its
tool_models.py is auto-generated from the Java OpenAPI spec. This file is its
manually-maintained counterpart for tools backed by AI agent pipelines.
"""
@@ -9,7 +9,7 @@ from __future__ import annotations
from enum import StrEnum
from stirling.models.base import ApiModel
from stirling.models.tool_models import OperationId, ParamToolModel
from stirling.models.tool_models import ParamToolModel, ToolEndpoint
class AgentToolId(StrEnum):
@@ -22,7 +22,7 @@ class MathAuditorAgentParams(ApiModel):
type AgentParamModel = MathAuditorAgentParams
type AnyToolId = OperationId | AgentToolId
type AnyToolId = ToolEndpoint | AgentToolId
type AnyParamModel = ParamToolModel | AgentParamModel
AGENT_OPERATIONS: dict[AgentToolId, type[AgentParamModel]] = {
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -12,14 +12,14 @@ from stirling.contracts import (
PdfEditRequest,
ToolOperationStep,
)
from stirling.models.tool_models import CompressParams, OperationId, RotateParams
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[OperationId]
operation_plan: list[ToolEndpoint]
operation_index: int
generated_steps: list[ToolOperationStep]
@@ -31,10 +31,10 @@ class RecordingParameterSelector:
async def select(
self,
request: PdfEditRequest,
operation_plan: list[OperationId],
operation_plan: list[ToolEndpoint],
operation_index: int,
generated_steps: list[ToolOperationStep],
) -> RotateParams | CompressParams:
) -> RotatePdfParams | FlattenParams:
self.calls.append(
ParameterSelectorCall(
request=request,
@@ -44,8 +44,8 @@ class RecordingParameterSelector:
)
)
if operation_index == 0:
return RotateParams(angle=90)
return CompressParams(compression_level=5)
return RotatePdfParams(angle=Angle(90))
return FlattenParams(flatten_only_forms=False, render_dpi=None)
class StubPdfEditAgent(PdfEditAgent):
@@ -73,7 +73,7 @@ async def test_pdf_edit_agent_builds_multi_step_plan(runtime: AppRuntime) -> Non
agent = StubPdfEditAgent(
runtime,
PdfEditPlanSelection(
operations=[OperationId.ROTATE, OperationId.COMPRESS],
operations=[ToolEndpoint.ROTATE_PDF, ToolEndpoint.FLATTEN],
summary="Rotate the PDF, then compress it.",
rationale="The pages need reorientation before reducing file size.",
),
@@ -90,9 +90,9 @@ async def test_pdf_edit_agent_builds_multi_step_plan(runtime: AppRuntime) -> Non
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] == [OperationId.ROTATE, OperationId.COMPRESS]
assert isinstance(response.steps[0].parameters, RotateParams)
assert isinstance(response.steps[1].parameters, CompressParams)
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
@@ -101,7 +101,7 @@ async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector(runtim
agent = StubPdfEditAgent(
runtime,
PdfEditPlanSelection(
operations=[OperationId.ROTATE, OperationId.COMPRESS],
operations=[ToolEndpoint.ROTATE_PDF, ToolEndpoint.FLATTEN],
summary="Rotate the PDF, then compress it.",
),
parameter_selector=parameter_selector,
@@ -120,8 +120,8 @@ async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector(runtim
assert parameter_selector.calls[1].operation_index == 1
assert parameter_selector.calls[1].generated_steps == [
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
]
+5 -5
View File
@@ -25,7 +25,7 @@ from stirling.contracts import (
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
)
from stirling.models.tool_models import RotateParams
from stirling.models.tool_models import Angle, RotatePdfParams
class StubOrchestratorAgent:
@@ -127,8 +127,8 @@ def test_agent_revise_route() -> None:
"steps": [
{
"kind": "tool",
"tool": "rotate",
"parameters": RotateParams(angle=90).model_dump(by_alias=True),
"tool": "/api/v1/general/rotate-pdf",
"parameters": RotatePdfParams(angle=Angle(90)).model_dump(by_alias=True),
}
],
},
@@ -150,8 +150,8 @@ def test_next_action_route() -> None:
"steps": [
{
"kind": "tool",
"tool": "rotate",
"parameters": RotateParams(angle=90).model_dump(by_alias=True),
"tool": "/api/v1/general/rotate-pdf",
"parameters": RotatePdfParams(angle=Angle(90)).model_dump(by_alias=True),
}
],
},
+5 -5
View File
@@ -12,7 +12,7 @@ from stirling.contracts import (
PdfTextSelection,
ToolOperationStep,
)
from stirling.models.tool_models import OperationId, RotateParams
from stirling.models.tool_models import Angle, RotatePdfParams, ToolEndpoint
def test_orchestrator_request_accepts_user_message() -> None:
@@ -38,8 +38,8 @@ def test_orchestrator_request_accepts_user_message() -> None:
def test_agent_execution_request_uses_typed_agent_spec() -> None:
steps: list[AgentSpecStep] = [
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
]
request = AgentExecutionRequest(
@@ -57,13 +57,13 @@ def test_agent_execution_request_uses_typed_agent_spec() -> None:
def test_edit_plan_response_has_typed_steps() -> None:
steps = [ToolOperationStep(tool=OperationId.ROTATE, parameters=RotateParams(angle=90))]
steps = [ToolOperationStep(tool=ToolEndpoint.ROTATE_PDF, parameters=RotatePdfParams(angle=Angle(90)))]
response = EditPlanResponse(
summary="Rotate the input PDF by 90 degrees.",
steps=steps,
)
assert response.steps[0].tool == OperationId.ROTATE
assert response.steps[0].tool == ToolEndpoint.ROTATE_PDF
def test_pdf_question_answer_defaults_evidence_list() -> None:
+13 -13
View File
@@ -13,7 +13,7 @@ from stirling.contracts import (
EditPlanResponse,
ToolOperationStep,
)
from stirling.models.tool_models import CompressParams, OperationId, RotateParams
from stirling.models.tool_models import Angle, FlattenParams, RotatePdfParams, ToolEndpoint
from stirling.services.runtime import AppRuntime
@@ -26,8 +26,8 @@ class StubUserSpecAgent(UserSpecAgent):
summary="Rotate the document.",
steps=[
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
],
)
@@ -63,8 +63,8 @@ async def test_user_spec_agent_drafts_agent_spec(runtime: AppRuntime) -> None:
objective="Normalize invoices before accounting review.",
steps=[
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
],
),
@@ -98,8 +98,8 @@ async def test_user_spec_agent_revises_existing_draft(runtime: AppRuntime) -> No
objective="Normalize invoices before accounting review.",
steps=[
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
)
],
)
@@ -112,12 +112,12 @@ async def test_user_spec_agent_revises_existing_draft(runtime: AppRuntime) -> No
objective="Normalize invoices before accounting review.",
steps=[
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=RotateParams(angle=90),
tool=ToolEndpoint.ROTATE_PDF,
parameters=RotatePdfParams(angle=Angle(90)),
),
ToolOperationStep(
tool=OperationId.COMPRESS,
parameters=CompressParams(compression_level=5),
tool=ToolEndpoint.FLATTEN,
parameters=FlattenParams(flatten_only_forms=False, render_dpi=None),
),
],
),
@@ -138,8 +138,8 @@ async def test_user_spec_agent_revises_existing_draft(runtime: AppRuntime) -> No
def test_tool_operation_step_rejects_mismatched_parameters() -> None:
with pytest.raises(ValidationError):
ToolOperationStep(
tool=OperationId.ROTATE,
parameters=CompressParams(compression_level=5),
tool=ToolEndpoint.ROTATE_PDF,
parameters=FlattenParams(flatten_only_forms=False, render_dpi=None),
)
+208
View File
@@ -1,6 +1,10 @@
version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]]
name = "ag-ui-protocol"
@@ -212,6 +216,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
[[package]]
name = "black"
version = "26.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" },
{ url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" },
{ url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" },
{ url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" },
{ url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" },
{ url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" },
{ url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" },
{ url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" },
{ url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" },
{ url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" },
]
[[package]]
name = "boto3"
version = "1.42.74"
@@ -485,6 +516,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/2261922126b2e50c601fe22d7ff5194e0a4d50e654836260c0665e24d862/cyclopts-4.10.1-py3-none-any.whl", hash = "sha256:35f37257139380a386d9fe4475e1e7c87ca7795765ef4f31abba579fcfcb6ecd", size = 204331, upload-time = "2026-03-23T14:43:02.625Z" },
]
[[package]]
name = "datamodel-code-generator"
version = "0.56.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argcomplete" },
{ name = "black" },
{ name = "genson" },
{ name = "inflect" },
{ name = "isort" },
{ name = "jinja2" },
{ name = "pydantic" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/7d/7fc2bb3d8946ca45851da3f23497a2c6e252e92558ccbd89d609cf1e13d4/datamodel_code_generator-0.56.0.tar.gz", hash = "sha256:e7c003fb5421b890aabe12f66ae65b57198b04cfe1da7c40810798020835b3a8", size = 837708, upload-time = "2026-04-04T09:46:19.636Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" },
]
[package.optional-dependencies]
ruff = [
{ name = "ruff" },
]
[[package]]
name = "distro"
version = "1.9.0"
@@ -551,8 +606,10 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "datamodel-code-generator", extra = ["ruff"] },
{ name = "pyright" },
{ name = "pytest" },
{ name = "referencing" },
{ name = "ruff" },
]
@@ -570,8 +627,10 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.26.0" },
{ name = "pyright", specifier = ">=1.1.408" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "referencing", specifier = ">=0.35.0" },
{ name = "ruff", specifier = ">=0.14.10" },
]
@@ -783,6 +842,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/f6/8ef7e4c286deb2709d11ca96a5237caae3ef4876ab3c48095856cfd2df30/genai_prices-0.0.56-py3-none-any.whl", hash = "sha256:dbe86be8f3f556bed1b72209ed36851fec8b01793b3b220f42921a4e7da945f6", size = 68966, upload-time = "2026-03-20T20:33:02.555Z" },
]
[[package]]
name = "genson"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/cf/2303c8ad276dcf5ee2ad6cf69c4338fd86ef0f471a5207b069adf7a393cf/genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37", size = 34919, upload-time = "2024-05-15T22:08:49.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" },
]
[[package]]
name = "google-auth"
version = "2.49.1"
@@ -1010,6 +1078,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]]
name = "inflect"
version = "7.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
{ name = "typeguard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -1019,6 +1100,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "8.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
@@ -1061,6 +1151,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jiter"
version = "0.13.0"
@@ -1241,6 +1343,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mcp"
version = "1.26.0"
@@ -1384,6 +1538,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "nexus-rpc"
version = "1.2.0"
@@ -1576,6 +1739,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.4"
@@ -2067,6 +2239,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },
{ url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },
{ url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },
{ url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
{ url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },
{ url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },
{ url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },
{ url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },
{ url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },
{ url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
{ url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
{ url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -2525,6 +2721,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "typeguard"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/e8/66e25efcc18542d58706ce4e50415710593721aae26e794ab1dec34fb66f/typeguard-4.5.1.tar.gz", hash = "sha256:f6f8ecbbc819c9bc749983cc67c02391e16a9b43b8b27f15dc70ed7c4a007274", size = 80121, upload-time = "2026-02-19T16:09:03.392Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/88/b55b3117287a8540b76dbdd87733808d4d01c8067a3b339408c250bb3600/typeguard-4.5.1-py3-none-any.whl", hash = "sha256:44d2bf329d49a244110a090b55f5f91aa82d9a9834ebfd30bcc73651e4a8cc40", size = 36745, upload-time = "2026-02-19T16:09:01.6Z" },
]
[[package]]
name = "typer"
version = "0.24.1"
@@ -2084,8 +2084,18 @@ thinking = "Thinking..."
analyzing = "Analysing your request..."
calling_engine = "AI is thinking..."
extracting_content = "Extracting content from your documents..."
executing_tool_single = "Running {{tool}}..."
executing_tool_step = "Running {{tool}} (step {{step}} of {{total}})..."
executing_tool_generic = "Running tool..."
executing_tool_generic_step = "Running tool (step {{step}} of {{total}})..."
processing = "Processing extracted content..."
[chat.toolsUsed]
summary_one = "Ran 1 tool"
summary_other = "Ran {{count}} tools"
summary = "Ran {{count}} tools"
unknownTool = "Unknown tool"
[certSign]
allSigned = "All participants have signed. Ready to finalize."
awaitingSignatures = "Awaiting signatures"
@@ -6,23 +6,49 @@ import {
useRef,
type ReactNode,
} from "react";
import { useAllFiles } from "@app/contexts/FileContext";
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
import apiClient from "@app/services/apiClient";
import { getAuthHeaders } from "@app/services/apiClientSetup";
import { createChildStub } from "@app/contexts/file/fileActions";
import {
createNewStirlingFileStub,
createStirlingFile,
type StirlingFileStub,
} from "@app/types/fileContext";
import type { ToolOperation } from "@app/types/file";
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: number;
/**
* Tool endpoint paths executed during this assistant turn (e.g.
* {@code /api/v1/general/rotate-pdf}). Populated for assistant messages when the workflow
* ran one or more tools, in execution order. Undefined for user messages and for assistant
* turns that answered without running any tool.
*/
toolsUsed?: string[];
}
export enum AiWorkflowPhase {
ANALYZING = "analyzing",
CALLING_ENGINE = "calling_engine",
EXTRACTING_CONTENT = "extracting_content",
EXECUTING_TOOL = "executing_tool",
PROCESSING = "processing",
}
export interface AiWorkflowProgress {
phase: AiWorkflowPhase;
/** Tool endpoint path currently executing, for EXECUTING_TOOL events. */
tool?: string;
/** 1-based step index, for EXECUTING_TOOL events. */
stepIndex?: number;
/** Total number of plan steps, for EXECUTING_TOOL events. */
stepCount?: number;
}
type AiWorkflowOutcome =
| "answer"
| "not_found"
@@ -35,6 +61,13 @@ type AiWorkflowOutcome =
| "unsupported_capability"
| "cannot_continue";
interface AiWorkflowResultFile {
/** Stirling file ID — download with /api/v1/general/files/{fileId}. */
fileId: string;
fileName: string;
contentType: string;
}
interface AiWorkflowResponse {
outcome: AiWorkflowOutcome;
answer?: string;
@@ -46,19 +79,25 @@ interface AiWorkflowResponse {
message?: string;
evidence?: Array<{ pageNumber: number; text: string }>;
steps?: Array<Record<string, unknown>>;
/** Every file produced by the workflow (empty if the outcome has no files). */
resultFiles?: AiWorkflowResultFile[];
// Legacy single-file fields — mirror the first entry of resultFiles.
fileId?: string;
fileName?: string;
contentType?: string;
}
interface ChatState {
messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean;
progressPhase: AiWorkflowPhase | null;
progress: AiWorkflowProgress | null;
}
type ChatAction =
| { type: "ADD_MESSAGE"; message: ChatMessage }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_PROGRESS"; phase: AiWorkflowPhase | null }
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
| { type: "TOGGLE_OPEN" }
| { type: "SET_OPEN"; open: boolean };
@@ -69,7 +108,7 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState {
case "SET_LOADING":
return { ...state, isLoading: action.loading };
case "SET_PROGRESS":
return { ...state, progressPhase: action.phase };
return { ...state, progress: action.progress };
case "TOGGLE_OPEN":
return { ...state, isOpen: !state.isOpen };
case "SET_OPEN":
@@ -114,10 +153,18 @@ function formatWorkflowResponse(data: AiWorkflowResponse): string {
/**
* Parses an SSE text stream and invokes callbacks for each named event.
*/
interface ProgressEvent {
phase: string;
timestamp: number;
tool?: string;
stepIndex?: number;
stepCount?: number;
}
async function consumeSSEStream(
response: Response,
handlers: {
onProgress: (data: { phase: string; timestamp: number }) => void;
onProgress: (data: ProgressEvent) => void;
onResult: (data: AiWorkflowResponse) => void;
onError: (data: { message: string }) => void;
},
@@ -171,7 +218,7 @@ interface ChatContextValue {
messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean;
progressPhase: AiWorkflowPhase | null;
progress: AiWorkflowProgress | null;
toggleOpen: () => void;
setOpen: (open: boolean) => void;
sendMessage: (content: string) => Promise<void>;
@@ -183,14 +230,86 @@ const initialState: ChatState = {
messages: [],
isOpen: false,
isLoading: false,
progressPhase: null,
progress: null,
};
export function ChatProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(chatReducer, initialState);
const { files: activeFiles } = useAllFiles();
const { files: activeFiles, fileStubs: activeFileStubs } = useAllFiles();
const { actions: fileActions } = useFileActions();
const abortRef = useRef<AbortController | null>(null);
// Download a File from the Stirling files endpoint.
const downloadFile = useCallback(
async (descriptor: AiWorkflowResultFile): Promise<File> => {
const response = await apiClient.get<Blob>(
`/api/v1/general/files/${descriptor.fileId}`,
{ responseType: "blob" },
);
return new File([response.data], descriptor.fileName, {
type: descriptor.contentType ?? response.data.type,
});
},
[],
);
// Import the files produced by an AI workflow result into FileContext.
//
// If the workflow produced the same number of outputs as inputs, map each output to its
// corresponding input as a new version in the same chain. Otherwise (merge, split, etc.)
// add the outputs as new root files.
const importResultFile = useCallback(
async (
result: AiWorkflowResponse,
sourceStubs: StirlingFileStub[],
): Promise<void> => {
const descriptors = result.resultFiles?.length
? result.resultFiles
: result.fileId && result.fileName && result.contentType
? [
{
fileId: result.fileId,
fileName: result.fileName,
contentType: result.contentType,
} satisfies AiWorkflowResultFile,
]
: [];
if (descriptors.length === 0) return;
const files = await Promise.all(descriptors.map(downloadFile));
const operation: ToolOperation = {
toolId: "ai-workflow",
timestamp: Date.now(),
};
const isVersionMapping =
sourceStubs.length > 0 && files.length === sourceStubs.length;
const stubs = files.map((file, i) =>
isVersionMapping
? createChildStub(sourceStubs[i], operation, file)
: createNewStirlingFileStub(file),
);
const stirlingFiles = files.map((file, i) =>
createStirlingFile(file, stubs[i].id),
);
if (sourceStubs.length > 0) {
// Always consume the inputs so merge/split inputs are removed from the workbench.
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
// merge/split they're fresh roots.
await fileActions.consumeFiles(
sourceStubs.map((s) => s.id),
stirlingFiles,
stubs,
);
} else {
// No inputs were provided (unlikely for completed workflows, but handle it safely).
await fileActions.addFiles(files, { selectFiles: true });
}
},
[fileActions, downloadFile],
);
const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []);
const setOpen = useCallback(
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
@@ -212,7 +331,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
};
dispatch({ type: "ADD_MESSAGE", message: userMessage });
dispatch({ type: "SET_LOADING", loading: true });
dispatch({ type: "SET_PROGRESS", phase: null });
dispatch({ type: "SET_PROGRESS", progress: null });
try {
const formData = new FormData();
@@ -234,17 +353,29 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}
let receivedResult = false;
const toolsUsed: string[] = [];
await consumeSSEStream(response, {
onProgress: (data) => {
if (
data.phase === AiWorkflowPhase.EXECUTING_TOOL &&
typeof data.tool === "string"
) {
toolsUsed.push(data.tool);
}
dispatch({
type: "SET_PROGRESS",
phase: data.phase as AiWorkflowPhase,
progress: {
phase: data.phase as AiWorkflowPhase,
tool: data.tool,
stepIndex: data.stepIndex,
stepCount: data.stepCount,
},
});
},
onResult: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", phase: null });
dispatch({ type: "SET_PROGRESS", progress: null });
const replyContent = formatWorkflowResponse(data);
dispatch({
type: "ADD_MESSAGE",
@@ -253,12 +384,28 @@ export function ChatProvider({ children }: { children: ReactNode }) {
role: "assistant",
content: replyContent,
timestamp: Date.now(),
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
},
});
if (data.fileId || data.resultFiles?.length) {
importResultFile(data, activeFileStubs).catch((err) => {
console.error("Failed to import AI result file", err);
dispatch({
type: "ADD_MESSAGE",
message: {
id: crypto.randomUUID(),
role: "assistant",
content:
"The file was processed but I couldn't download it.",
timestamp: Date.now(),
},
});
});
}
},
onError: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", phase: null });
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
@@ -276,7 +423,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}
} catch (e) {
if ((e as Error).name === "AbortError") return;
dispatch({ type: "SET_PROGRESS", phase: null });
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
@@ -294,7 +441,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}
}
},
[activeFiles],
[activeFiles, activeFileStubs, importResultFile],
);
return (
@@ -303,7 +450,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
messages: state.messages,
isOpen: state.isOpen,
isLoading: state.isLoading,
progressPhase: state.progressPhase,
progress: state.progress,
toggleOpen,
setOpen,
sendMessage,
@@ -1,4 +1,10 @@
import { useRef, useEffect, useState, type KeyboardEvent } from "react";
import {
useMemo,
useRef,
useEffect,
useState,
type KeyboardEvent,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
@@ -11,19 +17,142 @@ import {
Transition,
Loader,
Group,
Collapse,
UnstyledButton,
List,
} from "@mantine/core";
import SendIcon from "@mui/icons-material/Send";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
import CloseIcon from "@mui/icons-material/Close";
import { useChat } from "@app/components/chat/ChatContext";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import {
useChat,
AiWorkflowPhase,
type AiWorkflowProgress,
} from "@app/components/chat/ChatContext";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import "@app/components/chat/ChatPanel.css";
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
/** Resolver mapping a tool endpoint path to its translated display name. */
type ToolNameResolver = (endpoint: string) => string | null;
/**
* Look up a tool's translated name from the tool catalog. The catalog's {@code operationConfig}
* exposes the full API endpoint path for each tool, so we key the lookup on the exact path that
* arrives in SSE progress events no string parsing.
*/
function useToolNameResolver(): ToolNameResolver {
const { allTools } = useTranslatedToolCatalog();
return useMemo(() => {
const nameByEndpoint = new Map<string, string>();
Object.values(allTools).forEach((tool) => {
const endpoint = tool.operationConfig?.endpoint;
// Only register tools with a static endpoint. Tools whose endpoint is a function
// (dynamic routing, e.g. Convert / Split) need runtime params to resolve, so they fall
// through to the generic progress message rather than mis-matching.
if (typeof endpoint === "string") {
nameByEndpoint.set(endpoint, tool.name);
}
});
return (endpoint: string) => nameByEndpoint.get(endpoint) ?? null;
}, [allTools]);
}
function formatProgress(
progress: AiWorkflowProgress,
t: TranslateFn,
resolveToolName: ToolNameResolver,
): string {
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL && progress.tool) {
const tool = resolveToolName(progress.tool);
const hasSteps =
progress.stepIndex != null &&
progress.stepCount != null &&
progress.stepCount > 1;
if (tool) {
return hasSteps
? t("chat.progress.executing_tool_step", {
tool,
step: progress.stepIndex,
total: progress.stepCount,
})
: t("chat.progress.executing_tool_single", { tool });
}
// Unknown tool — fall back to a generic translated message rather than
// prettifying the endpoint path by hand.
return hasSteps
? t("chat.progress.executing_tool_generic_step", {
step: progress.stepIndex,
total: progress.stepCount,
})
: t("chat.progress.executing_tool_generic");
}
return t(`chat.progress.${progress.phase}`);
}
function ToolsUsedBlock({
tools,
resolveToolName,
t,
}: {
tools: string[];
resolveToolName: ToolNameResolver;
t: TranslateFn;
}) {
const [expanded, setExpanded] = useState(false);
const names = tools.map(
(endpoint) => resolveToolName(endpoint) ?? t("chat.toolsUsed.unknownTool"),
);
const label = t("chat.toolsUsed.summary", { count: tools.length });
return (
<Box mt={6}>
<UnstyledButton
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<Group gap={4} wrap="nowrap">
{expanded ? (
<ExpandLessIcon sx={{ fontSize: 14 }} />
) : (
<ExpandMoreIcon sx={{ fontSize: 14 }} />
)}
<Text size="xs" c="dimmed">
{label}
</Text>
</Group>
</UnstyledButton>
<Collapse in={expanded}>
<List
type="ordered"
size="xs"
mt={4}
pl="lg"
styles={{ itemWrapper: { lineHeight: 1.4 } }}
>
{names.map((name, i) => (
<List.Item key={i}>{name}</List.Item>
))}
</List>
</Collapse>
</Box>
);
}
function ChatMessageBubble({
role,
content,
toolsUsed,
resolveToolName,
t,
}: {
role: "user" | "assistant";
content: string;
toolsUsed?: string[];
resolveToolName: ToolNameResolver;
t: TranslateFn;
}) {
return (
<div className={`chat-message chat-message-${role}`}>
@@ -31,6 +160,13 @@ function ChatMessageBubble({
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{content}
</Text>
{toolsUsed && toolsUsed.length > 0 && (
<ToolsUsedBlock
tools={toolsUsed}
resolveToolName={resolveToolName}
t={t}
/>
)}
</Paper>
</div>
);
@@ -38,14 +174,9 @@ function ChatMessageBubble({
export function ChatPanel() {
const { t } = useTranslation();
const {
messages,
isOpen,
isLoading,
progressPhase,
toggleOpen,
sendMessage,
} = useChat();
const { messages, isOpen, isLoading, progress, toggleOpen, sendMessage } =
useChat();
const resolveToolName = useToolNameResolver();
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -129,6 +260,9 @@ export function ChatPanel() {
key={msg.id}
role={msg.role}
content={msg.content}
toolsUsed={msg.toolsUsed}
resolveToolName={resolveToolName}
t={t}
/>
))}
{isLoading && (
@@ -141,8 +275,8 @@ export function ChatPanel() {
<Group gap="xs" wrap="nowrap">
<Loader size="xs" type="dots" />
<Text size="sm" c="dimmed">
{progressPhase
? t(`chat.progress.${progressPhase}`)
{progress
? formatProgress(progress, t, resolveToolName)
: t("chat.progress.thinking")}
</Text>
</Group>