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_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:
@ -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,
}

View File

@ -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 %}

View File

@ -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 youd 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.

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_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

View File

@ -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

View File

@ -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}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Role
</label>
<Input
value={resolvedIdentityProfile.role}
onChange={(event) =>
setIdentityProfile({
...resolvedIdentityProfile,
role: event.target.value,
})
}
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading}
/>
</div>
<AgentRoleField
value={resolvedIdentityProfile.role}
onChange={(role) =>
setIdentityProfile({
...resolvedIdentityProfile,
role,
})
}
disabled={isLoading}
listId="edit-agent-role-options"
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">

View File

@ -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() {
<p className="mt-1 text-sm text-strong"></p>
)}
</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>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen

View File

@ -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}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Role
</label>
<Input
value={identityProfile.role}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
role: event.target.value,
}))
}
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading}
/>
</div>
<AgentRoleField
value={identityProfile.role}
onChange={(role) =>
setIdentityProfile((current) => ({
...current,
role,
}))
}
disabled={isLoading}
listId="new-agent-role-options"
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">

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",
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",

View File

@ -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 }) => (
<span className="text-sm text-foreground">
{getAgentRole(row.original.identity_profile)}
</span>
),
},
{
accessorKey: "openclaw_session_id",
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;
};