test(agent-runner): strengthen local agent e2e gate

This commit is contained in:
huanghuoguoguo
2026-07-01 20:20:43 +08:00
parent b3387eb0a3
commit dff260bbd5
44 changed files with 3524 additions and 137 deletions
+350 -14
View File
@@ -9,7 +9,43 @@ const args = parseArgs(process.argv.slice(2));
const host = args.host || env.LANGBOT_FAKE_PROVIDER_HOST || "127.0.0.1";
const port = integer(args.port ?? env.LANGBOT_FAKE_PROVIDER_PORT, 0);
const stateFile = args["state-file"] || env.LANGBOT_FAKE_PROVIDER_STATE_FILE || "";
const modelName = env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || "gpt-4o-mini";
const modelName = args.model || env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || env.LANGBOT_FAKE_PROVIDER_MODEL || "gpt-4o-mini";
const COMPACTION_SENTINEL = "qa_compaction_sentinel_7391";
const COMBO_SENTINEL = "qa_combo_compaction_sentinel_2406";
const RAG_SENTINEL = "azalea-cobalt-7421";
const COMBO_TOOL_INPUT = "combo-tool-ok-local-agent";
const COMBO_TOOL_RESULT = `qa-plugin-smoke:${COMBO_TOOL_INPUT}`;
const COMBO_FINAL = `COMBO_FINAL ${COMBO_SENTINEL} ${RAG_SENTINEL} ${COMBO_TOOL_RESULT}`;
const MULTITOOL_SENTINEL = "qa_multitool_compaction_sentinel_6718";
const MULTITOOL_TOOL_A_INPUT = "multi-tool-a-local-agent";
const MULTITOOL_TOOL_B_INPUT = "multi-tool-b-local-agent";
const MULTITOOL_TOOL_A_RESULT = `qa-plugin-smoke:${MULTITOOL_TOOL_A_INPUT}`;
const MULTITOOL_TOOL_B_RESULT = `qa-plugin-smoke:${MULTITOOL_TOOL_B_INPUT}`;
const MULTITOOL_FINAL = [
"MULTITOOL_COMBO_FINAL",
MULTITOOL_SENTINEL,
RAG_SENTINEL,
MULTITOOL_TOOL_A_RESULT,
MULTITOOL_TOOL_B_RESULT,
].join(" ");
const PARALLEL_SENTINEL = "qa_parallel_compaction_sentinel_8142";
const PARALLEL_TOOL_A_INPUT = "parallel-tool-a-local-agent";
const PARALLEL_TOOL_B_INPUT = "parallel-tool-b-local-agent";
const PARALLEL_TOOL_A_RESULT = `qa-plugin-smoke:${PARALLEL_TOOL_A_INPUT}`;
const PARALLEL_TOOL_B_RESULT = `qa-plugin-smoke:${PARALLEL_TOOL_B_INPUT}`;
const PARALLEL_FINAL = [
"PARALLEL_COMBO_FINAL",
PARALLEL_SENTINEL,
RAG_SENTINEL,
PARALLEL_TOOL_A_RESULT,
PARALLEL_TOOL_B_RESULT,
].join(" ");
const LOOP_LIMIT_INPUT = "loop-limit-repeat-local-agent";
const TOOL_ERROR_INPUT = "tool-error-recovery-local-agent";
const TOOL_ERROR_FINAL = "TOOL_ERROR_RECOVERY_FINAL qa-plugin-smoke forced failure observed";
const STEERING_FOLLOWUP_SENTINEL = "qa_steering_sentinel_6194";
const STEERING_SLEEP_INPUT = "steering-e2e-anchor";
const STEERING_SLEEP_RESULT = `qa-plugin-smoke:sleep:8:${STEERING_SLEEP_INPUT}`;
const config = {
response_text: env.LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT || "OK",
first_token_delay_ms: integer(env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS, 25),
@@ -88,6 +124,7 @@ const server = createServer(async (request, response) => {
created: 1,
owned_by: "langbot-qa",
type: "llm",
context_length: 8192,
},
],
});
@@ -100,7 +137,8 @@ const server = createServer(async (request, response) => {
const requestId = `chatcmpl-langbot-fake-${requestCount}`;
const shouldFail = requestCount <= config.fail_first_n
|| (config.fail_every_n > 0 && requestCount % config.fail_every_n === 0);
const replyText = responseTextForBody(body);
const replyMessage = buildResponse(body);
const replyText = replyMessage.content || "";
requestRecord = recordRequest({
id: requestId,
request_number: requestCount,
@@ -140,7 +178,7 @@ const server = createServer(async (request, response) => {
await streamCompletion(response, {
requestId,
model: body.model || modelName,
content: replyText,
message: replyMessage,
failAfterFirstChunk: config.fail_after_first_chunk,
requestRecord,
startedPerf,
@@ -150,7 +188,7 @@ const server = createServer(async (request, response) => {
sendJson(response, 200, completionPayload({
requestId,
model: body.model || modelName,
content: replyText,
message: replyMessage,
}));
markRequestTiming(requestRecord, "first_chunk", startedPerf);
markRequestTiming(requestRecord, "first_content_chunk", startedPerf);
@@ -270,8 +308,8 @@ function sendJson(response, status, payload) {
response.end(text);
}
function completionPayload({ requestId, model, content }) {
const completionTokens = tokenEstimate(content);
function completionPayload({ requestId, model, message }) {
const completionTokens = tokenEstimate(message.content || JSON.stringify(message.tool_calls || []));
return {
id: requestId,
object: "chat.completion",
@@ -280,11 +318,8 @@ function completionPayload({ requestId, model, content }) {
choices: [
{
index: 0,
message: {
role: "assistant",
content,
},
finish_reason: "stop",
message,
finish_reason: message.tool_calls?.length ? "tool_calls" : "stop",
},
],
usage: {
@@ -298,7 +333,7 @@ function completionPayload({ requestId, model, content }) {
async function streamCompletion(response, {
requestId,
model,
content,
message,
failAfterFirstChunk: failMidStream,
requestRecord,
startedPerf,
@@ -319,7 +354,56 @@ async function streamCompletion(response, {
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
const chunks = splitContent(content);
const chunks = message.tool_calls?.length ? [] : splitContent(message.content || "");
if (message.tool_calls?.length) {
await sleep(config.chunk_delay_ms);
markRequestTiming(requestRecord, "first_content_chunk", startedPerf);
requestRecord.content_chunk_count = (requestRecord.content_chunk_count || 0) + 1;
writeSse(response, {
id: requestId,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
delta: {
tool_calls: message.tool_calls.map((call, index) => ({
index,
id: call.id,
type: call.type,
function: {
name: call.function.name,
arguments: call.function.arguments,
},
})),
},
finish_reason: null,
},
],
});
await sleep(config.chunk_delay_ms);
writeSse(response, {
id: requestId,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
usage: {
prompt_tokens: 8,
completion_tokens: tokenEstimate(JSON.stringify(message.tool_calls)),
total_tokens: 8 + tokenEstimate(JSON.stringify(message.tool_calls)),
},
});
response.write("data: [DONE]\n\n");
response.end();
finishRequestRecord(requestRecord, startedPerf, {
status: "ok",
http_status: 200,
});
return;
}
for (let index = 0; index < chunks.length; index += 1) {
await sleep(config.chunk_delay_ms);
if (index === 0) markRequestTiming(requestRecord, "first_content_chunk", startedPerf);
@@ -342,7 +426,7 @@ async function streamCompletion(response, {
}
await sleep(config.chunk_delay_ms);
const completionTokens = tokenEstimate(content);
const completionTokens = tokenEstimate(message.content || "");
writeSse(response, {
id: requestId,
object: "chat.completion.chunk",
@@ -399,6 +483,258 @@ function responseTextForBody(body) {
return config.response_text;
}
function messageText(messages = []) {
return messages
.map((message) => {
const parts = [contentText(message?.content)];
if (Array.isArray(message?.tool_calls)) {
parts.push(
message.tool_calls
.map((call) => `${call?.function?.name || call?.name || ""} ${call?.function?.arguments || ""}`)
.join("\n"),
);
}
return parts.filter(Boolean).join("\n");
})
.join("\n");
}
function contentText(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) return content.map((part) => contentText(part)).join("");
if (content && typeof content === "object") {
for (const key of ["text", "content", "message", "error", "value"]) {
if (content[key] !== undefined && content[key] !== null) {
return contentText(content[key]);
}
}
return JSON.stringify(content);
}
return "";
}
function latestUserText(messages = []) {
const latest = [...messages].reverse().find((message) => message?.role === "user");
return contentText(latest?.content);
}
function toolNames(tools = []) {
return tools
.map((tool) => tool?.function?.name || tool?.name || "")
.filter(Boolean);
}
function firstToolName(tools = [], candidates = []) {
const names = toolNames(tools);
return candidates.find((candidate) => names.includes(candidate)) || "";
}
function isSummarizationRequest(messages = []) {
const text = messageText(messages);
return /context summarization assistant|conversation to summarize|<previous-summary>/i.test(text);
}
function buildSummaryResponse(text) {
const references = [];
if (text.includes(COMPACTION_SENTINEL)) references.push(COMPACTION_SENTINEL);
if (text.includes(COMBO_SENTINEL)) references.push(COMBO_SENTINEL);
if (text.includes(MULTITOOL_SENTINEL)) references.push(MULTITOOL_SENTINEL);
if (text.includes(PARALLEL_SENTINEL)) references.push(PARALLEL_SENTINEL);
if (/RAG_TOOL_COMBO_GOAL/i.test(text)) references.push("RAG_TOOL_COMBO_GOAL");
if (/MULTITOOL_RAG_GOAL/i.test(text)) references.push("MULTITOOL_RAG_GOAL");
if (/PARALLEL_RAG_GOAL/i.test(text)) references.push("PARALLEL_RAG_GOAL");
const critical = references.length
? references.map((reference) => `- ${reference}`).join("\n")
: "- (none)";
return {
role: "assistant",
content: [
"## Goal",
"Preserve deterministic LangBot E2E context across compaction.",
"",
"## Constraints & Preferences",
"- Keep exact sentinel values verbatim.",
"",
"## Progress",
"### Done",
"- [x] Earlier conversation was compacted by the fake provider.",
"",
"### In Progress",
"- [ ] Continue the current Debug Chat run.",
"",
"### Blocked",
"- (none)",
"",
"## Key Decisions",
"- **Deterministic QA**: Return only known sentinel values from compacted input.",
"",
"## Next Steps",
"1. Answer the current user request using preserved context.",
"",
"## Critical Context",
critical,
].join("\n"),
};
}
function buildResponse(payload) {
const text = messageText(payload.messages || []);
const current = latestUserText(payload.messages || []);
const tools = payload.tools || [];
if (isSummarizationRequest(payload.messages || [])) {
return buildSummaryResponse(text);
}
const pluginTool = firstToolName(tools, ["qa_plugin_echo"]);
const pluginFailTool = firstToolName(tools, ["qa_plugin_fail"]);
const pluginSleepTool = firstToolName(tools, ["qa_plugin_sleep"]);
const mcpTool = firstToolName(tools, ["qa_mcp_echo"]);
if (/STEERING_NO_FOLLOWUP|qa_plugin_sleep|steering-e2e-anchor|qa_steering_sentinel_6194/i.test(current || text)) {
if (text.includes(STEERING_FOLLOWUP_SENTINEL) && text.includes(STEERING_SLEEP_RESULT)) {
return { role: "assistant", content: STEERING_FOLLOWUP_SENTINEL };
}
if (text.includes(STEERING_SLEEP_RESULT) && !text.includes(STEERING_FOLLOWUP_SENTINEL)) {
return { role: "assistant", content: "STEERING_NO_FOLLOWUP" };
}
if (pluginSleepTool && !text.includes(STEERING_SLEEP_RESULT)) {
return toolCall("call_qa_plugin_sleep_steering", pluginSleepTool, { seconds: 8, text: STEERING_SLEEP_INPUT });
}
}
if (/测试暗号是什么|original sentinel|first.*sentinel/i.test(current)) {
return {
role: "assistant",
content: text.includes(COMPACTION_SENTINEL) ? COMPACTION_SENTINEL : "COMPACTION_SENTINEL_MISSING",
};
}
if ((current.includes(COMPACTION_SENTINEL)
|| current.includes(COMBO_SENTINEL)
|| current.includes(MULTITOOL_SENTINEL)
|| current.includes(PARALLEL_SENTINEL))
&& /请只回复 MEMORY_SET|only reply MEMORY_SET/i.test(current)) {
return { role: "assistant", content: "MEMORY_SET" };
}
if (/PARALLEL_CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "PARALLEL_CONTEXT_PRESSURE_READY" };
if (/MULTITOOL_CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "MULTITOOL_CONTEXT_PRESSURE_READY" };
if (/COMBO_CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "COMBO_CONTEXT_PRESSURE_READY" };
if (/CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "CONTEXT_PRESSURE_READY" };
if (/qa_mcp_echo:mcp-ok-local-agent/.test(text)) return { role: "assistant", content: "qa_mcp_echo:mcp-ok-local-agent" };
if (/qa_mcp_echo|mcp-ok-local-agent/i.test(current || text) && mcpTool && !/qa_mcp_echo:mcp-ok-local-agent/.test(text)) {
return toolCall("call_qa_mcp_echo", mcpTool, { text: "mcp-ok-local-agent" });
}
if (/LOOP_LIMIT|loop-limit-repeat-local-agent|iteration limit/i.test(current || text) && pluginTool) {
return toolCall(`call_qa_plugin_echo_loop_limit_${Date.now()}`, pluginTool, { text: LOOP_LIMIT_INPUT });
}
if (/TOOL_ERROR_RECOVERY|tool-error-recovery-local-agent|qa_plugin_fail/i.test(current || text)) {
if (/(?:Error:|Tool execution failed:|ActionCallError:|RuntimeError:)/i.test(text)
&& /(?:qa-plugin-smoke forced failure|qa_plugin_fail|tool-error-recovery-local-agent)/i.test(text)) {
return { role: "assistant", content: TOOL_ERROR_FINAL };
}
if (pluginFailTool) {
return toolCall("call_qa_plugin_fail_recovery", pluginFailTool, { text: TOOL_ERROR_INPUT });
}
}
const isParallelComboRequest = /PARALLEL_COMBO|parallel-tool-a-local-agent|parallel-tool-b-local-agent|PARALLEL_RAG_GOAL/i.test(current || text);
if (isParallelComboRequest && pluginTool && !text.includes(PARALLEL_TOOL_A_RESULT) && !text.includes(PARALLEL_TOOL_B_RESULT)) {
return toolCalls([
["call_qa_plugin_echo_parallel_a", pluginTool, { text: PARALLEL_TOOL_A_INPUT }],
["call_qa_plugin_echo_parallel_b", pluginTool, { text: PARALLEL_TOOL_B_INPUT }],
]);
}
if (isParallelComboRequest && (text.includes(PARALLEL_TOOL_A_RESULT) || text.includes(PARALLEL_TOOL_B_RESULT))) {
return missingOrFinal(text, [
[PARALLEL_SENTINEL, "parallel-memory"],
[RAG_SENTINEL, "rag"],
[PARALLEL_TOOL_A_RESULT, "tool-a"],
[PARALLEL_TOOL_B_RESULT, "tool-b"],
], "PARALLEL_COMBO_MISSING", PARALLEL_FINAL);
}
const isMultiToolComboRequest = /MULTITOOL_COMBO|multi-tool-a-local-agent|multi-tool-b-local-agent|MULTITOOL_RAG_GOAL/i.test(current || text);
if (isMultiToolComboRequest && pluginTool && !text.includes(MULTITOOL_TOOL_A_RESULT)) {
return toolCall("call_qa_plugin_echo_multi_a", pluginTool, { text: MULTITOOL_TOOL_A_INPUT });
}
if (isMultiToolComboRequest && pluginTool && text.includes(MULTITOOL_TOOL_A_RESULT) && !text.includes(MULTITOOL_TOOL_B_RESULT)) {
return toolCall("call_qa_plugin_echo_multi_b", pluginTool, { text: MULTITOOL_TOOL_B_INPUT });
}
if (isMultiToolComboRequest && text.includes(MULTITOOL_TOOL_A_RESULT) && text.includes(MULTITOOL_TOOL_B_RESULT)) {
return missingOrFinal(text, [
[MULTITOOL_SENTINEL, "multi-memory"],
[RAG_SENTINEL, "rag"],
[MULTITOOL_TOOL_A_RESULT, "tool-a"],
[MULTITOOL_TOOL_B_RESULT, "tool-b"],
], "MULTITOOL_COMBO_MISSING", MULTITOOL_FINAL);
}
const isComboRequest = /qa_combo|\bCOMBO_FINAL\b|combo-tool-ok-local-agent|RAG_TOOL_COMBO_GOAL/i.test(current || text);
if (isComboRequest && pluginTool && !text.includes(COMBO_TOOL_RESULT)) {
return toolCall("call_qa_plugin_echo_combo", pluginTool, { text: COMBO_TOOL_INPUT });
}
if (isComboRequest && text.includes(COMBO_TOOL_RESULT)) {
return missingOrFinal(text, [
[COMBO_SENTINEL, "combo-memory"],
[RAG_SENTINEL, "rag"],
[COMBO_TOOL_RESULT, "tool-result"],
], "COMBO_MISSING", COMBO_FINAL);
}
if (/qa-plugin-smoke:plugin-tool-ok-local-agent/.test(text)) {
return { role: "assistant", content: "qa-plugin-smoke:plugin-tool-ok-local-agent" };
}
if (/qa_plugin_echo|plugin-tool-ok-local-agent/i.test(current || text) && pluginTool && !/qa-plugin-smoke:plugin-tool-ok-local-agent/.test(text)) {
return toolCall("call_qa_plugin_echo", pluginTool, { text: "plugin-tool-ok-local-agent" });
}
const e2eTool = firstToolName(tools, ["e2e_lookup"]);
if (/Use the e2e lookup tool|e2e lookup tool|tool loop/i.test(current || text) && e2eTool && !/tool-result:alpha/.test(text)) {
return toolCall("call_e2e_lookup", e2eTool, { query: "alpha" });
}
if (/tool-result:alpha/.test(text)) return { role: "assistant", content: "Tool loop final answer after tool-result:alpha" };
if (/RAG_SENTINEL/.test(text)) return { role: "assistant", content: "RAG final answer with RAG_SENTINEL" };
if (/NONSTREAM_OK/i.test(current || text)) return { role: "assistant", content: "NONSTREAM_OK" };
if (/IMAGE_OK/i.test(current || text)
&& /(?:\[Image\]|langbot_input_attachments|data:image\/|\"type\":\s*\"image\")/i.test(current || text)) {
return { role: "assistant", content: "IMAGE_OK" };
}
if (/azalea-cobalt-7421/.test(text)) return { role: "assistant", content: "azalea-cobalt-7421" };
return { role: "assistant", content: responseTextForBody(payload) };
}
function toolCall(id, name, args) {
return toolCalls([[id, name, args]]);
}
function toolCalls(calls) {
return {
role: "assistant",
content: "",
tool_calls: calls.map(([id, name, args]) => ({
id,
type: "function",
function: {
name,
arguments: JSON.stringify(args),
},
})),
};
}
function missingOrFinal(text, requirements, prefix, finalText) {
const missing = requirements
.filter(([needle]) => !text.includes(needle))
.map(([, label]) => label);
return {
role: "assistant",
content: missing.length ? `${prefix}_${missing.join("_")}` : finalText,
};
}
function flattenContent(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {