feat: add governed chat agent selection
This commit is contained in:
@@ -108,6 +108,19 @@ export interface ChatAnswer {
|
||||
};
|
||||
action?: { id: string; label: string; description: string } | null;
|
||||
action_gate?: { outcome?: string; reason?: string; exit_code?: number };
|
||||
agent_binding?: {
|
||||
success: boolean;
|
||||
decision: string;
|
||||
reason: string;
|
||||
agent_selected: string;
|
||||
skill_selected: string;
|
||||
delegation_level: number;
|
||||
model_role?: string;
|
||||
mode?: string;
|
||||
tool_allowlist: string[];
|
||||
requires_approval: boolean;
|
||||
audit?: { record_hash?: string; head?: string; seq?: number };
|
||||
};
|
||||
actor: SettingsActor;
|
||||
}
|
||||
|
||||
@@ -118,6 +131,19 @@ export interface ChatAction {
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
export interface ChatAgent {
|
||||
id: string;
|
||||
label: string;
|
||||
mode: string;
|
||||
model_role: string;
|
||||
roles_allowed: string[];
|
||||
skills_allowed: string[];
|
||||
tool_allowlist: string[];
|
||||
max_delegation_level: number;
|
||||
requires_approval?: boolean;
|
||||
allowed_for_role?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
actor: SettingsActor;
|
||||
capabilities: {
|
||||
@@ -164,10 +190,11 @@ export const api = {
|
||||
post<{ proposal: any; audit_verify: { ok: boolean; output: string } }>('approvals/submit', body, actorHeaders(actor)),
|
||||
decideApproval: (actor: SettingsActor, body: { id: string; decision: 'approve' | 'reject'; reason: string }) =>
|
||||
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
|
||||
askChat: (actor: SettingsActor, body: { message: string; chatId?: string }) =>
|
||||
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number }) =>
|
||||
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
|
||||
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
|
||||
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
|
||||
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
|
||||
};
|
||||
|
||||
// Health is raw (not enveloped) + carries HTTP status.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, ChatAction, ChatAnswer, SettingsActor } from '../lib/api';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
|
||||
@@ -26,6 +26,9 @@ export function Chat() {
|
||||
tenant: 'default',
|
||||
});
|
||||
const [chatId, setChatId] = useState('chat-default');
|
||||
const [agentId, setAgentId] = useState('evidence-reader');
|
||||
const [skillId, setSkillId] = useState('evidence-summary');
|
||||
const [delegationLevel, setDelegationLevel] = useState(0);
|
||||
const [message, setMessage] = useState('Summarize Plan 18 MVP-0 status');
|
||||
const [last, setLast] = useState<ChatAnswer | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -40,9 +43,25 @@ export function Chat() {
|
||||
queryFn: () => api.chatActions(actor),
|
||||
retry: false,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ['chat-agents', actor],
|
||||
queryFn: () => api.chatAgents(actor),
|
||||
retry: false,
|
||||
});
|
||||
const agents = agentsQuery.data?.agents ?? [];
|
||||
const selectedAgent = agents.find((a) => a.id === agentId) ?? agents.find((a) => a.allowed_for_role) ?? agents[0];
|
||||
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId)
|
||||
? skillId
|
||||
: (selectedAgent?.skills_allowed[0] ?? '');
|
||||
|
||||
const ask = useMutation({
|
||||
mutationFn: (override?: { message?: string }) => api.askChat(actor, { message: override?.message ?? message, chatId }),
|
||||
mutationFn: (override?: { message?: string; agentId?: string; skillId?: string }) => api.askChat(actor, {
|
||||
message: override?.message ?? message,
|
||||
chatId,
|
||||
agentId: override?.agentId ?? selectedAgent?.id ?? agentId,
|
||||
skillId: override?.skillId ?? selectedSkill,
|
||||
delegationLevel,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLast(res);
|
||||
setError(null);
|
||||
@@ -111,6 +130,33 @@ export function Chat() {
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2 xl:col-span-1">
|
||||
<span className="text-gray-500">Agent</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedAgent?.id ?? agentId}
|
||||
onChange={(e) => {
|
||||
const next = agents.find((a) => a.id === e.target.value);
|
||||
setAgentId(e.target.value);
|
||||
setSkillId(next?.skills_allowed[0] ?? '');
|
||||
}}>
|
||||
{agents.map((a: ChatAgent) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.label}{a.allowed_for_role ? '' : ' (locked)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Skill</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedSkill}
|
||||
onChange={(e) => setSkillId(e.target.value)}>
|
||||
{(selectedAgent?.skills_allowed ?? []).map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Delegation</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" type="number" min={0} max={5}
|
||||
value={delegationLevel} onChange={(e) => setDelegationLevel(Number(e.target.value || 0))} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -125,6 +171,8 @@ export function Chat() {
|
||||
<StatusBadge value={last.mode} />
|
||||
<StatusBadge value={last.risk} />
|
||||
<StatusBadge value={last.decision} />
|
||||
{last.agent_binding && <StatusBadge value={last.agent_binding.agent_selected} />}
|
||||
{last.agent_binding && <StatusBadge value={`L${last.agent_binding.delegation_level}`} />}
|
||||
<StatusBadge value={last.audit_verify.ok ? 'audit ok' : 'audit fail'} />
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-6 text-gray-800">{last.answer}</div>
|
||||
@@ -174,6 +222,18 @@ export function Chat() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{last.agent_binding && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Agent binding</div>
|
||||
<div className="mt-1 font-medium text-gray-800">{last.agent_binding.agent_selected}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{last.agent_binding.skill_selected || 'no skill'}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.agent_binding.decision} />
|
||||
<StatusBadge value={last.agent_binding.model_role ?? 'model'} />
|
||||
<StatusBadge value={`tools ${last.agent_binding.tool_allowlist.length}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Matched rules</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
@@ -202,7 +262,7 @@ export function Chat() {
|
||||
disabled={ask.isPending}
|
||||
onClick={() => {
|
||||
setMessage(trigger);
|
||||
ask.mutate({ message: trigger });
|
||||
ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' });
|
||||
}}
|
||||
className="text-left rounded border border-gray-200 p-3 hover:border-blue-300 hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user