feat(tasks): inline ARTIFACT/SESSION links in the context bundle

The context bundle resolved only TASK and DOC link targets; ARTIFACT and
SESSION stayed raw. Add two ports (TaskArtifactResolver/TaskSessionResolver)
with apps/server adapters: artifacts resolve to producing stage/session +
content excerpt from CAS, sessions to status/intent/workflow. Unresolved
targets still fall back to raw links. Wired through the tool and REST bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 19:59:18 +00:00
parent 6e044d0ab9
commit 6b00d89c2c
12 changed files with 364 additions and 39 deletions
@@ -0,0 +1,21 @@
package com.correx.core.tasks
import kotlinx.serialization.Serializable
/**
* Port for resolving an ARTIFACT link target (an `ArtifactId`) to an inline summary, so the
* context bundle can carry the producing stage/session and a content excerpt instead of a bare id.
* Implemented in a higher layer (apps/server reads the event log + CAS); `core:tasks` stays
* decoupled from storage. Returns null when the artifact can't be resolved — the link then stays a
* raw related link.
*/
interface TaskArtifactResolver {
suspend fun resolve(targetId: String): ResolvedArtifact?
}
@Serializable
data class ResolvedArtifact(
val stage: String?,
val sessionId: String?,
val excerpt: String?,
)
@@ -5,36 +5,36 @@ import com.correx.core.events.types.TaskTargetKind
/**
* Assembles a [TaskContextBundle] for a task. Links are partitioned by their recorded
* [com.correx.core.events.types.TaskTargetKind]: TASK targets are resolved to [RelatedTask]
* dependencies (with live status), DOC targets are resolved inline via [documentResolver], and
* anything unresolved (or ARTIFACT/SESSION, not inlined yet) becomes a raw [RelatedLink].
* [com.correx.core.events.types.TaskTargetKind]: TASK targets resolve to [RelatedTask] dependencies
* (with live status), DOC targets inline via [documentResolver], ARTIFACT targets via
* [artifactResolver], SESSION targets via [sessionResolver]. Any kind whose resolver is absent or
* comes up empty falls back to a raw [RelatedLink].
*
* When a [TaskKnowledgeRetriever] is supplied, the bundle is additionally enriched with
* semantically-relevant snippets over the task's title+goal. Retrieval and doc-resolution failures
* are swallowed (the bundle still returns) so context assembly never fails on a flaky dependency.
* semantically-relevant snippets over the task's title+goal. Retrieval and resolution failures are
* swallowed (the bundle still returns) so context assembly never fails on a flaky dependency.
*/
class TaskContextAssembler(
private val service: TaskService,
private val retriever: TaskKnowledgeRetriever? = null,
private val documentResolver: TaskDocumentResolver? = null,
private val artifactResolver: TaskArtifactResolver? = null,
private val sessionResolver: TaskSessionResolver? = null,
private val knowledgeLimit: Int = DEFAULT_KNOWLEDGE_LIMIT,
) {
suspend fun assemble(taskId: TaskId): TaskContextBundle? {
val state = service.getTask(taskId)?.state ?: return null
val dependencies = mutableListOf<RelatedTask>()
val documents = mutableListOf<TaskDocument>()
val related = mutableListOf<RelatedLink>()
val resolved = ResolvedLinks()
for (link in state.links) {
// Resolution dispatches on the link's recorded targetKind — no guessing from the id.
// A kind whose resolution comes up empty (missing task, unresolvable doc, or an
// ARTIFACT/SESSION we don't inline yet) falls back to a raw related link.
// A kind whose resolution comes up empty falls back to a raw related link.
when (link.targetKind) {
TaskTargetKind.TASK -> addTask(link, dependencies, related)
TaskTargetKind.DOC -> addDocument(link, documents, related)
TaskTargetKind.ARTIFACT, TaskTargetKind.SESSION ->
related += RelatedLink(link.targetId, link.type.name)
TaskTargetKind.TASK -> addTask(link, resolved)
TaskTargetKind.DOC -> addDocument(link, resolved)
TaskTargetKind.ARTIFACT -> addArtifact(link, resolved)
TaskTargetKind.SESSION -> addSession(link, resolved)
}
}
@@ -46,40 +46,43 @@ class TaskContextAssembler(
goal = state.goal,
acceptanceCriteria = state.acceptanceCriteria,
relevantFiles = state.affectedPaths,
dependencies = dependencies,
documents = documents,
relatedLinks = related,
dependencies = resolved.dependencies,
documents = resolved.documents,
artifacts = resolved.artifacts,
sessions = resolved.sessions,
relatedLinks = resolved.related,
notes = state.notes.map { NoteEntry(it.author.name, it.body) },
relevantKnowledge = retrieveKnowledge(state),
)
}
private fun addTask(
link: TaskLink,
dependencies: MutableList<RelatedTask>,
related: MutableList<RelatedLink>,
) {
/** Accumulators for one assembly pass — keeps the per-kind helpers to a single parameter. */
private class ResolvedLinks {
val dependencies = mutableListOf<RelatedTask>()
val documents = mutableListOf<TaskDocument>()
val artifacts = mutableListOf<TaskArtifact>()
val sessions = mutableListOf<RelatedSession>()
val related = mutableListOf<RelatedLink>()
}
private fun addTask(link: TaskLink, out: ResolvedLinks) {
val linked = runCatching { service.getTask(TaskId(link.targetId)) }.getOrNull()
if (linked != null) {
dependencies += RelatedTask(
out.dependencies += RelatedTask(
id = linked.taskId.value,
status = linked.state.status.name,
title = linked.state.title,
link = link.type.name,
)
} else {
related += RelatedLink(link.targetId, link.type.name)
out.related += RelatedLink(link.targetId, link.type.name)
}
}
private suspend fun addDocument(
link: TaskLink,
documents: MutableList<TaskDocument>,
related: MutableList<RelatedLink>,
) {
val doc = resolveDocument(link.targetId)
private suspend fun addDocument(link: TaskLink, out: ResolvedLinks) {
val doc = documentResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() }
if (doc != null) {
documents += TaskDocument(
out.documents += TaskDocument(
targetId = link.targetId,
type = link.type.name,
title = doc.title,
@@ -87,13 +90,38 @@ class TaskContextAssembler(
excerpt = doc.excerpt,
)
} else {
related += RelatedLink(link.targetId, link.type.name)
out.related += RelatedLink(link.targetId, link.type.name)
}
}
private suspend fun resolveDocument(targetId: String): ResolvedDocument? {
val resolver = documentResolver ?: return null
return runCatching { resolver.resolve(targetId) }.getOrNull()
private suspend fun addArtifact(link: TaskLink, out: ResolvedLinks) {
val artifact = artifactResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() }
if (artifact != null) {
out.artifacts += TaskArtifact(
targetId = link.targetId,
type = link.type.name,
stage = artifact.stage,
sessionId = artifact.sessionId,
excerpt = artifact.excerpt,
)
} else {
out.related += RelatedLink(link.targetId, link.type.name)
}
}
private suspend fun addSession(link: TaskLink, out: ResolvedLinks) {
val session = sessionResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() }
if (session != null) {
out.sessions += RelatedSession(
targetId = link.targetId,
type = link.type.name,
status = session.status,
intent = session.intent,
workflowId = session.workflowId,
)
} else {
out.related += RelatedLink(link.targetId, link.type.name)
}
}
private suspend fun retrieveKnowledge(state: TaskState): List<KnowledgeHit> {
@@ -18,6 +18,8 @@ data class TaskContextBundle(
val relevantFiles: List<String>,
val dependencies: List<RelatedTask>,
val documents: List<TaskDocument>,
val artifacts: List<TaskArtifact> = emptyList(),
val sessions: List<RelatedSession> = emptyList(),
val relatedLinks: List<RelatedLink>,
val notes: List<NoteEntry>,
val relevantKnowledge: List<KnowledgeHit> = emptyList(),
@@ -30,6 +32,8 @@ data class TaskContextBundle(
if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}")
appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() }
appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" }
appendList("artifacts", artifacts) { " - ${it.targetId} (${it.type})${it.stage?.let { s -> " @$s" }.orEmpty()}: ${it.excerpt.orEmpty()}".trimEnd() }
appendList("sessions", sessions) { " - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd() }
appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" }
appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" }
appendList("notes", notes) { " - [${it.author}] ${it.body}" }
@@ -61,6 +65,26 @@ data class TaskDocument(
val excerpt: String,
)
/** A link whose target resolved to a produced artifact: producing stage/session + content excerpt. */
@Serializable
data class TaskArtifact(
val targetId: String,
val type: String,
val stage: String?,
val sessionId: String?,
val excerpt: String?,
)
/** A link whose target resolved to an agent run: status + intent so the agent has the run's gist. */
@Serializable
data class RelatedSession(
val targetId: String,
val type: String,
val status: String,
val intent: String?,
val workflowId: String?,
)
/** A link whose target is neither a task nor a resolvable doc — left for the agent to fetch. */
@Serializable
data class RelatedLink(
@@ -0,0 +1,21 @@
package com.correx.core.tasks
import kotlinx.serialization.Serializable
/**
* Port for resolving a SESSION link target (a `SessionId`) to an inline summary, so the context
* bundle can carry the run's status/intent instead of a bare id. Implemented in a higher layer
* (apps/server projects the session's events); `core:tasks` stays decoupled from the session
* aggregate. Returns null when the session can't be resolved — the link then stays a raw related
* link.
*/
interface TaskSessionResolver {
suspend fun resolve(targetId: String): ResolvedSession?
}
@Serializable
data class ResolvedSession(
val status: String,
val intent: String?,
val workflowId: String?,
)
@@ -125,4 +125,39 @@ class TaskContextAssemblerTest {
assertTrue(bundle.relevantKnowledge.isEmpty())
assertEquals("JWT refresh", bundle.title)
}
@Test
fun `linked artifacts and sessions are resolved inline and unresolved ones stay raw`() = runBlocking {
val artifacts = object : TaskArtifactResolver {
override suspend fun resolve(targetId: String): ResolvedArtifact? =
if (targetId == "art-1") ResolvedArtifact("architect", "sess-9", "approach: redis") else null
}
val sessions = object : TaskSessionResolver {
override suspend fun resolve(targetId: String): ResolvedSession? =
if (targetId == "sess-9") ResolvedSession("COMPLETED", "build auth", "role_pipeline") else null
}
val enriched = TaskContextAssembler(service, artifactResolver = artifacts, sessionResolver = sessions)
val task = service.createTask(project, "JWT refresh", "auth")
service.link(task.taskId, "art-1", TaskLinkType.PRODUCED, TaskTargetKind.ARTIFACT)
service.link(task.taskId, "sess-9", TaskLinkType.CONTEXT, TaskTargetKind.SESSION)
service.link(task.taskId, "art-missing", TaskLinkType.RELATES_TO, TaskTargetKind.ARTIFACT) // unresolved → raw
val bundle = enriched.assemble(task.taskId)!!
val artifact = bundle.artifacts.single()
assertEquals("art-1", artifact.targetId)
assertEquals("architect", artifact.stage)
assertEquals("sess-9", artifact.sessionId)
assertEquals("PRODUCED", artifact.type)
val session = bundle.sessions.single()
assertEquals("sess-9", session.targetId)
assertEquals("COMPLETED", session.status)
assertEquals("build auth", session.intent)
assertEquals("CONTEXT", session.type)
assertEquals(RelatedLink("art-missing", "RELATES_TO"), bundle.relatedLinks.single())
assertTrue(bundle.render().contains("artifacts:"))
assertTrue(bundle.render().contains("sessions:"))
}
}