mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
* fix(subagents): report model_length_capped when a subagent hits the output limit ModelLengthFinishReasonMiddleware only ran on the lead chain. A subagent whose response was cut at the provider output limit (finish_reason=length, stop_reason=max_tokens, MAX_TOKENS, Responses max_output_tokens) reached the lead as a clean `completed`, and a tool call truncated at that boundary still executed with partial arguments. Register the same middleware on the subagent chain, in the lead's relative position (after configured extensions, before Safety). It now also records each detected cap per context run_id and exposes `consume_stop_reason`, so the executor's existing duck-typed guard collection reports `stop_reason=model_length_capped`. Add the value to the subagent status contract (Python enum, contract fixture, frontend whitelist) with the label "model output length limit", and update the subagent docs. Tests cover the middleware channel, chain placement, the contract, and an end-to-end executor run with a fake model. Co-Authored-By: Claude Code <noreply@anthropic.com> * test(tasks): cover cap reasons on completed and failed runs Extend the existing shared-contract round-trip matrix to preserve both terminal statuses alongside every supported stop reason, including model_length_capped and loop_capped. --------- Co-authored-by: huangzz <110116@nd.com> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
import type { Message } from "@langchain/langgraph-sdk";
|
|
import { describe, expect, it } from "@rstest/core";
|
|
|
|
import {
|
|
SUBAGENT_ERROR_KEY,
|
|
SUBAGENT_MODEL_NAME_KEY,
|
|
SUBAGENT_RESULT_BRIEF_KEY,
|
|
SUBAGENT_STATUS_KEY,
|
|
SUBAGENT_STOP_REASON_KEY,
|
|
SUBAGENT_TOKEN_USAGE_KEY,
|
|
derivePendingSubtaskStatus,
|
|
hasSubtaskToolResult,
|
|
parseSubtaskResult,
|
|
} from "@/core/tasks/subtask-result";
|
|
|
|
interface ContractFile {
|
|
valid_status_values: string[];
|
|
valid_stop_reason_values: string[];
|
|
}
|
|
|
|
const CONTRACT_PATH = resolve(
|
|
__dirname,
|
|
"../../../../../contracts/subagent_status_contract.json",
|
|
);
|
|
const CONTRACT: ContractFile = JSON.parse(
|
|
readFileSync(CONTRACT_PATH, "utf-8"),
|
|
) as ContractFile;
|
|
|
|
describe("parseSubtaskResult", () => {
|
|
it("uses legacy task result text when structured metadata is absent", () => {
|
|
expect(
|
|
parseSubtaskResult(
|
|
"Task Succeeded. Result: investigated and produced a 3-page report",
|
|
),
|
|
).toEqual({
|
|
status: "completed",
|
|
result: "investigated and produced a 3-page report",
|
|
});
|
|
|
|
expect(
|
|
parseSubtaskResult(
|
|
"Task failed. Error: underlying tool raised RuntimeError",
|
|
),
|
|
).toEqual({
|
|
status: "failed",
|
|
error: "Error: underlying tool raised RuntimeError",
|
|
});
|
|
|
|
expect(parseSubtaskResult("Task cancelled by user.")).toEqual({
|
|
status: "failed",
|
|
error: "Task cancelled by user.",
|
|
});
|
|
|
|
expect(parseSubtaskResult("Task timed out. Error: 900 seconds")).toEqual({
|
|
status: "failed",
|
|
error: "Task timed out. Error: 900 seconds",
|
|
});
|
|
|
|
expect(
|
|
parseSubtaskResult(
|
|
"Task polling timed out after 15 minutes. Status: RUNNING",
|
|
),
|
|
).toEqual({
|
|
status: "failed",
|
|
error: "Task polling timed out after 15 minutes. Status: RUNNING",
|
|
});
|
|
|
|
expect(
|
|
parseSubtaskResult("Error: Tool 'task' failed with TypeError: boom"),
|
|
).toEqual({
|
|
status: "failed",
|
|
error: "Error: Tool 'task' failed with TypeError: boom",
|
|
});
|
|
});
|
|
|
|
it("keeps unknown content-only task results in progress", () => {
|
|
const parsed = parseSubtaskResult("partial streaming chunk");
|
|
|
|
expect(parsed.status).toBe("in_progress");
|
|
expect(parsed.error).toBeUndefined();
|
|
expect(parsed.result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("hasSubtaskToolResult", () => {
|
|
it("matches a task tool call to its ToolMessage", () => {
|
|
const messages = [
|
|
{ type: "ai" },
|
|
{ type: "tool", tool_call_id: "call_task_1" },
|
|
] as Message[];
|
|
|
|
expect(hasSubtaskToolResult("call_task_1", messages)).toBe(true);
|
|
});
|
|
|
|
it("returns false when a task tool call has no ToolMessage", () => {
|
|
const messages = [
|
|
{ type: "ai" },
|
|
{ type: "tool", tool_call_id: "call_other" },
|
|
] as Message[];
|
|
|
|
expect(hasSubtaskToolResult("call_task_1", messages)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("derivePendingSubtaskStatus", () => {
|
|
it("keeps a task in progress while its own assistant turn is loading", () => {
|
|
const messages = [{ type: "ai" }] as Message[];
|
|
|
|
expect(derivePendingSubtaskStatus("call_task_1", messages, true)).toBe(
|
|
"in_progress",
|
|
);
|
|
});
|
|
|
|
it("does not revive an earlier unfinished task during a later turn", () => {
|
|
const messages = [{ type: "ai" }] as Message[];
|
|
|
|
expect(derivePendingSubtaskStatus("call_task_1", messages, false)).toBe(
|
|
"failed",
|
|
);
|
|
});
|
|
|
|
it("leaves result parsing to the ToolMessage path when a result exists", () => {
|
|
const messages = [
|
|
{ type: "ai" },
|
|
{ type: "tool", tool_call_id: "call_task_1" },
|
|
] as Message[];
|
|
|
|
expect(derivePendingSubtaskStatus("call_task_1", messages, false)).toBe(
|
|
"in_progress",
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Structured-status path (bytedance/deer-flow#3146).
|
|
*
|
|
* The backend stamps `ToolMessage.additional_kwargs.subagent_status`
|
|
* directly. The frontend should prefer that over reverse-engineering it
|
|
* from the content string.
|
|
*/
|
|
describe("parseSubtaskResult — structured additional_kwargs (preferred path)", () => {
|
|
it("uses additional_kwargs.subagent_status when present", () => {
|
|
const parsed = parseSubtaskResult("Task Succeeded. Result: foo", {
|
|
[SUBAGENT_STATUS_KEY]: "completed",
|
|
});
|
|
expect(parsed.status).toBe("completed");
|
|
});
|
|
|
|
it("restores terminal model and token usage metadata", () => {
|
|
expect(
|
|
parseSubtaskResult("Task Succeeded. Result: done", {
|
|
[SUBAGENT_STATUS_KEY]: "completed",
|
|
[SUBAGENT_MODEL_NAME_KEY]: "claude-3-7-sonnet",
|
|
[SUBAGENT_TOKEN_USAGE_KEY]: {
|
|
input_tokens: 100,
|
|
output_tokens: 20,
|
|
total_tokens: 120,
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
status: "completed",
|
|
modelName: "claude-3-7-sonnet",
|
|
usage: {
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
totalTokens: 120,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("collapses cancelled / timed_out / polling_timed_out to failed for the card UI", () => {
|
|
for (const backendStatus of [
|
|
"cancelled",
|
|
"timed_out",
|
|
"polling_timed_out",
|
|
]) {
|
|
const parsed = parseSubtaskResult("anything at all", {
|
|
[SUBAGENT_STATUS_KEY]: backendStatus,
|
|
});
|
|
expect(parsed.status).toBe("failed");
|
|
}
|
|
});
|
|
|
|
it("renders legacy max_turns_reached (checkpointed under #3949) as a terminal failed pill, not spinning in_progress", () => {
|
|
// Phase 1 wrote `subagent_status: "max_turns_reached"` into ToolMessage
|
|
// additional_kwargs, which is checkpointed in thread history. Phase 2 (#3980)
|
|
// stopped producing it, but old turns still carry it. Without the deprecated
|
|
// alias, readStructuredStatus returns null while hasStructuredSubagentMetadata
|
|
// stays true (sibling keys present) -> parseSubtaskResult returns
|
|
// { status: "in_progress" } and the card spins forever. The alias keeps it
|
|
// terminal, matching how Phase 1 itself rendered the value.
|
|
const parsed = parseSubtaskResult("ignored content", {
|
|
[SUBAGENT_STATUS_KEY]: "max_turns_reached",
|
|
[SUBAGENT_ERROR_KEY]: "Reached max_turns=150",
|
|
[SUBAGENT_RESULT_BRIEF_KEY]: "investigated 3 of 5 sources",
|
|
});
|
|
expect(parsed.status).toBe("failed");
|
|
expect(parsed.error).toBe("Reached max_turns=150");
|
|
// result only attaches for the completed pill; legacy data renders as failed.
|
|
expect(parsed.result).toBeUndefined();
|
|
});
|
|
|
|
it("surfaces stop_reason on a capped run while keeping a normal pill status", () => {
|
|
// bytedance/deer-flow#3875 Phase 2: a token-capped run produced a final
|
|
// answer, so it is `completed` with the cap on the additive
|
|
// `subagent_stop_reason` field. The card stays green; stopReason carries
|
|
// the cap detail for a future badge, and the recovered partial result
|
|
// lives on subagent_result_brief.
|
|
const parsed = parseSubtaskResult("ignored content", {
|
|
[SUBAGENT_STATUS_KEY]: "completed",
|
|
[SUBAGENT_RESULT_BRIEF_KEY]: "investigated 3 of 5 sources",
|
|
[SUBAGENT_STOP_REASON_KEY]: "token_capped",
|
|
});
|
|
expect(parsed.status).toBe("completed");
|
|
expect(parsed.result).toBe("investigated 3 of 5 sources");
|
|
expect(parsed.stopReason).toBe("token_capped");
|
|
});
|
|
|
|
it("surfaces stop_reason on a turn-capped run that produced no usable result", () => {
|
|
// No usable partial -> the backend stamps `failed` + turn_capped. The card
|
|
// goes red; stopReason still carries the cap so a future badge can say so.
|
|
const parsed = parseSubtaskResult("ignored content", {
|
|
[SUBAGENT_STATUS_KEY]: "failed",
|
|
[SUBAGENT_ERROR_KEY]: "Reached max_turns=150",
|
|
[SUBAGENT_STOP_REASON_KEY]: "turn_capped",
|
|
});
|
|
expect(parsed.status).toBe("failed");
|
|
expect(parsed.error).toBe("Reached max_turns=150");
|
|
expect(parsed.stopReason).toBe("turn_capped");
|
|
});
|
|
|
|
it("ignores an unknown subagent_stop_reason value", () => {
|
|
// An unrecognized stop_reason is dropped so a stale frontend never renders
|
|
// a bogus cap badge.
|
|
const parsed = parseSubtaskResult("ignored content", {
|
|
[SUBAGENT_STATUS_KEY]: "completed",
|
|
[SUBAGENT_STOP_REASON_KEY]: "future_cap_kind",
|
|
});
|
|
expect(parsed.status).toBe("completed");
|
|
expect(parsed.stopReason).toBeUndefined();
|
|
});
|
|
|
|
it("uses subagent_error when supplied", () => {
|
|
const parsed = parseSubtaskResult("ignored content", {
|
|
[SUBAGENT_STATUS_KEY]: "failed",
|
|
[SUBAGENT_ERROR_KEY]: "boom from backend",
|
|
});
|
|
expect(parsed.status).toBe("failed");
|
|
expect(parsed.error).toBe("boom from backend");
|
|
});
|
|
|
|
it("ignores empty / non-string subagent_error", () => {
|
|
const parsed = parseSubtaskResult("ignored content", {
|
|
[SUBAGENT_STATUS_KEY]: "failed",
|
|
[SUBAGENT_ERROR_KEY]: "",
|
|
});
|
|
expect(parsed.status).toBe("failed");
|
|
expect(parsed.error).toBeUndefined();
|
|
});
|
|
|
|
it("ignores terminal-looking content when partial structured metadata is present", () => {
|
|
const parsed = parseSubtaskResult("Task Succeeded. Result: foo", {
|
|
[SUBAGENT_RESULT_BRIEF_KEY]: "structured result without status",
|
|
});
|
|
expect(parsed.status).toBe("in_progress");
|
|
expect(parsed.result).toBeUndefined();
|
|
});
|
|
|
|
it("ignores terminal-looking content when the structured status is unknown", () => {
|
|
const parsed = parseSubtaskResult("Task Succeeded. Result: foo", {
|
|
[SUBAGENT_STATUS_KEY]: "renamed_in_v3",
|
|
});
|
|
expect(parsed.status).toBe("in_progress");
|
|
});
|
|
|
|
it.each(["constructor", "toString", "__proto__"])(
|
|
"keeps prototype-named unknown status %s in progress",
|
|
(status) => {
|
|
expect(
|
|
parseSubtaskResult("Task Succeeded. Result: misleading text", {
|
|
[SUBAGENT_STATUS_KEY]: status,
|
|
[SUBAGENT_RESULT_BRIEF_KEY]: "not a valid completed result",
|
|
[SUBAGENT_ERROR_KEY]: "not a valid failure",
|
|
}),
|
|
).toEqual({ status: "in_progress" });
|
|
},
|
|
);
|
|
|
|
it("structured status overrides misleading content", () => {
|
|
const parsed = parseSubtaskResult("Task Succeeded. Result: this is a lie", {
|
|
[SUBAGENT_STATUS_KEY]: "failed",
|
|
});
|
|
expect(parsed.status).toBe("failed");
|
|
expect(parsed.result).toBeUndefined();
|
|
expect(parsed.error).toBeUndefined();
|
|
});
|
|
|
|
it("does not back-fill result from content when structured result metadata is missing", () => {
|
|
const parsed = parseSubtaskResult("Task Succeeded. Result: text-only", {
|
|
[SUBAGENT_STATUS_KEY]: "completed",
|
|
});
|
|
expect(parsed.status).toBe("completed");
|
|
expect(parsed.result).toBeUndefined();
|
|
});
|
|
|
|
it("uses bounded structured result metadata when present for completed task", () => {
|
|
const parsed = parseSubtaskResult("Task Succeeded. Result: text body", {
|
|
[SUBAGENT_STATUS_KEY]: "completed",
|
|
subagent_result_brief: "structured",
|
|
subagent_result_sha256: "a".repeat(64),
|
|
});
|
|
expect(parsed.status).toBe("completed");
|
|
expect(parsed.result).toBe("structured");
|
|
});
|
|
|
|
it("does not back-fill error from content when structured error metadata is missing", () => {
|
|
const parsed = parseSubtaskResult(
|
|
"Error: Tool 'task' failed with TypeError: boom",
|
|
{
|
|
[SUBAGENT_STATUS_KEY]: "failed",
|
|
},
|
|
);
|
|
expect(parsed.status).toBe("failed");
|
|
expect(parsed.error).toBeUndefined();
|
|
});
|
|
|
|
it("leaves `error` undefined when structured says failed with no error and unrecognised text", () => {
|
|
// Don't dump arbitrary content into the error field — better to render
|
|
// an empty `failed` pill than to surface noise.
|
|
const parsed = parseSubtaskResult("partial streaming chunk", {
|
|
[SUBAGENT_STATUS_KEY]: "failed",
|
|
});
|
|
expect(parsed.status).toBe("failed");
|
|
expect(parsed.error).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Cross-language contract test for the structured subagent status field.
|
|
* The backend and frontend share the enum values, but task result text is
|
|
* no longer part of the wire contract.
|
|
*/
|
|
describe("parseSubtaskResult — shared contract fixture", () => {
|
|
const expectedCardStatus = (backendStatus: string): string => {
|
|
if (backendStatus === "completed") return "completed";
|
|
return "failed";
|
|
};
|
|
|
|
for (const status of CONTRACT.valid_status_values) {
|
|
it(`maps structured status: ${status}`, () => {
|
|
const parsed = parseSubtaskResult("ignored content", {
|
|
[SUBAGENT_STATUS_KEY]: status,
|
|
});
|
|
expect(parsed.status).toBe(expectedCardStatus(status));
|
|
});
|
|
}
|
|
|
|
for (const stopReason of CONTRACT.valid_stop_reason_values) {
|
|
it.each(["completed", "failed"])(
|
|
`carries stop_reason ${stopReason} through unchanged for %s`,
|
|
(status) => {
|
|
const parsed = parseSubtaskResult("ignored content", {
|
|
[SUBAGENT_STATUS_KEY]: status,
|
|
[SUBAGENT_RESULT_BRIEF_KEY]: "partial work",
|
|
[SUBAGENT_STOP_REASON_KEY]: stopReason,
|
|
});
|
|
expect(parsed).toMatchObject({ status, stopReason });
|
|
},
|
|
);
|
|
}
|
|
});
|