qaify-playwright-reporter 0.9.5 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/command.d.ts +19 -0
- package/dist/agent/command.js +76 -0
- package/dist/agent/llm.d.ts +46 -0
- package/dist/agent/llm.js +111 -0
- package/dist/agent/mcp.d.ts +31 -0
- package/dist/agent/mcp.js +115 -0
- package/dist/agent/run.d.ts +56 -0
- package/dist/agent/run.js +177 -0
- package/dist/agent/tools.d.ts +22 -0
- package/dist/agent/tools.js +250 -0
- package/dist/ai-env.d.ts +29 -0
- package/dist/ai-env.js +89 -0
- package/dist/analyze.d.ts +12 -0
- package/dist/analyze.js +12 -0
- package/dist/audit-api.d.ts +4 -1
- package/dist/audit-api.js +37 -14
- package/dist/audit.js +35 -32
- package/dist/cli.js +69 -20
- package/dist/fix.d.ts +6 -1
- package/dist/fix.js +62 -50
- package/dist/init.d.ts +2 -2
- package/dist/init.js +51 -47
- package/dist/usage.d.ts +7 -0
- package/dist/usage.js +20 -0
- package/package.json +51 -51
- package/templates/auto-fix.yml +47 -12
- package/templates/qa.yml +37 -6
- package/templates/qaify-audit.yml +58 -17
- package/templates/qaify-fix.yml +48 -14
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AgentOutput } from './run.js';
|
|
2
|
+
export interface AgentCommandOptions {
|
|
3
|
+
envFile: string;
|
|
4
|
+
out: string;
|
|
5
|
+
schemaFile: string;
|
|
6
|
+
promptFile?: string;
|
|
7
|
+
prompt?: string;
|
|
8
|
+
inputFile?: string;
|
|
9
|
+
instructionsFile?: string;
|
|
10
|
+
tools: string[];
|
|
11
|
+
maxTurns: number;
|
|
12
|
+
mcpConfig?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
cwd: string;
|
|
15
|
+
log?: (msg: string) => void;
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
}
|
|
18
|
+
export declare function childEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
19
|
+
export declare function agentCommand(options: AgentCommandOptions): Promise<AgentOutput>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `qaify agent`: jalankan Qaify Agent dengan kredensial dari env file `qaify ai-env`, tulis claude.json.
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { consumeEnvFile, MODEL_PATTERN } from '../ai-env.js';
|
|
5
|
+
import { loadMcpConfig, McpServer } from './mcp.js';
|
|
6
|
+
import { runAgent } from './run.js';
|
|
7
|
+
/** Variabel yang tidak boleh diwarisi bash / server MCP (key AI, token Qaify, token langganan). */
|
|
8
|
+
const PRIVATE_ENV = /^(QAIFY_AI_|ANTHROPIC_|QA_FACTORY_TOKEN$|CLAUDE_CODE_OAUTH_TOKEN$|GITHUB_TOKEN$|ACTIONS_RUNTIME_TOKEN$|ACTIONS_ID_TOKEN_REQUEST_)/;
|
|
9
|
+
export function childEnv(env = process.env) {
|
|
10
|
+
return Object.fromEntries(Object.entries(env).filter(([name]) => !PRIVATE_ENV.test(name)));
|
|
11
|
+
}
|
|
12
|
+
export async function agentCommand(options) {
|
|
13
|
+
const log = options.log ?? console.log;
|
|
14
|
+
const env = await consumeEnvFile(options.envFile);
|
|
15
|
+
const model = options.model && MODEL_PATTERN.test(options.model) ? options.model : env.QAIFY_AI_MODEL;
|
|
16
|
+
const write = async (output) => {
|
|
17
|
+
await mkdir(dirname(options.out), { recursive: true });
|
|
18
|
+
await writeFile(options.out, JSON.stringify(output));
|
|
19
|
+
return output;
|
|
20
|
+
};
|
|
21
|
+
const fail = (message, code) => write({
|
|
22
|
+
type: 'result', subtype: `error_${code}`, is_error: true, result: message, num_turns: 0, duration_ms: 0, total_cost_usd: null,
|
|
23
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 }, modelUsage: {}, engine: 'qaify_agent',
|
|
24
|
+
provider: env.QAIFY_AI_PROVIDER ?? 'unknown', error_code: code,
|
|
25
|
+
});
|
|
26
|
+
if (!env.QAIFY_AI_API_KEY || !env.QAIFY_AI_BASE_URL || !model) {
|
|
27
|
+
log('::error::Qaify Agent: API key / base URL / model tidak tersedia (step "Pilih agen AI" gagal?)');
|
|
28
|
+
return fail('API key Qaify Agent tidak tersedia di runner.', 'ai_env_missing');
|
|
29
|
+
}
|
|
30
|
+
const schema = JSON.parse(await readFile(options.schemaFile, 'utf8'));
|
|
31
|
+
const instructions = options.instructionsFile ? await readFile(options.instructionsFile, 'utf8') : '';
|
|
32
|
+
let prompt = options.promptFile ? await readFile(options.promptFile, 'utf8') : (options.prompt ?? '');
|
|
33
|
+
if (options.inputFile)
|
|
34
|
+
prompt += `\n\nInput JSON:\n\`\`\`json\n${await readFile(options.inputFile, 'utf8')}\n\`\`\``;
|
|
35
|
+
const safeEnv = childEnv();
|
|
36
|
+
const servers = [];
|
|
37
|
+
if (options.mcpConfig) {
|
|
38
|
+
for (const [name, config] of Object.entries(await loadMcpConfig(options.mcpConfig))) {
|
|
39
|
+
const server = new McpServer(name, config, safeEnv, (m) => log(m.slice(0, 500)));
|
|
40
|
+
try {
|
|
41
|
+
await server.start();
|
|
42
|
+
servers.push(server);
|
|
43
|
+
log(`qaify-agent: server MCP ${name} siap (${server.tools.length} tool)`);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
server.close();
|
|
47
|
+
log(`::warning::Server MCP ${name} gagal dijalankan (${e.message}); agen berjalan tanpa tool itu.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const price = (name) => (env[name] !== undefined && env[name] !== '' && Number.isFinite(Number(env[name])) ? Number(env[name]) : null);
|
|
52
|
+
try {
|
|
53
|
+
const output = await runAgent({
|
|
54
|
+
llm: { baseUrl: env.QAIFY_AI_BASE_URL, apiKey: env.QAIFY_AI_API_KEY, provider: env.QAIFY_AI_PROVIDER ?? 'custom', model, ...(options.fetch ? { fetch: options.fetch } : {}) },
|
|
55
|
+
instructions,
|
|
56
|
+
prompt,
|
|
57
|
+
schema,
|
|
58
|
+
tools: options.tools,
|
|
59
|
+
mcp: servers,
|
|
60
|
+
maxTurns: options.maxTurns,
|
|
61
|
+
cwd: options.cwd,
|
|
62
|
+
env: safeEnv,
|
|
63
|
+
contextTokens: price('QAIFY_AI_CONTEXT') ?? undefined,
|
|
64
|
+
price: { input: price('QAIFY_AI_PRICE_INPUT'), output: price('QAIFY_AI_PRICE_OUTPUT') },
|
|
65
|
+
log,
|
|
66
|
+
});
|
|
67
|
+
log(`qaify-agent: ${output.is_error ? `gagal (${output.error_code})` : 'selesai'} · ${output.num_turns} giliran · ` +
|
|
68
|
+
`${output.usage.input_tokens + output.usage.cache_read_input_tokens} token masuk / ${output.usage.output_tokens} keluar` +
|
|
69
|
+
(output.total_cost_usd !== null ? ` · US$${output.total_cost_usd.toFixed(4)}` : ''));
|
|
70
|
+
return await write(output);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
for (const server of servers)
|
|
74
|
+
server.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ToolSpec } from './tools.js';
|
|
2
|
+
export interface ChatMessage {
|
|
3
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
4
|
+
content: string | null;
|
|
5
|
+
tool_calls?: ToolCall[];
|
|
6
|
+
tool_call_id?: string;
|
|
7
|
+
/** dikembalikan apa adanya ke provider (mis. reasoning_details OpenRouter) */
|
|
8
|
+
[extra: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface ToolCall {
|
|
11
|
+
id: string;
|
|
12
|
+
type: 'function';
|
|
13
|
+
function: {
|
|
14
|
+
name: string;
|
|
15
|
+
arguments: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface ChatUsage {
|
|
19
|
+
prompt_tokens: number;
|
|
20
|
+
completion_tokens: number;
|
|
21
|
+
cached_tokens: number;
|
|
22
|
+
/** USD dari provider (OpenRouter usage.cost); null = tidak dilaporkan */
|
|
23
|
+
cost: number | null;
|
|
24
|
+
}
|
|
25
|
+
export interface ChatResult {
|
|
26
|
+
message: ChatMessage;
|
|
27
|
+
finish_reason: string | null;
|
|
28
|
+
usage: ChatUsage;
|
|
29
|
+
}
|
|
30
|
+
export interface LlmConfig {
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
apiKey: string;
|
|
33
|
+
provider: string;
|
|
34
|
+
model: string;
|
|
35
|
+
fetch?: typeof fetch;
|
|
36
|
+
sleep?: (ms: number) => Promise<void>;
|
|
37
|
+
/** retry 429 / 5xx / galat jaringan */
|
|
38
|
+
retries?: number;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
export declare class LlmError extends Error {
|
|
42
|
+
readonly code: 'ai_key_invalid' | 'ai_credit_low' | 'rate_limit' | 'model_no_tools' | 'model_not_found' | 'provider_error';
|
|
43
|
+
readonly status: number | null;
|
|
44
|
+
constructor(message: string, code: 'ai_key_invalid' | 'ai_credit_low' | 'rate_limit' | 'model_no_tools' | 'model_not_found' | 'provider_error', status?: number | null);
|
|
45
|
+
}
|
|
46
|
+
export declare function chat(config: LlmConfig, messages: ChatMessage[], tools: ToolSpec[]): Promise<ChatResult>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export class LlmError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
constructor(message, code, status = null) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const sleepDefault = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
export async function chat(config, messages, tools) {
|
|
12
|
+
const doFetch = config.fetch ?? fetch;
|
|
13
|
+
const wait = config.sleep ?? sleepDefault;
|
|
14
|
+
const retries = config.retries ?? 5;
|
|
15
|
+
const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
16
|
+
const body = {
|
|
17
|
+
model: config.model,
|
|
18
|
+
messages,
|
|
19
|
+
...(tools.length ? { tools: tools.map((t) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } })), tool_choice: 'auto' } : {}),
|
|
20
|
+
// OpenRouter: biaya per request di usage.cost
|
|
21
|
+
...(config.provider === 'openrouter' ? { usage: { include: true } } : {}),
|
|
22
|
+
};
|
|
23
|
+
const headers = {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
26
|
+
...(config.provider === 'openrouter' ? { 'HTTP-Referer': 'https://qaify.vercel.app', 'X-Title': 'Qaify' } : {}),
|
|
27
|
+
};
|
|
28
|
+
let lastError = null;
|
|
29
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await doFetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(config.timeoutMs ?? 300_000) });
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
lastError = new LlmError(`Tidak bisa menghubungi provider: ${e.message}`, 'provider_error');
|
|
36
|
+
if (attempt < retries)
|
|
37
|
+
await wait(backoff(attempt));
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let data = null;
|
|
42
|
+
try {
|
|
43
|
+
data = JSON.parse(text);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* body bukan JSON */
|
|
47
|
+
}
|
|
48
|
+
if (res.ok && !data?.error) {
|
|
49
|
+
const choice = data?.choices?.[0];
|
|
50
|
+
if (!choice?.message)
|
|
51
|
+
throw new LlmError(`Respons provider tanpa pilihan jawaban: ${text.slice(0, 300)}`, 'provider_error', res.status);
|
|
52
|
+
return { message: normalizeMessage(choice.message), finish_reason: choice.finish_reason ?? null, usage: readUsage(data.usage) };
|
|
53
|
+
}
|
|
54
|
+
const status = data?.error?.code && Number.isInteger(data.error.code) ? Number(data.error.code) : res.status;
|
|
55
|
+
const message = String(data?.error?.message ?? data?.message ?? text ?? res.statusText).slice(0, 500);
|
|
56
|
+
lastError = classify(status, message);
|
|
57
|
+
const retryable = status === 429 || status >= 500 || status === 408;
|
|
58
|
+
if (!retryable || attempt === retries)
|
|
59
|
+
break;
|
|
60
|
+
const after = Number(res.headers.get('retry-after'));
|
|
61
|
+
await wait(Number.isFinite(after) && after > 0 ? Math.min(after * 1000, 60_000) : backoff(attempt));
|
|
62
|
+
}
|
|
63
|
+
throw lastError ?? new LlmError('Provider tidak merespons', 'provider_error');
|
|
64
|
+
}
|
|
65
|
+
function classify(status, message) {
|
|
66
|
+
const lower = message.toLowerCase();
|
|
67
|
+
if (status === 401 || status === 403 || /invalid api key|incorrect api key|api key not valid|unauthorized/.test(lower)) {
|
|
68
|
+
return new LlmError(`API key ditolak provider (${status}): ${message}`, 'ai_key_invalid', status);
|
|
69
|
+
}
|
|
70
|
+
if (status === 402 || /insufficient (credits|balance|quota)|credit balance|exceeded your current quota/.test(lower)) {
|
|
71
|
+
return new LlmError(`Saldo / kredit provider habis (${status}): ${message}`, 'ai_credit_low', status);
|
|
72
|
+
}
|
|
73
|
+
if (status === 429)
|
|
74
|
+
return new LlmError(`Provider membatasi request (rate limit): ${message}`, 'rate_limit', status);
|
|
75
|
+
if (/tool|function call/.test(lower) && /support|not available|unsupported/.test(lower)) {
|
|
76
|
+
return new LlmError(`Model ini tidak mendukung tool calling: ${message}`, 'model_no_tools', status);
|
|
77
|
+
}
|
|
78
|
+
if (status === 404 || /model .*not (found|exist)|no endpoints found|invalid model/.test(lower)) {
|
|
79
|
+
return new LlmError(`Model tidak ditemukan di provider: ${message}`, 'model_not_found', status);
|
|
80
|
+
}
|
|
81
|
+
return new LlmError(`Provider membalas ${status}: ${message}`, 'provider_error', status);
|
|
82
|
+
}
|
|
83
|
+
function normalizeMessage(raw) {
|
|
84
|
+
const { role: _role, content, tool_calls, refusal: _refusal, ...extra } = raw ?? {};
|
|
85
|
+
const calls = Array.isArray(tool_calls)
|
|
86
|
+
? tool_calls
|
|
87
|
+
.filter((c) => c?.function?.name)
|
|
88
|
+
.map((c, i) => ({
|
|
89
|
+
id: String(c.id || `call_${Date.now()}_${i}`),
|
|
90
|
+
type: 'function',
|
|
91
|
+
function: { name: String(c.function.name), arguments: typeof c.function.arguments === 'string' ? c.function.arguments : JSON.stringify(c.function.arguments ?? {}) },
|
|
92
|
+
}))
|
|
93
|
+
: [];
|
|
94
|
+
// reasoning (DeepSeek/Kimi/OpenRouter) disertakan kembali hanya bila berbentuk yang dikenal
|
|
95
|
+
const keep = Object.fromEntries(Object.entries(extra).filter(([k]) => k === 'reasoning_details' || k === 'reasoning_content'));
|
|
96
|
+
return {
|
|
97
|
+
role: 'assistant',
|
|
98
|
+
content: typeof content === 'string' ? content : Array.isArray(content) ? content.map((p) => p?.text ?? '').join('') : null,
|
|
99
|
+
...(calls.length ? { tool_calls: calls } : {}),
|
|
100
|
+
...keep,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function readUsage(u) {
|
|
104
|
+
return {
|
|
105
|
+
prompt_tokens: Number(u?.prompt_tokens ?? 0),
|
|
106
|
+
completion_tokens: Number(u?.completion_tokens ?? 0),
|
|
107
|
+
cached_tokens: Number(u?.prompt_tokens_details?.cached_tokens ?? u?.prompt_cache_hit_tokens ?? 0),
|
|
108
|
+
cost: typeof u?.cost === 'number' ? u.cost : null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const backoff = (attempt) => Math.min(30_000, 1000 * 2 ** attempt);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ToolSpec } from './tools.js';
|
|
2
|
+
interface ServerConfig {
|
|
3
|
+
command: string;
|
|
4
|
+
args?: string[];
|
|
5
|
+
env?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export declare class McpServer {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
private readonly config;
|
|
10
|
+
private readonly env;
|
|
11
|
+
private readonly log;
|
|
12
|
+
private child;
|
|
13
|
+
private nextId;
|
|
14
|
+
private pending;
|
|
15
|
+
private buffer;
|
|
16
|
+
readonly tools: (ToolSpec & {
|
|
17
|
+
remote: string;
|
|
18
|
+
})[];
|
|
19
|
+
constructor(name: string, config: ServerConfig, env: NodeJS.ProcessEnv, log?: (msg: string) => void);
|
|
20
|
+
start(timeoutMs?: number): Promise<void>;
|
|
21
|
+
/** Hasil tool sebagai teks; gambar diganti keterangan (tidak semua provider menerima gambar di pesan tool). */
|
|
22
|
+
call(remote: string, args: unknown, timeoutMs?: number): Promise<string>;
|
|
23
|
+
close(): void;
|
|
24
|
+
private request;
|
|
25
|
+
private notify;
|
|
26
|
+
private onData;
|
|
27
|
+
private failAll;
|
|
28
|
+
}
|
|
29
|
+
/** Baca .qaify/mcp.json (format Claude Code: {mcpServers: {name: {command, args, env}}}). */
|
|
30
|
+
export declare function loadMcpConfig(path: string): Promise<Record<string, ServerConfig>>;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Klien MCP stdio minimal (JSON-RPC 2.0, satu pesan per baris) untuk Playwright MCP di audit Qaify Agent.
|
|
2
|
+
// Nama tool diberi prefix mcp__<server>__ seperti Claude Code agar instruksi audit yang sama tetap berlaku.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
6
|
+
export class McpServer {
|
|
7
|
+
name;
|
|
8
|
+
config;
|
|
9
|
+
env;
|
|
10
|
+
log;
|
|
11
|
+
child = null;
|
|
12
|
+
nextId = 1;
|
|
13
|
+
pending = new Map();
|
|
14
|
+
buffer = '';
|
|
15
|
+
tools = [];
|
|
16
|
+
constructor(name, config, env, log = () => { }) {
|
|
17
|
+
this.name = name;
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.env = env;
|
|
20
|
+
this.log = log;
|
|
21
|
+
}
|
|
22
|
+
async start(timeoutMs = 180_000) {
|
|
23
|
+
this.child = spawn(this.config.command, this.config.args ?? [], { env: { ...this.env, ...this.config.env }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
24
|
+
this.child.stdout.on('data', (chunk) => this.onData(chunk.toString()));
|
|
25
|
+
this.child.stderr.on('data', (chunk) => this.log(`[mcp ${this.name}] ${chunk.toString().trimEnd()}`));
|
|
26
|
+
this.child.on('exit', (code) => this.failAll(new Error(`server MCP ${this.name} berhenti (exit ${code})`)));
|
|
27
|
+
this.child.on('error', (e) => this.failAll(e));
|
|
28
|
+
await this.request('initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'qaify-agent', version: '1' } }, timeoutMs);
|
|
29
|
+
this.notify('notifications/initialized');
|
|
30
|
+
let cursor;
|
|
31
|
+
do {
|
|
32
|
+
const page = await this.request('tools/list', cursor ? { cursor } : {});
|
|
33
|
+
for (const tool of page?.tools ?? []) {
|
|
34
|
+
this.tools.push({
|
|
35
|
+
name: `mcp__${this.name}__${tool.name}`,
|
|
36
|
+
remote: tool.name,
|
|
37
|
+
description: String(tool.description ?? '').slice(0, 1000),
|
|
38
|
+
parameters: tool.inputSchema && typeof tool.inputSchema === 'object' ? tool.inputSchema : { type: 'object', properties: {} },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
cursor = page?.nextCursor;
|
|
42
|
+
} while (cursor);
|
|
43
|
+
}
|
|
44
|
+
/** Hasil tool sebagai teks; gambar diganti keterangan (tidak semua provider menerima gambar di pesan tool). */
|
|
45
|
+
async call(remote, args, timeoutMs = 120_000) {
|
|
46
|
+
const result = await this.request('tools/call', { name: remote, arguments: args ?? {} }, timeoutMs);
|
|
47
|
+
const parts = (result?.content ?? []).map((c) => c?.type === 'text' ? String(c.text) : c?.type === 'image' ? `[gambar ${c.mimeType ?? ''} diterima]` : `[konten ${c?.type ?? '?'}]`);
|
|
48
|
+
const text = parts.join('\n') || '(kosong)';
|
|
49
|
+
return result?.isError ? `Error: ${text}` : text;
|
|
50
|
+
}
|
|
51
|
+
close() {
|
|
52
|
+
this.failAll(new Error('ditutup'));
|
|
53
|
+
this.child?.kill('SIGTERM');
|
|
54
|
+
this.child = null;
|
|
55
|
+
}
|
|
56
|
+
request(method, params, timeoutMs = 60_000) {
|
|
57
|
+
if (!this.child)
|
|
58
|
+
return Promise.reject(new Error(`server MCP ${this.name} belum berjalan`));
|
|
59
|
+
const id = this.nextId++;
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
this.pending.delete(id);
|
|
63
|
+
reject(new Error(`${method} ke server MCP ${this.name} melewati ${Math.round(timeoutMs / 1000)} detik`));
|
|
64
|
+
}, timeoutMs);
|
|
65
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
66
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
notify(method, params) {
|
|
70
|
+
this.child?.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`);
|
|
71
|
+
}
|
|
72
|
+
onData(text) {
|
|
73
|
+
this.buffer += text;
|
|
74
|
+
let index;
|
|
75
|
+
while ((index = this.buffer.indexOf('\n')) >= 0) {
|
|
76
|
+
const line = this.buffer.slice(0, index).trim();
|
|
77
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
78
|
+
if (!line)
|
|
79
|
+
continue;
|
|
80
|
+
let message;
|
|
81
|
+
try {
|
|
82
|
+
message = JSON.parse(line);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// permintaan dari server (mis. roots/list, ping): balas kosong agar server tidak menunggu
|
|
88
|
+
if (message.method && message.id !== undefined) {
|
|
89
|
+
this.child?.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: message.method === 'roots/list' ? { roots: [] } : {} })}\n`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const pending = typeof message.id === 'number' ? this.pending.get(message.id) : undefined;
|
|
93
|
+
if (!pending)
|
|
94
|
+
continue;
|
|
95
|
+
this.pending.delete(message.id);
|
|
96
|
+
clearTimeout(pending.timer);
|
|
97
|
+
if (message.error)
|
|
98
|
+
pending.reject(new Error(`MCP ${this.name}: ${message.error.message ?? 'error'}`));
|
|
99
|
+
else
|
|
100
|
+
pending.resolve(message.result);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
failAll(error) {
|
|
104
|
+
for (const [id, pending] of this.pending) {
|
|
105
|
+
clearTimeout(pending.timer);
|
|
106
|
+
pending.reject(error);
|
|
107
|
+
this.pending.delete(id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Baca .qaify/mcp.json (format Claude Code: {mcpServers: {name: {command, args, env}}}). */
|
|
112
|
+
export async function loadMcpConfig(path) {
|
|
113
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
114
|
+
return parsed?.mcpServers && typeof parsed.mcpServers === 'object' ? parsed.mcpServers : {};
|
|
115
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ChatMessage, type LlmConfig } from './llm.js';
|
|
2
|
+
import { McpServer } from './mcp.js';
|
|
3
|
+
export declare const SUBMIT_TOOL = "submit_result";
|
|
4
|
+
export interface AgentOptions {
|
|
5
|
+
llm: LlmConfig;
|
|
6
|
+
/** instruksi sistem (berkas instructions.txt yang sama dengan Claude Code) */
|
|
7
|
+
instructions: string;
|
|
8
|
+
prompt: string;
|
|
9
|
+
schema: Record<string, unknown>;
|
|
10
|
+
/** nama tool lokal: read, list, grep, write, edit, bash */
|
|
11
|
+
tools: string[];
|
|
12
|
+
mcp?: McpServer[];
|
|
13
|
+
maxTurns: number;
|
|
14
|
+
cwd: string;
|
|
15
|
+
/** env untuk bash & MCP: tanpa QAIFY_AI_* */
|
|
16
|
+
env: NodeJS.ProcessEnv;
|
|
17
|
+
/** token konteks model (dari katalog); default 128k */
|
|
18
|
+
contextTokens?: number;
|
|
19
|
+
/** USD per 1 juta token, dipakai bila provider tidak melaporkan biaya */
|
|
20
|
+
price?: {
|
|
21
|
+
input: number | null;
|
|
22
|
+
output: number | null;
|
|
23
|
+
};
|
|
24
|
+
log?: (msg: string) => void;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}
|
|
27
|
+
export interface AgentOutput {
|
|
28
|
+
type: 'result';
|
|
29
|
+
subtype: string;
|
|
30
|
+
is_error: boolean;
|
|
31
|
+
result: string;
|
|
32
|
+
structured_output?: unknown;
|
|
33
|
+
num_turns: number;
|
|
34
|
+
duration_ms: number;
|
|
35
|
+
total_cost_usd: number | null;
|
|
36
|
+
usage: {
|
|
37
|
+
input_tokens: number;
|
|
38
|
+
output_tokens: number;
|
|
39
|
+
cache_read_input_tokens: number;
|
|
40
|
+
};
|
|
41
|
+
modelUsage: Record<string, {
|
|
42
|
+
outputTokens: number;
|
|
43
|
+
inputTokens: number;
|
|
44
|
+
}>;
|
|
45
|
+
engine: 'qaify_agent';
|
|
46
|
+
provider: string;
|
|
47
|
+
error_code?: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function runAgent(options: AgentOptions): Promise<AgentOutput>;
|
|
50
|
+
/**
|
|
51
|
+
* Perkiraan token = karakter / 4. Di atas 70% konteks: isi hasil tool lama dipangkas (6 pesan terakhir utuh),
|
|
52
|
+
* struktur percakapan tetap (setiap tool_call tetap punya balasan).
|
|
53
|
+
*/
|
|
54
|
+
export declare function trimContext(messages: ChatMessage[], contextTokens: number): void;
|
|
55
|
+
/** Validator JSON Schema kecil (type, properties, required, items, enum) untuk hasil submit_result. */
|
|
56
|
+
export declare function validate(schema: any, value: unknown, path?: string): string[];
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Qaify Agent: loop agen tool-calling di runner untuk API key milik user (BYOK, OpenAI-compatible).
|
|
2
|
+
// Pengganti `claude -p` untuk agen yang diatur ke mesin Qaify Agent. Keluarannya berbentuk sama dengan
|
|
3
|
+
// `claude -p --output-format json --json-schema` (structured_output, usage, total_cost_usd, modelUsage) sehingga
|
|
4
|
+
// audit-submit / fix-submit / ai-submit tidak perlu tahu mesin mana yang dipakai.
|
|
5
|
+
import { chat, LlmError } from './llm.js';
|
|
6
|
+
import { LOCAL_TOOLS, truncate } from './tools.js';
|
|
7
|
+
export const SUBMIT_TOOL = 'submit_result';
|
|
8
|
+
const BASE_INSTRUCTIONS = `Anda adalah Qaify Agent, agen otomatis yang bekerja tanpa manusia di runner GitHub Actions.
|
|
9
|
+
- Kerjakan tugas dengan tool yang tersedia (membaca/mencari/mengubah berkas, menjalankan perintah, browser bila ada).
|
|
10
|
+
- Panggil beberapa tool sekaligus bila tidak saling bergantung. Jangan bertanya ke pengguna; tidak ada yang menjawab.
|
|
11
|
+
- Setelah selesai, WAJIB panggil tool ${SUBMIT_TOOL} tepat satu kali dengan hasil akhir sesuai skemanya.
|
|
12
|
+
Jangan menulis hasil akhir sebagai teks biasa.`;
|
|
13
|
+
export async function runAgent(options) {
|
|
14
|
+
const now = options.now ?? Date.now;
|
|
15
|
+
const log = options.log ?? (() => { });
|
|
16
|
+
const started = now();
|
|
17
|
+
const usage = { prompt: 0, completion: 0, cached: 0, cost: 0, costKnown: true };
|
|
18
|
+
const ctx = { cwd: options.cwd, env: options.env };
|
|
19
|
+
const local = options.tools.map((name) => LOCAL_TOOLS[name]).filter((t) => !!t);
|
|
20
|
+
const remote = new Map();
|
|
21
|
+
for (const server of options.mcp ?? [])
|
|
22
|
+
for (const tool of server.tools)
|
|
23
|
+
remote.set(tool.name, { server, remote: tool.remote });
|
|
24
|
+
const specs = [
|
|
25
|
+
...local.map(({ name, description, parameters }) => ({ name, description, parameters })),
|
|
26
|
+
...(options.mcp ?? []).flatMap((s) => s.tools.map(({ name, description, parameters }) => ({ name, description, parameters }))),
|
|
27
|
+
{ name: SUBMIT_TOOL, description: 'Kirim hasil akhir tugas (sekali, di akhir). Isi sesuai skema.', parameters: options.schema },
|
|
28
|
+
];
|
|
29
|
+
const messages = [
|
|
30
|
+
{ role: 'system', content: `${BASE_INSTRUCTIONS}\n\n${options.instructions}`.trim() },
|
|
31
|
+
{ role: 'user', content: options.prompt },
|
|
32
|
+
];
|
|
33
|
+
const finish = (subtype, isError, result, structured, errorCode, turns = 0) => ({
|
|
34
|
+
type: 'result',
|
|
35
|
+
subtype,
|
|
36
|
+
is_error: isError,
|
|
37
|
+
result,
|
|
38
|
+
...(structured !== undefined ? { structured_output: structured } : {}),
|
|
39
|
+
num_turns: turns,
|
|
40
|
+
duration_ms: now() - started,
|
|
41
|
+
total_cost_usd: usage.costKnown ? round(usage.cost) : null,
|
|
42
|
+
usage: { input_tokens: usage.prompt - usage.cached, output_tokens: usage.completion, cache_read_input_tokens: usage.cached },
|
|
43
|
+
modelUsage: { [options.llm.model]: { outputTokens: usage.completion, inputTokens: usage.prompt } },
|
|
44
|
+
engine: 'qaify_agent',
|
|
45
|
+
provider: options.llm.provider,
|
|
46
|
+
...(errorCode ? { error_code: errorCode } : {}),
|
|
47
|
+
});
|
|
48
|
+
let nudges = 0;
|
|
49
|
+
for (let turn = 1; turn <= options.maxTurns; turn++) {
|
|
50
|
+
trimContext(messages, options.contextTokens ?? 128_000);
|
|
51
|
+
if (turn === options.maxTurns - 1) {
|
|
52
|
+
messages.push({ role: 'user', content: `Giliran hampir habis. Segera panggil ${SUBMIT_TOOL} dengan hasil terbaik yang Anda punya.` });
|
|
53
|
+
}
|
|
54
|
+
let reply;
|
|
55
|
+
try {
|
|
56
|
+
reply = await chat(options.llm, messages, specs);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
const error = e instanceof LlmError ? e : new LlmError(e.message, 'provider_error');
|
|
60
|
+
log(`qaify-agent: ${error.message}`);
|
|
61
|
+
return finish(`error_${error.code}`, true, error.message, undefined, error.code, turn - 1);
|
|
62
|
+
}
|
|
63
|
+
addUsage(usage, reply.usage, options.price);
|
|
64
|
+
const message = reply.message;
|
|
65
|
+
messages.push(message);
|
|
66
|
+
const calls = message.tool_calls ?? [];
|
|
67
|
+
if (calls.length === 0) {
|
|
68
|
+
if (message.content)
|
|
69
|
+
log(`qaify-agent: ${truncate(message.content, 400)}`);
|
|
70
|
+
if (++nudges > 3)
|
|
71
|
+
return finish('error_no_result', true, `Model tidak memanggil ${SUBMIT_TOOL} setelah diingatkan.`, undefined, 'no_result', turn);
|
|
72
|
+
messages.push({ role: 'user', content: `Lanjutkan tugas memakai tool. Bila sudah selesai, panggil ${SUBMIT_TOOL} dengan hasil akhir sesuai skema.` });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
for (const call of calls) {
|
|
76
|
+
let args;
|
|
77
|
+
try {
|
|
78
|
+
args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: 'Error: argumen bukan JSON yang valid. Ulangi dengan JSON yang benar.' });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (call.function.name === SUBMIT_TOOL) {
|
|
85
|
+
const problems = validate(options.schema, args);
|
|
86
|
+
if (problems.length === 0) {
|
|
87
|
+
log(`qaify-agent: hasil dikirim setelah ${turn} giliran`);
|
|
88
|
+
return finish('success', false, 'Selesai.', args, undefined, turn);
|
|
89
|
+
}
|
|
90
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: `Hasil belum sesuai skema:\n- ${problems.slice(0, 15).join('\n- ')}\nPerbaiki lalu panggil ${SUBMIT_TOOL} lagi.` });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: await runTool(call.function.name, args, local, remote, ctx, log) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return finish('error_max_turns', true, `Batas ${options.maxTurns} giliran habis sebelum hasil dikirim.`, undefined, 'max_turns', options.maxTurns);
|
|
97
|
+
}
|
|
98
|
+
async function runTool(name, args, local, remote, ctx, log) {
|
|
99
|
+
const tool = local.find((t) => t.name === name);
|
|
100
|
+
const target = remote.get(name);
|
|
101
|
+
if (!tool && !target)
|
|
102
|
+
return `Error: tool ${name} tidak tersedia.`;
|
|
103
|
+
log(`qaify-agent: ${name} ${truncate(JSON.stringify(args ?? {}), 160)}`);
|
|
104
|
+
try {
|
|
105
|
+
return truncate(tool ? await tool.run(args, ctx) : await target.server.call(target.remote, args));
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
return `Error: ${e.message}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function addUsage(total, u, price) {
|
|
112
|
+
total.prompt += u.prompt_tokens;
|
|
113
|
+
total.completion += u.completion_tokens;
|
|
114
|
+
total.cached += u.cached_tokens;
|
|
115
|
+
if (u.cost !== null)
|
|
116
|
+
total.cost += u.cost;
|
|
117
|
+
else if (price?.input != null && price?.output != null)
|
|
118
|
+
total.cost += (u.prompt_tokens * price.input + u.completion_tokens * price.output) / 1_000_000;
|
|
119
|
+
else
|
|
120
|
+
total.costKnown = false;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Perkiraan token = karakter / 4. Di atas 70% konteks: isi hasil tool lama dipangkas (6 pesan terakhir utuh),
|
|
124
|
+
* struktur percakapan tetap (setiap tool_call tetap punya balasan).
|
|
125
|
+
*/
|
|
126
|
+
export function trimContext(messages, contextTokens) {
|
|
127
|
+
const budget = contextTokens * 0.7 * 4;
|
|
128
|
+
const size = () => messages.reduce((n, m) => n + (m.content?.length ?? 0) + JSON.stringify(m.tool_calls ?? '').length, 0);
|
|
129
|
+
if (size() <= budget)
|
|
130
|
+
return;
|
|
131
|
+
for (let i = 2; i < messages.length - 6 && size() > budget; i++) {
|
|
132
|
+
const m = messages[i];
|
|
133
|
+
if (m.role === 'tool' && (m.content?.length ?? 0) > 200)
|
|
134
|
+
m.content = '[hasil tool lama dipangkas untuk menghemat konteks]';
|
|
135
|
+
else if (m.role === 'assistant' && (m.content?.length ?? 0) > 2000)
|
|
136
|
+
m.content = `${m.content.slice(0, 500)}… [dipangkas]`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Validator JSON Schema kecil (type, properties, required, items, enum) untuk hasil submit_result. */
|
|
140
|
+
export function validate(schema, value, path = 'hasil') {
|
|
141
|
+
if (!schema || typeof schema !== 'object')
|
|
142
|
+
return [];
|
|
143
|
+
const errors = [];
|
|
144
|
+
const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
|
|
145
|
+
if (types.length && !types.some((t) => matchesType(t, value))) {
|
|
146
|
+
return [`${path} harus bertipe ${types.join('/')}`];
|
|
147
|
+
}
|
|
148
|
+
if (schema.enum && !schema.enum.includes(value))
|
|
149
|
+
errors.push(`${path} harus salah satu dari ${JSON.stringify(schema.enum)}`);
|
|
150
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
151
|
+
for (const key of schema.required ?? []) {
|
|
152
|
+
if (value[key] === undefined)
|
|
153
|
+
errors.push(`${path}.${key} wajib diisi`);
|
|
154
|
+
}
|
|
155
|
+
for (const [key, sub] of Object.entries(schema.properties ?? {})) {
|
|
156
|
+
const v = value[key];
|
|
157
|
+
if (v !== undefined && v !== null)
|
|
158
|
+
errors.push(...validate(sub, v, `${path}.${key}`));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(value) && schema.items)
|
|
162
|
+
value.forEach((v, i) => errors.push(...validate(schema.items, v, `${path}[${i}]`)));
|
|
163
|
+
return errors;
|
|
164
|
+
}
|
|
165
|
+
function matchesType(type, value) {
|
|
166
|
+
switch (type) {
|
|
167
|
+
case 'object': return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
168
|
+
case 'array': return Array.isArray(value);
|
|
169
|
+
case 'string': return typeof value === 'string';
|
|
170
|
+
case 'boolean': return typeof value === 'boolean';
|
|
171
|
+
case 'integer': return Number.isInteger(value);
|
|
172
|
+
case 'number': return typeof value === 'number';
|
|
173
|
+
case 'null': return value === null;
|
|
174
|
+
default: return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const round = (n) => Math.round(n * 1_000_000) / 1_000_000;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ToolSpec {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
parameters: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export interface LocalTool extends ToolSpec {
|
|
7
|
+
run(args: any, ctx: ToolContext): Promise<string>;
|
|
8
|
+
}
|
|
9
|
+
export interface ToolContext {
|
|
10
|
+
cwd: string;
|
|
11
|
+
/** env untuk perintah bash (tanpa QAIFY_AI_*) */
|
|
12
|
+
env: NodeJS.ProcessEnv;
|
|
13
|
+
}
|
|
14
|
+
/** Nama tool (untuk --tools) → definisi. */
|
|
15
|
+
export declare const LOCAL_TOOLS: Record<string, LocalTool>;
|
|
16
|
+
/** Path relatif / absolut → absolut di dalam folder kerja; keluar dari folder kerja ditolak. */
|
|
17
|
+
export declare function confine(cwd: string, path: unknown): string;
|
|
18
|
+
/** Glob sederhana: ** (lintas folder), * dan ? (dalam satu segmen), {a,b}. */
|
|
19
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
20
|
+
export declare function truncate(text: string, max?: number): string;
|
|
21
|
+
/** Cek ada / tidaknya berkas (dipakai test). */
|
|
22
|
+
export declare function exists(path: string): Promise<boolean>;
|