Skip to content

Commit 20204f0

Browse files
authored
Improve consistency and reliability of tools in Stirling Engine (Stirling-Tools#6855)
# Description of Changes A few changes to improve things in the engine: - Changed the PDF to Markdown code to be a real tool in Java, to remove the need for the `pdf_ingest` code, which looked a bit like an agent but wasn't behaving as an agent. It's now just covered automatically by the edit agent. - Noticed that 0-parameter-endpoints were previously being ignored by the `tool_models` generator, so some tools which require no params were being mistakenly excluded. - Removed tools which currently never succeed like Add Stamp, Cert Sign, and Overlay, because they require the supporting files to be sent in a different location in the API call, which we don't currently do. Ideally, we'd add proper support for this, but we're better off now removing support for these tools rather than just have them crash. We can re-add these tools in a future PR properly.
1 parent 1df6a17 commit 20204f0

10 files changed

Lines changed: 157 additions & 318 deletions

File tree

app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ public enum AiWorkflowOutcome {
2121
COMPLETED("completed"),
2222
UNSUPPORTED_CAPABILITY("unsupported_capability"),
2323
CANNOT_CONTINUE("cannot_continue"),
24-
GENERATE_FILE("generate_file"),
25-
CONVERT_MARKDOWN("convert_markdown");
24+
GENERATE_FILE("generate_file");
2625

2726
private final String value;
2827

app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java

Lines changed: 0 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@
6868
public class AiWorkflowService {
6969

7070
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
71-
private static final String PDF_TO_MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
7271

7372
private final CustomPDFDocumentFactory pdfDocumentFactory;
7473
private final AiEngineClient aiEngineClient;
@@ -196,7 +195,6 @@ private WorkflowState advance(
196195
return switch (response.getOutcome()) {
197196
case NEED_CONTENT -> onNeedContent(response, filesById, request, listener);
198197
case NEED_INGEST -> onNeedIngest(response, filesById, request, listener);
199-
case CONVERT_MARKDOWN -> onConvertMarkdown(response, filesById, listener);
200198
case TOOL_CALL -> onToolCall(response, filesById, listener);
201199
case PLAN -> onPlan(response, filesById, request, listener);
202200
case ANSWER -> onAnswer(response, filesById, request, listener);
@@ -333,72 +331,6 @@ private WorkflowState onNeedIngest(
333331
return new WorkflowState.Pending(nextRequest);
334332
}
335333

336-
/**
337-
* Deterministically convert each requested PDF to Markdown via the {@code
338-
* /convert/pdf/markdown} endpoint (backed by {@code PdfMarkdownConverter}) and return the
339-
* {@code .md} file(s) as a completed result. No AI resume — the conversion output is the final
340-
* answer.
341-
*/
342-
private WorkflowState onConvertMarkdown(
343-
AiWorkflowResponse response,
344-
Map<String, MultipartFile> filesById,
345-
ProgressListener listener) {
346-
List<AiFile> filesToConvert = response.getFilesToIngest();
347-
if (filesToConvert == null || filesToConvert.isEmpty()) {
348-
return new WorkflowState.Terminal(
349-
cannotContinue(
350-
"AI engine requested markdown conversion without listing any files."));
351-
}
352-
353-
try {
354-
List<Resource> resultFiles = new ArrayList<>();
355-
List<String> inputNames = new ArrayList<>();
356-
for (int i = 0; i < filesToConvert.size(); i++) {
357-
AiFile file = filesToConvert.get(i);
358-
MultipartFile multipartFile = filesById.get(file.getId());
359-
if (multipartFile == null) {
360-
return new WorkflowState.Terminal(
361-
cannotContinue(
362-
"AI engine requested markdown conversion for unknown file: "
363-
+ file.getName()));
364-
}
365-
listener.onProgress(
366-
AiWorkflowProgressEvent.executingTool(
367-
PDF_TO_MARKDOWN_ENDPOINT, i + 1, filesToConvert.size()));
368-
Resource input = toResource(multipartFile);
369-
PipelineDefinition definition =
370-
new PipelineDefinition(
371-
"convert-markdown",
372-
List.of(new PipelineStep(PDF_TO_MARKDOWN_ENDPOINT, Map.of())),
373-
null);
374-
PolicyExecutionResult result =
375-
policyExecutor.execute(
376-
definition,
377-
PolicyInputs.of(List.of(input)),
378-
PolicyProgressListener.NOOP);
379-
resultFiles.addAll(result.files());
380-
inputNames.add(multipartFile.getOriginalFilename());
381-
}
382-
return new WorkflowState.Terminal(
383-
buildCompletedResponse(null, resultFiles, inputNames, null));
384-
} catch (InternalApiTimeoutException e) {
385-
log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
386-
return new WorkflowState.Terminal(
387-
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
388-
} catch (Exception e) {
389-
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
390-
if (limit != null) {
391-
log.info(
392-
"AI markdown conversion blocked by downstream entitlement gate ({})",
393-
limit.getErrorCode());
394-
return new WorkflowState.Terminal(limit);
395-
}
396-
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
397-
return new WorkflowState.Terminal(
398-
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
399-
}
400-
}
401-
402334
private Resource toResource(MultipartFile file) throws IOException {
403335
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
404336
file.transferTo(tempFile.getPath());

app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -221,34 +221,6 @@ void unknownIngestFile() throws IOException {
221221
}
222222
}
223223

224-
@Nested
225-
@DisplayName("convert_markdown guards")
226-
class ConvertMarkdownGuards {
227-
228-
@Test
229-
@DisplayName("no files listed yields CANNOT_CONTINUE")
230-
void noFiles() throws IOException {
231-
stubOrchestrator("{\"outcome\":\"convert_markdown\",\"filesToIngest\":[]}");
232-
AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "to md"));
233-
assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
234-
}
235-
236-
@Test
237-
@DisplayName("unknown file id yields CANNOT_CONTINUE")
238-
void unknownFile() throws IOException {
239-
when(fileIdStrategy.idFor(any())).thenReturn("real-id");
240-
stubOrchestrator(
241-
"""
242-
{"outcome":"convert_markdown",
243-
"filesToIngest":[{"id":"other-id","name":"other.pdf"}]}
244-
""");
245-
AiWorkflowResponse result =
246-
service.orchestrate(requestFor(pdf("real.pdf", "x"), "to md"));
247-
assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
248-
assertThat(result.getReason()).contains("other.pdf");
249-
}
250-
}
251-
252224
@Nested
253225
@DisplayName("plan guards and errors")
254226
class PlanGuardsAndErrors {

app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ class AiWorkflowServiceTest {
7878
private static final String SPLIT_ENDPOINT = "/api/v1/general/split-pages";
7979
private static final String MERGE_ENDPOINT = "/api/v1/general/merge-pdfs";
8080
private static final String COMPRESS_ENDPOINT = "/api/v1/misc/compress-pdf";
81+
private static final String MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
8182

8283
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
8384
@Mock private AiEngineClient aiEngineClient;
@@ -440,32 +441,31 @@ void generateFileStoresContentDirectlyWithoutToolCall() throws IOException {
440441
}
441442

442443
@Test
443-
void convertMarkdownRunsDeterministicConversionAndReturnsMdFile() throws IOException {
444+
void planWithMarkdownStepReturnsMdFile() throws IOException {
445+
// PDF→Markdown is a normal tool the edit agent emits as a plan step (no bespoke
446+
// outcome); the plan executor runs the converter and returns the .md file.
444447
MockMultipartFile input = pdf("multi-column-test_lorem.pdf", "pdf-bytes");
445-
when(fileIdStrategy.idFor(any())).thenReturn("doc-1");
446448
stubOrchestrator(
447449
"""
448450
{
449-
"outcome":"convert_markdown",
450-
"reason":"PDF to Markdown requested.",
451-
"filesToIngest":[{"id":"doc-1","name":"multi-column-test_lorem.pdf"}]
451+
"outcome":"plan",
452+
"summary":"Convert to Markdown",
453+
"steps":[{"tool":"%s","parameters":{}}]
452454
}
453-
""");
454-
when(toolMetadataService.shouldUnpackZipResponse("/api/v1/convert/pdf/markdown"))
455-
.thenReturn(false);
456-
stubEndpoint(
457-
"/api/v1/convert/pdf/markdown",
458-
pdfResource("# Title", "multi-column-test_lorem.md"));
459-
AtomicInteger ids = stubFileStorage();
455+
"""
456+
.formatted(MARKDOWN_ENDPOINT));
457+
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
458+
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
459+
stubEndpoint(MARKDOWN_ENDPOINT, pdfResource("# Title", "multi-column-test_lorem.md"));
460+
stubFileStorage();
460461

461462
AiWorkflowResponse result = service.orchestrate(requestFor(input, "convert to markdown"));
462463

463464
assertEquals(AiWorkflowOutcome.COMPLETED, result.getOutcome());
464465
assertEquals(1, result.getResultFiles().size());
465466
// Extension changes (pdf -> md), so the converter's response filename wins.
466467
assertEquals("multi-column-test_lorem.md", result.getResultFiles().get(0).getFileName());
467-
assertEquals(1, ids.get());
468-
verify(internalApiClient, times(1)).post(eq("/api/v1/convert/pdf/markdown"), any());
468+
verify(internalApiClient, times(1)).post(eq(MARKDOWN_ENDPOINT), any());
469469
}
470470

471471
@Test

engine/scripts/generate_tool_models.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,36 @@ class ToolDiscovery:
5959
"/api/v1/convert/",
6060
)
6161

62+
# Endpoints under the allowed prefixes that are NOT edit-agent operations. A listed
63+
# path and everything nested under it is dropped. Several kinds live here:
64+
EXCLUDED_PATHS = (
65+
# 1. Cert-signing family: needs certificate/key files the agent can't supply, plus
66+
# interactive session and hardware-token management. The whole subtree is dropped.
67+
"/api/v1/security/cert-sign",
68+
# 2. Interactive PDF text-editor endpoints, not one-shot operations.
69+
"/api/v1/convert/pdf/text-editor",
70+
"/api/v1/convert/text-editor/pdf",
71+
# 3. Introspection / query endpoints that return metadata, a listing, or a
72+
# verification verdict rather than a transformed document, so they belong to
73+
# the question path, not the edit agent. (decompress is a dev-only stream op.)
74+
"/api/v1/security/get-info-on-pdf",
75+
"/api/v1/security/verify-pdf",
76+
"/api/v1/security/validate-signature",
77+
"/api/v1/misc/list-attachments",
78+
"/api/v1/misc/show-javascript",
79+
"/api/v1/misc/decompress-pdf",
80+
"/api/v1/general/extract-bookmarks",
81+
# 4. Require a secondary file (image, overlay PDF, attachments) on top of the input
82+
# PDF. The agent only ever supplies the input PDF(s), so these can never run.
83+
# (add-stamp / add-watermark stay: their text mode needs no extra file.)
84+
"/api/v1/misc/add-image",
85+
"/api/v1/misc/add-attachments",
86+
"/api/v1/general/overlay-pdfs",
87+
)
88+
89+
def _is_excluded(self, path: str) -> bool:
90+
return any(path == p or path.startswith(p + "/") for p in self.EXCLUDED_PATHS)
91+
6292
def __init__(self, spec: dict[str, Any]):
6393
resource = Resource.from_contents(spec, default_specification=DRAFT202012)
6494
self.resolver = Registry().with_resource("", resource).resolver()
@@ -73,17 +103,15 @@ def discover(self) -> DiscoveryResult:
73103
for path, path_item in sorted(self.spec.get("paths", {}).items()):
74104
if "{" in path or not any(path.startswith(p) for p in self.ALLOWED_PATH_PREFIXES):
75105
continue
106+
if self._is_excluded(path):
107+
continue
76108
body_schema = self._get_request_body_schema(path_item) or {}
77109
query_props = self._get_query_parameters(path_item)
78110
body_props = body_schema.get("properties") or {}
79111
# Body properties win on name collision — body is the canonical param source
80112
# for the existing tools; query params are additive.
81113
properties = {**query_props, **body_props}
82-
if not properties:
83-
continue
84114
clean_props = self._filter_properties(properties)
85-
if not clean_props:
86-
continue
87115

88116
enum_name = _deduplicate(_path_to_enum_name(path), used_enum)
89117
class_name = _deduplicate(_path_to_class_name(path), used_class)

engine/src/stirling/agents/orchestrator.py

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from stirling.agents.user_spec import UserSpecAgent
1616
from stirling.contracts import (
1717
AgentDraftWorkflowResponse,
18-
ConvertMarkdownResponse,
1918
ExtractedTextArtifact,
2019
OrchestratorRequest,
2120
OrchestratorResponse,
@@ -48,7 +47,7 @@ def __init__(self, runtime: AppRuntime) -> None:
4847
ToolOutput(
4948
self.delegate_pdf_edit,
5049
name="delegate_pdf_edit",
51-
description="Delegate requests for PDF modifications and return the PDF edit result.",
50+
description="Delegate requests to modify or convert PDFs and return the PDF edit result.",
5251
),
5352
ToolOutput(
5453
self.delegate_pdf_question,
@@ -71,13 +70,6 @@ def __init__(self, runtime: AppRuntime) -> None:
7170
" feedback')."
7271
),
7372
),
74-
ToolOutput(
75-
self.delegate_pdf_ingest,
76-
name="delegate_pdf_ingest",
77-
description=(
78-
"Delegate requests to convert a PDF to Markdown or extract its content as readable text."
79-
),
80-
),
8173
ToolOutput(
8274
self.delegate_pdf_create,
8375
name="delegate_pdf_create",
@@ -98,16 +90,14 @@ def __init__(self, runtime: AppRuntime) -> None:
9890
system_prompt=(
9991
"You are the top-level orchestrator. "
10092
"Choose exactly one output function that best handles the request. "
101-
"Use delegate_pdf_edit for any requested modification of one or more PDFs. "
93+
"Use delegate_pdf_edit for any request to modify or convert one or more PDFs. "
10294
"Use delegate_pdf_question for questions about the contents of the attached PDFs. "
10395
"Use delegate_user_spec for requests to create or define an agent spec. "
10496
"Use delegate_pdf_review when the user wants the PDF returned with review"
10597
" comments attached — anything like 'review this', 'annotate with comments',"
10698
" 'leave feedback on the PDF'. "
10799
"Use delegate_pdf_create when the user wants to generate a new document from"
108100
" scratch with no input file — invoices, reports, letters, contracts, etc. "
109-
"Use delegate_pdf_ingest for any request to convert a PDF to Markdown "
110-
"or extract its content as readable text. "
111101
"Use unsupported_capability when the user asks about the assistant itself "
112102
"or when none of the other outputs fit; supply a helpful message."
113103
),
@@ -177,13 +167,6 @@ async def delegate_user_spec(self, ctx: RunContext[OrchestratorDeps]) -> AgentDr
177167
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
178168
return await UserSpecAgent(self.runtime).orchestrate(request)
179169

180-
async def delegate_pdf_ingest(self, ctx: RunContext[OrchestratorDeps]) -> ConvertMarkdownResponse:
181-
request = ctx.deps.request
182-
return ConvertMarkdownResponse(
183-
reason="PDF to Markdown requested — Java converts deterministically.",
184-
files_to_ingest=request.files,
185-
)
186-
187170
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> PdfReviewOrchestrateResponse:
188171
return await self._run_pdf_review(ctx.deps.request)
189172

engine/src/stirling/contracts/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
AiFile,
1414
ArtifactKind,
1515
ConversationMessage,
16-
ConvertMarkdownResponse,
1716
ExtractedFileText,
1817
GenerateFileResponse,
1918
MathAuditorToolReportArtifact,
@@ -163,7 +162,6 @@
163162
"NeedContentFileRequest",
164163
"NeedContentResponse",
165164
"NeedIngestResponse",
166-
"ConvertMarkdownResponse",
167165
"NextExecutionAction",
168166
"OrchestratorRequest",
169167
"OrchestratorResponse",

engine/src/stirling/contracts/common.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ class WorkflowOutcome(StrEnum):
6262
CANNOT_CONTINUE = "cannot_continue"
6363
UNSUPPORTED_CAPABILITY = "unsupported_capability"
6464
GENERATE_FILE = "generate_file"
65-
CONVERT_MARKDOWN = "convert_markdown"
6665

6766

6867
class ArtifactKind(StrEnum):
@@ -184,19 +183,6 @@ class NeedIngestResponse(ApiModel):
184183
content_types: list[PdfContentType] = Field(default_factory=list)
185184

186185

187-
class ConvertMarkdownResponse(ApiModel):
188-
"""Terminal signal: convert the listed files to Markdown deterministically.
189-
190-
This is a deterministic, non-AI conversion. Java runs the PDF→Markdown converter
191-
(``PdfMarkdownConverter``) on each file and returns the resulting ``.md`` file(s) as a
192-
completed result. There is no resume turn — the conversion output is the final answer.
193-
"""
194-
195-
outcome: Literal[WorkflowOutcome.CONVERT_MARKDOWN] = WorkflowOutcome.CONVERT_MARKDOWN
196-
reason: str
197-
files_to_ingest: list[AiFile]
198-
199-
200186
class ToolOperationStep(ApiModel):
201187
kind: Literal[StepKind.TOOL] = StepKind.TOOL
202188
tool: AnyToolId

engine/src/stirling/contracts/orchestrator.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
AiFile,
1212
ArtifactKind,
1313
ConversationMessage,
14-
ConvertMarkdownResponse,
1514
ExtractedFileText,
1615
GenerateFileResponse,
1716
NeedContentResponse,
@@ -61,7 +60,6 @@ class UnsupportedCapabilityResponse(ApiModel):
6160
| GenerateFileResponse
6261
| NeedContentResponse
6362
| NeedIngestResponse
64-
| ConvertMarkdownResponse
6563
| AgentDraftResponse
6664
| NextExecutionAction
6765
| UnsupportedCapabilityResponse,

0 commit comments

Comments
 (0)