27 KiB
Master Chat — scoping (read-only investigation)
Feature target. A human asks the OpenHuman agent a question in a "Master chat". OpenHuman either (a) answers from its own persisted session history with other agents, or (b) asks an external agent on the human's behalf by DMing it under a session id (new or existing), with the external reply threading back into the Master-chat answer.
Grounding. All EXISTS refs are against upstream/main (git show upstream/main:<path>,
fetched at investigation time — upstream/main @ f49249360). In-flight work accounted
for: PR #4582 is already MERGED into main (shared per-pair session_key, plus the
warn-only seq guard); issue #4583 is OPEN (robust monotonic ingest cursor — not yet
implemented, only the interim warn shipped); plugin PR tiny.place#227 (one shared
session id per thread, reused on reply) is the peer-side counterpart the merged core code
already assumes.
Terminology caution: in the code today, "master" (
ChatKind::Master) is the window that aggregates plain (non-envelope) DMs from peers, andorchestration_send_master_messageis the human→front-end steering send. The task's "Master chat" (human asks OpenHuman, OpenHuman answers back to the human) is a superset that is only partially realized by this window — see the gap analysis.
WHAT EXISTS TODAY
Domain root: src/openhuman/orchestration/ (declared in mod.rs:16-34; enabled behind
config.orchestration.enabled, schema src/openhuman/config/schema/orchestration.rs:39).
Startup wiring: src/core/jsonrpc.rs:2294-2300 (ingest subscriber, wake subscriber, drain
supervisor).
1. Master vs Session windows model
ChatKind—Master | Subconscious | Session(types.rs:113-155,as_str/from_strwith Master as the safe default at:150).SessionEnvelopeV1— hand-rolled mirror of the tiny.place harness envelope (types.rs:79-96);is_valid_v1requiresenvelope_version == "tinyplace.harness.session.v1"and a non-emptyharness_session_id(types.rs:99-104);parsereturnsNonefor any non-envelope body so it routes to Master (types.rs:108-112).session_key()(types.rs:123-135) — PR #4582, on main: returnsscope.wrapper_session_id(the shared per-pair conversation id both peers stamp on every message for a thread), falling back toharness_session_idonly for a legacy envelope. This is the sole inbound routing key.SessionEnvelopeV1::outgoing()(types.rs:139-162) — builds a v1 envelope with bothwrapper_session_idandharness_session_idset to the samesession_id,role="owner", so a compliant peer threads its reply under the same id.classify_message(ingest.rs:44-90) — pure classifier: a parseable envelope →ChatKind::Sessionkeyed bysession_key()(ingest.rs:47-75, seq =env.message.line, source =harness.provider, label from folder scope, workspace from cwd); anything else →ChatKind::Master,session_id="master",role="user",seq=0(ingest.rs:76-88).- Persisted rows —
OrchestrationSession(types.rssessionId/agentId/source/label/ workspace/lastSeq/...) andOrchestrationMessage(id/agentId/sessionId/chatKind/role/ body/timestamp/seq);bodyis decrypted plaintext ⇒ workspace-internal only.
2. Ingest → persist → wake pipeline
- Bus subscribers (
bus.rs):OrchestrationIngestSubscriber(domaintinyplace,bus.rs:60-92) feedsingest_stream_message;OrchestrationWakeSubscriber(domainagent,bus.rs:112-133) fans each persistedOrchestrationSessionMessageto the renderer socket and callsops::schedule_wake— for all kinds except subconscious. ingest_one(ingest.rs:171-247):- linked-sender gate — only DMs from paired (linked) agents are decrypted; unpaired
senders are skipped without decrypt/ack so they stay in the Messaging UI mailbox
(
ingest.rs:187-198). - dedupe-before-decrypt by relay
msg_id, protecting the non-idempotent Signal ratchet; a duplicate re-acks but never re-decrypts (ingest.rs:200-217). - decrypt-once →
classify_message→persist_message(ingest.rs:219-224). - on a new row:
acknowledge_message+publish_global(OrchestrationSessionMessage{...})(ingest.rs:226-242).
- linked-sender gate — only DMs from paired (linked) agents are decrypted; unpaired
senders are skipped without decrypt/ack so they stay in the Messaging UI mailbox
(
persist_message(ingest.rs:92-149): upserts the session row and inserts the message idempotently. Contains the #4583 interim guard — ifchat_kind == Sessionand the inboundseq <= existing last_seq, it logs awarn(ingest.rs:107-127) but still persists (proved by testpersist_still_stores_a_lower_seq_in_the_same_wrapper_session).- Delivery is poll-only:
drain_mailbox_once(ingest.rs:264-310) lists/messagesevery 15 s viastart_message_drain_supervisor(ops.rs:129-145) — relay DMs never hit the/inbox/streamWS, so the poller is the real inbound path. schedule_wake(ops.rs:83-118): per-session debounce via a generation counter (ops.rs:58-78); subconscious kind returns early (ops.rs:96-98); the last trigger withindebounce_mswins →invoke_orchestration_graph.- Per-session idempotence cursor (
ops.rs:51-54,192-211):has_new_workcompareslatest_seqagainstkv["cursor:{agent}:{session}"];advance_cursormoves it only after a completed, DM-sent cycle (ops.rs:518-522). Store:session_last_seq(store.rs:219),upsert_sessionclampslast_seq = MAX(old,new)(store.rs:169),ingest_cursor_lag(store.rs:385). There is no monotonicnext_session_seqon main (confirmed absent) — cursor and lag both key onenv.message.line, which is the #4583 defect.
3. Wake graph nodes (what runs the LLM)
- Graph shape (
graph/mod.rs:1-30, wired ingraph/build.rs:328-345):normalize → frontend → execute → compress → world_diff → frontend → send_dm → context_guard → done. OneOrchestrationState(graph/state.rs:49-95) is threaded and SQLite-checkpointed per super-step under threadorchestration:<session_id>(build.rs:351-383). - Injected
OrchestrationRuntimeseam (build.rs:44-61) — every LLM/effect op is behind this trait; production implProductionRuntimeinops.rs:550-817. frontend(router) (build.rs:149-211): two-pass Quick-LLM. Pass 1 →frontend_instruct(ops.rs:595-604) → macro-instructions →execute. Pass 2 (onceagent_replypresent) →frontend_compile(ops.rs:606-617) →channel_response→send_dm.max_superstepsbackstop guarantees termination (build.rs:166-181). Agent package:frontend_agent/(prompt.md,agent.tomltierchat, toolsdefer_to_orchestrator+reply_to_channelfromtools.rs).execute(reasoning core) (build.rs:217-231/ops.rs:619-640): runsreasoning_agentwith the current steering directive scoped in (reasoning_agent/steering.rs,ops.rs:626-633); producesreply+ atrace. Packagereasoning_agent/agent.toml: tierreasoning, subagentsresearcher/code_executor/tools_agent, toolsspawn_async_subagent, wait, tinyplace_whoami, tinyplace_status, tinyplace_feed, current_time, resolve_time— i.e. only tiny.place read tools; no DM-send tool, no session-mint tool.compress(ops.rs:642-707): 20:1 summary of the trace →compressed_historyrow (store.rs:474).world_diff(ops.rs:709-736): append-only per-(agent,session)mutation timeline (store.rs:515append_world_diff, monotonicseq).context_guard/evict(ops.rs:738-802): utilization estimate; over threshold, evicts the oldest compressed summaries into memory-RAG underpath_scope = orchestration/<session>(ops.rs:764-784).send_dm(build.rs:273-294/ops.rs:804-816): sendschannel_responseback tostate.counterpart_agent_id(the session's peer).dm_sentlatch prevents double-send.
4. The SEND path (OpenHuman → peer under a sessionId)
Two send surfaces exist, both routing through tinyplace::handle_tinyplace_signal_send_message:
- Graph reply —
ProductionRuntime::send_dm(ops.rs:804-816) →session_send_plaintext(ops.rs:822-835): for a real session id it wraps the body inSessionEnvelopeV1::outgoing(so the peer threads its reply under the same id);"master"/"subconscious"stay plain. This is a reply only — it always targets the current session'scounterpart_agent_id; it cannot initiate a new outbound thread to a different agent or mint a new session id. - Human steering send —
orchestration_send_master_message(schemas.rs:108-118, handler:437-539): body + optionalrecipient+ optionalsessionId. Recipient resolution (schemas.rs:457-472): explicit wins; else the session's contact (store::session_agent_id,store.rs:368); else the latest master peer (store::latest_master_peer,store.rs:356). WithsessionIdit wraps a v1 envelope (session_envelope_plaintext,schemas.rs:425-435) and mirrors into that session window; without it, plain into"master". It thennotify_orchestration_message(schemas.rs:535) — which, via the wake subscriber, wakes the local graph on that window. - Session mint —
orchestration_sessions_create(schemas.rs:82-91, handler:347-380) mints a freshuuidsession id for a contact,source="user_created". Renderer-driven only — no agent-side or graph-side caller.
5. Session persistence + read surface + frontend
- Store reads:
list_sessions(store.rs:254),list_messages_by_session(store.rs:279, session-key aggregated, paged bybefore),list_recent_messages(store.rs:441, chronological window used by the graph),unread_count/mark_chat_read(store.rs:329/339). - RPC surface (
schemas.rs:23-71):orchestration_sessions_list,_sessions_create,_messages_list,_send_master_message,_mark_read,_status,_self_identity,_relay_info— internal registry (renderer-only, never advertised to agents). - Frontend Master-chat surface:
- Client
app/src/lib/orchestration/orchestrationClient.ts:166-200(sessionsList,messagesList,sendMasterMessage,sessionsCreate,markRead,status, ...). - Hook
app/src/lib/orchestration/useOrchestrationChats.ts—sendMessage(:260-303): a session chat sends{ body, recipient, sessionId }; master/subconscious send{ body }(steering to the latest master peer). Optimistic append + reconcile vialoadMessages/loadSessions. - UI:
app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx+TinyPlaceRoster,InstanceCard,InstanceStatusDot,HarnessGlyph,SelfIdentityCard,RelayBadge(roster of instance-shaped sessions; recent commits0500aa9fb/05a3c97b9).
- Client
6. Is cross-agent Session-window history fed to the LLM today?
No. The graph seeds from one window only: seed_state (ops.rs:150-166) calls
list_recent_messages(agent_id, session_id, message_window) and OrchestrationState::seed
(state.rs:97-134) folds just those messages + the single counterpart_agent_id. The
reasoning core's only cross-session recall is indirect (memory-RAG over evicted compressed
summaries at orchestration/<session>, and the tinyplace_feed/_status read tools). There
is no node or tool that loads/validates the OpenHuman↔agentX session transcript(s) as
explicit context for answering a question, and none that spans multiple sessions/peers.
WHAT'S NEEDED (gap analysis)
Target flow: human asks in Master chat → orchestration LOADS the relevant OpenHuman↔agent session history (validate persistence) → runs the LLM over that history + the question → if an external agent is needed, SEND a DM under a session id (reuse the per-pair id if a thread exists, else mint) → the external reply threads back (shared-session-id model) → update the Master-chat answer.
Wired-vs-missing summary of the 5 sub-flows. The State at scoping column is the
pre-PR baseline (kept as historical context); the Landed column is what this PR
delivers.
| # | Sub-flow | State at scoping (pre-PR) | Landed in this PR |
|---|---|---|---|
| 1 | human→OpenHuman question intake + routing into the graph | Partial — send_master_message + master wake exist, but they reply outbound to a peer, not back to the human. No "answer the asker" surface. |
✅ W2 local-ask path + human-facing master_agent; answers land back in the Master window. |
| 2 | graph loads + validates cross-agent session history as context | Missing — single-window seed only. | ✅ orchestration_list_sessions / orchestration_read_session browse tools. |
| 3 | decision + tool for OpenHuman to ask an external agent | Missing — reasoning core has no send/ask tool. | ✅ orchestration_send_to_agent (linked-peers-only guardrail). |
| 4 | choose new-vs-existing session id for the outbound ask | Missing — sessions_create mints; no reuse-lookup; no agent caller. |
✅ reuse latest_session_for_agent, else mint fresh. |
| 5 | thread external reply back into the Master-chat answer | Missing — reply wakes the sub-session graph and replies to the peer; never correlated back to the originating Master question. | ✅ W7 correlation (process-global beacon) → reply surfaced as OpenHuman's own message. |
Session-persistence-validity concerns to carry through: dedupe (already solid —
message_exists before decrypt, INSERT OR IGNORE); ordering/#4583 (cursor keyed on
env.message.line can silently drop the 2nd+ inbound message on a shared wrapper_session_id
— blocks reliable reply-threading, must be fixed before sub-flow 5 is trustworthy);
stale/dead session ids (no liveness/expiry on session rows); shared-id reuse rules
(#227/#4582 give inbound threading; outbound initiation id-choice is unspecified).
Status update — PR #4599 (OPEN, Closes #4583, follow-up to #4582)
#4599 lands the reliable reactive reply loop (ingest.rs/ops.rs/store.rs/tools.rs
only). It closes the P0 foundation and adds plumbing the answer path reuses — but delivers
none of the target-flow gaps (it makes peer→OpenHuman→peer replies reliable, not
human→OpenHuman→(history|external agent)→human):
- W1 → DONE:
store::next_session_seq=MAX(seq)+1per(agent,session), stamped inpersist_messageon bothlast_seqand messageseq(replaces the warn guard); also fixes thecycle_idcollision. - Reply-content fix → DONE (new prerequisite for a correct answer):
tools::with_decision_capturetask-local captures thereply_to_channel/defer_to_orchestratorpayload;frontend_instruct/frontend_compileprefer it overrun_single's trailing narration. - Answer-display plumbing → DONE (reused by W7/W10):
ProductionRuntime::persist_outgoing_replywrites the replyrole=owner(monotonic seq) +notify_orchestration_message→ the reply now renders inorchestration_messages_list/ the chat window. - Wake resilience → DONE:
schedule_wakeretries with 5s/15s/45s backoff, checkpoint-resumed, bails if superseded.
Three follow-ups #4599 explicitly defers (its "Related") are folded in below as F1/F2/F3.
Design pivot — agentic tools, not static seed (branch feat/master-chat-orchestration-tools)
Per the owner: the master orchestration layer should have tools to (1) browse its OpenHuman↔agent session chats and (2) send messages on OpenHuman's behalf — rather than the graph pre-loading history into the prompt. This replaces W3/W4 ("seed history into state") with on-demand read tools the reasoning core calls, and reframes W5 as the send tool.
Shipped in this branch (read slice):
- ✅
orchestration_list_sessions+orchestration_read_sessionread tools (orchestration/tools.rs), registered intools/ops.rs, added to thereasoning_agentallowlist + prompt nudge. Read-only, concurrency-safe, workspace-internal store access viastore::{list_sessions,count_messages,list_recent_messages,list_messages_by_session}. Delivers target half (a): answer from own history.
Shipped in this branch (send slice — W5 + W6):
- ✅
orchestration_send_to_agent(orchestration/tools.rs) — DM a peer on OpenHuman's behalf. Guardrail: linked-peers-only (pairing::linked_agent_idsOR an existing session with the peer) — refuses cold-DMs from the un-gated background origin. Session id: reuse-or-mint per peer via newstore::latest_session_for_agent(reuse the peer's newest thread's sharedwrapper_session_idso the reply threads back; else mint a uuid). Sends a v1 envelope viahandle_tinyplace_signal_send_message, records the outboundrole=ownermessage +notify_orchestration_message(mirrors #4599'spersist_outgoing_reply).PermissionLevel::Write. Added to thereasoning_agentallowlist + prompt. - Verified:
cargo test openhuman::orchestration→ 64/64 (6 new); loader 85/85; lib clean.
Shipped in this branch (reply-threading — W7, core-only):
- ✅ One-shot outbound-ask correlation. For a local-master turn the
executenode opens a process-global origin beacon (tools::begin_master_origin/end_master_origin) around the agent turn;orchestration_send_to_agentreads it and recordsstore::set_pending_ask((peer_agent, ask_session) → origin). A process-global is used instead of a task-local because the harness dispatches tool calls past an internaltokio::spawn, and task-locals do not cross a spawn — the earlierwith_origin_sessiontask-local silently never reached the tool, so the correlation never armed. The key is scoped by(peer_agent, session)so a legacy sharedwrapper_session_idacross peers can't misroute a reply. When the peer's reply lands,invoke_with_runtimecorrelates it and finishes the cycle without running the reply graph — no ping-pong. One-shot: the pending marker is consumed only after the reply is durably surfaced (store::{pending_ask_origin,clear_pending_ask}), so a transient store failure retries on the next drain instead of dropping the answer. - ✅ Reply surfaced as OpenHuman's own message, not the peer's raw words. For a master-initiated
ask the reply is run through the tool-free
master_reporter(report_peer_reply_to_master) — the peer text is untrusted, so the reporter carries no tiny.place tools/sub-agents (no prompt-injection surface) and emits anassistantmessage in the Master window. Peer/A2A origins keep the deterministic rawthread_reply_to_origin. - ✅ Fire-and-forget:
orchestration_send_to_agentreturns an immediate ack and themaster_agentprompt forbids polling/read_sessionfor the reply, so W7 is the sole async reporter (no duplicate surfacing). - Verified:
cargo test openhuman::orchestrationgreen (incl.outbound_ask_reply_threads_to_ origin_and_skips_the_reply_graph,pending_ask_correlation_is_one_shot,master_origin_beacon_sets_and_clears); full loop proven live on staging. - Limitation (needs F3): correlation is a pragmatic 1:1 request/response — it assumes the
next inbound message on the ask session is the answer. Many-in-flight / interleaved replies
need an explicit envelope
inReplyTo/fromSession(F3, cross-repo). The process-global beacon is safe because local-master wakes are serialized; a concurrent A2A send during a master turn is the one documented edge (single-user desktop, non-fatal). Peer-session (A2A) W7 still rides the best-effort task-local.
Still to do here: RPC/UI surface for the human-facing master ask/answer (W8–W12) and perf/robustness F1/F2; robust correlation F3 (cross-repo).
Prioritized work-item CHECKLIST (ordered Rust core → JSON-RPC → UI → tests) — remaining after #4599
P0 — foundation / correctness
-
W1 — Fix the #4583 wake-cursor/seq decoupling. — DONE in #4599 (
store::next_session_seqstamped inpersist_message). Note: #4599 keyslast_seq/messageseqon the ordinal;has_new_work/advance_cursor/ingest_cursor_lagalready ridelast_seq, so they inherit the fix. No remaining work. -
W2 — Master-chat "ask" intake distinct from steering. Introduce a question record (role
owner, apending/answered marker) and route it into the graph as the question to answer for the human, separate fromsend_master_message's outbound peer-steering. Where:orchestration/{types.rs,store.rs,ops.rs,schemas.rs}(+ maybe amasterchat mode). Size: M. Deps: W1.
P1 — the answer path
-
W3 — Cross-agent session-history loader + validity check. A store read that gathers the relevant OpenHuman↔agent transcript(s) (by peer and/or across peers), validates ordering (monotonic seq from W1), drops dead/stale sessions, and returns a bounded, LLM-ready context. Where:
orchestration/store.rs(newload_history_for_question) +ops.rs::seed_state(extend state with ahistory_context). Size: M/L. Deps: #4599 (monotonic seq). -
W4 — Feed the history into the reasoning/front-end prompt. Extend
OrchestrationState(graph/state.rs) with the loaded history and render it infrontend_instruct/executeprompts (ops.rs:595-640). Where:orchestration/graph/state.rs,ops.rs. Size: S/M. Deps: W3. -
W5 — "Ask an external agent" decision + tool. A new domain tool (e.g.
ask_external_agent { recipient, question, sessionId? }) that the reasoning core can call; it performs the outbound session-scoped send. Add toreasoning_agent/agent.tomlallowlist and gate it (approval/linked-only). Where:orchestration/tools.rs(+ re-export intools/mod.rs),reasoning_agent/agent.toml, reusesession_send_plaintext/handle_tinyplace_signal_send_message. Size: M. Deps: W2. -
W6 — New-vs-existing session-id chooser for the outbound ask. Resolve "do I already have a live thread with peer X?" → reuse that
wrapper_session_id; else mint (uuid, persessions_create) and record the(peer → session_id)mapping. Encode the #227/#4582 reuse rule (one shared id per thread; both peers reuse on reply). Where:orchestration/store.rs(afind_or_create_session_for_peer), consumed by W5. Size: M. Deps: W5. -
W7 — Thread the external reply back into the Master-chat answer. Correlate the inbound session reply (arriving under the shared id,
ChatKind::Session, waking that sub-session's graph) back to the originating Master question, and update/emit the Master-chat answer instead of (or in addition to) replying to the peer. Needs a pending-ask ↔ session-id correlation table and a resume/notify into the master window. Reuse #4599'spersist_outgoing_reply/notify_orchestration_messagefor surfacing the answer; the correlation primitive is F3 (envelopeinReplyTo/fromSession). Where:orchestration/store.rs(correlation kv/table),ops.rs(wake handling for a correlated session),bus.rs. Size: L. Deps: W2, W5, W6, F3. Risk: highest — cross-graph correlation + the reactivesend_dm-always-to-counterpart assumption inbuild.rs:273-294must be relaxed.
P2 — JSON-RPC surface
-
W8 — RPC for master-chat ask + answer polling. e.g.
orchestration_ask_master(submit question) and answer surfacing via existingmessages_list/socket, plus a pending status. Where:orchestration/schemas.rs(new schema + handler; register inall_*controllers). Size: M. Deps: W2, W7. -
W9 — Extend
orchestration_status/session DTOs with pending-ask + external-ask in-flight signals for the UI. Where:schemas.rs. Size: S. Deps: W8.
P3 — UI
- W10 — Master-chat ask/answer UX. Client method + hook wiring so a master question
shows "OpenHuman is asking @peer…" and the threaded answer updates in place. Where:
app/src/lib/orchestration/orchestrationClient.ts,useOrchestrationChats.ts,TinyPlaceOrchestrationTab.tsx. Size: M. Deps: W8. Note: i18n keys inen.ts+ all locales; no dynamic imports.
P4 — tests (each item lands with its own unit tests; these are the cross-cutting suites)
- W11 — Rust: threading correctness. (a,b) seq/cursor line-0 + reuse cases are already
covered by #4599 (
persist_stamps_monotonic_ingest_seq_so_line_zero_dms_still_wake) — only (c) remains: outbound ask reuses an existing per-pair id and the reply correlates back to the originating master question. Where:orchestration/{ops.rs,store.rs}inline tests +tests/json_rpc_e2e.rs. Size: M. Deps: W6, W7. - W12 — JSON-RPC E2E + Vitest for ask→answer round-trip (mock peer reply). Where:
scripts/test-rust-with-mock.sh,app/src/**/*.test.tsx. Size: M. Deps: W8, W10.
F — deferred by #4599 (fold into the target work)
- F1 —
send_dmbeforecompress/world_diffto cut ~20s time-to-reply (reorder the graph edges so the outbound reply dispatches before the post-processing tail). Where:orchestration/graph/build.rs(edge wiring). Size: S. Deps: none. Note: perf, not blocking, but improves the human-facing answer latency the target cares about. - F2 — Scope the checkpoint thread id to
(agent, session). Today it isorchestration:<session_id>(build.rs:358), so two peers sharing a session id can collide on checkpoints. Where:orchestration/graph/build.rs::run_orchestration_graph. Size: S. Deps: none. Correctness — do alongside W6/W7 (which multiply active sessions per id). - F3 —
SessionEnvelopeV1inReplyTo/fromSessioncorrelation. The primitive W7 needs to tie an inbound reply to the exact outbound ask (vs. inferring from session id alone), and to drive asend_and_wait-style block. Where:orchestration/types.rs(SessionEnvelopeV1), plus tiny.place SDK / plugin #227 peer coordination. Size: M (core) + external. Deps: peer/SDK support. Risk: cross-repo — the blocking dependency for a clean W7.
Open questions / risks
- Semantic overload of "master". The window is currently peer-plain-DM aggregation +
human steering; the target needs a human-question/answer channel. Decide whether to reuse
ChatKind::Masteror add a mode — affects W2/W7 and the wake router's terminate predicate. send_dmis unconditional-to-counterpart (build.rs:273-294). Sub-flow 5 requires the graph to sometimes answer the human instead of/along with the peer — a structural change to the terminal node, not just a new tool.- #4583 silent-drop — RESOLVED by #4599 (
next_session_seqmonotonic ordinal). Build reply-threading onlast_seq/messageseq, not the wireline. - Outbound id-choice reuse rule is peer-side (#227) only in code. The core has no "find existing thread with peer X" lookup; W6 must define freshness/liveness (ties to the stale-session-id concern — session rows have no expiry today).
env.message.lineis now ignored for ordering (#4599). Its value no longer matters to the wake cursor; correlation (W7) must ride the store seq / an explicitinReplyTo(F3), notline.- Approval/security: an agent-initiated outbound DM (W5) is a new external-effect tool;
must respect the linked-agent gate (
ingest.rs:187-198) and the approval gate — scope itsCommandClass/tier deliberately.