feat(hub): raise agent limits + teacher failure notices (v0.0.38) (#17)

This commit is contained in:
2026-07-20 20:07:47 +08:00
11 changed files with 267 additions and 26 deletions
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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=
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.37",
"version": "0.0.38",
"private": true,
"type": "module",
"engines": {
+8 -4
View File
@@ -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<void> {
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);
+106
View File
@@ -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)}...`;
}
+29 -4
View File
@@ -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 },
+50 -8
View File
@@ -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<void> {
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<void> {
allowLegacyFeishuIdentity: false,
maxFeishuEventsPerMinute: feishuEventsPerMinute,
});
feishuRuntime = await startFeishuListenerWithClient(
const runtime = await startFeishuListenerWithClient(
feishuConfig,
larkClient,
app.log,
@@ -186,6 +193,8 @@ export async function startHub(): Promise<void> {
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<void> {
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<void> {
if (interruptedRuns.length === 0) return;
const byProject = new Map<string, string[]>();
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);
+2 -2
View File
@@ -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;
+64
View File
@@ -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);
});
});