Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/features/workspaces/content/workspace-content-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const workspacePathSchema = z.string().min(1);

const readWorkspaceItemsFailureCodes = [
"content_changed",
"extraction_failed",
"extraction_stalled",
"invalid_cursor",
"invalid_selection",
"page_range_out_of_range",
Expand Down Expand Up @@ -93,22 +95,48 @@ const workspaceContentReadResultSchema = z.union([
z.object({
assetKind: workspaceFileAssetKindSchema,
content: z.string(),
emptyPages: z
.array(z.number().int().min(1))
.optional()
.describe(
"Returned pages that extracted no text. Expect these to fill in later while provisional is true.",
),
format: z.literal("markdown"),
itemId: z.string().min(1),
location: workspaceReadPagesSchema.extend({ kind: z.literal("pages") }),
nextCursor: z.string().optional(),
path: workspacePathSchema,
provisional: z
.boolean()
.optional()
.describe(
"True when this content came from the fast extraction pass and a higher-quality pass is still running.",
),
relations: workspaceReadRelationsSchema.optional(),
status: z.literal("ready"),
type: z.literal("file"),
}),
z.object({
elapsedSeconds: z
.number()
.int()
.nonnegative()
.describe("How long extraction has been running."),
path: workspacePathSchema,
phase: z
.enum(["queued", "extracting"])
.describe("Whether extraction has started yet for this file."),
retryAfterSeconds: z
.number()
.int()
.positive()
.describe("Suggested wait before reading this path again."),
status: z.literal("pending"),
type: z.literal("file"),
}),
z.object({
code: z.enum(readWorkspaceItemsFailureCodes),
message: z.string().optional().describe("Why extraction failed, when known."),
path: workspacePathSchema,
status: z.literal("failed"),
type: z.literal("file").optional(),
Expand Down
71 changes: 52 additions & 19 deletions src/features/workspaces/content/workspace-content-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type {
DocumentMarkdownChunkReadResult,
} from "#/features/workspaces/documents/document-markdown-chunk";
import { readWorkspacePageProjection } from "#/features/workspaces/extraction/workspace-page-projection";
import {
resolveWorkspaceProjectionReadiness,
type WorkspaceProjectionReadiness,
} from "#/features/workspaces/extraction/workspace-projection-readiness";
import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access";
import { resolveWorkspaceFileTypeFromItem } from "#/features/workspaces/model/workspace-file";
import { serializeWorkspaceRelations } from "#/features/workspaces/operations/relations";
Expand Down Expand Up @@ -190,24 +194,12 @@ async function readFile(input: {
return { code: "unsupported_item_type", path: input.path, status: "failed" };
}

const projection = await input.kernel.readFileProjection({
itemId: input.item.id,
format: "pages",
});
if (
!projection ||
projection.status === "not_started" ||
projection.status === "queued" ||
projection.status === "processing"
) {
return { path: input.path, status: "pending", type: "file" };
}
if (
projection.status !== "ready" ||
projection.objectKey === null ||
projection.sourceHash === null
) {
return { code: "projection_failed", path: input.path, status: "failed", type: "file" };
const projection = resolveWorkspaceProjectionReadiness(
await input.kernel.readFileProjection({ itemId: input.item.id, format: "pages" }),
Date.now(),
);
if (projection.state !== "ready") {
return describeUnreadableProjection(projection, input.path);
}

const encodedCursor = input.request.mode === "continue" ? input.request.cursor : undefined;
Expand All @@ -223,7 +215,7 @@ async function readFile(input: {
pageRead = await readWorkspacePageProjection({
bucket: input.bucket,
expectedSourceHash: projection.sourceHash,
manifestObjectKey: projection.objectKey,
manifestObjectKey: projection.manifestObjectKey,
pages:
cursor?.kind === "file"
? String(cursor.nextPage)
Expand All @@ -241,6 +233,7 @@ async function readFile(input: {
return {
assetKind: fileType.assetKind,
content: pageRead.content,
...(pageRead.emptyPages.length > 0 ? { emptyPages: pageRead.emptyPages } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Completed projections with intentionally blank pages are reported as still extracting. Only expose emptyPages for provisional projections (or change downstream guidance) so a final blank page is not retried indefinitely.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/workspaces/content/workspace-content-reader.ts, line 236:

<comment>Completed projections with intentionally blank pages are reported as still extracting. Only expose `emptyPages` for provisional projections (or change downstream guidance) so a final blank page is not retried indefinitely.</comment>

<file context>
@@ -241,6 +233,7 @@ async function readFile(input: {
 	return {
 		assetKind: fileType.assetKind,
 		content: pageRead.content,
+		...(pageRead.emptyPages.length > 0 ? { emptyPages: pageRead.emptyPages } : {}),
 		format: "markdown",
 		itemId: input.item.id,
</file context>
Suggested change
...(pageRead.emptyPages.length > 0 ? { emptyPages: pageRead.emptyPages } : {}),
...(projection.provisional && pageRead.emptyPages.length > 0 ? { emptyPages: pageRead.emptyPages } : {}),

format: "markdown",
itemId: input.item.id,
location: { kind: "pages", ...pageRead.pages },
Expand All @@ -256,11 +249,51 @@ async function readFile(input: {
}),
}),
path: input.path,
...(projection.provisional ? { provisional: true } : {}),
status: "ready",
type: "file",
};
}

/**
* Maps a non-ready projection onto the read result the model sees.
*
* @param projection - Readiness for a projection that is not serving content.
* @param path - Absolute workspace path that was read.
* @returns The pending or failed read result for that path.
*/
function describeUnreadableProjection(
projection: Exclude<WorkspaceProjectionReadiness, { state: "ready" }>,
path: string,
): WorkspaceContentReadResult {
if (projection.state === "pending") {
return {
elapsedSeconds: projection.elapsedSeconds,
path,
phase: projection.phase,
retryAfterSeconds: projection.retryAfterSeconds,
status: "pending",
type: "file",
};
}

if (projection.state === "stalled") {
return { code: "extraction_stalled", path, status: "failed", type: "file" };
}

if (projection.state === "failed") {
return {
code: "extraction_failed",
...(projection.message ? { message: projection.message } : {}),
path,
status: "failed",
type: "file",
};
}

return { code: "projection_failed", path, status: "failed", type: "file" };
}

async function attachRelationPaths(
kernel: WorkspaceKernelClient,
readyResults: PendingReadyResult[],
Expand Down
83 changes: 83 additions & 0 deletions src/features/workspaces/content/workspace-read-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,89 @@ describe("workspace read references", () => {
});
expect(modelOutput.results[0]).not.toHaveProperty("pageReferences");
});

it("stays silent when every read succeeded outright", () => {
const results = [documentResult(), fileResult()] satisfies WorkspaceContentReadResult[];

expect(
createWorkspaceReadItemsModelOutput({
references: createWorkspaceReadReferences(results),
results,
}),
).not.toHaveProperty("guidance");
});

it("explains a pending read once however many paths are waiting", () => {
const results = [
{
elapsedSeconds: 4,
path: "/A.pdf",
phase: "extracting",
retryAfterSeconds: 15,
status: "pending",
type: "file",
},
{
elapsedSeconds: 0,
path: "/B.pdf",
phase: "queued",
retryAfterSeconds: 15,
status: "pending",
type: "file",
},
] satisfies WorkspaceContentReadResult[];
const guidance = createWorkspaceReadItemsModelOutput({ references: [], results }).guidance;

expect(guidance).toHaveLength(1);
expect(guidance?.[0]).toContain("Never sleep");
});

it("separates failures that will never resolve from transient ones", () => {
const results = [
{ code: "extraction_failed", path: "/A.pdf", status: "failed", type: "file" },
{ code: "extraction_stalled", path: "/B.pdf", status: "failed", type: "file" },
{ code: "projection_failed", path: "/C.pdf", status: "failed", type: "file" },
] satisfies WorkspaceContentReadResult[];
const guidance = createWorkspaceReadItemsModelOutput({ references: [], results }).guidance;

expect(guidance).toHaveLength(2);
expect(guidance?.[0]).toContain("do not suggest re-uploading");
expect(guidance?.[1]).toContain("One repeat read is reasonable");
});

it("says nothing about failures that are the caller's own mistake", () => {
const results = [
{ code: "path_not_found", path: "/Missing.pdf", status: "failed" },
] satisfies WorkspaceContentReadResult[];

expect(createWorkspaceReadItemsModelOutput({ references: [], results })).not.toHaveProperty(
"guidance",
);
});

it("warns that blank pages from the fast pass are not final", () => {
const results = [
{ ...fileResult(), emptyPages: [13], provisional: true },
] satisfies WorkspaceContentReadResult[];
const guidance = createWorkspaceReadItemsModelOutput({
references: createWorkspaceReadReferences(results),
results,
}).guidance;

expect(guidance).toHaveLength(1);
expect(guidance?.[0]).toContain("still extracting");
});

it("stays silent about blank pages on a final, non-provisional read", () => {
const results = [{ ...fileResult(), emptyPages: [13] }] satisfies WorkspaceContentReadResult[];

expect(
createWorkspaceReadItemsModelOutput({
references: createWorkspaceReadReferences(results),
results,
}),
).not.toHaveProperty("guidance");
});
});

function documentResult(): Extract<
Expand Down
62 changes: 61 additions & 1 deletion src/features/workspaces/content/workspace-read-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,70 @@ export function createWorkspaceReadReferences(
return createWorkspaceReferenceRecords(locations);
}

/**
* Guidance for read outcomes the model has to react to, keyed by situation.
*
* These live here rather than in the tool description because none of them
* change how a read is issued, and background extraction states are rare enough
* that every request should not carry the instructions for handling them.
*/
const workspaceReadGuidance = {
pending:
"Some paths are still extracting. Never sleep, poll, or otherwise stall waiting for them, including inside compute, sandbox_bash, or orchestrate. Either do other work and read those paths again later in this reply, or tell the user they are still processing and to ask again in about retryAfterSeconds. Never read the same pending path more than twice in one reply.",
unrecoverable:
Comment on lines +61 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the reported retry delay rather than a literal token.

retryAfterSeconds is emitted verbatim here, so the model may tell users to retry “in about retryAfterSeconds” instead of using the numeric value on each pending result.

Proposed fix
 	pending:
-		"Some paths are still extracting. Never sleep, poll, or otherwise stall waiting for them, including inside compute, sandbox_bash, or orchestrate. Either do other work and read those paths again later in this reply, or tell the user they are still processing and to ask again in about retryAfterSeconds. Never read the same pending path more than twice in one reply.",
+		"Some paths are still extracting. Never sleep, poll, or otherwise stall waiting for them, including inside compute, sandbox_bash, or orchestrate. Either do other work and read those paths again later in this reply, or tell the user they are still processing and can retry after the retryAfterSeconds value reported for that path. Never read the same pending path more than twice in one reply.",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const workspaceReadGuidance = {
pending:
"Some paths are still extracting. Never sleep, poll, or otherwise stall waiting for them, including inside compute, sandbox_bash, or orchestrate. Either do other work and read those paths again later in this reply, or tell the user they are still processing and to ask again in about retryAfterSeconds. Never read the same pending path more than twice in one reply.",
unrecoverable:
const workspaceReadGuidance = {
pending:
"Some paths are still extracting. Never sleep, poll, or otherwise stall waiting for them, including inside compute, sandbox_bash, or orchestrate. Either do other work and read those paths again later in this reply, or tell the user they are still processing and can retry after the retryAfterSeconds value reported for that path. Never read the same pending path more than twice in one reply.",
unrecoverable:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/workspaces/content/workspace-read-references.ts` around lines 61
- 64, Update the pending guidance in workspaceReadGuidance so it instructs the
model to use the numeric retryAfterSeconds value reported by each pending
result, rather than repeating the literal token name. Preserve the existing
instructions about not waiting and limiting repeated reads.

"Extraction will not finish for some paths. Report the code and any message to the user; do not retry those reads and do not suggest re-uploading the file.",
transient:
"Some paths failed on a transient storage problem. One repeat read is reasonable; if it fails again, tell the user.",
provisional:
"Some content came from a fast first pass. Pages listed in emptyPages are still extracting, so read them again later rather than reporting them as blank.",
} as const;

/**
* Collects the handling guidance a set of read results calls for.
*
* Emitted once per situation rather than once per result so a batch of pending
* reads does not repeat the same paragraph for every path.
*
* @param results - Ordered workspace read results.
* @returns Guidance lines for the situations present, most actionable first.
*/
function createWorkspaceReadGuidance(results: readonly WorkspaceContentReadResult[]): string[] {
const situations = new Set<keyof typeof workspaceReadGuidance>();

for (const result of results) {
if (result.status === "pending") {
situations.add("pending");
continue;
}

if (result.status === "failed") {
if (result.code === "extraction_failed" || result.code === "extraction_stalled") {
situations.add("unrecoverable");
} else if (result.code === "projection_failed") {
situations.add("transient");
}
continue;
}

// Gate on provisional only: emptyPages on a final (non-provisional) read
// describes genuinely blank pages, which must not be reported as still
// extracting or the model retries a completed read forever.
if (result.type === "file" && result.provisional) {
situations.add("provisional");
}
}

return (Object.keys(workspaceReadGuidance) as Array<keyof typeof workspaceReadGuidance>)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-combine-iterations (warning)

This loops over your list twice because .filter().map() makes two passes, so do it in one pass with .reduce() or a for...of loop

Fix → Combine .map().filter() style chains into one pass with .reduce() or a for...of loop, so you only loop over the list once

Docs

.filter((situation) => situations.has(situation))
.map((situation) => workspaceReadGuidance[situation]);
}

/**
* Projects a rich workspace read result into compact model-visible JSON.
*
* Raw item IDs and durable locations remain in the persisted tool result.
* Model-visible content receives only opaque refs next to the content they
* identify.
* identify, plus guidance for any outcome that needs handling.
*
* @param output - Validated rich workspace read output.
* @returns JSON-safe results annotated with short workspace refs.
Expand All @@ -65,8 +123,10 @@ export function createWorkspaceReadItemsModelOutput(output: WorkspaceReadItemsOu
const refsByLocation = new Map(
output.references.map((record) => [getWorkspaceLocationKey(record.location), record.ref]),
);
const guidance = createWorkspaceReadGuidance(output.results);

return {
...(guidance.length > 0 ? { guidance } : {}),
results: output.results.map((result) => {
if (result.status !== "ready") {
return result;
Expand Down
Loading