feat(research): wire tools + research workflow graph (research-workflow §2/§3)

Makes the research feature runnable end-to-end, off by default.

- config: [tools.research] (enabled, searxng_url, max_results, max_fetch_bytes).
- registration: web_search/web_fetch are built into BOTH the default and per-workspace
  tool registries when research.enabled, sharing one HTTP client threaded from Main
  (none built on the static path). Egress stays harness-enforced: web_fetch is T2
  (operator-approved) and the existing NetworkHostRule still applies.
- workflow: examples/workflows/research.toml — decompose → gather → report, with the
  three artifact schemas and prompts. Fan-out (search per sub-question, fetch per source)
  runs as repeated tool calls inside the gather stage (Correx has no parallel agents);
  per-source synthesis into the dossier is the compression step, so the report stage
  consumes summaries, never raw pages. ResearchWorkflowTest validates the graph contract.

To run: set [tools.research].enabled, register the 3 [[artifacts]], copy research.toml +
prompts + schemas into the workflows dir, start SearXNG. Launch like any workflow (the
T2 fetch approval surfaces as an approval card; the report opens in the artifact viewer).

Follow-ups (noted, not blocking): batch fetch-approval at the source-list level (§3),
a dedicated SourceFetched/LowQualityExtraction event (quality + content hash are already
in tool-result metadata), dynamic per-session egress allowlist, and the web approval client (§6).
This commit is contained in:
2026-06-13 23:18:19 +04:00
parent ad2d38ce46
commit 7a0d4d0ee2
11 changed files with 321 additions and 1 deletions
@@ -195,12 +195,26 @@ fun main() {
?.let { Path.of(it) }
?: toolsConfig.workspaceRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: workingDir
// One shared HTTP client backs both the default and per-workspace registries' research tools
// (web_search/web_fetch). Built only when research is enabled, so the static path stays offline.
val researchToolConfig = com.correx.infrastructure.tools.ResearchToolConfig(
enabled = toolsConfig.research.enabled,
searxngUrl = toolsConfig.research.searxngUrl,
maxResults = toolsConfig.research.maxResults,
maxFetchBytes = toolsConfig.research.maxFetchBytes,
httpClient = if (toolsConfig.research.enabled) {
io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
} else {
null
},
)
val toolRegistry = InfrastructureModule.createToolRegistry(
buildToolConfig(
workspaceRoot,
workingDir,
shellAllowedExecutables,
toolsConfig,
researchToolConfig,
),
)
val toolExecutor = InfrastructureModule.createToolExecutor(
@@ -228,7 +242,7 @@ fun main() {
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
val wsRegistry = InfrastructureModule.createToolRegistry(
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig),
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
)
val wsExecutor = DispatchingToolExecutor(wsRegistry)
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
@@ -626,6 +640,7 @@ private fun buildToolConfig(
workingDir: Path,
shellAllowedExecutables: Set<String>,
toolsConfig: com.correx.core.config.ToolsConfig,
research: com.correx.infrastructure.tools.ResearchToolConfig,
): ToolConfig {
val allowed = setOf(workspaceRoot, workingDir)
return ToolConfig(
@@ -648,6 +663,7 @@ private fun buildToolConfig(
allowedPaths = allowed,
workingDir = workingDir,
),
research = research,
)
}
@@ -655,7 +671,9 @@ private fun buildToolConfigForWorkspace(
workspace: WorkspaceContext,
shellAllowedExecutables: Set<String>,
toolsConfig: com.correx.core.config.ToolsConfig,
research: com.correx.infrastructure.tools.ResearchToolConfig,
): ToolConfig = ToolConfig(
research = research,
shell = ShellConfig(
enabled = toolsConfig.shellEnabled,
allowedExecutables = shellAllowedExecutables,