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
94 changes: 94 additions & 0 deletions src/hal0/agents/persona.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Persona + skill enums consumed by the dashboard's Agent surface.

The dashboard's PersonaEditModal (#226) renders a Tone select + an
Allowed-tools picker; the Skills tab (#227) renders a catalogue of
agent-callable skills. Both used to hardcode their option lists in
JSX. This module is the single server-side source of truth so the
catalogue can grow without UI patches.

The enum membership matches what Hermes ships with today (see
``hermes_templates/config.yaml.j2``) plus the OmniRouter capability
ladder the dashboard's #426 inbox already reflects. Adding a new
persona tone or skill capability lands here first.
"""

from __future__ import annotations

from typing import TypedDict


class PersonaTone(TypedDict):
id: str
label: str
desc: str


class PersonaTool(TypedDict):
id: str
label: str
cap: str


class AgentSkill(TypedDict):
name: str
cap: str
policy: str
src: str


# Persona tone presets shown in PersonaEditModal's Tone select.
# ``id`` is what persists into the persona record; ``label`` + ``desc``
# are display-only. Keep ``operator`` first — it's the safe default
# baked into the modal's useState seed.
PERSONA_TONES: tuple[PersonaTone, ...] = (
{"id": "operator", "label": "operator", "desc": "terse + technical"},
{"id": "code-focused", "label": "code-focused", "desc": "refactors, reviews"},
{"id": "low-latency", "label": "low-latency", "desc": "NPU coresident"},
{"id": "vision", "label": "vision-first", "desc": "image-aware"},
{"id": "conversational", "label": "conversational", "desc": "slower, fuller"},
)


# Allowed-tools picker membership. Mirrors the OmniRouter-callable tool
# set Hermes wires by default (see ``config.yaml.j2`` skills.search).
# ``cap`` is the capability bucket the approval queue gates on.
PERSONA_TOOLS: tuple[PersonaTool, ...] = (
{"id": "read_file", "label": "read_file", "cap": "fs-read"},
{"id": "write_file", "label": "write_file", "cap": "fs-write"},
{"id": "edit_file", "label": "edit_file", "cap": "fs-write"},
{"id": "shell_exec", "label": "shell_exec", "cap": "shell-exec"},
{"id": "generate_image", "label": "generate_image", "cap": "tool-call"},
{"id": "transcribe_audio", "label": "transcribe_audio", "cap": "tool-call"},
{"id": "text_to_speech", "label": "text_to_speech", "cap": "tool-call"},
{"id": "embed_text", "label": "embed_text", "cap": "tool-call"},
)


# Skills catalogue rendered in the Agent > Skills tab. Static for v0.3
# (registry-backed dynamic catalog tracked in #227 follow-up). Policy
# strings line up with the approval queue's enum: ``always`` (gated),
# ``remember`` (gated once + cached), ``auto`` (no gate), ``deny``.
AGENT_SKILLS: tuple[AgentSkill, ...] = (
{"name": "read_file", "cap": "fs-read", "policy": "remember", "src": "builtin"},
{"name": "write_file", "cap": "fs-write", "policy": "always", "src": "builtin"},
{"name": "edit_file", "cap": "fs-write", "policy": "always", "src": "builtin"},
{"name": "list_dir", "cap": "fs-read", "policy": "remember", "src": "builtin"},
{"name": "shell_exec", "cap": "shell-exec", "policy": "always", "src": "builtin"},
{"name": "model_pull", "cap": "registry-write", "policy": "always", "src": "hal0-router"},
{"name": "restart_slot", "cap": "slot-control", "policy": "always", "src": "hal0-router"},
{"name": "generate_image", "cap": "tool-call", "policy": "auto", "src": "omnirouter"},
{"name": "transcribe_audio", "cap": "tool-call", "policy": "auto", "src": "omnirouter"},
{"name": "text_to_speech", "cap": "tool-call", "policy": "auto", "src": "omnirouter"},
{"name": "embed_text", "cap": "tool-call", "policy": "auto", "src": "omnirouter"},
{"name": "rerank_documents", "cap": "tool-call", "policy": "auto", "src": "omnirouter"},
)


__all__ = [
"AGENT_SKILLS",
"PERSONA_TONES",
"PERSONA_TOOLS",
"AgentSkill",
"PersonaTone",
"PersonaTool",
]
36 changes: 36 additions & 0 deletions src/hal0/api/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
HermesNotHal0AwareError,
)
from hal0.agents.manager import BUNDLED_AGENTS
from hal0.agents.persona import AGENT_SKILLS, PERSONA_TONES, PERSONA_TOOLS
from hal0.errors import BadRequest, Conflict, Hal0Error, NotFound

router = APIRouter()
Expand All @@ -55,6 +56,41 @@ async def list_agents() -> dict[str, object]:
return {"agents": items, "count": len(items)}


# ── GET /api/agents/persona-enums ────────────────────────────────────────────


@router.get("/persona-enums")
async def persona_enums() -> dict[str, object]:
"""Enum payload for PersonaEditModal (#226).

Returns the canonical tone presets + the allowed-tools catalogue
the modal renders. Tones + tools live in
:mod:`hal0.agents.persona`; adding new entries lands there.
"""
return {
"tones": list(PERSONA_TONES),
"tools": list(PERSONA_TOOLS),
}


# ── GET /api/agents/skills ───────────────────────────────────────────────────


@router.get("/skills")
async def list_skills() -> dict[str, object]:
"""Catalogue for the dashboard's Agent → Skills tab (#227).

Static for v0.3 — sources from :data:`hal0.agents.persona.AGENT_SKILLS`.
The ``calls`` column is omitted at the API layer; the dashboard
shows zero counts until #227's follow-up wires the journal-derived
counters.
"""
return {
"skills": list(AGENT_SKILLS),
"count": len(AGENT_SKILLS),
}


# ── POST /api/agents/install ──────────────────────────────────────────────────


Expand Down
100 changes: 100 additions & 0 deletions tests/api/test_agents_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Integration tests for ``/api/agents/*`` enum + skills surface.

Covers the two read-only catalogues the dashboard's Agent surface
consumes:

- ``GET /api/agents/persona-enums`` — PersonaEditModal (#226)
- ``GET /api/agents/skills`` — Agent > Skills tab (#227)

Both routes are stateless veneers over :mod:`hal0.agents.persona`, so
the test pins the response shape + a couple of guard-rail invariants
(non-empty payload, no duplicate ids) rather than the full enum
membership — that lives in the module itself and would just churn
the test on every catalogue add.
"""

from __future__ import annotations

from collections.abc import Iterator

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from hal0.api.middleware import error_codes
from hal0.api.routes import agents as agents_routes


def _build_app() -> FastAPI:
app = FastAPI()
error_codes.install(app)
app.include_router(agents_routes.router, prefix="/api/agents", tags=["agents"])
return app


@pytest.fixture
def client() -> Iterator[TestClient]:
with TestClient(_build_app()) as c:
yield c


# ── /api/agents/persona-enums ────────────────────────────────────────────


def test_persona_enums_shape(client: TestClient) -> None:
res = client.get("/api/agents/persona-enums")
assert res.status_code == 200
body = res.json()
assert set(body.keys()) == {"tones", "tools"}

tones = body["tones"]
tools = body["tools"]
assert isinstance(tones, list) and tones, "tones must be non-empty"
assert isinstance(tools, list) and tools, "tools must be non-empty"

# Every entry carries id/label; tones have desc, tools have cap.
for t in tones:
assert {"id", "label", "desc"} <= set(t.keys())
assert t["id"] and t["label"]
for t in tools:
assert {"id", "label", "cap"} <= set(t.keys())
assert t["id"] and t["label"] and t["cap"]


def test_persona_enums_ids_unique(client: TestClient) -> None:
body = client.get("/api/agents/persona-enums").json()
tone_ids = [t["id"] for t in body["tones"]]
tool_ids = [t["id"] for t in body["tools"]]
assert len(tone_ids) == len(set(tone_ids)), "duplicate tone ids"
assert len(tool_ids) == len(set(tool_ids)), "duplicate tool ids"


def test_persona_enums_operator_default_present(client: TestClient) -> None:
# The modal seeds tone="operator" — guard so we don't accidentally
# rename it out from under the UI.
tone_ids = [t["id"] for t in client.get("/api/agents/persona-enums").json()["tones"]]
assert "operator" in tone_ids


# ── /api/agents/skills ───────────────────────────────────────────────────


def test_skills_catalog_shape(client: TestClient) -> None:
res = client.get("/api/agents/skills")
assert res.status_code == 200
body = res.json()
assert set(body.keys()) == {"skills", "count"}
skills = body["skills"]
assert isinstance(skills, list) and skills
assert body["count"] == len(skills)

expected_keys = {"name", "cap", "policy", "src"}
for s in skills:
assert expected_keys <= set(s.keys())
assert s["policy"] in {"always", "remember", "auto", "deny"}


def test_skills_catalog_names_unique(client: TestClient) -> None:
skills = client.get("/api/agents/skills").json()["skills"]
names = [s["name"] for s in skills]
assert len(names) == len(set(names)), "duplicate skill names"
9 changes: 9 additions & 0 deletions ui/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ export const ENDPOINTS = {
// ── Hardware ─────────────────────────────────────────────────────
hardware: '/api/hardware',

// ── Agents — list + dashboard catalogues ─────────────────────────
// ``agents`` is the installed-bundled list (#207). ``agentSkills`` +
// ``agentPersonaEnums`` back the Skills tab (#227) + the
// PersonaEditModal selects (#226). Static catalogues sourced from
// ``hal0.agents.persona`` server-side.
agents: '/api/agents',
agentSkills: '/api/agents/skills',
agentPersonaEnums: '/api/agents/persona-enums',

// ── Agents — MCP-client allow-list (ADR-0013) ────────────────────
agentMcpClients: '/api/agents/mcp/clients',
agentMcpClient: (name: string) =>
Expand Down
1 change: 1 addition & 0 deletions ui/src/api/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from './useLogs'
export * from './useUpdates'
export * from './useSecrets'
export * from './useFirstRun'
export * from './useAgents'
export * from './useAgentMcpClients'
export * from './useMcp'
export * from './useMemory'
Expand Down
95 changes: 95 additions & 0 deletions ui/src/api/hooks/useAgents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// hal0 v3 dashboard — bundled-agent + persona/skills catalogues.
//
// Backs the Agent surface (#207, #226, #227). Three read-only queries:
//
// - useAgents() → GET /api/agents
// - useAgentSkills() → GET /api/agents/skills
// - useAgentPersonaEnums() → GET /api/agents/persona-enums
//
// All catalogues are static for v0.3; ``staleTime`` is generous so the
// dashboard doesn't refetch on every tab switch. Re-fetch on
// invalidate (install/uninstall mutations) handles the dynamic case
// for the list endpoint.
//
// Note: the per-agent activity endpoint already lives in this surface
// (/api/agents/{name}/activity) but the Agent Inbox tab still reads
// from HAL0_DATA mock — wiring that lives in a separate follow-up.

import { useQuery } from '@tanstack/react-query'
import { apiGet } from '../client'
import { ENDPOINTS } from '../endpoints'

// ── /api/agents ──────────────────────────────────────────────────────

export interface AgentRecord {
name: string
shape: string
state: string
installed_at?: string | null
// Manager.as_dict() may attach more keys (driver paths, etc); the
// dashboard only cares about identity + state for v0.3.
[key: string]: unknown
}

export interface AgentsResponse {
agents: AgentRecord[]
count: number
}

export function useAgents() {
return useQuery<AgentsResponse>({
queryKey: ['agents', 'list'],
queryFn: () => apiGet<AgentsResponse>(ENDPOINTS.agents),
staleTime: 30_000,
})
}

// ── /api/agents/skills ───────────────────────────────────────────────

export interface AgentSkill {
name: string
cap: string
policy: 'always' | 'remember' | 'auto' | 'deny'
src: string
}

export interface AgentSkillsResponse {
skills: AgentSkill[]
count: number
}

export function useAgentSkills() {
return useQuery<AgentSkillsResponse>({
queryKey: ['agents', 'skills'],
queryFn: () => apiGet<AgentSkillsResponse>(ENDPOINTS.agentSkills),
staleTime: 5 * 60_000,
})
}

// ── /api/agents/persona-enums ────────────────────────────────────────

export interface PersonaTone {
id: string
label: string
desc: string
}

export interface PersonaTool {
id: string
label: string
cap: string
}

export interface PersonaEnumsResponse {
tones: PersonaTone[]
tools: PersonaTool[]
}

export function useAgentPersonaEnums(options?: { enabled?: boolean }) {
return useQuery<PersonaEnumsResponse>({
queryKey: ['agents', 'persona-enums'],
queryFn: () => apiGet<PersonaEnumsResponse>(ENDPOINTS.agentPersonaEnums),
staleTime: 5 * 60_000,
enabled: options?.enabled ?? true,
})
}
Loading
Loading