cfd38d82fc
* refactor delegation system and add PR summary comments Delegation system: - replace mode-based delegation with select_mode → delegate two-step flow - orchestrator crafts self-contained subagent prompts (clean context — no system/repo/event instructions leak) - add role-based tool filtering via FastMCP authenticate hook (?role=subagent hides orchestrator-only tools) - add select_mode tool for orchestrator guidance per mode - add ask_question tool for lightweight research subagents - extract shared subagent lifecycle into subagent.ts (create, complete, stdout, instructions) - route set_output to per-subagent state when activeSubagentId is set - track per-subagent state (SubagentState Map) replacing boolean delegationActive flag - capture and aggregate AgentUsage across all agents (claude, codex, gemini, opencode) - write usage summary table to GitHub job summary - block built-in subagent spawning (Task for Claude, Task(*) for Cursor) - increase activity timeout from 60s to 300s (subagent thinking phases) - fix gh CLI misguidance in system prompt — explicitly forbid usage PR summary comments: - add prSummaryComment trigger (DB schema + migrations + Zod + UI toggle) - dispatch mini-effort summary job alongside PR review on pr.created - add update_pull_request_body MCP tool - add defaultEffort option to webhook dispatch Hardening: - rewrite delegate/selectMode tests with simulated state management - add toolFiltering.test.ts for role extraction, canAccess, set_output routing - remove non-null assertions for PULLFROG_TEMP_DIR (proper error throws) - use fetchWithRetry for direct tarball downloads - DRY fix for rate limit check in test runner Co-authored-by: Cursor <cursoragent@cursor.com> * fix: add type keyword to Effort import in handleWebhook.ts Co-authored-by: Cursor <cursoragent@cursor.com> * clean up delegation system, improve code quality across the codebase - simplify delegate tool to instructions + effort params with subagent lifecycle in subagent.ts - add select_mode and ask_question orchestrator-only tools with canAccess filtering - replace delegate.test.ts/selectMode.test.ts with toolFiltering.test.ts (live MCP integration) - add set_output routing for subagent context and AgentUsage tracking across all agents - add PR summary comment trigger (schema, UI, webhook dispatch with silent flag) - add update_pull_request_body MCP tool - fix changed-agents.sh to always include claude canary for non-agent action changes - fix cursor pagination bug in getSelectedInstallationReposPage - remove destructuring patterns, inline type definitions, and unsafe type casts - replace non-null assertions with explicit checks in install.ts - convert multi-param functions to single param objects (postCleanup, runActionLocal, etc.) - use isHttpError helper in API routes instead of catch-any patterns - add adhoc test fixtures for delegation scenarios (context isolation, error handling, synthesis, etc.) Co-authored-by: Cursor <cursoragent@cursor.com> * no subagent mutation, one mcp per subagent * address review feedback: parallel-safe usage tracking, subagent isolation, minor improvements * fix subagent state isolation: replace Object.freeze with shallow copy Object.freeze throws TypeErrors when subagent tools (checkout_pr, report_progress) write scalar properties to toolState. A shallow copy achieves the same isolation for scalar fields while allowing tools to work normally. Shared references (subagents Map, usageEntries array) remain shared for coordination. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
import { encode as toonEncode } from "@toon-format/toon";
|
|
import type { FastMCP, Tool } from "fastmcp";
|
|
import { formatJsonValue, log } from "../utils/cli.ts";
|
|
import type { ToolContext } from "./server.ts";
|
|
|
|
export const tool = <const params>(
|
|
toolDef: Tool<any, StandardSchemaV1<params>>
|
|
): Tool<any, StandardSchemaV1<params>> => toolDef;
|
|
|
|
export interface ToolResult {
|
|
content: {
|
|
type: "text";
|
|
text: string;
|
|
}[];
|
|
isError?: boolean;
|
|
}
|
|
|
|
export const handleToolSuccess = (data: Record<string, any> | string): ToolResult => {
|
|
const text = typeof data === "string" ? data : toonEncode(data);
|
|
return {
|
|
content: [{ type: "text", text }],
|
|
};
|
|
};
|
|
|
|
export const handleToolError = (error: unknown): ToolResult => {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: ${errorMessage}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Helper to wrap a tool execute function with error handling.
|
|
* Captures ctx in closure so tools don't need to handle try/catch.
|
|
* @param fn - the function to execute
|
|
* @param toolName - optional tool name for error logging
|
|
*/
|
|
export const execute = <T, R extends Record<string, any> | string>(
|
|
fn: (params: T) => Promise<R>,
|
|
toolName?: string
|
|
) => {
|
|
const _fn = async (params: T): Promise<ToolResult> => {
|
|
try {
|
|
const result = await fn(params);
|
|
return handleToolSuccess(result);
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const prefix = toolName ? `[${toolName}]` : "tool";
|
|
log.info(`${prefix} error: ${errorMessage}`);
|
|
log.debug(`${prefix} params: ${formatJsonValue(params)}`);
|
|
return handleToolError(error);
|
|
}
|
|
};
|
|
return _fn;
|
|
};
|
|
|
|
/**
|
|
* Sanitize JSON schema to remove problematic fields that Gemini CLI/API can't handle
|
|
* - Removes $schema field (causes "no schema with key or ref" errors)
|
|
* - Converts $defs to definitions (draft-07 compatibility)
|
|
* - Removes any draft-2020-12 specific features
|
|
* - Converts any_of with enum values to direct STRING enum (Google API requirement)
|
|
*/
|
|
function sanitizeSchema(schema: any): any {
|
|
if (!schema || typeof schema !== "object") {
|
|
return schema;
|
|
}
|
|
|
|
if (Array.isArray(schema)) {
|
|
return schema.map(sanitizeSchema);
|
|
}
|
|
|
|
// handle any_of with enum values - convert to direct STRING enum for Google API
|
|
// Google API requires: {type: "string", enum: [...]} not {anyOf: [{enum: [...]}, {enum: [...]}]}
|
|
if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
const enumValues: string[] = [];
|
|
let allAreEnumObjects = true;
|
|
|
|
for (const item of schema.anyOf) {
|
|
if (item && typeof item === "object" && Array.isArray(item.enum)) {
|
|
// collect enum values (only strings)
|
|
const stringEnums = item.enum.filter((v: any) => typeof v === "string");
|
|
if (stringEnums.length > 0) {
|
|
enumValues.push(...stringEnums);
|
|
} else {
|
|
allAreEnumObjects = false;
|
|
break;
|
|
}
|
|
} else {
|
|
allAreEnumObjects = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// if all any_of items are enum objects with string values, convert to direct STRING enum
|
|
if (allAreEnumObjects && enumValues.length > 0) {
|
|
const uniqueEnums = [...new Set(enumValues)];
|
|
// preserve other properties from the original schema (like description)
|
|
const result: any = {
|
|
type: "string",
|
|
enum: uniqueEnums,
|
|
};
|
|
if (schema.description) {
|
|
result.description = schema.description;
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const sanitized: any = {};
|
|
|
|
for (const [key, value] of Object.entries(schema)) {
|
|
// skip $schema field entirely
|
|
if (key === "$schema") {
|
|
continue;
|
|
}
|
|
|
|
// skip any_of if we already converted it above
|
|
if (key === "anyOf" && schema.anyOf) {
|
|
continue;
|
|
}
|
|
|
|
// convert $defs to definitions for draft-07 compatibility
|
|
if (key === "$defs") {
|
|
sanitized.definitions = sanitizeSchema(value);
|
|
continue;
|
|
}
|
|
|
|
// recursively sanitize nested objects
|
|
sanitized[key] = sanitizeSchema(value);
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
/**
|
|
* Wrap a StandardSchemaV1 to intercept toJsonSchema() calls and sanitize the output
|
|
*/
|
|
function wrapSchema(schema: StandardSchemaV1<any>): StandardSchemaV1<any> {
|
|
const originalToJsonSchema = (schema as any).toJsonSchema?.bind(schema);
|
|
|
|
if (!originalToJsonSchema) {
|
|
return schema;
|
|
}
|
|
|
|
// create a proxy that intercepts toJsonSchema calls
|
|
return new Proxy(schema, {
|
|
get(target, prop) {
|
|
if (prop === "toJsonSchema") {
|
|
return () => {
|
|
const originalSchema = originalToJsonSchema();
|
|
return sanitizeSchema(originalSchema);
|
|
};
|
|
}
|
|
return (target as any)[prop];
|
|
},
|
|
}) as StandardSchemaV1<any>;
|
|
}
|
|
|
|
/**
|
|
* Transform tool to sanitize its parameter schema for Gemini CLI compatibility
|
|
*/
|
|
function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
|
|
if (!tool.parameters) {
|
|
return tool;
|
|
}
|
|
|
|
// wrap the schema object to intercept toJsonSchema() calls
|
|
const wrappedSchema = wrapSchema(tool.parameters);
|
|
|
|
// create a new tool with wrapped schema
|
|
return {
|
|
...tool,
|
|
parameters: wrappedSchema,
|
|
} as T;
|
|
}
|
|
|
|
export const addTools = (ctx: ToolContext, server: FastMCP<any>, tools: Tool<any, any>[]) => {
|
|
// sanitize schemas for gemini agent and opencode (when using Google API)
|
|
// both have issues with draft-2020-12 schemas and any_of enum constructs
|
|
const shouldSanitize = ctx.agent.name === "gemini" || ctx.agent.name === "opencode";
|
|
|
|
for (const tool of tools) {
|
|
const processedTool = shouldSanitize ? sanitizeTool(tool) : tool;
|
|
server.addTool(processedTool);
|
|
}
|
|
return server;
|
|
};
|