create agent (#6520)

Added the create agent. Use [these
prompts](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/main/docgen/backend/default_templates/sample_prompts.md)
to test or try your own :)

Here’s the one I use

```
Hey, I need to generate an employee expense report for reimbursement.
Company: Summit Consulting Partners Company address: 88 Riverside Plaza, Suite 1400, New York, NY 10069 Accounting department email: [email protected]
Employee details:
* Employee Name: Michael Tran
* Employee ID: EMP-1047
* Department: Client Services
* Report Date: January 20th, 2026
* Reporting Period: January 5th, 2026 – January 16th, 2026
* Manager Approver: Laura Simmons
Trip purpose: Client onsite meetings with Atlantic Energy Solutions in Boston, MA.
Expense items:
* Flight (NYC to Boston roundtrip) — $325.40 — January 5th, 2026 — Airline ticket
* Hotel (3 nights at Harborview Hotel) — $822.75 — January 5th-8th, 2026
* Taxi from airport to hotel — $48.00 — January 5th, 2026
* Client dinner (3 attendees) — $186.20 — January 6th, 2026
* Parking at JFK Airport — $72.00 — January 5th-8th, 2026
* Breakfast (per diem not used) — $18.50 — January 7th, 2026
* Uber to client office — $22.10 — January 7th, 2026
* Printing + presentation materials — $46.90 — January 8th, 2026
* Lunch with client — $39.75 — January 8th, 2026
* Office supplies (notebooks, pens) — $27.60 — January 10th, 2026
* Mileage reimbursement (client visit in NJ, 42 miles @ $0.67/mile) — $28.14 — January 14th, 2026
* Team lunch meeting (internal) — $64.30 — January 15th, 2026
Reimbursement method should be direct deposit.
Add a notes section stating: "All receipts attached. Expenses are business-related and comply with company travel policy."
```

---------

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
EthanHealy01
2026-06-11 14:18:13 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent 9b877d4f8d
commit 88adb7adad
20 changed files with 1828 additions and 40 deletions
@@ -97,6 +97,17 @@ public class InternalApiClient {
headers.add("X-API-KEY", apiKey);
}
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
// without this RestTemplate would use urlencoded instead of the multipart the controller
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/");
boolean hasFilePart =
body.values().stream()
.flatMap(java.util.List::stream)
.anyMatch(v -> v instanceof Resource);
if (isAiTool && !hasFilePart) {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
}
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
@@ -0,0 +1,144 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
*
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
* template so sanitization is intentionally skipped.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/ai/tools")
@RequiredArgsConstructor
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class CreatePdfAgentController {
private final TempFileManager tempFileManager;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
/**
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
* be launched at all, or it launched but immediately failed to load a required system library.
* Other conversion failures (bad HTML, output errors, etc.) return false so they surface as
* real errors rather than a misleading "dependency missing" message.
*/
private static boolean isMissingDependencyError(IOException e) {
String msg = e.getMessage();
if (msg == null) return false;
// OS could not start the process — binary not on PATH or not at the configured path.
if (msg.contains("Cannot run program")) return true;
// Process started but crashed immediately loading a shared library.
// "cannot load library" — Python/cffi error (Linux and macOS via pip)
// "Library not loaded" / "image not found" — macOS dyld error (Homebrew installs)
String lower = msg.toLowerCase();
if (lower.contains("cannot load library")) return true;
if (lower.contains("library not loaded")) return true;
if (lower.contains("image not found")) return true;
return false;
}
@PostMapping(
value = "/create-pdf-from-html-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Convert AI-generated HTML to a PDF",
description =
"Accepts an HTML document as a plain-text parameter and returns a PDF."
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
+ " plan step; it is not intended for direct client use.")
public ResponseEntity<Resource> createPdfFromHtml(
@RequestParam("htmlContent") String htmlContent,
@RequestParam("filename") String filename)
throws Exception {
log.info(
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
htmlContent.length());
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getWeasyPrintPath());
command.add("-e");
command.add("utf-8");
command.add("-v");
// SSRF: the HTML is self-contained and the engine validates style colours, so no
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
command.add(htmlFile.getAbsolutePath());
command.add(pdfFile.getAbsolutePath());
try {
ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)
.runCommandWithOutputHandling(command);
} catch (IOException e) {
if (isMissingDependencyError(e)) {
throw new IOException(
"AI document creation is not available on this server because a required"
+ " system dependency is not installed. Please contact your"
+ " system administrator.");
}
throw e;
}
String safeFilename = Filenames.toSimpleFileName(filename);
if (safeFilename == null || safeFilename.isBlank() || !safeFilename.endsWith(".pdf")) {
safeFilename = "generated-document.pdf";
}
// Stamp the standard Stirling metadata onto the WeasyPrint output and write the result
// straight to the response temp file. Loading from the file and saving to the file
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
// which matters for large generated documents.
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
document.save(tempOut.getPath().toFile());
} catch (Exception e) {
tempOut.close();
throw e;
}
log.info(
"[create-pdf-agent] PDF ready — filename={} bytes={}",
safeFilename,
Files.size(tempOut.getPath()));
return WebResponseUtils.pdfFileToWebResponse(tempOut, safeFilename);
}
}
}
@@ -17,6 +17,7 @@ import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
@@ -175,7 +176,6 @@ public class AiWorkflowService {
initialRequest.setFiles(files);
initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
WorkflowState state = new WorkflowState.Pending(initialRequest);
@@ -580,6 +580,10 @@ public class AiWorkflowService {
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
return new WorkflowState.Terminal(
cannotContinue(toolTimeoutMessage(e.getEndpointPath(), e)));
} catch (HttpServerErrorException e) {
String reason = extractDetailFromHttpError(e);
log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason);
return new WorkflowState.Terminal(cannotContinue(reason));
} catch (Exception e) {
log.error("Failed to execute plan: {}", e.getMessage(), e);
return new WorkflowState.Terminal(
@@ -605,6 +609,27 @@ public class AiWorkflowService {
return String.format("The %s tool failed: %s", endpointPath, reason);
}
/**
* Extracts the {@code detail} field from an HTTP error response body if it is valid JSON,
* otherwise falls back to the exception message. This lets controller-level error messages
* (e.g. missing system dependency) surface cleanly in the chat response.
*/
private String extractDetailFromHttpError(HttpServerErrorException e) {
try {
String body = e.getResponseBodyAsString();
if (body != null && !body.isBlank()) {
JsonNode node = objectMapper.readTree(body);
JsonNode detail = node.get("detail");
if (detail != null && detail.isTextual() && !detail.asText().isBlank()) {
return detail.asText();
}
}
} catch (Exception ignored) {
// fall through to generic message
}
return "The request could not be completed. Please try again or contact your system administrator.";
}
/**
* Adapt the AI workflow's {@link ProgressListener} to the engine's {@link
* PolicyProgressListener}: each step start maps to an {@code EXECUTING_TOOL} progress event