@spec-wave/cli 0.15.0 → 0.16.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 +1 -0
- package/bin/spec-wave.mjs +41 -2
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-rest.mjs +8 -0
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +11 -49
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +6 -20
- package/src/commands/generate-spec.mjs +6 -22
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +145 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +16 -3
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +111 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +53 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +37 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +154 -0
- package/src/templates/skill/SKILL.md +54 -4
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- package/src/ui/wizard.mjs +5 -2
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Backend OpenRouter: loop de tool-calling contra o /chat/completions
|
|
2
|
+
// compatível com OpenAI. Porte de `agent-cli/src/openrouterAgent.ts`.
|
|
3
|
+
//
|
|
4
|
+
// Não há subprocesso aqui — este processo é dono do loop do modelo E da
|
|
5
|
+
// execução das tools (ver `tools.mjs`), e fala com a rede por `fetch` puro.
|
|
6
|
+
// Não adicione SDK de provider para ele.
|
|
7
|
+
//
|
|
8
|
+
// Três capacidades que o agent-cli não tinha e a spec-wave depende:
|
|
9
|
+
//
|
|
10
|
+
// 1. `maxTokens` — teto de saída por ação (`ai.maxTokensByAction`).
|
|
11
|
+
// 2. Detecção de TRUNCAMENTO — `finish_reason: "length"` vira erro. Um
|
|
12
|
+
// documento cortado no meio de uma frase PASSA na validação de seções e
|
|
13
|
+
// vale menos que documento nenhum, então a geração falha e nada é gravado.
|
|
14
|
+
// 3. Saída ESTRUTURADA — `responseSchema` força uma tool call única com
|
|
15
|
+
// schema, que é como a crítica adversarial garante `severity` no enum.
|
|
16
|
+
// Sem isso, um payload fora do contrato viraria texto livre parseado "na
|
|
17
|
+
// tolerância" — exatamente o bug que rebaixava um finding grave a menor.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createToolContext,
|
|
21
|
+
executeTool,
|
|
22
|
+
toolDefinitionsFor,
|
|
23
|
+
} from './tools.mjs';
|
|
24
|
+
import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
|
|
25
|
+
import { TruncatedOutputError, isTruncationReason } from './errors.mjs';
|
|
26
|
+
|
|
27
|
+
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
28
|
+
const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
|
|
29
|
+
const MAX_TURNS = 25;
|
|
30
|
+
const REQUEST_TIMEOUT_MS = 300_000;
|
|
31
|
+
const MAX_ATTEMPTS = 3;
|
|
32
|
+
const RETRY_STATUS = new Set([408, 409, 429, 500, 502, 503, 504]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Não há preset `claude_code` para construir em cima aqui, então as instruções
|
|
36
|
+
* base do agente são nossas. O `systemPromptAppend` (o model-prompt da skill)
|
|
37
|
+
* vem depois disto.
|
|
38
|
+
*/
|
|
39
|
+
export const OPENROUTER_SYSTEM_PROMPT = `You are an autonomous CLI coding agent working in the directory {{CWD}}.
|
|
40
|
+
|
|
41
|
+
Use the provided tools to inspect and change files — never guess at file contents you have not read, and never claim to have written a file you did not write with the Write tool.
|
|
42
|
+
Work in small, verifiable steps: locate the relevant files first (Glob/Grep), read them, then act.
|
|
43
|
+
Tool calls are executed by the CLI that hosts you; some paths are blocked by policy, and a refusal comes back as the tool result. If a call is refused, adapt instead of retrying it unchanged.
|
|
44
|
+
When the task is done, stop calling tools and reply with a short summary of what you did.`;
|
|
45
|
+
|
|
46
|
+
function errorMessage(err) {
|
|
47
|
+
return err instanceof Error ? err.message : String(err);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
51
|
+
|
|
52
|
+
async function postChatCompletion(body, apiKey, verbose) {
|
|
53
|
+
const baseUrl = process.env.OPENROUTER_BASE_URL ?? DEFAULT_BASE_URL;
|
|
54
|
+
const headers = {
|
|
55
|
+
Authorization: `Bearer ${apiKey}`,
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
'X-Title': process.env.OPENROUTER_APP_NAME ?? 'spec-wave',
|
|
58
|
+
};
|
|
59
|
+
if (process.env.OPENROUTER_SITE_URL) headers['HTTP-Referer'] = process.env.OPENROUTER_SITE_URL;
|
|
60
|
+
|
|
61
|
+
let lastError;
|
|
62
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers,
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const detail = (await response.text().catch(() => '')).slice(0, 500);
|
|
72
|
+
const err = new Error(
|
|
73
|
+
`OpenRouter request failed: ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ''}`,
|
|
74
|
+
);
|
|
75
|
+
if (RETRY_STATUS.has(response.status) && attempt < MAX_ATTEMPTS) {
|
|
76
|
+
lastError = err;
|
|
77
|
+
if (verbose) console.log(`[Agent] ${err.message}; retrying (${attempt}/${MAX_ATTEMPTS - 1})`);
|
|
78
|
+
await sleep(1_000 * 2 ** (attempt - 1));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
// Lê como texto e parseia aqui (em vez de `response.json()`) para que um
|
|
84
|
+
// corpo não-JSON entre na mensagem de erro. "Unexpected token <" sem o
|
|
85
|
+
// corpo não diz nada sobre uma página de erro do gateway.
|
|
86
|
+
const raw = await response.text();
|
|
87
|
+
let json;
|
|
88
|
+
try {
|
|
89
|
+
json = JSON.parse(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error(`OpenRouter devolveu um corpo não-JSON: ${raw.slice(0, 500)}`);
|
|
92
|
+
}
|
|
93
|
+
// O OpenRouter reporta falha do provider upstream com HTTP 200.
|
|
94
|
+
if (json.error) {
|
|
95
|
+
throw new Error(`OpenRouter returned an error: ${json.error.message ?? JSON.stringify(json.error)}`);
|
|
96
|
+
}
|
|
97
|
+
return json;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
lastError = err;
|
|
100
|
+
const retryable =
|
|
101
|
+
attempt < MAX_ATTEMPTS &&
|
|
102
|
+
(err instanceof TypeError || (err instanceof Error && err.name === 'TimeoutError'));
|
|
103
|
+
if (!retryable) throw err;
|
|
104
|
+
if (verbose) console.log(`[Agent] ${errorMessage(err)}; retrying (${attempt}/${MAX_ATTEMPTS - 1})`);
|
|
105
|
+
await sleep(1_000 * 2 ** (attempt - 1));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Corpo EXATO de POST /chat/completions (função PURA — testável sem HTTP).
|
|
113
|
+
*
|
|
114
|
+
* Com `schema`, força a ferramenta via `tool_choice`: o modelo não tem como
|
|
115
|
+
* responder em texto livre, que é o que produzia JSON sujo na crítica. Sem
|
|
116
|
+
* schema, as tools de leitura entram com `tool_choice: "auto"`.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} params
|
|
119
|
+
* @returns {object}
|
|
120
|
+
*/
|
|
121
|
+
export function buildChatCompletionBody({
|
|
122
|
+
model, messages, tools = [], schema = null, maxTokens, temperature,
|
|
123
|
+
}) {
|
|
124
|
+
return {
|
|
125
|
+
model,
|
|
126
|
+
messages,
|
|
127
|
+
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
|
128
|
+
...(temperature !== undefined ? { temperature } : {}),
|
|
129
|
+
...(schema
|
|
130
|
+
? {
|
|
131
|
+
tools: [{
|
|
132
|
+
type: 'function',
|
|
133
|
+
function: {
|
|
134
|
+
name: schema.name,
|
|
135
|
+
description: schema.description,
|
|
136
|
+
parameters: schema.jsonSchema,
|
|
137
|
+
...(schema.strict ? { strict: true } : {}),
|
|
138
|
+
},
|
|
139
|
+
}],
|
|
140
|
+
tool_choice: { type: 'function', function: { name: schema.name } },
|
|
141
|
+
}
|
|
142
|
+
: tools.length > 0
|
|
143
|
+
? { tools, tool_choice: 'auto' }
|
|
144
|
+
: {}),
|
|
145
|
+
// Pede ao OpenRouter que inclua a contabilidade de custo em `usage`.
|
|
146
|
+
usage: { include: true },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Roda o agente no OpenRouter.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} prompt
|
|
154
|
+
* @param {import('./run-types.mjs').AgentRunOptions} options
|
|
155
|
+
* @returns {Promise<import('./run-types.mjs').AgentRunResult>}
|
|
156
|
+
*/
|
|
157
|
+
export async function runOpenRouterAgent(prompt, options) {
|
|
158
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
159
|
+
if (!apiKey) {
|
|
160
|
+
throw new Error('OPENROUTER_API_KEY não definida — adicione-a como secret do repositório.');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const schema = options.responseSchema ?? null;
|
|
164
|
+
// Com schema, o modelo não explora nada: a resposta é UMA tool call com o
|
|
165
|
+
// payload. Misturar as duas coisas daria ao modelo a saída de escape de
|
|
166
|
+
// "chamar Read de novo" em vez de fechar o contrato.
|
|
167
|
+
const tools = schema ? [] : (options.tools ?? DEFAULT_TOOLS);
|
|
168
|
+
const toolDefinitions = toolDefinitionsFor(tools);
|
|
169
|
+
const toolContext = createToolContext({
|
|
170
|
+
tools,
|
|
171
|
+
cwd: options.cwd ?? process.cwd(),
|
|
172
|
+
allowedWritePaths: options.allowedWritePaths,
|
|
173
|
+
});
|
|
174
|
+
const maxTurns = schema ? 1 : (options.maxTurns ?? MAX_TURNS);
|
|
175
|
+
const runResult = {
|
|
176
|
+
resultSubtype: null,
|
|
177
|
+
outputText: '',
|
|
178
|
+
costUsd: null,
|
|
179
|
+
numTurns: 0,
|
|
180
|
+
structured: null,
|
|
181
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
182
|
+
};
|
|
183
|
+
const quiet = options.quiet !== false;
|
|
184
|
+
|
|
185
|
+
await withTrace(
|
|
186
|
+
{
|
|
187
|
+
name: options.traceName ?? 'agent-execution',
|
|
188
|
+
sessionId: options.sessionId,
|
|
189
|
+
userId: options.userId,
|
|
190
|
+
tags: ['spec-wave', 'agent', 'openrouter', ...(options.extraTags ?? [])],
|
|
191
|
+
metadata: { model: options.model, provider: 'openrouter' },
|
|
192
|
+
nested: options.nested === true,
|
|
193
|
+
},
|
|
194
|
+
async span => {
|
|
195
|
+
updateSpan(span, { input: prompt });
|
|
196
|
+
|
|
197
|
+
const systemPrompt = [
|
|
198
|
+
schema ? null : OPENROUTER_SYSTEM_PROMPT.replace('{{CWD}}', toolContext.cwd),
|
|
199
|
+
options.systemPromptAppend,
|
|
200
|
+
]
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.join('\n\n');
|
|
203
|
+
|
|
204
|
+
const messages = [
|
|
205
|
+
{ role: 'system', content: systemPrompt },
|
|
206
|
+
{ role: 'user', content: prompt },
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
let turns = 0;
|
|
210
|
+
let totalCost = 0;
|
|
211
|
+
let sawCost = false;
|
|
212
|
+
let finalText = '';
|
|
213
|
+
let openGeneration = null;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
while (turns < maxTurns) {
|
|
217
|
+
turns++;
|
|
218
|
+
const generation = startObservation(
|
|
219
|
+
span,
|
|
220
|
+
'generate-response',
|
|
221
|
+
{ model: options.model, ...(turns === 1 ? { input: prompt } : {}) },
|
|
222
|
+
'generation',
|
|
223
|
+
);
|
|
224
|
+
openGeneration = generation;
|
|
225
|
+
|
|
226
|
+
const response = await postChatCompletion(
|
|
227
|
+
buildChatCompletionBody({
|
|
228
|
+
model: options.model,
|
|
229
|
+
messages,
|
|
230
|
+
tools: toolDefinitions,
|
|
231
|
+
schema,
|
|
232
|
+
maxTokens: options.maxTokens,
|
|
233
|
+
temperature: options.temperature,
|
|
234
|
+
}),
|
|
235
|
+
apiKey,
|
|
236
|
+
options.verbose === true,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const choice = response.choices?.[0];
|
|
240
|
+
const message = choice?.message;
|
|
241
|
+
const text = message?.content ?? '';
|
|
242
|
+
const toolCalls = message?.tool_calls ?? [];
|
|
243
|
+
const usage = response.usage;
|
|
244
|
+
const cost = typeof usage?.cost === 'number' ? usage.cost : null;
|
|
245
|
+
if (cost !== null) {
|
|
246
|
+
totalCost += cost;
|
|
247
|
+
sawCost = true;
|
|
248
|
+
}
|
|
249
|
+
runResult.usage.inputTokens += usage?.prompt_tokens ?? 0;
|
|
250
|
+
runResult.usage.outputTokens += usage?.completion_tokens ?? 0;
|
|
251
|
+
|
|
252
|
+
if (!quiet && text.trim() !== '') console.log(`\nAssistant: ${text}`);
|
|
253
|
+
|
|
254
|
+
updateSpan(generation, {
|
|
255
|
+
model: response.model ?? options.model,
|
|
256
|
+
output: toolCalls.length > 0
|
|
257
|
+
? { role: 'assistant', content: text, tool_calls: toolCalls }
|
|
258
|
+
: text,
|
|
259
|
+
usageDetails: {
|
|
260
|
+
input: usage?.prompt_tokens ?? 0,
|
|
261
|
+
output: usage?.completion_tokens ?? 0,
|
|
262
|
+
cache_read_input_tokens: usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
|
263
|
+
},
|
|
264
|
+
...(cost !== null ? { costDetails: { total: cost } } : {}),
|
|
265
|
+
});
|
|
266
|
+
endObservation(generation);
|
|
267
|
+
openGeneration = null;
|
|
268
|
+
|
|
269
|
+
// TRUNCAMENTO: o corte tem de virar erro ANTES de qualquer coisa ser
|
|
270
|
+
// gravada. Repetir a mesma requisição dá o mesmo corte, então este
|
|
271
|
+
// erro não é transitório e não entra no retry.
|
|
272
|
+
if (isTruncationReason(choice?.finish_reason)) {
|
|
273
|
+
throw new TruncatedOutputError({
|
|
274
|
+
provider: 'openrouter',
|
|
275
|
+
model: response.model ?? options.model,
|
|
276
|
+
maxTokens: options.maxTokens ?? null,
|
|
277
|
+
// `native_finish_reason` preserva o motivo cru do provedor
|
|
278
|
+
// upstream — sem ele o diagnóstico perde de qual camada veio o
|
|
279
|
+
// corte, que é o que distingue teto nosso de teto do provider.
|
|
280
|
+
reason: `${choice.finish_reason}${choice.native_finish_reason ? `/${choice.native_finish_reason}` : ''}`,
|
|
281
|
+
chars: (runResult.outputText + text).length,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Saída estruturada: a tool forçada é o resultado, não um passo.
|
|
286
|
+
if (schema) {
|
|
287
|
+
const call = toolCalls.find(c => c.function?.name === schema.name) ?? toolCalls[0];
|
|
288
|
+
if (!call) {
|
|
289
|
+
// Alguns modelos ainda respondem em texto apesar do tool_choice
|
|
290
|
+
// forçado. TRANSITÓRIO de propósito: repetir a mesma requisição
|
|
291
|
+
// costuma resolver, ao contrário de truncamento (mesmo corte).
|
|
292
|
+
const err = new Error(
|
|
293
|
+
`O modelo não chamou a ferramenta "${schema.name}" ` +
|
|
294
|
+
`(finish_reason: ${choice?.finish_reason ?? 'desconhecido'}).`,
|
|
295
|
+
);
|
|
296
|
+
err.transient = true;
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
runResult.structured = JSON.parse(call.function.arguments || '{}');
|
|
300
|
+
runResult.outputText = call.function.arguments ?? '';
|
|
301
|
+
runResult.resultSubtype = 'success';
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (toolCalls.length === 0) {
|
|
306
|
+
finalText = text;
|
|
307
|
+
runResult.outputText = text;
|
|
308
|
+
runResult.resultSubtype = 'success';
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
messages.push({
|
|
313
|
+
role: 'assistant',
|
|
314
|
+
content: text === '' ? null : text,
|
|
315
|
+
tool_calls: toolCalls,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
for (const call of toolCalls) {
|
|
319
|
+
let parsedInput;
|
|
320
|
+
let parseError = null;
|
|
321
|
+
try {
|
|
322
|
+
parsedInput = call.function.arguments.trim() === ''
|
|
323
|
+
? {}
|
|
324
|
+
: JSON.parse(call.function.arguments);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
parseError = `Could not parse tool arguments as JSON: ${errorMessage(err)}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (options.verbose) console.log(`\n[Tool] ${call.function.name}(${call.function.arguments})`);
|
|
330
|
+
|
|
331
|
+
const observation = startObservation(
|
|
332
|
+
span,
|
|
333
|
+
`tool-${call.function.name}`,
|
|
334
|
+
{ input: parseError === null ? parsedInput : call.function.arguments },
|
|
335
|
+
'tool',
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const result = parseError !== null
|
|
339
|
+
? { output: parseError, isError: true }
|
|
340
|
+
: executeTool(call.function.name, parsedInput, toolContext);
|
|
341
|
+
|
|
342
|
+
updateSpan(observation, {
|
|
343
|
+
output: result.isError ? `[tool failed] ${result.output}` : result.output,
|
|
344
|
+
...(result.isError ? { level: 'ERROR' } : {}),
|
|
345
|
+
});
|
|
346
|
+
endObservation(observation);
|
|
347
|
+
|
|
348
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: result.output });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (runResult.resultSubtype === null) {
|
|
353
|
+
runResult.resultSubtype = 'error_max_turns';
|
|
354
|
+
console.warn(`\n[Agent] Run ended without success: error_max_turns (${maxTurns} turns)`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
updateSpan(span, {
|
|
358
|
+
output: runResult.resultSubtype === 'success' ? finalText || runResult.outputText : `[${runResult.resultSubtype}]`,
|
|
359
|
+
metadata: {
|
|
360
|
+
total_cost_usd: sawCost ? totalCost : null,
|
|
361
|
+
num_turns: turns,
|
|
362
|
+
provider: 'openrouter',
|
|
363
|
+
permission_denials: toolContext.denials,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
runResult.costUsd = sawCost ? totalCost : null;
|
|
367
|
+
runResult.numTurns = turns;
|
|
368
|
+
} finally {
|
|
369
|
+
endObservation(openGeneration);
|
|
370
|
+
if (runResult.resultSubtype === null) {
|
|
371
|
+
updateSpan(span, { output: '[run ended without a result]' });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
return runResult;
|
|
378
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Contrato de execução que os DOIS backends implementam, para que quem chama
|
|
2
|
+
// nunca ramifique por provider. Porte de `agent-cli/src/runTypes.ts`: lá são
|
|
3
|
+
// `interface`s de TypeScript, aqui typedefs de JSDoc — este pacote não tem
|
|
4
|
+
// etapa de build.
|
|
5
|
+
//
|
|
6
|
+
// Os campos marcados como "extensão spec-wave" não existem no agent-cli; foram
|
|
7
|
+
// acrescentados porque a CLI depende deles (ver `openrouter-agent.mjs`).
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} ResponseSchema
|
|
11
|
+
* Saída estruturada: força UMA tool call com este schema. Extensão spec-wave —
|
|
12
|
+
* é o que garante `severity` dentro do enum na crítica adversarial.
|
|
13
|
+
* @property {string} name nome da ferramenta forçada
|
|
14
|
+
* @property {string} description
|
|
15
|
+
* @property {object} jsonSchema JSON Schema dos parâmetros
|
|
16
|
+
* @property {boolean} [strict] envia `strict: true` (nem todo modelo aceita)
|
|
17
|
+
* @property {(payload: object) => object} [validate] valida/normaliza; lançar
|
|
18
|
+
* aqui dispara o re-pedido de quem chama
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} AgentRunOptions
|
|
23
|
+
* @property {string} model
|
|
24
|
+
* @property {string} sessionId
|
|
25
|
+
* @property {string} userId
|
|
26
|
+
* @property {boolean} [verbose]
|
|
27
|
+
* @property {string[]} [tools] superfície de tools. Default: Read/Glob/Grep —
|
|
28
|
+
* somente leitura, ao contrário do agent-cli (que inclui Write): aqui quem
|
|
29
|
+
* grava documento é o comando, não o modelo
|
|
30
|
+
* @property {string} [systemPromptAppend] anexado ao preset `claude_code`
|
|
31
|
+
* (anthropic) ou ao OPENROUTER_SYSTEM_PROMPT (openrouter)
|
|
32
|
+
* @property {string[]} [allowedWritePaths] quando presente, Write só passa nestes
|
|
33
|
+
* @property {number} [maxTurns] teto de turnos. Default: 25
|
|
34
|
+
* @property {string[]} [extraTags] tags extras da trace
|
|
35
|
+
* @property {string} [traceName] nome da observação raiz. Default: agent-execution
|
|
36
|
+
* @property {boolean} [nested] herda o contexto OTel ambiente em vez de abrir trace
|
|
37
|
+
* @property {boolean} [quiet] silencia o stdout por turno. Default aqui: true
|
|
38
|
+
* (o agent-cli tem stdout como produto; esta CLI tem o documento)
|
|
39
|
+
* @property {string} [cwd] extensão spec-wave: raiz do repositório do usuário
|
|
40
|
+
* @property {number} [maxTokens] extensão spec-wave: teto de tokens de saída
|
|
41
|
+
* @property {number} [temperature] extensão spec-wave
|
|
42
|
+
* @property {ResponseSchema} [responseSchema] extensão spec-wave
|
|
43
|
+
* @property {string} [action] extensão spec-wave: spec|plan|decompose|critique,
|
|
44
|
+
* usado nas mensagens de erro e no relatório de custo
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} AgentRunResult
|
|
49
|
+
* @property {string|null} resultSubtype "success", "error_max_turns", ...
|
|
50
|
+
* @property {string} outputText resposta final; vazia quando o run não produziu
|
|
51
|
+
* texto. Um backend que a deixe vazia quebra quem consome em silêncio
|
|
52
|
+
* @property {number|null} costUsd custo total, ou null se o backend não reportou
|
|
53
|
+
* @property {number} numTurns turnos consumidos
|
|
54
|
+
* @property {object|null} structured extensão spec-wave: payload da tool forçada
|
|
55
|
+
* @property {{inputTokens: number, outputTokens: number}} usage extensão
|
|
56
|
+
* spec-wave: alimenta o relatório de uso comentado na issue
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// OpenTelemetry + Langfuse. Porte de `agent-cli/src/telemetry.ts`.
|
|
2
|
+
//
|
|
3
|
+
// Por que o NodeSDK importa: o SDK v4 do Langfuse é OTel-nativo — sem
|
|
4
|
+
// `NodeSDK.start()` registrando o tracer provider e o context manager, TODA
|
|
5
|
+
// chamada de observação vira no-op silencioso. E o `shutdown()` é load-bearing:
|
|
6
|
+
// spans exportam em lote, e um processo curto (que é o caso de todo comando
|
|
7
|
+
// desta CLI dentro de um Action) sai antes do último lote sem ele.
|
|
8
|
+
//
|
|
9
|
+
// Diferença em relação ao agent-cli: lá a telemetria é obrigatória. Aqui ela é
|
|
10
|
+
// OPCIONAL — sem as chaves do Langfuse a CLI segue rodando sem tracing, porque
|
|
11
|
+
// gerar a spec.md não pode depender de um serviço de observabilidade.
|
|
12
|
+
|
|
13
|
+
let sdk = null;
|
|
14
|
+
|
|
15
|
+
/** True quando há credenciais do Langfuse no ambiente. */
|
|
16
|
+
export function telemetryConfigured(env = process.env) {
|
|
17
|
+
return Boolean(env.LANGFUSE_PUBLIC_KEY && env.LANGFUSE_SECRET_KEY);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inicializa a telemetria, se configurada. Idempotente e best-effort: falha ao
|
|
22
|
+
* subir o SDK NUNCA derruba o comando — no pior caso o run roda sem traces.
|
|
23
|
+
*
|
|
24
|
+
* @returns {Promise<boolean>} true se a telemetria ficou ativa
|
|
25
|
+
*/
|
|
26
|
+
export async function initTelemetry({ env = process.env } = {}) {
|
|
27
|
+
if (sdk) return true;
|
|
28
|
+
if (!telemetryConfigured(env)) return false;
|
|
29
|
+
try {
|
|
30
|
+
const [{ NodeSDK }, { LangfuseSpanProcessor }] = await Promise.all([
|
|
31
|
+
import('@opentelemetry/sdk-node'),
|
|
32
|
+
import('@langfuse/otel'),
|
|
33
|
+
]);
|
|
34
|
+
sdk = new NodeSDK({ spanProcessors: [new LangfuseSpanProcessor()] });
|
|
35
|
+
sdk.start();
|
|
36
|
+
return true;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.warn(`[spec-wave] telemetria desativada: ${err.message}`);
|
|
39
|
+
sdk = null;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Faz o flush do último lote de spans. Best-effort, como o init. */
|
|
45
|
+
export async function shutdownTelemetry() {
|
|
46
|
+
if (!sdk) return;
|
|
47
|
+
try {
|
|
48
|
+
await sdk.shutdown();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn(`[spec-wave] falha ao encerrar a telemetria: ${err.message}`);
|
|
51
|
+
} finally {
|
|
52
|
+
sdk = null;
|
|
53
|
+
}
|
|
54
|
+
}
|