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
4 changes: 1 addition & 3 deletions 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,7 +43,6 @@ 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 @@ -62,12 +61,6 @@ 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
87 changes: 3 additions & 84 deletions engines/collavre/app/models/collavre/creative.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,12 @@ 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" }

# ---------------------------------------------------------------------------
# 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.
# "kind" must be reserved: it is the discriminator `inbox?`/`Creative.inboxes`
# match on, so a metadata save that omits it makes the row undiscoverable and
# `inbox_for` creates a duplicate inbox for that user.
# ---------------------------------------------------------------------------
BUILTIN_RESERVED_METADATA_KEYS = %w[markdown_source content_type editor kind].freeze

Expand Down Expand Up @@ -81,17 +72,6 @@ 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 @@ -158,63 +138,6 @@ 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

created = false
creative = profiles.create_or_find_by!(user: user) do |c|
c.description = user.name.to_s
c.data = { "kind" => PROFILE_KIND }
c.progress = 0.0
created = true
end
grant_creator_admin_share(creative, user) if created
creative
end

# A managed AI agent's profile creative is owned by the agent itself, so its
# human creator has no Creative-level path to it (permissions are owner or
# CreativeShare only). Give the creator `admin` on creation: admin — not
# write — because the profile subtree is where the agent's skills live, and
# approving an agent's use of a feedback-level skill is an owner/admin act.
#
# Only for AI agents: `created_by_id` is also set on humans invited by
# another user, and a person's own profile must never be exposed to whoever
# created their account.
#
# Granted once, at profile creation only — never re-asserted on later
# profile_for calls, so a creator who deliberately removes the share (or
# lowers it) keeps that decision.
def self.grant_creator_admin_share(creative, user)
return unless user.respond_to?(:ai_user?) && user.ai_user?

creator_id = user.try(:created_by_id)
return if creator_id.blank? || creator_id == user.id

share = Collavre::CreativeShare.new(
creative: creative,
user_id: creator_id,
shared_by: user,
permission: :admin
)
# The creator is the actor here, so the "X shared a creative with you"
# inbox message would be addressed from them to themselves.
share.skip_recipient_notification = true
share.save!
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
Rails.logger.warn("[Creative#profile_for] creator share skipped for user #{user.id}: #{e.message}")
end
private_class_method :grant_creator_admin_share

attr_accessor :filtered_progress

belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
Expand Down Expand Up @@ -432,10 +355,6 @@ 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: 1 addition & 16 deletions engines/collavre/app/models/collavre/creative/describable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,6 @@ 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 @@ -152,17 +147,7 @@ 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.
# 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)
end
rewritten_source = Collavre::MarkdownConverter.rewrite_data_uri_images(new_source)
self.data["markdown_source"] = rewritten_source
self.description = Collavre::MarkdownConverter.markdown_to_html(rewritten_source)
else
Expand Down
7 changes: 1 addition & 6 deletions engines/collavre/app/models/collavre/creative_share.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,7 @@ class CreativeShare < ApplicationRecord
"permission" => :repropagate
}.freeze

# Set on shares the system creates on the recipient's own behalf (e.g. the
# admin share an agent profile grants its creator), where the notification
# would read as the recipient sharing something with themselves.
attr_accessor :skip_recipient_notification

after_create_commit :notify_recipient, unless: -> { no_access? || skip_recipient_notification }
after_create_commit :notify_recipient, unless: :no_access?
after_save :touch_creative_subtree
after_destroy :touch_creative_subtree

Expand Down
40 changes: 0 additions & 40 deletions engines/collavre/app/models/collavre/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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 @@ -208,45 +207,6 @@ 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)
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
end

def claude_channel_agent?
llm_model == "claude-code"
end
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,6 @@ 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.effective_system_prompt.to_s.downcase
prompt = user.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,15 +244,7 @@ def context_changed_since_last_reply?
.exists?
agent_changed = @agent.updated_at > last_reply_at

# 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
creative_changed || agent_changed
end

# Collects the IDs of all creatives whose content is included in the
Expand Down
7 changes: 1 addition & 6 deletions engines/collavre/app/services/collavre/ai_agent_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,8 @@ 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
rendered = AiSystemPromptRenderer.new(
template: template,
template: @agent.system_prompt,
context: rendering_context
).render

Expand Down
3 changes: 0 additions & 3 deletions engines/collavre/app/services/collavre/mcp_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,6 @@ 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.effective_system_prompt.to_s
prompt = user.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,15 +208,12 @@ def calculate_keyword_score(agent, message_text)
def extract_expertise_text(agent)
texts = []

# 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 agent's system_prompt (primary source for AI agents)
texts << agent.system_prompt if 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.effective_system_prompt if agent.ai_agent.respond_to?(:effective_system_prompt)
texts << agent.ai_agent.system_prompt if agent.ai_agent.respond_to?(:system_prompt)
texts << agent.ai_agent.description if agent.ai_agent.respond_to?(:description)
end

Expand Down
Loading