connect: hand off an operator's ping

Merged
RO
1d ago
+1573-100

An agent like R2-D2 now answers when its operator pings it. Today basecamp connect blocks every line in a Ping. The line was never handed off, so a direct message to your own agent went nowhere (#903).

This depends on #913 (the pings commands) and on the unreleased SDK in basecamp/basecamp-sdk#1015, through #913's DO-NOT-MERGE replace commit. basecamp/basecamp-sdk#1015 is also where Summarize learned to find a Ping line's Campfire. It needs bc3 #13943 server-side. The base is pings-commands.

Who can now make the agent run

WhoDirect Ping with the agentGroup Ping with the agent
The operatorEvery line is a request (trigger: pinged, owner: true)Only a line that @mentions the agent (mentioned)
Someone the allowlist names (--allow)Every line is a request (role: operator, owner: false)Only a line that @mentions the agent
Someone let into this Ping (--allow-in-ping)Only a line that @mentions the agent (mentioned, role: participant, owner: false)Only a line that @mentions the agent, the same
The same person in any other PingDiscarded, untrusted_performer (gate, no read)Discarded, untrusted_performer
A project member under --trust projectDiscarded, untrusted_performerDiscarded, untrusted_performer
Anyone elseDiscarded, untrusted_performerDiscarded, untrusted_performer
The agent itselfDiscarded, agent_authored (gate, no read)Discarded, agent_authored
Another agent, allowed (--allow-agent) or notDiscarded at the gate, no readDiscarded at the gate, no read
An agent acting for the operatorDiscarded, delegatedDiscarded, delegated

Pings are on by default and need no setup. Existing setups keep working without a re-run. There is no switch to turn pings off. A run narrowed with --project hears no Ping, because the feed filter already drops its buckets.

What this does not open. Project trust's participants are a project's members, and a Ping belongs to no project, so they get nothing here. An allowed agent's mention stays a participant's request. A Ping hears none, and the gate discards it as it already does in any unserved bucket. That also keeps the agent caps out of Pings. A line's author must be an operator as well as its performer. The owner's direct-ping line counts as their word, the same as their mention, so it can carry a connector change (owner: true). The reason is that nobody but its author writes a chat line. An assignment is the opposite case, because anyone can edit the card.

A person in one Ping only

Trust used to be agent-wide: an operator was heard everywhere, and nobody else in any Ping. The operator can now let one person reach the agent in one Ping and nowhere else:

basecamp connect setup -P <agent> --allow-in-ping <ping-id>:<person-id>     # repeatable
basecamp connect setup -P <agent> --disallow-in-ping <ping-id>:<person-id>
  • connect.json gains "pings": {"<ping-id>": {"participants": [<person-id>, ...]}}, read into admission.Policy.Pings (map[int64]PingTrust). Absent means nobody. Setup writes no key when there is no entry, so a file that uses none is byte-for-byte what it was.
  • Role. Such a person is a participant there, so subagent.md's A participant asks, an operator authorizes applies, and owner is false.
  • Mention only, even in a direct Ping. Every line of a direct Ping is an operator's privilege, so a participant needs a mention everywhere. This is a decision, not a constraint: the alternative is a one-line change in matchPing.
  • Anywhere else (another Ping, their direct Ping with the agent, any project, an assignment) they are whoever the rest of the policy makes them, so untrusted_performer unless trusted some other way. Works in every trust mode, additively.
  • Where the rule lives. The gate opens the entry only in a bucket connect.json does not serve, and before project trust, so a Ping is never asked for members. Decide discards the event unless the Ping read confirms the bucket is a Ping. matchPing takes the author's role from the operator set or the Ping's entry, and gives pinged only when both performer and author are operators.
  • No agent. Policy.Validate refuses the agent itself and any --allow-agent agent as a participant. Setup reads the Ping as the agent, the way admission does, and refuses a Ping the agent cannot read, a person not among its people, an Agent, and a client (as project trust refuses clients). A person already recorded who has since left, or whose Ping can no longer be read, is a warning, as for the allowlist.
  • Older CLIs. setup.Parse refuses unknown keys, so an older CLI refuses a connect.json that has a pings entry. connect, show and setup all stop with "unknown field" rather than run without it. That fails closed, and it happens only once someone uses the flag. Admission's own reader ignores unknown keys, but nothing reaches it without setup.Load.
  • Trust is read when the connector starts, so restart it after a change, as for every other trust change.
  • connect show lists each Ping's participants. The skill has a "talk to it only in our group Ping" row and explains how to find the Ping id (pings list, .../circles/<id> or .../my/sidebar/circles/<id>) and the person id (pings show <ping> --json). The subagent learns that the operator's go in a Ping is a mention only, because a boost in a Ping never reaches the agent, and that it should stop and say so when the operator is not in the Ping.

Data shape

  • Reads.Pings (PingReader.Ping(ctx, bucketID) (*basecamp.Ping, error)). It returns nil when the bucket is not a Ping the agent is in: a 404, which is every project, or a 403, which means Pings are turned off. The SDK adapter caches the "not a Ping" answer for 10 minutes. A Ping itself is read fresh each time, because its people decide whether it is direct.
  • Admission order. For a chat.line.created in a bucket connect.json does not serve, the bucket is read as a Ping before any trust read. The place decides whose word counts there. Under --trust project it also prevents a membership read on the Circle, which bc3 answers 404 and which used to hold the line read_failed.
  • matchPing holds the Ping rules in one place. The author must be an operator and no Agent. A mention gives mentioned. A direct Ping gives pinged. Anything else is not_addressed.
  • TriggerPinged is a new trigger. It is not a matrix row, because only a Ping can admit under it.
  • Conversation key ping:<id> (admission.PingConversationKey). The reply goes to the Ping's Campfire. Served stays false and Class stays empty, since connect.json serves no Ping. The handoff hands off (served AND bucket in served projects) OR conversation_key = 'ping:' || bucket_id. There is no ledger schema change, so an older CLI can still open the ledger.
  • Request line gets "ping": true|false, added to the request contract and the skill's example.

Direct versus group. The wire has no group flag, so "direct" is computed from Ping.People as the agent sees it, which lists the others in the Ping. The rule is exactly one other person, and that person wrote the line. One edge case is a group that has shrunk to the agent and one person: it reads as direct. I think that is fine, because nobody else reads the line any more. A direct Ping never gains a third person, since bc3 allows can_change_access? only on groups. A stale read can therefore only make a Ping look like a group, which is the safe direction.

Why not bc3's pinged addressing reason. It lives on the inbox lane, and the connector reads the account feed, where intake refuses reasons. It also addresses every participant for every line, group Pings included, so it says nothing about direct versus group or about mentions. The Ping read and the mention check would still be needed.

The live lane. The live subscription holds every bucket the agent can see, Pings included. The membership listing named only projects. Every line in a Ping therefore looked like it came from an unheld bucket and reconnected the feed, repeating every 10 minutes for each active Ping. The listing now includes the agent's Pings. If Pings are turned off (403), it lists none.

Skill and docs

  • SKILL.md covers the pinged trigger and the ping key. For repo choice, a Ping has no AGENTS.md or last.json mapping, so the agent uses the repo the request names or asks. The owner rule now accepts mentioned or pinged. The setup section says operators can ping the agent with no setup. The per-request section sat at 13,983 of its 14,000-character compaction budget, so some prose there was tightened to make room. It now ends at 13,974.
  • subagent.md says to read a Ping with basecamp pings messages, to skip AGENTS.md, and to reply with basecamp pings post <bucket_id> -. It also describes the pinged trigger.
  • connect setup --help has a Pings paragraph, and connect --help and --project mention pings. The no-project warning and the guided summary now say operators' pings still reach the agent.

Verification

  • Admission tests are in internal/connector/admission/pings_test.go. They were written before the code and failed against it: direct pings were discarded, and a member's ping in project trust was held read_failed. Each rule was then removed in turn from the code, and its test failed every time (7 of 7): the performer check, the author check, the "exactly one other person" rule, the "a direct line needs no mention" rule, the served-project exemption, Agent-authored words, and the owner's direct ping.
  • Per-Ping participants are tested in internal/connector/admission/ping_participants_test.go, internal/connector/setup/pings_test.go and TestConnectSetupLetsAPersonIntoOnePing. The admission tests failed before the gate and verdict change. 18 mutations were applied one at a time, and each made a test fail: 9 in admission (entry for any Ping, served buckets, entry checked after project trust, no Ping-read confirmation, in-ping refused in a Ping, participant needing no mention in a direct Ping, any author, and the two Validate refusals) and 9 in setup and the command (not-in-Ping, client, Agent, unreadable Ping, recorded warning, disallow, Apply aliasing, verification unwired, show omitting pings). Each setup refusal was also checked to fail for its own reason, not an incidental one.
  • The handoff test (TestAPingsRequestIsHandedOffWithNoServedProject) fails when Pings are left out of the handoff query. The membership test fails without the Pings listing. TestThePingReadTellsAPingFromAProject covers the SDK adapter's 404, 403 and 500 answers and its cache.
  • End to end on bc3 dev: a real agent credential was obtained through auth agent connect, with the device approval done server-side. The connector ran in --trust project from a throwaway state directory.
    • The operator's direct Ping line was admitted as pinged and its request line had ping:true, owner:true, and reply_to set to the Ping's Campfire.
    • A project member's direct Ping line was discarded as untrusted_performer. Two earlier lines from the same member, held read_failed, were retried and also ended untrusted_performer.
    • An operator's line in a group Ping without a mention was discarded as not_addressed. With a mention it was admitted as mentioned, with ping:true.
    • As the agent, a boost on the line's URL, chat line <url>, pings messages and pings post <ping> - all worked.
    • The Pings that already existed at start caused no reconnect. A group Ping created after start caused one.
  • bin/ci passes locally, including skill drift, the request contract and the compaction budget. The one exception is the SDK provenance check, which it skips while #913's replace points at the local SDK. Local golangci-lint 2.15 flagged cap shadowing a predeclared name in policy.go. CI pins 2.11.1, which does not flag it, and I renamed it to limit.

Not run: the paid connect skill evals (make -C skill-evals eval-connect). The existing cases cover setup and repo mapping, which this keeps.


Summary by cubic

Makes the connector hand off an operator's Ping, so the agent answers when its operator pings it. Until now every line in a Ping was blocked, because Pings are not served projects.

Pings reach the agent with no setup: the pinged trigger, the "ping": true request flag, and the ping:<id> conversation key carry the line, and the ledger schema is unchanged. The live feed subscription now lists the agent's Pings, stopping a reconnect loop that repeated every 10 minutes per active Ping. A Ping has no project, so there is no AGENTS.md or repo mapping: the agent uses the repo the request names or asks, and replies with basecamp pings post <bucket_id> -.

Who can ping

WhoDirect Ping with the agentGroup Ping with the agent
The operatorEvery line is a request (pinged, owner: true)Only a line that @mentions the agent (mentioned)
Someone the allowlist names (--allow)Every line is a request (role: operator, owner: false)Only a line that @mentions the agent
Someone let into this Ping (--allow-in-ping)Only a line that @mentions the agentOnly a line that @mentions the agent
Anyone else, including project trust's membersDiscarded (untrusted_performer), no readDiscarded
The agent itself, another agent, a delegated lineDiscarded (agent_authored, delegated)Discarded

Project trust's members are a project's members, and a Ping belongs to no project, so they get nothing here. Each line's author must be an operator as well as its performer; the owner's direct-ping line counts as their word, like their mention. A run narrowed with --project hears no Ping.

Data shape

For a chat.line.created in a bucket connect.json does not serve, the bucket is read as a Ping before any trust read. The SDK adapter caches the "not a Ping" answer (404, or 403 when Pings are off) for 10 minutes; a Ping's people are read fresh, since they decide whether it is direct. Direct means exactly one other person, who wrote the line; a stale read can only make a Ping look like a group, the safe direction. The handoff takes records whose conversation is ping:<id>.

connect setup --allow-in-ping <ping-id>:<person-id> and --disallow-in-ping write connect.json's pings key, kept only when non-empty so an older CLI still opens a file that uses none (and refuses one that has it, as an unknown field). Setup reads each Ping as the agent and refuses a Ping it cannot read, a person not among its people, the agent, an Agent, and a client; a recorded person who has since left is a warning, as for the allowlist. Admission tests were written before the code and fail without each change, and per-Ping participants are covered with one-at-a-time mutations.