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
@@ -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');
});
});