771 lines
51 KiB
Java
771 lines
51 KiB
Java
package com.agenticcode.codeserver.mcp;
|
||
|
||
import com.agenticcode.codeserver.service.*;
|
||
import com.agenticcode.neo4jstore.graph.*;
|
||
import com.agenticcode.parsercore.ast.model.NodeType;
|
||
import io.quarkiverse.mcp.server.Tool;
|
||
import io.quarkiverse.mcp.server.ToolArg;
|
||
import io.quarkiverse.mcp.server.ToolResponse;
|
||
import io.smallrye.common.annotation.Blocking;
|
||
import io.smallrye.mutiny.Uni;
|
||
import jakarta.enterprise.context.ApplicationScoped;
|
||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||
import org.jspecify.annotations.Nullable;
|
||
|
||
import java.io.IOException;
|
||
import java.nio.file.Path;
|
||
import java.util.*;
|
||
import java.util.function.Function;
|
||
import java.util.function.Supplier;
|
||
|
||
/**
|
||
* MCP tools mirroring the read endpoints of {@code AnalysisResource}. Each tool delegates to the
|
||
* same {@link GraphRepository} query the REST resource uses, so the graph is queried identically
|
||
* whether an agent goes through REST or MCP.
|
||
*
|
||
* <p>All tools are {@link Blocking} (they drive the blocking Neo4j driver, exactly like the
|
||
* {@code @Blocking} REST resource) and return a {@link ToolResponse} whose text content is the same
|
||
* concise JSON the REST API returns. Every module/variable/data-structure tool takes the
|
||
* {@code project} as its first argument and returns a {@code PROJECT_NOT_FOUND} tool error if it
|
||
* does not exist.
|
||
*/
|
||
@ApplicationScoped
|
||
@McpLogged
|
||
public class McpQueryTools {
|
||
|
||
private static final int DEFAULT_PAGE_LIMIT = 50;
|
||
private final GraphRepository graphRepository;
|
||
private final McpSupport support;
|
||
private final ProjectRootResolver rootResolver;
|
||
private final DeepIngestCoordinator deepIngestCoordinator;
|
||
private final SourceSnippetService sourceSnippetService;
|
||
private final SourceSearchService sourceSearchService;
|
||
private final VersionInfo versionInfo;
|
||
private final int defaultCallTreeDepth;
|
||
private final int maxCallTreeDepth;
|
||
/**
|
||
* Item 67: raw hops allowed per module hop. A pure safety cap — the query prunes its traversal to the
|
||
* modules within the requested depth, so this only stops a pathological internal chain. Measured on
|
||
* upms: a single module hop reached 9 internal hops, and the quantifier costs nothing (identical
|
||
* results and runtime at 8, 20 and 40), so it is sized well above the observed maximum. Too tight a
|
||
* value silently truncates; exhausting it sets truncated=true on the response.
|
||
*/
|
||
private final int callTreeInternalBudget;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Projects & module discovery
|
||
// -------------------------------------------------------------------------
|
||
|
||
public McpQueryTools(GraphRepository graphRepository, McpSupport support,
|
||
ProjectRootResolver rootResolver, DeepIngestCoordinator deepIngestCoordinator,
|
||
SourceSnippetService sourceSnippetService, SourceSearchService sourceSearchService,
|
||
VersionInfo versionInfo,
|
||
@ConfigProperty(name = "agenticcode.call-tree.default-depth", defaultValue = "3") int defaultCallTreeDepth,
|
||
@ConfigProperty(name = "agenticcode.call-tree.max-depth", defaultValue = "10") int maxCallTreeDepth,
|
||
@ConfigProperty(name = "agenticcode.call-tree.internal-budget", defaultValue = "20") int callTreeInternalBudget) {
|
||
this.graphRepository = graphRepository;
|
||
this.support = support;
|
||
this.rootResolver = rootResolver;
|
||
this.deepIngestCoordinator = deepIngestCoordinator;
|
||
this.sourceSnippetService = sourceSnippetService;
|
||
this.sourceSearchService = sourceSearchService;
|
||
this.versionInfo = versionInfo;
|
||
this.defaultCallTreeDepth = defaultCallTreeDepth;
|
||
this.maxCallTreeDepth = maxCallTreeDepth;
|
||
this.callTreeInternalBudget = callTreeInternalBudget;
|
||
}
|
||
|
||
@Tool(name = "version", description = "AgenticCode server name and version.")
|
||
public ToolResponse version() {
|
||
return support.ok(versionInfo);
|
||
}
|
||
|
||
private static int effectiveLimit(@Nullable Integer limit) {
|
||
return limit != null ? Math.max(limit, 0) : DEFAULT_PAGE_LIMIT;
|
||
}
|
||
|
||
private static int effectiveOffset(@Nullable Integer offset) {
|
||
return offset != null ? Math.max(offset, 0) : 0;
|
||
}
|
||
|
||
/**
|
||
* Item 103: see {@code AnalysisResource.uncappedLimit} — absent {@code limit} means "all rows" for
|
||
* db/workfile accesses, because their bare-array response cannot signal that it was cut.
|
||
*/
|
||
private static int uncappedLimit(@Nullable Integer limit) {
|
||
return limit != null ? Math.max(limit, 0) : Integer.MAX_VALUE;
|
||
}
|
||
|
||
private static DeepIngestRequired deepIngestHint(String module, ModuleIngestState state) {
|
||
String status = state.exists() ? "NOT_DEEPLY_INGESTED" : "NOT_INGESTED";
|
||
String detail = state.exists()
|
||
? "Module '" + module + "' has only its call graph ingested; field-level dataflow requires a deep ingest."
|
||
: "Module '" + module + "' is not ingested. Ingest the call graph and/or deep-ingest it.";
|
||
return new DeepIngestRequired(status, module, detail, "ingest_module");
|
||
}
|
||
|
||
@Tool(name = "list_projects", description = "List all ingested projects (name, description, root, excludeDirs, language, generatedDir, userExitDir).")
|
||
@Blocking
|
||
public Uni<ToolResponse> listProjects() {
|
||
return graphRepository.listProjects().map(support::ok);
|
||
}
|
||
|
||
@Tool(name = "list_modules", description = "List MODULE nodes (name, sourceFile, moduleKind, loc, sloc) in a project, optionally filtered by source file, moduleKind (CLASS/INTERFACE/PROGRAM/SUBPROGRAM), and/or direct EXTENDS base class name. loc = physical lines, sloc = source (non-blank, non-comment) lines; null for modules ingested before this existed.")
|
||
@Blocking
|
||
public Uni<ToolResponse> listModules(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Optional source file path to filter by", required = false) @Nullable String sourceFile,
|
||
@ToolArg(description = "Optional module sub-kind to filter by (CLASS/INTERFACE/PROGRAM/SUBPROGRAM)", required = false) @Nullable String moduleKind,
|
||
@ToolArg(description = "Optional base class name; lists only its direct EXTENDS subclasses", required = false) @Nullable String extendsName) {
|
||
return withProject(project, () -> graphRepository.listModules(project, sourceFile, moduleKind, extendsName).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "search_source", description = "Regex search over module SOURCE TEXT (item 54), read from disk. Returns matches as {module, sourceFile, lineNo, line}, with a 'truncated' flag when the match limit is hit. Case-insensitive by default (legacy Natural is case-insensitive). Use this to grep for code patterns (statements, table names, literals) across a project — complementary to search_identifier (which matches declared names).")
|
||
@Blocking
|
||
public Uni<ToolResponse> searchSource(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Java regular expression to search for") String regex,
|
||
@ToolArg(description = "Max matches to return (default 200)", required = false) @Nullable Integer limit,
|
||
@ToolArg(description = "Case-insensitive match (default true)", required = false) @Nullable Boolean ignoreCase) {
|
||
if (regex == null || regex.isBlank()) {
|
||
return Uni.createFrom().item(support.error("MISSING_REGEX", "'regex' is required"));
|
||
}
|
||
boolean caseInsensitive = ignoreCase == null || ignoreCase;
|
||
java.util.regex.Pattern pattern;
|
||
try {
|
||
pattern = java.util.regex.Pattern.compile(regex, caseInsensitive ? java.util.regex.Pattern.CASE_INSENSITIVE : 0);
|
||
} catch (java.util.regex.PatternSyntaxException e) {
|
||
return Uni.createFrom().item(support.error("INVALID_REGEX", "Invalid regex: " + e.getMessage()));
|
||
}
|
||
int effectiveLimit = limit != null && limit > 0 ? limit : 200;
|
||
String root;
|
||
switch (rootResolver.resolve(project)) {
|
||
case ProjectRootResolver.Resolved r -> root = r.project().root();
|
||
case ProjectRootResolver.Failed failed -> {
|
||
return Uni.createFrom().item(support.error(failed.code(), failed.message()));
|
||
}
|
||
}
|
||
List<ModuleInfo> modules = graphRepository.listModules(project, null, null, null).await().indefinitely();
|
||
return Uni.createFrom().item(support.ok(sourceSearchService.search(root, modules, pattern, effectiveLimit)));
|
||
}
|
||
|
||
@Tool(name = "project_loc", description = "Per-language LoC/SLoC rollup for a project: a breakdown by language (fileCount, loc, sloc) plus the project-wide total. loc = physical lines, sloc = source (non-blank, non-comment) lines computed per language. Each source file is counted once. For a project with a generated/user_exit split (item 47), each row also carries userExitLoc/userExitSloc and generatedExclusiveLoc/generatedExclusiveSloc (= total − user_exit); zero when unconfigured. Optionally narrow to one language ('natural'/'java') and/or one source file.")
|
||
@Blocking
|
||
public Uni<ToolResponse> projectLoc(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Optional language to filter by ('natural'/'java')", required = false) @Nullable String language,
|
||
@ToolArg(description = "Optional source file path to filter by", required = false) @Nullable String sourceFile) {
|
||
return withProject(project, () -> graphRepository.projectLoc(project, language, sourceFile).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "module_digest", description = "Small triage bundle for a module: description, function count, caller/callee names, DB table names, data-structure names + field counts.")
|
||
@Blocking
|
||
public Uni<ToolResponse> moduleDigest(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name (not a file path)") String name) {
|
||
return withProject(project, () -> graphRepository.moduleDigest(project, name).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "module_context", description = "Full context bundle for a module. Heavy sub-arrays (variableAccesses, large sqlStatements) are summarized unless requested via 'include'.")
|
||
@Blocking
|
||
public Uni<ToolResponse> moduleContext(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Comma-separated sections to fully expand, e.g. 'variableAccesses,sqlStatements'", required = false) @Nullable String include,
|
||
@ToolArg(description = "Max items for expanded sub-arrays (0 = unbounded)", required = false) @Nullable Integer limit,
|
||
@ToolArg(description = "Offset into expanded sub-arrays", required = false) @Nullable Integer offset) {
|
||
int effLimit = limit != null ? Math.max(limit, 0) : 0;
|
||
int effOffset = offset != null ? Math.max(offset, 0) : 0;
|
||
return withProject(project, () -> graphRepository.moduleContext(project, name, include, effLimit, effOffset).map(support::ok));
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Call graph
|
||
// -------------------------------------------------------------------------
|
||
|
||
@Tool(name = "module_functions", description = "List functions (Natural subroutines / Java methods) declared in a module; set includeInherited to include inherited Java methods. 'kind' filters Java methods by modifier: abstract/final/overridable.")
|
||
@Blocking
|
||
public Uni<ToolResponse> moduleFunctions(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Include inherited (Java) methods", required = false) @Nullable Boolean includeInherited,
|
||
@ToolArg(description = "Optional Java modifier filter: abstract/final/overridable", required = false) @Nullable String kind) {
|
||
boolean inherited = includeInherited != null && includeInherited;
|
||
return withProject(project, () -> graphRepository.moduleFunctions(project, name, inherited, kind).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "function_overrides", description = "Concrete subclass overrides of a base-class method (Java virtual/template-method dispatch). Pass 'function' for one hook method, or omit it to get overrides for every abstract method of the base class at once, grouped by hook method.")
|
||
@Blocking
|
||
public Uni<ToolResponse> functionOverrides(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module (base class) name") String name,
|
||
@ToolArg(description = "Base method name; omit for the bulk (all-abstract-methods) variant", required = false) @Nullable String function) {
|
||
return withProject(project, () -> function == null || function.isBlank()
|
||
? graphRepository.functionOverrides(project, name).map(support::ok)
|
||
: graphRepository.functionOverrides(project, name, function).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "function_callers", description = "FUNCTION-level callers of a subroutine/method: who PERFORMs (Natural) or calls (Java cross-class) the given 'function' in module 'name', with call-site line numbers. Finer-grained than module-level 'callers'.")
|
||
@Blocking
|
||
public Uni<ToolResponse> functionCallers(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name that defines the subroutine/method") String name,
|
||
@ToolArg(description = "Subroutine/method name") String function) {
|
||
return withProject(project, () -> graphRepository.functionCallers(project, name, function).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "module_data_structures", description = "Data structures (Natural DEFINE DATA blocks / Java DTOs) referenced by a module.")
|
||
@Blocking
|
||
public Uni<ToolResponse> moduleDataStructures(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name) {
|
||
return withProject(project, () -> graphRepository.moduleDataStructures(project, name).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "module_payload", description = "XML wire payload contract of a Natural module: the tag/field/direction triples emitted via the ADD-XML-LINE idiom.")
|
||
@Blocking
|
||
public Uni<ToolResponse> modulePayload(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name) {
|
||
return withProject(project, () -> graphRepository.payload(project, name).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "module_dispatch_table", description = "Dispatch table for a module: dynamic-call lookup arrays and the module names their literals resolve to.")
|
||
@Blocking
|
||
public Uni<ToolResponse> moduleDispatchTable(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name) {
|
||
return withProject(project, () -> graphRepository.dispatchTable(project, name).map(support::ok));
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Dynamic-CALLNAT manual overrides (item 82)
|
||
// -------------------------------------------------------------------------
|
||
|
||
@Tool(name = "list_unresolved_dynamic_calls", description = "List the unresolved dynamic CALLNAT <var> call sites in a project — the ones the auto-resolvers couldn't recover. Each row is {module, originFile, lineNo, variable}; (originFile, lineNo) is the key to pin a target with set_dynamic_call_override.")
|
||
@Blocking
|
||
public Uni<ToolResponse> listUnresolvedDynamicCalls(
|
||
@ToolArg(description = "Project name") String project) {
|
||
return withProject(project, () -> graphRepository.listUnresolvedDynamicCalls(project).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "list_dynamic_call_overrides", description = "List the manual dynamic-CALLNAT overrides in a project. Each carries its targets and an 'obsolete' flag (true once an auto-resolver has since resolved that call site).")
|
||
@Blocking
|
||
public Uni<ToolResponse> listDynamicCallOverrides(
|
||
@ToolArg(description = "Project name") String project) {
|
||
return withProject(project, () -> graphRepository.listDynamicCallOverrides(project).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "set_dynamic_call_override", description = "Pin an unresolvable dynamic CALLNAT call site (identified by originFile + lineNo) to one or more target modules. Multiple targets model deliberate branches. Applied immediately (no refresh) and persisted across refreshes. Rejected if any target is not a real module.")
|
||
@Blocking
|
||
public Uni<ToolResponse> setDynamicCallOverride(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "File the call site lives in (module's own file or an included copycode)") String originFile,
|
||
@ToolArg(description = "Line number of the CALLNAT within originFile") Integer lineNo,
|
||
@ToolArg(description = "Comma-separated target module name(s), e.g. 'YGEAGGN0' or 'A,B'") String targets,
|
||
@ToolArg(description = "Dispatch variable name, for reference (optional)", required = false) @Nullable String variable,
|
||
@ToolArg(description = "Free-text note (optional)", required = false) @Nullable String note) {
|
||
List<String> targetList = Arrays.stream(targets.split(","))
|
||
.map(String::trim).filter(s -> !s.isEmpty()).distinct().toList();
|
||
if (originFile.isBlank() || lineNo == null || lineNo <= 0 || targetList.isEmpty()) {
|
||
return Uni.createFrom().item(support.error("INVALID_REQUEST",
|
||
"originFile, a positive lineNo and at least one target are required"));
|
||
}
|
||
return withProject(project, () -> graphRepository.upsertDynamicCallOverride(project, originFile, lineNo,
|
||
targetList, variable, note, "mcp")
|
||
.map(result -> {
|
||
DynamicCallOverride ov = result.override();
|
||
return ov != null
|
||
? support.ok(ov)
|
||
: support.error("UNKNOWN_TARGET",
|
||
"not real MODULE(s) in project '" + project + "': " + result.invalidTargets());
|
||
}));
|
||
}
|
||
|
||
@Tool(name = "reset_dynamic_call_override", description = "Remove manual dynamic-CALLNAT overrides and restore the unresolved placeholder inline: pass originFile + lineNo for one call site, or omit both to reset every override in the project.")
|
||
@Blocking
|
||
public Uni<ToolResponse> resetDynamicCallOverride(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Call site file; omit (with lineNo) to reset all", required = false) @Nullable String originFile,
|
||
@ToolArg(description = "Call site line; omit (with originFile) to reset all", required = false) @Nullable Integer lineNo) {
|
||
return withProject(project, () -> graphRepository.resetDynamicCallOverride(project, originFile, lineNo)
|
||
.map(removed -> support.ok(Map.of("removed", removed))));
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// DB access
|
||
// -------------------------------------------------------------------------
|
||
|
||
@Tool(name = "module_columns", description = "Entity/table columns exposed by a module that maps to a DB table (e.g. a JPA @Entity).")
|
||
@Blocking
|
||
public Uni<ToolResponse> moduleColumns(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name) {
|
||
return withProject(project, () -> graphRepository.entityColumns(project, name).map(support::ok));
|
||
}
|
||
|
||
private static Collection<String> stepModules(List<DataflowStep> steps) {
|
||
return steps.stream().map(DataflowStep::module).filter(Objects::nonNull).distinct().toList();
|
||
}
|
||
|
||
@Tool(name = "callees", description = "Modules/functions the given module calls (plus Java INJECTS/REFERENCES wiring). 'scope' filters by edge kind; resolveInterfaces hops interface callees to their implementations.")
|
||
@Blocking
|
||
public Uni<ToolResponse> callees(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Optional edge-kind scope filter", required = false) @Nullable String scope,
|
||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset,
|
||
@ToolArg(description = "Hop interface callees to their implementation(s) (Java)", required = false) @Nullable Boolean resolveInterfaces) {
|
||
return withProject(project, () -> graphRepository.callees(project, name, scope, effectiveLimit(limit), effectiveOffset(offset),
|
||
resolveInterfaces != null && resolveInterfaces).map(support::ok));
|
||
}
|
||
|
||
private static Collection<String> fieldFlowModules(List<FieldFlow> flows) {
|
||
Set<String> names = new LinkedHashSet<>();
|
||
for (FieldFlow flow : flows) {
|
||
names.add(flow.producer());
|
||
names.add(flow.consumer());
|
||
}
|
||
return names;
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Search
|
||
// -------------------------------------------------------------------------
|
||
|
||
@Tool(name = "db_accesses", description = "DB tables a module reads/writes and the access mode. With 'depth' > 0, includes accesses of transitively-called modules.")
|
||
@Blocking
|
||
public Uni<ToolResponse> dbAccesses(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Transitive depth (0/absent = direct only)", required = false) @Nullable Integer depth,
|
||
@ToolArg(description = "Page size (default: all rows)", required = false) @Nullable Integer limit,
|
||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||
int effLimit = uncappedLimit(limit);
|
||
int effOffset = effectiveOffset(offset);
|
||
return withProject(project, () -> depth != null && depth > 0
|
||
? graphRepository.dbAccessesTransitive(project, name, depth, effLimit, effOffset).map(support::ok)
|
||
: graphRepository.dbAccesses(project, name, effLimit, effOffset).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "workfile_accesses", description = "Natural work files (sequential/flat file I/O) a module reads/writes: work-file number, mode (READS/WRITES), record buffers, and physical name from DEFINE WORK FILE. Separate from db_accesses (work files are not DB tables).")
|
||
@Blocking
|
||
public Uni<ToolResponse> workfileAccesses(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Page size (default: all rows)", required = false) @Nullable Integer limit,
|
||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||
int effLimit = uncappedLimit(limit);
|
||
int effOffset = effectiveOffset(offset);
|
||
return withProject(project, () -> graphRepository.workfileAccesses(project, name, effLimit, effOffset).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "sql_statements", description = "SQL/ADABAS statements issued by a module. With 'depth' > 0, includes statements of transitively-called modules.")
|
||
@Blocking
|
||
public Uni<ToolResponse> sqlStatements(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Transitive depth (0/absent = direct only)", required = false) @Nullable Integer depth) {
|
||
return withProject(project, () -> depth != null && depth > 0
|
||
? graphRepository.sqlStatementsTransitive(project, name, depth).map(support::ok)
|
||
: graphRepository.sqlStatements(project, name).map(support::ok));
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Variable access & dataflow
|
||
// -------------------------------------------------------------------------
|
||
|
||
@Tool(name = "data_structure_fields", description = "Fields of a data structure (Natural DEFINE DATA / Java DTO): name, type, level, redefines. Each field carries its sourceFile.")
|
||
@Blocking
|
||
public Uni<ToolResponse> dataStructureFields(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Data-structure name") String name,
|
||
@ToolArg(description = "Restrict to the definition in this source file (only needed when the name is not unique)",
|
||
required = false) @Nullable String sourceFile) {
|
||
return withProject(project, () -> graphRepository.dataStructureFields(project, name, sourceFile).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "db_table_columns", description = "Columns of a DB table (ADABAS view / SQL table).")
|
||
@Blocking
|
||
public Uni<ToolResponse> dbTableColumns(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "DB table name") String name) {
|
||
return withProject(project, () -> graphRepository.dbTableColumns(project, name).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "callers", description = "Callers of the given module. Default/'external' = modules that call it (CALLNAT/inheritance), each rolled up to the calling module (a call from inside a subroutine is attributed to its module, not the FUNCTION; repeated sites collapse to one row); 'internal' = its own subroutines' PERFORM wiring. Finer function-level callers: function_callers.")
|
||
@Blocking
|
||
public Uni<ToolResponse> callers(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Scope: 'external' (default) = module callers; 'internal' = own-subroutine PERFORM callers", required = false) @Nullable String scope,
|
||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||
return withFanoutWarm(project,
|
||
() -> graphRepository.callers(project, name, scope, effectiveLimit(limit), effectiveOffset(offset)),
|
||
CallRefResponse::sourceFiles);
|
||
}
|
||
|
||
@Tool(name = "search_value", description = "Find nodes whose literal/assigned value matches the given string across a project.")
|
||
@Blocking
|
||
public Uni<ToolResponse> searchValue(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Value to search for") String value,
|
||
@ToolArg(description = "If true, case-insensitive substring match instead of exact", required = false) @Nullable Boolean contains,
|
||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||
if (value.isBlank()) {
|
||
return Uni.createFrom().item(support.error("MISSING_VALUE", "Argument 'value' is required"));
|
||
}
|
||
return withProject(project, () -> graphRepository.searchByValue(project, value, Boolean.TRUE.equals(contains),
|
||
effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "search_annotation", description = "Find nodes (Java only) carrying an annotation whose name matches (substring, case-insensitive), optionally filtered by node type.")
|
||
@Blocking
|
||
public Uni<ToolResponse> searchAnnotation(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Annotation name / substring") String name,
|
||
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
|
||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||
if (name.isBlank()) {
|
||
return Uni.createFrom().item(support.error("MISSING_NAME", "Argument 'name' is required"));
|
||
}
|
||
if (type != null) {
|
||
try {
|
||
NodeType.valueOf(type.toUpperCase());
|
||
} catch (IllegalArgumentException e) {
|
||
return Uni.createFrom().item(support.error("INVALID_TYPE", "Unknown node type '" + type + "'"));
|
||
}
|
||
}
|
||
return withProject(project, () -> graphRepository.searchAnnotation(project, name,
|
||
type != null ? type.toUpperCase() : null, effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "inspect_node", description = "Every property of a single node by id (roadmap item 27). Node ids are regenerated on re-ingest, so only valid until the next one.")
|
||
@Blocking
|
||
public Uni<ToolResponse> inspectNode(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Node id") String id) {
|
||
return withProject(project, () -> graphRepository.nodeById(project, id).map(node -> node == null
|
||
? support.error("NODE_NOT_FOUND", "No node with id '" + id + "' in project '" + project + "' (ids are regenerated on re-ingest)")
|
||
: support.ok(node)));
|
||
}
|
||
|
||
@Tool(name = "call_tree", description = "Transitive call tree rooted at a module, up to 'depth' hops (clamped to the configured maximum). resolveInterfaces drops dead-end interface nodes whose implementations are already reached. followWiring also traverses Java INJECTS/REFERENCES edges (DI/class-literal wiring), not just CALLS.")
|
||
@Blocking
|
||
public Uni<ToolResponse> callTree(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth,
|
||
@ToolArg(description = "Drop interface nodes that have a known implementation (Java)", required = false) @Nullable Boolean resolveInterfaces,
|
||
@ToolArg(description = "Also traverse INJECTS/REFERENCES edges (Java DI/class-literal wiring), not just CALLS", required = false) @Nullable Boolean followWiring) {
|
||
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
|
||
return withFanoutWarm(project,
|
||
() -> graphRepository.callTree(project, name, effectiveDepth,
|
||
resolveInterfaces != null && resolveInterfaces, followWiring != null && followWiring,
|
||
callTreeInternalBudget),
|
||
CallTreeResponse::sourceFiles);
|
||
}
|
||
|
||
@Tool(name = "ego_graph", description = "Bounded call-graph neighbourhood (nodes + edges) around one module, at module granularity (item 49). Unlike call_tree it returns the edges too, so a caller can render an interactive sub-graph. 'direction' is out (callees), in (callers), or both; 'depth' is the max call hops (clamped to the configured maximum); 'limit' caps the number of nodes (BFS order) and sets 'truncated' when hit. Unresolved dynamic/cross-file targets appear as nodes with an empty sourceFile and unresolved=true.")
|
||
@Blocking
|
||
public Uni<ToolResponse> egoGraph(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Max call hops from the module (default configured, clamped 1..max)", required = false) @Nullable Integer depth,
|
||
@ToolArg(description = "Traversal direction: out (callees), in (callers), or both (default out)", required = false) @Nullable String direction,
|
||
@ToolArg(description = "Cap on number of nodes returned (default 50, BFS order)", required = false) @Nullable Integer limit) {
|
||
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
|
||
String dir = direction != null ? direction : "out";
|
||
int effectiveLimit = effectiveLimit(limit);
|
||
return withProject(project, () -> graphRepository.egoGraph(project, name, effectiveDepth, dir, effectiveLimit)
|
||
.map(support::ok));
|
||
}
|
||
|
||
@Tool(name = "search_identifier", description = "Find identifiers across a project by exact name and optional node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE). Name match is sigil-insensitive: a leading Natural sigil (# user, & AIV, + GDA) is ignored on both sides, so 'K-OUT-MAX' matches the declared '#K-OUT-MAX'. Optionally scope to one source file or one module (by name) to pinpoint the local declaration when a name recurs across modules; or use priorityModule to keep the global list but pin one module's matches to the front so they survive the limit.")
|
||
@Blocking
|
||
public Uni<ToolResponse> searchIdentifier(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Identifier name", required = false) @Nullable String name,
|
||
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
|
||
@ToolArg(description = "Scope to this exact source file (relative path)", required = false) @Nullable String sourceFile,
|
||
@ToolArg(description = "Scope to nodes of this module (by name)", required = false) @Nullable String module,
|
||
@ToolArg(description = "Do not filter, but pin this module's matches to the front so its local declaration survives the limit when a name recurs across many modules", required = false) @Nullable String priorityModule,
|
||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||
if (type != null) {
|
||
try {
|
||
NodeType.valueOf(type.toUpperCase());
|
||
} catch (IllegalArgumentException e) {
|
||
return Uni.createFrom().item(support.error("INVALID_TYPE", "Unknown node type '" + type + "'"));
|
||
}
|
||
}
|
||
return withFanoutWarm(project,
|
||
() -> graphRepository.searchIdentifier(project, name,
|
||
type != null ? type.toUpperCase() : null, sourceFile, module, priorityModule, effectiveLimit(limit), effectiveOffset(offset)),
|
||
matches -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList());
|
||
}
|
||
|
||
@Tool(name = "node_source", description = "Source-text snippet for a single node by id (roadmap item 28), read from the project root on disk.")
|
||
@Blocking
|
||
public Uni<ToolResponse> nodeSource(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Node id") String id) {
|
||
return withProject(project, () -> graphRepository.nodeById(project, id).flatMap(node -> {
|
||
if (node == null) {
|
||
return Uni.createFrom().item(support.error("NODE_NOT_FOUND", "No node with id '" + id + "' in project '" + project + "' (ids are regenerated on re-ingest)"));
|
||
}
|
||
String sourceFile = (String) node.get("sourceFile");
|
||
if (sourceFile == null || sourceFile.isEmpty()) {
|
||
return Uni.createFrom().item(support.error("NO_SOURCE_FILE", "Node '" + id + "' is an unresolved placeholder with no source file"));
|
||
}
|
||
int startLine = ((Number) Objects.requireNonNull(node.get("startLine"))).intValue();
|
||
int endLine = ((Number) Objects.requireNonNull(node.get("endLine"))).intValue();
|
||
return sourceSnippet(project, sourceFile, startLine, endLine);
|
||
}));
|
||
}
|
||
|
||
@Tool(name = "module_source", description = "Source text for a module by name (roadmap item 28), read from the project root on disk. Omit both startLine and endLine to get the whole file (M1); or pass both for a line range.")
|
||
@Blocking
|
||
public Uni<ToolResponse> moduleSource(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Module name") String name,
|
||
@ToolArg(description = "Start line (1-indexed); omit with endLine for the whole file", required = false) @Nullable Integer startLine,
|
||
@ToolArg(description = "End line (1-indexed, inclusive); omit with startLine for the whole file", required = false) @Nullable Integer endLine) {
|
||
boolean wholeFile = startLine == null && endLine == null;
|
||
if (!wholeFile && (startLine == null || endLine == null)) {
|
||
return Uni.createFrom().item(support.error("MISSING_LINE_RANGE",
|
||
"Supply both startLine and endLine, or neither (whole file)"));
|
||
}
|
||
int sl = startLine != null ? startLine : 1;
|
||
int el = endLine != null ? endLine : Integer.MAX_VALUE;
|
||
return withProject(project, () -> graphRepository.moduleSourceFile(project, name).flatMap(sourceFile -> sourceFile == null
|
||
? Uni.createFrom().item(support.error("MODULE_NOT_FOUND", "No module '" + name + "' in project '" + project + "'"))
|
||
: wholeFile
|
||
? sourceSnippetWhole(project, sourceFile)
|
||
: sourceSnippet(project, sourceFile, sl, el)));
|
||
}
|
||
|
||
@Tool(name = "file_source", description = "Source text for a project file by its relative path (not a module name), read from the project root on disk. Use for files that aren't standalone modules — e.g. a Natural data area (PDA/LDA) USING'd by a module, whose fields' line numbers refer to that file. Omit both startLine and endLine to get the whole file; or pass both for a line range.")
|
||
@Blocking
|
||
public Uni<ToolResponse> fileSource(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Relative source file path (as reported by e.g. module_data_structures.sourceFile)") String file,
|
||
@ToolArg(description = "Start line (1-indexed); omit with endLine for the whole file", required = false) @Nullable Integer startLine,
|
||
@ToolArg(description = "End line (1-indexed, inclusive); omit with startLine for the whole file", required = false) @Nullable Integer endLine) {
|
||
if (file == null || file.isBlank()) {
|
||
return Uni.createFrom().item(support.error("MISSING_FILE", "'file' (relative source path) is required"));
|
||
}
|
||
boolean wholeFile = startLine == null && endLine == null;
|
||
if (!wholeFile && (startLine == null || endLine == null)) {
|
||
return Uni.createFrom().item(support.error("MISSING_LINE_RANGE",
|
||
"Supply both startLine and endLine, or neither (whole file)"));
|
||
}
|
||
int sl = startLine != null ? startLine : 1;
|
||
int el = endLine != null ? endLine : Integer.MAX_VALUE;
|
||
return withProject(project, () -> wholeFile ? sourceSnippetWhole(project, file) : sourceSnippet(project, file, sl, el));
|
||
}
|
||
|
||
@Tool(name = "variable_reads", description = "Functions that read a given variable, following the call graph up to 'depth' hops.")
|
||
@Blocking
|
||
public Uni<ToolResponse> variableReads(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Variable name") String name,
|
||
@ToolArg(description = "Optional module to scope to", required = false) @Nullable String module,
|
||
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth) {
|
||
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
|
||
return withProject(project, () -> graphRepository.variableReads(project, name, module, effectiveDepth).map(support::ok));
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Helpers (mirror AnalysisResource)
|
||
// -------------------------------------------------------------------------
|
||
|
||
@Tool(name = "variable_writes", description = "Functions that write a given variable, following the call graph up to 'depth' hops.")
|
||
@Blocking
|
||
public Uni<ToolResponse> variableWrites(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Variable name") String name,
|
||
@ToolArg(description = "Optional module to scope to", required = false) @Nullable String module,
|
||
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth) {
|
||
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
|
||
return withProject(project, () -> graphRepository.variableWrites(project, name, module, effectiveDepth).map(support::ok));
|
||
}
|
||
|
||
/**
|
||
* Looks up {@code sourceFile}'s stored content hash (item 41), then reads its {@code [startLine,
|
||
* endLine]} slice with a stale check.
|
||
*/
|
||
private Uni<ToolResponse> sourceSnippet(String project, String sourceFile, int startLine, int endLine) {
|
||
return graphRepository.sourceHash(project, sourceFile)
|
||
.map(expectedHash -> readSnippet(project, sourceFile,
|
||
(root, hash) -> sourceSnippetService.read(root, sourceFile, startLine, endLine, hash), expectedHash));
|
||
}
|
||
|
||
/**
|
||
* M1: whole-file variant of {@link #sourceSnippet}.
|
||
*/
|
||
private Uni<ToolResponse> sourceSnippetWhole(String project, String sourceFile) {
|
||
return graphRepository.sourceHash(project, sourceFile)
|
||
.map(expectedHash -> readSnippet(project, sourceFile,
|
||
(root, hash) -> sourceSnippetService.readWholeFile(root, sourceFile, hash), expectedHash));
|
||
}
|
||
|
||
/**
|
||
* Resolves the project root and runs {@code reader} against it, mapping root-resolution/IO
|
||
* failures to MCP tool errors. When the file on disk no longer matches the stored hash (item 41),
|
||
* returns a {@code STALE_SOURCE} error telling the caller to re-ingest, rather than serving
|
||
* changed text against stale line numbers.
|
||
*/
|
||
private ToolResponse readSnippet(String project, String sourceFile, SnippetReader reader,
|
||
@Nullable String expectedHash) {
|
||
String root;
|
||
switch (rootResolver.resolve(project)) {
|
||
case ProjectRootResolver.Resolved r -> root = r.project().root();
|
||
case ProjectRootResolver.Failed failed -> {
|
||
return support.error(failed.code(), failed.message());
|
||
}
|
||
}
|
||
// Defence in depth: file_source takes a client-supplied path, so reject any that escapes root.
|
||
Path base = Path.of(root).toAbsolutePath().normalize();
|
||
if (!base.resolve(sourceFile).normalize().startsWith(base)) {
|
||
return support.error("INVALID_SOURCE_FILE", "Source file '" + sourceFile + "' escapes the project root");
|
||
}
|
||
try {
|
||
return support.ok(reader.read(root, expectedHash));
|
||
} catch (StaleSourceException e) {
|
||
return support.error("STALE_SOURCE", e.detail());
|
||
} catch (IOException e) {
|
||
return support.error("SOURCE_READ_FAILED", "Failed to read '" + sourceFile + "': " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
@FunctionalInterface
|
||
private interface SnippetReader {
|
||
SourceSnippet read(String root, @Nullable String expectedHash) throws IOException, StaleSourceException;
|
||
}
|
||
|
||
@Tool(name = "flow_forward", description = "Forward field-level dataflow from a variable (where its value flows to). Requires the module to be deep-ingested.")
|
||
@Blocking
|
||
public Uni<ToolResponse> flowForward(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Variable name") String name,
|
||
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
|
||
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
|
||
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
|
||
return withFlowPathWarm(project, module,
|
||
() -> graphRepository.flowForward(project, name, module, effectiveDepth),
|
||
McpQueryTools::stepModules);
|
||
}
|
||
|
||
/**
|
||
* Runs {@code action} if the project exists, otherwise a {@code PROJECT_NOT_FOUND} tool error.
|
||
*/
|
||
private Uni<ToolResponse> withProject(String project, Supplier<Uni<ToolResponse>> action) {
|
||
return graphRepository.projectExists(project).flatMap(exists -> exists
|
||
? action.get()
|
||
: Uni.createFrom().item(support.error("PROJECT_NOT_FOUND", "Project '" + project + "' does not exist")));
|
||
}
|
||
|
||
@Tool(name = "flow_backward", description = "Backward field-level dataflow into a variable (where its value comes from). Requires the module to be deep-ingested.")
|
||
@Blocking
|
||
public Uni<ToolResponse> flowBackward(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Variable name") String name,
|
||
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
|
||
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
|
||
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
|
||
return withFlowPathWarm(project, module,
|
||
() -> graphRepository.flowBackward(project, name, module, effectiveDepth),
|
||
McpQueryTools::stepModules);
|
||
}
|
||
|
||
@Tool(name = "field_flow", description = "Producer/consumer field-flow pairs for a variable within its module. Requires the module to be deep-ingested.")
|
||
@Blocking
|
||
public Uni<ToolResponse> fieldFlow(
|
||
@ToolArg(description = "Project name") String project,
|
||
@ToolArg(description = "Variable name") String name,
|
||
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
|
||
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
|
||
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
|
||
return withFlowPathWarm(project, module,
|
||
() -> graphRepository.fieldFlow(project, name, module, effectiveDepth),
|
||
McpQueryTools::fieldFlowModules);
|
||
}
|
||
|
||
/**
|
||
* Fan-out / traversal deep-ingest warm (item 37, blocking-then-rerun): runs {@code query} against
|
||
* the graph as-is, deep-ingests the modules the result surfaced ({@code surfaced}) via
|
||
* {@link DeepIngestCoordinator#ensureDeepMany}, and — only if that warm actually deepened
|
||
* something — re-runs {@code query} against the now-deeper graph so the response reflects
|
||
* newly-resolved dynamic dispatch. When nothing changed (already deep, empty set, or the warm
|
||
* failed) the first result is returned directly, so the query never pays for a redundant re-run.
|
||
*
|
||
* @param surfaced maps a result to the {@code sourceFile} paths it surfaced (the warm target)
|
||
*/
|
||
private <T> Uni<ToolResponse> withFanoutWarm(String project, Supplier<Uni<T>> query,
|
||
Function<T, Collection<String>> surfaced) {
|
||
return withProject(project, () -> query.get().flatMap(first ->
|
||
deepIngestCoordinator.ensureDeepMany(project, surfaced.apply(first)).flatMap(changed ->
|
||
changed ? query.get().map(support::ok) : Uni.createFrom().item(support.ok(first)))));
|
||
}
|
||
|
||
/**
|
||
* Cross-module {@code flow_*} path-ingest (item 37a), the MCP counterpart of
|
||
* {@code AnalysisResource.withFlowPathWarm}: deep-ingests the named start {@code module} (falling
|
||
* back to the agent-actionable deep-ingest hint if it does not resolve to {@code FULL}), then runs
|
||
* {@code query} and drives a bounded ingest-and-re-traverse fixpoint via {@link #flowFixpoint} —
|
||
* each round deep-ingests the frontier the current result surfaced
|
||
* ({@link DeepIngestCoordinator#ensureFlowFrontier}) and re-runs the trace, so the flow crosses
|
||
* into callees (notably dynamically-dispatched ones) as they become deeply ingested. With no
|
||
* {@code module} it is a plain project-wide query.
|
||
*/
|
||
private <T> Uni<ToolResponse> withFlowPathWarm(String project, @Nullable String module,
|
||
Supplier<Uni<List<T>>> query,
|
||
Function<List<T>, Collection<String>> surfaced) {
|
||
if (module == null || module.isBlank()) {
|
||
return withProject(project, () -> query.get().map(support::ok));
|
||
}
|
||
String startModule = module;
|
||
return withProject(project, () -> deepIngestCoordinator.ensureDeep(project, startModule)
|
||
.flatMap(ignored -> graphRepository.moduleIngestState(project, startModule).flatMap(state ->
|
||
state.isFull()
|
||
? flowFixpoint(project, startModule, query, surfaced, deepIngestCoordinator.flowRounds())
|
||
: Uni.createFrom().item(support.errorBody(deepIngestHint(startModule, state))))));
|
||
}
|
||
|
||
/**
|
||
* One iteration of the flow path-ingest fixpoint: runs {@code query}, and while rounds remain and
|
||
* {@link DeepIngestCoordinator#ensureFlowFrontier} reports it deepened the frontier, re-traverses;
|
||
* otherwise returns the current result. The frontier seed always includes the start {@code module}
|
||
* (not just what the trace surfaced) so the first round pulls in the start module's not-yet-deep —
|
||
* notably dynamically-dispatched — callees even when the trace is still empty. Bounded by
|
||
* {@code roundsLeft} (configured {@code flow-rounds}) so it terminates even if the graph keeps
|
||
* surfacing new callees.
|
||
*/
|
||
private <T> Uni<ToolResponse> flowFixpoint(String project, String module, Supplier<Uni<List<T>>> query,
|
||
Function<List<T>, Collection<String>> surfaced, int roundsLeft) {
|
||
return query.get().flatMap(result -> {
|
||
if (roundsLeft <= 0) {
|
||
return Uni.createFrom().item(support.ok(result));
|
||
}
|
||
Set<String> frontier = new LinkedHashSet<>();
|
||
frontier.add(module);
|
||
frontier.addAll(surfaced.apply(result));
|
||
return deepIngestCoordinator.ensureFlowFrontier(project, frontier).flatMap(changed ->
|
||
changed
|
||
? flowFixpoint(project, module, query, surfaced, roundsLeft - 1)
|
||
: Uni.createFrom().item(support.ok(result)));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Agent-actionable hint: a deep query needs the module (deep-)ingested first via the named MCP tool.
|
||
*/
|
||
public record DeepIngestRequired(String status, String module, String detail, String nextTool) {
|
||
}
|
||
}
|