Skip to content

Commit 9133f81

Browse files
committed
package
2 parents 4685e0d + 7923b92 commit 9133f81

File tree

4 files changed

+281
-9
lines changed

4 files changed

+281
-9
lines changed

__tests__/inference.test.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,49 @@ describe('inference.ts', () => {
9595
expect(result).toBeNull()
9696
expect(core.info).toHaveBeenCalledWith('Model response: No response content')
9797
})
98+
99+
it('includes response format when specified', async () => {
100+
const requestWithResponseFormat = {
101+
...mockRequest,
102+
responseFormat: {
103+
type: 'json_schema' as const,
104+
json_schema: {type: 'object'},
105+
},
106+
}
107+
108+
const mockResponse = {
109+
choices: [
110+
{
111+
message: {
112+
content: '{"result": "success"}',
113+
},
114+
},
115+
],
116+
}
117+
118+
mockCreate.mockResolvedValue(mockResponse)
119+
120+
const result = await simpleInference(requestWithResponseFormat)
121+
122+
expect(result).toBe('{"result": "success"}')
123+
124+
// Verify response format was included in the request
125+
expect(mockCreate).toHaveBeenCalledWith({
126+
messages: [
127+
{
128+
role: 'system',
129+
content: 'You are a test assistant',
130+
},
131+
{
132+
role: 'user',
133+
content: 'Hello, AI!',
134+
},
135+
],
136+
max_tokens: 100,
137+
model: 'gpt-4',
138+
response_format: requestWithResponseFormat.responseFormat,
139+
})
140+
})
98141
})
99142

100143
describe('mcpInference', () => {
@@ -140,6 +183,7 @@ describe('inference.ts', () => {
140183
// eslint-disable-next-line @typescript-eslint/no-explicit-any
141184
const callArgs = mockCreate.mock.calls[0][0] as any
142185
expect(callArgs.tools).toEqual(mockMcpClient.tools)
186+
expect(callArgs.response_format).toBeUndefined()
143187
expect(callArgs.model).toBe('gpt-4')
144188
expect(callArgs.max_tokens).toBe(100)
145189
})
@@ -315,5 +359,191 @@ describe('inference.ts', () => {
315359

316360
expect(result).toBe('Second message')
317361
})
362+
363+
it('makes additional loop with response format when no tool calls are made', async () => {
364+
const requestWithResponseFormat = {
365+
...mockRequest,
366+
responseFormat: {
367+
type: 'json_schema' as const,
368+
json_schema: {type: 'object'},
369+
},
370+
}
371+
372+
// First response without tool calls
373+
const firstResponse = {
374+
choices: [
375+
{
376+
message: {
377+
content: 'First response',
378+
tool_calls: null,
379+
},
380+
},
381+
],
382+
}
383+
384+
// Second response with response format applied
385+
const secondResponse = {
386+
choices: [
387+
{
388+
message: {
389+
content: '{"result": "formatted response"}',
390+
tool_calls: null,
391+
},
392+
},
393+
],
394+
}
395+
396+
mockCreate.mockResolvedValueOnce(firstResponse).mockResolvedValueOnce(secondResponse)
397+
398+
const result = await mcpInference(requestWithResponseFormat, mockMcpClient)
399+
400+
expect(result).toBe('{"result": "formatted response"}')
401+
expect(mockCreate).toHaveBeenCalledTimes(2)
402+
expect(core.info).toHaveBeenCalledWith('Making one more MCP loop with the requested response format...')
403+
404+
// First call should have tools but no response format
405+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
406+
const firstCall = mockCreate.mock.calls[0][0] as any
407+
expect(firstCall.tools).toEqual(mockMcpClient.tools)
408+
expect(firstCall.response_format).toBeUndefined()
409+
410+
// Second call should have response format but no tools
411+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
412+
const secondCall = mockCreate.mock.calls[1][0] as any
413+
expect(secondCall.tools).toBeUndefined()
414+
expect(secondCall.response_format).toEqual(requestWithResponseFormat.responseFormat)
415+
416+
// Second call should include the user message requesting JSON format
417+
expect(secondCall.messages).toHaveLength(5) // system, user, assistant, user, assistant
418+
expect(secondCall.messages[3].role).toBe('user')
419+
expect(secondCall.messages[3].content).toContain('Please provide your response in the exact')
420+
})
421+
422+
it('uses response format only on final iteration after tool calls', async () => {
423+
const requestWithResponseFormat = {
424+
...mockRequest,
425+
responseFormat: {
426+
type: 'json_schema' as const,
427+
json_schema: {type: 'object'},
428+
},
429+
}
430+
431+
const toolCalls = [
432+
{
433+
id: 'call-123',
434+
function: {
435+
name: 'test-tool',
436+
arguments: '{"param": "value"}',
437+
},
438+
},
439+
]
440+
441+
const toolResults = [
442+
{
443+
tool_call_id: 'call-123',
444+
role: 'tool',
445+
name: 'test-tool',
446+
content: 'Tool result',
447+
},
448+
]
449+
450+
// First response with tool calls
451+
const firstResponse = {
452+
choices: [
453+
{
454+
message: {
455+
content: 'Using tool',
456+
tool_calls: toolCalls,
457+
},
458+
},
459+
],
460+
}
461+
462+
// Second response without tool calls, but should trigger final message loop
463+
const secondResponse = {
464+
choices: [
465+
{
466+
message: {
467+
content: 'Intermediate result',
468+
tool_calls: null,
469+
},
470+
},
471+
],
472+
}
473+
474+
// Third response with response format
475+
const thirdResponse = {
476+
choices: [
477+
{
478+
message: {
479+
content: '{"final": "result"}',
480+
tool_calls: null,
481+
},
482+
},
483+
],
484+
}
485+
486+
mockCreate
487+
.mockResolvedValueOnce(firstResponse)
488+
.mockResolvedValueOnce(secondResponse)
489+
.mockResolvedValueOnce(thirdResponse)
490+
491+
mockExecuteToolCalls.mockResolvedValue(toolResults)
492+
493+
const result = await mcpInference(requestWithResponseFormat, mockMcpClient)
494+
495+
expect(result).toBe('{"final": "result"}')
496+
expect(mockCreate).toHaveBeenCalledTimes(3)
497+
498+
// First call: tools but no response format
499+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
500+
const firstCall = mockCreate.mock.calls[0][0] as any
501+
expect(firstCall.tools).toEqual(mockMcpClient.tools)
502+
expect(firstCall.response_format).toBeUndefined()
503+
504+
// Second call: tools but no response format (after tool execution)
505+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
506+
const secondCall = mockCreate.mock.calls[1][0] as any
507+
expect(secondCall.tools).toEqual(mockMcpClient.tools)
508+
expect(secondCall.response_format).toBeUndefined()
509+
510+
// Third call: response format but no tools (final message)
511+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
512+
const thirdCall = mockCreate.mock.calls[2][0] as any
513+
expect(thirdCall.tools).toBeUndefined()
514+
expect(thirdCall.response_format).toEqual(requestWithResponseFormat.responseFormat)
515+
})
516+
517+
it('returns immediately when response format is set and finalMessage is already true', async () => {
518+
const requestWithResponseFormat = {
519+
...mockRequest,
520+
responseFormat: {
521+
type: 'json_schema' as const,
522+
json_schema: {type: 'object'},
523+
},
524+
}
525+
526+
// Response without tool calls on what would be the final message iteration
527+
const mockResponse = {
528+
choices: [
529+
{
530+
message: {
531+
content: '{"immediate": "result"}',
532+
tool_calls: null,
533+
},
534+
},
535+
],
536+
}
537+
538+
mockCreate.mockResolvedValue(mockResponse)
539+
540+
// We need to test a scenario where finalMessage would already be true
541+
// This happens when we're already in the final iteration
542+
const result = await mcpInference(requestWithResponseFormat, mockMcpClient)
543+
544+
// The function should make two calls: one normal, then one with response format
545+
expect(mockCreate).toHaveBeenCalledTimes(2)
546+
expect(result).toBe('{"immediate": "result"}')
547+
})
318548
})
319549
})

dist/index.js

Lines changed: 24 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/inference.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ export async function mcpInference(
8989

9090
let iterationCount = 0
9191
const maxIterations = 5 // Prevent infinite loops
92+
// We want to use response_format (e.g. JSON) on the last iteration only, so the model can output
93+
// the final result in the expected format without interfering with tool calls
94+
let finalMessage = false
9295

9396
while (iterationCount < maxIterations) {
9497
iterationCount++
@@ -98,13 +101,14 @@ export async function mcpInference(
98101
messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
99102
max_tokens: request.maxTokens,
100103
model: request.modelName,
101-
tools: githubMcpClient.tools as OpenAI.Chat.Completions.ChatCompletionTool[],
102104
}
103105

104-
// Add response format if specified (only on first iteration to avoid conflicts)
105-
if (iterationCount === 1 && request.responseFormat) {
106+
// Add response format if specified (only on final iteration to avoid conflicts with tool calls)
107+
if (finalMessage && request.responseFormat) {
106108
// eslint-disable-next-line @typescript-eslint/no-explicit-any
107109
chatCompletionRequest.response_format = request.responseFormat as any
110+
} else {
111+
chatCompletionRequest.tools = githubMcpClient.tools as OpenAI.Chat.Completions.ChatCompletionTool[]
108112
}
109113

110114
try {
@@ -128,7 +132,25 @@ export async function mcpInference(
128132

129133
if (!toolCalls || toolCalls.length === 0) {
130134
core.info('No tool calls requested, ending GitHub MCP inference loop')
131-
return modelResponse || null
135+
136+
// If we have a response format set and we haven't explicitly run one final message iteration,
137+
// do another loop with the response format set
138+
if (request.responseFormat && !finalMessage) {
139+
core.info('Making one more MCP loop with the requested response format...')
140+
141+
// Add a user message requesting JSON format and try again
142+
messages.push({
143+
role: 'user',
144+
content: `Please provide your response in the exact ${request.responseFormat.type} format specified.`,
145+
})
146+
147+
finalMessage = true
148+
149+
// Continue the loop to get a properly formatted response
150+
continue
151+
} else {
152+
return modelResponse || null
153+
}
132154
}
133155

134156
core.info(`Model requested ${toolCalls.length} tool calls`)

0 commit comments

Comments
 (0)