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:
@@ -5,6 +5,9 @@ import com.correx.infrastructure.tools.filesystem.FileEditTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileReadTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileWriteTool
|
||||
import com.correx.infrastructure.tools.shell.ShellTool
|
||||
import com.correx.infrastructure.tools.web.WebFetchTool
|
||||
import com.correx.infrastructure.tools.web.WebSearchTool
|
||||
import io.ktor.client.HttpClient
|
||||
import java.nio.file.Path
|
||||
|
||||
data class ToolConfig(
|
||||
@@ -12,6 +15,20 @@ data class ToolConfig(
|
||||
val fileRead: FileReadConfig = FileReadConfig(),
|
||||
val fileWrite: FileWriteConfig = FileWriteConfig(),
|
||||
val fileEdit: FileEditConfig = FileEditConfig(),
|
||||
val research: ResearchToolConfig = ResearchToolConfig(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Research tool registration. [httpClient] is the single shared client threaded in from the
|
||||
* composition root (one per process, not per registry); when null the tools are not built even
|
||||
* if [enabled], so tests and the static path never spin up a network client.
|
||||
*/
|
||||
class ResearchToolConfig(
|
||||
val enabled: Boolean = false,
|
||||
val searxngUrl: String = "http://localhost:8888",
|
||||
val maxResults: Int = 8,
|
||||
val maxFetchBytes: Long = 10_000_000,
|
||||
val httpClient: HttpClient? = null,
|
||||
)
|
||||
|
||||
data class FileReadConfig(
|
||||
@@ -73,4 +90,8 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
|
||||
),
|
||||
)
|
||||
}
|
||||
research.httpClient?.takeIf { research.enabled }?.let { client ->
|
||||
add(WebSearchTool(client, searxngBaseUrl = research.searxngUrl, maxResults = research.maxResults))
|
||||
add(WebFetchTool(client, maxBytes = research.maxFetchBytes))
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.artifacts.kind.ConfigArtifactKind
|
||||
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
|
||||
/**
|
||||
* Validates that the shipped research workflow (examples/workflows/research.toml) parses into a
|
||||
* structurally valid WorkflowGraph: reachable stages, every `needs` produced upstream, the gather
|
||||
* stage wired to the two network tools. Synthesis quality is inference-dependent and out of scope;
|
||||
* this guards the graph contract.
|
||||
*/
|
||||
class ResearchWorkflowTest {
|
||||
|
||||
private val registry = DefaultArtifactKindRegistry().apply {
|
||||
listOf("research_plan", "source_dossier", "research_report").forEach { id ->
|
||||
register(ConfigArtifactKind(id = id, schema = JsonSchema(type = "object"), llmEmitted = true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun repoFile(relative: String): Path {
|
||||
var dir: Path? = Path.of("").toAbsolutePath()
|
||||
while (dir != null) {
|
||||
val candidate = dir.resolve(relative)
|
||||
if (candidate.exists()) return candidate
|
||||
dir = dir.parent
|
||||
}
|
||||
error("could not locate $relative from ${Path.of("").toAbsolutePath()}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `research workflow builds a valid graph`() {
|
||||
val graph = TomlWorkflowLoader(registry).load(repoFile("examples/workflows/research.toml"))
|
||||
|
||||
assertEquals("research", graph.id)
|
||||
assertEquals("decompose", graph.start.value)
|
||||
assertEquals(setOf("decompose", "gather", "report"), graph.stages.keys.map { it.value }.toSet())
|
||||
|
||||
val gather = graph.stages.values.first { it.produces.any { a -> a.name.value == "source_dossier" } }
|
||||
assertEquals(setOf("research_plan"), gather.needs.map { it.value }.toSet())
|
||||
assertEquals(setOf("web_search", "web_fetch"), gather.allowedTools)
|
||||
|
||||
val report = graph.stages.values.first { it.produces.any { a -> a.name.value == "research_report" } }
|
||||
assertEquals(setOf("source_dossier"), report.needs.map { it.value }.toSet())
|
||||
|
||||
assertTrue(graph.transitions.any { it.to.value == "done" }, "must terminate at done")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user