-
Notifications
You must be signed in to change notification settings - Fork 11
feat(ai): attach pending PDFs while extraction runs #754
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e0b29de
dedb1aa
618a82e
cda117b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import type { Tool } from "ai"; | ||
|
|
||
| import { | ||
| workspaceReadItemsInputSchema, | ||
| workspaceReadItemsOutputSchema, | ||
| } from "#/features/workspaces/content/workspace-content-contract"; | ||
| import { createWorkspaceReadItemsModelOutput } from "#/features/workspaces/content/workspace-read-references"; | ||
| import { getWorkspaceFileSourceObject } from "#/features/workspaces/extraction/workspace-file-source"; | ||
| import { getWorkspaceKernelFromEnv } from "#/features/workspaces/kernel/workspace-kernel-access"; | ||
|
|
||
| const maxPendingPdfBytes = 3.5 * 1024 * 1024; | ||
| type ModelToolOutput = Awaited<ReturnType<NonNullable<Tool["toModelOutput"]>>>; | ||
|
|
||
| export function isPendingPdfFallbackInput(input: unknown) { | ||
| const parsed = workspaceReadItemsInputSchema.safeParse(input); | ||
| return ( | ||
| parsed.success && parsed.data.requests.length === 1 && parsed.data.requests[0]?.mode === "start" | ||
| ); | ||
| } | ||
|
|
||
| export async function createPendingPdfModelOutput(input: { | ||
| env: Cloudflare.Env; | ||
| toolOutput: unknown; | ||
| workspaceId: string; | ||
| }): Promise<ModelToolOutput | null> { | ||
| const parsedOutput = workspaceReadItemsOutputSchema.safeParse(input.toolOutput); | ||
| if (!parsedOutput.success) { | ||
| return null; | ||
| } | ||
|
|
||
| const [result] = parsedOutput.data.results; | ||
| if ( | ||
| parsedOutput.data.results.length !== 1 || | ||
| !result || | ||
| result.status !== "pending" || | ||
| !result.itemId | ||
| ) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| const kernel = await getWorkspaceKernelFromEnv(input.env, input.workspaceId); | ||
| const { object, source } = await getWorkspaceFileSourceObject({ | ||
| env: input.env, | ||
| itemId: result.itemId, | ||
| kernel, | ||
| }); | ||
| if (source.contentType !== "application/pdf" || object.size > maxPendingPdfBytes) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The byte-size check alone does not make a PDF safe to send as a model attachment: a valid PDF below 3.5 MiB can still contain hundreds of mostly textual pages, while supported providers impose finite PDF page/context limits. For such accepted workspace uploads—especially when a Claude model is selected—the fallback turns an otherwise recoverable Useful? React with 👍 / 👎.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The eligibility check for attaching a pending PDF only validates content type and byte size ( Prompt for AI agents |
||
| return null; | ||
| } | ||
|
|
||
| return { | ||
| type: "content", | ||
| value: [ | ||
| { | ||
| type: "text", | ||
| text: `${JSON.stringify(createWorkspaceReadItemsModelOutput(parsedOutput.data))}\n\nThe original PDF is attached temporarily for this response. Do not claim extraction is complete or invent ThinkEx page citations. On a later turn, call workspace_read_items again for extracted, citable content.`, | ||
| }, | ||
| { | ||
| type: "file", | ||
| data: { | ||
| data: new Uint8Array(await object.arrayBuffer()), | ||
| type: "data", | ||
| }, | ||
| filename: source.fileName, | ||
| mediaType: "application/pdf", | ||
| }, | ||
| ], | ||
| }; | ||
| } catch { | ||
| // This is an optional bridge while extraction runs; preserve the normal pending result on failure. | ||
| return null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { | ||
| createPendingPdfModelOutput, | ||
| isPendingPdfFallbackInput, | ||
| } from "#/features/workspaces/ai/workspace-read-file-fallback"; | ||
| import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access"; | ||
|
|
||
| describe("pending PDF model output", () => { | ||
| it("attaches only a small pending PDF", async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The test covers only the happy path (single small PDF) and the too-large rejection. The other branches that the new helper guards — non-PDF content type, multiple results, missing itemId, non-pending status, and the getFileSourceObject/getWorkspaceKernelFromEnv failure fallback to null — are untested, even though the PR's stated goal depends on preserving the normal pending result on any of those failures. Consider adding a case where getFileSourceObject rejects (e.g. size mismatch) and asserting null, plus a non-pdf contentType case. Prompt for AI agents |
||
| const bytes = new Uint8Array([1, 2, 3]); | ||
| const output = await createPendingPdfModelOutput({ | ||
| env: createEnv(bytes), | ||
| toolOutput: pendingOutput(), | ||
| workspaceId: "workspace-1", | ||
| }); | ||
|
|
||
| expect(output).toMatchObject({ | ||
| type: "content", | ||
| value: [ | ||
| { type: "text" }, | ||
| { | ||
| data: { data: bytes, type: "data" }, | ||
| filename: "Report.pdf", | ||
| mediaType: "application/pdf", | ||
| type: "file", | ||
| }, | ||
| ], | ||
| }); | ||
| const text = output?.type === "content" ? output.value[0] : null; | ||
| expect(text).toMatchObject({ | ||
| text: expect.stringContaining('"retryAfterSeconds":15'), | ||
| }); | ||
| expect(text).toMatchObject({ | ||
| text: expect.stringContaining("The original PDF is attached temporarily"), | ||
| }); | ||
|
|
||
| const largeBytes = new Uint8Array(3.5 * 1024 * 1024 + 1); | ||
| await expect( | ||
| createPendingPdfModelOutput({ | ||
| env: createEnv(largeBytes), | ||
| toolOutput: pendingOutput(), | ||
| workspaceId: "workspace-1", | ||
| }), | ||
| ).resolves.toBeNull(); | ||
| }); | ||
|
|
||
| it("allows the fallback only for one initial read", () => { | ||
| expect(isPendingPdfFallbackInput({ requests: [{ mode: "start", path: "/Report.pdf" }] })).toBe( | ||
| true, | ||
| ); | ||
| expect( | ||
| isPendingPdfFallbackInput({ | ||
| requests: [{ cursor: "opaque", mode: "continue", path: "/Report.pdf" }], | ||
| }), | ||
| ).toBe(false); | ||
| expect( | ||
| isPendingPdfFallbackInput({ | ||
| requests: [{ mode: "pages", path: "/Report.pdf", range: "2" }], | ||
| }), | ||
| ).toBe(false); | ||
| expect( | ||
| isPendingPdfFallbackInput({ | ||
| requests: [ | ||
| { mode: "start", path: "/Report.pdf" }, | ||
| { mode: "start", path: "/Appendix.pdf" }, | ||
| ], | ||
| }), | ||
| ).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| function pendingOutput() { | ||
| return { | ||
| references: [], | ||
| results: [ | ||
| { | ||
| elapsedSeconds: 8, | ||
| itemId: "file-1", | ||
| path: "/Report.pdf", | ||
| phase: "extracting", | ||
| retryAfterSeconds: 15, | ||
| status: "pending", | ||
| type: "file", | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
|
|
||
| function createEnv(bytes: Uint8Array): Cloudflare.Env { | ||
| const kernel = { | ||
| getFileSource: async () => ({ | ||
| contentType: "application/pdf", | ||
| fileName: "Report.pdf", | ||
| objectKey: "sources/file-1.pdf", | ||
| sizeBytes: bytes.byteLength, | ||
| }), | ||
| } as unknown as WorkspaceKernelClient; | ||
|
|
||
| return { | ||
| WORKSPACE_KERNEL_FILES: { | ||
| get: async () => ({ | ||
| arrayBuffer: async () => bytes.buffer, | ||
| size: bytes.byteLength, | ||
| }), | ||
| }, | ||
| WorkspaceKernel: { getByName: () => kernel }, | ||
| } as unknown as Cloudflare.Env; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,10 @@ import type { ToolSet } from "ai"; | |||||||
|
|
||||||||
| import type { AIThreadContext } from "#/features/workspaces/ai/ai-thread-metadata"; | ||||||||
| import { defineAIThreadTool } from "#/features/workspaces/ai/ai-thread-tool"; | ||||||||
| import { | ||||||||
| createPendingPdfModelOutput, | ||||||||
| isPendingPdfFallbackInput, | ||||||||
| } from "#/features/workspaces/ai/workspace-read-file-fallback"; | ||||||||
| import { getWorkspaceToolResultAdapter } from "#/features/workspaces/ai/workspace-tool-result-adapters"; | ||||||||
| import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location"; | ||||||||
| import { | ||||||||
|
|
@@ -16,6 +20,7 @@ import { | |||||||
|
|
||||||||
| type WorkspaceThreadToolConfig = { | ||||||||
| definition: (typeof workspaceToolDefinitions)[number]; | ||||||||
| env: Cloudflare.Env; | ||||||||
| getThreadContext: () => Promise<AIThreadContext | null>; | ||||||||
| onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; | ||||||||
| resolveWorkspaceReferences?: (refs: readonly string[]) => Promise<WorkspaceReferenceRecord[]>; | ||||||||
|
|
@@ -24,6 +29,9 @@ type WorkspaceThreadToolConfig = { | |||||||
| function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { | ||||||||
| const { definition } = input; | ||||||||
| const resultAdapter = getWorkspaceToolResultAdapter(definition.name); | ||||||||
| // Projection also runs for history; only fresh calls may hydrate raw bytes. | ||||||||
| const freshWorkspaceIds = | ||||||||
| definition.name === "workspace_read_items" ? new Map<string, string>() : null; | ||||||||
|
|
||||||||
| return defineAIThreadTool({ | ||||||||
| description: definition.description, | ||||||||
|
|
@@ -32,10 +40,24 @@ function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { | |||||||
| outputSchema: definition.outputSchema, | ||||||||
| ...(resultAdapter | ||||||||
| ? { | ||||||||
| toModelOutput: ({ output }) => ({ | ||||||||
| type: "json" as const, | ||||||||
| value: resultAdapter.projectOutput(output), | ||||||||
| }), | ||||||||
| toModelOutput: async ({ output, toolCallId }) => { | ||||||||
| const workspaceId = freshWorkspaceIds?.get(toolCallId); | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: History/replayed projection can attach the pending PDF again because this entry remains after the fresh projection; consume the Prompt for AI agents
Suggested change
|
||||||||
| if (workspaceId) { | ||||||||
| const fileOutput = await createPendingPdfModelOutput({ | ||||||||
| env: input.env, | ||||||||
| toolOutput: output, | ||||||||
| workspaceId, | ||||||||
| }); | ||||||||
| if (fileOutput) { | ||||||||
| return fileOutput; | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| return { | ||||||||
| type: "json" as const, | ||||||||
| value: resultAdapter.projectOutput(output), | ||||||||
| }; | ||||||||
| }, | ||||||||
| } | ||||||||
| : {}), | ||||||||
| execute: async (args, context) => { | ||||||||
|
|
@@ -55,13 +77,17 @@ function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { | |||||||
| if (references.length > 0 && input.onWorkspaceReferences) { | ||||||||
| input.onWorkspaceReferences(references); | ||||||||
| } | ||||||||
| if (freshWorkspaceIds && isPendingPdfFallbackInput(args)) { | ||||||||
| freshWorkspaceIds.set(context.invocationId, thread.workspaceId); | ||||||||
| } | ||||||||
|
|
||||||||
| return output; | ||||||||
| }, | ||||||||
| }); | ||||||||
| } | ||||||||
|
|
||||||||
| export function createAIThreadWorkspaceTools(input: { | ||||||||
| env: Cloudflare.Env; | ||||||||
| getThreadContext: () => Promise<AIThreadContext | null>; | ||||||||
| onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; | ||||||||
| resolveWorkspaceReferences?: (refs: readonly string[]) => Promise<WorkspaceReferenceRecord[]>; | ||||||||
|
|
@@ -71,6 +97,7 @@ export function createAIThreadWorkspaceTools(input: { | |||||||
| definition.name, | ||||||||
| createWorkspaceThreadTool({ | ||||||||
| definition, | ||||||||
| env: input.env, | ||||||||
| getThreadContext: input.getThreadContext, | ||||||||
| onWorkspaceReferences: input.onWorkspaceReferences, | ||||||||
| resolveWorkspaceReferences: input.resolveWorkspaceReferences, | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fallback now decides eligibility only from the result, so a fresh
workspace_read_itemscall withmode: "continue"and one pending PDF hydrates and attaches the whole source file. A continuation is intended to retrieve the next extracted-content chunk, not switch to the raw-file fallback; the previous implementation explicitly required onemode: "start"request. Pass a validated initial-read eligibility flag (or the request input) into this function and require exactly onestartrequest before attaching bytes.Artifacts
Focused continuation probe source
Continuation probe output showing raw PDF attachment