fix(ui): roles

This commit is contained in:
null 2026-05-22 00:55:56 -05:00
parent 7678efedc8
commit 36025c6c67
12 changed files with 321 additions and 32 deletions

View File

@ -70,6 +70,13 @@ class ProvisionOptions:
_ROLE_SOUL_MAX_CHARS = 24_000 _ROLE_SOUL_MAX_CHARS = 24_000
_ROLE_SOUL_WORD_RE = re.compile(r"[a-z0-9]+") _ROLE_SOUL_WORD_RE = re.compile(r"[a-z0-9]+")
_ROLE_INSTRUCTION_START = "<!-- mission-control:role-instructions:start -->"
_ROLE_INSTRUCTION_END = "<!-- mission-control:role-instructions: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: 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} 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: def _role_slug(role: str) -> str:
tokens = _ROLE_SOUL_WORD_RE.findall(role.strip().lower()) tokens = _ROLE_SOUL_WORD_RE.findall(role.strip().lower())
return "-".join(tokens) 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( def _select_role_soul_ref(
refs: list[souls_directory.SoulRef], refs: list[souls_directory.SoulRef],
*, *,
@ -373,6 +484,7 @@ def _build_context(
base_url = settings.base_url base_url = settings.base_url
main_session_key = GatewayAgentIdentity.session_key(gateway) main_session_key = GatewayAgentIdentity.session_key(gateway)
identity_context = _identity_context(agent) identity_context = _identity_context(agent)
role_instruction_context = _role_instruction_context(agent)
user_context = _user_context(user) user_context = _user_context(user)
return { return {
"agent_name": agent.name, "agent_name": agent.name,
@ -402,6 +514,7 @@ def _build_context(
"workspace_root": workspace_root, "workspace_root": workspace_root,
**user_context, **user_context,
**identity_context, **identity_context,
**role_instruction_context,
} }
@ -413,6 +526,7 @@ def _build_main_context(
) -> dict[str, str]: ) -> dict[str, str]:
base_url = settings.base_url base_url = settings.base_url
identity_context = _identity_context(agent) identity_context = _identity_context(agent)
role_instruction_context = _role_instruction_context(agent)
user_context = _user_context(user) user_context = _user_context(user)
return { return {
"agent_name": agent.name, "agent_name": agent.name,
@ -425,6 +539,7 @@ def _build_main_context(
"workspace_root": gateway.workspace_root or "", "workspace_root": gateway.workspace_root or "",
**user_context, **user_context,
**identity_context, **identity_context,
**role_instruction_context,
} }

View File

@ -9,6 +9,11 @@
- Communication Style: {{ identity_communication_style }} - Communication Style: {{ identity_communication_style }}
- Emoji: {{ identity_emoji }} - 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 %} {% if identity_purpose or is_lead %}
## Purpose ## Purpose
{% if identity_purpose %} {% if identity_purpose %}

View File

@ -1,4 +1,5 @@
{% set is_lead = (is_board_lead | default(false) | string | lower) in ["true", "1", "yes"] %} {% 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 %} {% if is_lead %}
# SOUL.md - Who You Are # SOUL.md - Who You Are
@ -24,12 +25,22 @@ Be the assistant youd actually want to talk to. Concise when needed, thorough
Each session starts fresh. `MEMORY.md` and `USER.md` are your continuity anchors. 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 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. This file is yours to evolve. As you learn who you are, update it.
{% else %} {% else %}
{% set remote_role_soul = directory_role_soul_markdown | default("") | trim %} {% set remote_role_soul = directory_role_soul_markdown | default("") | trim %}
{% if remote_role_soul %} {% if remote_role_soul %}
{{ remote_role_soul }} {{ remote_role_soul }}
{% if local_role_instructions %}
---
{{ local_role_instructions }}
{% endif %}
{% else %} {% else %}
# SOUL.md # 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. This file is your stable core. Changes here should be rare and significant.
Put evolving preferences and identity changes in `MEMORY.md`. Put evolving preferences and identity changes in `MEMORY.md`.
{% if local_role_instructions %}
{{ local_role_instructions }}
{% endif %}
## Core Truths ## 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. **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.

View File

@ -124,6 +124,7 @@ This avoids relying on startup hooks to populate `api/openapi.json`.
- `identity_role`, `identity_communication_style`, `identity_emoji` - `identity_role`, `identity_communication_style`, `identity_emoji`
- `identity_autonomy_level`, `identity_verbosity`, `identity_output_format`, `identity_update_cadence` - `identity_autonomy_level`, `identity_verbosity`, `identity_output_format`, `identity_update_cadence`
- `identity_purpose`, `identity_personality`, `identity_custom_instructions` - `identity_purpose`, `identity_personality`, `identity_custom_instructions`
- `local_role_instruction_markdown`: deterministic local instructions derived from `identity_role`
### Board-agent-only keys ### Board-agent-only keys

View File

@ -112,6 +112,60 @@ def test_user_context_prefers_name_token_when_preferred_name_missing():
assert context["user_preferred_name"] == "Jane" 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 @dataclass
class _GatewayStub: class _GatewayStub:
id: UUID id: UUID

View File

@ -18,6 +18,7 @@ import {
useListBoardsApiV1BoardsGet, useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards"; } from "@/api/generated/boards/boards";
import type { AgentRead, AgentUpdate, BoardRead } from "@/api/generated/model"; import type { AgentRead, AgentUpdate, BoardRead } from "@/api/generated/model";
import { AgentRoleField } from "@/components/agents/AgentRoleField";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -277,23 +278,18 @@ export default function EditAgentPage() {
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className="space-y-2"> <AgentRoleField
<label className="text-sm font-medium text-foreground">
Role
</label>
<Input
value={resolvedIdentityProfile.role} value={resolvedIdentityProfile.role}
onChange={(event) => onChange={(role) =>
setIdentityProfile({ setIdentityProfile({
...resolvedIdentityProfile, ...resolvedIdentityProfile,
role: event.target.value, role,
}) })
} }
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading} disabled={isLoading}
listId="edit-agent-role-options"
/> />
</div> </div>
</div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -26,6 +26,7 @@ import {
formatRelativeTimestamp as formatRelative, formatRelativeTimestamp as formatRelative,
formatTimestamp, formatTimestamp,
} from "@/lib/formatters"; } from "@/lib/formatters";
import { getAgentRole } from "@/lib/agent-roles";
import { useOrganizationMembership } from "@/lib/use-organization-membership"; import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type { import type {
ActivityEventRead, ActivityEventRead,
@ -138,6 +139,7 @@ export default function AgentDetailPage() {
const isDeleting = deleteMutation.isPending; const isDeleting = deleteMutation.isPending;
const agentStatus = agent?.status ?? "unknown"; const agentStatus = agent?.status ?? "unknown";
const agentRole = getAgentRole(agent?.identity_profile);
const handleDelete = () => { const handleDelete = () => {
if (!agentId || !isSignedIn) return; if (!agentId || !isSignedIn) return;
@ -263,6 +265,14 @@ export default function AgentDetailPage() {
<p className="mt-1 text-sm text-strong"></p> <p className="mt-1 text-sm text-strong"></p>
)} )}
</div> </div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Role
</p>
<p className="mt-1 text-sm text-strong">
{agentRole}
</p>
</div>
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen Last seen

View File

@ -15,6 +15,7 @@ import {
import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents"; import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents";
import { useOrganizationMembership } from "@/lib/use-organization-membership"; import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type { BoardRead } from "@/api/generated/model"; import type { BoardRead } from "@/api/generated/model";
import { AgentRoleField } from "@/components/agents/AgentRoleField";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -161,23 +162,18 @@ export default function NewAgentPage() {
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className="space-y-2"> <AgentRoleField
<label className="text-sm font-medium text-foreground">
Role
</label>
<Input
value={identityProfile.role} value={identityProfile.role}
onChange={(event) => onChange={(role) =>
setIdentityProfile((current) => ({ setIdentityProfile((current) => ({
...current, ...current,
role: event.target.value, role,
})) }))
} }
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading} disabled={isLoading}
listId="new-agent-role-options"
/> />
</div> </div>
</div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-foreground"> <label className="text-sm font-medium text-foreground">

View File

@ -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 (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Role</label>
<Input
value={value}
list={listId}
onChange={(event) => onChange(event.target.value)}
placeholder="e.g. Backend Code, UI, Security Review"
disabled={disabled}
/>
<datalist id={listId}>
{AGENT_ROLE_OPTIONS.map((role) => (
<option key={role} value={role} />
))}
</datalist>
<div className="flex flex-wrap gap-2">
{PRIMARY_AGENT_ROLE_OPTIONS.map((role) => (
<Button
key={role}
type="button"
variant={value === role ? "primary" : "outline"}
size="sm"
className="h-8 rounded-lg px-2.5 text-xs"
onClick={() => onChange(role)}
disabled={disabled}
>
{role}
</Button>
))}
</div>
</div>
);
}

View File

@ -27,6 +27,7 @@ const buildAgent = (overrides: Partial<AgentRead> = {}): AgentRead => ({
board_id: "board-1", board_id: "board-1",
status: "online", status: "online",
openclaw_session_id: "session-1234", openclaw_session_id: "session-1234",
identity_profile: { role: "Backend Code" },
last_seen_at: "2026-01-01T00:00:00Z", last_seen_at: "2026-01-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z", created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z",
@ -61,6 +62,7 @@ describe("AgentsTable", () => {
"href", "href",
"/boards/board-1", "/boards/board-1",
); );
expect(screen.getByRole("cell", { name: "Backend Code" })).toBeVisible();
expect(screen.getByRole("link", { name: "Edit" })).toHaveAttribute( expect(screen.getByRole("link", { name: "Edit" })).toHaveAttribute(
"href", "href",
"/agents/agent-1/edit", "/agents/agent-1/edit",

View File

@ -18,6 +18,7 @@ import {
linkifyCell, linkifyCell,
pillCell, pillCell,
} from "@/components/tables/cell-formatters"; } from "@/components/tables/cell-formatters";
import { getAgentRole } from "@/lib/agent-roles";
import { truncateText as truncate } from "@/lib/formatters"; import { truncateText as truncate } from "@/lib/formatters";
type AgentsTableEmptyState = { type AgentsTableEmptyState = {
@ -116,6 +117,16 @@ export function AgentsTable({
header: "Status", header: "Status",
cell: ({ row }) => pillCell(row.original.status), cell: ({ row }) => pillCell(row.original.status),
}, },
{
id: "role",
header: "Role",
accessorFn: (agent) => getAgentRole(agent.identity_profile),
cell: ({ row }) => (
<span className="text-sm text-foreground">
{getAgentRole(row.original.identity_profile)}
</span>
),
},
{ {
accessorKey: "openclaw_session_id", accessorKey: "openclaw_session_id",
header: "Session", header: "Session",

View File

@ -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<string, unknown>).role;
return typeof role === "string" && role.trim() ? role.trim() : fallback;
};