diff --git a/plugins/brains/generated/capability-catalog.json b/plugins/brains/generated/capability-catalog.json new file mode 100644 index 0000000..c349311 --- /dev/null +++ b/plugins/brains/generated/capability-catalog.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "catalog_schema_version": 1, + "renderer_version": 2, + "capability_id": "integration-actions", + "source_repository": "https://github.com/ssvlabs/brains", + "source_generator": "apps/mcp/scripts/generate-capability-catalog.ts", + "catalog_sha256": "c46b9cf2f88ed3cefe0f5766665c12f06dafaa0e56dba1f5586ab9cd56c39ac2", + "artifact_path": "plugins/brains/skills/brains-write/SKILL.md", + "artifact_sha256": "f14839e4ed852771fc8dd91b0196f9caeeb63ce56dead747a53d83fd83dd9475" +} diff --git a/plugins/brains/skills/brains-write/SKILL.md b/plugins/brains/skills/brains-write/SKILL.md index b85c55f..e8f4e6f 100644 --- a/plugins/brains/skills/brains-write/SKILL.md +++ b/plugins/brains/skills/brains-write/SKILL.md @@ -3,73 +3,59 @@ name: brains-write description: How to take ACTIONS on the user's integrations through brains — send an email, create a calendar event, make a Drive doc, comment on a Monday item, run a live Gmail search, post a message. Use whenever the user wants to send/create/reply/forward/mark/schedule something, or "message/ping/DM" someone. Covers the codex action flow and the draft→confirm gate. --- -# Acting on integrations (writes) - -Codex-first. Each connected integration declares its own actions; discover and -dispatch them generically. If nothing matches, refine the discovery query or say -no installed action covers it; there is no source-enum fallback. - -## The codex path (preferred) - -1. **Discover** — `query type=integration_action text=""`. - It returns ranked page slugs; call `get_page` on the selected result to read - its frontmatter: `install_id`, `action_name`, `description`, `input_schema`, - `requires_confirmation`, and `side_effect`. The page body carries example - requests — strong hints for shaping `input`. -2. **Dispatch** — `act_on_integration install_id=<…> action_name=<…> input={…}` - (input matches `input_schema`; do not send a free-form `request`). - Build `input` yourself from the user's words; if the ask is fuzzy, the - discovery `query` + the action's `examples` tell you which action and shape. - -### Return kinds — know which is the success state - -- **`requires_confirmation: false`** → executes inline now, returns - `{kind:"auto_executed", result, action_record_id}`. **Done** — do NOT try to - confirm it, and do not assume it was read-only. -- **`kind:"rate_limited"`** → nothing ran and no upstream call was made. Retry - after `retry_after_seconds`. -- **`kind:"auto_failed"`** → the action was attempted, so whether an outbound - write reached the provider is **unknown**. Never blind-retry: read the state - back (or send the user to `/inbox`) before re-sending. A pure read is safe to - retry. -- **`requires_confirmation` absent from the frontmatter** → the page predates - the field, so the outcome cannot be predicted. Never assume it will draft: - dispatch the full tuple, branch on the returned `kind`, and report - `auto_executed` in the past tense. -- **`requires_confirmation: true`** → returns - `{kind:"draft", draft_id, action, preview, payload, confirm_hint, expires_at}`. - Relay the `preview` and `confirm_hint`, then stop. The user confirms through - the real controls in `/inbox` (web/mobile) or Telegram. Do **not** call - `confirm_action` from this agent loop. If the user cancels or corrects the - draft, call `discard_action`; for a correction, draft the structured action - again with the new input. Destructive sends (email, calendar invite, doc - create) live here. After 1 hour a draft moves to the `/inbox` Expired tab but - remains approvable, so discard the old draft before redrafting. + -The out-of-band surfaces hold the confirmation capability; this chat does not. - -### Live Gmail search - -gmail-inbox ships `search_emails` (`requires_confirmation: false`) — runs native -Gmail search at runtime for mail the ingested pages don't cover. `input={query: -"", limit: 1..50}`; pass `write_pages: true` to ALSO persist each -match as an `email` page. Reach for it AFTER `list_pages`/`search` come up short, -not before. - -## Gmail / Calendar / Drive +# Acting on integrations (writes) -These are codex installs like any other. `query type=integration_action` finds -their action pages; `get_page` reveals the `install_id`. Dispatch the same -`install_id` + `action_name` + `input` tuple. A bare `source` drafts nothing. +Each connected integration declares its own actions. If nothing matches, refine +the discovery query or say no installed action covers it; there is no +source-enum fallback. + +Discover with `query type=integration_action text=""`. +It returns slug/title/snippet, so call `get_page` on the selected +slug to read frontmatter. Use its `install_id`, `action_name`, and structured +`input` in `act_on_integration`; this tuple is the only call +shape. Partial tuples error; only bare legacy `source` returns `clarification`. +`requires_confirmation:true` drafts for out-of-band approval; +`requires_confirmation:false` runs inline. If +`requires_confirmation` is absent, the page predates the field: treat whether +it drafts or runs as unknown. `side_effect` says where it writes +(`external` = the provider, visible outside brains; +`null` or absent = undeclared, treat as external). Inline external writes +include `rsvp_event`, +`create_draft`, and `add_labels`; do not infer safety from read vs write. +Cap: 30 auto-executions/install/60s. +Automation `dry_run` suppresses external writes to no-call `[DRY RUN]` drafts. + +| `kind` | What happened | What you do | +|---|---|---| +| `draft` | Nothing sent; it carries `preview`, `confirm_hint`, and `expires_at`. | Relay the preview and hint, then stop; only `draft` carries `confirm_hint`. | +| `auto_executed` | It already ran; it carries `result` and `action_record_id`. | Report what happened, past tense; never say it is awaiting approval. | +| `auto_failed` | It was attempted; whether an outbound write reached the provider is **unknown**. | Don't blind-retry. Read provider state or `/inbox` before re-sending; retry only when the action is idempotent or the error proves nothing was sent. A pure read is safe to retry. | +| `rate_limited` | Nothing ran and no upstream call occurred. | Retry after `retry_after_seconds`. | +| `clarification` | A `source`-only legacy call reached the shim; no action ran. | Answer its `question`, then resend the full tuple. | +| `noop` | The integration is unavailable, or the action was suppressed because an automation is running in verify mode. | Relay its `reason`. If it starts with `verify_mode:`, report that the action was safely suppressed; otherwise point to `/integrations`. | + +You can't confirm a draft from this loop. Only the user's out-of-band +`/inbox` or Telegram surface has the confirmation secret. So never call `confirm_action` yourself. +After one hour a draft moves to the `/inbox` Expired tab but remains +approvable. There is no edit-at-confirm step: call +`discard_action` before re-drafting corrected input. + +The selected action page also carries `description`, `input_schema`, +`side_effect`, and example requests. Build structured `input` from those +facts; do not send a free-form `request`. + +## Live Gmail search + +`gmail-inbox` ships `search_emails` with `requires_confirmation:false`. +Use `input={query:"", limit:1..50}`; `write_pages:true` also +persists each match. Reach for it only after `list_pages` or `search` comes +up short. ## Routing a generic "message someone" -No hardcoded default channel. For a -generic "send a msg / message X / ping / DM" with no channel named, use the codex -discovery above (`query type=integration_action`) and dispatch to whichever -messaging integration the user actually has connected. Use `gmail` only when they -say "email," give an email address, or are replying to/forwarding a thread. -`calendar`/`drive` only when explicit. - -**Always relay a destructive action's preview and confirmation hint, then leave -the decision to the user's out-of-band approval surface.** +Do not hardcode a default channel. Discover the messaging actions the user +actually has installed. Use Gmail only when they say "email", provide an email +address, or are replying to or forwarding a thread. diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index 03919ef..276b54d 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -141,6 +142,7 @@ const inboxHook = readFileSync(join(PLUGIN, "hooks", "lib", "brains-inbox.sh"), const readme = readFileSync(join(ROOT, "README.md"), "utf8"); const core = readFileSync(join(PLUGIN, "core.md"), "utf8"); const writeSkill = readFileSync(join(PLUGIN, "skills", "brains-write", "SKILL.md"), "utf8"); +const capabilityManifest = readJson(join(PLUGIN, "generated", "capability-catalog.json")); const coreNormalized = core.replace(/\s+/g, " "); const writeSkillNormalized = writeSkill.replace(/\s+/g, " "); @@ -438,41 +440,85 @@ for (const signal of [ } assert(core.length < 3_000, "always-loaded core must stay below 3,000 characters"); -// This public plugin is a sixth model-visible copy of the act contract, outside -// the monorepo's ACT_CONTRACT_COPIES gate. Mirror its four load-bearing rules -// here so a future compaction cannot drift independently again. -assert(writeSkillNormalized.includes("install_id=<…> action_name=<…> input={…}"), "structured action tuple missing"); -assert(writeSkillNormalized.includes("call `get_page` on the selected result"), "action discovery must resolve frontmatter"); +// The public face is generated from the monorepo capability catalog. Verify its +// immutable artifact digest locally; installation never fetches a mutable copy. +assert(capabilityManifest.schema_version === 1, "capability manifest schema mismatch"); +assert(capabilityManifest.catalog_schema_version === 1, "catalog schema mismatch"); +assert(capabilityManifest.renderer_version === 2, "catalog renderer mismatch"); +assert(capabilityManifest.capability_id === "integration-actions", "capability id mismatch"); +assert( + capabilityManifest.artifact_path === "plugins/brains/skills/brains-write/SKILL.md", + "generated artifact path mismatch", +); +assert( + capabilityManifest.artifact_sha256 === + createHash("sha256").update(writeSkill, "utf8").digest("hex"), + "generated brains-write artifact digest mismatch", +); +assert( + !/automation_secret|adminPool|handler_source|telegram_push|grant_token/.test(writeSkill), + "public skill leaked an internal-only capability", +); + +// Keep independent semantic assertions: digest equality proves provenance, not +// that the canonical source itself kept the load-bearing safety rules. assert( - writeSkillNormalized.includes("`requires_confirmation` absent from the frontmatter") && - writeSkillNormalized.includes("the outcome cannot be predicted") && - writeSkillNormalized.includes("Never assume it will draft"), + writeSkillNormalized.includes("its `install_id`, `action_name`, and structured `input` in `act_on_integration`") && + writeSkillNormalized.includes("this tuple is the only call shape"), + "structured action tuple missing", +); +assert(writeSkillNormalized.includes("call `get_page` on the selected"), "action discovery must resolve frontmatter"); +assert( + writeSkillNormalized.includes("`requires_confirmation` is absent") && + writeSkillNormalized.includes("treat whether it drafts or runs as unknown"), "absent requires_confirmation must remain unknown rather than predict a draft", ); assert( - writeSkillNormalized.includes('**`requires_confirmation: false`** → executes inline now, returns') && - writeSkillNormalized.includes('{kind:"auto_executed", result, action_record_id}'), - "requires_confirmation:false must be documented as already executed", + writeSkillNormalized.includes("`requires_confirmation:true` drafts for out-of-band approval") && + writeSkillNormalized.includes("`requires_confirmation:false` runs inline") && + writeSkillNormalized.includes("| `auto_executed` | It already ran; it carries `result` and `action_record_id`."), + "both requires_confirmation branches must retain their distinct behavior", +); +assert( + writeSkillNormalized.includes("Partial tuples error") && + writeSkillNormalized.includes("only bare legacy `source` returns `clarification`"), + "partial tuples must error; only the bare legacy source shim may clarify", ); assert(writeSkillNormalized.includes("there is no source-enum fallback"), "legacy source-enum fallback must stay removed"); assert(!writeSkillNormalized.includes("Fall back to the legacy source-enum"), "stale legacy fallback pointer must not return"); assert(writeSkillNormalized.includes("action_record_id"), "auto-executed result must expose action_record_id"); assert(!writeSkillNormalized.includes("audit_id"), "stale auto-executed audit_id field must not return"); assert( - writeSkillNormalized.includes('**`kind:"rate_limited"`** → nothing ran and no upstream call was made'), + writeSkillNormalized.includes("| `rate_limited` | Nothing ran and no upstream call occurred."), "rate-limited actions must be documented as not attempted", ); +assert( + writeSkillNormalized.includes("30 auto-executions/install/60s"), + "auto-execution rate-limit context missing", +); +assert(writeSkillNormalized.includes("| `clarification` |"), "clarification result kind missing"); +assert(writeSkillNormalized.includes("| `noop` |"), "noop result kind missing"); assert( writeSkillNormalized.includes("whether an outbound write reached the provider is **unknown**"), "auto-failed actions must preserve unknown-outcome guidance", ); -assert(writeSkillNormalized.includes("Never blind-retry"), "auto-failed external writes must not be blindly retried"); -assert(writeSkillNormalized.includes("The out-of-band surfaces hold the confirmation capability"), "approval boundary missing"); -assert(writeSkillNormalized.includes("Do **not** call `confirm_action`"), "agent self-confirm prohibition missing"); +assert(writeSkillNormalized.includes("Don't blind-retry"), "auto-failed external writes must not be blindly retried"); +assert( + writeSkillNormalized.includes("only `draft` carries `confirm_hint`") && + writeSkillNormalized.includes("never say it is awaiting approval"), + "draft and auto-executed reporting gates must remain distinct", +); +assert( + writeSkillNormalized.includes("`dry_run` suppresses external writes to no-call `[DRY RUN]` drafts"), + "dry-run external-write suppression missing", +); +assert(writeSkill.endsWith("\n"), "generated public skill must end with a newline"); +assert(writeSkillNormalized.includes("out-of-band") && writeSkillNormalized.includes("confirmation secret"), "approval boundary missing"); +assert(writeSkillNormalized.includes("never call `confirm_action` yourself"), "agent self-confirm prohibition missing"); assert(writeSkillNormalized.includes("call `discard_action`"), "agent-side draft discard path missing"); assert(!writeSkillNormalized.includes("never call `discard_action`"), "draft discard guidance must remain actionable"); assert(writeSkillNormalized.includes("remains approvable"), "expired drafts must not be described as inert"); -assert(writeSkillNormalized.includes("A bare `source` drafts nothing"), "source-only action fallback must stay prohibited"); +assert(writeSkillNormalized.includes("this tuple is the only call shape"), "source-only action fallback must stay prohibited"); assert(!/act_on_integration[^.]{0,200}request=/.test(writeSkillNormalized), "free-form action request must not return"); // This README is the install instructions for anyone who finds the repo directly rather than the