Constructs the agents, moderator guide, and turn-taking flow, then runs one
focus group session. config has no default; every agent and group-level
call uses the one supplied. Before a live run, the function estimates the
number of generated model outputs and applies the max_calls limit unless
confirm is TRUE.
Usage
run_focus_group(
topic,
config,
n_participants = 6,
guide = c(Opening = 1, Icebreaker = 1, Engagement = 2, Exploration = 3, Closing = 1),
demographics = NULL,
survey_responses = NULL,
flow = "desire_based",
seed = NULL,
message_mode = c("roleflip", "flat"),
verbose = TRUE,
max_participant_responses = NULL,
max_calls = 120L,
confirm = FALSE,
max_tokens_config = NULL,
reasoning_effort = NULL,
on_desire_failure = c("error", "random"),
injections = NULL,
probes = NULL,
moderator_verbatim = FALSE,
call_on_silent = c("opening", "midpoint", "end"),
call_on_text = NULL,
handles = NULL,
final_summary = TRUE,
.runner = NULL
)Arguments
- topic
Character. Focus group topic.
- config
An explicit
LLMR::llm_configused by all agents and group-level model tasks.- n_participants
Integer. Number of participants, excluding the moderator.
- guide
A named numeric vector or named list. The names are phases, among
"Opening","Icebreaker","Engagement","Exploration", and"Closing", each at most once and in that order. Numeric values select that many moderator instructions from the phase banks. Character vectors supply the ordered instructions directly. The default is a compact session (one opening, one icebreaker, two engagement and three exploration questions, one closing); with the default flow, participants, response limit, and call-ons it plans at most 111 model outputs, inside the defaultmax_callsof 120. Larger guides multiply quickly: each question round costs one output per response plus, under"desire_based", one scoring output per participant.NULLruns a minimal script: an opening, one open discussion oftopic, and a closing.- demographics
Optional participant demographics, one row per participant. With this,
survey_responses, and personas all absent, the participants are drawn from the bundled ANES 2024 records.- survey_responses
Optional participant survey answers, one row per participant.
- flow
Character. One of
"round_robin","probabilistic", or"desire_based".- seed
Optional whole number (a fraction such as 1.7 is an error) governing in-package sampling: the respondents drawn from the bundled records and the random choices of the turn-taking flow, such as the speaker draws of
"probabilistic"and the tie-breaks of"desire_based". The random waits of a provider's retries do not draw from this stream. The caller's random-number state is restored when the function returns. The optionfocusgroup.seed, when set, fixes the respondent draw instead.- message_mode
Character.
"roleflip"or"flat".- verbose
Logical. Print session progress.
- max_participant_responses
Optional integer maximum number of participant responses per moderator question.
NULL, the default, uses the optionfocusgroup.max_participant_responses, which is 2 unless you set it.- max_calls
Integer. Maximum estimated live model outputs allowed without confirmation; a finite whole number.
- confirm
Logical. Permit a live run whose estimate exceeds
max_calls.- max_tokens_config
Optional named list of token limits by call type, as for
FocusGroup$new():utterance,moderator,desire,desire_retry,summary, andinterim_summary. A model that reasons before it answers needsdesireanddesire_retrylarge enough to hold its reasoning and the score.- reasoning_effort
Optional reasoning-effort setting passed to the provider: one string, such as
"low", for every call, or a named vector by call type ("moderator","participant","desire","summary","probe"). It is written over thereasoning_effortofconfigfor those calls;NULLleavesconfigas it is. LLMR forwards it on OpenAI-compatible providers.- on_desire_failure
Character. Under
"desire_based", what happens when a participant's desire score cannot be obtained after its retry."error"(the default) stops the session with an error of classfocusgroup_desire_failure, returned as a partial session as described in Details."random"chooses the speaker among the participants with valid scores by the usual rule, and uniformly among the eligible participants only when none has a valid score; the failure is recorded in the selected message'smetadata$desire_scoring_failure.- injections
Optional
fg_injection()or list of injections, each placed into the session at its point. To compare sessions that share everything before an injection, userun_focus_group_arms().- probes
Optional
fg_probe()or list of probes, private questions asked of each participant at stated points. The answers are returned inprobesand never enter the discussion.- moderator_verbatim
FALSE(the default) has the moderator's model render each guide item as a line of its own.TRUEdelivers every guide item exactly as written, with no model call, and a vector of item numbers delivers those items so. A verbatim message carriesmetadata$verbatim = TRUE. The opening and closing instructions of the numeric phase banks are instructions to the moderator, so verbatim delivery suits a guide written as the lines to be said.- call_on_silent
Where the moderator calls on the participants who have not spoken since the previous such point, as described in Details: any of
"opening","midpoint", and"end"(the default is all three), guide item numbers, or"none"(orFALSE) for no call-ons.- call_on_text
The call-on line. It must contain
{participant}, which is replaced by the participant's handle;NULLuses "{participant}, I'd like to hear from you as well. What are your thoughts?".- handles
The labels under which the agents see each other:
NULL(the default) for"P1","P2", ... and"Moderator","ids"for the agent IDs, or a character vector of handles named by agent ID, as forFocusGroup$new().- final_summary
Logical.
TRUE(the default) ends the session with a model-written summary;FALSEmakes no summary call and leavessummaryempty.- .runner
Optional function used instead of live model calls. It receives a data frame with
configandmessageslist-columns and returns those rows with at leastresponse_text. Token columns such assent_tokens,rec_tokens, andtotal_tokensare optional; when they are absent, usage is unknown.
Value
A focus_group_result, a list with these components:
- focus_group
The
FocusGroupobject that ran the session. It keeps the model configuration, so model analyses can be run on it later.- transcript
A data frame with one row per logged message, starting with the
"System"message that lists the roster. Its columns aremessage_id,round,item(the guide item the message belongs to, 0 for the roster row),speaker_id(the agent ID),handle(the label the agents saw),is_moderator,text,phase,response_id,finish_reason,sent_tokens,rec_tokens,total_tokens,duration_s,provider,model,timestamp(in UTC), and the list-columnmetadata, which marks, among others, delivered lines (verbatim), injected messages (injected,injection), call-on lines (call_on, a list ofpoint,participant, andhandle), the replies to them (called_on), replies to a moderator line that addressed the speaker (addressed), replies whose self-label was removed (self_label_removed), replies whose opening self-introduction was removed (preamble_removed), and injected participant turns (as_turn; seefg_injection()).- summary
The final summary. It is
NAwhen the final summary call failed, andcharacter(0)in a partial result that stopped before it or in a session run withfinal_summary = FALSE.- participants
A data frame with one row per agent:
id,handle,role,is_moderator,persona,communication_style(the agent's style instruction),demographics,survey_responses,provider, andmodel. When the participants were drawn from the bundled ANES records, the rows drawn are recorded inattr(result$focus_group$agents, "source_rows").- usage
A list of the token subtotals
sent,rec, andtotal, and the countunknowndescribed in Details. It records the session as run; model analyses run later add to the counters offocus_groupbut not to this list.- metadata
A list with
topic,purpose,flow,message_mode,n_participants,estimated_calls,provider,model,complete(TRUEfor a finished session,FALSEfor a partial one), andfailure(NULL, or a list with thephaseandmessageof the error that stopped the session); then the conditions of the run:max_participant_responses,seed(NAwhen none was given),on_desire_failure,reasoning_effort,max_tokens(the token limit of each call type),call_settings(a data frame giving, for each call type and agent, the provider, model, and model parameters the calls start from),prompt_templates,standing_rules(the system-message rules for participants and the moderator),disclosure,versions(of FocusGroup, LLMR, and R),started, andbranch(NULL, or for a session run byrun_focus_group_arms()its arm and branch point),handles(a data frame ofagent_idandhandle),call_on_silent(a list of thepointsand the guideitemsthey fall on, both empty when call-ons are off),call_on_text,final_summary,guide(the guide items as the session ran them, each a list withphaseand, when set,textandverbatim), andflow_params(the parameters of the turn-taking rule:initial_propensitiesandrecovery_incrementfor"probabilistic",min_desire_thresholdandon_desire_failurefor"desire_based", none otherwise). It holds no configuration object.- calls
A data frame with one row per model call the session made, in order, including retries and failed calls:
call_id,call_type("moderator","participant","desire","summary","interim_summary", or"probe"),agent_id,attempt(2 for a retry),round,phase,message_id(the message the call produced, if any),provider,model, the list-columnssettings(the model parameters sent) andmessages(the messages sent),raw_text(the reply before any cleaning),thinking(reasoning the provider returned separately),cleaned_text,score(for desire calls),finish_reason,response_id,sent_tokens,rec_tokens,total_tokens,reasoning_tokens,duration_s,error(the error a failed call raised),timestamp, andarm(the arm of a session continued byrun_focus_group_arms(), otherwiseNA). Only scalar sampling and length parameters entersettings; keys and endpoint addresses never do.- routing
A data frame with one row per speaker selection:
pick,round,phase,turn(the participant turn it chose for),flow,rule(such as"rotation","propensity_draw","highest_score","threshold_lowered","highest_below_threshold","uniform_after_failure", or, for turns the flow did not choose,"called_on","addressed", and"injected"),selected_id,message_id,trigger_message_id(for"called_on"and"addressed", the moderator line that gave the turn; otherwiseNA),threshold,tie_break("none","random", or"draw"),scoring_mode, and the list-columnscandidates,excluded(named by participant, giving the reason, such as"previous_speaker"or"score_failed"),scores(desire scores, or selection probabilities for"probabilistic"), andtied, followed byarm.- probes
A data frame with one row per probe answer:
probe(its label),point,agent_id,question,answer(the final answer, orNAwhen none came),value(the number read from the answer for a probe with ascale, otherwiseNA; seefg_probe()),raw_text,finish_reason,attempts(the calls made for the answer, retries and the re-ask included),call_id(the row incallsof the call that gave the answer),round,phase,turn(the next participant turn),after_message_id(the last message before it), andarm.- injections
A data frame with one row per injection:
label,arm,point,delivered(FALSEfor one whose point the session never reached, which is also reported with a warning),message_id,speaker_id,from,visibility,text,cut_after_message_id(the message it follows),round,phase,turn,as_turn(TRUEfor a participant's injected turn), andreplaced(for such a turn, the turn it took the place of:"flow","addressed", or"called_on";"none"for a line from someone other than the participant called on, who still answers next).
Results saved by FocusGroup 0.5.2 and earlier lack usage$unknown,
metadata$complete, and metadata$failure; results saved by 0.5.3 and
earlier lack calls, routing, probes, injections, and the metadata
that follows failure; results saved by 0.5.4 and earlier lack the
item and handle columns, trigger_message_id, and the metadata that
follows branch; results saved by 0.5.5 and earlier lack the value
and attempts columns of probes, communication_style in
participants, as_turn and replaced in injections, and
metadata$guide and metadata$flow_params.
A result is saved whole with saveRDS(), and its focus_group then
holds the model configuration, which may contain a literal API key or a
private endpoint, and the runner. format() writes the record without
them, one JSON object per line, for a file that
focus_group_from_transcript() reads back; see
format.focus_group_result().
Details
At the end of each call-on item's discussion the moderator calls on every
participant who has not spoken since the previous call-on point, or since
the start for the first, in roster order. "opening" is the first guide
item participants answer, "midpoint" the middle one, and "end" the
last; points that fall on the same item count once, and item numbers count
every guide item, as fg_after_item() does. Each call-on line is
delivered as written, with no model call, and carries
metadata$call_on; the participant then answers, with a cue in the turn
instruction and no desire score, and the reply carries
metadata$called_on = TRUE. With the default guide the points are items
2, 4, and 7, so every participant speaks during the icebreaker. Krueger
(2002) describes the opening question of a focus group as a round robin.
The participants and the moderator see each other under handles, never
under the IDs of the records: the IDs of create_agents() are already
"P1", "P2", ..., and the moderator appears as "Moderator". When
the moderator's last question opens or closes with one participant's
handle ("P3, do you agree?", "P3: what do you think?", "Could you say
more, P3?"), or is a short follow-up naming one ("And P3?", "What about
P3?"), and does not also put it to the group, that participant takes the
next turn, without a
desire score and with a sentence in its turn instruction saying that the
question was put to it, and the reply carries metadata$addressed = TRUE.
A label
a speaker writes for itself at the start of its reply (**MOD:**,
P3:) is removed, and the message carries
metadata$self_label_removed = TRUE. A short opening self-introduction
("As a teacher, ", "As a retired nurse, ") is removed too, and the
message carries metadata$preamble_removed = TRUE; a longer opening
clause, or one that belongs to the statement ("As a teacher I grade
papers at night, ...", "As a result, ...", "As an alternative, ..."), is
kept. In both cases the
reply as returned stays in calls.
The estimate counts one output for each moderator turn the model writes,
one for each participant response a question round allows, one for each
participant at each call-on point, one for each probe answer (two when the
probe has a scale, for a possible re-ask), and one for the final
summary. Under
"desire_based" it adds one desire score per participant before each
response the flow chooses. With the default guide, six participants, and
the default flow, two responses per question give an estimate of 111
outputs (93 without call-ons) and three give 153. A session can make
fewer calls, since the desire-based flow does not score the previous
speaker, an addressed turn needs no scores, and a call-on point calls on
only the participants who have been silent, or more, since incomplete
replies are retried and a long discussion triggers interim summaries.
max_calls applies to the estimate, and only to live runs: with
.runner the session runs whatever the estimate.
When a model call fails partway through the session (a provider error that
persists after retries, an error from .runner, a reply still incomplete
after its retry, or a failed interim summary), the turns already completed
are not lost. The function raises an error of class
focusgroup_incomplete_session whose partial field holds the
focus_group_result built from those turns, with metadata$complete set to
FALSE. The error's phase field names the script phase that was running,
such as "engagement_question", or "interim_summary", and its parent
field holds the original error. Errors raised before the session starts,
such as an invalid argument or the max_calls check, carry no partial
result. A failed final summary is not an error of this kind: the function
warns and returns the complete transcript with an NA summary.
Token usage that a provider did not report is unknown, not zero. A call's
total is its reported total, or the sum of its sent and received counts when
both are known. The usage component adds the reported counts of every
call FocusGroup makes, including retries of incomplete replies, desire
scores, and interim summaries, and its unknown element counts the calls
whose usage was incomplete. When unknown is positive the subtotals are
lower bounds, and when it is NA, as after a call that raised an error, the
number of such calls cannot be determined. Retries made inside
LLMR::call_llm_robust() are not visible here and are not counted. A
reported count that is not a nonnegative whole number is treated as
unknown, with a warning.
In the transcript, a message's token count is NA unless every call behind
the message reported it. The message's metadata element usage_unknown
gives the number of those calls whose usage was incomplete.
References
Krueger, R. A. (2002). Designing and Conducting Focus Group Interviews. University of Minnesota.
Examples
cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b",
api_key = LLMR::llm_api_key_env("GROQ_API_KEY"))
guide <- list(
Opening = "Welcome the participants and state the ground rules.",
Exploration = "Which funding priority deserves attention first?",
Closing = "Thank the participants and close the session."
)
scripted <- function(experiments, ...) {
experiments$response_text <- "Evening opening hours matter most to me."
experiments
}
result <- run_focus_group(
topic = "Library funding priorities",
config = cfg,
n_participants = 2,
guide = guide,
flow = "round_robin",
verbose = FALSE,
.runner = scripted
)
result$transcript[, c("speaker_id", "phase", "text")]
#> speaker_id phase text
#> 1 System setup Participants: P1, P2; Moderator: MOD
#> 2 MOD opening Evening opening hours matter most to me.
#> 3 MOD exploration_question Evening opening hours matter most to me.
#> 4 P1 exploration_question Evening opening hours matter most to me.
#> 5 P2 exploration_question Evening opening hours matter most to me.
#> 6 MOD closing Evening opening hours matter most to me.
# The scripted runner reports no token counts, so usage is unknown.
result$usage
#> $sent
#> [1] 0
#>
#> $rec
#> [1] 0
#>
#> $total
#> [1] 0
#>
#> $unknown
#> [1] 6
#>
# A session that fails partway returns what it completed.
failing <- local({
calls <- 0
function(experiments, ...) {
calls <<- calls + 1
if (calls == 3) stop("The provider is unavailable.")
scripted(experiments)
}
})
partial <- tryCatch(
run_focus_group("Library funding priorities", cfg, n_participants = 2,
guide = guide, flow = "round_robin", verbose = FALSE,
.runner = failing),
focusgroup_incomplete_session = function(e) e$partial
)
partial$metadata[c("complete", "failure")]
#> $complete
#> [1] FALSE
#>
#> $failure
#> $failure$phase
#> [1] "exploration_question"
#>
#> $failure$message
#> [1] "The provider is unavailable."
#>
#>
