Skip to content

Commit e8b2cae

Browse files
authored
feat: skills: define skills interface (#251)
1 parent f7a86e9 commit e8b2cae

File tree

5 files changed

+399
-0
lines changed

5 files changed

+399
-0
lines changed

core/src/common.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ export {zodObjectToSchema} from './utils/simple_zod_to_json.js';
225225
export {GoogleLLMVariant} from './utils/variant_utils.js';
226226
export {version} from './version.js';
227227

228+
export type {Frontmatter, Resources, Script, Skill} from './skills/skill.js';
229+
228230
export * from './artifacts/base_artifact_service.js';
229231
export * from './memory/base_memory_service.js';
230232
export * from './sessions/base_session_service.js';

core/src/skills/prompt.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {Frontmatter, Skill} from './skill.js';
8+
9+
function escapeHtml(unsafe: string): string {
10+
return unsafe
11+
.replace(/&/g, '&')
12+
.replace(/</g, '&lt;')
13+
.replace(/>/g, '&gt;')
14+
.replace(/"/g, '&quot;')
15+
.replace(/'/g, '&#039;');
16+
}
17+
18+
/**
19+
* Formats available skills into a standard XML string.
20+
*
21+
* @param skills A list of skill frontmatter or full skill objects.
22+
* @returns XML string with <available_skills> block.
23+
*/
24+
export function formatSkillsAsXml(skills: Array<Skill | Frontmatter>): string {
25+
if (!skills || skills.length === 0) {
26+
return '<available_skills>\n</available_skills>';
27+
}
28+
29+
const lines = ['<available_skills>'];
30+
31+
for (const item of skills) {
32+
const frontmatter =
33+
'frontmatter' in item ? (item.frontmatter as Frontmatter) : item;
34+
35+
lines.push(' <skill>');
36+
lines.push(` <name>${escapeHtml(frontmatter.name)}</name>`);
37+
lines.push(
38+
` <description>${escapeHtml(frontmatter.description)}</description>`,
39+
);
40+
lines.push(' </skill>');
41+
}
42+
43+
lines.push('</available_skills>');
44+
45+
return lines.join('\n');
46+
}

core/src/skills/skill.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {z} from 'zod';
8+
9+
export const SNAKE_OR_KEBAB_NAME_PATTERN =
10+
/^([a-z0-9]+(-[a-z0-9]+)*|[a-z0-9]+(_[a-z0-9]+)*)$/;
11+
12+
/**
13+
* Schema and Type for Skill Frontmatter metadata.
14+
*/
15+
export const FrontmatterSchema = z.preprocess(
16+
(data) => {
17+
if (typeof data === 'object' && data !== null) {
18+
const obj = data as Record<string, unknown>;
19+
if ('allowed-tools' in obj && !('allowedTools' in obj)) {
20+
return {
21+
...obj,
22+
allowedTools: obj['allowed-tools'],
23+
};
24+
}
25+
}
26+
return data;
27+
},
28+
z
29+
.object({
30+
name: z
31+
.string()
32+
.regex(SNAKE_OR_KEBAB_NAME_PATTERN, {
33+
message:
34+
'name must be lowercase kebab-case (a-z, 0-9, hyphens) or snake_case (a-z, 0-9, underscores), with no leading, trailing, or consecutive delimiters. Mixing hyphens and underscores is not allowed.',
35+
})
36+
.max(64),
37+
description: z.string().min(1).max(1024),
38+
license: z.string().optional(),
39+
compatibility: z.string().max(500).optional(),
40+
'allowed-tools': z.string().optional(),
41+
metadata: z
42+
.record(z.string(), z.any())
43+
.default({})
44+
.refine(
45+
(data) => {
46+
if ('adk_additional_tools' in data) {
47+
return (
48+
Array.isArray(data.adk_additional_tools) &&
49+
data.adk_additional_tools.every(
50+
(item) => typeof item === 'string',
51+
)
52+
);
53+
}
54+
return true;
55+
},
56+
{
57+
message: 'adk_additional_tools must be a list of strings',
58+
},
59+
),
60+
})
61+
.loose(),
62+
);
63+
64+
export interface Frontmatter {
65+
name: string;
66+
description: string;
67+
license?: string;
68+
compatibility?: string;
69+
allowedTools?: string;
70+
metadata?: Record<string, unknown>;
71+
}
72+
73+
/**
74+
* Wrapper for script content.
75+
*/
76+
export interface Script {
77+
src: string;
78+
}
79+
80+
/**
81+
* L3 skill content: additional instructions, assets, and scripts.
82+
*/
83+
export interface Resources {
84+
references?: Record<string, string | Buffer>;
85+
assets?: Record<string, string | Buffer>;
86+
scripts?: Record<string, Script>;
87+
}
88+
89+
/**
90+
* Complete skill representation including frontmatter, instructions, and resources.
91+
*/
92+
export interface Skill {
93+
frontmatter: Frontmatter;
94+
instructions: string;
95+
resources?: Resources;
96+
}

core/test/skills/prompt_test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {describe, expect, it} from 'vitest';
8+
import {formatSkillsAsXml} from '../../src/skills/prompt.js';
9+
import {Frontmatter, Skill} from '../../src/skills/skill.js';
10+
11+
describe('prompt', () => {
12+
describe('formatSkillsAsXml', () => {
13+
it('returns empty tags for empty skills list', () => {
14+
expect(formatSkillsAsXml([])).toBe(
15+
'<available_skills>\n</available_skills>',
16+
);
17+
});
18+
19+
it('formats a single skill from frontmatter', () => {
20+
const skills: Frontmatter[] = [
21+
{name: 'test-skill', description: 'A test skill'},
22+
];
23+
const expected = `<available_skills>
24+
<skill>
25+
<name>test-skill</name>
26+
<description>A test skill</description>
27+
</skill>
28+
</available_skills>`;
29+
expect(formatSkillsAsXml(skills)).toBe(expected);
30+
});
31+
32+
it('formats multiple skills', () => {
33+
const skills: Frontmatter[] = [
34+
{name: 'skill-1', description: 'Desc 1'},
35+
{name: 'skill-2', description: 'Desc 2'},
36+
];
37+
const expected = `<available_skills>
38+
<skill>
39+
<name>skill-1</name>
40+
<description>Desc 1</description>
41+
</skill>
42+
<skill>
43+
<name>skill-2</name>
44+
<description>Desc 2</description>
45+
</skill>
46+
</available_skills>`;
47+
expect(formatSkillsAsXml(skills)).toBe(expected);
48+
});
49+
50+
it('formats skills passed as Skill objects', () => {
51+
const skills = [
52+
{
53+
frontmatter: {name: 'skill-1', description: 'Desc 1'},
54+
instructions: 'Instructions 1',
55+
resources: {},
56+
} as Skill,
57+
];
58+
const expected = `<available_skills>
59+
<skill>
60+
<name>skill-1</name>
61+
<description>Desc 1</description>
62+
</skill>
63+
</available_skills>`;
64+
expect(formatSkillsAsXml(skills)).toBe(expected);
65+
});
66+
67+
it('escapes HTML entities in name and description', () => {
68+
const skills: Frontmatter[] = [
69+
{
70+
name: 'dangerous<name>',
71+
description: 'dangerous&description"with\'quotes',
72+
},
73+
];
74+
const expected = `<available_skills>
75+
<skill>
76+
<name>dangerous&lt;name&gt;</name>
77+
<description>dangerous&amp;description&quot;with&#039;quotes</description>
78+
</skill>
79+
</available_skills>`;
80+
expect(formatSkillsAsXml(skills)).toBe(expected);
81+
});
82+
});
83+
});

0 commit comments

Comments
 (0)