diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 6d30b5bb..557c5a6f 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -732,6 +732,7 @@ The per-agent VoIP config + voice-picker UI lives in the agent Settings/Sharing | 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 | `/api/agents/{name}/clients` | External-client roster: channel users who've messaged the agent, aggregated across Telegram + WhatsApp, sorted by `last_active` desc (never-active last). Owner-only, read-only, DB-sourced (renders when agent stopped). Slack/VoIP additive (#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 | @@ -978,6 +979,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/public-access.md b/docs/memory/requirements/public-access.md index 5b09f824..0b41d103 100644 --- a/docs/memory/requirements/public-access.md +++ b/docs/memory/requirements/public-access.md @@ -512,3 +512,34 @@ but was never surfaced. This is the read surface; per-client controls - **FR-4 — Tenant boundary**: the endpoint is scoped to the path agent; the DB join filters by `agent_name` through the channel binding, so a client of another agent never appears. + +--- + +## 47. Public & Channel Custom Instructions (#1205) + +### 47.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). diff --git a/src/backend/adapters/message_router.py b/src/backend/adapters/message_router.py index 6605d381..0c7b52f8 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, ) @@ -505,7 +506,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 b103291d..e96aebbf 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -771,6 +771,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 f297a0e8..69e0b87d 100644 --- a/src/backend/db/agents.py +++ b/src/backend/db/agents.py @@ -463,3 +463,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 98262097..6729f62d 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. @@ -2746,4 +2762,5 @@ def _migrate_agent_reports_table(cursor, conn): ("agent_ownership_public_channel_model", _migrate_agent_ownership_public_channel_model), ("agent_ownership_mcp_exposed", _migrate_agent_ownership_mcp_exposed), ("agent_ownership_tts_voice", _migrate_agent_ownership_tts_voice), + ("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 0d8f19a3..07c2bf03 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 4a45e7db..d1708999 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/migrations/versions/0012_agent_ownership_public_channel_prompt.py b/src/backend/migrations/versions/0012_agent_ownership_public_channel_prompt.py new file mode 100644 index 00000000..582c4d4c --- /dev/null +++ b/src/backend/migrations/versions/0012_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: 0012_agent_ownership_public_channel_prompt +Revises: 0011_agent_ownership_tts_voice +Create Date: 2026-06-25 +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0012_agent_ownership_public_channel_prompt" +down_revision = "0011_agent_ownership_tts_voice" +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" + ) diff --git a/src/backend/models.py b/src/backend/models.py index 2a33b7e5..78d8fe50 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -670,6 +670,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 0622418b..5d97033a 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 64dfe98f..4d36af9c 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 bb6a2bdc..5f5a3727 100644 --- a/src/backend/routers/sharing.py +++ b/src/backend/routers/sharing.py @@ -10,7 +10,7 @@ from models import ( AccessPolicy, AccessPolicyUpdate, AccessRequest, AccessRequestDecision, - User, ClientRosterEntry, + User, ClientRosterEntry, PublicChannelPrompt, PublicChannelPromptUpdate, ) from database import db, AgentShare, AgentOperatorAccess, AgentShareRequest from dependencies import get_current_user, OwnedAgentByName, CurrentUser @@ -204,6 +204,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) + ) @router.get("/{agent_name}/clients", response_model=List[ClientRosterEntry]) diff --git a/src/backend/services/platform_prompt_service.py b/src/backend/services/platform_prompt_service.py index 53d98c1a..fd031e07 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 fc098c6c..9400f4de 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

@@ -220,9 +259,10 @@ diff --git a/src/frontend/src/stores/agents.js b/src/frontend/src/stores/agents.js index 21bc17cf..0552e47c 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 00000000..ea511d33 --- /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