Преглед изворни кода

fix: reject agent action hallucinations

Ma пре 6 месеци
родитељ
комит
5c25e0b748

+ 15 - 0
packages/core/src/__tests__/agent-tools.test.ts

@@ -160,6 +160,21 @@ describe("agent deterministic writing tools", () => {
     expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600);
   });
 
+  it("surfaces writer sub-agent pipeline failures as tool errors", async () => {
+    const pipeline = {
+      writeNextChapter: vi.fn(async () => {
+        throw new Error("disk write failed");
+      }),
+    };
+    const tool = createSubAgentTool(pipeline as never, "harbor");
+
+    await expect(tool.execute("tool-writer-fails", {
+      agent: "writer",
+      bookId: "harbor",
+      instruction: "继续写下一章",
+    } as any)).rejects.toThrow("disk write failed");
+  });
+
   it("uses the active book for writer when bookId is omitted", async () => {
     const pipeline = {
       writeNextChapter: vi.fn(async () => ({

+ 9 - 1
packages/core/src/agent/agent-tools.ts

@@ -190,6 +190,14 @@ export function createSubAgentTool(
             return textResult(
               `Chapter written for "${targetBookId}". ` +
               `Word count: ${(result as any).wordCount ?? "unknown"}.`,
+              {
+                kind: "chapter_written",
+                bookId: targetBookId,
+                chapterNumber: (result as any).chapterNumber,
+                title: (result as any).title,
+                wordCount: (result as any).wordCount,
+                status: (result as any).status,
+              },
             );
           }
 
@@ -240,7 +248,7 @@ export function createSubAgentTool(
         }
       } catch (err: any) {
         console.error(`[sub_agent] "${agent}" failed:`, err);
-        return textResult(`Sub-agent "${agent}" failed: ${err?.message ?? String(err)}`);
+        throw err;
       }
     },
   };

+ 52 - 6
packages/studio/src/api/server.test.ts

@@ -230,6 +230,7 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
     listModelsForService: listModelsForServiceMock,
     getAllEndpoints: getAllEndpointsMock,
     probeModelsFromUpstream: probeModelsFromUpstreamMock,
+    fetchWithProxy: vi.fn((input: Parameters<typeof fetch>[0], init?: RequestInit) => fetch(input, init)),
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
   };
 });
@@ -1744,12 +1745,30 @@ describe("createStudioServer daemon lifecycle", () => {
   });
 
   it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
-    runAgentSessionMock.mockResolvedValueOnce({
-      responseText: "Completed write_next for demo-book.",
-      messages: [
-        { role: "user", content: "continue" },
-        { role: "assistant", content: "Completed write_next for demo-book." },
-      ],
+    runAgentSessionMock.mockImplementationOnce(async (config: { onEvent?: (event: unknown) => void }) => {
+      config.onEvent?.({
+        type: "tool_execution_start",
+        toolName: "sub_agent",
+        toolCallId: "tool-writer-1",
+        args: { agent: "writer" },
+      });
+      config.onEvent?.({
+        type: "tool_execution_end",
+        toolName: "sub_agent",
+        toolCallId: "tool-writer-1",
+        isError: false,
+        result: {
+          content: [{ type: "text", text: "Chapter written for demo-book. Word count: 1800." }],
+          details: { kind: "chapter_written", bookId: "demo-book", chapterNumber: 4 },
+        },
+      });
+      return {
+        responseText: "Completed write_next for demo-book.",
+        messages: [
+          { role: "user", content: "continue" },
+          { role: "assistant", content: "Completed write_next for demo-book." },
+        ],
+      };
     });
 
     const { createStudioServer } = await import("./server.js");
@@ -1777,6 +1796,33 @@ describe("createStudioServer daemon lifecycle", () => {
     );
   });
 
+  it("rejects text-only write-next agent responses that never executed the writer tool", async () => {
+    runAgentSessionMock.mockResolvedValueOnce({
+      responseText: "已为 demo-book 完成下一章写作。",
+      messages: [
+        { role: "user", content: "继续" },
+        { role: "assistant", content: "已为 demo-book 完成下一章写作。" },
+      ],
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "继续", activeBookId: "demo-book", sessionId: "agent-session-1" }),
+    });
+
+    expect(response.status).toBe(502);
+    await expect(response.json()).resolves.toMatchObject({
+      error: {
+        code: "AGENT_ACTION_NOT_EXECUTED",
+      },
+      response: expect.stringContaining("没有实际调用写作工具"),
+    });
+  });
+
   it("rejects unsafe activeBookId in the Studio agent API", async () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);

+ 87 - 1
packages/studio/src/api/server.ts

@@ -143,6 +143,65 @@ function extractToolError(result: unknown): string {
   return String(result).slice(0, 500);
 }
 
+function isLikelyFailedToolResult(exec: CollectedToolExec): boolean {
+  if (exec.status === "error") return true;
+  const text = `${exec.error ?? ""}\n${exec.result ?? ""}`.toLowerCase();
+  return /\bfailed\b|\berror\b|失败|异常|出错/.test(text);
+}
+
+function hasSuccessfulSubAgentExec(
+  execs: ReadonlyArray<CollectedToolExec>,
+  agent: string,
+): boolean {
+  return execs.some((exec) =>
+    exec.tool === "sub_agent"
+    && exec.agent === agent
+    && exec.status === "completed"
+    && !isLikelyFailedToolResult(exec)
+  );
+}
+
+function isWriteNextInstruction(instruction: string): boolean {
+  const trimmed = instruction.trim();
+  return /^(continue|继续|继续写|写下一章|write next|下一章|再来一章)$/i.test(trimmed)
+    || /(继续写|写下一章|下一章|再来一章|write\s+next)/i.test(trimmed);
+}
+
+function looksLikeBookCreatedClaim(responseText: string): boolean {
+  return /(?:已|已经|成功).{0,12}(?:创建|建书|初始化|保存).{0,12}(?:作品|书|书籍|文件夹)?/.test(responseText)
+    || /\b(?:created|initiali[sz]ed|saved)\b.{0,40}\b(?:book|project|novel)\b/i.test(responseText);
+}
+
+function validateAgentActionExecution(args: {
+  readonly instruction: string;
+  readonly agentBookId: string | null | undefined;
+  readonly responseText: string;
+  readonly collectedToolExecs: ReadonlyArray<CollectedToolExec>;
+}): string | undefined {
+  const failedExec = args.collectedToolExecs.find(isLikelyFailedToolResult);
+  if (failedExec) {
+    return `${failedExec.label} 执行失败:${failedExec.error ?? failedExec.result ?? "未知错误"}`;
+  }
+
+  if (
+    args.agentBookId
+    && isWriteNextInstruction(args.instruction)
+    && !hasSuccessfulSubAgentExec(args.collectedToolExecs, "writer")
+  ) {
+    return "模型声称已完成下一章,但没有实际调用写作工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
+  }
+
+  if (
+    !args.agentBookId
+    && looksLikeBookCreatedClaim(args.responseText)
+    && !resolveCreatedBookIdFromToolExecs(args.collectedToolExecs)
+  ) {
+    return "模型声称已创建作品,但没有实际调用建书工具,也没有生成作品文件。请补充书名/题材后重试,或换用支持工具调用的模型。";
+  }
+
+  return undefined;
+}
+
 interface CollectedToolExec {
   id: string;
   tool: string;
@@ -780,7 +839,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       model: overrides?.model ?? currentConfig.llm.model,
       projectRoot: root,
       defaultLLMConfig: currentConfig.llm,
-      foundationReviewRetries: currentConfig.foundation.reviewRetries,
+      foundationReviewRetries: currentConfig.foundation?.reviewRetries ?? 2,
       modelOverrides: currentConfig.modelOverrides,
       notifyChannels: currentConfig.notify,
       logger,
@@ -1940,6 +1999,21 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         instruction,
       );
 
+      if (result.responseText) {
+        const actionExecutionError = validateAgentActionExecution({
+          instruction,
+          agentBookId,
+          responseText: result.responseText,
+          collectedToolExecs,
+        });
+        if (actionExecutionError) {
+          return c.json({
+            error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
+            response: actionExecutionError,
+          }, 502);
+        }
+      }
+
       let broadcastedCreatedBookId: string | null = null;
       const finalizeCreatedBook = async (): Promise<string | null> => {
         if (agentBookId) return null;
@@ -2000,6 +2074,18 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             { maxTokens: 256 },
           );
           if (fallback.content?.trim()) {
+            const actionExecutionError = validateAgentActionExecution({
+              instruction,
+              agentBookId,
+              responseText: fallback.content,
+              collectedToolExecs,
+            });
+            if (actionExecutionError) {
+              return c.json({
+                error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
+                response: actionExecutionError,
+              }, 502);
+            }
             await appendManualSessionMessages(root, bookSession.sessionId, [{
               role: "assistant",
               content: [{ type: "text", text: fallback.content }],