mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip lets users choose an agent harness and account. > - The server resolves the runner for new agents. > - Claude still needs the same ordinary native-default journey as Codex. > - Existing agents must retain their runner, model and account. > - This PR defaults new Apple Silicon Claude agents to Paperclip Runner with Opus 5.5. > - Users can connect and work without a runner override. ## Linked Issues or Issue Description Refs #15683. This PR depends on #15994 and precedes #15997. Related scope: #15422, #13978 and #15224. ## What Changed - Resolve new Apple Silicon Claude agents to `paperclip_runner`, `provider: acpx`, `acpxAgent: claude`. Keep explicit Legacy, models, provider routing and saved execution. - Use `claude-opus-5-5` for ordinary Claude defaults and retain explicit overrides. - Reuse existing API-key/subscription connections, credential storage and checks. Generalize the shared Advanced runner control with Claude branding. - Carry configured effort into the shared v9 contract. Preserve compatible permissions, instructions, skills, timeouts and session behavior. Require Legacy for unsupported settings. - Qualify a new immutable managed Claude target at explicit Connect through the existing permission-gated probe. Use fresh scoped discovery for test and hire. Discard stale results and require an explicit Connect after settings change. - Retain merged Codex onboarding recovery, auth preparation, import/effort corrections and Grok Bot gates. - Check the Claude first-task wizard's saved default directly. Update configuration/runtime documentation and exact Opus 5.5 accounting. - Align hire fixtures with the merged host-environment restriction. Native agent-authenticated hires cannot inject host env settings; both creation endpoints verify that no extra agent is saved. ## Verification - Updated onto master `9179d8dcc3e908c217fde4f63c9d08ccf8132eeb` through #15994. Native input v9 carries effort while preserving v7 home modes and v8 Muse. Codex defaults remain unchanged. - Focused local verification passed 285 server tests, 323 UI tests, 124 setup/registry tests, and two v9 permission-pin transport cases. Direct server/UI TypeScript checks passed. An initial 30-second hook timeout was resolved by serial verification with a longer hook budget: all 122 credential/permission cases and 232 snapshot/environment cases pass. Direct server TypeScript checking also passes. - Ordinary web creation with the experiment disabled saved native Claude, Opus 5.5 and High effort without runner/model overrides. The actual product task and follow-up both succeeded on integrated source `b2c9fe43f9cd3bc5dd9d22552da0148845ba71a4` with the cloud-built daemon from `04e0202ca81387b17cbe7fd094a8338648356fe9`. The final master reconciliation also changes shared runtime source. Historical proof retains its source identity; a final-source cloud qualification is running before refreshing the preview daemon. - Both actual browser downloads were checked: v1 has three sections/six checkboxes (451 bytes); v2 preserves the original body and adds two Rollback checks (612 bytes). The original attachment stayed byte-identical. The follow-up records session resume and the same provider session. Total estimated API usage: $0.518432800, with no pending/unpriced usage in the successful fixture. - Fixed the live-discovered setup incompatibility: native API-key validation no longer depends on the global Legacy Claude CLI. The existing provider endpoint verifies the key without a paid prompt; subscription/custom routing probes remain intact. - Fixed security review's native-hire credential redirection: agent-supplied URLs, proxy/launch settings and other host environment controls are rejected before inheritance. Native credential references remain supported and partial edits use the saved harness. Added create/hire/update regression coverage. - Current-head cloud CI is green. All three stack PRs have fresh Greptile 5/5 reviews and zero unresolved review threads. Required typecheck, test and build gates ran in cloud CI; Docker work stayed remote. Earlier failed checks remain in their original runs. - Docker and native release builds ran in cloud infrastructure. An inadvertent local Rust prerequisite was stopped; no local Docker build was run. - The final master merge preserves task-loading, permission, skill-retention and OpenRouter changes. Pi uses a fresh combined profile 27, preserving both historical profile-24 declarations; Copilot remains profile 22. Focused protocol checks passed 155 cases (one skipped), the updated Pi attestation check passed 18 (one skipped), and 91 evaluation/catalog checks passed. - Latest-application manual continuation passed in 37.942 seconds on `695e5c005a85bddc8711d3fcddcaf6b7e1fb64d0`, with High effort, native Opus 5.5, Anthropic API attribution, the same resumed Claude session, an independently checked 635-byte file and persisted Done status. It uses the earlier verified Mac daemon; the preceding-source package qualification passed at `dca62c7aeb4b45f0dd91a8e18d386a16a2658ef8`. That package run predates the small TypeScript startup fix and is not relabeled as qualification for it. - Upgrade limitation: changed compiled sidecar identity rejected old durable launch state and recovery hit session-open bounds before eventually resuming. That attempt was operator-cancelled and retained; the successful manual continuation does not qualify seamless upgrades. - Local latency profile, Opus 5.5/High unchanged: three alternating real-ACP loopback samples per variant reduce median provider-ready time from 5.081s to 4.034s. The matched real API task startup improves from 5.533s to 4.348s, but total reply time changes from 17.933s to 20.221s because agent work varies. This establishes a startup saving of about one second, not a consistent total-reply speedup. A real candidate follow-up passed in 20.146s and resumed the same saved session. The loopback fixture makes no external model requests; failed initial probes remain retained. - The startup change passes 107 focused runtime/effort/lease tests, 53 profile checks (one skipped), TypeScript checking and the TypeScript-only runtime build. Native hire fixture checks pass 70 tests; their first local run retained a socket-hangup failure and stale OpenCode expectation, both accounted for before the passing rerun. Broader required checks run in cloud CI. ## Risks - Missing packages, authentication rejection, unsupported settings and unavailable models must fail explicitly. Existing agents retain saved execution; explicit runner changes use existing revision/session invalidation. - Subscription follow-up remains unverified; remaining live acceptance uses API keys per human steering. Historical accounting holds and the separate $50 ceiling remain intact. - The subscription acceptance limit remains explicit for review. No Claude database migration is introduced. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, code execution and browser testing. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] Focused local tests and final cloud verification pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2212 lines
92 KiB
TypeScript
2212 lines
92 KiB
TypeScript
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
||
import fs from "node:fs/promises";
|
||
import { readFileSync } from "node:fs";
|
||
import { createRequire } from "node:module";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||
import { and, desc, eq, ne } from "drizzle-orm";
|
||
import type { Db } from "@paperclipai/db";
|
||
import { activityLog, agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db";
|
||
import { agentHarnessType, agentRunner, syncRoutineVariablesWithTemplate, type AgentRunnerChoice } from "@paperclipai/shared";
|
||
import type { Agent, Approval, CompanySkill, PermissionKey, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
|
||
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
|
||
import { logActivity } from "./activity-log.js";
|
||
import { adoptAgentFiles, agentFilePath, fileHash, readAgentFile, snapshotAgentFiles } from "./agent-file-store.js";
|
||
import { assertInstructionPathSafe, instructionBytes, materializeInstructionBytes } from "./agent-instruction-files.js";
|
||
import { agentInstructionRevisionService } from "./agent-instruction-revisions.js";
|
||
import type { AuthorizationActor } from "./authorization.js";
|
||
import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js";
|
||
import { agentService } from "./agents.js";
|
||
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
||
import { approvalService } from "./approvals.js";
|
||
import {
|
||
readBuiltInAgentMarker,
|
||
withBuiltInAgentMarker,
|
||
} from "../lib/built-in-agent-metadata.js";
|
||
import { companySkillService } from "./company-skills.js";
|
||
import { routineService } from "./routines.js";
|
||
import { accessService } from "./access.js";
|
||
import { listAdapterModels } from "../adapters/registry.js";
|
||
import {
|
||
resourceStatus,
|
||
stableJson,
|
||
stockHash,
|
||
type ManagedResourceStockStatus,
|
||
} from "./managed-resource-drift.js";
|
||
|
||
export type BuiltInAgentStatus = "not_provisioned" | "pending_approval" | "needs_setup" | "ready" | "paused";
|
||
|
||
export interface BuiltInAgentDefinition {
|
||
key: string;
|
||
displayName: string;
|
||
featureKeys: string[];
|
||
shortPurpose: string;
|
||
defaultInstructions: string;
|
||
defaultRole: string;
|
||
defaultTitle?: string | null;
|
||
defaultIcon?: string | null;
|
||
defaultPermissions?: Record<string, unknown>;
|
||
defaultStatus?: "idle" | "paused";
|
||
defaultManager?: "single_root_agent" | null;
|
||
allowedAdapterTypes?: string[];
|
||
defaultAdapterType?: string;
|
||
defaultAdapterConfig?: Record<string, unknown>;
|
||
defaultBudgetMonthlyCents?: number;
|
||
defaultRuntimeConfig?: Record<string, unknown>;
|
||
bundle?: BuiltInAgentBundleDefinition;
|
||
}
|
||
|
||
export interface BuiltInAgentState {
|
||
definition: BuiltInAgentDefinition;
|
||
status: BuiltInAgentStatus;
|
||
agentId: string | null;
|
||
agent: Agent | null;
|
||
pauseReason: string | null;
|
||
resources: BuiltInManagedResourceState[];
|
||
approval?: Approval | null;
|
||
}
|
||
|
||
export interface BuiltInAgentProvisionInput {
|
||
runner?: AgentRunnerChoice;
|
||
adapterType?: string;
|
||
adapterConfig?: Record<string, unknown>;
|
||
budgetMonthlyCents?: number;
|
||
}
|
||
|
||
export interface BuiltInAgentProvisionActor {
|
||
requestedByAgentId?: string | null;
|
||
requestedByUserId?: string | null;
|
||
}
|
||
|
||
export interface BuiltInAgentProvisionResult {
|
||
state: BuiltInAgentState;
|
||
approval: Approval | null;
|
||
}
|
||
|
||
export type BuiltInManagedResourceKind = "instructions" | "skill" | "routine";
|
||
export type BuiltInManagedResourceStockStatus = ManagedResourceStockStatus;
|
||
|
||
export interface BuiltInManagedResourceState {
|
||
resourceKind: BuiltInManagedResourceKind;
|
||
resourceKey: string;
|
||
resourceId: string | null;
|
||
stockVersion: string;
|
||
stockHash: string;
|
||
currentHash: string | null;
|
||
stockStatus: BuiltInManagedResourceStockStatus;
|
||
updateAvailable: boolean;
|
||
resetAvailable: boolean;
|
||
changedFiles?: string[];
|
||
scheduleEnabled?: boolean;
|
||
pendingUpdateInteractionId?: string | null;
|
||
pendingUpdateIssueId?: string | null;
|
||
pendingUpdateIssueIdentifier?: string | null;
|
||
}
|
||
|
||
export interface BuiltInAgentBundleDefinition {
|
||
stockVersion: string;
|
||
instructions: {
|
||
entryFile: string;
|
||
files: Record<string, string>;
|
||
};
|
||
skill: {
|
||
skillKey: string;
|
||
displayName: string;
|
||
slug: string;
|
||
canonicalKey: string;
|
||
files: Record<string, string>;
|
||
};
|
||
routine: {
|
||
routineKey: string;
|
||
title: string;
|
||
description: string;
|
||
status: "active" | "paused";
|
||
priority: "critical" | "high" | "medium" | "low";
|
||
concurrencyPolicy: "always_enqueue" | "coalesce_if_active" | "skip_if_active";
|
||
catchUpPolicy: "enqueue_missed_with_cap" | "skip_missed";
|
||
variables: RoutineVariable[];
|
||
triggers: Array<{
|
||
kind: "schedule";
|
||
label: string | null;
|
||
enabled: boolean;
|
||
cronExpression: string;
|
||
timezone: string;
|
||
}>;
|
||
};
|
||
}
|
||
|
||
export interface RequiredBuiltInAgentWarning {
|
||
code: "built_in_agent_paused";
|
||
key: string;
|
||
agentId: string;
|
||
message: string;
|
||
pauseReason: string | null;
|
||
}
|
||
|
||
export interface RequiredBuiltInAgent {
|
||
definition: BuiltInAgentDefinition;
|
||
agent: Agent;
|
||
warning: RequiredBuiltInAgentWarning | null;
|
||
}
|
||
|
||
const BUILT_IN_AGENT_KEY_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
||
|
||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||
const require = createRequire(import.meta.url);
|
||
const BUILT_INS_DIR = path.resolve(moduleDir, "../built-ins/agents");
|
||
const SOURCE_BUILT_INS_DIR = path.resolve(moduleDir, "../../src/built-ins/agents");
|
||
|
||
const FALLBACK_REFLECTION_COACH_INSTRUCTIONS = [
|
||
"# Reflection Coach",
|
||
"",
|
||
"You are Paperclip's built-in Reflection Coach.",
|
||
"Review recent agent execution records, identify evidence-backed improvement patterns, and propose the smallest durable instruction, skill, or tool-description change.",
|
||
"Do not apply changes in the same run. Present a reviewable diff and wait for the required Paperclip issue-thread approval before any follow-up applies it.",
|
||
"",
|
||
].join("\n");
|
||
|
||
const FALLBACK_REFLECTION_COACH_ROUTINE = [
|
||
"Review recent agent work for coaching opportunities.",
|
||
"",
|
||
"Select recent target agents, inspect their work history and current instructions, then propose small, review-gated improvements that would prevent repeated misses.",
|
||
"",
|
||
].join("\n");
|
||
|
||
const FALLBACK_REFLECTION_COACH_SKILL = [
|
||
"---",
|
||
"name: reflection-coach",
|
||
"description: Reflect on another agent's recent execution record and propose the smallest review-gated improvement.",
|
||
"key: paperclipai/bundled/paperclip-operations/reflection-coach",
|
||
"---",
|
||
"",
|
||
"# Reflection Coach",
|
||
"",
|
||
"Review another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable improvement as a reviewable diff. Do not hot-swap instructions or skills in the same run.",
|
||
"",
|
||
].join("\n");
|
||
|
||
const FALLBACK_SUMMARIZER_INSTRUCTIONS = [
|
||
"You are Summarizer, a built-in reporting agent at Paperclip.",
|
||
"",
|
||
"Turn the current state of a Paperclip scope (project, workspaces overview, project workspace, or execution workspace) into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. Use the `summarize-status` skill as your operating procedure.",
|
||
"",
|
||
"Read-and-report only: never change issues, workspaces, or code. Cite issue identifiers, never fabricate status, keep every read company-scoped, and run on the low-cost model profile lane by default.",
|
||
"",
|
||
].join("\n");
|
||
|
||
const FALLBACK_SUMMARIZER_ROUTINE = [
|
||
"Regenerate summary slots whose scope has changed since their last revision.",
|
||
"",
|
||
"Paused by default; spends no tokens until an operator enables the schedule or runs it manually. Read-and-report only — the only write is the summary revision.",
|
||
"",
|
||
].join("\n");
|
||
|
||
const FALLBACK_SUMMARIZER_SKILL = [
|
||
"---",
|
||
"name: summarize-status",
|
||
"description: Write a short, colloquial summary for a Paperclip summary slot: open with the 1–3 specific, concrete actions the reader needs to take right now to unblock the work, then a brief plain-language status, streaming progress as it works.",
|
||
"key: paperclipai/bundled/paperclip-operations/summarize-status",
|
||
"---",
|
||
"",
|
||
"# Summarize status",
|
||
"",
|
||
"Turn a Paperclip scope's current state into a short, colloquial Markdown summary and write it back to the scope's summary slot. Open with the 1–3 specific, concrete, actionable items the reader should do right now to unblock the work — each saying what to do and why it's the thing holding up progress, with an inline link — then a brief plain-prose status of where things stand, written for a reader who has not memorized issue ids or threads. Read whatever issues you need to understand the state, then focus on what's most important; never a task list or a dump of issue links. If genuinely nothing needs the reader, say so plainly in one line and name the next thing worth watching. Post the first `STATUS:` line immediately from the first task in context, keep streaming `STATUS:` lines while working, and emit the final Markdown between the summary-draft sentinels before the slot write. Read-and-report only; never fabricate status.",
|
||
"",
|
||
].join("\n");
|
||
|
||
const warnedBuiltInTextFallbacks = new Set<string>();
|
||
const warnedBuiltInTextReadErrors = new Set<string>();
|
||
|
||
function resolvePackageRoot(packageName: string) {
|
||
try {
|
||
return path.dirname(require.resolve(`${packageName}/package.json`));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function readBuiltInTextWithFallback(
|
||
label: string,
|
||
candidatePaths: string[],
|
||
fallbackText: string,
|
||
) {
|
||
const attemptedPaths = candidatePaths.filter((candidatePath) => candidatePath.trim().length > 0);
|
||
for (const candidatePath of attemptedPaths) {
|
||
try {
|
||
return readFileSync(candidatePath, "utf8");
|
||
} catch (error) {
|
||
const code = (error as NodeJS.ErrnoException).code;
|
||
if (code !== "ENOENT") {
|
||
const codeLabel = code || String(error);
|
||
const warningKey = [label, candidatePath, codeLabel].join(":");
|
||
if (!warnedBuiltInTextReadErrors.has(warningKey)) {
|
||
warnedBuiltInTextReadErrors.add(warningKey);
|
||
console.warn(
|
||
"[paperclip] Built-in agent asset " + label + " read error on " + candidatePath + ": " + codeLabel,
|
||
);
|
||
}
|
||
}
|
||
// Try every known runtime/source path before falling back to compiled text.
|
||
}
|
||
}
|
||
|
||
if (!warnedBuiltInTextFallbacks.has(label)) {
|
||
warnedBuiltInTextFallbacks.add(label);
|
||
console.warn(
|
||
`[paperclip] Built-in agent asset ${label} was not readable; using bundled fallback text. `
|
||
+ `Checked: ${attemptedPaths.join(", ")}`,
|
||
);
|
||
}
|
||
return fallbackText;
|
||
}
|
||
|
||
function readBuiltInText(relativePath: string, fallbackText: string) {
|
||
return readBuiltInTextWithFallback(
|
||
relativePath,
|
||
[path.join(BUILT_INS_DIR, relativePath), path.join(SOURCE_BUILT_INS_DIR, relativePath)],
|
||
fallbackText,
|
||
);
|
||
}
|
||
|
||
const skillsCatalogRoot = resolvePackageRoot("@paperclipai/skills-catalog");
|
||
const REFLECTION_COACH_INSTRUCTIONS = readBuiltInText("reflection-coach/AGENTS.md", FALLBACK_REFLECTION_COACH_INSTRUCTIONS);
|
||
const REFLECTION_COACH_ROUTINE = readBuiltInText(
|
||
"reflection-coach/routines/recent-agent-reflection.md",
|
||
FALLBACK_REFLECTION_COACH_ROUTINE,
|
||
);
|
||
const REFLECTION_COACH_SKILL = readBuiltInTextWithFallback(
|
||
"reflection-coach/SKILL.md",
|
||
[
|
||
path.resolve(
|
||
moduleDir,
|
||
"../../../packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md",
|
||
),
|
||
...(skillsCatalogRoot
|
||
? [path.join(skillsCatalogRoot, "catalog/bundled/paperclip-operations/reflection-coach/SKILL.md")]
|
||
: []),
|
||
],
|
||
FALLBACK_REFLECTION_COACH_SKILL,
|
||
);
|
||
|
||
const SUMMARIZER_INSTRUCTIONS = readBuiltInText("summarizer/AGENTS.md", FALLBACK_SUMMARIZER_INSTRUCTIONS);
|
||
const SUMMARIZER_ROUTINE = readBuiltInText(
|
||
"summarizer/routines/refresh-stale-summaries.md",
|
||
FALLBACK_SUMMARIZER_ROUTINE,
|
||
);
|
||
const SUMMARIZER_SKILL = readBuiltInTextWithFallback(
|
||
"summarizer/SKILL.md",
|
||
[
|
||
path.resolve(
|
||
moduleDir,
|
||
"../../../packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md",
|
||
),
|
||
...(skillsCatalogRoot
|
||
? [path.join(skillsCatalogRoot, "catalog/bundled/paperclip-operations/summarize-status/SKILL.md")]
|
||
: []),
|
||
],
|
||
FALLBACK_SUMMARIZER_SKILL,
|
||
);
|
||
|
||
const DEFINITIONS = validateBuiltInAgentDefinitions([
|
||
{
|
||
key: "briefs",
|
||
displayName: "Briefs Agent",
|
||
featureKeys: ["briefs"],
|
||
shortPurpose: "Prepares concise operational briefs for the board and agent company.",
|
||
defaultInstructions:
|
||
"You are Paperclip's built-in Briefs agent. Produce concise, sourced operational briefs that help the board understand current company work, risks, and next actions.",
|
||
defaultRole: "general",
|
||
allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"],
|
||
defaultBudgetMonthlyCents: 0,
|
||
},
|
||
{
|
||
key: "learning",
|
||
displayName: "Learning Agent",
|
||
featureKeys: ["learning"],
|
||
shortPurpose: "Maintains reusable company learning from completed work and recurring patterns.",
|
||
defaultInstructions:
|
||
"You are Paperclip's built-in Learning agent. Extract durable lessons from completed work, preserve useful patterns, and keep learning artifacts grounded in source context.",
|
||
defaultRole: "general",
|
||
allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"],
|
||
defaultBudgetMonthlyCents: 0,
|
||
},
|
||
{
|
||
key: "reflection-coach",
|
||
displayName: "Reflection Coach",
|
||
featureKeys: ["reflection-coach"],
|
||
shortPurpose:
|
||
"Runs evidence-backed reflection loops on recent agent work, proposes small instruction and skill improvements, and requests approval before changes are applied.",
|
||
defaultInstructions: REFLECTION_COACH_INSTRUCTIONS,
|
||
defaultRole: "general",
|
||
defaultTitle: "Reflection Coach",
|
||
defaultIcon: "eye",
|
||
defaultPermissions: {
|
||
canCreateAgents: false,
|
||
canCreateSkills: false,
|
||
builtInMutationPolicy: {
|
||
requiresDisplayedDiff: true,
|
||
requiresAcceptedTaskInteraction: true,
|
||
applyInSeparateFollowUpRun: true,
|
||
},
|
||
},
|
||
defaultStatus: "paused",
|
||
defaultManager: "single_root_agent",
|
||
allowedAdapterTypes: ["claude_local", "codex_local", "gemini_local", "opencode_local", "process"],
|
||
defaultBudgetMonthlyCents: 0,
|
||
bundle: {
|
||
stockVersion: "2026-07-08",
|
||
instructions: {
|
||
entryFile: "AGENTS.md",
|
||
files: {
|
||
"AGENTS.md": REFLECTION_COACH_INSTRUCTIONS,
|
||
},
|
||
},
|
||
skill: {
|
||
skillKey: "reflection-coach",
|
||
displayName: "Reflection Coach",
|
||
slug: "reflection-coach",
|
||
canonicalKey: "paperclipai/bundled/paperclip-operations/reflection-coach",
|
||
files: {
|
||
"reflection-coach/SKILL.md": REFLECTION_COACH_SKILL,
|
||
},
|
||
},
|
||
routine: {
|
||
routineKey: "recent-agent-reflection",
|
||
title: "Review recent agent trajectories for coaching proposals",
|
||
description: REFLECTION_COACH_ROUTINE,
|
||
status: "paused",
|
||
priority: "medium",
|
||
concurrencyPolicy: "coalesce_if_active",
|
||
catchUpPolicy: "skip_missed",
|
||
variables: [
|
||
{ name: "lookbackDays", label: "Lookback days", type: "number", defaultValue: 7, required: true, options: [] },
|
||
{ name: "maxTargetAgents", label: "Max target agents", type: "number", defaultValue: 8, required: true, options: [] },
|
||
{
|
||
name: "targetAgentMode",
|
||
label: "Target agent mode",
|
||
type: "select",
|
||
defaultValue: "recent_active",
|
||
required: true,
|
||
options: ["recent_active", "recent_blocked", "recent_completed"],
|
||
},
|
||
{ name: "excludeAgentIds", label: "Excluded agent ids", type: "text", defaultValue: "", required: false, options: [] },
|
||
],
|
||
triggers: [
|
||
{
|
||
kind: "schedule",
|
||
label: "Weekly reflection review",
|
||
enabled: false,
|
||
cronExpression: "0 9 * * 1",
|
||
timezone: "UTC",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
key: "summarizer",
|
||
displayName: "Summarizer",
|
||
featureKeys: ["summarizer"],
|
||
shortPurpose:
|
||
"Writes short, human-readable Markdown status summaries into project, workspaces-overview, project-workspace, and execution-workspace summary slots on demand.",
|
||
defaultInstructions: SUMMARIZER_INSTRUCTIONS,
|
||
defaultRole: "general",
|
||
defaultTitle: "Summarizer",
|
||
defaultIcon: "sparkles",
|
||
defaultPermissions: {
|
||
canCreateAgents: false,
|
||
canCreateSkills: false,
|
||
},
|
||
defaultStatus: "paused",
|
||
defaultManager: "single_root_agent",
|
||
allowedAdapterTypes: ["claude_local", "codex_local", "gemini_local", "opencode_local", "process"],
|
||
defaultAdapterType: "claude_local",
|
||
defaultAdapterConfig: {
|
||
model: "claude-haiku-4-5",
|
||
},
|
||
defaultBudgetMonthlyCents: 0,
|
||
bundle: {
|
||
stockVersion: "2026-08-02",
|
||
instructions: {
|
||
entryFile: "AGENTS.md",
|
||
files: {
|
||
"AGENTS.md": SUMMARIZER_INSTRUCTIONS,
|
||
},
|
||
},
|
||
skill: {
|
||
skillKey: "summarize-status",
|
||
displayName: "Summarize status",
|
||
slug: "summarize-status",
|
||
canonicalKey: "paperclipai/bundled/paperclip-operations/summarize-status",
|
||
files: {
|
||
"summarize-status/SKILL.md": SUMMARIZER_SKILL,
|
||
},
|
||
},
|
||
routine: {
|
||
routineKey: "refresh-stale-summaries",
|
||
title: "Refresh stale summary slots",
|
||
description: SUMMARIZER_ROUTINE,
|
||
status: "paused",
|
||
priority: "medium",
|
||
concurrencyPolicy: "coalesce_if_active",
|
||
catchUpPolicy: "skip_missed",
|
||
variables: [
|
||
{ name: "staleAfterHours", label: "Refresh slots older than (hours)", type: "number", defaultValue: 24, required: true, options: [] },
|
||
{ name: "maxSlots", label: "Max slots to refresh per run", type: "number", defaultValue: 10, required: true, options: [] },
|
||
{
|
||
name: "scopeKinds",
|
||
label: "Scope kinds to include",
|
||
type: "select",
|
||
defaultValue: "all",
|
||
required: true,
|
||
options: ["all", "project", "workspaces_overview", "project_workspace", "execution_workspace"],
|
||
},
|
||
],
|
||
triggers: [
|
||
{
|
||
kind: "schedule",
|
||
label: "Daily stale-summary refresh",
|
||
enabled: false,
|
||
cronExpression: "0 8 * * *",
|
||
timezone: "UTC",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
]);
|
||
|
||
const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition]));
|
||
|
||
// Bundled built-in agents that should be provisioned automatically when a
|
||
// company is created (and re-ensured on startup reconcile). Empty by default so
|
||
// a new user starts clean — the Reflection Coach and Summarizer are opt-in, not
|
||
// seeded. Add a definition key here to restore automatic provisioning.
|
||
const AUTO_PROVISION_ON_COMPANY_CREATE_KEYS = new Set<string>([]);
|
||
|
||
const ROOT_AGENT_DEFAULT_CHANGE_GRANTS: PermissionKey[] = ["agents:configure", "skills:create"];
|
||
const BUILT_IN_AGENT_DEFAULT_GRANTS: Record<string, PermissionKey[]> = {
|
||
"reflection-coach": ["agents:suggest-changes", "skills:suggest-changes"],
|
||
};
|
||
|
||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function nonEmptyString(value: unknown) {
|
||
return typeof value === "string" && value.trim().length > 0;
|
||
}
|
||
|
||
function uniqueNonEmptyStrings(values: string[]) {
|
||
const seen = new Set<string>();
|
||
const result: string[] = [];
|
||
for (const value of values) {
|
||
const normalized = value.trim();
|
||
if (!normalized || seen.has(normalized)) continue;
|
||
seen.add(normalized);
|
||
result.push(normalized);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function changedFileList(currentFiles: Record<string, string | null>, stockFiles: Record<string, string>) {
|
||
const paths = new Set([...Object.keys(currentFiles), ...Object.keys(stockFiles)]);
|
||
return [...paths]
|
||
.filter((filePath) => (currentFiles[filePath] ?? null) !== (stockFiles[filePath] ?? null))
|
||
.sort((left, right) => left.localeCompare(right));
|
||
}
|
||
|
||
function stockState(input: {
|
||
resourceKind: BuiltInManagedResourceKind;
|
||
resourceKey: string;
|
||
resourceId: string | null;
|
||
stockVersion: string;
|
||
latestStockHash: string;
|
||
currentHash: string | null;
|
||
bindingStockHash: string | null;
|
||
changedFiles?: string[];
|
||
scheduleEnabled?: boolean;
|
||
pendingUpdateInteractionId?: string | null;
|
||
pendingUpdateIssueId?: string | null;
|
||
pendingUpdateIssueIdentifier?: string | null;
|
||
}): BuiltInManagedResourceState {
|
||
const status = resourceStatus({
|
||
resourceId: input.resourceId,
|
||
currentHash: input.currentHash,
|
||
bindingStockHash: input.bindingStockHash,
|
||
latestStockHash: input.latestStockHash,
|
||
});
|
||
return {
|
||
resourceKind: input.resourceKind,
|
||
resourceKey: input.resourceKey,
|
||
resourceId: input.resourceId,
|
||
stockVersion: input.stockVersion,
|
||
stockHash: input.latestStockHash,
|
||
currentHash: input.currentHash,
|
||
stockStatus: status,
|
||
updateAvailable: status === "stock_update_available" || status === "operator_modified",
|
||
resetAvailable: status !== "stock_current",
|
||
...(input.changedFiles && input.changedFiles.length > 0 ? { changedFiles: input.changedFiles } : {}),
|
||
...(input.scheduleEnabled !== undefined ? { scheduleEnabled: input.scheduleEnabled } : {}),
|
||
...(input.pendingUpdateInteractionId !== undefined
|
||
? { pendingUpdateInteractionId: input.pendingUpdateInteractionId }
|
||
: {}),
|
||
...(input.pendingUpdateIssueId !== undefined ? { pendingUpdateIssueId: input.pendingUpdateIssueId } : {}),
|
||
...(input.pendingUpdateIssueIdentifier !== undefined
|
||
? { pendingUpdateIssueIdentifier: input.pendingUpdateIssueIdentifier }
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
export function validateBuiltInAgentDefinitions(definitions: BuiltInAgentDefinition[]) {
|
||
const seenKeys = new Set<string>();
|
||
for (const definition of definitions) {
|
||
if (!BUILT_IN_AGENT_KEY_PATTERN.test(definition.key)) {
|
||
throw new Error(`Invalid built-in agent key: ${definition.key}`);
|
||
}
|
||
if (seenKeys.has(definition.key)) {
|
||
throw new Error(`Duplicate built-in agent key: ${definition.key}`);
|
||
}
|
||
seenKeys.add(definition.key);
|
||
if (!definition.displayName.trim()) {
|
||
throw new Error(`Built-in agent ${definition.key} requires a displayName`);
|
||
}
|
||
if (!definition.shortPurpose.trim()) {
|
||
throw new Error(`Built-in agent ${definition.key} requires a shortPurpose`);
|
||
}
|
||
if (!definition.defaultInstructions.trim()) {
|
||
throw new Error(`Built-in agent ${definition.key} requires defaultInstructions`);
|
||
}
|
||
if (!definition.defaultRole.trim()) {
|
||
throw new Error(`Built-in agent ${definition.key} requires a defaultRole`);
|
||
}
|
||
if (uniqueNonEmptyStrings(definition.featureKeys).length !== definition.featureKeys.length) {
|
||
throw new Error(`Built-in agent ${definition.key} featureKeys must be unique non-empty strings`);
|
||
}
|
||
if (definition.featureKeys.length === 0) {
|
||
throw new Error(`Built-in agent ${definition.key} requires at least one featureKey`);
|
||
}
|
||
if (
|
||
definition.allowedAdapterTypes
|
||
&& uniqueNonEmptyStrings(definition.allowedAdapterTypes).length !== definition.allowedAdapterTypes.length
|
||
) {
|
||
throw new Error(`Built-in agent ${definition.key} allowedAdapterTypes must be unique non-empty strings`);
|
||
}
|
||
if (
|
||
definition.defaultAdapterType
|
||
&& definition.allowedAdapterTypes
|
||
&& !definition.allowedAdapterTypes.includes(definition.defaultAdapterType)
|
||
) {
|
||
throw new Error(`Built-in agent ${definition.key} defaultAdapterType must be allowed`);
|
||
}
|
||
if (
|
||
definition.defaultBudgetMonthlyCents !== undefined
|
||
&& (!Number.isInteger(definition.defaultBudgetMonthlyCents) || definition.defaultBudgetMonthlyCents < 0)
|
||
) {
|
||
throw new Error(`Built-in agent ${definition.key} defaultBudgetMonthlyCents must be a non-negative integer`);
|
||
}
|
||
if (definition.bundle) {
|
||
if (!definition.bundle.stockVersion.trim()) {
|
||
throw new Error(`Built-in agent ${definition.key} bundle requires a stockVersion`);
|
||
}
|
||
if (!definition.bundle.instructions.files[definition.bundle.instructions.entryFile]) {
|
||
throw new Error(`Built-in agent ${definition.key} bundle instructions require the entry file`);
|
||
}
|
||
if (!definition.bundle.skill.files[`${definition.bundle.skill.slug}/SKILL.md`]) {
|
||
throw new Error(`Built-in agent ${definition.key} bundle skill requires SKILL.md`);
|
||
}
|
||
if (!definition.bundle.routine.description.trim()) {
|
||
throw new Error(`Built-in agent ${definition.key} bundle routine requires a description`);
|
||
}
|
||
}
|
||
}
|
||
return definitions.map((definition) => ({
|
||
...definition,
|
||
featureKeys: [...definition.featureKeys],
|
||
allowedAdapterTypes: definition.allowedAdapterTypes ? [...definition.allowedAdapterTypes] : undefined,
|
||
defaultAdapterConfig: definition.defaultAdapterConfig ? { ...definition.defaultAdapterConfig } : undefined,
|
||
bundle: definition.bundle ? {
|
||
...definition.bundle,
|
||
instructions: {
|
||
...definition.bundle.instructions,
|
||
files: { ...definition.bundle.instructions.files },
|
||
},
|
||
skill: {
|
||
...definition.bundle.skill,
|
||
files: { ...definition.bundle.skill.files },
|
||
},
|
||
routine: {
|
||
...definition.bundle.routine,
|
||
variables: definition.bundle.routine.variables.map((variable) => ({ ...variable, options: [...variable.options] })),
|
||
triggers: definition.bundle.routine.triggers.map((trigger) => ({ ...trigger })),
|
||
},
|
||
} : undefined,
|
||
}));
|
||
}
|
||
|
||
export function listBuiltInAgentDefinitions() {
|
||
return DEFINITIONS.map((definition) => ({
|
||
...definition,
|
||
featureKeys: [...definition.featureKeys],
|
||
allowedAdapterTypes: definition.allowedAdapterTypes ? [...definition.allowedAdapterTypes] : undefined,
|
||
defaultAdapterConfig: definition.defaultAdapterConfig ? { ...definition.defaultAdapterConfig } : undefined,
|
||
}));
|
||
}
|
||
|
||
export function getBuiltInAgentDefinition(key: string) {
|
||
return DEFINITIONS_BY_KEY.get(key) ?? null;
|
||
}
|
||
|
||
export function requireBuiltInAgentDefinition(key: string) {
|
||
const definition = getBuiltInAgentDefinition(key);
|
||
if (!definition) throw notFound(`Built-in agent definition not found: ${key}`);
|
||
return definition;
|
||
}
|
||
|
||
function defaultAdapterType(definition: BuiltInAgentDefinition) {
|
||
return definition.defaultAdapterType ?? definition.allowedAdapterTypes?.[0] ?? "process";
|
||
}
|
||
|
||
function normalizeAdapterType(value: unknown) {
|
||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||
}
|
||
|
||
function selectPreferredAdapterType(
|
||
definition: BuiltInAgentDefinition,
|
||
usage: Array<{ adapterType: string; count: number }>,
|
||
) {
|
||
const fallback = defaultAdapterType(definition);
|
||
const preference = definition.allowedAdapterTypes ?? [];
|
||
if (preference.length === 0) return fallback;
|
||
|
||
const rank = new Map(preference.map((adapterType, index) => [adapterType, index]));
|
||
let selected: { adapterType: string; count: number; rank: number } | null = null;
|
||
for (const entry of usage) {
|
||
const adapterRank = rank.get(entry.adapterType);
|
||
if (adapterRank === undefined) continue;
|
||
if (!selected || entry.count > selected.count || (entry.count === selected.count && adapterRank < selected.rank)) {
|
||
selected = { ...entry, rank: adapterRank };
|
||
}
|
||
}
|
||
return selected?.adapterType ?? fallback;
|
||
}
|
||
|
||
function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string, adapterConfig: Record<string, unknown> = {}) {
|
||
if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(agentHarnessType(adapterType, adapterConfig))) {
|
||
throw unprocessable(`Adapter type ${adapterType} is not allowed for built-in agent ${definition.key}`, {
|
||
code: "built_in_agent_adapter_not_allowed",
|
||
key: definition.key,
|
||
allowedAdapterTypes: definition.allowedAdapterTypes,
|
||
});
|
||
}
|
||
}
|
||
|
||
function hasCompleteAdapterConfig(adapterType: string, adapterConfig: unknown) {
|
||
if (!isPlainRecord(adapterConfig)) return false;
|
||
if (["process", "command"].includes(adapterType)) {
|
||
return nonEmptyString(adapterConfig.command) || nonEmptyString(adapterConfig.script);
|
||
}
|
||
if (adapterType === "http") {
|
||
return nonEmptyString(adapterConfig.url) || nonEmptyString(adapterConfig.endpoint) || nonEmptyString(adapterConfig.webhookUrl);
|
||
}
|
||
if (adapterType === "openclaw_gateway" || adapterType === "hermes_gateway") {
|
||
return nonEmptyString(adapterConfig.baseUrl) || nonEmptyString(adapterConfig.url);
|
||
}
|
||
return nonEmptyString(adapterConfig.model);
|
||
}
|
||
|
||
export function deriveBuiltInAgentStatus(agent: Pick<Agent, "adapterType" | "adapterConfig" | "status" | "pausedAt"> & { metadata?: Agent["metadata"] } | null): BuiltInAgentStatus {
|
||
if (!agent) return "not_provisioned";
|
||
if (agent.status === "pending_approval") return "pending_approval";
|
||
if (agent.status === "paused" || agent.pausedAt) return "paused";
|
||
return agent.metadata?.paperclipBuiltInSetupRequired !== true && hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup";
|
||
}
|
||
|
||
function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record<string, unknown> | null) {
|
||
return withBuiltInAgentMarker(existing, {
|
||
key: definition.key,
|
||
featureKeys: definition.featureKeys,
|
||
});
|
||
}
|
||
|
||
function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput = {}) {
|
||
const adapterType = input.adapterType ?? defaultAdapterType(definition);
|
||
assertAdapterAllowed(definition, adapterType, input.adapterConfig);
|
||
return {
|
||
runner: input.runner,
|
||
name: definition.displayName,
|
||
role: definition.defaultRole,
|
||
title: definition.defaultTitle ?? null,
|
||
icon: definition.defaultIcon ?? null,
|
||
capabilities: definition.shortPurpose,
|
||
adapterType,
|
||
adapterConfig: input.adapterConfig ?? definition.defaultAdapterConfig ?? {},
|
||
permissions: definition.defaultPermissions ?? {},
|
||
budgetMonthlyCents: input.budgetMonthlyCents ?? definition.defaultBudgetMonthlyCents ?? 0,
|
||
};
|
||
}
|
||
|
||
async function assertKnownBuiltInAgentModel(
|
||
definition: BuiltInAgentDefinition,
|
||
input: BuiltInAgentProvisionInput,
|
||
) {
|
||
const adapterType = input.adapterType ?? defaultAdapterType(definition);
|
||
const adapterConfig = input.adapterConfig ?? definition.defaultAdapterConfig ?? {};
|
||
const model = typeof adapterConfig.model === "string" ? adapterConfig.model.trim() : "";
|
||
if (!model || !hasCompleteAdapterConfig(adapterType, adapterConfig)) return;
|
||
|
||
const models = await listAdapterModels(agentHarnessType(adapterType, adapterConfig));
|
||
if (models.length === 0 || models.some((candidate) => candidate.id === model)) return;
|
||
|
||
throw unprocessable(`Model "${model}" is not available for adapter ${adapterType}.`, {
|
||
code: "built_in_agent_model_unknown",
|
||
key: definition.key,
|
||
adapterType,
|
||
model,
|
||
availableModelIds: models.map((candidate) => candidate.id),
|
||
});
|
||
}
|
||
|
||
function builtInAgentNotConfiguredError(state: BuiltInAgentState) {
|
||
return new HttpError(412, `Built-in agent is not configured: ${state.definition.key}`, {
|
||
code: "built_in_agent_not_configured",
|
||
key: state.definition.key,
|
||
status: state.status,
|
||
agentId: state.agentId,
|
||
featureKeys: state.definition.featureKeys,
|
||
});
|
||
}
|
||
|
||
function hasProvisionSetupInput(input: BuiltInAgentProvisionInput) {
|
||
return input.adapterType !== undefined || input.adapterConfig !== undefined || input.runner !== undefined || input.budgetMonthlyCents !== undefined;
|
||
}
|
||
|
||
function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) {
|
||
const marker = readBuiltInAgentMarker(row.metadata);
|
||
return marker?.key === key;
|
||
}
|
||
|
||
// Partial unique index (migration 0192) that guarantees one active built-in
|
||
// agent per (company, marker key). A losing provisioning race raises this as a
|
||
// 23505; provision()/ensure() catch it and re-resolve to the winning row.
|
||
const BUILT_IN_AGENT_MARKER_UNIQUE_INDEX = "agents_company_built_in_agent_key_unique_idx";
|
||
|
||
function isBuiltInAgentMarkerConflict(error: unknown): boolean {
|
||
const seen = new Set<unknown>();
|
||
let current: unknown = error;
|
||
while (typeof current === "object" && current !== null && !seen.has(current)) {
|
||
seen.add(current);
|
||
const maybe = current as { code?: string; constraint?: string; constraint_name?: string; cause?: unknown };
|
||
const constraint = maybe.constraint ?? maybe.constraint_name;
|
||
if (maybe.code === "23505" && constraint === BUILT_IN_AGENT_MARKER_UNIQUE_INDEX) {
|
||
return true;
|
||
}
|
||
current = maybe.cause;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function builtInAgentService(db: Db) {
|
||
const agentSvc = agentService(db);
|
||
const agentSvcLifecycle = createAgentLifecycle(db);
|
||
const accessSvc = accessService(db);
|
||
const approvalSvc = approvalService(db);
|
||
const instructionsSvc = agentInstructionsService(db);
|
||
const skillSvc = companySkillService(db);
|
||
const routineSvc = routineService(db);
|
||
|
||
async function findSingleRootManager(companyId: string) {
|
||
const roots = await db
|
||
.select()
|
||
.from(agents)
|
||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||
const nonBuiltInRoots = roots.filter((agent) => !readBuiltInAgentMarker(agent.metadata) && !agent.reportsTo);
|
||
return nonBuiltInRoots.length === 1 ? nonBuiltInRoots[0]!.id : null;
|
||
}
|
||
|
||
async function ensureAgentDefaultGrants(companyId: string, agentId: string, grantKeys: PermissionKey[]) {
|
||
if (grantKeys.length === 0) return 0;
|
||
await accessSvc.ensureMembership(companyId, "agent", agentId, "member", "active");
|
||
let ensured = 0;
|
||
for (const permissionKey of grantKeys) {
|
||
await accessSvc.setPrincipalPermission(companyId, "agent", agentId, permissionKey, true, null);
|
||
ensured += 1;
|
||
}
|
||
return ensured;
|
||
}
|
||
|
||
async function ensureBuiltInAgentDefaultGrants(agent: Agent, definition: BuiltInAgentDefinition) {
|
||
if (agent.status === "pending_approval" || agent.status === "terminated") return 0;
|
||
return ensureAgentDefaultGrants(
|
||
agent.companyId,
|
||
agent.id,
|
||
BUILT_IN_AGENT_DEFAULT_GRANTS[definition.key] ?? [],
|
||
);
|
||
}
|
||
|
||
async function ensureRootAgentDefaultChangeGrants(companyId: string) {
|
||
const rows = await db
|
||
.select()
|
||
.from(agents)
|
||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||
const rootCeoRows = rows.filter((agent) =>
|
||
!readBuiltInAgentMarker(agent.metadata) &&
|
||
!agent.reportsTo &&
|
||
agent.role.trim().toLowerCase() === "ceo" &&
|
||
agent.status !== "pending_approval"
|
||
);
|
||
if (rootCeoRows.length !== 1) return 0;
|
||
return ensureAgentDefaultGrants(companyId, rootCeoRows[0]!.id, ROOT_AGENT_DEFAULT_CHANGE_GRANTS);
|
||
}
|
||
|
||
async function ensureCompanyDefaultAgentGrants(companyId: string) {
|
||
let ensured = await ensureRootAgentDefaultChangeGrants(companyId);
|
||
for (const definition of DEFINITIONS) {
|
||
const agent = await findSingleAgent(companyId, definition);
|
||
if (!agent) continue;
|
||
ensured += await ensureBuiltInAgentDefaultGrants(agent as Agent, definition);
|
||
}
|
||
return ensured;
|
||
}
|
||
|
||
async function defaultProvisionInput(companyId: string, definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput) {
|
||
if (input.adapterType || input.adapterConfig) return input;
|
||
if (definition.defaultAdapterType || definition.defaultAdapterConfig) {
|
||
return {
|
||
...input,
|
||
adapterType: definition.defaultAdapterType,
|
||
adapterConfig: definition.defaultAdapterConfig ? { ...definition.defaultAdapterConfig } : undefined,
|
||
};
|
||
}
|
||
if (!definition.bundle) return input;
|
||
const rows = await db
|
||
.select({
|
||
adapterType: agents.adapterType,
|
||
adapterConfig: agents.adapterConfig,
|
||
})
|
||
.from(agents)
|
||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||
const candidate = rows.find((row) =>
|
||
definition.allowedAdapterTypes?.includes(agentHarnessType(row.adapterType, row.adapterConfig))
|
||
&& hasCompleteAdapterConfig(row.adapterType, row.adapterConfig)
|
||
);
|
||
if (!candidate) return input;
|
||
return {
|
||
...input,
|
||
adapterType: agentHarnessType(candidate.adapterType, candidate.adapterConfig),
|
||
adapterConfig: {},
|
||
};
|
||
}
|
||
|
||
async function getManagedResourceBinding(
|
||
companyId: string,
|
||
bundleKey: string,
|
||
resourceKind: BuiltInManagedResourceKind,
|
||
resourceKey: string,
|
||
) {
|
||
return db
|
||
.select()
|
||
.from(builtInManagedResources)
|
||
.where(and(
|
||
eq(builtInManagedResources.companyId, companyId),
|
||
eq(builtInManagedResources.bundleKey, bundleKey),
|
||
eq(builtInManagedResources.resourceKind, resourceKind),
|
||
eq(builtInManagedResources.resourceKey, resourceKey),
|
||
))
|
||
.then((rows) => rows[0] ?? null);
|
||
}
|
||
|
||
async function upsertManagedResourceBinding(input: {
|
||
companyId: string;
|
||
bundleKey: string;
|
||
resourceKind: BuiltInManagedResourceKind;
|
||
resourceKey: string;
|
||
resourceId: string;
|
||
stockVersion: string;
|
||
stockHash: string;
|
||
defaultsJson: Record<string, unknown>;
|
||
}, connection: Db | Parameters<Parameters<Db["transaction"]>[0]>[0] = db) {
|
||
const now = new Date();
|
||
return connection
|
||
.insert(builtInManagedResources)
|
||
.values(input)
|
||
.onConflictDoUpdate({
|
||
target: [
|
||
builtInManagedResources.companyId,
|
||
builtInManagedResources.bundleKey,
|
||
builtInManagedResources.resourceKind,
|
||
builtInManagedResources.resourceKey,
|
||
],
|
||
set: {
|
||
resourceId: input.resourceId,
|
||
stockVersion: input.stockVersion,
|
||
stockHash: input.stockHash,
|
||
defaultsJson: input.defaultsJson,
|
||
updatedAt: now,
|
||
},
|
||
})
|
||
.returning()
|
||
.then((rows) => rows[0] ?? null);
|
||
}
|
||
|
||
async function currentInstructionFiles(agent: Agent, bundle: BuiltInAgentBundleDefinition) {
|
||
const currentFiles: Record<string, string | null> = {};
|
||
for (const filePath of Object.keys(bundle.instructions.files)) {
|
||
try {
|
||
currentFiles[filePath] = (await instructionsSvc.readFile(agent, filePath)).content;
|
||
} catch {
|
||
currentFiles[filePath] = null;
|
||
}
|
||
}
|
||
return currentFiles;
|
||
}
|
||
|
||
async function materializeInstructions(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset", actor?: AuthorizationActor) {
|
||
const bundle = definition.bundle!;
|
||
const stock = stockHash(bundle.instructions.files);
|
||
const binding = await getManagedResourceBinding(agent.companyId, definition.key, "instructions", "AGENTS.md");
|
||
// Stock operations also read the committed projection after interrupted writes.
|
||
if (agentInstructionsBundleMode(agent) === "managed") {
|
||
try { await agentInstructionRevisionService(db).materializeCurrent({ companyId: agent.companyId, agentId: agent.id }); }
|
||
catch (error) { if (!(error instanceof HttpError && error.status === 404)) throw error; }
|
||
}
|
||
const currentFiles = await currentInstructionFiles(agent, bundle);
|
||
const currentHash = Object.values(currentFiles).some((value) => value === null) ? null : stockHash(currentFiles);
|
||
const currentState = stockState({
|
||
resourceKind: "instructions",
|
||
resourceKey: "AGENTS.md",
|
||
resourceId: agent.id,
|
||
stockVersion: bundle.stockVersion,
|
||
latestStockHash: stock,
|
||
currentHash,
|
||
bindingStockHash: binding?.stockHash ?? null,
|
||
changedFiles: changedFileList(currentFiles, bundle.instructions.files),
|
||
});
|
||
|
||
if (!actor && mode === "reconcile" && agentInstructionsBundleMode(agent) === "managed"
|
||
&& binding && (binding.stockHash !== stock || binding.defaultsJson.pendingInstructionsUpdate)) {
|
||
const bindingWhere = and(eq(builtInManagedResources.companyId, agent.companyId),
|
||
eq(builtInManagedResources.bundleKey, definition.key), eq(builtInManagedResources.resourceKind, "instructions"),
|
||
eq(builtInManagedResources.resourceKey, "AGENTS.md"));
|
||
const incoming = Object.fromEntries(Object.entries(bundle.instructions.files)
|
||
.map(([file, content]) => [agentFilePath(file), instructionBytes(content)]));
|
||
// Persist only the pending operation's baseline hashes before touching
|
||
// files. If a write or DB commit fails, the next reconciliation can accept
|
||
// already-applied bytes and retry. This is not instruction revision history.
|
||
const prepared = await db.transaction(async (tx) => {
|
||
const [current] = await tx.select().from(agents).where(and(eq(agents.companyId, agent.companyId), eq(agents.id, agent.id))).for("update");
|
||
if (!current) throw notFound("Built-in agent not found");
|
||
if (agentInstructionsBundleMode(current) !== "managed") return false;
|
||
const root = await adoptAgentFiles(tx, current);
|
||
const [latest] = await tx.select().from(builtInManagedResources).where(bindingWhere);
|
||
if (!latest) return false;
|
||
const pending = latest.defaultsJson.pendingInstructionsUpdate as {
|
||
stockHash?: string; baseHashes?: Record<string, string | null>; nextHashes?: Record<string, string | null>;
|
||
} | undefined;
|
||
if (pending?.stockHash === stock) return true;
|
||
if (!pending && latest.stockHash === stock) return false;
|
||
const previousPaths = latest.defaultsJson.files;
|
||
if (!Array.isArray(previousPaths) || !previousPaths.every((file): file is string => typeof file === "string")) return false;
|
||
const ownedPaths = new Set(previousPaths);
|
||
if (pending) {
|
||
// A newer release (or rollback) can supersede an interrupted update.
|
||
// Recognize only the previous operation's baseline or intended bytes;
|
||
// never mistake an intervening operator edit for partially applied stock.
|
||
if (!pending.baseHashes || !pending.nextHashes) return false;
|
||
for (const [file, baseHash] of Object.entries(pending.baseHashes)) {
|
||
if (!(file in pending.nextHashes)) return false;
|
||
const bytes = await readAgentFile(root, agentFilePath(file));
|
||
const hash = bytes === null ? null : fileHash(bytes);
|
||
if (hash !== baseHash && hash !== pending.nextHashes[file]) return false;
|
||
ownedPaths.add(file);
|
||
}
|
||
} else {
|
||
const previousFiles: Record<string, string | null> = {};
|
||
for (const file of previousPaths) {
|
||
try { previousFiles[file] = (await instructionsSvc.readFile(current, file)).content; }
|
||
catch { previousFiles[file] = null; }
|
||
}
|
||
if (stockHash(previousFiles) !== latest.stockHash) return false;
|
||
}
|
||
await snapshotAgentFiles(root);
|
||
const baseHashes: Record<string, string | null> = {};
|
||
const nextHashes: Record<string, string | null> = {};
|
||
for (const file of new Set([...ownedPaths, ...Object.keys(incoming)])) {
|
||
const bytes = await readAgentFile(root, agentFilePath(file));
|
||
// A newly declared stock path must not overwrite a personal file.
|
||
if (!ownedPaths.has(file) && bytes !== null && !bytes.equals(incoming[file]!)) return false;
|
||
baseHashes[file] = bytes === null ? null : fileHash(bytes);
|
||
nextHashes[file] = incoming[file] ? fileHash(incoming[file]) : null;
|
||
}
|
||
await tx.update(builtInManagedResources).set({ defaultsJson: { ...latest.defaultsJson,
|
||
pendingInstructionsUpdate: { stockHash: stock, baseHashes, nextHashes } }, updatedAt: new Date() }).where(bindingWhere);
|
||
return true;
|
||
});
|
||
if (!prepared) return currentState;
|
||
return db.transaction(async (tx) => {
|
||
const [current] = await tx.select().from(agents).where(and(eq(agents.companyId, agent.companyId), eq(agents.id, agent.id))).for("update");
|
||
if (!current) throw notFound("Built-in agent not found");
|
||
if (agentInstructionsBundleMode(current) !== "managed") return currentState;
|
||
const root = await adoptAgentFiles(tx, current);
|
||
const [latest] = await tx.select().from(builtInManagedResources).where(bindingWhere);
|
||
const pending = latest?.defaultsJson.pendingInstructionsUpdate as { stockHash?: string; baseHashes?: Record<string, string | null> } | undefined;
|
||
if (pending?.stockHash !== stock || !pending.baseHashes) return currentState;
|
||
await snapshotAgentFiles(root);
|
||
// Preflight every changed or removed path before mutation; preserve any
|
||
// operator edit made between preparation, failure, and this retry.
|
||
for (const [file, baseHash] of Object.entries(pending.baseHashes)) {
|
||
const bytes = await readAgentFile(root, agentFilePath(file));
|
||
const hash = bytes === null ? null : fileHash(bytes);
|
||
const nextHash = incoming[file] ? fileHash(incoming[file]) : null;
|
||
if (hash !== baseHash && hash !== nextHash) return currentState;
|
||
}
|
||
for (const [file, bytes] of Object.entries(incoming)) await materializeInstructionBytes(root, file, bytes);
|
||
for (const file of Object.keys(pending.baseHashes)) {
|
||
if (!(file in incoming)) await fs.rm(await assertInstructionPathSafe(root, file), { force: true });
|
||
}
|
||
await upsertManagedResourceBinding({ companyId: agent.companyId, bundleKey: definition.key,
|
||
resourceKind: "instructions", resourceKey: "AGENTS.md", resourceId: agent.id,
|
||
stockVersion: bundle.stockVersion, stockHash: stock,
|
||
defaultsJson: { entryFile: bundle.instructions.entryFile, files: Object.keys(bundle.instructions.files) },
|
||
}, tx);
|
||
await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: "system", actorId: "built-in-reconcile",
|
||
action: "agent.files_updated", entityType: "agent", entityId: agent.id,
|
||
details: { source: "built-in-stock-update", bundleKey: definition.key, stockHash: stock } });
|
||
return stockState({ resourceKind: "instructions", resourceKey: "AGENTS.md", resourceId: agent.id,
|
||
stockVersion: bundle.stockVersion, latestStockHash: stock, currentHash: stock, bindingStockHash: stock });
|
||
});
|
||
}
|
||
|
||
const shouldWrite =
|
||
mode === "reset"
|
||
|| currentState.stockStatus === "missing"
|
||
|| currentState.stockStatus === "stock_update_available";
|
||
if (!shouldWrite) {
|
||
if (!binding && currentHash === stock) {
|
||
await upsertManagedResourceBinding({
|
||
companyId: agent.companyId,
|
||
bundleKey: definition.key,
|
||
resourceKind: "instructions",
|
||
resourceKey: "AGENTS.md",
|
||
resourceId: agent.id,
|
||
stockVersion: bundle.stockVersion,
|
||
stockHash: stock,
|
||
defaultsJson: {
|
||
entryFile: bundle.instructions.entryFile,
|
||
files: Object.keys(bundle.instructions.files),
|
||
},
|
||
});
|
||
}
|
||
return currentState;
|
||
}
|
||
|
||
let adapterConfig: Record<string, unknown>;
|
||
if (currentFiles[bundle.instructions.entryFile] !== null && currentFiles[bundle.instructions.entryFile] !== undefined) {
|
||
if (!actor && mode === "reconcile") return currentState;
|
||
if (!actor) throw unprocessable("Resetting existing instructions requires an authenticated operator", { code: "INSTRUCTION_IDENTITY_INVALID" });
|
||
const revisions = agentInstructionRevisionService(db);
|
||
const target = { companyId: agent.companyId, agentId: agent.id };
|
||
const baseline = await revisions.readCurrent(target, actor);
|
||
const receipt = await revisions.commit({ ...target, entryFile: bundle.instructions.entryFile,
|
||
baseRevisionId: baseline?.revision.id ?? null, content: bundle.instructions.files[bundle.instructions.entryFile] ?? "",
|
||
source: actor.type === "board" ? "board" : "api" }, actor);
|
||
if (receipt.materialization === "pending") throw conflict("Instruction revision saved; retry reset to repair its disk copy", { revisionId: receipt.revision.id });
|
||
const refreshed = await agentSvc.getById(agent.id);
|
||
if (!refreshed) throw notFound("Built-in agent not found");
|
||
for (const [file, content] of Object.entries(bundle.instructions.files)) {
|
||
if (file !== bundle.instructions.entryFile) await instructionsSvc.writeFile(refreshed, file, content);
|
||
}
|
||
// Preserve personal files outside this built-in bundle's declared paths.
|
||
adapterConfig = { ...refreshed.adapterConfig };
|
||
delete adapterConfig.promptTemplate;
|
||
delete adapterConfig.bootstrapPromptTemplate;
|
||
} else {
|
||
const materialized = await instructionsSvc.materializeManagedBundle(agent, bundle.instructions.files, {
|
||
entryFile: bundle.instructions.entryFile,
|
||
replaceExisting: false,
|
||
clearLegacyPromptTemplate: true,
|
||
});
|
||
adapterConfig = materialized.adapterConfig;
|
||
}
|
||
const updated = await agentSvc.update(agent.id, {
|
||
adapterConfig,
|
||
}, {
|
||
allowBuiltInAgentMetadata: true,
|
||
recordRevision: { source: `built-in-bundle:${mode}:instructions` },
|
||
});
|
||
if (!updated) throw notFound("Built-in agent not found");
|
||
await upsertManagedResourceBinding({
|
||
companyId: agent.companyId,
|
||
bundleKey: definition.key,
|
||
resourceKind: "instructions",
|
||
resourceKey: "AGENTS.md",
|
||
resourceId: agent.id,
|
||
stockVersion: bundle.stockVersion,
|
||
stockHash: stock,
|
||
defaultsJson: {
|
||
entryFile: bundle.instructions.entryFile,
|
||
files: Object.keys(bundle.instructions.files),
|
||
},
|
||
});
|
||
return stockState({
|
||
resourceKind: "instructions",
|
||
resourceKey: "AGENTS.md",
|
||
resourceId: agent.id,
|
||
stockVersion: bundle.stockVersion,
|
||
latestStockHash: stock,
|
||
currentHash: stock,
|
||
bindingStockHash: stock,
|
||
});
|
||
}
|
||
|
||
async function getCurrentSkillFiles(companyId: string, skill: CompanySkill | null, bundle: BuiltInAgentBundleDefinition) {
|
||
const currentFiles: Record<string, string | null> = {};
|
||
const stockFiles = bundle.skill.files;
|
||
for (const packagePath of Object.keys(stockFiles)) {
|
||
const relativePath = packagePath.split("/").slice(1).join("/") || "SKILL.md";
|
||
if (!skill) {
|
||
currentFiles[packagePath] = null;
|
||
continue;
|
||
}
|
||
if (relativePath === "SKILL.md") {
|
||
currentFiles[packagePath] = skill.markdown;
|
||
continue;
|
||
}
|
||
try {
|
||
currentFiles[packagePath] = (await skillSvc.readFile(companyId, skill.id, relativePath))?.content ?? null;
|
||
} catch {
|
||
currentFiles[packagePath] = null;
|
||
}
|
||
}
|
||
return currentFiles;
|
||
}
|
||
|
||
async function importBundledSkill(companyId: string, definition: BuiltInAgentDefinition) {
|
||
const results = await skillSvc.importPackageFiles(companyId, definition.bundle!.skill.files, { onConflict: "replace" });
|
||
const imported = results.find((result) => result.skill.key === definition.bundle!.skill.canonicalKey)?.skill
|
||
?? results[0]?.skill
|
||
?? await skillSvc.getByKey(companyId, definition.bundle!.skill.canonicalKey);
|
||
if (!imported) throw notFound("Built-in bundled skill was not imported");
|
||
return imported;
|
||
}
|
||
|
||
async function syncBundledSkillToAgent(agent: Agent, skill: CompanySkill) {
|
||
const desired = readPaperclipSkillSyncPreference(agent.adapterConfig as Record<string, unknown>).desiredSkillEntries;
|
||
const nextDesired = [
|
||
...desired.filter((entry) => entry.key !== skill.key),
|
||
{ key: skill.key, versionId: skill.currentVersionId ?? null },
|
||
];
|
||
const adapterConfig = writePaperclipSkillSyncPreference(agent.adapterConfig as Record<string, unknown>, nextDesired);
|
||
const updated = await agentSvc.update(agent.id, { adapterConfig }, {
|
||
allowBuiltInAgentMetadata: true,
|
||
recordRevision: { source: "built-in-bundle:skill-sync" },
|
||
});
|
||
if (!updated) throw notFound("Built-in agent not found");
|
||
return updated as Agent;
|
||
}
|
||
|
||
async function materializeSkill(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") {
|
||
const bundle = definition.bundle!;
|
||
const stock = stockHash(bundle.skill.files);
|
||
const binding = await getManagedResourceBinding(agent.companyId, definition.key, "skill", bundle.skill.skillKey);
|
||
const boundSkill = binding ? await skillSvc.getById(agent.companyId, binding.resourceId) : null;
|
||
const existingByKey = await skillSvc.getByKey(agent.companyId, bundle.skill.canonicalKey);
|
||
const skill = boundSkill ?? existingByKey;
|
||
const currentFiles = await getCurrentSkillFiles(agent.companyId, skill, bundle);
|
||
const currentHash = skill && Object.values(currentFiles).every((value) => value !== null)
|
||
? stockHash(currentFiles)
|
||
: null;
|
||
const currentState = stockState({
|
||
resourceKind: "skill",
|
||
resourceKey: bundle.skill.skillKey,
|
||
resourceId: skill?.id ?? null,
|
||
stockVersion: bundle.stockVersion,
|
||
latestStockHash: stock,
|
||
currentHash,
|
||
bindingStockHash: binding?.stockHash ?? null,
|
||
changedFiles: changedFileList(currentFiles, bundle.skill.files),
|
||
});
|
||
|
||
const shouldWrite =
|
||
mode === "reset"
|
||
|| currentState.stockStatus === "missing"
|
||
|| currentState.stockStatus === "stock_update_available";
|
||
const nextSkill = shouldWrite ? await importBundledSkill(agent.companyId, definition) : skill!;
|
||
await upsertManagedResourceBinding({
|
||
companyId: agent.companyId,
|
||
bundleKey: definition.key,
|
||
resourceKind: "skill",
|
||
resourceKey: bundle.skill.skillKey,
|
||
resourceId: nextSkill.id,
|
||
stockVersion: bundle.stockVersion,
|
||
stockHash: shouldWrite ? stock : binding?.stockHash ?? stock,
|
||
defaultsJson: {
|
||
canonicalKey: bundle.skill.canonicalKey,
|
||
slug: bundle.skill.slug,
|
||
files: Object.keys(bundle.skill.files),
|
||
},
|
||
});
|
||
await syncBundledSkillToAgent(agent, nextSkill);
|
||
return stockState({
|
||
resourceKind: "skill",
|
||
resourceKey: bundle.skill.skillKey,
|
||
resourceId: nextSkill.id,
|
||
stockVersion: bundle.stockVersion,
|
||
latestStockHash: stock,
|
||
currentHash: shouldWrite ? stock : currentHash,
|
||
bindingStockHash: shouldWrite ? stock : binding?.stockHash ?? stock,
|
||
changedFiles: shouldWrite ? [] : changedFileList(currentFiles, bundle.skill.files),
|
||
});
|
||
}
|
||
|
||
function normalizeRoutineVariablesForHash(input: {
|
||
title: string;
|
||
description?: string | null;
|
||
variables?: RoutineVariable[] | null;
|
||
}) {
|
||
return syncRoutineVariablesWithTemplate(
|
||
[input.title, input.description ?? ""],
|
||
input.variables ?? [],
|
||
).map((variable) => ({
|
||
name: variable.name,
|
||
label: variable.label ?? null,
|
||
type: variable.type ?? "text",
|
||
defaultValue: variable.defaultValue ?? null,
|
||
required: variable.required ?? true,
|
||
options: variable.options ?? [],
|
||
}));
|
||
}
|
||
|
||
function normalizeRoutineTriggersForHash(
|
||
triggers: Array<{
|
||
kind: string;
|
||
label?: string | null;
|
||
cronExpression?: string | null;
|
||
timezone?: string | null;
|
||
}>,
|
||
) {
|
||
return triggers
|
||
.filter((trigger) => trigger.kind === "schedule")
|
||
.map((trigger) => ({
|
||
kind: "schedule",
|
||
label: trigger.label ?? null,
|
||
cronExpression: trigger.cronExpression ?? "",
|
||
timezone: trigger.timezone ?? "UTC",
|
||
}))
|
||
.sort((left, right) => stableJson(left).localeCompare(stableJson(right)));
|
||
}
|
||
|
||
function routineDefaultsHash(
|
||
routine: Pick<
|
||
BuiltInAgentBundleDefinition["routine"],
|
||
"title" | "description" | "priority" | "concurrencyPolicy" | "catchUpPolicy" | "variables"
|
||
>,
|
||
triggers: Array<{
|
||
kind: string;
|
||
label?: string | null;
|
||
cronExpression?: string | null;
|
||
timezone?: string | null;
|
||
}>,
|
||
) {
|
||
return stockHash({
|
||
title: routine.title,
|
||
description: routine.description ?? "",
|
||
priority: routine.priority,
|
||
concurrencyPolicy: routine.concurrencyPolicy,
|
||
catchUpPolicy: routine.catchUpPolicy,
|
||
variables: normalizeRoutineVariablesForHash(routine),
|
||
triggers: normalizeRoutineTriggersForHash(triggers),
|
||
});
|
||
}
|
||
|
||
async function getRoutineByBinding(companyId: string, definition: BuiltInAgentDefinition) {
|
||
const binding = await getManagedResourceBinding(companyId, definition.key, "routine", definition.bundle!.routine.routineKey);
|
||
const routine = binding
|
||
? await db
|
||
.select()
|
||
.from(routines)
|
||
.where(and(eq(routines.companyId, companyId), eq(routines.id, binding.resourceId)))
|
||
.then((rows) => rows[0] as Routine | undefined ?? null)
|
||
: await db
|
||
.select()
|
||
.from(routines)
|
||
.where(and(
|
||
eq(routines.companyId, companyId),
|
||
eq(routines.originKind, "built_in_agent_bundle"),
|
||
eq(routines.originId, `${definition.key}:${definition.bundle!.routine.routineKey}`),
|
||
))
|
||
.then((rows) => rows[0] as Routine | undefined ?? null);
|
||
const triggers = routine
|
||
? await db
|
||
.select()
|
||
.from(routineTriggers)
|
||
.where(eq(routineTriggers.routineId, routine.id))
|
||
.then((rows) => rows as RoutineTrigger[])
|
||
: [];
|
||
return { binding, routine, triggers };
|
||
}
|
||
|
||
function routineScheduleEnabled(routine: Routine | null, triggers: RoutineTrigger[]) {
|
||
return Boolean(
|
||
routine?.status === "active"
|
||
&& triggers.some((trigger) => trigger.kind === "schedule" && trigger.enabled),
|
||
);
|
||
}
|
||
|
||
async function pendingUpdateProposal(agent: Agent | null) {
|
||
if (!agent) return null;
|
||
return db
|
||
.select({
|
||
interactionId: issueThreadInteractions.id,
|
||
issueId: issues.id,
|
||
issueIdentifier: issues.identifier,
|
||
})
|
||
.from(issueThreadInteractions)
|
||
.innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id))
|
||
.where(and(
|
||
eq(issueThreadInteractions.companyId, agent.companyId),
|
||
eq(issueThreadInteractions.createdByAgentId, agent.id),
|
||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||
eq(issueThreadInteractions.status, "pending"),
|
||
))
|
||
.orderBy(desc(issueThreadInteractions.createdAt), desc(issueThreadInteractions.id))
|
||
.limit(1)
|
||
.then((rows) => rows[0] ?? null);
|
||
}
|
||
|
||
function withRoutineControls(
|
||
state: BuiltInManagedResourceState,
|
||
input: {
|
||
routine: Routine | null;
|
||
triggers: RoutineTrigger[];
|
||
proposal: Awaited<ReturnType<typeof pendingUpdateProposal>>;
|
||
},
|
||
) {
|
||
if (state.resourceKind !== "routine") return state;
|
||
return {
|
||
...state,
|
||
scheduleEnabled: routineScheduleEnabled(input.routine, input.triggers),
|
||
pendingUpdateInteractionId: input.proposal?.interactionId ?? null,
|
||
pendingUpdateIssueId: input.proposal?.issueId ?? null,
|
||
pendingUpdateIssueIdentifier: input.proposal?.issueIdentifier ?? null,
|
||
};
|
||
}
|
||
|
||
async function requireBundleRoutine(companyId: string, key: string, routineKey: string) {
|
||
const definition = requireBuiltInAgentDefinition(key);
|
||
if (!definition.bundle || definition.bundle.routine.routineKey !== routineKey) {
|
||
throw notFound("Built-in routine not found");
|
||
}
|
||
await ensureCompany(companyId);
|
||
const agent = await findSingleAgent(companyId, definition);
|
||
if (!agent) throw notFound("Built-in agent is not provisioned");
|
||
const current = await getRoutineByBinding(companyId, definition);
|
||
if (!current.routine) throw notFound("Built-in routine not found");
|
||
const schedule = current.triggers.find((trigger) => trigger.kind === "schedule") ?? null;
|
||
return { definition, agent, routine: current.routine, triggers: current.triggers, schedule };
|
||
}
|
||
|
||
async function ensureBuiltInAgentAssignable(agent: Agent) {
|
||
if (agent.status !== "paused" || ["preparing", "verifying", "resuming"].includes(agent.lifecycleState ?? "")) return agent;
|
||
const resumed = await agentSvcLifecycle.resumeAgent(agent.id);
|
||
if (!resumed) throw notFound("Built-in agent not found");
|
||
return resumed as Agent;
|
||
}
|
||
|
||
async function createOrResetRoutine(agent: Agent, definition: BuiltInAgentDefinition, existing: Routine | null, mode: "reconcile" | "reset") {
|
||
const routine = definition.bundle!.routine;
|
||
const actor = { agentId: null, userId: "built-in-bundles" };
|
||
const nextRoutine = existing
|
||
? await routineSvc.update(existing.id, {
|
||
title: routine.title,
|
||
description: routine.description,
|
||
assigneeAgentId: agent.id,
|
||
priority: routine.priority,
|
||
status: routine.status,
|
||
concurrencyPolicy: routine.concurrencyPolicy,
|
||
catchUpPolicy: routine.catchUpPolicy,
|
||
variables: routine.variables,
|
||
}, actor)
|
||
: await routineSvc.create(agent.companyId, {
|
||
title: routine.title,
|
||
description: routine.description,
|
||
assigneeAgentId: agent.id,
|
||
priority: routine.priority,
|
||
status: routine.status,
|
||
concurrencyPolicy: routine.concurrencyPolicy,
|
||
catchUpPolicy: routine.catchUpPolicy,
|
||
variables: routine.variables,
|
||
}, actor);
|
||
if (!nextRoutine) throw notFound("Built-in routine not found");
|
||
await db
|
||
.update(routines)
|
||
.set({
|
||
originKind: "built_in_agent_bundle",
|
||
originId: `${definition.key}:${routine.routineKey}`,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(eq(routines.id, nextRoutine.id));
|
||
|
||
const currentTriggers = await db
|
||
.select()
|
||
.from(routineTriggers)
|
||
.where(eq(routineTriggers.routineId, nextRoutine.id))
|
||
.then((rows) => rows as RoutineTrigger[]);
|
||
const firstSchedule = currentTriggers.find((trigger) => trigger.kind === "schedule");
|
||
const stockTrigger = routine.triggers[0];
|
||
if (stockTrigger && firstSchedule) {
|
||
await routineSvc.updateTrigger(firstSchedule.id, {
|
||
label: stockTrigger.label,
|
||
enabled: stockTrigger.enabled,
|
||
cronExpression: stockTrigger.cronExpression,
|
||
timezone: stockTrigger.timezone,
|
||
}, actor);
|
||
} else if (stockTrigger) {
|
||
await routineSvc.createTrigger(nextRoutine.id, {
|
||
kind: "schedule",
|
||
label: stockTrigger.label,
|
||
enabled: stockTrigger.enabled,
|
||
cronExpression: stockTrigger.cronExpression,
|
||
timezone: stockTrigger.timezone,
|
||
}, actor);
|
||
}
|
||
await logActivity(db, {
|
||
companyId: agent.companyId,
|
||
actorType: "system",
|
||
actorId: "built-in-bundles",
|
||
action: mode === "reset" ? "built_in_agent.routine_reset" : "built_in_agent.routine_reconciled",
|
||
entityType: "routine",
|
||
entityId: nextRoutine.id,
|
||
details: {
|
||
key: definition.key,
|
||
routineKey: routine.routineKey,
|
||
status: routine.status,
|
||
},
|
||
});
|
||
return nextRoutine;
|
||
}
|
||
|
||
async function materializeRoutine(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") {
|
||
const bundle = definition.bundle!;
|
||
const stock = routineDefaultsHash(bundle.routine, bundle.routine.triggers);
|
||
const { binding, routine, triggers } = await getRoutineByBinding(agent.companyId, definition);
|
||
const currentHash = routine ? routineDefaultsHash({
|
||
title: routine.title,
|
||
description: routine.description ?? "",
|
||
priority: routine.priority as "critical" | "high" | "medium" | "low",
|
||
concurrencyPolicy: routine.concurrencyPolicy as "always_enqueue" | "coalesce_if_active" | "skip_if_active",
|
||
catchUpPolicy: routine.catchUpPolicy as "enqueue_missed_with_cap" | "skip_missed",
|
||
variables: routine.variables ?? [],
|
||
}, triggers) : null;
|
||
const currentState = stockState({
|
||
resourceKind: "routine",
|
||
resourceKey: bundle.routine.routineKey,
|
||
resourceId: routine?.id ?? null,
|
||
stockVersion: bundle.stockVersion,
|
||
latestStockHash: stock,
|
||
currentHash,
|
||
bindingStockHash: binding?.stockHash ?? null,
|
||
});
|
||
const shouldWrite =
|
||
mode === "reset"
|
||
|| currentState.stockStatus === "missing"
|
||
|| currentState.stockStatus === "stock_update_available";
|
||
const nextRoutine = shouldWrite
|
||
? await createOrResetRoutine(agent, definition, routine, mode)
|
||
: routine!;
|
||
await upsertManagedResourceBinding({
|
||
companyId: agent.companyId,
|
||
bundleKey: definition.key,
|
||
resourceKind: "routine",
|
||
resourceKey: bundle.routine.routineKey,
|
||
resourceId: nextRoutine.id,
|
||
stockVersion: bundle.stockVersion,
|
||
stockHash: shouldWrite ? stock : binding?.stockHash ?? stock,
|
||
defaultsJson: {
|
||
title: bundle.routine.title,
|
||
status: bundle.routine.status,
|
||
triggerCount: bundle.routine.triggers.length,
|
||
},
|
||
});
|
||
const next = await getRoutineByBinding(agent.companyId, definition);
|
||
return withRoutineControls(stockState({
|
||
resourceKind: "routine",
|
||
resourceKey: bundle.routine.routineKey,
|
||
resourceId: nextRoutine.id,
|
||
stockVersion: bundle.stockVersion,
|
||
latestStockHash: stock,
|
||
currentHash: shouldWrite ? stock : currentHash,
|
||
bindingStockHash: shouldWrite ? stock : binding?.stockHash ?? stock,
|
||
}), {
|
||
routine: next.routine,
|
||
triggers: next.triggers,
|
||
proposal: await pendingUpdateProposal(agent),
|
||
});
|
||
}
|
||
|
||
async function bundleResourceStates(companyId: string, definition: BuiltInAgentDefinition, agent: Agent | null) {
|
||
if (!definition.bundle || !agent) return [];
|
||
const bundle = definition.bundle;
|
||
const [instructionBinding, skillBinding, routineBinding] = await Promise.all([
|
||
getManagedResourceBinding(companyId, definition.key, "instructions", "AGENTS.md"),
|
||
getManagedResourceBinding(companyId, definition.key, "skill", bundle.skill.skillKey),
|
||
getManagedResourceBinding(companyId, definition.key, "routine", bundle.routine.routineKey),
|
||
]);
|
||
const instructionFiles = await currentInstructionFiles(agent, bundle);
|
||
const skill = skillBinding
|
||
? await skillSvc.getById(companyId, skillBinding.resourceId)
|
||
: await skillSvc.getByKey(companyId, bundle.skill.canonicalKey);
|
||
const skillFiles = await getCurrentSkillFiles(companyId, skill, bundle);
|
||
const { routine, triggers } = await getRoutineByBinding(companyId, definition);
|
||
const proposal = await pendingUpdateProposal(agent);
|
||
const instructionHash = Object.values(instructionFiles).every((value) => value !== null)
|
||
? stockHash(instructionFiles)
|
||
: null;
|
||
const skillHash = skill && Object.values(skillFiles).every((value) => value !== null)
|
||
? stockHash(skillFiles)
|
||
: null;
|
||
const routineHash = routine ? routineDefaultsHash({
|
||
title: routine.title,
|
||
description: routine.description ?? "",
|
||
priority: routine.priority as "critical" | "high" | "medium" | "low",
|
||
concurrencyPolicy: routine.concurrencyPolicy as "always_enqueue" | "coalesce_if_active" | "skip_if_active",
|
||
catchUpPolicy: routine.catchUpPolicy as "enqueue_missed_with_cap" | "skip_missed",
|
||
variables: routine.variables ?? [],
|
||
}, triggers) : null;
|
||
return [
|
||
stockState({
|
||
resourceKind: "instructions",
|
||
resourceKey: "AGENTS.md",
|
||
resourceId: agent.id,
|
||
stockVersion: bundle.stockVersion,
|
||
latestStockHash: stockHash(bundle.instructions.files),
|
||
currentHash: instructionHash,
|
||
bindingStockHash: instructionBinding?.stockHash ?? null,
|
||
changedFiles: changedFileList(instructionFiles, bundle.instructions.files),
|
||
}),
|
||
stockState({
|
||
resourceKind: "skill",
|
||
resourceKey: bundle.skill.skillKey,
|
||
resourceId: skill?.id ?? null,
|
||
stockVersion: bundle.stockVersion,
|
||
latestStockHash: stockHash(bundle.skill.files),
|
||
currentHash: skillHash,
|
||
bindingStockHash: skillBinding?.stockHash ?? null,
|
||
changedFiles: changedFileList(skillFiles, bundle.skill.files),
|
||
}),
|
||
withRoutineControls(stockState({
|
||
resourceKind: "routine",
|
||
resourceKey: bundle.routine.routineKey,
|
||
resourceId: routine?.id ?? null,
|
||
stockVersion: bundle.stockVersion,
|
||
latestStockHash: routineDefaultsHash(bundle.routine, bundle.routine.triggers),
|
||
currentHash: routineHash,
|
||
bindingStockHash: routineBinding?.stockHash ?? null,
|
||
}), { routine, triggers, proposal }),
|
||
];
|
||
}
|
||
|
||
async function reconcileBundleResources(
|
||
agent: Agent,
|
||
definition: BuiltInAgentDefinition,
|
||
mode: "reconcile" | "reset",
|
||
resources?: Array<"instructions" | "skill" | "routine">,
|
||
actor?: AuthorizationActor,
|
||
) {
|
||
if (!definition.bundle) return [];
|
||
const selected = new Set(resources ?? ["instructions", "skill", "routine"]);
|
||
const existingStates = await bundleResourceStates(agent.companyId, definition, agent);
|
||
const byKind = new Map(existingStates.map((state) => [state.resourceKind, state]));
|
||
const instruction = selected.has("instructions")
|
||
? await materializeInstructions(agent, definition, mode, actor)
|
||
: byKind.get("instructions")!;
|
||
const refreshedAgent = await agentSvc.getById(agent.id) as Agent | null;
|
||
if (!refreshedAgent) throw notFound("Built-in agent not found");
|
||
const skill = selected.has("skill")
|
||
? await materializeSkill(refreshedAgent, definition, mode)
|
||
: byKind.get("skill")!;
|
||
const refreshedAfterSkill = await agentSvc.getById(agent.id) as Agent | null;
|
||
if (!refreshedAfterSkill) throw notFound("Built-in agent not found");
|
||
const routine = selected.has("routine")
|
||
? await materializeRoutine(refreshedAfterSkill, definition, mode)
|
||
: byKind.get("routine")!;
|
||
return [instruction, skill, routine];
|
||
}
|
||
|
||
async function ensureCompany(companyId: string) {
|
||
const company = await db
|
||
.select({
|
||
id: companies.id,
|
||
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||
})
|
||
.from(companies)
|
||
.where(eq(companies.id, companyId))
|
||
.then((rows) => rows[0] ?? null);
|
||
if (!company) throw notFound("Company not found");
|
||
return company;
|
||
}
|
||
|
||
async function findMarkedRows(companyId: string, key: string) {
|
||
const rows = await db
|
||
.select()
|
||
.from(agents)
|
||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||
return rows
|
||
.filter((row) => rowIsBuiltInAgent(row, key))
|
||
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id));
|
||
}
|
||
|
||
// Self-heal duplicate built-in agents. `findMarkedRows` already sorts oldest
|
||
// first, so the first row is authoritative: keep it, terminate the rest, and
|
||
// cancel each duplicate's orphan pending hire_agent approval. Idempotent, so
|
||
// concurrent callers converge on the same surviving row.
|
||
async function resolveDuplicateMarkedRows(
|
||
companyId: string,
|
||
definition: BuiltInAgentDefinition,
|
||
markedRows: Array<typeof agents.$inferSelect>,
|
||
) {
|
||
const [keep, ...duplicates] = markedRows;
|
||
for (const duplicate of duplicates) {
|
||
const openApproval = await approvalSvc.findOpenHireApprovalForAgent(companyId, duplicate.id);
|
||
await agentSvcLifecycle.terminateAgent(duplicate.id);
|
||
if (openApproval) {
|
||
await approvalSvc.cancel(
|
||
openApproval.id,
|
||
`Cancelled: duplicate built-in ${definition.key} agent resolved during reconciliation.`,
|
||
);
|
||
}
|
||
await logActivity(db, {
|
||
companyId,
|
||
actorType: "system",
|
||
actorId: "built-in-agents",
|
||
action: "built_in_agent.duplicate_resolved",
|
||
entityType: "agent",
|
||
entityId: duplicate.id,
|
||
details: {
|
||
key: definition.key,
|
||
keptAgentId: keep!.id,
|
||
terminatedAgentId: duplicate.id,
|
||
cancelledApprovalId: openApproval?.id ?? null,
|
||
},
|
||
});
|
||
}
|
||
return keep!;
|
||
}
|
||
|
||
async function findSingleAgent(companyId: string, definition: BuiltInAgentDefinition) {
|
||
const markedRows = await findMarkedRows(companyId, definition.key);
|
||
if (markedRows.length === 0) return null;
|
||
const survivor = markedRows.length > 1
|
||
? await resolveDuplicateMarkedRows(companyId, definition, markedRows)
|
||
: markedRows[0]!;
|
||
const agent = await agentSvc.getById(survivor.id);
|
||
return agent as Agent | null;
|
||
}
|
||
|
||
async function state(
|
||
definition: BuiltInAgentDefinition,
|
||
agent: Agent | null,
|
||
resources?: BuiltInManagedResourceState[],
|
||
): Promise<BuiltInAgentState> {
|
||
return {
|
||
definition,
|
||
status: deriveBuiltInAgentStatus(agent),
|
||
agentId: agent?.id ?? null,
|
||
agent,
|
||
pauseReason: agent?.pauseReason ?? null,
|
||
resources: resources ?? await bundleResourceStates(agent?.companyId ?? "", definition, agent),
|
||
};
|
||
}
|
||
|
||
async function get(companyId: string, key: string) {
|
||
const definition = requireBuiltInAgentDefinition(key);
|
||
await ensureCompany(companyId);
|
||
return state(definition, await findSingleAgent(companyId, definition));
|
||
}
|
||
|
||
async function ensure(
|
||
companyId: string,
|
||
key: string,
|
||
input: BuiltInAgentProvisionInput = {},
|
||
options: { isRaceRetry?: boolean; createdByUserId?: string | null } = {},
|
||
) {
|
||
const definition = requireBuiltInAgentDefinition(key);
|
||
await ensureCompany(companyId);
|
||
const existing = await findSingleAgent(companyId, definition);
|
||
const existingPendingApproval = existing?.status === "pending_approval";
|
||
const preserveExistingAdapter = Boolean(
|
||
existing
|
||
&& !existingPendingApproval
|
||
&& input.adapterType === undefined
|
||
&& input.adapterConfig === undefined
|
||
&& input.runner === undefined,
|
||
);
|
||
const resolvedInput = existingPendingApproval || preserveExistingAdapter
|
||
? input
|
||
: existing && input.runner !== undefined && input.adapterType === undefined && input.adapterConfig === undefined
|
||
? { ...input, adapterType: existing.adapterType, adapterConfig: existing.adapterConfig }
|
||
: await defaultProvisionInput(companyId, definition, input);
|
||
if (!existingPendingApproval && !preserveExistingAdapter) {
|
||
await assertKnownBuiltInAgentModel(definition, resolvedInput);
|
||
}
|
||
if (existing) {
|
||
const patch: Partial<typeof agents.$inferInsert> = {
|
||
metadata: builtInMetadata(definition, existing.metadata),
|
||
};
|
||
if (
|
||
!existingPendingApproval
|
||
&& (resolvedInput.adapterType !== undefined || resolvedInput.adapterConfig !== undefined || resolvedInput.runner !== undefined)
|
||
) {
|
||
const adapterType = resolvedInput.adapterType ?? existing.adapterType;
|
||
const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig;
|
||
assertAdapterAllowed(definition, adapterType, adapterConfig);
|
||
const selectedHarness = agentHarnessType(adapterType, adapterConfig);
|
||
const sameQualifiedHarness = ["codex_local", "claude_local"].includes(selectedHarness)
|
||
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === selectedHarness;
|
||
const runner = resolvedInput.runner ?? (sameQualifiedHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
|
||
? agentRunner(existing.adapterType) : undefined);
|
||
const retainedNative = sameQualifiedHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner";
|
||
const resolvedAdapterConfig = { ...adapterConfig };
|
||
if (retainedNative) {
|
||
for (const field of ["provider", "acpxAgent", "acpxMode"]) {
|
||
if (existing.adapterConfig[field] !== undefined) resolvedAdapterConfig[field] = existing.adapterConfig[field];
|
||
}
|
||
}
|
||
Object.assign(patch, runner !== undefined || existing.metadata?.paperclipBuiltInSetupRequired === true
|
||
? await resolveNewAgentRunnerForCompany(db, companyId, {
|
||
adapterType: retainedNative ? existing.adapterType : adapterType,
|
||
adapterConfig: resolvedAdapterConfig, runner,
|
||
defaultEnvironmentId: existing.defaultEnvironmentId,
|
||
})
|
||
: { adapterType, adapterConfig });
|
||
if (hasCompleteAdapterConfig(patch.adapterType ?? existing.adapterType, patch.adapterConfig ?? existing.adapterConfig)) {
|
||
patch.metadata = { ...patch.metadata, paperclipBuiltInSetupRequired: false };
|
||
}
|
||
}
|
||
if (!existingPendingApproval && resolvedInput.budgetMonthlyCents !== undefined) {
|
||
patch.budgetMonthlyCents = resolvedInput.budgetMonthlyCents;
|
||
}
|
||
if (
|
||
!existingPendingApproval
|
||
&& definition.defaultManager === "single_root_agent"
|
||
&& !existing.reportsTo
|
||
) {
|
||
const reportsTo = await findSingleRootManager(companyId);
|
||
if (reportsTo) patch.reportsTo = reportsTo;
|
||
}
|
||
const updated = await agentSvc.update(existing.id, patch, {
|
||
allowBuiltInAgentMetadata: true,
|
||
recordRevision: { source: "built-in-agent:ensure" },
|
||
});
|
||
if (!updated) throw notFound("Built-in agent not found");
|
||
if (existingPendingApproval) {
|
||
return state(definition, updated as Agent);
|
||
}
|
||
await ensureBuiltInAgentDefaultGrants(updated as Agent, definition);
|
||
const resources = await reconcileBundleResources(updated as Agent, definition, "reconcile");
|
||
return state(definition, await agentSvc.getById(existing.id) as Agent, resources);
|
||
}
|
||
|
||
const reportsTo = definition.defaultManager === "single_root_agent"
|
||
? await findSingleRootManager(companyId)
|
||
: null;
|
||
let created: Agent;
|
||
try {
|
||
created = await agentSvcLifecycle.requestHire(companyId, {
|
||
...definitionPatch(definition, resolvedInput),
|
||
status: definition.defaultStatus ?? "idle",
|
||
pauseReason: definition.defaultStatus === "paused"
|
||
? `Built-in ${definition.displayName} is disabled until explicitly configured.`
|
||
: null,
|
||
pausedAt: definition.defaultStatus === "paused" ? new Date() : null,
|
||
reportsTo,
|
||
metadata: {
|
||
...builtInMetadata(definition),
|
||
paperclipBuiltInSetupRequired: !hasCompleteAdapterConfig(
|
||
definitionPatch(definition, resolvedInput).adapterType,
|
||
definitionPatch(definition, resolvedInput).adapterConfig,
|
||
),
|
||
},
|
||
runtimeConfig: definition.defaultRuntimeConfig ?? {},
|
||
permissions: definition.defaultPermissions ?? {},
|
||
spentMonthlyCents: 0,
|
||
lastHeartbeatAt: null,
|
||
}, { allowBuiltInAgentMetadata: true, createdByUserId: options.createdByUserId ?? null }) as Agent;
|
||
} catch (error) {
|
||
// Lost the provisioning race: a concurrent writer inserted the row first
|
||
// and the partial unique index rejected ours. Re-run once; the winning
|
||
// row now exists, so we take the update path instead of inserting again.
|
||
if (!options.isRaceRetry && isBuiltInAgentMarkerConflict(error)) {
|
||
return ensure(companyId, key, input, { isRaceRetry: true, createdByUserId: options.createdByUserId });
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
await logActivity(db, {
|
||
companyId,
|
||
actorType: "system",
|
||
actorId: "built-in-agents",
|
||
action: "built_in_agent.provisioned",
|
||
entityType: "agent",
|
||
entityId: created.id,
|
||
details: {
|
||
key: definition.key,
|
||
featureKeys: definition.featureKeys,
|
||
status: deriveBuiltInAgentStatus(created),
|
||
},
|
||
});
|
||
|
||
await ensureBuiltInAgentDefaultGrants(created, definition);
|
||
const resources = await reconcileBundleResources(created, definition, "reconcile");
|
||
return state(definition, await agentSvc.getById(created.id) as Agent, resources);
|
||
}
|
||
|
||
async function provision(
|
||
companyId: string,
|
||
key: string,
|
||
input: BuiltInAgentProvisionInput = {},
|
||
actor: BuiltInAgentProvisionActor = {},
|
||
): Promise<BuiltInAgentProvisionResult> {
|
||
const definition = requireBuiltInAgentDefinition(key);
|
||
const company = await ensureCompany(companyId);
|
||
if (!company.requireBoardApprovalForNewAgents) {
|
||
return { state: await ensure(companyId, key, input, { createdByUserId: actor.requestedByUserId ?? null }), approval: null };
|
||
}
|
||
await assertKnownBuiltInAgentModel(definition, input);
|
||
|
||
const existing = await findSingleAgent(companyId, definition);
|
||
if (existing) {
|
||
if (existing.status === "pending_approval") {
|
||
if (hasProvisionSetupInput(input)) {
|
||
throw conflict("Built-in agent setup is already pending board approval.", {
|
||
code: "built_in_agent_pending_approval",
|
||
key: definition.key,
|
||
agentId: existing.id,
|
||
});
|
||
}
|
||
const approval = await approvalSvc.findOpenHireApprovalForAgent(companyId, existing.id);
|
||
return {
|
||
state: await state(definition, existing),
|
||
approval: approval as Approval | null,
|
||
};
|
||
}
|
||
|
||
const providesAdapterSetup = input.adapterType !== undefined || input.adapterConfig !== undefined || input.runner !== undefined;
|
||
|
||
// A built-in row that has never completed adapter setup (incomplete
|
||
// config, i.e. `needs_setup`) is still first-time configuration, not a
|
||
// reconfiguration of a live agent. Its existence was already sanctioned
|
||
// when the row was created — e.g. the auto-provisioned Reflection Coach
|
||
// hire approval resolves (`activatePendingApproval`) to an idle row whose
|
||
// adapterConfig is still empty. Completing that setup applies directly, as
|
||
// it does when board approval is not required, instead of dead-ending on a
|
||
// fresh board-approval requirement the operator can never satisfy.
|
||
if (providesAdapterSetup && (existing.metadata?.paperclipBuiltInSetupRequired === true || !hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig))) {
|
||
return { state: await ensure(companyId, key, input), approval: null };
|
||
}
|
||
|
||
// Changing the adapter of an already-configured (`ready`/`paused`)
|
||
// built-in agent is a genuine reconfiguration and stays gated.
|
||
if (providesAdapterSetup) {
|
||
throw conflict("Built-in agent adapter changes require board approval before they can be applied.", {
|
||
code: "built_in_agent_reconfiguration_requires_approval",
|
||
key: definition.key,
|
||
agentId: existing.id,
|
||
});
|
||
}
|
||
|
||
return { state: await state(definition, existing), approval: null };
|
||
}
|
||
|
||
const reportsTo = definition.defaultManager === "single_root_agent"
|
||
? await findSingleRootManager(companyId)
|
||
: null;
|
||
let pending: Agent;
|
||
try {
|
||
pending = await agentSvcLifecycle.requestHire(companyId, {
|
||
...definitionPatch(definition, input),
|
||
status: "pending_approval",
|
||
reportsTo,
|
||
metadata: {
|
||
...builtInMetadata(definition),
|
||
paperclipBuiltInSetupRequired: !hasCompleteAdapterConfig(
|
||
definitionPatch(definition, input).adapterType,
|
||
definitionPatch(definition, input).adapterConfig,
|
||
),
|
||
},
|
||
runtimeConfig: definition.defaultRuntimeConfig ?? {},
|
||
permissions: definition.defaultPermissions ?? {},
|
||
spentMonthlyCents: 0,
|
||
lastHeartbeatAt: null,
|
||
}, { allowBuiltInAgentMetadata: true, createdByUserId: actor.requestedByUserId ?? null }) as Agent;
|
||
} catch (error) {
|
||
// Lost the provisioning race: a concurrent writer inserted the row (and
|
||
// its own hire approval) first, and the partial unique index rejected
|
||
// ours before we created a paired approval. Re-resolve to the winner and
|
||
// return its pending state + open approval instead of surfacing the 23505.
|
||
if (isBuiltInAgentMarkerConflict(error)) {
|
||
const winner = await findSingleAgent(companyId, definition);
|
||
if (winner) {
|
||
const winnerApproval = winner.status === "pending_approval"
|
||
? await approvalSvc.findOpenHireApprovalForAgent(companyId, winner.id)
|
||
: null;
|
||
return { state: await state(definition, winner), approval: winnerApproval as Approval | null };
|
||
}
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
const approval = await approvalSvc.create(companyId, {
|
||
type: "hire_agent",
|
||
requestedByAgentId: actor.requestedByAgentId ?? null,
|
||
requestedByUserId: actor.requestedByUserId ?? null,
|
||
status: "pending",
|
||
payload: {
|
||
name: pending.name,
|
||
role: pending.role,
|
||
title: pending.title,
|
||
icon: pending.icon,
|
||
reportsTo: pending.reportsTo,
|
||
capabilities: pending.capabilities,
|
||
adapterType: pending.adapterType,
|
||
adapterConfig: pending.adapterConfig,
|
||
runtimeConfig: pending.runtimeConfig,
|
||
permissions: pending.permissions,
|
||
budgetMonthlyCents: pending.budgetMonthlyCents,
|
||
metadata: pending.metadata,
|
||
agentId: pending.id,
|
||
sourceBuiltInAgentKey: definition.key,
|
||
featureKeys: definition.featureKeys,
|
||
},
|
||
decisionNote: null,
|
||
decidedByUserId: null,
|
||
decidedAt: null,
|
||
updatedAt: new Date(),
|
||
}) as Approval;
|
||
|
||
return { state: await state(definition, pending), approval };
|
||
}
|
||
|
||
async function list(companyId: string) {
|
||
await ensureCompany(companyId);
|
||
return Promise.all(DEFINITIONS.map(async (definition) => state(definition, await findSingleAgent(companyId, definition))));
|
||
}
|
||
|
||
async function reconcileDefinitionDefaults(companyId: string, key: string) {
|
||
const definition = requireBuiltInAgentDefinition(key);
|
||
await ensureCompany(companyId);
|
||
const existing = await findSingleAgent(companyId, definition);
|
||
if (!existing) return state(definition, null);
|
||
const patch = {
|
||
name: definition.displayName,
|
||
role: definition.defaultRole,
|
||
title: definition.defaultTitle ?? null,
|
||
icon: definition.defaultIcon ?? null,
|
||
capabilities: definition.shortPurpose,
|
||
metadata: builtInMetadata(definition, existing.metadata),
|
||
};
|
||
const updated = await agentSvc.update(existing.id, patch, {
|
||
allowBuiltInAgentMetadata: true,
|
||
recordRevision: { source: "built-in-agent:reconcile-defaults" },
|
||
});
|
||
if (!updated) throw notFound("Built-in agent not found");
|
||
await ensureBuiltInAgentDefaultGrants(updated as Agent, definition);
|
||
return state(definition, updated as Agent);
|
||
}
|
||
|
||
async function reset(companyId: string, key: string, input: { resources?: Array<"agent" | "instructions" | "skill" | "routine"> } = {}, actor?: AuthorizationActor) {
|
||
const definition = requireBuiltInAgentDefinition(key);
|
||
const resetAgentDefaults = !input.resources || input.resources.includes("agent");
|
||
const current = resetAgentDefaults
|
||
? await reconcileDefinitionDefaults(companyId, key)
|
||
: await get(companyId, key);
|
||
if (!current.agent || !definition.bundle) return current;
|
||
const selectedBundleResources = input.resources?.filter(
|
||
(resource): resource is "instructions" | "skill" | "routine" => resource !== "agent",
|
||
);
|
||
const resources = await reconcileBundleResources(
|
||
current.agent,
|
||
definition,
|
||
"reset",
|
||
input.resources ? selectedBundleResources ?? [] : undefined,
|
||
actor,
|
||
);
|
||
return state(definition, await agentSvc.getById(current.agent.id) as Agent, resources);
|
||
}
|
||
|
||
async function setRoutineSchedule(
|
||
companyId: string,
|
||
key: string,
|
||
routineKey: string,
|
||
enabled: boolean,
|
||
actor: { agentId?: string | null; userId?: string | null; runId?: string | null } = {},
|
||
) {
|
||
const { definition, agent, routine, schedule } = await requireBundleRoutine(companyId, key, routineKey);
|
||
if (!schedule) throw notFound("Built-in routine schedule not found");
|
||
if (enabled) {
|
||
await ensureBuiltInAgentAssignable(agent);
|
||
await routineSvc.update(routine.id, { status: "active" }, actor);
|
||
await routineSvc.updateTrigger(schedule.id, { enabled: true }, actor);
|
||
} else {
|
||
await routineSvc.updateTrigger(schedule.id, { enabled: false }, actor);
|
||
await routineSvc.update(routine.id, { status: "paused" }, actor);
|
||
}
|
||
return state(definition, await agentSvc.getById(agent.id) as Agent);
|
||
}
|
||
|
||
async function runRoutine(
|
||
companyId: string,
|
||
key: string,
|
||
routineKey: string,
|
||
actor: { agentId?: string | null; userId?: string | null; runId?: string | null } = {},
|
||
) {
|
||
const { agent, routine } = await requireBundleRoutine(companyId, key, routineKey);
|
||
await ensureBuiltInAgentAssignable(agent);
|
||
return routineSvc.runRoutine(routine.id, { source: "manual" }, actor);
|
||
}
|
||
|
||
async function requireBuiltInAgent(companyId: string, key: string): Promise<RequiredBuiltInAgent> {
|
||
const current = await get(companyId, key);
|
||
if (!current.agent) throw builtInAgentNotConfiguredError(current);
|
||
if (current.status === "ready") {
|
||
return { definition: current.definition, agent: current.agent, warning: null };
|
||
}
|
||
if (current.status === "paused") {
|
||
return {
|
||
definition: current.definition,
|
||
agent: current.agent,
|
||
warning: {
|
||
code: "built_in_agent_paused",
|
||
key: current.definition.key,
|
||
agentId: current.agent.id,
|
||
message: `Built-in agent ${current.definition.key} is paused; scheduled/background work should be skipped.`,
|
||
pauseReason: current.pauseReason,
|
||
},
|
||
};
|
||
}
|
||
throw builtInAgentNotConfiguredError(current);
|
||
}
|
||
|
||
async function autoProvisionBundledAgents(companyId: string) {
|
||
const company = await ensureCompany(companyId);
|
||
let autoEnsured = 0;
|
||
let pendingApprovals = 0;
|
||
// A fresh company starts with only its own lead agent — the Reflection
|
||
// Coach and Summarizer are no longer auto-created for new users. They stay
|
||
// available to enable on demand (via ensure / provision / the built-in
|
||
// bundle panel). We still reconcile any bundled agent that already exists
|
||
// (e.g. one an operator enabled) so its instructions/skill/routine keep
|
||
// tracking stock. Add a key to AUTO_PROVISION_ON_COMPANY_CREATE_KEYS to
|
||
// restore automatic creation for that definition.
|
||
for (const definition of DEFINITIONS.filter((entry) => entry.bundle)) {
|
||
const existing = await findSingleAgent(companyId, definition);
|
||
const shouldProvision = existing !== null || AUTO_PROVISION_ON_COMPANY_CREATE_KEYS.has(definition.key);
|
||
if (!shouldProvision) continue;
|
||
if (company.requireBoardApprovalForNewAgents) {
|
||
const result = await provision(companyId, definition.key);
|
||
if (result.approval) pendingApprovals += 1;
|
||
} else {
|
||
await ensure(companyId, definition.key);
|
||
}
|
||
autoEnsured += 1;
|
||
}
|
||
const defaultGrantsEnsured = await ensureCompanyDefaultAgentGrants(companyId);
|
||
return { autoEnsured, pendingApprovals, defaultGrantsEnsured };
|
||
}
|
||
|
||
return {
|
||
definitions: listBuiltInAgentDefinitions,
|
||
get,
|
||
ensure,
|
||
provision,
|
||
list,
|
||
reset,
|
||
enableRoutineSchedule: (
|
||
companyId: string,
|
||
key: string,
|
||
routineKey: string,
|
||
actor?: { agentId?: string | null; userId?: string | null; runId?: string | null },
|
||
) => setRoutineSchedule(companyId, key, routineKey, true, actor),
|
||
disableRoutineSchedule: (
|
||
companyId: string,
|
||
key: string,
|
||
routineKey: string,
|
||
actor?: { agentId?: string | null; userId?: string | null; runId?: string | null },
|
||
) => setRoutineSchedule(companyId, key, routineKey, false, actor),
|
||
runRoutine,
|
||
requireBuiltInAgent,
|
||
autoProvisionBundledAgents,
|
||
ensureCompanyDefaultAgentGrants,
|
||
reconcileDefinitionDefaults,
|
||
};
|
||
}
|
||
|
||
export async function reconcileBuiltInAgentsOnStartup(db: Db) {
|
||
const svc = builtInAgentService(db);
|
||
const companyRows = await db
|
||
.select({ id: companies.id })
|
||
.from(companies);
|
||
let autoEnsured = 0;
|
||
let pendingApprovals = 0;
|
||
let defaultGrantsEnsured = 0;
|
||
// Isolate per-company failures so one bad company (e.g. an unresolvable data
|
||
// problem) can no longer abort reconciliation for every company after it.
|
||
let companyFailures = 0;
|
||
for (const company of companyRows) {
|
||
try {
|
||
const result = await svc.autoProvisionBundledAgents(company.id);
|
||
autoEnsured += result.autoEnsured;
|
||
pendingApprovals += result.pendingApprovals;
|
||
defaultGrantsEnsured += result.defaultGrantsEnsured;
|
||
} catch (err) {
|
||
companyFailures += 1;
|
||
console.error(
|
||
`built-in agent auto-provisioning failed for company ${company.id}; continuing with remaining companies`,
|
||
err,
|
||
);
|
||
}
|
||
}
|
||
const rows = await db
|
||
.select({
|
||
companyId: agents.companyId,
|
||
metadata: agents.metadata,
|
||
status: agents.status,
|
||
})
|
||
.from(agents)
|
||
.where(ne(agents.status, "terminated"));
|
||
const seen = new Set<string>();
|
||
let scanned = 0;
|
||
let reconciled = 0;
|
||
let unknown = 0;
|
||
let duplicates = 0;
|
||
|
||
for (const row of rows) {
|
||
const marker = readBuiltInAgentMarker(row.metadata);
|
||
if (!marker) continue;
|
||
scanned += 1;
|
||
if (!getBuiltInAgentDefinition(marker.key)) {
|
||
unknown += 1;
|
||
continue;
|
||
}
|
||
const instanceKey = `${row.companyId}:${marker.key}`;
|
||
if (seen.has(instanceKey)) {
|
||
duplicates += 1;
|
||
continue;
|
||
}
|
||
seen.add(instanceKey);
|
||
try {
|
||
await svc.reconcileDefinitionDefaults(row.companyId, marker.key);
|
||
reconciled += 1;
|
||
} catch (err) {
|
||
companyFailures += 1;
|
||
console.error(
|
||
`built-in agent default reconciliation failed for company ${row.companyId} key ${marker.key}; continuing`,
|
||
err,
|
||
);
|
||
}
|
||
}
|
||
|
||
return { scanned, reconciled, unknown, duplicates, autoEnsured, pendingApprovals, defaultGrantsEnsured, companyFailures };
|
||
}
|