salmon-loop 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/authorization/non-interactive.js +9 -13
- package/dist/cli/chat.js +12 -6
- package/dist/cli/commands/allowlist.js +1 -1
- package/dist/cli/commands/chat.js +13 -13
- package/dist/cli/commands/parallel.js +1 -1
- package/dist/cli/commands/run/handler.js +6 -3
- package/dist/cli/commands/run/loop-params.js +1 -0
- package/dist/cli/commands/run/parse-options.js +14 -26
- package/dist/cli/commands/run/runtime-llm.js +15 -12
- package/dist/cli/commands/serve.js +14 -1
- package/dist/cli/headless/openai-responses-canonical-applier.js +1 -7
- package/dist/cli/reporters/standard.js +2 -3
- package/dist/cli/reporters/stream-json.js +2 -1
- package/dist/cli/slash/runtime.js +2 -2
- package/dist/cli/ui/components/CommandSuggestionList.js +1 -1
- package/dist/cli/ui/hooks/useLoopEvents.js +1 -1
- package/dist/cli/ui/hooks/useLoopState.js +1 -1
- package/dist/core/ast/parser.js +18 -9
- package/dist/core/config/schema.js +738 -0
- package/dist/core/config/validate.js +11 -922
- package/dist/core/context/gatherers/ast-gatherer.js +4 -12
- package/dist/core/context/gatherers/ghost-dependency-gatherer.js +0 -1
- package/dist/core/context/gatherers/knowledge-gatherer.js +3 -0
- package/dist/core/context/service.js +39 -8
- package/dist/core/context/token/encoding-registry.js +7 -6
- package/dist/core/extensions/index.js +48 -3
- package/dist/core/extensions/load.js +3 -2
- package/dist/core/extensions/merge.js +5 -1
- package/dist/core/extensions/paths.js +6 -0
- package/dist/core/extensions/schemas.js +21 -0
- package/dist/core/facades/cli-command-chat.js +2 -0
- package/dist/core/facades/cli-run-handler.js +1 -0
- package/dist/core/facades/cli-utils-serialize.js +2 -0
- package/dist/core/grizzco/dsl/llm-strategy.js +3 -2
- package/dist/core/grizzco/engine/outcome/loop-result-mapper.js +15 -10
- package/dist/core/grizzco/engine/pipeline/pipeline.js +149 -240
- package/dist/core/grizzco/engine/transaction/attempt-failure.js +5 -4
- package/dist/core/grizzco/engine/transaction/authorization-summary.js +2 -1
- package/dist/core/grizzco/runtime/apply-back-runtime.js +2 -1
- package/dist/core/grizzco/services/registry.js +18 -0
- package/dist/core/grizzco/steps/audit.js +20 -10
- package/dist/core/grizzco/steps/display-report.js +4 -11
- package/dist/core/grizzco/steps/explore.js +9 -2
- package/dist/core/grizzco/steps/patch/prompt-input.js +4 -1
- package/dist/core/grizzco/steps/patch.js +1 -0
- package/dist/core/grizzco/steps/plan.js +58 -49
- package/dist/core/grizzco/steps/tool-runtime.js +3 -0
- package/dist/core/grizzco/workers/strata-sync-worker.js +2 -1
- package/dist/core/llm/ai-sdk/message-mapper.js +24 -18
- package/dist/core/llm/ai-sdk/request-params.js +1 -3
- package/dist/core/llm/ai-sdk/result-mapper.js +14 -8
- package/dist/core/llm/ai-sdk/retry-classifier.js +6 -4
- package/dist/core/llm/contracts/repair.js +16 -8
- package/dist/core/llm/errors.js +13 -10
- package/dist/core/llm/output-policy.js +8 -0
- package/dist/core/llm/redact.js +1 -3
- package/dist/core/llm/sub-agent-factory.js +48 -0
- package/dist/core/llm/tool-calling-stub.js +48 -0
- package/dist/core/llm/utils.js +17 -6
- package/dist/core/mcp/bridge/prompt-command-provider.js +4 -3
- package/dist/core/mcp/bridge/tool-bridge.js +5 -14
- package/dist/core/mcp/client/connection-manager.js +3 -2
- package/dist/core/mcp/host/sampling-provider.js +1 -1
- package/dist/core/mcp/schema/json-schema-to-zod.js +2 -1
- package/dist/core/memory/relevant-retrieval.js +6 -4
- package/dist/core/observability/authorization-decisions.js +13 -12
- package/dist/core/observability/error-mapping.js +2 -1
- package/dist/core/observability/token-usage.js +5 -4
- package/dist/core/plugin/loader.js +5 -4
- package/dist/core/prompts/registry.js +11 -29
- package/dist/core/protocols/a2a/sdk/server.js +2 -3
- package/dist/core/protocols/acp/formal-agent.js +10 -4
- package/dist/core/protocols/acp/stdio-server.js +6 -6
- package/dist/core/runtime/agent-server-runtime.js +3 -2
- package/dist/core/runtime/initialize.js +70 -6
- package/dist/core/session/compaction/index.js +4 -3
- package/dist/core/session/manager.js +41 -47
- package/dist/core/session/token-tracker.js +18 -7
- package/dist/core/skills/parser.js +3 -2
- package/dist/core/skills/runtime/MicroTaskRunner.js +1 -1
- package/dist/core/skills/runtime/SkillRunner.js +5 -2
- package/dist/core/slash/steps/slash-execute.js +7 -5
- package/dist/core/slash/strategy.js +1 -1
- package/dist/core/strata/layers/worktree.js +7 -9
- package/dist/core/strata/runtime/synchronizer.js +10 -9
- package/dist/core/streaming/canonical/parts-from-llm-stream-chunk.js +1 -11
- package/dist/core/structured-output/json-schema-validator.js +1 -13
- package/dist/core/sub-agent/context-snapshot.js +12 -6
- package/dist/core/sub-agent/controller.js +70 -1
- package/dist/core/sub-agent/core/loop.js +25 -3
- package/dist/core/sub-agent/core/manager.js +319 -116
- package/dist/core/sub-agent/registry-defaults.js +12 -0
- package/dist/core/sub-agent/registry.js +8 -0
- package/dist/core/sub-agent/team.js +98 -0
- package/dist/core/sub-agent/tools/task-await.js +109 -0
- package/dist/core/sub-agent/tools/task-spawn.js +49 -7
- package/dist/core/sub-agent/tools/team.js +92 -0
- package/dist/core/sub-agent/types.js +11 -2
- package/dist/core/tools/budget.js +4 -11
- package/dist/core/tools/builtin/code-search/executor.js +46 -43
- package/dist/core/tools/builtin/fs.js +14 -6
- package/dist/core/tools/builtin/index.js +41 -107
- package/dist/core/tools/builtin/interaction.js +13 -15
- package/dist/core/tools/builtin/proposal.js +11 -2
- package/dist/core/tools/capability/executor.js +5 -5
- package/dist/core/tools/headless-payload.js +1 -3
- package/dist/core/tools/mapper.js +8 -42
- package/dist/core/tools/parallel/persistence.js +17 -5
- package/dist/core/tools/parallel/scheduler.js +23 -21
- package/dist/core/tools/permissions/permission-rules.js +66 -114
- package/dist/core/tools/plugins/loader.js +4 -3
- package/dist/core/tools/router.js +24 -53
- package/dist/core/tools/session.js +54 -97
- package/dist/core/tools/streaming/ToolCallAccumulator.js +1 -3
- package/dist/core/tools/tool-visibility.js +2 -1
- package/dist/core/tools/types.js +10 -0
- package/dist/core/utils/error.js +79 -0
- package/dist/core/utils/serialize.js +63 -0
- package/dist/core/utils/zod.js +29 -0
- package/dist/core/workspace/capabilities.js +3 -2
- package/dist/integrations/langfuse/litellm-langfuse-outcome-reporter.js +9 -8
- package/dist/locales/en.js +2 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
|
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
|