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', () => {
|
describe('Piece Loader IT: invalid YAML handling', () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
StatusSchema,
|
StatusSchema,
|
||||||
PermissionModeSchema,
|
PermissionModeSchema,
|
||||||
PieceConfigRawSchema,
|
PieceConfigRawSchema,
|
||||||
|
McpServerConfigSchema,
|
||||||
CustomAgentConfigSchema,
|
CustomAgentConfigSchema,
|
||||||
GlobalConfigSchema,
|
GlobalConfigSchema,
|
||||||
} from '../core/models/index.js';
|
} from '../core/models/index.js';
|
||||||
@ -143,6 +144,210 @@ describe('PieceConfigRawSchema', () => {
|
|||||||
|
|
||||||
expect(() => PieceConfigRawSchema.parse(config)).toThrow();
|
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', () => {
|
describe('CustomAgentConfigSchema', () => {
|
||||||
|
|||||||
@ -3,9 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
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 { mapToCodexSandboxMode } from '../infra/codex/types.js';
|
||||||
import type { PermissionMode } from '../core/models/index.js';
|
import type { PermissionMode } from '../core/models/index.js';
|
||||||
|
import type { ClaudeSpawnOptions } from '../infra/claude/types.js';
|
||||||
|
|
||||||
describe('SdkOptionsBuilder.mapToSdkPermissionMode', () => {
|
describe('SdkOptionsBuilder.mapToSdkPermissionMode', () => {
|
||||||
it('should map readonly to SDK default', () => {
|
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,
|
cwd: options.cwd,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
allowedTools: options.allowedTools ?? agentConfig?.allowedTools,
|
allowedTools: options.allowedTools ?? agentConfig?.allowedTools,
|
||||||
|
mcpServers: options.mcpServers,
|
||||||
maxTurns: options.maxTurns,
|
maxTurns: options.maxTurns,
|
||||||
model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig),
|
model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig),
|
||||||
permissionMode: options.permissionMode,
|
permissionMode: options.permissionMode,
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/index.js';
|
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 };
|
export type { StreamCallback };
|
||||||
|
|
||||||
@ -17,6 +17,8 @@ export interface RunAgentOptions {
|
|||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
/** Allowed tools for this agent run */
|
/** Allowed tools for this agent run */
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
|
/** MCP servers for this agent run */
|
||||||
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
/** Maximum number of agentic turns */
|
/** Maximum number of agentic turns */
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
/** Permission mode for tool execution (from piece step) */
|
/** Permission mode for tool execution (from piece step) */
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export type {
|
|||||||
OutputContractLabelPath,
|
OutputContractLabelPath,
|
||||||
OutputContractItem,
|
OutputContractItem,
|
||||||
OutputContractEntry,
|
OutputContractEntry,
|
||||||
|
McpServerConfig,
|
||||||
AgentResponse,
|
AgentResponse,
|
||||||
SessionState,
|
SessionState,
|
||||||
PieceRule,
|
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 */
|
/** Union type for output contract entries */
|
||||||
export type OutputContractEntry = OutputContractLabelPath | OutputContractItem;
|
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 */
|
/** Single movement in a piece */
|
||||||
export interface PieceMovement {
|
export interface PieceMovement {
|
||||||
name: string;
|
name: string;
|
||||||
@ -66,6 +91,8 @@ export interface PieceMovement {
|
|||||||
personaDisplayName: string;
|
personaDisplayName: string;
|
||||||
/** Allowed tools for this movement (optional, passed to agent execution) */
|
/** Allowed tools for this movement (optional, passed to agent execution) */
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
|
/** MCP servers configuration for this movement */
|
||||||
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
/** Resolved absolute path to persona prompt file (set by loader) */
|
/** Resolved absolute path to persona prompt file (set by loader) */
|
||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
/** Provider override for this movement */
|
/** Provider override for this movement */
|
||||||
|
|||||||
@ -6,6 +6,9 @@
|
|||||||
|
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import { DEFAULT_LANGUAGE } from '../../shared/constants.js';
|
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) */
|
/** Agent model schema (opus, sonnet, haiku) */
|
||||||
export const AgentModelSchema = z.enum(['opus', 'sonnet', 'haiku']).default('sonnet');
|
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 reference(s) — key name(s) from piece-level knowledge map */
|
||||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
|
mcp_servers: McpServersSchema,
|
||||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
permission_mode: PermissionModeSchema.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 reference(s) — key name(s) from piece-level knowledge map */
|
||||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
|
mcp_servers: McpServersSchema,
|
||||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
/** Permission mode for tool execution in this movement */
|
/** Permission mode for tool execution in this movement */
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export type {
|
|||||||
OutputContractLabelPath,
|
OutputContractLabelPath,
|
||||||
OutputContractItem,
|
OutputContractItem,
|
||||||
OutputContractEntry,
|
OutputContractEntry,
|
||||||
|
McpServerConfig,
|
||||||
PieceMovement,
|
PieceMovement,
|
||||||
LoopDetectionConfig,
|
LoopDetectionConfig,
|
||||||
LoopMonitorConfig,
|
LoopMonitorConfig,
|
||||||
|
|||||||
@ -68,6 +68,7 @@ export class OptionsBuilder {
|
|||||||
...this.buildBaseOptions(step),
|
...this.buildBaseOptions(step),
|
||||||
sessionId: shouldResumeSession ? this.getSessionId(step.persona ?? step.name) : undefined,
|
sessionId: shouldResumeSession ? this.getSessionId(step.persona ?? step.name) : undefined,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
|
mcpServers: step.mcpServers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,51 +11,10 @@ import { createLogger } from '../../shared/utils/index.js';
|
|||||||
import { loadTemplate } from '../../shared/prompts/index.js';
|
import { loadTemplate } from '../../shared/prompts/index.js';
|
||||||
|
|
||||||
export type { ClaudeCallOptions } from './types.js';
|
export type { ClaudeCallOptions } from './types.js';
|
||||||
|
export { detectRuleIndex, isRegexSafe } from './utils.js';
|
||||||
|
|
||||||
const log = createLogger('client');
|
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.
|
* High-level Claude client for calling Claude with various configurations.
|
||||||
*
|
*
|
||||||
@ -81,6 +40,7 @@ export class ClaudeClient {
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
allowedTools: options.allowedTools,
|
allowedTools: options.allowedTools,
|
||||||
|
mcpServers: options.mcpServers,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
maxTurns: options.maxTurns,
|
maxTurns: options.maxTurns,
|
||||||
systemPrompt: options.systemPrompt,
|
systemPrompt: options.systemPrompt,
|
||||||
@ -167,6 +127,7 @@ export class ClaudeClient {
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
allowedTools: options.allowedTools,
|
allowedTools: options.allowedTools,
|
||||||
|
mcpServers: options.mcpServers,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
maxTurns: options.maxTurns,
|
maxTurns: options.maxTurns,
|
||||||
permissionMode: options.permissionMode,
|
permissionMode: options.permissionMode,
|
||||||
|
|||||||
@ -63,6 +63,7 @@ export class SdkOptionsBuilder {
|
|||||||
if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns;
|
if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns;
|
||||||
if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools;
|
if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools;
|
||||||
if (this.options.agents) sdkOptions.agents = this.options.agents;
|
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 (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt;
|
||||||
if (canUseTool) sdkOptions.canUseTool = canUseTool;
|
if (canUseTool) sdkOptions.canUseTool = canUseTool;
|
||||||
if (hooks) sdkOptions.hooks = hooks;
|
if (hooks) sdkOptions.hooks = hooks;
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PermissionUpdate, AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
|
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';
|
import type { PermissionResult } from '../../core/piece/index.js';
|
||||||
|
|
||||||
// Re-export PermissionResult for convenience
|
// Re-export PermissionResult for convenience
|
||||||
@ -121,6 +121,8 @@ export interface ClaudeCallOptions {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
|
/** MCP servers configuration */
|
||||||
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
model?: string;
|
model?: string;
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
@ -145,6 +147,8 @@ export interface ClaudeSpawnOptions {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
|
/** MCP servers configuration */
|
||||||
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
model?: string;
|
model?: string;
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
systemPrompt?: string;
|
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 { readFileSync, existsSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { dirname } from 'node:path';
|
||||||
import { join, dirname, basename } from 'node:path';
|
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js';
|
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 { 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>;
|
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). */
|
/** 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 } {
|
function isOutputContractItem(raw: unknown): raw is { name: string; order?: string; format?: string } {
|
||||||
return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw;
|
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),
|
personaDisplayName: displayName || (personaSpec ? extractPersonaDisplayName(personaSpec) : step.name),
|
||||||
personaPath,
|
personaPath,
|
||||||
allowedTools: step.allowed_tools,
|
allowedTools: step.allowed_tools,
|
||||||
|
mcpServers: step.mcp_servers,
|
||||||
provider: step.provider,
|
provider: step.provider,
|
||||||
model: step.model,
|
model: step.model,
|
||||||
permissionMode: step.permission_mode,
|
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,
|
cwd: options.cwd,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
allowedTools: options.allowedTools,
|
allowedTools: options.allowedTools,
|
||||||
|
mcpServers: options.mcpServers,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
maxTurns: options.maxTurns,
|
maxTurns: options.maxTurns,
|
||||||
permissionMode: options.permissionMode,
|
permissionMode: options.permissionMode,
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js';
|
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 */
|
/** Agent setup configuration — determines HOW the provider invokes the agent */
|
||||||
export interface AgentSetup {
|
export interface AgentSetup {
|
||||||
@ -23,6 +23,8 @@ export interface ProviderCallOptions {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
|
/** MCP servers configuration */
|
||||||
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
/** Maximum number of agentic turns */
|
/** Maximum number of agentic turns */
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
/** Permission mode for tool execution (from piece step) */
|
/** Permission mode for tool execution (from piece step) */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user