Repository navigation
Bots tell you when something needs you - #929
Conversation
The base every check-in slice builds on, so no slice touches the schema and there is one generated migration (0070_proactive_check_ins). - proactive_settings: new settings check in hourly (default 60 minutes, the 60 to 1440 check unchanged; rows at 240 keep it). - proactive_suggestions: dedupe_key and ping_id, a partial unique index that stops two check-ins opening the same open item for one person and Bot, and a partial index for the ping pass's unpinged items. - proactive_runs: the run ledger for the window, the per-Bot daily cap and the admin numbers, with one running check-in per setting and a skip reason only on skipped runs. - proactive_people: the per-person hourly ping slot, since one ping an hour folds every Bot's items for a person. - proactive_pings: one row per folded ping with the text it sends, keyed on the person. - bot_tasks: a task per started item in a conversation of its own, with no status column (state is read from the ledgers). - users.preferences.proactive, "on" by default, accepted by the preferences route, so a person can turn off check-ins for all their Bots. - server/src/proactive/contracts.ts: the route payloads and the calls between slices (startFromItem and counts, notifyAttention, proactiveAllowed and proactiveDailyRuns). Types only. A schema test pins the database backstops against real Postgres. It caught the skip reason check passing a skipped run with no reason (NULL IN (...) is unknown, and an unknown check passes), fixed before the migration was generated. The typecheck baseline line for an existing composio-connection-kinds error is reworded only: the new "unreachable" skip reason makes tsc print that error's union members in a different order.
Start on an item now opens a task: a new conversation with the item's Bot and a bot_tasks row, committed together, with the ask run there as the person's own turn through the approval continuation runner, so every send or change meets the approval gate. Ported from OpenDuet's task store, task threads and the task half of its engine. - tasks/state.ts: where a task stands, read on every request from the work queue, the approval inbox and the questions its Bot asked (needs input, working, resolved, failed, ready for review), with OpenDuet's taskStatus, plainFailure and next-step rules. - tasks/store.ts: the owner's tasks (open, and done for 7 days), one task, Mark done and Reopen, what a failed turn asked, and per-Bot counts. - tasks/turn.ts: the turn. The ask is the person's line, a stop for an approval or a question ends it waiting, and a failure on its last run says so in the conversation. - tasks/runner.ts: startFromItem (idempotent on the item), the proactive.task drain with two runs, a 3-minute lease and a 10-second retry, kick-now, the backstop sweep, and Try again keyed on the turn count so a double press runs once. A turn queued by the old Start gets its task when it runs. - tasks/routes.ts: GET /api/tasks, GET /api/tasks/:id, and POST resolve, reopen and again. Another person's task is answered like none. The channel store's create takes an optional transaction, so a task's conversation is never visible without its row, and direct() leaves task conversations out: a task's conversation is often the newest one with the Bot alone, and a follow-up looking for the person's own conversation would otherwise land in it.
A Bot now checks in the background hourly, reads only what is new, and
reaches the person only when something needs them: one ping an hour per
person, folding every Bot's waiting items, each named with its Bot.
Check-ins (proactive/engine.ts, window.ts)
- Gates before any model call, each writing a skipped run row with its
reason: person off, Bot paused, admin off, the Bot's daily cap (24 until
the admin controls answer), conversation unreachable, no app the Bot can
read, already running. The sweep no longer claims settings of a person
who turned check-ins off or paused the Bot.
- Each check-in runs in a fresh thread recorded on its run row, so an
hourly run never carries every earlier run and its mail.
- The window comes from the run ledger: since the start of the last quiet
or raised check-in, else the last day (first run, or after a failure).
The instruction is OpenDuet's check-in prompt, now listing open items
with ids, open tasks, what was hidden in 30 days with its app, and what
was started.
- A run that finds nothing is one quiet row: no item, no ping, no message,
no second turn. A run left running past its lease is closed as failed.
Items (store.ts, types.ts)
- An item needs a source this check-in read. Repeats are refused by
normalized title or source link against open items, unresolved tasks
and anything hidden in the last 30 days; dedupe_key and the partial
unique index stop two racing check-ins opening the same item.
- clear_next_step lets a check-in clear one of its own open items. Open
items are no longer replaced wholesale by each run.
Pings (ping.ts)
- tryPing takes the person's slot with one conditional UPDATE on
proactive_people, writes the ping and marks its items in the same
transaction, then offers it. The sweep's ping pass sends what waited
for the hour and re-offers pings a stopped replica never offered.
Items of a Bot set to notify nothing, and everything while the person
or an admin has check-ins off, are never pinged.
- Until delivery takes pings, one notification goes through the
existing notify path into the person's conversation with the Bot whose
item is newest.
Routes and counts
- Start marks the item started and opens a task through
tasks.startFromItem, answering { suggestion, task }; without the tasks
module it is refused, and a failed task open puts the item back.
- GET /api/proactive/settings gains allowed. Items carry sourceRef and
pingId.
- GET /api/bots/attention gains per-Bot items and tasks, and the
person's lastPingId.
- New check-in settings and default adoption run hourly (60 minutes).
The app half of proactive check-ins, against the shapes in
server/src/proactive/contracts.ts.
- Needs you, on the Bots page and on each Bot's page, now lists in one
card the Bots with a question, an approval or a stalled hand-off, the
tasks started from check-ins, and what every Bot's check-ins found.
Each item says what it is, why, which Bot found it and where it read
that (with Open), and offers Start and Hide. One ping folds several
Bots' items, so every row names its Bot.
- Tasks are listed in OpenDuet's order (ready for review, needs input,
failed, working), with Open for the task's own conversation, Mark
done, and Try again once one couldn't finish. A task whose
conversation is running a turn reads Working. Tasks marked done this
week fold under Done, where they can be reopened.
- The sidebar badge counts items and the tasks that wait on the person.
A task waiting on input is counted once, as the approval or question
it waits on.
- One browser notification per ping, never per item, named like the
phone push ("One thing needs you: ..."), tagged by the ping so two
tabs show one.
- Settings, Notifications gains the person's Check-ins switch for all
their Bots, which says when an admin turned check-ins off, and a
"Something needs you" routing row once the server routes pings.
- A Bot's own background research is named Check-ins, as the switch
is, turns on hourly by default, and says when check-ins are off for
all the person's Bots or turned off by an admin.
- OpenDuet's Active view lists the tasks under its own headings; Up
next and OpenMuse's Ideas use the same item row.
… task's wait once - index.ts: the task store and runner (approval continuation runner for its turns), a 60-second task sweep that logs task-sweep-error and stops on SIGINT and SIGTERM, and the engine's tasks and readableApps (a Bot with none of the person's apps to read is skipped before the model). startTask is gone from the engine and its deps. The attention route gets tasks for its counts. The comment above the engine describes check-ins and pings. - app.ts: /api/tasks. - audit-retention.ts: the retention timer also prunes the check-in ledger, whatever the audit policy: finished runs older than 30 days, and pings older than that unless an open item still names one (that ping is what keeps the item from being pinged again) or it is the person's newest. - activity.ts: the attention counts leave out approvals and questions raised in a listed task's conversation or thread. The task already counts them as needing input, so a task waiting on one approval was counted twice. The task list's own rule for which tasks it shows is exported (listedTask), so both count the same tasks. Tests: the ledger prune and its schedule, and the attention counts for a task waiting on an approval and a question beside an approval in the person's own conversation (fails on the previous activity.ts: approvals 2, questions 1).
The attention response is now the one place that decides what is counted where: a task's own approval or question is counted as the task needing input and left out of approvals and questions (activity.ts). The app had been leaving tasks.needsInput out of its badge for the opposite reason, so together a task's wait was counted nowhere. The app now adds every number as the server gives it: the badge and the browser notification include tasks waiting on the person (asksOf), the summary names them, and the roster still lists a Bot under Needs you for its own questions, approvals and stalled hand-offs, with its tasks shown as tasks. The new badge test fails on the merged app (no notification, a badge one short). The server's person-facing sentences say Check-ins and conversation, as the app does: "Check-ins aren't available right now", "Turn on check-ins for this Bot first", and the rest.
…asks From the first end-to-end run of the proactive core loop. - A settings change changes only the fields sent. The patch schema was picked from the input schema, and Zod keeps a field's default through .partial(), so turning check-ins off reset the interval and focus, and changing how often turned them back on and ran one at once. Turning them on (or creating them) now schedules the first check-in one interval out; only Run now runs one at once. - Run now is never a silent nothing. It refuses plainly while one runs or within ten minutes of one that reached the model, skips at once with its reason when a gate would stop it anyway, and is queued under a key that names the setting's newest check-in, so a skip no longer blocks the next press for the rest of a ten-minute bucket. - Each setting carries its last check-in from the ledger, and the Bot's check-ins say what it did: found nothing, found something, couldn't finish and why, or was skipped and why. - The check-in row leads with how often and when it runs next; a long focus follows on one line. - Turn on check-ins is offered only for a Bot without them, and its conversation picker leaves out task conversations (the conversation list now says which is a task's). - An item's Open button names the site its link goes to, not the app it was read with. - A task waiting on the person says what for (the approval or the question), never the person's own last message quoted back. - A check-in is put to the model as a check-in, not wrapped in the routine frame, and says whether it is on its schedule or the person pressed Run now. - A check-in that reached the model leaves a model_usage row, as a browser run does. - A task started from a check-in asks before every send or change, whatever "Ask before making changes" or the person's rules would allow. Reads still run freely.
A task started from a check-in asks before every change whatever the person's rules say, so a standing rule saved from one of its approvals would be ignored the next time and only mislead. Its approvals now offer Allow once and Deny: in the conversation's card, on the Bot's page, on the Slack and Teams card (an older card's Always is taken as once), and in the mobile app. The server refuses to save a rule from one, in case anything still asks it to.
A check-in now reads every app the person connected with their own account, whatever kind of connection it is, and nothing an admin connected for everybody. Whose credential a server runs on is declared where each kind of server is resolved (ServerAccess.ownCredential), and whether the person connected it is one store answer across every table (ownConnections), so a connection kind added later falls under the same rule without the check-in code knowing its name. Team Bots another person published read only the person's own account, with a standing consent. In those apps a check-in calls only tools that say they only read: a broker's read label, a reviewed list, or the MCP server's own readOnlyHint, which is now recorded as its own fact (mcp_tools.declared_read, migration 0071) and used only for check-ins; chat approvals are unchanged. A destructive label or a reviewed write always wins. The dispatcher refuses anything else during a check-in too, and judges a labelled read as a read, so a check-in never opens an approval nobody is there to answer. Bots on another framework check in on OpenBot's own runner, as themselves, so their endpoint and the tools it holds out of OpenBot's sight are never called while nobody is watching. Skill narrowing is off for check-ins, so no connected app is dropped. The instruction leads with its one question (is there something the person needs to do that they probably don't know about yet, in what they connected), names those apps, and drops the Gmail and calendar wording. A Bot that can read nothing the person connected is skipped with "Nothing to check: this Bot can't read anything you've connected. Connect an app with your own account." Hardening: a server can no longer be added under the memory tools' id, and a connector's memory/recall_personal_memory no longer passes as the person's memory. Notion names its reads, so a tool Notion adds later is a write until it is listed.
…pen Needs you on the phone
A ping is per person and folds every Bot's waiting items, so delivery takes it whole
(notifyAttention, delivery kind and routing kind `attention`) instead of riding a Bot's reply
through one conversation. It goes once to each push device, titled for the person ("3 things need
you") with a line per item naming its Bot, and a tap opens the mobile app's Needs you list. On
Slack, Teams and SMS it is one message per platform, into the account the person used there last
(by the last message they sent through it), passing over one whose conversation they lost; that
conversation's Bot posts it and a reply there reaches that Bot, as any reply does. A person with
no chat account gets the push and the app. Their routing for "Something needs you" picks the
transports; a Bot on notify nothing already contributes no item, so delivery reads no per-Bot
level for a ping.
Dedupe keys are per platform and per device, not per account, so a ping offered again (a replica
stopped before marking it offered) sends nothing new even after the person used another account.
The push is filed under the newest item's check-in conversation the person can still reach, so a
send is refused, as any is, once they can reach none.
The proactive engine drops its interim path (one `reply` notification through a Bot's
conversation) and requires `attention`. The ping's push body becomes a line per item naming its
Bot; the headline is the push title.
Mobile: a Needs you screen (open items with Start, Hide and Open, and the tasks that wait on the
person), linked from home and opened by `openbotmobile://needs-you`; a Bot's screen lists its own
open items. The link parser accepts needs-you with nothing else in it.
The engine hands a ping to the delivery router's attention path instead of the interim reply notification, the router learns each person's delivery scope, and the outbox and routing kinds include attention. The app's routing type now always carries the attention kind, since the server answers for it.
An admin can now stop check-ins, limit how often each Bot checks, and read per Bot how often people start what it raises, without seeing anything a check-in read. - Capability "Check-ins" (proactive), on by default, with organization and role rows and a group that only widens, like every other capability. It shows on Enterprise controls through CAPABILITIES. - Enterprise setting proactiveDailyRuns: how many check-ins each Bot may run for one person a day, 24 by default, a whole number from 1 to 1440, saved through PUT /api/admin/enterprise/settings/proactiveDailyRuns and audited like the others. - proactiveAdmin (admin/controls.ts), the engine's ProactiveAdmin: the switch and the cap read fresh, defaults without the controls, refusing when they can't be read. - GET /api/admin/proactive?days=7|30 (admin/proactive-metrics.ts), admins only: per Bot, people with check-ins on now, runs by outcome and skip reason, the window's items by where they stand, acceptance as started out of started and hidden, and items a ping carried; pings sent and items per ping for everybody. Counts only, no titles and no people. - Admin, Check-ins: the switch, the daily limit, and each Bot's numbers with a breakdown, in the app's own rows and dialogs, linked from the admin sidebar and overview. - BotProactiveMetrics gains avatarSeed so each Bot is drawn as everywhere else.
The engine reads the organization's Check-ins capability and the per-Bot daily cap from the admin controls instead of Slice A's defaults, and /api/admin/proactive serves the per-Bot activity numbers. The admin test's engine takes the attention path delivery now requires.
A ping's chat message went into the person's most recently used Slack, Teams or SMS account on each platform, whatever that account's Bot was set to. When that Bot was set to notify them of nothing, the folded ping still posted in its conversation, as that Bot. Each account is now asked the route its own Bot gets for its own updates, for `attention`: a silenced Bot's account is passed over for the next one the person used on that platform, and a platform left with none gets nothing while the push and the app still carry the ping. The push keeps the person's routing alone, since each Bot's preference was applied to its items when the ping was written.
A check-in that read, found nothing new and ended without a closing line was
recorded as failed ("the run ended without a reply"), and the next one went
back a whole day. Empty turns now throw a typed EmptyTurnError, and a check-in
treats one as quiet (or raised) when its reads answered. One that read nothing
and said nothing still fails; every other kind of turn is unchanged.
A check-in whose app could not be reached was recorded as quiet, and the next
one read only from its start, so a request that arrived while the app was down
was never raised. A run in which every call to an app failed, with nothing
raised, is now failed and the Bot's check-ins say "Couldn't reach Mail. The
next check-in will try again." The window opens from the last check-in that
read everything (quiet, or raised with no app left unread), so nothing that
arrived meanwhile is missed; a failed turn no longer widens it to a day.
No-repeat and Hide held per Bot, so four Bots reading one source raised the
same requests up to four times, and a request hidden on one Bot came back from
another. The repeat check now reads the person's open items, tasks and hidden
items across all their Bots, and the check and the insert run under one
advisory lock per person, so two Bots finding the same thing at once on two
replicas open it once. Another Bot's titles are never quoted to a Bot.
The browser notification for a ping now says what the phone says: "3 things
need you" over one line per item naming its Bot, each thing listed once.
A row read "push · attention" over "failed": the server's own names for the channel, the kind and the state. Each row now says what it was about in the words of the routing switches above it (Progress, Decision, Question, Something needs you), then the channel and where it got to.
The Bots list said "2 suggestions" where the badge and Needs you say "2 things need you". The summary under each Bot, and the OpenMuse sheet that shares it, now use the same words.
An app's tool is asked about as <app>/<tool> on the app, so its approval read "requests/get request on requests" in the conversation, on the Bot's page and as a waiting task's next step. It now reads "get request on Requests", named the way the conversation already names the call. Computer actions and commands keep their words. The tool naming moves from the app to shared/tool-name.ts so the server's task line and the app's cards say the same thing.
Each item stacked three system buttons, which are filled, upper-cased bars on Android and the web. Start, Hide and Open are now link-coloured text, as the Bots list and a Bot's Customize rows are, and a task card opens its conversation the way a Bot row opens its Bot. A lint rule keeps the system Button out of these two files.
…apps unasked, and carry on after Allow once A Bot's MCP calls ran with no approval context in a real conversation: the AI SDK runs a step's tools from the provider stream's flush, and Bun calls that flush with no async context when the connection ends after the last event. So a task's changes ran unasked and every send to other people was refused as "not part of a run that can ask". A native tool call now finds its run by its call id when the scope is gone. In a task's conversation a connector send asks once, on the task's own card, not again as a private share. A task runs unasked only a read of an app the person connected with their own account whose server says it only reads; every other read asks, a browser look included. The browser lets go of a wait the server already answered, so the message after Allow once is sent, and a message that cannot leave the browser goes back in the box with a notice. The Bot's reply after the browser answers a call is reported, and a task's next step is the Bot's own outcome: never the person's words, never a source footnote.
guidovizoso
left a comment
There was a problem hiding this comment.
Nice work. The feature is complete across every surface, the tests run through real Postgres and real MCP calls, and the Bun approval-context fix is a real improvement.
I'm requesting changes for four places where a task can get around "asks before every send or change". Those four are marked Blocking inline:
approvals/service.ts: a task under the default "ask" switch isn't taggedtask, so it offers "Always allow" and its waits can be withdrawn.plugins/catalogue.ts: a write can run unasked in a task, because the code trustsreadOnlyHintand treats any tool missing from a write list as a read.toolkit/native-approval.ts: the fallback matches tool-call ids across every user's open runs, so a call can attach to another person's conversation or fail.approval-tools.tsx: page reads in a task are only gated if the browser's channel cache happens to contain the task's channel.
The other five comments are non-blocking (marked Non-blocking or Nit). These findings come from reading the code, not from reproducing them in a running instance, so push back if I've misread a path.
A task under the person's own "Ask before making changes" (or one of their ask rules) kept source default or personal_rule, so its card offered Always allow and switching the setting off or saving an allow rule withdrew what the task was waiting on. Any ask in a task is now source task; a hand-off or a refusal keeps its own source.
A task trusted two signals that don't prove a call is a read: the server's own readOnlyHint, and a name missing from an entry's write list. A task now runs a call unasked only when a reviewed readTools list names it; anything else asks, including a broker's best-effort read tag. Google Drive's entry names its four adapter reads so they stay unasked. Check-ins keep their label-based rule.
…bject The assertion-shape guard refuses expect(x?.y).toBeUndefined(), which passes when the entry itself is missing.
When Bun drops the async context at the end of a model stream, a native tool call was matched to its run by tool call id across every open run in the process. Ids are not unique (scripted models and some providers number them), so a call could fail, or be asked in another person's conversation. Each built-in agent now holds the run it is in and its tools re-enter exactly that run; an agent runs one run at a time, so the match holds by construction.
The browser sent a Bot's page look straight to the computer unless the task's channel happened to be in its cached conversation list, so a task opened by a link, or past the list's first page, read pages without asking. The browser now asks GET /api/approvals/threads/:threadId, answered by the gate's own rule, and any answer but a plain no sends the look through the gate. In a task's conversation a step with no conversation to ask in is not taken at all.
The pass took the 20 oldest waiting people and stopped. Someone refused by the admin's Check-ins switch keeps the items raised before it, so 20 of them at the front meant nobody behind them was ever pinged by the sweep. The pass now asks each person once, skips the refused, and keeps going until it has pinged 20 or nobody is left.
Nothing between beginRun and finishRun was guarded, so a transient database error in recordRun, priorItems, countSuggestions or finishRun left the run row running until the lease sweep closed it ten minutes later. Meanwhile the next scheduled check-in skipped as busy and Run now said the Bot was checking in. The run is now closed as failed, and its status recorded, before the error goes on.
An automatic retry got a new run id, and the ask was recorded and sent under it, so after one provider failure the task's conversation showed the ask twice and the model saw two identical user messages. The ask is now keyed on the turn's first run, and a headless turn whose message the thread already holds does not add it again.
Every item raised, memory formed and unreachable app listed the whole catalogue again, so a check-in with three items and a failed app listed it five times, hourly for every Bot. A check-in now lists it once and names every app from that listing; a listing that failed is not kept.
The browser repeated the server's offersStandingRule check. It now lives in shared/standing-rule.ts, read by the approval store, the chat card and the browser's approval cards, so the two sides cannot drift apart.
The browser now asks the server whether a conversation asks before every step before a Bot's look goes straight to the computer. The hand-back test's fake server answered that with a 404, so the look went through the gate, which the fake does not serve. It now answers as the server does for a person's own conversation. The approval-tools test also stops recording an undefined body, which the assertion-shape guard refuses.
# Conflicts: # app/src/components/channels/channel-chat.tsx
A GET is recorded without a body, which the previous commit made explicit, so the record's type has to allow it.
Main now has 0070_memory_dedup_per_bot, so this branch's migrations move after it: 0071_proactive_check_ins and 0072_check_in_read_labels. Both were regenerated with drizzle-kit on top of main's 0070 snapshot and came out byte-identical to the old SQL; 0071's snapshot chains from main's 0070 and 0072's from 0071.
|
Renumbered this PR's migrations to 0071_proactive_check_ins and 0072_check_in_read_labels, after main's 0070_memory_dedup_per_bot (27829a3). |
guidovizoso
left a comment
There was a problem hiding this comment.
Approving. Thanks for the quick and thorough turnaround: every finding has a fix and a regression test, and CI is green. Carrying the run on the agent's own tools (instead of narrowing the call-id lookup) is a better fix than the one I suggested.
I left three non-blocking notes inline. One is a product decision worth recording: check-ins still trust a server's own read label. The other two are small edge cases. None needs to hold the merge.
Main is red: `proactive-connected-reads.integration.test.ts` fails on "a Bot on another framework checks in on OpenBot's own runner". **Cause:** #929 and #940 each passed alone but don't compose. #940 put `COMPONENT_GUIDANCE` between the provenance and untrusted-content guidance in a Bot's standing role. #929's check-in runner (`onOpenBotRunner`) strips that guidance from a remote Bot's role before adding OpenBot's own, but only when the role ends with provenance then untrusted-content, which it no longer did. A harness Bot's check-in carried all three twice. **Fix:** `STANDING_GUIDANCE` now matches what `standingRoleMessage` ends with. The test now checks that each of the three is said once. **Checked:** the test fails on main and passes with the fix. The other server tests that use the standing role (130) pass, along with format, lint, server typecheck and test typecheck.
Bots now tell a person when something needs them.
What it does
Also fixed on the way: on Bun, a Bot's tool calls lost their approval context when a real model's stream closed. That meant private-share sends were refused, and changes could run unasked with "Ask before making changes" on. Each built-in Bot now carries the run it is in, and its tools re-enter that run, so a call can never land in another person's conversation. The regression tests run through a real MCP server on the Bun this repo pins.
Migrations:
0071_proactive_check_insand0072_check_in_read_labels, after main's0070_memory_dedup_per_bot. #927 and #931 also add a0071, so whichever merges second renumbers.Tests: regression tests through real Postgres, the real engine, delivery router, outbox, task runner, approval gate and MCP calls, with the scripted model as the only fake. Full checks locally: 11,389 tests pass. Two timing tests failed under load and passed when run alone: a Recorder test (3 of 3) and a Desktop test, and this branch touches neither area. The Desktop test that needs port 3001 can't run on this machine. An end-to-end run in a real browser covered check-ins against an MCP server, pings on every channel up to each vendor's API, tasks, Hide, the switches and the admin page. Its failures are fixed here.