diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py index f49fecf..7b63274 100644 --- a/backend/app/services/openclaw/provisioning.py +++ b/backend/app/services/openclaw/provisioning.py @@ -70,6 +70,13 @@ class ProvisionOptions: _ROLE_SOUL_MAX_CHARS = 24_000 _ROLE_SOUL_WORD_RE = re.compile(r"[a-z0-9]+") +_ROLE_INSTRUCTION_START = "" +_ROLE_INSTRUCTION_END = "" +_ROLE_INSTRUCTION_RE = re.compile( + rf"{re.escape(_ROLE_INSTRUCTION_START)}.*?{re.escape(_ROLE_INSTRUCTION_END)}", + re.DOTALL, +) +_ROLE_INSTRUCTION_PATCH_FILES = frozenset({"IDENTITY.md", "SOUL.md"}) def _is_missing_session_error(exc: OpenClawGatewayError) -> bool: @@ -290,11 +297,115 @@ def _identity_context(agent: Agent) -> dict[str, str]: return {**identity_context, **extra_identity_context} +def _role_instruction_context(agent: Agent) -> dict[str, str]: + role = _identity_context(agent).get("identity_role", "") + return { + "local_role_instruction_markdown": _local_role_instruction_markdown(role), + } + + def _role_slug(role: str) -> str: tokens = _ROLE_SOUL_WORD_RE.findall(role.strip().lower()) return "-".join(tokens) +_ROLE_INSTRUCTION_MARKDOWN_BY_SLUG: dict[str, str] = { + "backend-code": """## Role Instructions - Backend Code + +- Own server-side behavior: APIs, services, data models, migrations, background jobs, integrations, and backend tests. +- Start by reading the relevant backend route, schema, model, service, and migration paths before editing. +- Preserve API contracts unless the task explicitly asks for a contract change; when a contract changes, update callers and generated clients as needed. +- Add or update focused backend tests for changed behavior, including permission, validation, and persistence edge cases. +- Call out risks around data integrity, backwards compatibility, idempotency, concurrency, and operational rollout in task comments.""", + "ui": """## Role Instructions - UI + +- Own the user-facing experience: page structure, component behavior, responsive layout, accessibility, and interaction polish. +- Follow the existing design system and component patterns before adding new UI abstractions. +- Check empty, loading, error, disabled, long-text, and mobile states for every changed workflow. +- Keep copy clear and action-oriented; avoid explanatory clutter inside the app. +- Verify visual changes with the strongest local signal available, such as lint, tests, builds, or screenshots when a browser workflow is practical.""", + "security-review": """## Role Instructions - Security Review + +- Review for concrete security risks before implementation convenience: auth boundaries, authorization, secrets, injection, unsafe redirects, SSRF, XSS, CSRF, file access, and auditability. +- Treat trust boundaries explicitly. Identify who can call the path, what data they can reach, and what external side effects can happen. +- Prefer minimal, targeted fixes that reduce exploitability without changing unrelated behavior. +- Include regression tests or clear verification evidence for the security boundary being protected. +- Report residual risk plainly, including assumptions and any follow-up hardening that should be tracked separately.""", + "code-review": """## Role Instructions - Code Review + +- Review before rewriting. Prioritize correctness, regressions, missing tests, maintainability, and operational risk. +- Lead with specific findings tied to files, lines, payloads, or workflows; keep summaries secondary. +- Distinguish must-fix defects from style preferences, and avoid broad refactors unless they directly reduce task risk. +- Verify claims against the codebase and tests when possible. +- If asked to implement review fixes, keep edits scoped to the identified issues and preserve unrelated user work.""", + "qa": """## Role Instructions - QA + +- Own verification strategy: expected behavior, edge cases, regression paths, and evidence quality. +- Convert requirements into concrete test cases before executing or recommending changes. +- Exercise happy paths, boundary cases, permissions, failure states, and cross-browser or responsive concerns when relevant. +- Record reproducible steps for failures and include the smallest useful evidence in task comments. +- Prefer automated regression coverage for behavior that is likely to recur.""", + "devops": """## Role Instructions - DevOps + +- Own deployment, runtime configuration, observability, infrastructure, CI/CD, and operational reliability concerns. +- Check environment variables, secrets handling, logs, health checks, migrations, rollbacks, and resource limits. +- Prefer idempotent, reversible operational changes with clear verification commands. +- Avoid destructive infrastructure or production-impacting actions without explicit approval. +- Document rollout risks, monitoring signals, and rollback paths when task changes affect operations.""", + "data": """## Role Instructions - Data + +- Own data shape, quality, querying, transformations, analytics, and persistence semantics. +- Validate source assumptions, null behavior, units, time zones, deduplication, and migration/backfill needs. +- Prefer structured parsers and typed schemas over ad hoc string handling. +- Add focused checks for data integrity and representative edge cases. +- Explain any data caveats, sampling limits, or confidence constraints in task comments.""", + "documentation": """## Role Instructions - Documentation + +- Own clarity, accuracy, structure, and maintenance burden of written artifacts. +- Verify commands, paths, API names, and workflow steps against the repository before documenting them. +- Write for the target reader's next action, not for exhaustive narration. +- Keep docs consistent with existing terminology and formatting. +- Flag stale or conflicting documentation discovered while working.""", + "product": """## Role Instructions - Product + +- Own user outcome, scope clarity, acceptance criteria, and tradeoff framing. +- Translate ambiguity into concrete workflow, success metric, and done-signal questions or proposals. +- Prioritize the smallest useful increment that preserves the intended user experience. +- Consider permissions, onboarding, empty states, and operational support as part of the product surface. +- Capture decisions and open questions in task comments so implementation stays aligned.""", + "triage": """## Role Instructions - Triage + +- Own intake clarity, prioritization, routing, and next-action definition. +- Reproduce or validate the issue when possible, then classify severity, owner, affected surface, and likely cause. +- Split vague work into actionable tasks with clear acceptance criteria and dependencies. +- Route work to the best-fit agent role and explain why when the assignment is not obvious. +- Keep comments concise: current state, blocker if any, next action, and owner.""", + "generalist": """## Role Instructions - Generalist + +- Adapt to the task context while staying inside the repository's existing architecture and conventions. +- Read enough surrounding code to avoid local fixes that break adjacent workflows. +- Prefer focused changes with direct verification evidence. +- Escalate when the task needs a specialist lens such as security, data, UI, or backend architecture. +- Leave task comments that make the outcome and remaining risk easy for the lead to understand.""", +} + + +def _local_role_instruction_markdown(role: str) -> str: + role = role.strip() + if not role: + return "" + markdown = _ROLE_INSTRUCTION_MARKDOWN_BY_SLUG.get(_role_slug(role)) + if markdown: + return markdown + return f"""## Role Instructions - {role} + +- Treat `{role}` as your execution lens for assigned tasks. +- Start each task by identifying the artifact you own, the quality bar for that role, and the evidence needed to prove completion. +- Stay within the role unless the task or lead explicitly asks you to broaden scope. +- When work crosses into another specialty, call that out and suggest the best-fit collaborator or review path. +- Capture role-specific assumptions, risks, and verification evidence in task comments.""" + + def _select_role_soul_ref( refs: list[souls_directory.SoulRef], *, @@ -373,6 +484,7 @@ def _build_context( base_url = settings.base_url main_session_key = GatewayAgentIdentity.session_key(gateway) identity_context = _identity_context(agent) + role_instruction_context = _role_instruction_context(agent) user_context = _user_context(user) return { "agent_name": agent.name, @@ -402,6 +514,7 @@ def _build_context( "workspace_root": workspace_root, **user_context, **identity_context, + **role_instruction_context, } @@ -413,6 +526,7 @@ def _build_main_context( ) -> dict[str, str]: base_url = settings.base_url identity_context = _identity_context(agent) + role_instruction_context = _role_instruction_context(agent) user_context = _user_context(user) return { "agent_name": agent.name, @@ -425,6 +539,7 @@ def _build_main_context( "workspace_root": gateway.workspace_root or "", **user_context, **identity_context, + **role_instruction_context, } diff --git a/backend/templates/BOARD_IDENTITY.md.j2 b/backend/templates/BOARD_IDENTITY.md.j2 index f81e1de..451f654 100644 --- a/backend/templates/BOARD_IDENTITY.md.j2 +++ b/backend/templates/BOARD_IDENTITY.md.j2 @@ -9,6 +9,11 @@ - Communication Style: {{ identity_communication_style }} - Emoji: {{ identity_emoji }} +{% set local_role_instructions = local_role_instruction_markdown | default("") | trim %} +{% if local_role_instructions %} +{{ local_role_instructions }} + +{% endif %} {% if identity_purpose or is_lead %} ## Purpose {% if identity_purpose %} diff --git a/backend/templates/BOARD_SOUL.md.j2 b/backend/templates/BOARD_SOUL.md.j2 index 2ee61c6..5ec7f50 100644 --- a/backend/templates/BOARD_SOUL.md.j2 +++ b/backend/templates/BOARD_SOUL.md.j2 @@ -1,4 +1,5 @@ {% set is_lead = (is_board_lead | default(false) | string | lower) in ["true", "1", "yes"] %} +{% set local_role_instructions = local_role_instruction_markdown | default("") | trim %} {% if is_lead %} # SOUL.md - Who You Are @@ -24,12 +25,22 @@ Be the assistant you’d actually want to talk to. Concise when needed, thorough Each session starts fresh. `MEMORY.md` and `USER.md` are your continuity anchors. If this file changes materially, make that explicit in your next status update. +{% if local_role_instructions %} +{{ local_role_instructions }} + +{% endif %} This file is yours to evolve. As you learn who you are, update it. {% else %} {% set remote_role_soul = directory_role_soul_markdown | default("") | trim %} {% if remote_role_soul %} {{ remote_role_soul }} +{% if local_role_instructions %} + +--- + +{{ local_role_instructions }} +{% endif %} {% else %} # SOUL.md @@ -38,6 +49,10 @@ _You're not a chatbot. You're becoming someone._ This file is your stable core. Changes here should be rare and significant. Put evolving preferences and identity changes in `MEMORY.md`. +{% if local_role_instructions %} +{{ local_role_instructions }} + +{% endif %} ## Core Truths **Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" -- just help. Actions speak louder than filler words. diff --git a/backend/templates/README.md b/backend/templates/README.md index 819f36e..f88793a 100644 --- a/backend/templates/README.md +++ b/backend/templates/README.md @@ -124,6 +124,7 @@ This avoids relying on startup hooks to populate `api/openapi.json`. - `identity_role`, `identity_communication_style`, `identity_emoji` - `identity_autonomy_level`, `identity_verbosity`, `identity_output_format`, `identity_update_cadence` - `identity_purpose`, `identity_personality`, `identity_custom_instructions` +- `local_role_instruction_markdown`: deterministic local instructions derived from `identity_role` ### Board-agent-only keys diff --git a/backend/tests/test_agent_provisioning_utils.py b/backend/tests/test_agent_provisioning_utils.py index 871c62b..247d3e3 100644 --- a/backend/tests/test_agent_provisioning_utils.py +++ b/backend/tests/test_agent_provisioning_utils.py @@ -112,6 +112,60 @@ def test_user_context_prefers_name_token_when_preferred_name_missing(): assert context["user_preferred_name"] == "Jane" +def test_local_role_instruction_catalog_includes_backend_code() -> None: + markdown = agent_provisioning._local_role_instruction_markdown("Backend Code") + + assert "## Role Instructions - Backend Code" in markdown + assert "APIs, services, data models, migrations" in markdown + assert "backend tests" in markdown + + +def test_local_role_instruction_has_custom_role_fallback() -> None: + markdown = agent_provisioning._local_role_instruction_markdown("Release Captain") + + assert "## Role Instructions - Release Captain" in markdown + assert "execution lens" in markdown + assert "best-fit collaborator" in markdown + + +def test_rendered_agent_files_include_local_role_instructions() -> None: + agent = _AgentStub( + name="Backend Agent", + identity_profile={"role": "Backend Code"}, + ) + context = { + "agent_name": agent.name, + "agent_id": str(agent.id), + "identity_role": "Backend Code", + "identity_communication_style": "direct", + "identity_emoji": ":gear:", + "identity_purpose": "", + "identity_personality": "", + "identity_custom_instructions": "", + "is_board_lead": "false", + "directory_role_soul_markdown": "", + "directory_role_soul_source_url": "", + "local_role_instruction_markdown": agent_provisioning._local_role_instruction_markdown( + "Backend Code" + ), + } + + rendered = agent_provisioning._render_agent_files( + context, + agent, # type: ignore[arg-type] + {"IDENTITY.md", "SOUL.md"}, + include_bootstrap=False, + template_overrides={ + "IDENTITY.md": "BOARD_IDENTITY.md.j2", + "SOUL.md": "BOARD_SOUL.md.j2", + }, + ) + + assert "## Role Instructions - Backend Code" in rendered["IDENTITY.md"] + assert "## Role Instructions - Backend Code" in rendered["SOUL.md"] + assert "backend tests" in rendered["SOUL.md"] + + @dataclass class _GatewayStub: id: UUID diff --git a/frontend/src/app/agents/[agentId]/edit/page.tsx b/frontend/src/app/agents/[agentId]/edit/page.tsx index 7abcfe9..f96737e 100644 --- a/frontend/src/app/agents/[agentId]/edit/page.tsx +++ b/frontend/src/app/agents/[agentId]/edit/page.tsx @@ -18,6 +18,7 @@ import { useListBoardsApiV1BoardsGet, } from "@/api/generated/boards/boards"; import type { AgentRead, AgentUpdate, BoardRead } from "@/api/generated/model"; +import { AgentRoleField } from "@/components/agents/AgentRoleField"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -277,22 +278,17 @@ export default function EditAgentPage() { disabled={isLoading} /> -
- - - setIdentityProfile({ - ...resolvedIdentityProfile, - role: event.target.value, - }) - } - placeholder="e.g. Founder, Social Media Manager" - disabled={isLoading} - /> -
+ + setIdentityProfile({ + ...resolvedIdentityProfile, + role, + }) + } + disabled={isLoading} + listId="edit-agent-role-options" + />
diff --git a/frontend/src/app/agents/[agentId]/page.tsx b/frontend/src/app/agents/[agentId]/page.tsx index cf25d18..3c49ad2 100644 --- a/frontend/src/app/agents/[agentId]/page.tsx +++ b/frontend/src/app/agents/[agentId]/page.tsx @@ -26,6 +26,7 @@ import { formatRelativeTimestamp as formatRelative, formatTimestamp, } from "@/lib/formatters"; +import { getAgentRole } from "@/lib/agent-roles"; import { useOrganizationMembership } from "@/lib/use-organization-membership"; import type { ActivityEventRead, @@ -138,6 +139,7 @@ export default function AgentDetailPage() { const isDeleting = deleteMutation.isPending; const agentStatus = agent?.status ?? "unknown"; + const agentRole = getAgentRole(agent?.identity_profile); const handleDelete = () => { if (!agentId || !isSignedIn) return; @@ -263,6 +265,14 @@ export default function AgentDetailPage() {

)}
+
+

+ Role +

+

+ {agentRole} +

+

Last seen diff --git a/frontend/src/app/agents/new/page.tsx b/frontend/src/app/agents/new/page.tsx index b54f812..c38520c 100644 --- a/frontend/src/app/agents/new/page.tsx +++ b/frontend/src/app/agents/new/page.tsx @@ -15,6 +15,7 @@ import { import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents"; import { useOrganizationMembership } from "@/lib/use-organization-membership"; import type { BoardRead } from "@/api/generated/model"; +import { AgentRoleField } from "@/components/agents/AgentRoleField"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -161,22 +162,17 @@ export default function NewAgentPage() { disabled={isLoading} />

-
- - - setIdentityProfile((current) => ({ - ...current, - role: event.target.value, - })) - } - placeholder="e.g. Founder, Social Media Manager" - disabled={isLoading} - /> -
+ + setIdentityProfile((current) => ({ + ...current, + role, + })) + } + disabled={isLoading} + listId="new-agent-role-options" + />
diff --git a/frontend/src/components/agents/AgentRoleField.tsx b/frontend/src/components/agents/AgentRoleField.tsx new file mode 100644 index 0000000..ba0d210 --- /dev/null +++ b/frontend/src/components/agents/AgentRoleField.tsx @@ -0,0 +1,53 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + AGENT_ROLE_OPTIONS, + PRIMARY_AGENT_ROLE_OPTIONS, +} from "@/lib/agent-roles"; + +type AgentRoleFieldProps = { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + listId: string; +}; + +export function AgentRoleField({ + value, + onChange, + disabled = false, + listId, +}: AgentRoleFieldProps) { + return ( +
+ + onChange(event.target.value)} + placeholder="e.g. Backend Code, UI, Security Review" + disabled={disabled} + /> + + {AGENT_ROLE_OPTIONS.map((role) => ( + +
+ {PRIMARY_AGENT_ROLE_OPTIONS.map((role) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/agents/AgentsTable.test.tsx b/frontend/src/components/agents/AgentsTable.test.tsx index 67011a2..af30a21 100644 --- a/frontend/src/components/agents/AgentsTable.test.tsx +++ b/frontend/src/components/agents/AgentsTable.test.tsx @@ -27,6 +27,7 @@ const buildAgent = (overrides: Partial = {}): AgentRead => ({ board_id: "board-1", status: "online", openclaw_session_id: "session-1234", + identity_profile: { role: "Backend Code" }, last_seen_at: "2026-01-01T00:00:00Z", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", @@ -61,6 +62,7 @@ describe("AgentsTable", () => { "href", "/boards/board-1", ); + expect(screen.getByRole("cell", { name: "Backend Code" })).toBeVisible(); expect(screen.getByRole("link", { name: "Edit" })).toHaveAttribute( "href", "/agents/agent-1/edit", diff --git a/frontend/src/components/agents/AgentsTable.tsx b/frontend/src/components/agents/AgentsTable.tsx index e96888f..e631f3c 100644 --- a/frontend/src/components/agents/AgentsTable.tsx +++ b/frontend/src/components/agents/AgentsTable.tsx @@ -18,6 +18,7 @@ import { linkifyCell, pillCell, } from "@/components/tables/cell-formatters"; +import { getAgentRole } from "@/lib/agent-roles"; import { truncateText as truncate } from "@/lib/formatters"; type AgentsTableEmptyState = { @@ -116,6 +117,16 @@ export function AgentsTable({ header: "Status", cell: ({ row }) => pillCell(row.original.status), }, + { + id: "role", + header: "Role", + accessorFn: (agent) => getAgentRole(agent.identity_profile), + cell: ({ row }) => ( + + {getAgentRole(row.original.identity_profile)} + + ), + }, { accessorKey: "openclaw_session_id", header: "Session", diff --git a/frontend/src/lib/agent-roles.ts b/frontend/src/lib/agent-roles.ts new file mode 100644 index 0000000..58e2126 --- /dev/null +++ b/frontend/src/lib/agent-roles.ts @@ -0,0 +1,31 @@ +export const AGENT_ROLE_OPTIONS = [ + "Generalist", + "Backend Code", + "UI", + "Security Review", + "Code Review", + "QA", + "DevOps", + "Data", + "Documentation", + "Product", + "Triage", +] as const; + +export const PRIMARY_AGENT_ROLE_OPTIONS = [ + "Backend Code", + "UI", + "Security Review", + "Code Review", +] as const; + +export const getAgentRole = ( + identityProfile: unknown, + fallback = "Unassigned", +): string => { + if (!identityProfile || typeof identityProfile !== "object") { + return fallback; + } + const role = (identityProfile as Record).role; + return typeof role === "string" && role.trim() ? role.trim() : fallback; +};