Skip to content

feat(agents): the agent schedules its own follow-ups on the daemon clock - #5694

Open
kovtcharov wants to merge 27 commits into
amd:mainfrom
kovtcharov:kalin/agent-follow-ups
Open

kovtcharov wants to merge 27 commits into
amd:mainfrom
kovtcharov:kalin/agent-follow-ups

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

GAIA couldn't follow up on anything by itself. "Check on the build in 2 hours" or "remind me tomorrow at 9" got a cheerful "Set!", but nothing ever ran or alerted. A reminder was just a memory note that came up the next time you chatted (#5681). Now the agent schedules its own one-shot follow-ups. At the set time, even with no chat open, GAIA runs the task, posts a desktop notification, and opens your next chat, in the terminal app or the Agent UI, with the result. It builds on the daemon's existing clock, which until now had no executor at all, as docs/plans/autonomy-engine.mdx §5 planned.

Test plan

  • pytest tests/unit/test_daemon_followups.py tests/unit/test_follow_up_tools.py: 27 pass. They use the real daemon clock and store on a temp DB, with the agent call and notification stubbed. They cover:
    • time phrases, and the limits (1 minute to 7 days, at most 10 waiting);
    • a due follow-up running, notifying and keeping its answer;
    • one that isn't due not running;
    • a failure being kept so the user hears about it;
    • a failed notification still keeping the answer;
    • each result reported once;
    • the routes;
    • the tool sending the chat's model;
    • the per-turn check only attaching to a running daemon, never starting one.
  • test_memory_mixin.py adds a test that the timed-reminder rule appears only where follow-ups exist. 577 pass across the memory, scheduler, bundle, relay-profile, prompt-context, registry and capability-matrix suites. All 783 daemon tests pass except one that fails on main too under a custom GAIA_HOME.
  • vitest (components): 1042 pass; tsc clean.
  • Live on macOS, DeepSeek via Fireworks, an isolated daemon:
    • "In 2 minutes, count the .txt files under …" fired on time and answered correctly (8, each listed), with a banner. The next session opened with the result, and the one after didn't repeat it.
    • "Remind me in 2 minutes to stretch" fired with a banner.
    • "Remind me tomorrow morning to call mom" became a job for 9:00, and cancelling one worked.
    • In the 24-step memory suite, every step still passes. "Dentist next Tuesday at 3pm" now also schedules a 2:30pm heads-up on its own.
  • Fixed during live testing: the first fired run used the local default model instead of the chat's cloud model, and the next tried a shell command that an unattended run must refuse. Follow-ups now carry the chat's model, and the fired prompt names the no-approval tools to use.
  • Memory and tool-selection eval categories on the next RC: this adds tools, a memory-prompt line for timed reminders, and a sentence in the Agent UI scheduling line.

Not in this PR: the cheap pre-fire gate and the agent noticing on its own that it's waiting (§5, #3969). No Windows banner yet: the existing notification sink only supports macOS and Linux, and on Windows the result is still kept and shown in the next chat.

Part of #555 (the agent schedules its own follow-ups; proactive messaging beyond that is still open there).

"Check on the build in 2 hours" or "remind me tomorrow at 9" had nowhere
to go: a reminder was a memory note that only came up in the next chat, and
the daemon's clock had no way to register a job and nothing to run one.

- schedule_follow_up, list_follow_ups and cancel_follow_up register one-shot
  agent_follow_up jobs through new /daemon/v1/followups routes: a minute to
  7 days ahead, at most 10 waiting.
- The clock's first executor runs a due one as a fresh /v1/gaia/query turn on
  the model the chat used, keeps the answer (or the failure), and posts a
  desktop notification.
- The next turn's context opens with finished follow-ups, then marks them
  seen. It only attaches to a running daemon, never starts one.
- With follow-ups available, a reminder at a time is scheduled as well as
  remembered, so it alerts.
@github-actions github-actions Bot added documentation Documentation changes tests Test changes agents daemon Daemon supervisor / sidecar control plane labels Oct 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Request changes

This PR gives GAIA working follow-ups: "check on the build in 2 hours" now runs on the daemon's clock, posts a desktop notification, and the result shows up in the next chat. The design holds up: real limits, a fired run that can't use approval-gated tools, and tests that run the real clock and store. One delivery bug needs fixing before merge, because it can stop the user from ever hearing a result.

  • One follow-up's run can hide another's result. Each turn, the agent collects unseen results and marks them seen. That includes the unattended turn the daemon starts for a fired follow-up. If two follow-ups fire before the user chats again, the second run takes the first one's result. The first result then reaches the user only if the second answer happens to repeat it. The fired run should skip that step, or results should be marked seen only once a user-facing turn has used them.
  • A failed turn loses the result. Results are marked seen before the model answers. If that turn errors (backend down, timeout), the result is gone. Also, if marking them seen fails, the error escapes and breaks the turn. That step can also start a daemon, which the code says a turn must never do.
  • Agent UI screenshot still to come. The description shows live runs as text, which is fine for the daemon and tool path. The Agent UI part (new activity labels, plus the new line in its scheduling prompt) has no before/after screenshot yet. Please add one, but don't hold the merge on it.

Real-world evidence

N/A: no evidence bundle was produced for this run. This verdict comes from reading the code, plus the author's own live macOS test notes in the PR description (scheduling, firing, notification, cancelling, each result shown once). Nothing was re-run here.

🔍 Technical details

🟡 Fired follow-up run consumes other follow-ups' results (src/gaia/agents/tools/follow_up_tools.py:539-570, src/gaia/daemon/followups.py:1105)
ask_sidecar sends the fired prompt to the gaia sidecar's /query. That runs MemoryMixin.process_query → get_memory_dynamic_context() → follow_up_context(), which GETs every unseen result for agent_id="gaia" and marks it seen (memory.py:3474). Scenario: A fires at 10:00, B fires at 10:05, and the user next chats at 11:00. B's run marks A seen, and the 11:00 turn sees only B. A test covers "each result reported once", but not two results firing before a user turn. Options:

  • The daemon passes a marker on the fired body (e.g. a follow_up_run field on the query, or a header), and follow_up_context returns "" for that run.
  • Or tag results with an origin and skip them in daemon-initiated turns.
    Add a test with two jobs firing back to back, followed by one user turn.

🟡 Results marked seen before delivery; mark-seen failures uncaught (follow_up_tools.py:569, :515)

  • _mark_follow_ups_seen runs before the LLM call. A turn that then fails drops the result for good. Consider marking seen after the turn completes (e.g. in _after_process_query), keeping the fetched ids on self.
  • _mark_follow_ups_seen sits outside the try, so a DaemonError propagates out of get_memory_dynamic_context and fails the whole turn. That breaks the docstring's promise ("logged and leaves the turn without the line").
  • It calls followups_request with the default start=True, which contradicts the "attach only" comment a few lines up. Minimum fix:
        try:
            self._mark_follow_ups_seen([r["job_id"] for r in results])
        except DaemonError as e:
            logger.info("follow-up results not marked seen this turn: %s", e)
        return "\n".join(lines)

Also pass start=False, timeout=3.0 through _mark_follow_ups_seen when it's called from the per-turn path. The same uncaught raise exists in _list_follow_ups. There it fails a tool call, which is less severe.

🟢 parse_when uses a fixed UTC offset (followups.py:872, :914)
datetime.now().astimezone() gives a fixed offset. "Tomorrow at 9am" across a DST change fires an hour off. zoneinfo/tzlocal-aware arithmetic would fix it. Low impact.

🟢 registry.py import reflow is unrelated to the feature
The GPU_CTX_SIZE, NPU_CTX_SIZE import collapse is a drive-by change. Harmless, but it's outside the PR's scope.

Strengths

  • The executor reuses the existing clock's claim, heartbeat and fail-marking. A failed run is recorded and re-raised, so nothing is swallowed, and _record is idempotent across stale-lease recovery.
  • Strong guardrails against a runaway agent: one-shot jobs, 1 minute to 7 days ahead, at most 10 waiting, a cap on task length, and a fired prompt that states the no-approval tool limits.
  • Every bundled doc changed together, consistently (README, SPEC, SKILL, CHANGELOG, capability matrix, tool counts).

Kalin Ovtcharov added 5 commits October 23, 2026 18:10
…import order

The Chat agent's full profile now registers the three follow-up tools, so its
manifest's tools_count is 68, not 65. The registry import goes back to the
wrapped form isort expects.
# Conflicts:
#	hub/agents/gaia/npm/CHANGELOG.md
#	hub/agents/gaia/npm/README.md
…e first

Three ways a result could be lost before the user heard it:

- A fired follow-up's own run read the unseen results and marked them seen,
  so when two fired before the user chatted again, the second run used up the
  first one's result. Each fired run now runs in its own follow-up-<job id>
  session, and the agent skips the follow-up line (and listing) there. The run
  also says it can't answer a question mid-run.
- Results were marked seen before the model answered, so a failed turn lost
  them. They are now marked seen after the turn, and only when it answered.
- Marking seen could raise out of the turn and could start a daemon. It now
  attaches only and is logged on failure.

"Tomorrow at 9am" also keeps its hour across a DST change: wall-clock times
take the offset for the day they land on.
…own tests

The unit suite runs without the hub agent packages installed, so importing
gaia_agent_chat there failed every platform and tripped the import guard. The
session-id check moves to the chat agent's tests; the unit test keeps the
daemon-side assertions.
The chat agent's follow-up tools and its test_chat_follow_ups_told.py import
gaia.daemon.followups, so a change to it must run that lane.
@github-actions github-actions Bot added the devops DevOps/infrastructure changes label Oct 24, 2026
grtninja pushed a commit to grtninja/gaia that referenced this pull request Oct 25, 2026
)

`test_trickle_just_under_the_idle_limit_is_not_killed` failed the Linux
unit job on amd#5694, a PR that doesn't touch the stream watchdog: the
stream was cut off as stalled. The test's server paused 1.5 s between
bytes against a 2 s idle limit, so 0.5 s of scheduling delay on a loaded
runner was enough. It now pauses 0.8 s eight times, which still adds up
to more than three idle limits. A watchdog that ignored the trickled
bytes would still kill the stream, so the test still catches what it's
for.

## Test plan
- [x] `pytest tests/unit/test_stream_stall_watchdog.py` (24 passed)
- [x] The changed test three more times (3 passed, ~6.8 s each)

Co-authored-by: Kalin Ovtcharov <kalin@Kalins-Mac-mini.local>
Kalin Ovtcharov and others added 3 commits October 27, 2026 23:53
# Conflicts:
#	hub/agents/gaia/npm/SPEC.md
#	src/gaia/apps/webui/src/components/AgentActivity.tsx
…witches

A follow-up scheduled from a chat with internet off, or from a private chat,
ran later with internet on and memory saving, because the fired run started
in a fresh session with the sidecar's defaults. The tool now records the
chat's internet_off and memory mode with the job, the daemon route accepts
them, and the fired /query carries them alongside the model.
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

A follow-up scheduled from a chat with internet turned off, or from a private chat, ran later with internet on and memory saving. Fixed in 25972b5: the fired run now carries the chat's internet and memory switches along with its model.

The other permission paths hold: a fired run that reaches an approval-gated tool ends with a refusal the user sees in the next chat, rather than waiting or approving. The earlier review's delivery findings are addressed by f4d5cfa.

🔍 Technical details
  • The fired /query used a fresh follow-up-<id> session and sent neither internet_off nor memory, so the sidecar fell back to its defaults (internet_off=False, memory on).
  • _run_switches() in follow_up_tools.py records internet_off and, when the agent is incognito, memory: "private" | "off". CreateFollowUp accepts both (memory is a Literal, so an unknown mode gets a 422). followups.run_settings() forwards only model, internet_off and memory, so other payload keys can't reach the query body.
  • AskAgent's third argument went from model to a settings dict. The tests cover the payload round trip, the route, the ask_sidecar body, and the private, memory-off and internet-off chats.
  • Gate check: with can_answer_questions: false, _declare_who_answers sets nobody_answers, and needs_confirmation ends the stream with _confirmation_refusal (test_server_query.py). The only way past it is the process-wide GAIA_AUTO_APPROVE opt-in, the same as for any unattended run.

Kalin Ovtcharov added 2 commits October 29, 2026 12:08
…ol set

The full profile now registers schedule_follow_up, list_follow_ups and
cancel_follow_up, so its characterization goldens list them. The other
rows and every captured prompt are unchanged.
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

Holding this for eval results: the permission fix checks out, but the new always-on "remind me" tool sits right on top of memory scenarios that use "remind me" to mean "tell me again", and there are no eval numbers yet showing the agent still recalls instead of scheduling.

Before merge, please run gaia_memory, memory, gaia_tool_selection and tool_selection (memory, gaia-signal, gaia-tasks and tools lanes in eval_flagship.yml), plus gaia_skills_tasks, and compare against the flagship baseline.

🔍 Technical details

Why this plausibly shifts baselined behaviour

  • schedule_follow_up joins the always-on set (16 → 17), so every turn's tool list carries it. Its bundle description leads with "remind", and the new memory-prompt line tells the model to call it for "A reminder at a time … tomorrow morning".
  • Baselined turns that say "remind me" but mean recall:
    • gaia_memory/mem_correction_in_passing: "remind me, where do I live?"
    • gaia_memory/mem_forget_then_verify: "I need to book a cleaning — remind me who my dentist is?"
    • gaia_memory/mem_long_horizon_distractors: "OK — remind me when and where my talk is?" This one also seeds a dated event. The PR's own live test notes that a dated appointment now gets a heads-up scheduled without being asked.
  • gaia_skills_tasks/task_checkin_follows_up scores "follows up" behaviour and ends on "run starts tomorrow.", which is a natural spot for an unrequested schedule_follow_up.
  • The Agent UI scheduling line now steers one-off requests to the tool, which touches tool_selection and gaia_tool_selection.

What I checked and found sound

  • Fired runs send can_answer_questions: false. run_settings() filters the payload to model/internet_off/memory before it is spread into the body, so nothing can override that flag. _declare_who_answers then sets nobody_answers, and gated tools refuse instead of waiting.
  • No Always-allow grant carries over. session.permissions.attach is per session, and each fired run uses a fresh follow-up-<job_id> session.
  • 25972b5: _run_switches() forwards internet_off and, when incognito, memory: private|off. The sidecar's _apply_internet_switch/_apply_memory_mode honour both, and tests cover the route, the payload and the ask_sidecar body.
  • CI is green on 5ed1da9 (84 pass, 12 skipped), there are no conflicts, and the earlier review's delivery findings are addressed by f4d5cfa.

Kalin Ovtcharov added 4 commits October 29, 2026 15:35
ChatAgent moved into the flagship package (amd#6139), so the follow-up
mixin, profile group and bundle now land in gaia_agent.chat_agent,
gaia_agent.profiles and gaia_agent.tool_bundles, and the told-marking
test moves to the flagship's tests.
Tool labels moved to the i18n locales (amd#6165), so the follow-up tools
get their icon in TOOL_ICON and English and Arabic labels there.
Kalin Ovtcharov added 3 commits November 1, 2026 07:45
Main's flagship guide now counts load_tools as the 102nd tool; with the
three follow-up tools it is the 105th.
…golden

The memory prompt now tells the model to schedule a follow-up for a
reminder at a time; the golden from main predates that line.
Keep main's Gemma tool-tier note and its "on a larger model" wording,
with the counts that include the follow-up tools (104 tools, 69 in the
full profile).
main now ships forget_everything (amd#5687), so both changes count:
105 tools, 70 in the full profile, 69 for the Chat agent, and 18 core
tools. The dynamic-tools cap grows to 31 so 13 slots stay free for
loaded bundles, as the cap's comment asks when the core set grows.
Kalin Ovtcharov and others added 7 commits November 6, 2026 14:34
main now starts sessions on a 27-tool core and finds the rest with
search_tools. schedule_follow_up stays in that core so a reminder
request always reaches it, which makes 28 core tools and 106 in all.
The guide's "26 most-used" becomes 28 core tools.
main adds analyze_transcript and export_transcript; with the three
follow-up tools that is 108 in all, 72 in the full profile and 71 for
the Chat agent.
On Windows the follow-up scheduling test failed by a tenth of a
microsecond: datetime.timestamp() and time.time() disagree at that
scale, so a later clock read could land the 2-hour gap just over 7200 s.
The check now brackets fire_at between clock reads taken before and
after the call.
cancel_follow_up now accepts only ids in the form the daemon mints them and returns an actionable error for anything else without contacting the daemon. The id is also URL-quoted when building the request path.
# Conflicts:
#	hub/agents/gaia/npm/SKILL.md
# Conflicts:
#	hub/agents/chat/python/gaia-agent.yaml

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

agents daemon Daemon supervisor / sidecar control plane devops DevOps/infrastructure changes documentation Documentation changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants