salmon-loop 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/dist/cli/authorization/non-interactive.js +9 -13
  2. package/dist/cli/authorization/provider.js +2 -10
  3. package/dist/cli/chat.js +12 -6
  4. package/dist/cli/commands/allowlist.js +1 -1
  5. package/dist/cli/commands/chat.js +13 -13
  6. package/dist/cli/commands/config.js +2 -2
  7. package/dist/cli/commands/mode.js +2 -2
  8. package/dist/cli/commands/parallel.js +1 -1
  9. package/dist/cli/commands/run/handler.js +9 -4
  10. package/dist/cli/commands/run/loop-params.js +2 -0
  11. package/dist/cli/commands/run/parse-options.js +14 -26
  12. package/dist/cli/commands/run/runtime-llm.js +15 -12
  13. package/dist/cli/commands/run/runtime-options.js +3 -1
  14. package/dist/cli/config.js +0 -8
  15. package/dist/cli/headless/openai-responses-canonical-applier.js +1 -7
  16. package/dist/cli/locales/en.js +2 -2
  17. package/dist/cli/reporters/standard.js +12 -3
  18. package/dist/cli/reporters/stream-json.js +2 -1
  19. package/dist/cli/slash/runtime.js +2 -2
  20. package/dist/cli/ui/hooks/useLoopEvents.js +1 -1
  21. package/dist/cli/ui/hooks/useLoopState.js +1 -1
  22. package/dist/core/adapters/fs/file-adapter.js +3 -1
  23. package/dist/core/adapters/git/git-adapter.js +6 -3
  24. package/dist/core/adapters/git/git-runner.js +5 -2
  25. package/dist/core/adapters/git/lock-manager.js +7 -4
  26. package/dist/core/ast/parser.js +18 -9
  27. package/dist/core/checkpoint-domain/manifest-store.js +21 -13
  28. package/dist/core/checkpoint-domain/service.js +3 -1
  29. package/dist/core/config/limits.js +1 -1
  30. package/dist/core/config/model-pricing.js +61 -0
  31. package/dist/core/config/schema.js +738 -0
  32. package/dist/core/config/validate.js +11 -922
  33. package/dist/core/context/ast/skeleton-extractor.js +225 -0
  34. package/dist/core/context/ast/source-outline.js +24 -1
  35. package/dist/core/context/budget/dynamic-adjuster.js +20 -5
  36. package/dist/core/context/builder.js +7 -3
  37. package/dist/core/context/cache/store-factory.js +3 -1
  38. package/dist/core/context/dependencies.js +2 -1
  39. package/dist/core/context/effectiveness/persistence.js +50 -0
  40. package/dist/core/context/effectiveness/tracker.js +24 -0
  41. package/dist/core/context/gatherers/architecture-gatherer.js +2 -1
  42. package/dist/core/context/gatherers/artifact-gatherer.js +7 -4
  43. package/dist/core/context/gatherers/ast-gatherer.js +34 -40
  44. package/dist/core/context/gatherers/ghost-dependency-gatherer.js +0 -1
  45. package/dist/core/context/gatherers/git-history-gatherer.js +3 -1
  46. package/dist/core/context/gatherers/knowledge-gatherer.js +21 -2
  47. package/dist/core/context/gatherers/metadata-gatherer.js +12 -7
  48. package/dist/core/context/gatherers/ripgrep-gatherer.js +6 -3
  49. package/dist/core/context/service.js +12 -2
  50. package/dist/core/context/steps/context-gather.js +14 -3
  51. package/dist/core/context/steps/context-targets.js +1 -0
  52. package/dist/core/context/targeting/target-resolver.js +29 -11
  53. package/dist/core/context/token/cache.js +5 -2
  54. package/dist/core/context/token/encoding-registry.js +7 -6
  55. package/dist/core/context/truncation/strategies/json.js +5 -2
  56. package/dist/core/context/truncation/type-detector.js +3 -1
  57. package/dist/core/extensions/index.js +48 -3
  58. package/dist/core/extensions/load.js +3 -2
  59. package/dist/core/extensions/merge.js +5 -1
  60. package/dist/core/extensions/paths.js +8 -2
  61. package/dist/core/extensions/schemas.js +21 -0
  62. package/dist/core/facades/cli-authorization-provider.js +1 -0
  63. package/dist/core/facades/cli-command-chat.js +2 -0
  64. package/dist/core/facades/cli-run-handler.js +1 -0
  65. package/dist/core/facades/cli-utils-serialize.js +2 -0
  66. package/dist/core/feedback/parsers.js +290 -1
  67. package/dist/core/grizzco/dsl/llm-strategy.js +4 -3
  68. package/dist/core/grizzco/engine/observability/loop-telemetry.js +5 -2
  69. package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +30 -13
  70. package/dist/core/grizzco/engine/pipeline/pipeline.js +149 -240
  71. package/dist/core/grizzco/engine/transaction/attempt-failure.js +49 -24
  72. package/dist/core/grizzco/engine/transaction/authorization-summary.js +2 -1
  73. package/dist/core/grizzco/engine/transaction/transaction-runner.js +40 -34
  74. package/dist/core/grizzco/execution/RejectionManager.js +7 -5
  75. package/dist/core/grizzco/runtime/apply-back-runtime.js +5 -2
  76. package/dist/core/grizzco/services/implementations/default/GitConfigService.js +2 -1
  77. package/dist/core/grizzco/services/registry.js +18 -0
  78. package/dist/core/grizzco/steps/audit.js +20 -10
  79. package/dist/core/grizzco/steps/autopilot.js +21 -32
  80. package/dist/core/grizzco/steps/display-report.js +4 -11
  81. package/dist/core/grizzco/steps/explore.js +14 -4
  82. package/dist/core/grizzco/steps/generateReview.js +3 -1
  83. package/dist/core/grizzco/steps/patch/prompt-input.js +4 -1
  84. package/dist/core/grizzco/steps/patch.js +1 -0
  85. package/dist/core/grizzco/steps/plan.js +58 -49
  86. package/dist/core/grizzco/steps/research.js +3 -1
  87. package/dist/core/grizzco/steps/tool-runtime.js +3 -0
  88. package/dist/core/grizzco/steps/verify.js +7 -1
  89. package/dist/core/grizzco/validation/AstValidationService.js +3 -1
  90. package/dist/core/grizzco/workers/strata-sync-worker.js +2 -1
  91. package/dist/core/history/input-history.js +3 -1
  92. package/dist/core/intent/chat-intent.js +3 -1
  93. package/dist/core/llm/ai-sdk/message-mapper.js +37 -26
  94. package/dist/core/llm/ai-sdk/request-params.js +2 -6
  95. package/dist/core/llm/ai-sdk/result-mapper.js +14 -8
  96. package/dist/core/llm/ai-sdk/retry-classifier.js +17 -7
  97. package/dist/core/llm/ai-sdk/retry-executor.js +1 -1
  98. package/dist/core/llm/contracts/repair.js +16 -8
  99. package/dist/core/llm/errors.js +18 -14
  100. package/dist/core/llm/output-policy.js +8 -0
  101. package/dist/core/llm/redact.js +1 -3
  102. package/dist/core/llm/retry-utils.js +8 -2
  103. package/dist/core/llm/stream-utils.js +5 -3
  104. package/dist/core/llm/sub-agent-factory.js +51 -0
  105. package/dist/core/llm/tool-calling-stub.js +48 -0
  106. package/dist/core/llm/utils.js +17 -6
  107. package/dist/core/mcp/bridge/prompt-command-provider.js +4 -3
  108. package/dist/core/mcp/bridge/resource-context-provider.js +3 -1
  109. package/dist/core/mcp/bridge/tool-bridge.js +5 -14
  110. package/dist/core/mcp/catalog/discovery.js +3 -1
  111. package/dist/core/mcp/client/connection-manager.js +7 -4
  112. package/dist/core/mcp/client/transport-factory.js +7 -3
  113. package/dist/core/mcp/host/sampling-provider.js +1 -1
  114. package/dist/core/mcp/schema/json-schema-to-zod.js +2 -1
  115. package/dist/core/memory/relevant-retrieval.js +6 -4
  116. package/dist/core/observability/audit-file.js +2 -1
  117. package/dist/core/observability/audit-trail.js +3 -1
  118. package/dist/core/observability/authorization-decisions.js +13 -12
  119. package/dist/core/observability/error-mapping.js +2 -1
  120. package/dist/core/observability/logger.js +2 -1
  121. package/dist/core/observability/monitor.js +24 -0
  122. package/dist/core/observability/run-outcome-reporter.js +1 -0
  123. package/dist/core/observability/token-usage.js +5 -4
  124. package/dist/core/permission-gate/default-gate.js +5 -8
  125. package/dist/core/plan/storage.js +7 -4
  126. package/dist/core/plugin/loader.js +8 -5
  127. package/dist/core/prompts/registry.js +12 -30
  128. package/dist/core/prompts/runtime.js +3 -1
  129. package/dist/core/prompts/templates/system/autopilot_system.hbs +28 -4
  130. package/dist/core/protocols/a2a/sdk/executor.js +3 -1
  131. package/dist/core/protocols/a2a/sdk/server.js +5 -4
  132. package/dist/core/protocols/acp/acp-command-runner.js +7 -6
  133. package/dist/core/protocols/acp/acp-session-persistence.js +13 -10
  134. package/dist/core/protocols/acp/formal-agent.js +13 -6
  135. package/dist/core/protocols/acp/permission-provider.js +3 -2
  136. package/dist/core/protocols/acp/stdio-server.js +6 -6
  137. package/dist/core/reflection/engine.js +114 -14
  138. package/dist/core/runtime/agent-server-runtime.js +3 -2
  139. package/dist/core/runtime/batch-runner.js +81 -0
  140. package/dist/core/runtime/initialize.js +71 -6
  141. package/dist/core/runtime/loop-finalize.js +3 -0
  142. package/dist/core/runtime/loop-session-runner.js +5 -0
  143. package/dist/core/runtime/loop.js +4 -0
  144. package/dist/core/runtime/paths.js +9 -6
  145. package/dist/core/runtime/spawn-interactive.js +5 -4
  146. package/dist/core/security/redaction.js +3 -2
  147. package/dist/core/session/compaction/index.js +4 -3
  148. package/dist/core/session/compression.js +3 -1
  149. package/dist/core/session/manager.js +26 -38
  150. package/dist/core/session/pruning-strategy.js +2 -1
  151. package/dist/core/session/token-tracker.js +27 -9
  152. package/dist/core/skills/parser.js +3 -2
  153. package/dist/core/skills/permissions.js +2 -2
  154. package/dist/core/skills/runtime/MicroTaskRunner.js +1 -1
  155. package/dist/core/skills/runtime/SkillRunner.js +5 -2
  156. package/dist/core/slash/steps/slash-execute.js +7 -5
  157. package/dist/core/slash/strategy.js +1 -1
  158. package/dist/core/strata/checkpoint/manager.js +16 -10
  159. package/dist/core/strata/checkpoint/snapshot-create.js +5 -4
  160. package/dist/core/strata/checkpoint/snapshot-write-tree.js +7 -3
  161. package/dist/core/strata/engine/shadow-merge-engine.js +4 -2
  162. package/dist/core/strata/interaction/file-system-provider.js +2 -1
  163. package/dist/core/strata/layers/file-state-resolver.js +9 -7
  164. package/dist/core/strata/layers/immutable-git-layer.js +3 -1
  165. package/dist/core/strata/layers/shadow-driver/readonly-lock.js +8 -6
  166. package/dist/core/strata/layers/shadow-driver/shadow-driver.js +2 -1
  167. package/dist/core/strata/layers/worktree.js +9 -10
  168. package/dist/core/strata/runtime/environment.js +2 -1
  169. package/dist/core/strata/runtime/synchronizer.js +28 -26
  170. package/dist/core/streaming/canonical/parts-from-llm-stream-chunk.js +1 -11
  171. package/dist/core/structured-output/json-extract.js +3 -1
  172. package/dist/core/structured-output/json-schema-validator.js +1 -13
  173. package/dist/core/sub-agent/artifacts/store.js +2 -1
  174. package/dist/core/sub-agent/context-snapshot.js +12 -6
  175. package/dist/core/sub-agent/controller.js +70 -1
  176. package/dist/core/sub-agent/core/loop.js +25 -3
  177. package/dist/core/sub-agent/core/manager.js +343 -117
  178. package/dist/core/sub-agent/registry-defaults.js +12 -0
  179. package/dist/core/sub-agent/registry.js +8 -0
  180. package/dist/core/sub-agent/summary.js +96 -0
  181. package/dist/core/sub-agent/team.js +98 -0
  182. package/dist/core/sub-agent/tools/task-await.js +109 -0
  183. package/dist/core/sub-agent/tools/task-spawn.js +52 -7
  184. package/dist/core/sub-agent/tools/team.js +92 -0
  185. package/dist/core/sub-agent/types.js +11 -2
  186. package/dist/core/target-runtime/profile.js +3 -1
  187. package/dist/core/tools/audit.js +3 -2
  188. package/dist/core/tools/budget.js +7 -12
  189. package/dist/core/tools/builtin/ast.js +144 -0
  190. package/dist/core/tools/builtin/code-search/backends/powershell.js +3 -1
  191. package/dist/core/tools/builtin/code-search/backends/rg.js +3 -1
  192. package/dist/core/tools/builtin/code-search/executor.js +46 -43
  193. package/dist/core/tools/builtin/code-search/parse/plain-grep.js +3 -1
  194. package/dist/core/tools/builtin/code-search/parse/rg-json.js +3 -1
  195. package/dist/core/tools/builtin/fs.js +90 -7
  196. package/dist/core/tools/builtin/git.js +242 -0
  197. package/dist/core/tools/builtin/glob.js +79 -0
  198. package/dist/core/tools/builtin/index.js +53 -111
  199. package/dist/core/tools/builtin/interaction.js +13 -15
  200. package/dist/core/tools/builtin/knowledge.js +146 -4
  201. package/dist/core/tools/builtin/proposal.js +14 -3
  202. package/dist/core/tools/builtin/verify.js +35 -3
  203. package/dist/core/tools/capability/executor.js +5 -5
  204. package/dist/core/tools/headless-payload.js +1 -3
  205. package/dist/core/tools/mapper.js +8 -42
  206. package/dist/core/tools/parallel/persistence.js +17 -5
  207. package/dist/core/tools/parallel/scheduler.js +23 -21
  208. package/dist/core/tools/permissions/permission-rules.js +69 -115
  209. package/dist/core/tools/plugins/loader.js +4 -3
  210. package/dist/core/tools/router.js +112 -58
  211. package/dist/core/tools/session.js +64 -102
  212. package/dist/core/tools/streaming/ToolCallAccumulator.js +1 -3
  213. package/dist/core/tools/tool-visibility.js +2 -1
  214. package/dist/core/tools/types.js +10 -0
  215. package/dist/core/types/batch.js +2 -0
  216. package/dist/core/utils/error.js +79 -0
  217. package/dist/core/utils/sanitizer.js +5 -2
  218. package/dist/core/utils/serialize.js +66 -0
  219. package/dist/core/utils/zod.js +29 -0
  220. package/dist/core/verification/detect-runner.js +86 -0
  221. package/dist/core/verification/runner.js +76 -0
  222. package/dist/core/version.js +3 -1
  223. package/dist/core/workspace/capabilities.js +3 -2
  224. package/dist/integrations/langfuse/litellm-langfuse-outcome-reporter.js +9 -8
  225. package/dist/languages/python/index.js +154 -0
  226. package/dist/locales/en.js +8 -1
  227. package/package.json +2 -1
@@ -3,16 +3,19 @@ import { text } from '../../../locales/index.js';
3
3
  import { createFileSystemAdapter } from '../../adapters/fs/index.js';
4
4
  import * as fs from '../../adapters/fs/node-fs.js';
5
5
  import { GitAdapter } from '../../adapters/git/git-adapter.js';
6
+ import { createTaskEventBus } from '../../interaction/events/bus.js';
6
7
  import { recordAuditEvent } from '../../observability/audit-trail.js';
7
8
  import { getLogger } from '../../observability/logger.js';
8
9
  import { FileStateResolver } from '../../strata/layers/file-state-resolver.js';
9
10
  import { RuntimeEnvironment } from '../../strata/runtime/environment.js';
10
11
  import { ErrorType } from '../../types/index.js';
12
+ import { errorMessage } from '../../utils/error.js';
11
13
  import { ArtifactStore } from '../artifacts/store.js';
12
14
  import { cloneSubAgentContextSnapshot } from '../context-snapshot.js';
13
15
  import { isReadOnlySubAgentContext, resolveSubAgentDryRun } from '../dispatch-policy.js';
14
16
  import { validateSharedPrefixConsistency } from '../prefix-consistency.js';
15
17
  import { getSubAgentRegistry } from '../registry.js';
18
+ import { generateSubAgentSummary, formatSubAgentSummary } from '../summary.js';
16
19
  import { SmallfryLoop } from './loop.js';
17
20
  /**
18
21
  * SubAgentManager coordinates the lifecycle of Smallfrys.
@@ -22,6 +25,7 @@ export class SubAgentManager {
22
25
  ctx;
23
26
  controller;
24
27
  activeAgents = new Map();
28
+ completedResults = [];
25
29
  deps;
26
30
  constructor(ctx, controller, deps) {
27
31
  this.ctx = ctx;
@@ -31,45 +35,182 @@ export class SubAgentManager {
31
35
  createRuntimeEnvironment: deps?.createRuntimeEnvironment ??
32
36
  ((options, emit) => new RuntimeEnvironment(options, emit)),
33
37
  artifactStore: deps?.artifactStore ?? ArtifactStore,
38
+ eventBus: deps?.eventBus ?? createTaskEventBus(),
39
+ llmFactory: deps?.llmFactory,
40
+ onSubAgentComplete: deps?.onSubAgentComplete,
34
41
  };
35
42
  }
36
43
  /**
37
- * Spawns a new sub-agent and monitors its execution.
44
+ * Spawns a new sub-agent. When request.async is true, returns a handle immediately;
45
+ * otherwise blocks until the sub-agent completes.
38
46
  */
39
47
  async execute(request) {
40
- const normalizedRequest = request.session_target === 'shared'
41
- ? (() => {
42
- const consistency = validateSharedPrefixConsistency({
43
- requestSnapshot: request.contextSnapshot,
44
- runtimeSnapshot: this.ctx.contextSnapshot,
45
- });
46
- if (consistency.compatible)
47
- return request;
48
- recordAuditEvent('sub_agent.shared.prefix_consistency_failed', {
49
- metric: 'shared_fallback_rate',
50
- fallbackMode: 'isolated',
51
- reason: consistency.reason,
52
- expected: consistency.expected,
53
- actual: consistency.actual,
54
- }, {
55
- source: 'smallfry',
56
- severity: 'medium',
57
- scope: 'session',
58
- phase: this.ctx.phase,
59
- });
60
- return {
61
- ...request,
62
- session_target: 'isolated',
63
- contextSnapshot: undefined,
64
- };
65
- })()
66
- : request;
48
+ const normalizedRequest = this.normalizeRequest(request);
67
49
  const profile = this.deps.registry.get(normalizedRequest.agent_ref);
68
50
  if (!profile) {
69
51
  return this.fail(normalizedRequest.agent_ref, text.smallfry.errors.profileNotFound(normalizedRequest.agent_ref), 'LOOP_FAILED');
70
52
  }
71
53
  const agentId = `smallfry-${randomBytes(4).toString('hex')}`;
72
- const currentDepth = normalizedRequest.recursionDepth || 0;
54
+ if (normalizedRequest.async) {
55
+ return this.executeAsync(normalizedRequest, profile, agentId);
56
+ }
57
+ return this.executeSync(normalizedRequest, profile, agentId);
58
+ }
59
+ /**
60
+ * Waits for an async sub-agent to complete and returns its result.
61
+ * @param handle - The sub-agent handle returned by executeAsync
62
+ * @param timeoutMs - Maximum time to wait in milliseconds. Defaults to 300_000 (5 minutes).
63
+ */
64
+ async awaitResult(handle, timeoutMs) {
65
+ // Check if already completed
66
+ const entry = this.activeAgents.get(handle.agentId);
67
+ if (entry?.result) {
68
+ return entry.result;
69
+ }
70
+ // Check historical events
71
+ const historical = this.deps.eventBus.list(handle.taskId, { limit: 10 });
72
+ const terminalEvent = historical.find((e) => e.type === 'subagent.completed' || e.type === 'subagent.failed');
73
+ if (terminalEvent) {
74
+ return terminalEvent.state === 'completed'
75
+ ? terminalEvent.result
76
+ : this.fail(handle.agentId, terminalEvent.reason ?? 'Sub-agent failed', 'LOOP_FAILED');
77
+ }
78
+ const effectiveTimeout = timeoutMs ?? 300_000;
79
+ // Subscribe and wait for terminal event
80
+ return new Promise((resolve, reject) => {
81
+ const timeout = setTimeout(() => {
82
+ unsub();
83
+ // Request stop on the sub-agent so it can clean up
84
+ this.controller.requestStop(handle.agentId);
85
+ reject(new Error(`Timed out waiting for sub-agent ${handle.agentId} after ${effectiveTimeout}ms`));
86
+ }, effectiveTimeout);
87
+ const unsub = this.deps.eventBus.subscribe((event) => {
88
+ if (event.taskId !== handle.taskId)
89
+ return;
90
+ if (event.type === 'subagent.completed' || event.type === 'subagent.failed') {
91
+ clearTimeout(timeout);
92
+ unsub();
93
+ if (event.type === 'subagent.completed') {
94
+ resolve(event.result);
95
+ }
96
+ else {
97
+ resolve(this.fail(handle.agentId, event.reason ?? 'Sub-agent failed', 'LOOP_FAILED'));
98
+ }
99
+ }
100
+ });
101
+ });
102
+ }
103
+ /**
104
+ * Get a summary of all completed sub-agent results.
105
+ * Includes conflict detection across patches.
106
+ */
107
+ getSummary() {
108
+ if (this.completedResults.length === 0)
109
+ return null;
110
+ return generateSubAgentSummary(this.completedResults);
111
+ }
112
+ /**
113
+ * Get a formatted summary string for LLM context injection.
114
+ */
115
+ getFormattedSummary() {
116
+ const summary = this.getSummary();
117
+ if (!summary)
118
+ return null;
119
+ return formatSubAgentSummary(summary);
120
+ }
121
+ normalizeRequest(request) {
122
+ // Fork mode: no prefix consistency validation needed (it's a clone, not a shared session)
123
+ if (request.session_target === 'fork')
124
+ return request;
125
+ if (request.session_target !== 'shared')
126
+ return request;
127
+ const consistency = validateSharedPrefixConsistency({
128
+ requestSnapshot: request.contextSnapshot,
129
+ runtimeSnapshot: this.ctx.contextSnapshot,
130
+ });
131
+ if (consistency.compatible)
132
+ return request;
133
+ recordAuditEvent('sub_agent.shared.prefix_consistency_failed', {
134
+ metric: 'shared_fallback_rate',
135
+ fallbackMode: 'isolated',
136
+ reason: consistency.reason,
137
+ expected: consistency.expected,
138
+ actual: consistency.actual,
139
+ }, {
140
+ source: 'smallfry',
141
+ severity: 'medium',
142
+ scope: 'session',
143
+ phase: this.ctx.phase,
144
+ });
145
+ return {
146
+ ...request,
147
+ session_target: 'isolated',
148
+ contextSnapshot: undefined,
149
+ };
150
+ }
151
+ /**
152
+ * Async dispatch: fire-and-forget, publish events, return handle.
153
+ */
154
+ executeAsync(request, profile, agentId) {
155
+ const taskId = agentId;
156
+ this.activeAgents.set(agentId, { profile, status: 'hiring' });
157
+ this.controller.registerAgent(agentId, profile, 'hiring');
158
+ this.deps.eventBus.publish({
159
+ type: 'subagent.accepted',
160
+ taskId,
161
+ state: 'accepted',
162
+ });
163
+ // Fire-and-forget: executeCore runs in the background
164
+ this.executeCore(request, profile, agentId)
165
+ .then((result) => {
166
+ const entry = this.activeAgents.get(agentId);
167
+ if (entry)
168
+ entry.result = result;
169
+ this.controller.setResult(agentId, result);
170
+ this.controller.updateStatus(agentId, 'terminated', result.summary);
171
+ this.deps.eventBus.publish({
172
+ type: result.success ? 'subagent.completed' : 'subagent.failed',
173
+ taskId,
174
+ state: result.success ? 'completed' : 'failed',
175
+ });
176
+ // Track for summary generation
177
+ this.completedResults.push({ agentId, result });
178
+ // Notify completion listener (for background auto-notify)
179
+ this.deps.onSubAgentComplete?.(agentId, result);
180
+ })
181
+ .catch((error) => {
182
+ const failResult = this.fail(profile.id, errorMessage(error), 'LOOP_CRASH');
183
+ const entry = this.activeAgents.get(agentId);
184
+ if (entry)
185
+ entry.result = failResult;
186
+ this.controller.setResult(agentId, failResult);
187
+ this.controller.updateStatus(agentId, 'terminated', failResult.summary);
188
+ this.deps.eventBus.publish({
189
+ type: 'subagent.failed',
190
+ taskId,
191
+ state: 'failed',
192
+ });
193
+ })
194
+ .finally(() => {
195
+ const entry = this.activeAgents.get(agentId);
196
+ if (!entry?.result) {
197
+ this.activeAgents.delete(agentId);
198
+ }
199
+ });
200
+ return { agentId, status: 'working', taskId };
201
+ }
202
+ /**
203
+ * Synchronous dispatch: blocks until the sub-agent completes.
204
+ */
205
+ async executeSync(request, profile, agentId) {
206
+ return this.executeCore(request, profile, agentId);
207
+ }
208
+ /**
209
+ * Core execution logic shared by async and sync paths.
210
+ * Retries up to profile.maxAttempts on LOOP_FAILED (not LOOP_CRASH).
211
+ */
212
+ async executeCore(request, profile, agentId) {
213
+ const currentDepth = request.recursionDepth || 0;
73
214
  const MAX_RECURSION_DEPTH = 2;
74
215
  if (currentDepth >= MAX_RECURSION_DEPTH) {
75
216
  const msg = text.smallfry.errors.recursionLimitExceeded(currentDepth, MAX_RECURSION_DEPTH);
@@ -79,97 +220,128 @@ export class SubAgentManager {
79
220
  this.activeAgents.set(agentId, { profile, status: 'hiring' });
80
221
  this.controller.registerAgent(agentId, profile, 'hiring');
81
222
  getLogger().debug(`[SubAgentManager] ${text.smallfry.status.spawning} (ID: ${agentId}, Role: ${profile.role})`);
82
- const llm = this.ctx.llm;
83
- if (!llm) {
223
+ // Resolve LLM: per-profile model override or inherit parent
224
+ const parentLlm = this.ctx.llm;
225
+ if (!parentLlm) {
84
226
  const msg = text.smallfry.errors.dispatchMissingRuntimeLlm;
85
227
  getLogger().error(`[SubAgentManager] ${msg}`);
86
228
  return this.fail(profile.id, msg, 'LOOP_CRASH');
87
229
  }
88
- try {
89
- this.updateStatus(agentId, 'working');
90
- if (this.controller.isStopRequested(agentId)) {
91
- throw new Error('Stop requested before launching Smallfry');
92
- }
93
- const effectiveDryRun = resolveSubAgentDryRun({
94
- parentDryRun: this.ctx.dryRun,
95
- flowMode: this.ctx.flowMode,
96
- phase: this.ctx.phase,
97
- });
98
- const runtimeEnv = await this.setupIsolatedEnvironment(normalizedRequest, llm, agentId, effectiveDryRun);
230
+ const llm = this.resolveLlm(profile, parentLlm);
231
+ if (!llm) {
232
+ const msg = `Failed to resolve LLM for model "${profile.model}"`;
233
+ getLogger().error(`[SubAgentManager] ${msg}`);
234
+ return this.fail(profile.id, msg, 'LOOP_CRASH');
235
+ }
236
+ const maxAttempts = profile.maxAttempts ?? 1;
237
+ let lastResult;
238
+ let lastError;
239
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
99
240
  try {
100
- const workspace = runtimeEnv.workspace;
101
- const activePath = workspace.workPath;
102
- const git = new GitAdapter(activePath);
103
- const resolver = new FileStateResolver(git, activePath);
104
- const flowMode = 'patch';
105
- const fsAdapter = createFileSystemAdapter(flowMode);
106
- // 2. Construct InitCtx for the smallfry
107
- const initCtx = this.applyContextSnapshot(normalizedRequest.contextSnapshot, {
108
- workspace: {
109
- workPath: activePath,
110
- baseRepoPath: workspace.baseRepoPath,
111
- strategy: workspace.strategy,
112
- },
113
- options: {
114
- instruction: normalizedRequest.task,
115
- repoPath: activePath,
116
- dryRun: effectiveDryRun,
117
- contextFiles: normalizedRequest.contextFiles || [],
118
- llm,
119
- recursionDepth: currentDepth + 1, // Increment depth for child
120
- allowedToolNames: this.filterAllowedTools(profile.allowedTools, this.ctx.phase),
121
- timeoutMs: normalizedRequest.timeout_seconds
122
- ? normalizedRequest.timeout_seconds * 1000
123
- : profile.timeoutMs,
124
- },
125
- mode: flowMode,
126
- fs: fsAdapter,
127
- emit: (event) => {
128
- // Bridge status to parent/UI
129
- if (event.type === 'phase.start') {
130
- this.updateStatus(agentId, 'working');
131
- }
132
- if (event.type === 'log') {
133
- getLogger().debug(`[Smallfry:${agentId}] ${event.level}: ${event.message}`);
134
- }
135
- else {
136
- getLogger().debug(`[Smallfry:${agentId}] ${event.type}`);
137
- }
138
- },
139
- fileStateResolver: resolver,
140
- shadowInitialRef: runtimeEnv?.initialSnapshotHash || 'HEAD',
241
+ this.updateStatus(agentId, 'working');
242
+ if (this.controller.isStopRequested(agentId)) {
243
+ throw new Error('Stop requested before launching Smallfry');
244
+ }
245
+ const effectiveDryRun = resolveSubAgentDryRun({
246
+ parentDryRun: this.ctx.dryRun,
247
+ flowMode: this.ctx.flowMode,
248
+ phase: this.ctx.phase,
141
249
  });
142
- // 3. Launch the "Little Fry"
143
- const subLoop = new SmallfryLoop(profile);
144
- const result = await subLoop.execute(initCtx);
145
- return await this.persistArtifacts(agentId, result);
250
+ const runtimeEnv = await this.setupIsolatedEnvironment(request, llm, agentId, effectiveDryRun);
251
+ try {
252
+ const workspace = runtimeEnv.workspace;
253
+ if (!workspace) {
254
+ throw new Error('Runtime environment setup succeeded but workspace was not initialized');
255
+ }
256
+ const activePath = workspace.workPath;
257
+ const git = new GitAdapter(activePath);
258
+ const resolver = new FileStateResolver(git, activePath);
259
+ const flowMode = 'patch';
260
+ const fsAdapter = createFileSystemAdapter(flowMode);
261
+ const initCtx = this.applyContextSnapshot(request.contextSnapshot, {
262
+ workspace: {
263
+ workPath: activePath,
264
+ baseRepoPath: workspace.baseRepoPath,
265
+ strategy: workspace.strategy,
266
+ },
267
+ options: {
268
+ instruction: request.task,
269
+ repoPath: activePath,
270
+ dryRun: effectiveDryRun,
271
+ contextFiles: request.contextFiles || [],
272
+ llm,
273
+ recursionDepth: currentDepth + 1,
274
+ allowedToolNames: this.resolveAllowedTools(profile, request.teamId),
275
+ timeoutMs: request.timeout_seconds
276
+ ? request.timeout_seconds * 1000
277
+ : profile.timeoutMs,
278
+ subAgentSystemPrompt: profile.systemPrompt,
279
+ agentId,
280
+ },
281
+ lastError,
282
+ mode: flowMode,
283
+ fs: fsAdapter,
284
+ emit: (event) => {
285
+ if (event.type === 'phase.start') {
286
+ this.updateStatus(agentId, 'working');
287
+ }
288
+ if (event.type === 'log') {
289
+ getLogger().debug(`[Smallfry:${agentId}] ${event.level}: ${event.message}`);
290
+ }
291
+ else {
292
+ getLogger().debug(`[Smallfry:${agentId}] ${event.type}`);
293
+ }
294
+ },
295
+ fileStateResolver: resolver,
296
+ shadowInitialRef: runtimeEnv?.initialSnapshotHash || 'HEAD',
297
+ });
298
+ const subLoop = new SmallfryLoop(profile);
299
+ const result = await subLoop.execute(initCtx);
300
+ lastResult = result;
301
+ // Success or non-retryable failure — return immediately
302
+ if (result.success || result.reasonCode === 'LOOP_CRASH' || attempt >= maxAttempts) {
303
+ return await this.persistArtifacts(agentId, {
304
+ ...result,
305
+ attempts: attempt,
306
+ });
307
+ }
308
+ // Retryable failure — log and continue
309
+ lastError = result.reason || result.summary;
310
+ getLogger().warn(`[SubAgentManager] Smallfry ${agentId} attempt ${attempt}/${maxAttempts} failed (${result.reasonCode}), retrying...`);
311
+ }
312
+ finally {
313
+ await runtimeEnv.teardown();
314
+ }
146
315
  }
147
- finally {
148
- await runtimeEnv.teardown();
316
+ catch (error) {
317
+ this.controller.appendLog(agentId, `Execution failed: ${errorMessage(error)}`);
318
+ getLogger().error(`[SubAgentManager] Smallfry ${agentId} crashed: ${errorMessage(error)}`);
319
+ // Crashes are not retryable
320
+ return {
321
+ agent_ref: profile.id,
322
+ success: false,
323
+ summary: text.smallfry.errors.missionFailedWithReason(errorMessage(error)),
324
+ tokenUsage: 0,
325
+ reason: errorMessage(error),
326
+ reasonCode: 'LOOP_CRASH',
327
+ attempts: attempt,
328
+ logs: [],
329
+ errorType: ErrorType.UNKNOWN,
330
+ };
149
331
  }
150
332
  }
151
- catch (error) {
152
- this.controller.appendLog(agentId, `Execution failed: ${(error instanceof Error ? error.message : undefined) ?? error}`);
153
- getLogger().error(`[SubAgentManager] Smallfry ${agentId} crashed: ${error instanceof Error ? error.message : String(error)}`);
154
- return {
155
- agent_ref: profile.id,
156
- success: false,
157
- summary: text.smallfry.errors.missionFailedWithReason(error instanceof Error ? error.message : String(error)),
158
- tokenUsage: 0,
159
- reason: error instanceof Error ? error.message : String(error),
160
- reasonCode: 'LOOP_CRASH',
161
- attempts: 1,
162
- logs: [],
163
- errorType: ErrorType.UNKNOWN,
164
- };
165
- }
166
- finally {
167
- this.activeAgents.delete(agentId);
168
- }
333
+ // Should not reach here, but safety fallback
334
+ return lastResult ?? this.fail(profile.id, text.smallfry.errors.missionFailed, 'LOOP_FAILED');
169
335
  }
170
- // Backward compatibility for internal calls
336
+ // Backward compatibility for internal calls (always synchronous)
171
337
  async spawn(request) {
172
- return this.execute(request);
338
+ const normalizedRequest = this.normalizeRequest(request);
339
+ const profile = this.deps.registry.get(normalizedRequest.agent_ref);
340
+ if (!profile) {
341
+ return this.fail(normalizedRequest.agent_ref, text.smallfry.errors.profileNotFound(normalizedRequest.agent_ref), 'LOOP_FAILED');
342
+ }
343
+ const agentId = `smallfry-${randomBytes(4).toString('hex')}`;
344
+ return this.executeSync(normalizedRequest, profile, agentId);
173
345
  }
174
346
  applyContextSnapshot(snapshot, initCtx) {
175
347
  const normalized = cloneSubAgentContextSnapshot(snapshot);
@@ -209,6 +381,54 @@ export class SubAgentManager {
209
381
  errorType: ErrorType.UNKNOWN,
210
382
  };
211
383
  }
384
+ resolveAllowedTools(profile, teamId) {
385
+ const base = this.filterAllowedTools(profile.allowedTools, this.ctx.phase, profile.toolInheritance);
386
+ // Apply disallowedTools (denylist) — subtract from resolved tools
387
+ let resolved = base;
388
+ if (resolved !== undefined && profile.disallowedTools && profile.disallowedTools.length > 0) {
389
+ const denied = new Set(profile.disallowedTools);
390
+ resolved = resolved.filter((name) => !denied.has(name));
391
+ }
392
+ if (!teamId)
393
+ return resolved;
394
+ // When a teamId is present, add agent_team to the allowed tools
395
+ if (resolved === undefined)
396
+ return undefined; // Inherited all tools — agent_team already available
397
+ return [...new Set([...resolved, 'agent_team'])];
398
+ }
399
+ /**
400
+ * Resolve the LLM for a sub-agent based on profile.model.
401
+ * 'inherit' or undefined → use parent LLM.
402
+ * Other values → use llmFactory to create a model-specific LLM.
403
+ */
404
+ resolveLlm(profile, parentLlm) {
405
+ const model = profile.model;
406
+ // Try llmFactory first for all models (including 'inherit').
407
+ // This allows test harnesses to provide isolated LLMs for sub-agents.
408
+ // In production, factories typically return undefined for 'inherit',
409
+ // so the fallback to parentLlm is preserved.
410
+ if (this.deps.llmFactory) {
411
+ const modelLlm = this.deps.llmFactory(model ?? 'inherit');
412
+ if (modelLlm) {
413
+ getLogger().debug(`[SubAgentManager] Using llmFactory LLM for profile "${profile.id}" (model="${model ?? 'inherit'}")`);
414
+ return modelLlm;
415
+ }
416
+ }
417
+ if (!model || model === 'inherit') {
418
+ return parentLlm;
419
+ }
420
+ if (!this.deps.llmFactory) {
421
+ getLogger().warn(`[SubAgentManager] Profile "${profile.id}" requests model "${model}" but no llmFactory configured. Falling back to parent LLM.`);
422
+ return parentLlm;
423
+ }
424
+ const modelLlm = this.deps.llmFactory(model);
425
+ if (!modelLlm) {
426
+ getLogger().warn(`[SubAgentManager] llmFactory returned no LLM for model "${model}". Falling back to parent LLM.`);
427
+ return parentLlm;
428
+ }
429
+ getLogger().debug(`[SubAgentManager] Using model "${model}" for profile "${profile.id}"`);
430
+ return modelLlm;
431
+ }
212
432
  async setupIsolatedEnvironment(request, llm, agentId, effectiveDryRun) {
213
433
  if (isReadOnlySubAgentContext({
214
434
  flowMode: this.ctx.flowMode,
@@ -251,7 +471,7 @@ export class SubAgentManager {
251
471
  await env.teardown();
252
472
  }
253
473
  catch (teardownError) {
254
- getLogger().warn(`[SubAgentManager] Failed to teardown isolated environment after setup error: ${teardownError instanceof Error ? teardownError.message : String(teardownError)}`);
474
+ getLogger().warn(`[SubAgentManager] Failed to teardown isolated environment after setup error: ${errorMessage(teardownError)}`);
255
475
  }
256
476
  throw error;
257
477
  }
@@ -275,11 +495,20 @@ export class SubAgentManager {
275
495
  return {
276
496
  ...rest,
277
497
  auditPath: auditArtifact?.handle ?? rest.auditPath,
278
- auditArtifact: auditArtifact ?? undefined,
498
+ auditArtifact: auditArtifact,
279
499
  patchArtifact: saved,
280
500
  };
281
501
  }
282
- filterAllowedTools(allowed, phase) {
502
+ filterAllowedTools(allowed, phase, toolInheritance) {
503
+ const readOnlyPhase = isReadOnlySubAgentContext({
504
+ flowMode: this.ctx.flowMode,
505
+ phase,
506
+ });
507
+ // When toolInheritance is 'safe' or 'all' in non-read-only phase,
508
+ // return undefined to skip allowlist filtering (inherits parent toolstack)
509
+ if (!readOnlyPhase && toolInheritance && toolInheritance !== 'none') {
510
+ return undefined;
511
+ }
283
512
  const safeReadOnlyTools = new Set([
284
513
  'agent_dispatch',
285
514
  'code.search',
@@ -290,10 +519,6 @@ export class SubAgentManager {
290
519
  'artifact.read',
291
520
  ]);
292
521
  const readOnlyPlanTools = new Set(['plan.init', 'plan.read', 'plan.update']);
293
- const readOnlyPhase = isReadOnlySubAgentContext({
294
- flowMode: this.ctx.flowMode,
295
- phase,
296
- });
297
522
  if (!readOnlyPhase) {
298
523
  return allowed;
299
524
  }
@@ -337,7 +562,8 @@ export class SubAgentManager {
337
562
  fileExt: 'json',
338
563
  });
339
564
  }
340
- catch {
565
+ catch (error) {
566
+ getLogger().debug(`[SubAgentManager] Failed to persist audit artifact: ${error instanceof Error ? error.message : String(error)}`);
341
567
  return undefined;
342
568
  }
343
569
  }
@@ -7,9 +7,12 @@ const DEFAULT_SUB_AGENT_PROFILES = [
7
7
  allowedTools: ['code.search', 'fs.read', 'git.status', 'git.cat', 'code.ast'],
8
8
  readOnly: true,
9
9
  stratagem: 'investigator',
10
+ toolInheritance: 'safe',
10
11
  maxTokens: 50000,
11
12
  maxAttempts: 3,
12
13
  timeoutMs: 60_000,
14
+ maxTurns: 20,
15
+ model: 'inherit',
13
16
  },
14
17
  {
15
18
  id: 'surgeon',
@@ -19,9 +22,12 @@ const DEFAULT_SUB_AGENT_PROFILES = [
19
22
  allowedTools: ['code.search', 'fs.read', 'test.run', 'code.ast'],
20
23
  readOnly: false,
21
24
  stratagem: 'surgeon',
25
+ toolInheritance: 'none',
22
26
  maxTokens: 100000,
23
27
  maxAttempts: 5,
24
28
  timeoutMs: 180_000,
29
+ maxTurns: 50,
30
+ model: 'inherit',
25
31
  },
26
32
  {
27
33
  id: 'reviewer',
@@ -31,9 +37,12 @@ const DEFAULT_SUB_AGENT_PROFILES = [
31
37
  allowedTools: ['code.search', 'fs.read', 'code.ast'],
32
38
  readOnly: true,
33
39
  stratagem: 'investigator',
40
+ toolInheritance: 'safe',
34
41
  maxTokens: 30000,
35
42
  maxAttempts: 2,
36
43
  timeoutMs: 60_000,
44
+ maxTurns: 15,
45
+ model: 'inherit',
37
46
  },
38
47
  {
39
48
  id: 'cleaner',
@@ -43,9 +52,12 @@ const DEFAULT_SUB_AGENT_PROFILES = [
43
52
  allowedTools: ['fs.read', 'test.run', 'code.search'],
44
53
  readOnly: false,
45
54
  stratagem: 'surgeon',
55
+ toolInheritance: 'none',
46
56
  maxTokens: 50000,
47
57
  maxAttempts: 3,
48
58
  timeoutMs: 120_000,
59
+ maxTurns: 30,
60
+ model: 'inherit',
49
61
  },
50
62
  ];
51
63
  export function registerDefaultSubAgentProfiles(registry) {
@@ -3,12 +3,20 @@ export class SubAgentRegistry {
3
3
  register(profile) {
4
4
  this.profiles.set(profile.id, profile);
5
5
  }
6
+ registerMany(profiles) {
7
+ for (const profile of profiles) {
8
+ this.profiles.set(profile.id, profile);
9
+ }
10
+ }
6
11
  get(id) {
7
12
  return this.profiles.get(id);
8
13
  }
9
14
  getAll() {
10
15
  return Array.from(this.profiles.values());
11
16
  }
17
+ has(id) {
18
+ return this.profiles.has(id);
19
+ }
12
20
  clear() {
13
21
  this.profiles.clear();
14
22
  }