skyloom 1.16.2 → 1.17.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.
- package/README.md +15 -3
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +17 -0
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/cli/main.js +37 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent.d.ts +2 -0
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +13 -0
- package/dist/core/agent.js.map +1 -1
- package/dist/core/bgproc.d.ts +59 -0
- package/dist/core/bgproc.d.ts.map +1 -0
- package/dist/core/bgproc.js +135 -0
- package/dist/core/bgproc.js.map +1 -0
- package/dist/core/commands.d.ts.map +1 -1
- package/dist/core/commands.js +20 -0
- package/dist/core/commands.js.map +1 -1
- package/dist/core/diagnostics.d.ts +39 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +206 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +82 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/envcontext.d.ts +25 -0
- package/dist/core/envcontext.d.ts.map +1 -0
- package/dist/core/envcontext.js +112 -0
- package/dist/core/envcontext.js.map +1 -0
- package/dist/core/factory.d.ts +2 -0
- package/dist/core/factory.d.ts.map +1 -1
- package/dist/core/factory.js +35 -2
- package/dist/core/factory.js.map +1 -1
- package/dist/core/sandbox.d.ts +1 -0
- package/dist/core/sandbox.d.ts.map +1 -1
- package/dist/core/sandbox.js +1 -0
- package/dist/core/sandbox.js.map +1 -1
- package/dist/core/security.d.ts +22 -2
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +54 -24
- package/dist/core/security.js.map +1 -1
- package/dist/core/skill.d.ts +4 -0
- package/dist/core/skill.d.ts.map +1 -1
- package/dist/core/skill.js +1 -0
- package/dist/core/skill.js.map +1 -1
- package/dist/core/subagent.d.ts +75 -0
- package/dist/core/subagent.d.ts.map +1 -0
- package/dist/core/subagent.js +287 -0
- package/dist/core/subagent.js.map +1 -0
- package/dist/core/tool.d.ts +15 -1
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +88 -30
- package/dist/core/tool.js.map +1 -1
- package/dist/plugins/loader.d.ts +49 -8
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +129 -16
- package/dist/plugins/loader.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +118 -13
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/spawn.d.ts +23 -0
- package/dist/tools/spawn.d.ts.map +1 -0
- package/dist/tools/spawn.js +77 -0
- package/dist/tools/spawn.js.map +1 -0
- package/docs/OPTIMIZATION_PLAN.md +21 -4
- package/package.json +1 -1
- package/src/cli/loom_chat.ts +11 -0
- package/src/cli/main.ts +31 -1
- package/src/core/agent.ts +13 -0
- package/src/core/bgproc.ts +153 -0
- package/src/core/commands.ts +20 -0
- package/src/core/diagnostics.ts +178 -0
- package/src/core/diff.ts +98 -0
- package/src/core/envcontext.ts +79 -0
- package/src/core/factory.ts +31 -2
- package/src/core/sandbox.ts +1 -1
- package/src/core/security.ts +62 -21
- package/src/core/skill.ts +1 -1
- package/src/core/subagent.ts +272 -0
- package/src/core/tool.ts +86 -31
- package/src/plugins/loader.ts +145 -18
- package/src/tools/builtin.ts +107 -13
- package/src/tools/spawn.ts +92 -0
- package/tests/bgproc.test.ts +65 -0
- package/tests/diagnostics.test.ts +86 -0
- package/tests/edit_diff.test.ts +102 -0
- package/tests/envcontext.test.ts +67 -0
- package/tests/plugins.test.ts +84 -0
- package/tests/security.test.ts +87 -0
- package/tests/subagent.test.ts +211 -0
- package/tests/tool.test.ts +76 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment context snapshot — a small, model-visible block describing the
|
|
3
|
+
* runtime world (working directory, platform, git, Node, date), kept separate
|
|
4
|
+
* from conversation history. Mirrors Claude Code's <env> block so the agent
|
|
5
|
+
* grounds itself without having to probe with tools every turn.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
|
|
12
|
+
export interface GitInfo {
|
|
13
|
+
repo: boolean;
|
|
14
|
+
branch?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cheap git detection: walk up for a `.git` (handles worktrees, where `.git`
|
|
19
|
+
* is a file pointing at the real gitdir), then read HEAD for the branch. No
|
|
20
|
+
* subprocess — just file reads.
|
|
21
|
+
*/
|
|
22
|
+
export function gitInfo(cwd: string = process.cwd()): GitInfo {
|
|
23
|
+
let dir = path.resolve(cwd);
|
|
24
|
+
for (let i = 0; i < 40; i++) {
|
|
25
|
+
const dotGit = path.join(dir, '.git');
|
|
26
|
+
if (fs.existsSync(dotGit)) {
|
|
27
|
+
let gitDir = dotGit;
|
|
28
|
+
try {
|
|
29
|
+
if (fs.statSync(dotGit).isFile()) {
|
|
30
|
+
const m = fs.readFileSync(dotGit, 'utf8').match(/gitdir:\s*(.+)/);
|
|
31
|
+
if (m) gitDir = path.resolve(dir, m[1].trim());
|
|
32
|
+
}
|
|
33
|
+
} catch { /* treat as repo without branch */ }
|
|
34
|
+
try {
|
|
35
|
+
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
|
|
36
|
+
const ref = head.match(/ref:\s*refs\/heads\/(.+)/);
|
|
37
|
+
return { repo: true, branch: ref ? ref[1] : head.slice(0, 8) };
|
|
38
|
+
} catch {
|
|
39
|
+
return { repo: true };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const parent = path.dirname(dir);
|
|
43
|
+
if (parent === dir) break;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
return { repo: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the environment block. `now` is injectable for deterministic tests.
|
|
51
|
+
*/
|
|
52
|
+
export function buildEnvBlock(opts?: { cwd?: string; lang?: string; now?: Date }): string {
|
|
53
|
+
const cwd = opts?.cwd || process.cwd();
|
|
54
|
+
const lang = opts?.lang || 'zh';
|
|
55
|
+
const now = opts?.now || new Date();
|
|
56
|
+
const git = gitInfo(cwd);
|
|
57
|
+
const date = now.toISOString().slice(0, 10);
|
|
58
|
+
const platform = `${process.platform} ${os.release()}`;
|
|
59
|
+
const gitLine = git.repo ? (git.branch ? `yes (branch: ${git.branch})` : 'yes') : 'no';
|
|
60
|
+
|
|
61
|
+
if (lang === 'en') {
|
|
62
|
+
return [
|
|
63
|
+
'## Environment',
|
|
64
|
+
`- Working directory: ${cwd}`,
|
|
65
|
+
`- Platform: ${platform}`,
|
|
66
|
+
`- Node: ${process.version}`,
|
|
67
|
+
`- Git repo: ${gitLine}`,
|
|
68
|
+
`- Date: ${date}`,
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
return [
|
|
72
|
+
'## 运行环境',
|
|
73
|
+
`- 工作目录: ${cwd}`,
|
|
74
|
+
`- 平台: ${platform}`,
|
|
75
|
+
`- Node: ${process.version}`,
|
|
76
|
+
`- Git 仓库: ${gitLine === 'no' ? '否' : gitLine.replace('yes', '是')}`,
|
|
77
|
+
`- 日期: ${date}`,
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
package/src/core/factory.ts
CHANGED
|
@@ -23,6 +23,7 @@ export class SystemContext {
|
|
|
23
23
|
workspacePath: string = '';
|
|
24
24
|
mcp: any = null;
|
|
25
25
|
mcpStatus: string[] = [];
|
|
26
|
+
plugins: any = null;
|
|
26
27
|
|
|
27
28
|
constructor(opts: {
|
|
28
29
|
config: ReturnType<typeof loadConfig>;
|
|
@@ -33,6 +34,7 @@ export class SystemContext {
|
|
|
33
34
|
workspacePath?: string;
|
|
34
35
|
mcp?: any;
|
|
35
36
|
mcpStatus?: string[];
|
|
37
|
+
plugins?: any;
|
|
36
38
|
}) {
|
|
37
39
|
this.config = opts.config;
|
|
38
40
|
this.bus = opts.bus;
|
|
@@ -42,9 +44,15 @@ export class SystemContext {
|
|
|
42
44
|
this.workspacePath = opts.workspacePath || '';
|
|
43
45
|
this.mcp = opts.mcp || null;
|
|
44
46
|
this.mcpStatus = opts.mcpStatus || [];
|
|
47
|
+
this.plugins = opts.plugins || null;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
async initAll(): Promise<void> {
|
|
51
|
+
// Plugin `init` hook — fires once after load, before agents come up.
|
|
52
|
+
if (this.plugins) {
|
|
53
|
+
try { await this.plugins.emit('init', { config: this.config }); }
|
|
54
|
+
catch (e) { log.warn('plugin_init_hook_failed', { error: String(e) }); }
|
|
55
|
+
}
|
|
48
56
|
if (this.mcp) {
|
|
49
57
|
try {
|
|
50
58
|
this.mcpStatus = await this.mcp.connectAll();
|
|
@@ -61,6 +69,11 @@ export class SystemContext {
|
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
async closeAll(): Promise<void> {
|
|
72
|
+
// Terminate any background shell jobs started this session.
|
|
73
|
+
try {
|
|
74
|
+
const { getBackgroundManager } = require('./bgproc');
|
|
75
|
+
getBackgroundManager().killAll();
|
|
76
|
+
} catch { /* best-effort */ }
|
|
64
77
|
for (const agent of this.agentMap.values()) {
|
|
65
78
|
await agent.close();
|
|
66
79
|
}
|
|
@@ -114,10 +127,11 @@ export function createSystemContext(): SystemContext {
|
|
|
114
127
|
log.warn('skills_not_available', { error: String(e) });
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
// Load plugins
|
|
130
|
+
// Load plugins (ordered hook lifecycle — see plugins/loader)
|
|
131
|
+
let pluginLoader: any = null;
|
|
118
132
|
try {
|
|
119
133
|
const { PluginLoader } = require('../plugins/loader');
|
|
120
|
-
|
|
134
|
+
pluginLoader = new PluginLoader(baseToolRegistry, config);
|
|
121
135
|
const pluginConfig = (config as any).plugins;
|
|
122
136
|
const pluginDirs = pluginConfig?.enabled ? (pluginConfig.directories || []) : [];
|
|
123
137
|
pluginLoader.loadFromDirectories(pluginDirs);
|
|
@@ -195,6 +209,20 @@ export function createSystemContext(): SystemContext {
|
|
|
195
209
|
log.warn('delegate_tool_not_available', { agent: name, error: String(e) });
|
|
196
210
|
}
|
|
197
211
|
|
|
212
|
+
// Register the spawn_agent tool — isolated-context subagents (Task tool).
|
|
213
|
+
try {
|
|
214
|
+
const { createSpawnAgentTool } = require('../tools/spawn');
|
|
215
|
+
agentRegistry.register(createSpawnAgentTool({
|
|
216
|
+
config,
|
|
217
|
+
llm,
|
|
218
|
+
bus,
|
|
219
|
+
baseToolRegistry,
|
|
220
|
+
baseSkillRegistry,
|
|
221
|
+
}));
|
|
222
|
+
} catch (e) {
|
|
223
|
+
log.warn('spawn_tool_not_available', { agent: name, error: String(e) });
|
|
224
|
+
}
|
|
225
|
+
|
|
198
226
|
// Register model self-service tools (list_models / set_my_model)
|
|
199
227
|
try {
|
|
200
228
|
const { createModelTools } = require('../tools/model_tool');
|
|
@@ -234,6 +262,7 @@ export function createSystemContext(): SystemContext {
|
|
|
234
262
|
toolRegistry: baseToolRegistry,
|
|
235
263
|
workspacePath,
|
|
236
264
|
mcp: mcpManager,
|
|
265
|
+
plugins: pluginLoader,
|
|
237
266
|
});
|
|
238
267
|
}
|
|
239
268
|
|
package/src/core/sandbox.ts
CHANGED
|
@@ -44,7 +44,7 @@ function cleanup(dir: string): void {
|
|
|
44
44
|
/* ═══════════════════════════════════════
|
|
45
45
|
Pre-execution check
|
|
46
46
|
═══════════════════════════════════════ */
|
|
47
|
-
function preflightCheck(command: string): string | null {
|
|
47
|
+
export function preflightCheck(command: string): string | null {
|
|
48
48
|
if (!command || !command.trim()) return "Empty command";
|
|
49
49
|
|
|
50
50
|
const lower = command.toLowerCase().trim();
|
package/src/core/security.ts
CHANGED
|
@@ -137,20 +137,75 @@ export interface AuditEntry {
|
|
|
137
137
|
/* ═══════════════════════════════════════
|
|
138
138
|
Security context — per-session security state
|
|
139
139
|
═══════════════════════════════════════ */
|
|
140
|
+
/**
|
|
141
|
+
* Permission modes (Claude Code parity):
|
|
142
|
+
* - strict deny every non-SAFE tool (read-only-ish lockdown)
|
|
143
|
+
* - interactive ask before every non-SAFE tool ("default")
|
|
144
|
+
* - auto allow LOW, ask MEDIUM/HIGH, deny CRITICAL
|
|
145
|
+
* - acceptEdits auto-accept file-edit tools, otherwise behave like auto
|
|
146
|
+
* - bypass allow everything except red-line patterns ("yolo")
|
|
147
|
+
*/
|
|
148
|
+
export type ApprovalMode = "auto" | "interactive" | "strict" | "acceptEdits" | "bypass";
|
|
149
|
+
export type Decision = "allow" | "ask" | "deny";
|
|
150
|
+
|
|
151
|
+
/** User-facing aliases → canonical permission mode (for /perm and config). */
|
|
152
|
+
export const PERMISSION_MODE_ALIASES: Record<string, ApprovalMode> = {
|
|
153
|
+
default: "interactive",
|
|
154
|
+
interactive: "interactive",
|
|
155
|
+
ask: "interactive",
|
|
156
|
+
auto: "auto",
|
|
157
|
+
accept: "acceptEdits",
|
|
158
|
+
acceptedits: "acceptEdits",
|
|
159
|
+
edits: "acceptEdits",
|
|
160
|
+
strict: "strict",
|
|
161
|
+
readonly: "strict",
|
|
162
|
+
bypass: "bypass",
|
|
163
|
+
yolo: "bypass",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Tools that mutate the filesystem — the ones acceptEdits waves through. */
|
|
167
|
+
const EDIT_TOOL_RE = /^(write_|edit_|append_|replace_|create_|make_|copy_|move_|delete_)/;
|
|
168
|
+
export function isEditTool(toolName: string): boolean { return EDIT_TOOL_RE.test(toolName); }
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Pure permission decision (red-line is gated separately, before this). Keeps
|
|
172
|
+
* the ask/allow/deny semantics in one testable place across all modes.
|
|
173
|
+
*/
|
|
174
|
+
export function decideApproval(level: DangerLevel, mode: ApprovalMode, toolName: string): Decision {
|
|
175
|
+
if (level === DangerLevel.SAFE) return "allow";
|
|
176
|
+
switch (mode) {
|
|
177
|
+
case "bypass": return "allow";
|
|
178
|
+
case "strict": return "deny";
|
|
179
|
+
case "interactive": return "ask";
|
|
180
|
+
case "acceptEdits":
|
|
181
|
+
if (level === DangerLevel.CRITICAL) return "deny";
|
|
182
|
+
if (isEditTool(toolName)) return "allow";
|
|
183
|
+
return level <= DangerLevel.LOW ? "allow" : "ask";
|
|
184
|
+
case "auto":
|
|
185
|
+
default:
|
|
186
|
+
if (level <= DangerLevel.LOW) return "allow";
|
|
187
|
+
if (level === DangerLevel.CRITICAL) return "deny";
|
|
188
|
+
return "ask";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
140
192
|
export class SecurityContext {
|
|
141
193
|
public auditLog: AuditEntry[] = [];
|
|
142
194
|
public deniedCount = 0;
|
|
143
195
|
public autoApprovedCount = 0;
|
|
144
196
|
public manualApprovedCount = 0;
|
|
145
|
-
public approvalMode:
|
|
197
|
+
public approvalMode: ApprovalMode = "auto";
|
|
146
198
|
|
|
147
199
|
private approvalCallback: ((tool: string, args: Record<string, any>, level: DangerLevel) => Promise<boolean>) | null = null;
|
|
148
200
|
|
|
149
|
-
constructor(opts?: { mode?:
|
|
201
|
+
constructor(opts?: { mode?: ApprovalMode; onApprove?: (tool: string, args: Record<string, any>, level: DangerLevel) => Promise<boolean> }) {
|
|
150
202
|
if (opts?.mode) this.approvalMode = opts.mode;
|
|
151
203
|
if (opts?.onApprove) this.approvalCallback = opts.onApprove;
|
|
152
204
|
}
|
|
153
205
|
|
|
206
|
+
/** Switch the active permission mode at runtime. */
|
|
207
|
+
setMode(mode: ApprovalMode): void { this.approvalMode = mode; }
|
|
208
|
+
|
|
154
209
|
/** Get the danger level for a tool. Defaults to SAFE for unknown tools. */
|
|
155
210
|
getDangerLevel(toolName: string): DangerLevel {
|
|
156
211
|
return TOOL_DANGER_MAP[toolName] ?? DangerLevel.SAFE;
|
|
@@ -180,27 +235,13 @@ export class SecurityContext {
|
|
|
180
235
|
return [false, redline];
|
|
181
236
|
}
|
|
182
237
|
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (this.approvalMode === "strict") {
|
|
188
|
-
return [false, `Strict mode: tool '${toolName}' (level ${level}) requires manual approval`];
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Auto mode — allow LOW, prompt for MEDIUM+, deny CRITICAL
|
|
192
|
-
if (this.approvalMode === "auto") {
|
|
193
|
-
if (level <= DangerLevel.LOW) return [true, "auto-low"];
|
|
194
|
-
if (level === DangerLevel.CRITICAL) return [false, `CRITICAL tool '${toolName}' requires explicit human approval`];
|
|
195
|
-
// MEDIUM/HIGH with auto mode => need callback
|
|
196
|
-
if (this.approvalCallback) {
|
|
197
|
-
const approved = await this.approvalCallback(toolName, args, level);
|
|
198
|
-
return [approved, approved ? "user-approved" : "user-denied"];
|
|
199
|
-
}
|
|
200
|
-
return [true, "auto-med"]; // no callback → auto-allow but log
|
|
238
|
+
const decision = decideApproval(level, this.approvalMode, toolName);
|
|
239
|
+
if (decision === "allow") return [true, level === DangerLevel.SAFE ? "safe" : `${this.approvalMode}-allow`];
|
|
240
|
+
if (decision === "deny") {
|
|
241
|
+
return [false, `${this.approvalMode} mode: tool '${toolName}' (level ${level}) requires approval / is blocked`];
|
|
201
242
|
}
|
|
202
243
|
|
|
203
|
-
//
|
|
244
|
+
// decision === "ask" — defer to the interactive callback if present.
|
|
204
245
|
if (this.approvalCallback) {
|
|
205
246
|
const approved = await this.approvalCallback(toolName, args, level);
|
|
206
247
|
return [approved, approved ? "user-approved" : "user-denied"];
|
package/src/core/skill.ts
CHANGED
|
@@ -210,7 +210,7 @@ function parseFrontmatter(text: string): { fm: Record<string, any>; body: string
|
|
|
210
210
|
/**
|
|
211
211
|
* Normalize a Claude Code tool name into sky's registry name.
|
|
212
212
|
*/
|
|
213
|
-
function normalizeClaudeToolName(raw: string): string {
|
|
213
|
+
export function normalizeClaudeToolName(raw: string): string {
|
|
214
214
|
let s = raw.trim();
|
|
215
215
|
if (!s) return s;
|
|
216
216
|
// Strip permission scoping: Bash(ls *) -> Bash
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagents — general-purpose, user-definable agents spawned with an ISOLATED
|
|
3
|
+
* context to handle one focused, self-contained task (the `spawn_agent` tool).
|
|
4
|
+
*
|
|
5
|
+
* This mirrors Claude Code's Task tool / opencode subagents: an orchestrator
|
|
6
|
+
* spins up a child agent that has its own fresh memory and a (optionally
|
|
7
|
+
* restricted) tool set, runs it to completion, and gets back only the child's
|
|
8
|
+
* final report — keeping the parent's context clean.
|
|
9
|
+
*
|
|
10
|
+
* Definitions come from three places (later wins on name collision):
|
|
11
|
+
* 1. built-in: general-purpose, explore
|
|
12
|
+
* 2. user: ~/.claude/agents/ (Claude Code compatible), ~/.skyloom/agents/
|
|
13
|
+
* 3. project: <cwd>/.claude/agents/, <cwd>/.sky/agents/
|
|
14
|
+
*
|
|
15
|
+
* Each file is `<root>/<name>.md` with Claude Code compatible YAML frontmatter
|
|
16
|
+
* (name / description / tools / model). The body is the subagent's system
|
|
17
|
+
* prompt. `tools` accepts Claude names (Read, Grep, Bash…) — they're aliased to
|
|
18
|
+
* sky's registry names. Omitting `tools` grants the full inherited tool set.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as os from 'os';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import { parse as parseYaml } from 'yaml';
|
|
25
|
+
|
|
26
|
+
import { BaseAgent } from './agent';
|
|
27
|
+
import type { MessageBus } from './bus';
|
|
28
|
+
import { LLMClient } from './llm';
|
|
29
|
+
import { ToolRegistry } from './tool';
|
|
30
|
+
import { SkillRegistry } from './skill';
|
|
31
|
+
import { normalizeClaudeToolName } from './skill';
|
|
32
|
+
import { getLogger } from './logger';
|
|
33
|
+
|
|
34
|
+
const log = getLogger('subagent');
|
|
35
|
+
|
|
36
|
+
export interface SubagentDefinition {
|
|
37
|
+
/** Identifier used as `agent_type` in the spawn_agent tool. */
|
|
38
|
+
name: string;
|
|
39
|
+
/** One-line summary — shown to the orchestrator to choose the right subagent. */
|
|
40
|
+
description: string;
|
|
41
|
+
/** The subagent's system prompt (markdown body of the definition file). */
|
|
42
|
+
systemPrompt: string;
|
|
43
|
+
/** Allowlist of tool names; `null` means inherit the full tool set. */
|
|
44
|
+
tools: string[] | null;
|
|
45
|
+
/** Optional model override (else the spawning agent's default model). */
|
|
46
|
+
model?: string;
|
|
47
|
+
/** Where this definition came from: 'builtin' or an absolute file path. */
|
|
48
|
+
source: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read-only tools for the `explore` subagent — search/read, never mutate. */
|
|
52
|
+
export const READ_ONLY_TOOLS = [
|
|
53
|
+
'read_file', 'list_directory', 'tree', 'file_search', 'code_search', 'grep',
|
|
54
|
+
'web_search', 'fetch_page', 'http_get',
|
|
55
|
+
'git_status', 'git_diff', 'git_log',
|
|
56
|
+
'system_info',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const BUILTIN_DEFS: SubagentDefinition[] = [
|
|
60
|
+
{
|
|
61
|
+
name: 'general-purpose',
|
|
62
|
+
description: '通用子智能体 — 研究复杂问题、搜索代码、执行多步任务。当你不确定一两次能否定位答案时,把搜索/调研整段交给它。',
|
|
63
|
+
systemPrompt:
|
|
64
|
+
'你是一个通用子智能体,擅长把一个目标拆成步骤并用工具逐一推进:搜索、阅读、分析、必要时修改,然后汇报。' +
|
|
65
|
+
'独立完成任务,不要反问;遇到歧义就合理假设并说明。最终回复要完整自洽 —— 编排者只看得到这一条。',
|
|
66
|
+
tools: null,
|
|
67
|
+
source: 'builtin',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'explore',
|
|
71
|
+
description: '只读探索子智能体 — 在大量文件/目录中做广度搜索,只读不写。需要"扫一遍代码库定位某物"且只要结论时用它。',
|
|
72
|
+
systemPrompt:
|
|
73
|
+
'你是一个只读探索子智能体。你的工作是在代码库/网络中快速定位信息并给出结论,绝不修改任何文件。' +
|
|
74
|
+
'优先用 grep / code_search / file_search / tree 做广度扫描,读取关键片段而非整文件。' +
|
|
75
|
+
'汇报时给出精确的文件路径与行号(file_path:line),以及一段简明结论。',
|
|
76
|
+
tools: READ_ONLY_TOOLS,
|
|
77
|
+
source: 'builtin',
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/** User/project subagent definition roots, lowest precedence first. */
|
|
82
|
+
export function subagentDirs(cwd: string = process.cwd()): string[] {
|
|
83
|
+
return [
|
|
84
|
+
path.join(os.homedir(), '.claude', 'agents'),
|
|
85
|
+
path.join(os.homedir(), '.skyloom', 'agents'),
|
|
86
|
+
path.join(cwd, '.claude', 'agents'),
|
|
87
|
+
path.join(cwd, '.sky', 'agents'),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseToolsField(raw: unknown): string[] | null {
|
|
92
|
+
let list: string[] | null = null;
|
|
93
|
+
if (Array.isArray(raw)) {
|
|
94
|
+
list = raw.filter((t): t is string => typeof t === 'string');
|
|
95
|
+
} else if (typeof raw === 'string') {
|
|
96
|
+
const trimmed = raw.trim();
|
|
97
|
+
if (!trimmed || trimmed === '*' || trimmed.toLowerCase() === 'all') return null;
|
|
98
|
+
list = trimmed.split(',').map((t) => t.trim()).filter(Boolean);
|
|
99
|
+
} else {
|
|
100
|
+
return null; // omitted → inherit all
|
|
101
|
+
}
|
|
102
|
+
if (!list || list.length === 0) return null;
|
|
103
|
+
// Normalize Claude names → sky names, dedupe preserving order
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
const out: string[] = [];
|
|
106
|
+
for (const t of list.map(normalizeClaudeToolName)) {
|
|
107
|
+
if (!seen.has(t)) { seen.add(t); out.push(t); }
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse a single `<name>.md` subagent definition file. */
|
|
113
|
+
export function parseSubagentFile(filePath: string): SubagentDefinition | null {
|
|
114
|
+
let text: string;
|
|
115
|
+
try {
|
|
116
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
let fm: Record<string, any> = {};
|
|
121
|
+
let body = text;
|
|
122
|
+
const m = text.match(/^---\s*\n(.*?)\n---\s*\n?(.*)/s);
|
|
123
|
+
if (m) {
|
|
124
|
+
try { fm = parseYaml(m[1]) || {}; } catch { fm = {}; }
|
|
125
|
+
body = m[2];
|
|
126
|
+
}
|
|
127
|
+
const fileBase = path.basename(filePath).replace(/\.md$/i, '');
|
|
128
|
+
const name = (typeof fm.name === 'string' && fm.name.trim()) ? fm.name.trim() : fileBase;
|
|
129
|
+
if (!name) return null;
|
|
130
|
+
const description = (typeof fm.description === 'string' && fm.description.trim())
|
|
131
|
+
? fm.description.trim()
|
|
132
|
+
: `自定义子智能体 ${name}`;
|
|
133
|
+
const model = (typeof fm.model === 'string' && fm.model.trim()) ? fm.model.trim() : undefined;
|
|
134
|
+
return {
|
|
135
|
+
name,
|
|
136
|
+
description,
|
|
137
|
+
systemPrompt: body.trim() || `你是 ${name} 子智能体。${description}`,
|
|
138
|
+
tools: parseToolsField(fm.tools),
|
|
139
|
+
model,
|
|
140
|
+
source: filePath,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Load all subagent definitions: built-ins overlaid by user then project files.
|
|
146
|
+
* Cheap enough to call per spawn so edits to definition files apply live.
|
|
147
|
+
*/
|
|
148
|
+
export function loadSubagentDefinitions(cwd: string = process.cwd()): Map<string, SubagentDefinition> {
|
|
149
|
+
const map = new Map<string, SubagentDefinition>();
|
|
150
|
+
for (const d of BUILTIN_DEFS) map.set(d.name, d);
|
|
151
|
+
|
|
152
|
+
for (const dir of subagentDirs(cwd)) {
|
|
153
|
+
let entries: string[];
|
|
154
|
+
try {
|
|
155
|
+
if (!fs.existsSync(dir)) continue;
|
|
156
|
+
entries = fs.readdirSync(dir);
|
|
157
|
+
} catch { continue; }
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (!entry.toLowerCase().endsWith('.md')) continue;
|
|
160
|
+
const def = parseSubagentFile(path.join(dir, entry));
|
|
161
|
+
if (def) map.set(def.name, def);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return map;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A generic agent built from a SubagentDefinition. Reuses the full BaseAgent
|
|
169
|
+
* reasoning loop but supplies its own identity block (the team-persona block
|
|
170
|
+
* would crash for a non-team name).
|
|
171
|
+
*/
|
|
172
|
+
export class GenericSubagent extends BaseAgent {
|
|
173
|
+
private _subDef: SubagentDefinition;
|
|
174
|
+
|
|
175
|
+
constructor(
|
|
176
|
+
def: SubagentDefinition,
|
|
177
|
+
config: any,
|
|
178
|
+
llm: LLMClient,
|
|
179
|
+
bus: MessageBus,
|
|
180
|
+
toolRegistry: ToolRegistry,
|
|
181
|
+
skillRegistry: SkillRegistry,
|
|
182
|
+
runtimeName: string,
|
|
183
|
+
) {
|
|
184
|
+
super(config, llm, bus, toolRegistry, skillRegistry);
|
|
185
|
+
this.name = runtimeName;
|
|
186
|
+
this.displayName = def.name;
|
|
187
|
+
this.emoji = '◇';
|
|
188
|
+
this.specialty = def.description;
|
|
189
|
+
this.systemPrompt = def.systemPrompt;
|
|
190
|
+
this._subDef = def;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
protected runtimeIdentityBlock(): string {
|
|
194
|
+
const lang = (this.config as any).llm?.language || 'zh';
|
|
195
|
+
if (lang === 'en') {
|
|
196
|
+
return `\n\n## Who You Are\nYou are the **${this._subDef.name}** subagent — ${this._subDef.description}\nYou run in an ISOLATED context spawned by an orchestrator for one focused, self-contained task. The orchestrator sees ONLY your final message, never your intermediate steps. So your final message must be a COMPLETE, self-contained report: what you found, what you did, and concrete results (file paths, code, answers). Be thorough; do not ask follow-up questions — make reasonable assumptions and act.`;
|
|
197
|
+
}
|
|
198
|
+
return `\n\n## 你是谁\n你是 **${this._subDef.name}** 子智能体 —— ${this._subDef.description}\n你运行在编排者派生的**隔离上下文**中,负责一个聚焦、自洽的任务。编排者只能看到你的最终回复,看不到任何中间步骤。因此你的最终回复必须是一份**完整、自洽的报告**:你发现了什么、做了什么、以及具体结果(文件路径、代码、答案)。要彻底;不要反问,合理假设后直接行动。`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let _spawnSeq = 0;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run a subagent to completion in an isolated context and return its final
|
|
206
|
+
* report. Creates an ephemeral on-disk memory in a temp dir and removes it
|
|
207
|
+
* afterward, so nothing leaks into the parent agent or the user's ~/.skyloom.
|
|
208
|
+
*/
|
|
209
|
+
export async function runSubagent(opts: {
|
|
210
|
+
def: SubagentDefinition;
|
|
211
|
+
task: string;
|
|
212
|
+
config: any;
|
|
213
|
+
llm: LLMClient;
|
|
214
|
+
bus: MessageBus;
|
|
215
|
+
baseToolRegistry: ToolRegistry;
|
|
216
|
+
baseSkillRegistry: SkillRegistry;
|
|
217
|
+
onStatus?: ((status: string) => void) | null;
|
|
218
|
+
}): Promise<string> {
|
|
219
|
+
const { def, task, config, llm, bus, baseToolRegistry, baseSkillRegistry, onStatus } = opts;
|
|
220
|
+
|
|
221
|
+
const safe = def.name.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 24) || 'sub';
|
|
222
|
+
const runtimeName = `sub-${safe}-${Date.now().toString(36)}-${(_spawnSeq++).toString(36)}`;
|
|
223
|
+
|
|
224
|
+
// Filtered tool registry (allowlist), never carrying spawn_agent (no recursion).
|
|
225
|
+
const reg = new ToolRegistry();
|
|
226
|
+
reg.merge(baseToolRegistry);
|
|
227
|
+
if (def.tools !== null) {
|
|
228
|
+
const allow = new Set(def.tools);
|
|
229
|
+
for (const n of reg.listNames()) {
|
|
230
|
+
if (!allow.has(n)) reg.unregister(n);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
reg.unregister('spawn_agent');
|
|
234
|
+
reg.unregister('delegate_to');
|
|
235
|
+
|
|
236
|
+
const skills = new SkillRegistry();
|
|
237
|
+
skills.merge(baseSkillRegistry);
|
|
238
|
+
|
|
239
|
+
let tmpDir: string;
|
|
240
|
+
try {
|
|
241
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sky-sub-'));
|
|
242
|
+
} catch (e) {
|
|
243
|
+
return `[spawn_agent error] could not create isolated workspace: ${e}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const subConfig = {
|
|
247
|
+
...config,
|
|
248
|
+
agents: {
|
|
249
|
+
...(config?.agents || {}),
|
|
250
|
+
[runtimeName]: def.model ? { model: def.model } : {},
|
|
251
|
+
},
|
|
252
|
+
memory: {
|
|
253
|
+
...(config?.memory || {}),
|
|
254
|
+
dbPath: path.join(tmpDir, 'mem'),
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const agent = new GenericSubagent(def, subConfig, llm, bus, reg, skills, runtimeName);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await agent.init();
|
|
262
|
+
if (onStatus) onStatus(`spawn ${def.name}…`);
|
|
263
|
+
const report = await agent.chat(task, onStatus || undefined);
|
|
264
|
+
return report || '(subagent produced no output)';
|
|
265
|
+
} catch (e) {
|
|
266
|
+
log.warn('subagent_run_failed', { agent: def.name, error: String(e) });
|
|
267
|
+
return `[spawn_agent error] subagent '${def.name}' failed: ${e}`;
|
|
268
|
+
} finally {
|
|
269
|
+
try { await agent.close(); } catch { /* ignore */ }
|
|
270
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
271
|
+
}
|
|
272
|
+
}
|