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
@@ -3,11 +3,13 @@ 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';
@@ -31,45 +33,162 @@ export class SubAgentManager {
31
33
  createRuntimeEnvironment: deps?.createRuntimeEnvironment ??
32
34
  ((options, emit) => new RuntimeEnvironment(options, emit)),
33
35
  artifactStore: deps?.artifactStore ?? ArtifactStore,
36
+ eventBus: deps?.eventBus ?? createTaskEventBus(),
37
+ llmFactory: deps?.llmFactory,
38
+ onSubAgentComplete: deps?.onSubAgentComplete,
34
39
  };
35
40
  }
36
41
  /**
37
- * Spawns a new sub-agent and monitors its execution.
42
+ * Spawns a new sub-agent. When request.async is true, returns a handle immediately;
43
+ * otherwise blocks until the sub-agent completes.
38
44
  */
39
45
  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;
46
+ const normalizedRequest = this.normalizeRequest(request);
67
47
  const profile = this.deps.registry.get(normalizedRequest.agent_ref);
68
48
  if (!profile) {
69
49
  return this.fail(normalizedRequest.agent_ref, text.smallfry.errors.profileNotFound(normalizedRequest.agent_ref), 'LOOP_FAILED');
70
50
  }
71
51
  const agentId = `smallfry-${randomBytes(4).toString('hex')}`;
72
- const currentDepth = normalizedRequest.recursionDepth || 0;
52
+ if (normalizedRequest.async) {
53
+ return this.executeAsync(normalizedRequest, profile, agentId);
54
+ }
55
+ return this.executeSync(normalizedRequest, profile, agentId);
56
+ }
57
+ /**
58
+ * Waits for an async sub-agent to complete and returns its result.
59
+ * @param handle - The sub-agent handle returned by executeAsync
60
+ * @param timeoutMs - Maximum time to wait in milliseconds. Defaults to 300_000 (5 minutes).
61
+ */
62
+ async awaitResult(handle, timeoutMs) {
63
+ // Check if already completed
64
+ const entry = this.activeAgents.get(handle.agentId);
65
+ if (entry?.result) {
66
+ return entry.result;
67
+ }
68
+ // Check historical events
69
+ const historical = this.deps.eventBus.list(handle.taskId, { limit: 10 });
70
+ const terminalEvent = historical.find((e) => e.type === 'subagent.completed' || e.type === 'subagent.failed');
71
+ if (terminalEvent) {
72
+ return terminalEvent.state === 'completed'
73
+ ? terminalEvent.result
74
+ : this.fail(handle.agentId, terminalEvent.reason ?? 'Sub-agent failed', 'LOOP_FAILED');
75
+ }
76
+ const effectiveTimeout = timeoutMs ?? 300_000;
77
+ // Subscribe and wait for terminal event
78
+ return new Promise((resolve, reject) => {
79
+ const timeout = setTimeout(() => {
80
+ unsub();
81
+ // Request stop on the sub-agent so it can clean up
82
+ this.controller.requestStop(handle.agentId);
83
+ reject(new Error(`Timed out waiting for sub-agent ${handle.agentId} after ${effectiveTimeout}ms`));
84
+ }, effectiveTimeout);
85
+ const unsub = this.deps.eventBus.subscribe((event) => {
86
+ if (event.taskId !== handle.taskId)
87
+ return;
88
+ if (event.type === 'subagent.completed' || event.type === 'subagent.failed') {
89
+ clearTimeout(timeout);
90
+ unsub();
91
+ if (event.type === 'subagent.completed') {
92
+ resolve(event.result);
93
+ }
94
+ else {
95
+ resolve(this.fail(handle.agentId, event.reason ?? 'Sub-agent failed', 'LOOP_FAILED'));
96
+ }
97
+ }
98
+ });
99
+ });
100
+ }
101
+ normalizeRequest(request) {
102
+ // Fork mode: no prefix consistency validation needed (it's a clone, not a shared session)
103
+ if (request.session_target === 'fork')
104
+ return request;
105
+ if (request.session_target !== 'shared')
106
+ return request;
107
+ const consistency = validateSharedPrefixConsistency({
108
+ requestSnapshot: request.contextSnapshot,
109
+ runtimeSnapshot: this.ctx.contextSnapshot,
110
+ });
111
+ if (consistency.compatible)
112
+ return request;
113
+ recordAuditEvent('sub_agent.shared.prefix_consistency_failed', {
114
+ metric: 'shared_fallback_rate',
115
+ fallbackMode: 'isolated',
116
+ reason: consistency.reason,
117
+ expected: consistency.expected,
118
+ actual: consistency.actual,
119
+ }, {
120
+ source: 'smallfry',
121
+ severity: 'medium',
122
+ scope: 'session',
123
+ phase: this.ctx.phase,
124
+ });
125
+ return {
126
+ ...request,
127
+ session_target: 'isolated',
128
+ contextSnapshot: undefined,
129
+ };
130
+ }
131
+ /**
132
+ * Async dispatch: fire-and-forget, publish events, return handle.
133
+ */
134
+ executeAsync(request, profile, agentId) {
135
+ const taskId = agentId;
136
+ this.activeAgents.set(agentId, { profile, status: 'hiring' });
137
+ this.controller.registerAgent(agentId, profile, 'hiring');
138
+ this.deps.eventBus.publish({
139
+ type: 'subagent.accepted',
140
+ taskId,
141
+ state: 'accepted',
142
+ });
143
+ // Fire-and-forget: executeCore runs in the background
144
+ this.executeCore(request, profile, agentId)
145
+ .then((result) => {
146
+ const entry = this.activeAgents.get(agentId);
147
+ if (entry)
148
+ entry.result = result;
149
+ this.controller.setResult(agentId, result);
150
+ this.controller.updateStatus(agentId, 'terminated', result.summary);
151
+ this.deps.eventBus.publish({
152
+ type: result.success ? 'subagent.completed' : 'subagent.failed',
153
+ taskId,
154
+ state: result.success ? 'completed' : 'failed',
155
+ });
156
+ // Notify completion listener (for background auto-notify)
157
+ this.deps.onSubAgentComplete?.(agentId, result);
158
+ })
159
+ .catch((error) => {
160
+ const failResult = this.fail(profile.id, errorMessage(error), 'LOOP_CRASH');
161
+ const entry = this.activeAgents.get(agentId);
162
+ if (entry)
163
+ entry.result = failResult;
164
+ this.controller.setResult(agentId, failResult);
165
+ this.controller.updateStatus(agentId, 'terminated', failResult.summary);
166
+ this.deps.eventBus.publish({
167
+ type: 'subagent.failed',
168
+ taskId,
169
+ state: 'failed',
170
+ });
171
+ })
172
+ .finally(() => {
173
+ const entry = this.activeAgents.get(agentId);
174
+ if (!entry?.result) {
175
+ this.activeAgents.delete(agentId);
176
+ }
177
+ });
178
+ return { agentId, status: 'working', taskId };
179
+ }
180
+ /**
181
+ * Synchronous dispatch: blocks until the sub-agent completes.
182
+ */
183
+ async executeSync(request, profile, agentId) {
184
+ return this.executeCore(request, profile, agentId);
185
+ }
186
+ /**
187
+ * Core execution logic shared by async and sync paths.
188
+ * Retries up to profile.maxAttempts on LOOP_FAILED (not LOOP_CRASH).
189
+ */
190
+ async executeCore(request, profile, agentId) {
191
+ const currentDepth = request.recursionDepth || 0;
73
192
  const MAX_RECURSION_DEPTH = 2;
74
193
  if (currentDepth >= MAX_RECURSION_DEPTH) {
75
194
  const msg = text.smallfry.errors.recursionLimitExceeded(currentDepth, MAX_RECURSION_DEPTH);
@@ -79,97 +198,128 @@ export class SubAgentManager {
79
198
  this.activeAgents.set(agentId, { profile, status: 'hiring' });
80
199
  this.controller.registerAgent(agentId, profile, 'hiring');
81
200
  getLogger().debug(`[SubAgentManager] ${text.smallfry.status.spawning} (ID: ${agentId}, Role: ${profile.role})`);
82
- const llm = this.ctx.llm;
83
- if (!llm) {
201
+ // Resolve LLM: per-profile model override or inherit parent
202
+ const parentLlm = this.ctx.llm;
203
+ if (!parentLlm) {
84
204
  const msg = text.smallfry.errors.dispatchMissingRuntimeLlm;
85
205
  getLogger().error(`[SubAgentManager] ${msg}`);
86
206
  return this.fail(profile.id, msg, 'LOOP_CRASH');
87
207
  }
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);
208
+ const llm = this.resolveLlm(profile, parentLlm);
209
+ if (!llm) {
210
+ const msg = `Failed to resolve LLM for model "${profile.model}"`;
211
+ getLogger().error(`[SubAgentManager] ${msg}`);
212
+ return this.fail(profile.id, msg, 'LOOP_CRASH');
213
+ }
214
+ const maxAttempts = profile.maxAttempts ?? 1;
215
+ let lastResult;
216
+ let lastError;
217
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
99
218
  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',
219
+ this.updateStatus(agentId, 'working');
220
+ if (this.controller.isStopRequested(agentId)) {
221
+ throw new Error('Stop requested before launching Smallfry');
222
+ }
223
+ const effectiveDryRun = resolveSubAgentDryRun({
224
+ parentDryRun: this.ctx.dryRun,
225
+ flowMode: this.ctx.flowMode,
226
+ phase: this.ctx.phase,
141
227
  });
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);
228
+ const runtimeEnv = await this.setupIsolatedEnvironment(request, llm, agentId, effectiveDryRun);
229
+ try {
230
+ const workspace = runtimeEnv.workspace;
231
+ if (!workspace) {
232
+ throw new Error('Runtime environment setup succeeded but workspace was not initialized');
233
+ }
234
+ const activePath = workspace.workPath;
235
+ const git = new GitAdapter(activePath);
236
+ const resolver = new FileStateResolver(git, activePath);
237
+ const flowMode = 'patch';
238
+ const fsAdapter = createFileSystemAdapter(flowMode);
239
+ const initCtx = this.applyContextSnapshot(request.contextSnapshot, {
240
+ workspace: {
241
+ workPath: activePath,
242
+ baseRepoPath: workspace.baseRepoPath,
243
+ strategy: workspace.strategy,
244
+ },
245
+ options: {
246
+ instruction: request.task,
247
+ repoPath: activePath,
248
+ dryRun: effectiveDryRun,
249
+ contextFiles: request.contextFiles || [],
250
+ llm,
251
+ recursionDepth: currentDepth + 1,
252
+ allowedToolNames: this.resolveAllowedTools(profile, request.teamId),
253
+ timeoutMs: request.timeout_seconds
254
+ ? request.timeout_seconds * 1000
255
+ : profile.timeoutMs,
256
+ subAgentSystemPrompt: profile.systemPrompt,
257
+ agentId,
258
+ },
259
+ lastError,
260
+ mode: flowMode,
261
+ fs: fsAdapter,
262
+ emit: (event) => {
263
+ if (event.type === 'phase.start') {
264
+ this.updateStatus(agentId, 'working');
265
+ }
266
+ if (event.type === 'log') {
267
+ getLogger().debug(`[Smallfry:${agentId}] ${event.level}: ${event.message}`);
268
+ }
269
+ else {
270
+ getLogger().debug(`[Smallfry:${agentId}] ${event.type}`);
271
+ }
272
+ },
273
+ fileStateResolver: resolver,
274
+ shadowInitialRef: runtimeEnv?.initialSnapshotHash || 'HEAD',
275
+ });
276
+ const subLoop = new SmallfryLoop(profile);
277
+ const result = await subLoop.execute(initCtx);
278
+ lastResult = result;
279
+ // Success or non-retryable failure — return immediately
280
+ if (result.success || result.reasonCode === 'LOOP_CRASH' || attempt >= maxAttempts) {
281
+ return await this.persistArtifacts(agentId, {
282
+ ...result,
283
+ attempts: attempt,
284
+ });
285
+ }
286
+ // Retryable failure — log and continue
287
+ lastError = result.reason || result.summary;
288
+ getLogger().warn(`[SubAgentManager] Smallfry ${agentId} attempt ${attempt}/${maxAttempts} failed (${result.reasonCode}), retrying...`);
289
+ }
290
+ finally {
291
+ await runtimeEnv.teardown();
292
+ }
146
293
  }
147
- finally {
148
- await runtimeEnv.teardown();
294
+ catch (error) {
295
+ this.controller.appendLog(agentId, `Execution failed: ${errorMessage(error)}`);
296
+ getLogger().error(`[SubAgentManager] Smallfry ${agentId} crashed: ${errorMessage(error)}`);
297
+ // Crashes are not retryable
298
+ return {
299
+ agent_ref: profile.id,
300
+ success: false,
301
+ summary: text.smallfry.errors.missionFailedWithReason(errorMessage(error)),
302
+ tokenUsage: 0,
303
+ reason: errorMessage(error),
304
+ reasonCode: 'LOOP_CRASH',
305
+ attempts: attempt,
306
+ logs: [],
307
+ errorType: ErrorType.UNKNOWN,
308
+ };
149
309
  }
150
310
  }
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
- }
311
+ // Should not reach here, but safety fallback
312
+ return lastResult ?? this.fail(profile.id, text.smallfry.errors.missionFailed, 'LOOP_FAILED');
169
313
  }
170
- // Backward compatibility for internal calls
314
+ // Backward compatibility for internal calls (always synchronous)
171
315
  async spawn(request) {
172
- return this.execute(request);
316
+ const normalizedRequest = this.normalizeRequest(request);
317
+ const profile = this.deps.registry.get(normalizedRequest.agent_ref);
318
+ if (!profile) {
319
+ return this.fail(normalizedRequest.agent_ref, text.smallfry.errors.profileNotFound(normalizedRequest.agent_ref), 'LOOP_FAILED');
320
+ }
321
+ const agentId = `smallfry-${randomBytes(4).toString('hex')}`;
322
+ return this.executeSync(normalizedRequest, profile, agentId);
173
323
  }
174
324
  applyContextSnapshot(snapshot, initCtx) {
175
325
  const normalized = cloneSubAgentContextSnapshot(snapshot);
@@ -209,6 +359,54 @@ export class SubAgentManager {
209
359
  errorType: ErrorType.UNKNOWN,
210
360
  };
211
361
  }
362
+ resolveAllowedTools(profile, teamId) {
363
+ const base = this.filterAllowedTools(profile.allowedTools, this.ctx.phase, profile.toolInheritance);
364
+ // Apply disallowedTools (denylist) — subtract from resolved tools
365
+ let resolved = base;
366
+ if (resolved !== undefined && profile.disallowedTools && profile.disallowedTools.length > 0) {
367
+ const denied = new Set(profile.disallowedTools);
368
+ resolved = resolved.filter((name) => !denied.has(name));
369
+ }
370
+ if (!teamId)
371
+ return resolved;
372
+ // When a teamId is present, add agent_team to the allowed tools
373
+ if (resolved === undefined)
374
+ return undefined; // Inherited all tools — agent_team already available
375
+ return [...new Set([...resolved, 'agent_team'])];
376
+ }
377
+ /**
378
+ * Resolve the LLM for a sub-agent based on profile.model.
379
+ * 'inherit' or undefined → use parent LLM.
380
+ * Other values → use llmFactory to create a model-specific LLM.
381
+ */
382
+ resolveLlm(profile, parentLlm) {
383
+ const model = profile.model;
384
+ // Try llmFactory first for all models (including 'inherit').
385
+ // This allows test harnesses to provide isolated LLMs for sub-agents.
386
+ // In production, factories typically return undefined for 'inherit',
387
+ // so the fallback to parentLlm is preserved.
388
+ if (this.deps.llmFactory) {
389
+ const modelLlm = this.deps.llmFactory(model ?? 'inherit');
390
+ if (modelLlm) {
391
+ getLogger().debug(`[SubAgentManager] Using llmFactory LLM for profile "${profile.id}" (model="${model ?? 'inherit'}")`);
392
+ return modelLlm;
393
+ }
394
+ }
395
+ if (!model || model === 'inherit') {
396
+ return parentLlm;
397
+ }
398
+ if (!this.deps.llmFactory) {
399
+ getLogger().warn(`[SubAgentManager] Profile "${profile.id}" requests model "${model}" but no llmFactory configured. Falling back to parent LLM.`);
400
+ return parentLlm;
401
+ }
402
+ const modelLlm = this.deps.llmFactory(model);
403
+ if (!modelLlm) {
404
+ getLogger().warn(`[SubAgentManager] llmFactory returned no LLM for model "${model}". Falling back to parent LLM.`);
405
+ return parentLlm;
406
+ }
407
+ getLogger().debug(`[SubAgentManager] Using model "${model}" for profile "${profile.id}"`);
408
+ return modelLlm;
409
+ }
212
410
  async setupIsolatedEnvironment(request, llm, agentId, effectiveDryRun) {
213
411
  if (isReadOnlySubAgentContext({
214
412
  flowMode: this.ctx.flowMode,
@@ -251,7 +449,7 @@ export class SubAgentManager {
251
449
  await env.teardown();
252
450
  }
253
451
  catch (teardownError) {
254
- getLogger().warn(`[SubAgentManager] Failed to teardown isolated environment after setup error: ${teardownError instanceof Error ? teardownError.message : String(teardownError)}`);
452
+ getLogger().warn(`[SubAgentManager] Failed to teardown isolated environment after setup error: ${errorMessage(teardownError)}`);
255
453
  }
256
454
  throw error;
257
455
  }
@@ -275,11 +473,20 @@ export class SubAgentManager {
275
473
  return {
276
474
  ...rest,
277
475
  auditPath: auditArtifact?.handle ?? rest.auditPath,
278
- auditArtifact: auditArtifact ?? undefined,
476
+ auditArtifact: auditArtifact,
279
477
  patchArtifact: saved,
280
478
  };
281
479
  }
282
- filterAllowedTools(allowed, phase) {
480
+ filterAllowedTools(allowed, phase, toolInheritance) {
481
+ const readOnlyPhase = isReadOnlySubAgentContext({
482
+ flowMode: this.ctx.flowMode,
483
+ phase,
484
+ });
485
+ // When toolInheritance is 'safe' or 'all' in non-read-only phase,
486
+ // return undefined to skip allowlist filtering (inherits parent toolstack)
487
+ if (!readOnlyPhase && toolInheritance && toolInheritance !== 'none') {
488
+ return undefined;
489
+ }
283
490
  const safeReadOnlyTools = new Set([
284
491
  'agent_dispatch',
285
492
  'code.search',
@@ -290,10 +497,6 @@ export class SubAgentManager {
290
497
  'artifact.read',
291
498
  ]);
292
499
  const readOnlyPlanTools = new Set(['plan.init', 'plan.read', 'plan.update']);
293
- const readOnlyPhase = isReadOnlySubAgentContext({
294
- flowMode: this.ctx.flowMode,
295
- phase,
296
- });
297
500
  if (!readOnlyPhase) {
298
501
  return allowed;
299
502
  }
@@ -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: 'haiku',
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: 'haiku',
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
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * SubAgentTeam — lightweight task board for parallel sub-agent coordination.
3
+ *
4
+ * Multiple sub-agents can claim tasks/files to avoid duplicate work.
5
+ * Claims are advisory (not enforced) — the team board is a shared
6
+ * declaration board, not a mutex.
7
+ */
8
+ export class SubAgentTeam {
9
+ board = new Map();
10
+ /**
11
+ * Attempt to claim a task key. Returns true if the claim succeeded
12
+ * (key was unclaimed), false if already claimed by another agent.
13
+ * Re-claiming by the same agent is idempotent (returns true).
14
+ */
15
+ claim(taskKey, agentId) {
16
+ const existing = this.board.get(taskKey);
17
+ if (existing && existing.claimedBy !== agentId) {
18
+ return false;
19
+ }
20
+ this.board.set(taskKey, { claimedBy: agentId, claimedAt: Date.now() });
21
+ return true;
22
+ }
23
+ /**
24
+ * Release a claim (e.g., on completion or failure).
25
+ */
26
+ release(taskKey, agentId) {
27
+ const existing = this.board.get(taskKey);
28
+ if (!existing || existing.claimedBy !== agentId) {
29
+ return false;
30
+ }
31
+ this.board.delete(taskKey);
32
+ return true;
33
+ }
34
+ /**
35
+ * Check if a task key is already claimed by someone else.
36
+ */
37
+ isClaimed(taskKey, excludeAgent) {
38
+ const existing = this.board.get(taskKey);
39
+ if (!existing)
40
+ return false;
41
+ return existing.claimedBy !== excludeAgent;
42
+ }
43
+ /**
44
+ * List all current claims.
45
+ */
46
+ listClaims() {
47
+ return Array.from(this.board.entries()).map(([taskKey, entry]) => ({
48
+ taskKey,
49
+ claimedBy: entry.claimedBy,
50
+ claimedAt: entry.claimedAt,
51
+ }));
52
+ }
53
+ /**
54
+ * Get all claims for a specific agent.
55
+ */
56
+ getAgentClaims(agentId) {
57
+ const claims = [];
58
+ for (const [taskKey, entry] of this.board) {
59
+ if (entry.claimedBy === agentId) {
60
+ claims.push({ taskKey, claimedBy: entry.claimedBy, claimedAt: entry.claimedAt });
61
+ }
62
+ }
63
+ return claims;
64
+ }
65
+ /**
66
+ * Clear all claims for a specific agent (e.g., on termination).
67
+ */
68
+ releaseAll(agentId) {
69
+ let released = 0;
70
+ for (const [key, entry] of this.board) {
71
+ if (entry.claimedBy === agentId) {
72
+ this.board.delete(key);
73
+ released++;
74
+ }
75
+ }
76
+ return released;
77
+ }
78
+ }
79
+ // Global team registry — teams live for the duration of the parent session
80
+ const teams = new Map();
81
+ /** Get an existing team or create a new one. Teams are keyed by teamId. */
82
+ export function getOrCreateTeam(teamId) {
83
+ let team = teams.get(teamId);
84
+ if (!team) {
85
+ team = new SubAgentTeam();
86
+ teams.set(teamId, team);
87
+ }
88
+ return team;
89
+ }
90
+ /** Remove a team from the global registry. Returns false if not found. */
91
+ export function removeTeam(teamId) {
92
+ return teams.delete(teamId);
93
+ }
94
+ /** Remove all teams from the global registry. */
95
+ export function clearAllTeams() {
96
+ teams.clear();
97
+ }
98
+ //# sourceMappingURL=team.js.map