308 lines
8.8 KiB
TypeScript
308 lines
8.8 KiB
TypeScript
/**
|
|
* High-level Claude client for agent interactions
|
|
*
|
|
* Uses the Claude Agent SDK for native TypeScript integration.
|
|
*/
|
|
|
|
import { executeClaudeCli } from './process.js';
|
|
import type { ClaudeSpawnOptions, ClaudeCallOptions } from './types.js';
|
|
import type { AgentResponse, Status } from '../../core/models/index.js';
|
|
import { createLogger } from '../../shared/utils/index.js';
|
|
import { loadTemplate } from '../../shared/prompts/index.js';
|
|
|
|
// Re-export for backward compatibility
|
|
export type { ClaudeCallOptions } from './types.js';
|
|
|
|
const log = createLogger('client');
|
|
|
|
/**
|
|
* Detect rule index from numbered tag pattern [STEP_NAME:N].
|
|
* Returns 0-based rule index, or -1 if no match.
|
|
*
|
|
* Example: detectRuleIndex("... [PLAN:2] ...", "plan") → 1
|
|
*/
|
|
export function detectRuleIndex(content: string, movementName: string): number {
|
|
const tag = movementName.toUpperCase();
|
|
const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi');
|
|
const matches = [...content.matchAll(regex)];
|
|
const match = matches.at(-1);
|
|
if (match?.[1]) {
|
|
const index = Number.parseInt(match[1], 10) - 1;
|
|
return index >= 0 ? index : -1;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/** Validate regex pattern for ReDoS safety */
|
|
export function isRegexSafe(pattern: string): boolean {
|
|
if (pattern.length > 200) {
|
|
return false;
|
|
}
|
|
|
|
const dangerousPatterns = [
|
|
/\(\.\*\)\+/, // (.*)+
|
|
/\(\.\+\)\*/, // (.+)*
|
|
/\(\.\*\)\*/, // (.*)*
|
|
/\(\.\+\)\+/, // (.+)+
|
|
/\([^)]*\|[^)]*\)\+/, // (a|b)+
|
|
/\([^)]*\|[^)]*\)\*/, // (a|b)*
|
|
];
|
|
|
|
for (const dangerous of dangerousPatterns) {
|
|
if (dangerous.test(pattern)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* High-level Claude client for calling Claude with various configurations.
|
|
*
|
|
* Handles agent prompts, custom agents, skills, and AI judge evaluation.
|
|
*/
|
|
export class ClaudeClient {
|
|
/** Determine status from execution result */
|
|
private static determineStatus(
|
|
result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string },
|
|
): Status {
|
|
if (!result.success) {
|
|
if (result.interrupted) {
|
|
return 'interrupted';
|
|
}
|
|
return 'blocked';
|
|
}
|
|
return 'done';
|
|
}
|
|
|
|
/** Convert ClaudeCallOptions to ClaudeSpawnOptions */
|
|
private static toSpawnOptions(options: ClaudeCallOptions): ClaudeSpawnOptions {
|
|
return {
|
|
cwd: options.cwd,
|
|
sessionId: options.sessionId,
|
|
allowedTools: options.allowedTools,
|
|
model: options.model,
|
|
maxTurns: options.maxTurns,
|
|
systemPrompt: options.systemPrompt,
|
|
agents: options.agents,
|
|
permissionMode: options.permissionMode,
|
|
onStream: options.onStream,
|
|
onPermissionRequest: options.onPermissionRequest,
|
|
onAskUserQuestion: options.onAskUserQuestion,
|
|
bypassPermissions: options.bypassPermissions,
|
|
anthropicApiKey: options.anthropicApiKey,
|
|
};
|
|
}
|
|
|
|
/** Call Claude with an agent prompt */
|
|
async call(
|
|
agentType: string,
|
|
prompt: string,
|
|
options: ClaudeCallOptions,
|
|
): Promise<AgentResponse> {
|
|
const spawnOptions = ClaudeClient.toSpawnOptions(options);
|
|
const result = await executeClaudeCli(prompt, spawnOptions);
|
|
const status = ClaudeClient.determineStatus(result);
|
|
|
|
if (!result.success && result.error) {
|
|
log.error('Agent query failed', { agent: agentType, error: result.error });
|
|
}
|
|
|
|
return {
|
|
agent: agentType,
|
|
status,
|
|
content: result.content,
|
|
timestamp: new Date(),
|
|
sessionId: result.sessionId,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
/** Call Claude with a custom agent configuration */
|
|
async callCustom(
|
|
agentName: string,
|
|
prompt: string,
|
|
systemPrompt: string,
|
|
options: ClaudeCallOptions,
|
|
): Promise<AgentResponse> {
|
|
const spawnOptions: ClaudeSpawnOptions = {
|
|
...ClaudeClient.toSpawnOptions(options),
|
|
systemPrompt,
|
|
};
|
|
const result = await executeClaudeCli(prompt, spawnOptions);
|
|
const status = ClaudeClient.determineStatus(result);
|
|
|
|
if (!result.success && result.error) {
|
|
log.error('Agent query failed', { agent: agentName, error: result.error });
|
|
}
|
|
|
|
return {
|
|
agent: agentName,
|
|
status,
|
|
content: result.content,
|
|
timestamp: new Date(),
|
|
sessionId: result.sessionId,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
/** Call a Claude Code built-in agent */
|
|
async callAgent(
|
|
claudeAgentName: string,
|
|
prompt: string,
|
|
options: ClaudeCallOptions,
|
|
): Promise<AgentResponse> {
|
|
const systemPrompt = loadTemplate('perform_builtin_agent_system_prompt', 'en', { agentName: claudeAgentName });
|
|
return this.callCustom(claudeAgentName, prompt, systemPrompt, options);
|
|
}
|
|
|
|
/** Call a Claude Code skill (using /skill command) */
|
|
async callSkill(
|
|
skillName: string,
|
|
prompt: string,
|
|
options: ClaudeCallOptions,
|
|
): Promise<AgentResponse> {
|
|
const fullPrompt = `/${skillName}\n\n${prompt}`;
|
|
const spawnOptions: ClaudeSpawnOptions = {
|
|
cwd: options.cwd,
|
|
sessionId: options.sessionId,
|
|
allowedTools: options.allowedTools,
|
|
model: options.model,
|
|
maxTurns: options.maxTurns,
|
|
permissionMode: options.permissionMode,
|
|
onStream: options.onStream,
|
|
onPermissionRequest: options.onPermissionRequest,
|
|
onAskUserQuestion: options.onAskUserQuestion,
|
|
bypassPermissions: options.bypassPermissions,
|
|
anthropicApiKey: options.anthropicApiKey,
|
|
};
|
|
|
|
const result = await executeClaudeCli(fullPrompt, spawnOptions);
|
|
|
|
if (!result.success && result.error) {
|
|
log.error('Skill query failed', { skill: skillName, error: result.error });
|
|
}
|
|
|
|
return {
|
|
agent: `skill:${skillName}`,
|
|
status: result.success ? 'done' : 'blocked',
|
|
content: result.content,
|
|
timestamp: new Date(),
|
|
sessionId: result.sessionId,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Detect judge rule index from [JUDGE:N] tag pattern.
|
|
* Returns 0-based rule index, or -1 if no match.
|
|
*/
|
|
static detectJudgeIndex(content: string): number {
|
|
const regex = /\[JUDGE:(\d+)\]/i;
|
|
const match = content.match(regex);
|
|
if (match?.[1]) {
|
|
const index = Number.parseInt(match[1], 10) - 1;
|
|
return index >= 0 ? index : -1;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Build the prompt for the AI judge that evaluates agent output against ai() conditions.
|
|
*/
|
|
static buildJudgePrompt(
|
|
agentOutput: string,
|
|
aiConditions: { index: number; text: string }[],
|
|
): string {
|
|
const conditionList = aiConditions
|
|
.map((c) => `| ${c.index + 1} | ${c.text} |`)
|
|
.join('\n');
|
|
|
|
return loadTemplate('perform_judge_message', 'en', { agentOutput, conditionList });
|
|
}
|
|
|
|
/**
|
|
* Call AI judge to evaluate agent output against ai() conditions.
|
|
* Uses a lightweight model (haiku) for cost efficiency.
|
|
* Returns 0-based index of the matched ai() condition, or -1 if no match.
|
|
*/
|
|
async callAiJudge(
|
|
agentOutput: string,
|
|
aiConditions: { index: number; text: string }[],
|
|
options: { cwd: string },
|
|
): Promise<number> {
|
|
const prompt = ClaudeClient.buildJudgePrompt(agentOutput, aiConditions);
|
|
|
|
const spawnOptions: ClaudeSpawnOptions = {
|
|
cwd: options.cwd,
|
|
model: 'haiku',
|
|
maxTurns: 1,
|
|
};
|
|
|
|
const result = await executeClaudeCli(prompt, spawnOptions);
|
|
if (!result.success) {
|
|
log.error('AI judge call failed', { error: result.error });
|
|
return -1;
|
|
}
|
|
|
|
return ClaudeClient.detectJudgeIndex(result.content);
|
|
}
|
|
}
|
|
|
|
// ---- Backward-compatible module-level functions ----
|
|
|
|
const defaultClient = new ClaudeClient();
|
|
|
|
export async function callClaude(
|
|
agentType: string,
|
|
prompt: string,
|
|
options: ClaudeCallOptions,
|
|
): Promise<AgentResponse> {
|
|
return defaultClient.call(agentType, prompt, options);
|
|
}
|
|
|
|
export async function callClaudeCustom(
|
|
agentName: string,
|
|
prompt: string,
|
|
systemPrompt: string,
|
|
options: ClaudeCallOptions,
|
|
): Promise<AgentResponse> {
|
|
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
|
|
}
|
|
|
|
export async function callClaudeAgent(
|
|
claudeAgentName: string,
|
|
prompt: string,
|
|
options: ClaudeCallOptions,
|
|
): Promise<AgentResponse> {
|
|
return defaultClient.callAgent(claudeAgentName, prompt, options);
|
|
}
|
|
|
|
export async function callClaudeSkill(
|
|
skillName: string,
|
|
prompt: string,
|
|
options: ClaudeCallOptions,
|
|
): Promise<AgentResponse> {
|
|
return defaultClient.callSkill(skillName, prompt, options);
|
|
}
|
|
|
|
export function detectJudgeIndex(content: string): number {
|
|
return ClaudeClient.detectJudgeIndex(content);
|
|
}
|
|
|
|
export function buildJudgePrompt(
|
|
agentOutput: string,
|
|
aiConditions: { index: number; text: string }[],
|
|
): string {
|
|
return ClaudeClient.buildJudgePrompt(agentOutput, aiConditions);
|
|
}
|
|
|
|
export async function callAiJudge(
|
|
agentOutput: string,
|
|
aiConditions: { index: number; text: string }[],
|
|
options: { cwd: string },
|
|
): Promise<number> {
|
|
return defaultClient.callAiJudge(agentOutput, aiConditions, options);
|
|
}
|