{
diff --git a/frontend/src/components/shared/agent_model_selector.ts b/frontend/src/components/shared/agent_model_selector.ts
index dcc0f22cb..a099d1464 100644
--- a/frontend/src/components/shared/agent_model_selector.ts
+++ b/frontend/src/components/shared/agent_model_selector.ts
@@ -4,7 +4,12 @@ import '@material/web/textfield/filled-text-field.js';
import {LitElement, CSSResultGroup, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
-import {ApiKeyType, getDefaultModelForApiType} from '@deliberation-lab/utils';
+import {
+ ApiKeyType,
+ ExperimenterData,
+ checkApiKeyExists,
+ getDefaultModelForApiType,
+} from '@deliberation-lab/utils';
import {styles} from './agent_model_selector.scss';
@@ -16,6 +21,10 @@ export class AgentModelSelector extends LitElement {
@property() apiType: ApiKeyType = ApiKeyType.GEMINI_API_KEY;
@property() modelName: string = '';
@property({type: Boolean}) disabled = false;
+ @property({type: Object}) experimenterData:
+ | ExperimenterData
+ | null
+ | undefined = undefined;
private emitChange(apiType: ApiKeyType, modelName: string) {
this.dispatchEvent(
@@ -57,16 +66,29 @@ export class AgentModelSelector extends LitElement {
private renderApiTypeButton(label: string, apiType: ApiKeyType) {
const isActive = apiType === this.apiType;
+ // Only perform the key check when experimenterData is explicitly provided.
+ const hasKey =
+ this.experimenterData === undefined ||
+ checkApiKeyExists(apiType, this.experimenterData);
+ const isDisabled = this.disabled || !hasKey;
+ const tooltipText = !hasKey
+ ? apiType === ApiKeyType.OLLAMA_CUSTOM_URL
+ ? "You haven't added a compatible server URL"
+ : "You haven't added a compatible API key"
+ : '';
return html`
{
+ if (!hasKey) return;
this.emitChange(apiType, getDefaultModelForApiType(apiType));
}}
>
- ${label}
+ ${!hasKey ? html`⚠️ ` : ''}${label}
`;
}
diff --git a/frontend/src/components/shared/persona_generation_buttons.scss b/frontend/src/components/shared/persona_generation_buttons.scss
new file mode 100644
index 000000000..0a925aa9f
--- /dev/null
+++ b/frontend/src/components/shared/persona_generation_buttons.scss
@@ -0,0 +1,24 @@
+@use '../../sass/common';
+@use '../../sass/typescale';
+
+:host {
+ display: contents;
+}
+
+.persona-generation-row {
+ @include common.flex-row-align-center;
+ gap: common.$spacing-medium;
+ flex-wrap: wrap;
+}
+
+.generate-wrapper {
+ @include common.flex-row-align-center;
+ gap: common.$spacing-small;
+ position: relative;
+}
+
+.error-message {
+ @include typescale.body-small;
+ color: var(--md-sys-color-error);
+ margin-top: common.$spacing-small;
+}
diff --git a/frontend/src/components/shared/persona_generation_buttons.ts b/frontend/src/components/shared/persona_generation_buttons.ts
new file mode 100644
index 000000000..5eac39d37
--- /dev/null
+++ b/frontend/src/components/shared/persona_generation_buttons.ts
@@ -0,0 +1,203 @@
+import '../../pair-components/button';
+import '../../pair-components/icon_button';
+import '../../pair-components/tooltip';
+
+import {MobxLitElement} from '@adobe/lit-mobx';
+import {CSSResultGroup, html, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+
+import {core} from '../../core/core';
+import {AuthService} from '../../services/auth.service';
+import {FirebaseService} from '../../services/firebase.service';
+
+import {
+ AgentModelSettings,
+ ModelResponseStatus,
+ PersonaGenerationMode,
+} from '@deliberation-lab/utils';
+
+import {generatePersonaContextCallable} from '../../shared/callables';
+import {styles} from './persona_generation_buttons.scss';
+
+/**
+ * Renders 🪄 Generate, ✨ Enhance, and 🔄 Refresh buttons for AI-assisted
+ * persona writing.
+ *
+ * - Generate: writes a full character sketch on empty field; merge-expands on
+ * existing text to incorporate it into a full ~250-word sketch.
+ * - Enhance: appends 1-2 specific episodic memories to existing sketch.
+ * Disabled when field is empty.
+ * - Refresh: ⚠️ clears and regenerates from scratch. Shows warning if text.
+ *
+ * Fires a `persona-text-change` CustomEvent with `{ text, mode }` on success.
+ * The parent is responsible for applying the text.
+ */
+@customElement('persona-generation-buttons')
+export class PersonaGenerationButtons extends MobxLitElement {
+ static override styles: CSSResultGroup = [styles];
+
+ private readonly authService = core.getService(AuthService);
+ private readonly firebaseService = core.getService(FirebaseService);
+
+ @property() currentText = '';
+ @property({type: Object}) modelSettings: AgentModelSettings | undefined =
+ undefined;
+ @property({type: Boolean}) disabled = false;
+
+ @state() private isGenerating = false;
+ @state() private isEnhancing = false;
+ @state() private isRefreshing = false;
+ @state() private errorMessage = '';
+ // Counts successful Generate/Refresh calls in this session. Passed to the
+ // backend as batchIndex so successive agents traverse the Halton sequence
+ // in order — each new agent fills the biggest gap in Big Five trait space
+ // left by the previous ones. Resets on component remount.
+ @state() private generateCount = 0;
+
+ private get creatorId(): string {
+ return this.authService.experimenterData?.email ?? '';
+ }
+
+ private get modelDisabled(): boolean {
+ return !this.modelSettings?.modelName || !this.modelSettings?.apiType;
+ }
+
+ private get isAnyLoading(): boolean {
+ return this.isGenerating || this.isEnhancing || this.isRefreshing;
+ }
+
+ private async runGeneration(mode: PersonaGenerationMode) {
+ if (!this.modelSettings || this.modelDisabled || this.disabled) return;
+
+ if (mode === 'generate') this.isGenerating = true;
+ else if (mode === 'enhance') this.isEnhancing = true;
+ else if (mode === 'refresh') this.isRefreshing = true;
+ this.errorMessage = '';
+
+ try {
+ const response = await generatePersonaContextCallable(
+ this.firebaseService.functions,
+ {
+ creatorId: this.creatorId,
+ mode,
+ currentText: this.currentText,
+ apiType: this.modelSettings.apiType,
+ modelName: this.modelSettings.modelName,
+ // Enhance doesn't sample Big Five so batchIndex has no effect there.
+ ...(mode !== 'enhance' ? {batchIndex: this.generateCount} : {}),
+ },
+ );
+
+ if (
+ (response.status === ModelResponseStatus.OK ||
+ response.status === ModelResponseStatus.LENGTH_ERROR) &&
+ response.text
+ ) {
+ if (mode !== 'enhance') this.generateCount++;
+ this.dispatchEvent(
+ new CustomEvent('persona-text-change', {
+ detail: {text: response.text, mode},
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ } else {
+ this.errorMessage =
+ response.errorMessage ?? 'An error occurred. Please try again.';
+ }
+ } catch (e) {
+ this.errorMessage = 'An error occurred. Please try again.';
+ } finally {
+ this.isGenerating = false;
+ this.isEnhancing = false;
+ this.isRefreshing = false;
+ }
+ }
+
+ override render() {
+ const hasExistingText = this.currentText.trim().length > 0;
+
+ const modelTooltip = this.modelDisabled
+ ? 'Select a model to use this feature'
+ : '';
+
+ const generateTooltip = this.modelDisabled
+ ? modelTooltip
+ : 'Generate or fill out your persona prompt';
+
+ const refreshTooltip = this.modelDisabled
+ ? modelTooltip
+ : hasExistingText
+ ? '⚠️ Erase and regenerate persona from scratch'
+ : 'Regenerate persona from scratch';
+
+ const isButtonsDisabled =
+ this.disabled || this.modelDisabled || this.isAnyLoading;
+
+ return html`
+
+
+ this.runGeneration('generate')}
+ >
+ 🪄 Generate
+
+
+
+
+ this.runGeneration('enhance')}
+ >
+ ✨ Enhance
+
+
+
+
+ this.runGeneration('refresh')}
+ >
+
+
+
+ ${this.errorMessage
+ ? html`
${this.errorMessage}
`
+ : nothing}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'persona-generation-buttons': PersonaGenerationButtons;
+ }
+}
diff --git a/frontend/src/shared/callables.ts b/frontend/src/shared/callables.ts
index da2d5bdc8..ce80aa42c 100644
--- a/frontend/src/shared/callables.ts
+++ b/frontend/src/shared/callables.ts
@@ -44,6 +44,7 @@ import {
UpdateRankingStageParticipantAnswerData,
UpdateSurveyPerParticipantStageParticipantAnswerData,
UpdateSurveyStageParticipantAnswerData,
+ PersonaGenerationMode,
} from '@deliberation-lab/utils';
import {Functions, httpsCallable} from 'firebase/functions';
@@ -647,6 +648,42 @@ export const testAgentConfigCallable = async (
return data;
};
+/** Generate, enhance, or refresh an agent persona character sketch using an LLM. */
+export const generatePersonaContextCallable = async (
+ functions: Functions,
+ config: {
+ creatorId: string;
+ mode: PersonaGenerationMode;
+ currentText: string;
+ apiType: string;
+ modelName: string;
+ /**
+ * Optional. 0-based index of this generate call within the current session
+ * (0 for the first Generate press, 1 for the second, etc.). Passed to the
+ * backend so successive agents traverse the Halton sequence in order,
+ * provably covering the Big Five trait space as the batch grows.
+ * Omit for a random offset fallback.
+ */
+ batchIndex?: number;
+ },
+): Promise
=> {
+ const {data} = await httpsCallable<
+ {
+ creatorId: string;
+ mode: PersonaGenerationMode;
+ currentText: string;
+ apiType: string;
+ modelName: string;
+ batchIndex?: number;
+ },
+ ModelResponse
+ >(
+ functions,
+ 'generatePersonaContext',
+ )(config);
+ return data;
+};
+
/** Generic endpoint for sending alert message. */
export const sendAlertMessageCallable = async (
functions: Functions,
diff --git a/frontend/src/shared/templates/quickstart_agent_participants_demo.ts b/frontend/src/shared/templates/quickstart_agent_participants_demo.ts
new file mode 100644
index 000000000..553ab4d0b
--- /dev/null
+++ b/frontend/src/shared/templates/quickstart_agent_participants_demo.ts
@@ -0,0 +1,345 @@
+import {
+ AgentMediatorTemplate,
+ AgentParticipantTemplate,
+ AgentPersonaType,
+ ExperimentTemplate,
+ MediatorPromptConfig,
+ ParticipantPromptConfig,
+ ProfileType,
+ StageConfig,
+ StageKind,
+ RankingType,
+ RevealAudience,
+ StructuredOutputDataType,
+ createAgentChatSettings,
+ createAgentMediatorPersonaConfig,
+ createChatPromptConfig,
+ createChatStage,
+ createExperimentConfig,
+ createExperimentTemplate,
+ createMetadataConfig,
+ createParticipantProfileBase,
+ createProfileStage,
+ createDefaultMediatorGroupChatPrompt,
+ createStructuredOutputConfig,
+ createSurveyStage,
+ createTextSurveyQuestion,
+ createScaleSurveyQuestion,
+ createMultipleChoiceSurveyQuestion,
+ createMultipleChoiceItem,
+ createSurveyPerParticipantStage,
+ createRankingStage,
+ createRevealStage,
+ createRankingRevealItem,
+ createStageTextConfig,
+ createStageProgressConfig,
+} from '@deliberation-lab/utils';
+
+export function getAgentParticipantsDemoTemplate(): ExperimentTemplate {
+ const CHAT_STAGE_ID = 'chat';
+ const RANKING_STAGE_ID = 'ranking';
+
+ const metadata = createMetadataConfig({
+ name: 'Group Agent Participants Demo',
+ publicName: 'Simulated Participants Demo',
+ description:
+ 'This experiment showcases the social dynamics of agent participants in a group experiment. Add agent participants to a cohort to explore.',
+ });
+
+ const stageConfigs: StageConfig[] = [];
+
+ // 1. Set your profile
+ stageConfigs.push(
+ createProfileStage({
+ name: 'Set Your Profile',
+ profileType: ProfileType.DEFAULT,
+ }),
+ );
+
+ // 2. About Your Persona (Survey stage)
+ stageConfigs.push(
+ createSurveyStage({
+ name: 'About Your Persona',
+ descriptions: createStageTextConfig({
+ primaryText: 'Please answer a few questions about your persona.',
+ }),
+ questions: [
+ createTextSurveyQuestion({
+ questionTitle: 'Tell me briefly about yourself.',
+ }),
+ createTextSurveyQuestion({
+ questionTitle: 'How would you describe your communication style?',
+ }),
+ createScaleSurveyQuestion({
+ questionTitle: 'How much do you enjoy meeting new people?',
+ lowerValue: 1,
+ upperValue: 5,
+ lowerText: 'Not at all',
+ upperText: 'Very much',
+ }),
+ createScaleSurveyQuestion({
+ questionTitle:
+ 'How strongly do you hold your opinions when challenged?',
+ lowerValue: 1,
+ upperValue: 5,
+ lowerText: 'Not at all',
+ upperText: 'Very strongly',
+ }),
+ createTextSurveyQuestion({
+ questionTitle: "What's something you feel strongly about?",
+ }),
+ createTextSurveyQuestion({
+ questionTitle:
+ 'Describe a belief you hold that others might find surprising.',
+ }),
+ ],
+ }),
+ );
+
+ // 3. Group chat
+ stageConfigs.push(
+ createChatStage({
+ id: CHAT_STAGE_ID,
+ name: 'Group Discussion',
+ descriptions: createStageTextConfig({
+ primaryText:
+ 'What makes a good community? Spend this chat discussing this topic with other participants.',
+ }),
+ progress: createStageProgressConfig({waitForAllParticipants: true}),
+ }),
+ );
+
+ // 4. About You (Survey stage)
+ const professionOptions = [
+ 'Management, Business, & Financial Operations',
+ 'Computer, Engineering, & Science',
+ 'Education, Training, & Library',
+ 'Arts, Design, Media, & Entertainment',
+ 'Healthcare Practitioners & Support',
+ 'Service, Sales, & Hospitality',
+ 'Government, Law, & Public Safety',
+ 'Trades, Construction, & Manufacturing',
+ 'Farming, Maintenance, & Specialized Outdoor',
+ 'Non-Employed (Student, Retired, Homemaker, or Seeking Work)',
+ ].map((text, idx) => createMultipleChoiceItem({id: `prof_${idx}`, text}));
+
+ stageConfigs.push(
+ createSurveyStage({
+ name: 'About You',
+ descriptions: createStageTextConfig({
+ primaryText: 'Please answer the following questions about yourself.',
+ }),
+ questions: [
+ createMultipleChoiceSurveyQuestion({
+ questionTitle: 'What best describes your current profession?',
+ options: professionOptions,
+ }),
+ createTextSurveyQuestion({
+ questionTitle:
+ 'Please tell us about yourself. Describe your personality and what you currently find most meaningful or fulfilling in life (e.g., what keeps you going and why)?',
+ minCharCount: 150,
+ }),
+ createTextSurveyQuestion({
+ questionTitle:
+ 'What is your favorite way of spending an evening? Please describe 1-3 hobbies or activities you participate in regularly and why you enjoy them.',
+ minCharCount: 50,
+ }),
+ createTextSurveyQuestion({
+ questionTitle:
+ 'Reflecting on your choices in this study: Is there anything about your personal background, values, or life experiences that you feel influenced how you thought or acted? Please describe.',
+ }),
+ ],
+ }),
+ );
+
+ // 5. About Others (Survey per participant stage)
+ stageConfigs.push(
+ createSurveyPerParticipantStage({
+ name: 'About Others',
+ descriptions: createStageTextConfig({
+ primaryText:
+ 'Please describe your impressions of the other participants.',
+ }),
+ questions: [
+ createTextSurveyQuestion({
+ questionTitle:
+ "Please describe your impression of this participant's behavior, personality and communication style. Based on your interaction today, what kind of person do they seem to be?",
+ minCharCount: 50,
+ maxCharCount: 500,
+ }),
+ ],
+ }),
+ );
+
+ // 6. Popularity Contest Ranking
+ stageConfigs.push(
+ createRankingStage({
+ id: RANKING_STAGE_ID,
+ name: 'Popularity Contest Ranking',
+ descriptions: createStageTextConfig({
+ primaryText:
+ 'Rank the other participants in terms of who you found most interesting to talk to.',
+ }),
+ rankingType: RankingType.PARTICIPANTS,
+ enableSelfVoting: false,
+ }),
+ );
+
+ // 7. Results Reveal
+ stageConfigs.push(
+ createRevealStage({
+ name: 'Results Reveal',
+ descriptions: createStageTextConfig({
+ primaryText:
+ 'Here are the full rank orderings from the previous stage.',
+ }),
+ items: [
+ createRankingRevealItem({
+ id: RANKING_STAGE_ID,
+ revealAudience: RevealAudience.ALL_PARTICIPANTS,
+ }),
+ ],
+ }),
+ );
+
+ // 8. Meta-Survey
+ stageConfigs.push(
+ createSurveyStage({
+ name: 'Meta-Survey',
+ descriptions: createStageTextConfig({
+ primaryText: 'Please provide some final feedback on this study.',
+ }),
+ questions: [
+ createScaleSurveyQuestion({
+ questionTitle: 'Did you enjoy this study?',
+ lowerValue: 1,
+ upperValue: 5,
+ lowerText: 'Not at all',
+ upperText: 'Very much',
+ }),
+ createScaleSurveyQuestion({
+ questionTitle: 'Did the agents feel realistic?',
+ lowerValue: 1,
+ upperValue: 5,
+ lowerText: 'Not at all',
+ upperText: 'Very much',
+ }),
+ createMultipleChoiceSurveyQuestion({
+ questionTitle:
+ 'Would you be able to tell which participants were AI?',
+ options: [
+ createMultipleChoiceItem({id: 'yes', text: 'Yes'}),
+ createMultipleChoiceItem({id: 'no', text: 'No'}),
+ ],
+ }),
+ createTextSurveyQuestion({
+ questionTitle: 'Any feedback for the study?',
+ }),
+ ],
+ }),
+ );
+
+ // Mediator Setup
+ const mediatorPersona = createAgentMediatorPersonaConfig({
+ name: 'Mediator',
+ description:
+ 'Facilitates the conversation and ensures participants stay engaged.',
+ isDefaultAddToCohort: true,
+ defaultProfile: createParticipantProfileBase({
+ name: 'Mediator',
+ avatar: '⚖️',
+ }),
+ });
+
+ // Custom structured output schema — extends the default with a turnCount
+ // field so the mediator can self-track how many turns have elapsed and
+ // decide when to issue the 20-turn nudge and 30-turn close-out summary.
+ const mediatorStructuredOutputConfig = createStructuredOutputConfig({
+ schema: {
+ type: StructuredOutputDataType.OBJECT,
+ properties: [
+ {
+ name: 'explanation',
+ schema: {
+ type: StructuredOutputDataType.STRING,
+ description:
+ '1-2 sentences explaining why you are intervening now (or staying silent).',
+ },
+ },
+ {
+ name: 'turnCount',
+ schema: {
+ type: StructuredOutputDataType.INTEGER,
+ description:
+ 'Your best estimate of the total number of participant turns that have happened so far in this conversation (not counting your own messages). Increment this by counting the messages in the transcript.',
+ },
+ },
+ {
+ name: 'shouldRespond',
+ schema: {
+ type: StructuredOutputDataType.BOOLEAN,
+ description:
+ 'True if you will send a message now. False to stay silent. Be sparing — only intervene when you have something meaningful to add or when turn thresholds are reached.',
+ },
+ },
+ {
+ name: 'response',
+ schema: {
+ type: StructuredOutputDataType.STRING,
+ description:
+ 'Your message to the group (empty string if staying silent).',
+ },
+ },
+ {
+ name: 'readyToEndChat',
+ schema: {
+ type: StructuredOutputDataType.BOOLEAN,
+ description:
+ 'Set to true only when turnCount >= 30. This signals the platform to close the chat.',
+ },
+ },
+ ],
+ },
+ });
+
+ const MEDIATOR_PROMPT_TEXT = `You are a light-touch discussion facilitator for a group conversation on the topic: "What makes a good community?"
+
+Your role:
+- Encourage quieter participants to share their perspective.
+- Ask a follow-up question if the conversation stalls or goes in circles.
+- Stay silent most of the time — only intervene every 4-6 turns at most. Do NOT respond after every message.
+
+Turn thresholds (based on your turnCount):
+- At turnCount >= 20: Gently signal that the conversation should start wrapping up. Example: "We've had a great discussion — let's aim to bring things to a close soon. Any final thoughts?"
+- At turnCount >= 30: Set readyToEndChat to true. In your final message, write a 3-5 sentence summary of the conversation: the main themes that emerged, any points of agreement or tension, and a brief closing note. Then say goodbye.
+
+Be warm, brief, and human-sounding. Never dominate the conversation.`;
+
+ const mediatorPrompt = createDefaultMediatorGroupChatPrompt(
+ CHAT_STAGE_ID,
+ MEDIATOR_PROMPT_TEXT,
+ );
+
+ const mediatorPromptMap: Record = {
+ [CHAT_STAGE_ID]: createChatPromptConfig(CHAT_STAGE_ID, StageKind.CHAT, {
+ prompt: mediatorPrompt,
+ structuredOutputConfig: mediatorStructuredOutputConfig,
+ chatSettings: createAgentChatSettings({
+ canSelfTriggerCalls: true,
+ maxResponses: 10,
+ }),
+ }),
+ };
+
+ const mediator: AgentMediatorTemplate = {
+ persona: mediatorPersona,
+ promptMap: mediatorPromptMap,
+ };
+
+ return createExperimentTemplate({
+ experiment: createExperimentConfig(stageConfigs, {metadata}),
+ stageConfigs,
+ agentMediators: [mediator],
+ agentParticipants: [],
+ });
+}
diff --git a/functions/src/agent.endpoints.ts b/functions/src/agent.endpoints.ts
index 14a882cf8..444bd425e 100644
--- a/functions/src/agent.endpoints.ts
+++ b/functions/src/agent.endpoints.ts
@@ -1,10 +1,15 @@
import {
ModelResponse,
ModelResponseStatus,
+ PersonaGenerationMode,
+ buildGeneratePersonaPrompt,
+ buildMergePersonaPrompt,
+ buildEnhancePersonaPrompt,
createAgentModelSettings,
createModelGenerationConfig,
} from '@deliberation-lab/utils';
import {getAgentResponse} from './agent.utils';
+import {samplePersonaParams} from './agent_persona_sampling';
import {getExperimenterData} from './utils/firestore';
import {onCall} from 'firebase-functions/v2/https';
@@ -50,3 +55,94 @@ export const testAgentConfig = onCall(
return response;
},
);
+
+// ****************************************************************************
+// Generate or enhance an agent persona context (character sketch)
+// Input structure: { creatorId, mode, currentText, apiType, modelName }
+// Modes:
+// 'generate' — fresh sketch (empty) or merge-expand (has text)
+// 'enhance' — appends episodic memories to existing sketch
+// 'refresh' — ignores existing text, always generates fresh
+// ****************************************************************************
+export const generatePersonaContext = onCall(
+ async (request): Promise => {
+ const {data} = request;
+ const creatorId: string = data.creatorId;
+ const mode: PersonaGenerationMode = data.mode;
+ const currentText: string = data.currentText ?? '';
+ const apiType = data.apiType;
+ const modelName: string = data.modelName ?? '';
+ // batchIndex: the frontend passes 0, 1, 2, ... for successive generates
+ // within a session. Forwarded to samplePersonaParams so the Halton
+ // sequence is traversed in order, guaranteeing each new agent fills
+ // the biggest gap in Big Five trait space left by prior ones.
+ const batchIndex: number | undefined =
+ typeof data.batchIndex === 'number' ? data.batchIndex : undefined;
+
+ // Only allow experimenters to use this endpoint
+ await AuthGuard.isExperimenter(request);
+
+ if (!modelName) {
+ return {
+ status: ModelResponseStatus.INTERNAL_ERROR,
+ errorMessage: 'No model selected',
+ };
+ }
+
+ // Fetch experimenter's API keys
+ const experimenterData = await getExperimenterData(creatorId);
+ if (!experimenterData) {
+ return {
+ status: ModelResponseStatus.INTERNAL_ERROR,
+ errorMessage: 'Experimenter data not found',
+ };
+ }
+
+ // Build prompt based on mode
+ let prompt = '';
+
+ if (mode === 'enhance') {
+ // Enhance: append episodic memories to existing sketch
+ prompt = buildEnhancePersonaPrompt(currentText);
+ } else {
+ // Generate or Refresh: sample random parameters.
+ // All sampling logic lives in agent_persona_sampling.ts.
+ const isRefresh = mode === 'refresh';
+ const params = samplePersonaParams(batchIndex);
+
+ if (!isRefresh && currentText.trim()) {
+ prompt = buildMergePersonaPrompt(currentText, params);
+ } else {
+ prompt = buildGeneratePersonaPrompt(params);
+ }
+ }
+
+ const modelSettings = createAgentModelSettings({apiType, modelName});
+ // Generate/Refresh: no maxTokens cap — prompt instructs ~200-250 words.
+ // Enhance: small cap (200 tokens ≈ 150 words) to prevent runaway additions.
+ // Reasoning/thinking is explicitly disabled for speed.
+ const isEnhanceMode = mode === 'enhance';
+ const generationConfig = createModelGenerationConfig({
+ temperature: 1.0,
+ ...(isEnhanceMode ? {maxTokens: 200} : {}),
+ includeReasoning: false,
+ // Force thinkingBudget: 0 for Gemini 2.5+ (always-thinking models)
+ // and disable thinking for Anthropic. This overrides any auto-enable.
+ providerOptions: {
+ google: {thinkingConfig: {thinkingBudget: 0}},
+ anthropic: {thinking: {type: 'disabled'}},
+ },
+ });
+
+ const response = await getAgentResponse(
+ experimenterData.apiKeys,
+ prompt,
+ modelSettings,
+ generationConfig,
+ );
+
+ console.log('GENERATE PERSONA CONTEXT\n', mode, response);
+
+ return response;
+ },
+);
diff --git a/functions/src/agent_persona_sampling.ts b/functions/src/agent_persona_sampling.ts
new file mode 100644
index 000000000..9585d0596
--- /dev/null
+++ b/functions/src/agent_persona_sampling.ts
@@ -0,0 +1,461 @@
+import {PersonaGenerationParams} from '@deliberation-lab/utils';
+
+/**
+ * Persona parameter sampling for AI simulacra generation.
+ *
+ * All sampling logic lives here, separate from the Firebase endpoint handler.
+ * Call samplePersonaParams() to get a fully populated PersonaGenerationParams
+ * ready to pass to buildGeneratePersonaPrompt / buildMergePersonaPrompt.
+ *
+ * Research basis for distributions:
+ * - Difallah et al. 2018: MTurk workers are ~60% female, heavily 25–44
+ * - Peer et al. 2017: Prolific skews 18–35, more educated than MTurk
+ * - Ipeirotis 2010: MTurk modal age 25–34, ~10% over 50
+ * - Pew Research 2023: internet penetration drops steeply after 65
+ * - US Census 2020: race/ethnicity proportions
+ * - BLS OES 2023: occupation sector employment shares
+ */
+
+// ============================================================================
+// QUASI-RANDOM SAMPLING (Halton sequence)
+//
+// Big Five traits are sampled via a Halton low-discrepancy sequence rather than
+// independent Math.random() calls. Independent draws have no memory between
+// calls, so N generates can accidentally cluster — e.g. three agents all
+// scoring 5-7 on every trait. The Halton sequence is deterministic: each new
+// index fills the *largest gap* left by prior samples, so any contiguous slice
+// of the sequence is evenly spread across [0, 1).
+//
+// Each trait uses a different prime base so the five dimensions are
+// mathematically uncorrelated — coprime bases are guaranteed incommensurable,
+// meaning no two traits will accidentally move in lockstep.
+//
+// Motivation: Paglieri et al. 2026 (arXiv:2602.03545) found that quasi-random
+// Monte Carlo sampling in Stage 1 of their persona generator consistently
+// outperformed independent random draws on all six diversity metrics.
+// Sequence math: Halton 1960 (Numerische Mathematik 2, 84–90).
+// ============================================================================
+
+/**
+ * Returns the n-th term of the Halton sequence in the given base.
+ * Result is in [0, 1). n=0 returns 0; n=1 returns 1/base; etc.
+ */
+function halton(n: number, base: number): number {
+ let result = 0;
+ let f = 1;
+ let i = n;
+ while (i > 0) {
+ f /= base;
+ result += f * (i % base);
+ i = Math.floor(i / base);
+ }
+ return result;
+}
+
+/**
+ * Maps a Halton value to a Big Five score string on the 1–10 scale.
+ * halton() returns [0, 1), so Math.floor(h * 10) gives 0–9; +1 → 1–10.
+ */
+function haltonBig5Score(n: number, base: number): string {
+ const score = Math.floor(halton(n, base) * 10) + 1;
+ return `${score}/10`;
+}
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+interface WeightedItem {
+ label: string;
+ weight: number;
+}
+
+interface Archetype {
+ label: string;
+ ageMin: number;
+ ageMax: number;
+ educationWeights: WeightedItem[];
+ settingWeights: WeightedItem[];
+}
+
+// ============================================================================
+// HELPER
+// ============================================================================
+
+/** Weighted random pick. Falls back to last item if weights don't sum to 1. */
+function weightedPick(items: WeightedItem[]): string {
+ const r = Math.random();
+ let cumulative = 0;
+ for (const item of items) {
+ cumulative += item.weight;
+ if (r < cumulative) return item.label;
+ }
+ return items[items.length - 1].label;
+}
+
+// ============================================================================
+// LIFE-STAGE ARCHETYPES
+// Crowd-worker calibrated: College student 20%, Young professional 35%,
+// Mid-career 30%, Older adult 15%.
+// Age, education, and setting are drawn jointly to avoid incoherent combos
+// (e.g. 18-year-olds with PhDs, 80-year-olds on Prolific).
+// ============================================================================
+
+const ARCHETYPES: {archetype: Archetype; weight: number}[] = [
+ {
+ weight: 0.2,
+ archetype: {
+ label: 'College student / early adult',
+ ageMin: 18,
+ ageMax: 24,
+ educationWeights: [
+ {label: 'High school diploma', weight: 0.2},
+ {label: "Some college or Associate's degree", weight: 0.5},
+ {label: "Bachelor's degree", weight: 0.3},
+ ],
+ settingWeights: [
+ {label: 'Urban', weight: 0.45},
+ {label: 'Suburban', weight: 0.35},
+ {label: 'Rural', weight: 0.15},
+ {label: 'Remote/Isolated', weight: 0.05},
+ ],
+ },
+ },
+ {
+ weight: 0.35,
+ archetype: {
+ label: 'Young professional / gig worker',
+ ageMin: 25,
+ ageMax: 38,
+ educationWeights: [
+ {label: 'High school diploma', weight: 0.1},
+ {label: "Some college or Associate's degree", weight: 0.25},
+ {label: "Bachelor's degree", weight: 0.45},
+ {label: "Master's degree", weight: 0.18},
+ {label: 'Doctorate or Professional degree', weight: 0.02},
+ ],
+ settingWeights: [
+ {label: 'Urban', weight: 0.4},
+ {label: 'Suburban', weight: 0.4},
+ {label: 'Rural', weight: 0.12},
+ {label: 'Remote/Isolated', weight: 0.08},
+ ],
+ },
+ },
+ {
+ weight: 0.3,
+ archetype: {
+ label: 'Mid-career / family stage',
+ ageMin: 39,
+ ageMax: 55,
+ educationWeights: [
+ {label: 'No high school diploma', weight: 0.05},
+ {label: 'High school diploma', weight: 0.2},
+ {label: "Some college or Associate's degree", weight: 0.3},
+ {label: "Bachelor's degree", weight: 0.3},
+ {label: "Master's degree", weight: 0.12},
+ {label: 'Doctorate or Professional degree', weight: 0.03},
+ ],
+ settingWeights: [
+ {label: 'Urban', weight: 0.25},
+ {label: 'Suburban', weight: 0.5},
+ {label: 'Rural', weight: 0.2},
+ {label: 'Remote/Isolated', weight: 0.05},
+ ],
+ },
+ },
+ {
+ weight: 0.15,
+ archetype: {
+ label: 'Older adult / pre-retirement',
+ ageMin: 56,
+ ageMax: 75,
+ educationWeights: [
+ {label: 'No high school diploma', weight: 0.08},
+ {label: 'High school diploma', weight: 0.3},
+ {label: "Some college or Associate's degree", weight: 0.3},
+ {label: "Bachelor's degree", weight: 0.22},
+ {label: "Master's degree", weight: 0.08},
+ {label: 'Doctorate or Professional degree', weight: 0.02},
+ ],
+ settingWeights: [
+ {label: 'Urban', weight: 0.2},
+ {label: 'Suburban', weight: 0.45},
+ {label: 'Rural', weight: 0.3},
+ {label: 'Remote/Isolated', weight: 0.05},
+ ],
+ },
+ },
+];
+
+// ============================================================================
+// CULTURAL BACKGROUNDS
+// US Census 2020 race/ethnicity proportions.
+// Used IMPLICITLY: shapes name choice and family/community context.
+// The label is never stated in the output persona text.
+// ~45 distinct backgrounds → ~45× name-space expansion.
+// ============================================================================
+
+const CULTURAL_BACKGROUNDS: WeightedItem[] = [
+ // White non-Hispanic (~58%)
+ {label: 'Generic/mixed white American', weight: 0.09},
+ {label: 'German-American', weight: 0.08},
+ {label: 'Irish-American', weight: 0.07},
+ {label: 'English/British-American', weight: 0.06},
+ {label: 'Appalachian/Scots-Irish', weight: 0.04},
+ {
+ label: 'Scandinavian-American (Norwegian, Swedish, Danish, or Finnish)',
+ weight: 0.04,
+ },
+ {label: 'Italian-American', weight: 0.04},
+ {label: 'French-American / Cajun-Creole', weight: 0.025},
+ {label: 'Jewish (Ashkenazi)', weight: 0.02},
+ {label: 'Polish-American', weight: 0.02},
+ {label: 'Czech or Slovak-American', weight: 0.01},
+ {label: 'Russian or Ukrainian-American', weight: 0.01},
+ {label: 'Portuguese-American', weight: 0.008},
+ {label: 'Greek-American', weight: 0.007},
+ {label: 'Dutch-American', weight: 0.005},
+ {label: 'Armenian-American', weight: 0.003},
+ // Hispanic/Latino (~19%)
+ {label: 'Mexican-American', weight: 0.11},
+ {label: 'Puerto Rican', weight: 0.015},
+ {label: 'Other Latin American', weight: 0.015},
+ {label: 'Cuban-American', weight: 0.007},
+ {label: 'Salvadoran-American', weight: 0.007},
+ {label: 'Dominican', weight: 0.006},
+ {label: 'Colombian-American', weight: 0.005},
+ {label: 'Guatemalan-American', weight: 0.005},
+ {label: 'Honduran-American', weight: 0.003},
+ {label: 'Venezuelan-American', weight: 0.003},
+ // Black/African American (~12%)
+ {label: 'African American (multi-generational, US South)', weight: 0.05},
+ {label: 'African American (Northern or Midwest urban)', weight: 0.03},
+ {label: 'Nigerian-American', weight: 0.006},
+ {label: 'Haitian-American', weight: 0.005},
+ {label: 'Jamaican or Caribbean-American', weight: 0.005},
+ {label: 'Ethiopian-American', weight: 0.004},
+ {label: 'Ghanaian-American', weight: 0.003},
+ {label: 'Somali-American', weight: 0.002},
+ {label: 'Other Afro-Caribbean', weight: 0.003},
+ {label: 'Other African', weight: 0.003},
+ // Asian (~6%)
+ {label: 'Chinese-American', weight: 0.015},
+ {label: 'Indian-American', weight: 0.013},
+ {label: 'Filipino-American', weight: 0.009},
+ {label: 'Vietnamese-American', weight: 0.006},
+ {label: 'Korean-American', weight: 0.005},
+ {label: 'Japanese-American', weight: 0.004},
+ {label: 'Pakistani-American', weight: 0.002},
+ {label: 'Hmong-American', weight: 0.001},
+ {label: 'Cambodian-American', weight: 0.001},
+ {label: 'Thai-American', weight: 0.001},
+ // Other (~5%)
+ {label: 'Mixed or multiracial', weight: 0.03},
+ {
+ label: 'Arab-American (Lebanese, Syrian, Egyptian, Yemeni, or other)',
+ weight: 0.006,
+ },
+ {label: 'Native American or Alaska Native', weight: 0.006},
+ {label: 'Iranian-American', weight: 0.002},
+ {label: 'Pacific Islander-American', weight: 0.002},
+ {label: 'Turkish-American', weight: 0.001},
+ {label: 'Afghan-American', weight: 0.001},
+];
+
+// ============================================================================
+// OCCUPATION SECTORS
+// BLS OES 2023 employment shares.
+// LLM picks a specific, non-generic job title within the sector
+// (e.g. "wound care nurse at a VA hospital" not "works in healthcare").
+// ~22 sectors.
+// ============================================================================
+
+const OCCUPATION_SECTORS: WeightedItem[] = [
+ {label: 'Office & administrative support', weight: 0.13},
+ {label: 'Retail & customer service', weight: 0.11},
+ {label: 'Food service & hospitality', weight: 0.09},
+ {label: 'Education (K-12, higher ed, or tutoring)', weight: 0.09},
+ {label: 'Healthcare practitioners & nursing', weight: 0.08},
+ {label: 'Management & business operations', weight: 0.08},
+ {label: 'Transportation & logistics', weight: 0.08},
+ {label: 'Manufacturing & production', weight: 0.07},
+ {label: 'Healthcare support & aides', weight: 0.06},
+ {label: 'Construction & skilled trades', weight: 0.05},
+ {label: 'Government & public safety', weight: 0.05},
+ {
+ label: 'Professional services (legal, accounting, or consulting)',
+ weight: 0.04,
+ },
+ {label: 'Technology & IT', weight: 0.04},
+ {label: 'Personal services (beauty, childcare, or cleaning)', weight: 0.04},
+ {label: 'Finance & insurance', weight: 0.03},
+ {label: 'Self-employed, freelance, or gig work', weight: 0.03},
+ {
+ label:
+ 'Not currently employed (student, full-time caregiver, or job-seeking)',
+ weight: 0.03,
+ },
+ {label: 'Social services & nonprofits', weight: 0.02},
+ {label: 'Arts, entertainment & media', weight: 0.02},
+ {label: 'Agriculture, farming, forestry, or fishing', weight: 0.015},
+ {label: 'Real estate & property management', weight: 0.015},
+ {label: 'Military or recently separated veteran', weight: 0.005},
+];
+
+// ============================================================================
+// LIFE CIRCUMSTANCES
+// Approximate Pew/Gallup/Census prevalence.
+// One concrete current-life fact — makes the persona "present" and grounded.
+// ~30 circumstances.
+// ============================================================================
+
+const LIFE_CIRCUMSTANCES: WeightedItem[] = [
+ {label: 'Dealing with significant financial stress or debt', weight: 0.08},
+ {label: 'Raising young children (under 10)', weight: 0.08},
+ {label: 'Struggling with a difficult boss or coworker', weight: 0.05},
+ {label: 'Caring for an aging or sick parent', weight: 0.05},
+ {label: 'Active in a religious or faith community', weight: 0.05},
+ {
+ label: 'Dealing with housing cost pressure (rent or mortgage)',
+ weight: 0.04,
+ },
+ {label: 'Recently changed jobs or careers', weight: 0.04},
+ {
+ label: 'Trying to get healthier (fitness, diet, or quitting something)',
+ weight: 0.04,
+ },
+ {label: 'Considering a significant career change', weight: 0.04},
+ {label: 'Recently moved to a new city or neighborhood', weight: 0.04},
+ {label: 'Managing a chronic health condition', weight: 0.04},
+ {
+ label: 'Newly in a long-term relationship or recently married',
+ weight: 0.03,
+ },
+ {label: 'Dealing with a difficult divorce or separation', weight: 0.03},
+ {label: 'Recently promoted or given new responsibilities', weight: 0.03},
+ {
+ label: 'Planning a major purchase (house, car, or renovation)',
+ weight: 0.03,
+ },
+ {label: 'Going back to school or taking classes part-time', weight: 0.03},
+ {label: 'Recovering from an injury or medical procedure', weight: 0.03},
+ {label: 'Caring for a sick or disabled family member', weight: 0.03},
+ {label: 'Recently lost a close family member', weight: 0.02},
+ {label: 'Living alone for the first time', weight: 0.02},
+ {label: 'Newly empty-nester (children just left home)', weight: 0.02},
+ {label: 'Recently laid off or let go', weight: 0.02},
+ {label: 'Planning retirement in the next few years', weight: 0.02},
+ {label: 'Dealing with social isolation or loneliness', weight: 0.02},
+ {label: 'Just had a first child', weight: 0.015},
+ {
+ label: 'Recently got a significant raise or financial windfall',
+ weight: 0.015,
+ },
+ {
+ label: 'Deeply invested in a personal hobby or community project',
+ weight: 0.015,
+ },
+ {label: 'Going through a major spiritual or values shift', weight: 0.01},
+ {label: 'Navigating an immigration or citizenship process', weight: 0.01},
+ {label: 'Recovering from addiction', weight: 0.01},
+];
+
+// ============================================================================
+// PUBLIC API
+// ============================================================================
+
+/**
+ * Samples a fully populated PersonaGenerationParams object.
+ *
+ * Demographic dimensions (age, education, setting) are drawn jointly from a
+ * life-stage archetype to avoid incoherent combinations. Cultural background,
+ * occupation sector, and life circumstance are sampled independently from
+ * census/BLS-proportional distributions to maximize name and persona diversity.
+ *
+ * @param batchIndex - Optional. Pass 0, 1, 2, ... for successive generates
+ * within a session. Used as the Halton sequence index for Big Five sampling,
+ * so each new agent fills the biggest gap in the joint trait space left by
+ * previous ones. Omit for a random offset (good for isolated single calls).
+ */
+export function samplePersonaParams(
+ batchIndex?: number,
+): PersonaGenerationParams {
+ // Halton index: sequential when batchIndex is provided (guarantees spread
+ // across a session); random prime-offset fallback for isolated calls.
+ // 997 is prime — avoids period aliasing with the sequence bases.
+ const haltonIndex = batchIndex ?? Math.floor(Math.random() * 997);
+ // 1. Pick life-stage archetype (correlated: age + education + setting)
+ const archetypeRand = Math.random();
+ let cumulative = 0;
+ let selectedArchetype = ARCHETYPES[0].archetype;
+ for (const {weight, archetype} of ARCHETYPES) {
+ cumulative += weight;
+ if (archetypeRand < cumulative) {
+ selectedArchetype = archetype;
+ break;
+ }
+ }
+
+ // 2. Age — uniform within archetype range
+ const age =
+ Math.floor(
+ Math.random() * (selectedArchetype.ageMax - selectedArchetype.ageMin + 1),
+ ) + selectedArchetype.ageMin;
+
+ // 3. Pronouns — crowd worker populations skew ~55% female (Difallah 2018)
+ const pronounsRand = Math.random();
+ let pronouns = 'she/her';
+ if (pronounsRand < 0.55) {
+ pronouns = 'she/her';
+ } else if (pronounsRand < 0.97) {
+ pronouns = 'he/him';
+ } else {
+ pronouns = 'they/them';
+ }
+
+ // 4. Education — correlated with archetype
+ const education = weightedPick(selectedArchetype.educationWeights);
+
+ // 5. Setting — correlated with archetype
+ const setting = weightedPick(selectedArchetype.settingWeights);
+
+ // 6. Big Five — quasi-random Halton sampling (Halton 1960; Paglieri et al. 2026)
+ // Prime bases keep the five dimensions mathematically uncorrelated.
+ // Openness: base 2
+ // Conscientiousness: base 3
+ // Extraversion: base 5
+ // Agreeableness: base 7
+ // Neuroticism: base 11
+ const big5 = {
+ openness: haltonBig5Score(haltonIndex, 2),
+ conscientiousness: haltonBig5Score(haltonIndex, 3),
+ extraversion: haltonBig5Score(haltonIndex, 5),
+ agreeableness: haltonBig5Score(haltonIndex, 7),
+ neuroticism: haltonBig5Score(haltonIndex, 11),
+ };
+
+ // 7. Verbosity — 1–5, uniform
+ const verbosity = Math.ceil(Math.random() * 5);
+
+ // 8. Cultural background — US Census 2020 proportional (implicit in output)
+ const culturalBackground = weightedPick(CULTURAL_BACKGROUNDS);
+
+ // 9. Occupation sector — BLS OES 2023 proportional
+ const occupationSector = weightedPick(OCCUPATION_SECTORS);
+
+ // 10. Life circumstance — Pew/Gallup/Census approximate prevalence
+ const lifeCircumstance = weightedPick(LIFE_CIRCUMSTANCES);
+
+ return {
+ age,
+ pronouns,
+ education,
+ setting,
+ big5,
+ verbosity,
+ culturalBackground,
+ occupationSector,
+ lifeCircumstance,
+ };
+}
diff --git a/utils/src/agent_persona_generation.ts b/utils/src/agent_persona_generation.ts
new file mode 100644
index 000000000..d857dbc0f
--- /dev/null
+++ b/utils/src/agent_persona_generation.ts
@@ -0,0 +1,264 @@
+import {AgentModelSettings} from './agent';
+
+/** Types and prompt builders for AI-powered persona generation. */
+
+// ************************************************************************* //
+// TYPES //
+// ************************************************************************* //
+
+export type PersonaGenerationMode = 'generate' | 'enhance' | 'refresh';
+
+export interface PersonaGenerationRequest {
+ mode: PersonaGenerationMode;
+ currentText: string; // empty string for fresh generate
+ modelSettings: AgentModelSettings;
+}
+
+export interface PersonaGenerationResult {
+ text: string; // new text only (frontend appends for enhance)
+}
+
+// ************************************************************************* //
+// PROMPT BUILDERS //
+// ************************************************************************* //
+
+/**
+ * Parameters randomly sampled by the backend for each Generate call.
+ * Injected into the prompt to force structural diversity across calls.
+ */
+export interface PersonaGenerationParams {
+ age: number;
+ pronouns: string;
+ education: string;
+ setting: string;
+ verbosity: number; // 1 (terse) to 5 (verbose)
+ big5: {
+ openness: string;
+ conscientiousness: string;
+ extraversion: string;
+ agreeableness: string;
+ neuroticism: string;
+ };
+ // High-entropy seed dimensions sampled from census/BLS-proportional distributions.
+ // These are injected as hard constraints to break LLM name/occupation attractors.
+ culturalBackground: string; // e.g. "Irish-American", "Chinese-American"
+ occupationSector: string; // e.g. "Construction & skilled trades"
+ lifeCircumstance: string; // e.g. "Caring for an aging parent"
+}
+
+const VERBOSITY_DESCRIPTIONS: Record = {
+ 1: 'very terse on average — usually just a few words or a single short sentence, rarely explains themselves; but occasionally says more when something actually gets to them',
+ 2: 'brief on average — typically 1 to 2 sentences, gets to the point; will sometimes add a sentence when they feel strongly about something',
+ 3: 'moderate — usually 2 to 3 sentences, adds some context when it feels relevant',
+ 4: 'expansive on average — tends toward multiple sentences and elaboration, though not always',
+ 5: 'verbose on average — often writes long responses and rarely stops at the first thought, though may be brief when the moment calls for it',
+};
+
+/**
+ * The shared "Act!" meta-instruction from Concordia (DeepMind, Vezhnevets et al. 2023).
+ * Placed at the end of all persona prompts. Grounds the agent in moment-to-moment
+ * emotional state rather than purely trait-level performance — the key difference
+ * between a plausible simulacrum and an obviously LLM-like response.
+ */
+const ACT_INSTRUCTION =
+ `Finally, end the sketch with a single sentence beginning "When responding, always ask yourself:" ` +
+ `that instructs the agent to briefly consider what this specific person would actually feel or ` +
+ `think in this moment before replying — and to respond from that feeling, not from a general trait description.`;
+
+/**
+ * The shared dimensions list used in both Generate and Merge prompts.
+ *
+ * Research basis:
+ * - Park et al. 2023 (Generative Agents): dossier-style dimensions
+ * - Argyle et al. 2023 (Out of One, Many): blind spots, knowledge gaps
+ * - Concordia (DeepMind 2023): internal tensions, pushback disposition
+ */
+function buildDimensionsList(verbosity: number): string {
+ return `Cover all of the following dimensions in the sketch:
+- Who you are: name, age, occupation (must fit education level and feel realistic for the setting), location
+- Personality: how the Big Five scores manifest specifically in your behavior and reactions
+- Core values and beliefs: concrete stances on social, political, or personal issues
+- Motivations and goals: what drives you day-to-day and in interactions
+- Cognitive style: how you process information, what you know well, and your characteristic biases
+- Emotional disposition: baseline affect, what makes you anxious or angry or content
+- Social behavior: attitude toward authority, strangers, and group consensus; how quickly you warm up
+- Communication style: vocabulary, formality, and verbosity consistent with the ${verbosity}/5 level above
+- Blind spots: 1-2 specific domains or topics you have little knowledge of or no formed opinion on — things you would openly admit you "don't really know much about"
+- Internal contradiction: one unresolved tension you carry — two beliefs or desires that pull in opposite directions and that you have not reconciled
+- Pushback disposition: whether you tend to accept the framing of questions and statements from others, or tend to resist and restate things on your own terms`;
+}
+
+/**
+ * Builds the prompt for generating a fresh persona character sketch from scratch.
+ * Uses randomly sampled params to force diversity across calls.
+ *
+ * Research basis:
+ * - Park et al. 2023 (Generative Agents): dossier-style, second-person, concrete facts
+ * - Argyle et al. 2023 (Out of One, Many): demographic coherence, knowledge gaps
+ * - Concordia (DeepMind 2023): internal tensions, Act! grounding instruction
+ */
+export function buildGeneratePersonaPrompt(
+ params: PersonaGenerationParams,
+): string {
+ const birthDecade =
+ Math.floor((new Date().getFullYear() - params.age) / 10) * 10 + 's';
+
+ return `You are helping configure an AI research agent that will simulate a real human participant in an online study.
+
+You MUST write a character sketch that fits the following randomly generated profile parameters. Do not ignore them or default to a standard profile.
+
+Profile Parameters:
+- Age: ${params.age} (born in the ${birthDecade})
+- Pronouns: ${params.pronouns}
+- Education level: ${params.education}
+- Living environment: ${params.setting}
+- Cultural background: ${params.culturalBackground}
+- Occupation sector: ${params.occupationSector}
+- Current life circumstance: ${params.lifeCircumstance}
+- Verbosity when communicating: ${params.verbosity}/5 — ${VERBOSITY_DESCRIPTIONS[params.verbosity]}
+- Personality (Big Five scale 1-10):
+ - Openness to experience: ${params.big5.openness}
+ - Conscientiousness: ${params.big5.conscientiousness}
+ - Extraversion: ${params.big5.extraversion}
+ - Agreeableness: ${params.big5.agreeableness}
+ - Neuroticism: ${params.big5.neuroticism}
+
+Begin the sketch by completing this opening sentence (it becomes the first line):
+"You are [FirstName LastName], a [specific, non-generic job title within the occupation sector above] who is [2–3 adjectives]."
+
+For the name: choose a first name that feels authentic to someone of ${params.culturalBackground} background born in the ${birthDecade}. Do NOT mention the cultural background label anywhere in the sketch — let it be implicit in the name, family context, and life details.
+
+For the job: pick a specific, concrete role within "${params.occupationSector}" (e.g. not "works in healthcare" but "a wound care nurse at a VA hospital").
+
+Then continue the full character sketch in second person throughout — this text will be used verbatim as a system prompt.
+
+Write in a clear, matter-of-fact, and realistic style (like a sociological profile or background dossier). Avoid flowery prose, metaphors, or overly dramatic descriptions. Focus on concrete facts, specific attitudes, behavioral tendencies, and social dynamics that logically flow from the parameters above.
+
+${buildDimensionsList(params.verbosity)}
+
+Aim for 250–300 words. ${ACT_INSTRUCTION}
+
+CRITICAL: Your character must strictly adhere to the parameters above. Make the blind spots and internal contradiction feel genuinely specific to this person — not generic. Each sketch you write should feel completely unlike the last.
+
+Write ONLY the character sketch text, starting directly with "You are". No preamble, no labels, no meta-commentary.`;
+}
+
+/**
+ * Builds the prompt for a merge-expand Generate when there is already text.
+ * The LLM incorporates the existing text into a full ~300-word sketch.
+ */
+export function buildMergePersonaPrompt(
+ existingText: string,
+ params: PersonaGenerationParams,
+): string {
+ const birthDecade =
+ Math.floor((new Date().getFullYear() - params.age) / 10) * 10 + 's';
+
+ return `You are helping configure an AI research agent that will simulate a real human participant in an online study.
+
+The experimenter has started writing a persona for this agent:
+---
+${existingText}
+---
+
+Expand this into a complete character sketch, incorporating and staying consistent with everything already written. Do not contradict or erase any existing details. Fill in what is missing using the randomly generated profile parameters below.
+
+Profile Parameters (fill in missing dimensions only — do NOT override what is already written):
+- Age: ${params.age} (born in the ${birthDecade})
+- Pronouns: ${params.pronouns}
+- Education level: ${params.education}
+- Living environment: ${params.setting}
+- Cultural background: ${params.culturalBackground}
+- Occupation sector: ${params.occupationSector}
+- Current life circumstance: ${params.lifeCircumstance}
+- Verbosity when communicating: ${params.verbosity}/5 — ${VERBOSITY_DESCRIPTIONS[params.verbosity]}
+- Personality (Big Five scale 1-10):
+ - Openness to experience: ${params.big5.openness}
+ - Conscientiousness: ${params.big5.conscientiousness}
+ - Extraversion: ${params.big5.extraversion}
+ - Agreeableness: ${params.big5.agreeableness}
+ - Neuroticism: ${params.big5.neuroticism}
+
+If the existing text doesn't already have a full name and specific job title, begin the expanded sketch with:
+"You are [FirstName LastName], a [specific job title within the occupation sector above] who is [2–3 adjectives]."
+For the name: choose one authentic to someone of ${params.culturalBackground} background born in the ${birthDecade} — but do NOT mention the cultural background label in the text.
+For the job: be specific (e.g. "a night auditor at a Marriott" not "works in hospitality").
+
+Write the full resulting character sketch in second person ("You are...").
+
+${buildDimensionsList(params.verbosity)}
+
+Aim for 250–300 words. ${ACT_INSTRUCTION}
+
+Write ONLY the character sketch text, starting directly with "You are". No preamble, no labels, no meta-commentary.`;
+}
+
+/**
+ * Builds the prompt for enhancing an existing persona.
+ *
+ * Randomly rotates between four enhancement modes to maximize diversity across
+ * multiple Enhance presses on the same persona:
+ * memory — episodic/formative memory (Park et al. 2023, Concordia)
+ * trigger — hot-button emotional trigger (Concordia)
+ * preoccupation — current active concern (Generative Agents daily planning)
+ * private — private-vs-public self (Concordia internal state)
+ *
+ * @param currentText The existing persona sketch.
+ * @param mode Optional override; randomly chosen if omitted.
+ */
+export function buildEnhancePersonaPrompt(
+ currentText: string,
+ mode?: 'memory' | 'trigger' | 'preoccupation' | 'private',
+): string {
+ const modes = ['memory', 'trigger', 'preoccupation', 'private'] as const;
+ const selectedMode = mode ?? modes[Math.floor(Math.random() * modes.length)];
+
+ const instructions: Record = {
+ memory: `Add 1-2 short, specific episodic memories or formative past experiences to this persona. These should be concrete past events — things this specific person lived through — that shaped who they are today. They give the agent "memories" to draw on during conversation.
+
+Examples:
+- "You once filed a formal complaint against your supervisor for skipping a safety protocol. Nothing came of it."
+- "You spent three years as the primary caregiver for your mother after her stroke. She passed in 2019."
+- "You dropped out of a graduate program after the first semester when the funding fell through."`,
+
+ trigger: `Add 1 specific hot-button trigger to this persona — a topic, phrase, or situation that provokes a stronger emotional reaction than this person's baseline style would suggest. It should feel personal and grounded in their history, not generic.
+
+Examples:
+- "When someone describes poor planning as 'just being spontaneous,' you feel genuine irritation you struggle to hide."
+- "You find public displays of religious faith in professional settings deeply uncomfortable, though you would not say so directly."
+- "Discussions of inherited wealth or family money make you short-tempered in a way you are aware of but can't fully control."`,
+
+ preoccupation: `Add 1 current preoccupation to this persona — something concrete that is on this person's mind right now, this week. Not a general trait or long-term goal, but a specific active concern that might intrude into unrelated conversations.
+
+Examples:
+- "Your landlord has not responded to two messages about a leak in the ceiling, and you are deciding whether to escalate."
+- "You are waiting to hear back about a job application you submitted eleven days ago."
+- "You had a disagreement with your sister last week that you keep replaying."`,
+
+ private: `Add 1-2 sentences describing what this person privately thinks or feels but typically does not say out loud. This shapes what they volunteer vs. withhold in conversation.
+
+Examples:
+- "You privately find most group conversations tedious but have learned to perform interest well enough."
+- "You believe your coworker got the promotion because of a personal connection, not merit, but you have never said this to anyone."
+- "You think you made a mistake staying in this city but have never admitted it, even to yourself clearly."`,
+ };
+
+ return `You are helping refine an AI agent's persona. Here is its current character sketch:
+
+---
+${currentText}
+---
+
+${instructions[selectedMode]}
+
+Rules:
+1. Before writing anything: read the full sketch above and identify what's already there. Do NOT add anything already present — even if phrased differently. If the sketch already has a memory, add a different memory. If it already has a trigger, add something else.
+2. Never contradict anything already stated.
+3. Keep the exact same second-person voice ("You...").
+4. Be specific and concrete — grounded in this specific person, not a generic human.
+5. Keep it brief — aim for 30–60 words total.
+6. Write plainly. No metaphors, no flowery language.
+7. Do not use headers or bullet points.
+
+Return ONLY the new sentences. Do not repeat or include the original text.`;
+}
diff --git a/utils/src/index.ts b/utils/src/index.ts
index 397dff758..6c9a68d73 100644
--- a/utils/src/index.ts
+++ b/utils/src/index.ts
@@ -54,6 +54,7 @@ export * from './profile_sets';
// TODO: Organize these files into 'agent' subdirectory
export * from './agent';
export * from './agent.validation';
+export * from './agent_persona_generation';
export * from './model_config';
export * from './model_response';
export * from './prompt.validation';
diff --git a/utils/src/stages/chat_stage.prompts.ts b/utils/src/stages/chat_stage.prompts.ts
index d97e3afcf..993081e2d 100644
--- a/utils/src/stages/chat_stage.prompts.ts
+++ b/utils/src/stages/chat_stage.prompts.ts
@@ -40,7 +40,20 @@ export const DEFAULT_MEDIATOR_GROUP_CHAT_PROMPT_INSTRUCTIONS = `Follow any perso
export const DEFAULT_AGENT_PRIVATE_MEDIATOR_CHAT_PROMPT = `You are an agent who is chatting with a participant. Your task is to ensure that the participant's questions are answered.`;
/** Participant prompt text for group chat. */
-export const DEFAULT_AGENT_PARTICIPANT_CHAT_PROMPT = `Decide if your human persona would respond at this point in the live conversation. If yes, give a natural response that fits the persona and any earlier style rules. If no style rules exist, default to a short 1–2 sentence online-style message. If they would not respond, stay silent. Stay in character.`;
+export const DEFAULT_AGENT_PARTICIPANT_CHAT_PROMPT = `You are participating in a live group chat as your persona.
+
+First, react: read the last 1-2 messages and ask yourself how this specific person would feel in this moment. Are they amused? Annoyed? Curious? Uncertain? Let that reaction drive your response.
+
+Then decide whether to actually send a message. Not every message in a group chat deserves a reply — sometimes you'd scroll past. If yes, write it. If no, stay silent.
+
+Rules for how to write your message:
+- Let your persona determine how you open. Some people jump straight to their opinion; others react to what was just said first ("yeah but—", "wait, really?", "I mean, kind of"). Don't default to a thesis-statement opener just because it's the easiest thing to write — ask what *this person* would actually do.
+- Keep it short: 1-3 sentences as a default. Never write a paragraph. Real people in group chats don't write essays.
+- Verbosity is a tendency, not a rule. A terse persona is usually brief — but if something genuinely provokes them, they say more. A verbose persona usually elaborates — but sometimes a short reaction is all they have. Let the moment determine it.
+- Express your personality through word choice and content, not through formal sentence structure. Even a highly educated or pedantic persona types in chat voice, not essay voice. A pedant in a group chat writes "honestly not that hard to keep your inbox organized" — not "it is a profound failure of personal discipline."
+- Sound like your persona, not like an AI assistant. Use your persona's speech patterns, vocabulary level, and tone. Fragments and low-substance replies are valid when they fit the persona.
+- Match your persona's conversational style: some people always have something substantive to add, others often just signal agreement or confusion. Not every response needs to make a new point — but only write a low-substance reply if that genuinely fits how your persona communicates.
+- Stay in character throughout. Do not summarize, explain your reasoning, or step outside the persona.`;
/** Hardcoded text used in stage display of chat transcript. */
export const CHAT_PROMPT_TRANSCRIPT_EXPLANATION = `Below is the transcript of your discussion. Messages are shown in chronological order; new messages appear at the bottom. Each message / turn follows the format: (HH:MM) Name: message.`;
diff --git a/utils/src/stages/profile_stage.prompts.ts b/utils/src/stages/profile_stage.prompts.ts
index de9dbe505..1fefe7e19 100644
--- a/utils/src/stages/profile_stage.prompts.ts
+++ b/utils/src/stages/profile_stage.prompts.ts
@@ -28,7 +28,7 @@ export function createProfileStructuredOutputConfig(profileType: ProfileType) {
name: 'name',
schema: {
type: StructuredOutputDataType.STRING,
- description: 'Your name',
+ description: 'Your first name only (not your full name)',
},
},
{