agent-tool-forge 0.3.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/LICENSE +21 -0
- package/README.md +209 -0
- package/lib/agent-registry.js +170 -0
- package/lib/api-client.js +792 -0
- package/lib/api-loader.js +260 -0
- package/lib/auth.d.ts +25 -0
- package/lib/auth.js +158 -0
- package/lib/checks/check-adapter.js +172 -0
- package/lib/checks/compose.js +42 -0
- package/lib/checks/content-match.js +14 -0
- package/lib/checks/cost-budget.js +11 -0
- package/lib/checks/index.js +18 -0
- package/lib/checks/json-valid.js +15 -0
- package/lib/checks/latency.js +11 -0
- package/lib/checks/length-bounds.js +17 -0
- package/lib/checks/negative-match.js +14 -0
- package/lib/checks/no-hallucinated-numbers.js +63 -0
- package/lib/checks/non-empty.js +34 -0
- package/lib/checks/regex-match.js +12 -0
- package/lib/checks/run-checks.js +84 -0
- package/lib/checks/schema-match.js +26 -0
- package/lib/checks/tool-call-count.js +16 -0
- package/lib/checks/tool-selection.js +34 -0
- package/lib/checks/types.js +45 -0
- package/lib/comparison/compare.js +86 -0
- package/lib/comparison/format.js +104 -0
- package/lib/comparison/index.js +6 -0
- package/lib/comparison/statistics.js +59 -0
- package/lib/comparison/types.js +41 -0
- package/lib/config-schema.js +200 -0
- package/lib/config.d.ts +66 -0
- package/lib/conversation-store.d.ts +77 -0
- package/lib/conversation-store.js +443 -0
- package/lib/db.d.ts +6 -0
- package/lib/db.js +1112 -0
- package/lib/dep-check.js +99 -0
- package/lib/drift-background.js +61 -0
- package/lib/drift-monitor.js +187 -0
- package/lib/eval-runner.js +566 -0
- package/lib/fixtures/fixture-store.js +161 -0
- package/lib/fixtures/index.js +11 -0
- package/lib/forge-engine.js +982 -0
- package/lib/forge-eval-generator.js +417 -0
- package/lib/forge-file-writer.js +386 -0
- package/lib/forge-service-client.js +190 -0
- package/lib/forge-service.d.ts +4 -0
- package/lib/forge-service.js +655 -0
- package/lib/forge-verifier-generator.js +271 -0
- package/lib/handlers/admin.js +151 -0
- package/lib/handlers/agents.js +229 -0
- package/lib/handlers/chat-resume.js +334 -0
- package/lib/handlers/chat-sync.js +320 -0
- package/lib/handlers/chat.js +320 -0
- package/lib/handlers/conversations.js +92 -0
- package/lib/handlers/preferences.js +88 -0
- package/lib/handlers/tools-list.js +58 -0
- package/lib/hitl-engine.d.ts +60 -0
- package/lib/hitl-engine.js +261 -0
- package/lib/http-utils.js +92 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +141 -0
- package/lib/init.js +636 -0
- package/lib/manual-entry.js +59 -0
- package/lib/mcp-server.js +252 -0
- package/lib/output-groups.js +54 -0
- package/lib/postgres-store.d.ts +31 -0
- package/lib/postgres-store.js +465 -0
- package/lib/preference-store.d.ts +47 -0
- package/lib/preference-store.js +79 -0
- package/lib/prompt-store.d.ts +42 -0
- package/lib/prompt-store.js +60 -0
- package/lib/rate-limiter.d.ts +30 -0
- package/lib/rate-limiter.js +104 -0
- package/lib/react-engine.d.ts +110 -0
- package/lib/react-engine.js +337 -0
- package/lib/runner/cli.js +156 -0
- package/lib/runner/cost-estimator.js +71 -0
- package/lib/runner/gate.js +46 -0
- package/lib/runner/index.js +165 -0
- package/lib/sidecar.d.ts +83 -0
- package/lib/sidecar.js +161 -0
- package/lib/sse.d.ts +15 -0
- package/lib/sse.js +30 -0
- package/lib/tools-scanner.js +91 -0
- package/lib/tui.js +253 -0
- package/lib/verifier-report.js +78 -0
- package/lib/verifier-runner.js +338 -0
- package/lib/verifier-scanner.js +70 -0
- package/lib/verifier-worker-pool.js +196 -0
- package/lib/views/chat.js +340 -0
- package/lib/views/endpoints.js +203 -0
- package/lib/views/eval-run.js +206 -0
- package/lib/views/forge-agent.js +538 -0
- package/lib/views/forge.js +410 -0
- package/lib/views/main-menu.js +275 -0
- package/lib/views/mediation.js +381 -0
- package/lib/views/model-compare.js +430 -0
- package/lib/views/model-comparison.js +333 -0
- package/lib/views/onboarding.js +470 -0
- package/lib/views/performance.js +237 -0
- package/lib/views/run-evals.js +205 -0
- package/lib/views/settings.js +829 -0
- package/lib/views/tools-evals.js +514 -0
- package/lib/views/verifier-coverage.js +617 -0
- package/lib/workers/verifier-worker.js +52 -0
- package/package.json +123 -0
- package/widget/forge-chat.js +789 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge File Writer — generates tool file content via LLM.
|
|
3
|
+
* Does NOT write to disk — returns content strings for forge.js to preview and confirm.
|
|
4
|
+
*
|
|
5
|
+
* @module forge-file-writer
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { llmTurn } from './api-client.js';
|
|
9
|
+
|
|
10
|
+
// ── JSON extraction ────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract a JSON object from raw LLM response text.
|
|
14
|
+
* Tries ```json...``` fenced block first, then falls back to first `{` to
|
|
15
|
+
* its matching closing `}`.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} text - Raw LLM response text
|
|
18
|
+
* @returns {object} Parsed JSON object
|
|
19
|
+
* @throws {Error} If no valid JSON object can be found or parsed
|
|
20
|
+
*/
|
|
21
|
+
function extractJson(text) {
|
|
22
|
+
// Strategy 1: ```json ... ``` fenced block
|
|
23
|
+
const fenceMatch = text.match(/```json\s*([\s\S]*?)\s*```/);
|
|
24
|
+
if (fenceMatch) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(fenceMatch[1]);
|
|
27
|
+
} catch (_) {
|
|
28
|
+
// Fenced block was malformed JSON — fall through to strategy 2
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Strategy 2: first `{` to its matching `}`
|
|
33
|
+
const start = text.indexOf('{');
|
|
34
|
+
if (start === -1) {
|
|
35
|
+
throw new Error('No JSON object found in LLM response');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let depth = 0;
|
|
39
|
+
let inString = false;
|
|
40
|
+
let escape = false;
|
|
41
|
+
|
|
42
|
+
for (let i = start; i < text.length; i++) {
|
|
43
|
+
const ch = text[i];
|
|
44
|
+
|
|
45
|
+
if (escape) {
|
|
46
|
+
escape = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ch === '\\' && inString) {
|
|
50
|
+
escape = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === '"') {
|
|
54
|
+
inString = !inString;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (inString) continue;
|
|
58
|
+
|
|
59
|
+
if (ch === '{') depth++;
|
|
60
|
+
else if (ch === '}') {
|
|
61
|
+
depth--;
|
|
62
|
+
if (depth === 0) {
|
|
63
|
+
return JSON.parse(text.slice(start, i + 1));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
throw new Error('Unbalanced JSON object in LLM response');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate that the parsed LLM output has the required shape.
|
|
73
|
+
*
|
|
74
|
+
* @param {unknown} obj
|
|
75
|
+
* @returns {{ toolFile: string, testFile: string, barrelLine: string|undefined }}
|
|
76
|
+
* @throws {Error} If required fields are missing or wrong type
|
|
77
|
+
*/
|
|
78
|
+
function validateLlmOutput(obj) {
|
|
79
|
+
if (!obj || typeof obj !== 'object') {
|
|
80
|
+
throw new Error('LLM response did not parse to an object');
|
|
81
|
+
}
|
|
82
|
+
if (typeof obj.toolFile !== 'string' || obj.toolFile.trim() === '') {
|
|
83
|
+
throw new Error('LLM response missing required string field: toolFile');
|
|
84
|
+
}
|
|
85
|
+
if (!/mcpRouting/i.test(obj.toolFile)) {
|
|
86
|
+
throw new Error('toolFile must contain an mcpRouting declaration');
|
|
87
|
+
}
|
|
88
|
+
if (typeof obj.testFile !== 'string' || obj.testFile.trim() === '') {
|
|
89
|
+
throw new Error('LLM response missing required string field: testFile');
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
toolFile: obj.toolFile,
|
|
93
|
+
testFile: obj.testFile,
|
|
94
|
+
barrelLine: typeof obj.barrelLine === 'string' ? obj.barrelLine : undefined
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Prompt builder ─────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build the system prompt for the LLM code-generation request.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} spec - Tool specification
|
|
104
|
+
* @param {string[]} existingTools - Names of already-registered tools
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function buildSystemPrompt(spec, existingTools) {
|
|
108
|
+
const safeName = (spec.name || 'unnamed').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
109
|
+
const tagsStr = spec.tags?.join(', ') || '';
|
|
110
|
+
const dependsOnStr = spec.dependsOn?.join(', ') || '';
|
|
111
|
+
const triggersStr = spec.triggerPhrases?.join(', ') || '';
|
|
112
|
+
const schemaStr = JSON.stringify(spec.schema ?? {}, null, 2);
|
|
113
|
+
const existingStr = existingTools.length
|
|
114
|
+
? existingTools.join(', ')
|
|
115
|
+
: '(none)';
|
|
116
|
+
const paramMapStr = JSON.stringify(spec.paramMap || {});
|
|
117
|
+
|
|
118
|
+
return `You are generating a JavaScript tool file for an Agent Tool Forge project. \
|
|
119
|
+
This tool generates the MCP routing layer — a tool definition that points to an internal API \
|
|
120
|
+
endpoint or external data source. Business logic lives behind that endpoint.
|
|
121
|
+
|
|
122
|
+
Tool spec:
|
|
123
|
+
- Name: ${spec.name}
|
|
124
|
+
- Description: ${spec.description}
|
|
125
|
+
- Category: ${spec.category}
|
|
126
|
+
- Consequence level: ${spec.consequenceLevel}
|
|
127
|
+
- Requires confirmation: ${spec.requiresConfirmation}
|
|
128
|
+
- Timeout: ${spec.timeout || 30000}
|
|
129
|
+
- Schema: ${schemaStr}
|
|
130
|
+
- Tags: ${tagsStr}
|
|
131
|
+
- Depends on: ${dependsOnStr}
|
|
132
|
+
- Trigger phrases: ${triggersStr}
|
|
133
|
+
- Endpoint target: ${spec.endpointTarget || 'https://your-api.example.com/...'}
|
|
134
|
+
- HTTP method: ${spec.httpMethod || 'GET'}
|
|
135
|
+
- Auth type: ${spec.authType || 'bearer'}
|
|
136
|
+
- Param map: ${paramMapStr}
|
|
137
|
+
|
|
138
|
+
Existing registered tools (for barrel import reference): ${existingStr}
|
|
139
|
+
|
|
140
|
+
--- TOOL FILE FORMAT ---
|
|
141
|
+
The file must be a JavaScript ESM module (.js) exporting a named const.
|
|
142
|
+
Follow this exact shape (adapt field values from the spec above):
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* ${spec.name} — <one-line description>
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
export const ${_camelName(spec.name)}Tool = {
|
|
149
|
+
name: '${spec.name}',
|
|
150
|
+
description: '<full description>',
|
|
151
|
+
schema: <schema as JS object literal — not JSON>,
|
|
152
|
+
category: '${spec.category}',
|
|
153
|
+
consequenceLevel: '${spec.consequenceLevel}',
|
|
154
|
+
requiresConfirmation: ${spec.requiresConfirmation},
|
|
155
|
+
timeout: ${spec.timeout || 30000},
|
|
156
|
+
version: '1.0.0',
|
|
157
|
+
status: 'active',
|
|
158
|
+
tags: ${JSON.stringify(spec.tags ?? [])},
|
|
159
|
+
dependsOn: ${JSON.stringify(spec.dependsOn ?? [])},
|
|
160
|
+
triggerPhrases: ${JSON.stringify(spec.triggerPhrases ?? [])},
|
|
161
|
+
|
|
162
|
+
mcpRouting: {
|
|
163
|
+
// EXTENSION POINT: configure your endpoint here
|
|
164
|
+
endpoint: '${spec.endpointTarget || 'https://your-api.example.com/...'}',
|
|
165
|
+
method: '${spec.httpMethod || 'GET'}',
|
|
166
|
+
auth: '${spec.authType || 'bearer'}',
|
|
167
|
+
paramMap: ${paramMapStr}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
--- TEST FILE FORMAT ---
|
|
172
|
+
The test file must be a JavaScript ESM module using describe/it/expect (Jest or Vitest style).
|
|
173
|
+
It tests the exported tool object — NOT any execute() implementation.
|
|
174
|
+
|
|
175
|
+
import { ${_camelName(spec.name)}Tool } from '../${safeName}.tool.js';
|
|
176
|
+
|
|
177
|
+
describe('${spec.name}', () => {
|
|
178
|
+
it('has required fields', () => {
|
|
179
|
+
expect(${_camelName(spec.name)}Tool.name).toBe('${spec.name}');
|
|
180
|
+
expect(typeof ${_camelName(spec.name)}Tool.description).toBe('string');
|
|
181
|
+
expect(${_camelName(spec.name)}Tool.mcpRouting).toBeDefined();
|
|
182
|
+
});
|
|
183
|
+
it('schema matches spec', () => {
|
|
184
|
+
// assert each expected schema key is present
|
|
185
|
+
});
|
|
186
|
+
it('mcpRouting has required shape', () => {
|
|
187
|
+
expect(typeof ${_camelName(spec.name)}Tool.mcpRouting.endpoint).toBe('string');
|
|
188
|
+
expect(typeof ${_camelName(spec.name)}Tool.mcpRouting.method).toBe('string');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
--- BARREL LINE FORMAT ---
|
|
193
|
+
The barrelLine must be a single ESM named re-export, e.g.:
|
|
194
|
+
export { ${_camelName(spec.name)}Tool } from './${safeName}.tool.js';
|
|
195
|
+
|
|
196
|
+
--- RESPONSE FORMAT ---
|
|
197
|
+
Respond ONLY with a JSON object — no prose, no markdown outside the JSON itself.
|
|
198
|
+
Required keys:
|
|
199
|
+
"toolFile" — full file content as a string (the complete .tool.js file)
|
|
200
|
+
"testFile" — full test file content as a string (the complete .tool.test.js file)
|
|
201
|
+
"barrelLine" — single export line to add to the barrel index (string)
|
|
202
|
+
|
|
203
|
+
Example response shape:
|
|
204
|
+
{
|
|
205
|
+
"toolFile": "/** ... */\\nexport const myTool = { ... };",
|
|
206
|
+
"testFile": "import { myTool } from '../my_tool.tool.js';\\ndescribe(...)",
|
|
207
|
+
"barrelLine": "export { myTool } from './my_tool.tool.js';"
|
|
208
|
+
}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Camel-case helper ──────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Convert a snake_case tool name to camelCase for the exported const.
|
|
215
|
+
* e.g. "get_weather" → "getWeather"
|
|
216
|
+
*
|
|
217
|
+
* @param {string} name
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
function _camelName(name) {
|
|
221
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Main export ────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Generate tool file content via LLM.
|
|
228
|
+
*
|
|
229
|
+
* Does NOT write to disk. Returns content strings and computed paths so the
|
|
230
|
+
* caller (forge.js) can preview and confirm before writing.
|
|
231
|
+
*
|
|
232
|
+
* @param {object} opts
|
|
233
|
+
* @param {object} opts.spec - Tool specification
|
|
234
|
+
* @param {string} opts.spec.name - Snake_case tool name
|
|
235
|
+
* @param {string} opts.spec.description - Human-readable description
|
|
236
|
+
* @param {object} [opts.spec.schema] - Parameter schema
|
|
237
|
+
* @param {string} [opts.spec.category] - 'read' | 'write' | 'delete' | 'action'
|
|
238
|
+
* @param {string} [opts.spec.consequenceLevel] - 'low' | 'medium' | 'high'
|
|
239
|
+
* @param {boolean} [opts.spec.requiresConfirmation]
|
|
240
|
+
* @param {number} [opts.spec.timeout]
|
|
241
|
+
* @param {string[]} [opts.spec.tags]
|
|
242
|
+
* @param {string[]} [opts.spec.dependsOn]
|
|
243
|
+
* @param {string[]} [opts.spec.triggerPhrases]
|
|
244
|
+
* @param {object} opts.projectConfig - forge.config.json contents
|
|
245
|
+
* @param {string} opts.projectRoot - Absolute path to project root
|
|
246
|
+
* @param {object} opts.modelConfig - { provider, apiKey, model }
|
|
247
|
+
* @param {string} opts.modelConfig.provider - 'anthropic' | 'openai'
|
|
248
|
+
* @param {string} opts.modelConfig.apiKey
|
|
249
|
+
* @param {string} opts.modelConfig.model
|
|
250
|
+
* @param {string[]} [opts.existingTools] - Existing tool names for barrel example
|
|
251
|
+
*
|
|
252
|
+
* @returns {Promise<{
|
|
253
|
+
* toolFile: { path: string, content: string },
|
|
254
|
+
* testFile: { path: string, content: string },
|
|
255
|
+
* barrelDiff: { path: string, lineToAdd: string } | null
|
|
256
|
+
* }>}
|
|
257
|
+
*
|
|
258
|
+
* @throws {Error} If LLM returns invalid JSON after 2 retries
|
|
259
|
+
*/
|
|
260
|
+
export async function generateToolFiles({
|
|
261
|
+
spec,
|
|
262
|
+
projectConfig,
|
|
263
|
+
projectRoot,
|
|
264
|
+
modelConfig,
|
|
265
|
+
existingTools = []
|
|
266
|
+
}) {
|
|
267
|
+
const toolsDir = projectConfig?.project?.toolsDir || 'example/tools';
|
|
268
|
+
|
|
269
|
+
// Resolve absolute base directory for path construction
|
|
270
|
+
const absToolsDir = toolsDir.startsWith('/')
|
|
271
|
+
? toolsDir
|
|
272
|
+
: `${projectRoot}/${toolsDir}`;
|
|
273
|
+
|
|
274
|
+
const safeName = (spec.name || 'unnamed').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
275
|
+
const toolFilePath = `${absToolsDir}/${safeName}.tool.js`;
|
|
276
|
+
const testFilePath = `${absToolsDir}/__tests__/${safeName}.tool.test.js`;
|
|
277
|
+
const barrelPath = `${absToolsDir}/index.js`;
|
|
278
|
+
|
|
279
|
+
const systemPrompt = buildSystemPrompt(spec, existingTools);
|
|
280
|
+
const userMessage = `Generate the tool file, test file, and barrel line for the "${spec.name}" tool.`;
|
|
281
|
+
|
|
282
|
+
const messages = [{ role: 'user', content: userMessage }];
|
|
283
|
+
|
|
284
|
+
const MAX_RETRIES = 2;
|
|
285
|
+
let lastError;
|
|
286
|
+
|
|
287
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
288
|
+
let responseText;
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const turn = await llmTurn({
|
|
292
|
+
provider: modelConfig.provider,
|
|
293
|
+
apiKey: modelConfig.apiKey,
|
|
294
|
+
model: modelConfig.model,
|
|
295
|
+
system: systemPrompt,
|
|
296
|
+
messages,
|
|
297
|
+
maxTokens: 8192,
|
|
298
|
+
timeoutMs: 120_000
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
responseText = turn.text;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// Network / API errors propagate immediately — no retry benefit
|
|
304
|
+
throw new Error(
|
|
305
|
+
`LLM API call failed while generating tool "${spec.name}": ${err.message}`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!responseText || responseText.trim() === '') {
|
|
310
|
+
lastError = new Error(
|
|
311
|
+
`LLM returned an empty response on attempt ${attempt}/${MAX_RETRIES}`
|
|
312
|
+
);
|
|
313
|
+
// Append a nudge for the retry
|
|
314
|
+
messages.push({ role: 'assistant', content: responseText || '' });
|
|
315
|
+
messages.push({
|
|
316
|
+
role: 'user',
|
|
317
|
+
content:
|
|
318
|
+
'Your response was empty. Please respond with ONLY a JSON object containing ' +
|
|
319
|
+
'"toolFile", "testFile", and "barrelLine" keys.'
|
|
320
|
+
});
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = extractJson(responseText);
|
|
327
|
+
} catch (parseErr) {
|
|
328
|
+
lastError = new Error(
|
|
329
|
+
`Attempt ${attempt}/${MAX_RETRIES}: Could not extract JSON from LLM response — ` +
|
|
330
|
+
parseErr.message +
|
|
331
|
+
`\nRaw response (first 300 chars): ${responseText.slice(0, 300)}`
|
|
332
|
+
);
|
|
333
|
+
messages.push({ role: 'assistant', content: responseText });
|
|
334
|
+
messages.push({
|
|
335
|
+
role: 'user',
|
|
336
|
+
content:
|
|
337
|
+
'Your previous response did not contain a valid JSON object. ' +
|
|
338
|
+
'Respond ONLY with a JSON object with keys "toolFile", "testFile", and "barrelLine". ' +
|
|
339
|
+
'Do not include any text outside the JSON.'
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let validated;
|
|
345
|
+
try {
|
|
346
|
+
validated = validateLlmOutput(parsed);
|
|
347
|
+
} catch (validErr) {
|
|
348
|
+
lastError = new Error(
|
|
349
|
+
`Attempt ${attempt}/${MAX_RETRIES}: LLM JSON was missing required fields — ` +
|
|
350
|
+
validErr.message
|
|
351
|
+
);
|
|
352
|
+
messages.push({ role: 'assistant', content: responseText });
|
|
353
|
+
messages.push({
|
|
354
|
+
role: 'user',
|
|
355
|
+
content:
|
|
356
|
+
`The JSON you returned was invalid: ${validErr.message}. ` +
|
|
357
|
+
'Please provide a JSON object with non-empty string fields "toolFile", "testFile", ' +
|
|
358
|
+
'and optionally "barrelLine".'
|
|
359
|
+
});
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Success — build result
|
|
364
|
+
const barrelDiff = validated.barrelLine
|
|
365
|
+
? { path: barrelPath, lineToAdd: validated.barrelLine }
|
|
366
|
+
: null;
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
toolFile: {
|
|
370
|
+
path: toolFilePath,
|
|
371
|
+
content: validated.toolFile
|
|
372
|
+
},
|
|
373
|
+
testFile: {
|
|
374
|
+
path: testFilePath,
|
|
375
|
+
content: validated.testFile
|
|
376
|
+
},
|
|
377
|
+
barrelDiff
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Exhausted retries
|
|
382
|
+
throw new Error(
|
|
383
|
+
`generateToolFiles: failed to obtain valid LLM output for "${spec.name}" ` +
|
|
384
|
+
`after ${MAX_RETRIES} attempts. Last error: ${lastError?.message}`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forge Service Client — CLI wrapper for the forge-service HTTP bridge.
|
|
4
|
+
* Used by the forge-tool skill (Claude) to interact with the queue.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node cli/forge-service-client.js start
|
|
8
|
+
* node cli/forge-service-client.js health
|
|
9
|
+
* node cli/forge-service-client.js next # exits 0 + JSON or 1 on empty
|
|
10
|
+
* node cli/forge-service-client.js complete
|
|
11
|
+
* node cli/forge-service-client.js enqueue <json>
|
|
12
|
+
* node cli/forge-service-client.js shutdown
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync } from 'fs';
|
|
16
|
+
import { resolve, dirname } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import { spawn } from 'child_process';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const PROJECT_ROOT = resolve(__dirname, '..');
|
|
22
|
+
const LOCK_FILE = resolve(PROJECT_ROOT, '.forge-service.lock');
|
|
23
|
+
|
|
24
|
+
function readLock() {
|
|
25
|
+
if (!existsSync(LOCK_FILE)) return null;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(LOCK_FILE, 'utf-8'));
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function httpRequest(method, path, body, timeoutMs = 35_000) {
|
|
34
|
+
const lock = readLock();
|
|
35
|
+
if (!lock) throw new Error('No forge service running (.forge-service.lock not found)');
|
|
36
|
+
const { port } = lock;
|
|
37
|
+
|
|
38
|
+
const { request } = await import('http');
|
|
39
|
+
return new Promise((res, rej) => {
|
|
40
|
+
const payload = body ? JSON.stringify(body) : undefined;
|
|
41
|
+
const options = {
|
|
42
|
+
hostname: '127.0.0.1',
|
|
43
|
+
port,
|
|
44
|
+
path,
|
|
45
|
+
method,
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {})
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const req = request(options, (response) => {
|
|
53
|
+
let data = '';
|
|
54
|
+
response.on('data', (chunk) => { data += chunk; });
|
|
55
|
+
response.on('end', () => {
|
|
56
|
+
res({ statusCode: response.statusCode, body: data });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
req.on('error', rej);
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
req.destroy(new Error('timeout'));
|
|
63
|
+
}, timeoutMs);
|
|
64
|
+
|
|
65
|
+
req.on('close', () => clearTimeout(timer));
|
|
66
|
+
|
|
67
|
+
if (payload) req.write(payload);
|
|
68
|
+
req.end();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function cmdStart() {
|
|
73
|
+
const existing = readLock();
|
|
74
|
+
if (existing) {
|
|
75
|
+
// Verify it's actually alive
|
|
76
|
+
try {
|
|
77
|
+
const r = await httpRequest('GET', '/health', null, 3000);
|
|
78
|
+
if (r.statusCode === 200) {
|
|
79
|
+
const data = JSON.parse(r.body);
|
|
80
|
+
console.log(`Forge service already active on port ${existing.port}`);
|
|
81
|
+
console.log(JSON.stringify(data));
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
} catch (_) {
|
|
85
|
+
// Stale lock — remove it before spawning a new instance
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const { unlinkSync } = await import('fs');
|
|
89
|
+
unlinkSync(LOCK_FILE);
|
|
90
|
+
} catch (_) { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const child = spawn('node', [resolve(__dirname, 'forge-service.js')], {
|
|
94
|
+
detached: true,
|
|
95
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
let output = '';
|
|
99
|
+
let stderrOutput = '';
|
|
100
|
+
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
101
|
+
child.stderr.on('data', (d) => { stderrOutput += d.toString(); });
|
|
102
|
+
|
|
103
|
+
await new Promise((res, rej) => {
|
|
104
|
+
const timeout = setTimeout(() => {
|
|
105
|
+
const detail = stderrOutput.trim() ? `\nService stderr: ${stderrOutput.trim()}` : '';
|
|
106
|
+
rej(new Error(`Service start timeout${detail}`));
|
|
107
|
+
}, 10_000);
|
|
108
|
+
const poll = setInterval(() => {
|
|
109
|
+
if (readLock()) {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
clearInterval(poll);
|
|
112
|
+
res();
|
|
113
|
+
}
|
|
114
|
+
}, 200);
|
|
115
|
+
child.on('error', (err) => { clearTimeout(timeout); clearInterval(poll); rej(err); });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
child.unref();
|
|
119
|
+
const lock = readLock();
|
|
120
|
+
console.log(`Forge service started on port ${lock.port}`);
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function cmdHealth() {
|
|
125
|
+
const r = await httpRequest('GET', '/health');
|
|
126
|
+
console.log(r.body);
|
|
127
|
+
process.exit(r.statusCode === 200 ? 0 : 1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function cmdNext() {
|
|
131
|
+
// Long-poll /next — 30s server timeout + 35s client timeout
|
|
132
|
+
const r = await httpRequest('GET', '/next', null, 36_000);
|
|
133
|
+
if (r.statusCode === 200) {
|
|
134
|
+
console.log(r.body);
|
|
135
|
+
process.exit(0);
|
|
136
|
+
} else {
|
|
137
|
+
// 204 = nothing in queue after timeout
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function cmdComplete() {
|
|
143
|
+
const r = await httpRequest('POST', '/complete');
|
|
144
|
+
console.log(r.body);
|
|
145
|
+
process.exit(r.statusCode === 200 ? 0 : 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function cmdEnqueue(jsonArg) {
|
|
149
|
+
if (!jsonArg) {
|
|
150
|
+
console.error('Usage: forge-service-client.js enqueue <json>');
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
let endpoint;
|
|
154
|
+
try {
|
|
155
|
+
endpoint = JSON.parse(jsonArg);
|
|
156
|
+
} catch (_) {
|
|
157
|
+
console.error('Invalid JSON argument');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
const r = await httpRequest('POST', '/enqueue', { endpoint });
|
|
161
|
+
console.log(r.body);
|
|
162
|
+
process.exit(r.statusCode === 200 ? 0 : 1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function cmdShutdown() {
|
|
166
|
+
const r = await httpRequest('DELETE', '/shutdown');
|
|
167
|
+
console.log(r.body);
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const [,, cmd, ...args] = process.argv;
|
|
172
|
+
|
|
173
|
+
const handlers = {
|
|
174
|
+
start: cmdStart,
|
|
175
|
+
health: cmdHealth,
|
|
176
|
+
next: cmdNext,
|
|
177
|
+
complete: cmdComplete,
|
|
178
|
+
enqueue: () => cmdEnqueue(args[0]),
|
|
179
|
+
shutdown: cmdShutdown
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (!cmd || !handlers[cmd]) {
|
|
183
|
+
console.error(`Usage: forge-service-client.js <start|health|next|complete|enqueue|shutdown>`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
handlers[cmd]().catch((err) => {
|
|
188
|
+
console.error(`Error: ${err.message}`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// forge-service.js implements buildSidecarContext and createSidecarRouter.
|
|
2
|
+
// These functions are re-exported by sidecar.js and declared there to avoid duplication.
|
|
3
|
+
export { buildSidecarContext, createSidecarRouter } from './sidecar.js';
|
|
4
|
+
export type { SidecarContext, SidecarOptions } from './sidecar.js';
|