@jzakirov/spawn-agent 0.1.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/LICENSE +22 -0
- package/README.md +88 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +22 -0
- package/src/role-loader.ts +142 -0
- package/src/role-policy.ts +60 -0
- package/src/roles-list-tool.ts +51 -0
- package/src/spawn-role-tool.ts +154 -0
- package/src/spawn-subagent.ts +33 -0
- package/subagents/coder.md +21 -0
- package/subagents/reviewer.md +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamil Zakirov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# spawn-agent
|
|
2
|
+
|
|
3
|
+
Declarative subagent presets for [OpenClaw](https://openclaw.dev).
|
|
4
|
+
|
|
5
|
+
This plugin loads preset definitions from Markdown files with YAML frontmatter and exposes:
|
|
6
|
+
|
|
7
|
+
- `spawn_agent`: spawn a subagent using a preset + enforce tool policy
|
|
8
|
+
- `agent_presets_list`: list discovered presets
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
openclaw plugins install @jzakirov/spawn-agent
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or from a local path (development):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
openclaw plugins install ./openclaw-spawn-agent
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Preset discovery
|
|
23
|
+
|
|
24
|
+
Presets are discovered from multiple tiers (higher priority overrides lower priority):
|
|
25
|
+
|
|
26
|
+
1. `<workspace>/.openclaw/subagents/*.md`
|
|
27
|
+
2. `<stateDir>/subagents/*.md`
|
|
28
|
+
3. Plugin defaults: `subagents/*.md`
|
|
29
|
+
|
|
30
|
+
## Preset format
|
|
31
|
+
|
|
32
|
+
Each preset is a `.md` file with YAML frontmatter + a Markdown body used as the subagent system prompt.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
|
|
36
|
+
```md
|
|
37
|
+
---
|
|
38
|
+
name: reviewer
|
|
39
|
+
description: Read-only code reviewer focusing on bugs, security, and maintainability
|
|
40
|
+
model: anthropic/claude-sonnet-4-20250514
|
|
41
|
+
thinking: high
|
|
42
|
+
timeoutSeconds: 120
|
|
43
|
+
mode: run
|
|
44
|
+
cleanup: delete
|
|
45
|
+
sandbox: inherit
|
|
46
|
+
tools:
|
|
47
|
+
allow: [read, diffs, grep, glob]
|
|
48
|
+
deny: [exec, write, edit, apply_patch]
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
You are a code reviewer. Be concise. Focus on bugs, security, and maintainability.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Supported frontmatter fields:
|
|
55
|
+
|
|
56
|
+
| Field | Type | Default | Notes |
|
|
57
|
+
|-----------------|-------------------------|------------|------|
|
|
58
|
+
| `name` | string | — | Required. Unique key used by `spawn_agent`. |
|
|
59
|
+
| `description` | string | `""` | Shown in tool descriptions/listing. |
|
|
60
|
+
| `model` | string | — | Passed through to OpenClaw subagent spawn. |
|
|
61
|
+
| `thinking` | string | — | Passed through to OpenClaw subagent spawn. |
|
|
62
|
+
| `timeoutSeconds`| number | — | Passed through as `runTimeoutSeconds`. |
|
|
63
|
+
| `mode` | `run` \| `session` | `run` | `session` keeps a persistent session. |
|
|
64
|
+
| `cleanup` | `delete` \| `keep` | `delete` | Cleanup behavior for `run` mode. |
|
|
65
|
+
| `sandbox` | `inherit` \| `require` | `inherit` | Whether to require sandboxing. |
|
|
66
|
+
| `tools.allow` | string[] | — | If set, only these tools are allowed. |
|
|
67
|
+
| `tools.deny` | string[] | — | Tools to deny (even if allowed). |
|
|
68
|
+
|
|
69
|
+
## Tool: `spawn_agent`
|
|
70
|
+
|
|
71
|
+
Parameters:
|
|
72
|
+
|
|
73
|
+
- `agent` (string, required): preset name
|
|
74
|
+
- `task` (string, required): instruction for the subagent
|
|
75
|
+
- `label` (string, optional): human-readable label
|
|
76
|
+
- `thread` (boolean, optional): bind to a thread
|
|
77
|
+
- `mode` (`run` \| `session`, optional): override preset mode
|
|
78
|
+
|
|
79
|
+
Response includes `childSessionKey`, `runId`, and where the preset was resolved from.
|
|
80
|
+
|
|
81
|
+
## Tool: `agent_presets_list`
|
|
82
|
+
|
|
83
|
+
Returns all discovered presets and their metadata, including `source`.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
88
|
+
|
package/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { registerRolePolicyHooks } from "./src/role-policy.js";
|
|
3
|
+
import { createAgentPresetsListToolFactory } from "./src/roles-list-tool.js";
|
|
4
|
+
import { createSpawnAgentToolFactory } from "./src/spawn-role-tool.js";
|
|
5
|
+
|
|
6
|
+
export default function register(api: any) {
|
|
7
|
+
const pluginDir = path.dirname(new URL(import.meta.url).pathname);
|
|
8
|
+
|
|
9
|
+
const resolveStateDir = (): string | undefined => {
|
|
10
|
+
try {
|
|
11
|
+
return api.runtime?.state?.resolveStateDir?.();
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const factoryOpts = { pluginDir, resolveStateDir };
|
|
18
|
+
|
|
19
|
+
// Register spawn_agent as a tool factory (receives context per session)
|
|
20
|
+
api.registerTool(createSpawnAgentToolFactory(factoryOpts), { optional: true });
|
|
21
|
+
|
|
22
|
+
// Register agent_presets_list as a tool factory
|
|
23
|
+
api.registerTool(createAgentPresetsListToolFactory(factoryOpts), { optional: true });
|
|
24
|
+
|
|
25
|
+
// Register policy enforcement hooks
|
|
26
|
+
registerRolePolicyHooks(api);
|
|
27
|
+
|
|
28
|
+
api.logger.info("spawn-agent: registered tools and hooks");
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "spawn-agent",
|
|
3
|
+
"name": "Spawn Agent",
|
|
4
|
+
"description": "Declarative subagent presets with tool enforcement, loaded from .md files with YAML frontmatter.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {}
|
|
10
|
+
}
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jzakirov/spawn-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Declarative subagent presets with tool enforcement for OpenClaw",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jzakirov/openclaw-spawn-agent.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": ["openclaw", "openclaw-plugin", "subagent", "spawn-agent", "agent-presets"],
|
|
12
|
+
"files": ["index.ts", "src", "subagents", "openclaw.plugin.json", "README.md", "LICENSE"],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"gray-matter": "^4.0.3"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"openclaw": ">=2025.0.0"
|
|
18
|
+
},
|
|
19
|
+
"openclaw": {
|
|
20
|
+
"extensions": ["./index.ts"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
|
|
5
|
+
export type RoleToolPolicy = {
|
|
6
|
+
allow?: string[];
|
|
7
|
+
deny?: string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RoleConfig = {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
thinking?: string;
|
|
15
|
+
timeoutSeconds?: number;
|
|
16
|
+
mode?: "run" | "session";
|
|
17
|
+
cleanup?: "delete" | "keep";
|
|
18
|
+
sandbox?: "inherit" | "require";
|
|
19
|
+
tools?: RoleToolPolicy;
|
|
20
|
+
systemPrompt: string;
|
|
21
|
+
/** Where this role was loaded from */
|
|
22
|
+
source: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function parseRoleFile(content: string, filePath: string): RoleConfig | null {
|
|
26
|
+
let parsed: matter.GrayMatterFile<string>;
|
|
27
|
+
try {
|
|
28
|
+
parsed = matter(content);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const meta = parsed.data as Record<string, unknown>;
|
|
34
|
+
const body = parsed.content.trim();
|
|
35
|
+
const name = String(meta.name ?? "").trim();
|
|
36
|
+
if (!name) return null;
|
|
37
|
+
|
|
38
|
+
let tools: RoleToolPolicy | undefined;
|
|
39
|
+
if (meta.tools && typeof meta.tools === "object") {
|
|
40
|
+
tools = {};
|
|
41
|
+
const t = meta.tools as Record<string, unknown>;
|
|
42
|
+
if (Array.isArray(t.allow)) tools.allow = t.allow.map((s: unknown) => String(s).toLowerCase());
|
|
43
|
+
if (Array.isArray(t.deny)) tools.deny = t.deny.map((s: unknown) => String(s).toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
name,
|
|
48
|
+
description: String(meta.description ?? ""),
|
|
49
|
+
model: meta.model ? String(meta.model) : undefined,
|
|
50
|
+
thinking: meta.thinking ? String(meta.thinking) : undefined,
|
|
51
|
+
timeoutSeconds: typeof meta.timeoutSeconds === "number" ? meta.timeoutSeconds : undefined,
|
|
52
|
+
mode: meta.mode === "session" ? "session" : "run",
|
|
53
|
+
cleanup: meta.cleanup === "keep" ? "keep" : "delete",
|
|
54
|
+
sandbox: meta.sandbox === "require" ? "require" : "inherit",
|
|
55
|
+
tools,
|
|
56
|
+
systemPrompt: body,
|
|
57
|
+
source: filePath,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function loadRolesFromDir(dir: string): Promise<Map<string, RoleConfig>> {
|
|
62
|
+
const roles = new Map<string, RoleConfig>();
|
|
63
|
+
let entries: string[];
|
|
64
|
+
try {
|
|
65
|
+
entries = await fs.readdir(dir);
|
|
66
|
+
} catch {
|
|
67
|
+
return roles;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (!entry.endsWith(".md")) continue;
|
|
72
|
+
const filePath = path.join(dir, entry);
|
|
73
|
+
try {
|
|
74
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
75
|
+
const role = parseRoleFile(content, filePath);
|
|
76
|
+
if (role && !roles.has(role.name)) {
|
|
77
|
+
roles.set(role.name, role);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Skip unreadable files
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return roles;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Cache key for loaded roles */
|
|
87
|
+
type CacheKey = string;
|
|
88
|
+
|
|
89
|
+
function makeCacheKey(workspaceDir?: string, stateDir?: string): CacheKey {
|
|
90
|
+
return `${workspaceDir ?? ""}:${stateDir ?? ""}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rolesCache = new Map<CacheKey, { roles: Map<string, RoleConfig>; loadedAt: number }>();
|
|
94
|
+
const CACHE_TTL_MS = 30_000;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Load roles from all discovery tiers (higher priority first).
|
|
98
|
+
* 1. <workspace>/.openclaw/subagents/*.md
|
|
99
|
+
* 2. <stateDir>/subagents/*.md
|
|
100
|
+
* 3. Plugin bundled defaults
|
|
101
|
+
*
|
|
102
|
+
* Results are cached by (workspaceDir, stateDir) for 30s.
|
|
103
|
+
*/
|
|
104
|
+
export async function loadRoles(opts: {
|
|
105
|
+
workspaceDir?: string;
|
|
106
|
+
stateDir?: string;
|
|
107
|
+
pluginDir: string;
|
|
108
|
+
}): Promise<Map<string, RoleConfig>> {
|
|
109
|
+
const key = makeCacheKey(opts.workspaceDir, opts.stateDir);
|
|
110
|
+
const cached = rolesCache.get(key);
|
|
111
|
+
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
|
|
112
|
+
return cached.roles;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const merged = new Map<string, RoleConfig>();
|
|
116
|
+
|
|
117
|
+
// Load in reverse priority so higher-priority sources overwrite
|
|
118
|
+
const tiers: string[] = [];
|
|
119
|
+
|
|
120
|
+
// Tier 3: Plugin bundled defaults (lowest priority)
|
|
121
|
+
tiers.push(path.join(opts.pluginDir, "subagents"));
|
|
122
|
+
|
|
123
|
+
// Tier 2: User state dir
|
|
124
|
+
if (opts.stateDir) {
|
|
125
|
+
tiers.push(path.join(opts.stateDir, "subagents"));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Tier 1: Workspace (highest priority)
|
|
129
|
+
if (opts.workspaceDir) {
|
|
130
|
+
tiers.push(path.join(opts.workspaceDir, ".openclaw", "subagents"));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const dir of tiers) {
|
|
134
|
+
const roles = await loadRolesFromDir(dir);
|
|
135
|
+
for (const [name, config] of roles) {
|
|
136
|
+
merged.set(name, config);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
rolesCache.set(key, { roles: merged, loadedAt: Date.now() });
|
|
141
|
+
return merged;
|
|
142
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type RolePolicy = {
|
|
2
|
+
roleName: string;
|
|
3
|
+
systemPrompt: string;
|
|
4
|
+
spawnMode: "run" | "session";
|
|
5
|
+
allow?: string[];
|
|
6
|
+
deny?: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Runtime map: childSessionKey -> RolePolicy */
|
|
10
|
+
export const rolePolicyMap = new Map<string, RolePolicy>();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register hooks on the plugin API for role enforcement.
|
|
14
|
+
*/
|
|
15
|
+
export function registerRolePolicyHooks(api: any): void {
|
|
16
|
+
// Inject role system prompt into child agent
|
|
17
|
+
api.on("before_prompt_build", (event: any, ctx: any) => {
|
|
18
|
+
const policy = rolePolicyMap.get(ctx.sessionKey);
|
|
19
|
+
if (!policy) return;
|
|
20
|
+
return { prependContext: policy.systemPrompt };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Block tools not allowed by the role
|
|
24
|
+
api.on("before_tool_call", (event: any, ctx: any) => {
|
|
25
|
+
const policy = rolePolicyMap.get(ctx.sessionKey);
|
|
26
|
+
if (!policy) return;
|
|
27
|
+
|
|
28
|
+
const toolName = String(event.toolName).toLowerCase();
|
|
29
|
+
|
|
30
|
+
if (policy.allow && policy.allow.length > 0 && !policy.allow.includes(toolName)) {
|
|
31
|
+
return {
|
|
32
|
+
block: true,
|
|
33
|
+
blockReason: `Agent preset "${policy.roleName}" does not allow tool "${event.toolName}"`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (policy.deny && policy.deny.includes(toolName)) {
|
|
38
|
+
return {
|
|
39
|
+
block: true,
|
|
40
|
+
blockReason: `Agent preset "${policy.roleName}" denies tool "${event.toolName}"`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Clean up policy when subagent ends — only for mode=run.
|
|
46
|
+
// mode=session persists across runs, cleaned up on session_end instead.
|
|
47
|
+
api.on("subagent_ended", (event: any) => {
|
|
48
|
+
const key = event.targetSessionKey;
|
|
49
|
+
const policy = rolePolicyMap.get(key);
|
|
50
|
+
if (policy && policy.spawnMode === "run") {
|
|
51
|
+
rolePolicyMap.delete(key);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Clean up persistent (mode=session) policies on session end
|
|
56
|
+
api.on("session_end", (event: any) => {
|
|
57
|
+
const key = event.sessionKey;
|
|
58
|
+
if (key) rolePolicyMap.delete(key);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { loadRoles } from "./role-loader.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the agent_presets_list tool factory.
|
|
5
|
+
* Uses ctx.workspaceDir for correct per-session role discovery.
|
|
6
|
+
*/
|
|
7
|
+
export function createAgentPresetsListToolFactory(opts: {
|
|
8
|
+
pluginDir: string;
|
|
9
|
+
resolveStateDir: () => string | undefined;
|
|
10
|
+
}) {
|
|
11
|
+
return async (ctx: any) => {
|
|
12
|
+
const roles = await loadRoles({
|
|
13
|
+
workspaceDir: ctx.workspaceDir,
|
|
14
|
+
stateDir: opts.resolveStateDir(),
|
|
15
|
+
pluginDir: opts.pluginDir,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
name: "agent_presets_list",
|
|
20
|
+
label: "List Agent Presets",
|
|
21
|
+
description: "List all available subagent presets with their descriptions and configuration.",
|
|
22
|
+
parameters: {
|
|
23
|
+
type: "object",
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
properties: {},
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async execute() {
|
|
29
|
+
const entries = [...roles.entries()].map(([name, role]) => ({
|
|
30
|
+
name,
|
|
31
|
+
description: role.description,
|
|
32
|
+
model: role.model,
|
|
33
|
+
thinking: role.thinking,
|
|
34
|
+
mode: role.mode,
|
|
35
|
+
timeoutSeconds: role.timeoutSeconds,
|
|
36
|
+
tools: role.tools,
|
|
37
|
+
source: role.source,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: "text",
|
|
44
|
+
text: JSON.stringify({ agents: entries }, null, 2),
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { loadRoles } from "./role-loader.js";
|
|
2
|
+
import { rolePolicyMap } from "./role-policy.js";
|
|
3
|
+
import { loadSpawnSubagentDirect } from "./spawn-subagent.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the spawn_agent tool factory.
|
|
7
|
+
* Returns a factory function that receives context per-session and produces the tool.
|
|
8
|
+
* Roles are loaded per-context using ctx.workspaceDir for correct workspace discovery.
|
|
9
|
+
*/
|
|
10
|
+
export function createSpawnAgentToolFactory(opts: {
|
|
11
|
+
pluginDir: string;
|
|
12
|
+
resolveStateDir: () => string | undefined;
|
|
13
|
+
}) {
|
|
14
|
+
return async (ctx: any) => {
|
|
15
|
+
const roles = await loadRoles({
|
|
16
|
+
workspaceDir: ctx.workspaceDir,
|
|
17
|
+
stateDir: opts.resolveStateDir(),
|
|
18
|
+
pluginDir: opts.pluginDir,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (roles.size === 0) return null;
|
|
22
|
+
|
|
23
|
+
const roleNames = [...roles.keys()];
|
|
24
|
+
const roleDescriptions = roleNames
|
|
25
|
+
.map((name) => {
|
|
26
|
+
const r = roles.get(name)!;
|
|
27
|
+
return `- **${name}**: ${r.description}`;
|
|
28
|
+
})
|
|
29
|
+
.join("\n");
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: "spawn_agent",
|
|
33
|
+
label: "Spawn Agent",
|
|
34
|
+
description:
|
|
35
|
+
`Spawn a subagent with a predefined preset. Available agents:\n${roleDescriptions}`,
|
|
36
|
+
parameters: {
|
|
37
|
+
type: "object",
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
properties: {
|
|
40
|
+
agent: {
|
|
41
|
+
type: "string",
|
|
42
|
+
enum: roleNames,
|
|
43
|
+
description: "The agent preset to spawn",
|
|
44
|
+
},
|
|
45
|
+
task: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "The task instruction for the subagent",
|
|
48
|
+
},
|
|
49
|
+
label: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description: "Optional human-readable label for the spawned session",
|
|
52
|
+
},
|
|
53
|
+
thread: {
|
|
54
|
+
type: "boolean",
|
|
55
|
+
description: "Whether to bind the subagent to a thread",
|
|
56
|
+
},
|
|
57
|
+
mode: {
|
|
58
|
+
type: "string",
|
|
59
|
+
enum: ["run", "session"],
|
|
60
|
+
description: "Override the preset's default mode",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ["agent", "task"],
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
67
|
+
const agentName = String(params.agent ?? "").trim();
|
|
68
|
+
const task = String(params.task ?? "").trim();
|
|
69
|
+
|
|
70
|
+
if (!agentName || !roles.has(agentName)) {
|
|
71
|
+
const available = roleNames.join(", ");
|
|
72
|
+
return errorResult(`Unknown agent "${agentName}". Available: ${available}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!task) {
|
|
76
|
+
return errorResult("task is required");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const role = roles.get(agentName)!;
|
|
80
|
+
const spawnMode = (params.mode as string) ?? role.mode ?? "run";
|
|
81
|
+
|
|
82
|
+
const spawnSubagentDirect = await loadSpawnSubagentDirect();
|
|
83
|
+
|
|
84
|
+
const spawnParams: Record<string, unknown> = {
|
|
85
|
+
task,
|
|
86
|
+
label: params.label ?? `${agentName}: ${task.slice(0, 60)}`,
|
|
87
|
+
expectsCompletionMessage: true,
|
|
88
|
+
mode: spawnMode,
|
|
89
|
+
cleanup: role.cleanup ?? "delete",
|
|
90
|
+
sandbox: role.sandbox ?? "inherit",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (role.model) spawnParams.model = role.model;
|
|
94
|
+
if (role.thinking) spawnParams.thinking = role.thinking;
|
|
95
|
+
if (role.timeoutSeconds) spawnParams.runTimeoutSeconds = role.timeoutSeconds;
|
|
96
|
+
if (params.thread != null) spawnParams.thread = params.thread;
|
|
97
|
+
|
|
98
|
+
const spawnCtx: Record<string, unknown> = {
|
|
99
|
+
agentSessionKey: ctx.sessionKey,
|
|
100
|
+
agentChannel: ctx.messageChannel,
|
|
101
|
+
agentAccountId: ctx.agentAccountId,
|
|
102
|
+
sandboxed: ctx.sandboxed,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const result = await spawnSubagentDirect(spawnParams, spawnCtx);
|
|
106
|
+
|
|
107
|
+
if (result.status !== "accepted") {
|
|
108
|
+
return errorResult(result.error ?? `Spawn failed: ${result.status}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Register policy for the child session.
|
|
112
|
+
// Safe timing: spawnSubagentDirect enqueues the child asynchronously
|
|
113
|
+
// on the gateway work queue. The child won't start its first LLM turn
|
|
114
|
+
// (and therefore won't hit before_prompt_build / before_tool_call)
|
|
115
|
+
// until the gateway dequeues it, which happens after we return.
|
|
116
|
+
if (result.childSessionKey) {
|
|
117
|
+
rolePolicyMap.set(result.childSessionKey, {
|
|
118
|
+
roleName: agentName,
|
|
119
|
+
systemPrompt: role.systemPrompt,
|
|
120
|
+
spawnMode: spawnMode as "run" | "session",
|
|
121
|
+
allow: role.tools?.allow,
|
|
122
|
+
deny: role.tools?.deny,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: "text",
|
|
130
|
+
text: JSON.stringify(
|
|
131
|
+
{
|
|
132
|
+
status: result.status,
|
|
133
|
+
childSessionKey: result.childSessionKey,
|
|
134
|
+
runId: result.runId,
|
|
135
|
+
agent: agentName,
|
|
136
|
+
resolvedFrom: role.source,
|
|
137
|
+
},
|
|
138
|
+
null,
|
|
139
|
+
2,
|
|
140
|
+
),
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function errorResult(message: string) {
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text", text: JSON.stringify({ error: message }, null, 2) }],
|
|
152
|
+
isError: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic import of spawnSubagentDirect from the openclaw plugin-sdk.
|
|
3
|
+
* Requires openclaw with the plugin-sdk/subagents export.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
type SpawnFn = (params: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<{
|
|
7
|
+
status: string;
|
|
8
|
+
childSessionKey?: string;
|
|
9
|
+
runId?: string;
|
|
10
|
+
mode?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
let cached: SpawnFn | null = null;
|
|
15
|
+
|
|
16
|
+
export async function loadSpawnSubagentDirect(): Promise<SpawnFn> {
|
|
17
|
+
if (cached) return cached;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const mod = await import("openclaw/plugin-sdk/subagents");
|
|
21
|
+
if (typeof mod.spawnSubagentDirect === "function") {
|
|
22
|
+
cached = mod.spawnSubagentDirect as SpawnFn;
|
|
23
|
+
return cached;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// Fall through to error
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error(
|
|
30
|
+
"spawn-agent: could not import spawnSubagentDirect from openclaw/plugin-sdk/subagents. " +
|
|
31
|
+
"Ensure openclaw is up to date and this plugin runs inside the openclaw gateway process.",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: coder
|
|
3
|
+
description: Autonomous coding agent with full tool access for implementation tasks
|
|
4
|
+
model: anthropic/claude-sonnet-4-20250514
|
|
5
|
+
thinking: high
|
|
6
|
+
timeoutSeconds: 300
|
|
7
|
+
mode: run
|
|
8
|
+
cleanup: delete
|
|
9
|
+
tools:
|
|
10
|
+
allow: [read, write, edit, exec, glob, grep, apply_patch, diffs]
|
|
11
|
+
deny: []
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
You are a coding agent. Write clean, correct, production-quality code.
|
|
15
|
+
|
|
16
|
+
Guidelines:
|
|
17
|
+
- Read existing code before modifying it
|
|
18
|
+
- Follow the project's existing patterns and conventions
|
|
19
|
+
- Write minimal, focused changes — avoid unnecessary refactoring
|
|
20
|
+
- Include brief comments only for non-obvious logic
|
|
21
|
+
- Test your changes when a test framework is available
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reviewer
|
|
3
|
+
description: Read-only code reviewer focusing on bugs, security, and maintainability
|
|
4
|
+
model: anthropic/claude-sonnet-4-20250514
|
|
5
|
+
thinking: high
|
|
6
|
+
timeoutSeconds: 120
|
|
7
|
+
mode: run
|
|
8
|
+
cleanup: delete
|
|
9
|
+
tools:
|
|
10
|
+
allow: [read, diffs, grep, glob]
|
|
11
|
+
deny: [exec, write, edit, apply_patch]
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
You are a code reviewer. Be concise. Focus on:
|
|
15
|
+
- Bugs and logic errors
|
|
16
|
+
- Security issues (injection, auth bypass, data leaks)
|
|
17
|
+
- Maintainability concerns
|
|
18
|
+
|
|
19
|
+
Include specific fix suggestions with code examples. Do not make changes yourself.
|