Skip to content

feat(user_sim): the built-in user sim for always-on tasks - #567

Open
sarah-chitty-1 wants to merge 84 commits into
mainfrom
sarahchitty/user-sim-step
Open

sarah-chitty-1 wants to merge 84 commits into
mainfrom
sarahchitty/user-sim-step

Conversation

@sarah-chitty-1

@sarah-chitty-1 sarah-chitty-1 commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on sarahchitty/fd-5364-always-on-step (the always-on step, at 974a6aba). This PR adds the user sim: one
deploy_user_sim_agent step, the built-in user sim it renders, their tests and docs, and a few small edits to
always_on.py so 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_sim settings step and the deploy_agent that played the user are replaced by one step, as in Edgar's
"DeployUserSimAgentTaskStep" design (9c861380).

  • It deploys the agent that plays the user and sets it up there: it renders the system prompt with the built-in
    user sim from persona and opening_prompt, deploys the agent with it, and sets the model (this step's step_params, then the
    run's user_model, then the step's model). If the agent's card refuses the system prompt or the model, the step
    fails (a new opt-in system_prompt_required on DeployAgentTaskStep; plain deploy_agent is unchanged).
  • The tested agent never plays the user: _resolve_agent_ref ignores a run-wide a2a_agent_id; the step's own
    step_params still apply, and its a2a_agent_id is task-scoped. The step sets plays_user = True, which feat: bundle, training, task run and agent picks understand always_on and user sims (FD-5371) #565's role
    checks read (a step without it doesn't play the user).
  • It records the user in metadata["user_sims"][agent_name] with the model's source and the prompt's sha256.
    always_on reads that record and sends the user's opening_prompt as the first message; it takes no prompt of its
    own (files in the first message: FD-5494). It no longer sets the persona mid-run.
  • The user sim's words are unchanged: the prompt and every read are byte-for-byte the tested ones (golden tests).
    A temporary multi_day setting keeps the tested done line; always_on refuses to start if it disagrees with the run.
  • Fields dropped: email, plugin, user_version, versions, output_format. max_replies and the user's
    time_zone (in persona; else the clock's) are kept.
  • prompt_agent works with it too (FD-5409): a chat task whose user was deployed by this step reads it the way
    always_on does, one read per agent reply. reply is the next prompt; done with a message reaches the agent and
    ends the chat after its answer; done without one, no_reply or max_replies ends it. A failed user sim turn sets
    error_type. Each read is kept short in metadata and stored in full. Before the first turn it refuses
    multi_day: true, its own user_model / user_output_format, a schedule or reply times, and a prompt that isn't the
    user's opening_prompt (written twice for now: FD-5495). Other prompt_agent tasks are unchanged.
  • Where it lives: task_steps/agent/ (deploy_user_sim_agent.py, user_sim_versions.py, user_sim_common.py,
    user_sim.md). deploy_agent.py moves 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_sim step", read
deploy_user_sim_agent.

What and why

What it adds

  • user_sim_versions.py is now the built-in user sim, one version, ours. It replaces the test rig's adapter, which
    had "today" and ablations. A task that wants another version names a plug-in file.

    • The system prompt (setup), written once per run:

      • fixed lines on how a real person reads and answers: not the assistant; never do its work; can't open apps;
        what other people and apps send you is for you; say you don't know, never guess; what "done" means; used;
      • the task writer's description, as tagged sections;
      • the opening request, from person["opening"], right after the first "who you are" line;
      • the user's rules, written in plain English from permissions, then keep_private;
      • the answer format.
    • Each read (answer), rendered as one labelled message:

      • the time, and when the user last wrote;
      • YOUR NEW EMAILS, TEXTS AND NOTIFICATIONS in full, then YOUR EARLIER EMAILS, TEXTS AND NOTIFICATIONS, first lines
        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;
      • YOU SENT YOUR AI ASSISTANT: only the user's own lines;
      • everything new from the assistant at once.

      Earlier items and "last messaged" come from history. Keys of read it doesn't know are ignored. The read
      after 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?}.

      • A reply with no text is no_reply.
      • A malformed answer gets one retry in the same session: "Your last answer wasn't in the required format. Answer
        again, only as JSON: {…}". The answer records retried, the first answer and turns: 2, which the step logs
        as user_sim_turns. If the retry is malformed too, it's no_reply, with both answers' schema_errors
        recorded. An error from the user sim agent gets no retry.
      • used is 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.

    • For the built-in user sim:
      • the description sections;
      • permissions and keep_private;
      • the version.
    • For any plug-in: schedule, response_times and max_replies, under the setting names the loop already reads.
    • There's no apps setting.
  • user_sim.md: "Writing a user sim". It covers the description sections and the rules that mattered most, each with
    its reason:

    • a specific "don't mention X unless asked" line per tempting fact;
    • "say you don't know, never guess";
    • "done is only a short sign-off";
    • decisions with one answer;
    • nothing about the future.

    It also covers marking triggers to reach the user's phone, with the sender in channel, and general triggers that
    fill 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:

  • 45 real moments from two full runs of a parents' visit week, each replayed 10 times;
  • the same assistant messages and fixed earlier history for every version;
  • right answers written by someone who hadn't seen the design;
  • two judges, agreeing on 94% of items.

What it showed:

  • Fewer grade-changing mistakes. This design made 5.6–6.9 per 100 moments that matter. The current user sim made
    9.4–10.6.
  • The instructions drive most of the gain. A 2×2 swapped how the prompt is written with how each read is
    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.
  • Our delivery adds a smaller gain.
    • On grade-changing mistakes, it's within noise: ahead in 74–81% of resamples.
    • "Didn't do what the moment expected" fell from 40–53 to 13–28 per 450.
  • Events shown as they happen let the user catch mistakes only the confirmation email shows. She caught 50 of 50,
    with 0 false alarms. A list of events in her instructions can't do this.

How a plug-in file loads. A plugin file artifact is loaded and run in the runner's own process. With no
file_artifact_version, it loads the latest version. This is the same pattern as post_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:

  • Visible text only: the system prompt, every read and every answer, with the tested runs' assistant messages and
    two phone items a read.
    • With the 20-item, 7-day cap: about 61k–74k tokens.
    • Without it: about 157k–190k. The EARLIER section alone grew past 14k characters a read.
  • Measured in a live run: the user sim in our 21-read parents' visit run averaged about 31.6k prompt tokens 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.
  • At that measured rate, 80 reads come to about 160k–230k tokens. On a 200k-token window, Claude Code compacts
    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.
  • Before an 80-read task, measure context per read on its first live run. If needed, give the user sim a
    1M-token model, or less thinking.

Not in this PR

  • The always-on wiring, which is the always-on step's.
  • Removing the real date the user sim's harness adds: the user sim's agent image does it (FD-5380, below).
  • Read-only app tools for the user sim.
  • The satisfaction-score grader. score is 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-nodate is the image ML's tasks
already 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's
instructions 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, history and person shaped as
    the always-on step builds them:

    • the system prompt (tst/data/user_sim/hannah_system_prompt.txt), with the opening request coming from person;
    • all 45 reads of the two replayed runs (tst/data/user_sim/hannah_reads.json; 21 and 24 reads). Each is rendered
      from the earlier reads and answers in history, with senders in channel.
  • Unit tests (tst/unit/task_step/test_user_sim_versions.py, test_user_sim.py):

    • the parser: reply, no_reply, done, a reply with no text, used optional, score kept, fenced or embedded JSON,
      malformed answers;
    • the retry: malformed then valid uses the valid answer; malformed twice is no_reply with both errors; the retry is
      recorded; an empty reply or a valid answer gets none;
    • the earlier list dropping items older than 7 days, with its closing line;
    • a simulated 80-read run with two phone items a read. EARLIER never shows more than 20 items, and once the cap
      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 SENT filtering: a ping in you_sent is dropped and doesn't count as her last message;
    • "last messaged" counting a reply but not a no_reply;
    • phone items in full, then as earlier items, each shown once at its time;
    • an earlier phone item or own line with no time keeps the time of the read it came in;
    • a time zone like "Pacific", which is a whole region, is named as a bad setting;
    • the done wording;
    • the rules and empty sections;
    • not asking when the assistant sent nothing;
    • the read after the final check-in, rendered like any other, and its answer parsed;
    • the load checks, for the built-in user sim and for any plug-in.
  • The always-on loop with this plug-in, through the always-on step's own test harness:

    • one run where the user sim agent got exactly the rendered read, and her reply reached the agent;
    • one run where a malformed answer was retried once, in the same session, and the valid answer reached the agent;
    • one run where, after the final check-in, she read the agent's wrap-up like any other read. Her "done" was
      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, 16
    tests) pass, as do all its other always-on tests.

  • The full unit suite (pytest tst/unit packages/agentenv-protocol/tests -n 6):

    passed failed skipped
    base (12e03f5d, before the last rebase) 14,131 6 67
    this branch on 12e03f5d 14,244 6 67
    this branch on 3bfdf7e8 14,325 6 67
    this branch on 3d27a238 14,398 3 67
    • The failures on 3d27a238 are three of the five that every run had (the two test_mcp_cli_builder failures
      passed this time).
    • Five failures are in every run, on the base and on the branch. They're all outside the user sim and all come from
      this laptop: test_mcp_cli_builder (2), test_docker_image_github_clone (2, timeouts) and a Windows disk-recipe
      test.
    • The sixth differs by run:
      • the branch's disk-recipe launcher test fails under load and passes when run alone;
      • the base's version_lookup_benchmark_test needs a git checkout, and the base ran from an export.

Worth a look

  • setup gets no read, so it words the done line from person["done_ends_run"], which the always-on step sets before
    setup. When it's missing, it uses the multi-day wording.
  • The one-sitting done line now ends "after "done", the conversation is over." The tested line ended "a "done" message
    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.
  • The wording names what reaches the user as "emails, texts and notifications", not "your phone" (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.
  • The built-in user sim refuses a description written as one string. A task with its own plug-in can still use one.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no new blocking issue was established.

Summary

Adds deploy_user_sim_agent to deploy and configure the built-in user, then shares its reads between always_on and prompt_agent.

  • One deploy step sets up the user agent for an always-on run.
  • The built-in user sim gives each read a persona prompt and recent conversation context.
  • A deployed user agent can answer each reply in a prompt-agent chat.

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]
Loading

Reviews (28) · Last reviewed commit: "Merge sarahchitty/fd-5364-always-on-step..." · Reviewed by Greptile

sarah-chitty-1 and others added 30 commits November 8, 2026 11:45
… 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>
sarah-chitty-1 and others added 2 commits November 10, 2026 19:22
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>
Comment thread src/agent_env/task_step/task_step.py Resolved
…_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>
sarah-chitty-1 and others added 4 commits November 10, 2026 21:30
…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>
deploy_user_sim_agent.py, user_sim_versions.py, user_sim_common.py and user_sim.md move to task_steps/agent/, as Edgar
suggested; imports follow. deploy_agent.py moves in its own PR after #565-#567 merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sarah-chitty-1 and others added 2 commits November 11, 2026 19:31
…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>
sarah-chitty-1 and others added 7 commits November 11, 2026 19:41
…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>
Base automatically changed from sarahchitty/fd-5364-always-on-step to main November 12, 2026 18:35

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants