Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.jorisjonkers.personalstack.agents.config

import com.jorisjonkers.personalstack.agents.domain.model.WorkspaceAgentKind
import org.springframework.boot.context.properties.ConfigurationProperties

/**
Expand All @@ -13,10 +14,15 @@ import org.springframework.boot.context.properties.ConfigurationProperties
*
* `runnerPodWorkspaceId` and `runnerPodAgentKind` are only consulted when
* `backend=runner-pod`.
*
* `runnerPodEnableKbHooks` opts the chat-generation headless job into KB
* auto-recall/capture hooks. Defaults to false: the gateway sets
* KB_AUTO_MCP_DISABLED=1 so chat workers do not fire accidental KB writes.
*/
@ConfigurationProperties(prefix = "chat.generation")
data class ChatGenerationProperties(
val backend: String = "lightrag",
val runnerPodWorkspaceId: String? = null,
val runnerPodAgentKind: String = "CLAUDE",
val runnerPodAgentKind: WorkspaceAgentKind = WorkspaceAgentKind.CLAUDE,
val runnerPodEnableKbHooks: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ interface AgentGatewayClient {
val stableSessionId: WorkspaceAgentSessionId? = null,
val epoch: Long? = null,
val continuation: ContinuationMetadata? = null,
/**
* When false (the default), the gateway injects KB_AUTO_MCP_DISABLED=1
* so the headless worker does not fire auto-KB recall/capture hooks.
* Set to true only for runs that explicitly need KB hook access.
*/
val enableKbHooks: Boolean = false,
/**
* Opts into token-level streaming via Claude `--include-partial-messages`.
* Should be set to true when the caller is consuming the SSE stream directly
* (e.g. [RunnerPodChatGenerator]), so token deltas arrive incrementally.
*/
val partialMessages: Boolean = false,
)

fun startHeadlessJob(request: HeadlessJobRequest): HeadlessJob
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ class HttpAgentGatewayClient(
val epoch: Long? = null,
val continuation: ContinuationBody? = null,
val timeoutSeconds: Long? = null,
val enableKbHooks: Boolean = false,
val partialMessages: Boolean = false,
)

private data class HeadlessJobDto(
Expand All @@ -291,6 +293,8 @@ class HttpAgentGatewayClient(
epoch = request.epoch,
continuation = request.continuation?.toBody(),
timeoutSeconds = request.timeoutSeconds,
enableKbHooks = request.enableKbHooks,
partialMessages = request.partialMessages,
),
).retrieve()
.body(HeadlessJobDto::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.jorisjonkers.personalstack.agents.config.ChatGenerationProperties
import com.jorisjonkers.personalstack.agents.domain.model.WorkspaceId
import com.jorisjonkers.personalstack.agents.domain.port.AgentGatewayClient
import com.jorisjonkers.personalstack.agents.domain.port.ChatGenerationPort
import com.jorisjonkers.personalstack.agents.domain.port.WorkspaceRepository
import org.slf4j.LoggerFactory
Expand All @@ -16,16 +17,21 @@ import java.util.UUID

/**
* [ChatGenerationPort] adapter that routes a chat prompt through a headless
* agent job on the runner Pod.
* agent job on the runner Pod via the shared [AgentGatewayClient].
*
* Flow:
* 1. Resolve the target workspace via [ChatGenerationProperties.runnerPodWorkspaceId].
* 2. POST `{gatewayEndpoint}/agents/headless` with `kind` + `prompt`; read
* back the job `id`.
* 2. Submit the job via [AgentGatewayClient.startHeadlessJob] with the configured
* [ChatGenerationProperties.runnerPodAgentKind]; receive back the job id.
* 3. GET `{gatewayEndpoint}/agents/headless/{id}/stream` as text/event-stream;
* parse Spring SSE format (event:/data: pairs) and delegate each line to
* [parseStreamJsonLine] to extract assistant text chunks.
*
* Using the shared gateway client (step 2) ensures the job is submitted through
* the same typed request path as [StartHeadlessJobCommandHandler], so
* [AgentGatewayClient.HeadlessJobRequest.enableKbHooks] and any future
* request-level fields are honoured uniformly.
*
* Active only when `chat.generation.backend=runner-pod`.
*/
@Component
Expand All @@ -35,6 +41,7 @@ import java.util.UUID
havingValue = "runner-pod",
)
class RunnerPodChatGenerator(
private val gateway: AgentGatewayClient,
private val restClient: RestClient,
private val workspaces: WorkspaceRepository,
private val props: ChatGenerationProperties,
Expand All @@ -45,68 +52,65 @@ class RunnerPodChatGenerator(
prompt: String,
onChunk: (String) -> Unit,
): String {
val gatewayEndpoint = resolveGatewayEndpoint() ?: return ""
val jobId = startHeadlessJob(gatewayEndpoint, prompt) ?: return ""
val (workspace, gatewayEndpoint) = resolveWorkspace() ?: return ""
val jobId = startHeadlessJob(workspace, prompt) ?: return ""
return streamJobOutput(gatewayEndpoint, jobId, onChunk)
}

private fun resolveGatewayEndpoint(): String? {
/**
* Resolves the target workspace and validates that a gateway endpoint is present.
* Returns a pair of ([Workspace], gatewayEndpoint) or null when the workspace is
* not configured, not found, or has no gateway endpoint yet.
*/
private fun resolveWorkspace(): Pair<com.jorisjonkers.personalstack.agents.domain.model.Workspace, String>? {
val rawId = props.runnerPodWorkspaceId
if (rawId.isNullOrBlank()) {
log.warn("chat.generation.runner-pod-workspace-id is not configured — no answer produced")
return null
}
var endpoint: String? = null
runCatching {
return runCatching {
val workspaceId = WorkspaceId(UUID.fromString(rawId))
val workspace = workspaces.findById(workspaceId)
if (workspace == null) {
log.warn("RunnerPodChatGenerator: workspace {} not found — no answer produced", rawId)
return@runCatching
return@runCatching null
}
val ep = workspace.gatewayEndpoint
if (ep.isNullOrBlank()) {
val endpoint = workspace.gatewayEndpoint
if (endpoint.isNullOrBlank()) {
log.warn(
"RunnerPodChatGenerator: workspace {} has no gateway endpoint — no answer produced",
rawId,
)
return@runCatching
return@runCatching null
}
endpoint = ep
workspace to endpoint
}.onFailure { ex ->
log.warn("RunnerPodChatGenerator: failed to resolve workspace {}: {}", rawId, ex.message)
}
return endpoint
}.getOrNull()
}

private fun startHeadlessJob(
gatewayEndpoint: String,
workspace: com.jorisjonkers.personalstack.agents.domain.model.Workspace,
prompt: String,
): String? {
var jobId: String? = null
): String? =
runCatching {
val response =
restClient
.post()
.uri("$gatewayEndpoint/agents/headless")
.body(
mapOf(
"kind" to props.runnerPodAgentKind,
"prompt" to prompt,
"partialMessages" to true,
),
).retrieve()
.body(Map::class.java)
jobId = response?.get("id")?.toString()
if (jobId.isNullOrBlank()) {
log.warn("RunnerPodChatGenerator: gateway returned no job id")
jobId = null
}
val job =
gateway.startHeadlessJob(
AgentGatewayClient.HeadlessJobRequest(
workspace = workspace,
kind = props.runnerPodAgentKind,
prompt = prompt,
enableKbHooks = props.runnerPodEnableKbHooks,
// Token-level streaming is always enabled for chat-generation runs so
// the SseChatAccumulator receives incremental deltas rather than waiting
// for the full assistant turn before delivering any output.
partialMessages = true,
),
)
job.id
}.onFailure { ex ->
log.warn("RunnerPodChatGenerator: failed to start headless job: {}", ex.message)
}
return jobId
}
}.getOrNull()

private fun streamJobOutput(
gatewayEndpoint: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,41 @@ class HttpAgentGatewayClientTest {
server.verify()
}

@Test
fun `startHeadlessJob forwards enableKbHooks and partialMessages to gateway`() {
val builder = RestClient.builder()
val server = MockRestServiceServer.bindTo(builder).build()
val client = HttpAgentGatewayClient(builder.build())
val ws = workspace()
val prompt = "run with kb hooks"

server
.expect(requestTo("http://runner:8090/agents/headless"))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.enableKbHooks").value(true))
.andExpect(jsonPath("$.partialMessages").value(true))
.andRespond(
withSuccess(
"""{"id":"job-kbhooks-1","status":"RUNNING","exitCode":null,"output":null}""",
MediaType.APPLICATION_JSON,
),
)

val job =
client.startHeadlessJob(
AgentGatewayClient.HeadlessJobRequest(
workspace = ws,
kind = WorkspaceAgentKind.CLAUDE,
prompt = prompt,
enableKbHooks = true,
partialMessages = true,
),
)

assertThat(job.id).isEqualTo("job-kbhooks-1")
server.verify()
}

private fun workspace() =
Workspace(
id = WorkspaceId.parse("55555555-5555-4555-8555-555555555555"),
Expand Down
Loading