Files
PaperClipAI/server/src/services/instance-settings.ts
T
DottaandPaperclip 5b62a3883f feat(settings): add experimental Simplified English Interactions flag (#10934)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents ask humans for decisions through interaction blocks: plan
confirmations, structured questions, suggested tasks, and checkbox
prompts
> - Each agent writes these decision prompts in its own style, so
operators can get long or unclear text at the exact moment they must
decide
> - There is no instance-level control that makes agents use a
controlled language for these decision points only
> - This pull request adds an experimental setting that tells agents to
write all user-interaction content in ASD-STE100 Simplified Technical
English, with the context the user needs and the effect of each choice
> - The benefit is faster, clearer human decisions, with agent thinking
and normal responses unchanged

## Linked Issues or Issue Description

Refs #10410 (optional `/simplified-english` skill in the skills catalog;
this PR adds the instance-level toggle for interactions).

**Problem or motivation**

Agent-posted user interactions (plan confirmations, structured
questions, suggested-task proposals, checkbox prompts) are written in
each agent's default style. Operators who want fast, unambiguous
decisions have no way to ask agents to use a controlled language for
exactly those decision points.

**Proposed solution**

Add an experimental instance setting,
`enableSimplifiedEnglishInteractions` ("Simplified English
Interactions"). When it is on, the server sets
`simplifiedEnglishInteractions: true` in the heartbeat wake payload. The
shared wake-prompt renderer, used by every adapter, then emits a
directive: write all user-interaction content in ASD-STE100 Simplified
Technical English, state what information the user needs to decide, and
state what happens for each choice. The directive applies to interaction
content only. Thinking, comments, documents, and other responses keep
their usual style.

**Alternatives considered**

Per-agent instructions work today, but someone must maintain them on
every agent. An instance-level toggle applies uniformly and turns off in
one place. Server-side rewriting of interaction payloads was rejected:
post-hoc translation is lossy and cannot add the decision context that
only the agent has.

**Roadmap alignment**

Extends the experimental settings surface with another opt-in
agent-behavior refinement, consistent with existing prompt-side flags.

## What Changed

- Added `enableSimplifiedEnglishInteractions` to the experimental
instance-settings zod schema, mirror type, and feature catalog (default
off, tier preference) in `packages/shared`.
- Server `instance-settings.ts` normalizes the flag on both read
branches; `heartbeat.ts` reads it once and passes
`simplifiedEnglishInteractions` into `buildPaperclipWakePayload`.
- Shared adapter renderer
(`packages/adapter-utils/src/server-utils.ts`): added the field to
`PaperclipWakePayload`, normalization, and an `- interaction language
(experimental): ...` directive emitted in both fresh and resume prompt
lanes, so one injection point covers all adapters.
- UI: new experimental settings card "Simplified English Interactions"
in `ui/src/pages/InstanceExperimentalSettings.tsx`, in alphabetical card
order; fixtures updated.
- Tests: renderer coverage for flag on/off in both lanes, plus
schema/catalog/UI fixture updates.

## Verification

- From the repo root: `node_modules/.bin/vitest run
packages/adapter-utils` (88/88), `packages/shared` validators (25/25),
server instance-settings + heartbeat suites (47/47 and 70/70 consumer
tests), `ui` settings tests (32/32).
- Typecheck is clean in all four touched packages.
- Manual check: turn the flag on in Settings → Experimental, wake an
agent, and confirm the wake prompt contains the interaction-language
directive; turn it off and confirm the directive is absent.

## Risks

- Low risk: the flag defaults to off, and the only behavior change is
one extra directive line in the wake prompt when an operator turns it
on.
- The directive is advisory to the agent; models can still deviate from
STE. No data or API shape changes; no migration.

## Model Used

- Claude Fable 5 (Anthropic, model ID `claude-fable-5`), extended
thinking, agentic tool use via Claude Agent SDK.

## 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
- [x] I have run tests locally and they 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>
2026-08-05 21:50:41 -05:00

441 lines
16 KiB
TypeScript

import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
DEFAULT_BACKUP_RETENTION,
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
PAPERCLIP_CLOUD_MANAGED_BY,
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema,
type InstanceExperimentalSettings,
type InstanceExperimentalSettingsWithManaged,
type ManagedExperimentalFeatureKey,
type ManagedSettingMetadata,
type PatchInstanceGeneralSettings,
type InstanceSettings,
type PatchInstanceSettings,
type PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { eq } from "drizzle-orm";
import { getManagedInstanceConfig, type ManagedInstanceConfig } from "./managed-config.js";
const DEFAULT_SINGLETON_KEY = "default";
const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip();
const instanceExperimentalSettingsStorageSchema = instanceExperimentalSettingsSchema.strip();
const TRUTHY_RUNTIME_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
interface InstanceSettingsServiceOptions {
runtimeEnv?: Record<string, string | undefined>;
now?: () => Date;
}
type WorktreeRunExecutionSuppressedReason =
| "not_worktree_runtime"
| "flag_disabled"
| "missing_cutoff"
| "missing_instance_id"
| "instance_id_mismatch"
| "settings_read_error";
export type WorktreeRunExecutionActivationState =
| {
armed: true;
cutoff: string;
activationInstanceId: string;
reason: null;
}
| {
armed: false;
cutoff: null;
activationInstanceId: string | null;
reason: WorktreeRunExecutionSuppressedReason;
};
export function isTruthyRuntimeEnvValue(value: string | undefined) {
return typeof value === "string" && TRUTHY_RUNTIME_ENV_VALUES.has(value.trim().toLowerCase());
}
function getRuntimeInstanceId(env: Record<string, string | undefined>) {
const instanceId = env.PAPERCLIP_INSTANCE_ID?.trim();
return instanceId ? instanceId : null;
}
function stripServerManagedExperimentalPatchFields(
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
): PatchInstanceExperimentalSettings {
const {
worktreeRunExecutionActivatedAt: _ignoredActivatedAt,
worktreeRunExecutionActivationInstanceId: _ignoredActivationInstanceId,
...patchable
} = patch as Record<string, unknown>;
return patchable as PatchInstanceExperimentalSettings;
}
export function applyExperimentalSettingsPatch(
current: unknown,
patch: PatchInstanceExperimentalSettings | Record<string, unknown>,
options: InstanceSettingsServiceOptions = {},
): InstanceExperimentalSettings {
const previousExperimental = normalizeExperimentalSettings(current);
const patchable = stripServerManagedExperimentalPatchFields(patch);
const nextExperimental = normalizeExperimentalSettings({
...previousExperimental,
...patchable,
});
const hasWorktreeRunExecutionPatch = Object.prototype.hasOwnProperty.call(
patchable,
"enableWorktreeRunExecution",
);
if (!hasWorktreeRunExecutionPatch) {
return nextExperimental;
}
if (nextExperimental.enableWorktreeRunExecution !== true) {
return {
...nextExperimental,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
};
}
if (previousExperimental.enableWorktreeRunExecution === true) {
return nextExperimental;
}
const runtimeEnv = options.runtimeEnv ?? process.env;
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
return nextExperimental;
}
return {
...nextExperimental,
worktreeRunExecutionActivatedAt: (options.now ?? (() => new Date()))().toISOString(),
worktreeRunExecutionActivationInstanceId: getRuntimeInstanceId(runtimeEnv),
};
}
function suppressWorktreeRunExecution(
reason: WorktreeRunExecutionSuppressedReason,
activationInstanceId: string | null = null,
): WorktreeRunExecutionActivationState {
return {
armed: false,
cutoff: null,
activationInstanceId,
reason,
};
}
export function resolveWorktreeRunExecutionActivation(
experimental: InstanceExperimentalSettings,
currentInstanceId: string | null | undefined,
): WorktreeRunExecutionActivationState {
if (experimental.enableWorktreeRunExecution !== true) {
return suppressWorktreeRunExecution(
"flag_disabled",
experimental.worktreeRunExecutionActivationInstanceId,
);
}
if (!experimental.worktreeRunExecutionActivatedAt) {
return suppressWorktreeRunExecution(
"missing_cutoff",
experimental.worktreeRunExecutionActivationInstanceId,
);
}
if (!currentInstanceId) {
return suppressWorktreeRunExecution(
"missing_instance_id",
experimental.worktreeRunExecutionActivationInstanceId,
);
}
if (experimental.worktreeRunExecutionActivationInstanceId !== currentInstanceId) {
return suppressWorktreeRunExecution(
"instance_id_mismatch",
experimental.worktreeRunExecutionActivationInstanceId,
);
}
return {
armed: true,
cutoff: experimental.worktreeRunExecutionActivatedAt,
activationInstanceId: currentInstanceId,
reason: null,
};
}
export async function resolveWorktreeRunExecutionActivationState(options: {
getExperimental: () => Promise<InstanceExperimentalSettings>;
runtimeEnv?: Record<string, string | undefined>;
}): Promise<WorktreeRunExecutionActivationState> {
const runtimeEnv = options.runtimeEnv ?? process.env;
if (!isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE)) {
return suppressWorktreeRunExecution("not_worktree_runtime");
}
try {
return resolveWorktreeRunExecutionActivation(
await options.getExperimental(),
getRuntimeInstanceId(runtimeEnv),
);
} catch {
return suppressWorktreeRunExecution("settings_read_error");
}
}
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
const parsed = instanceGeneralSettingsStorageSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
keyboardShortcuts: parsed.data.keyboardShortcuts ?? false,
feedbackDataSharingPreference:
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION,
// Absent => unrestricted; only carry through an explicit policy.
...(parsed.data.executionMode ? { executionMode: parsed.data.executionMode } : {}),
};
}
return {
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
backupRetention: DEFAULT_BACKUP_RETENTION,
};
}
export function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
const parsed = instanceExperimentalSettingsStorageSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
enableEnvironments: parsed.data.enableEnvironments ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
enableApps: parsed.data.enableApps ?? false,
enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? false,
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
enableTaskChatRedesign: parsed.data.enableTaskChatRedesign ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
enableSmokeLab: parsed.data.enableSmokeLab ?? false,
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
enableBetaSkills: parsed.data.enableBetaSkills ?? false,
enableSummaries: parsed.data.enableSummaries ?? false,
enableStatusCards: parsed.data.enableStatusCards ?? false,
enableDecisions: parsed.data.enableDecisions ?? false,
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false,
enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false,
enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false,
worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null,
worktreeRunExecutionActivationInstanceId:
parsed.data.worktreeRunExecutionActivationInstanceId ?? null,
issueGraphLivenessAutoRecoveryLookbackHours:
parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ??
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
};
}
return {
enableEnvironments: false,
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
enableApps: false,
enablePipelines: false,
enableCases: false,
enableConferenceRoomChat: false,
enableTaskChatRedesign: false,
enableTaskWatchdogs: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableSmokeLab: false,
enableBuiltInAgents: false,
enableBetaSkills: false,
enableSummaries: false,
enableStatusCards: false,
enableDecisions: false,
enableGoalsSidebarLink: false,
enableServerInfoDebugView: false,
enableSimplifiedEnglishInteractions: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableOwnerInstanceAdmin: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
issueGraphLivenessAutoRecoveryLookbackHours:
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
};
}
export type ManagedExperimentalKeyMetadata = Partial<
Record<ManagedExperimentalFeatureKey, ManagedSettingMetadata>
>;
/**
* Overlay the cloud managed-config feature values over normalized settings.
*
* Read-time precedence: code floor (cloud) > managed overlay > tenant DB
* value > schema default. (No code floors are expressed as flags today —
* floors are enforced in code at the guarded routes, independent of any
* flag value.) The overlay is deliberately never persisted: it re-asserts on
* every read, so a DB restore or manual row edit cannot resurrect a
* capability the harness has disabled.
*/
export function applyManagedExperimentalOverlay(
experimental: InstanceExperimentalSettings,
managedConfig: ManagedInstanceConfig | null,
): { experimental: InstanceExperimentalSettings; managedKeys: ManagedExperimentalKeyMetadata } {
if (!managedConfig) return { experimental, managedKeys: {} };
const next: InstanceExperimentalSettings = { ...experimental };
const managedKeys: ManagedExperimentalKeyMetadata = {};
for (const [key, value] of Object.entries(managedConfig.features) as Array<
[ManagedExperimentalFeatureKey, boolean]
>) {
next[key] = value;
managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY };
}
return { experimental: next, managedKeys };
}
export function instanceSettingsService(db: Db, options: InstanceSettingsServiceOptions = {}) {
// Fail closed: a malformed PAPERCLIP_MANAGED_CONFIG throws here (and at
// boot in index.ts) rather than silently running without the overlay.
const managedConfig = getManagedInstanceConfig(options.runtimeEnv ?? process.env);
function toExperimentalView(raw: unknown): InstanceExperimentalSettingsWithManaged {
const { experimental, managedKeys } = applyManagedExperimentalOverlay(
normalizeExperimentalSettings(raw),
managedConfig,
);
// Self-hosted responses stay byte-identical: no managedKeys field at all.
return managedConfig ? { ...experimental, managedKeys } : experimental;
}
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
return {
id: row.id,
defaultEnvironmentId: row.defaultEnvironmentId ?? null,
general: normalizeGeneralSettings(row.general),
experimental: toExperimentalView(row.experimental),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
} as InstanceSettings;
}
async function getOrCreateRow() {
const existing = await db
.select()
.from(instanceSettings)
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
.then((rows) => rows[0] ?? null);
if (existing) return existing;
const now = new Date();
const [created] = await db
.insert(instanceSettings)
.values({
singletonKey: DEFAULT_SINGLETON_KEY,
general: {},
experimental: {},
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [instanceSettings.singletonKey],
set: {
updatedAt: now,
},
})
.returning();
if (created) return created;
const raced = await db
.select()
.from(instanceSettings)
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
.then((rows) => rows[0] ?? null);
if (raced) return raced;
throw new Error("Failed to initialize instance settings row");
}
return {
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
update: async (patch: PatchInstanceSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
...(Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId")
? { defaultEnvironmentId: patch.defaultEnvironmentId ?? null }
: {}),
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
getGeneral: async (): Promise<InstanceGeneralSettings> => {
const row = await getOrCreateRow();
return normalizeGeneralSettings(row.general);
},
getExperimental: async (): Promise<InstanceExperimentalSettingsWithManaged> => {
const row = await getOrCreateRow();
return toExperimentalView(row.experimental);
},
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextGeneral = normalizeGeneralSettings({
...normalizeGeneralSettings(current.general),
...patch,
});
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
general: { ...nextGeneral },
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextExperimental = applyExperimentalSettingsPatch(current.experimental, patch, options);
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
experimental: { ...nextExperimental },
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
listCompanyIds: async (): Promise<string[]> =>
db
.select({ id: companies.id })
.from(companies)
.then((rows) => rows.map((row) => row.id)),
};
}