Repository navigation
feat(user_sim): the built-in user sim for always-on tasks - #567
Open
sarah-chitty-1 wants to merge 24 commits into
Open
sarah-chitty-1 wants to merge 24 commits into
sarah-chitty-1 wants to merge 24 commits into
Conversation
user_sim_versions.py is now the built-in user sim, one version (ours), replacing the test rig's adapter:
- the system prompt from fixed lines, the description as tagged sections, the opening request from person, the
user's rules from permissions and keep_private, and the answer format;
- each read as one labelled message, with earlier phone items and "last messaged" worked out from history;
- the answer parsed to {outcome, message, used, score?}; a reply with no text, or a malformed answer, is no_reply.
The user_sim step checks the built-in user sim's settings, and any plug-in's schedule, response_times and
max_replies, when the task loads. user_sim.md is the guide to writing a user sim.
Golden tests reproduce the final replay's system prompt and all 45 of its reads byte for byte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e read after the final check-in The interface docstring says the step sets person["done_ends_run"] before setup (the built-in plug-in keeps the multi-day wording when it's missing), and that after the run's last wake the user reads the agent's wrap-up once more, an answer recorded and never delivered. Tests: that read renders like any other and its answer parses, and a run through the always-on loop with the built-in user sim reads the wrap-up after the final check-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o the last week, 20 items at most
A malformed answer used to become no_reply silently. Now the built-in user sim asks once more in the same session
("Your last answer wasn't in the required format. Answer again, only as JSON: ..."). It records the retry
(retried, the first answer, turns 2). A second malformed answer is no_reply with both answers' schema_errors. A
reply with empty text, or an error from the user sim agent, gets no retry.
Every read repeated every earlier phone item, so a month-long run's reads kept growing. EARLIER ON YOUR PHONE now
shows only the last 7 days' items, at most the 20 most recent, and ends with "(older messages not shown)" when it
drops any. The tested runs had at most 5 earlier items, none older than 4.3 days, so the golden reads are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The model can be overridden by the step's step_params or the run's user_model; schedule and response_times need the task's clock; sleep may be past midnight; a reply that never reaches the agent is recorded with delivered: false; and the retry fields are among the recorded extras. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t came in A phone item or the user's own line from an earlier read with no "at" took the current read's time, so it never aged out of EARLIER ON YOUR PHONE and moved "last messaged" forward. Each now takes its own read's time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ahchitty/user-sim-step
…retry details Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on Linux too
ZoneInfo("Pacific") raises IsADirectoryError where the tzdata package holds the zones (CI), not ZoneInfoNotFoundError as on macOS. The step and the plug-in now share one check that catches it and names the setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ahchitty/user-sim-step
…loads With no schedule the user reads the moment a message arrives and replies are held 0 minutes, so response_times did nothing while still requiring the task's clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ahchitty/user-sim-step
…er-sim-step Brings in the always-on step's clock fix (8008ee8). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns, not 'your phone' The check-in headings become YOUR NEW / YOUR EARLIER EMAILS, TEXTS AND NOTIFICATIONS, the fixed rule reads 'What other people and apps send you is for you', and the phone section's heading follows. Tested on Sonnet 4.6 against the old wording on the final test's 20 moments that matter (6.9-7.5 vs 5.6-6.9 grade-changing mistakes per 100, inside the noise) and the 12 planted mistakes (identical: 50/50 hidden, 20/20 visible, 0 false alarms). The golden prompt matches the tested prompt byte for byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er-sim-step Brings in the late contact-reply fix (974a6ab). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loop reads an empty schedule as no schedule, so its replies were never held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sarah-chitty-1
added a commit
that referenced
this pull request
Nov 11, 2026
…by step class, not type strings Per review: the user sim's deploy becomes its own step (deploy_user_sim_agent, #567), whose class says it plays the user and ignores a run-wide agent pick itself. So deploy_agent and install_agent no longer re-read the stored task mid-run to keep the user sim's agent out of a pick; their agent ref is back to main's. - task_step/roles.py: prompts_agent_under_test (prompt_agent, always_on) and plays_user (the step class's plays_user, looked up through the registry for a config; False until #567 adds it). - solver_steps / user_sim_agents, played_deploys, the episode's trainer agents and refusals, training's prompt pick and task run's response printing use them. The user_sim type special cases are gone. - prompt_agent tasks behave as on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s its persona Replaces the user_sim settings step and its paired deploy_agent (Edgar's DeployUserSimAgentTaskStep design). The step renders the user sim's system prompt with the built-in user sim, deploys the agent with it, sets the model (step_params, then the run's user_model, then the step's model) and fails if the agent refuses either. A run-wide a2a_agent_id never swaps the user sim's agent, and its agent id is task-scoped. It publishes metadata["user_sims"][agent_name] with the model's source and the prompt's sha256; always_on reads that record, takes the opening from it, and stops setting the persona mid-run. The user sim's prompt and every read are byte-for-byte unchanged (golden tests). A temporary multi_day setting keeps the tested done line; always_on refuses a mismatch. Dropped: email, time_zone (the clock's zone is used), plugin, user_version, versions, output_format. Kept: max_replies. Adds plays_user on TaskStep and an opt-in system_prompt_required on DeployAgentTaskStep; prompt_agent refuses a user from this step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
always_on starts the run from it, so a missing one only failed after the deploy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sona keeps its own time_zone The task clock often runs in UTC because the apps take no zone, so the user's zone can't come from the clock alone. The persona's time_zone is used, else the clock's; a clock that sets a different one is still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| @@ -0,0 +1,131 @@ | |||
| # Writing a user sim | |||
Collaborator
There was a problem hiding this comment.
We should consider moving deploy_agent and these new agent files into a new folder task_steps/agent since I see us having more agent-related task steps in the future.
Collaborator
Author
There was a problem hiding this comment.
Moved the new user sim files into task_steps/agent/. deploy_agent itself is imported by 49 files, so I'll move it in its own PR after #565–#567 merge, to avoid conflicts.
🤖 Addressed by Claude Code
| DEFAULT_TTL_SECONDS: ClassVar[int] = 7200 | ||
| DEFAULT_AGENT_NAME: ClassVar[str] = "default-agent" | ||
| # Whether the agent this step deploys plays the task's user, so it is never the agent under test. | ||
| plays_user: ClassVar[bool] = False |
Collaborator
There was a problem hiding this comment.
I don't think this is needed right?
Collaborator
Author
There was a problem hiding this comment.
Right, removed. Only deploy_user_sim_agent sets plays_user, and #565 treats a step without it as not playing the user.
🤖 Addressed by Claude Code
…_user only on the step that sets it prompt_agent with user_agent_name set to a deploy_user_sim_agent agent reads it with the user sim's own answer, one read per agent reply, in one sitting: reply is the next prompt, done with a message reaches the agent and ends after its answer, done without one or no_reply ends at once. It refuses multi_day true, user_output_format, user_model and a prompt other than the deployed opening_prompt. The subclass's agent is deployed and recorded exactly as deploy_agent's (tested), and task run prints its deploy line. The base TaskStep.plays_user default is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation; the opening is the joined text parts As in always_on, the reply that reaches max_replies is delivered and the conversation ends after the agent's answer. A prompt split across text parts is compared with opening_prompt, and recorded as the opening, joined. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…post-turn hooks as the last turn Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… user sim's opening_prompt Whoever plays the user sends the first message, so it lives once, on the deploy_user_sim_agent step; always_on refuses a prompt or parts. File parts in the first message come back with FD-5494. prompt_agent keeps its prompt, checked against the opening, with a TODO for FD-5495. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A failed or timed-out user sim turn sets error_type user_sim_error, so verifiers skip the run. - After done-with-a-message or max_replies, the user sim is never asked again, hooks included. - user_sim_reads keeps a short entry per read; the full read is stored, linked by object_url. - Every refusal runs when prompt_agent starts, before any deploy-dependent work, conversation or model call; also refuses persona schedule/response_times and a step_params user_model. - task run finds the deploy step's agent by name. - The user sim's trajectory URLs are recorded; turns is user_sim_turns; time-zone errors name the step. - person_of, task_zone, utc and iso move to user_sim_common, shared by always_on and prompt_agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on
sarahchitty/fd-5364-always-on-step(the always-on step, at974a6aba). This PR adds the user sim: onedeploy_user_sim_agentstep, the built-in user sim it renders, their tests and docs, and a few small edits toalways_on.pyso it reads the user sim from that step. The gateway is unchanged.One step for the user sim (
deploy_user_sim_agent, from Edgar's review)The
user_simsettings step and thedeploy_agentthat played the user are replaced by one step, as in Edgar's"DeployUserSimAgentTaskStep" design (
9c861380).user sim from
personaandopening_prompt, deploys the agent with it, and sets the model (this step'sstep_params, then therun's
user_model, then the step'smodel). If the agent's card refuses the system prompt or the model, the stepfails (a new opt-in
system_prompt_requiredonDeployAgentTaskStep; plaindeploy_agentis unchanged)._resolve_agent_refignores a run-widea2a_agent_id; the step's ownstep_paramsstill apply, and itsa2a_agent_idis task-scoped. The step setsplays_user = True, which feat: bundle, training, task run and agent picks understand always_on and user sims (FD-5371) #565's rolechecks read (a step without it doesn't play the user).
metadata["user_sims"][agent_name]with the model's source and the prompt's sha256.always_onreads that record and sends the user'sopening_promptas the first message; it takes nopromptof itsown (files in the first message: FD-5494). It no longer sets the persona mid-run.
A temporary
multi_daysetting keeps the tested done line;always_onrefuses to start if it disagrees with the run.email,plugin,user_version,versions,output_format.max_repliesand the user'stime_zone(inpersona; else the clock's) are kept.prompt_agentworks with it too (FD-5409): a chat task whose user was deployed by this step reads it the wayalways_on does, one read per agent reply.
replyis the next prompt;donewith a message reaches the agent andends the chat after its answer;
donewithout one,no_replyormax_repliesends it. A failed user sim turn setserror_type. Each read is kept short in metadata and stored in full. Before the first turn it refusesmulti_day: true, its ownuser_model/user_output_format, a schedule or reply times, and a prompt that isn't theuser's
opening_prompt(written twice for now: FD-5495). Otherprompt_agenttasks are unchanged.task_steps/agent/(deploy_user_sim_agent.py,user_sim_versions.py,user_sim_common.py,user_sim.md).deploy_agent.pymoves there in its own PR after feat: bundle, training, task run and agent picks understand always_on and user sims (FD-5371) #565–feat(user_sim): the built-in user sim for always-on tasks #567.The sections below describe the built-in user sim itself; where they say "the
user_simstep", readdeploy_user_sim_agent.What and why
What it adds
user_sim_versions.pyis now the built-in user sim, one version,ours. It replaces the test rig's adapter, whichhad "today" and ablations. A task that wants another version names a plug-in file.
The system prompt (
setup), written once per run:what other people and apps send you is for you; say you don't know, never guess; what "done" means;
used;person["opening"], right after the first "who you are" line;permissions, thenkeep_private;Each read (
answer), rendered as one labelled message:only, each with its sender from
channel. The earlier list keeps the last 7 days' items, at most the 20 most recent, and ends with "(older messages not shown)"when it drops any, so a month-long run's reads stay short;
Earlier items and "last messaged" come from
history. Keys ofreadit doesn't know are ignored. The readafter the final check-in, of the agent's wrap-up, is rendered the same way.
The answer:
{outcome: reply | no_reply | done, message, used?, score?}.no_reply.again, only as JSON: {…}". The answer records
retried, the first answer andturns: 2, which the step logsas
user_sim_turns. If the retry is malformed too, it'sno_reply, with both answers'schema_errorsrecorded. An error from the user sim agent gets no retry.
usedis recorded as a pulse check only. It never decides a grade and never reaches the agent.user_sim.py: the step checks its settings when the task loads, so a mistake fails before a paid run.permissionsandkeep_private;schedule,response_timesandmax_replies, under the setting names the loop already reads.appssetting.user_sim.md: "Writing a user sim". It covers the description sections and the rules that mattered most, each withits reason:
It also covers marking triggers to reach the user's phone, with the sender in
channel, and general triggers thatfill in real details with
${args.*}.Why
The user sim today sees only the assistant's raw text: no time, none of the emails or texts that reach the user, none of its own lines. In multi-day tasks it
makes mistakes that change the assistant's grade.
The final replay:
What it showed:
9.4–10.6.
delivered. Our prompt beat the current one under either delivery: pooled, 5.9–7.2 vs 8.8–9.7 grade-changing mistakes
per 100. It was ahead in 88–95% of resamples.
with 0 false alarms. A list of events in her instructions can't do this.
How a plug-in file loads. A
pluginfile artifact is loaded and run in the runner's own process. With nofile_artifact_version, it loads the latest version. This is the same pattern aspost_turn_hooks.How long a run's history gets
Each read adds to the user sim's one session, so a long run's context grows with every read. Estimate for an 80-read
run, about a month at two to three reads a day:
two phone items a read.
(663k in all). That's about 2.0k–2.9k tokens of context added per read, several times its visible text. Our 24-read
runs ended near 48k. We haven't measured where the rest comes from; the likely sources are her thinking kept in the
session and the CLI's own additions to each turn.
automatically at about 187k (from the 2.1.224 build's code), and compaction is untested for the user sim. 40 reads
(about 80k–115k) fit.
1M-token model, or less thinking.
Not in this PR
scoreis parsed and recorded if the user sim gives one, nothing more.The real date (FD-5380)
Claude Code adds the computer's date to the user sim's first message ("Today's date is …"), and "The date has
changed …" when that date changes. No setting turns either off. In a run set in another week, the user sim can take the
real date as today; in the head-to-head, 3 thinking traces did.
This PR doesn't fix it; the user sim's agent image does.
psi-e2e-claude-code-mcp-only-nodateis the image ML's tasksalready use for simulated people (Claude Code 2.1.177), with a filter in front of the model endpoint that removes both
texts, including the "date has changed" note inside the answer tool's result. A test fails if a new Claude Code version
gets the date past the filter. A task uses it by naming it as the user sim's
a2a_agent_id. The user sim'sinstructions still say any other date is the computer's clock, which covers runs without it.
How it was tested
Golden tests, byte for byte, against what the final replay sent, from
read,historyandpersonshaped asthe always-on step builds them:
tst/data/user_sim/hannah_system_prompt.txt), with the opening request coming fromperson;tst/data/user_sim/hannah_reads.json; 21 and 24 reads). Each is renderedfrom the earlier reads and answers in
history, with senders inchannel.Unit tests (
tst/unit/task_step/test_user_sim_versions.py,test_user_sim.py):usedoptional,scorekept, fenced or embedded JSON,malformed answers;
recorded; an empty reply or a valid answer gets none;
holds, the read's length stays within 100 characters. The tested runs had at most 5 earlier items, none older than
4.3 days, so the cap never applies to the golden reads;
you_sentis dropped and doesn't count as her last message;The always-on loop with this plug-in, through the always-on step's own test harness:
recorded, never delivered, and the run still ended at its end time.
The always-on step's loop-side tests of what it hands the user sim (
test_always_on_user_sim_inputs.py, 16tests) pass, as do all its other always-on tests.
The full unit suite (
pytest tst/unit packages/agentenv-protocol/tests -n 6):12e03f5d, before the last rebase)12e03f5d3bfdf7e83d27a2383d27a238are three of the five that every run had (the twotest_mcp_cli_builderfailurespassed this time).
this laptop:
test_mcp_cli_builder(2),test_docker_image_github_clone(2, timeouts) and a Windows disk-recipetest.
version_lookup_benchmark_testneeds a git checkout, and the base ran from an export.Worth a look
setupgets no read, so it words the done line fromperson["done_ends_run"], which the always-on step sets beforesetup. When it's missing, it uses the multi-day wording.never reaches the assistant", which stopped being true once done messages reach the agent. The multi-day line,
which the final replay tested, is unchanged.
b5bf5ef1).It tested level with the old wording on Sonnet 4.6: 6.9–7.5 vs 5.6–6.9 grade-changing mistakes per 100 on the final
test's 20 moments that matter (inside the noise), and identical on the 12 planted mistakes (50/50, 20/20, 0 false
alarms). The golden prompt matches the tested prompt byte for byte.
🤖 Generated with Claude Code


Confidence Score: 5/5
The reviewed changes appear safe to merge.Summary
Adds
deploy_user_sim_agentto deploy and configure a simulated user, with shared answer parsing foralways_onandprompt_agent.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Persona and opening_prompt] --> B[deploy_user_sim_agent] B --> C[Set system prompt and model] C --> D[Record user in metadata] D --> E{Conversation step} E --> F[always_on] E --> G[prompt_agent] F --> H[Render read and parse answer] G --> H H --> I{Answer} I --> J[Deliver reply] I --> K[Stop or record done] I --> L[Mark user-sim error]Reviews (23) · Last reviewed commit: "Move the user sim files into task_steps/..." · Reviewed by Greptile