Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/memory/requirements/public-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
6 changes: 5 additions & 1 deletion src/backend/adapters/message_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)

Expand Down
5 changes: 5 additions & 0 deletions src/backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions src/backend/db/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions src/backend/db/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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),
]
1 change: 1 addition & 0 deletions src/backend/db/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/backend/db/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
)
20 changes: 20 additions & 0 deletions src/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/backend/routers/paid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 9 additions & 2 deletions src/backend/routers/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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 [],
)

Expand Down
32 changes: 31 additions & 1 deletion src/backend/routers/sharing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down
30 changes: 30 additions & 0 deletions src/backend/services/platform_prompt_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading