diff --git a/hub/.env.example b/hub/.env.example index 5caf51c..c074f57 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -20,9 +20,9 @@ DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm" # Alpha Silo safety limits. max turns may use its default; every other value is # mandatory in production and should be calibrated on the target host. -# HUB_AGENT_MAX_TURNS=25 +HUB_AGENT_MAX_TURNS="150" HUB_AGENT_MAX_CONCURRENT_RUNS="1" -HUB_AGENT_MAX_RUN_SECONDS="900" +HUB_AGENT_MAX_RUN_SECONDS="1800" HUB_HTTP_BODY_LIMIT_BYTES="1048576" HUB_MAX_FILES_PER_MESSAGE="20" HUB_MAX_FILE_BYTES="26214400" diff --git a/hub/deploy/install_service.sh b/hub/deploy/install_service.sh index 3b2e31c..53d321d 100755 --- a/hub/deploy/install_service.sh +++ b/hub/deploy/install_service.sh @@ -169,7 +169,7 @@ PORT=$PORT HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT HUB_PUBLIC_BASE_URL= HUB_SESSION_SECRET= -HUB_AGENT_MAX_TURNS=25 +HUB_AGENT_MAX_TURNS=150 HUB_AGENT_MAX_CONCURRENT_RUNS= HUB_AGENT_MAX_RUN_SECONDS= HUB_HTTP_BODY_LIMIT_BYTES= diff --git a/hub/deploy/new_silo.sh b/hub/deploy/new_silo.sh index ea824c1..47383c4 100755 --- a/hub/deploy/new_silo.sh +++ b/hub/deploy/new_silo.sh @@ -367,9 +367,9 @@ seed_default PROVIDER_BASE_URL "https://openrouter.ai/api" seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5" seed_default DEFAULT_ROLE_ID "draft" seed_default DEFAULT_ROLE_LABEL "智能助手" -seed_default MAX_TURNS "25" +seed_default MAX_TURNS "150" seed_default MAX_CONCURRENT_RUNS "4" -seed_default MAX_RUN_SECONDS "900" +seed_default MAX_RUN_SECONDS "1800" seed_default HTTP_BODY_LIMIT_BYTES "1048576" seed_default MAX_FILES_PER_MESSAGE "20" seed_default MAX_FILE_BYTES "26214400" diff --git a/hub/package-lock.json b/hub/package-lock.json index 2e36f8b..8307efe 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.37", + "version": "0.0.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.37", + "version": "0.0.38", "dependencies": { "@alicloud/credentials": "^2.4.5", "@alicloud/docmind-api20220711": "^1.4.15", diff --git a/hub/package.json b/hub/package.json index bdd4f18..f3ef068 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.37", + "version": "0.0.38", "private": true, "type": "module", "engines": { diff --git a/hub/src/feishu/card/streaming-card.ts b/hub/src/feishu/card/streaming-card.ts index 3ab5473..9719e63 100644 --- a/hub/src/feishu/card/streaming-card.ts +++ b/hub/src/feishu/card/streaming-card.ts @@ -129,11 +129,16 @@ export class StreamingAgentCard { async finish( fallbackText: string, - options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}, + options: { + readonly interrupted?: boolean; + readonly footerText?: string | undefined; + readonly isError?: boolean; + } = {}, ): Promise { await this.flushChain; this.interrupted = options.interrupted === true; const footerText = options.footerText ?? ""; + const isError = options.isError === true; const fallbackWithFooter = appendFooter(fallbackText, footerText); try { let answerText = @@ -155,11 +160,10 @@ export class StreamingAgentCard { let updated = true; if (answerText.length > 0 || segments.length > 0) { - updated = await this.flushCard("complete", answerText, false, segments); + updated = await this.flushCard("complete", answerText, isError, segments); } else if (this.currentMessageId !== null) { - updated = await this.flushCard("complete", "", false, []); + updated = await this.flushCard("complete", "", isError, []); } - if (!updated) { // Card path failed (e.g. residual content policy). Deliver text + standalone images. updated = await this.deliverPlainFallback(segments, answerText); diff --git a/hub/src/feishu/runOutcomeNotice.ts b/hub/src/feishu/runOutcomeNotice.ts new file mode 100644 index 0000000..556e283 --- /dev/null +++ b/hub/src/feishu/runOutcomeNotice.ts @@ -0,0 +1,106 @@ +/** + * User-visible run termination copy for Feishu teachers. + * Keep messages short, actionable, and free of stack traces. + */ + +export interface RunOutcomeNoticeInput { + readonly wallTimeExceeded: boolean; + readonly interrupted: boolean; + readonly resultStatus: string; + readonly resultError: string | undefined; + readonly maxTurns: number; + readonly maxRunSeconds: number; + readonly hasPartialText: boolean; +} + +export interface RunOutcomeNotice { + /** Mark the streaming card as failed (red footer). */ + readonly isError: boolean; + /** + * Teacher-facing explanation. Appended after any partial answer text so the + * cause of a stop is never silent. + */ + readonly notice: string | undefined; +} + +export function teacherFacingRunOutcome(input: RunOutcomeNoticeInput): RunOutcomeNotice { + if (input.wallTimeExceeded) { + return { + isError: true, + notice: noticeLine( + input.hasPartialText, + `\u23F1 \u4EFB\u52A1\u8D85\u65F6\uFF1A\u5DF2\u8FBE\u5230\u5355\u6B21\u8FD0\u884C\u65F6\u95F4\u4E0A\u9650\uFF08${input.maxRunSeconds} \u79D2\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u91CD\u8BD5\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u8FD0\u884C\u65F6\u957F\u4E0A\u9650\u3002`, + ), + }; + } + + if (input.interrupted) { + return { isError: false, notice: undefined }; + } + + if (input.resultStatus === "completed") { + return { isError: false, notice: undefined }; + } + + const err = input.resultError ?? ""; + if (isMaxTurnsError(err) || input.resultStatus === "length") { + return { + isError: true, + notice: noticeLine( + input.hasPartialText, + `\u26A0\uFE0F \u4EFB\u52A1\u4E2D\u65AD\uFF1A\u5DF2\u8FBE\u5230\u6700\u5927\u6B65\u9AA4\u6570\uFF08${input.maxTurns} \u8F6E\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u7EE7\u7EED\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u6B65\u9AA4\u4E0A\u9650\u3002`, + ), + }; + } + + if (err.trim() !== "") { + const brief = sanitizeErrorBrief(err); + return { + isError: true, + notice: noticeLine( + input.hasPartialText, + `\u274C \u4EFB\u52A1\u5931\u8D25\uFF1A${brief}\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002`, + ), + }; + } + + return { + isError: true, + notice: noticeLine( + input.hasPartialText, + "\u274C \u4EFB\u52A1\u672A\u6B63\u5E38\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002", + ), + }; +} + +export function appendTeacherNotice(body: string, notice: string | undefined): string { + if (notice === undefined || notice === "") return body; + if (body.trim() === "") return notice; + return `${body.trimEnd()}\n\n${notice}`; +} + +export function isMaxTurnsError(error: string): boolean { + const lower = error.toLowerCase(); + return ( + lower.includes("maximum number of turns") || + lower.includes("max_turns") || + lower.includes("error_max_turns") || + lower.includes("result_error_max_turns") || + (lower.includes("result_error_during_execution") && lower.includes("turn")) + ); +} + +function noticeLine(hasPartialText: boolean, message: string): string { + if (!hasPartialText) return message; + return `${message}\n\uFF08\u4E0A\u65B9\u4E3A\u5DF2\u751F\u6210\u7684\u90E8\u5206\u7ED3\u679C\u3002\uFF09`; +} + +function sanitizeErrorBrief(error: string): string { + const oneLine = error.replace(/\s+/g, " ").trim(); + // Drop common SDK prefixes for readability. + const stripped = oneLine + .replace(/^Claude Code returned an error result:\s*/i, "") + .replace(/^Error:\s*/i, ""); + if (stripped.length <= 160) return stripped; + return `${stripped.slice(0, 157)}...`; +} diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index ff26ce4..4647315 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -40,6 +40,7 @@ import { createAgentSdkStderrSink } from "../agent/diagnostics.js"; import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js"; import { StreamingAgentCard } from "./card/streaming-card.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; +import { appendTeacherNotice, teacherFacingRunOutcome } from "./runOutcomeNotice.js"; import { readFeishuContext } from "./read.js"; import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js"; import { ApprovalManager } from "./approval.js"; @@ -598,13 +599,24 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { agentExecution .then(async (result) => { const interrupted = result.status === "interrupted" && !wallTimeExceeded; - const finalText = + const hasPartialText = result.text.trim() !== ""; + const outcome = teacherFacingRunOutcome({ + wallTimeExceeded, + interrupted, + resultStatus: result.status, + resultError: result.error, + maxTurns: runPolicy.maxTurns, + maxRunSeconds: runPolicy.maxRunSeconds, + hasPartialText, + }); + const baseText = result.text !== "" ? result.text - : result.status === "failed" && result.error !== undefined + : result.status === "failed" && result.error !== undefined && outcome.notice === undefined ? `\u5904\u7406\u5931\u8D25: ${result.error}` : result.text; - await card.finish(finalText, { interrupted }); + const finalText = appendTeacherNotice(baseText, outcome.notice); + await card.finish(finalText, { interrupted, isError: outcome.isError }); const metadataPatch = sessionMetadataPatch(result.sdkSessionId); if (metadataPatch !== null) { await deps.prisma.agentSession.update({ @@ -675,7 +687,20 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { if (removedProcessingReaction) { await addReaction(rt, msg.message_id, "CrossMark"); } - await card.fail(e instanceof Error ? e.message : String(e)); + await card.fail( + appendTeacherNotice( + "", + teacherFacingRunOutcome({ + wallTimeExceeded: false, + interrupted: false, + resultStatus: "failed", + resultError: e instanceof Error ? e.message : String(e), + maxTurns: runPolicy.maxTurns, + maxRunSeconds: runPolicy.maxRunSeconds, + hasPartialText: false, + }).notice ?? `\u274C \u4EFB\u52A1\u5931\u8D25\uFF1A${e instanceof Error ? e.message : String(e)}`, + ), + ); try { await deps.prisma.agentRun.update({ where: { id: run.id }, diff --git a/hub/src/hub.ts b/hub/src/hub.ts index c221ce4..b497f16 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -1,7 +1,7 @@ import Fastify from "fastify"; import { registerAdminPlugin } from "./admin/plugin.js"; import { prisma } from "./db.js"; -import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; +import { createLarkClient, sendText, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js"; import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js"; import { makeTriggerHandler } from "./feishu/trigger.js"; import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js"; @@ -118,15 +118,22 @@ export async function startHub(): Promise { const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788"; const bind = readServerBinding(); - // Startup reset: clear stale locks + mark dead runs as FAILED. - await prisma.projectAgentLock.deleteMany({}); - await prisma.agentRun.updateMany({ + // Startup reset: clear stale locks + mark dead runs as FAILED. Capture the + // killed runs first so we can tell their Feishu chats after the listener is up. + const interruptedRuns = await prisma.agentRun.findMany({ where: { status: "ACTIVE" }, - data: { status: "FAILED", error: "process restart", finishedAt: new Date() }, + select: { id: true, projectId: true }, }); - app.log.info("startup: cleared stale locks + dead runs"); + await prisma.projectAgentLock.deleteMany({}); + if (interruptedRuns.length > 0) { + await prisma.agentRun.updateMany({ + where: { id: { in: interruptedRuns.map((run) => run.id) } }, + data: { status: "FAILED", error: "process restart", finishedAt: new Date() }, + }); + } + app.log.info({ killedRuns: interruptedRuns.length }, "startup: cleared stale locks + dead runs"); - let feishuRuntime: { readonly isListenerReady?: () => boolean } | undefined; + let feishuRuntime: FeishuRuntime | undefined; app.get("/api/healthz", async (_request, reply) => { const feishuReady = feishuRuntime?.isListenerReady?.() ?? !booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true); if (!feishuReady) return reply.status(503).send({ ok: false, feishuReady, ts: Date.now() }); @@ -171,7 +178,7 @@ export async function startHub(): Promise { allowLegacyFeishuIdentity: false, maxFeishuEventsPerMinute: feishuEventsPerMinute, }); - feishuRuntime = await startFeishuListenerWithClient( + const runtime = await startFeishuListenerWithClient( feishuConfig, larkClient, app.log, @@ -186,6 +193,8 @@ export async function startHub(): Promise { app.log.info({ ...event, archived: result.archived, projectId: result.projectId }, "feishu binding lifecycle event handled"); }, ); + feishuRuntime = runtime; + await notifyBoundChatsOfInterruptedRuns(runtime, prisma, interruptedRuns, app.log); } else { app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); } @@ -193,6 +202,39 @@ export async function startHub(): Promise { app.log.info({ address }, "hub listening"); } +async function notifyBoundChatsOfInterruptedRuns( + rt: FeishuRuntime, + db: typeof prisma, + interruptedRuns: ReadonlyArray<{ readonly id: string; readonly projectId: string }>, + logger: { info: (obj: unknown, msg?: string) => void; warn: (obj: unknown, msg?: string) => void }, +): Promise { + if (interruptedRuns.length === 0) return; + const byProject = new Map(); + for (const run of interruptedRuns) { + const list = byProject.get(run.projectId) ?? []; + list.push(run.id); + byProject.set(run.projectId, list); + } + for (const [projectId, runIds] of byProject) { + const binding = await db.projectGroupBinding.findFirst({ + where: { projectId, archivedAt: null }, + select: { chatId: true }, + }); + if (binding === null) continue; + const n = runIds.length; + const text = + n === 1 + ? `\u26A0\uFE0F \u670D\u52A1\u521A\u521A\u91CD\u542F\uFF0C\u4E0A\u4E00\u4E2A\u6B63\u5728\u8FDB\u884C\u7684\u4EFB\u52A1\u88AB\u4E2D\u65AD\u4E86\u3002\u8BF7\u91CD\u65B0\u53D1\u8D77\u8BF7\u6C42\uFF08run: ${runIds[0]}\uFF09\u3002` + : `\u26A0\uFE0F \u670D\u52A1\u521A\u521A\u91CD\u542F\uFF0C${n} \u4E2A\u6B63\u5728\u8FDB\u884C\u7684\u4EFB\u52A1\u88AB\u4E2D\u65AD\u4E86\u3002\u8BF7\u91CD\u65B0\u53D1\u8D77\u8BF7\u6C42\u3002`; + try { + await sendText(rt, binding.chatId, text); + logger.info({ projectId, chatId: binding.chatId, runIds }, "startup: notified chat of interrupted runs"); + } catch (error) { + logger.warn({ projectId, chatId: binding.chatId, err: String(error) }, "startup: failed to notify chat of interrupted runs"); + } + } +} + function positiveIntegerEnv(name: string): number { const raw = requireEnv(name); const value = Number(raw); diff --git a/hub/src/settings/runtime.ts b/hub/src/settings/runtime.ts index 015b357..d424574 100644 --- a/hub/src/settings/runtime.ts +++ b/hub/src/settings/runtime.ts @@ -11,9 +11,9 @@ type EnvSource = Env | (() => Env); const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5"; const DEFAULT_SONNET_LABEL = "Claude Sonnet 5"; -const DEFAULT_AGENT_MAX_TURNS = 25; +const DEFAULT_AGENT_MAX_TURNS = 150; const DEFAULT_AGENT_MAX_CONCURRENT_RUNS = 1; -const DEFAULT_AGENT_MAX_RUN_SECONDS = 900; +const DEFAULT_AGENT_MAX_RUN_SECONDS = 1800; export interface ProviderRuntimeSettings { readonly id: string; diff --git a/hub/test/unit/run-outcome-notice.test.ts b/hub/test/unit/run-outcome-notice.test.ts new file mode 100644 index 0000000..84f7bbb --- /dev/null +++ b/hub/test/unit/run-outcome-notice.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + appendTeacherNotice, + isMaxTurnsError, + teacherFacingRunOutcome, +} from "../../src/feishu/runOutcomeNotice.js"; + +describe("teacherFacingRunOutcome", () => { + it("explains max-turn stops with the configured ceiling", () => { + const outcome = teacherFacingRunOutcome({ + wallTimeExceeded: false, + interrupted: false, + resultStatus: "failed", + resultError: "Claude Code returned an error result: Reached maximum number of turns (25)", + maxTurns: 150, + maxRunSeconds: 1800, + hasPartialText: true, + }); + expect(outcome.isError).toBe(true); + expect(outcome.notice).toContain("150"); + expect(outcome.notice).toContain("最大步骤数"); + expect(outcome.notice).toContain("部分结果"); + }); + + it("explains wall-clock timeouts", () => { + const outcome = teacherFacingRunOutcome({ + wallTimeExceeded: true, + interrupted: false, + resultStatus: "interrupted", + resultError: undefined, + maxTurns: 150, + maxRunSeconds: 1800, + hasPartialText: false, + }); + expect(outcome.isError).toBe(true); + expect(outcome.notice).toContain("1800"); + expect(outcome.notice).toContain("超时"); + }); + + it("is quiet on successful completion", () => { + expect( + teacherFacingRunOutcome({ + wallTimeExceeded: false, + interrupted: false, + resultStatus: "completed", + resultError: undefined, + maxTurns: 150, + maxRunSeconds: 1800, + hasPartialText: true, + }), + ).toEqual({ isError: false, notice: undefined }); + }); + + it("appendTeacherNotice joins body and notice", () => { + expect(appendTeacherNotice("hello", "bye")).toBe("hello\n\nbye"); + expect(appendTeacherNotice("", "only")).toBe("only"); + }); + + it("detects max-turn SDK wording", () => { + expect(isMaxTurnsError("Reached maximum number of turns (25)")).toBe(true); + expect(isMaxTurnsError("result_error_max_turns")).toBe(true); + expect(isMaxTurnsError("network glitch")).toBe(false); + }); +});