From e163992cc662cf0a4336e1b394de5c4a179febc3 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Wed, 24 Jun 2026 13:57:49 +0300 Subject: [PATCH 1/2] feat(sharing): per-agent custom instructions for public & channel chats (#1205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners can attach extra system-prompt instructions that apply to public-facing conversations ONLY — public links, channel chats (Slack/Telegram/WhatsApp), and x402 paid chat — without changing the agent's behavior in their own authenticated chats, scheduled runs, loops, or agent-to-agent calls. The text-surface counterpart of voice_system_prompt. - schema/migration: agent_ownership.public_channel_system_prompt TEXT (schema.py + tables.py Core object + versioned migration, Invariant #3) - db: get/set_public_channel_system_prompt (set strips, empty clears) + facade delegation - models: PublicChannelPrompt / PublicChannelPromptUpdate (4000-char cap) - api: owner-only GET/PUT /api/agents/{name}/public-prompt (sharing.py), mirroring the voice-prompt endpoints - injection: platform_prompt_service.build_public_channel_caller_prompt folds the fragment with the MEM-001 memory block (public fragment first); wired into the three public-facing sites — message_router (channels), public.py (sync+async), paid.py (x402). Authenticated chat / schedules / loops / a2a never call it, so the scope exclusion holds by construction. Unset = strict no-op; a DB error degrades to the memory block (never blocks a chat) - ui: Additional Instructions textarea (save/clear, char counter) in SharingPanel.vue via two agents-store methods - tests: db get/set/clear/isolation + helper composition (4 combos + db-error degradation), SQLite + Postgres; schema-parity + migrations suites green - docs: requirements section 44, architecture endpoint + column Related to #1205 Co-Authored-By: Claude Opus 4.8 --- .claude | 2 +- docs/memory/architecture.md | 2 + docs/memory/requirements.md | 31 ++++++ src/backend/adapters/message_router.py | 6 +- src/backend/database.py | 5 + src/backend/db/agents.py | 37 +++++++ src/backend/db/migrations.py | 17 ++++ src/backend/db/schema.py | 1 + src/backend/db/tables.py | 1 + src/backend/enterprise | 2 +- src/backend/models.py | 20 ++++ src/backend/routers/paid.py | 2 + src/backend/routers/public.py | 11 ++- src/backend/routers/sharing.py | 35 ++++++- .../services/platform_prompt_service.py | 30 ++++++ src/frontend/src/components/SharingPanel.vue | 89 ++++++++++++++++- src/frontend/src/stores/agents.js | 19 ++++ tests/unit/test_public_channel_prompt.py | 96 +++++++++++++++++++ 18 files changed, 398 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_public_channel_prompt.py diff --git a/.claude b/.claude index 301ecb1a8..b927710b9 160000 --- a/.claude +++ b/.claude @@ -1 +1 @@ -Subproject commit 301ecb1a8b22ca2948630cf734e3d01550f69d7b +Subproject commit b927710b9c488910eb17525fdefc0c42df26b15d diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 923815b76..aba79265d 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -728,6 +728,7 @@ The per-agent VoIP config + voice-picker UI lives in the agent Settings/Sharing | DELETE | `/api/agents/{name}/share/{email}` | Remove share | | GET | `/api/agents/{name}/shares` | List shares | | GET | `/api/agents/{name}/access` | Operator (Trinity-user) access roster for the **Access tab** (trinity-enterprise#17). Resolves each `agent_sharing` allow-list email against `users`: resolved → **active** operator (`username`/`role`/`last_active`), unresolved → **pending** invite. Read-only typed view over `agent_sharing`; add/remove reuse `/share` + `/share/{email}`. Drawing the operator-vs-client line on the read path is this endpoint's job (strict client roster is the Sharing redesign #18/#20) | +| GET/PUT | `/api/agents/{name}/public-prompt` | Owner-only per-agent custom instructions (`public_channel_system_prompt`, 4000-char cap) folded into the system prompt for **public-facing surfaces only** — public links, channel router (Slack/Telegram/WhatsApp), x402 paid chat — via `platform_prompt_service.build_public_channel_caller_prompt` (composes with the MEM-001 memory block). NOT applied to authenticated chat, schedules, loops, or a2a. Text counterpart of `voice_system_prompt` (#1205) | | GET/PUT | `/api/agents/{name}/access-policy` | Cross-channel access policy: `require_email` / `open_access` flags | | GET | `/api/agents/{name}/access-requests` | Pending access requests | | POST | `/api/agents/{name}/access-requests/{id}/decide` | Approve (auto-shares + fire-and-forget approval notification on the requester's originating channel for telegram/slack/whatsapp, #951) or reject | @@ -973,6 +974,7 @@ CREATE TABLE agent_ownership ( voice_system_prompt TEXT, voice_name TEXT, -- #28: persisted Gemini voice (NULL → 'Kore') public_channel_model TEXT, -- #894: per-agent model for public channels (NULL → platform default) + public_channel_system_prompt TEXT, -- #1205: public/channel-only custom-instructions fragment guardrails_config TEXT, file_sharing_enabled INTEGER DEFAULT 0, -- FILES-001 circuit_breaker_enabled INTEGER DEFAULT 0, -- RELIABILITY-007 (#526): dispatch-breaker opt-in diff --git a/docs/memory/requirements.md b/docs/memory/requirements.md index e3135f7e4..b6010d12c 100644 --- a/docs/memory/requirements.md +++ b/docs/memory/requirements.md @@ -3415,6 +3415,37 @@ servers (each replica polls + reconciles independently). --- +## 46. Public & Channel Custom Instructions (#1205) + +### 46.1 Per-Agent Public-Facing System-Prompt Fragment (#1205) + +**Description**: An agent owner can attach extra system-prompt instructions that +apply **only to public-facing conversations** — public links, channel chats +(Slack/Telegram/WhatsApp), and x402 paid chat — without changing the agent's +behavior in their own authenticated chats, scheduled runs, loops, or +agent-to-agent calls. The text-surface counterpart of `voice_system_prompt`. +Edited from the Sharing tab. + +- **FR-1 — Storage**: `agent_ownership.public_channel_system_prompt TEXT` + (versioned migration, Invariant #3). Unset/empty is the default. +- **FR-2 — API**: owner-only `GET/PUT /api/agents/{name}/public-prompt` + mirroring the voice-prompt endpoints; PUT enforces a 4000-char cap; + empty/whitespace clears the value. +- **FR-3 — Injection surfaces**: when set, the fragment is folded into the + `caller_prompt` passed to `compose_system_prompt` at exactly three sites — + `adapters/message_router.py` (all channels), `routers/public.py` (public chat + sync + async), and `routers/paid.py` (x402). It composes with the MEM-001 + per-user memory block (public fragment first, then memory). +- **FR-4 — Scope exclusion**: NOT applied to authenticated web Chat/Session + tabs, scheduled executions, loops, or agent-to-agent calls — those paths never + call the folding helper. +- **FR-5 — Strict no-op**: an unset value changes nothing for existing agents; + a DB lookup failure degrades to the memory block alone (never blocks a chat). +- **FR-6 — Group chats**: applied to group channels too (group surfaces are + public-facing). + +--- + ## Out of Scope - Multi-tenant deployment (single org only) diff --git a/src/backend/adapters/message_router.py b/src/backend/adapters/message_router.py index 8ad408aa9..bfd3dedf6 100644 --- a/src/backend/adapters/message_router.py +++ b/src/backend/adapters/message_router.py @@ -24,6 +24,7 @@ from database import db from services.docker_service import get_agent_container from services.platform_prompt_service import ( + build_public_channel_caller_prompt, format_user_memory_block, summarize_user_memory_background, ) @@ -434,7 +435,10 @@ async def _run_agent_task( allowed_tools=public_allowed_tools, # #894: per-agent public-channel model override (None → platform default). model=db.get_public_channel_model(agent_name), - system_prompt=memory_system_prompt, + # #1205: public/channel custom instructions + MEM-001 memory. + system_prompt=build_public_channel_caller_prompt( + agent_name, memory_system_prompt + ), images=image_data or None, ) diff --git a/src/backend/database.py b/src/backend/database.py index 404a04975..b7dc489b4 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -765,6 +765,11 @@ def get_voice_name(self, agent_name: str): def set_voice_name(self, agent_name: str, voice_name): return self._agent_ops.set_voice_name(agent_name, voice_name) + def get_public_channel_system_prompt(self, agent_name: str): + return self._agent_ops.get_public_channel_system_prompt(agent_name) + + def set_public_channel_system_prompt(self, agent_name: str, prompt): + return self._agent_ops.set_public_channel_system_prompt(agent_name, prompt) def get_public_channel_model(self, agent_name: str): return self._agent_ops.get_public_channel_model(agent_name) diff --git a/src/backend/db/agents.py b/src/backend/db/agents.py index 53aaa8367..07a3ae6b0 100644 --- a/src/backend/db/agents.py +++ b/src/backend/db/agents.py @@ -461,3 +461,40 @@ def set_public_channel_model(self, agent_name: str, model: Optional[str]) -> boo .values(public_channel_model=model or None) ) return result.rowcount > 0 + + # ========================================================================= + # Public/Channel System Prompt (#1205) + # Custom instructions injected into public-facing conversations only + # (public links, Slack/Telegram/WhatsApp channels, x402 paid chat). + # Text-surface counterpart of voice_system_prompt. + # ========================================================================= + + def get_public_channel_system_prompt(self, agent_name: str) -> Optional[str]: + """Get the public/channel system prompt for an agent (#1205).""" + stmt = select(agent_ownership.c.public_channel_system_prompt).where( + agent_ownership.c.agent_name == agent_name, + agent_ownership.c.deleted_at.is_(None), + ) + with get_engine().connect() as conn: + row = conn.execute(stmt).mappings().first() + return ( + row["public_channel_system_prompt"] + if row and row["public_channel_system_prompt"] + else None + ) + + def set_public_channel_system_prompt( + self, agent_name: str, prompt: Optional[str] + ) -> bool: + """Set the public/channel system prompt for an agent (#1205). + + Empty/whitespace-only clears the value (strict no-op surface). + """ + cleaned = prompt.strip() if prompt else None + with get_engine().begin() as conn: + result = conn.execute( + update(agent_ownership) + .where(agent_ownership.c.agent_name == agent_name) + .values(public_channel_system_prompt=cleaned or None) + ) + return result.rowcount > 0 diff --git a/src/backend/db/migrations.py b/src/backend/db/migrations.py index 2ba56efc5..7452368fb 100644 --- a/src/backend/db/migrations.py +++ b/src/backend/db/migrations.py @@ -1004,6 +1004,22 @@ def _migrate_agent_ownership_public_channel_model(cursor, conn): conn.commit() +def _migrate_agent_ownership_public_channel_prompt(cursor, conn): + """Add public_channel_system_prompt column to agent_ownership (#1205). + + Per-agent custom instructions appended to the system prompt for + public-facing conversations only (public links, Slack/Telegram/WhatsApp + channels, x402 paid chat). Text-surface counterpart of voice_system_prompt. + """ + _safe_add_column( + cursor, + "agent_ownership", + "public_channel_system_prompt", + "ALTER TABLE agent_ownership ADD COLUMN public_channel_system_prompt TEXT", + ) + conn.commit() + + def _migrate_slack_channel_agents(cursor, conn): """Add multi-agent Slack support: workspace table + channel-agent bindings. @@ -2720,4 +2736,5 @@ def _migrate_agent_reports_table(cursor, conn): ("agent_loops_max_cost", _migrate_agent_loops_max_cost), ("agent_ownership_public_channel_model", _migrate_agent_ownership_public_channel_model), ("agent_ownership_mcp_exposed", _migrate_agent_ownership_mcp_exposed), + ("agent_ownership_public_channel_prompt", _migrate_agent_ownership_public_channel_prompt), ] diff --git a/src/backend/db/schema.py b/src/backend/db/schema.py index 5f3a4bf24..0e2a97fe2 100644 --- a/src/backend/db/schema.py +++ b/src/backend/db/schema.py @@ -92,6 +92,7 @@ voice_system_prompt TEXT, voice_name TEXT, public_channel_model TEXT, + public_channel_system_prompt TEXT, guardrails_config TEXT, file_sharing_enabled INTEGER DEFAULT 0, circuit_breaker_enabled INTEGER DEFAULT 0, diff --git a/src/backend/db/tables.py b/src/backend/db/tables.py index d3c431700..af6b777b5 100644 --- a/src/backend/db/tables.py +++ b/src/backend/db/tables.py @@ -96,6 +96,7 @@ def process_bind_param(self, value, dialect): Column("voice_system_prompt", Text), Column("voice_name", Text), Column("public_channel_model", Text), # #894: per-agent public-channel model override (NULL = platform default) + Column("public_channel_system_prompt", Text), # #1205: per-agent public/channel custom instructions Column("guardrails_config", Text), Column("file_sharing_enabled", Integer), Column("circuit_breaker_enabled", Integer), diff --git a/src/backend/enterprise b/src/backend/enterprise index 1e916f604..eecc98e6e 160000 --- a/src/backend/enterprise +++ b/src/backend/enterprise @@ -1 +1 @@ -Subproject commit 1e916f60483ad2a11121ce997a0b9efa609477c1 +Subproject commit eecc98e6e039f385bacf079647bbb01790b7a582 diff --git a/src/backend/models.py b/src/backend/models.py index 118cf82bd..d4e351fe2 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -653,6 +653,26 @@ class AgentCapacityUpdate(BaseModel): max_parallel_tasks: int +# Max length for the public/channel custom-instructions fragment (#1205). +PUBLIC_CHANNEL_PROMPT_MAX_LEN = 4000 + + +class PublicChannelPrompt(BaseModel): + """Per-agent custom instructions for public & channel chats (#1205). + + Response for GET, and the stored value echoed back by PUT. `null`/empty + means unset — a strict no-op for the agent's behavior. + """ + public_channel_system_prompt: Optional[str] = None + + +class PublicChannelPromptUpdate(BaseModel): + """Body for PUT /api/agents/{name}/public-prompt (#1205).""" + public_channel_system_prompt: Optional[str] = Field( + default=None, max_length=PUBLIC_CHANNEL_PROMPT_MAX_LEN + ) + + # --------------------------------------------------------------------------- # Fleet Executions (EXEC-022 / Issue #18) # --------------------------------------------------------------------------- diff --git a/src/backend/routers/paid.py b/src/backend/routers/paid.py index 0622418bd..5d97033aa 100644 --- a/src/backend/routers/paid.py +++ b/src/backend/routers/paid.py @@ -19,6 +19,7 @@ NEVERMINED_AVAILABLE, ) from services.task_execution_service import get_task_execution_service +from services.platform_prompt_service import build_public_channel_caller_prompt router = APIRouter(prefix="/api/paid", tags=["paid"]) logger = logging.getLogger(__name__) @@ -172,6 +173,7 @@ async def paid_chat(agent_name: str, request_body: PaidChatRequest, request: Req agent_name=agent_name, message=request_body.message, triggered_by="paid", + system_prompt=build_public_channel_caller_prompt(agent_name), # #1205 resume_session_id=request_body.session_id, # #894: per-agent public-channel model override (None → platform default). model=db.get_public_channel_model(agent_name), diff --git a/src/backend/routers/public.py b/src/backend/routers/public.py index 64dfe98ff..4d36af9ce 100644 --- a/src/backend/routers/public.py +++ b/src/backend/routers/public.py @@ -32,6 +32,7 @@ from services.email_service import email_service from services.task_execution_service import get_task_execution_service from services.platform_prompt_service import ( + build_public_channel_caller_prompt, format_user_memory_block, summarize_user_memory_background, ) @@ -604,7 +605,10 @@ async def public_chat( timeout_seconds=900, # #894: per-agent public-channel model override (None → platform default). model=db.get_public_channel_model(agent_name), - system_prompt=memory_system_prompt, + # #1205: per-agent public/channel custom-instructions fragment. + system_prompt=build_public_channel_caller_prompt( + agent_name, memory_system_prompt + ), images=_pub_image_data, ) @@ -927,7 +931,10 @@ async def _execute_public_chat_background( execution_id=execution_id, # #894: per-agent public-channel model override (None → platform default). model=db.get_public_channel_model(agent_name), - system_prompt=memory_system_prompt, + # #1205: per-agent public/channel custom-instructions fragment. + system_prompt=build_public_channel_caller_prompt( + agent_name, memory_system_prompt + ), images=images or [], ) diff --git a/src/backend/routers/sharing.py b/src/backend/routers/sharing.py index cd11ddcb5..472147767 100644 --- a/src/backend/routers/sharing.py +++ b/src/backend/routers/sharing.py @@ -8,7 +8,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request -from models import AccessPolicy, AccessPolicyUpdate, AccessRequest, AccessRequestDecision +from models import ( + AccessPolicy, AccessPolicyUpdate, AccessRequest, AccessRequestDecision, + User, PublicChannelPrompt, PublicChannelPromptUpdate, +) from database import db, AgentShare, AgentOperatorAccess, AgentShareRequest from dependencies import get_current_user, OwnedAgentByName, CurrentUser from services.docker_service import get_agent_container @@ -200,6 +203,36 @@ async def get_agent_access_endpoint( raise HTTPException(status_code=404, detail="Agent not found") return db.get_agent_operator_access(agent_name) +@router.get("/{agent_name}/public-prompt", response_model=PublicChannelPrompt) +async def get_public_channel_prompt_endpoint( + agent_name: OwnedAgentByName, + current_user: CurrentUser, +): + """Get the agent's public/channel custom instructions (#1205). + + Owner-only. Applies to public links, channel chats (Slack/Telegram/ + WhatsApp), and x402 paid chat — not the owner's authenticated chats, + schedules, loops, or agent-to-agent calls. + """ + return PublicChannelPrompt( + public_channel_system_prompt=db.get_public_channel_system_prompt(agent_name) + ) + + +@router.put("/{agent_name}/public-prompt", response_model=PublicChannelPrompt) +async def set_public_channel_prompt_endpoint( + agent_name: OwnedAgentByName, + body: PublicChannelPromptUpdate, + current_user: CurrentUser, +): + """Set/clear the agent's public/channel custom instructions (#1205). + + Owner-only. Empty/whitespace clears the value (strict no-op surface). + """ + db.set_public_channel_system_prompt(agent_name, body.public_channel_system_prompt) + return PublicChannelPrompt( + public_channel_system_prompt=db.get_public_channel_system_prompt(agent_name) + ) # --------------------------------------------------------------------------- diff --git a/src/backend/services/platform_prompt_service.py b/src/backend/services/platform_prompt_service.py index 53d98c1ac..fd031e07b 100644 --- a/src/backend/services/platform_prompt_service.py +++ b/src/backend/services/platform_prompt_service.py @@ -602,6 +602,36 @@ def compose_system_prompt( return "\n\n".join(parts) +def build_public_channel_caller_prompt( + agent_name: str, memory_system_prompt: Optional[str] = None +) -> Optional[str]: + """Caller-prompt fragment for public/channel surfaces (#1205). + + Folds the per-agent public/channel custom instructions + (``public_channel_system_prompt``) together with the MEM-001 per-user memory + block into the single string public/channel callers pass as + ``execute_task(system_prompt=...)`` → ``compose_system_prompt(caller_prompt=...)``. + Public instructions come first (persona / guardrails / scope), then the + per-user memory block. + + Strict no-op when the agent has no public prompt set: returns the memory + block unchanged (or ``None``). Never raises — a lookup failure degrades to + just the memory block so a chat is never blocked on this. Only the + public-facing surfaces (channel router, public chat, paid chat) call this; + authenticated chat, schedules, loops, and agent-to-agent calls do not, which + is what keeps the fragment scoped to outside audiences. + """ + try: + public_prompt = db.get_public_channel_system_prompt(agent_name) + except Exception as e: # noqa: BLE001 - never block a chat on this lookup + logger.warning( + "public_channel_system_prompt fetch failed for %s: %s", agent_name, e + ) + public_prompt = None + parts = [p for p in (public_prompt, memory_system_prompt) if p and p.strip()] + return "\n\n".join(parts) if parts else None + + def is_execution_context_enabled() -> bool: """Operator kill-switch for the execution context block. Default: enabled.""" try: diff --git a/src/frontend/src/components/SharingPanel.vue b/src/frontend/src/components/SharingPanel.vue index 1d05ab46f..a24099781 100644 --- a/src/frontend/src/components/SharingPanel.vue +++ b/src/frontend/src/components/SharingPanel.vue @@ -115,6 +115,45 @@ + +
+

Additional instructions — public & channel chats only

+

+ Extra instructions injected into the agent's system prompt for outside audiences only — public links, Slack / Telegram / WhatsApp, and paid chat. + Use it for persona, scope limits, disclaimers, or guardrails like "you're talking to an external customer, never reveal internal project names." +

+

+ Does not affect your own authenticated chats, scheduled runs, loops, or agent-to-agent calls. Leave empty to disable (no behavior change). Voice/VoIP has its own prompt. +

+ + + +
+ {{ (publicPrompt || '').length }} / {{ PUBLIC_PROMPT_MAX }} +
+ + +
+
+
+

Channels

@@ -165,9 +204,10 @@ diff --git a/src/frontend/src/stores/agents.js b/src/frontend/src/stores/agents.js index 21bc17cf4..0552e47c0 100644 --- a/src/frontend/src/stores/agents.js +++ b/src/frontend/src/stores/agents.js @@ -140,6 +140,25 @@ export const useAgentsStore = defineStore('agents', { } }, + // #1205: per-agent custom instructions for public & channel chats + async fetchPublicChannelPrompt(name) { + const authStore = useAuthStore() + const response = await axios.get(`/api/agents/${name}/public-prompt`, { + headers: authStore.authHeader + }) + return response.data.public_channel_system_prompt + }, + + async savePublicChannelPrompt(name, prompt) { + const authStore = useAuthStore() + const response = await axios.put( + `/api/agents/${name}/public-prompt`, + { public_channel_system_prompt: prompt }, + { headers: authStore.authHeader } + ) + return response.data.public_channel_system_prompt + }, + async createAgent(config) { this.loading = true this.error = null diff --git a/tests/unit/test_public_channel_prompt.py b/tests/unit/test_public_channel_prompt.py new file mode 100644 index 000000000..ea511d33b --- /dev/null +++ b/tests/unit/test_public_channel_prompt.py @@ -0,0 +1,96 @@ +"""Unit tests for per-agent public/channel custom instructions (#1205). + +Covers: +- DB get/set on `agent_ownership.public_channel_system_prompt` (set strips, + empty clears, unset is None) — on SQLite and, when TEST_POSTGRES_URL is set, + PostgreSQL. +- `platform_prompt_service.build_public_channel_caller_prompt` composition: + public fragment first, then the MEM-001 memory block; strict no-op when the + public prompt is unset; never raises on a DB error. + +Module: src/backend/services/platform_prompt_service.py + src/backend/db/agents.py +Issue: https://github.com/abilityai/trinity/issues/1205 +""" + +import os +import sys +from pathlib import Path + +os.environ.setdefault("REDIS_URL", "redis://test:test@redis:6379") +os.environ.setdefault("REDIS_PASSWORD", "test") +os.environ.setdefault("REDIS_BACKEND_PASSWORD", "test") + +import pytest + +_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) +from db_harness import db_backend, seed_user, seed_agent # noqa: E402,F401 + + +class TestPublicChannelPromptDB: + def test_unset_is_none(self, db_backend): + from database import db + seed_user() + seed_agent("a1") + assert db.get_public_channel_system_prompt("a1") is None + + def test_set_strips_and_get(self, db_backend): + from database import db + seed_user() + seed_agent("a1") + assert db.set_public_channel_system_prompt("a1", " Be formal. ") is True + assert db.get_public_channel_system_prompt("a1") == "Be formal." + + def test_empty_clears(self, db_backend): + from database import db + seed_user() + seed_agent("a1") + db.set_public_channel_system_prompt("a1", "Something") + assert db.get_public_channel_system_prompt("a1") == "Something" + db.set_public_channel_system_prompt("a1", " ") + assert db.get_public_channel_system_prompt("a1") is None + + def test_isolated_per_agent(self, db_backend): + from database import db + seed_user() + seed_agent("a1") + seed_agent("a2") + db.set_public_channel_system_prompt("a1", "Only A1") + assert db.get_public_channel_system_prompt("a2") is None + + +class TestBuildPublicChannelCallerPrompt: + def _patch(self, monkeypatch, public_value): + import services.platform_prompt_service as svc + monkeypatch.setattr( + svc.db, "get_public_channel_system_prompt", lambda name: public_value + ) + return svc + + def test_public_and_memory_compose_public_first(self, monkeypatch): + svc = self._patch(monkeypatch, "PUBLIC RULES") + out = svc.build_public_channel_caller_prompt("a1", "MEMORY BLOCK") + assert out == "PUBLIC RULES\n\nMEMORY BLOCK" + + def test_public_only(self, monkeypatch): + svc = self._patch(monkeypatch, "PUBLIC RULES") + assert svc.build_public_channel_caller_prompt("a1", None) == "PUBLIC RULES" + + def test_memory_only_is_noop_for_public(self, monkeypatch): + svc = self._patch(monkeypatch, None) + assert svc.build_public_channel_caller_prompt("a1", "MEMORY BLOCK") == "MEMORY BLOCK" + + def test_neither_returns_none(self, monkeypatch): + svc = self._patch(monkeypatch, None) + assert svc.build_public_channel_caller_prompt("a1", None) is None + + def test_db_error_degrades_to_memory(self, monkeypatch): + import services.platform_prompt_service as svc + def boom(name): + raise RuntimeError("db down") + monkeypatch.setattr(svc.db, "get_public_channel_system_prompt", boom) + # never raises; falls back to just the memory block + assert svc.build_public_channel_caller_prompt("a1", "MEMORY") == "MEMORY" + assert svc.build_public_channel_caller_prompt("a1", None) is None From 53d5c1e1e043576465607ab36fe47845d719eebc Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Thu, 25 Jun 2026 17:37:17 +0300 Subject: [PATCH 2/2] fix(db): add Alembic revision for agent_ownership.public_channel_system_prompt (#1205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR predates Alembic adoption (#1183) and shipped only the SQLite migration. Add the paired Postgres revision (0005, chained after 0004_agent_ownership_voice_name) so the dual-track migration invariant holds — ADD COLUMN IF NOT EXISTS, no-op when 0001_baseline already created the column. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...1_agent_ownership_public_channel_prompt.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/backend/migrations/versions/0011_agent_ownership_public_channel_prompt.py diff --git a/src/backend/migrations/versions/0011_agent_ownership_public_channel_prompt.py b/src/backend/migrations/versions/0011_agent_ownership_public_channel_prompt.py new file mode 100644 index 000000000..bc1dc5cb6 --- /dev/null +++ b/src/backend/migrations/versions/0011_agent_ownership_public_channel_prompt.py @@ -0,0 +1,35 @@ +"""Add public_channel_system_prompt column to agent_ownership (#1205) + +Persists the per-agent public/channel custom-instructions fragment on the +PostgreSQL backend. Mirrors the SQLite ``agent_ownership_public_channel_prompt`` +migration in ``db/migrations.py`` and the DDL in ``db/schema.py`` / MetaData in +``db/tables.py``. + +Fresh PG builds already get the column because ``0001_baseline`` iterates +``db/schema.py:TABLES``. This revision exists so an *existing* PG deployment — +stamped at an earlier revision and never re-running baseline — also picks the +column up on ``alembic upgrade head``. + +Revision ID: 0011_agent_ownership_public_channel_prompt +Revises: 0010_agent_ownership_mcp_exposed +Create Date: 2026-06-25 +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0011_agent_ownership_public_channel_prompt" +down_revision = "0010_agent_ownership_mcp_exposed" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE agent_ownership ADD COLUMN IF NOT EXISTS public_channel_system_prompt TEXT" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE agent_ownership DROP COLUMN IF EXISTS public_channel_system_prompt" + )