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:
@@ -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:"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user