diff --git a/backend/alembic/versions/f7b3d0a1c9e2_add_agent_identity_profile.py b/backend/alembic/versions/f7b3d0a1c9e2_add_agent_identity_profile.py new file mode 100644 index 00000000..9037e98a --- /dev/null +++ b/backend/alembic/versions/f7b3d0a1c9e2_add_agent_identity_profile.py @@ -0,0 +1,28 @@ +"""Add agent identity profile. + +Revision ID: f7b3d0a1c9e2 +Revises: c1c8b3b9f4d1 +Create Date: 2026-02-04 22:45:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f7b3d0a1c9e2" +down_revision = "c1c8b3b9f4d1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "agents", + sa.Column("identity_profile", sa.JSON(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("agents", "identity_profile") diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index f4b21523..b6f9fcb8 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -38,6 +38,27 @@ OFFLINE_AFTER = timedelta(minutes=10) AGENT_SESSION_PREFIX = "agent" +def _normalize_identity_profile( + profile: dict[str, object] | None, +) -> dict[str, str] | None: + if not profile: + return None + normalized: dict[str, str] = {} + for key, raw in profile.items(): + if raw is None: + continue + if isinstance(raw, list): + parts = [str(item).strip() for item in raw if str(item).strip()] + if not parts: + continue + normalized[key] = ", ".join(parts) + continue + value = str(raw).strip() + if value: + normalized[key] = value + return normalized or None + + def _slugify(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") return slug or uuid4().hex @@ -176,6 +197,9 @@ async def create_agent( data["identity_template"] = None if data.get("soul_template") == "": data["soul_template"] = None + data["identity_profile"] = _normalize_identity_profile( + data.get("identity_profile") + ) agent = Agent.model_validate(data) agent.status = "provisioning" raw_token = generate_agent_token() @@ -267,6 +291,10 @@ async def update_agent( updates["identity_template"] = None if updates.get("soul_template") == "": updates["soul_template"] = None + if "identity_profile" in updates: + updates["identity_profile"] = _normalize_identity_profile( + updates.get("identity_profile") + ) if not updates: return _with_computed_status(agent) if "board_id" in updates: diff --git a/backend/app/models/agents.py b/backend/app/models/agents.py index 78fc6b30..efededb5 100644 --- a/backend/app/models/agents.py +++ b/backend/app/models/agents.py @@ -18,6 +18,7 @@ class Agent(SQLModel, table=True): openclaw_session_id: str | None = Field(default=None, index=True) agent_token_hash: str | None = Field(default=None, index=True) heartbeat_config: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + identity_profile: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) identity_template: str | None = Field(default=None, sa_column=Column(Text)) soul_template: str | None = Field(default=None, sa_column=Column(Text)) provision_requested_at: datetime | None = Field(default=None) diff --git a/backend/app/schemas/agents.py b/backend/app/schemas/agents.py index 02e01265..238b775c 100644 --- a/backend/app/schemas/agents.py +++ b/backend/app/schemas/agents.py @@ -12,6 +12,7 @@ class AgentBase(SQLModel): name: str status: str = "provisioning" heartbeat_config: dict[str, Any] | None = None + identity_profile: dict[str, Any] | None = None identity_template: str | None = None soul_template: str | None = None @@ -25,6 +26,7 @@ class AgentUpdate(SQLModel): name: str | None = None status: str | None = None heartbeat_config: dict[str, Any] | None = None + identity_profile: dict[str, Any] | None = None identity_template: str | None = None soul_template: str | None = None @@ -44,4 +46,3 @@ class AgentHeartbeat(SQLModel): class AgentHeartbeatCreate(AgentHeartbeat): name: str board_id: UUID | None = None - diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index 51012be7..2f46c78d 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -17,6 +17,17 @@ from app.models.gateways import Gateway from app.models.users import User DEFAULT_HEARTBEAT_CONFIG = {"every": "10m", "target": "none"} +DEFAULT_IDENTITY_PROFILE = { + "role": "Generalist", + "communication_style": "direct, concise, practical", + "emoji": ":gear:", +} + +IDENTITY_PROFILE_FIELDS = { + "role": "identity_role", + "communication_style": "identity_communication_style", + "emoji": "identity_emoji", +} DEFAULT_GATEWAY_FILES = frozenset( { @@ -94,6 +105,26 @@ def _build_context( session_key = agent.openclaw_session_id or "" base_url = settings.base_url or "REPLACE_WITH_BASE_URL" main_session_key = gateway.main_session_key + identity_profile: dict[str, Any] = {} + if isinstance(agent.identity_profile, dict): + identity_profile = agent.identity_profile + normalized_identity: dict[str, str] = {} + for key, value in identity_profile.items(): + if value is None: + continue + if isinstance(value, list): + parts = [str(item).strip() for item in value if str(item).strip()] + if not parts: + continue + normalized_identity[key] = ", ".join(parts) + continue + text = str(value).strip() + if text: + normalized_identity[key] = text + identity_context = { + context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field]) + for field, context_key in IDENTITY_PROFILE_FIELDS.items() + } return { "agent_name": agent.name, "agent_id": agent_id, @@ -110,6 +141,7 @@ def _build_context( "user_timezone": (user.timezone or "") if user else "", "user_notes": (user.notes or "") if user else "", "user_context": (user.context or "") if user else "", + **identity_context, } diff --git a/frontend/src/app/agents/[agentId]/edit/page.tsx b/frontend/src/app/agents/[agentId]/edit/page.tsx index 0f9af478..b15af721 100644 --- a/frontend/src/app/agents/[agentId]/edit/page.tsx +++ b/frontend/src/app/agents/[agentId]/edit/page.tsx @@ -12,10 +12,17 @@ import { Input } from "@/components/ui/input"; import SearchableSelect, { type SearchableSelectOption, } from "@/components/ui/searchable-select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { getApiBaseUrl } from "@/lib/api-base"; import { - DEFAULT_IDENTITY_TEMPLATE, + DEFAULT_IDENTITY_PROFILE, DEFAULT_SOUL_TEMPLATE, } from "@/lib/agent-templates"; @@ -29,6 +36,7 @@ type Agent = { every?: string; target?: string; } | null; + identity_profile?: IdentityProfile | null; identity_template?: string | null; soul_template?: string | null; }; @@ -39,6 +47,25 @@ type Board = { slug: string; }; +type IdentityProfile = { + role: string; + communication_style: string; + emoji: string; +}; + +const EMOJI_OPTIONS = [ + { value: ":gear:", label: "Gear", glyph: "⚙️" }, + { value: ":sparkles:", label: "Sparkles", glyph: "✨" }, + { value: ":rocket:", label: "Rocket", glyph: "🚀" }, + { value: ":megaphone:", label: "Megaphone", glyph: "📣" }, + { value: ":chart_with_upwards_trend:", label: "Growth", glyph: "📈" }, + { value: ":bulb:", label: "Idea", glyph: "💡" }, + { value: ":wrench:", label: "Builder", glyph: "🔧" }, + { value: ":shield:", label: "Shield", glyph: "🛡️" }, + { value: ":memo:", label: "Notes", glyph: "📝" }, + { value: ":brain:", label: "Brain", glyph: "🧠" }, +]; + const HEARTBEAT_TARGET_OPTIONS: SearchableSelectOption[] = [ { value: "none", label: "None (no outbound message)" }, { value: "last", label: "Last channel" }, @@ -50,6 +77,27 @@ const getBoardOptions = (boards: Board[]): SearchableSelectOption[] => label: board.name, })); +const normalizeIdentityProfile = ( + profile: IdentityProfile +): IdentityProfile | null => { + const normalized: IdentityProfile = { + role: profile.role.trim(), + communication_style: profile.communication_style.trim(), + emoji: profile.emoji.trim(), + }; + const hasValue = Object.values(normalized).some((value) => value.length > 0); + return hasValue ? normalized : null; +}; + +const withIdentityDefaults = ( + profile: Partial | null | undefined +): IdentityProfile => ({ + role: profile?.role ?? DEFAULT_IDENTITY_PROFILE.role, + communication_style: + profile?.communication_style ?? DEFAULT_IDENTITY_PROFILE.communication_style, + emoji: profile?.emoji ?? DEFAULT_IDENTITY_PROFILE.emoji, +}); + export default function EditAgentPage() { const { getToken, isSignedIn } = useAuth(); const router = useRouter(); @@ -63,9 +111,9 @@ export default function EditAgentPage() { const [boardId, setBoardId] = useState(""); const [heartbeatEvery, setHeartbeatEvery] = useState("10m"); const [heartbeatTarget, setHeartbeatTarget] = useState("none"); - const [identityTemplate, setIdentityTemplate] = useState( - DEFAULT_IDENTITY_TEMPLATE - ); + const [identityProfile, setIdentityProfile] = useState({ + ...DEFAULT_IDENTITY_PROFILE, + }); const [soulTemplate, setSoulTemplate] = useState(DEFAULT_SOUL_TEMPLATE); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -111,9 +159,7 @@ export default function EditAgentPage() { if (data.heartbeat_config?.target) { setHeartbeatTarget(data.heartbeat_config.target); } - setIdentityTemplate( - data.identity_template?.trim() || DEFAULT_IDENTITY_TEMPLATE - ); + setIdentityProfile(withIdentityDefaults(data.identity_profile)); setSoulTemplate(data.soul_template?.trim() || DEFAULT_SOUL_TEMPLATE); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); @@ -168,7 +214,7 @@ export default function EditAgentPage() { every: heartbeatEvery.trim() || "10m", target: heartbeatTarget, }, - identity_template: identityTemplate.trim() || null, + identity_profile: normalizeIdentityProfile(identityProfile), soul_template: soulTemplate.trim() || null, }), }); @@ -220,65 +266,111 @@ export default function EditAgentPage() { >

- Agent identity + Basic configuration

-
-
- - setName(event.target.value)} - placeholder="e.g. Deploy bot" - disabled={isLoading} - /> +
+
+
+ + setName(event.target.value)} + placeholder="e.g. Deploy bot" + disabled={isLoading} + /> +
+
+ + + setIdentityProfile((current) => ({ + ...current, + role: event.target.value, + })) + } + placeholder="e.g. Founder, Social Media Manager" + disabled={isLoading} + /> +
-
- - - {boards.length === 0 ? ( -

- Create a board before assigning agents. -

- ) : null} +
+
+ + + {boards.length === 0 ? ( +

+ Create a board before assigning agents. +

+ ) : null} +
+
+ + +

- Agent persona + Personality & behavior

-
+
-