feat: add governed chat agent selection

This commit is contained in:
thanhnv
2026-07-08 22:19:51 +09:00
parent 06d8d31b16
commit 004afa73c9
18 changed files with 677 additions and 39 deletions
+7 -4
View File
@@ -56,7 +56,7 @@ Approval inbox / HITL:
- `POST /api/v1/approvals/decide` — approve/reject with SoD and reason; approved
settings proposals apply through `control-plane-settings.py`.
Governed Chat (Plan-18 MVP-0/1):
Governed Chat (Plan-18 MVP-0/1 + MVP-2 Track 4 foundation):
- `POST /api/v1/chat/ask` — Ask CASAN endpoint. The API only wraps harness
`chat-turn.py`; router verdicts, H4 input/output scan, action-gate decisions,
@@ -64,11 +64,14 @@ Governed Chat (Plan-18 MVP-0/1):
execution remain harness-owned.
- `GET /api/v1/chat/actions` — list registered operator actions from
`operator-actions.yaml`; no free-command execution is exposed.
- `GET /api/v1/chat/agents` — list governed agents from `agent-registry.yaml`
with role visibility; selected agent/skill/delegation are bound by harness
`chat-agent-resolver.py`.
- `GET /api/v1/chat/audit/verify` — verifies the chat audit hash chain.
- `/chat` UI shows actor/role scope, `mode/risk/decision` badges, certified answer,
evidence sources, registered operator actions, action-gate status, router details,
and audit hash. Side-effect requests outside registered actions return governed
`BLOCK` or `NOT_SUPPORTED` responses.
evidence sources, registered operator actions, agent binding, action-gate status,
router details, and audit hash. Side-effect requests outside registered actions
return governed `BLOCK` or `NOT_SUPPORTED` responses.
FinOps/SLO:
@@ -21,4 +21,9 @@ export class ChatController {
actions(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.listActions(actorFromHeaders(headers)));
}
@Get('agents')
agents(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.listAgents(actorFromHeaders(headers)));
}
}
@@ -7,6 +7,9 @@ import type { SettingsActor } from '../settings/settings.service.js';
export interface ChatAskInput {
message: string;
chatId?: string;
agentId?: string;
skillId?: string;
delegationLevel?: number;
}
interface CommandResult {
@@ -18,6 +21,7 @@ interface CommandResult {
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
const CHAT_CLI = join(HARNESS_BIN, 'chat-turn.py');
const OPERATOR_CLI = join(HARNESS_BIN, 'chat-operator.py');
const AGENT_CLI = join(HARNESS_BIN, 'chat-agent-resolver.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
function runPython(script: string, args: string[]): CommandResult {
@@ -55,17 +59,25 @@ export class ChatService {
}
this.requireRead(actor);
const res = runPython(CHAT_CLI, [
const args = [
'ask',
'--message',
input.message,
'--actor',
actor.actor,
'--role',
actor.role,
'--project',
actor.project,
'--chat-id',
input.chatId || 'chat-default',
'--tenant',
actor.tenant,
]);
];
if (input.agentId) args.push('--agent', input.agentId);
if (input.skillId) args.push('--skill', input.skillId);
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
const res = runPython(CHAT_CLI, args);
const parsed = parseJson<Record<string, any>>(res.stdout);
if (parsed) {
return { ...parsed, actor, audit_verify: this.verifyAudit() };
@@ -89,6 +101,14 @@ export class ChatService {
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_OPERATOR_ACTIONS_FAILED');
}
listAgents(actor: SettingsActor) {
this.requireRead(actor);
const res = runPython(AGENT_CLI, ['list-agents', '--role', actor.role]);
const parsed = parseJson<Record<string, any>>(res.stdout);
if (parsed) return parsed;
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_AGENTS_FAILED');
}
private requireRead(actor: SettingsActor) {
const res = runPython(RBAC_CLI, [
'check',
@@ -6,6 +6,7 @@ import { join } from 'node:path';
import { ChatService } from '../src/chat/chat.service.js';
const viewer = { actor: 'chat-viewer', role: 'viewer', project: 'default', tenant: 'default' };
const operator = { actor: 'chat-operator', role: 'operator', project: 'default', tenant: 'default' };
function withTempChatState(fn: () => void) {
const saved = {
@@ -66,15 +67,38 @@ test('chat ask marks side-effect requests unsupported in MVP-0', () => {
test('chat ask executes registered operator action through action-gate', () => {
withTempChatState(() => {
const svc = new ChatService();
const actions = svc.listActions(viewer) as any;
const actions = svc.listActions(operator) as any;
assert.ok(actions.actions.some((a: any) => a.id === 'run-chat-tests'));
const agents = svc.listAgents(operator) as any;
assert.ok(agents.agents.some((a: any) => a.id === 'ops-operator' && a.allowed_for_role === true));
const res = svc.ask({ message: 'run tests', chatId: 'operator-chat' }, viewer) as any;
const res = svc.ask({
message: 'run tests',
chatId: 'operator-chat',
agentId: 'ops-operator',
skillId: 'registered-actions',
}, operator) as any;
assert.equal(res.success, true);
assert.equal(res.mode, 'OPERATOR');
assert.equal(res.decision, 'ACTION_COMPLETED');
assert.equal(res.action.id, 'run-chat-tests');
assert.equal(res.action_gate.outcome, 'ALLOW');
assert.equal(res.agent_binding.agent_selected, 'ops-operator');
assert.equal(res.agent_binding.skill_selected, 'registered-actions');
assert.equal(res.audit_verify.ok, true);
});
});
test('chat ask denies selected agent outside actor role', () => {
withTempChatState(() => {
const svc = new ChatService();
const agents = svc.listAgents(viewer) as any;
assert.ok(agents.agents.some((a: any) => a.id === 'ops-operator' && a.allowed_for_role === false));
const res = svc.ask({ message: 'run tests', agentId: 'ops-operator' }, viewer) as any;
assert.equal(res.success, false);
assert.equal(res.decision, 'DENIED');
assert.equal(res.agent_selected, 'ops-operator');
assert.equal(res.reason, 'role_not_allowed_for_agent');
});
});
@@ -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"
>