Skip to content

Bots tell you when something needs you - #929

Merged
davidmckayv merged 47 commits into
mainfrom
david/proactive
Nov 2, 2026
Merged

davidmckayv merged 47 commits into
mainfrom
david/proactive

Conversation

@davidmckayv

@davidmckayv davidmckayv commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Bots now tell a person when something needs them.

What it does

  • A Bot the person has talked to quietly checks what that person connected, at most once an hour. That's any app or server they connected with their own account (Composio, Arcade, MCP). It only reads.
  • When something needs the person, the Bot raises an item that says why and where it came from. When nothing does, nothing is sent; a quiet check is one database row.
  • The person hears at most once an hour, with every Bot's items folded into one message, each labelled with its Bot:
    • in the app: Needs you, a badge and a browser notification;
    • by phone push, which opens Needs you;
    • on Slack, Teams or SMS, in the conversation they used last, skipping a Bot they silenced.
  • Start turns an item into a task in its own conversation. It always asks before any send or change, and its approvals never offer "Always allow". The only thing it does unasked is a read a reviewed read list names, on an app the person connected with their own account; a tool only labelled a read, by its server or a broker, asks. Hide keeps the item from coming back for 30 days, across all of the person's Bots.
  • Settings → Notifications has a Check-ins switch and a "Something needs you" row for where these go.
  • Admin → Check-ins has an organization switch, a daily limit (24 per Bot), and per-Bot numbers: runs, items started and hidden, and pings. Counts only, no titles or names.

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_ins and 0072_check_in_read_labels, after main's 0070_memory_dedup_per_bot. #927 and #931 also add a 0071, 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.

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 guidovizoso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. approvals/service.ts: a task under the default "ask" switch isn't tagged task, so it offers "Always allow" and its waits can be withdrawn.
  2. plugins/catalogue.ts: a write can run unasked in a task, because the code trusts readOnlyHint and treats any tool missing from a write list as a read.
  3. 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.
  4. 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.

Comment thread server/src/approvals/service.ts Resolved
Comment thread server/src/plugins/catalogue.ts Resolved
Comment thread toolkit/src/native-approval.ts Resolved
Comment thread app/src/lib/copilot/approval-tools.tsx Resolved
Comment thread server/src/proactive/ping.ts Resolved
Comment thread server/src/proactive/engine.ts Resolved
Comment thread server/src/tasks/runner.ts Resolved
Comment thread server/src/proactive/engine.ts Resolved
Comment thread app/src/lib/approvals.ts Resolved
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.
@davidmckayv

Copy link
Copy Markdown
Contributor Author

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 guidovizoso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread toolkit/src/run-turn.ts Resolved
Comment thread server/src/proactive/engine.ts Resolved
@davidmckayv
davidmckayv merged commit 45ecd74 into main Nov 2, 2026
19 checks passed
@davidmckayv
davidmckayv deleted the david/proactive branch November 2, 2026 20:31
davidmckayv added a commit that referenced this pull request Nov 2, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants