diff --git a/docs/design-docs/autonomy.md b/docs/design-docs/autonomy.md index 962157623..2de0afaa5 100644 --- a/docs/design-docs/autonomy.md +++ b/docs/design-docs/autonomy.md @@ -43,9 +43,48 @@ The cortex assembles the autonomy channel's context before each wake. It gets: - **Task state** — all active tasks: ready, in-progress, backlog, pending_approval. Full detail on each, including all comments. - **Goals** — all active goals with descriptions and notes. Background context and direction, not a work queue. See [`goals.md`](goals.md). - **Active workers** — what's currently running so it doesn't duplicate work. -- **Last few run summaries** — the `autonomy_complete` output from its previous runs, with timestamps. This is the primary continuity mechanism. +- **Its own prior transcript** — the channel's persisted history, where each finished run has compacted to its `autonomy_complete` summary. This is the continuity mechanism; see [Continuity Between Runs](#continuity-between-runs). -The last run summaries are surfaced up front: "Last run (2h ago): enriched tasks X and Y, created tasks Z for backlog." The autonomy channel wakes with spatial awareness of where things stand and what it did recently. +Continuity arrives as the channel's own history rather than as injected run summaries — the run store stays the queryable index and provenance record, not a second delivery path for the same content. The autonomy channel wakes with spatial awareness of where things stand and what it did recently. + +### The briefing is the system prompt, not a message + +All of the above renders into the channel's system prompt, re-rendered on each wake. It is not delivered as an inbound message. + +This matters because the autonomy channel is one conversation that spans every run — a single conversation id, one persistent transcript. Anything delivered as a message is written into that transcript and stays there. A briefing sent as a message means run fifty wakes to forty-nine copies of its own instructions, with its actual work crowded out between them. Rendering the same content as the system prompt costs nothing extra, since the cortex already assembles it fresh each wake, and it leaves no residue. It is also the honest representation: a briefing stored with a user role and a system sender describes a participant who does not exist. + +Two things legitimately arrive mid-run and so must be messages: the soft wrap-up warning at `warn_secs` and the hard timeout notice at `timeout_secs`. Both are ephemeral — present in the live run's context, never persisted to the transcript. They are scaffolding for one run, not history. + +The rule the channel is built around: **the transcript holds the agent's own output.** Everything the system tells it is either the system prompt, re-rendered per wake, or an ephemeral mid-run injection. Nothing the system says accumulates. + +### Journaled events + +That rule governs scaffolding. It does not exclude the agent's own actions — and some of those leave the transcript entirely. When a run sends a message to the home channel, the effect lands on a human, not in the conversation the run is having with itself. The next wake has no way to know it happened. + +So actions with effects outside the transcript are journaled into it as they occur: + +```text +Sent to home channel (telegram:8659410676): +"Found three open issues on the repo you cloned — the oldest looks like a quick win." +``` + +**These are recorded as the agent's own turn.** Not as a system row: `log_system_message` persists `role = "system"`, and rehydration keeps only `user` and `assistant` rows (`render_conversation_history_backfill`). A system row is visible in the dashboard and invisible to the agent, which is precisely backwards for a journal entry — the dashboard is not who needs to read it. + +What qualifies is decided by one test: **journal only what the next wake cannot re-derive.** Task state, goals, worker status, and run counts are all re-rendered into the system prompt on every wake, so journaling them duplicates content that is already arriving fresh. An outbound message fails that test on both counts — nothing regenerates it, and it cannot be undone. The agent has to know it already spoke. + +Held to that test, the set stays small: things a human perceived, and writes to the world outside the agent. Internal state transitions stay out. The failure mode to avoid is a transcript that degrades from a train of thought into a syslog, which is the same pollution the briefing rule exists to prevent, arriving through a different door. + +**Journaling is independent of waking.** Two axes that happen to share a vocabulary: + +| | Wakes the agent | Appears in the transcript | +|---|---|---| +| Registered as a wake trigger | yes | yes, via the run it causes | +| Journal-only | no | yes, on the next wake | +| Neither | no | no | + +An event is journaled because the agent needs to remember it, and it triggers a wake because it needs acting on *now*. Most things are one or the other. This is what several declared-but-unproduced `SystemEvent` variants are reaching for: `cortex.observation` wants to be journal-only. + +**Journal entries must survive compaction.** A run's detail collapses into its summary on exit, and "have I already told them this?" is a question spanning days — exactly the range compaction removes. An outbound-message record that lives only in run detail works for one wake and then silently stops. Outward-facing actions are promoted into the run summary rather than discarded with the rest of the detail. --- @@ -60,15 +99,54 @@ During a run it can: - **Execute ready tasks** — tasks the user has approved. Uses execution tools directly (shell, file, browser) with no forced delegation. Workers available for genuine parallelism. - **Create new tasks** — identifies follow-on work and adds it to `pending_approval`. The agent proposes; the user decides. - **Update task metadata** — priority, blockers, progress notes. +- **Record what it notices** — the channel holds `memory_save` and `memory_recall` directly (it does not branch, so there is no persistence branch behind it). Findings are written as they are found, not batched at the end, because a run can time out and lose them. + +Recording is licensed, not quota'd. A run that genuinely learned nothing records nothing, and the briefing says so in as many words. An agent told to always produce an observation will produce one — restating what it was already given, or narrating its own activity as a discovery — and manufactured memories are worse than none, because they degrade every future recall that has to sift past them. What it **cannot** do: -- Reply to users (no `reply` tool) +- Hold a conversation. There is no `reply` tool: the channel has no inbound turn to answer, and a user who replies to something it sent is answered by the normal user channel for that conversation. Delivery to a configured target is not conversation — see **Reaching Out** below. - Execute tasks that are still in `pending_approval` - Create cron jobs - Spawn other autonomy channels --- +## The Empty Instance + +A fresh instance has no tasks, no goals, and no history. The default outcome is a run that surveys nothing, concludes "nothing new here", and exits — and because nothing changed, the next wake reaches the same conclusion. An agent that idles until someone gives it work is not autonomous; it is a queue consumer with a timer. + +The survey already knows when it came back empty, so the briefing branches on it rather than leaving the agent to notice. The template is already conditional on wake events, run history, goals, workers, and level; empty state is one more branch, and it fires deterministically. That matters more than it sounds: routing this through a skill the agent chooses to invoke reintroduces the exact failure being fixed, because the run that fails to reach for the skill is indistinguishable from the run that had nothing to do. + +The empty branch is built on one claim: **on an empty instance, learning the user and the system is the highest-value work available, not filler while waiting for real work.** + +- **Read what is actually here.** The workspace, registered projects, whatever the user has already done. A cloned repository is a statement of intent. +- **Record what it learns**, under the rules above. +- **Find capability gaps** via `spacebot_docs` — features that fit what the user appears to be doing and that they have not set up. +- **Ask one good question.** If there is a single thing the user could say that would unlock the most, ask that. + +The last one is the point. A question that gets answered converts an empty instance into a non-empty one and compounds into every later run. Ten manufactured observations compound into nothing. When the empty branch is deciding what is worth doing, one good question outranks a full survey of an empty system. + +`spacebot_docs` is currently registered only on the branch and cortex tool servers (`create_branch_tool_server`, `create_cortex_tool_server`), not in `add_direct_mode_tools` — which is what the autonomy channel receives, and it does not branch. It has to be added there before any of this is reachable. + +--- + +## Reaching Out + +At `suggest` and above, a run may send to the home channel ([`home-channel.md`](home-channel.md)). This is the one place autonomous work becomes visible to a human without them going looking, so the bar is deliberately high — an agent that reports in every interval gets muted, and a muted agent is worth less than a silent one. + +Send when: + +- It needs something only the user can provide — a decision, access, a credential, missing context that blocks otherwise-ready work. +- It found something time-sensitive, where waiting until the user next opens a channel has a cost. + +Do not send to report activity. "Here is what I did this run" is what run history is for, and it is visible on demand rather than pushed. + +Every send is journaled into the transcript as the agent's own turn, so the next run can see that it already raised something and decide against repeating it. That judgment is the primary control; the content-key backstop exists for loops, not for taste. An unanswered question asked twice in a week is a worse outcome than one asked once and left standing. + +With the dial at `observe`, or with no home channel configured, this section does not apply — findings are recorded and nothing is sent. + +--- + ## Task Comments Comments are the primary output of the enrichment loop. When the autonomy channel or a worker completes investigation on a task, findings are written as a comment — not appended to the task description, not stuffed into metadata. Comments are append-only and chronological. The task description remains the stable statement of what needs to be done; comments are everything that has been learned or decided since. @@ -115,7 +193,7 @@ wake → survey pending_approval tasks → reason about worker findings → add_task_comment: synthesised finding + worker_id(s) → repeat for next task within turn budget - → set_outcome → exit + → autonomy_complete → exit ``` The autonomy channel system prompt instructs: investigate and comment freely; never execute a task still in `pending_approval`. @@ -179,7 +257,7 @@ The cortex monitors elapsed time. At `warn_secs`, it injects an addendum into th ``` You have approximately 2 minutes remaining in this run. -Finish your current task, add any final comments, and call set_outcome. +Finish your current task, add any final comments, and call autonomy_complete. Do not start a new task. ``` @@ -193,9 +271,15 @@ Delivery mechanism: a synthetic system message between turns, the same pattern t **Task comments** — the primary record of what has been investigated and found. Persist indefinitely. The next run sees all prior comments when it reads task state on wake, so it does not duplicate completed investigation. -**Run summaries** — on exit, `autonomy_complete` records what was enriched, what was executed, what was created, and which wake events the run consumed. The next wake receives the last `run_history_count` summaries as part of its context, and the UI renders the consumed wakes as "woken by" provenance per run. +**Run summaries** — on exit, `autonomy_complete` records what was enriched, what was executed, what was created, and which wake events the run consumed. The summary is persisted as the run's assistant turn in the channel transcript, and the UI renders the consumed wakes as "woken by" provenance per run. + +**The summary is the compaction unit.** During a run the channel carries full detail: tool calls, worker results, intermediate reasoning. When the run ends, that detail collapses to the summary. What persists is a stream of summaries — roughly five lines per run — plus the live detail of whichever run is currently executing. The transcript is therefore a continuous record of the agent's own thinking that stays bounded no matter how many times it wakes. + +This makes the transcript itself the continuity mechanism, so `run_history_count` is a compaction window rather than a second delivery path. Persisting the transcript *and* injecting the last N summaries from the runs table would feed the same content twice by two mechanisms; the run store remains the queryable index and the provenance record, not a parallel context source. + +One consequence worth stating: wake provenance lives in the run store and the UI, not in the transcript. Read on its own, the transcript is uninterrupted thought with no visible cause — "why did run 47 happen" is answered by run history, not by scrolling back. -Working memory provides broader system context. Run summaries provide the autonomy-specific thread. +Working memory provides broader system context. The transcript provides the autonomy-specific thread. --- @@ -208,9 +292,10 @@ Cortex tick → no autonomy channel currently running → autonomy.enabled = true ↓ -Cortex assembles context (identity + bulletin + working memory + tasks + goals + run summaries) +Cortex assembles context (identity + bulletin + working memory + tasks + goals) + → rendered into the channel's system prompt, not sent as a message ↓ -Autonomy channel wakes with full context +Autonomy channel wakes with full context + its own prior transcript ↓ ├─ pending_approval tasks exist? │ → enrich: spawn investigation workers, reason about findings, add_task_comment @@ -222,7 +307,8 @@ Autonomy channel wakes with full context └─ no tasks worth acting on? → create pending_approval tasks from goals, or exit with "nothing to do" ↓ -Calls set_outcome → summary recorded +Calls autonomy_complete → summary recorded in the run store + → and persisted as the run's assistant turn; the run's detail compacts to it ↓ Channel exits → cortex records last_run_at, cleans up ``` diff --git a/docs/design-docs/home-channel.md b/docs/design-docs/home-channel.md new file mode 100644 index 000000000..232cd0c0a --- /dev/null +++ b/docs/design-docs/home-channel.md @@ -0,0 +1,162 @@ +# Home Channel + +The home channel is an instance's default outbound destination — the one conversation the agent can reach when no conversation is in scope. It is set once, from the chat that should receive it, and it is what makes autonomous outreach possible at all. + +This doc defines the home channel and the delivery resolution that consumes it. For the trigger model that produces autonomous work, see [`wakes.md`](wakes.md). For the channel that runs it, see [`autonomy.md`](autonomy.md). + +--- + +## Why This Exists + +Spacebot has three delivery situations and only two of them are solved. + +A user channel replies to whoever spoke — the target is the inbound message. A cron job delivers to the conversation it was created in: `default_delivery_target_for_conversation` (`src/tools.rs`) derives the target from the originating `conversation_id`. Both work because a conversation is in scope. + +An autonomy run has neither. There is no inbound message, no originating conversation, and no `reply` tool — the tool is gated behind `allow_direct_reply`, and [`autonomy.md`](autonomy.md) lists replying to users under what the channel deliberately cannot do. The channel is structurally mute. + +The gap is already visible in the schema. `WakeDef.delivery_target` is validated at config load (`src/wakes/config.rs` checks it parses as `adapter:target`), persisted, and round-tripped through the store (`src/wakes/defs.rs`) — and read by nothing. The field records the intent to deliver; what stopped it is that there is no sensible default behind it. A wake that wants to say something has nowhere to say it unless every wake is individually configured with a target, which is not a default anyone will set. + +Every proactive behavior worth having — onboarding, digests, "I looked at that repo and found something" — needs one answer to "where does this go?". + +--- + +## The Model + +```text +autonomous send + │ + ├── wake's delivery_target ──▶ send there + ├── home channel ────────────▶ send there + └── neither ─────────────────▶ record, don't send +``` + +An explicit `delivery_target` on the wake wins: a CI-failure wake can route to an engineering channel while everything else goes home. Otherwise the home channel receives it. If neither is set, the run records what it wanted to say as a memory and continues. + +That third branch is load-bearing. An autonomous run must never fail because it had nothing to say something to, and it must never fall back to "the most recent channel we saw" — that is how an agent posts a private observation into a group. Unset means silent, and silence degrades into memory rather than into a guess. + +--- + +## Addressing + +The addressing layer already exists and does not need extending. + +`parse_delivery_target` (`src/messaging/target.rs`) handles `adapter:target` and `adapter:instance:target`, including Signal's extra instance segment and named telegram/discord/slack instances. `resolve_broadcast_target(&ChannelInfo)` turns a live channel into a `BroadcastTarget { adapter, target }`, which `Display`s back into the same canonical string. + +So setting the home channel is: resolve the current channel, store the string. Reading it is: parse the string, broadcast. One fully-qualified string covers every adapter. + +**One home per instance, not one per adapter.** Per-adapter homes force the autonomy channel to choose which one to use on every send, and there is no principled answer to that question — the run has no adapter context to choose with. Goals and autonomy runs are instance-scoped; their outreach is too. + +--- + +## Setting It + +The intent is expressible in a sentence, so the primary path is a tool: + +- **`set_home_channel`** — the model calls it when the user says "make this your home channel". Registered on user channels only, and it resolves the channel it was called from rather than taking a target argument. + +The command is a second entry point to the same handler, not a second implementation: + +```rust +CommandDef { + name: "sethome", + description: "set this chat as the home channel", + category: CommandCategory::Session, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Control(ControlAction::SetHome), + access: CommandAccess::Authority, + busy: BusyPolicy::Queue, + availability: CommandAvailability::ALL, +} +``` + +A command earns its place here for one reason: a sentence can express the intent but cannot *discover* it. Platform slash menus — Discord application commands, the Telegram menu — are where a new user finds out the capability exists, and native registration already surfaces the registry there. That is the job conversation cannot do. Everything else about the command is a shortcut over the tool's handler. + +There is no `/unsethome` and no separate status command. `/sethome` with no arguments sets the current channel; `/status` already reports binding state and gains the resolved home. + +--- + +## Authority + +`CommandAccess::Authority`, not `Everyone`. Access never widens who may talk to the agent, only who may change state — and this is state that redirects where the agent speaks. In a group chat, any sender the binding admits could otherwise point every autonomous message at a destination of their choosing. That is a redirection vector, not a configuration mistake. + +The same reasoning as the wake authority model: the delivery target is a capability boundary, so it is set by principals the instance trusts, not by anyone who can reach the bot. + +Setting a home the agent cannot actually broadcast to fails at set time, not at first send. Validation resolves the channel and checks the adapter exists, mirroring the adapter-existence check the wake config validation already performs. + +--- + +## Storage + +`SettingsStore` (`src/settings/store.rs`), with typed accessors alongside `worker_log_mode` and `prompt_capture_enabled`. It is instance-scoped, survives restart, and is mutable at runtime — a chat command must not require editing a config file or bouncing the daemon. + +An optional `home_channel` key in config seeds an instance that ships pre-configured, following the ownership rule wakes and cron already use: config is a seed, the database is the source of truth, and a runtime change is never clobbered by a reload. + +--- + +## First Run + +A fresh instance has no home, which is exactly when the onboarding behavior most wants one — an agent with nothing to say hello to. + +The first channel to complete a user turn becomes the home, recorded as implicit. An explicit set replaces it and marks it explicit; an implicit value never overwrites an explicit one. The agent surfaces the assignment once when it happens, so the destination is never a silent default the user discovers by receiving something unexpected. + +--- + +## Level Gating + +A home channel does not make the agent talk. The dial does. + +| Level | Outbound behavior | +|---|---| +| `off` | Nothing fires, nothing sends. | +| `observe` | Never sends. Findings are recorded as memories and working-memory events. | +| `suggest` | May send to the resolved target. | +| `act` | May send to the resolved target. | + +Mute-by-default survives the feature: an instance with a home set and the dial at `observe` accumulates memories and says nothing. + +--- + +## Failure Behavior + +| Failure | Behavior | +|---|---| +| No home set, no wake target | Run records the intended message as a memory and completes normally. Not an error. | +| Adapter unbound or offline at send | Send fails, run completes, content falls back to a memory. The run is not retried for delivery alone. | +| Bot removed from the home channel | Send fails as above; repeated failures clear the implicit home and notify on the next reachable surface. An explicit home is never silently cleared. | +| Target no longer parses after an adapter rename | Treated as unset. Resolution falls through to the record branch. | +| Same finding repeatedly worth sending | Every send is journaled into the autonomy transcript, so the next run sees what it already said and judges whether repeating is worth it. A content key backstops that against loops. An agent that repeats itself daily gets muted by its user. | + +--- + +## Implementation Phases + +**Phase 1 — Storage and resolution** +- `SettingsStore` accessors for the home target, explicit/implicit flag included +- `resolve_home_target()` helper implementing the three-branch order +- `WakeDef.delivery_target` finally consumed, with home as the fallback + +**Phase 2 — Setting it** +- `set_home_channel` tool on user channels +- `ControlAction::SetHome` and the `/sethome` registry entry +- Authority gate and set-time validation +- `/status` reports the resolved home and whether it is explicit + +**Phase 3 — First run and surface** +- Implicit home on first completed user turn, announced once +- Settings UI row showing the resolved target with a clear action + +**Phase 4 — Autonomous outreach** +- Level gating on send +- Sends journaled into the autonomy transcript as the agent's own turn, surviving run compaction — see [`autonomy.md`](autonomy.md) +- Content-key dedupe as a loop backstop beneath that judgment +- Record-instead-of-send fallback wired to the memory path + +--- + +## Non-Goals + +- **No per-adapter homes.** One instance, one home. Fan-out to several destinations is a routing feature, not a default. +- **No content-based routing.** Wake-level `delivery_target` covers "this specific trigger goes elsewhere". Anything finer belongs to the wake definition, not to home resolution. +- **No `reply` tool on the autonomy channel.** Delivery to a configured target is not conversation. A user replying to an autonomous message is answered by the normal user channel for that conversation, which already has reply and full context. +- **No broadcast to every known channel.** There is no "announce" primitive here. diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index e75720f62..038498db5 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1174,6 +1174,15 @@ export interface AutonomyStatus { next_run_at: string | null; current_run: AutonomyCurrentRun | null; pending_wake_events: number; + /** Where proactive messages go, or null when the agent has nowhere to speak on its own. */ + home_channel: HomeChannelStatus | null; +} + +export interface HomeChannelStatus { + /** Canonical `adapter:target` string. */ + target: string; + /** Set deliberately, rather than adopted on the first completed turn. */ + explicit: boolean; } export interface AutonomyFleetResponse { @@ -1881,6 +1890,17 @@ export const api = { return response.json() as Promise; }, + clearHomeChannel: async (agentId: string) => { + const response = await fetch( + `${getApiBase()}/agents/autonomy/home?agent_id=${encodeURIComponent(agentId)}`, + { method: "DELETE" }, + ); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, + autonomyRuns: (agentId?: string, limit?: number) => { const search = new URLSearchParams(); if (agentId) search.set("agent_id", agentId); diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 11399ec3d..5e650eb7e 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -97,6 +97,28 @@ export interface paths { patch?: never; trace?: never; }; + "/agents/autonomy/home": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Clear an agent's home channel, returning it to sending nothing on its own. + * @description There is no set-from-here counterpart: a home is claimed from the chat that + * should receive it, so the only action this surface can offer is giving it + * up. + */ + delete: operations["clear_home_channel"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/agents/autonomy/runs": { parameters: { query?: never; @@ -3002,6 +3024,7 @@ export interface components { * actually runs at. */ effective_level: components["schemas"]["AutonomyLevel"]; + home_channel?: null | components["schemas"]["HomeChannelStatus"]; /** Format: int64 */ interval_secs: number; /** @description When the most recent finished run started. */ @@ -3732,6 +3755,13 @@ export interface components { /** Format: int64 */ hour: number; }; + /** @description Where this agent's proactive messages go when no wake overrides it. */ + HomeChannelStatus: { + /** @description Set deliberately, rather than adopted on the first completed turn. */ + explicit: boolean; + /** @description Canonical `adapter:target` string. */ + target: string; + }; IdentityResponse: { identity?: string | null; role?: string | null; @@ -5470,6 +5500,41 @@ export interface operations { }; }; }; + clear_home_channel: { + parameters: { + query: { + agent_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AutonomyStatusResponse"]; + }; + }; + /** @description Agent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; autonomy_runs: { parameters: { query?: { diff --git a/interface/src/components/autonomy/AutonomyDialCard.tsx b/interface/src/components/autonomy/AutonomyDialCard.tsx index 75bba35b5..2fd3cabb7 100644 --- a/interface/src/components/autonomy/AutonomyDialCard.tsx +++ b/interface/src/components/autonomy/AutonomyDialCard.tsx @@ -52,9 +52,15 @@ interface AutonomyDialCardProps { status: AutonomyStatus | undefined; onUpdate: (update: AutonomyUpdate) => void; agentName?: string; + onClearHome?: () => void; } -export function AutonomyDialCard({status, onUpdate, agentName}: AutonomyDialCardProps) { +export function AutonomyDialCard({ + status, + onUpdate, + agentName, + onClearHome, +}: AutonomyDialCardProps) { const [advancedOpen, setAdvancedOpen] = useState(false); const now = useNow(1000); @@ -65,6 +71,7 @@ export function AutonomyDialCard({status, onUpdate, agentName}: AutonomyDialCard const selected = LEVELS.find((l) => l.key === level) ?? LEVELS[0]; const isOff = level === "off"; + const home = status?.home_channel ?? null; return ( @@ -239,6 +246,22 @@ export function AutonomyDialCard({status, onUpdate, agentName}: AutonomyDialCard +
+
+

Home channel

+

+ {home + ? `Proactive messages go to ${home.target}${ + home.explicit ? "" : " — adopted on first run" + }` + : "Not set — findings are recorded instead of sent"} +

+
+ {home && onClearHome ? ( + + ) : null} +
+

Tasks per run

diff --git a/interface/src/routes/AgentAutonomy.tsx b/interface/src/routes/AgentAutonomy.tsx index 42b6ae184..8e10a2d3f 100644 --- a/interface/src/routes/AgentAutonomy.tsx +++ b/interface/src/routes/AgentAutonomy.tsx @@ -61,6 +61,16 @@ export function AgentAutonomy({agentId}: AgentAutonomyProps) { }, }); + const clearHomeMutation = useMutation({ + mutationFn: () => api.clearHomeChannel(agentId), + onSuccess: (next) => { + queryClient.setQueryData(["autonomy-status", agentId], next); + }, + onSettled: () => { + queryClient.invalidateQueries({queryKey: ["autonomy-status", agentId]}); + }, + }); + return (
@@ -69,6 +79,7 @@ export function AgentAutonomy({agentId}: AgentAutonomyProps) { status={status} onUpdate={(update) => configMutation.mutate(update)} agentName={agent?.display_name ?? agentId} + onClearHome={() => clearHomeMutation.mutate()} />
diff --git a/prompts/en/autonomy_channel.md.j2 b/prompts/en/autonomy_channel.md.j2 index 1ea876080..1d60c528c 100644 --- a/prompts/en/autonomy_channel.md.j2 +++ b/prompts/en/autonomy_channel.md.j2 @@ -42,7 +42,7 @@ Already running — do not duplicate this work. Survey the task state above and decide what is most valuable, given the goals and what recent runs already covered. This is not a FIFO queue — reason about priorities. {% if level == "observe" %} -Your autonomy level is **observe**: survey and summarize only. You may investigate and read anything, but you must NOT mutate anything — no creating tasks, no updating tasks, no executing work, no writing files. Your output is your `autonomy_complete` summary. +Your autonomy level is **observe**: survey and summarize only. You may investigate and read anything, and you record what you learn with `memory_save` — observing without remembering is what the level is for. Otherwise you must NOT change anything: no creating tasks, no updating tasks, no executing work, no writing files. Your output is your memories and your `autonomy_complete` summary. {% elif level == "suggest" %} Your autonomy level is **suggest**: enrich and propose, never execute. You may: - **Enrich pending_approval tasks** — investigate (workers, web, files) and record findings in task metadata/updates so the user reviews a fully reasoned brief. @@ -57,6 +57,18 @@ Your autonomy level is **act**: the full loop. You may: Treat this run as observe: survey and summarize only. Do not mutate anything — no creating tasks, no updating tasks, no executing work, no writing files. {% endif %} +{% if instance_is_empty %} +There are no tasks and no goals. That is not a reason to do nothing — on an instance this empty, learning about {{ agent_name }}'s user and this system is the most valuable work available, and it is what makes every later run better. This run: +- **Look at what is actually here.** The workspace, registered projects, files the user has already put in place. A cloned repository is a statement of intent — read it and work out what they are trying to do. +- **Record what you learn** as memories, following the rule below. +- **Find the gaps.** Use `spacebot_docs` to check which capabilities fit what this user appears to be doing and have not been set up yet. +- **Work out the one question worth asking.** If there is a single thing the user could tell you that would unlock the most, identify it and put it in your summary. One answered question is worth more than a long survey of an empty system. + +Do not invent tasks to look busy. Proposing work nobody asked for is worse than proposing nothing. +{% endif %} + +Record what you notice as you go, with `memory_save`, rather than saving it all up for the end — a run can be cut short by its timeout and lose everything it was holding. Save what would genuinely be useful to a future run or to the user: what this system is for, how they work, what they care about, what is broken. This is a licence, not a quota. A run that learned nothing records nothing, and that is a perfectly good run — do not restate what this briefing already told you, and do not record your own activity as though it were a discovery. + Hard rules, regardless of level: - Tasks in `pending_approval` are NEVER executed. They are waiting for the user. Enrichment only. - Do not message users, create cron jobs, or modify identity/config. diff --git a/prompts/en/tools/set_home_channel_description.md.j2 b/prompts/en/tools/set_home_channel_description.md.j2 new file mode 100644 index 000000000..3edfd1a50 --- /dev/null +++ b/prompts/en/tools/set_home_channel_description.md.j2 @@ -0,0 +1,7 @@ +Make this chat the home channel — the one conversation you reach when nothing else is in scope. + +Call this when someone asks you to make this your home, send proactive messages here, or check in here by default. It resolves the chat you are called from; there is no target argument. + +Proactive work (autonomy runs, wakes without their own delivery target) sends here. A wake configured with its own target still goes there instead. With no home set, an autonomous run that wants to say something records it as a memory and stays quiet. + +This is instance-wide: setting it here replaces whatever was set before, in this chat or another one. diff --git a/src/agent/autonomy.rs b/src/agent/autonomy.rs index 3530f6e47..6a2080d63 100644 --- a/src/agent/autonomy.rs +++ b/src/agent/autonomy.rs @@ -138,6 +138,12 @@ pub async fn maybe_run_autonomy(deps: &AgentDeps) { return; } + // A pause is an emergency stop on new work, and a self-directed run is + // the most new work the agent can start. + if deps.pause_reason().is_some() { + return; + } + let stale_after_secs = config.timeout_secs.saturating_mul(2).max(60); match deps .autonomy_run_store @@ -513,10 +519,21 @@ async fn build_run_briefing( }) .collect(); - let task_state = render_task_state(deps, config.claim_unowned).await?; + let (task_state, has_tasks) = render_task_state(deps, config.claim_unowned).await?; let active_goals = crate::goals::render_active_goals_extended(&deps.goal_store).await?; let active_workers = render_active_workers(deps).await?; + // Nothing to survey and no direction to work from. The run needs different + // instructions, not a shorter version of the same ones. + // + // A wake event or a running worker is direction: the run has a reason to + // exist and a bounded turn to spend on it, which cold-start discovery + // would spend on the workspace instead. + let instance_is_empty = !has_tasks + && active_goals.is_empty() + && wake_event_views.is_empty() + && active_workers.is_none(); + let prompt_engine = deps.runtime_config.prompts.load(); prompt_engine .render_autonomy_channel_prompt( @@ -530,6 +547,7 @@ async fn build_run_briefing( config.max_tasks_per_run, config.warn_secs.div_ceil(60).max(1), config.claim_unowned, + instance_is_empty, ) .map_err(|error| anyhow::anyhow!("failed to render autonomy channel prompt: {error}")) } @@ -543,7 +561,11 @@ fn compact_payload(payload: &serde_json::Value) -> String { } /// Render the full task survey: pending_approval, ready, in_progress, backlog. -async fn render_task_state(deps: &AgentDeps, claim_unowned: bool) -> anyhow::Result { +/// Returns the rendered survey and whether any task was visible in it. +async fn render_task_state( + deps: &AgentDeps, + claim_unowned: bool, +) -> anyhow::Result<(String, bool)> { let sections: [(TaskStatus, &str); 4] = [ ( TaskStatus::PendingApproval, @@ -584,7 +606,7 @@ async fn render_task_state(deps: &AgentDeps, claim_unowned: bool) -> anyhow::Res if !any { output.push_str("No active tasks.\n"); } - Ok(output) + Ok((output, any)) } fn render_task_line(task: &Task, agent_id: &str) -> String { diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 2dd64eee0..640b1dcf3 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -1485,6 +1485,8 @@ impl Channel { &mut self, def: &'static crate::commands::CommandDef, action: crate::commands::ControlAction, + args: &str, + is_authority: bool, ) { use crate::commands::ControlAction; @@ -1509,6 +1511,11 @@ impl Channel { channel_model, branch_model, &temporal_context.current_time_line(), + self.deps + .settings() + .and_then(|settings| settings.home_channel()) + .as_ref(), + self.deps.pause_reason().as_deref(), ); self.send_builtin_text(body, def.name).await; } @@ -1528,6 +1535,27 @@ impl Channel { self.send_builtin_text(self.deps.agent_id.to_string(), def.name) .await; } + ControlAction::SetHome => { + let is_portal = self.current_adapter() == Some("portal"); + let reply = + crate::commands::control::set_home_channel(&self.deps, &self.id, is_portal) + .await; + self.send_builtin_text(reply, def.name).await; + } + ControlAction::SetPause => { + let reply = crate::commands::control::set_pause(&self.deps, args); + self.send_builtin_text(reply, def.name).await; + } + ControlAction::WhoAmI => { + let surface = crate::commands::Surface::from_source( + self.current_adapter().unwrap_or("unknown"), + ); + self.send_builtin_text( + crate::commands::control::whoami_text(is_authority, surface), + def.name, + ) + .await; + } } } @@ -2175,6 +2203,7 @@ impl Channel { // Increment message counter for memory persistence self.message_count += message_count; self.check_memory_persistence().await; + self.claim_home_channel_if_unset().await; Ok(()) } @@ -2400,7 +2429,24 @@ impl Channel { match &parsed_command { crate::commands::ParseResult::Command(cmd) => { if let crate::commands::CommandHandler::Control(action) = cmd.def.handler { - self.handle_control_command(cmd.def, action).await; + // The router parses with the receiving bot's username and + // this path does not, so text it declined as addressed to + // another bot still resolves here. Gate on the authority + // it stamped, or `/sethome@otherbot` would run unchecked. + let is_authority = crate::commands::dispatch::sender_is_authority(&message); + if !crate::commands::access_allows(cmd.def, is_authority) { + self.send_builtin_text( + crate::commands::access::denial_text( + cmd.def, + crate::commands::Surface::from_source(&message.source), + ), + cmd.def.name, + ) + .await; + return Ok(()); + } + self.handle_control_command(cmd.def, action, &cmd.args, is_authority) + .await; return Ok(()); } } @@ -2708,11 +2754,38 @@ impl Channel { self.retrigger_count = 0; self.message_count += 1; self.check_memory_persistence().await; + self.claim_home_channel_if_unset().await; } Ok(()) } + /// A fresh instance has no home, which is when proactive behavior most + /// wants one. The first conversation to complete a turn adopts it, and + /// says so — the destination is never a default the user discovers by + /// receiving something unexpected. + async fn claim_home_channel_if_unset(&mut self) { + if self.state.kind != ChannelKind::User { + return; + } + let is_portal = self.current_adapter() == Some("portal"); + let Some(target) = + crate::commands::control::adopt_home_channel(&self.deps, &self.id, is_portal).await + else { + return; + }; + + self.send_builtin_text( + format!( + "heads up: nothing was set as my home channel, so i've taken this chat \ + ({target}). anything i bring up on my own lands here. use /sethome \ + elsewhere to move it." + ), + "home-adopted", + ) + .await; + } + /// Build the rendered available channels fragment for cross-channel awareness. async fn build_available_channels(&self) -> Option { self.deps.messaging_manager.as_ref()?; @@ -3091,6 +3164,11 @@ impl Channel { // reply() always sends live — cron channels use set_outcome() for delivery. let reply_target = crate::tools::ReplyTarget::Live(Box::new(routed_sender.clone())); + // Tools that change instance-wide state are registered per turn + // against the sender driving it, so a non-authority turn never has + // them on the table to be talked into calling. + let sender_is_authority = crate::commands::dispatch::sender_is_authority(¤t_inbound); + match self.resolved_settings.delegation { DelegationMode::Standard => { // Current behavior - standard channel tools only @@ -3108,6 +3186,7 @@ impl Channel { adapter.map(|s| s.to_string()), slack_thread_ts.as_deref(), self.state.cron_outcome.clone(), + sender_is_authority, ) .await { @@ -3131,6 +3210,7 @@ impl Channel { adapter.map(|s| s.to_string()), slack_thread_ts.as_deref(), self.state.cron_outcome.clone(), + sender_is_authority, ) .await { diff --git a/src/api/autonomy.rs b/src/api/autonomy.rs index ed845c896..52719116f 100644 --- a/src/api/autonomy.rs +++ b/src/api/autonomy.rs @@ -44,6 +44,15 @@ pub struct AutonomyCurrentRun { pub started_at: String, } +/// Where this agent's proactive messages go when no wake overrides it. +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct HomeChannelStatus { + /// Canonical `adapter:target` string. + pub target: String, + /// Set deliberately, rather than adopted on the first completed turn. + pub explicit: bool, +} + #[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct AutonomyStatusResponse { pub agent_id: String, @@ -64,6 +73,9 @@ pub struct AutonomyStatusResponse { /// The in-flight run, when one is active. pub current_run: Option, pub pending_wake_events: i64, + /// Resolved home channel, or `null` when the agent has nowhere to speak + /// on its own. + pub home_channel: Option, } #[derive(Serialize, Deserialize, utoipa::ToSchema)] @@ -146,6 +158,17 @@ async fn build_status( next_run_at, current_run, pending_wake_events, + home_channel: deps + .runtime_config + .settings + .load() + .as_ref() + .as_ref() + .and_then(|settings| settings.home_channel()) + .map(|home| HomeChannelStatus { + target: home.target, + explicit: home.explicit, + }), }) } @@ -295,6 +318,47 @@ pub(super) async fn update_autonomy_ceiling( autonomy_fleet(State(state)).await } +/// Clear an agent's home channel, returning it to sending nothing on its own. +/// +/// There is no set-from-here counterpart: a home is claimed from the chat that +/// should receive it, so the only action this surface can offer is giving it +/// up. +#[utoipa::path( + delete, + path = "/agents/autonomy/home", + params(AutonomyStatusQuery), + responses( + (status = 200, body = AutonomyStatusResponse), + (status = 404, description = "Agent not found"), + (status = 500, description = "Internal server error"), + ), + tag = "autonomy", +)] +pub(super) async fn clear_home_channel( + State(state): State>, + Query(query): Query, +) -> Result, StatusCode> { + let deps = agent_deps(&state, &query.agent_id) + .await + .ok_or(StatusCode::NOT_FOUND)?; + + let settings = deps + .runtime_config + .settings + .load() + .as_ref() + .clone() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + settings.clear_home_channel().map_err(|error| { + tracing::warn!(%error, agent_id = %query.agent_id, "failed to clear home channel"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + tracing::info!(agent_id = %query.agent_id, "home channel cleared via API"); + + autonomy_status(State(state), Query(query)).await +} + /// List recent autonomy runs, newest first. Scoped to one agent when /// `agent_id` is given, aggregated across all agents otherwise. #[utoipa::path( diff --git a/src/api/server.rs b/src/api/server.rs index 996dfdd4f..565f654be 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -131,6 +131,7 @@ pub fn api_router() -> OpenApiRouter> { .routes(routes!(autonomy::autonomy_status)) .routes(routes!(autonomy::autonomy_fleet)) .routes(routes!(autonomy::update_autonomy_ceiling)) + .routes(routes!(autonomy::clear_home_channel)) .routes(routes!(autonomy::autonomy_runs)) // Wake routes .routes(routes!(wakes::list_wakes)) diff --git a/src/commands.rs b/src/commands.rs index f7b0cac79..f95a9aa0c 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -8,6 +8,7 @@ pub mod dispatch; pub mod native; pub mod registry; +pub use access::access_allows; pub use registry::{ AgentAction, ArgSpec, BusyPolicy, COMMANDS, CommandAccess, CommandAvailability, CommandCategory, CommandDef, CommandHandler, CommandRegistry, ControlAction, ParseResult, diff --git a/src/commands/access.rs b/src/commands/access.rs index 4b29dea1a..068be8760 100644 --- a/src/commands/access.rs +++ b/src/commands/access.rs @@ -122,10 +122,19 @@ impl AccessContext<'_> { } pub fn allows(&self, def: &CommandDef) -> bool { - match def.access { - CommandAccess::Everyone => true, - CommandAccess::Authority => self.is_authority(), - } + access_allows(def, self.is_authority()) + } +} + +/// Whether an already-resolved authority verdict permits `def`. +/// +/// The router resolves authority from binding and adapter config; the channel +/// only receives the verdict. Both gate on this one rule so a command cannot +/// be open on one path and closed on the other. +pub fn access_allows(def: &CommandDef, is_authority: bool) -> bool { + match def.access { + CommandAccess::Everyone => true, + CommandAccess::Authority => is_authority, } } @@ -165,6 +174,110 @@ pub fn busy_queued_text(def: &CommandDef) -> String { ) } +#[cfg(test)] +mod authority_gate_tests { + use super::*; + use crate::commands::dispatch::sender_is_authority; + use crate::{InboundMessage, MessageContent}; + + fn message_with(metadata: Vec<(&str, serde_json::Value)>) -> InboundMessage { + let mut message = InboundMessage::empty(); + message.content = MessageContent::Text("/sethome".to_string()); + for (key, value) in metadata { + message.metadata.insert(key.to_string(), value); + } + message + } + + #[test] + fn unstamped_messages_carry_no_authority() { + // A path that never passed the router cannot inherit authority by + // omission. + assert!(!sender_is_authority(&message_with(vec![]))); + assert!(!sender_is_authority(&message_with(vec![( + super::super::dispatch::AUTHORITY_METADATA_KEY, + serde_json::Value::String("true".into()), + )]))); + } + + /// The router declines to dispatch a media caption — attachments have to + /// travel with the message — so the channel handles it. Skipping the + /// stamp on the way out would deny an authority sender their own + /// commands and tools on any turn that carries a file. + #[test] + fn every_content_type_is_stamped_before_the_router_gives_up() { + let defaults = AdapterAuthorityDefaults::default(); + let authority: Vec = vec!["boss".to_string()]; + + for content in [ + MessageContent::Text("/sethome".to_string()), + MessageContent::Media { + text: Some("/sethome".to_string()), + attachments: Vec::new(), + }, + MessageContent::Interaction { + action_id: "button".to_string(), + block_id: None, + values: Vec::new(), + label: None, + message_ts: None, + }, + ] { + for (sender, expected) in [("boss", true), ("someone-else", false)] { + let mut message = InboundMessage::empty(); + message.content = content.clone(); + message.sender_id = sender.to_string(); + let scope = crate::commands::dispatch::DispatchScope { + binding_authority: Some(&authority), + adapter_defaults: &defaults, + binding_settings: None, + turn_active: false, + }; + + let stamped = crate::commands::dispatch::stamp_authority(&mut message, &scope); + assert_eq!(stamped, expected); + assert_eq!( + sender_is_authority(&message), + expected, + "verdict must reach the channel for every content type" + ); + } + } + } + + #[test] + fn stamped_authority_round_trips() { + assert!(sender_is_authority(&message_with(vec![( + super::super::dispatch::AUTHORITY_METADATA_KEY, + serde_json::Value::Bool(true), + )]))); + assert!(!sender_is_authority(&message_with(vec![( + super::super::dispatch::AUTHORITY_METADATA_KEY, + serde_json::Value::Bool(false), + )]))); + } + + /// The channel re-parses raw text without the receiving bot's username, + /// so text the router declined as addressed elsewhere still resolves + /// there. That path must gate on the stamped verdict. + #[test] + fn addressed_commands_diverge_between_the_two_parsers() { + let registry = &crate::commands::REGISTRY; + assert!(matches!( + registry.parse_addressed("/sethome@otherbot", Some("spacebot")), + crate::commands::ParseResult::NotACommand + )); + let reparsed = registry.parse("/sethome@otherbot"); + let crate::commands::ParseResult::Command(parsed) = reparsed else { + panic!("channel-side parse should still resolve the command"); + }; + assert_eq!(parsed.def.name, "sethome"); + assert_eq!(parsed.def.access, CommandAccess::Authority); + assert!(!access_allows(parsed.def, false)); + assert!(access_allows(parsed.def, true)); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/commands/control.rs b/src/commands/control.rs index 3d369ed0e..85102e4e8 100644 --- a/src/commands/control.rs +++ b/src/commands/control.rs @@ -55,20 +55,42 @@ pub fn status_text( channel_model: &str, branch_model: &str, now_line: &str, + home: Option<&crate::settings::HomeChannel>, + pause_reason: Option<&str>, ) -> String { + // A pause overrides everything below it, so it leads. + let paused_line = match pause_reason { + Some("") => "- paused: yes\n".to_string(), + Some(reason) => format!("- paused: yes ({reason})\n"), + None => String::new(), + }; format!( "status\n\ + {paused_line}\ - agent: {agent_id}\n\ - channel: {channel_id}\n\ - adapter: {adapter}\n\ - mode: {}\n\ - channel model: {channel_model}\n\ - branch model: {branch_model}\n\ + - home: {}\n\ - time: {now_line}", - mode_label(mode) + mode_label(mode), + home_label(home) ) } +/// How `/status` renders the home channel. An implicit home is marked so the +/// destination is never a silent default the user discovers by receiving +/// something unexpected. +fn home_label(home: Option<&crate::settings::HomeChannel>) -> String { + match home { + Some(home) if home.explicit => home.target.clone(), + Some(home) => format!("{} (adopted on first run)", home.target), + None => "not set".to_string(), + } +} + /// Everything a control command needs, owned so execution can run in a /// spawned task without borrowing the router loop. pub struct ControlPlane { @@ -84,8 +106,9 @@ pub struct ControlPlane { } impl ControlPlane { - /// Execute a control action and return the reply text. - pub async fn execute(&self, action: ControlAction) -> String { + /// Execute a control action and return the reply text. `args` carries the + /// validated argument string for actions that take one. + pub async fn execute(&self, action: ControlAction, args: &str) -> String { match action { ControlAction::Status => self.status().await, ControlAction::SetResponseMode(mode) => self.set_response_mode(mode).await, @@ -93,9 +116,16 @@ impl ControlPlane { crate::commands::REGISTRY.help_text_for(Some(self.surface), self.is_authority) } ControlAction::AgentId => self.deps.agent_id.to_string(), + ControlAction::SetHome => self.set_home().await, + ControlAction::SetPause => set_pause(&self.deps, args), + ControlAction::WhoAmI => whoami_text(self.is_authority, self.surface), } } + async fn set_home(&self) -> String { + set_home_channel(&self.deps, &self.conversation_id, self.is_portal).await + } + /// Resolve the conversation's settings the same way channel creation /// does: per-conversation DB override > binding defaults > defaults. async fn resolved_settings(&self) -> ResolvedConversationSettings { @@ -169,6 +199,8 @@ impl ControlPlane { channel_model, branch_model, &temporal_context.current_time_line(), + self.deps.settings().and_then(|s| s.home_channel()).as_ref(), + self.deps.pause_reason().as_deref(), ) } @@ -207,6 +239,188 @@ impl ControlPlane { } } +/// Resolve a conversation into the canonical delivery target that reaches it. +/// Portal conversations have no broadcast address, so they cannot be a home. +async fn conversation_broadcast_target( + deps: &crate::AgentDeps, + conversation_id: &str, + is_portal: bool, +) -> std::result::Result { + if is_portal { + return Err( + "the portal isn't a delivery target — set the home from the chat that should \ + receive proactive messages" + .to_string(), + ); + } + + let store = crate::conversation::ChannelStore::new(deps.sqlite_pool.clone()); + let channel = match store.get(conversation_id).await { + Ok(Some(channel)) => channel, + Ok(None) => return Err("couldn't resolve this chat's delivery address".to_string()), + Err(error) => { + tracing::warn!( + %error, + %conversation_id, + "failed to load channel while setting home" + ); + return Err("couldn't read this chat's delivery address".to_string()); + } + }; + + crate::messaging::target::resolve_broadcast_target(&channel) + .ok_or_else(|| "couldn't resolve this chat's delivery address".to_string()) +} + +/// `/whoami` reply: what the sender may do here, not who they are. The point +/// is to make the authority split legible before someone hits a denial. +pub fn whoami_text(is_authority: bool, surface: Surface) -> String { + let restricted = crate::commands::REGISTRY + .defs() + .iter() + .filter(|def| def.availability.on(surface)) + .filter(|def| def.access == crate::commands::CommandAccess::Authority) + .count(); + + if is_authority { + format!( + "you have authority in this chat — every command is available to you, \ + including the {restricted} that change my state." + ) + } else { + format!( + "you can talk to me and run the read-only commands here. the {restricted} commands \ + that change my state are limited to this chat's authority list. /help lists what \ + you can run." + ) + } +} + +/// `/pause` reply, and the state change behind it. Empty args pause without a +/// stated reason; `off` resumes. +pub fn set_pause(deps: &crate::AgentDeps, args: &str) -> String { + let Some(settings) = deps.runtime_config.settings.load().as_ref().clone() else { + return "settings storage isn't available — pause state unchanged".to_string(); + }; + + let args = args.trim(); + if args.eq_ignore_ascii_case("off") { + if settings.pause_reason().is_none() { + return "not paused.".to_string(); + } + return match settings.set_paused(None) { + Ok(()) => { + tracing::info!(agent = %deps.agent_id, "resumed after pause"); + "resumed. new work starts again.".to_string() + } + Err(error) => { + tracing::warn!(%error, "failed to clear pause"); + "couldn't clear the pause — still paused".to_string() + } + }; + } + + match settings.set_paused(Some(args)) { + Ok(()) => { + tracing::warn!(agent = %deps.agent_id, reason = args, "paused: new work will not start"); + let suffix = if args.is_empty() { + String::new() + } else { + format!(" ({args})") + }; + format!( + "paused{suffix}. i won't start new work — commands still reach me. \ + /pause off resumes." + ) + } + Err(error) => { + tracing::warn!(%error, "failed to persist pause"); + "couldn't save the pause — still running".to_string() + } + } +} + +/// Claim a conversation as the home channel on behalf of first-run adoption, +/// returning the canonical target when this conversation actually took it. +/// +/// Unlike [`set_home_channel`] this never displaces an existing home, and it +/// is silent about every reason it might decline — the caller only cares +/// whether it now owns the home and therefore owes the user an announcement. +pub(crate) async fn adopt_home_channel( + deps: &crate::AgentDeps, + conversation_id: &str, + is_portal: bool, +) -> Option { + let settings = deps.runtime_config.settings.load().as_ref().clone()?; + if settings.home_channel().is_some() { + return None; + } + + let target = conversation_broadcast_target(deps, conversation_id, is_portal) + .await + .ok()? + .to_string(); + + match settings.adopt_home_channel(&target) { + Ok(true) => { + tracing::info!( + agent = %deps.agent_id, + home_channel = %target, + "adopted home channel on first completed turn" + ); + Some(target) + } + Ok(false) => None, + Err(error) => { + tracing::warn!(%error, "failed to adopt home channel"); + None + } + } +} + +/// Set a conversation as the instance's home channel, returning the reply. +/// +/// Shared by `/sethome` and the `set_home_channel` tool so both entry points +/// validate and persist identically. Validation runs here rather than at first +/// send, so a home the agent cannot reach is rejected while the user is still +/// looking at the reply. +pub async fn set_home_channel( + deps: &crate::AgentDeps, + conversation_id: &str, + is_portal: bool, +) -> String { + let target = match conversation_broadcast_target(deps, conversation_id, is_portal).await { + Ok(target) => target, + Err(message) => return message, + }; + + if let Some(manager) = deps.messaging_manager.as_ref() + && !manager.has_adapter(&target.adapter).await + { + return format!("no '{}' adapter is running — home not set", target.adapter); + } + + let Some(settings) = deps.runtime_config.settings.load().as_ref().clone() else { + return "settings storage isn't available — home not set".to_string(); + }; + + let canonical = target.to_string(); + match settings.set_home_channel(&canonical) { + Ok(()) => { + tracing::info!( + agent = %deps.agent_id, + home_channel = %canonical, + "home channel set" + ); + format!("home channel set to this chat ({canonical}). proactive messages land here.") + } + Err(error) => { + tracing::warn!(%error, "failed to persist home channel"); + "couldn't save the home channel — it's unchanged".to_string() + } + } +} + /// Persist the response mode through the stores' atomic field updates, /// leaving every other settings field untouched. No settings read happens /// here, so concurrent whole-row writers can't be clobbered with stale @@ -240,6 +454,89 @@ mod tests { use super::*; use sqlx::sqlite::SqlitePoolOptions; + fn status_with( + home: Option<&crate::settings::HomeChannel>, + pause_reason: Option<&str>, + ) -> String { + status_text( + "orion", + "discord:guild:1", + "discord", + ResponseMode::Active, + "model-a", + "model-b", + "now", + home, + pause_reason, + ) + } + + fn status_with_home(home: Option<&crate::settings::HomeChannel>) -> String { + status_with(home, None) + } + + #[test] + fn status_omits_the_pause_line_when_running() { + assert!(!status_with(None, None).contains("paused")); + } + + #[test] + fn status_leads_with_a_pause_and_its_reason() { + let body = status_with(None, Some("deploying")); + assert!(body.contains("- paused: yes (deploying)")); + // The pause outranks everything it suppresses, so it reads first. + assert!(body.find("paused").unwrap() < body.find("- agent:").unwrap()); + } + + #[test] + fn status_reports_a_reasonless_pause_without_empty_parens() { + let body = status_with(None, Some("")); + assert!(body.contains("- paused: yes\n")); + assert!(!body.contains("()")); + } + + #[test] + fn whoami_distinguishes_authority_from_everyone() { + let authority = whoami_text(true, Surface::Discord); + let everyone = whoami_text(false, Surface::Discord); + assert!(authority.contains("you have authority")); + assert!(everyone.contains("read-only")); + // Both name the same count, so the two replies describe one boundary. + let restricted = crate::commands::REGISTRY + .defs() + .iter() + .filter(|def| def.availability.on(Surface::Discord)) + .filter(|def| def.access == crate::commands::CommandAccess::Authority) + .count(); + assert!(authority.contains(&restricted.to_string())); + assert!(everyone.contains(&restricted.to_string())); + } + + #[test] + fn status_reports_an_unset_home() { + assert!(status_with_home(None).contains("- home: not set")); + } + + #[test] + fn status_marks_an_adopted_home_as_implicit() { + let home = crate::settings::HomeChannel { + target: "discord:123".to_string(), + explicit: false, + }; + assert!( + status_with_home(Some(&home)).contains("- home: discord:123 (adopted on first run)") + ); + } + + #[test] + fn status_reports_an_explicit_home_bare() { + let home = crate::settings::HomeChannel { + target: "discord:123".to_string(), + explicit: true, + }; + assert!(status_with_home(Some(&home)).contains("- home: discord:123\n")); + } + async fn memory_pool() -> sqlx::SqlitePool { SqlitePoolOptions::new() .max_connections(1) diff --git a/src/commands/dispatch.rs b/src/commands/dispatch.rs index b08a5b94f..264bb7405 100644 --- a/src/commands/dispatch.rs +++ b/src/commands/dispatch.rs @@ -13,7 +13,7 @@ //! Replies (usage, denials, control output, busy acks) are ephemeral where //! the platform supports it and degrade to plain messages elsewhere. -use super::access::{self, AccessContext}; +use super::access::{self, AccessContext, access_allows}; use super::control::ControlPlane; use super::registry::{BusyPolicy, CommandHandler, ControlAction, ParseResult, Surface}; use crate::messaging::MessagingManager; @@ -48,6 +48,50 @@ pub struct DispatchScope<'a> { /// Classify and, where possible, fully handle a slash command. Replies are /// sent on spawned tasks so the router loop never blocks on an adapter. +/// Metadata key carrying the sender's resolved authority to the channel. +/// +/// Authority resolves here, where the binding and adapter config live, and +/// travels with the message: everything downstream that must respect it — +/// the channel's own command path, authority-gated tool registration — reads +/// this rather than re-deriving a check it has no configuration for. Absent +/// means no authority, so a path that never passed through the router cannot +/// inherit one. +pub const AUTHORITY_METADATA_KEY: &str = "sender_is_authority"; + +/// Resolve the sender's authority for the scope this message arrived in and +/// record it on the message, returning the verdict. +/// +/// Every non-system inbound message gets one, whether or not the router goes +/// on to handle it as a command. +pub(crate) fn stamp_authority(message: &mut InboundMessage, scope: &DispatchScope<'_>) -> bool { + let is_authority = AccessContext { + binding_authority: scope.binding_authority, + adapter_default: scope.adapter_defaults.for_adapter(message.adapter_key()), + sender_id: &message.sender_id, + sender_login: message + .metadata + .get("twitch_user_login") + .and_then(|value| value.as_str()), + } + .is_authority(); + + message.metadata.insert( + AUTHORITY_METADATA_KEY.to_string(), + serde_json::Value::Bool(is_authority), + ); + is_authority +} + +/// Whether the router resolved this sender as holding authority in the scope +/// the message arrived in. +pub fn sender_is_authority(message: &InboundMessage) -> bool { + message + .metadata + .get(AUTHORITY_METADATA_KEY) + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} + pub async fn dispatch_inbound( message: &mut InboundMessage, scope: DispatchScope<'_>, @@ -57,12 +101,23 @@ pub async fn dispatch_inbound( if message.source == "system" { return Dispatch::Forward; } + let surface = Surface::from_source(&message.source); + // Stamped for every non-system message, ahead of both the content match + // and the parse. The channel builds its command text from media captions + // too, and registers authority-gated tools per turn, so anything this + // function declines to handle still has to carry the verdict. + let is_authority = stamp_authority(message, &scope); + let text = match &message.content { MessageContent::Text(text) => text.clone(), // Command content arrives from surfaces that parse client-side // (Discord interactions, Slack subcommands, the portal palette); it // renders as "/name args" so the shared parser revalidates it. MessageContent::Command { .. } => message.content.to_string(), + // A media caption can carry a command, but the router does not + // dispatch it: attachments have to reach the channel with the + // message. The channel's own command path handles it, gated on the + // verdict stamped above. _ => return Dispatch::Forward, }; @@ -79,18 +134,7 @@ pub async fn dispatch_inbound( ParseResult::Command(parsed) => parsed, }; - let surface = Surface::from_source(&message.source); - let context = AccessContext { - binding_authority: scope.binding_authority, - adapter_default: scope.adapter_defaults.for_adapter(message.adapter_key()), - sender_id: &message.sender_id, - sender_login: message - .metadata - .get("twitch_user_login") - .and_then(|value| value.as_str()), - }; - let is_authority = context.is_authority(); - if !context.allows(parsed.def) { + if !access_allows(parsed.def, is_authority) { reply_ephemeral( messaging, deps, @@ -119,18 +163,24 @@ pub async fn dispatch_inbound( // later /active. The work is a local SQLite roundtrip plus // an in-memory cell update; only the reply delivery goes // over the network, and that stays on a spawned task. - ControlAction::SetResponseMode(_) => { - let reply = plane.execute(action).await; + ControlAction::SetResponseMode(_) + | ControlAction::SetHome + | ControlAction::SetPause => { + let reply = plane.execute(action, &parsed.args).await; reply_ephemeral(messaging, deps, message, reply); } // Read-only control commands stay off the router's critical // path entirely. - ControlAction::Status | ControlAction::Help | ControlAction::AgentId => { + ControlAction::Status + | ControlAction::Help + | ControlAction::AgentId + | ControlAction::WhoAmI => { let messaging = messaging.clone(); let deps = deps.clone(); let target = message.clone(); + let args = parsed.args.clone(); tokio::spawn(async move { - let reply = plane.execute(action).await; + let reply = plane.execute(action, &args).await; send_ephemeral(&messaging, &deps, &target, reply).await; }); } diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 48d2fd948..a78ca513d 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -171,6 +171,12 @@ pub enum ControlAction { /// Print the runtime agent id. Deterministic so identity checks bypass /// model output drift. AgentId, + /// Adopt the calling conversation as the instance's home channel. + SetHome, + /// Hold off on starting new work, or resume. + SetPause, + /// Report the sender's authority in this scope. + WhoAmI, } /// Agent-turn commands. @@ -378,6 +384,28 @@ fn normalize_smart_dashes(args: &str) -> String { /// The command table. Order within a category is display order in `/help`. pub static COMMANDS: &[CommandDef] = &[ + CommandDef { + name: "sethome", + description: "set this chat as the home channel", + category: CommandCategory::Session, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Control(ControlAction::SetHome), + access: CommandAccess::Authority, + busy: BusyPolicy::Queue, + availability: CommandAvailability::ALL, + }, + CommandDef { + name: "pause", + description: "stop starting new work everywhere; '/pause off' resumes", + category: CommandCategory::Session, + aliases: &[], + args: ArgSpec::Optional("[reason | off]"), + handler: CommandHandler::Control(ControlAction::SetPause), + access: CommandAccess::Authority, + busy: BusyPolicy::Queue, + availability: CommandAvailability::ALL, + }, CommandDef { name: "active", description: "normal reply mode", @@ -466,6 +494,17 @@ pub static COMMANDS: &[CommandDef] = &[ busy: BusyPolicy::Queue, availability: CommandAvailability::ALL, }, + CommandDef { + name: "whoami", + description: "show your command access in this chat", + category: CommandCategory::Info, + aliases: &[], + args: ArgSpec::None, + handler: CommandHandler::Control(ControlAction::WhoAmI), + access: CommandAccess::Everyone, + busy: BusyPolicy::Queue, + availability: CommandAvailability::ALL, + }, CommandDef { name: "agent-id", description: "runtime agent id", diff --git a/src/config/load.rs b/src/config/load.rs index 738f90776..1ae125fba 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1888,6 +1888,11 @@ impl Config { .as_deref() .and_then(|s| s.parse().ok()) .unwrap_or(base_defaults.worker_log_mode), + home_channel: toml + .defaults + .home_channel + .clone() + .or_else(|| base_defaults.home_channel.clone()), projects: toml .defaults .projects @@ -1915,6 +1920,15 @@ impl Config { .unwrap_or_else(|| base_defaults.projects.clone()), }; + if let Some(home) = defaults.home_channel.as_deref() + && crate::messaging::target::parse_delivery_target(home).is_none() + { + return Err(ConfigError::Invalid(format!( + "defaults.home_channel '{home}' is invalid: expected format 'adapter:target'" + )) + .into()); + } + let mut agents: Vec = toml .agents .into_iter() diff --git a/src/config/toml_schema.rs b/src/config/toml_schema.rs index 713942aaa..58f63f717 100644 --- a/src/config/toml_schema.rs +++ b/src/config/toml_schema.rs @@ -320,6 +320,7 @@ pub(super) struct TomlDefaultsConfig { pub(super) user_timezone: Option, pub(super) opencode: Option, pub(super) worker_log_mode: Option, + pub(super) home_channel: Option, pub(super) projects: Option, } diff --git a/src/config/types.rs b/src/config/types.rs index 2395d09b3..c648655a2 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -667,6 +667,9 @@ pub struct DefaultsConfig { pub opencode: OpenCodeConfig, /// Worker log mode: "errors_only", "all_separate", or "all_combined". pub worker_log_mode: crate::settings::WorkerLogMode, + /// Seeds the home channel of an instance that ships pre-configured, in + /// `adapter:target` form. Only applied when no home is stored yet. + pub home_channel: Option, /// Projects workspace management defaults. pub projects: ProjectsConfig, } @@ -702,6 +705,7 @@ impl std::fmt::Debug for DefaultsConfig { .field("tool_use_enforcement", &self.tool_use_enforcement) .field("opencode", &self.opencode) .field("worker_log_mode", &self.worker_log_mode) + .field("home_channel", &self.home_channel) .field("projects", &self.projects) .finish() } @@ -1766,6 +1770,7 @@ impl Default for DefaultsConfig { tool_use_enforcement: ToolUseEnforcement::default(), opencode: OpenCodeConfig::default(), worker_log_mode: crate::settings::WorkerLogMode::default(), + home_channel: None, projects: ProjectsConfig::default(), } } diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 84dff11fd..58c7d760c 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -589,6 +589,18 @@ impl Scheduler { } } + // Skip the fire while paused, after the cursor has already + // advanced: a pause drops the runs it covers rather than + // banking them into a burst at resume. + if let Some(reason) = context.deps.pause_reason() { + tracing::info!( + cron_id = %job_id, + reason = %reason, + "skipping cron fire while paused" + ); + continue; + } + tracing::info!(cron_id = %job_id, "cron job firing"); execution_lock.store(true, std::sync::atomic::Ordering::Release); diff --git a/src/lib.rs b/src/lib.rs index bfd083eae..aaced1c6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -514,6 +514,17 @@ pub struct AgentDeps { } impl AgentDeps { + /// The settings store, once the runtime has wired it up. + pub fn settings(&self) -> Option> { + self.runtime_config.settings.load().as_ref().clone() + } + + /// Why this agent is holding off on new work, or `None` when running + /// normally. A pause with no stated reason yields an empty string. + pub fn pause_reason(&self) -> Option { + self.settings()?.pause_reason() + } + /// Lifecycle handle for daemon restart/shutdown requests. `None` outside /// the daemon runtime (tests, config preview). pub fn lifecycle(&self) -> Option { diff --git a/src/main.rs b/src/main.rs index 027c18848..6164f8579 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1625,6 +1625,18 @@ async fn run( spacebot::commands::dispatch::Dispatch::Forward | spacebot::commands::dispatch::Dispatch::ForwardCommand => {} } + + // A paused agent starts no new work. Commands dispatch + // above this line, so /pause off and /status still land. + if let Some(reason) = agent.deps.pause_reason() { + tracing::debug!( + agent_id = %agent_id, + conversation_id = %conversation_id, + reason = %reason, + "dropping inbound message while paused" + ); + continue; + } } // Find or create a channel for this conversation @@ -2439,6 +2451,19 @@ async fn initialize_agents( if let Err(error) = settings_store.set_worker_log_mode(config.defaults.worker_log_mode) { tracing::warn!(%error, agent = %agent_config.id, "failed to set worker_log_mode from config"); } + // Config seeds the home channel; a value set at runtime owns it from + // then on and is never clobbered by a reload. + if let Some(home) = config.defaults.home_channel.as_deref() { + match settings_store.adopt_home_channel(home) { + Ok(true) => { + tracing::info!(agent = %agent_config.id, home_channel = %home, "seeded home channel from config") + } + Ok(false) => {} + Err(error) => { + tracing::warn!(%error, agent = %agent_config.id, "failed to seed home_channel from config") + } + } + } // Share the instance-level secrets store with this agent. if let Some(secrets_store) = bootstrapped_store { diff --git a/src/messaging/target.rs b/src/messaging/target.rs index 3960fa398..e27e57826 100644 --- a/src/messaging/target.rs +++ b/src/messaging/target.rs @@ -49,6 +49,31 @@ pub fn parse_delivery_target(raw: &str) -> Option { }) } +/// Resolve where an autonomous send should go: an explicit per-wake target +/// wins, the instance's home channel is the default, and neither resolving +/// means the caller records instead of sending. +/// +/// Never falls back to a recently-seen channel — an unresolvable target is +/// silence, not a guess, so a private observation cannot land in a group the +/// agent merely happens to be in. +/// +/// A wake that names a target has said where its output belongs, and that it +/// does not belong at home. If that target stops parsing — an adapter rename, +/// a hand-edited config — the send is recorded, not redirected: the home is +/// exactly the destination the wake declined. +pub fn resolve_home_target( + settings: Option<&crate::settings::SettingsStore>, + wake_target: Option<&str>, +) -> Option { + match wake_target { + Some(explicit) => parse_delivery_target(explicit), + None => settings? + .home_channel() + .as_ref() + .and_then(|home| parse_delivery_target(&home.target)), + } +} + /// Resolve adapter and broadcast target from a tracked channel. pub fn resolve_broadcast_target(channel: &ChannelInfo) -> Option { let adapter = channel.platform.as_str(); @@ -1098,4 +1123,117 @@ mod tests { // 21 characters (over boundary) assert!(!super::is_valid_instance_name("exactly_twenty_chars_")); } + + fn store_with_home(home: Option<&str>) -> (tempfile::TempDir, crate::settings::SettingsStore) { + let dir = tempfile::tempdir().expect("tempdir"); + let store = + crate::settings::SettingsStore::new(&dir.path().join("settings.redb")).expect("store"); + if let Some(home) = home { + store.set_home_channel(home).expect("set home"); + } + (dir, store) + } + + #[test] + fn wake_target_wins_over_home() { + let (_dir, store) = store_with_home(Some("discord:111")); + let resolved = super::resolve_home_target(Some(&store), Some("telegram:222")); + assert_eq!(resolved.map(|t| t.to_string()), Some("telegram:222".into())); + } + + #[test] + fn home_is_the_fallback_when_no_wake_target() { + let (_dir, store) = store_with_home(Some("discord:111")); + let resolved = super::resolve_home_target(Some(&store), None); + assert_eq!(resolved.map(|t| t.to_string()), Some("discord:111".into())); + } + + #[test] + fn unparseable_wake_target_records_rather_than_redirecting_home() { + let (_dir, store) = store_with_home(Some("discord:111")); + // The wake named somewhere that is not home; a broken name must not + // silently become home. + assert!(super::resolve_home_target(Some(&store), Some("no-colon")).is_none()); + } + + #[test] + fn neither_set_resolves_to_nothing() { + let (_dir, store) = store_with_home(None); + assert!(super::resolve_home_target(Some(&store), None).is_none()); + assert!(super::resolve_home_target(None, None).is_none()); + } + + #[test] + fn concurrent_adoption_has_exactly_one_winner() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = std::sync::Arc::new( + crate::settings::SettingsStore::new(&dir.path().join("settings.redb")).expect("store"), + ); + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(8)); + let winners: Vec = (0..8) + .map(|i| { + let store = store.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + store + .adopt_home_channel(&format!("discord:{i}")) + .expect("adopt") + }) + }) + .collect::>() + .into_iter() + .map(|handle| handle.join().expect("thread")) + .collect(); + + assert_eq!( + winners.iter().filter(|claimed| **claimed).count(), + 1, + "exactly one caller may claim the home" + ); + let home = store.home_channel().expect("home"); + assert!(!home.explicit); + // The stored target belongs to the one caller that reported winning. + let winner = winners.iter().position(|claimed| *claimed).expect("winner"); + assert_eq!(home.target, format!("discord:{winner}")); + } + + #[test] + fn pause_flag_and_reason_move_together() { + let (_dir, store) = store_with_home(None); + assert!(store.pause_reason().is_none()); + + store.set_paused(Some("deploying")).expect("pause"); + assert_eq!(store.pause_reason().as_deref(), Some("deploying")); + + // Resuming clears the reason with the flag rather than leaving a + // stale one behind for the next pause to inherit. + store.set_paused(None).expect("resume"); + assert!(store.pause_reason().is_none()); + store.set_paused(Some("")).expect("pause"); + assert_eq!(store.pause_reason().as_deref(), Some("")); + } + + #[test] + fn implicit_home_never_overwrites_a_claimed_one() { + let (_dir, store) = store_with_home(None); + + assert!(store.adopt_home_channel("discord:111").expect("adopt")); + // A second implicit adoption loses to the first — first run wins. + assert!(!store.adopt_home_channel("telegram:222").expect("adopt")); + let home = store.home_channel().expect("home"); + assert_eq!(home.target, "discord:111"); + assert!(!home.explicit); + + // An explicit set replaces it and marks it explicit. + store.set_home_channel("telegram:222").expect("set"); + let home = store.home_channel().expect("home"); + assert_eq!(home.target, "telegram:222"); + assert!(home.explicit); + + // And an implicit adoption can never take it back. + assert!(!store.adopt_home_channel("discord:333").expect("adopt")); + assert_eq!(store.home_channel().expect("home").target, "telegram:222"); + } } diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index d4b31ccbc..ef9131c77 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -687,6 +687,7 @@ impl PromptEngine { max_tasks_per_run: u32, warn_minutes: u64, claim_unowned: bool, + instance_is_empty: bool, ) -> Result { self.render( "autonomy_channel", @@ -701,6 +702,7 @@ impl PromptEngine { max_tasks_per_run => max_tasks_per_run, warn_minutes => warn_minutes, claim_unowned => claim_unowned, + instance_is_empty => instance_is_empty, }, ) } @@ -1131,6 +1133,7 @@ mod tests { 2, 8, true, + false, ) .expect("observe prompt should render"); assert!(observe.contains("You are Iris.")); @@ -1156,12 +1159,17 @@ mod tests { 1, 1, false, + true, ) .expect("act prompt should render"); assert!(act.contains("Scheduled interval — no wake events pending.")); assert!(act.contains("Execute ready tasks")); assert!(act.contains("up to 1 task this run")); assert!(!act.contains("claim unowned tasks")); + // An empty instance gets cold-start guidance instead of a bare survey. + assert!(act.contains("There are no tasks and no goals.")); + assert!(act.contains("spacebot_docs")); + assert!(act.contains("Do not invent tasks to look busy.")); // An unrecognized level falls back to observe-only rules. let unknown = engine @@ -1176,9 +1184,13 @@ mod tests { 1, 1, false, + false, ) .expect("unknown level prompt should render"); assert!(unknown.contains("Treat this run as observe: survey and summarize only.")); + // Recording is instructed at every level; cold-start guidance is not. + assert!(unknown.contains("Record what you notice as you go")); + assert!(!unknown.contains("There are no tasks and no goals.")); } #[test] diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 37c52d3c9..cab8b53e2 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -193,6 +193,9 @@ fn lookup(lang: &str, key: &str) -> &'static str { include_str!("../../prompts/en/tools/set_status_description.md.j2") } ("en", "tools/shell") => include_str!("../../prompts/en/tools/shell_description.md.j2"), + ("en", "tools/set_home_channel") => { + include_str!("../../prompts/en/tools/set_home_channel_description.md.j2") + } ("en", "tools/restart") => { include_str!("../../prompts/en/tools/restart_description.md.j2") } diff --git a/src/settings.rs b/src/settings.rs index 8c6d73490..53dfbd16f 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -2,4 +2,4 @@ pub mod store; -pub use store::{SettingsStore, WORKER_LOG_MODE_KEY, WorkerLogMode}; +pub use store::{HOME_CHANNEL_KEY, HomeChannel, SettingsStore, WORKER_LOG_MODE_KEY, WorkerLogMode}; diff --git a/src/settings/store.rs b/src/settings/store.rs index d43b6f846..53ba2b87e 100644 --- a/src/settings/store.rs +++ b/src/settings/store.rs @@ -1,7 +1,7 @@ //! Key-value settings storage (redb). use crate::error::{Result, SettingsError}; -use redb::{Database, TableDefinition}; +use redb::{Database, ReadableTable, TableDefinition}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::sync::Arc; @@ -12,6 +12,14 @@ const SETTINGS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("settin /// Default key for worker log mode setting. pub const WORKER_LOG_MODE_KEY: &str = "worker_log_mode"; const PROMPT_CAPTURE_PREFIX: &str = "prompt_capture:"; +/// Canonical `adapter:target` string for the instance's home channel. +pub const HOME_CHANNEL_KEY: &str = "home_channel"; +/// Whether the stored home channel was set deliberately. +const HOME_CHANNEL_EXPLICIT_KEY: &str = "home_channel_explicit"; +/// Whether the agent is holding off on starting new work. +const PAUSED_KEY: &str = "paused"; +/// Operator-supplied reason shown wherever the pause surfaces. +const PAUSE_REASON_KEY: &str = "pause_reason"; /// How worker execution logs are stored. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -49,6 +57,15 @@ impl std::str::FromStr for WorkerLogMode { } } +/// The instance's default outbound destination. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HomeChannel { + /// Canonical `adapter:target` string, as produced by `BroadcastTarget`. + pub target: String, + /// Set deliberately by a principal, rather than adopted on first run. + pub explicit: bool, +} + /// Settings store backed by redb. pub struct SettingsStore { db: Arc, @@ -114,6 +131,78 @@ impl SettingsStore { Ok(value.value().to_string()) } + /// Read a key, distinguishing "no such key" from a failed read. Callers + /// that fold both into a default lose the difference between an unset + /// value and an unreadable store. + fn get_optional(&self, key: &str) -> Result> { + match self.get_raw(key) { + Ok(value) => Ok(Some(value)), + Err(crate::error::Error::Settings(boxed)) + if matches!(*boxed, SettingsError::NotFound { .. }) => + { + Ok(None) + } + Err(error) => Err(error), + } + } + + /// Write several keys in one transaction, so fields that are only + /// meaningful together cannot be left half-updated by a crash between + /// two commits. + fn set_many(&self, pairs: &[(&str, &str)]) -> Result<()> { + let failed = |key: &str, e: &dyn std::fmt::Display| SettingsError::WriteFailed { + key: key.to_string(), + details: e.to_string(), + }; + let first = pairs.first().map(|(key, _)| *key).unwrap_or_default(); + + let write_txn = self.db.begin_write().map_err(|e| failed(first, &e))?; + { + let mut table = write_txn + .open_table(SETTINGS_TABLE) + .map_err(|e| failed(first, &e))?; + for (key, value) in pairs { + table.insert(*key, *value).map_err(|e| failed(key, &e))?; + } + } + write_txn.commit().map_err(|e| failed(first, &e))?; + Ok(()) + } + + /// Write `pairs` only while `guard_key` holds no non-empty value, + /// returning whether the write happened. + /// + /// The check and the writes share one write transaction, which redb + /// serializes — two callers racing to claim the same slot cannot both + /// see it empty. + fn set_many_if_unset(&self, guard_key: &str, pairs: &[(&str, &str)]) -> Result { + let failed = |key: &str, e: &dyn std::fmt::Display| SettingsError::WriteFailed { + key: key.to_string(), + details: e.to_string(), + }; + + let write_txn = self.db.begin_write().map_err(|e| failed(guard_key, &e))?; + let claimed = { + let mut table = write_txn + .open_table(SETTINGS_TABLE) + .map_err(|e| failed(guard_key, &e))?; + let taken = table + .get(guard_key) + .map_err(|e| failed(guard_key, &e))? + .is_some_and(|value| !value.value().is_empty()); + if taken { + false + } else { + for (key, value) in pairs { + table.insert(*key, *value).map_err(|e| failed(key, &e))?; + } + true + } + }; + write_txn.commit().map_err(|e| failed(guard_key, &e))?; + Ok(claimed) + } + /// Set a raw string value by key. fn set_raw(&self, key: &str, value: &str) -> Result<()> { let write_txn = self @@ -173,6 +262,90 @@ impl SettingsStore { let key = format!("{PROMPT_CAPTURE_PREFIX}{channel_id}"); self.set_raw(&key, if enabled { "true" } else { "false" }) } + + /// The instance's home channel, or `None` when unset. + /// + /// An unreadable store reports "no home", which costs a proactive message + /// its destination — the message is recorded instead. Adoption does not + /// build on this read, so a failure here cannot displace a stored home. + pub fn home_channel(&self) -> Option { + let target = match self.get_optional(HOME_CHANNEL_KEY) { + Ok(target) => target.filter(|t| !t.is_empty())?, + Err(error) => { + tracing::warn!(%error, "failed to read home channel; treating as unset"); + return None; + } + }; + let explicit = match self.get_optional(HOME_CHANNEL_EXPLICIT_KEY) { + Ok(value) => value.as_deref() == Some("true"), + Err(error) => { + tracing::warn!(%error, "failed to read home channel provenance"); + false + } + }; + Some(HomeChannel { target, explicit }) + } + + /// Set the home channel deliberately, replacing whatever was there. + pub fn set_home_channel(&self, target: &str) -> Result<()> { + self.set_many(&[ + (HOME_CHANNEL_KEY, target), + (HOME_CHANNEL_EXPLICIT_KEY, "true"), + ]) + } + + /// Why the agent is paused, or `None` when it is running normally. A + /// pause with no stated reason yields an empty string. + /// An unreadable store reports paused. A stop the operator asked for + /// outranks the agent's availability: resuming is one command away, but + /// silently running through an emergency stop is not recoverable. + pub fn pause_reason(&self) -> Option { + match self.get_optional(PAUSED_KEY) { + Ok(value) => { + if value.as_deref() != Some("true") { + return None; + } + } + Err(error) => { + tracing::error!(%error, "failed to read pause state; holding work until it reads"); + return Some("pause state unreadable".to_string()); + } + } + Some( + self.get_optional(PAUSE_REASON_KEY) + .unwrap_or_default() + .unwrap_or_default(), + ) + } + + /// Hold off on starting new work, or resume. Survives restart so an + /// emergency stop is not undone by a bounce. + pub fn set_paused(&self, reason: Option<&str>) -> Result<()> { + match reason { + Some(reason) => self.set_many(&[(PAUSE_REASON_KEY, reason), (PAUSED_KEY, "true")]), + None => self.set_many(&[(PAUSE_REASON_KEY, ""), (PAUSED_KEY, "false")]), + } + } + + /// Drop the home channel, returning the instance to sending nothing on + /// its own. + pub fn clear_home_channel(&self) -> Result<()> { + self.set_many(&[(HOME_CHANNEL_KEY, ""), (HOME_CHANNEL_EXPLICIT_KEY, "false")]) + } + + /// Adopt `target` as the home channel only when nothing has claimed it. + /// Returns whether the value was taken. An implicit home never overwrites + /// an explicit one, and never overwrites another implicit one — first run + /// wins until a principal sets it deliberately. + pub fn adopt_home_channel(&self, target: &str) -> Result { + self.set_many_if_unset( + HOME_CHANNEL_KEY, + &[ + (HOME_CHANNEL_KEY, target), + (HOME_CHANNEL_EXPLICIT_KEY, "false"), + ], + ) + } } impl std::fmt::Debug for SettingsStore { diff --git a/src/tools.rs b/src/tools.rs index 8c93cfbfa..7da48963b 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -62,6 +62,7 @@ pub mod secret_set; pub mod send_agent_message; pub mod send_file; pub mod send_message_to_another_channel; +pub mod set_home_channel; pub mod set_outcome; pub mod set_status; pub mod shell; @@ -158,6 +159,9 @@ pub use send_file::{SendFileArgs, SendFileError, SendFileOutput, SendFileTool}; pub use send_message_to_another_channel::{ SendMessageArgs, SendMessageError, SendMessageOutput, SendMessageTool, }; +pub use set_home_channel::{ + SetHomeChannelArgs, SetHomeChannelError, SetHomeChannelOutput, SetHomeChannelTool, +}; pub use set_outcome::{SetOutcomeArgs, SetOutcomeError, SetOutcomeOutput, SetOutcomeTool}; pub use set_status::{SetStatusArgs, SetStatusError, SetStatusOutput, SetStatusTool, StatusKind}; pub use shell::{EnvVar, ShellArgs, ShellError, ShellOutput, ShellResult, ShellTool}; @@ -480,6 +484,7 @@ pub async fn add_channel_tools( current_adapter: Option, slack_thread_ts: Option<&str>, cron_outcome: Option, + sender_is_authority: bool, ) -> Result<(), rig::tool::server::ToolServerError> { let conversation_id = conversation_id.into(); let channel_kind = state.kind; @@ -507,6 +512,20 @@ pub async fn add_channel_tools( )) .await?; } + // The home channel is where the agent speaks when no conversation is in + // scope, so only a real user conversation can claim it — and only on a + // turn driven by someone holding authority. `/sethome` is authority-gated + // for the same reason, and a tool the model can be talked into calling + // must not be the way around it. + if channel_kind == crate::agent::channel::ChannelKind::User && sender_is_authority { + handle + .add_tool(SetHomeChannelTool::new( + state.deps.clone(), + conversation_id.clone(), + current_adapter.as_deref() == Some("portal"), + )) + .await?; + } handle.add_tool(BranchTool::new(state.clone())).await?; handle.add_tool(SpawnWorkerTool::new(state.clone())).await?; handle.add_tool(RouteTool::new(state.clone())).await?; @@ -648,6 +667,7 @@ pub async fn add_direct_mode_tools( current_adapter: Option, slack_thread_ts: Option<&str>, cron_outcome: Option, + sender_is_authority: bool, ) -> Result<(), rig::tool::server::ToolServerError> { // First add all standard channel tools add_channel_tools( @@ -664,6 +684,7 @@ pub async fn add_direct_mode_tools( current_adapter.clone(), slack_thread_ts, cron_outcome, + sender_is_authority, ) .await?; @@ -675,6 +696,11 @@ pub async fn add_direct_mode_tools( .add_tool(MemorySaveTool::new(state.deps.memory_search.clone())) .await?; + // Self-documentation lookup, for reasoning about capabilities the user + // has not set up yet. Direct-mode channels do not branch, so without this + // the docs are unreachable to them. + handle.add_tool(SpacebotDocsTool::new()).await?; + let rc = &state.deps.runtime_config; let workspace = rc.workspace_dir.clone(); let sandbox = state.deps.sandbox.clone(); diff --git a/src/tools/set_home_channel.rs b/src/tools/set_home_channel.rs new file mode 100644 index 000000000..d023d388d --- /dev/null +++ b/src/tools/set_home_channel.rs @@ -0,0 +1,88 @@ +//! Home channel tool: points the instance's proactive outreach at this chat. +//! +//! Registered on user channels only, and resolves the conversation it was +//! called from rather than taking a target argument — the intent ("make this +//! your home") is expressible in a sentence, so the model calls this directly. +//! `/sethome` is a second entry point over the same handler. + +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Tool that sets the calling conversation as the home channel. +#[derive(Clone)] +pub struct SetHomeChannelTool { + deps: crate::AgentDeps, + conversation_id: String, + is_portal: bool, +} + +impl std::fmt::Debug for SetHomeChannelTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SetHomeChannelTool") + .field("conversation_id", &self.conversation_id) + .field("is_portal", &self.is_portal) + .finish_non_exhaustive() + } +} + +impl SetHomeChannelTool { + pub fn new( + deps: crate::AgentDeps, + conversation_id: impl Into, + is_portal: bool, + ) -> Self { + Self { + deps, + conversation_id: conversation_id.into(), + is_portal, + } + } +} + +/// Error type for the home channel tool. +#[derive(Debug, thiserror::Error)] +#[error("Setting the home channel failed: {0}")] +pub struct SetHomeChannelError(String); + +/// The tool takes no arguments — it resolves the calling conversation. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SetHomeChannelArgs {} + +/// Output from the home channel tool. +#[derive(Debug, Serialize)] +pub struct SetHomeChannelOutput { + pub message: String, +} + +impl Tool for SetHomeChannelTool { + const NAME: &'static str = "set_home_channel"; + + type Error = SetHomeChannelError; + type Args = SetHomeChannelArgs; + type Output = SetHomeChannelOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/set_home_channel").to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } + + async fn call(&self, _args: Self::Args) -> Result { + let message = crate::commands::control::set_home_channel( + &self.deps, + &self.conversation_id, + self.is_portal, + ) + .await; + + Ok(SetHomeChannelOutput { message }) + } +} diff --git a/tests/context_dump.rs b/tests/context_dump.rs index d0f12371b..9439987cc 100644 --- a/tests/context_dump.rs +++ b/tests/context_dump.rs @@ -299,6 +299,8 @@ async fn dump_channel_context() { None, None, None, + // The context dump renders the authority-gated tool surface too. + true, ) .await .expect("failed to add channel tools"); @@ -563,6 +565,8 @@ async fn dump_all_contexts() { None, None, None, + // The context dump renders the authority-gated tool surface too. + true, ) .await .expect("failed to add channel tools");