Agent: expose incomplete results in status and threads (#4812)

* Expose partial Agent results through public status API

* Limit Agent partials to failed runs and expose SDK fields

* Preserve Agent schema validation errors across API passthrough

* Reject falsy invalid Agent schemas before admission

* Validate inherited and async Agent schemas before admission

* fix: accept bare threads in Agent continuation precheck

* Agent: drop schema-validation changes from the partial-result PR

Restore main's public schema validator and remove the x-* keyword
acceptance, $async rejection, falsy-schema check, inherited-schema
precheck and the upstream schema-400 forwarder, with their tests. The
extract-v3 side no longer changes its schema policy, so the two
validators already agree; the submit format fix is firecrawl/extract-v3#191.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rakshith Ramprakash
2026-10-03 00:10:44 +10:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e76d5b6fcd
commit 2e6f6aa908
7 changed files with 137 additions and 1 deletions
@@ -131,6 +131,9 @@ describe("agentStatusController", () => {
model: "spark-2",
effort: "medium",
data: { result: "ok" },
partial: { stale: true },
partialSchemaValid: false,
stopReason: "credit_limit_reached",
message: "Done",
threadId: "thread-123",
threadTurn: 2,
@@ -152,6 +155,69 @@ describe("agentStatusController", () => {
creditsUsed: 7,
}),
);
const body = (res.json as Mock).mock.calls[0][0];
expect(body).not.toHaveProperty("partial");
expect(body).not.toHaveProperty("partialSchemaValid");
expect(body).not.toHaveProperty("stopReason");
});
it("forwards a credit-limited partial without treating it as completed data", async () => {
(getAgentJobAccess as Mock).mockResolvedValue({
teamId: "team-123",
expiresAtMs: Date.now() + 60_000,
});
(getExtractV3AgentStatus as Mock).mockResolvedValue({
id: "job-123",
success: true,
status: "failed",
error: "Agent reached max credits",
data: { shouldNotAppear: true },
stopReason: "credit_limit_reached",
partial: { companies: [{ name: "Acme" }] },
partialSchemaValid: false,
model: "spark-2",
});
const res = buildRes();
await agentStatusController(baseReq, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
status: "failed",
data: undefined,
stopReason: "credit_limit_reached",
partial: { companies: [{ name: "Acme" }] },
partialSchemaValid: false,
}),
);
});
it("does not expose a live processing checkpoint", async () => {
(getAgentJobAccess as Mock).mockResolvedValue({
teamId: "team-123",
expiresAtMs: Date.now() + 60_000,
});
(getExtractV3AgentStatus as Mock).mockResolvedValue({
id: "job-123",
success: true,
status: "processing",
data: { companies: [{ name: "Acme" }] },
partial: { companies: [{ name: "Acme" }] },
partialSchemaValid: false,
stopReason: "credit_limit_reached",
model: "spark-2",
});
const res = buildRes();
await agentStatusController(baseReq, res);
const body = (res.json as Mock).mock.calls[0][0];
expect(body.status).toBe("processing");
expect(body.data).toBeUndefined();
expect(body).not.toHaveProperty("partial");
expect(body).not.toHaveProperty("partialSchemaValid");
expect(body).not.toHaveProperty("stopReason");
});
it.each([
+10 -1
View File
@@ -98,7 +98,16 @@ export async function agentStatusController(
success: true,
status: agent.status === "success" ? "completed" : agent.status,
error: agent.error,
data: agent.data,
data: agent.status === "success" ? agent.data : undefined,
// Checkpoints are internal while a job is running; only expose a partial
// when the run has stopped without a completed result.
...(agent.status === "failed"
? {
partial: agent.partial,
partialSchemaValid: agent.partialSchemaValid,
stopReason: agent.stopReason,
}
: {}),
model,
effort,
threadId: thread.threadId,
+8
View File
@@ -1812,6 +1812,11 @@ export type AgentStatusResponse =
status: "processing" | "completed" | "failed";
error?: string;
data?: any;
/** Best-effort JSON on a failed run; `data` remains completed-only. */
partial?: unknown;
/** Only present when the caller supplied a JSON Schema. */
partialSchemaValid?: boolean;
stopReason?: "credit_limit_reached";
model?: "spark-1-pro" | "spark-1-mini" | "spark-2";
effort?: "low" | "medium" | "high";
expiresAt: string;
@@ -1846,6 +1851,9 @@ type AgentThreadRun = {
message: string | null;
// Only present when the request asked for includeData.
data?: unknown;
partial?: unknown;
partialSchemaValid?: boolean;
stopReason?: "credit_limit_reached";
suggestions?: AgentSuggestion[] | null;
pendingApproval?: AgentPendingApproval | null;
exchange?: AgentExchangeSummary | null;
+4
View File
@@ -5,6 +5,10 @@ type ExtractV3AgentStatus = {
id: string;
status: "processing" | "success" | "failed";
data?: unknown;
/** Incomplete JSON from an interrupted run; never a completed `data` value. */
partial?: unknown;
partialSchemaValid?: boolean;
stopReason?: "credit_limit_reached";
error?: string;
model?: "spark-1-pro" | "spark-1-mini" | "spark-2";
effort?: "low" | "medium" | "high";
+8
View File
@@ -1627,6 +1627,11 @@ export interface AgentStatusResponse {
status: "processing" | "completed" | "failed";
error?: string;
data?: unknown;
/** Best-effort result on a failed run; never a completed `data` value. */
partial?: unknown;
/** Schema validity of `partial`, when a schema was supplied. */
partialSchemaValid?: boolean;
stopReason?: "credit_limit_reached";
/**
* Server-provided model name. Widened past the request-side union on
* purpose: new models ship without an SDK release, so pinning this to known
@@ -1672,6 +1677,9 @@ export interface AgentThreadRun {
message: string | null;
/** Only present when the request asked for includeData. */
data?: unknown;
partial?: unknown;
partialSchemaValid?: boolean;
stopReason?: "credit_limit_reached";
suggestions?: AgentSuggestion[] | null;
pendingApproval?: PendingApproval | null;
exchange?: AgentExchangeSummary | null;
+7
View File
@@ -1745,6 +1745,10 @@ class AgentResponse(BaseModel):
id: Optional[str] = None
status: Optional[Literal["processing", "completed", "failed"]] = None
data: Optional[Any] = None
# Best-effort result on a failed run; never a completed `data` value.
partial: Optional[Any] = None
partial_schema_valid: Optional[bool] = Field(default=None, alias="partialSchemaValid")
stop_reason: Optional[str] = Field(default=None, alias="stopReason")
error: Optional[str] = None
# Deliberately a plain str, not a Literal: this is server-provided and new
# models ship without an SDK release, so a narrow type turns an unknown
@@ -1838,6 +1842,9 @@ class AgentThreadRun(BaseModel):
message: Optional[str] = None
# Only present when the request asked for include_data.
data: Optional[Any] = None
partial: Optional[Any] = None
partial_schema_valid: Optional[bool] = Field(default=None, alias="partialSchemaValid")
stop_reason: Optional[str] = Field(default=None, alias="stopReason")
suggestions: Optional[List[AgentSuggestion]] = None
pending_approval: Optional[PendingApproval] = Field(
default=None, alias="pendingApproval"
@@ -0,0 +1,34 @@
from firecrawl.v2.types import AgentResponse, AgentThreadRun
def test_agent_status_preserves_credit_limit_partial():
response = AgentResponse.model_validate(
{
"success": True,
"status": "failed",
"stopReason": "credit_limit_reached",
"partial": {"companies": [{"name": "Acme"}]},
"partialSchemaValid": False,
}
)
assert response.data is None
assert response.stop_reason == "credit_limit_reached"
assert response.partial == {"companies": [{"name": "Acme"}]}
assert response.partial_schema_valid is False
def test_agent_thread_run_preserves_partial_for_continuation():
run = AgentThreadRun.model_validate(
{
"id": "job-123",
"status": "credit_limit_reached",
"stopReason": "credit_limit_reached",
"partial": {"companies": [{"name": "Acme"}]},
"partialSchemaValid": False,
}
)
assert run.partial == {"companies": [{"name": "Acme"}]}
assert run.partial_schema_valid is False
assert run.stop_reason == "credit_limit_reached"