feat: support validated agent tools and grounded file changes
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { AgentService } from './agent.service';
|
||||
import { validateAgentTool, validateAgentOperation, type AgentToolResult } from './agent-tools';
|
||||
import { CreateAgentPlanDto } from './dto/create-agent-plan.dto';
|
||||
import type { AgentPlan } from './ai.types';
|
||||
|
||||
const hash = (content: string) => createHash('sha256').update(content).digest('hex');
|
||||
const plan = (extra: Partial<AgentPlan> = {}): AgentPlan => ({ summary: '요약', explanation: '설명', requestedModules: [], requestedLogs: [], requestedDocs: [], actionPlan: [], commands: [], changes: [], createModule: null, ...extra });
|
||||
const result = (extra: Partial<AgentToolResult> = {}): AgentToolResult => ({ id: 'query-1', name: 'files.read', moduleName: 'NAV2-Control', status: 'ok', observedAt: new Date().toISOString(), target: 'ssh:test', truncated: false, data: JSON.stringify({ path: 'config/control.yaml', hash: hash('steering: ackermann'), complete: true, content: 'steering: ackermann', exists: true }), ...extra });
|
||||
const dto: CreateAgentPlanDto = { courseId: 'a1b2c3d4-e5f6-4789-8123-123456789abc', request: '분석', toolProtocol: 1, environment: 'remote', modules: [{ name: 'NAV2-Control', parameters: ['sim_mode:=false'], files: [] }] };
|
||||
type Harness = {
|
||||
parsePlan(content: string, modules: Set<string>): AgentPlan;
|
||||
groundToolPlan(plan: AgentPlan, dto: CreateAgentPlanDto, results: AgentToolResult[]): AgentPlan;
|
||||
parseToolResults(results: unknown): AgentToolResult[];
|
||||
isUnexecutablePlan(plan: AgentPlan): boolean;
|
||||
parseSyncedMessage(message: Record<string, unknown>, index: number): { plan: AgentPlan };
|
||||
toStoredActions(message: unknown): Array<Record<string, unknown>>;
|
||||
toSessionResponse(session: unknown): { messages: Array<{ plan: AgentPlan }> };
|
||||
};
|
||||
const service = Object.create(AgentService.prototype) as Harness;
|
||||
|
||||
describe('structured agent tools', () => {
|
||||
it('validates tool protocol, observations and module parameters in the request DTO', async () => {
|
||||
const value = plainToInstance(CreateAgentPlanDto, { ...dto, toolResults: [result()] });
|
||||
expect(await validate(value, { whitelist: true, forbidNonWhitelisted: true })).toEqual([]);
|
||||
const invalid = plainToInstance(CreateAgentPlanDto, { ...dto, toolResults: [result({ name: 'files.write' as never })] });
|
||||
expect((await validate(invalid, { whitelist: true, forbidNonWhitelisted: true })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts precise source requests but rejects unsafe paths and arbitrary runtime arguments', () => {
|
||||
expect(validateAgentTool({ id: 'search', name: 'files.search', moduleName: 'NAV2-Control', args: { query: 'steering', path: 'src' } }).name).toBe('files.search');
|
||||
expect(() => validateAgentTool({ id: 'read', name: 'files.read', moduleName: 'NAV2-Control', args: { path: '../secret' } })).toThrow();
|
||||
expect(() => validateAgentTool({ id: 'read', name: 'network.inspect', moduleName: 'NAV2-Control', args: { command: 'reboot' } } as never)).toThrow();
|
||||
expect(() => validateAgentOperation({ name: 'modules.start', command: 'reboot' } as never)).toThrow();
|
||||
});
|
||||
|
||||
it('does not confuse a tool request with a plan-only response and rejects mixed reads and writes', () => {
|
||||
const parsed = service.parsePlan(JSON.stringify(plan({ actionPlan: ['설정 조회'], toolRequests: [{ id: 'search', name: 'files.search', moduleName: 'NAV2-Control', args: { query: 'steering' } }] })), new Set(['NAV2-Control']));
|
||||
expect(service.isUnexecutablePlan(parsed)).toBe(false);
|
||||
expect(() => service.parsePlan(JSON.stringify({ ...parsed, commands: [{ id: 'start', moduleName: 'NAV2-Control', purpose: '시작', operation: { name: 'modules.start' } }] }), new Set(['NAV2-Control']))).toThrow(/컨텍스트/);
|
||||
expect(() => service.parsePlan(JSON.stringify(plan({ toolRequests: [{ id: 'read', name: 'files.list', moduleName: 'Other' }] })), new Set(['NAV2-Control']))).toThrow(/존재하지/);
|
||||
});
|
||||
|
||||
it('generates the displayed command from validated operation metadata, not model shell text', () => {
|
||||
const parsed = service.parsePlan(JSON.stringify(plan({ commands: [{ id: 'build', moduleName: 'NAV2-Control', purpose: '빌드', command: 'sudo reboot', operation: { name: 'modules.build' } }] })), new Set(['NAV2-Control']));
|
||||
expect(parsed.commands[0].command).toBe('lms modules.build');
|
||||
expect(service.groundToolPlan(parsed, dto, []).commands[0].operation?.name).toBe('modules.build');
|
||||
expect(() => service.groundToolPlan(plan({ commands: [{ id: 'shell', moduleName: 'NAV2-Control', purpose: '셸', command: './run.sh' }] }), dto, [])).toThrow(/구조화된/);
|
||||
});
|
||||
|
||||
it('requires a complete successful original read and replaces any model-supplied hash', () => {
|
||||
const change = { moduleName: 'NAV2-Control', path: 'config/control.yaml', content: 'steering: tricycle', reason: '설정 수정', expectedHash: '0'.repeat(64) };
|
||||
expect(service.groundToolPlan(plan({ changes: [change] }), dto, [result()]).changes[0].expectedHash).toBe(hash('steering: ackermann'));
|
||||
for (const evidence of [[], [result({ status: 'error' })], [result({ truncated: true })], [result({ data: JSON.stringify({ path: change.path, hash: null, complete: false }) })]]) {
|
||||
expect(() => service.groundToolPlan(plan({ changes: [change] }), dto, evidence)).toThrow(/파일 전체/);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows a new file only with explicit missing-file evidence and protects redacted secrets', () => {
|
||||
const change = { moduleName: 'NAV2-Control', path: 'config/new.yaml', content: 'enabled: true', reason: '새 설정' };
|
||||
const evidence = result({ data: JSON.stringify({ path: change.path, hash: null, exists: false, complete: true, content: '' }) });
|
||||
expect(service.groundToolPlan(plan({ changes: [change] }), dto, [evidence]).changes[0].expectedHash).toBeNull();
|
||||
expect(() => service.groundToolPlan(plan({ changes: [{ ...change, content: 'password: [REDACTED]' }] }), dto, [evidence])).toThrow(/비밀/);
|
||||
});
|
||||
|
||||
it('redacts observations without corrupting JSON and enforces aggregate budgets', () => {
|
||||
const parsed = service.parseToolResults([result({ data: JSON.stringify({ password: 'private', logs: 'API_KEY=secret-value\nready' }) })]);
|
||||
expect(JSON.parse(parsed[0].data)).toEqual({ password: '[REDACTED]', logs: 'API_KEY=[REDACTED]\nready' });
|
||||
expect(() => service.parseToolResults([result({ data: 'invalid' })])).toThrow(/JSON/);
|
||||
expect(() => service.parseToolResults(Array.from({ length: 4 }, (_, index) => result({ id: `r-${index}`, data: JSON.stringify('x'.repeat(50_000)) })))).toThrow(/용량/);
|
||||
});
|
||||
|
||||
it('persists and restores operation metadata, file hashes and read evidence in existing action payloads', () => {
|
||||
const message = service.parseSyncedMessage({ id: 'agent-1', role: 'assistant', content: '요약', plan: plan({
|
||||
commands: [{ id: 'build', moduleName: 'NAV2-Control', command: 'lms modules.build', purpose: '빌드', operation: { name: 'modules.build' } }],
|
||||
changes: [{ moduleName: 'NAV2-Control', path: 'config/control.yaml', content: 'changed', reason: '수정', expectedHash: hash('old') }],
|
||||
evidence: [result()],
|
||||
}) }, 0);
|
||||
const actions = service.toStoredActions(message);
|
||||
expect(actions.find(action => action.type === 'COMMAND')?.payload).toEqual({ operation: { name: 'modules.build' } });
|
||||
expect(actions.find(action => action.type === 'FILE_CHANGE')?.payload).toEqual({ expectedHash: hash('old') });
|
||||
expect(actions.find(action => action.type === 'TOOL_RESULT')?.payload).toMatchObject({ name: 'files.read', target: 'ssh:test' });
|
||||
const restored = service.toSessionResponse({ id: 'session', messages: [{ clientMessageId: 'agent-1', role: 'assistant', content: '요약', summary: '요약', explanation: '설명', actionPlan: [], actions }] }).messages[0].plan;
|
||||
expect(restored.commands[0].operation?.name).toBe('modules.build');
|
||||
expect(restored.changes[0].expectedHash).toBe(hash('old'));
|
||||
expect(restored.evidence?.[0].name).toBe('files.read');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user