import type { Prisma, PrismaClient } from "@prisma/client"; import { InMemoryModelRegistry, type ModelRegistry, type RoleEntry, type RoleSkillEntry } from "../agent/models.js"; import { lockActiveOrganization } from "../org/status.js"; import { decryptStoredProviderCredential } from "../connections/providerConnections.js"; import { openProviderProxyLease, type AgentProviderLease, type ProviderUpstreamCredential } from "../connections/providerProxy.js"; import { LocalSecretEnvelope } from "../security/secretEnvelope.js"; export type Env = Readonly>; 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_CONCURRENT_RUNS = 1; const DEFAULT_AGENT_MAX_RUN_SECONDS = 900; export interface ProviderRuntimeSettings { readonly id: string; openAgentLease(input: { readonly runId: string }): Promise; } export interface ProviderLeaseContext { readonly providerId: string; readonly projectId: string; readonly runId: string; } export type ProviderLeaseFactory = ( credential: ProviderUpstreamCredential, context: ProviderLeaseContext, ) => Promise; const DEFAULT_PROVIDER_LEASE_FACTORY: ProviderLeaseFactory = (credential) => openProviderProxyLease(credential); export interface RuntimeScope { readonly projectId?: string; } export interface RunPolicyInput { readonly projectId: string; readonly roleId: string; } export interface RunPolicy { readonly maxTurns: number; readonly maxConcurrentRuns: number; readonly maxRunSeconds: number; } export interface RuntimeSettings { provider(providerId: string, scope?: RuntimeScope): Promise; modelRegistry(scope?: RuntimeScope): Promise; runPolicy(input: RunPolicyInput): Promise; } export class EnvRuntimeSettings implements RuntimeSettings { private readonly envSource: () => Env; constructor(env: EnvSource = process.env) { this.envSource = typeof env === "function" ? env : () => env; } async provider(providerId: string, scope?: RuntimeScope): Promise { void providerId; void scope; throw new Error( "process-global provider credentials are disabled; use the project-scoped provider resolver", ); } async modelRegistry(scope?: RuntimeScope): Promise { void scope; return createDefaultModelRegistry(this.envSource()); } async runPolicy(input: RunPolicyInput): Promise { void input; return { maxTurns: positiveIntegerEnv(this.envSource(), "HUB_AGENT_MAX_TURNS", DEFAULT_AGENT_MAX_TURNS), maxConcurrentRuns: positiveIntegerEnv( this.envSource(), "HUB_AGENT_MAX_CONCURRENT_RUNS", DEFAULT_AGENT_MAX_CONCURRENT_RUNS, ), maxRunSeconds: positiveIntegerEnv( this.envSource(), "HUB_AGENT_MAX_RUN_SECONDS", DEFAULT_AGENT_MAX_RUN_SECONDS, ), }; } } export class DatabaseRuntimeSettings implements RuntimeSettings { private readonly envSettings: EnvRuntimeSettings; constructor( private readonly prisma: PrismaClient, private readonly secrets: LocalSecretEnvelope, env: EnvSource = process.env, private readonly leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY, ) { this.envSettings = new EnvRuntimeSettings(env); } async provider(providerId: string, scope?: RuntimeScope): Promise { const projectId = scope?.projectId?.trim(); if (projectId === undefined || projectId === "") { throw new Error("projectId is required to resolve provider credentials"); } await this.prisma.$transaction(async (tx) => { await loadActiveProviderSecret(tx, projectId, providerId); }); return { id: providerId, openAgentLease: async ({ runId }) => this.prisma.$transaction(async (tx) => { const resolved = await loadActiveProviderSecret(tx, projectId, providerId); const secretVersion = resolved.connection.activeSecretVersion; const credential = decryptStoredProviderCredential(this.secrets, { organizationId: resolved.organizationId, connectionId: resolved.connection.id, providerId, secretVersionId: secretVersion.id, envelopeVersion: secretVersion.envelopeVersion, keyId: secretVersion.keyId, envelope: secretVersion.envelope, }); return this.leaseFactory( { baseUrl: credential.baseUrl, authToken: credential.authToken, anthropicApiKey: credential.anthropicApiKey, }, { providerId, projectId, runId }, ); }), }; } async modelRegistry(scope?: RuntimeScope): Promise { const projectId = scope?.projectId?.trim(); if (projectId === undefined || projectId === "") { throw new Error("projectId is required to resolve Agent runtime configuration"); } const project = await this.prisma.project.findUnique({ where: { id: projectId }, select: { archivedAt: true, organization: { select: { id: true, status: true, agentRoles: { where: { disabledAt: null }, orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }], include: { skillBindings: { orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }], include: { skill: true }, }, }, }, }, }, }, }); if (project === null || project.archivedAt !== null) { throw new Error(`active project not found: ${projectId}`); } if (project.organization.status !== "ACTIVE") { throw new Error(`organization ${project.organization.id} is ${project.organization.status}`); } if (project.organization.agentRoles.length === 0) { throw new Error(`no active Agent roles configured for organization ${project.organization.id}`); } const defaultRoles = project.organization.agentRoles.filter((role) => role.isDefault); if (defaultRoles.length !== 1) { throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`); } const defaults = await this.envSettings.modelRegistry(scope); const enabledModels = new Set(defaults.listModels().map((model) => model.id)); const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({ id: role.roleId, label: role.label, defaultModel: validateRoleModel(role.roleId, role.defaultModel, enabledModels), ...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}), ...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}), skills: role.skillBindings.map((binding): RoleSkillEntry => { if (binding.skill.disabledAt !== null) { throw new Error(`role ${role.roleId} selects disabled skill ${binding.skill.name}`); } if (!/^[a-f0-9]{64}$/.test(binding.skill.contentDigest)) { throw new Error(`skill ${binding.skill.name} has invalid content digest`); } if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(binding.skill.name)) { throw new Error(`skill has invalid name: ${binding.skill.name}`); } if (binding.skill.version.trim() === "") { throw new Error(`skill ${binding.skill.name} has empty version`); } return { name: binding.skill.name, version: binding.skill.version, contentDigest: binding.skill.contentDigest, }; }), })); return new InMemoryModelRegistry(defaults.listModels(), roles); } runPolicy(input: RunPolicyInput): Promise { return this.envSettings.runPolicy(input); } } function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly string[] { if (!Array.isArray(value) || value.some((tool) => typeof tool !== "string")) { throw new Error(`role ${roleId} tools must be a JSON string array`); } return value as string[]; } function validateRoleModel( roleId: string, model: string | null, enabledModels: ReadonlySet, ): string | undefined { if (model === null) return undefined; if (!enabledModels.has(model)) throw new Error(`role ${roleId} selects unavailable model ${model}`); return model; } async function loadActiveProviderSecret( tx: Prisma.TransactionClient, projectId: string, providerId: string, ): Promise<{ readonly organizationId: string; readonly connection: { readonly id: string; readonly activeSecretVersion: { readonly id: string; readonly connectionId: string; readonly envelopeVersion: number; readonly keyId: string; readonly envelope: Prisma.JsonValue; readonly retiredAt: Date | null; }; }; }> { const project = await tx.project.findUnique({ where: { id: projectId }, select: { organizationId: true, archivedAt: true }, }); if (project === null || project.archivedAt !== null) { throw new Error(`active project not found: ${projectId}`); } await lockActiveOrganization(tx, project.organizationId); const connection = await tx.organizationProviderConnection.findUnique({ where: { organizationId_providerId: { organizationId: project.organizationId, providerId, }, }, include: { activeSecretVersion: true }, }); const activeSecretVersion = connection?.activeSecretVersion; if (connection === null || connection.status !== "ACTIVE" || activeSecretVersion === null || activeSecretVersion === undefined || activeSecretVersion.retiredAt !== null || activeSecretVersion.connectionId !== connection.id) { throw new Error(`active provider connection not found: ${providerId} for project ${projectId}`); } return { organizationId: project.organizationId, connection: { id: connection.id, activeSecretVersion }, }; } export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings { return new EnvRuntimeSettings(env); } export function createDatabaseRuntimeSettings( prisma: PrismaClient, secrets: LocalSecretEnvelope, env: EnvSource = process.env, leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY, ): RuntimeSettings { return new DatabaseRuntimeSettings(prisma, secrets, env, leaseFactory); } export function defaultSonnetModel(env: Env = process.env): string { return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL; } export function createDefaultModelRegistry(env: Env = process.env): InMemoryModelRegistry { const sonnetModel = defaultSonnetModel(env); const sonnetLabel = readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME") ?? (sonnetModel === DEFAULT_SONNET_MODEL ? DEFAULT_SONNET_LABEL : sonnetModel); return new InMemoryModelRegistry( [ { id: sonnetModel, label: sonnetLabel, toolCapable: true }, ], [ { id: "draft", label: "草稿", defaultModel: sonnetModel }, { id: "review", label: "审校", defaultModel: sonnetModel }, ], ); } function readEnv(env: Env, name: string): string | undefined { const value = env[name]?.trim(); return value === undefined || value === "" ? undefined : value; } function positiveIntegerEnv(env: Env, name: string, fallback: number): number { const raw = readEnv(env, name); if (raw === undefined) return fallback; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) { throw new Error(`runtime setting ${name} must be a positive integer`); } return value; }