salmon-loop 0.3.2 → 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.
Files changed (121) hide show
  1. package/dist/cli/authorization/non-interactive.js +9 -13
  2. package/dist/cli/chat.js +12 -6
  3. package/dist/cli/commands/allowlist.js +1 -1
  4. package/dist/cli/commands/chat.js +13 -13
  5. package/dist/cli/commands/parallel.js +1 -1
  6. package/dist/cli/commands/run/handler.js +6 -3
  7. package/dist/cli/commands/run/loop-params.js +1 -0
  8. package/dist/cli/commands/run/parse-options.js +14 -26
  9. package/dist/cli/commands/run/runtime-llm.js +15 -12
  10. package/dist/cli/headless/openai-responses-canonical-applier.js +1 -7
  11. package/dist/cli/reporters/standard.js +2 -3
  12. package/dist/cli/reporters/stream-json.js +2 -1
  13. package/dist/cli/slash/runtime.js +2 -2
  14. package/dist/cli/ui/hooks/useLoopEvents.js +1 -1
  15. package/dist/cli/ui/hooks/useLoopState.js +1 -1
  16. package/dist/core/ast/parser.js +18 -9
  17. package/dist/core/config/schema.js +738 -0
  18. package/dist/core/config/validate.js +11 -922
  19. package/dist/core/context/gatherers/ast-gatherer.js +4 -12
  20. package/dist/core/context/gatherers/ghost-dependency-gatherer.js +0 -1
  21. package/dist/core/context/gatherers/knowledge-gatherer.js +3 -0
  22. package/dist/core/context/service.js +8 -0
  23. package/dist/core/context/token/encoding-registry.js +7 -6
  24. package/dist/core/extensions/index.js +48 -3
  25. package/dist/core/extensions/load.js +3 -2
  26. package/dist/core/extensions/merge.js +5 -1
  27. package/dist/core/extensions/paths.js +6 -0
  28. package/dist/core/extensions/schemas.js +21 -0
  29. package/dist/core/facades/cli-command-chat.js +2 -0
  30. package/dist/core/facades/cli-run-handler.js +1 -0
  31. package/dist/core/facades/cli-utils-serialize.js +2 -0
  32. package/dist/core/grizzco/dsl/llm-strategy.js +3 -2
  33. package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +15 -10
  34. package/dist/core/grizzco/engine/pipeline/pipeline.js +149 -240
  35. package/dist/core/grizzco/engine/transaction/attempt-failure.js +5 -4
  36. package/dist/core/grizzco/engine/transaction/authorization-summary.js +2 -1
  37. package/dist/core/grizzco/runtime/apply-back-runtime.js +2 -1
  38. package/dist/core/grizzco/services/registry.js +18 -0
  39. package/dist/core/grizzco/steps/audit.js +20 -10
  40. package/dist/core/grizzco/steps/display-report.js +4 -11
  41. package/dist/core/grizzco/steps/explore.js +9 -2
  42. package/dist/core/grizzco/steps/patch/prompt-input.js +4 -1
  43. package/dist/core/grizzco/steps/patch.js +1 -0
  44. package/dist/core/grizzco/steps/plan.js +58 -49
  45. package/dist/core/grizzco/steps/tool-runtime.js +3 -0
  46. package/dist/core/grizzco/workers/strata-sync-worker.js +2 -1
  47. package/dist/core/llm/ai-sdk/message-mapper.js +24 -18
  48. package/dist/core/llm/ai-sdk/request-params.js +1 -3
  49. package/dist/core/llm/ai-sdk/result-mapper.js +14 -8
  50. package/dist/core/llm/ai-sdk/retry-classifier.js +6 -4
  51. package/dist/core/llm/contracts/repair.js +16 -8
  52. package/dist/core/llm/errors.js +13 -10
  53. package/dist/core/llm/output-policy.js +8 -0
  54. package/dist/core/llm/redact.js +1 -3
  55. package/dist/core/llm/sub-agent-factory.js +48 -0
  56. package/dist/core/llm/tool-calling-stub.js +48 -0
  57. package/dist/core/llm/utils.js +17 -6
  58. package/dist/core/mcp/bridge/prompt-command-provider.js +4 -3
  59. package/dist/core/mcp/bridge/tool-bridge.js +5 -14
  60. package/dist/core/mcp/client/connection-manager.js +3 -2
  61. package/dist/core/mcp/host/sampling-provider.js +1 -1
  62. package/dist/core/mcp/schema/json-schema-to-zod.js +2 -1
  63. package/dist/core/memory/relevant-retrieval.js +6 -4
  64. package/dist/core/observability/authorization-decisions.js +13 -12
  65. package/dist/core/observability/error-mapping.js +2 -1
  66. package/dist/core/observability/token-usage.js +5 -4
  67. package/dist/core/plugin/loader.js +5 -4
  68. package/dist/core/prompts/registry.js +11 -29
  69. package/dist/core/protocols/a2a/sdk/server.js +2 -3
  70. package/dist/core/protocols/acp/formal-agent.js +10 -4
  71. package/dist/core/protocols/acp/stdio-server.js +6 -6
  72. package/dist/core/runtime/agent-server-runtime.js +3 -2
  73. package/dist/core/runtime/initialize.js +70 -6
  74. package/dist/core/session/compaction/index.js +4 -3
  75. package/dist/core/session/manager.js +24 -37
  76. package/dist/core/session/token-tracker.js +18 -7
  77. package/dist/core/skills/parser.js +3 -2
  78. package/dist/core/skills/runtime/MicroTaskRunner.js +1 -1
  79. package/dist/core/skills/runtime/SkillRunner.js +5 -2
  80. package/dist/core/slash/steps/slash-execute.js +7 -5
  81. package/dist/core/slash/strategy.js +1 -1
  82. package/dist/core/strata/layers/worktree.js +7 -9
  83. package/dist/core/strata/runtime/synchronizer.js +10 -9
  84. package/dist/core/streaming/canonical/parts-from-llm-stream-chunk.js +1 -11
  85. package/dist/core/structured-output/json-schema-validator.js +1 -13
  86. package/dist/core/sub-agent/context-snapshot.js +12 -6
  87. package/dist/core/sub-agent/controller.js +70 -1
  88. package/dist/core/sub-agent/core/loop.js +25 -3
  89. package/dist/core/sub-agent/core/manager.js +319 -116
  90. package/dist/core/sub-agent/registry-defaults.js +12 -0
  91. package/dist/core/sub-agent/registry.js +8 -0
  92. package/dist/core/sub-agent/team.js +98 -0
  93. package/dist/core/sub-agent/tools/task-await.js +109 -0
  94. package/dist/core/sub-agent/tools/task-spawn.js +49 -7
  95. package/dist/core/sub-agent/tools/team.js +92 -0
  96. package/dist/core/sub-agent/types.js +11 -2
  97. package/dist/core/tools/budget.js +4 -11
  98. package/dist/core/tools/builtin/code-search/executor.js +46 -43
  99. package/dist/core/tools/builtin/fs.js +14 -6
  100. package/dist/core/tools/builtin/index.js +41 -107
  101. package/dist/core/tools/builtin/interaction.js +13 -15
  102. package/dist/core/tools/builtin/proposal.js +11 -2
  103. package/dist/core/tools/capability/executor.js +5 -5
  104. package/dist/core/tools/headless-payload.js +1 -3
  105. package/dist/core/tools/mapper.js +8 -42
  106. package/dist/core/tools/parallel/persistence.js +17 -5
  107. package/dist/core/tools/parallel/scheduler.js +23 -21
  108. package/dist/core/tools/permissions/permission-rules.js +66 -114
  109. package/dist/core/tools/plugins/loader.js +4 -3
  110. package/dist/core/tools/router.js +24 -53
  111. package/dist/core/tools/session.js +54 -97
  112. package/dist/core/tools/streaming/ToolCallAccumulator.js +1 -3
  113. package/dist/core/tools/tool-visibility.js +2 -1
  114. package/dist/core/tools/types.js +10 -0
  115. package/dist/core/utils/error.js +79 -0
  116. package/dist/core/utils/serialize.js +63 -0
  117. package/dist/core/utils/zod.js +29 -0
  118. package/dist/core/workspace/capabilities.js +3 -2
  119. package/dist/integrations/langfuse/litellm-langfuse-outcome-reporter.js +9 -8
  120. package/dist/locales/en.js +2 -1
  121. 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' ? output : output?.content;
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 msg = text.grizzco.validation[validation.errorCode] || validation.errorCode;
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 systemPrompt = await getPatchSystemPrompt(args.promptVisibleTools, {
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 systemPrompt = await getPlanSystemPrompt(promptVisibleTools, toolVisibility);
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
- try {
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 (e2) {
239
- recordPlanRepairResult({
240
- ok: false,
241
- contentLength: finalContent.length,
242
- error: sanitizeError(e2).slice(0, 400),
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 result = await engine.mergeFileContents(this.git.repoPath, baseContent, userContent, aiContent);
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._def;
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
- function isObjectRecord(value) {
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
- return messages.map((m) => {
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: m.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] = tool({
204
- description,
205
- parameters,
206
- });
207
- tools[spec.name].outputSchema = spec.outputSchema || z.any();
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] = tool({
219
- description,
220
- parameters: jsonSchema(fn?.parameters || { type: 'object', properties: {} }),
221
- });
222
- tools[name].outputSchema = z.any();
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,6 +1,4 @@
1
- function isRecord(value) {
2
- return typeof value === 'object' && value !== null && !Array.isArray(value);
3
- }
1
+ import { isRecord } from '../../utils/serialize.js';
4
2
  function isJsonValue(value) {
5
3
  if (value === null ||
6
4
  typeof value === 'string' ||
@@ -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 (typeof result?.reasoningText === 'string' && result.reasoningText.length > 0) {
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?.reasoning)
10
+ const reasoningParts = Array.isArray(result.reasoning)
8
11
  ? result.reasoning
9
- : Array.isArray(result?.content)
10
- ? result.content.filter((part) => part?.type === 'reasoning')
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?.text === 'string' ? part.text : ''))
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: result?.text || '',
25
+ content: typeof r.text === 'string' ? r.text : '',
22
26
  ...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
23
- tool_calls: toOpenAiToolCalls(result?.toolCalls),
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 && typeof response === 'object') {
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 === 'object' && typeof cause.code === 'string') {
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?.message ?? unwrapped).toLowerCase();
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 msg = String(unwrapRetryError(err)?.message ?? err).toLowerCase();
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 did not satisfy the contract.',
14
- `Reason: ${reason}`,
13
+ 'Your previous response failed to parse as a valid plan JSON object.',
15
14
  '',
16
- 'Return exactly one JSON object and nothing else.',
17
- 'The first non-whitespace character must be {.',
18
- 'The last non-whitespace character must be }.',
15
+ `Specific error: ${reason}`,
19
16
  '',
20
- 'Forbidden: Markdown fences, commentary, labels, multiple objects, or any leading/trailing text.',
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
- 'The JSON object MUST include keys: goal, files, changes, verify.',
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
- 'Previous response (truncated):',
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([
@@ -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 === 'object' && typeof cause.code === 'string') {
92
- const code = String(cause.code);
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 err?.name === 'string'
130
- ? String(err.name)
130
+ : typeof errObj?.name === 'string'
131
+ ? errObj.name
131
132
  : 'UnknownError';
132
133
  let message = err instanceof Error
133
134
  ? err.message
134
- : typeof err?.message === 'string'
135
- ? String(err.message)
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' || err?.lastError) {
139
- const lastError = err.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?.[Symbol.for('vercel.ai.error.AI_TypeValidationError')]) {
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)
@@ -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
@@ -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
- throw new Error(text.llm.planInvalidJson);
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
- throw new Error(text.llm.planInvalidJson);
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
- if (!plan.goal || !Array.isArray(plan.files) || !Array.isArray(plan.changes) || !plan.verify) {
30
- throw new Error(text.llm.planInvalid);
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
  }