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.
Files changed (123) 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/commands/serve.js +14 -1
  11. package/dist/cli/headless/openai-responses-canonical-applier.js +1 -7
  12. package/dist/cli/reporters/standard.js +2 -3
  13. package/dist/cli/reporters/stream-json.js +2 -1
  14. package/dist/cli/slash/runtime.js +2 -2
  15. package/dist/cli/ui/components/CommandSuggestionList.js +1 -1
  16. package/dist/cli/ui/hooks/useLoopEvents.js +1 -1
  17. package/dist/cli/ui/hooks/useLoopState.js +1 -1
  18. package/dist/core/ast/parser.js +18 -9
  19. package/dist/core/config/schema.js +738 -0
  20. package/dist/core/config/validate.js +11 -922
  21. package/dist/core/context/gatherers/ast-gatherer.js +4 -12
  22. package/dist/core/context/gatherers/ghost-dependency-gatherer.js +0 -1
  23. package/dist/core/context/gatherers/knowledge-gatherer.js +3 -0
  24. package/dist/core/context/service.js +39 -8
  25. package/dist/core/context/token/encoding-registry.js +7 -6
  26. package/dist/core/extensions/index.js +48 -3
  27. package/dist/core/extensions/load.js +3 -2
  28. package/dist/core/extensions/merge.js +5 -1
  29. package/dist/core/extensions/paths.js +6 -0
  30. package/dist/core/extensions/schemas.js +21 -0
  31. package/dist/core/facades/cli-command-chat.js +2 -0
  32. package/dist/core/facades/cli-run-handler.js +1 -0
  33. package/dist/core/facades/cli-utils-serialize.js +2 -0
  34. package/dist/core/grizzco/dsl/llm-strategy.js +3 -2
  35. package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +15 -10
  36. package/dist/core/grizzco/engine/pipeline/pipeline.js +149 -240
  37. package/dist/core/grizzco/engine/transaction/attempt-failure.js +5 -4
  38. package/dist/core/grizzco/engine/transaction/authorization-summary.js +2 -1
  39. package/dist/core/grizzco/runtime/apply-back-runtime.js +2 -1
  40. package/dist/core/grizzco/services/registry.js +18 -0
  41. package/dist/core/grizzco/steps/audit.js +20 -10
  42. package/dist/core/grizzco/steps/display-report.js +4 -11
  43. package/dist/core/grizzco/steps/explore.js +9 -2
  44. package/dist/core/grizzco/steps/patch/prompt-input.js +4 -1
  45. package/dist/core/grizzco/steps/patch.js +1 -0
  46. package/dist/core/grizzco/steps/plan.js +58 -49
  47. package/dist/core/grizzco/steps/tool-runtime.js +3 -0
  48. package/dist/core/grizzco/workers/strata-sync-worker.js +2 -1
  49. package/dist/core/llm/ai-sdk/message-mapper.js +24 -18
  50. package/dist/core/llm/ai-sdk/request-params.js +1 -3
  51. package/dist/core/llm/ai-sdk/result-mapper.js +14 -8
  52. package/dist/core/llm/ai-sdk/retry-classifier.js +6 -4
  53. package/dist/core/llm/contracts/repair.js +16 -8
  54. package/dist/core/llm/errors.js +13 -10
  55. package/dist/core/llm/output-policy.js +8 -0
  56. package/dist/core/llm/redact.js +1 -3
  57. package/dist/core/llm/sub-agent-factory.js +48 -0
  58. package/dist/core/llm/tool-calling-stub.js +48 -0
  59. package/dist/core/llm/utils.js +17 -6
  60. package/dist/core/mcp/bridge/prompt-command-provider.js +4 -3
  61. package/dist/core/mcp/bridge/tool-bridge.js +5 -14
  62. package/dist/core/mcp/client/connection-manager.js +3 -2
  63. package/dist/core/mcp/host/sampling-provider.js +1 -1
  64. package/dist/core/mcp/schema/json-schema-to-zod.js +2 -1
  65. package/dist/core/memory/relevant-retrieval.js +6 -4
  66. package/dist/core/observability/authorization-decisions.js +13 -12
  67. package/dist/core/observability/error-mapping.js +2 -1
  68. package/dist/core/observability/token-usage.js +5 -4
  69. package/dist/core/plugin/loader.js +5 -4
  70. package/dist/core/prompts/registry.js +11 -29
  71. package/dist/core/protocols/a2a/sdk/server.js +2 -3
  72. package/dist/core/protocols/acp/formal-agent.js +10 -4
  73. package/dist/core/protocols/acp/stdio-server.js +6 -6
  74. package/dist/core/runtime/agent-server-runtime.js +3 -2
  75. package/dist/core/runtime/initialize.js +70 -6
  76. package/dist/core/session/compaction/index.js +4 -3
  77. package/dist/core/session/manager.js +41 -47
  78. package/dist/core/session/token-tracker.js +18 -7
  79. package/dist/core/skills/parser.js +3 -2
  80. package/dist/core/skills/runtime/MicroTaskRunner.js +1 -1
  81. package/dist/core/skills/runtime/SkillRunner.js +5 -2
  82. package/dist/core/slash/steps/slash-execute.js +7 -5
  83. package/dist/core/slash/strategy.js +1 -1
  84. package/dist/core/strata/layers/worktree.js +7 -9
  85. package/dist/core/strata/runtime/synchronizer.js +10 -9
  86. package/dist/core/streaming/canonical/parts-from-llm-stream-chunk.js +1 -11
  87. package/dist/core/structured-output/json-schema-validator.js +1 -13
  88. package/dist/core/sub-agent/context-snapshot.js +12 -6
  89. package/dist/core/sub-agent/controller.js +70 -1
  90. package/dist/core/sub-agent/core/loop.js +25 -3
  91. package/dist/core/sub-agent/core/manager.js +319 -116
  92. package/dist/core/sub-agent/registry-defaults.js +12 -0
  93. package/dist/core/sub-agent/registry.js +8 -0
  94. package/dist/core/sub-agent/team.js +98 -0
  95. package/dist/core/sub-agent/tools/task-await.js +109 -0
  96. package/dist/core/sub-agent/tools/task-spawn.js +49 -7
  97. package/dist/core/sub-agent/tools/team.js +92 -0
  98. package/dist/core/sub-agent/types.js +11 -2
  99. package/dist/core/tools/budget.js +4 -11
  100. package/dist/core/tools/builtin/code-search/executor.js +46 -43
  101. package/dist/core/tools/builtin/fs.js +14 -6
  102. package/dist/core/tools/builtin/index.js +41 -107
  103. package/dist/core/tools/builtin/interaction.js +13 -15
  104. package/dist/core/tools/builtin/proposal.js +11 -2
  105. package/dist/core/tools/capability/executor.js +5 -5
  106. package/dist/core/tools/headless-payload.js +1 -3
  107. package/dist/core/tools/mapper.js +8 -42
  108. package/dist/core/tools/parallel/persistence.js +17 -5
  109. package/dist/core/tools/parallel/scheduler.js +23 -21
  110. package/dist/core/tools/permissions/permission-rules.js +66 -114
  111. package/dist/core/tools/plugins/loader.js +4 -3
  112. package/dist/core/tools/router.js +24 -53
  113. package/dist/core/tools/session.js +54 -97
  114. package/dist/core/tools/streaming/ToolCallAccumulator.js +1 -3
  115. package/dist/core/tools/tool-visibility.js +2 -1
  116. package/dist/core/tools/types.js +10 -0
  117. package/dist/core/utils/error.js +79 -0
  118. package/dist/core/utils/serialize.js +63 -0
  119. package/dist/core/utils/zod.js +29 -0
  120. package/dist/core/workspace/capabilities.js +3 -2
  121. package/dist/integrations/langfuse/litellm-langfuse-outcome-reporter.js +9 -8
  122. package/dist/locales/en.js +2 -1
  123. package/package.json +1 -1
@@ -8,7 +8,9 @@ import { CanonicalResponsesEventEmitter, } from '../streaming/canonical/canonica
8
8
  import { mapLlmStreamChunkToCanonicalStreamParts } from '../streaming/canonical/parts-from-llm-stream-chunk.js';
9
9
  import { ArtifactStore } from '../sub-agent/artifacts/store.js';
10
10
  import { Phase } from '../types/runtime.js';
11
+ import { extractNetworkCode, extractProvider, extractStatusCode } from '../utils/error.js';
11
12
  import { isSafeRelativePath, normalizePath } from '../utils/path.js';
13
+ import { isRecord } from '../utils/serialize.js';
12
14
  import { buildHeadlessToolInputPayload } from './headless-payload.js';
13
15
  import { toolToOpenAI } from './mapper.js';
14
16
  import { InMemoryLockManager } from './parallel/lock-manager.js';
@@ -17,6 +19,13 @@ import { ParallelScheduler } from './parallel/scheduler.js';
17
19
  import { isRecoverableToolInputErrorCode } from './recoverable-tool-errors.js';
18
20
  import { ToolCallAccumulator } from './streaming/ToolCallAccumulator.js';
19
21
  import { resolveVisibleToolSpecs } from './tool-visibility.js';
22
+ function findLast(array, predicate) {
23
+ for (let i = array.length - 1; i >= 0; i--) {
24
+ if (predicate(array[i]))
25
+ return array[i];
26
+ }
27
+ return undefined;
28
+ }
20
29
  function safeParseJson(argsText) {
21
30
  if (typeof argsText !== 'string') {
22
31
  return { ok: true, value: argsText };
@@ -126,7 +135,7 @@ function isArtifactHandleRecord(value) {
126
135
  typeof candidate.size === 'number');
127
136
  }
128
137
  function extractArtifactHandlesFromToolOutput(output) {
129
- if (!isObjectRecord(output)) {
138
+ if (!isRecord(output)) {
130
139
  return {};
131
140
  }
132
141
  const patchArtifact = isArtifactHandleRecord(output.patchArtifact)
@@ -144,12 +153,12 @@ function extractRecentReadResult(params) {
144
153
  if (params.toolName !== 'fs.read' && params.toolName !== 'code.read') {
145
154
  return undefined;
146
155
  }
147
- if (!isObjectRecord(params.output) || typeof params.output.content !== 'string') {
156
+ if (!isRecord(params.output) || typeof params.output.content !== 'string') {
148
157
  return undefined;
149
158
  }
150
159
  const args = safeParseJson(params.rawArgs);
151
160
  const argsValue = args.ok ? args.value : params.rawArgs;
152
- if (!isObjectRecord(argsValue))
161
+ if (!isRecord(argsValue))
153
162
  return undefined;
154
163
  const file = argsValue.file ?? argsValue.file_path ?? argsValue.filePath ?? argsValue.path;
155
164
  if (typeof file !== 'string' || !file.trim())
@@ -703,7 +712,7 @@ export async function chatWithTools(initialMessages, chatOptions, session) {
703
712
  tool_calls: assistant.tool_calls,
704
713
  });
705
714
  const toolCalls = assistant.tool_calls || [];
706
- if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
715
+ if (toolCalls.length === 0) {
707
716
  if (session.llmOutput) {
708
717
  emitLlmOutput({
709
718
  emit: session.emit,
@@ -728,7 +737,7 @@ export async function chatWithTools(initialMessages, chatOptions, session) {
728
737
  message: `Tool calling exceeded maximum rounds (${maxRounds}); continuing without further tool execution`,
729
738
  timestamp: new Date(),
730
739
  });
731
- const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
740
+ const lastAssistant = findLast(messages, (m) => m.role === 'assistant');
732
741
  if (session.llmOutput && lastAssistant?.content) {
733
742
  emitLlmOutput({
734
743
  emit: session.emit,
@@ -740,11 +749,8 @@ export async function chatWithTools(initialMessages, chatOptions, session) {
740
749
  }
741
750
  return lastAssistant || { role: 'assistant', content: '' };
742
751
  }
743
- function isObjectRecord(value) {
744
- return typeof value === 'object' && value !== null && !Array.isArray(value);
745
- }
746
752
  function isPlainObject(value) {
747
- if (!isObjectRecord(value))
753
+ if (!isRecord(value))
748
754
  return false;
749
755
  const proto = Object.getPrototypeOf(value);
750
756
  return proto === Object.prototype || proto === null;
@@ -789,61 +795,6 @@ function coercePlanUpdatePatch(args) {
789
795
  }
790
796
  return { args, error: formatPlanUpdatePatchTypeError(describeValueType(patch)) };
791
797
  }
792
- function unwrapRetryError(err) {
793
- if (!err || typeof err !== 'object')
794
- return err;
795
- const candidate = err;
796
- if (candidate.lastError)
797
- return candidate.lastError;
798
- return err;
799
- }
800
- function extractStatusCode(err) {
801
- const unwrapped = unwrapRetryError(err);
802
- if (!unwrapped || typeof unwrapped !== 'object')
803
- return undefined;
804
- const meta = unwrapped?.meta;
805
- if (meta && typeof meta === 'object' && typeof meta.statusCode === 'number') {
806
- return meta.statusCode;
807
- }
808
- const statusCode = unwrapped?.statusCode;
809
- if (typeof statusCode === 'number')
810
- return statusCode;
811
- const response = unwrapped?.response;
812
- if (response && typeof response === 'object' && typeof response.status === 'number') {
813
- return response.status;
814
- }
815
- return undefined;
816
- }
817
- function extractNetworkCode(err) {
818
- const unwrapped = unwrapRetryError(err);
819
- if (!unwrapped || typeof unwrapped !== 'object')
820
- return undefined;
821
- const code = unwrapped?.code;
822
- if (typeof code === 'string')
823
- return code;
824
- const cause = unwrapped?.cause;
825
- if (cause && typeof cause === 'object' && typeof cause.code === 'string') {
826
- return cause.code;
827
- }
828
- const meta = unwrapped?.meta;
829
- if (meta && typeof meta === 'object' && typeof meta.causeName === 'string') {
830
- return meta.causeName;
831
- }
832
- return undefined;
833
- }
834
- function extractProvider(err) {
835
- const unwrapped = unwrapRetryError(err);
836
- if (!unwrapped || typeof unwrapped !== 'object')
837
- return undefined;
838
- const meta = unwrapped?.meta;
839
- if (meta && typeof meta === 'object' && typeof meta.provider === 'string') {
840
- return meta.provider;
841
- }
842
- const provider = unwrapped?.provider;
843
- if (typeof provider === 'string')
844
- return provider;
845
- return undefined;
846
- }
847
798
  const ENABLE_TOOL_ARG_REPAIR = process.env.SALMONLOOP_ENABLE_TOOL_ARG_REPAIR === '1' ||
848
799
  process.env.SALMONLOOP_ENABLE_TOOL_ARG_REPAIR === 'true';
849
800
  const SAFE_INFERRED_EXTENSIONS = new Set([
@@ -901,7 +852,7 @@ function inferHighConfidenceFiles(instruction) {
901
852
  return Array.from(new Set(candidates));
902
853
  }
903
854
  function extractInstructionText(messages) {
904
- const lastUser = [...messages].reverse().find((m) => m.role === 'user');
855
+ const lastUser = findLast(messages, (m) => m.role === 'user');
905
856
  const text = typeof lastUser?.content === 'string' ? lastUser.content : '';
906
857
  if (!text)
907
858
  return '';
@@ -921,7 +872,7 @@ function prepareToolCallRequests(calls) {
921
872
  async function runToolExecutionPlan(params) {
922
873
  const scheduler = new ParallelScheduler(params.session.toolstack.router, new InMemoryLockManager());
923
874
  const runSignal = params.signal ?? new AbortController().signal;
924
- let result = (await scheduler.run(params.plan, { ...params.session.runtime, phase: params.phase }, runSignal));
875
+ let result = await scheduler.run(params.plan, { ...params.session.runtime, phase: params.phase }, runSignal);
925
876
  const persistEnabled = process.env.NODE_ENV !== 'test';
926
877
  const persistenceRoot = params.session.runtime.persistenceRoot || params.session.runtime.repoRoot;
927
878
  if (persistEnabled) {
@@ -943,10 +894,10 @@ async function runToolExecutionPlan(params) {
943
894
  await Promise.all(result.blockedApprovals.map(async (a) => {
944
895
  await waitForAuthorization?.(a.nodeId, runSignal);
945
896
  }));
946
- result = (await scheduler.run(params.plan, { ...params.session.runtime, phase: params.phase }, runSignal, {
897
+ result = await scheduler.run(params.plan, { ...params.session.runtime, phase: params.phase }, runSignal, {
947
898
  initialResults: result.nodeResults,
948
899
  resumeBlockedApprovals: true,
949
- }));
900
+ });
950
901
  if (persistEnabled) {
951
902
  await PlanPersistence.save(persistenceRoot, params.plan, result, {
952
903
  repoRoot: params.session.runtime.repoRoot,
@@ -983,12 +934,14 @@ function applyStrictToolOutputSchemaValidation(params) {
983
934
  }
984
935
  async function executeToolCalls(session, phase, round, calls, messages, signal) {
985
936
  const prepared = prepareToolCallRequests(calls);
937
+ const specByName = new Map();
938
+ for (const spec of session.toolstack.registry.listAll()) {
939
+ specByName.set(spec.name, spec);
940
+ }
986
941
  const bucketByCallId = new Map();
987
942
  const preparedCounts = { regular: 0, agent: 0 };
988
943
  for (const item of prepared) {
989
- const spec = typeof item.toolName === 'string'
990
- ? session.toolstack.registry.listAll().find((s) => s.name === item.toolName)
991
- : undefined;
944
+ const spec = typeof item.toolName === 'string' ? specByName.get(item.toolName) : undefined;
992
945
  const bucket = spec?.intent === 'AGENT' ? 'agent' : 'regular';
993
946
  bucketByCallId.set(item.callId, bucket);
994
947
  preparedCounts[bucket]++;
@@ -1031,7 +984,7 @@ async function executeToolCalls(session, phase, round, calls, messages, signal)
1031
984
  ENABLE_TOOL_ARG_REPAIR &&
1032
985
  phase === Phase.EXPLORE &&
1033
986
  normalizedToolName === 'fs.read' &&
1034
- isObjectRecord(argsValue) &&
987
+ isRecord(argsValue) &&
1035
988
  typeof argsValue.file !== 'string') {
1036
989
  const instruction = extractInstructionText(messages);
1037
990
  const inferred = inferHighConfidenceFiles(instruction);
@@ -1040,7 +993,7 @@ async function executeToolCalls(session, phase, round, calls, messages, signal)
1040
993
  }
1041
994
  }
1042
995
  let planUpdatePatchError;
1043
- if (parsedArgsOk && normalizedToolName === 'plan.update' && isObjectRecord(argsValue)) {
996
+ if (parsedArgsOk && normalizedToolName === 'plan.update' && isRecord(argsValue)) {
1044
997
  const patchGuard = coercePlanUpdatePatch(argsValue);
1045
998
  argsValue = patchGuard.args;
1046
999
  if (patchGuard.coercedPatchSource) {
@@ -1056,9 +1009,7 @@ async function executeToolCalls(session, phase, round, calls, messages, signal)
1056
1009
  const input = session.eventPayload?.includeToolInput && parsedArgsOk
1057
1010
  ? buildHeadlessToolInputPayload(argsValue)
1058
1011
  : undefined;
1059
- const spec = typeof toolName === 'string'
1060
- ? session.toolstack.registry.listAll().find((s) => s.name === toolName)
1061
- : undefined;
1012
+ const spec = typeof toolName === 'string' ? specByName.get(toolName) : undefined;
1062
1013
  if (typeof toolName === 'string') {
1063
1014
  session.emit?.({
1064
1015
  type: 'log',
@@ -1177,7 +1128,8 @@ async function executeToolCalls(session, phase, round, calls, messages, signal)
1177
1128
  };
1178
1129
  const patchCoercionSource = patchCoercionByCallId.get(callId);
1179
1130
  if (patchCoercionSource) {
1180
- parsedAuditEntry.coercedPatchSource = patchCoercionSource;
1131
+ parsedAuditEntry.coercedPatchSource =
1132
+ patchCoercionSource;
1181
1133
  }
1182
1134
  session.toolCallingAudit?.event(parsedAuditEntry);
1183
1135
  if (planUpdatePatchError) {
@@ -1269,9 +1221,10 @@ async function executeToolCalls(session, phase, round, calls, messages, signal)
1269
1221
  if (result.status !== 'ok' &&
1270
1222
  result.error?.code === 'INTERRUPT_REQUIRED' &&
1271
1223
  result.meta?.interrupt) {
1272
- const err = new Error(result.error.message || 'Interrupt required');
1273
- err.code = 'INTERRUPT_REQUIRED';
1274
- err.interrupt = result.meta.interrupt;
1224
+ const err = Object.assign(new Error(result.error.message || 'Interrupt required'), {
1225
+ code: 'INTERRUPT_REQUIRED',
1226
+ interrupt: result.meta.interrupt,
1227
+ });
1275
1228
  throw err;
1276
1229
  }
1277
1230
  if (result.status !== 'ok') {
@@ -1295,26 +1248,30 @@ async function executeToolCalls(session, phase, round, calls, messages, signal)
1295
1248
  };
1296
1249
  const patchCoercionSource = patchCoercionByCallId.get(callId);
1297
1250
  if (patchCoercionSource) {
1298
- errorAuditEntry.coercedPatchSource = patchCoercionSource;
1251
+ errorAuditEntry.coercedPatchSource =
1252
+ patchCoercionSource;
1299
1253
  }
1300
1254
  session.toolCallingAudit?.event(errorAuditEntry);
1301
1255
  }
1302
1256
  else {
1303
- const toolResultOutputOk = isObjectRecord(result.output) && typeof result.output.ok === 'boolean'
1257
+ const toolResultOutputOk = isRecord(result.output) && typeof result.output.ok === 'boolean'
1304
1258
  ? result.output.ok
1305
1259
  : undefined;
1306
1260
  const artifacts = extractArtifactHandlesFromToolOutput(result.output);
1307
- const recentReadArtifact = await persistRecentReadArtifact({
1308
- toolName: typeof toolName === 'string' ? toolName : 'unknown',
1309
- rawArgs,
1310
- output: result.output,
1311
- });
1312
- const toolResultPreviewArtifact = await persistToolResultPreviewArtifact({
1313
- toolName: typeof toolName === 'string' ? toolName : 'unknown',
1314
- output: result.output,
1315
- summary: result.summary,
1316
- outputSummary: result.outputSummary,
1317
- });
1261
+ const resolvedToolName = typeof toolName === 'string' ? toolName : 'unknown';
1262
+ const [recentReadArtifact, toolResultPreviewArtifact] = await Promise.all([
1263
+ persistRecentReadArtifact({
1264
+ toolName: resolvedToolName,
1265
+ rawArgs,
1266
+ output: result.output,
1267
+ }),
1268
+ persistToolResultPreviewArtifact({
1269
+ toolName: resolvedToolName,
1270
+ output: result.output,
1271
+ summary: result.summary,
1272
+ outputSummary: result.outputSummary,
1273
+ }),
1274
+ ]);
1318
1275
  session.toolCallingAudit?.event({
1319
1276
  timestamp: new Date().toISOString(),
1320
1277
  phase,
@@ -1410,9 +1367,9 @@ export async function chatWithToolsStreaming(initialMessages, chatOptions, sessi
1410
1367
  statusCode: extractStatusCode(e),
1411
1368
  networkCode: extractNetworkCode(e),
1412
1369
  errorName: e instanceof Error ? e.name : 'UnknownError',
1413
- errorCode: typeof e?.llmCode === 'string'
1370
+ errorCode: isRecord(e) && typeof e.llmCode === 'string'
1414
1371
  ? e.llmCode
1415
- : typeof e?.code === 'string'
1372
+ : isRecord(e) && typeof e.code === 'string'
1416
1373
  ? e.code
1417
1374
  : undefined,
1418
1375
  }, { source: 'llm', severity: 'low', scope: 'session', phase });
@@ -1470,7 +1427,7 @@ export async function chatWithToolsStreaming(initialMessages, chatOptions, sessi
1470
1427
  }, { source: 'llm', severity: 'low', scope: 'session', phase });
1471
1428
  messages.push(assistant);
1472
1429
  const calls = assistant.tool_calls || [];
1473
- if (!Array.isArray(calls) || calls.length === 0) {
1430
+ if (calls.length === 0) {
1474
1431
  if (session.llmOutput) {
1475
1432
  emitLlmStreamEnd({
1476
1433
  emit: session.emit,
@@ -1509,7 +1466,7 @@ export async function chatWithToolsStreaming(initialMessages, chatOptions, sessi
1509
1466
  message: `Tool calling exceeded maximum rounds (${maxRounds}); continuing without further tool execution`,
1510
1467
  timestamp: new Date(),
1511
1468
  });
1512
- const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
1469
+ const lastAssistant = findLast(messages, (m) => m.role === 'assistant');
1513
1470
  return lastAssistant || { role: 'assistant', content: '' };
1514
1471
  }
1515
1472
  //# sourceMappingURL=session.js.map
@@ -1,6 +1,4 @@
1
- function isRecord(value) {
2
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
3
- }
1
+ import { isRecord } from '../../utils/serialize.js';
4
2
  function mergeToolCall(prev, next) {
5
3
  if (!isRecord(next))
6
4
  return prev ?? next;
@@ -46,7 +46,8 @@ export function resolvePhaseVisibleTools(params) {
46
46
  export function resolveVisibleToolSpecs(params) {
47
47
  if (!params.toolstack)
48
48
  return [];
49
- const allowedSpecs = params.toolstack.registry.listAll().filter((spec) => params.toolstack.policy.decide(params.phase, spec, {
49
+ const { toolstack } = params;
50
+ const allowedSpecs = toolstack.registry.listAll().filter((spec) => toolstack.policy.decide(params.phase, spec, {
50
51
  worktreeRoot: params.worktreeRoot,
51
52
  flowMode: params.flowMode,
52
53
  }).allowed);
@@ -7,4 +7,14 @@ export const TOOL_INTENTS = [
7
7
  'AGENT',
8
8
  'REPORT',
9
9
  ];
10
+ /**
11
+ * Type-safe helper to create a fully-formed ToolSpec from a spec definition and executor.
12
+ * Eliminates the `{ ...spec, executor: exec as any }` boilerplate pattern.
13
+ *
14
+ * The executor parameter accepts a superset of ToolRuntimeCtx (e.g., with phase narrowed
15
+ * to non-optional) to support tools that require the router-injected phase field.
16
+ */
17
+ export function defineTool(spec, executor) {
18
+ return { ...spec, executor: executor };
19
+ }
10
20
  //# sourceMappingURL=types.js.map
@@ -0,0 +1,79 @@
1
+ import { isRecord } from './serialize.js';
2
+ /**
3
+ * Extract a human-readable error message from an unknown thrown value.
4
+ * Handles Error instances, strings, and falls back to String(value).
5
+ */
6
+ export function errorMessage(error) {
7
+ if (error instanceof Error)
8
+ return error.message;
9
+ if (typeof error === 'string')
10
+ return error;
11
+ return String(error);
12
+ }
13
+ /**
14
+ * Unwrap retry-style errors that wrap the real error in a `lastError` property.
15
+ */
16
+ export function unwrapRetryError(err) {
17
+ if (!isRecord(err))
18
+ return err;
19
+ if (err.lastError)
20
+ return err.lastError;
21
+ return err;
22
+ }
23
+ /**
24
+ * Extract an HTTP-style status code from an error object.
25
+ * Checks: meta.statusCode, statusCode, response.status
26
+ */
27
+ export function extractStatusCode(err) {
28
+ const unwrapped = unwrapRetryError(err);
29
+ if (!isRecord(unwrapped))
30
+ return undefined;
31
+ const meta = unwrapped.meta;
32
+ if (isRecord(meta) && typeof meta.statusCode === 'number') {
33
+ return meta.statusCode;
34
+ }
35
+ if (typeof unwrapped.statusCode === 'number')
36
+ return unwrapped.statusCode;
37
+ const response = unwrapped.response;
38
+ if (isRecord(response) && typeof response.status === 'number') {
39
+ return response.status;
40
+ }
41
+ return undefined;
42
+ }
43
+ /**
44
+ * Extract a network error code from an error object.
45
+ * Checks: code, cause.code, meta.causeName
46
+ */
47
+ export function extractNetworkCode(err) {
48
+ const unwrapped = unwrapRetryError(err);
49
+ if (!isRecord(unwrapped))
50
+ return undefined;
51
+ if (typeof unwrapped.code === 'string')
52
+ return unwrapped.code;
53
+ const cause = unwrapped.cause;
54
+ if (isRecord(cause) && typeof cause.code === 'string') {
55
+ return cause.code;
56
+ }
57
+ const meta = unwrapped.meta;
58
+ if (isRecord(meta) && typeof meta.causeName === 'string') {
59
+ return meta.causeName;
60
+ }
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Extract a provider name from an error object.
65
+ * Checks: meta.provider, provider
66
+ */
67
+ export function extractProvider(err) {
68
+ const unwrapped = unwrapRetryError(err);
69
+ if (!isRecord(unwrapped))
70
+ return undefined;
71
+ const meta = unwrapped.meta;
72
+ if (isRecord(meta) && typeof meta.provider === 'string') {
73
+ return meta.provider;
74
+ }
75
+ if (typeof unwrapped.provider === 'string')
76
+ return unwrapped.provider;
77
+ return undefined;
78
+ }
79
+ //# sourceMappingURL=error.js.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Safely serialize a value to JSON string.
3
+ * Returns '[Unserializable]' if JSON.stringify throws and String() also fails.
4
+ */
5
+ export function safeStringify(value, options) {
6
+ try {
7
+ const raw = JSON.stringify(value, null, options?.indent);
8
+ if (options?.maxLength && raw.length > options.maxLength) {
9
+ return `${raw.slice(0, options.maxLength)}...`;
10
+ }
11
+ return raw;
12
+ }
13
+ catch {
14
+ try {
15
+ return String(value);
16
+ }
17
+ catch {
18
+ return '[Unserializable]';
19
+ }
20
+ }
21
+ }
22
+ /**
23
+ * Type guard: narrow an unknown value to Record<string, unknown>.
24
+ * Returns false for arrays, primitives, null, and undefined.
25
+ */
26
+ export function isRecord(value) {
27
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
28
+ }
29
+ /**
30
+ * Narrow an unknown value to a Record<string, unknown>.
31
+ * Returns an empty object for non-object inputs (arrays, primitives, null, undefined).
32
+ */
33
+ export function asRecord(value) {
34
+ return isRecord(value) ? value : {};
35
+ }
36
+ /**
37
+ * Extract a string value from a record by key.
38
+ * Returns null if the key doesn't exist or the value isn't a string.
39
+ */
40
+ export function getString(record, key) {
41
+ const value = record[key];
42
+ return typeof value === 'string' ? value : null;
43
+ }
44
+ /**
45
+ * Extract a nested record from a record by key.
46
+ * Returns null if the key doesn't exist or the value isn't a record.
47
+ */
48
+ export function getRecord(record, key) {
49
+ const value = record[key];
50
+ return isRecord(value) ? value : null;
51
+ }
52
+ /**
53
+ * Extract a human-readable error message from an unknown thrown value.
54
+ * Handles Error instances, strings, and falls back to String(value).
55
+ */
56
+ export function errorMessage(error) {
57
+ if (error instanceof Error)
58
+ return error.message;
59
+ if (typeof error === 'string')
60
+ return error;
61
+ return String(error);
62
+ }
63
+ //# sourceMappingURL=serialize.js.map
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Unwrap Zod wrapper types (ZodPipe, ZodOptional, ZodNullable, ZodDefault)
4
+ * to get the underlying schema. Useful for schema generation and hint building.
5
+ */
6
+ export function unwrapZodSchema(schema) {
7
+ let current = schema;
8
+ for (let depth = 0; depth < 20; depth++) {
9
+ if (current instanceof z.ZodPipe) {
10
+ current = current.def.out;
11
+ continue;
12
+ }
13
+ if (current instanceof z.ZodOptional) {
14
+ current = current.def.innerType;
15
+ continue;
16
+ }
17
+ if (current instanceof z.ZodNullable) {
18
+ current = current.def.innerType;
19
+ continue;
20
+ }
21
+ if (current instanceof z.ZodDefault) {
22
+ current = current.def.innerType;
23
+ continue;
24
+ }
25
+ break;
26
+ }
27
+ return current;
28
+ }
29
+ //# sourceMappingURL=zod.js.map
@@ -1,6 +1,7 @@
1
1
  import { access, constants } from '../adapters/fs/node-fs.js';
2
2
  import { GitAdapter } from '../adapters/git/git-adapter.js';
3
3
  import { LIMITS } from '../config/limits.js';
4
+ import { errorMessage } from '../utils/error.js';
4
5
  const PROBE_LIMITS = { maxStdoutBytes: 4_096, maxStderrChars: 4_096 };
5
6
  function gitFailureReason(result) {
6
7
  if (result.error?.code === 'ENOENT')
@@ -23,7 +24,7 @@ async function detectFileSystemCapability(workspacePath) {
23
24
  return {
24
25
  readable: false,
25
26
  writable: false,
26
- reason: error instanceof Error ? error.message : String(error),
27
+ reason: errorMessage(error),
27
28
  };
28
29
  }
29
30
  try {
@@ -34,7 +35,7 @@ async function detectFileSystemCapability(workspacePath) {
34
35
  return {
35
36
  readable: true,
36
37
  writable: false,
37
- reason: error instanceof Error ? error.message : String(error),
38
+ reason: errorMessage(error),
38
39
  };
39
40
  }
40
41
  }
@@ -1,6 +1,7 @@
1
1
  import { readFile } from '../../core/adapters/fs/node-fs.js';
2
2
  import { recordAuditEvent } from '../../core/observability/audit-trail.js';
3
3
  import { getLogger } from '../../core/observability/logger.js';
4
+ import { isRecord } from '../../core/utils/serialize.js';
4
5
  import { text } from '../../locales/index.js';
5
6
  function nowIso() {
6
7
  return new Date().toISOString();
@@ -43,7 +44,7 @@ function buildPhaseDurations(traces) {
43
44
  return undefined;
44
45
  const out = {};
45
46
  for (const t of traces) {
46
- if (!t || typeof t !== 'object')
47
+ if (!isRecord(t))
47
48
  continue;
48
49
  const name = t.name;
49
50
  const duration = t.duration;
@@ -69,13 +70,13 @@ async function tryReadAuditJson(auditPath) {
69
70
  }
70
71
  function extractNetworkErrorCode(error) {
71
72
  const allow = (value) => typeof value === 'string' && /^[A-Z0-9_]{2,32}$/.test(value) ? value : undefined;
72
- if (error && typeof error === 'object') {
73
- const e = error;
74
- return (allow(e.code) ||
75
- allow(e.cause?.code) ||
76
- allow(e.cause?.errno) ||
77
- allow(e.errno) ||
78
- allow(e.cause?.name));
73
+ if (isRecord(error)) {
74
+ const cause = isRecord(error.cause) ? error.cause : undefined;
75
+ return (allow(error.code) ||
76
+ allow(cause?.code) ||
77
+ allow(cause?.errno) ||
78
+ allow(error.errno) ||
79
+ allow(cause?.name));
79
80
  }
80
81
  return undefined;
81
82
  }
@@ -770,7 +770,8 @@ Please return the patch in PURE unified diff format:`;
770
770
  missionFailedWithReason: (reason) => `Smallfry mission failed: ${reason}`,
771
771
  },
772
772
  ui: {
773
- spawnToolDescription: 'Delegate a concrete sub-task to a specialized sub-agent. This is not a no-argument action: always provide agent_ref and task. Use agent_ref="explorer" for read-only investigation, "reviewer" for audit, "surgeon" for implementation proposals, or "cleaner" for lint/format cleanup. Keep task self-contained with relevant files and the exact deliverable. Omit session_target unless shared context is explicitly required.',
773
+ spawnToolDescription: 'Delegate a concrete sub-task to a specialized sub-agent. This is not a no-argument action: always provide agent_ref and task. Use agent_ref="explorer" for read-only investigation, "reviewer" for audit, "surgeon" for implementation proposals, or "cleaner" for lint/format cleanup. Keep task self-contained with relevant files and the exact deliverable. Omit session_target unless shared context is explicitly required. Set async=true to get a handle immediately and use agent_await to collect the result later.',
774
+ awaitToolDescription: 'Wait for an async sub-agent (spawned with agent_dispatch async=true) to complete and return its result. Pass the agentId from the dispatch handle.',
774
775
  progressTitle: (id) => `[Smallfry: ${id}]`,
775
776
  },
776
777
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salmon-loop",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "A chat-first coding agent CLI for safe, reviewable repository changes",
5
5
  "type": "module",
6
6
  "bin": {