Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dec0da3
feat: add Creative.profile_for and profile/skill reserved kinds
sh1nj1 Jul 21, 2026
742ece3
feat: auto-create profile creative for every user including agents
sh1nj1 Jul 21, 2026
afb6a7a
feat: backfill profile creatives for existing users
sh1nj1 Jul 21, 2026
f63559c
feat: agent system prompt dual-reads from profile creative description
sh1nj1 Jul 21, 2026
ac8bff8
feat: mirror agent system prompt into profile description (create/upd…
sh1nj1 Jul 21, 2026
9051f15
fix: store agent prompt in profile markdown_source (lossless), not sa…
sh1nj1 Jul 21, 2026
11a20c4
fix: clearing an agent's system prompt drops the stale profile markdo…
sh1nj1 Jul 21, 2026
49f7f24
test: add empty topics.yml fixture to clean leaked profile-creative t…
sh1nj1 Jul 21, 2026
cb70bc1
fix: address Codex review — reserve kind discriminator, route readers…
sh1nj1 Jul 21, 2026
eeeed34
fix: pre-fill AI edit form from canonical prompt; make prompt read si…
sh1nj1 Jul 21, 2026
b5d59bc
fix: address Codex review — gate prompt sync on submission, converge …
sh1nj1 Jul 21, 2026
e57ba58
fix: route collaborator descriptions through effective_system_prompt
sh1nj1 Jul 21, 2026
ea5f94b
fix: store agent prompt verbatim, skip data-URI image rewrite on sync
sh1nj1 Jul 21, 2026
85619a0
fix: route completion API system prompt through effective_system_prompt
sh1nj1 Jul 21, 2026
0b20884
fix: keep data-URI prompts verbatim on direct profile-creative edits
sh1nj1 Jul 21, 2026
f1ecee5
fix: invalidate session context on direct profile prompt edit
sh1nj1 Jul 21, 2026
1d6e5a8
fix: enforce single profile creative per user with partial unique index
sh1nj1 Jul 21, 2026
591e5b5
fix: collapse duplicate profiles through the model so dependent topic…
sh1nj1 Jul 21, 2026
b2359a5
fix: skip MCP tool extraction for profile creatives
sh1nj1 Jul 21, 2026
6a1bb73
fix: index profile uniqueness on a promoted kind column, not a JSON e…
sh1nj1 Jul 21, 2026
95c573b
fix: add kind column before profile backfills so upgrades create prof…
sh1nj1 Jul 21, 2026
947f073
fix: skip promoted column sync when the column does not exist yet
sh1nj1 Jul 21, 2026
4a5f101
fix: preserve submitted AI prompt on validation error re-render
sh1nj1 Jul 21, 2026
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
4 changes: 3 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def create_ai
@user.agent_conf = params[:agent_conf] if @user.respond_to?(:agent_conf=) && params[:agent_conf].present?

if @user.save
@user.sync_profile_system_prompt!
Collavre::Contact.ensure(user: Current.user, contact_user: @user)
share_ai_agent_to_creative(@user, params[:creative_id])
redirect_to user_path(Current.user, tab: "contacts"), notice: I18n.t("collavre.users.create_ai.success")
Expand All @@ -61,6 +62,12 @@ def update_ai
ai_params = params.require(:user).permit(:name, :system_prompt, :llm_vendor, :llm_model, :llm_api_key, :gateway_url, :searchable, :routing_expression, :agent_conf, tools: [])

if @user.update(ai_params)
# Only mirror the column into the canonical profile prompt when the form
# actually submitted system_prompt. A partial PATCH (e.g. routing_expression
# only) leaves the legacy column at its stored value, which may be stale
# relative to a directly-edited profile; syncing it would clobber the
# canonical data["markdown_source"].
@user.sync_profile_system_prompt! if ai_params.key?(:system_prompt)
redirect_to edit_ai_user_path(@user), notice: I18n.t("collavre.users.update_ai.success")
else
@available_tools = load_available_tools
Expand Down
48 changes: 47 additions & 1 deletion engines/collavre/app/models/collavre/creative.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,23 @@ module Collavre
class Creative < ApplicationRecord
self.table_name = "creatives"

# Promote the `kind` discriminator (stored in `data`) to a real column so the
# profile-uniqueness index is a plain-column index that dumps identically on
# SQLite and PostgreSQL, instead of a JSON-expression index that serializes
# per-adapter and crashes `db:schema:load` on Postgres (see
# docs/engine_development.md and 20260721000010_add_unique_index_to_profile_creatives).
# `data` stays the source of truth; `kind` is re-derived from it on every save.
include Collavre::IndexedJsonColumns
indexed_json_columns json: :data, columns: { kind: "kind" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard kind syncing until the column exists

When this code is deployed to a database that still has pre-20260720235959 migrations pending, this callback is installed on the current Creative model before the creatives.kind column has been added. Older migrations that run before that point still save Creative records — for example engines/collavre/db/migrate/20251230113607_refactor_labels.rb calls Creative.create! — so sync_indexed_json_columns will try to write self["kind"] against a schema where the column does not exist and abort the migration. Guard the sync when the promoted column is absent, or keep those older migrations on a migration-local model that does not include the new callback.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — real, fixed in 947f073d. Confirmed the mechanism: IndexedJsonColumns#sync_indexed_json_columns (installed as a before_save on Creative via indexed_json_columns json: :data, columns: { kind: "kind" }) wrote self["kind"] unconditionally. On a full migration replay, 20251230113607_refactor_labels (Dec 2025) runs long before 20260720235959_add_kind_column_to_creatives and calls Creative.create!, so the sync hit MissingAttributeError against a table without the kind column and aborted the migration.

Rather than pin old migrations to a migration-local model (fragile — every future record-saving migration would have to remember), the guard moves into the concern itself: next unless has_attribute?(column). The JSON attribute stays the source of truth, so a promoted column that has not been added yet is simply skipped and re-derived on the next save once it exists. This makes every IndexedJsonColumns user (Creative kind, Channel repo_full_name/pr_number/worktree_id) robust to mid-replay saves, not just this one migration.

Regression test (skips promoted columns absent from the table instead of raising) points the column map at a not-yet-added column and asserts the save does not raise while still syncing the existing promoted columns — it errors without the guard, passes with it. Full IndexedJsonColumnsTest 5 runs / creative_profile 10 / user_profile_system_prompt 11, 0 failures; rubocop clean.


# ---------------------------------------------------------------------------
# Reserved metadata key registry — engines register their own namespaces so
# `update_metadata` preserves them without core naming vendor-specific keys.
# "kind" is the discriminator (`inbox`/`profile`/`skill`) that scopes like
# `profiles` query on (`data->>'kind'`); it must be preserved or a metadata
# save that omits it makes the row undiscoverable and a duplicate is created.
# ---------------------------------------------------------------------------
BUILTIN_RESERVED_METADATA_KEYS = %w[markdown_source content_type editor].freeze
BUILTIN_RESERVED_METADATA_KEYS = %w[markdown_source content_type editor kind].freeze

class << self
def registered_reserved_metadata_keys
Expand Down Expand Up @@ -69,6 +81,17 @@ def to_partial_path
# --- Inbox ---
scope :inboxes, -> { where("data->>'kind' = 'inbox'") }

# --- Profile ---
PROFILE_KIND = "profile"
SKILL_KIND = "skill"

scope :profiles, -> { where("data->>'kind' = ?", PROFILE_KIND) }

# A profile creative is an agent's system prompt, never a tool source.
def profile?
data&.dig("kind") == PROFILE_KIND
end

SYSTEM_TOPIC_NAME = "System"
MAIN_TOPIC_NAME = "Main"
CONTENT_TOPIC_NAME = "Content"
Expand Down Expand Up @@ -135,6 +158,25 @@ def self.inbox_for(user)
)
end

# Find or create the profile creative for a given user.
# Places it as a root creative (no parent) owned by the user.
#
# Guarantees a single profile creative per user. A partial unique index
# (index_creatives_on_user_id_profile_unique) prevents a duplicate at the DB,
# and create_or_find_by! resolves a concurrent insert to the surviving row —
# so there is never a second, separately-editable profile to split-brain on.
# The read-first fast path keeps the common (already-exists) case cheap.
def self.profile_for(user)
existing = profiles.where(user: user).order(:id).first
return existing if existing
Comment on lines +169 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep duplicate profile creatives from remaining editable

Fresh evidence beyond the earlier duplicate-profile thread: deterministic .order(:id).first only makes helper-based reads/writes choose the oldest profile, but the normal Creative tree still lists all owned root rows (IndexQuery#handle_root_query) and CreativesController#update edits the row identified by the request. If a race creates a newer duplicate, an agent can still edit that visible duplicate directly while effective_system_prompt continues reading the oldest one, so prompt edits can still land on a profile that execution ignores; hide/merge duplicates or prevent creation rather than relying only on ordered lookup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — real, and fixed at the root in 1d6e5a8e. You're right that .order(:id).first only makes the helper read/write paths converge: IndexQuery#handle_root_query still lists every owned root row and CreativesController#update edits the row in the request, so a race-created duplicate stayed directly editable while effective_system_prompt kept reading the oldest.

Rather than hide/merge duplicates, this now prevents them: a partial unique index on creatives(user_id) WHERE data->>'kind' = 'profile', and profile_for creates via create_or_find_by! so a lost create race resolves to the surviving row.

Correcting the record from the earlier duplicate-profile thread: I had declined this exact index claiming json ->> is STABLE and would be rejected at schema load. That was wrong. json_object_field_text (->>) is IMMUTABLE on Postgres (provolatile = 'i', verified on pg14 and pg16), so the partial predicate is accepted — the postgres_schema_load CI job on this commit is the proof, and it needs no jsonb migration on the current json column. (My earlier reasoning had conflated ->> with the genuinely-nonportable SQLite json_extract.)

Migration collapses any pre-index duplicate onto the oldest profile first. Regression tests: the DB rejects a second profile creative for the same user; create_or_find_by resolves a lost race to the surviving profile.


profiles.create_or_find_by!(user: user) do |creative|
creative.description = user.name.to_s
creative.data = { "kind" => PROFILE_KIND }
creative.progress = 0.0
Comment on lines +173 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Grant creators access to agent profile creatives

When this runs for an AI agent created by another user, the profile Creative is owned by the agent account itself, but the Creative permission path only grants access to the owner or to users with CreativeShare cache entries, and create_ai only shares the agent to an optional target Creative rather than sharing this new profile back to the creator. In that common managed-agent case, the human who created the agent cannot read or write the profile Creative through the normal Creative UI/API, so the directly edited profile prompt flow added by this commit is effectively unreachable unless a separate share is created.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the mechanism — accurate: profile_for owns the profile Creative as the agent (user_id = agent.id), create_ai only shares the optional target creative to the agent (share_ai_agent_to_creative), never the profile back to the creator, and the Creative permission path is owner-or-CreativeShare. So through the Creative UI/API the creator indeed has no access to the agent's profile Creative.

But "the directly edited profile prompt flow ... is effectively unreachable" overstates it for this PR:

  • The prompt is fully creator-editable today via the AI form (edit_ai/update_ai), authorized by created_by_id == Current.user.id. The form pre-fills from the canonical prompt (effective_system_prompt → profile data["markdown_source"]) and writes back through sync_profile_system_prompt!. That's a complete read+write surface for the prompt — it is not unreachable.
  • There is no direct-Creative-UI entry point to an agent's profile Creative in this PR (no route/view opens it). The direct-edit capability is only a model-layer read preference (effective_system_prompt prefers markdown_source), plumbed so a future UI's direct edits take effect everywhere — it is not a user-facing flow this commit exposes. The profile is a root Creative owned by the agent, so it never appears half-visible-but-uneditable in the creator's own tree either.

The genuine forward-looking need you've identified — the creator needs access to the agent's profile Creative — is foundational to the two card requirements this PR intentionally does not yet implement: skills created/linked under the profile Creative (B), and the settings UI that surfaces the profile for direct editing (C). Granting the share, and deciding its permission level (admin vs write) and whether the skills subtree inherits it, is a coherent piece of that work rather than a data/functional defect in this A-only PR — and doing it here would prematurely commit those semantics while touching the share-cache invalidation path.

Concrete fix location for when B/C lands: in create_ai, after sync_profile_system_prompt!, mirror share_ai_agent_to_creative to share @user.profile_creative back to Current.user (the creator), so the creator lands as an owner/admin of the agent's profile subtree.

end
end

attr_accessor :filtered_progress

belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
Expand Down Expand Up @@ -352,6 +394,10 @@ def progress_service
end

def mark_mcp_tools_sync_pending
# Profile creatives hold an agent's system prompt, so a fenced
# `extend ToolMeta` example in the prompt must not register a real tool.
return if profile?

@mcp_tools_sync_pending = true
end

Expand Down
17 changes: 16 additions & 1 deletion engines/collavre/app/models/collavre/creative/describable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ module Describable
# textarea. Stored in data["editor"]; decoupled from the storage format
# (content_type), which is now Markdown for both surfaces.
attr_accessor :markdown_editor
# When true, convert_markdown_to_html stores markdown_source verbatim
# instead of rewriting inline data-URI images into Active Storage blobs.
# Used by prompt sync, where markdown_source is the canonical text sent
# to the LLM and must stay byte-for-byte what the user entered.
attr_accessor :skip_data_uri_rewrite
attr_reader :markdown_source

def markdown_source=(value)
Expand Down Expand Up @@ -147,7 +152,17 @@ def convert_markdown_to_html
# FIRST, then persist the rewritten source. Subsequent edits
# around the same image carry the blob path instead of the
# data URI, so re-renders no longer create duplicate blobs.
rewritten_source = Collavre::MarkdownConverter.rewrite_data_uri_images(new_source)
# skip_data_uri_rewrite callers (prompt sync) keep the source
# verbatim; the derived HTML still renders the data URI inline.
# Profile creatives are agent prompts: markdown_source is sent
# verbatim to the LLM, so the rewrite must never fire for them
# regardless of caller — a direct Creative-editor edit reaches this
# path without the skip flag, but its source is equally LLM-bound.
rewritten_source = if skip_data_uri_rewrite || data["kind"] == PROFILE_KIND
new_source
else
Collavre::MarkdownConverter.rewrite_data_uri_images(new_source)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve data URI prompts on direct profile edits

Fresh evidence beyond the earlier sync-path data-URI fix: when an agent’s canonical prompt is edited directly through its profile Creative in Markdown/source mode, no caller sets skip_data_uri_rewrite, so this branch still rewrites ![...](data:image/...) prompt text into an Active Storage blob URL before saving data["markdown_source"]. In that scenario effective_system_prompt sends the mutated blob URL to the LLM instead of the exact prompt the user authored; exempt profile prompt writes here as well as the AI-settings sync path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in code — real, fixed in 0b208846. The earlier sync-path fix (ea5f94b1) only set skip_data_uri_rewrite on the AI-settings sync path. A prompt has a second writer: directly editing the profile Creative in the normal Markdown source editor reaches convert_markdown_to_html without that flag, so rewrite_data_uri_images still rewrote an inline ![](data:image/...) into an Active Storage blob path before persisting data["markdown_source"] — and effective_system_prompt then sent the mutated blob URL to the LLM (reproduced: data:image/png;base64,.../rails/active_storage/blobs/redirect/...).

The caller-level flag was the wrong altitude. A profile creative’s markdown_source is always LLM-bound, so the gate moved to the model:

rewritten_source = if skip_data_uri_rewrite || data["kind"] == PROFILE_KIND
  new_source
else
  Collavre::MarkdownConverter.rewrite_data_uri_images(new_source)
end

Profile creatives are now exempt from the rewrite regardless of caller (sync, direct editor, or any future write path); non-profile creatives still rewrite to avoid duplicate blobs. Regression test: editing the profile creative directly keeps a data-URI prompt verbatim. 50 runs across the profile/describable/creatives-update suites, 0 failures.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — real, and already fixed in 0b208846 (before this review landed). The skip_data_uri_rewrite flag only covered the AI-settings sync path; a direct Creative-editor edit of a profile reaches convert_markdown_to_html without it. The rewrite now also short-circuits whenever data["kind"] == PROFILE_KIND, so a profile's markdown_source is stored verbatim regardless of caller — the LLM receives the exact prompt the user authored, and the derived description HTML still renders the data URI inline. Regression test: a directly-edited profile prompt with an inline data-URI image round-trips verbatim (no blob path).

end
self.data["markdown_source"] = rewritten_source
self.description = Collavre::MarkdownConverter.markdown_to_html(rewritten_source)
else
Expand Down
40 changes: 40 additions & 0 deletions engines/collavre/app/models/collavre/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class User < ApplicationRecord
self.table_name = "users"

include HasInboxCreative
include HasProfileCreative

has_many :user_themes, class_name: "Collavre::UserTheme", dependent: :destroy

Expand Down Expand Up @@ -207,6 +208,45 @@ def ai_user?
llm_vendor.present?
end

# Mirror the agent's system prompt into its profile creative as Markdown,
# so the raw prompt lands losslessly in data["markdown_source"] — the
# canonical home for the prompt. Writing the prompt straight into
# `description` would run it through the HTML sanitizer (Describable), which
# strips <thinking>/<tool_call> tags and entity-escapes < > &, corrupting
# every prompt; the derived `description` is only a rendered display view.
# The system_prompt column is legacy, pending removal.
# No-op for humans and for prompt-less agents (profile keeps its name seed).
def sync_profile_system_prompt!
return unless ai_user?
creative = profile_creative
if system_prompt.present?
return if creative.data&.dig("markdown_source") == system_prompt
# Store the prompt verbatim: markdown_source is the canonical text sent
# to the LLM, so an inline data-URI image must NOT be rewritten into a
# blob path (which would mutate the prompt the agent receives).
creative.skip_data_uri_rewrite = true
creative.update!(content_type_input: "markdown", markdown_source: system_prompt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve prompt source instead of Markdown-rewriting it

When an agent prompt contains Markdown data-URI image syntax, such as an instruction/example ![x](data:image/png;base64,...) outside a code span, this routes the prompt through Describable's Markdown save path, which rewrites data-URI images into Active Storage blob URLs before storing data["markdown_source"]. Since effective_system_prompt later sends that stored source to the LLM, the agent receives mutated instructions rather than the prompt text the user entered; the backfill migration can also create blobs while rewriting existing prompts. Store the canonical prompt source without the editor-specific data-URI rewrite, and derive only the display HTML separately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in code — real, fixed in ea5f94b1. sync_profile_system_prompt! writes the prompt via the Markdown save path (content_type_input: "markdown"), and convert_markdown_to_html runs MarkdownConverter.rewrite_data_uri_images on any inline ![](data:image/...) outside a code span, uploading it to a blob and storing the blob-path version in data["markdown_source"]. Since effective_system_prompt sends that source straight to the LLM, the agent got a mutated prompt, and the backfill created blobs while rewriting existing prompts. (Code spans were already protected by with_code_protected, so only bare inline data URIs were affected.)

Fix keeps markdown_source byte-for-byte what the user entered: added an opt-in skip_data_uri_rewrite flag on Describable (default behavior unchanged for every existing editor caller — the rewrite still prevents duplicate blobs on rich-editor re-render) and set it only in the prompt-sync path. The derived description HTML still renders the data URI inline. On repeated full-form saves the stored source now equals the column value, so no rewrite fires and no blobs are created.

Regression test: a prompt with an inline data-URI image round-trips through sync and effective_system_prompt verbatim, asserting no /rails/active_storage/blobs or /public-assets/blobs path appears.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip MCP extraction for profile prompts

When an agent prompt contains a fenced example or instruction block with extend ToolMeta, this profile sync saves it as a normal Creative description, so Creative's existing saved_change_to_description? callback enqueues UpdateMcpToolsJob; McpService#update_from_creative then registers any code block containing that marker as an MCP tool and creates an approval comment. During the prompt backfill, AI-settings sync, or direct profile edit, prompt examples can therefore create/replace real tools even though the profile is just the agent's system prompt; exclude data["kind"] == "profile" creatives from MCP extraction/enqueueing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in code — real, fixed in b2359a5a. A profile creative's description is the agent's system prompt, but Creative's after_save :mark_mcp_tools_sync_pending, if: :saved_change_to_description? fed every description change into UpdateMcpToolsJobMcpService#update_from_creative, which registers any <pre>/<code> block containing extend ToolMeta as a real, approval-gated MCP tool. So a fenced tool-definition example in a prompt would create/replace tools on backfill, AI-settings sync, or a direct profile edit.

Gated at the model (the right altitude, matching the profile data-URI fix): added Creative#profile? (data->>'kind' == 'profile') and mark_mcp_tools_sync_pending now returns early for profiles, so no job is ever enqueued. Defense-in-depth: McpService#update_from_creative also returns early when creative.effective_origin.profile?, so a direct service/job call can't register a tool either. Non-profile creatives still extract tools unchanged.

Regression tests: (1) update_from_creative skips a profile creative whose description contains extend ToolMeta (no McpTool created); (2) saving a profile description does not enqueue UpdateMcpToolsJob; (3) end-to-end through sync_profile_system_prompt! and a direct profile_creative.update! — both leave no tool registered. Verified RED without the guards (the prompt example was registered as prompt_example_tool), GREEN with them.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — real, fixed in b2359a5a. A profile Creative's description is the agent's rendered system prompt, and Creative's saved_change_to_description? callback fed every description change into McpService, so backfill, AI-settings sync, or a direct profile edit could register/replace an approval-gated MCP tool from a fenced extend ToolMeta example in the prompt.

Fixed at two layers:

  • Model gate: added Creative#profile? (data["kind"] == "profile") and skip the UpdateMcpToolsJob enqueue for profile creatives on description change.
  • Defense-in-depth: early return in McpService#update_from_creative for profile creatives, so even a manually-enqueued job is a no-op.

Regression tests cover the service guard, the enqueue gate, and end-to-end sync/direct-edit paths.

elsif creative.data&.dig("markdown_source").present?
# The prompt was cleared. Demote out of markdown mode so the stale
# markdown_source is dropped and render falls back to the (now-blank)
# column — matching the pre-profile behavior where blanking the prompt
# took effect immediately. Also reseed `description` back to the name
# seed so the old rendered prompt doesn't stay visible/searchable on the
# owned profile root. Without this, the old prompt stays authoritative.
creative.update!(content_type_input: "html", description: name.to_s)
end
end

# The effective system prompt for all read consumers. The canonical prompt
# lives in the profile creative's data["markdown_source"]; the legacy
# `system_prompt` column is the fallback for rows not yet backfilled. Every
# prompt reader (orchestration matching, type classification, typo agent,
# admin view) must route here so that editing the profile creative directly
# takes effect everywhere, not just in AiAgentService.
def effective_system_prompt
profile_creative_if_present&.data&.dig("markdown_source").presence || system_prompt
Comment on lines +246 to +247

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route collaborator descriptions through profile prompts

When an agent's profile Creative is edited directly, this new method makes data["markdown_source"] the canonical prompt, but the collaboration prompt path still bypasses it: AgentContextBuilder#extract_agent_description (engines/collavre/app/services/collavre/orchestration/agent_context_builder.rb:181) reads user.system_prompt, and AiAgentService appends those descriptions into the collaborator section. In that scenario agents receive stale collaborator expertise even though routing/classification use the edited profile prompt, so the collaboration prompt can describe the wrong role; use effective_system_prompt there as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in code — real, fixed in e57ba580. AgentContextBuilder#extract_agent_description read user.system_prompt directly, so once an agent's profile Creative was edited directly, the collaborator section of other agents' prompts described the stale role while routing/classification already used the canonical prompt. Routed it through effective_system_prompt (canonical data["markdown_source"] first, column fallback).

Swept for the same class while here and found one more: new_ai.html.erb seeded the copy-source pre-fill from the stale column when duplicating an agent — also routed through effective_system_prompt. (show.html.erb and edit_ai were already canonical.)

Regression test: collaborator description follows a directly-edited profile prompt, not the stale column. 53 runs across the touched reader + profile suites, 0 failures.

Comment on lines +246 to +247

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route completion API through the profile prompt

Now that this method makes data["markdown_source"] the canonical prompt source, the OpenAI-compatible completion endpoint still bypasses it: engines/collavre_completion_api/app/controllers/collavre_completion_api/api/v1/chat/completions_controller.rb:61 appends agent.system_prompt directly. For agents whose profile Creative was edited directly, normal agent execution uses the new prompt but API calls keep sending the stale legacy-column instructions. Fresh evidence beyond the earlier resolved thread: a repo-wide rg "system_prompt" still shows that direct read in the completion API controller.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — real, and already fixed in 85619a0a. build_system_prompt in the OpenAI-compatible completion controller appended agent.system_prompt (the legacy column) directly, so an agent whose profile Creative was edited directly kept sending the stale column instructions through the API while normal execution used the canonical prompt.

Now it reads agent.effective_system_prompt (canonical data["markdown_source"] first, column fallback), matching every other reader. Regression test added (build_system_prompt uses the canonical profile prompt, not the stale column).

Comment on lines +246 to +247

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize profile prompts outside the AI form

When an agent already has a backfilled/synced profile prompt, any non-update_ai write to the legacy system_prompt column (for example seeds, console/admin maintenance, or another service calling user.update!(system_prompt: ...)) is ignored by all prompt readers because this method always prefers the stale profile markdown_source. The new sync call is only in the AI controller, not on User create/update, so column changes outside that form no longer affect execution; add a model-level sync for actual system_prompt changes or route those writers through the canonical profile update.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real gap, but deferring it rather than adding an auto-sync callback here — with reasoning.

The system_prompt column is documented as legacy/pending-removal; the canonical write path is the profile Creative (the AI form via sync_profile_system_prompt!, or a direct Markdown edit). External column writers today are the two seeds (collavre/db/seeds.rb, collavre_github/db/seeds.rb), and they work correctly through effective_system_prompt's column fallback: a freshly seeded agent has no profile Creative (readers use profile_creative_if_present, which never creates), so the column is authoritative until a profile is materialized.

A model-level after_save/after_update_commit sync gated on saved_change_to_system_prompt? would close the post-backfill reseed case, but it risks reintroducing hazards this PR already had to fix: (1) the clobber that finding #1 fixed (the controller gate on ai_params.key?(:system_prompt) exists precisely to handle the clear path when the column value is unchanged — saved_change_to_system_prompt? misses it), and (2) sync_profile_system_prompt! materializes a profile Creative (with a Main topic) as a side effect, so firing it from a commit callback re-opens the orphan-topic-on-commit path seen elsewhere. The durable fix is to remove the legacy column and make the profile Creative the sole source — tracked as follow-up rather than layered on here.

end

def claude_channel_agent?
llm_model == "claude-code"
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module Collavre
module HasProfileCreative
extend ActiveSupport::Concern

included do
after_create_commit :create_profile_creative
end

# Returns the user's profile creative, creating one if it doesn't exist.
# Use only from write paths (e.g. sync_profile_system_prompt!) — never from
# a hot read path, since creating a profile creative also creates a Topic.
def profile_creative
Collavre::Creative.profile_for(self)
end

# Read-only lookup: the existing profile creative or nil, never creating one.
# Read paths (effective_system_prompt) must use this so a hot-path read never
# writes, and so rendering never fails when the in-memory user is invalid
# (e.g. re-rendering the AI form after a failed save leaves name blank).
def profile_creative_if_present
return nil unless persisted?

# A partial unique index enforces one profile per user, so at most one row
# matches; order(:id) is defensive (matches profile_for's selection) for
# any pre-index legacy duplicate, keeping reads and writes on the same row.
Collavre::Creative.profiles.where(user: self).order(:id).first
end

private

def create_profile_creative
Collavre::Creative.profile_for(self)
rescue StandardError => e
Rails.logger.error("[HasProfileCreative] Failed to create profile for user #{id}: #{e.message}")
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ def indexed_json_columns(json:, columns:)
def sync_indexed_json_columns
json = public_send(indexed_json_source) || {}
indexed_json_column_map.each do |column, json_key|
# A promoted column may not exist yet when a record is saved during a
# migration that runs before the add-column migration -- a full replay
# reaches older record-saving migrations (e.g. 20251230113607_refactor_labels
# calls Creative.create!) first. The JSON attribute stays the source of
# truth, so skip the promoted column until it exists rather than aborting
# the save with MissingAttributeError; the next save re-derives it.
next unless has_attribute?(column)

self[column] = json[json_key]
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module AgentTypeClassifier
# Returns the agent type string ("developer", "pm", "qa", "researcher",
# "marketer", "planner") or "agent" as the default when nothing matches.
def classify(user)
prompt = user.system_prompt.to_s.downcase
prompt = user.effective_system_prompt.to_s.downcase
case prompt
when /developer|개발/ then "developer"
when /pm|project.?manager|프로젝트/ then "pm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,15 @@ def context_changed_since_last_reply?
.exists?
agent_changed = @agent.updated_at > last_reply_at

creative_changed || agent_changed
# The canonical system prompt now lives in the agent's profile creative's
# data["markdown_source"]. Editing it directly bumps only that Creative
# row (not the agent User row), and the profile creative sits outside the
# rendered topic tree — so without this a stateful re-send would keep the
# old session prompt and drop the freshly-edited one.
profile_updated_at = @agent.profile_creative_if_present&.updated_at
prompt_changed = profile_updated_at.present? && profile_updated_at > last_reply_at

creative_changed || agent_changed || prompt_changed
end

# Collects the IDs of all creatives whose content is included in the
Expand Down
7 changes: 6 additions & 1 deletion engines/collavre/app/services/collavre/ai_agent_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,13 @@ def prepare_rendering_context
end

def render_system_prompt(rendering_context)
# The prompt lives losslessly in the profile creative's markdown_source
# (data["markdown_source"]); `description` is the sanitized rendered view
# and would corrupt tags/angle-brackets, so never read it here. Fall back
# to the legacy system_prompt column for rows not yet backfilled.
template = @agent.effective_system_prompt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include profile edits in session context invalidation

When a stateful agent has already replied in a topic, SessionContextResolver sends only the trigger message whenever MessageBuilder#context_changed_since_last_reply? is false; that check still only compares @agent.updated_at plus injected creatives. With the prompt now coming from profile_creative.data["markdown_source"], a direct profile Creative edit updates only the Creative row, not the user row, so subsequent stateful calls keep using the old session instructions and this newly resolved prompt is dropped as nil. Include the selected profile Creative's updated_at in the context-change check so direct prompt edits force a full payload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in code — real, fixed in f1ecee5f. context_changed_since_last_reply? only compared @agent.updated_at (the User row) and the rendered creative subtree. The canonical prompt now lives in the profile creative's data["markdown_source"], which sits outside that subtree, and a direct edit bumps only the Creative row — so a stateful agent that had already replied kept the old session instructions and this newly resolved prompt was dropped as nil.

Now the check also includes the profile creative's updated_at:

profile_updated_at = @agent.profile_creative_if_present&.updated_at
prompt_changed = profile_updated_at.present? && profile_updated_at > last_reply_at
creative_changed || agent_changed || prompt_changed

profile_creative_if_present is the find-only reader (no write on this hot path). Regression test: a direct profile prompt edit after the last reply forces context_changed, with the agent User row asserted untouched.

rendered = AiSystemPromptRenderer.new(
template: @agent.system_prompt,
template: template,
context: rendering_context
).render

Expand Down
3 changes: 3 additions & 0 deletions engines/collavre/app/services/collavre/mcp_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ def self.delete_tool(tool_name)

def update_from_creative(input_creative)
creative = input_creative.effective_origin
# Profile creatives are agent system prompts, not tool sources: a fenced
# `extend ToolMeta` example in a prompt must never register a real tool.
return if creative.profile?
return unless creative.description.present?

# Parse HTML to find code blocks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ def permission_rank(permission)
end

def extract_agent_description(user)
prompt = user.system_prompt.to_s
prompt = user.effective_system_prompt.to_s
# Extract first meaningful line or sentence
first_line = prompt.lines.find { |l| l.strip.present? && !l.start_with?("#") }
return "AI Agent" unless first_line
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,15 @@ def calculate_keyword_score(agent, message_text)
def extract_expertise_text(agent)
texts = []

# From agent's system_prompt (primary source for AI agents)
texts << agent.system_prompt if agent.respond_to?(:system_prompt) && agent.system_prompt.present?
# From agent's system_prompt (primary source for AI agents). Route
# through effective_system_prompt so a directly-edited profile creative
# is matched on, falling back to the legacy column for other agent types.
texts << agent.effective_system_prompt if agent.respond_to?(:effective_system_prompt) && agent.effective_system_prompt.present?
texts << agent.system_prompt if !agent.respond_to?(:effective_system_prompt) && agent.respond_to?(:system_prompt) && agent.system_prompt.present?

# From AI agent profile (for nested ai_agent association)
if agent.respond_to?(:ai_agent) && agent.ai_agent.present?
texts << agent.ai_agent.system_prompt if agent.ai_agent.respond_to?(:system_prompt)
texts << agent.ai_agent.effective_system_prompt if agent.ai_agent.respond_to?(:effective_system_prompt)
texts << agent.ai_agent.description if agent.ai_agent.respond_to?(:description)
end

Expand Down
2 changes: 1 addition & 1 deletion engines/collavre/app/services/collavre/typo_corrector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def build_client
AiClient.new(
vendor: agent&.llm_vendor.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_VENDOR", "gemini"),
model: agent&.llm_model.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_MODEL", "gemini-3.1-flash-lite"),
system_prompt: agent&.system_prompt.presence || FALLBACK_SYSTEM_PROMPT,
system_prompt: agent&.effective_system_prompt.presence || FALLBACK_SYSTEM_PROMPT,
llm_api_key: agent&.llm_api_key,
gateway_url: agent&.gateway_url,
# Vendor adapters (e.g. OpenClaw) resolve the gateway/key from the context
Expand Down
12 changes: 11 additions & 1 deletion engines/collavre/app/views/collavre/users/edit_ai.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,17 @@

<div class="form-group">
<%= form.label :system_prompt, t('collavre.users.new_ai.system_prompt_label') %>
<%= form.text_area :system_prompt, required: true, class: "stacked-form-control", rows: 5 %>
<%# Pre-fill from the canonical prompt (profile creative's markdown_source),
not the legacy system_prompt column. If the profile creative was edited
directly, the column is stale; posting it back through update_ai's sync
would silently clobber the profile-authored prompt on any unrelated
setting change. Rendering the effective value makes a no-touch save a
no-op on the prompt.
On a validation-error re-render, though, show the *submitted* value
(@user.system_prompt) — the failed save never synced the profile, so
effective_system_prompt would read the old canonical prompt and silently
discard the user's unsaved edit. %>
<%= form.text_area :system_prompt, value: (@user.errors.any? ? @user.system_prompt : @user.effective_system_prompt), required: true, class: "stacked-form-control", rows: 5 %>
</div>

<%= render "collavre/users/trigger_field", form: form, routing_expression: @user.routing_expression %>
Expand Down
2 changes: 1 addition & 1 deletion engines/collavre/app/views/collavre/users/new_ai.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

<div class="form-group">
<%= form.label :system_prompt, t('collavre.users.new_ai.system_prompt_label') %>
<%= form.text_area :system_prompt, required: true, class: "stacked-form-control", rows: 5, value: @copy_source&.system_prompt || AiClient::SYSTEM_INSTRUCTIONS %>
<%= form.text_area :system_prompt, required: true, class: "stacked-form-control", rows: 5, value: @copy_source&.effective_system_prompt || AiClient::SYSTEM_INSTRUCTIONS %>
</div>

<%= render "collavre/users/trigger_field", form: form, routing_expression: @copy_source&.routing_expression %>
Expand Down
2 changes: 1 addition & 1 deletion engines/collavre/app/views/collavre/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<dd class="col-sm-9"><%= @user.llm_model %></dd>

<dt class="col-sm-3"><%= t('collavre.users.new_ai.system_prompt_label') %></dt>
<dd class="col-sm-9"><pre><%= @user.system_prompt %></pre></dd>
<dd class="col-sm-9"><pre><%= @user.effective_system_prompt %></pre></dd>
</dl>
</div>
<% else %>
Expand Down
Loading