Repository navigation
feat(user_sim): the built-in user sim for always-on tasks - #567
Open
sarah-chitty-1 wants to merge 84 commits into
Open
sarah-chitty-1 wants to merge 84 commits into
sarah-chitty-1 wants to merge 84 commits into
Conversation
… s (FD-5333) An executor holds message/send open until its task ends, so the 30 s read timeout failed every fire whose contact took longer, even though the reply usually still landed. The send now reads for the executor's timeout_seconds (never less than 30 s); connect and task polls keep 30 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user_chat gateway extension (message_user, opt-in stay_quiet), the user_sim and always_on steps, and the built-in user sim fallback, as they ran in the FD-5332 local rig (detached at a439754). The rig's own triggers.py change is left out; the reviewed FD-5333 fix is merged next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on by default (FD-5365) - stay_quiet, nudge and wake_reminder default to on; a task turns each off with false. The run record always carries the settings, and nudges are counted with their outcomes. - The agent is told the rules: appended to the step's system prompt, or, without one, opening the first wake. - An empty reply, or a harness's "(The assistant's reply was empty.)" stand-in, is not closing text, so it gets no nudge. - The regex that flagged closing text "meant for the user" is gone; the nudge replaces it. - Lines the step adds to a wake (rules, reminder, final reminder, nudge) carry metadata.agentenv.source, so a reader can tell them from the user's and the world's. - The user-chat card advertises stay_quiet in config. Tests: gateway user chat (message_user and stay_quiet through a deny-all), and the step's message rules on an in-process gateway, including that neither the nudge nor the reminder reaches the user sim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- user_sim: no apps setting (the user sim sees only what triggers notify to user_phone; scripted lines are notify to user_says); the version defaults to ours; a time_zone must be a real IANA zone. - The built-in ours works from the description alone: the read-and-answer rules (including "any other date is the computer's clock"), then WHO YOU ARE. versions.ours.system_prompt still replaces it. - Done ends only a one-sitting run. When end_time falls on a later day than the start (in the user's zone) the done is recorded (ends_run false, counts.user_dones) and the run goes on; the user sim is asked again when the agent next messages. - Time zone: the user sim's, else the one sync_env_clock gave the world; refused when the two disagree. It reaches the plug-in and the record. - What comes due in the same minute wakes the agent once: before a wake the clock moves to anything else due within that minute. - The next due time ignores items already due, so a pending one can't hide a later check. - Registration: always_on declares the user-scoped fields it takes, and the episode rules refuse an outside agent for always_on and user_sim (the same hunk as the FD-5371 branch, so the two merge cleanly). The gateway card test counts the user-chat extension. Tests on the in-process gateway: each wake source, jumping in order and never past an event, read times (asleep, busy) and reply delays, one read for messages sent before it, same-minute arrivals, what a read holds and that nothing meant for the agent is in it, done in one-sitting and multi-day runs, and the time zone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- While the task's clock is set, each wake opens with the task's date and time (with the year, and the user's local time when they aren't in UTC), and says any other date the agent sees is the computer's clock. The line is tagged metadata.agentenv.source "time". - Gateway: a role the user-chat config names always has get_time while the clock is set, like message_user, so a task's tool rules (a deny-all, say) can't hide it. Roles the config doesn't name are unchanged. - Without a system prompt, the first wake still opens with the message rules, now ahead of the time line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… multi-day runs (FD-5368) - scheduled_checks (default: on when end_time falls on a later day than the start, in the user's zone) gives the agent's role scheduled checks on each env whose clock is set, at least scheduled_check_min_interval apart (default PT3H, against the gateway's own PT1H default; hourly checks ran a 5-day Opus run out of wakes by Thursday). True or false turns them on or off whatever the run's length. - A role the task already gave checks (configure_scheduled_checks) keeps its own settings; other roles the task named keep theirs. - When checks are on, the agent's rules say it can schedule a wake-up. - Gateway: a role the user-chat config names also always has the check tools its notifications config gives it, so a deny-all can't hide them. - The run record says where checks came from (settings.scheduled_checks). A check's wake counts toward max_wakes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The core-loop ticket lists email among the user_sim settings; it is recorded and handed to the plug-in with the rest of the person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e always_on uses No behaviour change. UserSimTaskStep and USER_SIMS_KEY move, unchanged, from always_on.py to task_steps/user_sim.py, whose docstring is now the whole interface between the two steps: the settings always_on reads from metadata["user_sims"][name], the plug-in's configure / setup / answer, the person, read, history and ask shapes, and what answer returns. The registry imports the step from its new module; the user-sim-only tests move to test_user_sim.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user sim plug-in gets read["done_ends_run"]: true in a one-sitting run, false in a multi-day one, where done is only recorded. Documented with the read in user_sim.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A done with text is held for her reply time and delivered to the agent
like a reply (her prompt says her message reaches the assistant either
way). In a one-sitting run the run ends (user_done) after the agent's
turn on it, with no further reads; a blank done still ends it at once.
A multi-day run records the done and goes on, as before.
- person["opening"] = {"at", "text"}: the opening request and its time,
so a plug-in's setup can put it in her system prompt. It stays the
first line of her first read's you_sent.
- user_sim.py's interface says so, and that a phone item's sender rides
in its channel ("Gmail · Carol Reyes (Mom)"), as in the agent's alerts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One test per item the loop owns on the user sim's checklist: she reads only after message_user (A1); her phone shows only new user_phone items with their time and full text (A4/A5); you_sent holds only her own lines at their times (A8/B3); every message since her last read comes in one read, oldest first (A9); the opening is in person and her first you_sent (A13); one user sim session per run (A14); history is never trimmed (A15); nothing meant for the agent reaches her at any depth (B1/B2/B4); ask sends exactly its text (B8); reply and done arrive after her reply time (C2/C4); used and score are recorded, never sent (C5); and done_ends_run. The harness can now take a plug-in and play the user sim agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-side fires and executor time limit (FD-5367) state gains, per trigger, running (fires in flight), waits (when each waiting fire goes on) and executor_calls (each call to an executor in progress, with the seconds left of its time limit), and a top-level settings key. Each fire now keeps its own wait, so fires waiting at once don't clobber each other. New triggers/v1 method config, off by default so prompt_agent runs behave as before: - concurrent_fires: a further call that provokes a repeat trigger already firing starts its own fire at once instead of queueing behind the one running. - executor_timeout_seconds: the time limit of an executor whose registration set none (else 120). Calls in one executor conversation (its URL and the trigger's context id) take turns, so fires of one trigger running side by side never resume the same session at once; in sequential mode they never overlap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…goes only to the object store (FD-5371) A long run's timeline could push what a running job reports back over 2 MB. The summary in metadata["always_on"][step] now holds the counts, end reason, settings and the timeline's object_url, plus real_seconds and timeline_events; when storing fails it keeps the last 20 events instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts task first runs (FD-5367) A reader of state right after an advance could otherwise see a firing trigger with nothing running and call the world settled before the fire's actions ran. A fire cancelled before it began is uncounted when its task ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck is advanced to its resumes_at Virtual times are microseconds, so resumes_at can fall a hair short of a stochastic delay's true end. On a frozen clock nothing else moves time, so the fire waited forever (and a settle wait spun until its timeout). Under a microsecond left now counts as reached. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ide by side; 300 s contact limit (FD-5367) - The settle wait follows the gateway's executor calls: a contact's reply counts as in progress until it lands or its time limit passes (plus 30 s); other trigger work gets settle_timeout_seconds from the start or the last contact call seen. A gateway that reports no executor calls gets the old fixed wait. - New settings contact_replies_in_parallel (default on) and contact_timeout_seconds (default 300 s, for contacts whose task sets none), applied through triggers/v1 config at the start and recorded under settings.contacts. Old gateways are recorded as not applied. - clock_jump and settle_timeout events say how long the wait took, how many contact calls it saw and which triggers were still at work; the end records contact replies still in progress (pending_at_end.contact_replies_in_progress). - person gains done_ends_run, for setup, which writes her prompt before any read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, her last read, pending items, finished vs stuck (FD-5369) - end_time always brings a final check-in: the task's final line, else the step's own line (FINAL_DEFAULT_TEXT), which is not the user's and never reaches her you_sent. - The last-turn notice (final_reminder) goes on the run's last wake: at end_time, and on the wake that max_wakes makes the last. Wake events say last. - After the last wake the user sim reads the agent's wrap-up (user_sim_read with last_read); her answer is recorded and a reply is held as pending, never delivered. Not after a one-sitting done, a give-up or an error. - A reply still held at end_time is kept (it was dropped) and recorded. - pending_at_end: held_replies, unread_by_user (with the wake that sent each), user_lines_not_delivered, alerts_never_delivered, scheduled_checks_not_due, contact_replies_in_progress. The summary keeps counts and the first 20 of each; the stored end event has all. - nothing_can_wake_the_agent becomes finished_nothing_pending or stuck, from what is pending. The record and the end event carry end_note, a plain sentence for each end reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…D-5370 1-3, FD-5369 4 and 6) - The graded response is every message_user text in order, each with its pretend time; never the agent's closing text (it was the last wake's messages plus its closing text). - The conversation graders read (a2a_conversations[step]) is now the user's chat: her messages (opening, user_says and final lines, replies, done) as user turns, and the agent's message_user texts, with times and the wake that sent each, as one agent message per run of them. The alerts that woke the agent, the step's own lines and its closing text stay in the per-wake prompt parts, trajectories and timeline. - It ends with one last user turn: her replies that never reached the agent, her reaction when her last read said nothing (no reply, or a blank done), and an end marker with the end reason and end_note. - A failed or timed-out agent turn (or nudge turn) sets error_type on the prompt response (the agent's own, else agent_turn_failed / agent_turn_timeout) and error_code, as prompt_agent does, so agent_prompt_response_verifier and rubrics_verifier skip the run; user_sim_error and step_error ends do too. The record carries error_type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… answers, hook-shaped reply times (FD-5370 4, 6, 7) - App changes: from each env's changelog (urn:agentenv:changes/v1, from where it stood at the start), the step records every change with its actors, by (agent, others, or none: a write the gateway didn't serve, such as a website reached directly), the wake it was seen in and the phase (before_turn, turn, nudge, after_turn, end). The agent's own carry the wake that made them. app_changes timeline events; counts in the record. - reply_to on each reply: the agent's message it answers (wake, time, text) and how it was found: replying_to from the plug-in (a number or a quote), her used quoting it, the only message in the read, or its only question; None when it can't be told. - metadata["post_turn_hooks"][step]: a start record, then one per read in the old psi-wake hook's shape (hook, turn, read_at, wrote_at, outcome; user_reads_at = when her reply reached the agent; reply_to; delivered_in_wake). ML's asked_before_spending check (Surprise 30th process_checks.py) now grades a step run - checked offline: a booking before her reply fails, one after passes, one with no reply fails - instead of skipping it as 'no psi-wake hook records'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the run context, not every wake's trajectory (FD-5370, FD-5371) With 40-60 wakes, every wake's trajectory may not fit the judge and costs a lot. Only for a prompt an always_on step made (metadata["always_on"][its step]), the verifier grades from a copy of itself that sends no trajectories, shows the run context (conversation and trigger log, unless the verifier names its own parts) and adds the run wake by wake (always_on.render_for_judge): each wake's time, what woke the agent, how much it sent the user, its tool calls and the app changes made around it, then how the run ended and what was pending. grade_up_to_turn and screenshot-citing formats are refused for such a prompt. Every other prompt is graded exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…8-hour job (FD-5371) The hub runs a step as one 8-hour job and retries one that runs over, against a world whose clock has already moved. New setting time_budget_seconds: once the time spent plus one wake's worst case (wake_reserve_seconds: the agent's turn and nudge turn, two settle waits, a read) reaches it, the agent gets its closing wake (what is due, the step's wrap-up line, the last-turn notice), the user sim reads the wrap-up, and the run ends with end reason time_budget (a time_budget_reached event records when). The default is 7 h, lowered so the budget plus one wake's worst case fits the 8-hour job with 10 minutes spare: 6.98 h at the default 600 s turn timeout, 6.32 h at 1800 s (Surprise, Tree), 5.32 h at 3600 s (Holiday). A budget a task sets that can't fit, and turn timeouts that leave no room for any run, are refused when the step is built. The record's settings carry the budget and the reserve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s its number and its own executor calls - concurrent_fires now runs a repeat trigger's fires side by side only when every action is a contact's reply (nl). A trigger with tool actions queues as before, since two fires of a read-then-write action could lose a write. - A fire reads its number once, when it starts, and passes it down; overlapping fires used to read trig["attempts"] later and get the other fire's number. - A fire counts as realized by its executor only while its call holds the executor conversation's lock, so a fire waiting for its turn is no longer credited with the calls the executor makes for the other fire. Fires of one trigger taking turns on that lock is intended (two turns never resume one session), now documented. - A fire is counted in `running` only once its task exists, so a schedule that raises leaves no count. - A call queued while only side-by-side fires ran (concurrent_fires turned off meanwhile) is no longer stranded: the last of them to end starts a fire that drains the queue. - An executor reply that completes just past its time limit is kept: message/send reads a little past the limit, a terminal status in its answer is used, and the task is polled once more after the limit. With concurrent_fires off (the default), every trigger behaves as before, bar the late reply now being kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Declared readOnlyHint=False, each call made the trigger engine re-evaluate every armed state trigger. Nothing depends on that: the only reader of the hint in the gateway is TriggerEngine._is_readonly, the tools change no app's data a state check could read, and action triggers on them still fire (matched before the read-only check). So they are marked read-only, as get_time is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t would be graded _for_always_on refuses settings it can't grade an always_on prompt with (grade_up_to_turn, evidence-citing formats) and ran before the error_type skip, so a run whose agent turn failed raised instead of being skipped. The skip now comes first; every other prompt is verified as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ggers' marks PUT /clock/rate (clock/v1 set_rate) sets virtual_seconds_per_real_second from now on, keeping the virtual time, t0 and generation; 0 freezes the clock. The always_on step needs to freeze the clock outside the agent's turns and run it at the task's rate during them. Re-arming with set_time, as sync_env_clock does, can't be used for that: each re-arm bumps the generation, which re-anchors every relative or recurring time trigger to the new t0, so a trigger at PT2H would be pushed back on every turn and never fire. Additive: no existing route or method changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…izes, never anyone's words
_Run.log() wrote up to 400 characters of each event's fields to logger.info, including the agent's messages,
the user sim's reads and replies, plain replies and closing text. It now logs only fields that name, time or
count things; any other string is logged as its length and a list as its count, as the trigger engine does
("bodies and addresses are PII"). The stored timeline keeps everything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g gaps fold their clock jumps A recurring trigger that never wakes the agent (every PT1H, notify to user_phone) made step 3 of the loop jump the clock forever when the run had no end_time: nothing counted toward max_wakes (a probe made 685 jumps in 4.8 s and moved a month). The rule: with no end_time, a jump counts as idle when nothing the step tracks is ahead (no alert or check for the agent, no user line, no held reply, no read) and every trigger with something ahead is a recurring time trigger with no fire in flight (none running, queued, waiting out a delay or hours, calling a contact or evaluating) and no one-off mark to come. IDLE_JUMPS_MAX (24) idle jumps in a row end the run as nothing_can_wake, which finish() records as finished_nothing_pending or stuck from what is pending; a world_only_repeats event names the triggers. A wake or a read restarts the count. So the rule can't cut short a run waiting on a known event (a held reply, a one-off trigger, a contact's delayed reply), and only ends one in which a recurring trigger has fired that many times running without waking the agent. With an end_time the run still goes on to it. The timeline keeps the first JUMPS_KEPT_PER_GAP (20) clock jumps between two wakes and folds the rest into one clock_jumps_folded entry (count, span, reasons, unsettled count), so a long gap can't grow it without bound; counts.clock_jumps counts them all. 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>
…_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>
earakely-scale
approved these changes
Nov 11, 2026
…drop contact_timeout_seconds contact_timeout_seconds never reached the contacts register_env_triggers registers, since that step always sends its own timeout_seconds (executor_timeout_seconds, 120 by default). The setting is gone from the step, and with it the gateway's triggers config executor_timeout_seconds, which only the step used: config takes concurrent_fires alone again, and each executor keeps the limit it was registered with, as on main. The settle still needs the contacts' real limits: the triggers state now lists each executor's timeout_seconds (config.executor_timeouts), the run reads the longest when it starts, and caps every settle with it. The wake reserve and the time budget are worked out again from it, so a run whose contacts' limit leaves no room in the hub's job is refused before its first wake. When the task loads the step is checked with the gateway's default (120 s); a gateway that doesn't report its executors' limits gets that default too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gister_env_triggers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt of the agent's reply Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; rubrics_verifier no longer spots one rubrics_verifier used to recognise an always_on run itself (_for_always_on, a copy of the step and an always_on import). That moves into a subclass, AlwaysOnAgentVerifier (type always_on_agent_verifier): it grades the prompt from the user's chat, the run's context and the run wake by wake, as before, and refuses when the task loads what needs the wakes' trajectories: an output format that cites per-criterion screenshots, use_trajectory and grade_up_to_turn (a run's grade_up_to_turn is refused when it runs). - rubrics_verifier gains one override point, judge_prompt_sections(context, prompt_response), whose sections go into the judge prompt after the run context: the wake-by-wake summary is per run, so no copy of the step. With no override it adds nothing, so a plain rubrics_verifier grades every prompt exactly as before. - TaskStep.task_problems(steps), checked when a Task loads, refuses steps that don't go together: a plain rubrics_verifier on an always_on step's prompt (use always_on_agent_verifier), and an always_on_agent_verifier on another prompt step's prompt. - is_rubrics_verifier(step_or_config): a registry lookup plus issubclass, for steps and stored dicts, so code that would check the type string recognises the subclass. The explorer's rubric lookups and step colours, the outside agent rules and the field scope test know the new type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
always_on takes no prompt here, so the merged tests build their always_on step without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One set_rate and one route: main's PUT /clock/set-rate and its _clock_write path replace this branch's /clock/rate. Rate 0 still freezes the clock (the card says so and sets minimum 0). Clock.set_rate keeps main's version/count/lived bookkeeping and current-time overflow guard. _change_clock wakes the fires waiting out virtual time on every write: clock_advanced already does for set_rate and advance, and clock_changed now does for set_time and clear. The executor read keeps this branch's 30 s of slack past the executor's own timeout, on top of main's FD-5333 fix. always_on raises StepContextError (#620) for an agent or user sim missing from the run's context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
always_on keeps the base's StepContextError, naming the deploy_user_sim_agent step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…il the user is free; response_times needs a schedule When the agent's turn ran into the user's night or a busy block, her read went ahead at once in the mode it was scheduled in. Now it's rechecked at the time she'd read: she reads then if she's free, in that time's mode, else when she's next free. And user_sim refuses response_times without a schedule, which never held a reply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… user is free user_sim.py and its test stay deleted: deploy_user_sim_agent already refuses response_times without a schedule, with tests. 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 PR appears safe to merge; no new blocking issue was established.Summary
Adds
deploy_user_sim_agentto deploy and configure the built-in user, then shares its reads betweenalways_onandprompt_agent.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Persona and opening] --> B[deploy_user_sim_agent] B --> C[Configure user prompt and model] C --> D[Save user record] D --> E[always_on] D --> F[prompt_agent] E --> G[Render user read] F --> G G --> H[Ask user agent] H --> I{Answer valid?} I -->|No| J[Retry once] I -->|Yes| K[Record answer] J --> K K --> L[Deliver message or stop]Reviews (28) · Last reviewed commit: "Merge sarahchitty/fd-5364-always-on-step..." · Reviewed by Greptile