Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
"codebase-memory-mcp": {
"command": "codebase-memory-mcp",
"args": []
},
"puppeteer": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-puppeteer@2025.5.12"
]
}
}
}
325 changes: 269 additions & 56 deletions apps/api/README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/api/mcp/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { PluginRegistry } from "./registry.js";
import { ToolsDiscovery } from "./discovery.js";
import { ToolExecutor } from "./execute.js";
import { fileURLToPath } from "url";
import { fileManagerPlugin } from "./plugins/filemanager/manifest.js";
import { context7Plugin } from "./plugins/context7/manifest.js";
import { puppeteerPlugin } from "./plugins/puppeteer/manifest.js";

export const mcpRegistry = new PluginRegistry();

// Register plugins from modular layout manifests
mcpRegistry.registerPlugin(fileManagerPlugin);
mcpRegistry.registerPlugin(context7Plugin);
mcpRegistry.registerPlugin(puppeteerPlugin);

export const mcpDiscovery = new ToolsDiscovery(mcpRegistry);
export const mcpExecutor = new ToolExecutor(mcpDiscovery);
2 changes: 1 addition & 1 deletion apps/api/mcp/plugins/context7/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import "../../../../src/utils/env.js";
import "../../../src/utils/env.js";
import { StdioMCPServer } from "../../stdio-server.js";

const context7Env: Record<string, string> = {};
Expand Down
9 changes: 9 additions & 0 deletions apps/api/mcp/plugins/puppeteer/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import "../../../src/utils/env.js";
import { StdioMCPServer } from "../../stdio-server.js";

export const puppeteerPlugin = new StdioMCPServer(
"puppeteer",
"npx",
["-y", "@modelcontextprotocol/server-puppeteer"],
{}
);
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.4.0",
"@modelcontextprotocol/server-puppeteer": "^2025.5.12",
"@repo/db": "*",
"@repo/shared": "*",
"cors": "^2.8.5",
Expand Down
87 changes: 86 additions & 1 deletion apps/api/src/agent/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { runAgent } from "./loop.js";
import { createMemory } from "./memory.js";
import { llmClient } from "./llm.js";
import { llmClient, nextStep } from "./llm.js";

// Mock @repo/db
vi.mock("@repo/db", () => {
Expand Down Expand Up @@ -425,6 +425,91 @@ describe("Agent Module & Execution Loop", () => {
expect(result.reason).toBe("Approval not approved");
});

// 14) Parallel Tool Execution tests
describe("Parallel Tool Execution", () => {
it("should parse type: tool_calls from LLM and validate schemas", async () => {
vi.spyOn(llmClient, "callModel").mockResolvedValue(
JSON.stringify({
type: "tool_calls",
tool_calls: [
{ tool_name: "test_tool", arguments: { arg1: "val1" } },
{ tool_name: "test_tool", arguments: { arg1: "val2" } }
]
})
);

const memory = createMemory();
const tools = [
{
name: "test_tool",
description: "A test tool",
inputSchema: { type: "object", properties: { arg1: { type: "string" } } },
execute: vi.fn(),
}
];

const res = await nextStep(memory, tools);
expect(res.step.type).toBe("tool_calls");
if (res.step.type === "tool_calls") {
expect(res.step.tool_calls).toHaveLength(2);
expect(res.step.tool_calls[0]?.tool_name).toBe("test_tool");
}
});

it("should execute parallel tool calls successfully when allowed", async () => {
let callCount = 0;
vi.spyOn(llmClient, "callModel").mockImplementation(async () => {
callCount++;
if (callCount === 1) {
return JSON.stringify({
type: "tool_calls",
tool_calls: [
{ tool_name: "test_tool", arguments: { arg1: "val1" } },
{ tool_name: "test_tool", arguments: { arg1: "val2" } }
]
});
}
return JSON.stringify({
type: "final_answer",
answer: "Finished parallel work.",
});
});

vi.mocked(decide).mockResolvedValue({
decision: "ALLOW",
});

vi.mocked(mcpExecutor.execute).mockResolvedValue("mockResult");

const result = await runAgent("Do parallel tasks", "conv-parallel-1");
expect(result.status).toBe("SUCCESS");
expect(result.answer).toBe("Finished parallel work.");
expect(mcpExecutor.execute).toHaveBeenCalledTimes(2);
expect(result.memory.toolResults).toContain("mockResult");
});

it("should request approval for parallel tool calls when pending", async () => {
vi.spyOn(llmClient, "callModel").mockResolvedValue(
JSON.stringify({
type: "tool_calls",
tool_calls: [
{ tool_name: "test_tool", arguments: { arg1: "val1" } }
]
})
);

vi.mocked(decide).mockResolvedValue({
decision: "PENDING",
reason: "approval-parallel-123",
});

const result = await runAgent("Do parallel task requiring approval", "conv-parallel-2");
expect(result.status).toBe("PENDING");
expect(result.approvalId).toBe("approval-parallel-123");
expect(result.memory.approvalId).toBe("approval-parallel-123");
});
});

describe("Gemini API Client Timeout", () => {
afterEach(() => {
vi.unstubAllGlobals();
Expand Down
185 changes: 154 additions & 31 deletions apps/api/src/agent/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ import { Memory, Tool, ToolCall, FinalAnswer, AgentStep } from "../../types.js";

export const llmClient = {
async callModel(prompt: string): Promise<string> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY environment variable is not defined");
}
const geminiKey = process.env.GEMINI_API_KEY;
const grokKey = process.env.GROK_API_KEY ;

let timeoutMs = 30000;
if (process.env.GEMINI_TIMEOUT_MS) {
Expand All @@ -16,38 +14,120 @@ export const llmClient = {
}
}

const response = await fetch(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": apiKey,
},
body: JSON.stringify({
contents: [{
parts: [{ text: prompt }]
}],
generationConfig: {
responseMimeType: "application/json"
let geminiError: Error = new Error("Unknown error");

// 1. Try Gemini first (as first preference)
if (geminiKey && geminiKey.trim() !== "") {
try {
const response = await fetch(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": geminiKey,
},
body: JSON.stringify({
contents: [{
parts: [{ text: prompt }]
}],
generationConfig: {
responseMimeType: "application/json"
}
}),
signal: AbortSignal.timeout(timeoutMs)
}
}),
signal: AbortSignal.timeout(timeoutMs)
}
);
);

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini API returned status ${response.status}: ${errorText}`);
}

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini API request failed with status ${response.status}: ${errorText}`);
const json: any = await response.json();
const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) {
throw new Error("Invalid response received from Gemini API");
}
return text;
} catch (err: any) {
// If it is a client timeout/abort error, propagate it directly without fallback
if (err.name === "AbortError" || err.message.includes("aborted")) {
throw err;
}
geminiError = err;
console.warn("Gemini API call failed, attempting fallback to Grok:", err.message);
}
} else {
geminiError = new Error("GEMINI_API_KEY environment variable is not defined");
}

const json: any = await response.json();
const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) {
throw new Error("Invalid response received from Gemini API");
// 2. Fallback to Grok (xAI API) or Groq if Gemini failed
if (grokKey && grokKey.trim() !== "") {
const isGroq = grokKey.trim().startsWith("gsk_");
const endpoint = isGroq
? "https://api.groq.com/openai/v1/chat/completions"
: "https://api.x.ai/v1/chat/completions";

const defaultModel = isGroq ? "llama-3.3-70b-versatile" : "grok-2";
const model = process.env.GROK_MODEL || process.env.XAI_MODEL || defaultModel;
const providerName = isGroq ? "Groq" : "Grok";

try {
const response = await fetch(
endpoint,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${grokKey.trim()}`,
},
body: JSON.stringify({
model: model,
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" }
}),
signal: AbortSignal.timeout(timeoutMs)
}
);

if (!response.ok) {
const errorText = await response.text();
throw new Error(`${providerName} API returned status ${response.status}: ${errorText}`);
}

const json: any = await response.json();
// Support choices (Grok/Groq) and candidates (for stub testing)
if (json.choices) {
const text = json.choices?.[0]?.message?.content;
if (!text) {
throw new Error(`Invalid response received from ${providerName} API`);
}
return text;
} else if (json.candidates) {
const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) {
throw new Error("Invalid response received from fallback model");
}
return text;
} else {
throw new Error(`Unknown response format received from ${providerName} API`);
}
} catch (err: any) {
console.error(`${providerName} fallback API call failed:`, err.message);
throw new Error(
`Security Agent Service Error: Both primary (Gemini) and fallback (${providerName}) models failed to respond.\n` +
`• Gemini Error: ${geminiError.message}\n` +
`• ${providerName} Error: ${err.message}`
);
}
}

return text;
// 3. Display user-friendly message if both failed or fallback API key is missing
throw new Error(
`Security Agent Service Error: Primary model (Gemini) failed to respond, and no fallback model is configured.\n` +
`• Gemini Error: ${geminiError.message}`
);
}
};

Expand Down Expand Up @@ -126,13 +206,22 @@ Conversation history:
${messagesContext}

Output your next step as a single JSON object. Do not include any other text, markdown formatting, or code blocks.
If you need to call a tool, output:
If you need to call a single tool, output:
{
"type": "tool_call",
"tool_name": "name_of_tool",
"arguments": { ... }
}

If you need to call multiple independent tools in parallel, output:
{
"type": "tool_calls",
"tool_calls": [
{ "tool_name": "name_of_tool_1", "arguments": { ... } },
{ "tool_name": "name_of_tool_2", "arguments": { ... } }
]
}

If you are done and have a final answer, output:
{
"type": "final_answer",
Expand Down Expand Up @@ -179,6 +268,40 @@ If you are done and have a final answer, output:
},
tokens
};
} else if (parsed.type === "tool_calls") {
const { tool_calls } = parsed;
if (!Array.isArray(tool_calls)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
throw new Error("Invalid LLM output structure for parallel tool calls");
}
if (tool_calls.length === 0) {
throw new Error("LLM returned an empty tool_calls array; at least one tool is required");
}

for (const tc of tool_calls) {
if (!tc || typeof tc !== "object" || typeof tc.tool_name !== "string" || !tc.arguments || typeof tc.arguments !== "object" || Array.isArray(tc.arguments)) {
throw new Error("Invalid tool call in parallel list");
}

const tool = tools.find(t => t.name === tc.tool_name);
if (!tool) {
throw new Error(`Unknown tool: ${tc.tool_name}`);
}

if (!validateSchema(tool.inputSchema, tc.arguments)) {
throw new Error(`Invalid arguments for tool ${tc.tool_name}`);
}
}

return {
step: {
type: "tool_calls",
tool_calls: tool_calls.map(tc => ({
tool_name: tc.tool_name,
arguments: tc.arguments
}))
},
tokens
};
} else if (parsed.type === "final_answer") {
const { answer } = parsed;
if (typeof answer !== "string") {
Expand Down
Loading
Loading