forked from bai/curriculum-project-hub
Merge remote-tracking branch 'educraft/main' into merge/educraft-cph
# Conflicts: # .gitignore # hub/.env.example # hub/deploy/deploy_fleet_release.sh # hub/deploy/deploy_platform.sh # hub/test/integration/helpers.ts
This commit is contained in:
@@ -15,6 +15,10 @@
|
||||
* `commitSkillContent`, so the web path and CLI path share one ingestion
|
||||
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
|
||||
* / 16-byte limits, symlink rejection).
|
||||
*
|
||||
* Folder tree (ADR-0028): one org-scoped transparent folder tree shared by
|
||||
* roles and skills for management-surface grouping. Folder endpoints never
|
||||
* touch session state — assignment is a label-class change (ADR-0017).
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
@@ -243,4 +247,121 @@ export async function registerAgentConfigRoutes(
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- ADR-0028 shared agent-config folder tree (transparent grouping) ---
|
||||
|
||||
app.get("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const folders = await agentConfig.listFolders({ organizationId: auth.organization.id });
|
||||
return { folders };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { name?: unknown; parentId?: unknown };
|
||||
if (typeof body.name !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "name is required" },
|
||||
});
|
||||
}
|
||||
const folder = await agentConfig.createFolder({
|
||||
organizationId: auth.organization.id,
|
||||
name: body.name,
|
||||
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
|
||||
});
|
||||
return reply.status(201).send(folder);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { name?: unknown; parentId?: unknown };
|
||||
const folder = await agentConfig.updateFolder({
|
||||
organizationId: auth.organization.id,
|
||||
folderId,
|
||||
...(typeof body.name === "string" ? { name: body.name } : {}),
|
||||
...(body.parentId === null || typeof body.parentId === "string"
|
||||
? { parentId: body.parentId as string | null }
|
||||
: {}),
|
||||
});
|
||||
return folder;
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
await agentConfig.deleteFolder({
|
||||
organizationId: auth.organization.id,
|
||||
folderId,
|
||||
});
|
||||
return { deleted: true };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Folder assignment is a label-class change (ADR-0017): these endpoints
|
||||
// never archive Agent sessions (ADR-0028).
|
||||
app.patch("/api/org/:orgSlug/agent-roles/:roleId/folder", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { folderId?: unknown };
|
||||
if (body.folderId !== null && typeof body.folderId !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "folderId must be a string or null" },
|
||||
});
|
||||
}
|
||||
await agentConfig.setRoleFolder({
|
||||
organizationId: auth.organization.id,
|
||||
roleId,
|
||||
folderId: body.folderId as string | null,
|
||||
});
|
||||
return { folderId: body.folderId as string | null };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/agent-skills/:name/folder", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { folderId?: unknown };
|
||||
if (body.folderId !== null && typeof body.folderId !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "folderId must be a string or null" },
|
||||
});
|
||||
}
|
||||
await agentConfig.setSkillFolder({
|
||||
organizationId: auth.organization.id,
|
||||
name,
|
||||
folderId: body.folderId as string | null,
|
||||
});
|
||||
return { folderId: body.folderId as string | null };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
|
||||
import {
|
||||
CapabilityConnectionService,
|
||||
type CapabilityCredentialInput,
|
||||
} from "../../capability/capabilityConnectionService.js";
|
||||
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
|
||||
import { secretKindForCapability } from "../../capability/types.js";
|
||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
@@ -25,11 +29,10 @@ export async function registerCapabilityConnectionRoutes(
|
||||
config: CapabilityConnectionRouteConfig,
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
const connections = new CapabilityConnectionService(
|
||||
config.prisma,
|
||||
config.secretEnvelope,
|
||||
config.readinessProbe,
|
||||
);
|
||||
const connections =
|
||||
config.readinessProbe === undefined
|
||||
? new CapabilityConnectionService(config.prisma, config.secretEnvelope)
|
||||
: new CapabilityConnectionService(config.prisma, config.secretEnvelope, config.readinessProbe);
|
||||
|
||||
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
|
||||
try {
|
||||
@@ -60,12 +63,12 @@ export async function registerCapabilityConnectionRoutes(
|
||||
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = parseBody(request.body);
|
||||
const credential = parseCredentialBody(capabilityId, request.body);
|
||||
const result = await connections.rotate({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
actorUserId: auth.user.id,
|
||||
...body,
|
||||
credential,
|
||||
});
|
||||
request.log.info({
|
||||
organizationId: auth.organization.id,
|
||||
@@ -113,19 +116,57 @@ export async function registerCapabilityConnectionRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
|
||||
function parseCredentialBody(capabilityId: string, value: unknown): CapabilityCredentialInput {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error("invalid capability credential body");
|
||||
}
|
||||
const body = value as Record<string, unknown>;
|
||||
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
|
||||
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
const expectedKind = secretKindForCapability(capabilityId);
|
||||
const kind =
|
||||
body.kind === "docmind" || body.kind === "pbank"
|
||||
? body.kind
|
||||
: expectedKind;
|
||||
|
||||
if (kind !== expectedKind) {
|
||||
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}`);
|
||||
}
|
||||
|
||||
if (kind === "docmind") {
|
||||
return {
|
||||
kind: "docmind",
|
||||
accessKeyId: requireStringField(body, "accessKeyId"),
|
||||
accessKeySecret: requireStringField(body, "accessKeySecret"),
|
||||
endpoint: requireStringField(body, "endpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
const rightsStatus = optionalStringField(body, "rightsStatus");
|
||||
const rightsHolder = optionalStringField(body, "rightsHolder");
|
||||
const rightsScope = optionalStringField(body, "rightsScope");
|
||||
const rightsNote = optionalStringField(body, "rightsNote");
|
||||
return {
|
||||
accessKeyId: body["accessKeyId"] as string,
|
||||
accessKeySecret: body["accessKeySecret"] as string,
|
||||
endpoint: body["endpoint"] as string,
|
||||
kind: "pbank",
|
||||
baseUrl: requireStringField(body, "baseUrl"),
|
||||
username: requireStringField(body, "username"),
|
||||
password: requireStringField(body, "password"),
|
||||
...(rightsStatus !== undefined ? { rightsStatus } : {}),
|
||||
...(rightsHolder !== undefined ? { rightsHolder } : {}),
|
||||
...(rightsScope !== undefined ? { rightsScope } : {}),
|
||||
...(rightsNote !== undefined ? { rightsNote } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function requireStringField(body: Record<string, unknown>, name: string): string {
|
||||
const value = body[name];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalStringField(body: Record<string, unknown>, name: string): string | undefined {
|
||||
const value = body[name];
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,12 @@ export async function registerExplorerRoutes(
|
||||
workspaceRoot: config.projectWorkspaceRoot,
|
||||
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
||||
});
|
||||
return reply.status(201).send({ id: result.projectId, name: body.name });
|
||||
return reply.status(201).send({
|
||||
projectId: result.projectId,
|
||||
folderId: result.folderId,
|
||||
workspaceDir: result.workspaceDir,
|
||||
name: body.name,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
|
||||
+256
-11
@@ -18,6 +18,7 @@ export interface AgentRoleRow {
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly skillNames: readonly string[];
|
||||
readonly folderId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSkillRow {
|
||||
@@ -30,6 +31,17 @@ export interface AgentSkillRow {
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly boundRoleIds: readonly string[];
|
||||
readonly folderId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-0028 transparent folder node of the org's shared agent-config folder
|
||||
* tree. Grouping only: never part of skill/role identity or run resolution.
|
||||
*/
|
||||
export interface AgentConfigFolderRow {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly parentId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,9 +92,213 @@ export class OrganizationAgentConfiguration {
|
||||
createdAt: skill.createdAt.toISOString(),
|
||||
updatedAt: skill.updatedAt.toISOString(),
|
||||
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
folderId: skill.folderId,
|
||||
}));
|
||||
}
|
||||
|
||||
async listFolders(input: { readonly organizationId: string }): Promise<readonly AgentConfigFolderRow[]> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const folders = await this.prisma.organizationAgentConfigFolder.findMany({
|
||||
where: { organizationId: input.organizationId },
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
select: { id: true, name: true, parentId: true },
|
||||
});
|
||||
return folders;
|
||||
}
|
||||
|
||||
async createFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly name: string;
|
||||
readonly parentId?: string | undefined;
|
||||
}): Promise<AgentConfigFolderRow> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const name = nonEmpty(input.name, "folder name");
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (input.parentId !== undefined) {
|
||||
await requireFolder(tx, input.organizationId, input.parentId);
|
||||
}
|
||||
const folder = await tx.organizationAgentConfigFolder.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
name,
|
||||
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||
},
|
||||
select: { id: true, name: true, parentId: true },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_config_folder.created",
|
||||
metadata: { folderId: folder.id, name: folder.name, parentId: folder.parentId },
|
||||
},
|
||||
});
|
||||
return folder;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename and/or move a folder inside the same Organization tree. Moving is
|
||||
* rejected when the target parent is the folder itself or one of its
|
||||
* descendants (would create a cycle).
|
||||
*/
|
||||
async updateFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly folderId: string;
|
||||
readonly name?: string | undefined;
|
||||
readonly parentId?: string | null | undefined;
|
||||
}): Promise<AgentConfigFolderRow> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const folder = await requireFolder(tx, input.organizationId, input.folderId);
|
||||
if (input.parentId !== undefined && input.parentId !== null) {
|
||||
if (input.parentId === folder.id) {
|
||||
throw new Error("folder cannot be its own parent");
|
||||
}
|
||||
await requireFolder(tx, input.organizationId, input.parentId);
|
||||
const descendant = await tx.$queryRaw<Array<{ found: boolean }>>(Prisma.sql`
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT "id" FROM "OrganizationAgentConfigFolder" WHERE "parentId" = ${folder.id}
|
||||
UNION ALL
|
||||
SELECT child."id" FROM "OrganizationAgentConfigFolder" child
|
||||
JOIN descendants parent ON child."parentId" = parent."id"
|
||||
)
|
||||
SELECT EXISTS(SELECT 1 FROM descendants WHERE "id" = ${input.parentId}) AS found
|
||||
`);
|
||||
if (descendant[0]?.found === true) throw new Error("folder cannot be moved below its descendant");
|
||||
}
|
||||
const name = input.name !== undefined ? nonEmpty(input.name, "folder name") : undefined;
|
||||
const updated = await tx.organizationAgentConfigFolder.update({
|
||||
where: { id: folder.id },
|
||||
data: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||
},
|
||||
select: { id: true, name: true, parentId: true },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_config_folder.updated",
|
||||
metadata: {
|
||||
folderId: folder.id,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder. Refused while the folder still has child folders, roles
|
||||
* or skills (ADR-0028: items are relocated explicitly, so no orphan-placement
|
||||
* rule is needed).
|
||||
*/
|
||||
async deleteFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly folderId: string;
|
||||
}): Promise<void> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const folder = await requireFolder(tx, input.organizationId, input.folderId);
|
||||
const childFolders = await tx.organizationAgentConfigFolder.count({
|
||||
where: { parentId: folder.id },
|
||||
});
|
||||
if (childFolders > 0) {
|
||||
throw new Error(`cannot delete folder: still has ${childFolders} child folder(s)`);
|
||||
}
|
||||
const skills = await tx.organizationAgentSkill.count({
|
||||
where: { organizationId: input.organizationId, folderId: folder.id },
|
||||
});
|
||||
const roles = await tx.organizationAgentRole.count({
|
||||
where: { organizationId: input.organizationId, folderId: folder.id },
|
||||
});
|
||||
if (skills > 0 || roles > 0) {
|
||||
throw new Error(`cannot delete folder: still has ${roles} role(s) and ${skills} skill(s)`);
|
||||
}
|
||||
await tx.organizationAgentConfigFolder.delete({ where: { id: folder.id } });
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_config_folder.deleted",
|
||||
metadata: { folderId: folder.id, name: folder.name },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a skill to a folder (or unfile it with `folderId: null`). This is
|
||||
* a label-class change in the ADR-0017 sense — the execution surface is
|
||||
* untouched, so no session archival (ADR-0028).
|
||||
*/
|
||||
async setSkillFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly name: string;
|
||||
readonly folderId: string | null;
|
||||
}): Promise<void> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const skill = await tx.organizationAgentSkill.findUnique({
|
||||
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
|
||||
select: { id: true, disabledAt: true },
|
||||
});
|
||||
if (skill === null || skill.disabledAt !== null) {
|
||||
throw new Error(`active skill not found in organization: ${input.name}`);
|
||||
}
|
||||
if (input.folderId !== null) {
|
||||
await requireFolder(tx, input.organizationId, input.folderId);
|
||||
}
|
||||
await tx.organizationAgentSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: { folderId: input.folderId },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_skill.folder_set",
|
||||
metadata: { name: input.name, folderId: input.folderId },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a role to a folder (or unfile it with `folderId: null`). Same
|
||||
* label-class semantics as `setSkillFolder`: no session archival (ADR-0028).
|
||||
*/
|
||||
async setRoleFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly roleId: string;
|
||||
readonly folderId: string | null;
|
||||
}): Promise<void> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const role = await tx.organizationAgentRole.findUnique({
|
||||
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
|
||||
select: { id: true, disabledAt: true },
|
||||
});
|
||||
if (role === null || role.disabledAt !== null) {
|
||||
throw new Error(`active role not found in organization: ${input.roleId}`);
|
||||
}
|
||||
if (input.folderId !== null) {
|
||||
await requireFolder(tx, input.organizationId, input.folderId);
|
||||
}
|
||||
await tx.organizationAgentRole.update({
|
||||
where: { id: role.id },
|
||||
data: { folderId: input.folderId },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_role.folder_set",
|
||||
metadata: { roleId: input.roleId, folderId: input.folderId },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async installSkill(input: {
|
||||
readonly organizationId: string;
|
||||
readonly sourceDir: string;
|
||||
@@ -173,7 +389,7 @@ export class OrganizationAgentConfiguration {
|
||||
where: { id: skill.id },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
await archiveRoleSessions(
|
||||
await invalidateRoleSessionClaudeIds(
|
||||
tx,
|
||||
input.organizationId,
|
||||
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
@@ -271,7 +487,7 @@ export class OrganizationAgentConfiguration {
|
||||
},
|
||||
});
|
||||
if (previous !== null && previous.contentDigest !== skill.contentDigest) {
|
||||
await archiveRoleSessions(
|
||||
await invalidateRoleSessionClaudeIds(
|
||||
tx,
|
||||
input.organizationId,
|
||||
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
@@ -379,7 +595,7 @@ export class OrganizationAgentConfiguration {
|
||||
if (activeDefaultCount !== 1) {
|
||||
throw new Error(`organization ${input.organizationId} must have exactly one active default role`);
|
||||
}
|
||||
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
if (executionSurfaceChanged) await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
@@ -449,7 +665,7 @@ export class OrganizationAgentConfiguration {
|
||||
})),
|
||||
});
|
||||
}
|
||||
await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
@@ -472,7 +688,22 @@ export class OrganizationAgentConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveRoleSessions(
|
||||
/**
|
||||
* Invalidate the provider session cursor (e.g. `claudeSessionId`) for every
|
||||
* active session of the given roles, WITHOUT archiving the session.
|
||||
*
|
||||
* Execution-surface changes (role model/systemPrompt/tools, skill content or
|
||||
* binding changes) make a stale provider session cursor unsafe to resume: the
|
||||
* prior turns were produced under a different config. But the conversation
|
||||
* history itself (AgentMessage rows) is still valuable and the logical Hub
|
||||
* session should stay continuous — the next run re-seeds context from the
|
||||
* transcript instead of resuming the old provider session. So we drop only
|
||||
* the cursor, not the session.
|
||||
*
|
||||
* `userResumable` is cleared because the session is no longer backed by a
|
||||
* live provider cursor the user can drop back into.
|
||||
*/
|
||||
async function invalidateRoleSessionClaudeIds(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
roleIds: readonly string[],
|
||||
@@ -482,24 +713,36 @@ async function archiveRoleSessions(
|
||||
where: {
|
||||
roleId: { in: [...new Set(roleIds)] },
|
||||
project: { organizationId },
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { id: true, archivedAt: true, metadata: true },
|
||||
select: { id: true, metadata: true },
|
||||
});
|
||||
const archivedAt = new Date();
|
||||
for (const session of sessions) {
|
||||
const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata)
|
||||
? session.metadata as Prisma.JsonObject
|
||||
: {};
|
||||
const { claudeSessionId: _drop, ...rest } = metadata;
|
||||
await tx.agentSession.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
...(session.archivedAt === null ? { archivedAt } : {}),
|
||||
metadata: { ...metadata, userResumable: false },
|
||||
},
|
||||
data: { metadata: { ...rest, userResumable: false } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function requireFolder(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
folderId: string,
|
||||
): Promise<{ readonly id: string; readonly name: string; readonly parentId: string | null }> {
|
||||
const folder = await tx.organizationAgentConfigFolder.findFirst({
|
||||
where: { id: folderId, organizationId },
|
||||
select: { id: true, name: true, parentId: true },
|
||||
});
|
||||
if (folder === null) throw new Error(`folder not found in organization: ${folderId}`);
|
||||
return folder;
|
||||
}
|
||||
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "") throw new Error(`${label} is required`);
|
||||
@@ -530,6 +773,7 @@ function toRoleRow(role: {
|
||||
readonly disabledAt: Date | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
readonly folderId: string | null;
|
||||
readonly skillBindings: ReadonlyArray<{
|
||||
readonly skill: { readonly name: string; readonly disabledAt: Date | null };
|
||||
}>;
|
||||
@@ -549,5 +793,6 @@ function toRoleRow(role: {
|
||||
skillNames: role.skillBindings
|
||||
.filter((binding) => binding.skill.disabledAt === null)
|
||||
.map((binding) => binding.skill.name),
|
||||
folderId: role.folderId,
|
||||
};
|
||||
}
|
||||
|
||||
+80
-45
@@ -1,11 +1,13 @@
|
||||
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"TodoWrite",
|
||||
] as const;
|
||||
|
||||
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
||||
@@ -15,6 +17,10 @@ export const CPH_HUB_MCP_TOOL_IDS = [
|
||||
"feishu_download_resource",
|
||||
"request_approval",
|
||||
"convert_pdf_to_md",
|
||||
"pbank_search_problems",
|
||||
"pbank_get_problem",
|
||||
"pbank_get_many_problems",
|
||||
"todo_write",
|
||||
] as const;
|
||||
|
||||
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
|
||||
@@ -24,48 +30,65 @@ export interface ClaudeSdkToolConfig {
|
||||
readonly allowedTools: readonly string[];
|
||||
}
|
||||
|
||||
const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
|
||||
["read_file", ["Read"]],
|
||||
["write_file", ["Write"]],
|
||||
["list_files", ["Glob"]],
|
||||
["search_files", ["Grep"]],
|
||||
["bash", ["Bash"]],
|
||||
const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>> = {
|
||||
read_file: ["Read"],
|
||||
write_file: ["Write", "Edit"],
|
||||
list_files: ["Glob"],
|
||||
search_files: ["Grep"],
|
||||
bash: ["Bash"],
|
||||
// ADR-0017 replaced cph custom tools with Bash commands. Granting either
|
||||
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
|
||||
// would need a separate command-policy layer.
|
||||
["cph_check", ["Bash"]],
|
||||
["cph_build", ["Bash"]],
|
||||
["web_fetch", ["WebFetch"]],
|
||||
["web_search", ["WebSearch"]],
|
||||
["Read", ["Read"]],
|
||||
["Write", ["Write"]],
|
||||
["Bash", ["Bash"]],
|
||||
["Glob", ["Glob"]],
|
||||
["Grep", ["Grep"]],
|
||||
["WebFetch", ["WebFetch"]],
|
||||
["WebSearch", ["WebSearch"]],
|
||||
]);
|
||||
cph_check: ["Bash"],
|
||||
cph_build: ["Bash"],
|
||||
web_fetch: ["WebFetch"],
|
||||
web_search: ["WebSearch"],
|
||||
todo: ["TodoWrite"],
|
||||
TodoWrite: ["TodoWrite"],
|
||||
Read: ["Read"],
|
||||
Write: ["Write"],
|
||||
Edit: ["Edit"],
|
||||
Bash: ["Bash"],
|
||||
Glob: ["Glob"],
|
||||
Grep: ["Grep"],
|
||||
WebFetch: ["WebFetch"],
|
||||
WebSearch: ["WebSearch"],
|
||||
};
|
||||
|
||||
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
||||
["send_file", "send_file"],
|
||||
["feishu_read_context", "feishu_read_context"],
|
||||
["feishu_download_resource", "feishu_download_resource"],
|
||||
["request_approval", "request_approval"],
|
||||
["convert_pdf_to_md", "convert_pdf_to_md"],
|
||||
["mcp__cph_hub__send_file", "send_file"],
|
||||
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
|
||||
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
|
||||
["mcp__cph_hub__request_approval", "request_approval"],
|
||||
["mcp__cph_hub__convert_pdf_to_md", "convert_pdf_to_md"],
|
||||
]);
|
||||
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcpToolId[]>> = {
|
||||
send_file: ["send_file"],
|
||||
feishu_read_context: ["feishu_read_context"],
|
||||
feishu_download_resource: ["feishu_download_resource"],
|
||||
request_approval: ["request_approval"],
|
||||
convert_pdf_to_md: ["convert_pdf_to_md"],
|
||||
pbank: ["pbank_search_problems", "pbank_get_problem", "pbank_get_many_problems"],
|
||||
pbank_search_problems: ["pbank_search_problems"],
|
||||
pbank_get_problem: ["pbank_get_problem"],
|
||||
pbank_get_many_problems: ["pbank_get_many_problems"],
|
||||
todo: ["todo_write"],
|
||||
TodoWrite: ["todo_write"],
|
||||
todo_write: ["todo_write"],
|
||||
"mcp__cph_hub__send_file": ["send_file"],
|
||||
"mcp__cph_hub__feishu_read_context": ["feishu_read_context"],
|
||||
"mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"],
|
||||
"mcp__cph_hub__request_approval": ["request_approval"],
|
||||
"mcp__cph_hub__convert_pdf_to_md": ["convert_pdf_to_md"],
|
||||
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
|
||||
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
|
||||
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
|
||||
"mcp__cph_hub__todo_write": ["todo_write"],
|
||||
};
|
||||
|
||||
const SUPPORTED_ROLE_TOOLS = new Set([
|
||||
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(),
|
||||
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(),
|
||||
...Object.keys(ROLE_TOOL_TO_CLAUDE_BUILT_INS),
|
||||
...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS),
|
||||
]);
|
||||
|
||||
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
|
||||
if (roleTools === undefined) {
|
||||
export function claudeSdkToolConfigForRole(
|
||||
roleTools: readonly string[] | null | undefined,
|
||||
): ClaudeSdkToolConfig {
|
||||
// DB/runtime "unrestricted" is JSON null; treat the same as undefined.
|
||||
if (roleTools === undefined || roleTools === null) {
|
||||
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
|
||||
return {
|
||||
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
|
||||
@@ -77,13 +100,12 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi
|
||||
const allowedTools: string[] = [];
|
||||
for (const roleTool of roleTools) {
|
||||
assertSupportedRoleTool(roleTool);
|
||||
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS.get(roleTool) ?? []) {
|
||||
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS[roleTool] ?? []) {
|
||||
pushUnique(builtIns, tool);
|
||||
pushUnique(allowedTools, tool);
|
||||
}
|
||||
|
||||
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
|
||||
if (mcpTool !== undefined) {
|
||||
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
|
||||
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
|
||||
}
|
||||
}
|
||||
@@ -91,24 +113,37 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi
|
||||
return { tools: builtIns, allowedTools };
|
||||
}
|
||||
|
||||
export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
|
||||
if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
|
||||
export function cphHubMcpToolsForRole(
|
||||
roleTools: readonly string[] | null | undefined,
|
||||
): readonly CphHubMcpToolId[] {
|
||||
// Always expose hub-side todo_write so progress cards work even when the
|
||||
// native Claude TodoWrite tool is not registered in headless agent mode.
|
||||
if (roleTools === undefined || roleTools === null) {
|
||||
return [...CPH_HUB_MCP_TOOL_IDS];
|
||||
}
|
||||
|
||||
const tools: CphHubMcpToolId[] = [];
|
||||
const tools: CphHubMcpToolId[] = ["todo_write"];
|
||||
for (const roleTool of roleTools) {
|
||||
assertSupportedRoleTool(roleTool);
|
||||
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
|
||||
if (mcpTool !== undefined) pushUnique(tools, mcpTool);
|
||||
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
|
||||
pushUnique(tools, mcpTool);
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
|
||||
if (roleTools === undefined) return true;
|
||||
export function roleToolsAllow(
|
||||
roleTools: readonly string[] | null | undefined,
|
||||
roleTool: string,
|
||||
): boolean {
|
||||
if (roleTools === undefined || roleTools === null) return true;
|
||||
for (const configured of roleTools) {
|
||||
assertSupportedRoleTool(configured);
|
||||
if (configured === roleTool) return true;
|
||||
if (ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(configured) === roleTool) return true;
|
||||
const mapped = ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[configured];
|
||||
if (mapped !== undefined && mapped.includes(roleTool as CphHubMcpToolId)) return true;
|
||||
// Umbrella: role tool "pbank" allows any pbank_* MCP or role tool.
|
||||
if (configured === "pbank" && roleTool.startsWith("pbank")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+110
-11
@@ -47,7 +47,7 @@ export type StreamEvent =
|
||||
| { readonly type: "thinking-delta"; readonly text: string }
|
||||
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
|
||||
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
|
||||
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
|
||||
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly input?: unknown; readonly durationMs?: number }
|
||||
| { readonly type: "finish" };
|
||||
|
||||
export type StreamCallback = (event: StreamEvent) => void;
|
||||
@@ -140,7 +140,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
let cleanupSecurity = async (): Promise<void> => {};
|
||||
try {
|
||||
await persistAgentMessage(req, "user", req.prompt);
|
||||
const toolConfig = claudeSdkToolConfigForRole(req.tools);
|
||||
// Role tools JSON null means the default single-agent tool set, not "deny all".
|
||||
const roleToolIds = req.tools === null ? undefined : req.tools;
|
||||
const toolConfig = claudeSdkToolConfigForRole(roleToolIds);
|
||||
const workspaceRoot = req.project.workspaceRoot?.trim();
|
||||
if (workspaceRoot === undefined || workspaceRoot === "") {
|
||||
throw new Error("Agent run requires the configured workspace root");
|
||||
@@ -154,14 +156,42 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
});
|
||||
cleanupSecurity = security.cleanup;
|
||||
const hasSkills = security.skillIds.length > 0;
|
||||
|
||||
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
|
||||
// Always use an explicit tool list — never the claude_code preset.
|
||||
// The preset registers Agent/SendMessage/Task multi-agent machinery.
|
||||
// Concurrent background agents abort with reason "background", and the
|
||||
// Claude Agent SDK maps that to toolDenialKind "cancelled" with:
|
||||
// "The user doesn't want to take this action right now..."
|
||||
// which freezes Bash mid-run while Read/Glob continue to work.
|
||||
// Hub "unrestricted" means the default single-agent built-ins + MCP, not
|
||||
// the full interactive Claude product surface.
|
||||
const skillExtras = hasSkills ? (["Skill"] as const) : ([] as const);
|
||||
const toolsOption: QueryOptions["tools"] = uniqueTools([
|
||||
...toolConfig.tools,
|
||||
"TodoWrite",
|
||||
...skillExtras,
|
||||
]);
|
||||
const allowedToolsOption = uniqueTools([
|
||||
...toolConfig.allowedTools,
|
||||
"TodoWrite",
|
||||
"mcp__cph_hub__todo_write",
|
||||
...skillExtras,
|
||||
]);
|
||||
// Hard deny multi-agent orchestration even if a future preset/skills path
|
||||
// reintroduces them — bypassPermissions would otherwise auto-allow them.
|
||||
const disallowedToolsOption = [
|
||||
"Agent",
|
||||
"SendMessage",
|
||||
"TeamCreate",
|
||||
"Task",
|
||||
"ScheduleWakeup",
|
||||
] as const;
|
||||
|
||||
const options: QueryOptions = {
|
||||
cwd: security.cwd,
|
||||
// `skills` controls discovery/allowlisting, but an explicit `tools`
|
||||
// list still has to expose the Skill dispatcher itself.
|
||||
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
|
||||
allowedTools: [...toolConfig.allowedTools],
|
||||
tools: toolsOption,
|
||||
allowedTools: allowedToolsOption,
|
||||
disallowedTools: [...disallowedToolsOption],
|
||||
maxTurns: cap,
|
||||
includePartialMessages: true,
|
||||
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
||||
@@ -178,7 +208,16 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
// The project workspace is untrusted input. Do not load user/project
|
||||
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
|
||||
settingSources: [],
|
||||
settings: { disableBundledSkills: true },
|
||||
settings: {
|
||||
disableBundledSkills: true,
|
||||
todoFeatureEnabled: true,
|
||||
// Sessions are resumed across runs (ADR-0017). Without auto-compact
|
||||
// the SDK jsonl grows unboundedly — a long-lived project session hit
|
||||
// 31 MB / 1995 lines, making every API call resend the entire history
|
||||
// and inflating a "change a title" task to 22 minutes. Let the SDK
|
||||
// compact automatically when the context window fills.
|
||||
autoCompactEnabled: true,
|
||||
},
|
||||
...(hasSkills && security.skillPluginRoot !== undefined
|
||||
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
|
||||
: {}),
|
||||
@@ -198,13 +237,25 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
if (req.abortController !== undefined) options.abortController = req.abortController;
|
||||
if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr;
|
||||
|
||||
// When there is no provider session cursor to resume (first run, or after a
|
||||
// role/skill/model config change invalidated claudeSessionId), re-seed the
|
||||
// conversation from this Hub session's prior AgentMessage rows. This keeps
|
||||
// the logical session continuous across config changes and restarts — the
|
||||
// agent still "remembers" the earlier turns even though the SDK starts a
|
||||
// fresh provider session. The resume path above is preferred when available
|
||||
// (it carries tool calls/results natively and avoids re-sending tokens).
|
||||
const promptForAgent = req.resumeSessionId === undefined
|
||||
? await withSessionHistory(req, req.prompt)
|
||||
: req.prompt;
|
||||
|
||||
const conversation = query({
|
||||
prompt: req.prompt,
|
||||
prompt: promptForAgent,
|
||||
options,
|
||||
});
|
||||
|
||||
// Track tool start timestamps for duration calculation
|
||||
// Track tool start timestamps and names/inputs for duration + tool-result attribution.
|
||||
const toolStartTimestamps = new Map<string, number>();
|
||||
const toolMetaByUseId = new Map<string, { readonly name: string; readonly input: unknown }>();
|
||||
|
||||
for await (const message of conversation) {
|
||||
switch (message.type) {
|
||||
@@ -225,6 +276,10 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
|
||||
const toolUseId = evt.content_block.id;
|
||||
toolStartTimestamps.set(toolUseId, Date.now());
|
||||
toolMetaByUseId.set(toolUseId, {
|
||||
name: evt.content_block.name,
|
||||
input: undefined,
|
||||
});
|
||||
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
|
||||
}
|
||||
if (evt.type === "content_block_stop") {
|
||||
@@ -246,6 +301,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
const durationMs = toolStartTimestamps.has(block.id)
|
||||
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
|
||||
: undefined;
|
||||
toolMetaByUseId.set(block.id, { name: block.name, input: block.input });
|
||||
onStream?.({
|
||||
type: "tool-end",
|
||||
toolName: block.name,
|
||||
@@ -279,12 +335,14 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
const isError = block.is_error === true;
|
||||
const resultText = extractToolResultText(block.content);
|
||||
const durationMs = toolStartTimestamps.get(toolUseId);
|
||||
const meta = toolMetaByUseId.get(toolUseId);
|
||||
onStream?.({
|
||||
type: "tool-result",
|
||||
toolUseId,
|
||||
toolName: toolUseId,
|
||||
toolName: meta?.name ?? toolUseId,
|
||||
result: resultText,
|
||||
isError,
|
||||
...(meta?.input !== undefined ? { input: meta.input } : {}),
|
||||
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
|
||||
});
|
||||
}
|
||||
@@ -333,6 +391,39 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed a run's prompt with this Hub session's prior conversation when the
|
||||
* SDK cannot resume a provider session (no `resumeSessionId`). Pulls prior
|
||||
* `AgentMessage` rows for this session — excluding the current run's own user
|
||||
* message, which was already persisted before `runAgent` called this — and
|
||||
* frames them as `<session_history>` so the model treats them as prior turns,
|
||||
* not new instructions. The current run's prompt follows as the live request.
|
||||
*
|
||||
* Best-effort: if the history query fails, the run proceeds with the bare
|
||||
* prompt rather than aborting. A cap (`MAX_HISTORY_TURNS`) bounds token cost;
|
||||
* older turns beyond the cap are dropped, preserving the most recent context.
|
||||
*/
|
||||
async function withSessionHistory(req: RunRequest, prompt: string): Promise<string> {
|
||||
const MAX_HISTORY_TURNS = 40;
|
||||
try {
|
||||
const messages = await req.prisma.agentMessage.findMany({
|
||||
where: { sessionId: req.sessionId, runId: { not: req.runId } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { role: true, content: true },
|
||||
take: MAX_HISTORY_TURNS * 2, // user+assistant per turn
|
||||
});
|
||||
if (messages.length === 0) return prompt;
|
||||
const turns: string[] = [];
|
||||
for (const message of messages) {
|
||||
const label = message.role === "assistant" ? "Assistant" : "User";
|
||||
turns.push(`${label}: ${message.content}`);
|
||||
}
|
||||
return `<session_history>\nThis is the prior conversation in this session, replayed because the provider session could not be resumed. Treat these as earlier turns you produced or received.\n\n${turns.join("\n\n")}\n</session_history>\n\n${prompt}`;
|
||||
} catch {
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
|
||||
if (content === "") return;
|
||||
try {
|
||||
@@ -361,3 +452,11 @@ function extractToolResultText(content: unknown): string {
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function uniqueTools(tools: readonly string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const tool of tools) {
|
||||
if (!out.includes(tool)) out.push(tool);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
|
||||
import { chmod, cp, lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type { RoleSkillEntry } from "./models.js";
|
||||
@@ -21,6 +21,20 @@ const SAFE_HOST_ENV_KEYS = [
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"CPH_BIN",
|
||||
// Host egress is often only reachable via a local forward proxy. Without
|
||||
// these, sandboxed Bash/curl times out on public HTTPS (ADR-0018: network
|
||||
// open ≠ direct routing). Values come from the trusted service environment.
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"NODE_USE_ENV_PROXY",
|
||||
"TYPST_PACKAGE_PATH",
|
||||
"TYPST_PACKAGE_CACHE_PATH",
|
||||
] as const;
|
||||
|
||||
const SANDBOX_HIDDEN_ENV_KEYS = [
|
||||
@@ -122,6 +136,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
|
||||
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
|
||||
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
|
||||
const typstCacheWritePaths = hostTypstCacheWritePaths(hostEnv);
|
||||
const selectedSkills = input.skills ?? [];
|
||||
const skillPlugin = selectedSkills.length === 0
|
||||
? null
|
||||
@@ -130,6 +145,25 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
runId: input.runId,
|
||||
skills: selectedSkills,
|
||||
});
|
||||
// Mirror selected skills under the workspace so the agent can Read SKILL.md
|
||||
// without guessing the opaque host plugin UUID path. Workspace `.claude/` and
|
||||
// `.mcp.json` are Claude sandbox stubs — not skill/MCP source of truth.
|
||||
const runtimeSkillsRel = join(".cph", "runtime-skills");
|
||||
const runtimeSkillsAbs = join(workspaceDir, runtimeSkillsRel);
|
||||
await rm(runtimeSkillsAbs, { recursive: true, force: true });
|
||||
await mkdir(runtimeSkillsAbs, { recursive: true, mode: 0o700 });
|
||||
if (skillPlugin !== null) {
|
||||
const pluginSkillsRoot = join(skillPlugin.root, "skills");
|
||||
const skillNames = await readdir(pluginSkillsRoot);
|
||||
for (const skillName of skillNames) {
|
||||
await cp(join(pluginSkillsRoot, skillName), join(runtimeSkillsAbs, skillName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
env.CPH_RUNTIME_SKILLS_DIR = runtimeSkillsAbs;
|
||||
env.CPH_RUNTIME_SKILLS_REL = runtimeSkillsRel;
|
||||
}
|
||||
return {
|
||||
cwd: workspaceDir,
|
||||
workspaceRoot,
|
||||
@@ -143,7 +177,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [workspaceDir],
|
||||
allowWrite: [...new Set([workspaceDir, ...typstCacheWritePaths])],
|
||||
// Reject every write path by default, then re-open only the canonical
|
||||
// workspace. This prevents bubblewrap's ordinary temp exceptions from
|
||||
// turning an unauthorized path into a successful ephemeral write.
|
||||
@@ -213,9 +247,30 @@ function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>)
|
||||
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
|
||||
platformPaths.push(resolve(cphBin));
|
||||
}
|
||||
platformPaths.push(...configuredTypstPackagePaths(env, ["TYPST_PACKAGE_PATH", "TYPST_PACKAGE_CACHE_PATH"]));
|
||||
return [...new Set(platformPaths.map((path) => resolve(path)))];
|
||||
}
|
||||
|
||||
function hostTypstCacheWritePaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
return configuredTypstPackagePaths(env, ["TYPST_PACKAGE_CACHE_PATH"]);
|
||||
}
|
||||
|
||||
function configuredTypstPackagePaths(
|
||||
env: Readonly<Record<string, string | undefined>>,
|
||||
names: readonly ("TYPST_PACKAGE_PATH" | "TYPST_PACKAGE_CACHE_PATH")[],
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const name of names) {
|
||||
const packagePath = env[name]?.trim();
|
||||
if (packagePath === undefined || packagePath === "") continue;
|
||||
if (!isAbsolute(packagePath)) throw new Error(`${name} must be absolute for the Agent subprocess`);
|
||||
const canonical = resolve(packagePath);
|
||||
if (canonical === "/") throw new Error(`${name} must not be the filesystem root`);
|
||||
paths.push(canonical);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
const home = homedir();
|
||||
const paths = [
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Parse agent checklist tools into a stable model for Feishu card progress.
|
||||
*
|
||||
* Supports:
|
||||
* - Claude TodoWrite (and hub mcp todo_write): full-list replace
|
||||
* - Claude TaskCreate / TaskUpdate tools used in headless agent mode
|
||||
*/
|
||||
|
||||
export type AgentTodoStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
export interface AgentTodoItem {
|
||||
/** Present for Task* tools; optional for whole-list TodoWrite payloads. */
|
||||
readonly id: string | undefined;
|
||||
readonly content: string;
|
||||
readonly status: AgentTodoStatus;
|
||||
/** Present-tense label while the item is active, when the model supplies it. */
|
||||
readonly activeForm: string | undefined;
|
||||
}
|
||||
|
||||
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]);
|
||||
|
||||
/** True when the tool name is SDK TodoWrite or hub mcp todo_write. */
|
||||
export function isTodoWriteTool(toolName: string): boolean {
|
||||
const lower = toolName.toLowerCase();
|
||||
return (
|
||||
toolName === "TodoWrite" ||
|
||||
toolName.endsWith("__TodoWrite") ||
|
||||
lower === "todo_write" ||
|
||||
lower.endsWith("__todo_write")
|
||||
);
|
||||
}
|
||||
|
||||
export function isTaskChecklistTool(toolName: string): boolean {
|
||||
const base = stripToolSuffix(toolName);
|
||||
return (
|
||||
base === "TaskCreate" ||
|
||||
base === "TaskUpdate" ||
|
||||
base === "TaskList" ||
|
||||
base === "TaskGet" ||
|
||||
base === "TaskStop" ||
|
||||
base === "TaskOutput"
|
||||
);
|
||||
}
|
||||
|
||||
export function isChecklistProgressTool(toolName: string): boolean {
|
||||
return isTodoWriteTool(toolName) || isTaskChecklistTool(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the full todo list from a TodoWrite / todo_write tool_use input.
|
||||
* Returns null when the payload is not a usable body.
|
||||
*/
|
||||
export function parseTodoWriteInput(input: unknown): readonly AgentTodoItem[] | null {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
if (!("todos" in input)) return null;
|
||||
const rawTodos = (input as { todos?: unknown }).todos;
|
||||
if (!Array.isArray(rawTodos) || rawTodos.length === 0) return null;
|
||||
|
||||
const todos: AgentTodoItem[] = [];
|
||||
for (const raw of rawTodos) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const content = typeof record.content === "string" ? record.content.trim() : "";
|
||||
if (content === "") continue;
|
||||
const statusRaw = typeof record.status === "string" ? record.status : "pending";
|
||||
const status: AgentTodoStatus = STATUSES.has(statusRaw as AgentTodoStatus)
|
||||
? (statusRaw as AgentTodoStatus)
|
||||
: "pending";
|
||||
const activeForm =
|
||||
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
|
||||
? record.activeForm.trim()
|
||||
: undefined;
|
||||
const id =
|
||||
typeof record.id === "string" && record.id.trim() !== "" ? record.id.trim() : undefined;
|
||||
todos.push({ id, content, status, activeForm });
|
||||
}
|
||||
return todos.length === 0 ? null : todos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a Task* / TodoWrite tool event into the running checklist.
|
||||
* Returns null when the event does not change checklist state.
|
||||
*/
|
||||
export function applyChecklistToolEvent(
|
||||
current: readonly AgentTodoItem[],
|
||||
params: {
|
||||
readonly toolName: string;
|
||||
readonly input: unknown;
|
||||
readonly result: unknown;
|
||||
readonly toolUseId?: string | undefined;
|
||||
},
|
||||
): readonly AgentTodoItem[] | null {
|
||||
if (isTodoWriteTool(params.toolName)) {
|
||||
return parseTodoWriteInput(params.input);
|
||||
}
|
||||
|
||||
const baseName = stripToolSuffix(params.toolName);
|
||||
if (baseName === "TaskCreate") {
|
||||
return applyTaskCreate(current, params.input, params.result, params.toolUseId);
|
||||
}
|
||||
if (baseName === "TaskUpdate") {
|
||||
return applyTaskUpdate(current, params.input);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function todoProgressSummary(todos: readonly AgentTodoItem[]): {
|
||||
readonly completed: number;
|
||||
readonly total: number;
|
||||
readonly inProgress: number;
|
||||
} {
|
||||
let completed = 0;
|
||||
let inProgress = 0;
|
||||
for (const todo of todos) {
|
||||
if (todo.status === "completed") completed += 1;
|
||||
else if (todo.status === "in_progress") inProgress += 1;
|
||||
}
|
||||
return { completed, total: todos.length, inProgress };
|
||||
}
|
||||
|
||||
function stripToolSuffix(toolName: string): string {
|
||||
// SDK sometimes emits TaskCreate_0 sequential copies in the card title path.
|
||||
const bare = toolName.includes("__") ? (toolName.split("__").pop() ?? toolName) : toolName;
|
||||
return bare.replace(/_\d+$/, "");
|
||||
}
|
||||
|
||||
function applyTaskCreate(
|
||||
current: readonly AgentTodoItem[],
|
||||
input: unknown,
|
||||
result: unknown,
|
||||
toolUseId: string | undefined,
|
||||
): readonly AgentTodoItem[] | null {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
const record = input as Record<string, unknown>;
|
||||
const subject =
|
||||
typeof record.subject === "string"
|
||||
? record.subject.trim()
|
||||
: typeof record.description === "string"
|
||||
? record.description.trim()
|
||||
: "";
|
||||
if (subject === "") return null;
|
||||
const activeForm =
|
||||
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
|
||||
? record.activeForm.trim()
|
||||
: undefined;
|
||||
const idFromResult = extractTaskIdFromResult(result);
|
||||
const provisionalId = toolUseId ?? `task-${current.length + 1}`;
|
||||
const id = idFromResult ?? provisionalId;
|
||||
|
||||
// Replace any provisional row for this tool use or same pending subject.
|
||||
const without = current.filter(
|
||||
(t) =>
|
||||
t.id !== provisionalId &&
|
||||
t.id !== toolUseId &&
|
||||
!(t.content === subject && t.status === "pending" && t.id !== id),
|
||||
);
|
||||
const existing = without.find((t) => taskIdsMatch(t.id, id));
|
||||
if (existing !== undefined) {
|
||||
return without.map((t) =>
|
||||
taskIdsMatch(t.id, id)
|
||||
? {
|
||||
id,
|
||||
content: subject,
|
||||
status: existing.status,
|
||||
activeForm: activeForm ?? existing.activeForm,
|
||||
}
|
||||
: t,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...without,
|
||||
{
|
||||
id,
|
||||
content: subject,
|
||||
status: "pending",
|
||||
activeForm,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function applyTaskUpdate(
|
||||
current: readonly AgentTodoItem[],
|
||||
input: unknown,
|
||||
): readonly AgentTodoItem[] | null {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
const record = input as Record<string, unknown>;
|
||||
const taskId =
|
||||
typeof record.taskId === "string"
|
||||
? record.taskId.trim()
|
||||
: typeof record.id === "string"
|
||||
? record.id.trim()
|
||||
: "";
|
||||
if (taskId === "") return null;
|
||||
|
||||
const statusRaw = typeof record.status === "string" ? record.status : undefined;
|
||||
if (statusRaw === "deleted") {
|
||||
const next = current.filter((t) => !taskIdsMatch(t.id, taskId));
|
||||
return next.length === current.length ? null : next;
|
||||
}
|
||||
|
||||
const status: AgentTodoStatus | undefined =
|
||||
statusRaw !== undefined && STATUSES.has(statusRaw as AgentTodoStatus)
|
||||
? (statusRaw as AgentTodoStatus)
|
||||
: undefined;
|
||||
const subject =
|
||||
typeof record.subject === "string" && record.subject.trim() !== ""
|
||||
? record.subject.trim()
|
||||
: undefined;
|
||||
const activeForm =
|
||||
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
|
||||
? record.activeForm.trim()
|
||||
: undefined;
|
||||
|
||||
if (status === undefined && subject === undefined && activeForm === undefined) return null;
|
||||
|
||||
let found = false;
|
||||
const next = current.map((todo) => {
|
||||
if (!taskIdsMatch(todo.id, taskId)) return todo;
|
||||
found = true;
|
||||
return {
|
||||
id: todo.id ?? taskId,
|
||||
content: subject ?? todo.content,
|
||||
status: status ?? todo.status,
|
||||
activeForm: activeForm ?? todo.activeForm,
|
||||
};
|
||||
});
|
||||
if (found) return next;
|
||||
|
||||
// Update arrived before create (or id mismatch): synthesize a row so the
|
||||
// teacher still sees lifecycle updates.
|
||||
if (subject === undefined && status === undefined) return null;
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: taskId,
|
||||
content: subject ?? `任务 ${taskId}`,
|
||||
status: status ?? "pending",
|
||||
activeForm,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function extractTaskIdFromResult(result: unknown): string | undefined {
|
||||
const text =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: result !== null && typeof result === "object" && "content" in result
|
||||
? String((result as { content: unknown }).content)
|
||||
: "";
|
||||
if (text === "") return undefined;
|
||||
const hash = text.match(/Task\s*#\s*([0-9A-Za-z_-]+)/i);
|
||||
if (hash?.[1]) return hash[1];
|
||||
const bare = text.match(/\bid\s*[:=]\s*["']?([0-9A-Za-z_-]+)/i);
|
||||
if (bare?.[1]) return bare[1];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function taskIdsMatch(a: string | undefined, b: string): boolean {
|
||||
if (a === undefined) return false;
|
||||
return normalizeTaskId(a) === normalizeTaskId(b);
|
||||
}
|
||||
|
||||
function normalizeTaskId(id: string | undefined): string {
|
||||
if (id === undefined) return "";
|
||||
return id.trim().replace(/^#/, "");
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export function feishuContextTool(
|
||||
inputSchema: z.object({
|
||||
chat_id: z.string().describe("The Feishu chat id to read from."),
|
||||
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
|
||||
id: z.string().describe("The anchor id (message id or run id)."),
|
||||
id: z.string().describe("The anchor id: a message_id for trigger_message/status_card/reply, or a thread_id for thread."),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
if (args.chat_id !== ctx.boundChatId) {
|
||||
|
||||
@@ -10,22 +10,41 @@ import { randomUUID } from "node:crypto";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
|
||||
import type { CapabilitySecretPayload } from "./types.js";
|
||||
import { probeCapabilityCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
|
||||
import {
|
||||
CAPABILITY_IDS,
|
||||
type CapabilitySecretPayload,
|
||||
type DocmindCapabilitySecretPayload,
|
||||
type PbankCapabilitySecretPayload,
|
||||
secretKindForCapability,
|
||||
} from "./types.js";
|
||||
|
||||
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]);
|
||||
const KNOWN_CAPABILITY_IDS = new Set<string>(CAPABILITY_IDS);
|
||||
|
||||
export interface CapabilityCredentialInput {
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
export type CapabilityCredentialInput =
|
||||
| {
|
||||
readonly kind: "docmind";
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
| {
|
||||
readonly kind: "pbank";
|
||||
readonly baseUrl: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
readonly rightsStatus?: string;
|
||||
readonly rightsHolder?: string;
|
||||
readonly rightsScope?: string;
|
||||
readonly rightsNote?: string;
|
||||
};
|
||||
|
||||
export interface RotateCapabilityInput extends CapabilityCredentialInput {
|
||||
export interface RotateCapabilityInput {
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
readonly actorUserId: string;
|
||||
readonly credential: CapabilityCredentialInput;
|
||||
}
|
||||
|
||||
export interface CapabilityConnectionMetadata {
|
||||
@@ -45,25 +64,24 @@ export interface CapabilityConnectionWriteResult extends CapabilityConnectionMet
|
||||
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
|
||||
|
||||
export class CapabilityConnectionService {
|
||||
private readonly readinessProbe: CapabilityReadinessProbe;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly secrets: LocalSecretEnvelope,
|
||||
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
|
||||
) {}
|
||||
|
||||
readinessProbe: CapabilityReadinessProbe = probeCapabilityCredential,
|
||||
) {
|
||||
this.readinessProbe = readinessProbe;
|
||||
}
|
||||
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
|
||||
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
|
||||
throw new Error(`invalid capabilityId: ${input.capabilityId}`);
|
||||
}
|
||||
const payload = validateCredential(input);
|
||||
const payload = validateCredential(input.capabilityId, input.credential);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
});
|
||||
await this.readinessProbe({
|
||||
endpoint: payload.endpoint,
|
||||
accessKeyId: payload.accessKeyId,
|
||||
accessKeySecret: payload.accessKeySecret,
|
||||
});
|
||||
await this.readinessProbe(payload);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
@@ -142,6 +160,7 @@ export class CapabilityConnectionService {
|
||||
status: "ACTIVE",
|
||||
secretVersion: version,
|
||||
keyId: envelope.keyId,
|
||||
secretKind: payload.kind,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -209,14 +228,47 @@ export class CapabilityConnectionService {
|
||||
}
|
||||
}
|
||||
|
||||
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
|
||||
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
|
||||
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
|
||||
function validateCredential(
|
||||
capabilityId: string,
|
||||
input: CapabilityCredentialInput,
|
||||
): CapabilitySecretPayload {
|
||||
if (!KNOWN_CAPABILITY_IDS.has(capabilityId)) {
|
||||
throw new Error(`unsupported capabilityId: ${capabilityId}`);
|
||||
}
|
||||
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
|
||||
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
|
||||
const endpoint = nonEmpty(input.endpoint, "endpoint");
|
||||
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
|
||||
const expectedKind = secretKindForCapability(capabilityId);
|
||||
if (input.kind !== expectedKind) {
|
||||
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}, got ${input.kind}`);
|
||||
}
|
||||
|
||||
if (input.kind === "docmind") {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "docmind",
|
||||
accessKeyId: nonEmpty(input.accessKeyId, "accessKeyId"),
|
||||
accessKeySecret: nonEmpty(input.accessKeySecret, "accessKeySecret"),
|
||||
endpoint: nonEmpty(input.endpoint, "endpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = normalizeBaseUrl(nonEmpty(input.baseUrl, "baseUrl"));
|
||||
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://")) {
|
||||
throw new Error("baseUrl must be an absolute http(s) URL");
|
||||
}
|
||||
const rightsStatus = optional(input.rightsStatus);
|
||||
const rightsHolder = optional(input.rightsHolder);
|
||||
const rightsScope = optional(input.rightsScope);
|
||||
const rightsNote = optional(input.rightsNote);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "pbank",
|
||||
baseUrl,
|
||||
username: nonEmpty(input.username, "username"),
|
||||
password: nonEmpty(input.password, "password"),
|
||||
...(rightsStatus !== undefined ? { rightsStatus } : {}),
|
||||
...(rightsHolder !== undefined ? { rightsHolder } : {}),
|
||||
...(rightsScope !== undefined ? { rightsScope } : {}),
|
||||
...(rightsNote !== undefined ? { rightsNote } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toMetadata(
|
||||
@@ -264,3 +316,13 @@ function nonEmpty(value: string, label: string): string {
|
||||
if (trimmed === "") throw new Error(`${label} must not be empty`);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function optional(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
@@ -12,17 +12,19 @@ import type { PrismaClient } from "@prisma/client";
|
||||
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
|
||||
import {
|
||||
CapabilityConnectionUnavailable,
|
||||
normalizeCapabilitySecretPayload,
|
||||
secretKindForCapability,
|
||||
type CapabilitySecretPayload,
|
||||
type CapabilityId,
|
||||
} from "./types.js";
|
||||
|
||||
const CAPABILITY_PURPOSE = "capability";
|
||||
|
||||
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
|
||||
export type ResolvedCapabilityCredential = CapabilitySecretPayload & {
|
||||
readonly connectionId: string;
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the active capability credential for an organization. Throws
|
||||
@@ -54,17 +56,19 @@ export async function resolveCapabilityCredential(
|
||||
connectionId: connection.id,
|
||||
secretVersionId: version.id,
|
||||
};
|
||||
const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1);
|
||||
if (payload.schemaVersion !== 1) {
|
||||
throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`);
|
||||
const payload = normalizeCapabilitySecretPayload(
|
||||
secrets.decryptJson<unknown>(binding, version.envelope as unknown as SecretEnvelopeV1),
|
||||
);
|
||||
const expectedKind = secretKindForCapability(input.capabilityId);
|
||||
if (payload.kind !== expectedKind) {
|
||||
throw new Error(
|
||||
`capability ${input.capabilityId} secret kind mismatch: expected ${expectedKind}, got ${payload.kind}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
organizationId: connection.organizationId,
|
||||
capabilityId: connection.capabilityId,
|
||||
schemaVersion: 1,
|
||||
accessKeyId: payload.accessKeyId,
|
||||
accessKeySecret: payload.accessKeySecret,
|
||||
endpoint: payload.endpoint,
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
/**
|
||||
* ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind
|
||||
* credential by calling QueryDocParserStatus with a dummy id — a 400 (bad
|
||||
* request) means the credential is valid (the API accepted auth but rejected
|
||||
* the id); a 401/403 means the credential is bad.
|
||||
* ADR-0027: Capability readiness probes. Validate credentials before
|
||||
* activation. Docmind uses QueryDocParserStatus; PBank uses /login.
|
||||
*/
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
|
||||
import type { CapabilitySecretPayload, DocmindCapabilitySecretPayload, PbankCapabilitySecretPayload } from "./types.js";
|
||||
|
||||
export interface CapabilityReadinessInput {
|
||||
readonly endpoint: string;
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
}
|
||||
|
||||
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
|
||||
export type CapabilityReadinessProbe = (payload: CapabilitySecretPayload) => Promise<void>;
|
||||
|
||||
export class CapabilityReadinessError extends Error {
|
||||
constructor(
|
||||
@@ -33,7 +26,7 @@ export class CapabilityReadinessError extends Error {
|
||||
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
|
||||
* - network error → unreachable
|
||||
*/
|
||||
export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => {
|
||||
export async function probeDocmindCredentialPayload(input: DocmindCapabilitySecretPayload): Promise<void> {
|
||||
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
|
||||
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
|
||||
|
||||
@@ -61,6 +54,75 @@ export const probeDocmindCredential: CapabilityReadinessProbe = async (input) =>
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe PBank by logging in and ensuring a token is returned. */
|
||||
export async function probePbankCredentialPayload(input: PbankCapabilitySecretPayload): Promise<void> {
|
||||
const baseUrl = input.baseUrl.replace(/\/+$/, "");
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/login`, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ username: input.username, password: input.password }),
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_unreachable",
|
||||
"PBank credential readiness check could not reach the API",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
const data: unknown = await response.json().catch(() => null);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_rejected",
|
||||
`PBank credential rejected: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_rejected",
|
||||
`PBank login failed: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof data !== "object" ||
|
||||
data === null ||
|
||||
!("token" in data) ||
|
||||
typeof data.token !== "string" ||
|
||||
data.token.trim() === ""
|
||||
) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_rejected",
|
||||
"PBank login did not return a token",
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Default readiness probe: dispatch by secret kind. */
|
||||
export const probeCapabilityCredential: CapabilityReadinessProbe = async (payload) => {
|
||||
if (payload.kind === "docmind") {
|
||||
await probeDocmindCredentialPayload(payload);
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "pbank") {
|
||||
await probePbankCredentialPayload(payload);
|
||||
return;
|
||||
}
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_unsupported",
|
||||
"unsupported capability secret kind",
|
||||
"configuration",
|
||||
);
|
||||
};
|
||||
|
||||
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
|
||||
|
||||
@@ -19,9 +19,10 @@ import $DocmindClient, {
|
||||
QueryDocParserStatusRequest,
|
||||
} from "@alicloud/docmind-api20220711";
|
||||
import { RuntimeOptions } from "@alicloud/tea-util";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createReadStream, type ReadStream } from "node:fs";
|
||||
import { once } from "node:events";
|
||||
import { basename } from "node:path";
|
||||
import type { CapabilitySecretPayload } from "./types.js";
|
||||
import type { DocmindCapabilitySecretPayload } from "./types.js";
|
||||
|
||||
/** A single extracted image downloaded from the markdown's OSS image URLs. */
|
||||
export interface DocmindExtractedImage {
|
||||
@@ -43,7 +44,7 @@ export interface DocmindParseOptions {
|
||||
}
|
||||
|
||||
export interface CapabilityProviderClient {
|
||||
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
|
||||
parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
|
||||
}
|
||||
|
||||
export class DocmindClientError extends Error {
|
||||
@@ -61,11 +62,33 @@ export class DocmindClientError extends Error {
|
||||
const COST_PER_PAGE_USD = 0.0056;
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const POLL_TIMEOUT_MS = 5 * 60_000;
|
||||
/**
|
||||
* httpx (tea transport) defaults read/connect timeout to 3000ms when unset.
|
||||
* SubmitDocParserJobAdvance uploads the PDF to OSS; multi-MB files routinely
|
||||
* exceed 3s on the silo host (production: ReadTimeout(3000) on
|
||||
* docmind-api-cn-hangzhou.oss-cn-hangzhou.aliyuncs.com).
|
||||
*/
|
||||
export const DOCMIND_CONNECT_TIMEOUT_MS = 15_000;
|
||||
/** Allow slow / large PDF OSS uploads up to the same bound as job polling. */
|
||||
export const DOCMIND_READ_TIMEOUT_MS = POLL_TIMEOUT_MS;
|
||||
|
||||
/** RuntimeOptions for Docmind SDK calls that may upload or wait on the wire. */
|
||||
export function createDocmindRuntimeOptions(): RuntimeOptions {
|
||||
return new RuntimeOptions({
|
||||
connectTimeout: DOCMIND_CONNECT_TIMEOUT_MS,
|
||||
readTimeout: DOCMIND_READ_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
|
||||
|
||||
export class AliyunDocmindClient implements CapabilityProviderClient {
|
||||
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
|
||||
async parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
|
||||
// Open first so missing local inputs fail closed before touching the SDK.
|
||||
// Unhandled createReadStream('error') previously crashed the Hub process.
|
||||
const fileName = basename(options.inputFilePath);
|
||||
const fileStream = await openLocalFileStream(options.inputFilePath);
|
||||
|
||||
const config: DocmindConfig = {
|
||||
endpoint: credential.endpoint,
|
||||
accessKeyId: credential.accessKeyId,
|
||||
@@ -75,22 +98,21 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
|
||||
} as DocmindConfig;
|
||||
const client = new $DocmindClient.default(config);
|
||||
|
||||
// 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
|
||||
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
|
||||
// can't read; a Stream is uploaded as multipart form data).
|
||||
const fileName = basename(options.inputFilePath);
|
||||
const fileStream = createReadStream(options.inputFilePath);
|
||||
// Submit job with local file as a ReadStream (not a Buffer — the SDK
|
||||
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
|
||||
// can't read; a Stream is uploaded as multipart form data).
|
||||
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
|
||||
fileUrlObject: fileStream,
|
||||
fileName,
|
||||
outputFormat: ["markdown"],
|
||||
formulaEnhancement: true,
|
||||
});
|
||||
const runtime = new RuntimeOptions({});
|
||||
const runtime = createDocmindRuntimeOptions();
|
||||
let submitResponse;
|
||||
try {
|
||||
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
|
||||
} catch (e) {
|
||||
fileStream.destroy();
|
||||
throw new DocmindClientError(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"docmind_unreachable",
|
||||
@@ -229,3 +251,28 @@ function extractFilename(altText: string, url: string, index: number): string {
|
||||
if (base !== "" && base !== "/") return base;
|
||||
return `image_${index + 1}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a local file as a ReadStream only after the fd is successfully open.
|
||||
* createReadStream() emits asynchronous 'error' for missing paths; without a
|
||||
* listener that becomes an unhandled EventEmitter error and exits Node.
|
||||
*/
|
||||
async function openLocalFileStream(path: string): Promise<ReadStream> {
|
||||
const stream = createReadStream(path);
|
||||
try {
|
||||
await once(stream, "open");
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
throw new DocmindClientError(`input file not found: ${path}`, "docmind_rejected");
|
||||
}
|
||||
throw new DocmindClientError(err.message, "docmind_unreachable");
|
||||
}
|
||||
// After open, residual stream errors must not become unhandled and crash Hub.
|
||||
stream.on("error", () => {
|
||||
// The Aliyun SDK / destroy path owns consumption failures after open.
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* ADR-0027: pbank capability — Paradigm 题库 search/fetch tools for Agent runs.
|
||||
*
|
||||
* Invariants:
|
||||
* 1. Credential isolation — org ACTIVE connection is resolved in Hub; credentials
|
||||
* never reach the Agent process (ADR-0024/0027).
|
||||
* 2. Workspace containment — materialize path is always under the run workspace
|
||||
* (ADR-0018 AgentSurface).
|
||||
* 3. Mandatory fact — each successful tool call writes ≥1 UsageFact with
|
||||
* kind=external_capability and unit=requests (cost unknown unless reported).
|
||||
*/
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { resolveCapabilityCredential } from "./capabilityConnections.js";
|
||||
import {
|
||||
extractProblemId,
|
||||
HttpPbankClient,
|
||||
pbankRightsFromCredential,
|
||||
type PbankClient,
|
||||
} from "./pbankClient.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
asPbankSecret,
|
||||
type PbankCapabilitySecretPayload,
|
||||
} from "./types.js";
|
||||
|
||||
export const PBANK_CAPABILITY_ID = "pbank" as const;
|
||||
const PROVIDER_ID = "paradigm_pbank";
|
||||
const MAX_BATCH_SIZE = 20;
|
||||
const MAX_PROJECT_BYTES = 80 * 1024 * 1024;
|
||||
const MAX_PROJECT_TEXT_BYTES = 120 * 1024;
|
||||
const MAX_EXTRACTED_PROJECT_BYTES = 80 * 1024 * 1024;
|
||||
const MAX_INLINE_ASSET_BYTES = 1024 * 1024;
|
||||
const MAX_INLINE_ASSETS = 4;
|
||||
const CACHE_DIR_NAME = ".pbank-sources";
|
||||
|
||||
export class CapabilityPathEscape extends Error {
|
||||
constructor(readonly requested: string, readonly workspaceDir: string) {
|
||||
super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`);
|
||||
this.name = "CapabilityPathEscape";
|
||||
}
|
||||
}
|
||||
|
||||
export interface PbankServiceDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly secrets: LocalSecretEnvelope;
|
||||
readonly client?: PbankClient;
|
||||
}
|
||||
|
||||
export interface PbankToolContext {
|
||||
readonly organizationId: string;
|
||||
readonly runId: string;
|
||||
readonly workspaceDir: string;
|
||||
}
|
||||
|
||||
export interface PbankSearchArgs {
|
||||
readonly q?: string | undefined;
|
||||
readonly keywords?: readonly string[] | undefined;
|
||||
readonly pageNum?: number | undefined;
|
||||
readonly pageSize?: number | undefined;
|
||||
}
|
||||
|
||||
export interface PbankGetProblemArgs {
|
||||
readonly urlOrId: string;
|
||||
readonly includeProjects?: boolean | undefined;
|
||||
readonly materializeProjects?: boolean | undefined;
|
||||
readonly includeAssetImages?: boolean | undefined;
|
||||
readonly includeOccurrences?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PbankGetManyArgs {
|
||||
readonly urlsOrIds: readonly string[];
|
||||
readonly includeProjects?: boolean | undefined;
|
||||
readonly materializeProjects?: boolean | undefined;
|
||||
readonly includeAssetImages?: boolean | undefined;
|
||||
readonly includeOccurrences?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PbankToolResult {
|
||||
readonly data: unknown;
|
||||
readonly inlineImages: readonly { readonly data: string; readonly mimeType: string }[];
|
||||
}
|
||||
|
||||
interface TokenCacheEntry {
|
||||
readonly token: string;
|
||||
readonly expiresAt: number;
|
||||
readonly password: string;
|
||||
readonly username: string;
|
||||
readonly baseUrl: string;
|
||||
}
|
||||
export interface PbankService {
|
||||
searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<PbankToolResult>;
|
||||
getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise<PbankToolResult>;
|
||||
getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise<PbankToolResult>;
|
||||
}
|
||||
|
||||
export function createPbankService(deps: PbankServiceDeps): PbankService {
|
||||
const client = deps.client ?? new HttpPbankClient();
|
||||
const tokenCache = new Map<string, TokenCacheEntry>();
|
||||
|
||||
return {
|
||||
async searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<PbankToolResult> {
|
||||
const credential = await resolvePbankCredential(deps, ctx.organizationId);
|
||||
const token = await loginCached(client, tokenCache, credential);
|
||||
const pageNum = clampInt(args.pageNum ?? 1, 1, 10_000);
|
||||
const pageSize = clampInt(args.pageSize ?? 10, 1, 50);
|
||||
const result = await client.searchProblems(credential, token, {
|
||||
q: args.q,
|
||||
keywords: args.keywords,
|
||||
pageNum,
|
||||
pageSize,
|
||||
});
|
||||
const data = {
|
||||
rights: pbankRightsFromCredential(credential),
|
||||
...(typeof result === "object" && result !== null ? result : { result }),
|
||||
};
|
||||
await writeUsageFact(deps.prisma, ctx.runId, "search", 1);
|
||||
return { data, inlineImages: [] };
|
||||
},
|
||||
|
||||
async getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise<PbankToolResult> {
|
||||
const credential = await resolvePbankCredential(deps, ctx.organizationId);
|
||||
const bundle = await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, args);
|
||||
await writeUsageFact(deps.prisma, ctx.runId, extractProblemId(args.urlOrId), 1);
|
||||
return toToolResult(bundle);
|
||||
},
|
||||
|
||||
async getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise<PbankToolResult> {
|
||||
if (args.urlsOrIds.length === 0) {
|
||||
throw new Error("urlsOrIds must not be empty");
|
||||
}
|
||||
if (args.urlsOrIds.length > MAX_BATCH_SIZE) {
|
||||
throw new Error(`urlsOrIds exceeds max batch size ${MAX_BATCH_SIZE}`);
|
||||
}
|
||||
const credential = await resolvePbankCredential(deps, ctx.organizationId);
|
||||
const problems = [];
|
||||
for (const urlOrId of args.urlsOrIds) {
|
||||
problems.push(
|
||||
await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, {
|
||||
urlOrId,
|
||||
includeProjects: args.includeProjects,
|
||||
materializeProjects: args.materializeProjects,
|
||||
includeAssetImages: args.includeAssetImages,
|
||||
includeOccurrences: args.includeOccurrences,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await writeUsageFact(deps.prisma, ctx.runId, "batch", problems.length);
|
||||
return toToolResult({
|
||||
count: problems.length,
|
||||
rights: pbankRightsFromCredential(credential),
|
||||
problems,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function resolvePbankCredential(
|
||||
deps: PbankServiceDeps,
|
||||
organizationId: string,
|
||||
): Promise<PbankCapabilitySecretPayload & { connectionId: string }> {
|
||||
const resolved = await resolveCapabilityCredential(deps.prisma, deps.secrets, {
|
||||
organizationId,
|
||||
capabilityId: PBANK_CAPABILITY_ID,
|
||||
});
|
||||
const secret = asPbankSecret(resolved);
|
||||
return { ...secret, connectionId: resolved.connectionId };
|
||||
}
|
||||
|
||||
async function loginCached(
|
||||
client: PbankClient,
|
||||
cache: Map<string, TokenCacheEntry>,
|
||||
credential: PbankCapabilitySecretPayload & { connectionId: string },
|
||||
): Promise<string> {
|
||||
const now = Date.now();
|
||||
const cached = cache.get(credential.connectionId);
|
||||
if (
|
||||
cached !== undefined &&
|
||||
cached.expiresAt - 60_000 > now &&
|
||||
cached.username === credential.username &&
|
||||
cached.password === credential.password &&
|
||||
cached.baseUrl === credential.baseUrl
|
||||
) {
|
||||
return cached.token;
|
||||
}
|
||||
const login = await client.login(credential);
|
||||
cache.set(credential.connectionId, {
|
||||
token: login.token,
|
||||
expiresAt: login.expiresAt,
|
||||
username: credential.username,
|
||||
password: credential.password,
|
||||
baseUrl: credential.baseUrl,
|
||||
});
|
||||
return login.token;
|
||||
}
|
||||
|
||||
async function getProblemBundle(
|
||||
client: PbankClient,
|
||||
cache: Map<string, TokenCacheEntry>,
|
||||
credential: PbankCapabilitySecretPayload & { connectionId: string },
|
||||
workspaceDir: string,
|
||||
args: PbankGetProblemArgs,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const id = extractProblemId(args.urlOrId);
|
||||
const token = await loginCached(client, cache, credential);
|
||||
const problem = await client.getProblem(credential, token, id);
|
||||
const result: Record<string, unknown> = {
|
||||
id,
|
||||
source: `${credential.baseUrl.replace(/\/+$/, "")}/problem/${id}`,
|
||||
rights: pbankRightsFromCredential(credential),
|
||||
problem,
|
||||
};
|
||||
|
||||
const includeProjects = args.includeProjects !== false;
|
||||
const materializeProjects = args.materializeProjects !== false;
|
||||
const includeAssetImages = args.includeAssetImages !== false;
|
||||
if (includeProjects) {
|
||||
const projects: Record<string, unknown> = {};
|
||||
for (const target of ["problem", "answer"] as const) {
|
||||
try {
|
||||
projects[target] = await downloadAndMaterializeProject({
|
||||
client,
|
||||
credential,
|
||||
token,
|
||||
id,
|
||||
target,
|
||||
workspaceDir,
|
||||
materialize: materializeProjects,
|
||||
includeAssetImages,
|
||||
});
|
||||
} catch (error) {
|
||||
projects[target] = {
|
||||
target,
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
result.projects = projects;
|
||||
}
|
||||
|
||||
if (args.includeOccurrences === true) {
|
||||
try {
|
||||
result.occurrences = await client.getOccurrences(credential, token, id);
|
||||
} catch (error) {
|
||||
result.occurrences = {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function downloadAndMaterializeProject(input: {
|
||||
readonly client: PbankClient;
|
||||
readonly credential: PbankCapabilitySecretPayload;
|
||||
readonly token: string;
|
||||
readonly id: string;
|
||||
readonly target: "problem" | "answer";
|
||||
readonly workspaceDir: string;
|
||||
readonly materialize: boolean;
|
||||
readonly includeAssetImages: boolean;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const downloaded = await input.client.downloadProject(
|
||||
input.credential,
|
||||
input.token,
|
||||
input.id,
|
||||
input.target,
|
||||
);
|
||||
if (downloaded.buffer.byteLength > MAX_PROJECT_BYTES) {
|
||||
throw new Error(`project archive exceeds ${MAX_PROJECT_BYTES} bytes`);
|
||||
}
|
||||
|
||||
if (!isZipBuffer(downloaded.buffer, downloaded.contentType)) {
|
||||
const text = decodeUtf8IfText(downloaded.buffer);
|
||||
if (text !== null) {
|
||||
return {
|
||||
target: input.target,
|
||||
status: "text",
|
||||
bytes: downloaded.buffer.byteLength,
|
||||
content: text.slice(0, MAX_PROJECT_TEXT_BYTES),
|
||||
omitted: text.length > MAX_PROJECT_TEXT_BYTES ? [{ reason: "text truncated" }] : [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
target: input.target,
|
||||
status: "binary",
|
||||
bytes: downloaded.buffer.byteLength,
|
||||
contentType: downloaded.contentType,
|
||||
};
|
||||
}
|
||||
|
||||
return readProjectZip(downloaded.buffer, {
|
||||
id: input.id,
|
||||
target: input.target,
|
||||
workspaceDir: input.workspaceDir,
|
||||
materialize: input.materialize,
|
||||
includeAssetImages: input.includeAssetImages,
|
||||
});
|
||||
}
|
||||
|
||||
async function readProjectZip(
|
||||
buffer: Buffer,
|
||||
options: {
|
||||
readonly id: string;
|
||||
readonly target: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly materialize: boolean;
|
||||
readonly includeAssetImages: boolean;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Pure Node unzip (store/deflate). Do not shell out to host `unzip` —
|
||||
// silo service PATH/tooling must not gate 题库 materialize.
|
||||
const entries = listZipEntries(buffer);
|
||||
const cacheRoot = confineToWorkspace(CACHE_DIR_NAME, options.workspaceDir);
|
||||
const cacheDir = join(cacheRoot, safePathSegment(options.id), safePathSegment(options.target));
|
||||
const extractDir = options.materialize ? join(cacheDir, "source") : null;
|
||||
const zipPath = options.materialize ? join(cacheDir, `${options.target}.zip`) : null;
|
||||
|
||||
if (options.materialize) {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
if (extractDir !== null) {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
await mkdir(extractDir, { recursive: true });
|
||||
}
|
||||
if (zipPath !== null) await writeFile(zipPath, buffer);
|
||||
}
|
||||
|
||||
const files: Array<Record<string, unknown>> = [];
|
||||
const assets: Array<{
|
||||
path: string;
|
||||
localPath: string | null;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
inlineData?: string;
|
||||
}> = [];
|
||||
const extractedFiles: Array<Record<string, unknown>> = [];
|
||||
const omitted: Array<Record<string, unknown>> = [];
|
||||
let usedTextBytes = 0;
|
||||
let usedExtractedBytes = 0;
|
||||
let inlineAssetCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!safeZipPath(entry.name)) {
|
||||
omitted.push({ path: entry.name, reason: "unsafe path" });
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith("/") || entry.isDirectory) continue;
|
||||
if (usedExtractedBytes >= MAX_EXTRACTED_PROJECT_BYTES) {
|
||||
omitted.push({ path: entry.name, reason: "extracted byte limit reached" });
|
||||
continue;
|
||||
}
|
||||
const maxEntryBytes = Math.max(
|
||||
1,
|
||||
Math.min(MAX_PROJECT_BYTES, MAX_EXTRACTED_PROJECT_BYTES - usedExtractedBytes),
|
||||
);
|
||||
try {
|
||||
const entryBuffer = inflateZipEntry(buffer, entry, maxEntryBytes);
|
||||
usedExtractedBytes += entryBuffer.length;
|
||||
let localPath: string | null = null;
|
||||
if (extractDir !== null) {
|
||||
localPath = join(extractDir, ...entry.name.split(/[\\/]+/));
|
||||
await mkdir(dirname(localPath), { recursive: true });
|
||||
await writeFile(localPath, entryBuffer);
|
||||
extractedFiles.push({
|
||||
path: entry.name,
|
||||
localPath: toWorkspaceRelative(options.workspaceDir, localPath),
|
||||
bytes: entryBuffer.length,
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = assetMimeType(entry.name);
|
||||
if (mimeType !== null) {
|
||||
const asset: {
|
||||
path: string;
|
||||
localPath: string | null;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
inlineData?: string;
|
||||
} = {
|
||||
path: entry.name,
|
||||
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
|
||||
bytes: entryBuffer.length,
|
||||
mimeType,
|
||||
};
|
||||
if (
|
||||
options.includeAssetImages &&
|
||||
isInlineImageMime(mimeType) &&
|
||||
entryBuffer.length <= MAX_INLINE_ASSET_BYTES &&
|
||||
inlineAssetCount < MAX_INLINE_ASSETS
|
||||
) {
|
||||
asset.inlineData = entryBuffer.toString("base64");
|
||||
inlineAssetCount += 1;
|
||||
}
|
||||
assets.push(asset);
|
||||
}
|
||||
|
||||
if (isTextLikePath(entry.name)) {
|
||||
if (usedTextBytes >= MAX_PROJECT_TEXT_BYTES) {
|
||||
omitted.push({ path: entry.name, reason: "text byte limit reached" });
|
||||
continue;
|
||||
}
|
||||
const content = decodeUtf8IfText(entryBuffer);
|
||||
if (content === null) {
|
||||
omitted.push({ path: entry.name, reason: "text decode failed" });
|
||||
continue;
|
||||
}
|
||||
usedTextBytes += Buffer.byteLength(content, "utf8");
|
||||
files.push({
|
||||
path: entry.name,
|
||||
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
|
||||
content,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
omitted.push({
|
||||
path: entry.name,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
target: options.target,
|
||||
status: "downloaded",
|
||||
bytes: buffer.length,
|
||||
zipPath: zipPath === null ? null : toWorkspaceRelative(options.workspaceDir, zipPath),
|
||||
extractDir: extractDir === null ? null : toWorkspaceRelative(options.workspaceDir, extractDir),
|
||||
extractedFiles,
|
||||
files,
|
||||
assets: assets.map(({ inlineData: _inlineData, ...asset }) => asset),
|
||||
inlineAssets: assets
|
||||
.filter((asset) => asset.inlineData !== undefined)
|
||||
.map((asset) => ({
|
||||
path: asset.path,
|
||||
localPath: asset.localPath,
|
||||
bytes: asset.bytes,
|
||||
mimeType: asset.mimeType,
|
||||
data: asset.inlineData,
|
||||
})),
|
||||
omitted,
|
||||
};
|
||||
}
|
||||
|
||||
interface ZipEntryMeta {
|
||||
readonly name: string;
|
||||
readonly method: number;
|
||||
readonly compressedSize: number;
|
||||
readonly uncompressedSize: number;
|
||||
readonly localHeaderOffset: number;
|
||||
readonly isDirectory: boolean;
|
||||
}
|
||||
|
||||
/** Minimal ZIP central-directory reader (store + deflate). No external unzip binary. */
|
||||
function listZipEntries(buffer: Buffer): ZipEntryMeta[] {
|
||||
let eocd = -1;
|
||||
const minEocd = Math.max(0, buffer.length - (22 + 0xffff));
|
||||
for (let i = buffer.length - 22; i >= minEocd; i -= 1) {
|
||||
if (buffer.readUInt32LE(i) === 0x06054b50) {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0) throw new Error("invalid zip: missing end of central directory");
|
||||
|
||||
const totalEntries = buffer.readUInt16LE(eocd + 10);
|
||||
const centralSize = buffer.readUInt32LE(eocd + 12);
|
||||
const centralOffset = buffer.readUInt32LE(eocd + 16);
|
||||
if (centralOffset + centralSize > buffer.length) {
|
||||
throw new Error("invalid zip: central directory out of range");
|
||||
}
|
||||
|
||||
const entries: ZipEntryMeta[] = [];
|
||||
let offset = centralOffset;
|
||||
for (let i = 0; i < totalEntries; i += 1) {
|
||||
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) {
|
||||
throw new Error("invalid zip: bad central directory entry");
|
||||
}
|
||||
const method = buffer.readUInt16LE(offset + 10);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
||||
const nameLen = buffer.readUInt16LE(offset + 28);
|
||||
const extraLen = buffer.readUInt16LE(offset + 30);
|
||||
const commentLen = buffer.readUInt16LE(offset + 32);
|
||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
||||
const nameStart = offset + 46;
|
||||
const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
|
||||
entries.push({
|
||||
name,
|
||||
method,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
localHeaderOffset,
|
||||
isDirectory: name.endsWith("/"),
|
||||
});
|
||||
offset = nameStart + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function inflateZipEntry(buffer: Buffer, entry: ZipEntryMeta, maxBytes: number): Buffer {
|
||||
if (entry.uncompressedSize > maxBytes) {
|
||||
throw new Error(`zip entry exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
const local = entry.localHeaderOffset;
|
||||
if (local + 30 > buffer.length || buffer.readUInt32LE(local) !== 0x04034b50) {
|
||||
throw new Error("invalid zip: bad local header");
|
||||
}
|
||||
const nameLen = buffer.readUInt16LE(local + 26);
|
||||
const extraLen = buffer.readUInt16LE(local + 28);
|
||||
const dataStart = local + 30 + nameLen + extraLen;
|
||||
const dataEnd = dataStart + entry.compressedSize;
|
||||
if (dataEnd > buffer.length) throw new Error("invalid zip: compressed data out of range");
|
||||
const compressed = buffer.subarray(dataStart, dataEnd);
|
||||
|
||||
if (entry.method === 0) {
|
||||
if (compressed.length > maxBytes) throw new Error(`zip entry exceeds ${maxBytes} bytes`);
|
||||
return Buffer.from(compressed);
|
||||
}
|
||||
if (entry.method === 8) {
|
||||
return Buffer.from(inflateRawSync(compressed, { maxOutputLength: maxBytes }));
|
||||
}
|
||||
throw new Error(`unsupported zip compression method ${entry.method}`);
|
||||
}
|
||||
|
||||
|
||||
async function writeUsageFact(
|
||||
prisma: PrismaClient,
|
||||
runId: string,
|
||||
correlationId: string,
|
||||
quantity: number,
|
||||
): Promise<void> {
|
||||
const descriptor = CAPABILITIES[PBANK_CAPABILITY_ID];
|
||||
await prisma.usageFact.create({
|
||||
data: {
|
||||
runId,
|
||||
occurredAt: new Date(),
|
||||
kind: "external_capability",
|
||||
provider: PROVIDER_ID,
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
quantity,
|
||||
unit: descriptor.meteringUnit,
|
||||
costUsd: null,
|
||||
costSource: "unknown",
|
||||
capabilityId: PBANK_CAPABILITY_ID,
|
||||
correlationId,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toToolResult(data: unknown): PbankToolResult {
|
||||
const inlineImages: Array<{ data: string; mimeType: string }> = [];
|
||||
collectInlineAssets(data, inlineImages);
|
||||
return { data, inlineImages };
|
||||
}
|
||||
|
||||
function collectInlineAssets(
|
||||
value: unknown,
|
||||
out: Array<{ data: string; mimeType: string }>,
|
||||
): void {
|
||||
if (typeof value !== "object" || value === null) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectInlineAssets(item, out);
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (Array.isArray(record.inlineAssets)) {
|
||||
for (const asset of record.inlineAssets) {
|
||||
if (typeof asset !== "object" || asset === null) continue;
|
||||
const item = asset as Record<string, unknown>;
|
||||
if (typeof item.data === "string" && typeof item.mimeType === "string") {
|
||||
out.push({ data: item.data, mimeType: item.mimeType });
|
||||
}
|
||||
}
|
||||
delete record.inlineAssets;
|
||||
}
|
||||
if (record.projects !== undefined) collectInlineAssets(record.projects, out);
|
||||
if (Array.isArray(record.problems)) {
|
||||
for (const problem of record.problems) collectInlineAssets(problem, out);
|
||||
}
|
||||
}
|
||||
|
||||
function confineToWorkspace(requestedPath: string, workspaceDir: string): string {
|
||||
const resolved = resolve(workspaceDir, requestedPath);
|
||||
const rel = relative(workspaceDir, resolved);
|
||||
if (rel.startsWith("..") || rel === "") {
|
||||
throw new CapabilityPathEscape(requestedPath, workspaceDir);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function toWorkspaceRelative(workspaceDir: string, absolutePath: string): string {
|
||||
const rel = relative(workspaceDir, absolutePath);
|
||||
if (rel.startsWith("..")) {
|
||||
throw new CapabilityPathEscape(absolutePath, workspaceDir);
|
||||
}
|
||||
return rel;
|
||||
}
|
||||
|
||||
function decodeUtf8IfText(buffer: Buffer): string | null {
|
||||
const text = buffer.toString("utf8");
|
||||
const replacementRatio = (text.match(/\uFFFD/g) ?? []).length / Math.max(text.length, 1);
|
||||
if (replacementRatio > 0.02) return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
function isZipBuffer(buffer: Buffer, contentType: string): boolean {
|
||||
if (contentType.includes("zip")) return true;
|
||||
return buffer.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b;
|
||||
}
|
||||
|
||||
function isTextLikePath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase();
|
||||
return [".typ", ".md", ".txt", ".tex", ".json", ".yaml", ".yml", ".toml", ".csv"].some((ext) =>
|
||||
lower.endsWith(ext),
|
||||
);
|
||||
}
|
||||
|
||||
function assetMimeType(filePath: string): string | null {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isInlineImageMime(mimeType: string): boolean {
|
||||
return mimeType.startsWith("image/") && mimeType !== "image/svg+xml";
|
||||
}
|
||||
|
||||
function safeZipPath(entry: string): boolean {
|
||||
if (entry.includes("\0")) return false;
|
||||
const normalized = entry.replace(/\\/g, "/");
|
||||
if (normalized.startsWith("/") || normalized.includes("://")) return false;
|
||||
for (const part of normalized.split("/")) {
|
||||
if (part === ".." || part === "") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function safePathSegment(value: string): string {
|
||||
const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
if (cleaned === "" || cleaned === "." || cleaned === "..") return "item";
|
||||
return cleaned.slice(0, 80);
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
const n = Math.trunc(value);
|
||||
if (n < min) return min;
|
||||
if (n > max) return max;
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Paradigm PBank (题库) HTTP client.
|
||||
*
|
||||
* Credentials are injected per call from the org capability connection
|
||||
* (ADR-0024/0027) — never read from process env and never passed to the Agent.
|
||||
*/
|
||||
import type { PbankCapabilitySecretPayload } from "./types.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://pbank.paradigm-edu.net/api";
|
||||
const POSITIVE_RIGHTS_STATUSES = new Set(["owned", "exclusive_license", "licensed_adapt"]);
|
||||
|
||||
export interface PbankRights {
|
||||
readonly status: string;
|
||||
readonly holder: string;
|
||||
readonly scope: string;
|
||||
readonly note: string;
|
||||
readonly derivativeUseAllowed: boolean;
|
||||
readonly source: "operator_confirmed";
|
||||
}
|
||||
|
||||
export interface PbankLoginResult {
|
||||
readonly token: string;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface PbankSearchInput {
|
||||
readonly q?: string | undefined;
|
||||
readonly keywords?: readonly string[] | undefined;
|
||||
readonly pageNum: number;
|
||||
readonly pageSize: number;
|
||||
}
|
||||
|
||||
export interface PbankProjectDownload {
|
||||
readonly buffer: Buffer;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export class PbankClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: "pbank_unreachable" | "pbank_rejected" | "pbank_invalid_response",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PbankClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface PbankClient {
|
||||
login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult>;
|
||||
searchProblems(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
input: PbankSearchInput,
|
||||
): Promise<unknown>;
|
||||
getProblem(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
): Promise<unknown>;
|
||||
getOccurrences(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
): Promise<unknown>;
|
||||
downloadProject(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
target: "problem" | "answer",
|
||||
): Promise<PbankProjectDownload>;
|
||||
}
|
||||
|
||||
export function pbankRightsFromCredential(credential: PbankCapabilitySecretPayload): PbankRights {
|
||||
const status = normalizeRightsStatus(credential.rightsStatus ?? "unknown");
|
||||
const holder = (credential.rightsHolder ?? "").trim() || "Paradigm Education";
|
||||
const scope = (credential.rightsScope ?? "").trim() || "internal teaching-material production";
|
||||
const note =
|
||||
(credential.rightsNote ?? "").trim() ||
|
||||
"All content returned by this capability is operator-confirmed as owned by Paradigm Education or sufficiently licensed for excerpting, rewriting, and adaptation within current teaching-material projects.";
|
||||
return {
|
||||
status,
|
||||
holder,
|
||||
scope,
|
||||
note,
|
||||
derivativeUseAllowed: POSITIVE_RIGHTS_STATUSES.has(status),
|
||||
source: "operator_confirmed",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePbankBaseUrl(value: string | undefined): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (raw === "") return DEFAULT_BASE_URL;
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function extractProblemId(value: string): string {
|
||||
const input = value.trim();
|
||||
if (input === "") throw new PbankClientError("urlOrId is required", "pbank_invalid_response");
|
||||
|
||||
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
|
||||
const direct = input.match(uuidPattern);
|
||||
if (direct !== null) return direct[0]!;
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
for (const key of ["id", "problemId", "problem_id"] as const) {
|
||||
const fromQuery = url.searchParams.get(key);
|
||||
const match = fromQuery?.match(uuidPattern);
|
||||
if (match !== null && match !== undefined) return match[0]!;
|
||||
}
|
||||
} catch {
|
||||
// Not a URL.
|
||||
}
|
||||
|
||||
throw new PbankClientError(`Could not find a problem UUID in: ${input}`, "pbank_invalid_response");
|
||||
}
|
||||
|
||||
export class HttpPbankClient implements PbankClient {
|
||||
async login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult> {
|
||||
const data = await this.requestJson(credential, "/login", {
|
||||
method: "POST",
|
||||
body: { username: credential.username, password: credential.password },
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
throw new PbankClientError("PBank login returned non-object", "pbank_invalid_response");
|
||||
}
|
||||
const record = data as Record<string, unknown>;
|
||||
if (typeof record.token !== "string" || record.token.trim() === "") {
|
||||
throw new PbankClientError("PBank login did not return a token", "pbank_invalid_response");
|
||||
}
|
||||
const expiresAt =
|
||||
typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt)
|
||||
? record.expiresAt
|
||||
: Date.now() + 30 * 60_000;
|
||||
return { token: record.token, expiresAt };
|
||||
}
|
||||
|
||||
async searchProblems(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
input: PbankSearchInput,
|
||||
): Promise<unknown> {
|
||||
return this.requestJson(credential, "/problem/query", {
|
||||
token,
|
||||
query: {
|
||||
q: input.q,
|
||||
keywords: input.keywords !== undefined && input.keywords.length > 0 ? input.keywords.join(",") : undefined,
|
||||
pageNum: input.pageNum,
|
||||
pageSize: input.pageSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getProblem(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
): Promise<unknown> {
|
||||
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}`, { token });
|
||||
}
|
||||
|
||||
async getOccurrences(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
): Promise<unknown> {
|
||||
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}/occurrence`, {
|
||||
token,
|
||||
query: { pageNum: 1, pageSize: 20 },
|
||||
});
|
||||
}
|
||||
|
||||
async downloadProject(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
target: "problem" | "answer",
|
||||
): Promise<PbankProjectDownload> {
|
||||
const url = buildUrl(credential.baseUrl, `/problem/${encodeURIComponent(id)}/project/${target}`);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${token}`, accept: "*/*" },
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new PbankClientError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"pbank_unreachable",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new PbankClientError(
|
||||
`PBank project download failed: ${response.status}`,
|
||||
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return { buffer: Buffer.from(arrayBuffer), contentType };
|
||||
}
|
||||
|
||||
private async requestJson(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
route: string,
|
||||
options: {
|
||||
readonly method?: string;
|
||||
readonly token?: string;
|
||||
readonly body?: unknown;
|
||||
readonly query?: Record<string, string | number | undefined>;
|
||||
readonly timeoutMs?: number;
|
||||
},
|
||||
): Promise<unknown> {
|
||||
const url = buildUrl(credential.baseUrl, route, options.query);
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
if (options.token !== undefined) headers.authorization = `Bearer ${options.token}`;
|
||||
if (options.body !== undefined) headers["content-type"] = "application/json";
|
||||
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
||||
};
|
||||
if (options.body !== undefined) init.body = JSON.stringify(options.body);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch (error) {
|
||||
throw new PbankClientError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"pbank_unreachable",
|
||||
);
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new PbankClientError(
|
||||
extractErrorMessage(data, `PBank API request failed: ${response.status}`),
|
||||
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
baseUrl: string,
|
||||
route: string,
|
||||
query?: Record<string, string | number | undefined>,
|
||||
): string {
|
||||
const url = new URL(route.replace(/^\/+/, ""), `${normalizePbankBaseUrl(baseUrl)}/`);
|
||||
if (query !== undefined) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === "") continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function extractErrorMessage(data: unknown, fallback: string): string {
|
||||
if (typeof data === "object" && data !== null) {
|
||||
const record = data as Record<string, unknown>;
|
||||
if (typeof record.message === "string" && record.message !== "") return record.message;
|
||||
if (typeof record.error === "string" && record.error !== "") return record.error;
|
||||
}
|
||||
if (typeof data === "string" && data !== "") return data;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeRightsStatus(value: string): string {
|
||||
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
||||
if (normalized === "") return "unknown";
|
||||
return normalized;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { resolveCapabilityCredential } from "./capabilityConnections.js";
|
||||
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
asDocmindSecret,
|
||||
type CapabilityAdapter,
|
||||
type CapabilityInvocationInput,
|
||||
type CapabilityInvocationResult,
|
||||
@@ -63,6 +64,135 @@ export interface PdfToMdBundleDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
}
|
||||
|
||||
/** Default max concurrent Docmind jobs for one convert_pdf_to_md batch call. */
|
||||
export const DEFAULT_PDF_TO_MD_CONCURRENCY = 3;
|
||||
/** Hard ceiling for agent-requested concurrency (also clamps env). */
|
||||
export const MAX_PDF_TO_MD_CONCURRENCY = 8;
|
||||
/** Max PDFs accepted in one batch tool call. */
|
||||
export const MAX_PDF_TO_MD_BATCH_ITEMS = 32;
|
||||
|
||||
export function clampPdfToMdConcurrency(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_PDF_TO_MD_CONCURRENCY;
|
||||
const n = Math.trunc(value);
|
||||
if (n < 1) return 1;
|
||||
if (n > MAX_PDF_TO_MD_CONCURRENCY) return MAX_PDF_TO_MD_CONCURRENCY;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Read HUB_PDF_TO_MD_MAX_CONCURRENT (default 3, max 8). */
|
||||
export function readPdfToMdConcurrency(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): number {
|
||||
const raw = env["HUB_PDF_TO_MD_MAX_CONCURRENT"]?.trim();
|
||||
if (raw === undefined || raw === "") return DEFAULT_PDF_TO_MD_CONCURRENCY;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
throw new Error(`HUB_PDF_TO_MD_MAX_CONCURRENT must be a positive integer, got ${raw}`);
|
||||
}
|
||||
return clampPdfToMdConcurrency(parsed);
|
||||
}
|
||||
|
||||
export interface PdfToMdBatchItem {
|
||||
readonly inputPath: string;
|
||||
readonly outputDir: string;
|
||||
}
|
||||
|
||||
export type PdfToMdBatchItemResult =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly inputPath: string;
|
||||
readonly outputDir: string;
|
||||
readonly result: CapabilityInvocationResult;
|
||||
}
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly inputPath: string;
|
||||
readonly outputDir: string;
|
||||
readonly error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run worker over items with bounded parallelism. Order of results matches
|
||||
* input order. Rejects in worker are not swallowed — caller should catch.
|
||||
*/
|
||||
export async function mapPool<T, R>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (items.length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(Math.trunc(concurrency), items.length));
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
async function runWorker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await worker(items[index]!, index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: limit }, () => runWorker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert multiple PDFs with bounded concurrency. Each item is attribute-
|
||||
* independent (own paths + own UsageFact). Failures are per-item and do not
|
||||
* cancel siblings; order matches `items`.
|
||||
*/
|
||||
export async function invokePdfToMdBatch(
|
||||
adapter: CapabilityAdapter,
|
||||
base: Omit<CapabilityInvocationInput, "inputPath" | "outputDir">,
|
||||
items: readonly PdfToMdBatchItem[],
|
||||
concurrency: number = DEFAULT_PDF_TO_MD_CONCURRENCY,
|
||||
): Promise<PdfToMdBatchItemResult[]> {
|
||||
if (items.length === 0) {
|
||||
throw new Error("pdf_to_md batch requires at least one item");
|
||||
}
|
||||
if (items.length > MAX_PDF_TO_MD_BATCH_ITEMS) {
|
||||
throw new Error(
|
||||
`pdf_to_md batch supports at most ${MAX_PDF_TO_MD_BATCH_ITEMS} items per call (got ${items.length})`,
|
||||
);
|
||||
}
|
||||
const seenOutputDirs = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.inputPath.trim() === "" || item.outputDir.trim() === "") {
|
||||
throw new Error("pdf_to_md batch items require non-empty inputPath and outputDir");
|
||||
}
|
||||
const key = item.outputDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (seenOutputDirs.has(key)) {
|
||||
throw new Error(
|
||||
`pdf_to_md batch items must use distinct output_dir values; duplicate: ${item.outputDir}`,
|
||||
);
|
||||
}
|
||||
seenOutputDirs.add(key);
|
||||
}
|
||||
const limit = clampPdfToMdConcurrency(concurrency);
|
||||
return mapPool(items, limit, async (item) => {
|
||||
try {
|
||||
const result = await adapter.invoke({
|
||||
...base,
|
||||
inputPath: item.inputPath,
|
||||
outputDir: item.outputDir,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
inputPath: item.inputPath,
|
||||
outputDir: item.outputDir,
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
inputPath: item.inputPath,
|
||||
outputDir: item.outputDir,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
|
||||
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
|
||||
return {
|
||||
@@ -83,7 +213,7 @@ export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityA
|
||||
// 3. Call the backing service.
|
||||
let result;
|
||||
try {
|
||||
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
|
||||
result = await deps.client.parse(asDocmindSecret(credential), { inputFilePath: absoluteInput });
|
||||
} catch (e) {
|
||||
if (e instanceof DocmindClientError) throw e;
|
||||
throw new DocmindClientError(
|
||||
|
||||
+123
-7
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* ADR-0027: External capability types shared across the adapter layer.
|
||||
*
|
||||
* A capability is a platform-registered, org-enabled document/media transform
|
||||
* A capability is a platform-registered, org-enabled external service
|
||||
* invoked as a side effect of an AgentRun. The adapter resolves the org's
|
||||
* active capability connection, calls the backing service via an injectable
|
||||
* client, writes output into the run's workspace (AgentSurface, ADR-0018),
|
||||
* and records consumption on a UsageFact (ADR-0026).
|
||||
* client, writes output into the run's workspace when applicable (AgentSurface,
|
||||
* ADR-0018), and records consumption on a UsageFact (ADR-0026).
|
||||
*/
|
||||
import type { PrismaClient, Prisma } from "@prisma/client";
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { PrismaClient, Prisma } from "@prisma/client";
|
||||
export const CAPABILITY_IDS = [
|
||||
"pdf_to_md_bundle",
|
||||
"audio_video_to_text",
|
||||
"pbank",
|
||||
] as const;
|
||||
|
||||
export type CapabilityId = (typeof CAPABILITY_IDS)[number];
|
||||
@@ -27,6 +28,7 @@ export interface CapabilityDescriptor {
|
||||
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
|
||||
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
|
||||
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
|
||||
pbank: { id: "pbank", meteringUnit: "requests" },
|
||||
};
|
||||
|
||||
/** Input passed to a capability adapter invocation. */
|
||||
@@ -59,7 +61,7 @@ export interface CapabilityConsumption {
|
||||
readonly model: string | null;
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
/** Non-token meter (page count, audio seconds). */
|
||||
/** Non-token meter (page count, audio seconds, request count). */
|
||||
readonly quantity: number;
|
||||
readonly unit: string;
|
||||
/** USD cost if the service reported one; null = unknown (ADR-0022). */
|
||||
@@ -80,15 +82,39 @@ export interface CapabilityAdapter {
|
||||
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
|
||||
}
|
||||
|
||||
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
|
||||
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
|
||||
export interface CapabilitySecretPayload {
|
||||
/** Alibaba Cloud Document Mind (docmind) AccessKey + endpoint. */
|
||||
export interface DocmindCapabilitySecretPayload {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: "docmind";
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paradigm PBank (题库) login credentials.
|
||||
* Rights fields are operator-confirmed license signals echoed to the agent;
|
||||
* the credential secret itself never reaches the agent process (ADR-0024/0027).
|
||||
*/
|
||||
export interface PbankCapabilitySecretPayload {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: "pbank";
|
||||
readonly baseUrl: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
readonly rightsStatus?: string;
|
||||
readonly rightsHolder?: string;
|
||||
readonly rightsScope?: string;
|
||||
readonly rightsNote?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
|
||||
* Discriminated by `kind`. Legacy envelopes without `kind` are normalized to
|
||||
* `docmind` when accessKey fields are present.
|
||||
*/
|
||||
export type CapabilitySecretPayload = DocmindCapabilitySecretPayload | PbankCapabilitySecretPayload;
|
||||
|
||||
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
|
||||
export class CapabilityConnectionUnavailable extends Error {
|
||||
constructor(readonly capabilityId: string, readonly organizationId: string) {
|
||||
@@ -99,3 +125,93 @@ export class CapabilityConnectionUnavailable extends Error {
|
||||
|
||||
/** Prisma transaction client type alias (for resolver signatures). */
|
||||
export type TxClient = Prisma.TransactionClient;
|
||||
|
||||
/** Map capability id → expected secret kind. */
|
||||
export function secretKindForCapability(capabilityId: string): "docmind" | "pbank" {
|
||||
switch (capabilityId) {
|
||||
case "pdf_to_md_bundle":
|
||||
case "audio_video_to_text":
|
||||
return "docmind";
|
||||
case "pbank":
|
||||
return "pbank";
|
||||
default:
|
||||
throw new Error(`unsupported capabilityId: ${capabilityId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a decrypted envelope payload into a full CapabilitySecretPayload.
|
||||
* Accepts legacy docmind payloads that omit `kind`.
|
||||
*/
|
||||
export function normalizeCapabilitySecretPayload(raw: unknown): CapabilitySecretPayload {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
throw new Error("invalid capability secret payload");
|
||||
}
|
||||
const payload = raw as Record<string, unknown>;
|
||||
if (payload.schemaVersion !== 1) {
|
||||
throw new Error(`unsupported capability secret schemaVersion: ${String(payload.schemaVersion)}`);
|
||||
}
|
||||
|
||||
const kind =
|
||||
payload.kind === "pbank" || payload.kind === "docmind"
|
||||
? payload.kind
|
||||
: typeof payload.accessKeyId === "string"
|
||||
? "docmind"
|
||||
: typeof payload.username === "string"
|
||||
? "pbank"
|
||||
: null;
|
||||
if (kind === null) throw new Error("capability secret payload missing kind");
|
||||
|
||||
if (kind === "docmind") {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "docmind",
|
||||
accessKeyId: requireString(payload.accessKeyId, "accessKeyId"),
|
||||
accessKeySecret: requireString(payload.accessKeySecret, "accessKeySecret"),
|
||||
endpoint: requireString(payload.endpoint, "endpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
const rightsStatus = optionalString(payload.rightsStatus);
|
||||
const rightsHolder = optionalString(payload.rightsHolder);
|
||||
const rightsScope = optionalString(payload.rightsScope);
|
||||
const rightsNote = optionalString(payload.rightsNote);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "pbank",
|
||||
baseUrl: requireString(payload.baseUrl, "baseUrl"),
|
||||
username: requireString(payload.username, "username"),
|
||||
password: requireString(payload.password, "password"),
|
||||
...(rightsStatus !== undefined ? { rightsStatus } : {}),
|
||||
...(rightsHolder !== undefined ? { rightsHolder } : {}),
|
||||
...(rightsScope !== undefined ? { rightsScope } : {}),
|
||||
...(rightsNote !== undefined ? { rightsNote } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function asDocmindSecret(payload: CapabilitySecretPayload): DocmindCapabilitySecretPayload {
|
||||
if (payload.kind !== "docmind") {
|
||||
throw new Error(`expected docmind capability secret, got ${payload.kind}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function asPbankSecret(payload: CapabilitySecretPayload): PbankCapabilitySecretPayload {
|
||||
if (payload.kind !== "pbank") {
|
||||
throw new Error(`expected pbank capability secret, got ${payload.kind}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error(`${label} must not be empty`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
resolveActiveFeishuApplication,
|
||||
type ResolvedFeishuApplication,
|
||||
} from "../connections/feishuApplicationConnections.js";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import {
|
||||
WorkspaceFileBoundaryError,
|
||||
writeNewWorkspaceFileNoFollow,
|
||||
} from "../security/workspaceFiles.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 180_000;
|
||||
const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
|
||||
const DEFAULT_CLI_PATH = "/usr/local/bin:/usr/bin:/bin";
|
||||
|
||||
const SAFE_CLI_ENV_KEYS = [
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TZ",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"NODE_USE_ENV_PROXY",
|
||||
] as const;
|
||||
|
||||
export interface FeishuBotCliDownloadRequest {
|
||||
readonly messageId: string;
|
||||
readonly fileKey: string;
|
||||
readonly resourceType: "image" | "file";
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly workspaceRelativePath: string;
|
||||
readonly maxBytes?: number | undefined;
|
||||
}
|
||||
|
||||
export interface FeishuBotCli {
|
||||
downloadResource(request: FeishuBotCliDownloadRequest): Promise<string>;
|
||||
}
|
||||
|
||||
export interface FeishuBotCliOptions {
|
||||
readonly organizationId: string;
|
||||
readonly prisma: PrismaClient;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly binary?: string | undefined;
|
||||
readonly timeoutMs?: number | undefined;
|
||||
readonly resolveCredential?: (() => Promise<ResolvedFeishuApplication>) | undefined;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the real lark-cli as a Hub-owned bot operation.
|
||||
*
|
||||
* The CLI receives the App Secret over stdin and gets a disposable HOME. No
|
||||
* Feishu credential is placed in Agent environment, project files, argv, or
|
||||
* the process-global CLI configuration. The caller still owns project/chat
|
||||
* authorization; this adapter only performs bot-identity transport.
|
||||
*/
|
||||
export function createFeishuBotCli(options: FeishuBotCliOptions): FeishuBotCli {
|
||||
const resolveCredential = options.resolveCredential ?? (() => resolveActiveFeishuApplication(
|
||||
options.prisma,
|
||||
options.secretEnvelope,
|
||||
{ organizationId: options.organizationId },
|
||||
));
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
return {
|
||||
async downloadResource(request): Promise<string> {
|
||||
const credential = await resolveCredential();
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-feishu-bot-cli-"), { encoding: "utf8" });
|
||||
const home = join(root, "home");
|
||||
await mkdirPrivate(home);
|
||||
const cliEnv = buildCliEnv(home);
|
||||
const cliBinary = options.binary ?? process.env["HUB_FEISHU_CLI_BIN"] ?? "lark-cli";
|
||||
const temporaryName = "resource.bin";
|
||||
const temporaryPath = join(home, temporaryName);
|
||||
|
||||
try {
|
||||
await runCli(
|
||||
cliBinary,
|
||||
["config", "init", "--app-id", credential.appId, "--app-secret-stdin", "--brand", "feishu"],
|
||||
{ cwd: root, env: cliEnv, stdin: `${credential.appSecret}\n`, timeoutMs, label: "config init" },
|
||||
);
|
||||
await runCli(
|
||||
cliBinary,
|
||||
[
|
||||
"im",
|
||||
"+messages-resources-download",
|
||||
"--as",
|
||||
"bot",
|
||||
"--message-id",
|
||||
request.messageId,
|
||||
"--file-key",
|
||||
request.fileKey,
|
||||
"--type",
|
||||
request.resourceType,
|
||||
"--output",
|
||||
temporaryName,
|
||||
],
|
||||
{ cwd: home, env: cliEnv, timeoutMs, label: "resource download" },
|
||||
);
|
||||
|
||||
const metadata = await stat(temporaryPath);
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error("lark-cli resource download did not produce a regular file");
|
||||
}
|
||||
if (request.maxBytes !== undefined && metadata.size > request.maxBytes) {
|
||||
throw new WorkspaceFileBoundaryError(
|
||||
`Feishu resource exceeds ${request.maxBytes} bytes: ${request.fileKey}`,
|
||||
request.workspaceRelativePath,
|
||||
"limit",
|
||||
);
|
||||
}
|
||||
|
||||
return await writeNewWorkspaceFileNoFollow(
|
||||
request.workspaceRoot,
|
||||
request.workspaceDir,
|
||||
request.workspaceRelativePath,
|
||||
createReadStream(temporaryPath),
|
||||
request.maxBytes,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function mkdirPrivate(path: string): Promise<void> {
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
await chmod(path, 0o700);
|
||||
}
|
||||
|
||||
function buildCliEnv(home: string): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const name of SAFE_CLI_ENV_KEYS) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) env[name] = value;
|
||||
}
|
||||
if (env.PATH === undefined || env.PATH.trim() === "") env.PATH = DEFAULT_CLI_PATH;
|
||||
env.HOME = home;
|
||||
env.XDG_CONFIG_HOME = join(home, ".config");
|
||||
env.XDG_CACHE_HOME = join(home, ".cache");
|
||||
env.XDG_STATE_HOME = join(home, ".state");
|
||||
return env;
|
||||
}
|
||||
|
||||
async function runCli(
|
||||
binary: string,
|
||||
args: readonly string[],
|
||||
input: {
|
||||
readonly cwd: string;
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly stdin?: string | undefined;
|
||||
readonly timeoutMs: number;
|
||||
readonly label: string;
|
||||
},
|
||||
): Promise<CommandResult> {
|
||||
return new Promise<CommandResult>((resolve, reject) => {
|
||||
const child = spawn(binary, args, {
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let outputBytes = 0;
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
finish(new Error(`lark-cli ${input.label} timed out after ${input.timeoutMs}ms`));
|
||||
}, input.timeoutMs);
|
||||
|
||||
const appendOutput = (target: "stdout" | "stderr", chunk: Buffer | string): void => {
|
||||
if (outputBytes >= MAX_COMMAND_OUTPUT_BYTES) return;
|
||||
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||
const remaining = MAX_COMMAND_OUTPUT_BYTES - outputBytes;
|
||||
const bounded = text.slice(0, remaining);
|
||||
outputBytes += Buffer.byteLength(bounded);
|
||||
if (target === "stdout") stdout += bounded;
|
||||
else stderr += bounded;
|
||||
};
|
||||
|
||||
const finish = (error?: Error, result?: CommandResult): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error !== undefined) reject(error);
|
||||
else resolve(result!);
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer | string) => appendOutput("stdout", chunk));
|
||||
child.stderr.on("data", (chunk: Buffer | string) => appendOutput("stderr", chunk));
|
||||
child.once("error", (error) => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
if (code !== 0) {
|
||||
const detail = (stderr.trim() || stdout.trim()).slice(0, 500);
|
||||
const status = code === null ? signal ?? "signal" : `exit ${code}`;
|
||||
finish(new Error(
|
||||
detail === ""
|
||||
? `lark-cli ${input.label} failed (${status})`
|
||||
: `lark-cli ${input.label} failed (${status}): ${detail}`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
finish(undefined, { stdout, stderr });
|
||||
});
|
||||
child.stdin.end(input.stdin);
|
||||
});
|
||||
}
|
||||
+111
-15
@@ -2,9 +2,10 @@
|
||||
* Feishu interactive card builder for agent run output.
|
||||
*
|
||||
* Produces card JSON with:
|
||||
* 1. A collapsible tool-use panel (tool steps with status, params, results)
|
||||
* 2. A collapsible reasoning panel (thinking text)
|
||||
* 3. The streaming/final answer text (markdown)
|
||||
* 1. A live todo checklist when the agent uses TodoWrite (Manus-style progress)
|
||||
* 2. A collapsible tool-use panel (tool steps with status, params, results)
|
||||
* 3. A collapsible reasoning panel (thinking text)
|
||||
* 4. The streaming/final answer text (markdown)
|
||||
*
|
||||
* Adapted from openclaw-lark's builder.ts, simplified for our
|
||||
* message.patch-based approach (no CardKit 2.0 streaming_mode).
|
||||
@@ -12,7 +13,9 @@
|
||||
*/
|
||||
|
||||
import type { ToolUseTraceStep } from "./trace-store.js";
|
||||
import type { CardContentSegment } from "../outboundImages.js";
|
||||
import type { AgentTodoItem } from "../../agent/todoList.js";
|
||||
import { todoProgressSummary } from "../../agent/todoList.js";
|
||||
import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -39,6 +42,8 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
glob: "search-filled",
|
||||
grep: "search-filled",
|
||||
edit: "edit-filled",
|
||||
todowrite: "todo-filled",
|
||||
todo_write: "todo-filled",
|
||||
send_file: "send-filled",
|
||||
request_approval: "thumb-up-filled",
|
||||
feishu_read_context: "search-filled",
|
||||
@@ -48,10 +53,26 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function toolIcon(toolName: string): string {
|
||||
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
|
||||
const normalized = toolName.toLowerCase().replace(/^mcp_/, "").replace(/^cph_hub__/, "");
|
||||
return TOOL_ICONS[normalized] ?? "tool-filled";
|
||||
}
|
||||
|
||||
function isTodoToolName(toolName: string): boolean {
|
||||
const lower = toolName.toLowerCase();
|
||||
const bare = lower.includes("__") ? (lower.split("__").pop() ?? lower) : lower;
|
||||
const stripped = bare.replace(/_\d+$/, "");
|
||||
return (
|
||||
stripped === "todowrite" ||
|
||||
stripped === "todo_write" ||
|
||||
stripped === "taskcreate" ||
|
||||
stripped === "taskupdate" ||
|
||||
stripped === "tasklist" ||
|
||||
stripped === "taskget" ||
|
||||
stripped === "taskstop" ||
|
||||
stripped === "taskoutput"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card builder
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,20 +82,32 @@ export function buildAgentCard(params: {
|
||||
text: string;
|
||||
contentSegments?: readonly CardContentSegment[] | undefined;
|
||||
reasoningText: string | undefined;
|
||||
todos: readonly AgentTodoItem[] | undefined;
|
||||
toolUseSteps: ToolUseTraceStep[];
|
||||
toolUseElapsedMs: number | undefined;
|
||||
isError: boolean | undefined;
|
||||
interrupted: boolean | undefined;
|
||||
runId: string | undefined;
|
||||
}): Record<string, unknown> {
|
||||
const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||
const { phase, text, contentSegments, reasoningText, todos, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||
const elements: unknown[] = [];
|
||||
|
||||
// Tool-use panel (always present if there are steps)
|
||||
if (toolUseSteps.length > 0) {
|
||||
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
|
||||
} else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
|
||||
elements.push(buildPendingToolUsePanel());
|
||||
// Todo checklist panel — primary progress signal; hide bare TodoWrite noise below.
|
||||
if (todos !== undefined && todos.length > 0) {
|
||||
elements.push(buildTodoPanel(todos, phase !== "complete"));
|
||||
}
|
||||
|
||||
// Tool-use panel (exclude todo tools — already shown as checklist)
|
||||
const visibleToolSteps = toolUseSteps.filter((step) => !isTodoToolName(step.toolName));
|
||||
if (visibleToolSteps.length > 0) {
|
||||
elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete"));
|
||||
} else if (
|
||||
todos === undefined ||
|
||||
todos.length === 0
|
||||
) {
|
||||
if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
|
||||
elements.push(buildPendingToolUsePanel());
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning panel
|
||||
@@ -161,6 +194,62 @@ function buildInterruptAction(runId: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Todo checklist panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildTodoPanel(todos: readonly AgentTodoItem[], expanded: boolean): unknown {
|
||||
const { completed, total, inProgress } = todoProgressSummary(todos);
|
||||
const titleParts = [`\u{1F4CB} \u4EFB\u52A1\u8FDB\u5EA6 ${completed}/${total}`];
|
||||
if (inProgress > 0 && completed < total) titleParts.push(`(\u8FDB\u884C\u4E2D ${inProgress})`);
|
||||
const lines = todos.map((todo) => formatTodoLine(todo));
|
||||
return {
|
||||
tag: "collapsible_panel",
|
||||
expanded,
|
||||
header: {
|
||||
title: {
|
||||
tag: "plain_text",
|
||||
content: titleParts.join(" "),
|
||||
text_color: completed === total && total > 0 ? "green" : "grey",
|
||||
text_size: "notation",
|
||||
},
|
||||
vertical_align: "center",
|
||||
icon: {
|
||||
tag: "standard_icon",
|
||||
token: "down-small-ccm_outlined",
|
||||
color: "grey",
|
||||
size: "16px 16px",
|
||||
},
|
||||
icon_position: "right",
|
||||
icon_expanded_angle: -180,
|
||||
},
|
||||
border: { color: "grey", corner_radius: "5px" },
|
||||
vertical_spacing: "4px",
|
||||
padding: "8px 8px 8px 8px",
|
||||
elements: [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: lines.join("\n"),
|
||||
text_size: "notation",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function formatTodoLine(todo: AgentTodoItem): string {
|
||||
const label =
|
||||
todo.status === "in_progress" && todo.activeForm !== undefined && todo.activeForm !== ""
|
||||
? todo.activeForm
|
||||
: todo.content;
|
||||
if (todo.status === "completed") return `- [\u2713] ~~${escapeMd(todo.content)}~~`;
|
||||
if (todo.status === "in_progress") return `- [\u25B6] **${escapeMd(label)}**`;
|
||||
return `- [ ] ${escapeMd(todo.content)}`;
|
||||
}
|
||||
|
||||
function escapeMd(text: string): string {
|
||||
return text.replace(/([\\`*_{}\[\]()#+\-.!>])/g, "\\$1");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool-use panel
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -411,6 +500,7 @@ function buildAnswerElements(
|
||||
let remaining = MAX_TEXT_LENGTH;
|
||||
for (const segment of contentSegments) {
|
||||
if (segment.type === "image") {
|
||||
if (segment.imgKey.trim() === "") continue;
|
||||
elements.push({
|
||||
tag: "img",
|
||||
img_key: segment.imgKey,
|
||||
@@ -421,9 +511,13 @@ function buildAnswerElements(
|
||||
continue;
|
||||
}
|
||||
if (segment.content === "" || remaining <= 0) continue;
|
||||
const slice = segment.content.length <= remaining
|
||||
? segment.content
|
||||
: truncateText(segment.content, remaining);
|
||||
// Feishu card markdown rejects  without a Feishu image_key
|
||||
// ("card contains images but no imagekey" / empty image key).
|
||||
const safe = maskMarkdownImagesForStreaming(segment.content);
|
||||
if (safe === "" || remaining <= 0) continue;
|
||||
const slice = safe.length <= remaining
|
||||
? safe
|
||||
: truncateText(safe, remaining);
|
||||
remaining -= slice.length;
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
@@ -433,9 +527,11 @@ function buildAnswerElements(
|
||||
return elements;
|
||||
}
|
||||
if (text === "") return [];
|
||||
const safe = maskMarkdownImagesForStreaming(text);
|
||||
if (safe === "") return [];
|
||||
return [{
|
||||
tag: "markdown",
|
||||
content: truncateText(text, MAX_TEXT_LENGTH),
|
||||
content: truncateText(safe, MAX_TEXT_LENGTH),
|
||||
}];
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
getToolUseTraceSteps,
|
||||
} from "./trace-store.js";
|
||||
import { buildAgentCard, type CardPhase } from "./builder.js";
|
||||
import {
|
||||
applyChecklistToolEvent,
|
||||
isChecklistProgressTool,
|
||||
type AgentTodoItem,
|
||||
} from "../../agent/todoList.js";
|
||||
import {
|
||||
type CardContentSegment,
|
||||
maskMarkdownImagesForStreaming,
|
||||
@@ -66,6 +71,7 @@ export class StreamingAgentCard {
|
||||
private currentMessageId: string | null = null;
|
||||
private text = "";
|
||||
private reasoningText = "";
|
||||
private todos: readonly AgentTodoItem[] = [];
|
||||
private runStartedAt = Date.now();
|
||||
private toolUseElapsedMs: number | undefined;
|
||||
private flushChain: Promise<void> = Promise.resolve();
|
||||
@@ -123,17 +129,31 @@ export class StreamingAgentCard {
|
||||
error: string | undefined;
|
||||
durationMs: number | undefined;
|
||||
}): void {
|
||||
if (isChecklistProgressTool(params.toolName)) {
|
||||
const next = applyChecklistToolEvent(this.todos, {
|
||||
toolName: params.toolName,
|
||||
input: params.input,
|
||||
result: params.result,
|
||||
toolUseId: params.toolUseId,
|
||||
});
|
||||
if (next !== null) this.todos = next;
|
||||
}
|
||||
recordToolUseEnd({ runId: this.runId, ...params });
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
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 =
|
||||
@@ -153,18 +173,20 @@ export class StreamingAgentCard {
|
||||
);
|
||||
}
|
||||
|
||||
let updated = true;
|
||||
let cardUpdated = true;
|
||||
if (answerText.length > 0 || segments.length > 0) {
|
||||
updated = await this.flushCard("complete", answerText, false, segments);
|
||||
cardUpdated = await this.flushCard("complete", answerText, isError, segments);
|
||||
} else if (this.currentMessageId !== null) {
|
||||
updated = await this.flushCard("complete", "", false, []);
|
||||
cardUpdated = await this.flushCard("complete", "", isError, []);
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
if (!cardUpdated) {
|
||||
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
|
||||
updated = await this.deliverPlainFallback(segments, answerText);
|
||||
await this.deliverPlainFallback(segments, answerText);
|
||||
}
|
||||
if (!updated && this.interrupted) {
|
||||
// Interrupt is terminal; if the live card could not be finalized, always
|
||||
// send an explicit notice so the teacher sees the abort even when plain
|
||||
// text partial delivery succeeded.
|
||||
if (!cardUpdated && this.interrupted) {
|
||||
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
||||
}
|
||||
} finally {
|
||||
@@ -231,6 +253,7 @@ export class StreamingAgentCard {
|
||||
? contentSegments
|
||||
: undefined,
|
||||
reasoningText: this.reasoningText || undefined,
|
||||
todos: this.todos.length > 0 ? this.todos : undefined,
|
||||
toolUseSteps,
|
||||
toolUseElapsedMs: this.toolUseElapsedMs,
|
||||
isError,
|
||||
@@ -249,6 +272,7 @@ export class StreamingAgentCard {
|
||||
phase,
|
||||
text: chunk,
|
||||
reasoningText: undefined,
|
||||
todos: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError,
|
||||
@@ -270,6 +294,7 @@ export class StreamingAgentCard {
|
||||
phase,
|
||||
text: chunk,
|
||||
reasoningText: undefined,
|
||||
todos: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError,
|
||||
|
||||
+32
-12
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import type { ToolContext } from "../agent/tools.js";
|
||||
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
|
||||
import type { FeishuBotCli } from "./botCli.js";
|
||||
import type { FeishuRuntime } from "./client.js";
|
||||
|
||||
export interface FeishuMessageResourceArgs {
|
||||
readonly messageId: string;
|
||||
@@ -13,7 +14,11 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
|
||||
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & {
|
||||
readonly workspaceRoot: string;
|
||||
readonly botCli: FeishuBotCli;
|
||||
readonly maxFileBytes?: number | undefined;
|
||||
};
|
||||
|
||||
interface MessageLookupResult {
|
||||
readonly data?: {
|
||||
@@ -51,14 +56,29 @@ export async function downloadFeishuMessageResource(
|
||||
"inbox",
|
||||
`feishu-${args.resourceType}-${randomUUID()}${extension}`,
|
||||
);
|
||||
const savePath = await downloadMessageFile(
|
||||
rt,
|
||||
args.messageId,
|
||||
args.fileKey,
|
||||
context.workspaceRoot,
|
||||
context.workspaceDir,
|
||||
workspaceRelativePath,
|
||||
args.resourceType,
|
||||
);
|
||||
return { ...args, path: savePath };
|
||||
try {
|
||||
const savePath = await context.botCli.downloadResource({
|
||||
messageId: args.messageId,
|
||||
fileKey: args.fileKey,
|
||||
resourceType: args.resourceType,
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
workspaceDir: context.workspaceDir,
|
||||
workspaceRelativePath,
|
||||
maxBytes: context.maxFileBytes,
|
||||
});
|
||||
return { ...args, path: savePath };
|
||||
} catch (error) {
|
||||
rt.logger.error(
|
||||
{
|
||||
err: error,
|
||||
messageId: args.messageId,
|
||||
fileKey: args.fileKey,
|
||||
resourceType: args.resourceType,
|
||||
boundChatId: context.boundChatId,
|
||||
workspaceDir: context.workspaceDir,
|
||||
},
|
||||
"Feishu bot CLI resource download failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { z } from "zod";
|
||||
import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
||||
import { createFeishuBotCli } from "./botCli.js";
|
||||
import { resolveDeliverableFile } from "./fileDelivery.js";
|
||||
import { downloadFeishuMessageResource } from "./download.js";
|
||||
import { readFeishuContext } from "./read.js";
|
||||
@@ -9,8 +10,16 @@ import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.j
|
||||
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js";
|
||||
import {
|
||||
createPdfToMdBundleAdapter,
|
||||
invokePdfToMdBatch,
|
||||
readPdfToMdConcurrency,
|
||||
MAX_PDF_TO_MD_BATCH_ITEMS,
|
||||
type PdfToMdBatchItemResult,
|
||||
} from "../capability/pdfToMdBundle.js";
|
||||
import { AliyunDocmindClient } from "../capability/docmindClient.js";
|
||||
import { createPbankService, type PbankToolResult } from "../capability/pbank.js";
|
||||
import { CapabilityConnectionUnavailable } from "../capability/types.js";
|
||||
|
||||
export interface FileDeliveryToolOptions {
|
||||
readonly rt: FeishuRuntime;
|
||||
@@ -30,6 +39,11 @@ export interface FileDeliveryToolOptions {
|
||||
}
|
||||
|
||||
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
|
||||
const botCli = createFeishuBotCli({
|
||||
organizationId: options.organizationId,
|
||||
prisma: options.prisma,
|
||||
secretEnvelope: options.secretEnvelope,
|
||||
});
|
||||
const enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS);
|
||||
const tools: Array<SdkMcpToolDefinition<any>> = [];
|
||||
|
||||
@@ -147,7 +161,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
tools.push(
|
||||
tool(
|
||||
"feishu_download_resource",
|
||||
"Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace. Use message_id and file_key returned by feishu_read_context.",
|
||||
"Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace using the Organization bot identity. Use message_id and file_key returned by feishu_read_context.",
|
||||
{
|
||||
message_id: z.string().describe("The Feishu message id containing the resource."),
|
||||
file_key: z.string().describe("The image_key or file_key from that message's content."),
|
||||
@@ -175,6 +189,8 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
boundChatId: options.chatId,
|
||||
workspaceRoot,
|
||||
workspaceDir: options.workspaceDir,
|
||||
botCli,
|
||||
maxFileBytes: options.maxFileBytes,
|
||||
},
|
||||
options.rt,
|
||||
);
|
||||
@@ -246,21 +262,54 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
tools.push(
|
||||
tool(
|
||||
"convert_pdf_to_md",
|
||||
"Convert a PDF file in the workspace to a Markdown bundle (markdown + extracted images) using Alibaba Cloud Document Mind. The PDF must already be in the workspace (use feishu_download_resource first if it came from Feishu). Returns the path to the generated markdown file and the list of extracted image paths. Mathematical formulas are converted to LaTeX.",
|
||||
"Convert one or more PDF files in the workspace to Markdown bundles (markdown + extracted images) via Alibaba Cloud Document Mind. PDFs must already be in the workspace (use feishu_download_resource first for Feishu attachments). Prefer a single call with `items` for multiple PDFs — Hub converts them concurrently (bounded). Each item needs its own output_dir because the tool always writes document.md inside that directory. Formulas become LaTeX.",
|
||||
{
|
||||
input_path: z.string().describe("Relative path to the input PDF within the workspace."),
|
||||
output_dir: z.string().describe("Relative directory within the workspace to write the markdown and images into. Will be created if it does not exist."),
|
||||
input_path: z.string().optional().describe("Single-file mode: workspace-relative path to the input PDF. Required when `items` is omitted."),
|
||||
output_dir: z.string().optional().describe("Single-file mode: workspace-relative directory for document.md + images. Required when `items` is omitted."),
|
||||
items: z.array(z.object({
|
||||
input_path: z.string().describe("Workspace-relative path to one input PDF."),
|
||||
output_dir: z.string().describe("Workspace-relative output directory for this PDF (must be unique per item)."),
|
||||
})).min(1).max(MAX_PDF_TO_MD_BATCH_ITEMS).optional().describe(`Batch mode: multiple PDFs converted concurrently. Max ${MAX_PDF_TO_MD_BATCH_ITEMS} items. Do not reuse output_dir across items.`),
|
||||
concurrency: z.number().int().min(1).max(8).optional().describe("Optional parallel job limit for batch mode (1-8). Defaults to HUB_PDF_TO_MD_MAX_CONCURRENT (usually 3)."),
|
||||
},
|
||||
async (args) => {
|
||||
const base = {
|
||||
runId: options.runId,
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
prisma: options.prisma,
|
||||
};
|
||||
try {
|
||||
if (args.items !== undefined && args.items.length > 0) {
|
||||
const batchResults = await invokePdfToMdBatch(
|
||||
adapter,
|
||||
base,
|
||||
args.items.map((item) => ({
|
||||
inputPath: item.input_path,
|
||||
outputDir: item.output_dir,
|
||||
})),
|
||||
args.concurrency ?? readPdfToMdConcurrency(),
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: formatPdfToMdBatchResult(batchResults) }],
|
||||
...(batchResults.every((item) => item.ok) ? {} : { isError: true }),
|
||||
};
|
||||
}
|
||||
if (args.input_path === undefined || args.input_path.trim() === ""
|
||||
|| args.output_dir === undefined || args.output_dir.trim() === "") {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "convert_pdf_to_md requires either items[{input_path,output_dir},...] or both input_path and output_dir.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
const result = await adapter.invoke({
|
||||
runId: options.runId,
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
...base,
|
||||
inputPath: args.input_path,
|
||||
outputDir: args.output_dir,
|
||||
prisma: options.prisma,
|
||||
});
|
||||
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
|
||||
for (const artifact of result.artifacts) {
|
||||
@@ -282,6 +331,127 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
);
|
||||
}
|
||||
|
||||
const pbankEnabled =
|
||||
enabledTools.has("pbank_search_problems") ||
|
||||
enabledTools.has("pbank_get_problem") ||
|
||||
enabledTools.has("pbank_get_many_problems");
|
||||
if (pbankEnabled) {
|
||||
const pbank = createPbankService({
|
||||
prisma: options.prisma,
|
||||
secrets: options.secretEnvelope,
|
||||
});
|
||||
const pbankCtx = {
|
||||
organizationId: options.organizationId,
|
||||
runId: options.runId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
};
|
||||
|
||||
if (enabledTools.has("pbank_search_problems")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"pbank_search_problems",
|
||||
"Search Paradigm PBank (题库) by title/keyword. Returns page metadata, operator-confirmed rights guidance, and matching problem summaries. Requires an ACTIVE org capability connection for `pbank`.",
|
||||
{
|
||||
q: z.string().optional().describe("Search text."),
|
||||
keywords: z.array(z.string()).optional().describe("Exact keywords to filter by."),
|
||||
pageNum: z.number().int().min(1).optional().describe("Page number (default 1)."),
|
||||
pageSize: z.number().int().min(1).max(50).optional().describe("Page size (default 10, max 50)."),
|
||||
},
|
||||
async (args) =>
|
||||
runPbankTool(() =>
|
||||
pbank.searchProblems(pbankCtx, {
|
||||
q: args.q,
|
||||
keywords: args.keywords,
|
||||
pageNum: args.pageNum,
|
||||
pageSize: args.pageSize,
|
||||
}),
|
||||
),
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (enabledTools.has("pbank_get_problem")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"pbank_get_problem",
|
||||
"Fetch one PBank problem by URL or UUID. Returns metadata, rights guidance, text-like source files, local zip/extract paths under .pbank-sources/, and optional image assets. Requires ACTIVE org capability `pbank`.",
|
||||
{
|
||||
urlOrId: z.string().min(1).describe("PBank problem URL or UUID."),
|
||||
includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."),
|
||||
materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."),
|
||||
includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."),
|
||||
includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."),
|
||||
},
|
||||
async (args) => runPbankTool(() => pbank.getProblem(pbankCtx, args)),
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (enabledTools.has("pbank_get_many_problems")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"pbank_get_many_problems",
|
||||
"Fetch several PBank problems by URL or UUID. Use when the teacher pastes multiple example links. Returns rights guidance together with each problem. Requires ACTIVE org capability `pbank`.",
|
||||
{
|
||||
urlsOrIds: z.array(z.string().min(1)).min(1).max(20).describe("PBank problem URLs or UUIDs."),
|
||||
includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."),
|
||||
materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."),
|
||||
includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."),
|
||||
includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."),
|
||||
},
|
||||
async (args) => runPbankTool(() => pbank.getManyProblems(pbankCtx, args)),
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (enabledTools.has("todo_write")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"todo_write",
|
||||
"Create or replace the shared task checklist for this run. Call this first when the user`s request has multiple steps, then again whenever progress changes. Each item needs content + status (pending|in_progress|completed). Optionally set activeForm (present-tense label) for the current in_progress item. Keep exactly one item in_progress when work is underway. Hub shows this list on the Feishu card so teachers can track progress.",
|
||||
{
|
||||
todos: z
|
||||
.array(
|
||||
z.object({
|
||||
content: z.string().min(1).describe("Imperative task description, e.g. Search PBank for derivatives."),
|
||||
status: z
|
||||
.enum(["pending", "in_progress", "completed"])
|
||||
.describe("pending | in_progress | completed"),
|
||||
activeForm: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Present continuous label while in_progress, e.g. Searching PBank."),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(32)
|
||||
.describe("Full replacement list for the checklist (not a patch)."),
|
||||
},
|
||||
async (args) => {
|
||||
const completed = args.todos.filter((t) => t.status === "completed").length;
|
||||
const inProgress = args.todos.filter((t) => t.status === "in_progress").length;
|
||||
const lines = args.todos.map((t, i) => {
|
||||
const mark = t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " ";
|
||||
const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
|
||||
return `${i + 1}. [${mark}] ${label}`;
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Checklist updated ${completed}/${args.todos.length} completed, ${inProgress} in progress.\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const instructions = mcpInstructions(enabledTools);
|
||||
return createSdkMcpServer({
|
||||
name: "cph_hub",
|
||||
@@ -292,11 +462,68 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function formatPdfToMdBatchResult(results: readonly PdfToMdBatchItemResult[]): string {
|
||||
const ok = results.filter((item) => item.ok);
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
const lines = [
|
||||
`Batch PDF→Markdown finished: ${ok.length} succeeded, ${failed.length} failed (of ${results.length}).`,
|
||||
];
|
||||
for (const item of results) {
|
||||
if (item.ok) {
|
||||
const md = item.result.artifacts.find((artifact) => artifact.kind === "markdown")?.path;
|
||||
lines.push(
|
||||
`OK ${item.inputPath} → ${item.outputDir}`
|
||||
+ (md !== undefined ? ` (${md})` : "")
|
||||
+ `; pages=${item.result.consumption.quantity}`
|
||||
+ `; cost=$${(item.result.consumption.costUsd ?? 0).toFixed(4)}`,
|
||||
);
|
||||
for (const artifact of item.result.artifacts) {
|
||||
lines.push(` - ${artifact.path} (${artifact.kind})`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`FAIL ${item.inputPath} → ${item.outputDir}: ${item.error}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function runPbankTool(
|
||||
invoke: () => Promise<PbankToolResult>,
|
||||
): Promise<{
|
||||
content: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
>;
|
||||
isError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const result = await invoke();
|
||||
const content: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
> = [{ type: "text", text: JSON.stringify(result.data, null, 2) }];
|
||||
for (const image of result.inlineImages) {
|
||||
content.push({ type: "image", data: image.data, mimeType: image.mimeType });
|
||||
}
|
||||
return { content };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof CapabilityConnectionUnavailable
|
||||
? `${error.message}. Ask an org admin to configure the pbank capability connection.`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
return { isError: true, content: [{ type: "text", text: message }] };
|
||||
}
|
||||
}
|
||||
|
||||
function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
const instructions: string[] = [];
|
||||
if (enabledTools.has("send_file")) {
|
||||
instructions.push(
|
||||
"Use send_file when the user asks to receive, resend, download, or attach a file.",
|
||||
"Use send_file only for downloadable attachments the user should save (PDF, DOCX, ZIP, etc.).",
|
||||
"For inline 图文 answers, put  in the final assistant text instead of send_file; the hub embeds those images in the reply card.",
|
||||
"Do not claim a file was sent unless send_file returns success.",
|
||||
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
|
||||
);
|
||||
@@ -314,10 +541,32 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
}
|
||||
if (enabledTools.has("convert_pdf_to_md")) {
|
||||
instructions.push(
|
||||
"Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.",
|
||||
"If the PDF came from a Feishu message, first use feishu_download_resource to save it to the workspace, then call convert_pdf_to_md.",
|
||||
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md for accurate text, formula, and image extraction.",
|
||||
"Use convert_pdf_to_md when the user asks to convert a PDF (or several PDFs) to Markdown.",
|
||||
"If PDFs came from Feishu, download each with feishu_download_resource first, then convert.",
|
||||
"For multiple PDFs, call convert_pdf_to_md once with items=[{input_path,output_dir},...] so Hub converts them concurrently; give each file its own output_dir (the tool writes document.md inside it).",
|
||||
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
enabledTools.has("pbank_search_problems") ||
|
||||
enabledTools.has("pbank_get_problem") ||
|
||||
enabledTools.has("pbank_get_many_problems")
|
||||
) {
|
||||
instructions.push(
|
||||
"Use pbank_search_problems / pbank_get_problem / pbank_get_many_problems for Paradigm PBank (题库) selection.",
|
||||
"Treat the returned rights object as authoritative for derivative use.",
|
||||
"Materialized sources land under workspace-relative .pbank-sources/ — read them; do not invent problem content.",
|
||||
"If tools fail because no ACTIVE pbank capability connection exists, tell the user an org admin must configure 题库 on the admin capabilities page.",
|
||||
);
|
||||
}
|
||||
if (enabledTools.has("todo_write")) {
|
||||
instructions.push(
|
||||
"For multi-step work, call todo_write first with a full checklist, then update it as each step starts/finishes so the teacher sees live progress on the card.",
|
||||
"Prefer mcp__cph_hub__todo_write (todo_write) for progress tracking — do not skip it because built-in TodoWrite is absent.",
|
||||
);
|
||||
}
|
||||
instructions.push(
|
||||
"Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.",
|
||||
);
|
||||
return instructions.join(" ");
|
||||
}
|
||||
|
||||
@@ -14,8 +14,15 @@ import {
|
||||
export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10;
|
||||
|
||||
const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/g;
|
||||
const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]*)\)/g;
|
||||
const FENCED_CODE_RE = /```[\s\S]*?```/g;
|
||||
const INLINE_CODE_RE = /`[^`\n]+`/g;
|
||||
const IMAGE_FETCH_HEADERS: Record<string, string> = {
|
||||
accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
// Some CDNs (incl. Wikimedia) reject bare programmatic clients with 400 HTML.
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (compatible; EducraftHub/1.0; +https://educraft.paradigm-edu.net) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
};
|
||||
|
||||
export type CardContentSegment =
|
||||
| { readonly type: "markdown"; readonly content: string }
|
||||
@@ -261,6 +268,14 @@ function blockedRanges(text: string): Array<{ start: number; end: number }> {
|
||||
while ((match = FENCED_CODE_RE.exec(text)) !== null) {
|
||||
ranges.push({ start: match.index, end: match.index + match[0].length });
|
||||
}
|
||||
INLINE_CODE_RE.lastIndex = 0;
|
||||
while ((match = INLINE_CODE_RE.exec(text)) !== null) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
// Skip inline spans fully inside a fence already recorded above.
|
||||
if (ranges.some((range) => start >= range.start && end <= range.end)) continue;
|
||||
ranges.push({ start, end });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
@@ -297,11 +312,13 @@ async function fetchRemoteImage(
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
// Direct fetch: host env may enable NODE_USE_ENV_PROXY; several image CDNs
|
||||
// reject or rewrite traffic through shared egress proxies.
|
||||
const response = await fetchWithoutEnvProxy(fetchImpl, url, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "image/*,*/*;q=0.8" },
|
||||
headers: IMAGE_FETCH_HEADERS,
|
||||
});
|
||||
// One safe redirect hop to another public http(s) host.
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
@@ -315,11 +332,11 @@ async function fetchRemoteImage(
|
||||
}
|
||||
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
|
||||
if (!isPublicHttpHost(redirected.hostname)) return null;
|
||||
const second = await fetchImpl(redirected, {
|
||||
const second = await fetchWithoutEnvProxy(fetchImpl, redirected, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "image/*,*/*;q=0.8" },
|
||||
headers: IMAGE_FETCH_HEADERS,
|
||||
});
|
||||
return readImageBody(second, maxBytes);
|
||||
}
|
||||
@@ -329,6 +346,29 @@ async function fetchRemoteImage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch without inheriting HTTP(S)_PROXY from the process env for one call.
|
||||
* Restores env immediately so unrelated concurrent work keeps proxy settings.
|
||||
*/
|
||||
async function fetchWithoutEnvProxy(
|
||||
fetchImpl: typeof fetch,
|
||||
url: URL,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const;
|
||||
const saved: Array<[string, string | undefined]> = proxyKeys.map((key) => [key, process.env[key]]);
|
||||
try {
|
||||
for (const key of proxyKeys) delete process.env[key];
|
||||
return await fetchImpl(url, init);
|
||||
} finally {
|
||||
for (const [key, value] of saved) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function readImageBody(response: Response, maxBytes: number): Promise<Buffer | null> {
|
||||
if (!response.ok) return null;
|
||||
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
||||
|
||||
+10
-5
@@ -7,8 +7,10 @@
|
||||
*
|
||||
* - `trigger_message` / `reply`: `message.get` by message_id.
|
||||
* - `status_card`: the run's status card message — same `message.get` by id.
|
||||
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
|
||||
* `parent_message_id` filter; we map "thread" to that.
|
||||
* - `thread`: lark's thread replies. `im.v1.message.list` with
|
||||
* `container_id_type="thread"` and `container_id` = the thread_id (NOT a
|
||||
* message_id, which Feishu rejects with 230001). The caller supplies the
|
||||
* thread_id via `args.id`; the trigger context exposes it as `thread_id`.
|
||||
*
|
||||
* The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types);
|
||||
* we cast through a known request/response shape and return a compact JSON for
|
||||
@@ -69,11 +71,14 @@ export async function readFeishuContext(
|
||||
return JSON.stringify(compact(msg));
|
||||
}
|
||||
case "thread": {
|
||||
// Thread = replies to a parent message. `container_id` is the parent's
|
||||
// message_id; container_id_type=message_id scopes the list to that thread.
|
||||
// Thread = replies to a topic. Feishu's `im.v1.message.list` scopes
|
||||
// thread replies when `container_id_type="thread"` and `container_id`
|
||||
// is the thread_id (NOT a message_id — that is rejected with 230001
|
||||
// "invalid container_id_type"). The caller supplies the thread_id via
|
||||
// `args.id`; the trigger context exposes it as `thread_id`.
|
||||
const res = await api.list({
|
||||
params: {
|
||||
container_id_type: "message_id",
|
||||
container_id_type: "thread",
|
||||
container_id: args.id,
|
||||
page_size: 50,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
removeWorkspaceFileIfUnchangedNoFollow,
|
||||
type WorkspaceFileWriteResult,
|
||||
} from "../security/workspaceFiles.js";
|
||||
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
|
||||
import type { FeishuBotCli } from "./botCli.js";
|
||||
|
||||
export interface MessageResourceStageRequest {
|
||||
readonly fileKey: string;
|
||||
@@ -33,7 +33,7 @@ export interface PublishedMessageResource extends WorkspaceFileWriteResult {
|
||||
|
||||
/** Download Feishu resources into a private temporary workspace, never the tenant workspace. */
|
||||
export async function stageMessageResources(
|
||||
rt: FeishuRuntime,
|
||||
botCli: FeishuBotCli,
|
||||
messageId: string,
|
||||
requests: readonly MessageResourceStageRequest[],
|
||||
workspaceRoot: string,
|
||||
@@ -49,16 +49,18 @@ export async function stageMessageResources(
|
||||
try {
|
||||
await mkdir(stagingRoot, { mode: 0o700 });
|
||||
for (const [index, request] of requests.entries()) {
|
||||
const stagedPath = await downloadMessageFile(
|
||||
rt,
|
||||
// Bot-identity transport only (ADR-0024): org App Secret stays in Hub,
|
||||
// never crosses into the Agent surface. Staging still lands under the
|
||||
// private .cph-staging tree before publish link into the tenant workspace.
|
||||
const stagedPath = await botCli.downloadResource({
|
||||
messageId,
|
||||
request.fileKey,
|
||||
fileKey: request.fileKey,
|
||||
resourceType: request.resourceType,
|
||||
workspaceRoot,
|
||||
stagingRoot,
|
||||
`resource-${index}`,
|
||||
request.resourceType,
|
||||
limits?.maxBytesPerFile,
|
||||
);
|
||||
workspaceDir: stagingRoot,
|
||||
workspaceRelativePath: `resource-${index}`,
|
||||
maxBytes: limits?.maxBytesPerFile,
|
||||
});
|
||||
resources.push({
|
||||
resourceType: request.resourceType,
|
||||
workspaceRelativePath: request.workspaceRelativePath,
|
||||
|
||||
@@ -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)}...`;
|
||||
}
|
||||
+56
-12
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Sends a "processing" reaction immediately, then streams a single
|
||||
* interactive card through the full agent run lifecycle: thinking → tool
|
||||
* calls (with trace panel) → streaming answer text → final card. The card
|
||||
* calls (with trace panel) → streaming answer text → final card. On finish,
|
||||
* replaces Typing with CheckMark (success) or CrossMark (failure). The card
|
||||
* shows a collapsible tool-use panel, a collapsible reasoning panel, and
|
||||
* the markdown answer text. Throttled to ~2.5 patches/sec to avoid
|
||||
* spamming the Feishu API.
|
||||
@@ -40,6 +41,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";
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
type MessageResourceStageRequest,
|
||||
type StagedMessageResourceBatch,
|
||||
} from "./resourceStaging.js";
|
||||
import { createFeishuBotCli, type FeishuBotCli } from "./botCli.js";
|
||||
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
|
||||
import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js";
|
||||
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
|
||||
@@ -113,6 +116,8 @@ interface TriggerDeps {
|
||||
readonly allowLegacyFeishuIdentity?: boolean | undefined;
|
||||
/** Alpha Silo aggregate ingress ceiling across message and card events. */
|
||||
readonly maxFeishuEventsPerMinute?: number | undefined;
|
||||
/** Test/injection seam for bot-identity Feishu resource downloads. */
|
||||
readonly feishuBotCli?: FeishuBotCli | undefined;
|
||||
}
|
||||
|
||||
interface TriggerActor {
|
||||
@@ -301,8 +306,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
senderOpenId,
|
||||
});
|
||||
const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
|
||||
const botCli = deps.feishuBotCli ?? createFeishuBotCli({
|
||||
organizationId: deps.siloOrganizationId,
|
||||
prisma: deps.prisma,
|
||||
secretEnvelope: deps.secretEnvelope,
|
||||
});
|
||||
const stagedResources = await stageTriggerMessageResources(
|
||||
rt,
|
||||
botCli,
|
||||
msg,
|
||||
projectWorkspaceRoot,
|
||||
deps.resourceLimits,
|
||||
@@ -579,7 +589,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
card.onToolEnd({
|
||||
toolName: event.toolName,
|
||||
toolUseId: event.toolUseId,
|
||||
input: undefined,
|
||||
input: event.input,
|
||||
result: event.result,
|
||||
error: event.isError ? event.result : undefined,
|
||||
durationMs: event.durationMs,
|
||||
@@ -598,13 +608,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({
|
||||
@@ -668,14 +689,36 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
initializedSkills: [...(result.initializedSkillIds ?? [])],
|
||||
},
|
||||
});
|
||||
await removeProcessingReaction();
|
||||
// Mirror the start "Typing" reaction: drop processing, then stamp a
|
||||
// terminal emoji so teachers see done/failed without reading the card.
|
||||
const removedProcessingReaction = await removeProcessingReaction();
|
||||
if (removedProcessingReaction) {
|
||||
await addReaction(
|
||||
rt,
|
||||
msg.message_id,
|
||||
outcome.isError ? "CrossMark" : "CheckMark",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(async (e) => {
|
||||
const removedProcessingReaction = await removeProcessingReaction();
|
||||
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 },
|
||||
@@ -1834,8 +1877,9 @@ async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<P
|
||||
function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined {
|
||||
if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt;
|
||||
const fileDeliveryPrompt =
|
||||
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
|
||||
"Do not say a file is attached or sent unless that tool returns success.";
|
||||
"When the user asks for a downloadable file attachment (PDF/DOCX/ZIP/etc.), call the cph_hub send_file tool with the actual existing file path. " +
|
||||
"Do not say a file is attached or sent unless that tool returns success. " +
|
||||
"For 图文并茂 / inline illustrations inside your answer, do NOT use send_file. Put workspace-relative images in the final answer with markdown image syntax  (or a public https image URL). The platform uploads those into the Feishu card. Prefer workspace files over remote URLs.";
|
||||
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
|
||||
}
|
||||
|
||||
@@ -1916,7 +1960,7 @@ function isPrismaUniqueConstraintError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
async function stageTriggerMessageResources(
|
||||
rt: FeishuRuntime,
|
||||
botCli: FeishuBotCli,
|
||||
msg: MessageReceiveEvent["message"],
|
||||
workspaceRoot: string,
|
||||
limits?: TriggerDeps["resourceLimits"],
|
||||
@@ -1950,7 +1994,7 @@ async function stageTriggerMessageResources(
|
||||
}
|
||||
}
|
||||
return stageMessageResources(
|
||||
rt,
|
||||
botCli,
|
||||
msg.message_id,
|
||||
requests,
|
||||
workspaceRoot,
|
||||
|
||||
+50
-8
@@ -2,7 +2,7 @@ import Fastify from "fastify";
|
||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||
import { registerDatabasePlugin } from "./database/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";
|
||||
@@ -119,15 +119,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() });
|
||||
@@ -180,7 +187,7 @@ export async function startHub(): Promise<void> {
|
||||
allowLegacyFeishuIdentity: false,
|
||||
maxFeishuEventsPerMinute: feishuEventsPerMinute,
|
||||
});
|
||||
feishuRuntime = await startFeishuListenerWithClient(
|
||||
const runtime = await startFeishuListenerWithClient(
|
||||
feishuConfig,
|
||||
larkClient,
|
||||
app.log,
|
||||
@@ -195,6 +202,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");
|
||||
}
|
||||
@@ -202,6 +211,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user