Refactor AI chat: streaming, tool calls, UI polish
All checks were successful
CI / check (pull_request) Successful in 3m25s
All checks were successful
CI / check (pull_request) Successful in 3m25s
This commit is contained in:
@@ -2,9 +2,16 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
|
||||
import { AuthorizationService } from '../authorization';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { AGENT_SKILLS } from './agent-skill.catalog';
|
||||
import { AgentToolRegistry } from './agent-tool.registry';
|
||||
import { AgentToolContextFactory } from './agent-tool.types';
|
||||
import type { AgentToolContext, ToolExecutionResult, ToolStatus, ToolDescriptor } from './agent-tool.types';
|
||||
import type {
|
||||
AgentSkillDescriptor,
|
||||
AgentToolContext,
|
||||
ToolDescriptor,
|
||||
ToolExecutionResult,
|
||||
ToolStatus,
|
||||
} from './agent-tool.types';
|
||||
|
||||
/** Safe tool name: alphanumeric + underscore, max 64 chars. */
|
||||
const TOOL_NAME_RE = /^[a-zA-Z0-9_]+$/;
|
||||
@@ -60,7 +67,7 @@ export class AgentToolExecutor {
|
||||
* @param context — trusted context from
|
||||
* {@link AgentToolContextFactory.fromAuthenticatedUser}.
|
||||
*/
|
||||
listAvailable(context: AgentToolContext): ToolDescriptor[] {
|
||||
listAvailable(context: AgentToolContext, skillKey?: string | null): ToolDescriptor[] {
|
||||
AgentToolContextFactory.assertTrusted(context);
|
||||
|
||||
const ability = this.abilityFactory.createForUser({
|
||||
@@ -70,13 +77,25 @@ export class AgentToolExecutor {
|
||||
|
||||
return this.registry
|
||||
.listAvailableInternal(ability)
|
||||
.map(({ name, description, inputSchema }) => ({
|
||||
.filter((tool) => !skillKey || tool.skillKey === skillKey)
|
||||
.map(({ name, skillKey: toolSkillKey, description, inputSchema }) => ({
|
||||
name,
|
||||
skillKey: toolSkillKey,
|
||||
description,
|
||||
...(inputSchema ? { inputSchema } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
listSkills(context: AgentToolContext): AgentSkillDescriptor[] {
|
||||
const tools = this.listAvailable(context);
|
||||
return AGENT_SKILLS.map((skill) => ({
|
||||
...skill,
|
||||
tools: tools
|
||||
.filter((tool) => tool.skillKey === skill.key)
|
||||
.map(({ name, description }) => ({ name, description })),
|
||||
})).filter((skill) => skill.tools.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool by name.
|
||||
*
|
||||
@@ -89,6 +108,7 @@ export class AgentToolExecutor {
|
||||
name: string,
|
||||
rawInput: unknown,
|
||||
context: AgentToolContext,
|
||||
allowedSkillKey?: string | null,
|
||||
): Promise<ToolExecutionResult> {
|
||||
// 0. Context trust validation — must be first
|
||||
try {
|
||||
@@ -111,6 +131,17 @@ export class AgentToolExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
if (allowedSkillKey && tool.skillKey !== allowedSkillKey) {
|
||||
return this.auditAndReturn(
|
||||
safeName,
|
||||
'denied',
|
||||
undefined,
|
||||
SAFE_MESSAGES.permissionDenied,
|
||||
context,
|
||||
tool.skillKey,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Build ability from principal fields — never trust a pre-built one
|
||||
const ability = this.abilityFactory.createForUser({
|
||||
permissions: context.permissions,
|
||||
@@ -125,6 +156,7 @@ export class AgentToolExecutor {
|
||||
undefined,
|
||||
SAFE_MESSAGES.permissionDenied,
|
||||
context,
|
||||
tool.skillKey,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,6 +168,7 @@ export class AgentToolExecutor {
|
||||
undefined,
|
||||
SAFE_MESSAGES.invalidInput,
|
||||
context,
|
||||
tool.skillKey,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,6 +183,7 @@ export class AgentToolExecutor {
|
||||
undefined,
|
||||
SAFE_MESSAGES.invalidInput,
|
||||
context,
|
||||
tool.skillKey,
|
||||
);
|
||||
}
|
||||
if (!parsed.ok) {
|
||||
@@ -159,13 +193,21 @@ export class AgentToolExecutor {
|
||||
undefined,
|
||||
SAFE_MESSAGES.invalidInput,
|
||||
context,
|
||||
tool.skillKey,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Execute
|
||||
try {
|
||||
const result = await tool.execute(parsed.value, context);
|
||||
return this.auditAndReturn(safeName, 'success', result, undefined, context);
|
||||
return this.auditAndReturn(
|
||||
safeName,
|
||||
'success',
|
||||
result,
|
||||
undefined,
|
||||
context,
|
||||
tool.skillKey,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
// NotFoundException → not_found with safe message
|
||||
if (err instanceof NotFoundException) {
|
||||
@@ -175,6 +217,7 @@ export class AgentToolExecutor {
|
||||
undefined,
|
||||
SAFE_MESSAGES.notFound,
|
||||
context,
|
||||
tool.skillKey,
|
||||
);
|
||||
}
|
||||
// All other errors → generic failed message
|
||||
@@ -184,6 +227,7 @@ export class AgentToolExecutor {
|
||||
undefined,
|
||||
SAFE_MESSAGES.executionFailed,
|
||||
context,
|
||||
tool.skillKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -213,6 +257,7 @@ export class AgentToolExecutor {
|
||||
result: unknown,
|
||||
error: string | undefined,
|
||||
context: AgentToolContext,
|
||||
skillKey?: string,
|
||||
): Promise<ToolExecutionResult> {
|
||||
// Await audit (best-effort — failure is silently swallowed)
|
||||
try {
|
||||
@@ -228,7 +273,7 @@ export class AgentToolExecutor {
|
||||
// Swallow — audit failure must not break the tool call
|
||||
}
|
||||
|
||||
return { status, toolName, result, error };
|
||||
return { status, toolName, skillKey, result, error };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user