cdsa-harness 0.1.1 → 0.4.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/README.md +106 -44
- package/package.json +2 -2
- package/src/cli.js +321 -64
- package/src/config.js +23 -4
- package/src/llm.js +188 -49
- package/src/loop.js +65 -8
- package/src/plugins.js +191 -0
- package/src/skills.js +61 -0
- package/src/tools.js +43 -3
- package/workspace/.cdsa/plugins/word_count.mjs +31 -0
- package/workspace/.cdsa/skills/summarize.md +5 -0
package/src/llm.js
CHANGED
|
@@ -1,31 +1,58 @@
|
|
|
1
|
-
// LLM 호출 계층. OpenAI / OpenRouter (OpenAI 호환)
|
|
1
|
+
// LLM 호출 계층. OpenAI / OpenRouter (OpenAI 호환) / Anthropic(Claude) + mock.
|
|
2
2
|
// Node 18+ 내장 fetch 사용 → 외부 의존성 없음.
|
|
3
|
+
//
|
|
4
|
+
// 모든 provider 의 응답을 아래 한 가지 형태로 정규화해서 돌려준다(교육 모드용 메타 포함):
|
|
5
|
+
// { content, toolCalls:[{id,name,args}], usage:{input,output,total}|null,
|
|
6
|
+
// latencyMs, request:{ provider, endpoint, model, temperature, toolCount, bodyBytes } }
|
|
3
7
|
|
|
4
8
|
export class LLMError extends Error {}
|
|
5
9
|
|
|
6
10
|
const ENDPOINTS = {
|
|
7
11
|
openai: "https://api.openai.com/v1/chat/completions",
|
|
8
12
|
openrouter: "https://openrouter.ai/api/v1/chat/completions",
|
|
13
|
+
anthropic: "https://api.anthropic.com/v1/messages",
|
|
9
14
|
};
|
|
10
15
|
|
|
11
16
|
export class LLMClient {
|
|
12
|
-
constructor({ provider, apiKey, model, temperature = 0.2, timeout = 60000 }) {
|
|
17
|
+
constructor({ provider, apiKey, model, temperature = 0.2, maxTokens = 1024, timeout = 60000 }) {
|
|
13
18
|
this.provider = provider;
|
|
14
19
|
this.apiKey = apiKey;
|
|
15
20
|
this.model = model;
|
|
16
21
|
this.temperature = temperature;
|
|
22
|
+
this.maxTokens = maxTokens;
|
|
17
23
|
this.timeout = timeout;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
async chat(messages, tools) {
|
|
21
27
|
if (this.provider === "mock") return mockChat(messages);
|
|
22
|
-
if (
|
|
23
|
-
|
|
28
|
+
if (this.provider === "anthropic") return this._anthropicChat(messages, tools);
|
|
29
|
+
if (ENDPOINTS[this.provider]) return this._openaiChat(messages, tools);
|
|
30
|
+
throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async _post(url, headers, body) {
|
|
34
|
+
const json = JSON.stringify(body);
|
|
35
|
+
const ctrl = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeout);
|
|
37
|
+
const started = Date.now();
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetch(url, { method: "POST", headers, body: json, signal: ctrl.signal });
|
|
41
|
+
} catch (e) {
|
|
42
|
+
throw new LLMError(`네트워크 오류: ${e.message}`);
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
const latencyMs = Date.now() - started;
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const text = await res.text().catch(() => "");
|
|
49
|
+
throw new LLMError(`API 오류 ${res.status}: ${trim(text) || res.statusText}`);
|
|
24
50
|
}
|
|
25
|
-
return
|
|
51
|
+
return { payload: await res.json(), latencyMs, bodyBytes: Buffer.byteLength(json, "utf8") };
|
|
26
52
|
}
|
|
27
53
|
|
|
28
|
-
|
|
54
|
+
// --- OpenAI / OpenRouter (chat/completions 형식) ---
|
|
55
|
+
async _openaiChat(messages, tools) {
|
|
29
56
|
const url = ENDPOINTS[this.provider];
|
|
30
57
|
const body = { model: this.model, messages, temperature: this.temperature };
|
|
31
58
|
if (tools && tools.length) {
|
|
@@ -37,96 +64,206 @@ export class LLMClient {
|
|
|
37
64
|
"Content-Type": "application/json",
|
|
38
65
|
};
|
|
39
66
|
if (this.provider === "openrouter") {
|
|
40
|
-
headers["HTTP-Referer"] = "https://github.com/
|
|
67
|
+
headers["HTTP-Referer"] = "https://github.com/cdsassj00/miniharness";
|
|
41
68
|
headers["X-Title"] = "CDSA Harness";
|
|
42
69
|
}
|
|
70
|
+
const { payload, latencyMs, bodyBytes } = await this._post(url, headers, body);
|
|
71
|
+
const parsed = parseOpenAiReply(payload);
|
|
72
|
+
return {
|
|
73
|
+
...parsed,
|
|
74
|
+
latencyMs,
|
|
75
|
+
request: this._meta(url, tools, bodyBytes),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
43
78
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
79
|
+
// --- Anthropic (messages 형식) ---
|
|
80
|
+
async _anthropicChat(messages, tools) {
|
|
81
|
+
const url = ENDPOINTS.anthropic;
|
|
82
|
+
const body = toAnthropicBody(messages, tools, this.model, this.temperature, this.maxTokens);
|
|
83
|
+
const headers = {
|
|
84
|
+
"x-api-key": this.apiKey,
|
|
85
|
+
"anthropic-version": "2023-06-01",
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
};
|
|
88
|
+
const { payload, latencyMs, bodyBytes } = await this._post(url, headers, body);
|
|
89
|
+
const parsed = parseAnthropicReply(payload);
|
|
90
|
+
return {
|
|
91
|
+
...parsed,
|
|
92
|
+
latencyMs,
|
|
93
|
+
request: this._meta(url, tools, bodyBytes),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
59
96
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
97
|
+
_meta(endpoint, tools, bodyBytes) {
|
|
98
|
+
return {
|
|
99
|
+
provider: this.provider,
|
|
100
|
+
endpoint,
|
|
101
|
+
model: this.model,
|
|
102
|
+
temperature: this.temperature,
|
|
103
|
+
toolCount: tools ? tools.length : 0,
|
|
104
|
+
bodyBytes,
|
|
105
|
+
};
|
|
66
106
|
}
|
|
67
107
|
}
|
|
68
108
|
|
|
109
|
+
function trim(s) {
|
|
110
|
+
s = String(s || "");
|
|
111
|
+
return s.length > 400 ? s.slice(0, 400) + " …" : s;
|
|
112
|
+
}
|
|
113
|
+
|
|
69
114
|
function parseOpenAiReply(payload) {
|
|
70
115
|
const msg = payload?.choices?.[0]?.message;
|
|
71
116
|
if (!msg) {
|
|
72
|
-
throw new LLMError(
|
|
73
|
-
`예상치 못한 응답 형식입니다: ${JSON.stringify(payload).slice(0, 300)}`
|
|
74
|
-
);
|
|
117
|
+
throw new LLMError(`예상치 못한 응답 형식입니다: ${JSON.stringify(payload).slice(0, 300)}`);
|
|
75
118
|
}
|
|
76
119
|
const toolCalls = [];
|
|
77
120
|
for (const tc of msg.tool_calls || []) {
|
|
78
121
|
let args = {};
|
|
79
122
|
try {
|
|
80
|
-
args =
|
|
81
|
-
|
|
82
|
-
|
|
123
|
+
args =
|
|
124
|
+
typeof tc.function?.arguments === "string"
|
|
125
|
+
? JSON.parse(tc.function.arguments || "{}")
|
|
126
|
+
: tc.function?.arguments || {};
|
|
83
127
|
} catch {
|
|
84
128
|
args = { _raw: tc.function?.arguments };
|
|
85
129
|
}
|
|
86
130
|
toolCalls.push({ id: tc.id || `call_${toolCalls.length}`, name: tc.function?.name || "", args });
|
|
87
131
|
}
|
|
88
|
-
|
|
132
|
+
const u = payload.usage;
|
|
133
|
+
const usage = u
|
|
134
|
+
? { input: u.prompt_tokens ?? null, output: u.completion_tokens ?? null, total: u.total_tokens ?? null }
|
|
135
|
+
: null;
|
|
136
|
+
return { content: msg.content ?? null, toolCalls, usage };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 내부(OpenAI 형식) 메시지를 Anthropic messages 형식으로 변환.
|
|
140
|
+
// - system 메시지 → 최상위 system 필드
|
|
141
|
+
// - assistant tool_calls → content 의 tool_use 블록
|
|
142
|
+
// - tool 메시지 → user 의 tool_result 블록 (연속된 것은 하나의 user 로 합침)
|
|
143
|
+
export function toAnthropicBody(messages, tools, model, temperature, maxTokens) {
|
|
144
|
+
let system = "";
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const m of messages) {
|
|
147
|
+
if (m.role === "system") {
|
|
148
|
+
system += (system ? "\n" : "") + (m.content || "");
|
|
149
|
+
} else if (m.role === "user") {
|
|
150
|
+
pushUserBlock(out, { type: "text", text: m.content || "" });
|
|
151
|
+
} else if (m.role === "assistant") {
|
|
152
|
+
const content = [];
|
|
153
|
+
if (m.content) content.push({ type: "text", text: m.content });
|
|
154
|
+
for (const tc of m.tool_calls || []) {
|
|
155
|
+
let input = {};
|
|
156
|
+
try {
|
|
157
|
+
input = JSON.parse(tc.function?.arguments || "{}");
|
|
158
|
+
} catch {
|
|
159
|
+
input = {};
|
|
160
|
+
}
|
|
161
|
+
content.push({ type: "tool_use", id: tc.id, name: tc.function?.name, input });
|
|
162
|
+
}
|
|
163
|
+
out.push({ role: "assistant", content: content.length ? content : [{ type: "text", text: "" }] });
|
|
164
|
+
} else if (m.role === "tool") {
|
|
165
|
+
pushUserBlock(out, { type: "tool_result", tool_use_id: m.tool_call_id, content: m.content || "" });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const body = { model, max_tokens: maxTokens, system, messages: out, temperature };
|
|
169
|
+
if (tools && tools.length) {
|
|
170
|
+
body.tools = tools.map((t) => ({
|
|
171
|
+
name: t.function.name,
|
|
172
|
+
description: t.function.description,
|
|
173
|
+
input_schema: t.function.parameters,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
return body;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 직전 메시지가 user 면 블록을 이어붙이고, 아니면 새 user 메시지를 만든다.
|
|
180
|
+
function pushUserBlock(out, block) {
|
|
181
|
+
const last = out[out.length - 1];
|
|
182
|
+
if (last && last.role === "user") last.content.push(block);
|
|
183
|
+
else out.push({ role: "user", content: [block] });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseAnthropicReply(payload) {
|
|
187
|
+
if (payload?.type === "error") {
|
|
188
|
+
throw new LLMError(`Anthropic 오류: ${payload.error?.message || JSON.stringify(payload)}`);
|
|
189
|
+
}
|
|
190
|
+
const blocks = payload?.content || [];
|
|
191
|
+
let text = "";
|
|
192
|
+
const toolCalls = [];
|
|
193
|
+
for (const b of blocks) {
|
|
194
|
+
if (b.type === "text") text += b.text;
|
|
195
|
+
else if (b.type === "tool_use") toolCalls.push({ id: b.id, name: b.name, args: b.input || {} });
|
|
196
|
+
}
|
|
197
|
+
const u = payload?.usage;
|
|
198
|
+
const usage = u
|
|
199
|
+
? {
|
|
200
|
+
input: u.input_tokens ?? null,
|
|
201
|
+
output: u.output_tokens ?? null,
|
|
202
|
+
total: (u.input_tokens || 0) + (u.output_tokens || 0),
|
|
203
|
+
}
|
|
204
|
+
: null;
|
|
205
|
+
return { content: text || null, toolCalls, usage };
|
|
89
206
|
}
|
|
90
207
|
|
|
91
208
|
// ---------------------------------------------------------------------------
|
|
92
209
|
// Mock 에이전트: 키 없이 Agent Loop 전체를 체험.
|
|
93
|
-
//
|
|
210
|
+
// 인사/잡담엔 그냥 대화로 답하고, 파일 작업을 시킬 때만 도구 데모를 돌린다.
|
|
94
211
|
// ---------------------------------------------------------------------------
|
|
212
|
+
const FILE_TASK_RE =
|
|
213
|
+
/(파일|폴더|디렉|notes|\.txt|\.md|\.js|읽|쓰|수정|고치|만들|생성|추가|삭제|편집|list|read|write|만들어|적어|기록)/i;
|
|
214
|
+
|
|
215
|
+
function mockReturn(extra) {
|
|
216
|
+
return { usage: null, latencyMs: 1, request: { provider: "mock", endpoint: "(mock)", model: "mock-agent", temperature: 0, toolCount: 0, bodyBytes: 0 }, ...extra };
|
|
217
|
+
}
|
|
218
|
+
|
|
95
219
|
function mockChat(messages) {
|
|
96
220
|
const toolMsgs = messages.filter((m) => m.role === "tool");
|
|
97
221
|
const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content || "";
|
|
98
222
|
const phase = toolMsgs.length;
|
|
99
223
|
|
|
224
|
+
// 아직 도구를 쓰기 전 + 파일 작업 요청이 아니면 → 그냥 대화로 응답(도구 X)
|
|
225
|
+
if (phase === 0 && !FILE_TASK_RE.test(lastUser)) {
|
|
226
|
+
return mockReturn({
|
|
227
|
+
content:
|
|
228
|
+
"안녕하세요! 저는 CDSA Harness 의 mock(연습) 에이전트예요. 실제 AI 는 아니고, " +
|
|
229
|
+
"에이전트가 도구로 파일을 다루는 흐름을 보여주는 데모입니다.\n" +
|
|
230
|
+
"예) \"notes.txt 맨 아래에 할 일 3개 추가해줘\" 처럼 파일 작업을 시켜보세요. " +
|
|
231
|
+
"진짜 AI 와 대화하려면 /setup 으로 OpenAI·Claude 키를 연결하면 됩니다.",
|
|
232
|
+
toolCalls: [],
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
100
236
|
if (phase === 0) {
|
|
101
|
-
return {
|
|
237
|
+
return mockReturn({
|
|
102
238
|
content: "작업 폴더 구조부터 확인하겠습니다.",
|
|
103
239
|
toolCalls: [{ id: "mock_1", name: "list_dir", args: { path: "." } }],
|
|
104
|
-
};
|
|
240
|
+
});
|
|
105
241
|
}
|
|
106
242
|
if (phase === 1) {
|
|
107
243
|
const target = guessFile(toolMsgs[0].content || "", lastUser);
|
|
108
|
-
return {
|
|
244
|
+
return mockReturn({
|
|
109
245
|
content: `\`${target}\` 파일을 읽어 현재 내용을 확인하겠습니다.`,
|
|
110
246
|
toolCalls: [{ id: "mock_2", name: "read_file", args: { path: target } }],
|
|
111
|
-
};
|
|
247
|
+
});
|
|
112
248
|
}
|
|
113
249
|
if (phase === 2) {
|
|
114
250
|
const target = guessFile(toolMsgs[0].content || "", lastUser);
|
|
115
251
|
const current = toolMsgs[1].content || "";
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
252
|
+
const base = /파일이 없습니다|경로가 없습니다/.test(current) ? "" : current;
|
|
253
|
+
const addition = `# (CDSA Harness mock 에이전트가 추가) 요청: ${lastUser.trim().slice(0, 60)}`;
|
|
254
|
+
const newContent = (base.replace(/\n+$/, "") + "\n\n" + addition + "\n").replace(/^\n+/, "");
|
|
255
|
+
return mockReturn({
|
|
256
|
+
content: `\`${target}\` 에 메모 한 줄을 추가하는 수정을 제안합니다.`,
|
|
120
257
|
toolCalls: [{ id: "mock_3", name: "write_file", args: { path: target, content: newContent } }],
|
|
121
|
-
};
|
|
258
|
+
});
|
|
122
259
|
}
|
|
123
|
-
return {
|
|
260
|
+
return mockReturn({
|
|
124
261
|
content:
|
|
125
262
|
"완료했습니다. 방금까지의 흐름이 바로 하네스의 Agent Loop 입니다:\n" +
|
|
126
|
-
"①
|
|
127
|
-
"실제 LLM
|
|
263
|
+
"① 폴더 보기 → ② 파일 읽기 → ③ 수정 제안 → ④ 승인 → ⑤ 저장 → ⑥ 결과를 다시 모델에 전달.\n" +
|
|
264
|
+
"실제 LLM 으로 같은 흐름을 보려면 /setup 으로 키를 연결하세요.",
|
|
128
265
|
toolCalls: [],
|
|
129
|
-
};
|
|
266
|
+
});
|
|
130
267
|
}
|
|
131
268
|
|
|
132
269
|
function guessFile(listing, userText) {
|
|
@@ -142,5 +279,7 @@ function guessFile(listing, userText) {
|
|
|
142
279
|
for (const name of files) {
|
|
143
280
|
if (userText.includes(name)) return name;
|
|
144
281
|
}
|
|
282
|
+
const m = userText.match(/[\w./-]+\.(txt|md|js|json|py|ts|csv)/i);
|
|
283
|
+
if (m) return m[0];
|
|
145
284
|
return files[0] || "notes.txt";
|
|
146
285
|
}
|
package/src/loop.js
CHANGED
|
@@ -7,7 +7,7 @@ import fs from "node:fs";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
|
|
9
9
|
import { LLMError } from "./llm.js";
|
|
10
|
-
import {
|
|
10
|
+
import { TOOL_LABELS, ToolError, toolSchemas } from "./tools.js";
|
|
11
11
|
|
|
12
12
|
// 단계(Step) 상수
|
|
13
13
|
export const Step = {
|
|
@@ -19,6 +19,7 @@ export const Step = {
|
|
|
19
19
|
APPROVAL: "approval",
|
|
20
20
|
TOOL_RUN: "tool_run",
|
|
21
21
|
TOOL_RESULT: "tool_result",
|
|
22
|
+
FEEDBACK: "feedback",
|
|
22
23
|
DONE: "done",
|
|
23
24
|
ERROR: "error",
|
|
24
25
|
};
|
|
@@ -32,10 +33,35 @@ export const STEP_LABELS = {
|
|
|
32
33
|
approval: "사용자 승인",
|
|
33
34
|
tool_run: "도구 실행",
|
|
34
35
|
tool_result: "결과 반영",
|
|
36
|
+
feedback: "결과 되먹임",
|
|
35
37
|
done: "완료",
|
|
36
38
|
error: "오류",
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
// 아주 거친 토큰 추정(영문 ~4자/토큰, 한글은 더 많지만 교육용 어림값).
|
|
42
|
+
export function estimateTokens(text) {
|
|
43
|
+
return Math.max(1, Math.round((text || "").length / 4));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// messages 를 교육용으로 요약: 역할별 글자수 + 도구호출 수.
|
|
47
|
+
function summarizeMessages(messages) {
|
|
48
|
+
let totalChars = 0;
|
|
49
|
+
const rows = messages.map((m) => {
|
|
50
|
+
const text = m.content || "";
|
|
51
|
+
let chars = text.length;
|
|
52
|
+
let extra = "";
|
|
53
|
+
if (m.tool_calls && m.tool_calls.length) {
|
|
54
|
+
const j = JSON.stringify(m.tool_calls);
|
|
55
|
+
chars += j.length;
|
|
56
|
+
extra = ` +tool_calls(${m.tool_calls.length})`;
|
|
57
|
+
}
|
|
58
|
+
if (m.role === "tool") extra = " (도구 결과)";
|
|
59
|
+
totalChars += chars;
|
|
60
|
+
return { role: m.role, chars, extra };
|
|
61
|
+
});
|
|
62
|
+
return { rows, totalChars, estTokens: estimateTokens(messages.map((m) => (m.content || "") + JSON.stringify(m.tool_calls || "")).join("")) };
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
const RULES_FILENAMES = ["AGENT.md", "AGENTS.md", "CLAUDE.md", "rules.md", "RULES.md"];
|
|
40
66
|
|
|
41
67
|
function findRules(workspace) {
|
|
@@ -98,7 +124,13 @@ export class AgentLoop {
|
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
reset() {
|
|
101
|
-
this.
|
|
127
|
+
this.systemPromptText = this._systemPrompt();
|
|
128
|
+
this.messages = [{ role: "system", content: this.systemPromptText }];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// /context 명령용: 현재 대화 컨텍스트 요약.
|
|
132
|
+
contextSummary() {
|
|
133
|
+
return { ...summarizeMessages(this.messages), systemPrompt: this.systemPromptText };
|
|
102
134
|
}
|
|
103
135
|
|
|
104
136
|
async run(userInput) {
|
|
@@ -113,14 +145,27 @@ export class AgentLoop {
|
|
|
113
145
|
"규칙 파일 + 작업 폴더 내용을 시스템 프롬프트로 묶어 모델에 전달합니다."
|
|
114
146
|
);
|
|
115
147
|
|
|
116
|
-
const tools = toolSchemas(this.config.allow_shell);
|
|
148
|
+
const tools = toolSchemas(this.config.allow_shell, this.toolbox.plugins);
|
|
149
|
+
const toolNames = tools.map((t) => t.function.name);
|
|
117
150
|
let finalText = "";
|
|
118
151
|
|
|
119
152
|
for (let stepNo = 1; stepNo <= this.config.max_steps; stepNo++) {
|
|
153
|
+
// ② 모델에 보내는 컨텍스트를 그대로 드러낸다(교육 모드 핵심).
|
|
154
|
+
const ctx = summarizeMessages(this.messages);
|
|
120
155
|
this._emit(
|
|
121
156
|
Step.MODEL_CALL,
|
|
122
|
-
`LLM 호출 (반복 ${stepNo})`,
|
|
123
|
-
`메시지 ${this.messages.length}
|
|
157
|
+
`LLM 호출 (반복 ${stepNo}/${this.config.max_steps})`,
|
|
158
|
+
`메시지 ${this.messages.length}개(추정 ${ctx.estTokens} 토큰)를 모델에 전송합니다.`,
|
|
159
|
+
{
|
|
160
|
+
iteration: stepNo,
|
|
161
|
+
provider: this.config.provider,
|
|
162
|
+
model: this.config.model,
|
|
163
|
+
messages: ctx.rows,
|
|
164
|
+
totalChars: ctx.totalChars,
|
|
165
|
+
estTokens: ctx.estTokens,
|
|
166
|
+
tools: toolNames,
|
|
167
|
+
systemPrompt: stepNo === 1 ? this.systemPromptText : null,
|
|
168
|
+
}
|
|
124
169
|
);
|
|
125
170
|
|
|
126
171
|
let reply;
|
|
@@ -134,8 +179,12 @@ export class AgentLoop {
|
|
|
134
179
|
throw e;
|
|
135
180
|
}
|
|
136
181
|
|
|
182
|
+
// ③ 모델의 원본 판단 + 실측 메타(응답시간/토큰/요청크기)를 드러낸다.
|
|
137
183
|
this._emit(Step.MODEL_REPLY, "모델 응답", reply.content || "(텍스트 없음)", {
|
|
138
184
|
toolCalls: reply.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })),
|
|
185
|
+
usage: reply.usage || null,
|
|
186
|
+
latencyMs: reply.latencyMs ?? null,
|
|
187
|
+
request: reply.request || null,
|
|
139
188
|
});
|
|
140
189
|
if (reply.content) finalText = reply.content;
|
|
141
190
|
|
|
@@ -163,6 +212,14 @@ export class AgentLoop {
|
|
|
163
212
|
const resultText = await this._handleToolCall(tc);
|
|
164
213
|
this.messages.push({ role: "tool", tool_call_id: tc.id, content: resultText });
|
|
165
214
|
}
|
|
215
|
+
|
|
216
|
+
// ⑤ 도구 결과를 messages 에 넣고 다시 ②로 — 이 되먹임이 'Loop' 의 정체.
|
|
217
|
+
this._emit(
|
|
218
|
+
Step.FEEDBACK,
|
|
219
|
+
"결과 되먹임",
|
|
220
|
+
`도구 결과를 대화에 추가했습니다(현재 메시지 ${this.messages.length}개). 같은 컨텍스트로 다시 모델을 호출합니다.`,
|
|
221
|
+
{ messageCount: this.messages.length }
|
|
222
|
+
);
|
|
166
223
|
}
|
|
167
224
|
|
|
168
225
|
this._emit(Step.DONE, "반복 한도 도달", `max_steps(${this.config.max_steps})에 도달해 종료했습니다.`);
|
|
@@ -170,8 +227,8 @@ export class AgentLoop {
|
|
|
170
227
|
}
|
|
171
228
|
|
|
172
229
|
async _handleToolCall(tc) {
|
|
173
|
-
const label = TOOL_LABELS[tc.name] || tc.name;
|
|
174
|
-
const needsApproval =
|
|
230
|
+
const label = this.toolbox.label ? this.toolbox.label(tc.name) : TOOL_LABELS[tc.name] || tc.name;
|
|
231
|
+
const needsApproval = this.toolbox.isMutating(tc.name);
|
|
175
232
|
|
|
176
233
|
if (needsApproval && this.config.approval_mode === "manual") {
|
|
177
234
|
const req = this._buildApprovalRequest(tc);
|
|
@@ -193,7 +250,7 @@ export class AgentLoop {
|
|
|
193
250
|
|
|
194
251
|
this._emit(Step.TOOL_RUN, `도구 실행: ${label}`, JSON.stringify(tc.args).slice(0, 2000));
|
|
195
252
|
try {
|
|
196
|
-
const result = this.toolbox.execute(tc.name, tc.args);
|
|
253
|
+
const result = await this.toolbox.execute(tc.name, tc.args);
|
|
197
254
|
this._emit(Step.TOOL_RESULT, `결과 반영: ${label}`, (result.output || "").slice(0, 4000));
|
|
198
255
|
return result.output;
|
|
199
256
|
} catch (e) {
|
package/src/plugins.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// 플러그인 시스템 — OpenCode 스타일 확장성.
|
|
2
|
+
// `.cdsa/plugins/` (작업 폴더) 와 `~/.cdsa_harness/plugins/` (전역) 의 .js/.mjs 파일을
|
|
3
|
+
// 불러와 '새 도구'로 등록한다. 각 파일은 아래 형태의 객체를 default export 한다:
|
|
4
|
+
//
|
|
5
|
+
// export default {
|
|
6
|
+
// name: "word_count",
|
|
7
|
+
// description: "텍스트 파일의 글자/줄 수를 센다",
|
|
8
|
+
// parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
9
|
+
// mutating: false, // true 면 실행 전 사용자 승인 필요
|
|
10
|
+
// async handler(args, ctx) { ... } // ctx = { workspace }
|
|
11
|
+
// }
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
export function pluginDirs(workspace) {
|
|
19
|
+
return [
|
|
20
|
+
path.join(os.homedir(), ".cdsa_harness", "plugins"),
|
|
21
|
+
path.join(workspace, ".cdsa", "plugins"),
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadPlugins(workspace) {
|
|
26
|
+
const plugins = [];
|
|
27
|
+
for (const dir of pluginDirs(workspace)) {
|
|
28
|
+
let files = [];
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(dir)) continue;
|
|
31
|
+
files = fs.readdirSync(dir).filter((f) => /\.(mjs|js)$/.test(f)).sort();
|
|
32
|
+
} catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
const full = path.join(dir, f);
|
|
37
|
+
try {
|
|
38
|
+
const mod = await import(pathToFileURL(full).href);
|
|
39
|
+
const def = mod.default || mod.plugin || mod;
|
|
40
|
+
if (!def || !def.name || typeof def.handler !== "function") {
|
|
41
|
+
plugins.push({ error: `${f}: name/handler 가 없습니다` });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
plugins.push({
|
|
45
|
+
name: def.name,
|
|
46
|
+
description: def.description || "",
|
|
47
|
+
parameters: def.parameters || { type: "object", properties: {} },
|
|
48
|
+
mutating: Boolean(def.mutating),
|
|
49
|
+
handler: def.handler,
|
|
50
|
+
source: full,
|
|
51
|
+
});
|
|
52
|
+
} catch (e) {
|
|
53
|
+
plugins.push({ error: `${f}: ${e.message}` });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return plugins;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// npm 패키지로 설치한 플러그인 자동 발견.
|
|
62
|
+
// - 이름이 `cdsa-harness-plugin-*` (또는 `@scope/cdsa-harness-plugin-*`)
|
|
63
|
+
// - 또는 package.json 에 keywords: ["cdsa-harness-plugin"] / "cdsaHarness" 필드
|
|
64
|
+
// 인 패키지를 node_modules 에서 찾아 불러온다.
|
|
65
|
+
// 패키지는 다음 중 하나를 default export:
|
|
66
|
+
// · 플러그인 def 객체 · def 배열 · { tools:[...], skills:[{name,description,body}] }
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
function isPluginPackage(pkgJson, name) {
|
|
69
|
+
if (/^(@[^/]+\/)?cdsa-harness-plugin-/.test(name)) return true;
|
|
70
|
+
if (pkgJson && pkgJson.cdsaHarness) return true;
|
|
71
|
+
const kw = (pkgJson && pkgJson.keywords) || [];
|
|
72
|
+
return Array.isArray(kw) && kw.includes("cdsa-harness-plugin");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function importPackage(nmDir, name) {
|
|
76
|
+
const req = createRequire(path.join(nmDir, "__cdsa_resolve__.js"));
|
|
77
|
+
const entry = req.resolve(name);
|
|
78
|
+
return import(pathToFileURL(entry).href);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeModule(mod, source) {
|
|
82
|
+
const out = { plugins: [], skills: [] };
|
|
83
|
+
const def = (mod && (mod.default ?? mod)) || null;
|
|
84
|
+
if (!def) return out;
|
|
85
|
+
let tools = [];
|
|
86
|
+
let skills = [];
|
|
87
|
+
if (Array.isArray(def)) tools = def;
|
|
88
|
+
else if (def.tools || def.skills) {
|
|
89
|
+
tools = def.tools || [];
|
|
90
|
+
skills = def.skills || [];
|
|
91
|
+
} else if (def.name && typeof def.handler === "function") tools = [def];
|
|
92
|
+
for (const t of tools) {
|
|
93
|
+
if (t && t.name && typeof t.handler === "function") {
|
|
94
|
+
out.plugins.push({
|
|
95
|
+
name: t.name,
|
|
96
|
+
description: t.description || "",
|
|
97
|
+
parameters: t.parameters || { type: "object", properties: {} },
|
|
98
|
+
mutating: Boolean(t.mutating),
|
|
99
|
+
handler: t.handler,
|
|
100
|
+
source,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const s of skills) if (s && s.name && s.body) out.skills.push(s);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 한 node_modules 디렉터리를 훑어 플러그인 패키지를 모은다.
|
|
109
|
+
export async function scanNodeModules(nmDir) {
|
|
110
|
+
const result = { plugins: [], skills: [], errors: [] };
|
|
111
|
+
let entries = [];
|
|
112
|
+
try {
|
|
113
|
+
if (!fs.existsSync(nmDir)) return result;
|
|
114
|
+
entries = fs.readdirSync(nmDir);
|
|
115
|
+
} catch {
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
const names = [];
|
|
119
|
+
for (const e of entries) {
|
|
120
|
+
if (e.startsWith(".")) continue;
|
|
121
|
+
if (e.startsWith("@")) {
|
|
122
|
+
try {
|
|
123
|
+
for (const sub of fs.readdirSync(path.join(nmDir, e))) names.push(`${e}/${sub}`);
|
|
124
|
+
} catch {
|
|
125
|
+
/* ignore */
|
|
126
|
+
}
|
|
127
|
+
} else names.push(e);
|
|
128
|
+
}
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
let pkgJson = {};
|
|
131
|
+
try {
|
|
132
|
+
pkgJson = JSON.parse(fs.readFileSync(path.join(nmDir, name, "package.json"), "utf8"));
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!isPluginPackage(pkgJson, name)) continue;
|
|
137
|
+
try {
|
|
138
|
+
const mod = await importPackage(nmDir, name);
|
|
139
|
+
const norm = normalizeModule(mod, name);
|
|
140
|
+
result.plugins.push(...norm.plugins);
|
|
141
|
+
result.skills.push(...norm.skills);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
result.errors.push(`${name}: ${e.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// cwd 의 node_modules + cdsa-harness 자신의 node_modules(전역 설치 시 형제 패키지) 를 훑고,
|
|
150
|
+
// config.plugins 에 적힌 패키지는 이름 규칙과 무관하게 강제로 불러온다.
|
|
151
|
+
export async function discoverNpmExtensions(cwd, explicitNames = []) {
|
|
152
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
153
|
+
const nmDirs = [];
|
|
154
|
+
const add = (d) => {
|
|
155
|
+
const r = path.resolve(d);
|
|
156
|
+
if (!nmDirs.includes(r)) nmDirs.push(r);
|
|
157
|
+
};
|
|
158
|
+
add(path.join(cwd, "node_modules"));
|
|
159
|
+
add(path.resolve(here, "..", "..")); // .../node_modules/cdsa-harness/src → .../node_modules
|
|
160
|
+
|
|
161
|
+
const merged = { plugins: [], skills: [], errors: [] };
|
|
162
|
+
for (const nm of nmDirs) {
|
|
163
|
+
const r = await scanNodeModules(nm);
|
|
164
|
+
merged.plugins.push(...r.plugins);
|
|
165
|
+
merged.skills.push(...r.skills);
|
|
166
|
+
merged.errors.push(...r.errors);
|
|
167
|
+
}
|
|
168
|
+
for (const name of explicitNames) {
|
|
169
|
+
if (merged.plugins.some((p) => p.source === name)) continue;
|
|
170
|
+
let loaded = false;
|
|
171
|
+
for (const nm of nmDirs) {
|
|
172
|
+
try {
|
|
173
|
+
const norm = normalizeModule(await importPackage(nm, name), name);
|
|
174
|
+
if (norm.plugins.length || norm.skills.length) {
|
|
175
|
+
merged.plugins.push(...norm.plugins);
|
|
176
|
+
merged.skills.push(...norm.skills);
|
|
177
|
+
loaded = true;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
/* try next dir */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!loaded) merged.errors.push(`${name}: 불러올 수 없음(설치되어 있나요? npm i ${name})`);
|
|
185
|
+
}
|
|
186
|
+
// 이름 중복 제거(먼저 발견된 것 우선)
|
|
187
|
+
const byName = new Map();
|
|
188
|
+
for (const p of merged.plugins) if (!byName.has(p.name)) byName.set(p.name, p);
|
|
189
|
+
merged.plugins = [...byName.values()];
|
|
190
|
+
return merged;
|
|
191
|
+
}
|