salmon-loop 0.3.1 → 0.4.1
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/cli/authorization/non-interactive.js +9 -13
- package/dist/cli/chat.js +12 -6
- package/dist/cli/commands/allowlist.js +1 -1
- package/dist/cli/commands/chat.js +13 -13
- package/dist/cli/commands/parallel.js +1 -1
- package/dist/cli/commands/run/handler.js +6 -3
- package/dist/cli/commands/run/loop-params.js +1 -0
- package/dist/cli/commands/run/parse-options.js +14 -26
- package/dist/cli/commands/run/runtime-llm.js +15 -12
- package/dist/cli/commands/serve.js +14 -1
- package/dist/cli/headless/openai-responses-canonical-applier.js +1 -7
- package/dist/cli/reporters/standard.js +2 -3
- package/dist/cli/reporters/stream-json.js +2 -1
- package/dist/cli/slash/runtime.js +2 -2
- package/dist/cli/ui/components/CommandSuggestionList.js +1 -1
- package/dist/cli/ui/hooks/useLoopEvents.js +1 -1
- package/dist/cli/ui/hooks/useLoopState.js +1 -1
- package/dist/core/ast/parser.js +18 -9
- package/dist/core/config/schema.js +738 -0
- package/dist/core/config/validate.js +11 -922
- package/dist/core/context/gatherers/ast-gatherer.js +4 -12
- package/dist/core/context/gatherers/ghost-dependency-gatherer.js +0 -1
- package/dist/core/context/gatherers/knowledge-gatherer.js +3 -0
- package/dist/core/context/service.js +39 -8
- package/dist/core/context/token/encoding-registry.js +7 -6
- package/dist/core/extensions/index.js +48 -3
- package/dist/core/extensions/load.js +3 -2
- package/dist/core/extensions/merge.js +5 -1
- package/dist/core/extensions/paths.js +6 -0
- package/dist/core/extensions/schemas.js +21 -0
- package/dist/core/facades/cli-command-chat.js +2 -0
- package/dist/core/facades/cli-run-handler.js +1 -0
- package/dist/core/facades/cli-utils-serialize.js +2 -0
- package/dist/core/grizzco/dsl/llm-strategy.js +3 -2
- package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +15 -10
- package/dist/core/grizzco/engine/pipeline/pipeline.js +149 -240
- package/dist/core/grizzco/engine/transaction/attempt-failure.js +5 -4
- package/dist/core/grizzco/engine/transaction/authorization-summary.js +2 -1
- package/dist/core/grizzco/runtime/apply-back-runtime.js +2 -1
- package/dist/core/grizzco/services/registry.js +18 -0
- package/dist/core/grizzco/steps/audit.js +20 -10
- package/dist/core/grizzco/steps/display-report.js +4 -11
- package/dist/core/grizzco/steps/explore.js +9 -2
- package/dist/core/grizzco/steps/patch/prompt-input.js +4 -1
- package/dist/core/grizzco/steps/patch.js +1 -0
- package/dist/core/grizzco/steps/plan.js +58 -49
- package/dist/core/grizzco/steps/tool-runtime.js +3 -0
- package/dist/core/grizzco/workers/strata-sync-worker.js +2 -1
- package/dist/core/llm/ai-sdk/message-mapper.js +24 -18
- package/dist/core/llm/ai-sdk/request-params.js +1 -3
- package/dist/core/llm/ai-sdk/result-mapper.js +14 -8
- package/dist/core/llm/ai-sdk/retry-classifier.js +6 -4
- package/dist/core/llm/contracts/repair.js +16 -8
- package/dist/core/llm/errors.js +13 -10
- package/dist/core/llm/output-policy.js +8 -0
- package/dist/core/llm/redact.js +1 -3
- package/dist/core/llm/sub-agent-factory.js +48 -0
- package/dist/core/llm/tool-calling-stub.js +48 -0
- package/dist/core/llm/utils.js +17 -6
- package/dist/core/mcp/bridge/prompt-command-provider.js +4 -3
- package/dist/core/mcp/bridge/tool-bridge.js +5 -14
- package/dist/core/mcp/client/connection-manager.js +3 -2
- package/dist/core/mcp/host/sampling-provider.js +1 -1
- package/dist/core/mcp/schema/json-schema-to-zod.js +2 -1
- package/dist/core/memory/relevant-retrieval.js +6 -4
- package/dist/core/observability/authorization-decisions.js +13 -12
- package/dist/core/observability/error-mapping.js +2 -1
- package/dist/core/observability/token-usage.js +5 -4
- package/dist/core/plugin/loader.js +5 -4
- package/dist/core/prompts/registry.js +11 -29
- package/dist/core/protocols/a2a/sdk/server.js +2 -3
- package/dist/core/protocols/acp/formal-agent.js +10 -4
- package/dist/core/protocols/acp/stdio-server.js +6 -6
- package/dist/core/runtime/agent-server-runtime.js +3 -2
- package/dist/core/runtime/initialize.js +70 -6
- package/dist/core/session/compaction/index.js +4 -3
- package/dist/core/session/manager.js +41 -47
- package/dist/core/session/token-tracker.js +18 -7
- package/dist/core/skills/parser.js +3 -2
- package/dist/core/skills/runtime/MicroTaskRunner.js +1 -1
- package/dist/core/skills/runtime/SkillRunner.js +5 -2
- package/dist/core/slash/steps/slash-execute.js +7 -5
- package/dist/core/slash/strategy.js +1 -1
- package/dist/core/strata/layers/worktree.js +7 -9
- package/dist/core/strata/runtime/synchronizer.js +10 -9
- package/dist/core/streaming/canonical/parts-from-llm-stream-chunk.js +1 -11
- package/dist/core/structured-output/json-schema-validator.js +1 -13
- package/dist/core/sub-agent/context-snapshot.js +12 -6
- package/dist/core/sub-agent/controller.js +70 -1
- package/dist/core/sub-agent/core/loop.js +25 -3
- package/dist/core/sub-agent/core/manager.js +319 -116
- package/dist/core/sub-agent/registry-defaults.js +12 -0
- package/dist/core/sub-agent/registry.js +8 -0
- package/dist/core/sub-agent/team.js +98 -0
- package/dist/core/sub-agent/tools/task-await.js +109 -0
- package/dist/core/sub-agent/tools/task-spawn.js +49 -7
- package/dist/core/sub-agent/tools/team.js +92 -0
- package/dist/core/sub-agent/types.js +11 -2
- package/dist/core/tools/budget.js +4 -11
- package/dist/core/tools/builtin/code-search/executor.js +46 -43
- package/dist/core/tools/builtin/fs.js +14 -6
- package/dist/core/tools/builtin/index.js +41 -107
- package/dist/core/tools/builtin/interaction.js +13 -15
- package/dist/core/tools/builtin/proposal.js +11 -2
- package/dist/core/tools/capability/executor.js +5 -5
- package/dist/core/tools/headless-payload.js +1 -3
- package/dist/core/tools/mapper.js +8 -42
- package/dist/core/tools/parallel/persistence.js +17 -5
- package/dist/core/tools/parallel/scheduler.js +23 -21
- package/dist/core/tools/permissions/permission-rules.js +66 -114
- package/dist/core/tools/plugins/loader.js +4 -3
- package/dist/core/tools/router.js +24 -53
- package/dist/core/tools/session.js +54 -97
- package/dist/core/tools/streaming/ToolCallAccumulator.js +1 -3
- package/dist/core/tools/tool-visibility.js +2 -1
- package/dist/core/tools/types.js +10 -0
- package/dist/core/utils/error.js +79 -0
- package/dist/core/utils/serialize.js +63 -0
- package/dist/core/utils/zod.js +29 -0
- package/dist/core/workspace/capabilities.js +3 -2
- package/dist/integrations/langfuse/litellm-langfuse-outcome-reporter.js +9 -8
- package/dist/locales/en.js +2 -1
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@ import { chatWithTools, chatWithToolsStreaming } from '../../tools/session.js';
|
|
|
8
8
|
import { SalmonError } from '../../types/errors.js';
|
|
9
9
|
import { Phase } from '../../types/runtime.js';
|
|
10
10
|
import { ensureInSandbox, isSafeRelativePath, normalizePath } from '../../utils/path.js';
|
|
11
|
+
import { isRecord } from '../../utils/serialize.js';
|
|
11
12
|
import { resolveLlmToolCallingPolicy } from '../dsl/llm-strategy.js';
|
|
12
13
|
import { ContextValidator } from '../validation/ContextValidator.js';
|
|
13
14
|
import { buildPhaseRequestEnvelope } from './request-assembly.js';
|
|
@@ -103,7 +104,11 @@ export const exploreCodebase = async (ctx) => {
|
|
|
103
104
|
// Intercept tools with READ intent
|
|
104
105
|
if (intent === 'READ' && result.status === 'ok') {
|
|
105
106
|
const output = result.output;
|
|
106
|
-
const content = typeof output === 'string'
|
|
107
|
+
const content = typeof output === 'string'
|
|
108
|
+
? output
|
|
109
|
+
: isRecord(output) && typeof output.content === 'string'
|
|
110
|
+
? output.content
|
|
111
|
+
: undefined;
|
|
107
112
|
if (typeof content === 'string') {
|
|
108
113
|
try {
|
|
109
114
|
// Attempt to parse arguments to get the file path
|
|
@@ -199,7 +204,9 @@ export const exploreCodebase = async (ctx) => {
|
|
|
199
204
|
// Validation: Check for exploration consistency using ContextValidator on LOCAL audit
|
|
200
205
|
const validation = ContextValidator.validateExploration(localAudit, capturedFiles.size);
|
|
201
206
|
if (!validation.isValid) {
|
|
202
|
-
const
|
|
207
|
+
const validationMessages = text.grizzco.validation;
|
|
208
|
+
const raw = validation.errorCode ? validationMessages[validation.errorCode] : undefined;
|
|
209
|
+
const msg = typeof raw === 'string' ? raw : (validation.errorCode ?? 'unknown');
|
|
203
210
|
ctx.emit({
|
|
204
211
|
type: 'log',
|
|
205
212
|
level: 'error',
|
|
@@ -5,9 +5,12 @@ import { Phase } from '../../../types/runtime.js';
|
|
|
5
5
|
import { buildPhaseRequestEnvelope } from '../request-assembly.js';
|
|
6
6
|
export async function buildPatchPromptInput(args) {
|
|
7
7
|
const planStr = JSON.stringify(args.plan, null, 2);
|
|
8
|
-
const
|
|
8
|
+
const baseSystemPrompt = await getPatchSystemPrompt(args.promptVisibleTools, {
|
|
9
9
|
plan: args.planRuntime,
|
|
10
10
|
});
|
|
11
|
+
const systemPrompt = args.subAgentSystemPrompt
|
|
12
|
+
? [baseSystemPrompt, args.subAgentSystemPrompt]
|
|
13
|
+
: baseSystemPrompt;
|
|
11
14
|
const requestEnvelope = await buildPhaseRequestEnvelope({
|
|
12
15
|
phase: args.phase ?? Phase.PATCH,
|
|
13
16
|
defaultNamespace: 'patch',
|
|
@@ -95,6 +95,7 @@ export const generatePatch = async (ctx) => {
|
|
|
95
95
|
artifactHints: ctx.artifactHints,
|
|
96
96
|
replacementState: ctx.replacementState,
|
|
97
97
|
toolCallingAudit: ctx.toolCallingAudit,
|
|
98
|
+
subAgentSystemPrompt: ctx.options.subAgentSystemPrompt,
|
|
98
99
|
});
|
|
99
100
|
const { cacheSurface, envelope, baseMessages } = patchPromptInput;
|
|
100
101
|
const supportsStreaming = supportsLlmStreaming(ctx.options.llm, Phase.PATCH);
|
|
@@ -133,7 +133,10 @@ export const generatePlan = async (ctx) => {
|
|
|
133
133
|
flowMode: ctx.mode,
|
|
134
134
|
runtime: toolVisibility,
|
|
135
135
|
});
|
|
136
|
-
const
|
|
136
|
+
const baseSystemPrompt = await getPlanSystemPrompt(promptVisibleTools, toolVisibility);
|
|
137
|
+
const systemPrompt = ctx.options.subAgentSystemPrompt
|
|
138
|
+
? [baseSystemPrompt, ctx.options.subAgentSystemPrompt]
|
|
139
|
+
: baseSystemPrompt;
|
|
137
140
|
const requestEnvelope = await buildPhaseRequestEnvelope({
|
|
138
141
|
phase: Phase.PLAN,
|
|
139
142
|
defaultNamespace: 'plan',
|
|
@@ -192,62 +195,68 @@ export const generatePlan = async (ctx) => {
|
|
|
192
195
|
});
|
|
193
196
|
const content = response.content;
|
|
194
197
|
let finalContent = content || '';
|
|
198
|
+
// Codex-style self-healing: if plan JSON parsing fails, send the error back to the LLM
|
|
199
|
+
// and let it self-correct. Try up to MAX_REPAIR_ATTEMPTS times.
|
|
200
|
+
const MAX_REPAIR_ATTEMPTS = 2;
|
|
195
201
|
let plan;
|
|
196
|
-
|
|
197
|
-
if (!finalContent) {
|
|
198
|
-
throw new Error(text.llm.planEmpty);
|
|
199
|
-
}
|
|
200
|
-
plan = parsePlanFromLLMContent(finalContent);
|
|
201
|
-
}
|
|
202
|
-
catch (e) {
|
|
203
|
-
recordPlanRepairAttempt({
|
|
204
|
-
reason: sanitizeError(e),
|
|
205
|
-
badContentLength: finalContent.length,
|
|
206
|
-
});
|
|
207
|
-
let repaired;
|
|
208
|
-
try {
|
|
209
|
-
repaired = await repairToJsonObject({
|
|
210
|
-
llm: ctx.options.llm,
|
|
211
|
-
baseMessages,
|
|
212
|
-
chatOptions: { signal: ctx.options.signal },
|
|
213
|
-
badContent: finalContent,
|
|
214
|
-
reason: sanitizeError(e),
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
catch (repairError) {
|
|
218
|
-
recordPlanRepairResult({
|
|
219
|
-
ok: false,
|
|
220
|
-
contentLength: 0,
|
|
221
|
-
error: sanitizeError(repairError).slice(0, 400),
|
|
222
|
-
});
|
|
223
|
-
throw new Error(text.llm.planParseFailed(finalContent, sanitizeError(repairError)));
|
|
224
|
-
}
|
|
225
|
-
finalContent = repaired.content || '';
|
|
226
|
-
emitLlmOutput({
|
|
227
|
-
emit: ctx.emit,
|
|
228
|
-
policy: ctx.options.llmOutput,
|
|
229
|
-
kind: 'plan',
|
|
230
|
-
step: 'PLAN',
|
|
231
|
-
content: finalContent,
|
|
232
|
-
});
|
|
202
|
+
for (let attempt = 0; attempt <= MAX_REPAIR_ATTEMPTS; attempt++) {
|
|
233
203
|
try {
|
|
234
|
-
if (!finalContent)
|
|
204
|
+
if (!finalContent) {
|
|
235
205
|
throw new Error(text.llm.planEmpty);
|
|
206
|
+
}
|
|
236
207
|
plan = parsePlanFromLLMContent(finalContent);
|
|
208
|
+
break; // Success
|
|
237
209
|
}
|
|
238
|
-
catch (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
210
|
+
catch (parseError) {
|
|
211
|
+
// On last attempt, throw
|
|
212
|
+
if (attempt >= MAX_REPAIR_ATTEMPTS) {
|
|
213
|
+
recordPlanRepairResult({
|
|
214
|
+
ok: false,
|
|
215
|
+
contentLength: finalContent.length,
|
|
216
|
+
error: sanitizeError(parseError).slice(0, 400),
|
|
217
|
+
});
|
|
218
|
+
throw new Error(text.llm.planParseFailed(finalContent, sanitizeError(parseError)));
|
|
219
|
+
}
|
|
220
|
+
// Repair attempt: send error feedback to LLM and let it self-correct
|
|
221
|
+
recordPlanRepairAttempt({
|
|
222
|
+
reason: sanitizeError(parseError),
|
|
223
|
+
badContentLength: finalContent.length,
|
|
224
|
+
});
|
|
225
|
+
let repaired;
|
|
226
|
+
try {
|
|
227
|
+
repaired = await repairToJsonObject({
|
|
228
|
+
llm: ctx.options.llm,
|
|
229
|
+
baseMessages,
|
|
230
|
+
chatOptions: { signal: ctx.options.signal },
|
|
231
|
+
badContent: finalContent,
|
|
232
|
+
reason: sanitizeError(parseError),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch (repairError) {
|
|
236
|
+
recordPlanRepairResult({
|
|
237
|
+
ok: false,
|
|
238
|
+
contentLength: 0,
|
|
239
|
+
error: sanitizeError(repairError).slice(0, 400),
|
|
240
|
+
});
|
|
241
|
+
throw new Error(text.llm.planParseFailed(finalContent, sanitizeError(repairError)));
|
|
242
|
+
}
|
|
243
|
+
finalContent = repaired.content || '';
|
|
244
|
+
emitLlmOutput({
|
|
245
|
+
emit: ctx.emit,
|
|
246
|
+
policy: ctx.options.llmOutput,
|
|
247
|
+
kind: 'plan',
|
|
248
|
+
step: 'PLAN',
|
|
249
|
+
content: finalContent,
|
|
243
250
|
});
|
|
244
|
-
throw new Error(text.llm.planParseFailed(finalContent, sanitizeError(e2)));
|
|
245
251
|
}
|
|
246
|
-
recordPlanRepairResult({
|
|
247
|
-
ok: true,
|
|
248
|
-
contentLength: finalContent.length,
|
|
249
|
-
});
|
|
250
252
|
}
|
|
253
|
+
if (!plan) {
|
|
254
|
+
throw new Error(text.llm.planParseFailed(finalContent, 'Plan was not parsed successfully'));
|
|
255
|
+
}
|
|
256
|
+
recordPlanRepairResult({
|
|
257
|
+
ok: true,
|
|
258
|
+
contentLength: finalContent.length,
|
|
259
|
+
});
|
|
251
260
|
ctx.emit({
|
|
252
261
|
type: 'log',
|
|
253
262
|
level: 'debug',
|
|
@@ -14,6 +14,9 @@ export function buildPhaseToolRuntimeContext(ctx, phase, cacheSurface) {
|
|
|
14
14
|
agentKind: ctx.options.agentKind ?? 'primary',
|
|
15
15
|
languagePlugins: ctx.options.languagePlugins,
|
|
16
16
|
subAgentController: ctx.options.subAgentController,
|
|
17
|
+
llmFactory: ctx.options.llmFactory,
|
|
18
|
+
onSubAgentComplete: ctx.options.onSubAgentComplete,
|
|
19
|
+
agentId: ctx.options.agentId,
|
|
17
20
|
phase,
|
|
18
21
|
contextSnapshot: {
|
|
19
22
|
conversationContext: ctx.options.conversationContext,
|
|
@@ -44,7 +44,8 @@ export class StrataSyncWorker {
|
|
|
44
44
|
const aiContent = op.content;
|
|
45
45
|
// Invoke legacy merge logic
|
|
46
46
|
// private method: async mergeFileContents(repoPath, base, user, ai, options?)
|
|
47
|
-
const
|
|
47
|
+
const engineMethods = engine;
|
|
48
|
+
const result = await engineMethods.mergeFileContents(this.git.repoPath, baseContent, userContent, aiContent);
|
|
48
49
|
return {
|
|
49
50
|
path: state.path,
|
|
50
51
|
success: !result.conflict,
|
|
@@ -2,11 +2,12 @@ import { jsonSchema, tool } from 'ai';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
4
4
|
import { toolToOpenAI } from '../../tools/mapper.js';
|
|
5
|
+
import { isRecord } from '../../utils/serialize.js';
|
|
5
6
|
function formatOutputSchema(schema) {
|
|
6
7
|
if (!schema)
|
|
7
8
|
return 'any (dynamic)';
|
|
8
|
-
const def = schema.
|
|
9
|
-
if (def?.description) {
|
|
9
|
+
const def = schema.def;
|
|
10
|
+
if (typeof def?.description === 'string') {
|
|
10
11
|
return def.description;
|
|
11
12
|
}
|
|
12
13
|
try {
|
|
@@ -46,9 +47,7 @@ function deepCloneJson(value, fallback) {
|
|
|
46
47
|
return fallback;
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
51
|
-
}
|
|
50
|
+
const isObjectRecord = isRecord;
|
|
52
51
|
export function extractUsageFromAiSdkResult(result) {
|
|
53
52
|
if (!isObjectRecord(result))
|
|
54
53
|
return null;
|
|
@@ -93,7 +92,9 @@ function toAiSdkToolResultOutput(value) {
|
|
|
93
92
|
};
|
|
94
93
|
}
|
|
95
94
|
export function toAiSdkMessages(messages) {
|
|
96
|
-
|
|
95
|
+
// Each branch returns a structurally valid ModelMessage; the union is too
|
|
96
|
+
// complex for TS to verify inline, so we assert the array at the end.
|
|
97
|
+
const result = messages.map((m) => {
|
|
97
98
|
if (m.role === 'tool') {
|
|
98
99
|
const toolCallId = m.tool_call_id || 'unknown';
|
|
99
100
|
const toolName = m.name || 'unknown';
|
|
@@ -143,7 +144,7 @@ export function toAiSdkMessages(messages) {
|
|
|
143
144
|
content = JSON.stringify(content);
|
|
144
145
|
}
|
|
145
146
|
return {
|
|
146
|
-
role:
|
|
147
|
+
role: 'assistant',
|
|
147
148
|
content: content,
|
|
148
149
|
};
|
|
149
150
|
}
|
|
@@ -191,6 +192,7 @@ export function toAiSdkMessages(messages) {
|
|
|
191
192
|
content: content,
|
|
192
193
|
};
|
|
193
194
|
});
|
|
195
|
+
return result;
|
|
194
196
|
}
|
|
195
197
|
export function toAiSdkToolSet(openAiTools, toolSpecs) {
|
|
196
198
|
const tools = {};
|
|
@@ -199,12 +201,14 @@ export function toAiSdkToolSet(openAiTools, toolSpecs) {
|
|
|
199
201
|
const outputDesc = formatOutputSchema(spec.outputSchema);
|
|
200
202
|
const description = `${spec.description}\n\nReturns: ${outputDesc}`;
|
|
201
203
|
const openAiDef = toolToOpenAI(spec);
|
|
202
|
-
const parameters = jsonSchema(openAiDef.function?.parameters
|
|
203
|
-
tools[spec.name] =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
const parameters = jsonSchema(openAiDef.function?.parameters ?? {});
|
|
205
|
+
tools[spec.name] = {
|
|
206
|
+
...tool({
|
|
207
|
+
description,
|
|
208
|
+
inputSchema: parameters,
|
|
209
|
+
}),
|
|
210
|
+
outputSchema: spec.outputSchema ?? z.any(),
|
|
211
|
+
};
|
|
208
212
|
}
|
|
209
213
|
}
|
|
210
214
|
if (Array.isArray(openAiTools)) {
|
|
@@ -215,11 +219,13 @@ export function toAiSdkToolSet(openAiTools, toolSpecs) {
|
|
|
215
219
|
continue;
|
|
216
220
|
const rawDesc = typeof fn?.description === 'string' ? fn.description : '';
|
|
217
221
|
const description = `${rawDesc}\n\nReturns: any (dynamic)`.trim();
|
|
218
|
-
tools[name] =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
222
|
+
tools[name] = {
|
|
223
|
+
...tool({
|
|
224
|
+
description,
|
|
225
|
+
inputSchema: jsonSchema(fn?.parameters ?? { type: 'object', properties: {} }),
|
|
226
|
+
}),
|
|
227
|
+
outputSchema: z.any(),
|
|
228
|
+
};
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
return Object.keys(tools).length > 0 ? tools : undefined;
|
|
@@ -1,32 +1,38 @@
|
|
|
1
|
+
import { isRecord } from '../../utils/serialize.js';
|
|
1
2
|
import { mapAiSdkStreamPartToChunk } from '../stream-utils.js';
|
|
2
3
|
import { toOpenAiToolCalls } from './message-mapper.js';
|
|
3
4
|
function extractReasoningContent(result) {
|
|
4
|
-
if (
|
|
5
|
+
if (!isRecord(result))
|
|
6
|
+
return undefined;
|
|
7
|
+
if (typeof result.reasoningText === 'string' && result.reasoningText.length > 0) {
|
|
5
8
|
return result.reasoningText;
|
|
6
9
|
}
|
|
7
|
-
const reasoningParts = Array.isArray(result
|
|
10
|
+
const reasoningParts = Array.isArray(result.reasoning)
|
|
8
11
|
? result.reasoning
|
|
9
|
-
: Array.isArray(result
|
|
10
|
-
? result.content.filter((part) => part
|
|
12
|
+
: Array.isArray(result.content)
|
|
13
|
+
? result.content.filter((part) => isRecord(part) && part.type === 'reasoning')
|
|
11
14
|
: [];
|
|
12
15
|
const text = reasoningParts
|
|
13
|
-
.map((part) => (typeof part
|
|
16
|
+
.map((part) => (typeof part.text === 'string' ? part.text : ''))
|
|
14
17
|
.join('');
|
|
15
18
|
return text.length > 0 ? text : undefined;
|
|
16
19
|
}
|
|
17
20
|
export function mapAiSdkGenerateResultToMessage(result) {
|
|
18
21
|
const reasoningContent = extractReasoningContent(result);
|
|
22
|
+
const r = isRecord(result) ? result : {};
|
|
19
23
|
return {
|
|
20
24
|
role: 'assistant',
|
|
21
|
-
content:
|
|
25
|
+
content: typeof r.text === 'string' ? r.text : '',
|
|
22
26
|
...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
|
|
23
|
-
tool_calls: toOpenAiToolCalls(
|
|
27
|
+
tool_calls: toOpenAiToolCalls(Array.isArray(r.toolCalls)
|
|
28
|
+
? r.toolCalls
|
|
29
|
+
: undefined),
|
|
24
30
|
};
|
|
25
31
|
}
|
|
26
32
|
export async function* mapAiSdkStreamResultToChunks(fullStream) {
|
|
27
33
|
let doneEmitted = false;
|
|
28
34
|
for await (const part of fullStream) {
|
|
29
|
-
if (!part)
|
|
35
|
+
if (!part || !isRecord(part))
|
|
30
36
|
continue;
|
|
31
37
|
if (part.type === 'error')
|
|
32
38
|
throw part.error;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from '../../utils/serialize.js';
|
|
1
2
|
function unwrapRetryError(err) {
|
|
2
3
|
if (!err || typeof err !== 'object')
|
|
3
4
|
return err;
|
|
@@ -15,7 +16,7 @@ function findStatusCode(err) {
|
|
|
15
16
|
if (typeof direct === 'number' && Number.isFinite(direct))
|
|
16
17
|
return direct;
|
|
17
18
|
const response = obj.response;
|
|
18
|
-
if (response
|
|
19
|
+
if (isRecord(response)) {
|
|
19
20
|
const status = response.status;
|
|
20
21
|
if (typeof status === 'number' && Number.isFinite(status))
|
|
21
22
|
return status;
|
|
@@ -31,7 +32,7 @@ function findNetworkCode(err) {
|
|
|
31
32
|
if (typeof code === 'string')
|
|
32
33
|
return code;
|
|
33
34
|
const cause = obj.cause;
|
|
34
|
-
if (cause && typeof cause
|
|
35
|
+
if (isRecord(cause) && typeof cause.code === 'string') {
|
|
35
36
|
return cause.code;
|
|
36
37
|
}
|
|
37
38
|
return undefined;
|
|
@@ -39,7 +40,7 @@ function findNetworkCode(err) {
|
|
|
39
40
|
function isAbortLikeError(err) {
|
|
40
41
|
const unwrapped = unwrapRetryError(err);
|
|
41
42
|
const name = unwrapped instanceof Error ? unwrapped.name : '';
|
|
42
|
-
const msg = String(unwrapped
|
|
43
|
+
const msg = String((isRecord(unwrapped) ? unwrapped.message : undefined) ?? unwrapped).toLowerCase();
|
|
43
44
|
return name === 'AbortError' || msg.includes('aborted');
|
|
44
45
|
}
|
|
45
46
|
export function classifyRetryableApiError(err) {
|
|
@@ -47,7 +48,8 @@ export function classifyRetryableApiError(err) {
|
|
|
47
48
|
return { retryable: false, reason: 'aborted' };
|
|
48
49
|
const statusCode = findStatusCode(err);
|
|
49
50
|
const networkCode = findNetworkCode(err);
|
|
50
|
-
const
|
|
51
|
+
const unwrapped = unwrapRetryError(err);
|
|
52
|
+
const msg = String((isRecord(unwrapped) ? unwrapped.message : undefined) ?? err).toLowerCase();
|
|
51
53
|
if (statusCode === 408)
|
|
52
54
|
return { retryable: true, reason: 'timeout', statusCode, networkCode };
|
|
53
55
|
if (statusCode === 429)
|
|
@@ -10,18 +10,26 @@ function truncateForPrompt(text, maxChars) {
|
|
|
10
10
|
export async function repairToJsonObject(args) {
|
|
11
11
|
const { llm, baseMessages, chatOptions, badContent, reason } = args;
|
|
12
12
|
const prompt = [
|
|
13
|
-
'Your previous response
|
|
14
|
-
`Reason: ${reason}`,
|
|
13
|
+
'Your previous response failed to parse as a valid plan JSON object.',
|
|
15
14
|
'',
|
|
16
|
-
|
|
17
|
-
'The first non-whitespace character must be {.',
|
|
18
|
-
'The last non-whitespace character must be }.',
|
|
15
|
+
`Specific error: ${reason}`,
|
|
19
16
|
'',
|
|
20
|
-
'
|
|
17
|
+
'To fix this, return EXACTLY one JSON object with these keys:',
|
|
18
|
+
' - goal: string (what this plan accomplishes)',
|
|
19
|
+
' - files: string[] (list of file paths to modify)',
|
|
20
|
+
' - changes: string[] (list of change descriptions)',
|
|
21
|
+
' - verify: string (command to verify the changes)',
|
|
21
22
|
'',
|
|
22
|
-
'
|
|
23
|
+
'Example of a valid response:',
|
|
24
|
+
'{"goal":"Add error handling","files":["src/index.ts"],"changes":["Add try-catch to main"],"verify":"npm test"}',
|
|
23
25
|
'',
|
|
24
|
-
'
|
|
26
|
+
'Rules:',
|
|
27
|
+
'- The first non-whitespace character must be {',
|
|
28
|
+
'- The last non-whitespace character must be }',
|
|
29
|
+
'- No markdown fences, commentary, or leading/trailing text',
|
|
30
|
+
'- All four keys (goal, files, changes, verify) are REQUIRED',
|
|
31
|
+
'',
|
|
32
|
+
'Your previous invalid response (truncated):',
|
|
25
33
|
truncateForPrompt(badContent, Math.min(1200, Math.max(400, LIMITS.maxContextChars / 100))),
|
|
26
34
|
].join('\n');
|
|
27
35
|
return llm.chat([
|
package/dist/core/llm/errors.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SalmonError } from '../types/errors.js';
|
|
2
2
|
import { sanitizeErrorMessage } from '../utils/sanitizer.js';
|
|
3
|
+
import { isRecord } from '../utils/serialize.js';
|
|
3
4
|
export class LlmError extends SalmonError {
|
|
4
5
|
llmCode;
|
|
5
6
|
meta;
|
|
@@ -88,9 +89,8 @@ function extractNetworkCode(err) {
|
|
|
88
89
|
if (typeof direct === 'string' && direct.trim())
|
|
89
90
|
return direct;
|
|
90
91
|
const cause = candidate.cause;
|
|
91
|
-
if (cause && typeof cause
|
|
92
|
-
|
|
93
|
-
return code.trim() ? code : undefined;
|
|
92
|
+
if (isRecord(cause) && typeof cause.code === 'string') {
|
|
93
|
+
return cause.code.trim() || undefined;
|
|
94
94
|
}
|
|
95
95
|
return undefined;
|
|
96
96
|
}
|
|
@@ -124,19 +124,20 @@ export function sanitizeError(err) {
|
|
|
124
124
|
return sanitizeErrorMessage(err);
|
|
125
125
|
}
|
|
126
126
|
export function toLlmError(err, provider) {
|
|
127
|
+
const errObj = isRecord(err) ? err : null;
|
|
127
128
|
let name = err instanceof Error
|
|
128
129
|
? err.name
|
|
129
|
-
: typeof
|
|
130
|
-
?
|
|
130
|
+
: typeof errObj?.name === 'string'
|
|
131
|
+
? errObj.name
|
|
131
132
|
: 'UnknownError';
|
|
132
133
|
let message = err instanceof Error
|
|
133
134
|
? err.message
|
|
134
|
-
: typeof
|
|
135
|
-
?
|
|
135
|
+
: typeof errObj?.message === 'string'
|
|
136
|
+
? errObj.message
|
|
136
137
|
: String(err);
|
|
137
138
|
// Unwrap RetryError to get the last error's message if available
|
|
138
|
-
if (name === 'AI_RetryError' ||
|
|
139
|
-
const lastError =
|
|
139
|
+
if (name === 'AI_RetryError' || errObj?.lastError) {
|
|
140
|
+
const lastError = errObj?.lastError;
|
|
140
141
|
// Update the error reference so subsequent checks work on the actual cause
|
|
141
142
|
err = lastError;
|
|
142
143
|
if (lastError instanceof Error) {
|
|
@@ -149,7 +150,9 @@ export function toLlmError(err, provider) {
|
|
|
149
150
|
name === 'ZodError' ||
|
|
150
151
|
name.includes('TypeValidationError') ||
|
|
151
152
|
message.includes('TypeValidationError') ||
|
|
152
|
-
err
|
|
153
|
+
(err != null &&
|
|
154
|
+
typeof err === 'object' &&
|
|
155
|
+
Symbol.for('vercel.ai.error.AI_TypeValidationError') in err)) {
|
|
153
156
|
return new LlmError('LLM validation failed', 'LLM_VALIDATION_FAILED', {
|
|
154
157
|
provider,
|
|
155
158
|
causeName: name,
|
|
@@ -147,6 +147,14 @@ export function emitLlmStreamDelta(params) {
|
|
|
147
147
|
timestamp,
|
|
148
148
|
});
|
|
149
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Clean up stream state without emitting end events.
|
|
152
|
+
* Call this when a stream errors and emitLlmStreamEnd won't be reached.
|
|
153
|
+
*/
|
|
154
|
+
export function cleanupLlmStream(streamId) {
|
|
155
|
+
STREAM_CANONICAL_EMITTERS.delete(streamId);
|
|
156
|
+
STREAM_SANITIZATION_STATE.delete(streamId);
|
|
157
|
+
}
|
|
150
158
|
export function emitLlmStreamEnd(params) {
|
|
151
159
|
const { emit, policy, kind, step, streamId, finishReason } = params;
|
|
152
160
|
if (!emit)
|
package/dist/core/llm/redact.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from '../utils/serialize.js';
|
|
1
2
|
const SECRET_KEY_REGEX = /(api[-_]?key|authorization|token|secret|password|cookie)/i;
|
|
2
3
|
const STRING_SECRET_PATTERNS = [
|
|
3
4
|
{
|
|
@@ -17,9 +18,6 @@ const STRING_SECRET_PATTERNS = [
|
|
|
17
18
|
replacement: '$1=[REDACTED]',
|
|
18
19
|
},
|
|
19
20
|
];
|
|
20
|
-
function isRecord(value) {
|
|
21
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
22
|
-
}
|
|
23
21
|
function truncate(value, max = 500) {
|
|
24
22
|
if (value.length <= max)
|
|
25
23
|
return value;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AiSdkLLM } from './ai-sdk.js';
|
|
2
|
+
import { StubLLM } from './openai.js';
|
|
3
|
+
/**
|
|
4
|
+
* Model alias → concrete model ID mapping.
|
|
5
|
+
*
|
|
6
|
+
* These follow the Anthropic model naming conventions.
|
|
7
|
+
* Providers that don't support these IDs will fall back to StubLLM.
|
|
8
|
+
*/
|
|
9
|
+
const MODEL_ALIAS_MAP = {
|
|
10
|
+
haiku: 'claude-3-5-haiku-20241022',
|
|
11
|
+
sonnet: 'claude-sonnet-4-20250514',
|
|
12
|
+
opus: 'claude-opus-4-20250514',
|
|
13
|
+
};
|
|
14
|
+
function resolveModelId(alias) {
|
|
15
|
+
return MODEL_ALIAS_MAP[alias] ?? alias;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a SubAgentLlmFactory that produces model-specific LLM instances
|
|
19
|
+
* from a base provider configuration.
|
|
20
|
+
*
|
|
21
|
+
* The factory reuses the parent provider's connection settings (API key,
|
|
22
|
+
* base URL, headers) and only overrides the model ID.
|
|
23
|
+
*/
|
|
24
|
+
export function createSubAgentLlmFactory(baseProvider) {
|
|
25
|
+
return (modelAlias) => {
|
|
26
|
+
const modelId = resolveModelId(modelAlias);
|
|
27
|
+
if (baseProvider.type === 'openai-compatible' || baseProvider.type === 'openai') {
|
|
28
|
+
const clientPackage = baseProvider.clientPackage === '@ai-sdk/openai'
|
|
29
|
+
? '@ai-sdk/openai'
|
|
30
|
+
: '@ai-sdk/openai-compatible';
|
|
31
|
+
if (!baseProvider.api.apiKey) {
|
|
32
|
+
return new StubLLM();
|
|
33
|
+
}
|
|
34
|
+
return new AiSdkLLM({
|
|
35
|
+
clientPackage,
|
|
36
|
+
providerName: baseProvider.id,
|
|
37
|
+
apiKey: baseProvider.api.apiKey,
|
|
38
|
+
baseUrl: baseProvider.api.baseUrl,
|
|
39
|
+
modelId,
|
|
40
|
+
headers: baseProvider.api.headers,
|
|
41
|
+
timeoutMs: baseProvider.api.timeoutMs,
|
|
42
|
+
capabilities: baseProvider.capabilities,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=sub-agent-factory.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolCallingStubLLM — A deterministic LLM stub that emits tool_calls
|
|
3
|
+
* to drive the chatWithTools loop in tests and evaluation harnesses.
|
|
4
|
+
*
|
|
5
|
+
* Unlike StubLLM (toolCalling: false, no tool_calls), this stub populates
|
|
6
|
+
* the assistant.tool_calls field so that chatWithTools executes tools and
|
|
7
|
+
* continues the loop.
|
|
8
|
+
*/
|
|
9
|
+
export class ToolCallingStubLLM {
|
|
10
|
+
toolCalling = true;
|
|
11
|
+
turns;
|
|
12
|
+
callCount = 0;
|
|
13
|
+
constructor(turns) {
|
|
14
|
+
this.turns = turns;
|
|
15
|
+
}
|
|
16
|
+
getCapabilities() {
|
|
17
|
+
return {
|
|
18
|
+
toolCalling: true,
|
|
19
|
+
responseFormatJsonObject: false,
|
|
20
|
+
streaming: false,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async chat(_messages) {
|
|
24
|
+
const idx = this.callCount;
|
|
25
|
+
const turn = this.turns[idx] ?? { content: '[stub: no more turns]' };
|
|
26
|
+
this.callCount++;
|
|
27
|
+
return {
|
|
28
|
+
role: 'assistant',
|
|
29
|
+
content: turn.content ?? '',
|
|
30
|
+
tool_calls: turn.toolCalls,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
getCallCount() {
|
|
34
|
+
return this.callCount;
|
|
35
|
+
}
|
|
36
|
+
async createPlan(_context, instruction) {
|
|
37
|
+
return {
|
|
38
|
+
goal: `Stub plan for: ${instruction}`,
|
|
39
|
+
files: [],
|
|
40
|
+
changes: [],
|
|
41
|
+
verify: 'echo ok',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async createPatch() {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=tool-calling-stub.js.map
|
package/dist/core/llm/utils.js
CHANGED
|
@@ -13,21 +13,32 @@ export function formatContextForPrompt(context, options = {}) {
|
|
|
13
13
|
export function parsePlanFromLLMContent(content) {
|
|
14
14
|
const trimmed = String(content ?? '').trim();
|
|
15
15
|
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
|
|
16
|
-
|
|
16
|
+
const preview = trimmed.slice(0, 80);
|
|
17
|
+
throw new Error(`${text.llm.planInvalidJson} — Content must start with { and end with }. Got: ${preview}${trimmed.length > 80 ? '…' : ''}`);
|
|
17
18
|
}
|
|
18
19
|
let parsed;
|
|
19
20
|
try {
|
|
20
21
|
parsed = JSON.parse(trimmed);
|
|
21
22
|
}
|
|
22
|
-
catch {
|
|
23
|
-
|
|
23
|
+
catch (jsonError) {
|
|
24
|
+
const errorMsg = jsonError instanceof Error ? jsonError.message : String(jsonError);
|
|
25
|
+
throw new Error(`${text.llm.planInvalidJson} — JSON parse error: ${errorMsg}`);
|
|
24
26
|
}
|
|
25
27
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
26
|
-
throw new Error(text.llm.planInvalidJson);
|
|
28
|
+
throw new Error(`${text.llm.planInvalidJson} — Expected object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
|
|
27
29
|
}
|
|
28
30
|
const plan = parsed;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
const missingKeys = [];
|
|
32
|
+
if (!plan.goal)
|
|
33
|
+
missingKeys.push('goal');
|
|
34
|
+
if (!Array.isArray(plan.files))
|
|
35
|
+
missingKeys.push('files (must be array)');
|
|
36
|
+
if (!Array.isArray(plan.changes))
|
|
37
|
+
missingKeys.push('changes (must be array)');
|
|
38
|
+
if (!plan.verify)
|
|
39
|
+
missingKeys.push('verify');
|
|
40
|
+
if (missingKeys.length > 0) {
|
|
41
|
+
throw new Error(`${text.llm.planInvalid} — Missing or invalid keys: ${missingKeys.join(', ')}`);
|
|
31
42
|
}
|
|
32
43
|
return plan;
|
|
33
44
|
}
|