mirror of
https://github.com/firecrawl/firecrawl.git
synced 2026-10-25 09:53:16 +08:00
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:
co-authored by
Claude Opus 5.5
parent
e76d5b6fcd
commit
2e6f6aa908
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user