feat: add governed chat agent selection
This commit is contained in:
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user