Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/spec/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## 3.3.1

### Minor Changes

- AI Agent/Skill/Tool metadata protocol refactoring (aligned with Salesforce Agentforce, Microsoft Copilot Studio, ServiceNow Now Assist)
- **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.
- **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.
- **Agent protocol updated**: Added `skills: string[]` for Agent→Skill→Tool architecture; existing `tools` retained as backward-compatible fallback. Added `permissions: string[]` for access control.
- **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.)
- **Exports**: `defineTool`, `defineSkill`, `Tool`, `Skill` exported from `@objectstack/spec` root and `@objectstack/spec/ai` subpath.

## 3.3.0

## 3.2.9
Expand Down
71 changes: 71 additions & 0 deletions packages/spec/src/ai/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,77 @@ describe('AgentSchema', () => {

expect(() => AgentSchema.parse(agent)).not.toThrow();
});

it('should accept agent with skills (Agent→Skill→Tool architecture)', () => {
const agent: Agent = {
name: 'skill_agent',
label: 'Skill-based Agent',
role: 'Support Specialist',
instructions: 'Use skills to help customers.',
skills: ['case_management', 'knowledge_search', 'order_management'],
};

const result = AgentSchema.parse(agent);
expect(result.skills).toHaveLength(3);
expect(result.skills).toContain('case_management');
});

it('should accept agent with both skills and tools fallback', () => {
const agent: Agent = {
name: 'hybrid_agent',
label: 'Hybrid Agent',
role: 'Versatile Assistant',
instructions: 'Use skills primarily, tools as fallback.',
skills: ['case_management'],
tools: [
{ type: 'action', name: 'send_email' },
],
};

const result = AgentSchema.parse(agent);
expect(result.skills).toHaveLength(1);
expect(result.tools).toHaveLength(1);
});

it('should accept agent with permissions', () => {
const agent: Agent = {
name: 'restricted_agent',
label: 'Restricted Agent',
role: 'Limited Assistant',
instructions: 'Operate with limited permissions.',
skills: ['read_only_search'],
permissions: ['agent.basic', 'data.read'],
};

const result = AgentSchema.parse(agent);
expect(result.permissions).toEqual(['agent.basic', 'data.read']);
});

it('should enforce snake_case for skill name references', () => {
expect(() => AgentSchema.parse({
name: 'test_agent',
label: 'Test',
role: 'Test',
instructions: 'Test',
skills: ['valid_skill', 'another_skill'],
})).not.toThrow();

expect(() => AgentSchema.parse({
name: 'test_agent',
label: 'Test',
role: 'Test',
instructions: 'Test',
skills: ['InvalidSkill'],
})).toThrow();

expect(() => AgentSchema.parse({
name: 'test_agent',
label: 'Test',
role: 'Test',
instructions: 'Test',
skills: ['valid_skill', 'Invalid-Skill'],
})).toThrow();
});
});

describe('Access Control', () => {
Expand Down
81 changes: 56 additions & 25 deletions packages/spec/src/ai/agent.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,48 +90,68 @@ export type StructuredOutputConfig = z.infer<typeof StructuredOutputConfigSchema
/**
* AI Agent Schema
* Definition of an autonomous agent specialized for a domain.
*
* @example Customer Support Agent
* {
* name: "support_tier_1",
* label: "First Line Support",
* role: "Help Desk Assistant",
* instructions: "You are a helpful assistant. Always verify user identity first.",
* model: {
* provider: "openai",
* model: "gpt-4-turbo",
* temperature: 0.3
* },
*
* The Agent → Skill → Tool three-tier architecture aligns with
* Salesforce Agentforce, Microsoft Copilot Studio, and ServiceNow
* Now Assist metadata patterns.
*
* - **skills**: Primary capability model — references skill names.
* - **tools**: Fallback / direct tool references (legacy inline format).
*
* @example Agent-Skill Architecture
* ```ts
* defineAgent({
* name: 'support_tier_1',
* label: 'First Line Support',
* role: 'Help Desk Assistant',
* instructions: 'You are a helpful assistant. Always verify user identity first.',
* skills: ['case_management', 'knowledge_search'],
* knowledge: { topics: ['faq', 'policies'], indexes: ['support_docs'] },
* });
* ```
*
* @example Legacy Tool References (backward-compatible)
* ```ts
* defineAgent({
* name: 'support_tier_1',
* label: 'First Line Support',
* role: 'Help Desk Assistant',
* instructions: 'You are a helpful assistant.',
* tools: [
* { type: "flow", name: "reset_password", description: "Trigger password reset email" },
* { type: "query", name: "get_order_status", description: "Check order shipping status" }
* { type: 'flow', name: 'reset_password', description: 'Trigger password reset email' },
* { type: 'query', name: 'get_order_status', description: 'Check order shipping status' },
* ],
* knowledge: {
* topics: ["faq", "policies"],
* indexes: ["support_docs"]
* }
* }
* });
* ```
*/
export const AgentSchema = z.object({
/** Identity */
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Agent unique identifier'),
label: z.string().describe('Agent display name'),
avatar: z.string().optional(),
role: z.string().describe('The persona/role (e.g. "Senior Support Engineer")'),

/** Cognition */
instructions: z.string().describe('System Prompt / Prime Directives'),
model: AIModelConfigSchema.optional(),
lifecycle: StateMachineSchema.optional().describe('State machine defining the agent conversation follow and constraints'),

/** Capabilities */
tools: z.array(AIToolSchema).optional().describe('Available tools'),

/** Capabilities — Skill-based (primary) */
skills: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).optional().describe('Skill names to attach (Agent→Skill→Tool architecture)'),

/** Capabilities — Direct tool references (fallback / legacy) */
tools: z.array(AIToolSchema).optional().describe('Direct tool references (legacy fallback)'),

/** Knowledge */
knowledge: AIKnowledgeSchema.optional().describe('RAG access'),

/** Interface */
active: z.boolean().default(true),
access: z.array(z.string()).optional().describe('Who can chat with this agent'),

/** Permission profiles/roles required to use this agent */
permissions: z.array(z.string()).optional().describe('Required permissions or roles'),

/** Multi-tenancy & Visibility */
tenantId: z.string().optional().describe('Tenant/Organization ID'),
visibility: z.enum(['global', 'organization', 'private']).default('organization'),
Expand Down Expand Up @@ -196,7 +216,18 @@ export const AgentSchema = z.object({
*
* Validates the config at creation time using Zod `.parse()`.
*
* @example
* @example Agent-Skill Architecture (recommended)
* ```ts
* const supportAgent = defineAgent({
* name: 'support_agent',
* label: 'Support Agent',
* role: 'Senior Support Engineer',
* instructions: 'You help customers resolve technical issues.',
* skills: ['case_management', 'knowledge_search'],
* });
* ```
*
* @example Legacy Tool References (backward-compatible)
* ```ts
* const supportAgent = defineAgent({
* name: 'support_agent',
Expand Down
6 changes: 5 additions & 1 deletion packages/spec/src/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
* AI Protocol Exports
*
* AI/ML Capabilities
* - Agent Configuration
* - Agent Configuration (Agent → Skill → Tool architecture)
* - Tool Metadata (first-class AI tool definitions)
* - Skill Metadata (ability groups / capability bundles)
* - DevOps Agent (Self-iterating Development)
* - Model Registry & Selection
* - Model Context Protocol (MCP)
Expand All @@ -19,6 +21,8 @@
*/

export * from './agent.zod';
export * from './tool.zod';
export * from './skill.zod';
export * from './agent-action.zod';
export * from './devops-agent.zod';
export * from './plugin-development.zod';
Expand Down
172 changes: 172 additions & 0 deletions packages/spec/src/ai/skill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, it, expect } from 'vitest';
import {
SkillSchema,
SkillTriggerConditionSchema,
defineSkill,
type Skill,
} from './skill.zod';

describe('SkillTriggerConditionSchema', () => {
it('should accept all operators', () => {
const operators = ['eq', 'neq', 'in', 'not_in', 'contains'] as const;

operators.forEach(operator => {
expect(() => SkillTriggerConditionSchema.parse({
field: 'objectName',
operator,
value: 'support_case',
})).not.toThrow();
});
});

it('should accept array value for in/not_in', () => {
const result = SkillTriggerConditionSchema.parse({
field: 'userRole',
operator: 'in',
value: ['admin', 'support_agent'],
});
expect(result.value).toEqual(['admin', 'support_agent']);
});

it('should accept string value', () => {
const result = SkillTriggerConditionSchema.parse({
field: 'channel',
operator: 'eq',
value: 'web',
});
expect(result.value).toBe('web');
});
});

describe('SkillSchema', () => {
it('should accept minimal skill', () => {
const skill: Skill = {
name: 'case_management',
label: 'Case Management',
tools: ['create_case', 'update_case', 'resolve_case'],
};

const result = SkillSchema.parse(skill);
expect(result.name).toBe('case_management');
expect(result.active).toBe(true);
expect(result.tools).toHaveLength(3);
});

it('should accept full skill', () => {
const skill = {
name: 'order_management',
label: 'Order Management',
description: 'Handles order lifecycle operations',
instructions: 'Use these tools to manage customer orders. Always verify order ownership first.',
tools: ['create_order', 'update_order', 'cancel_order', 'query_orders'],
triggerPhrases: ['place an order', 'cancel my order', 'check order status'],
triggerConditions: [
{ field: 'objectName', operator: 'eq' as const, value: 'order' },
{ field: 'userRole', operator: 'in' as const, value: ['sales', 'support'] },
],
permissions: ['order.manage', 'order.view'],
active: true,
};

const result = SkillSchema.parse(skill);
expect(result.name).toBe('order_management');
expect(result.tools).toHaveLength(4);
expect(result.triggerPhrases).toHaveLength(3);
expect(result.triggerConditions).toHaveLength(2);
expect(result.permissions).toEqual(['order.manage', 'order.view']);
});

it('should enforce snake_case for skill name', () => {
const validNames = ['case_management', 'order_ops', '_internal', 'knowledge_search'];
validNames.forEach(name => {
expect(() => SkillSchema.parse({
name,
label: 'Test',
tools: [],
})).not.toThrow();
});

const invalidNames = ['caseManagement', 'Order-Ops', '123skill'];
invalidNames.forEach(name => {
expect(() => SkillSchema.parse({
name,
label: 'Test',
tools: [],
})).toThrow();
});
});

it('should accept empty tools array', () => {
const result = SkillSchema.parse({
name: 'empty_skill',
label: 'Empty Skill',
tools: [],
});
expect(result.tools).toHaveLength(0);
});

it('should accept skill with instructions', () => {
const result = SkillSchema.parse({
name: 'knowledge_search',
label: 'Knowledge Search',
instructions: 'Search the knowledge base before escalating to a human agent.',
tools: ['search_knowledge', 'get_article'],
});
expect(result.instructions).toContain('knowledge base');
});

it('should enforce snake_case for tool name references', () => {
expect(() => SkillSchema.parse({
name: 'valid_skill',
label: 'Test',
tools: ['valid_tool', 'another_tool'],
})).not.toThrow();

expect(() => SkillSchema.parse({
name: 'valid_skill',
label: 'Test',
tools: ['InvalidTool'],
})).toThrow();

expect(() => SkillSchema.parse({
name: 'valid_skill',
label: 'Test',
tools: ['valid_tool', 'Invalid-Tool'],
})).toThrow();
});
});

describe('defineSkill', () => {
it('should return a parsed skill', () => {
const skill = defineSkill({
name: 'case_management',
label: 'Case Management',
description: 'Handles support case lifecycle',
instructions: 'Use these tools to create, update, and resolve support cases.',
tools: ['create_case', 'update_case', 'resolve_case'],
triggerPhrases: ['create a case', 'open a ticket'],
});

expect(skill.name).toBe('case_management');
expect(skill.tools).toHaveLength(3);
expect(skill.active).toBe(true);
});

it('should apply defaults', () => {
const skill = defineSkill({
name: 'simple_skill',
label: 'Simple',
tools: ['tool_a'],
});

expect(skill.active).toBe(true);
});

it('should throw on invalid skill name', () => {
expect(() => defineSkill({
name: 'InvalidName',
label: 'Test',
tools: [],
})).toThrow();
});
});
Loading