ai agent
This commit is contained in:
+28
-148
@@ -17,11 +17,8 @@
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
isProcessAlive,
|
||||
readEndpoint,
|
||||
readEndpointForProject,
|
||||
} from "./endpoint";
|
||||
import { LiveClient } from "./liveClient";
|
||||
import { pruneInstances } from "./instances";
|
||||
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
|
||||
import {
|
||||
findAssets,
|
||||
@@ -53,6 +50,7 @@ Use these tools instead of full-text grepping the XML tree when you need exact f
|
||||
- is_file_active(path) -> whether a file is part of an indexed include stream
|
||||
- find_define(name) -> $DEFINE definitions
|
||||
- resolve_include(source) -> candidate source file
|
||||
- list_projects() -> project roots the live extension has indexed
|
||||
- get_status() -> current index state
|
||||
|
||||
Tips:
|
||||
@@ -228,6 +226,17 @@ const TOOLS: McpTool[] = [
|
||||
"get_asset_references requires a live index. Open the project in VS Code (with AI Agent access enabled) and retry.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "list_projects",
|
||||
description:
|
||||
"Lists the project roots the live extension currently has indexed. Use it to discover which projects this server can answer for.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
handler: () => ({
|
||||
index: { state: "no_index" },
|
||||
error:
|
||||
"list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "get_usage_guide",
|
||||
description: "Returns guidance for using the RA3 Mod XML index tools.",
|
||||
@@ -237,142 +246,7 @@ const TOOLS: McpTool[] = [
|
||||
];
|
||||
|
||||
/** Tools that can only be answered by the live extension server. */
|
||||
const LIVE_ONLY_TOOLS = new Set(["get_asset_references"]);
|
||||
|
||||
export function liveUrlForTool(
|
||||
endpointUrl: string,
|
||||
projectDir: string | null,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): string | null {
|
||||
const base = endpointUrl.replace(/\/$/, "");
|
||||
const q = new URLSearchParams();
|
||||
// Always pin the requested project. Without this the server would silently
|
||||
// answer from whatever project its active editor points at.
|
||||
if (projectDir) q.set("project", projectDir);
|
||||
switch (toolName) { case "get_status":
|
||||
return q.toString() ? `${base}/status?${q}` : `${base}/status`;
|
||||
case "find_asset":
|
||||
q.set("id", String(args.id ?? ""));
|
||||
if (args.type != null) q.set("type", String(args.type));
|
||||
return `${base}/find_asset?${q}`;
|
||||
case "find_references":
|
||||
q.set("id", String(args.id ?? ""));
|
||||
if (args.type != null) q.set("type", String(args.type));
|
||||
return `${base}/find_references?${q}`;
|
||||
case "get_asset_references":
|
||||
q.set("id", String(args.id ?? ""));
|
||||
if (args.type != null) q.set("type", String(args.type));
|
||||
if (args.depth != null) q.set("depth", String(args.depth));
|
||||
if (Array.isArray(args.targetTypes)) {
|
||||
q.set("targetTypes", (args.targetTypes as unknown[]).map(String).join(","));
|
||||
}
|
||||
if (args.maxEdges != null) q.set("maxEdges", String(args.maxEdges));
|
||||
if (args.includeUnresolved != null) {
|
||||
q.set("includeUnresolved", String(args.includeUnresolved));
|
||||
}
|
||||
return `${base}/get_asset_references?${q}`;
|
||||
case "list_assets_by_type":
|
||||
q.set("type", String(args.type ?? ""));
|
||||
if (args.prefix != null) q.set("prefix", String(args.prefix));
|
||||
if (args.limit != null) q.set("limit", String(args.limit));
|
||||
return `${base}/list_assets?${q}`;
|
||||
case "is_file_active":
|
||||
q.set("path", String(args.path ?? ""));
|
||||
return `${base}/is_file_active?${q}`;
|
||||
case "find_define":
|
||||
q.set("name", String(args.name ?? ""));
|
||||
return `${base}/find_define?${q}`;
|
||||
case "resolve_include":
|
||||
q.set("source", String(args.source ?? ""));
|
||||
return `${base}/resolve_include?${q}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a live response that belongs to a different project than the one
|
||||
* this MCP client was started for. This is the guard against the
|
||||
* multi-window cross-talk described in docs/ai-agent-integration-plan.md §10:
|
||||
* returning another project's data silently would be worse than returning
|
||||
* nothing, because the agent cannot tell the difference.
|
||||
*/
|
||||
export function responseProjectMismatch(
|
||||
payload: unknown,
|
||||
projectDir: string | null,
|
||||
): boolean {
|
||||
if (!projectDir) return false;
|
||||
const index = (payload as { index?: { projectDir?: string } } | null)?.index;
|
||||
const reported = index?.projectDir;
|
||||
if (!reported) return false;
|
||||
return normalizePath(reported) !== normalizePath(projectDir);
|
||||
}
|
||||
|
||||
/** Cooldown after a failed live attempt, to avoid a probe per tool call. */
|
||||
const LIVE_RETRY_COOLDOWN_MS = 5000;
|
||||
let liveUnavailableUntil = 0;
|
||||
|
||||
interface LiveResult {
|
||||
payload: unknown;
|
||||
/** True when the live server answered but for a different project. */
|
||||
mismatched: boolean;
|
||||
}
|
||||
|
||||
/** Tries the live extension server; returns null when unavailable. */
|
||||
async function tryLiveQuery(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
projectDir: string | null,
|
||||
agentHome?: string,
|
||||
): Promise<LiveResult | null> {
|
||||
if (Date.now() < liveUnavailableUntil) return null;
|
||||
// Prefer the per-project endpoint so two open windows cannot shadow each
|
||||
// other; fall back to the legacy global file only when it matches.
|
||||
let endpoint = projectDir
|
||||
? await readEndpointForProject(projectDir, agentHome)
|
||||
: null;
|
||||
if (!endpoint) {
|
||||
const fallback = await readEndpoint(agentHome);
|
||||
if (
|
||||
fallback &&
|
||||
(!projectDir ||
|
||||
!fallback.projectDir ||
|
||||
normalizePath(fallback.projectDir) === normalizePath(projectDir))
|
||||
) {
|
||||
endpoint = fallback;
|
||||
}
|
||||
}
|
||||
if (!endpoint) return null;
|
||||
// A crashed VS Code can leave the file behind; a dead PID means stale.
|
||||
if (!isProcessAlive(endpoint.processId)) return null;
|
||||
|
||||
const url = liveUrlForTool(endpoint.url, projectDir, toolName, args);
|
||||
if (!url) return null;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${endpoint.token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 404) return null;
|
||||
return null;
|
||||
}
|
||||
const payload: unknown = await res.json();
|
||||
return { payload, mismatched: responseProjectMismatch(payload, projectDir) };
|
||||
} catch {
|
||||
liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
const LIVE_ONLY_TOOLS = new Set(["get_asset_references", "list_projects"]);
|
||||
|
||||
function sendMessage(message: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
@@ -390,7 +264,7 @@ async function handleRequest(
|
||||
message: Record<string, unknown>,
|
||||
snapshot: AgentIndexSnapshot | null,
|
||||
projectDir: string | null,
|
||||
agentHome?: string,
|
||||
live: LiveClient,
|
||||
): Promise<unknown | null> {
|
||||
const method = String(message.method ?? "");
|
||||
const id = message.id;
|
||||
@@ -418,12 +292,12 @@ async function handleRequest(
|
||||
const tool = TOOLS.find((t) => t.name === toolName);
|
||||
if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`);
|
||||
const args = (params.arguments ?? {}) as Record<string, unknown>;
|
||||
const live = await tryLiveQuery(toolName, args, projectDir, agentHome);
|
||||
const result = await live.query(toolName, args);
|
||||
|
||||
if (live?.mismatched) {
|
||||
if (result?.mismatched) {
|
||||
// The server answered for another project. Refuse it: a plausible
|
||||
// wrong answer is worse than an explicit failure.
|
||||
liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS;
|
||||
live.markUnavailable();
|
||||
return textResult(id, {
|
||||
index: { state: "error", projectDir: projectDir ?? undefined },
|
||||
error: `The live server answered for a different project than "${projectDir}"; refusing the result. Re-run "RA3 Mod XML: Enable AI Agent access…" for this project.`,
|
||||
@@ -435,14 +309,14 @@ async function handleRequest(
|
||||
// not conclude "this asset has no references".
|
||||
return textResult(
|
||||
id,
|
||||
live?.payload ?? {
|
||||
result?.payload ?? {
|
||||
index: { state: "no_index", projectDir: projectDir ?? undefined },
|
||||
error: `"${toolName}" requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const output = live?.payload ?? tool.handler(args, snapshot);
|
||||
const output = result?.payload ?? tool.handler(args, snapshot);
|
||||
return textResult(id, output);
|
||||
}
|
||||
default:
|
||||
@@ -474,6 +348,12 @@ async function main(): Promise<void> {
|
||||
let snapshot: AgentIndexSnapshot | null = null;
|
||||
if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath);
|
||||
|
||||
// One-shot crash cleanup: a window that died without disposing leaves its
|
||||
// instance file behind, and whichever instance starts next prunes it.
|
||||
void pruneInstances(agentHome).catch(() => undefined);
|
||||
|
||||
const live = new LiveClient({ projectDir, agentHome });
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
@@ -486,7 +366,7 @@ async function main(): Promise<void> {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void handleRequest(message, snapshot, projectDir, agentHome).then((response) => {
|
||||
void handleRequest(message, snapshot, projectDir, live).then((response) => {
|
||||
if (response != null) sendMessage(response);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user