Skip to content

Commit 3602c93

Browse files
Dev (#8)
* Add direct call plugin * Update packages/direct-call/tsconfig.json Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update packages/direct-call/README.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Add cjs builds * Update versions * Add pure string to text plugin --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2 parents 5d40a8e + 7df67a3 commit 3602c93

File tree

4 files changed

+191
-56
lines changed

4 files changed

+191
-56
lines changed

packages/text/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@utcp/text",
3-
"version": "1.0.6",
3+
"version": "1.0.7",
44
"description": "Text utilities for UTCP",
55
"main": "dist/index.cjs",
66
"module": "dist/index.js",

packages/text/src/text_call_template.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,25 @@ import { Serializer } from '@utcp/sdk';
66

77
/**
88
* REQUIRED
9-
* Call template for text file-based manuals and tools.
9+
* Call template for text-based manuals and tools.
1010
*
11-
* Reads UTCP manuals or tool definitions from local JSON/YAML files. Useful for
12-
* static tool configurations or environments where manuals are distributed as files.
11+
* Supports both reading UTCP manuals or tool definitions from local JSON/YAML files
12+
* or directly from string content. Useful for static tool configurations or environments
13+
* where manuals are distributed as files or dynamically generated strings.
1314
*
1415
* Attributes:
15-
* call_template_type: Always "text" for text file call templates.
16-
* file_path: Path to the file containing the UTCP manual or tool definitions.
16+
* call_template_type: Always "text" for text call templates.
17+
* file_path: Path to the file containing the UTCP manual or tool definitions (optional if content is provided).
18+
* content: Direct string content of the UTCP manual or tool definitions (optional if file_path is provided).
1719
* auth: Always None - text call templates don't support authentication for file access.
1820
* auth_tools: Optional authentication to apply to generated tools from OpenAPI specs.
21+
*
22+
* Note: At least one of file_path or content must be provided. If both are provided, content takes precedence.
1923
*/
2024
export interface TextCallTemplate extends CallTemplate {
2125
call_template_type: 'text';
22-
file_path: string;
26+
file_path?: string;
27+
content?: string;
2328
auth?: undefined;
2429
auth_tools?: Auth | null;
2530
}
@@ -30,7 +35,8 @@ export interface TextCallTemplate extends CallTemplate {
3035
export const TextCallTemplateSchema: z.ZodType<TextCallTemplate> = z.object({
3136
name: z.string().optional(),
3237
call_template_type: z.literal('text'),
33-
file_path: z.string().describe('The path to the file containing the UTCP manual or tool definitions.'),
38+
file_path: z.string().optional().describe('The path to the file containing the UTCP manual or tool definitions.'),
39+
content: z.string().optional().describe('Direct string content of the UTCP manual or tool definitions.'),
3440
auth: z.undefined().optional(),
3541
auth_tools: AuthSchema.nullable().optional().transform((val) => {
3642
if (val === null || val === undefined) return null;
@@ -39,7 +45,10 @@ export const TextCallTemplateSchema: z.ZodType<TextCallTemplate> = z.object({
3945
}
4046
return val as Auth;
4147
}).describe('Authentication to apply to generated tools from OpenAPI specs.'),
42-
}).strict() as z.ZodType<TextCallTemplate>;
48+
}).strict().refine(
49+
(data) => data.file_path !== undefined || data.content !== undefined,
50+
{ message: 'Either file_path or content must be provided' }
51+
) as z.ZodType<TextCallTemplate>;
4352

4453
/**
4554
* REQUIRED
@@ -55,6 +64,7 @@ export class TextCallTemplateSerializer extends Serializer<TextCallTemplate> {
5564
name: obj.name,
5665
call_template_type: obj.call_template_type,
5766
file_path: obj.file_path,
67+
content: obj.content,
5868
auth: obj.auth,
5969
auth_tools: obj.auth_tools ? new AuthSerializer().toDict(obj.auth_tools) : null,
6070
};

packages/text/src/text_communication_protocol.ts

Lines changed: 72 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -30,42 +30,68 @@ export class TextCommunicationProtocol implements CommunicationProtocol {
3030
* Register a text manual and return its tools as a UtcpManual.
3131
*/
3232
public async registerManual(caller: IUtcpClient, manualCallTemplate: CallTemplate): Promise<RegisterManualResult> {
33-
if (!(manualCallTemplate as any).file_path) {
34-
throw new Error('TextCommunicationProtocol requires a TextCallTemplate');
35-
}
36-
3733
const textCallTemplate = TextCallTemplateSchema.parse(manualCallTemplate);
38-
let filePath = path.resolve(textCallTemplate.file_path);
39-
if (!path.isAbsolute(textCallTemplate.file_path) && caller.root_dir) {
40-
filePath = path.resolve(caller.root_dir, textCallTemplate.file_path);
41-
}
42-
43-
this._log_info(`Reading manual from '${filePath}'`);
4434

4535
try {
46-
// Check if file exists
47-
try {
48-
await fs.access(filePath);
49-
} catch (err: any) {
50-
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`);
36+
let content: string;
37+
let fileExt: string = '.json'; // Default extension
38+
let sourceInfo: string;
39+
40+
// Prefer content over file_path if both are provided
41+
if (textCallTemplate.content) {
42+
this._log_info('Using direct content for manual');
43+
content = textCallTemplate.content;
44+
sourceInfo = 'direct content';
45+
// Try to infer format from content structure
46+
} else if (textCallTemplate.file_path) {
47+
let filePath = path.resolve(textCallTemplate.file_path);
48+
if (!path.isAbsolute(textCallTemplate.file_path) && caller.root_dir) {
49+
filePath = path.resolve(caller.root_dir, textCallTemplate.file_path);
50+
}
51+
sourceInfo = filePath;
52+
this._log_info(`Reading manual from '${filePath}'`);
53+
54+
// Check if file exists
55+
try {
56+
await fs.access(filePath);
57+
} catch (err: any) {
58+
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`);
59+
}
60+
61+
content = await fs.readFile(filePath, 'utf-8');
62+
fileExt = path.extname(filePath).toLowerCase();
63+
} else {
64+
throw new Error('Either file_path or content must be provided');
5165
}
5266

53-
const fileContent = await fs.readFile(filePath, 'utf-8');
54-
const fileExt = path.extname(filePath).toLowerCase();
5567
let data: any;
5668

57-
// Parse based on extension
69+
// Parse based on extension or content format
5870
if (fileExt === '.yaml' || fileExt === '.yml') {
59-
data = yaml.load(fileContent);
71+
data = yaml.load(content);
6072
} else {
61-
data = JSON.parse(fileContent);
73+
// Try JSON first
74+
try {
75+
data = JSON.parse(content);
76+
} catch (jsonError) {
77+
// If JSON fails and we're using direct content, try YAML
78+
if (textCallTemplate.content) {
79+
try {
80+
data = yaml.load(content);
81+
} catch (yamlError) {
82+
throw jsonError; // Throw original JSON error if YAML also fails
83+
}
84+
} else {
85+
throw jsonError;
86+
}
87+
}
6288
}
6389

6490
let utcpManual: UtcpManual;
6591
if (data && typeof data === 'object' && (data.openapi || data.swagger || data.paths)) {
6692
this._log_info('Detected OpenAPI specification. Converting to UTCP manual.');
6793
const converter = new OpenApiConverter(data, {
68-
specUrl: `file://${filePath}`,
94+
specUrl: textCallTemplate.file_path ? `file://${sourceInfo}` : 'direct-content://',
6995
callTemplateName: textCallTemplate.name,
7096
authTools: textCallTemplate.auth_tools || undefined,
7197
});
@@ -75,15 +101,16 @@ export class TextCommunicationProtocol implements CommunicationProtocol {
75101
utcpManual = new UtcpManualSerializer().validateDict(data);
76102
}
77103

78-
this._log_info(`Loaded ${utcpManual.tools.length} tools from '${filePath}'`);
104+
this._log_info(`Loaded ${utcpManual.tools.length} tools from ${sourceInfo}`);
79105
return {
80106
manualCallTemplate,
81107
manual: utcpManual,
82108
success: true,
83109
errors: [],
84110
};
85111
} catch (error: any) {
86-
this._log_error(`Failed to parse manual '${filePath}': ${error.stack || error.message}`);
112+
const source = textCallTemplate.content ? 'direct content' : textCallTemplate.file_path || 'unknown';
113+
this._log_error(`Failed to parse manual from '${source}': ${error.stack || error.message}`);
87114
return {
88115
manualCallTemplate,
89116
manual: new UtcpManualSerializer().validateDict({ tools: [] }),
@@ -103,29 +130,34 @@ export class TextCommunicationProtocol implements CommunicationProtocol {
103130

104131
/**
105132
* REQUIRED
106-
* Call a tool: for text templates, return file content from the configured path.
133+
* Call a tool: for text templates, return content from either the direct content or file path.
107134
*/
108135
public async callTool(caller: IUtcpClient, toolName: string, toolArgs: Record<string, any>, toolCallTemplate: CallTemplate): Promise<any> {
109-
if (!(toolCallTemplate as any).file_path) {
110-
throw new Error('TextCommunicationProtocol requires a TextCallTemplate for tool calls');
111-
}
112-
113136
const textCallTemplate = TextCallTemplateSchema.parse(toolCallTemplate);
114-
let filePath = path.resolve(textCallTemplate.file_path);
115-
if (!path.isAbsolute(textCallTemplate.file_path) && caller.root_dir) {
116-
filePath = path.resolve(caller.root_dir, textCallTemplate.file_path);
117-
}
118137

119-
this._log_info(`Reading content from '${filePath}' for tool '${toolName}'`);
138+
// Prefer content over file_path if both are provided
139+
if (textCallTemplate.content) {
140+
this._log_info(`Returning direct content for tool '${toolName}'`);
141+
return textCallTemplate.content;
142+
} else if (textCallTemplate.file_path) {
143+
let filePath = path.resolve(textCallTemplate.file_path);
144+
if (!path.isAbsolute(textCallTemplate.file_path) && caller.root_dir) {
145+
filePath = path.resolve(caller.root_dir, textCallTemplate.file_path);
146+
}
120147

121-
try {
122-
const content = await fs.readFile(filePath, 'utf-8');
123-
return content;
124-
} catch (error: any) {
125-
if (error.code === 'ENOENT') {
126-
this._log_error(`File not found for tool '${toolName}': ${filePath}`);
148+
this._log_info(`Reading content from '${filePath}' for tool '${toolName}'`);
149+
150+
try {
151+
const content = await fs.readFile(filePath, 'utf-8');
152+
return content;
153+
} catch (error: any) {
154+
if (error.code === 'ENOENT') {
155+
this._log_error(`File not found for tool '${toolName}': ${filePath}`);
156+
}
157+
throw error;
127158
}
128-
throw error;
159+
} else {
160+
throw new Error('Either file_path or content must be provided in TextCallTemplate for tool calls');
129161
}
130162
}
131163

packages/text/tests/text_communication_protocol.test.ts

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import path from 'path';
66
import { TextCommunicationProtocol, TextCallTemplate } from "@utcp/text";
77
import "@utcp/http"; // Needed for OpenAPI conversion
88
import { IUtcpClient } from "@utcp/sdk";
9-
import { CallTemplateBase } from "@utcp/sdk";
109

1110
const tempFiles: string[] = [];
1211
const mockClient = {} as IUtcpClient;
@@ -121,43 +120,137 @@ describe("TextCommunicationProtocol", () => {
121120
expect(result.errors.length).toBeGreaterThan(0);
122121
expect(result.errors[0]).toMatch(/JSON/i);
123122
});
123+
124+
test("should load a UTCP manual from direct JSON content", async () => {
125+
const callTemplate: TextCallTemplate = {
126+
name: "direct_content_manual",
127+
call_template_type: 'text',
128+
content: JSON.stringify(sampleUtcpManual)
129+
};
130+
131+
const result = await protocol.registerManual(mockClient, callTemplate);
132+
133+
expect(result.success).toBe(true);
134+
expect(result.errors).toEqual([]);
135+
expect(result.manual.tools).toHaveLength(1);
136+
expect(result.manual.tools[0]?.name).toBe("test.tool");
137+
});
138+
139+
test("should load and convert an OpenAPI spec from direct YAML content", async () => {
140+
const yaml = await import("js-yaml");
141+
const yamlContent = yaml.dump(sampleOpenApiSpec);
142+
const callTemplate: TextCallTemplate = {
143+
name: "direct_openapi_manual",
144+
call_template_type: 'text',
145+
content: yamlContent
146+
};
147+
148+
const result = await protocol.registerManual(mockClient, callTemplate);
149+
150+
expect(result.success).toBe(true);
151+
expect(result.manual.tools).toHaveLength(1);
152+
expect(result.manual.tools[0]?.name).toBe("getTest");
153+
});
154+
155+
test("should prefer content over file_path when both are provided", async () => {
156+
const filePath = await createTempFile("unused.json", JSON.stringify({ tools: [] }));
157+
const callTemplate: TextCallTemplate = {
158+
name: "content_precedence_manual",
159+
call_template_type: 'text',
160+
file_path: filePath,
161+
content: JSON.stringify(sampleUtcpManual)
162+
};
163+
164+
const result = await protocol.registerManual(mockClient, callTemplate);
165+
166+
expect(result.success).toBe(true);
167+
expect(result.manual.tools).toHaveLength(1);
168+
expect(result.manual.tools[0]?.name).toBe("test.tool");
169+
});
170+
171+
test("should fail validation when neither file_path nor content is provided", async () => {
172+
const callTemplate = {
173+
name: "invalid_manual",
174+
call_template_type: 'text'
175+
};
176+
177+
const action = async () => await protocol.registerManual(mockClient, callTemplate);
178+
await expect(action()).rejects.toThrow(/Either file_path or content must be provided/);
179+
});
124180
});
125181

126182
describe("callTool", () => {
127183
test("should return the raw content of the specified file", async () => {
128184
const fileContent = "This is the raw content of the file.";
129185
const filePath = await createTempFile("content.txt", fileContent);
130-
const callTemplate: CallTemplateBase = {
186+
const callTemplate: TextCallTemplate = {
131187
name: "file_content_tool",
132188
call_template_type: 'text',
133189
file_path: filePath
134-
} as TextCallTemplate;
190+
};
135191

136192
const result = await protocol.callTool(mockClient, "any.tool", {}, callTemplate);
137193
expect(result).toBe(fileContent);
138194
});
139195

140196
test("should throw an error if the file does not exist", async () => {
141-
const callTemplate: CallTemplateBase = {
197+
const callTemplate: TextCallTemplate = {
142198
name: "nonexistent_file_tool",
143199
call_template_type: 'text',
144200
file_path: "/path/to/nonexistent/file.txt"
145-
} as TextCallTemplate;
201+
};
146202

147203
const action = async () => await protocol.callTool(mockClient, "any.tool", {}, callTemplate);
148204
await expect(action()).rejects.toThrow(/no such file or directory/);
149205
});
206+
207+
test("should return direct content when content is provided", async () => {
208+
const directContent = "This is direct content.";
209+
const callTemplate: TextCallTemplate = {
210+
name: "direct_content_tool",
211+
call_template_type: 'text',
212+
content: directContent
213+
};
214+
215+
const result = await protocol.callTool(mockClient, "any.tool", {}, callTemplate);
216+
expect(result).toBe(directContent);
217+
});
218+
219+
test("should prefer content over file_path when both are provided", async () => {
220+
const fileContent = "File content.";
221+
const directContent = "Direct content wins.";
222+
const filePath = await createTempFile("ignored.txt", fileContent);
223+
const callTemplate: TextCallTemplate = {
224+
name: "precedence_tool",
225+
call_template_type: 'text',
226+
file_path: filePath,
227+
content: directContent
228+
};
229+
230+
const result = await protocol.callTool(mockClient, "any.tool", {}, callTemplate);
231+
expect(result).toBe(directContent);
232+
});
233+
234+
test("should throw an error when neither file_path nor content is provided", async () => {
235+
const callTemplate = {
236+
name: "invalid_tool",
237+
call_template_type: 'text'
238+
};
239+
240+
const action = async () => await protocol.callTool(mockClient, "any.tool", {}, callTemplate);
241+
await expect(action()).rejects.toThrow(/Either file_path or content must be provided/);
242+
});
150243
});
151244

152245
describe("callToolStreaming", () => {
153246
test("should yield the file content as a single chunk", async () => {
154247
const fileContent = JSON.stringify({ data: "stream content" });
155248
const filePath = await createTempFile("stream.json", fileContent);
156-
const callTemplate: CallTemplateBase = {
249+
const callTemplate: TextCallTemplate = {
157250
name: "streaming_file_tool",
158251
call_template_type: 'text',
159252
file_path: filePath
160-
} as TextCallTemplate;
253+
};
161254

162255
const stream = protocol.callToolStreaming(mockClient, "any.tool", {}, callTemplate);
163256

0 commit comments

Comments
 (0)