Skip to content

Commit a4e188c

Browse files
authored
Merge pull request #1019 from objectstack-ai/copilot/refactor-agent-skill-tool-protocol
2 parents dc1a20c + 3163230 commit a4e188c

File tree

11 files changed

+761
-27
lines changed

11 files changed

+761
-27
lines changed

packages/spec/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
## 3.3.1
44

5+
### Minor Changes
6+
7+
- AI Agent/Skill/Tool metadata protocol refactoring (aligned with Salesforce Agentforce, Microsoft Copilot Studio, ServiceNow Now Assist)
8+
- **Tool as first-class metadata** (`src/ai/tool.zod.ts`): `ToolSchema`, `ToolCategorySchema`, `defineTool()` factory. Fields: name, label, description, category, parameters (JSON Schema), outputSchema, objectName, requiresConfirmation, permissions, active, builtIn.
9+
- **Skill as ability group** (`src/ai/skill.zod.ts`): `SkillSchema`, `SkillTriggerConditionSchema`, `defineSkill()` factory. Fields: name, label, description, instructions, tools (tool name references), triggerPhrases, triggerConditions, permissions, active.
10+
- **Agent protocol updated**: Added `skills: string[]` for Agent→Skill→Tool architecture; existing `tools` retained as backward-compatible fallback. Added `permissions: string[]` for access control.
11+
- **Metadata registry**: `tool` and `skill` registered as first-class metadata types in `MetadataTypeSchema` and `DEFAULT_METADATA_TYPE_REGISTRY` (domain: `ai`, filePatterns: `**/*.tool.ts`, `**/*.skill.ts`, etc.)
12+
- **Exports**: `defineTool`, `defineSkill`, `Tool`, `Skill` exported from `@objectstack/spec` root and `@objectstack/spec/ai` subpath.
13+
514
## 3.3.0
615

716
## 3.2.9

packages/spec/src/ai/agent.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,77 @@ describe('AgentSchema', () => {
240240

241241
expect(() => AgentSchema.parse(agent)).not.toThrow();
242242
});
243+
244+
it('should accept agent with skills (Agent→Skill→Tool architecture)', () => {
245+
const agent: Agent = {
246+
name: 'skill_agent',
247+
label: 'Skill-based Agent',
248+
role: 'Support Specialist',
249+
instructions: 'Use skills to help customers.',
250+
skills: ['case_management', 'knowledge_search', 'order_management'],
251+
};
252+
253+
const result = AgentSchema.parse(agent);
254+
expect(result.skills).toHaveLength(3);
255+
expect(result.skills).toContain('case_management');
256+
});
257+
258+
it('should accept agent with both skills and tools fallback', () => {
259+
const agent: Agent = {
260+
name: 'hybrid_agent',
261+
label: 'Hybrid Agent',
262+
role: 'Versatile Assistant',
263+
instructions: 'Use skills primarily, tools as fallback.',
264+
skills: ['case_management'],
265+
tools: [
266+
{ type: 'action', name: 'send_email' },
267+
],
268+
};
269+
270+
const result = AgentSchema.parse(agent);
271+
expect(result.skills).toHaveLength(1);
272+
expect(result.tools).toHaveLength(1);
273+
});
274+
275+
it('should accept agent with permissions', () => {
276+
const agent: Agent = {
277+
name: 'restricted_agent',
278+
label: 'Restricted Agent',
279+
role: 'Limited Assistant',
280+
instructions: 'Operate with limited permissions.',
281+
skills: ['read_only_search'],
282+
permissions: ['agent.basic', 'data.read'],
283+
};
284+
285+
const result = AgentSchema.parse(agent);
286+
expect(result.permissions).toEqual(['agent.basic', 'data.read']);
287+
});
288+
289+
it('should enforce snake_case for skill name references', () => {
290+
expect(() => AgentSchema.parse({
291+
name: 'test_agent',
292+
label: 'Test',
293+
role: 'Test',
294+
instructions: 'Test',
295+
skills: ['valid_skill', 'another_skill'],
296+
})).not.toThrow();
297+
298+
expect(() => AgentSchema.parse({
299+
name: 'test_agent',
300+
label: 'Test',
301+
role: 'Test',
302+
instructions: 'Test',
303+
skills: ['InvalidSkill'],
304+
})).toThrow();
305+
306+
expect(() => AgentSchema.parse({
307+
name: 'test_agent',
308+
label: 'Test',
309+
role: 'Test',
310+
instructions: 'Test',
311+
skills: ['valid_skill', 'Invalid-Skill'],
312+
})).toThrow();
313+
});
243314
});
244315

245316
describe('Access Control', () => {

packages/spec/src/ai/agent.zod.ts

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -90,48 +90,68 @@ export type StructuredOutputConfig = z.infer<typeof StructuredOutputConfigSchema
9090
/**
9191
* AI Agent Schema
9292
* Definition of an autonomous agent specialized for a domain.
93-
*
94-
* @example Customer Support Agent
95-
* {
96-
* name: "support_tier_1",
97-
* label: "First Line Support",
98-
* role: "Help Desk Assistant",
99-
* instructions: "You are a helpful assistant. Always verify user identity first.",
100-
* model: {
101-
* provider: "openai",
102-
* model: "gpt-4-turbo",
103-
* temperature: 0.3
104-
* },
93+
*
94+
* The Agent → Skill → Tool three-tier architecture aligns with
95+
* Salesforce Agentforce, Microsoft Copilot Studio, and ServiceNow
96+
* Now Assist metadata patterns.
97+
*
98+
* - **skills**: Primary capability model — references skill names.
99+
* - **tools**: Fallback / direct tool references (legacy inline format).
100+
*
101+
* @example Agent-Skill Architecture
102+
* ```ts
103+
* defineAgent({
104+
* name: 'support_tier_1',
105+
* label: 'First Line Support',
106+
* role: 'Help Desk Assistant',
107+
* instructions: 'You are a helpful assistant. Always verify user identity first.',
108+
* skills: ['case_management', 'knowledge_search'],
109+
* knowledge: { topics: ['faq', 'policies'], indexes: ['support_docs'] },
110+
* });
111+
* ```
112+
*
113+
* @example Legacy Tool References (backward-compatible)
114+
* ```ts
115+
* defineAgent({
116+
* name: 'support_tier_1',
117+
* label: 'First Line Support',
118+
* role: 'Help Desk Assistant',
119+
* instructions: 'You are a helpful assistant.',
105120
* tools: [
106-
* { type: "flow", name: "reset_password", description: "Trigger password reset email" },
107-
* { type: "query", name: "get_order_status", description: "Check order shipping status" }
121+
* { type: 'flow', name: 'reset_password', description: 'Trigger password reset email' },
122+
* { type: 'query', name: 'get_order_status', description: 'Check order shipping status' },
108123
* ],
109-
* knowledge: {
110-
* topics: ["faq", "policies"],
111-
* indexes: ["support_docs"]
112-
* }
113-
* }
124+
* });
125+
* ```
114126
*/
115127
export const AgentSchema = z.object({
116128
/** Identity */
117129
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Agent unique identifier'),
118130
label: z.string().describe('Agent display name'),
119131
avatar: z.string().optional(),
120132
role: z.string().describe('The persona/role (e.g. "Senior Support Engineer")'),
121-
133+
122134
/** Cognition */
123135
instructions: z.string().describe('System Prompt / Prime Directives'),
124136
model: AIModelConfigSchema.optional(),
125137
lifecycle: StateMachineSchema.optional().describe('State machine defining the agent conversation follow and constraints'),
126-
127-
/** Capabilities */
128-
tools: z.array(AIToolSchema).optional().describe('Available tools'),
138+
139+
/** Capabilities — Skill-based (primary) */
140+
skills: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).optional().describe('Skill names to attach (Agent→Skill→Tool architecture)'),
141+
142+
/** Capabilities — Direct tool references (fallback / legacy) */
143+
tools: z.array(AIToolSchema).optional().describe('Direct tool references (legacy fallback)'),
144+
145+
/** Knowledge */
129146
knowledge: AIKnowledgeSchema.optional().describe('RAG access'),
130-
147+
131148
/** Interface */
132149
active: z.boolean().default(true),
133150
access: z.array(z.string()).optional().describe('Who can chat with this agent'),
134151

152+
/** Permission profiles/roles required to use this agent */
153+
permissions: z.array(z.string()).optional().describe('Required permissions or roles'),
154+
135155
/** Multi-tenancy & Visibility */
136156
tenantId: z.string().optional().describe('Tenant/Organization ID'),
137157
visibility: z.enum(['global', 'organization', 'private']).default('organization'),
@@ -196,7 +216,18 @@ export const AgentSchema = z.object({
196216
*
197217
* Validates the config at creation time using Zod `.parse()`.
198218
*
199-
* @example
219+
* @example Agent-Skill Architecture (recommended)
220+
* ```ts
221+
* const supportAgent = defineAgent({
222+
* name: 'support_agent',
223+
* label: 'Support Agent',
224+
* role: 'Senior Support Engineer',
225+
* instructions: 'You help customers resolve technical issues.',
226+
* skills: ['case_management', 'knowledge_search'],
227+
* });
228+
* ```
229+
*
230+
* @example Legacy Tool References (backward-compatible)
200231
* ```ts
201232
* const supportAgent = defineAgent({
202233
* name: 'support_agent',

packages/spec/src/ai/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
* AI Protocol Exports
55
*
66
* AI/ML Capabilities
7-
* - Agent Configuration
7+
* - Agent Configuration (Agent → Skill → Tool architecture)
8+
* - Tool Metadata (first-class AI tool definitions)
9+
* - Skill Metadata (ability groups / capability bundles)
810
* - DevOps Agent (Self-iterating Development)
911
* - Model Registry & Selection
1012
* - Model Context Protocol (MCP)
@@ -19,6 +21,8 @@
1921
*/
2022

2123
export * from './agent.zod';
24+
export * from './tool.zod';
25+
export * from './skill.zod';
2226
export * from './agent-action.zod';
2327
export * from './devops-agent.zod';
2428
export * from './plugin-development.zod';

packages/spec/src/ai/skill.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
SkillSchema,
4+
SkillTriggerConditionSchema,
5+
defineSkill,
6+
type Skill,
7+
} from './skill.zod';
8+
9+
describe('SkillTriggerConditionSchema', () => {
10+
it('should accept all operators', () => {
11+
const operators = ['eq', 'neq', 'in', 'not_in', 'contains'] as const;
12+
13+
operators.forEach(operator => {
14+
expect(() => SkillTriggerConditionSchema.parse({
15+
field: 'objectName',
16+
operator,
17+
value: 'support_case',
18+
})).not.toThrow();
19+
});
20+
});
21+
22+
it('should accept array value for in/not_in', () => {
23+
const result = SkillTriggerConditionSchema.parse({
24+
field: 'userRole',
25+
operator: 'in',
26+
value: ['admin', 'support_agent'],
27+
});
28+
expect(result.value).toEqual(['admin', 'support_agent']);
29+
});
30+
31+
it('should accept string value', () => {
32+
const result = SkillTriggerConditionSchema.parse({
33+
field: 'channel',
34+
operator: 'eq',
35+
value: 'web',
36+
});
37+
expect(result.value).toBe('web');
38+
});
39+
});
40+
41+
describe('SkillSchema', () => {
42+
it('should accept minimal skill', () => {
43+
const skill: Skill = {
44+
name: 'case_management',
45+
label: 'Case Management',
46+
tools: ['create_case', 'update_case', 'resolve_case'],
47+
};
48+
49+
const result = SkillSchema.parse(skill);
50+
expect(result.name).toBe('case_management');
51+
expect(result.active).toBe(true);
52+
expect(result.tools).toHaveLength(3);
53+
});
54+
55+
it('should accept full skill', () => {
56+
const skill = {
57+
name: 'order_management',
58+
label: 'Order Management',
59+
description: 'Handles order lifecycle operations',
60+
instructions: 'Use these tools to manage customer orders. Always verify order ownership first.',
61+
tools: ['create_order', 'update_order', 'cancel_order', 'query_orders'],
62+
triggerPhrases: ['place an order', 'cancel my order', 'check order status'],
63+
triggerConditions: [
64+
{ field: 'objectName', operator: 'eq' as const, value: 'order' },
65+
{ field: 'userRole', operator: 'in' as const, value: ['sales', 'support'] },
66+
],
67+
permissions: ['order.manage', 'order.view'],
68+
active: true,
69+
};
70+
71+
const result = SkillSchema.parse(skill);
72+
expect(result.name).toBe('order_management');
73+
expect(result.tools).toHaveLength(4);
74+
expect(result.triggerPhrases).toHaveLength(3);
75+
expect(result.triggerConditions).toHaveLength(2);
76+
expect(result.permissions).toEqual(['order.manage', 'order.view']);
77+
});
78+
79+
it('should enforce snake_case for skill name', () => {
80+
const validNames = ['case_management', 'order_ops', '_internal', 'knowledge_search'];
81+
validNames.forEach(name => {
82+
expect(() => SkillSchema.parse({
83+
name,
84+
label: 'Test',
85+
tools: [],
86+
})).not.toThrow();
87+
});
88+
89+
const invalidNames = ['caseManagement', 'Order-Ops', '123skill'];
90+
invalidNames.forEach(name => {
91+
expect(() => SkillSchema.parse({
92+
name,
93+
label: 'Test',
94+
tools: [],
95+
})).toThrow();
96+
});
97+
});
98+
99+
it('should accept empty tools array', () => {
100+
const result = SkillSchema.parse({
101+
name: 'empty_skill',
102+
label: 'Empty Skill',
103+
tools: [],
104+
});
105+
expect(result.tools).toHaveLength(0);
106+
});
107+
108+
it('should accept skill with instructions', () => {
109+
const result = SkillSchema.parse({
110+
name: 'knowledge_search',
111+
label: 'Knowledge Search',
112+
instructions: 'Search the knowledge base before escalating to a human agent.',
113+
tools: ['search_knowledge', 'get_article'],
114+
});
115+
expect(result.instructions).toContain('knowledge base');
116+
});
117+
118+
it('should enforce snake_case for tool name references', () => {
119+
expect(() => SkillSchema.parse({
120+
name: 'valid_skill',
121+
label: 'Test',
122+
tools: ['valid_tool', 'another_tool'],
123+
})).not.toThrow();
124+
125+
expect(() => SkillSchema.parse({
126+
name: 'valid_skill',
127+
label: 'Test',
128+
tools: ['InvalidTool'],
129+
})).toThrow();
130+
131+
expect(() => SkillSchema.parse({
132+
name: 'valid_skill',
133+
label: 'Test',
134+
tools: ['valid_tool', 'Invalid-Tool'],
135+
})).toThrow();
136+
});
137+
});
138+
139+
describe('defineSkill', () => {
140+
it('should return a parsed skill', () => {
141+
const skill = defineSkill({
142+
name: 'case_management',
143+
label: 'Case Management',
144+
description: 'Handles support case lifecycle',
145+
instructions: 'Use these tools to create, update, and resolve support cases.',
146+
tools: ['create_case', 'update_case', 'resolve_case'],
147+
triggerPhrases: ['create a case', 'open a ticket'],
148+
});
149+
150+
expect(skill.name).toBe('case_management');
151+
expect(skill.tools).toHaveLength(3);
152+
expect(skill.active).toBe(true);
153+
});
154+
155+
it('should apply defaults', () => {
156+
const skill = defineSkill({
157+
name: 'simple_skill',
158+
label: 'Simple',
159+
tools: ['tool_a'],
160+
});
161+
162+
expect(skill.active).toBe(true);
163+
});
164+
165+
it('should throw on invalid skill name', () => {
166+
expect(() => defineSkill({
167+
name: 'InvalidName',
168+
label: 'Test',
169+
tools: [],
170+
})).toThrow();
171+
});
172+
});

0 commit comments

Comments
 (0)