takt: github-issue-125-claude-agent (#133)
This commit is contained in:
parent
d9ab76f08b
commit
ffc151cd8d
@ -447,6 +447,131 @@ movements:
|
||||
});
|
||||
});
|
||||
|
||||
describe('Piece Loader IT: mcp_servers parsing', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = createTestDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should parse mcp_servers from YAML to PieceMovement.mcpServers', () => {
|
||||
const piecesDir = join(testDir, '.takt', 'pieces');
|
||||
mkdirSync(piecesDir, { recursive: true });
|
||||
|
||||
writeFileSync(join(piecesDir, 'with-mcp.yaml'), `
|
||||
name: with-mcp
|
||||
description: Piece with MCP servers
|
||||
max_iterations: 5
|
||||
initial_movement: e2e-test
|
||||
|
||||
movements:
|
||||
- name: e2e-test
|
||||
persona: coder
|
||||
mcp_servers:
|
||||
playwright:
|
||||
command: npx
|
||||
args: ["-y", "@anthropic-ai/mcp-server-playwright"]
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Bash
|
||||
- mcp__playwright__*
|
||||
rules:
|
||||
- condition: Done
|
||||
next: COMPLETE
|
||||
instruction: "Run E2E tests"
|
||||
`);
|
||||
|
||||
const config = loadPiece('with-mcp', testDir);
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
const e2eStep = config!.movements.find((s) => s.name === 'e2e-test');
|
||||
expect(e2eStep).toBeDefined();
|
||||
expect(e2eStep!.mcpServers).toEqual({
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow movement without mcp_servers', () => {
|
||||
const piecesDir = join(testDir, '.takt', 'pieces');
|
||||
mkdirSync(piecesDir, { recursive: true });
|
||||
|
||||
writeFileSync(join(piecesDir, 'no-mcp.yaml'), `
|
||||
name: no-mcp
|
||||
description: Piece without MCP servers
|
||||
max_iterations: 5
|
||||
initial_movement: implement
|
||||
|
||||
movements:
|
||||
- name: implement
|
||||
persona: coder
|
||||
rules:
|
||||
- condition: Done
|
||||
next: COMPLETE
|
||||
instruction: "Implement the feature"
|
||||
`);
|
||||
|
||||
const config = loadPiece('no-mcp', testDir);
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
const implementStep = config!.movements.find((s) => s.name === 'implement');
|
||||
expect(implementStep).toBeDefined();
|
||||
expect(implementStep!.mcpServers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse mcp_servers with multiple servers and transports', () => {
|
||||
const piecesDir = join(testDir, '.takt', 'pieces');
|
||||
mkdirSync(piecesDir, { recursive: true });
|
||||
|
||||
writeFileSync(join(piecesDir, 'multi-mcp.yaml'), `
|
||||
name: multi-mcp
|
||||
description: Piece with multiple MCP servers
|
||||
max_iterations: 5
|
||||
initial_movement: test
|
||||
|
||||
movements:
|
||||
- name: test
|
||||
persona: coder
|
||||
mcp_servers:
|
||||
playwright:
|
||||
command: npx
|
||||
args: ["-y", "@anthropic-ai/mcp-server-playwright"]
|
||||
remote-api:
|
||||
type: http
|
||||
url: http://localhost:3000/mcp
|
||||
headers:
|
||||
Authorization: "Bearer token123"
|
||||
rules:
|
||||
- condition: Done
|
||||
next: COMPLETE
|
||||
instruction: "Run tests"
|
||||
`);
|
||||
|
||||
const config = loadPiece('multi-mcp', testDir);
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
const testStep = config!.movements.find((s) => s.name === 'test');
|
||||
expect(testStep).toBeDefined();
|
||||
expect(testStep!.mcpServers).toEqual({
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
|
||||
},
|
||||
'remote-api': {
|
||||
type: 'http',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer token123' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Piece Loader IT: invalid YAML handling', () => {
|
||||
let testDir: string;
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
StatusSchema,
|
||||
PermissionModeSchema,
|
||||
PieceConfigRawSchema,
|
||||
McpServerConfigSchema,
|
||||
CustomAgentConfigSchema,
|
||||
GlobalConfigSchema,
|
||||
} from '../core/models/index.js';
|
||||
@ -143,6 +144,210 @@ describe('PieceConfigRawSchema', () => {
|
||||
|
||||
expect(() => PieceConfigRawSchema.parse(config)).toThrow();
|
||||
});
|
||||
|
||||
it('should parse movement with stdio mcp_servers', () => {
|
||||
const config = {
|
||||
name: 'test-piece',
|
||||
movements: [
|
||||
{
|
||||
name: 'e2e-test',
|
||||
persona: 'coder',
|
||||
mcp_servers: {
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
|
||||
},
|
||||
},
|
||||
allowed_tools: ['mcp__playwright__*'],
|
||||
instruction: '{task}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = PieceConfigRawSchema.parse(config);
|
||||
expect(result.movements![0]?.mcp_servers).toEqual({
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse movement with sse mcp_servers', () => {
|
||||
const config = {
|
||||
name: 'test-piece',
|
||||
movements: [
|
||||
{
|
||||
name: 'step1',
|
||||
persona: 'coder',
|
||||
mcp_servers: {
|
||||
remote: {
|
||||
type: 'sse',
|
||||
url: 'http://localhost:8080/sse',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
},
|
||||
instruction: '{task}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = PieceConfigRawSchema.parse(config);
|
||||
expect(result.movements![0]?.mcp_servers).toEqual({
|
||||
remote: {
|
||||
type: 'sse',
|
||||
url: 'http://localhost:8080/sse',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse movement with http mcp_servers', () => {
|
||||
const config = {
|
||||
name: 'test-piece',
|
||||
movements: [
|
||||
{
|
||||
name: 'step1',
|
||||
persona: 'coder',
|
||||
mcp_servers: {
|
||||
api: {
|
||||
type: 'http',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
},
|
||||
},
|
||||
instruction: '{task}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = PieceConfigRawSchema.parse(config);
|
||||
expect(result.movements![0]?.mcp_servers).toEqual({
|
||||
api: {
|
||||
type: 'http',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow omitting mcp_servers', () => {
|
||||
const config = {
|
||||
name: 'test-piece',
|
||||
movements: [
|
||||
{
|
||||
name: 'step1',
|
||||
persona: 'coder',
|
||||
instruction: '{task}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = PieceConfigRawSchema.parse(config);
|
||||
expect(result.movements![0]?.mcp_servers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject invalid mcp_servers (missing command for stdio)', () => {
|
||||
const config = {
|
||||
name: 'test-piece',
|
||||
movements: [
|
||||
{
|
||||
name: 'step1',
|
||||
persona: 'coder',
|
||||
mcp_servers: {
|
||||
broken: { args: ['--flag'] },
|
||||
},
|
||||
instruction: '{task}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => PieceConfigRawSchema.parse(config)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid mcp_servers (missing url for sse)', () => {
|
||||
const config = {
|
||||
name: 'test-piece',
|
||||
movements: [
|
||||
{
|
||||
name: 'step1',
|
||||
persona: 'coder',
|
||||
mcp_servers: {
|
||||
broken: { type: 'sse' },
|
||||
},
|
||||
instruction: '{task}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => PieceConfigRawSchema.parse(config)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpServerConfigSchema', () => {
|
||||
it('should parse stdio config', () => {
|
||||
const config = { command: 'npx', args: ['-y', 'some-server'], env: { NODE_ENV: 'test' } };
|
||||
const result = McpServerConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('should parse stdio config with command only', () => {
|
||||
const config = { command: 'mcp-server' };
|
||||
const result = McpServerConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('should parse stdio config with explicit type', () => {
|
||||
const config = { type: 'stdio' as const, command: 'npx', args: ['-y', 'some-server'] };
|
||||
const result = McpServerConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('should parse sse config', () => {
|
||||
const config = { type: 'sse' as const, url: 'http://localhost:8080/sse' };
|
||||
const result = McpServerConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('should parse sse config with headers', () => {
|
||||
const config = { type: 'sse' as const, url: 'http://example.com', headers: { 'X-Key': 'val' } };
|
||||
const result = McpServerConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('should parse http config', () => {
|
||||
const config = { type: 'http' as const, url: 'http://localhost:3000/mcp' };
|
||||
const result = McpServerConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('should parse http config with headers', () => {
|
||||
const config = { type: 'http' as const, url: 'http://example.com', headers: { Authorization: 'Bearer x' } };
|
||||
const result = McpServerConfigSchema.parse(config);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('should reject empty command for stdio', () => {
|
||||
expect(() => McpServerConfigSchema.parse({ command: '' })).toThrow();
|
||||
});
|
||||
|
||||
it('should reject missing url for sse', () => {
|
||||
expect(() => McpServerConfigSchema.parse({ type: 'sse' })).toThrow();
|
||||
});
|
||||
|
||||
it('should reject missing url for http', () => {
|
||||
expect(() => McpServerConfigSchema.parse({ type: 'http' })).toThrow();
|
||||
});
|
||||
|
||||
it('should reject empty url for sse', () => {
|
||||
expect(() => McpServerConfigSchema.parse({ type: 'sse', url: '' })).toThrow();
|
||||
});
|
||||
|
||||
it('should reject unknown type', () => {
|
||||
expect(() => McpServerConfigSchema.parse({ type: 'websocket', url: 'ws://localhost' })).toThrow();
|
||||
});
|
||||
|
||||
it('should reject empty object', () => {
|
||||
expect(() => McpServerConfigSchema.parse({})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CustomAgentConfigSchema', () => {
|
||||
|
||||
@ -3,9 +3,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SdkOptionsBuilder } from '../infra/claude/options-builder.js';
|
||||
import { SdkOptionsBuilder, buildSdkOptions } from '../infra/claude/options-builder.js';
|
||||
import { mapToCodexSandboxMode } from '../infra/codex/types.js';
|
||||
import type { PermissionMode } from '../core/models/index.js';
|
||||
import type { ClaudeSpawnOptions } from '../infra/claude/types.js';
|
||||
|
||||
describe('SdkOptionsBuilder.mapToSdkPermissionMode', () => {
|
||||
it('should map readonly to SDK default', () => {
|
||||
@ -52,3 +53,53 @@ describe('mapToCodexSandboxMode', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SdkOptionsBuilder.build() — mcpServers', () => {
|
||||
it('should include mcpServers in SDK options when provided', () => {
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
cwd: '/tmp/test',
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sdkOptions = buildSdkOptions(spawnOptions);
|
||||
expect(sdkOptions.mcpServers).toEqual({
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include mcpServers in SDK options when not provided', () => {
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
cwd: '/tmp/test',
|
||||
};
|
||||
|
||||
const sdkOptions = buildSdkOptions(spawnOptions);
|
||||
expect(sdkOptions).not.toHaveProperty('mcpServers');
|
||||
});
|
||||
|
||||
it('should include mcpServers alongside other options', () => {
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
cwd: '/tmp/test',
|
||||
allowedTools: ['Read', 'mcp__playwright__*'],
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
|
||||
},
|
||||
},
|
||||
permissionMode: 'edit',
|
||||
};
|
||||
|
||||
const sdkOptions = buildSdkOptions(spawnOptions);
|
||||
expect(sdkOptions.mcpServers).toBeDefined();
|
||||
expect(sdkOptions.allowedTools).toEqual(['Read', 'mcp__playwright__*']);
|
||||
expect(sdkOptions.permissionMode).toBe('acceptEdits');
|
||||
});
|
||||
});
|
||||
|
||||
@ -102,6 +102,7 @@ export class AgentRunner {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools ?? agentConfig?.allowedTools,
|
||||
mcpServers: options.mcpServers,
|
||||
maxTurns: options.maxTurns,
|
||||
model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/index.js';
|
||||
import type { PermissionMode, Language } from '../core/models/index.js';
|
||||
import type { PermissionMode, Language, McpServerConfig } from '../core/models/index.js';
|
||||
|
||||
export type { StreamCallback };
|
||||
|
||||
@ -17,6 +17,8 @@ export interface RunAgentOptions {
|
||||
personaPath?: string;
|
||||
/** Allowed tools for this agent run */
|
||||
allowedTools?: string[];
|
||||
/** MCP servers for this agent run */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
/** Maximum number of agentic turns */
|
||||
maxTurns?: number;
|
||||
/** Permission mode for tool execution (from piece step) */
|
||||
|
||||
@ -7,6 +7,7 @@ export type {
|
||||
OutputContractLabelPath,
|
||||
OutputContractItem,
|
||||
OutputContractEntry,
|
||||
McpServerConfig,
|
||||
AgentResponse,
|
||||
SessionState,
|
||||
PieceRule,
|
||||
|
||||
40
src/core/models/mcp-schemas.ts
Normal file
40
src/core/models/mcp-schemas.ts
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Zod schemas for MCP (Model Context Protocol) server configuration.
|
||||
*
|
||||
* Supports three transports: stdio, SSE, and HTTP.
|
||||
* Note: Uses zod v4 syntax for SDK compatibility.
|
||||
*/
|
||||
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
/** MCP server configuration for stdio transport */
|
||||
const McpStdioServerSchema = z.object({
|
||||
type: z.literal('stdio').optional(),
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/** MCP server configuration for SSE transport */
|
||||
const McpSseServerSchema = z.object({
|
||||
type: z.literal('sse'),
|
||||
url: z.string().min(1),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/** MCP server configuration for HTTP transport */
|
||||
const McpHttpServerSchema = z.object({
|
||||
type: z.literal('http'),
|
||||
url: z.string().min(1),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/** MCP server configuration (union of all YAML-configurable transports) */
|
||||
export const McpServerConfigSchema = z.union([
|
||||
McpStdioServerSchema,
|
||||
McpSseServerSchema,
|
||||
McpHttpServerSchema,
|
||||
]);
|
||||
|
||||
/** MCP servers map: server name → config */
|
||||
export const McpServersSchema = z.record(z.string(), McpServerConfigSchema).optional();
|
||||
@ -53,6 +53,31 @@ export interface OutputContractItem {
|
||||
/** Union type for output contract entries */
|
||||
export type OutputContractEntry = OutputContractLabelPath | OutputContractItem;
|
||||
|
||||
/** MCP server configuration for stdio transport */
|
||||
export interface McpStdioServerConfig {
|
||||
type?: 'stdio';
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** MCP server configuration for SSE transport */
|
||||
export interface McpSseServerConfig {
|
||||
type: 'sse';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** MCP server configuration for HTTP transport */
|
||||
export interface McpHttpServerConfig {
|
||||
type: 'http';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** MCP server configuration (union of all YAML-configurable transports) */
|
||||
export type McpServerConfig = McpStdioServerConfig | McpSseServerConfig | McpHttpServerConfig;
|
||||
|
||||
/** Single movement in a piece */
|
||||
export interface PieceMovement {
|
||||
name: string;
|
||||
@ -66,6 +91,8 @@ export interface PieceMovement {
|
||||
personaDisplayName: string;
|
||||
/** Allowed tools for this movement (optional, passed to agent execution) */
|
||||
allowedTools?: string[];
|
||||
/** MCP servers configuration for this movement */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
/** Resolved absolute path to persona prompt file (set by loader) */
|
||||
personaPath?: string;
|
||||
/** Provider override for this movement */
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
|
||||
import { z } from 'zod/v4';
|
||||
import { DEFAULT_LANGUAGE } from '../../shared/constants.js';
|
||||
import { McpServersSchema } from './mcp-schemas.js';
|
||||
|
||||
export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js';
|
||||
|
||||
/** Agent model schema (opus, sonnet, haiku) */
|
||||
export const AgentModelSchema = z.enum(['opus', 'sonnet', 'haiku']).default('sonnet');
|
||||
@ -137,6 +140,7 @@ export const ParallelSubMovementRawSchema = z.object({
|
||||
/** Knowledge reference(s) — key name(s) from piece-level knowledge map */
|
||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
allowed_tools: z.array(z.string()).optional(),
|
||||
mcp_servers: McpServersSchema,
|
||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||
model: z.string().optional(),
|
||||
permission_mode: PermissionModeSchema.optional(),
|
||||
@ -166,6 +170,7 @@ export const PieceMovementRawSchema = z.object({
|
||||
/** Knowledge reference(s) — key name(s) from piece-level knowledge map */
|
||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
allowed_tools: z.array(z.string()).optional(),
|
||||
mcp_servers: McpServersSchema,
|
||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||
model: z.string().optional(),
|
||||
/** Permission mode for tool execution in this movement */
|
||||
|
||||
@ -29,6 +29,7 @@ export type {
|
||||
OutputContractLabelPath,
|
||||
OutputContractItem,
|
||||
OutputContractEntry,
|
||||
McpServerConfig,
|
||||
PieceMovement,
|
||||
LoopDetectionConfig,
|
||||
LoopMonitorConfig,
|
||||
|
||||
@ -68,6 +68,7 @@ export class OptionsBuilder {
|
||||
...this.buildBaseOptions(step),
|
||||
sessionId: shouldResumeSession ? this.getSessionId(step.persona ?? step.name) : undefined,
|
||||
allowedTools,
|
||||
mcpServers: step.mcpServers,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -11,51 +11,10 @@ import { createLogger } from '../../shared/utils/index.js';
|
||||
import { loadTemplate } from '../../shared/prompts/index.js';
|
||||
|
||||
export type { ClaudeCallOptions } from './types.js';
|
||||
export { detectRuleIndex, isRegexSafe } from './utils.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.
|
||||
*
|
||||
@ -81,6 +40,7 @@ export class ClaudeClient {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
mcpServers: options.mcpServers,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
systemPrompt: options.systemPrompt,
|
||||
@ -167,6 +127,7 @@ export class ClaudeClient {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
mcpServers: options.mcpServers,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
permissionMode: options.permissionMode,
|
||||
|
||||
@ -63,6 +63,7 @@ export class SdkOptionsBuilder {
|
||||
if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns;
|
||||
if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools;
|
||||
if (this.options.agents) sdkOptions.agents = this.options.agents;
|
||||
if (this.options.mcpServers) sdkOptions.mcpServers = this.options.mcpServers;
|
||||
if (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt;
|
||||
if (canUseTool) sdkOptions.canUseTool = canUseTool;
|
||||
if (hooks) sdkOptions.hooks = hooks;
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { PermissionUpdate, AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { PermissionMode } from '../../core/models/index.js';
|
||||
import type { PermissionMode, McpServerConfig } from '../../core/models/index.js';
|
||||
import type { PermissionResult } from '../../core/piece/index.js';
|
||||
|
||||
// Re-export PermissionResult for convenience
|
||||
@ -121,6 +121,8 @@ export interface ClaudeCallOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
allowedTools?: string[];
|
||||
/** MCP servers configuration */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
model?: string;
|
||||
maxTurns?: number;
|
||||
systemPrompt?: string;
|
||||
@ -145,6 +147,8 @@ export interface ClaudeSpawnOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
allowedTools?: string[];
|
||||
/** MCP servers configuration */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
model?: string;
|
||||
maxTurns?: number;
|
||||
systemPrompt?: string;
|
||||
|
||||
47
src/infra/claude/utils.ts
Normal file
47
src/infra/claude/utils.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Utility functions for Claude client operations.
|
||||
*
|
||||
* Stateless helpers for rule detection and regex safety validation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -6,114 +6,23 @@
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
import { dirname } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import type { z } from 'zod';
|
||||
import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js';
|
||||
import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js';
|
||||
import {
|
||||
type PieceSections,
|
||||
resolveResourceContent,
|
||||
resolveRefToContent,
|
||||
resolveRefList,
|
||||
resolveSectionMap,
|
||||
extractPersonaDisplayName,
|
||||
resolvePersona,
|
||||
} from './resource-resolver.js';
|
||||
|
||||
type RawStep = z.output<typeof PieceMovementRawSchema>;
|
||||
|
||||
/** Resolve a resource spec to an absolute file path. */
|
||||
function resolveResourcePath(spec: string, pieceDir: string): string {
|
||||
if (spec.startsWith('./')) return join(pieceDir, spec.slice(2));
|
||||
if (spec.startsWith('~')) return join(homedir(), spec.slice(1));
|
||||
if (spec.startsWith('/')) return spec;
|
||||
return join(pieceDir, spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a resource spec to its file content.
|
||||
* If the spec ends with .md and the file exists, returns file content.
|
||||
* Otherwise returns the spec as-is (treated as inline content).
|
||||
*/
|
||||
function resolveResourceContent(spec: string | undefined, pieceDir: string): string | undefined {
|
||||
if (spec == null) return undefined;
|
||||
if (spec.endsWith('.md')) {
|
||||
const resolved = resolveResourcePath(spec, pieceDir);
|
||||
if (existsSync(resolved)) return readFileSync(resolved, 'utf-8');
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a section reference to content.
|
||||
* Looks up ref in resolvedMap first, then falls back to resolveResourceContent.
|
||||
*/
|
||||
function resolveRefToContent(
|
||||
ref: string,
|
||||
resolvedMap: Record<string, string> | undefined,
|
||||
pieceDir: string,
|
||||
): string | undefined {
|
||||
const mapped = resolvedMap?.[ref];
|
||||
if (mapped) return mapped;
|
||||
return resolveResourceContent(ref, pieceDir);
|
||||
}
|
||||
|
||||
/** Resolve multiple references to content strings (for fields that accept string | string[]). */
|
||||
function resolveRefList(
|
||||
refs: string | string[] | undefined,
|
||||
resolvedMap: Record<string, string> | undefined,
|
||||
pieceDir: string,
|
||||
): string[] | undefined {
|
||||
if (refs == null) return undefined;
|
||||
const list = Array.isArray(refs) ? refs : [refs];
|
||||
const contents: string[] = [];
|
||||
for (const ref of list) {
|
||||
const content = resolveRefToContent(ref, resolvedMap, pieceDir);
|
||||
if (content) contents.push(content);
|
||||
}
|
||||
return contents.length > 0 ? contents : undefined;
|
||||
}
|
||||
|
||||
/** Resolve a piece-level section map (each value resolved to file content or inline). */
|
||||
function resolveSectionMap(
|
||||
raw: Record<string, string> | undefined,
|
||||
pieceDir: string,
|
||||
): Record<string, string> | undefined {
|
||||
if (!raw) return undefined;
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(raw)) {
|
||||
const content = resolveResourceContent(value, pieceDir);
|
||||
if (content) resolved[name] = content;
|
||||
}
|
||||
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
||||
}
|
||||
|
||||
/** Extract display name from persona path (e.g., "coder.md" → "coder"). */
|
||||
function extractPersonaDisplayName(personaPath: string): string {
|
||||
return basename(personaPath, '.md');
|
||||
}
|
||||
|
||||
/** Resolve persona from YAML field to spec + absolute path. */
|
||||
function resolvePersona(
|
||||
rawPersona: string | undefined,
|
||||
sections: PieceSections,
|
||||
pieceDir: string,
|
||||
): { personaSpec?: string; personaPath?: string } {
|
||||
if (!rawPersona) return {};
|
||||
const personaSpec = sections.personas?.[rawPersona] ?? rawPersona;
|
||||
|
||||
const resolved = resolveResourcePath(personaSpec, pieceDir);
|
||||
const personaPath = existsSync(resolved) ? resolved : undefined;
|
||||
return { personaSpec, personaPath };
|
||||
}
|
||||
|
||||
/** Pre-resolved section maps passed to movement normalization. */
|
||||
interface PieceSections {
|
||||
/** Persona name → file path (raw, not content-resolved) */
|
||||
personas?: Record<string, string>;
|
||||
/** Policy name → resolved content */
|
||||
resolvedPolicies?: Record<string, string>;
|
||||
/** Knowledge name → resolved content */
|
||||
resolvedKnowledge?: Record<string, string>;
|
||||
/** Instruction name → resolved content */
|
||||
resolvedInstructions?: Record<string, string>;
|
||||
/** Report format name → resolved content */
|
||||
resolvedReportFormats?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Check if a raw output contract item is the object form (has 'name' property). */
|
||||
function isOutputContractItem(raw: unknown): raw is { name: string; order?: string; format?: string } {
|
||||
return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw;
|
||||
@ -271,6 +180,7 @@ function normalizeStepFromRaw(
|
||||
personaDisplayName: displayName || (personaSpec ? extractPersonaDisplayName(personaSpec) : step.name),
|
||||
personaPath,
|
||||
allowedTools: step.allowed_tools,
|
||||
mcpServers: step.mcp_servers,
|
||||
provider: step.provider,
|
||||
model: step.model,
|
||||
permissionMode: step.permission_mode,
|
||||
|
||||
109
src/infra/config/loaders/resource-resolver.ts
Normal file
109
src/infra/config/loaders/resource-resolver.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Resource resolution helpers for piece YAML parsing.
|
||||
*
|
||||
* Resolves file paths, content references, and persona specs
|
||||
* from piece-level section maps.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, basename } from 'node:path';
|
||||
|
||||
/** Pre-resolved section maps passed to movement normalization. */
|
||||
export interface PieceSections {
|
||||
/** Persona name → file path (raw, not content-resolved) */
|
||||
personas?: Record<string, string>;
|
||||
/** Policy name → resolved content */
|
||||
resolvedPolicies?: Record<string, string>;
|
||||
/** Knowledge name → resolved content */
|
||||
resolvedKnowledge?: Record<string, string>;
|
||||
/** Instruction name → resolved content */
|
||||
resolvedInstructions?: Record<string, string>;
|
||||
/** Report format name → resolved content */
|
||||
resolvedReportFormats?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Resolve a resource spec to an absolute file path. */
|
||||
export function resolveResourcePath(spec: string, pieceDir: string): string {
|
||||
if (spec.startsWith('./')) return join(pieceDir, spec.slice(2));
|
||||
if (spec.startsWith('~')) return join(homedir(), spec.slice(1));
|
||||
if (spec.startsWith('/')) return spec;
|
||||
return join(pieceDir, spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a resource spec to its file content.
|
||||
* If the spec ends with .md and the file exists, returns file content.
|
||||
* Otherwise returns the spec as-is (treated as inline content).
|
||||
*/
|
||||
export function resolveResourceContent(spec: string | undefined, pieceDir: string): string | undefined {
|
||||
if (spec == null) return undefined;
|
||||
if (spec.endsWith('.md')) {
|
||||
const resolved = resolveResourcePath(spec, pieceDir);
|
||||
if (existsSync(resolved)) return readFileSync(resolved, 'utf-8');
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a section reference to content.
|
||||
* Looks up ref in resolvedMap first, then falls back to resolveResourceContent.
|
||||
*/
|
||||
export function resolveRefToContent(
|
||||
ref: string,
|
||||
resolvedMap: Record<string, string> | undefined,
|
||||
pieceDir: string,
|
||||
): string | undefined {
|
||||
const mapped = resolvedMap?.[ref];
|
||||
if (mapped) return mapped;
|
||||
return resolveResourceContent(ref, pieceDir);
|
||||
}
|
||||
|
||||
/** Resolve multiple references to content strings (for fields that accept string | string[]). */
|
||||
export function resolveRefList(
|
||||
refs: string | string[] | undefined,
|
||||
resolvedMap: Record<string, string> | undefined,
|
||||
pieceDir: string,
|
||||
): string[] | undefined {
|
||||
if (refs == null) return undefined;
|
||||
const list = Array.isArray(refs) ? refs : [refs];
|
||||
const contents: string[] = [];
|
||||
for (const ref of list) {
|
||||
const content = resolveRefToContent(ref, resolvedMap, pieceDir);
|
||||
if (content) contents.push(content);
|
||||
}
|
||||
return contents.length > 0 ? contents : undefined;
|
||||
}
|
||||
|
||||
/** Resolve a piece-level section map (each value resolved to file content or inline). */
|
||||
export function resolveSectionMap(
|
||||
raw: Record<string, string> | undefined,
|
||||
pieceDir: string,
|
||||
): Record<string, string> | undefined {
|
||||
if (!raw) return undefined;
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(raw)) {
|
||||
const content = resolveResourceContent(value, pieceDir);
|
||||
if (content) resolved[name] = content;
|
||||
}
|
||||
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
||||
}
|
||||
|
||||
/** Extract display name from persona path (e.g., "coder.md" → "coder"). */
|
||||
export function extractPersonaDisplayName(personaPath: string): string {
|
||||
return basename(personaPath, '.md');
|
||||
}
|
||||
|
||||
/** Resolve persona from YAML field to spec + absolute path. */
|
||||
export function resolvePersona(
|
||||
rawPersona: string | undefined,
|
||||
sections: PieceSections,
|
||||
pieceDir: string,
|
||||
): { personaSpec?: string; personaPath?: string } {
|
||||
if (!rawPersona) return {};
|
||||
const personaSpec = sections.personas?.[rawPersona] ?? rawPersona;
|
||||
|
||||
const resolved = resolveResourcePath(personaSpec, pieceDir);
|
||||
const personaPath = existsSync(resolved) ? resolved : undefined;
|
||||
return { personaSpec, personaPath };
|
||||
}
|
||||
@ -12,6 +12,7 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
mcpServers: options.mcpServers,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
permissionMode: options.permissionMode,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js';
|
||||
import type { AgentResponse, PermissionMode } from '../../core/models/index.js';
|
||||
import type { AgentResponse, PermissionMode, McpServerConfig } from '../../core/models/index.js';
|
||||
|
||||
/** Agent setup configuration — determines HOW the provider invokes the agent */
|
||||
export interface AgentSetup {
|
||||
@ -23,6 +23,8 @@ export interface ProviderCallOptions {
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
allowedTools?: string[];
|
||||
/** MCP servers configuration */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
/** Maximum number of agentic turns */
|
||||
maxTurns?: number;
|
||||
/** Permission mode for tool execution (from piece step) */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user